diff --git a/dashboard/css/simulator.css b/dashboard/css/simulator.css
index c86685f..ddbe7d5 100644
--- a/dashboard/css/simulator.css
+++ b/dashboard/css/simulator.css
@@ -5,20 +5,157 @@
*/
/* ============================================
- Simulator Panel
+ Simulator Container
============================================ */
-.simulator-panel {
- position: fixed;
- top: 44px; /* Below status bar */
- right: 0;
- width: 380px;
- max-height: calc(100vh - 44px);
+#simulator-container {
+ position: relative;
+ width: 100%;
+ height: calc(100vh - 44px); /* Below header */
+}
+
+.simulator-scene {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+}
+
+.gdop-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ pointer-events: none;
+ z-index: 10;
+}
+
+/* ============================================
+ Simulator Panels
+ ============================================ */
+.sim-panel {
+ position: absolute;
+ width: 320px;
+ max-height: calc(100vh - 100px);
background: var(--bg-page);
- border-left: 1px solid var(--slate-5);
+ border: 1px solid var(--slate-6);
+ border-radius: var(--radius-lg);
+ padding: var(--space-4);
overflow-y: auto;
- overflow-x: hidden;
- z-index: 100;
- box-shadow: -2px 0 10px var(--shadow);
+ z-index: 50;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+}
+
+.sim-panel h3 {
+ margin: 0 0 var(--space-3) 0;
+ font-size: var(--text-base);
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.sim-panel--controls {
+ top: var(--space-4);
+ left: var(--space-4);
+}
+
+.sim-panel--space {
+ top: var(--space-4);
+ right: var(--space-4);
+}
+
+.sim-panel--nodes {
+ bottom: var(--space-4);
+ left: var(--space-4);
+ max-height: 250px;
+}
+
+.sim-panel--results {
+ bottom: var(--space-4);
+ right: var(--space-4);
+ max-height: 200px;
+}
+
+/* ============================================
+ Space Form
+ ============================================ */
+.sim-space__form {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+
+.sim-space__row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: var(--space-2);
+}
+
+.sim-space__row label {
+ font-size: var(--text-sm);
+ color: var(--text-secondary);
+}
+
+.sim-space__row input[type="number"] {
+ width: 70px;
+ padding: var(--space-1) var(--space-2);
+ background: var(--bg-hover);
+ border: 1px solid var(--slate-6);
+ border-radius: var(--radius-control);
+ color: var(--text-on-accent);
+ font-size: var(--text-sm);
+}
+
+/* ============================================
+ Node List
+ ============================================ */
+.sim-nodes__list {
+ max-height: 200px;
+ overflow-y: auto;
+ margin-bottom: var(--space-3);
+}
+
+.sim-nodes__add {
+ margin-bottom: var(--space-3);
+}
+
+.sim-nodes__suggestions {
+ margin-top: var(--space-3);
+ padding: var(--space-2);
+ background: var(--bg-hover);
+ border-radius: var(--radius-control);
+ font-size: var(--text-sm);
+ color: var(--text-muted);
+}
+
+/* ============================================
+ Results Metrics
+ ============================================ */
+.sim-results__metrics {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ margin-bottom: var(--space-3);
+}
+
+.sim-metric {
+ display: flex;
+ justify-content: space-between;
+ padding: var(--space-2);
+ background: var(--bg-hover);
+ border-radius: var(--radius-control);
+}
+
+.sim-metric__label {
+ font-size: var(--text-sm);
+ color: var(--text-secondary);
+}
+
+.sim-metric__value {
+ font-size: var(--text-sm);
+ color: var(--text-primary);
+ font-weight: 600;
}
.simulator-panel::-webkit-scrollbar {
@@ -270,6 +407,21 @@
background: var(--alert-bg);
}
+.sim-item.selected {
+ background: var(--blue-9);
+ border-color: var(--blue-9);
+}
+
+.sim-item.selected .sim-item-name {
+ color: var(--text-on-accent);
+ font-weight: 600;
+}
+
+.sim-item.selected .sim-item-position {
+ color: var(--text-on-accent);
+ opacity: 0.8;
+}
+
/* ============================================
Walker Controls
============================================ */
diff --git a/dashboard/js/simulate.js b/dashboard/js/simulate.js
index c3359ab..5df7a38 100644
--- a/dashboard/js/simulate.js
+++ b/dashboard/js/simulate.js
@@ -3,6 +3,14 @@
*
* Allows users to model their space, place virtual nodes, and run synthetic walkers
* to estimate expected accuracy before purchasing hardware.
+ *
+ * Features:
+ * - Space configuration (width, depth, height)
+ * - Virtual node placement with 3D editor reuse (TransformControls)
+ * - Ghost wireframe nodes for virtual nodes
+ * - Dashed links between virtual nodes
+ * - GDOP coverage overlay
+ * - Simulation with synthetic walkers
*/
(function() {
@@ -51,9 +59,7 @@
// UI state
currentTool: 'select', // select, wall, node, walker
- editingWall: null,
editingNode: null,
- editingWalker: null,
// GDOP overlay
showGDOP: false,
@@ -75,10 +81,15 @@
let _camera = null;
let _renderer = null;
let _controls = null;
- let _wallMeshes = [];
+ let _transformControls = null;
let _nodeMeshes = new Map();
let _walkerMeshes = new Map();
let _gdopMesh = null;
+ let _roomBoundsMesh = null;
+ let _linkLineMeshes = [];
+
+ // Node MAC address generator for virtual nodes
+ let _virtualNodeCounter = 0;
// ============================================
// Initialization
@@ -97,314 +108,236 @@
function initAfterViz3D() {
// Get Three.js references from Viz3D
const container = document.getElementById('scene-container');
- if (!container) return;
-
- // Create simulator UI
- createSimulatorUI();
-
- // Listen for router mode changes
- if (window.SpaxelRouter) {
- window.SpaxelRouter.onModeChange(onModeChange);
+ if (!container) {
+ console.warn('[Simulate] No scene-container found');
+ return;
}
+ // Initialize UI elements from existing HTML
+ initSimulatorUI();
+
+ // Initialize TransformControls for 3D editing
+ initTransformControls();
+
+ // Apply default space
+ applySpace();
+
console.log('[Simulate] Ready');
}
// ============================================
// Simulator UI
// ============================================
- function createSimulatorUI() {
- // Create simulator panel (hidden by default)
- const panel = document.createElement('div');
- panel.id = 'simulator-panel';
- panel.className = 'simulator-panel';
- panel.style.display = 'none';
-
- panel.innerHTML = `
-
-
-
-
-
-
-
-
-
Virtual Nodes
-
-
-
-
-
-
-
-
-
-
Synthetic Walkers
-
-
-
-
-
-
-
-
-
-
-
Coverage Analysis
-
-
-
-
-
-
-
- Excellent (GDOP < 2)
-
-
-
- Good (GDOP 2-4)
-
-
-
- Fair (GDOP 4-8)
-
-
-
- Poor (GDOP > 8)
-
-
-
- No Coverage
-
-
- Coverage: --
- Mean GDOP: --
-
-
-
-
-
-
-
Simulation
-
-
-
-
-
-
-
-
-
-
-
Results
-
-
- Expected Accuracy:
- --
-
-
- Coverage Score:
- --
-
-
-
-
-
-
-
-
-
-
- `;
-
- document.body.appendChild(panel);
-
- // Store element references
+ function initSimulatorUI() {
+ // Store element references from existing HTML structure in simulator.html
elements = {
- panel: panel,
- closeBtn: document.getElementById('sim-close-btn'),
- spaceWidth: document.getElementById('sim-space-width'),
- spaceDepth: document.getElementById('sim-space-depth'),
- spaceHeight: document.getElementById('sim-space-height'),
- applySpace: document.getElementById('sim-apply-space'),
- toolBtns: document.querySelectorAll('.sim-tool-btn'),
- addNode: document.getElementById('sim-add-node'),
- clearNodes: document.getElementById('sim-clear-nodes'),
- nodesContainer: document.getElementById('sim-nodes-container'),
- walkerType: document.getElementById('sim-walker-type'),
- addWalker: document.getElementById('sim-add-walker'),
- clearWalkers: document.getElementById('sim-clear-walkers'),
- walkersContainer: document.getElementById('sim-walkers-container'),
- showGDOP: document.getElementById('sim-show-gdop'),
- updateGDOP: document.getElementById('sim-update-gdop'),
- startBtn: document.getElementById('sim-start-btn'),
- pauseBtn: document.getElementById('sim-pause-btn'),
- stopBtn: document.getElementById('sim-stop-btn'),
- time: document.getElementById('sim-time'),
- progressFill: document.getElementById('sim-progress-fill'),
- resultsSection: document.getElementById('sim-results-section'),
- resultAccuracy: document.getElementById('sim-result-accuracy'),
- resultCoverage: document.getElementById('sim-result-coverage'),
- recommendationsSection: document.getElementById('sim-recommendations-section'),
- recommendations: document.getElementById('sim-recommendations'),
- shoppingSection: document.getElementById('sim-shopping-section'),
- shoppingList: document.getElementById('sim-shopping-list'),
+ // Space configuration
+ spaceWidth: document.getElementById('space-width'),
+ spaceDepth: document.getElementById('space-depth'),
+ spaceHeight: document.getElementById('space-height'),
+ applySpace: document.getElementById('btn-apply-space'),
+
+ // Node controls
+ addNode: document.getElementById('btn-add-node'),
+ nodeList: document.getElementById('node-list'),
+ nodeSuggestions: document.getElementById('node-suggestions'),
+
+ // Simulation controls
+ simulateBtn: document.getElementById('btn-simulate'),
+ stopBtn: document.getElementById('btn-stop'),
+ resetBtn: document.getElementById('btn-reset'),
+ duration: document.getElementById('sim-duration'),
+ walkers: document.getElementById('sim-walkers'),
+ showPaths: document.getElementById('sim-show-paths'),
+
+ // Results display
+ metricCoverage: document.getElementById('metric-coverage'),
+ metricGdop: document.getElementById('metric-gdop'),
+ metricBlobs: document.getElementById('metric-blobs'),
+ simStatus: document.getElementById('sim-status'),
+
+ // GDOP canvas
+ gdopCanvas: document.getElementById('gdop-canvas'),
};
// Attach event listeners
- elements.closeBtn.addEventListener('click', exitSimulator);
- elements.applySpace.addEventListener('click', applySpace);
- elements.toolBtns.forEach(btn => {
- btn.addEventListener('click', () => selectTool(btn.dataset.tool));
- });
- elements.addNode.addEventListener('click', addNode);
- elements.clearNodes.addEventListener('click', clearNodes);
- elements.addWalker.addEventListener('click', addWalker);
- elements.clearWalkers.addEventListener('click', clearWalkers);
- elements.showGDOP.addEventListener('change', toggleGDOP);
- elements.updateGDOP.addEventListener('click', updateGDOP);
- elements.startBtn.addEventListener('click', startSimulation);
- elements.pauseBtn.addEventListener('click', pauseSimulation);
- elements.stopBtn.addEventListener('click', stopSimulation);
-
- // Set default tool
- selectTool('select');
- }
-
- // ============================================
- // Router Integration
- // ============================================
- function onModeChange(newMode, oldMode) {
- if (newMode === 'simulate') {
- enterSimulator();
- } else if (oldMode === 'simulate') {
- exitSimulator();
+ if (elements.applySpace) {
+ elements.applySpace.addEventListener('click', applySpace);
}
- }
-
- function enterSimulator() {
- console.log('[Simulate] Entering simulator mode');
- elements.panel.style.display = 'block';
-
- // Apply default space
- applySpace();
-
- // Create session
- createSession();
- }
-
- function exitSimulator() {
- console.log('[Simulate] Exiting simulator mode');
- elements.panel.style.display = 'none';
-
- // Stop simulation if running
- if (state.simulationRunning) {
- stopSimulation();
+ if (elements.addNode) {
+ elements.addNode.addEventListener('click', addNode);
+ }
+ if (elements.simulateBtn) {
+ elements.simulateBtn.addEventListener('click', startSimulation);
+ }
+ if (elements.stopBtn) {
+ elements.stopBtn.addEventListener('click', stopSimulation);
+ }
+ if (elements.resetBtn) {
+ elements.resetBtn.addEventListener('click', resetSimulation);
}
- // Clear visualization
- clearSimulationMeshes();
+ console.log('[Simulate] Simulator UI initialized');
}
// ============================================
- // Session Management
+ // TransformControls (3D Editor Reuse)
// ============================================
- async function createSession() {
- try {
- const response = await fetch('/api/simulator/session', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- space: state.space,
- }),
+ function initTransformControls() {
+ if (!window.Viz3D) return;
+
+ _scene = window.Viz3D.getScene?.();
+ _camera = window.Viz3D.getCamera?.();
+ _renderer = window.Viz3D.getRenderer?.();
+ _controls = window.Viz3D.getControls?.();
+
+ if (!_scene || !_camera || !_renderer) {
+ console.warn('[Simulate] Viz3D not ready yet');
+ return;
+ }
+
+ // Initialize TransformControls for dragging nodes
+ if (typeof THREE !== 'undefined' && THREE.TransformControls) {
+ _transformControls = new THREE.TransformControls(_camera, _renderer.domElement);
+ _transformControls.setMode('translate');
+ _transformControls.setSize(0.75);
+ _scene.add(_transformControls.getHelper());
+
+ // Disable orbit controls while dragging
+ _transformControls.addEventListener('dragging-changed', function (event) {
+ if (_controls) {
+ _controls.enabled = !event.value;
+ }
+
+ // Save position on drag end
+ if (!event.value && state.editingNode) {
+ const mesh = _nodeMeshes.get(state.editingNode.id);
+ if (mesh) {
+ updateNodePosition(state.editingNode.id, {
+ x: mesh.position.x,
+ y: mesh.position.y,
+ z: mesh.position.z
+ });
+ // Update GDOP when node position changes
+ if (state.showGDOP) {
+ updateGDOP();
+ }
+ }
+ state.editingNode = null;
+ }
});
- if (!response.ok) throw new Error('Failed to create session');
+ // Constrain to room bounds during drag
+ _transformControls.addEventListener('objectChange', function () {
+ if (!state.editingNode || !state.space) return;
- const data = await response.json();
- state.sessionId = data.session_id;
- console.log('[Simulate] Session created:', state.sessionId);
- } catch (err) {
- console.error('[Simulate] Failed to create session:', err);
+ const obj = _transformControls.object;
+ const { width, depth, height } = state.space;
+
+ // Clamp to room bounds
+ obj.position.x = Math.max(0, Math.min(width, obj.position.x));
+ obj.position.y = Math.max(0.1, Math.min(height, obj.position.y));
+ obj.position.z = Math.max(0, Math.min(depth, obj.position.z));
+
+ // Update link lines during drag
+ rebuildLinkLines();
+ });
}
+
+ // Add pointer event listener for node selection
+ _renderer.domElement.addEventListener('pointerdown', onPointerDown);
+ _renderer.domElement.addEventListener('pointerup', onPointerUp);
+
+ console.log('[Simulate] TransformControls initialized');
+ }
+
+ // Pointer event handlers for click-to-select
+ let _mouseDown = { x: 0, y: 0 };
+ const CLICK_THRESHOLD = 5;
+
+ function onPointerDown(event) {
+ _mouseDown.x = event.clientX;
+ _mouseDown.y = event.clientY;
+ }
+
+ function onPointerUp(event) {
+ const dx = event.clientX - _mouseDown.x;
+ const dy = event.clientY - _mouseDown.y;
+ if (Math.sqrt(dx * dx + dy * dy) > CLICK_THRESHOLD) return;
+ if (event.target !== _renderer?.domElement) return;
+
+ const rect = _renderer.domElement.getBoundingClientRect();
+ const mouse = new THREE.Vector2(
+ ((event.clientX - rect.left) / rect.width) * 2 - 1,
+ -((event.clientY - rect.top) / rect.height) * 2 + 1
+ );
+
+ const raycaster = new THREE.Raycaster();
+ raycaster.setFromCamera(mouse, _camera);
+
+ // Collect all node meshes
+ const meshes = Array.from(_nodeMeshes.values());
+ const intersects = raycaster.intersectObjects(meshes);
+
+ if (intersects.length > 0) {
+ // Find which node owns this mesh
+ for (const [nodeId, mesh] of _nodeMeshes.entries()) {
+ if (mesh === intersects[0].object || mesh === intersects[0].object.parent) {
+ selectNode(nodeId);
+ return;
+ }
+ }
+ }
+
+ deselectNode();
+ }
+
+ // ============================================
+ // Node Selection (3D Editor)
+ // ============================================
+ function selectNode(nodeId) {
+ if (state.editingNode?.id === nodeId) return;
+
+ deselectNode();
+
+ const node = state.nodes.find(n => n.id === nodeId);
+ if (!node) return;
+
+ state.editingNode = node;
+
+ const mesh = _nodeMeshes.get(nodeId);
+ if (mesh && _transformControls) {
+ _transformControls.attach(mesh);
+ }
+
+ // Update UI
+ renderNodes();
+
+ console.log('[Simulate] Node selected:', nodeId);
+ }
+
+ function deselectNode() {
+ if (_transformControls) {
+ _transformControls.detach();
+ }
+ state.editingNode = null;
+
+ // Update UI
+ renderNodes();
}
// ============================================
// Space Management
// ============================================
function applySpace() {
- const width = parseFloat(elements.spaceWidth.value);
- const depth = parseFloat(elements.spaceDepth.value);
- const height = parseFloat(elements.spaceHeight.value);
+ const width = elements.spaceWidth ? parseFloat(elements.spaceWidth.value) : 10;
+ const depth = elements.spaceDepth ? parseFloat(elements.spaceDepth.value) : 8;
+ const height = elements.spaceHeight ? parseFloat(elements.spaceHeight.value) : 2.5;
state.space = { width, depth, height, walls: [] };
- // Update 3D visualization
- if (window.Viz3D) {
+ // Update 3D visualization with Viz3D
+ if (window.Viz3D && window.Viz3D.applyRoom) {
window.Viz3D.applyRoom({
width: width,
depth: depth,
@@ -414,36 +347,66 @@
});
}
+ // Update room bounds wireframe visualization
+ updateRoomBoundsVisualization();
+
console.log('[Simulate] Space applied:', state.space);
}
// ============================================
- // Tool Selection
+ // Room Bounds Visualization
// ============================================
- function selectTool(tool) {
- state.currentTool = tool;
+ function updateRoomBoundsVisualization() {
+ const scene = window.Viz3D?.getScene?.();
+ if (!scene || !state.space) return;
- // Update button states
- elements.toolBtns.forEach(btn => {
- if (btn.dataset.tool === tool) {
- btn.classList.add('active');
- } else {
- btn.classList.remove('active');
- }
+ // Remove existing room bounds mesh
+ if (_roomBoundsMesh) {
+ scene.remove(_roomBoundsMesh);
+ _roomBoundsMesh.geometry.dispose();
+ _roomBoundsMesh.material.dispose();
+ _roomBoundsMesh = null;
+ }
+
+ const { width, depth, height } = state.space;
+
+ // Create wireframe box geometry
+ const geometry = new THREE.BoxGeometry(width, height, depth);
+ const edges = new THREE.EdgesGeometry(geometry);
+
+ const material = new THREE.LineBasicMaterial({
+ color: 0x4fc3f7,
+ opacity: 0.4,
+ transparent: true,
});
- console.log('[Simulate] Tool selected:', tool);
+ _roomBoundsMesh = new THREE.LineSegments(edges, material);
+ _roomBoundsMesh.position.set(width / 2, height / 2, depth / 2);
+
+ scene.add(_roomBoundsMesh);
+
+ console.log('[Simulate] Room bounds visualization updated:', { width, depth, height });
}
// ============================================
// Node Management
// ============================================
function addNode() {
- const id = 'node_' + Date.now();
+ // Generate virtual MAC address
+ _virtualNodeCounter++;
+ const mac = 'AA:BB:CC:' +
+ (_virtualNodeCounter & 0xFF).toString(16).padStart(2, '0').toUpperCase() + ':' +
+ ((_virtualNodeCounter >> 8) & 0xFF).toString(16).padStart(2, '0').toUpperCase() + ':' +
+ ((_virtualNodeCounter >> 16) & 0xFF).toString(16).padStart(2, '0').toUpperCase();
+
+ const id = 'node_' + Date.now() + '_' + _virtualNodeCounter;
const node = {
id: id,
- name: 'Node ' + (state.nodes.length + 1),
+ mac: mac,
+ name: 'Virtual Node ' + (state.nodes.length + 1),
type: 'virtual',
+ node_type: 'esp32',
+ virtual: true,
position: {
x: state.space.width / 2,
y: 1.0,
@@ -456,21 +419,26 @@
state.nodes.push(node);
renderNodes();
updateNodeVisualization(node);
+ rebuildLinkLines();
- // Sync with backend
- syncNode(node);
+ // Update status
+ updateStatus(`Added ${node.name} at (${node.position.x.toFixed(1)}, ${node.position.y.toFixed(1)}, ${node.position.z.toFixed(1)})`);
+
+ // Auto-show GDOP if this is the second node
+ if (state.nodes.length >= 2 && !state.showGDOP) {
+ toggleGDOP();
+ }
console.log('[Simulate] Node added:', node);
}
function removeNode(nodeId) {
+ const node = state.nodes.find(n => n.id === nodeId);
state.nodes = state.nodes.filter(n => n.id !== nodeId);
renderNodes();
removeNodeVisualization(nodeId);
- // Sync with backend
- deleteNode(nodeId);
-
+ updateStatus(`Removed ${node ? node.name : 'node'}`);
console.log('[Simulate] Node removed:', nodeId);
}
@@ -479,190 +447,51 @@
if (node) {
node.position = position;
updateNodeVisualization(node);
- syncNode(node);
}
}
- function clearNodes() {
- state.nodes.forEach(n => removeNodeVisualization(n.id));
- state.nodes = [];
- renderNodes();
- console.log('[Simulate] All nodes cleared');
- }
-
function renderNodes() {
- elements.nodesContainer.innerHTML = '';
+ if (!elements.nodeList) return;
+
+ elements.nodeList.innerHTML = '';
state.nodes.forEach(node => {
const div = document.createElement('div');
- div.className = 'sim-item';
+ div.className = 'sim-item' + (state.editingNode?.id === node.id ? ' selected' : '');
+ div.style.cssText = 'display:flex;justify-content:space-between;align-items:center;padding:8px;background:var(--bg-hover);border-radius:4px;margin-bottom:4px;cursor:pointer;';
div.innerHTML = `
- ${node.name}
-
+ ${node.name}
+
(${node.position.x.toFixed(1)}, ${node.position.y.toFixed(1)}, ${node.position.z.toFixed(1)})
-
+
`;
- elements.nodesContainer.appendChild(div);
+ elements.nodeList.appendChild(div);
+
+ // Add click handler for selection
+ div.addEventListener('click', (e) => {
+ if (!e.target.classList.contains('sim-item-delete')) {
+ selectNode(node.id);
+ }
+ });
});
// Attach delete handlers
- elements.nodesContainer.querySelectorAll('.sim-item-delete').forEach(btn => {
+ elements.nodeList.querySelectorAll('.sim-item-delete').forEach(btn => {
btn.addEventListener('click', () => removeNode(btn.dataset.id));
});
}
- async function syncNode(node) {
- try {
- await fetch('/api/simulator/nodes', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(node),
- });
- } catch (err) {
- console.error('[Simulate] Failed to sync node:', err);
+ function updateStatus(message) {
+ if (elements.simStatus) {
+ elements.simStatus.textContent = message;
}
}
- async function deleteNode(nodeId) {
- try {
- await fetch(`/api/simulator/nodes/${nodeId}`, {
- method: 'DELETE',
- });
- } catch (err) {
- console.error('[Simulate] Failed to delete node:', err);
- }
- }
-
- // ============================================
- // Walker Management
- // ============================================
- 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: backendType,
- position: {
- x: state.space.width / 2,
- y: 1.0,
- z: state.space.depth / 2,
- },
- velocity: {
- x: (Math.random() - 0.5) * CONFIG.walkerSpeed,
- 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.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;
- }
-
- state.walkers.push(walker);
- renderWalkers();
- updateWalkerVisualization(walker);
-
- // Sync with backend
- syncWalker(walker);
-
- console.log('[Simulate] Walker added:', walker);
- }
-
- function removeWalker(walkerId) {
- state.walkers = state.walkers.filter(w => w.id !== walkerId);
- renderWalkers();
- removeWalkerVisualization(walkerId);
-
- // Sync with backend
- deleteWalker(walkerId);
-
- console.log('[Simulate] Walker removed:', walkerId);
- }
-
- function clearWalkers() {
- state.walkers.forEach(w => removeWalkerVisualization(w.id));
- state.walkers = [];
- renderWalkers();
- console.log('[Simulate] All walkers cleared');
- }
-
- function renderWalkers() {
- elements.walkersContainer.innerHTML = '';
- state.walkers.forEach(walker => {
- const div = document.createElement('div');
- div.className = 'sim-item';
- div.innerHTML = `
- ${walker.type} walker
-
- (${walker.position.x.toFixed(1)}, ${walker.position.z.toFixed(1)})
-
-
- `;
- elements.walkersContainer.appendChild(div);
- });
-
- // Attach delete handlers
- elements.walkersContainer.querySelectorAll('.sim-item-delete').forEach(btn => {
- btn.addEventListener('click', () => removeWalker(btn.dataset.id));
- });
- }
-
- async function syncWalker(walker) {
- try {
- await fetch('/api/simulator/walkers', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(walker),
- });
- } catch (err) {
- console.error('[Simulate] Failed to sync walker:', err);
- }
- }
-
- async function deleteWalker(walkerId) {
- try {
- await fetch(`/api/simulator/walkers/${walkerId}`, {
- method: 'DELETE',
- });
- } catch (err) {
- console.error('[Simulate] Failed to delete walker:', err);
- }
- }
-
- // ============================================
- // 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
// ============================================
function toggleGDOP() {
- state.showGDOP = elements.showGDOP.checked;
+ state.showGDOP = !state.showGDOP;
if (state.showGDOP) {
updateGDOP();
} else {
@@ -731,11 +560,6 @@
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 >= 9999) {
color = { r: 80, g: 80, b: 80 }; // None - gray
@@ -780,59 +604,18 @@
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) {
+ // Update metrics
+ if (data.coverage_score !== undefined && elements.metricCoverage) {
const coveragePercent = (data.coverage_score * 100).toFixed(1);
- if (coverageEl) {
- coverageEl.textContent = coveragePercent + '%';
- }
- console.log('[Simulate] Coverage score:', coveragePercent + '%');
+ elements.metricCoverage.textContent = coveragePercent + '%';
}
- if (data.mean_gdop !== undefined) {
+ if (data.mean_gdop !== undefined && elements.metricGdop) {
const meanGDOP = data.mean_gdop < 9999 ? data.mean_gdop.toFixed(2) : '∞';
- if (meanEl) {
- meanEl.textContent = meanGDOP;
- }
- console.log('[Simulate] Mean GDOP:', meanGDOP);
+ elements.metricGdop.textContent = 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;
- }
- }
- }
- });
- }
+ console.log('[Simulate] GDOP rendered');
}
function clearGDOPMesh() {
@@ -843,12 +626,6 @@
_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';
- }
}
// ============================================
@@ -856,14 +633,11 @@
// ============================================
async function startSimulation() {
if (state.nodes.length < 2) {
- alert('Please add at least 2 nodes before starting simulation');
+ updateStatus('Error: Add at least 2 nodes first');
return;
}
- if (state.walkers.length === 0) {
- alert('Please add at least 1 walker before starting simulation');
- return;
- }
+ updateStatus('Starting simulation...');
try {
const response = await fetch('/api/simulator/simulate', {
@@ -883,12 +657,8 @@
state.simulationTime = 0;
// Update UI
- elements.startBtn.disabled = true;
- elements.pauseBtn.disabled = false;
- elements.stopBtn.disabled = false;
- elements.resultsSection.style.display = 'none';
- elements.recommendationsSection.style.display = 'none';
- elements.shoppingSection.style.display = 'none';
+ if (elements.simulateBtn) elements.simulateBtn.disabled = true;
+ if (elements.stopBtn) elements.stopBtn.disabled = false;
// Start progress update
startProgressLoop();
@@ -899,37 +669,41 @@
console.log('[Simulate] Simulation started');
} catch (err) {
console.error('[Simulate] Failed to start simulation:', err);
- alert('Failed to start simulation: ' + err.message);
+ updateStatus('Error: ' + err.message);
}
}
- function pauseSimulation() {
- if (!state.simulationRunning) return;
-
- state.simulationPaused = !state.simulationPaused;
- elements.pauseBtn.textContent = state.simulationPaused ? 'Resume' : 'Pause';
-
- console.log('[Simulate] Simulation', state.simulationPaused ? 'paused' : 'resumed');
- }
-
- async function stopSimulation() {
+ function stopSimulation() {
state.simulationRunning = false;
state.simulationPaused = false;
state.simulationTime = 0;
// Update UI
- elements.startBtn.disabled = false;
- elements.pauseBtn.disabled = true;
- elements.pauseBtn.textContent = 'Pause';
- elements.stopBtn.disabled = true;
+ if (elements.simulateBtn) elements.simulateBtn.disabled = false;
+ if (elements.stopBtn) elements.stopBtn.disabled = true;
- // Reset progress
- elements.time.textContent = '0:00 / 0:30';
- elements.progressFill.style.width = '0%';
+ updateStatus('Simulation stopped');
console.log('[Simulate] Simulation stopped');
}
+ function resetSimulation() {
+ stopSimulation();
+ state.nodes = [];
+ state.walkers = [];
+ renderNodes();
+ clearSimulationMeshes();
+ clearGDOPMesh();
+
+ if (elements.metricCoverage) elements.metricCoverage.textContent = '--';
+ if (elements.metricGdop) elements.metricGdop.textContent = '--';
+ if (elements.metricBlobs) elements.metricBlobs.textContent = '--';
+
+ updateStatus('Simulation reset');
+
+ console.log('[Simulate] Simulation reset');
+ }
+
function startProgressLoop() {
const interval = setInterval(() => {
if (!state.simulationRunning) {
@@ -939,21 +713,10 @@
if (!state.simulationPaused) {
state.simulationTime += 0.1;
- updateProgress();
}
}, 100);
}
- function updateProgress() {
- const duration = CONFIG.defaultDurationSec;
- const progress = Math.min(state.simulationTime / duration, 1);
-
- const elapsed = Math.floor(state.simulationTime);
- const total = Math.floor(duration);
- elements.time.textContent = `${Math.floor(elapsed / 60)}:${(elapsed % 60).toString().padStart(2, '0')} / ${Math.floor(total / 60)}:${(total % 60).toString().padStart(2, '0')}`;
- elements.progressFill.style.width = (progress * 100) + '%';
- }
-
async function pollSimulationResults() {
const pollInterval = setInterval(async () => {
if (!state.simulationRunning) {
@@ -1007,45 +770,19 @@
}
function displayResults(data) {
- // Show results section
- elements.resultsSection.style.display = 'block';
-
- // Display accuracy
- const accuracy = data.expected_accuracy_m || 0;
- elements.resultAccuracy.textContent = accuracy < 0.5 ? '< 0.5m (Excellent)' :
- accuracy < 1.0 ? '< 1.0m (Good)' :
- accuracy < 1.5 ? '< 1.5m (Fair)' :
- '> 1.5m (Poor)';
-
- // Display coverage
- const coverage = data.coverage_score || 0;
- elements.resultCoverage.textContent = (coverage * 100).toFixed(0) + '%';
-
- // Display recommendations
- if (data.recommendations && data.recommendations.length > 0) {
- elements.recommendationsSection.style.display = 'block';
- elements.recommendations.innerHTML = data.recommendations.map(rec => `
-
- ${rec.priority}
- ${rec.message}
-
- `).join('');
+ if (elements.metricCoverage && data.coverage_score) {
+ elements.metricCoverage.textContent = (data.coverage_score * 100).toFixed(0) + '%';
}
- // Display shopping list
- if (data.shopping_list) {
- elements.shoppingSection.style.display = 'block';
- elements.shoppingList.innerHTML = `
-
- Minimum nodes:
- ${data.shopping_list.min_nodes || state.nodes.length}
-
-
- Recommended nodes:
- ${data.shopping_list.recommended_nodes || state.nodes.length}
-
- `;
+ if (elements.metricGdop && data.mean_gdop) {
+ elements.metricGdop.textContent = data.mean_gdop < 9999 ? data.mean_gdop.toFixed(2) : '∞';
}
+
+ if (elements.metricBlobs && data.blob_count !== undefined) {
+ elements.metricBlobs.textContent = data.blob_count;
+ }
+
+ updateStatus('Simulation complete');
}
// ============================================
@@ -1059,22 +796,133 @@
// Remove existing mesh
removeNodeVisualization(node.id);
- // Create node mesh
- const geometry = new THREE.SphereGeometry(0.15, 16, 16);
- const material = new THREE.MeshLambertMaterial({
- color: node.role === 'tx' ? 0xff6b6b : node.role === 'rx' ? 0x4ecdc4 : 0x45b7d1,
- emissive: node.role === 'tx' ? 0xff6b6b : node.role === 'rx' ? 0x4ecdc4 : 0x45b7d1,
- emissiveIntensity: 0.3,
- });
+ let mesh;
+
+ if (node.virtual) {
+ // Ghost wireframe node for virtual nodes
+ // Use wireframe octahedron (matching viz3d.js style)
+ const geometry = new THREE.OctahedronGeometry(0.15, 0);
+ const material = new THREE.MeshPhongMaterial({
+ color: 0x80cbc4, // Teal for virtual nodes
+ wireframe: true,
+ transparent: true,
+ opacity: 0.6,
+ });
+
+ mesh = new THREE.Mesh(geometry, material);
+ mesh.userData = {
+ nodeId: node.id,
+ mac: node.mac,
+ virtual: true,
+ node_type: node.node_type || 'esp32',
+ };
+ } else {
+ // Solid sphere for real nodes
+ const geometry = new THREE.SphereGeometry(0.15, 16, 16);
+ const material = new THREE.MeshLambertMaterial({
+ color: node.role === 'tx' ? 0xff6b6b : node.role === 'rx' ? 0x4ecdc4 : 0x45b7d1,
+ emissive: node.role === 'tx' ? 0xff6b6b : node.role === 'rx' ? 0x4ecdc4 : 0x45b7d1,
+ emissiveIntensity: 0.3,
+ });
+
+ mesh = new THREE.Mesh(geometry, material);
+ mesh.userData = {
+ nodeId: node.id,
+ mac: node.mac || '',
+ virtual: false,
+ };
+ }
- const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(node.position.x, node.position.y, node.position.z);
- mesh.userData.nodeId = node.id;
scene.add(mesh);
_nodeMeshes.set(node.id, mesh);
}
+ // ============================================
+ // Link Lines (with dashed lines for virtual nodes)
+ // ============================================
+ function rebuildLinkLines() {
+ const scene = window.Viz3D?.getScene?.();
+ if (!scene) return;
+
+ // Clear existing link lines
+ clearLinkLines();
+
+ if (state.nodes.length < 2) return;
+
+ // Create links between all node pairs
+ for (let i = 0; i < state.nodes.length; i++) {
+ for (let j = i + 1; j < state.nodes.length; j++) {
+ const nodeA = state.nodes[i];
+ const nodeB = state.nodes[j];
+ const meshA = _nodeMeshes.get(nodeA.id);
+ const meshB = _nodeMeshes.get(nodeB.id);
+
+ if (!meshA || !meshB) continue;
+
+ // Check if either node is virtual
+ const aIsVirtual = nodeA.virtual || false;
+ const bIsVirtual = nodeB.virtual || false;
+ const isVirtualLink = aIsVirtual || bIsVirtual;
+
+ // Create line geometry
+ const points = [
+ meshA.position.clone(),
+ meshB.position.clone()
+ ];
+ const geometry = new THREE.BufferGeometry().setFromPoints(points);
+
+ let material;
+ if (isVirtualLink) {
+ // Dashed line for virtual links (teal color)
+ material = new THREE.LineDashedMaterial({
+ color: 0x80cbc4,
+ dashSize: 0.1,
+ gapSize: 0.05,
+ opacity: 0.5,
+ transparent: true,
+ });
+ } else {
+ // Solid line for real links
+ material = new THREE.LineBasicMaterial({
+ color: 0x4fc3f7,
+ opacity: 0.3,
+ transparent: true,
+ });
+ }
+
+ const line = new THREE.Line(geometry, material);
+ line.userData = {
+ nodeA: nodeA.id,
+ nodeB: nodeB.id,
+ virtual: isVirtualLink
+ };
+
+ if (isVirtualLink) {
+ line.computeLineDistances(); // Required for dashed lines
+ }
+
+ scene.add(line);
+ _linkLineMeshes.push(line);
+ }
+ }
+
+ console.log('[Simulate] Rebuilt', _linkLineMeshes.length, 'link lines');
+ }
+
+ function clearLinkLines() {
+ const scene = window.Viz3D?.getScene?.();
+ if (!scene) return;
+
+ for (const line of _linkLineMeshes) {
+ scene.remove(line);
+ line.geometry.dispose();
+ line.material.dispose();
+ }
+ _linkLineMeshes = [];
+ }
+
function removeNodeVisualization(nodeId) {
const mesh = _nodeMeshes.get(nodeId);
if (mesh) {
@@ -1084,6 +932,9 @@
mesh.material.dispose();
_nodeMeshes.delete(nodeId);
}
+
+ // Rebuild link lines after removing a node
+ rebuildLinkLines();
}
function updateWalkerVisualization(walker) {
@@ -1124,6 +975,7 @@
function clearSimulationMeshes() {
_nodeMeshes.forEach((mesh, id) => removeNodeVisualization(id));
_walkerMeshes.forEach((mesh, id) => removeWalkerVisualization(id));
+ clearLinkLines();
clearGDOPMesh();
}
@@ -1133,9 +985,14 @@
window.SpaxelSimulator = {
init: init,
getState: () => state,
+ addNode: addNode,
+ removeNode: removeNode,
+ applySpace: applySpace,
+ toggleGDOP: toggleGDOP,
+ updateGDOP: updateGDOP,
};
- // Auto-initialize
+ // Auto-initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
diff --git a/dashboard/simulator.html b/dashboard/simulator.html
index 4d15010..4040c2f 100644
--- a/dashboard/simulator.html
+++ b/dashboard/simulator.html
@@ -169,6 +169,9 @@
+
+
+