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 = ` -
-

Pre-Deployment Simulator

- -
- -
- -
-

Space Configuration

-
- - - - -
- -
- - - - -
-
- - -
-

Virtual Nodes

-
- - -
-
-
- - -
-

Synthetic Walkers

-
- - - -
-
-
- - -
-

Coverage Analysis

-
- - -
- -
- - -
-

Simulation

-
- - - -
-
- 0:00 / 0:30 -
-
-
-
-
- - - - - - - - - -
- `; - - 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 @@ + + +