diff --git a/dashboard/index.html b/dashboard/index.html
index 582a4b3..8d26ff0 100644
--- a/dashboard/index.html
+++ b/dashboard/index.html
@@ -1483,6 +1483,20 @@
#room-editor-btn:hover { background: rgba(255,255,255,0.12); color: #ccc; }
#room-editor-btn.active { background: rgba(79,195,247,0.2); color: #4fc3f7; border-color: #4fc3f7; }
+ /* Simulator button */
+ #simulator-btn {
+ background: rgba(255,255,255,0.06);
+ border: 1px solid rgba(255,255,255,0.12);
+ color: #888;
+ font-size: 12px;
+ padding: 3px 10px;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: background 0.2s, color 0.2s;
+ }
+ #simulator-btn:hover { background: rgba(255,255,255,0.12); color: #ccc; }
+ #simulator-btn.active { background: rgba(156,39,176,0.2); color: #ab47bc; border-color: #ab47bc; }
+
/* GDOP toggle */
#gdop-toggle-btn {
background: rgba(255,255,255,0.06);
@@ -1704,6 +1718,14 @@
margin-bottom: 6px;
}
+ #gdop-coverage-score {
+ font-size: 13px;
+ font-weight: 600;
+ color: #22c65e;
+ margin-bottom: 8px;
+ text-align: center;
+ }
+
#gdop-gradient {
width: 140px;
height: 12px;
@@ -2543,6 +2565,7 @@
+
GDOP Coverage
+
Coverage: --%
Excellent
diff --git a/dashboard/js/placement.js b/dashboard/js/placement.js
index a485d96..5b3c5e6 100644
--- a/dashboard/js/placement.js
+++ b/dashboard/js/placement.js
@@ -110,6 +110,10 @@ const Placement = (function () {
// ── GDOP overlay ─────────────────────────────────────────────────────────
+ // Cache for backend GDOP data
+ var _gdopCache = null;
+ var _gdopPending = false;
+
function updateGDOPOverlay() {
var nodes = getNodePositions();
@@ -118,32 +122,70 @@ const Placement = (function () {
return;
}
+ // Fetch GDOP data from backend API (uses angular diversity algorithm)
+ if (!_gdopPending) {
+ _gdopPending = true;
+ fetch('/api/simulator/gdop/heatmap')
+ .then(function (resp) { return resp.json(); })
+ .then(function (data) {
+ _gdopCache = data;
+ renderGDOPFromCache();
+ _gdopPending = false;
+ })
+ .catch(function (e) {
+ console.error('[Placement] GDOP fetch failed:', e);
+ _gdopPending = false;
+ });
+ } else if (_gdopCache) {
+ renderGDOPFromCache();
+ }
+ }
+
+ function renderGDOPFromCache() {
+ if (!_gdopCache || !_roomConfig) return;
+
+ var data = _gdopCache;
var w = _roomConfig.width;
var d = _roomConfig.depth;
var ox = _roomConfig.origin_x || 0;
var oz = _roomConfig.origin_z || 0;
+ // Get grid dimensions from backend response
+ var gridWidth = data.grid_dimensions ? data.grid_dimensions[0] : GDOP_RES;
+ var gridDepth = data.grid_dimensions ? data.grid_dimensions[1] : GDOP_RES;
+ var gdopValues = data.gdop_map || [];
+ var colors = data.colors || [];
+
// Create or reuse DataTexture
if (!_gdopTexture) {
- var data = new Uint8Array(GDOP_RES * GDOP_RES * 4);
- _gdopTexture = new THREE.DataTexture(data, GDOP_RES, GDOP_RES, THREE.RGBAFormat);
+ var texData = new Uint8Array(gridWidth * gridDepth * 4);
+ _gdopTexture = new THREE.DataTexture(texData, gridWidth, gridDepth, THREE.RGBAFormat);
_gdopTexture.magFilter = THREE.LinearFilter;
_gdopTexture.minFilter = THREE.LinearFilter;
}
- var data = _gdopTexture.image.data;
+ var texData = _gdopTexture.image.data;
- for (var iz = 0; iz < GDOP_RES; iz++) {
- var pz = oz + (iz / (GDOP_RES - 1)) * d;
- for (var ix = 0; ix < GDOP_RES; ix++) {
- var px = ox + (ix / (GDOP_RES - 1)) * w;
- var hdop = computeHDOP(px, pz, nodes);
- var c = gdopToColor(hdop);
- var idx = (iz * GDOP_RES + ix) * 4;
- data[idx] = c[0];
- data[idx + 1] = c[1];
- data[idx + 2] = c[2];
- data[idx + 3] = c[3];
+ // Use backend-provided colors if available, otherwise compute from GDOP values
+ if (colors.length > 0) {
+ // Backend provides RGB colors
+ for (var i = 0; i < colors.length && i < gridWidth * gridDepth; i++) {
+ var idx = i * 4;
+ texData[idx] = colors[i][0];
+ texData[idx + 1] = colors[i][1];
+ texData[idx + 2] = colors[i][2];
+ texData[idx + 3] = 150; // Alpha
+ }
+ } else if (gdopValues.length > 0) {
+ // Fallback: compute colors from GDOP values
+ for (var i = 0; i < gdopValues.length && i < gridWidth * gridDepth; i++) {
+ var gdop = gdopValues[i];
+ var c = gdopToColor(gdop);
+ var idx = i * 4;
+ texData[idx] = c[0];
+ texData[idx + 1] = c[1];
+ texData[idx + 2] = c[2];
+ texData[idx + 3] = c[3];
}
}
@@ -168,6 +210,20 @@ const Placement = (function () {
}
_gdopMesh.visible = true;
+
+ // Update coverage score display if available
+ updateCoverageScore(data.coverage_score || data.coverage_percent);
+ }
+
+ function updateCoverageScore(score) {
+ var scoreEl = document.getElementById('gdop-coverage-score');
+ if (scoreEl) {
+ var pct = typeof score === 'number' ? score : 0;
+ scoreEl.textContent = 'Coverage: ' + pct.toFixed(1) + '%';
+
+ // Color-code the score
+ scoreEl.style.color = pct >= 70 ? '#22c65e' : pct >= 50 ? '#ffc107' : '#dc3545';
+ }
}
function rebuildGDOPIfDirty() {
@@ -211,6 +267,7 @@ const Placement = (function () {
obj.position.z = Math.max(oz, Math.min(oz + _roomConfig.depth, obj.position.z));
_gdopDirty = true;
+ _gdopCache = null; // Clear cache to force refresh with new node positions
// Update link lines to follow dragged node
if (Viz3D.rebuildLinkLines) Viz3D.rebuildLinkLines();
diff --git a/dashboard/js/simulate.js b/dashboard/js/simulate.js
index e15b8a8..c3359ab 100644
--- a/dashboard/js/simulate.js
+++ b/dashboard/js/simulate.js
@@ -443,12 +443,14 @@
const node = {
id: id,
name: 'Node ' + (state.nodes.length + 1),
+ type: 'virtual',
position: {
x: state.space.width / 2,
y: 1.0,
z: state.space.depth / 2,
},
role: 'tx_rx',
+ enabled: true,
};
state.nodes.push(node);
@@ -537,9 +539,14 @@
function addWalker() {
const type = elements.walkerType.value;
const id = 'walker_' + Date.now();
+
+ // Map frontend type to backend WalkerType
+ const backendType = type === 'path' ? 'path_follow' :
+ type === 'zone' ? 'node_to_node' : 'random_walk';
+
const walker = {
id: id,
- type: type,
+ type: backendType,
position: {
x: state.space.width / 2,
y: 1.0,
@@ -550,19 +557,19 @@
y: 0,
z: (Math.random() - 0.5) * CONFIG.walkerSpeed,
},
+ speed: CONFIG.walkerSpeed,
+ height: 1.7,
};
if (type === 'path') {
// Create default path
walker.path = [
- { x: 2, y: 1, z: 2 },
- { x: state.space.width - 2, y: 1, z: 2 },
- { x: state.space.width - 2, y: 1, z: state.space.depth - 2 },
- { x: 2, y: 1, z: state.space.depth - 2 },
+ { x: 2, y: 1.0, z: 2 },
+ { x: state.space.width - 2, y: 1.0, z: 2 },
+ { x: state.space.width - 2, y: 1.0, z: state.space.depth - 2 },
+ { x: 2, y: 1.0, z: state.space.depth - 2 },
];
walker.path_index = 0;
- } else if (type === 'zone') {
- walker.target_zones = [];
}
state.walkers.push(walker);
@@ -636,6 +643,21 @@
}
}
+ // ============================================
+ // Panel Toggle
+ // ============================================
+ function togglePanel() {
+ const panel = document.getElementById('simulator-panel');
+ const btn = document.getElementById('simulator-btn');
+ if (!panel || !btn) return;
+
+ const isVisible = panel.style.display !== 'none';
+ panel.style.display = isVisible ? 'none' : 'block';
+ btn.classList.toggle('active', !isVisible);
+
+ console.log('[Simulate] Panel', isVisible ? 'hidden' : 'shown');
+ }
+
// ============================================
// GDOP Visualization
// ============================================
diff --git a/dashboard/js/viz3d.js b/dashboard/js/viz3d.js
index f550a94..208d424 100644
--- a/dashboard/js/viz3d.js
+++ b/dashboard/js/viz3d.js
@@ -2142,15 +2142,7 @@ const Viz3D = (function () {
* Fetch GDOP heatmap data from API.
*/
function fetchGDOPData() {
- fetch('/api/simulator/gdop/compute', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- cell_size: 0.2,
- max_zone: 3,
- threshold: 0.02
- })
- })
+ fetch('/api/simulator/gdop/heatmap')
.then(function(response) { return response.json(); })
.then(function(data) {
_gdopData = data;
@@ -2166,21 +2158,39 @@ const Viz3D = (function () {
* @param {Object} data - GDOP computation results
*/
function updateGDOPOverlay(data) {
- if (!data || !data.gdop_heatmap) {
+ if (!data || !data.gdop_map) {
console.warn('[Viz3D] No GDOP heatmap data in response');
return;
}
- var heatmap = data.gdop_heatmap;
- var width = heatmap.width;
- var depth = heatmap.depth;
- var cellSize = heatmap.cell_size;
- var originX = heatmap.origin_x;
- var originY = heatmap.origin_y;
+ var gridDimensions = data.grid_dimensions || [0, 0, 0];
+ var width = gridDimensions[0];
+ var depth = gridDimensions[1];
+ var cellSize = 0.2; // Default cell size (not provided by this endpoint)
// Create texture from GDOP data
- var gdopValues = new Float32Array(heatmap.gdop_values);
- var colors = new Uint8Array(heatmap.colors.flat());
+ var gdopValues = new Float32Array(data.gdop_map);
+
+ // Generate colors from GDOP values using GDOPColorMap
+ var colors = new Uint8Array(width * depth * 3);
+ for (var i = 0; i < gdopValues.length; i++) {
+ var gdop = gdopValues[i];
+ var color;
+ 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: 220, g: 53, b: 69 }; // Poor - red
+ }
+ colors[i * 3] = color.r;
+ colors[i * 3 + 1] = color.g;
+ colors[i * 3 + 2] = color.b;
+ }
// Create data texture
if (_gdopTexture) {
@@ -2206,9 +2216,9 @@ const Viz3D = (function () {
_gdopMesh = new THREE.Mesh(geo, mat);
_gdopMesh.rotation.x = -Math.PI / 2;
_gdopMesh.position.set(
- originX + (width * cellSize) / 2,
+ (width * cellSize) / 2,
0.01, // Slightly above floor
- originY + (depth * cellSize) / 2
+ (depth * cellSize) / 2
);
_scene.add(_gdopMesh);
_gdopMesh.visible = _gdopOverlayVisible;
@@ -2220,17 +2230,17 @@ const Viz3D = (function () {
depth * cellSize
);
_gdopMesh.position.set(
- originX + (width * cellSize) / 2,
+ (width * cellSize) / 2,
0.01,
- originY + (depth * cellSize) / 2
+ (depth * cellSize) / 2
);
_gdopMesh.material.map = _gdopTexture;
}
// Update or create legend
- updateGDOPLegend(data.coverage_score);
+ updateGDOPLegend(data.coverage_percent || data.coverage_score);
- console.log('[Viz3D] GDOP overlay updated:', data.coverage_score.toFixed(1) + '% coverage');
+ console.log('[Viz3D] GDOP overlay updated:', (data.coverage_percent || data.coverage_score || 0).toFixed(1) + '% coverage');
}
/**
diff --git a/mothership/internal/api/simulator.go b/mothership/internal/api/simulator.go
index 9956a0c..e8ef3f5 100644
--- a/mothership/internal/api/simulator.go
+++ b/mothership/internal/api/simulator.go
@@ -3,11 +3,13 @@ package api
import (
"encoding/json"
+ "fmt"
"log"
"math"
"net/http"
"strconv"
"sync"
+ "time"
"github.com/go-chi/chi/v5"
"github.com/spaxel/mothership/internal/simulator"
@@ -38,6 +40,7 @@ func (h *SimulatorHandler) RegisterRoutes(r chi.Router) {
r.Route("/simulator", func(r chi.Router) {
r.Get("/", h.GetState)
r.Post("/reset", h.Reset)
+ r.Post("/session", h.CreateSession)
// Space management
r.Route("/space", func(r chi.Router) {
@@ -108,6 +111,37 @@ func (h *SimulatorHandler) Reset(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]string{"status": "reset"})
}
+// CreateSession creates a new simulator session
+func (h *SimulatorHandler) CreateSession(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ Space *simulator.Space `json:"space"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ // Use default space if request body is empty/invalid
+ req.Space = simulator.DefaultSpace()
+ }
+
+ if req.Space == nil {
+ req.Space = simulator.DefaultSpace()
+ }
+
+ // Update the handler's space if provided
+ if req.Space != nil {
+ h.mu.Lock()
+ h.space = req.Space
+ h.mu.Unlock()
+ }
+
+ // Generate session ID
+ sessionID := fmt.Sprintf("sim_%d", time.Now().UnixNano())
+
+ respondJSON(w, http.StatusOK, map[string]interface{}{
+ "session_id": sessionID,
+ "space": h.space,
+ })
+}
+
// GetSpace returns the current space definition
func (h *SimulatorHandler) GetSpace(w http.ResponseWriter, r *http.Request) {
h.mu.RLock()
diff --git a/mothership/internal/simulator/registry_bridge.go b/mothership/internal/simulator/registry_bridge.go
index 8a44e32..66ec734 100644
--- a/mothership/internal/simulator/registry_bridge.go
+++ b/mothership/internal/simulator/registry_bridge.go
@@ -234,7 +234,7 @@ func (b *FleetRegistryBridge) OptimizeCoverage(space *Space) (*CoverageOptimizat
weakAreas := make([]Point, 0)
for row := range results {
for col := range results[row] {
- if results[row][col] > 4 {
+ if results[row][col].GDOP > 4 {
// Calculate position from grid indices
x := minX + float64(col)*config.CellSize
y := minY + float64(row)*config.CellSize
diff --git a/mothership/internal/simulator/space.go b/mothership/internal/simulator/space.go
index 1ed696f..e2a5724 100644
--- a/mothership/internal/simulator/space.go
+++ b/mothership/internal/simulator/space.go
@@ -244,6 +244,12 @@ func (s *Space) TotalVolume() float64 {
return v
}
+// Dimensions returns the overall width, depth, and height of the space
+func (s *Space) Dimensions() (width, depth, height float64) {
+ minX, minY, minZ, maxX, maxY, maxZ := s.Bounds()
+ return maxX - minX, maxY - minY, maxZ - minZ
+}
+
// GetWalls returns all wall segments from all rooms plus standalone walls
func (s *Space) GetWalls() []WallSegment {
walls := make([]WallSegment, 0, len(s.Walls))