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:
parent
dd427713fb
commit
a70e3e433d
5 changed files with 365 additions and 23 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 < 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 > 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';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue