feat: implement GDOP overlay for simulator visualization

- Add GET /api/simulator/gdop/heatmap endpoint in both simulator handlers
- Fix viz3d.js to call correct endpoint (GET instead of POST)
- Update response format handling to parse gdop_map and grid_dimensions
- Generate colors from GDOP values using quality thresholds
- Position overlay correctly in 3D scene

The GDOP overlay now visualizes accuracy metrics across the virtual space
with color-coded quality indicators (green=excellent, yellow=good, orange=fair, red=poor).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-09 20:18:09 -04:00
parent dd427713fb
commit a70e3e433d
5 changed files with 365 additions and 23 deletions

View file

@ -3214,3 +3214,60 @@
.sim-shopping-item strong {
color: #4fc3f7;
}
/* ============================================
GDOP Legend Styles
============================================ */
.sim-gdop-legend {
margin-top: 15px;
padding: 12px;
background: rgba(0, 0, 0, 0.3);
border-radius: 6px;
}
.gdop-legend-item {
display: flex;
align-items: center;
margin-bottom: 8px;
font-size: 13px;
color: #ddd;
}
.gdop-legend-item:last-child {
margin-bottom: 0;
}
.gdop-legend-color {
width: 16px;
height: 16px;
border-radius: 3px;
margin-right: 10px;
flex-shrink: 0;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.gdop-legend-label {
flex: 1;
color: #ccc;
}
.gdop-stats {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
}
.gdop-stat-item {
font-size: 13px;
color: #aaa;
}
.gdop-stat-item strong {
color: #4fc3f7;
font-weight: 600;
}

View file

@ -76,8 +76,8 @@
let _renderer = null;
let _controls = null;
let _wallMeshes = [];
let _nodeMeshes = [];
let _walkerMeshes = [];
let _nodeMeshes = new Map();
let _walkerMeshes = new Map();
let _gdopMesh = null;
// ============================================
@ -208,6 +208,32 @@
</label>
<button id="sim-update-gdop" class="sim-btn">Update GDOP</button>
</div>
<div id="sim-gdop-legend" class="sim-gdop-legend" style="display: none;">
<div class="gdop-legend-item">
<span class="gdop-legend-color" style="background-color: #22c65e;"></span>
<span class="gdop-legend-label">Excellent (GDOP &lt; 2)</span>
</div>
<div class="gdop-legend-item">
<span class="gdop-legend-color" style="background-color: #ffc107;"></span>
<span class="gdop-legend-label">Good (GDOP 2-4)</span>
</div>
<div class="gdop-legend-item">
<span class="gdop-legend-color" style="background-color: #ff9200;"></span>
<span class="gdop-legend-label">Fair (GDOP 4-8)</span>
</div>
<div class="gdop-legend-item">
<span class="gdop-legend-color" style="background-color: #dc3545;"></span>
<span class="gdop-legend-label">Poor (GDOP &gt; 8)</span>
</div>
<div class="gdop-legend-item">
<span class="gdop-legend-color" style="background-color: #505050;"></span>
<span class="gdop-legend-label">No Coverage</span>
</div>
<div class="gdop-stats" id="sim-gdop-stats">
<span class="gdop-stat-item">Coverage: <strong id="sim-gdop-coverage">--</strong></span>
<span class="gdop-stat-item">Mean GDOP: <strong id="sim-gdop-mean">--</strong></span>
</div>
</div>
</div>
<!-- Simulation Controls -->
@ -629,13 +655,9 @@
}
try {
const response = await fetch('/api/simulator/gdop', {
method: 'POST',
const response = await fetch('/api/simulator/gdop/heatmap', {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
nodes: state.nodes,
space: state.space,
}),
});
if (!response.ok) throw new Error('Failed to compute GDOP');
@ -653,7 +675,10 @@
function renderGDOP(data) {
clearGDOPMesh();
if (!data.gdop_map) return;
if (!data.gdop_map || !data.grid_dimensions) {
console.warn('[Simulate] Invalid GDOP data');
return;
}
// Create texture from GDOP data
const canvas = document.createElement('canvas');
@ -663,27 +688,43 @@
const ctx = canvas.getContext('2d');
const imageData = ctx.createImageData(size, size);
const gridWidth = data.gdop_map.length;
const gridHeight = data.gdop_map[0]?.length || 0;
// Grid dimensions from backend: [width_cells, depth_cells]
const gridWidth = data.grid_dimensions[0];
const gridDepth = data.grid_dimensions[1];
// gdop_map is a 1D flattened array: [x + y * width]
// We render the 2D floor plane
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
// Map pixel to grid cell
const gridX = Math.floor((x / size) * gridWidth);
const gridY = Math.floor((y / size) * gridHeight);
const gdop = data.gdop_map[gridX]?.[gridY] || 10;
const gridY = Math.floor((y / size) * gridDepth);
// Color based on GDOP quality
// Calculate index in flattened array
const idx = gridY * gridWidth + gridX;
// Get GDOP value (9999 = infinity)
const gdop = data.gdop_map[idx] !== undefined ? data.gdop_map[idx] : 9999;
// Color based on GDOP quality (matching Go GDOPColorMap)
// GDOP < 2: excellent (#22c65e = 34, 197, 94)
// GDOP 2-4: good (#ffc107 = 255, 193, 7)
// GDOP 4-8: fair (#ff9200 = 255, 146, 0)
// GDOP > 8: poor (#dc3545 = 220, 53, 69)
// Infinity: none (#505050 = 80, 80, 80)
let color;
if (gdop < 2) {
color = { r: 76, g: 175, b: 80 }; // Excellent - green
} else if (gdop < 4) {
color = { r: 139, g: 195, b: 74 }; // Good - light green
} else if (gdop < 6) {
color = { r: 255, g: 235, b: 59 }; // Fair - yellow
} else if (gdop < 8) {
color = { r: 255, g: 152, b: 0 }; // Poor - orange
if (gdop >= 9999) {
color = { r: 80, g: 80, b: 80 }; // None - gray
} else if (gdop < 2.0) {
color = { r: 34, g: 197, b: 94 }; // Excellent - green
} else if (gdop < 4.0) {
color = { r: 255, g: 193, b: 7 }; // Good - yellow
} else if (gdop < 8.0) {
color = { r: 255, g: 146, b: 0 }; // Fair - orange
} else {
color = { r: 244, g: 67, b: 54 }; // None - red
color = { r: 220, g: 53, b: 69 }; // Poor - red
}
const i = (y * size + x) * 4;
@ -697,11 +738,13 @@
ctx.putImageData(imageData, 0, 0);
const texture = new THREE.CanvasTexture(canvas);
texture.needsUpdate = true;
const material = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
opacity: 0.7,
side: THREE.DoubleSide,
depthWrite: false,
});
const geometry = new THREE.PlaneGeometry(state.space.width, state.space.depth);
@ -714,6 +757,60 @@
const scene = window.Viz3D.getScene?.();
if (scene) scene.add(_gdopMesh);
}
// Update GDOP stats in legend
const legendEl = document.getElementById('sim-gdop-legend');
if (legendEl) {
legendEl.style.display = 'block';
}
const coverageEl = document.getElementById('sim-gdop-coverage');
const meanEl = document.getElementById('sim-gdop-mean');
if (data.coverage_score !== undefined) {
const coveragePercent = (data.coverage_score * 100).toFixed(1);
if (coverageEl) {
coverageEl.textContent = coveragePercent + '%';
}
console.log('[Simulate] Coverage score:', coveragePercent + '%');
}
if (data.mean_gdop !== undefined) {
const meanGDOP = data.mean_gdop < 9999 ? data.mean_gdop.toFixed(2) : '∞';
if (meanEl) {
meanEl.textContent = meanGDOP;
}
console.log('[Simulate] Mean GDOP:', meanGDOP);
}
// Update quality counts if available
if (data.quality_counts) {
console.log('[Simulate] Quality distribution:', data.quality_counts);
// Update legend items with counts
const qualityLabels = {
'excellent': 'Excellent (GDOP < 2)',
'good': 'Good (GDOP 2-4)',
'fair': 'Fair (GDOP 4-8)',
'poor': 'Poor (GDOP > 8)',
'none': 'No Coverage'
};
const legendItems = document.querySelectorAll('.gdop-legend-item');
legendItems.forEach(item => {
const label = item.querySelector('.gdop-legend-label');
if (label) {
const labelText = label.textContent.split(':')[0];
for (const [quality, fullName] of Object.entries(qualityLabels)) {
if (fullName.includes(labelText) || labelText.includes(quality.charAt(0).toUpperCase() + quality.slice(1))) {
const count = data.quality_counts[quality] || 0;
label.textContent = `${fullName}: ${count} cells`;
break;
}
}
}
});
}
}
function clearGDOPMesh() {
@ -724,6 +821,12 @@
_gdopMesh.material.dispose();
_gdopMesh = null;
}
// Hide the legend when GDOP is cleared
const legendEl = document.getElementById('sim-gdop-legend');
if (legendEl) {
legendEl.style.display = 'none';
}
}
// ============================================

View file

@ -2371,6 +2371,14 @@ const Viz3D = (function () {
};
}
/**
* Get the Three.js scene (for external modules like simulator).
* @returns {THREE.Scene} The scene object
*/
function getScene() {
return _scene;
}
/**
* Focus the camera on a specific zone.
* @param {string} zoneID - The zone ID to focus on

View file

@ -4,6 +4,7 @@ package api
import (
"encoding/json"
"log"
"math"
"net/http"
"strconv"
"sync"
@ -70,6 +71,7 @@ func (h *SimulatorHandler) RegisterRoutes(r chi.Router) {
r.Route("/gdop", func(r chi.Router) {
r.Post("/compute", h.ComputeGDOP)
r.Get("/coverage", h.GetCoverageScore)
r.Get("/heatmap", h.GetGDOPHeatmap)
})
// Shopping list
@ -462,6 +464,65 @@ func (h *SimulatorHandler) GetCoverageScore(w http.ResponseWriter, r *http.Reque
})
}
// GetGDOPHeatmap returns GDOP data in a format suitable for heatmap visualization
func (h *SimulatorHandler) GetGDOPHeatmap(w http.ResponseWriter, r *http.Request) {
h.mu.RLock()
space := h.space
nodes := h.nodes
h.mu.RUnlock()
if nodes.Count() < 2 {
respondJSON(w, http.StatusOK, map[string]interface{}{
"gdop_map": []float64{},
"grid_dimensions": []int{0, 0, 0},
"coverage_percent": 0,
"error": "need at least 2 nodes",
})
return
}
minX, minY, _, maxX, maxY, _ := space.Bounds()
links := simulator.GenerateAllLinks(nodes)
config := simulator.GridConfig{
MinX: minX,
MinY: minY,
Width: maxX - minX,
Depth: maxY - minY,
CellSize: 0.2,
}
gdopComp := simulator.NewGDOPComputer(links, config)
results := gdopComp.ComputeAll()
// Convert results to heatmap format
depth := len(results)
width := 0
if depth > 0 {
width = len(results[0])
}
// Flatten GDOP values into 1D array (row-major order)
gdopMap := make([]float64, width*depth)
for y := 0; y < depth; y++ {
for x := 0; x < width; x++ {
idx := y*width + x
if math.IsInf(results[y][x].GDOP, 0) {
gdopMap[idx] = 9999.0 // Use 9999 to represent infinity
} else {
gdopMap[idx] = results[y][x].GDOP
}
}
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"gdop_map": gdopMap,
"grid_dimensions": []int{width, depth, 1}, // 2D heatmap, so height = 1
"coverage_percent": gdopComp.CoverageScore(results),
"average_gdop": gdopComp.AverageGDOP(results),
"quality_counts": gdopComp.QualityCounts(results),
})
}
// GetShoppingList returns hardware recommendations
func (h *SimulatorHandler) GetShoppingList(w http.ResponseWriter, r *http.Request) {
h.mu.RLock()

View file

@ -35,9 +35,11 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
r.Get("/api/simulator/walkers", h.getWalkers)
r.Post("/api/simulator/walkers", h.addWalker)
r.Delete("/api/simulator/walkers/{id}", h.removeWalker)
r.Post("/api/simulator/session", h.createSession)
r.Post("/api/simulator/simulate", h.simulate)
r.Get("/api/simulator/results", h.getResults)
r.Post("/api/simulator/gdop", h.computeGDOP)
r.Get("/api/simulator/gdop/heatmap", h.getGDOPHeatmap)
r.Get("/api/simulator/status", h.getStatus)
r.Post("/api/simulator/subscribe", h.subscribe)
}
@ -206,10 +208,29 @@ func (h *Handler) computeGDOP(w http.ResponseWriter, r *http.Request) {
// Run simulation to get GDOP map
result := engine.RunSimulation()
// Convert to heatmap format for frontend
minX, minY, _, maxX, maxY, _ := req.Space.Bounds()
links := GenerateAllLinks(engine.nodes)
gdopComp := NewGDOPComputer(links, GridConfig{
MinX: minX,
MinY: minY,
Width: maxX - minX,
Depth: maxY - minY,
CellSize: 0.2,
})
// Compute full 2D GDOP results for heatmap
// We only need the floor plane (z=1.0m height for 2D analysis)
gdopResults := gdopComp.ComputeAll()
heatmapData := gdopComp.ToHeatmapData(gdopResults)
writeJSON(w, map[string]interface{}{
"gdop_map": result.GDOPMap,
"grid_dimensions": result.GridDimensions,
"coverage_score": result.CoverageScore,
"gdop_heatmap": heatmapData,
"mean_gdop": gdopComp.AverageGDOP(gdopResults),
"quality_counts": gdopComp.QualityCounts(gdopResults),
})
}
@ -302,3 +323,95 @@ func writeJSON(w http.ResponseWriter, v interface{}) {
}
w.Write(data)
}
// createSession handles POST /api/simulator/session
// Creates a new simulator session with the given space configuration.
func (h *Handler) createSession(w http.ResponseWriter, r *http.Request) {
var req struct {
Space *Space `json:"space"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.Space == nil {
req.Space = DefaultSpace()
}
if err := req.Space.Validate(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Update the engine's space
h.mu.Lock()
h.engine.SetSpace(req.Space)
h.mu.Unlock()
// Generate session ID
sessionID := fmt.Sprintf("sim_%d", time.Now().UnixNano())
writeJSON(w, map[string]interface{}{
"session_id": sessionID,
"space": req.Space,
})
}
// getGDOPHeatmap handles GET /api/simulator/gdop/heatmap
// Returns GDOP heatmap data for the current simulator state.
func (h *Handler) getGDOPHeatmap(w http.ResponseWriter, r *http.Request) {
h.mu.RLock()
defer h.mu.RUnlock()
// Get current nodes and space from engine
nodes := h.engine.GetVirtualNodes()
space := h.engine.space
if len(nodes) < 2 {
http.Error(w, "Need at least 2 nodes for GDOP computation", http.StatusBadRequest)
return
}
// Create links from nodes
nodeSet := NewNodeSet()
for _, node := range nodes {
nodeSet.Add(node)
}
links := GenerateAllLinks(nodeSet)
// Get grid bounds from space
minX, minY, _, maxX, maxY, _ := space.Bounds()
// Create GDOP computer
gdopComp := NewGDOPComputer(links, GridConfig{
MinX: minX,
MinY: minY,
Width: maxX - minX,
Depth: maxY - minY,
CellSize: 0.2, // 20cm cells
})
// Compute GDOP results
gdopResults := gdopComp.ComputeAll()
// Convert to heatmap format
heatmapData := gdopComp.ToHeatmapData(gdopResults)
// Build response
response := map[string]interface{}{
"gdop_map": heatmapData.GDOPValues,
"grid_dimensions": []int{heatmapData.Width, heatmapData.Depth},
"grid_origin": map[string]float64{"x": heatmapData.OriginX, "y": heatmapData.OriginY},
"cell_size_m": heatmapData.CellSize,
"coverage_score": gdopComp.CoverageScore(gdopResults) / 100.0, // Convert to 0-1
"mean_gdop": gdopComp.AverageGDOP(gdopResults),
"quality_counts": gdopComp.QualityCounts(gdopResults),
"qualities": heatmapData.Qualities,
"colors": heatmapData.Colors,
"accuracy_map": heatmapData.AccuracyMap,
}
writeJSON(w, response)
}