/** * Spaxel Dashboard - Pre-Deployment Simulator * * Allows users to model their space, place virtual nodes, and run synthetic walkers * to estimate expected accuracy before purchasing hardware. */ (function() { 'use strict'; // ============================================ // Configuration // ============================================ const CONFIG = { // Simulation tick rate (Hz) tickRateHz: 10, // Default simulation duration (seconds) defaultDurationSec: 30, // Walker speed (m/s) walkerSpeed: 1.0, // Grid resolution for GDOP calculation (meters) gridResolutionM: 0.2, // Fresnel zone parameters fresnelSigma: 0.3, signalAmplitude: 0.05, }; // ============================================ // State // ============================================ const state = { // Space definition space: { width: 10, depth: 10, height: 2.5, walls: [], }, // Virtual nodes nodes: [], // Walkers walkers: [], // Simulation state simulationRunning: false, simulationPaused: false, simulationTime: 0, simulationResults: null, // UI state currentTool: 'select', // select, wall, node, walker editingWall: null, editingNode: null, editingWalker: null, // GDOP overlay showGDOP: false, gdopData: null, // Session ID sessionId: null, }; // ============================================ // DOM Elements // ============================================ let elements = {}; // ============================================ // Three.js references // ============================================ let _scene = null; let _camera = null; let _renderer = null; let _controls = null; let _wallMeshes = []; let _nodeMeshes = new Map(); let _walkerMeshes = new Map(); let _gdopMesh = null; // ============================================ // Initialization // ============================================ function init() { console.log('[Simulate] Initializing pre-deployment simulator'); // Wait for Three.js scene to be ready if (window.Viz3D) { initAfterViz3D(); } else { document.addEventListener('viz3d-ready', initAfterViz3D); } } 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); } 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 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'), }; // 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(); } } 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(); } // Clear visualization clearSimulationMeshes(); } // ============================================ // Session Management // ============================================ async function createSession() { try { const response = await fetch('/api/simulator/session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ space: state.space, }), }); if (!response.ok) throw new Error('Failed to create session'); 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); } } // ============================================ // Space Management // ============================================ function applySpace() { const width = parseFloat(elements.spaceWidth.value); const depth = parseFloat(elements.spaceDepth.value); const height = parseFloat(elements.spaceHeight.value); state.space = { width, depth, height, walls: [] }; // Update 3D visualization if (window.Viz3D) { window.Viz3D.applyRoom({ width: width, depth: depth, height: height, origin_x: 0, origin_z: 0, }); } console.log('[Simulate] Space applied:', state.space); } // ============================================ // Tool Selection // ============================================ function selectTool(tool) { state.currentTool = tool; // Update button states elements.toolBtns.forEach(btn => { if (btn.dataset.tool === tool) { btn.classList.add('active'); } else { btn.classList.remove('active'); } }); console.log('[Simulate] Tool selected:', tool); } // ============================================ // Node Management // ============================================ function addNode() { const id = 'node_' + Date.now(); 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); renderNodes(); updateNodeVisualization(node); // Sync with backend syncNode(node); console.log('[Simulate] Node added:', node); } function removeNode(nodeId) { state.nodes = state.nodes.filter(n => n.id !== nodeId); renderNodes(); removeNodeVisualization(nodeId); // Sync with backend deleteNode(nodeId); console.log('[Simulate] Node removed:', nodeId); } function updateNodePosition(nodeId, position) { const node = state.nodes.find(n => n.id === nodeId); 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 = ''; state.nodes.forEach(node => { const div = document.createElement('div'); div.className = 'sim-item'; div.innerHTML = ` ${node.name} (${node.position.x.toFixed(1)}, ${node.position.y.toFixed(1)}, ${node.position.z.toFixed(1)}) `; elements.nodesContainer.appendChild(div); }); // Attach delete handlers elements.nodesContainer.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); } } 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; if (state.showGDOP) { updateGDOP(); } else { clearGDOPMesh(); } } async function updateGDOP() { if (state.nodes.length < 2) { console.warn('[Simulate] Need at least 2 nodes for GDOP'); return; } try { const response = await fetch('/api/simulator/gdop/heatmap', { method: 'GET', headers: { 'Content-Type': 'application/json' }, }); if (!response.ok) throw new Error('Failed to compute GDOP'); const data = await response.json(); state.gdopData = data; renderGDOP(data); console.log('[Simulate] GDOP updated:', data); } catch (err) { console.error('[Simulate] Failed to update GDOP:', err); } } function renderGDOP(data) { clearGDOPMesh(); if (!data.gdop_map || !data.grid_dimensions) { console.warn('[Simulate] Invalid GDOP data'); return; } // Create texture from GDOP data const canvas = document.createElement('canvas'); const size = 256; canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d'); const imageData = ctx.createImageData(size, size); // 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) * gridDepth); // 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 >= 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 } const i = (y * size + x) * 4; imageData.data[i] = color.r; imageData.data[i + 1] = color.g; imageData.data[i + 2] = color.b; imageData.data[i + 3] = 180; // Alpha } } 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); _gdopMesh = new THREE.Mesh(geometry, material); _gdopMesh.rotation.x = -Math.PI / 2; _gdopMesh.position.set(state.space.width / 2, 0.01, state.space.depth / 2); // Get scene from Viz3D if (window.Viz3D) { 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() { if (_gdopMesh) { const scene = window.Viz3D?.getScene?.(); if (scene) scene.remove(_gdopMesh); _gdopMesh.geometry.dispose(); _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'; } } // ============================================ // Simulation Control // ============================================ async function startSimulation() { if (state.nodes.length < 2) { alert('Please add at least 2 nodes before starting simulation'); return; } if (state.walkers.length === 0) { alert('Please add at least 1 walker before starting simulation'); return; } try { const response = await fetch('/api/simulator/simulate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ duration_sec: CONFIG.defaultDurationSec, tick_rate_hz: CONFIG.tickRateHz, }), }); if (!response.ok) throw new Error('Failed to start simulation'); const data = await response.json(); state.simulationRunning = true; state.simulationPaused = false; 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'; // Start progress update startProgressLoop(); // Poll for results pollSimulationResults(); console.log('[Simulate] Simulation started'); } catch (err) { console.error('[Simulate] Failed to start simulation:', err); alert('Failed to start simulation: ' + 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() { 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; // Reset progress elements.time.textContent = '0:00 / 0:30'; elements.progressFill.style.width = '0%'; console.log('[Simulate] Simulation stopped'); } function startProgressLoop() { const interval = setInterval(() => { if (!state.simulationRunning) { clearInterval(interval); return; } 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) { clearInterval(pollInterval); return; } try { const response = await fetch('/api/simulator/status'); if (!response.ok) return; const data = await response.json(); // Update walker positions if (data.walker_positions) { data.walker_positions.forEach(pos => { const walker = state.walkers.find(w => w.id === pos.id); if (walker) { walker.position = pos.position; updateWalkerVisualization(walker); } }); } // Check if simulation complete if (data.state === 'complete' || data.state === 'stopped') { clearInterval(pollInterval); await fetchSimulationResults(); } } catch (err) { console.error('[Simulate] Failed to poll results:', err); } }, 200); } async function fetchSimulationResults() { try { const response = await fetch('/api/simulator/results'); if (!response.ok) throw new Error('Failed to fetch results'); const data = await response.json(); state.simulationResults = data; displayResults(data); stopSimulation(); console.log('[Simulate] Simulation results:', data); } catch (err) { console.error('[Simulate] Failed to fetch results:', err); } } 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(''); } // 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}
`; } } // ============================================ // 3D Visualization // ============================================ function updateNodeVisualization(node) { // Get scene from Viz3D const scene = window.Viz3D?.getScene?.(); if (!scene) return; // 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, }); 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); } function removeNodeVisualization(nodeId) { const mesh = _nodeMeshes.get(nodeId); if (mesh) { const scene = window.Viz3D?.getScene?.(); if (scene) scene.remove(mesh); mesh.geometry.dispose(); mesh.material.dispose(); _nodeMeshes.delete(nodeId); } } function updateWalkerVisualization(walker) { // Get scene from Viz3D const scene = window.Viz3D?.getScene?.(); if (!scene) return; // Remove existing mesh removeWalkerVisualization(walker.id); // Create walker mesh (capsule for person) const geometry = new THREE.CapsuleGeometry(0.1, 0.5, 4, 8); const material = new THREE.MeshLambertMaterial({ color: 0xffa726, transparent: true, opacity: 0.8, }); const mesh = new THREE.Mesh(geometry, material); mesh.position.set(walker.position.x, walker.position.y, walker.position.z); mesh.userData.walkerId = walker.id; scene.add(mesh); _walkerMeshes.set(walker.id, mesh); } function removeWalkerVisualization(walkerId) { const mesh = _walkerMeshes.get(walkerId); if (mesh) { const scene = window.Viz3D?.getScene?.(); if (scene) scene.remove(mesh); mesh.geometry.dispose(); mesh.material.dispose(); _walkerMeshes.delete(walkerId); } } function clearSimulationMeshes() { _nodeMeshes.forEach((mesh, id) => removeNodeVisualization(id)); _walkerMeshes.forEach((mesh, id) => removeWalkerVisualization(id)); clearGDOPMesh(); } // ============================================ // Public API // ============================================ window.SpaxelSimulator = { init: init, getState: () => state, }; // Auto-initialize if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } console.log('[Simulate] Simulator module loaded'); })();