diff --git a/dashboard/css/panels.css b/dashboard/css/panels.css index 0d2c1b6..0ccf787 100644 --- a/dashboard/css/panels.css +++ b/dashboard/css/panels.css @@ -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; +} diff --git a/dashboard/js/simulate.js b/dashboard/js/simulate.js index 51676ac..e15b8a8 100644 --- a/dashboard/js/simulate.js +++ b/dashboard/js/simulate.js @@ -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 @@ + @@ -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'; + } } // ============================================ diff --git a/dashboard/js/viz3d.js b/dashboard/js/viz3d.js index e7f4239..f550a94 100644 --- a/dashboard/js/viz3d.js +++ b/dashboard/js/viz3d.js @@ -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 diff --git a/mothership/internal/api/simulator.go b/mothership/internal/api/simulator.go index 802d055..9956a0c 100644 --- a/mothership/internal/api/simulator.go +++ b/mothership/internal/api/simulator.go @@ -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() diff --git a/mothership/internal/simulator/handler.go b/mothership/internal/simulator/handler.go index a52180a..9cb41e9 100644 --- a/mothership/internal/simulator/handler.go +++ b/mothership/internal/simulator/handler.go @@ -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) +}