espilon-source/tools/C3PO/static/js/mlat.js
Eun0us 8b6c1cd53d ε - ChaCha20-Poly1305 AEAD + HKDF crypto upgrade + C3PO rewrite + docs
Crypto:
- Replace broken ChaCha20 (static nonce) with ChaCha20-Poly1305 AEAD
- HKDF-SHA256 key derivation from per-device factory NVS master keys
- Random 12-byte nonce per message (ESP32 hardware RNG)
- crypto_init/encrypt/decrypt API with mbedtls legacy (ESP-IDF v5.3.2)
- Custom partition table with factory NVS (fctry at 0x10000)

Firmware:
- crypto.c full rewrite, messages.c device_id prefix + AEAD encrypt
- crypto_init() at boot with esp_restart() on failure
- Fix command_t initializations across all modules (sub/help fields)
- Clean CMakeLists dependencies for ESP-IDF v5.3.2

C3PO (C2):
- Rename tools/c2 + tools/c3po -> tools/C3PO
- Per-device CryptoContext with HKDF key derivation
- KeyStore (keys.json) for master key management
- Transport parses device_id:base64(...) wire format

Tools:
- New tools/provisioning/provision.py for factory NVS key generation
- Updated flasher with mbedtls config for v5.3.2

Docs:
- Update all READMEs for new crypto, C3PO paths, provisioning
- Update roadmap, architecture diagrams, security sections
- Update CONTRIBUTING.md project structure
2026-02-10 21:28:45 +01:00

831 lines
25 KiB
JavaScript

/**
* MLAT (Multilateration) Visualization for ESPILON C2
* Supports Map view (Leaflet/OSM) and Plan view (Canvas)
* Supports both GPS (lat/lon) and Local (x/y in meters) coordinates
*/
// ============================================================
// State
// ============================================================
let currentView = 'map';
let coordMode = 'gps'; // 'gps' or 'local'
let map = null;
let planCanvas = null;
let planCtx = null;
let planImage = null;
// Plan settings for local coordinate mode
let planSettings = {
width: 50, // meters
height: 30, // meters
originX: 0, // meters offset
originY: 0 // meters offset
};
// Plan display options
let showGrid = true;
let showLabels = true;
let planZoom = 1.0; // 1.0 = 100%
let panOffset = { x: 0, y: 0 }; // Pan offset in pixels
let isPanning = false;
let lastPanPos = { x: 0, y: 0 };
// Markers
let scannerMarkers = {};
let targetMarker = null;
let rangeCircles = {};
// Data
let scanners = [];
let target = null;
// ============================================================
// Map View (Leaflet) - GPS Mode
// ============================================================
function initMap() {
if (map) return;
const centerLat = parseFloat(document.getElementById('map-center-lat').value) || 48.8566;
const centerLon = parseFloat(document.getElementById('map-center-lon').value) || 2.3522;
const zoom = parseInt(document.getElementById('map-zoom').value) || 18;
map = L.map('leaflet-map', {
center: [centerLat, centerLon],
zoom: zoom,
zoomControl: true
});
// Dark tile layer (CartoDB Dark Matter)
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://carto.com/">CARTO</a>',
subdomains: 'abcd',
maxZoom: 20
}).addTo(map);
}
function createScannerIcon() {
return L.divIcon({
className: 'scanner-marker',
iconSize: [16, 16],
iconAnchor: [8, 8]
});
}
function createTargetIcon() {
return L.divIcon({
className: 'target-marker',
html: `<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" fill="#f87171" fill-opacity="0.3"/>
<circle cx="12" cy="12" r="6" fill="#f87171"/>
<circle cx="12" cy="12" r="3" fill="#fff"/>
</svg>`,
iconSize: [24, 24],
iconAnchor: [12, 12]
});
}
function updateMapMarkers() {
if (!map) return;
// Only show GPS mode scanners on map
const gpsFilteredScanners = scanners.filter(s => s.position && s.position.lat !== undefined);
const currentIds = new Set(gpsFilteredScanners.map(s => s.id));
// Remove old markers
for (const id in scannerMarkers) {
if (!currentIds.has(id)) {
map.removeLayer(scannerMarkers[id]);
delete scannerMarkers[id];
if (rangeCircles[id]) {
map.removeLayer(rangeCircles[id]);
delete rangeCircles[id];
}
}
}
// Update/add scanner markers
for (const scanner of gpsFilteredScanners) {
const pos = scanner.position;
if (scannerMarkers[scanner.id]) {
scannerMarkers[scanner.id].setLatLng([pos.lat, pos.lon]);
} else {
scannerMarkers[scanner.id] = L.marker([pos.lat, pos.lon], {
icon: createScannerIcon()
}).addTo(map);
scannerMarkers[scanner.id].bindPopup(`
<strong>${scanner.id}</strong><br>
RSSI: ${scanner.last_rssi || '-'} dBm<br>
Distance: ${scanner.estimated_distance || '-'} m
`);
}
// Update popup content
scannerMarkers[scanner.id].setPopupContent(`
<strong>${scanner.id}</strong><br>
RSSI: ${scanner.last_rssi || '-'} dBm<br>
Distance: ${scanner.estimated_distance || '-'} m
`);
// Update range circle
if (scanner.estimated_distance) {
if (rangeCircles[scanner.id]) {
rangeCircles[scanner.id].setLatLng([pos.lat, pos.lon]);
rangeCircles[scanner.id].setRadius(scanner.estimated_distance);
} else {
rangeCircles[scanner.id] = L.circle([pos.lat, pos.lon], {
radius: scanner.estimated_distance,
color: 'rgba(129, 140, 248, 0.4)',
fillColor: 'rgba(129, 140, 248, 0.1)',
fillOpacity: 0.3,
weight: 2
}).addTo(map);
}
}
}
// Update target marker (GPS only)
if (target && target.lat !== undefined) {
if (targetMarker) {
targetMarker.setLatLng([target.lat, target.lon]);
} else {
targetMarker = L.marker([target.lat, target.lon], {
icon: createTargetIcon()
}).addTo(map);
}
} else if (targetMarker) {
map.removeLayer(targetMarker);
targetMarker = null;
}
}
function centerMap() {
if (!map) return;
const lat = parseFloat(document.getElementById('map-center-lat').value);
const lon = parseFloat(document.getElementById('map-center-lon').value);
const zoom = parseInt(document.getElementById('map-zoom').value);
map.setView([lat, lon], zoom);
}
function fitMapToBounds() {
if (!map || scanners.length === 0) return;
const points = scanners
.filter(s => s.position && s.position.lat !== undefined)
.map(s => [s.position.lat, s.position.lon]);
if (target && target.lat !== undefined) {
points.push([target.lat, target.lon]);
}
if (points.length > 0) {
map.fitBounds(points, { padding: [50, 50] });
}
}
// ============================================================
// Plan View (Canvas) - Supports both GPS and Local coords
// ============================================================
function initPlanCanvas() {
planCanvas = document.getElementById('plan-canvas');
if (!planCanvas) return;
planCtx = planCanvas.getContext('2d');
resizePlanCanvas();
setupPlanPanning();
window.addEventListener('resize', resizePlanCanvas);
}
function resizePlanCanvas() {
if (!planCanvas) return;
const wrapper = planCanvas.parentElement;
planCanvas.width = wrapper.clientWidth - 32;
planCanvas.height = wrapper.clientHeight - 32;
drawPlan();
}
function drawPlan() {
if (!planCtx) return;
const ctx = planCtx;
const w = planCanvas.width;
const h = planCanvas.height;
// Clear (before transform)
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#06060a';
ctx.fillRect(0, 0, w, h);
// Apply zoom and pan transform
const centerX = w / 2;
const centerY = h / 2;
ctx.setTransform(planZoom, 0, 0, planZoom,
centerX - centerX * planZoom + panOffset.x,
centerY - centerY * planZoom + panOffset.y);
// Draw plan image if loaded
if (planImage) {
ctx.drawImage(planImage, 0, 0, w, h);
}
// Draw grid (always when enabled, on top of image)
if (showGrid) {
drawGrid(ctx, w, h, !!planImage);
}
// Draw range circles
for (const scanner of scanners) {
if (scanner.estimated_distance) {
drawPlanRangeCircle(ctx, scanner);
}
}
// Draw scanners
for (const scanner of scanners) {
drawPlanScanner(ctx, scanner);
}
// Draw target
if (target) {
drawPlanTarget(ctx);
}
// Reset transform for any UI overlay
ctx.setTransform(1, 0, 0, 1, 0, 0);
}
function drawGrid(ctx, w, h, hasImage = false) {
// More visible grid when over image
ctx.strokeStyle = hasImage ? 'rgba(129, 140, 248, 0.4)' : '#21262d';
ctx.lineWidth = hasImage ? 1.5 : 1;
ctx.font = '10px monospace';
ctx.fillStyle = hasImage ? 'rgba(200, 200, 200, 0.9)' : '#484f58';
if (coordMode === 'local') {
// Draw grid based on plan size in meters
const metersPerPixelX = planSettings.width / w;
const metersPerPixelY = planSettings.height / h;
// Grid every 5 meters
const gridMeters = 5;
const gridPixelsX = gridMeters / metersPerPixelX;
const gridPixelsY = gridMeters / metersPerPixelY;
// Vertical lines
for (let x = gridPixelsX; x < w; x += gridPixelsX) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, h);
ctx.stroke();
// Label
if (showLabels) {
const meters = (x * metersPerPixelX + planSettings.originX).toFixed(0);
if (hasImage) {
// Background for readability
ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
ctx.fillRect(x + 1, 2, 25, 12);
ctx.fillStyle = 'rgba(200, 200, 200, 0.9)';
}
ctx.fillText(`${meters}m`, x + 2, 12);
}
}
// Horizontal lines
for (let y = gridPixelsY; y < h; y += gridPixelsY) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(w, y);
ctx.stroke();
// Label
if (showLabels) {
const meters = (planSettings.height - y * metersPerPixelY + planSettings.originY).toFixed(0);
if (hasImage) {
ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
ctx.fillRect(1, y - 13, 25, 12);
ctx.fillStyle = 'rgba(200, 200, 200, 0.9)';
}
ctx.fillText(`${meters}m`, 2, y - 2);
}
}
// Size label
if (showLabels) {
ctx.fillStyle = hasImage ? 'rgba(129, 140, 248, 0.9)' : '#818cf8';
if (hasImage) {
ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
ctx.fillRect(w - 65, h - 16, 62, 14);
ctx.fillStyle = 'rgba(129, 140, 248, 0.9)';
}
ctx.fillText(`${planSettings.width}x${planSettings.height}m`, w - 60, h - 5);
}
} else {
// Simple grid for GPS mode
const gridSize = 50;
for (let x = gridSize; x < w; x += gridSize) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, h);
ctx.stroke();
}
for (let y = gridSize; y < h; y += gridSize) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(w, y);
ctx.stroke();
}
}
}
function toggleGrid() {
showGrid = !showGrid;
document.getElementById('grid-toggle').classList.toggle('active', showGrid);
drawPlan();
}
function toggleLabels() {
showLabels = !showLabels;
document.getElementById('labels-toggle').classList.toggle('active', showLabels);
drawPlan();
}
function zoomPlan(direction) {
const zoomStep = 0.25;
const minZoom = 0.25;
const maxZoom = 4.0;
if (direction > 0) {
planZoom = Math.min(maxZoom, planZoom + zoomStep);
} else {
planZoom = Math.max(minZoom, planZoom - zoomStep);
}
updateZoomDisplay();
drawPlan();
}
function resetZoom() {
planZoom = 1.0;
panOffset = { x: 0, y: 0 };
updateZoomDisplay();
drawPlan();
}
function updateZoomDisplay() {
const el = document.getElementById('zoom-level');
if (el) {
el.textContent = Math.round(planZoom * 100) + '%';
}
}
function setupPlanPanning() {
if (!planCanvas) return;
// Mouse wheel zoom
planCanvas.addEventListener('wheel', (e) => {
e.preventDefault();
const direction = e.deltaY < 0 ? 1 : -1;
zoomPlan(direction);
}, { passive: false });
// Pan with mouse drag
planCanvas.addEventListener('mousedown', (e) => {
if (e.button === 0) { // Left click
isPanning = true;
lastPanPos = { x: e.clientX, y: e.clientY };
planCanvas.style.cursor = 'grabbing';
}
});
planCanvas.addEventListener('mousemove', (e) => {
if (isPanning) {
const dx = e.clientX - lastPanPos.x;
const dy = e.clientY - lastPanPos.y;
panOffset.x += dx;
panOffset.y += dy;
lastPanPos = { x: e.clientX, y: e.clientY };
drawPlan();
}
});
planCanvas.addEventListener('mouseup', () => {
isPanning = false;
planCanvas.style.cursor = 'grab';
});
planCanvas.addEventListener('mouseleave', () => {
isPanning = false;
planCanvas.style.cursor = 'grab';
});
planCanvas.style.cursor = 'grab';
}
function worldToCanvas(pos) {
const w = planCanvas.width;
const h = planCanvas.height;
if (coordMode === 'local' || (pos.x !== undefined && pos.lat === undefined)) {
// Local coordinates (x, y in meters)
const x = pos.x !== undefined ? pos.x : 0;
const y = pos.y !== undefined ? pos.y : 0;
const canvasX = ((x - planSettings.originX) / planSettings.width) * w;
const canvasY = h - ((y - planSettings.originY) / planSettings.height) * h;
return {
x: Math.max(0, Math.min(w, canvasX)),
y: Math.max(0, Math.min(h, canvasY))
};
} else {
// GPS coordinates (lat, lon)
const centerLat = parseFloat(document.getElementById('map-center-lat').value) || 48.8566;
const centerLon = parseFloat(document.getElementById('map-center-lon').value) || 2.3522;
const range = 0.002; // ~200m
const canvasX = ((pos.lon - centerLon + range) / (2 * range)) * w;
const canvasY = ((centerLat + range - pos.lat) / (2 * range)) * h;
return {
x: Math.max(0, Math.min(w, canvasX)),
y: Math.max(0, Math.min(h, canvasY))
};
}
}
function distanceToPixels(distance) {
if (coordMode === 'local') {
// Direct conversion: distance in meters to pixels
const pixelsPerMeter = planCanvas.width / planSettings.width;
return distance * pixelsPerMeter;
} else {
// GPS mode: approximate conversion
const range = 0.002; // degrees
const rangeMeters = range * 111000; // ~222m
const pixelsPerMeter = planCanvas.width / rangeMeters;
return distance * pixelsPerMeter;
}
}
function drawPlanRangeCircle(ctx, scanner) {
const pos = scanner.position;
if (!pos) return;
// Check if position is valid for current mode
if (coordMode === 'local' && pos.x === undefined && pos.lat !== undefined) return;
if (coordMode === 'gps' && pos.lat === undefined && pos.x !== undefined) return;
const canvasPos = worldToCanvas(pos);
const radius = distanceToPixels(scanner.estimated_distance);
ctx.beginPath();
ctx.arc(canvasPos.x, canvasPos.y, radius, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(129, 140, 248, 0.3)';
ctx.lineWidth = 2;
ctx.stroke();
}
function drawPlanScanner(ctx, scanner) {
const pos = scanner.position;
if (!pos) return;
// Check if position is valid
const hasGPS = pos.lat !== undefined;
const hasLocal = pos.x !== undefined;
if (!hasGPS && !hasLocal) return;
const canvasPos = worldToCanvas(pos);
// Dot
ctx.beginPath();
ctx.arc(canvasPos.x, canvasPos.y, 8, 0, Math.PI * 2);
ctx.fillStyle = '#818cf8';
ctx.fill();
// Label
ctx.font = '12px monospace';
ctx.fillStyle = '#c9d1d9';
ctx.textAlign = 'center';
ctx.fillText(scanner.id, canvasPos.x, canvasPos.y - 15);
// RSSI
if (scanner.last_rssi !== null) {
ctx.font = '10px monospace';
ctx.fillStyle = '#484f58';
ctx.fillText(`${scanner.last_rssi} dBm`, canvasPos.x, canvasPos.y + 20);
}
ctx.textAlign = 'left';
}
function drawPlanTarget(ctx) {
if (!target) return;
const hasGPS = target.lat !== undefined;
const hasLocal = target.x !== undefined;
if (!hasGPS && !hasLocal) return;
const canvasPos = worldToCanvas(target);
// Glow
ctx.beginPath();
ctx.arc(canvasPos.x, canvasPos.y, 20, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(248, 113, 113, 0.3)';
ctx.fill();
// Cross
ctx.strokeStyle = '#f87171';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(canvasPos.x - 12, canvasPos.y - 12);
ctx.lineTo(canvasPos.x + 12, canvasPos.y + 12);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(canvasPos.x + 12, canvasPos.y - 12);
ctx.lineTo(canvasPos.x - 12, canvasPos.y + 12);
ctx.stroke();
// Label
ctx.font = 'bold 12px monospace';
ctx.fillStyle = '#f87171';
ctx.textAlign = 'center';
ctx.fillText('TARGET', canvasPos.x, canvasPos.y - 25);
ctx.textAlign = 'left';
}
// ============================================================
// Plan Image Upload & Calibration
// ============================================================
function uploadPlanImage(input) {
const file = input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(e) {
planImage = new Image();
planImage.onload = function() {
document.getElementById('calibrate-btn').disabled = false;
drawPlan();
};
planImage.src = e.target.result;
};
reader.readAsDataURL(file);
}
function calibratePlan() {
alert('Calibration: Set the plan dimensions in Plan Settings panel.\n\nThe grid will map x,y meters to your uploaded image.');
drawPlan();
}
function clearPlan() {
planImage = null;
document.getElementById('calibrate-btn').disabled = true;
drawPlan();
}
function applyPlanSettings() {
planSettings.width = parseFloat(document.getElementById('plan-width').value) || 50;
planSettings.height = parseFloat(document.getElementById('plan-height').value) || 30;
planSettings.originX = parseFloat(document.getElementById('plan-origin-x').value) || 0;
planSettings.originY = parseFloat(document.getElementById('plan-origin-y').value) || 0;
updateSizeDisplay();
drawPlan();
}
function adjustPlanSize(delta) {
// Adjust both width and height proportionally
const minSize = 10;
const maxSize = 500;
planSettings.width = Math.max(minSize, Math.min(maxSize, planSettings.width + delta));
planSettings.height = Math.max(minSize, Math.min(maxSize, planSettings.height + Math.round(delta * 0.6)));
// Update input fields in sidebar
document.getElementById('plan-width').value = planSettings.width;
document.getElementById('plan-height').value = planSettings.height;
updateSizeDisplay();
drawPlan();
}
function updateSizeDisplay() {
const el = document.getElementById('size-display');
if (el) {
el.textContent = `${planSettings.width}x${planSettings.height}m`;
}
}
// ============================================================
// View Switching
// ============================================================
function switchView(view) {
currentView = view;
// Update buttons
document.querySelectorAll('.view-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.view === view);
});
// Update views
document.getElementById('map-view').classList.toggle('active', view === 'map');
document.getElementById('plan-view').classList.toggle('active', view === 'plan');
// Show/hide settings panels based on view
document.getElementById('map-settings').style.display = view === 'map' ? 'block' : 'none';
document.getElementById('plan-settings').style.display = view === 'plan' ? 'block' : 'none';
// Initialize view if needed
if (view === 'map') {
setTimeout(() => {
if (!map) initMap();
else map.invalidateSize();
updateMapMarkers();
}, 100);
} else {
if (!planCanvas) initPlanCanvas();
else resizePlanCanvas();
}
}
// ============================================================
// UI Updates
// ============================================================
function updateCoordMode(mode) {
coordMode = mode;
const modeDisplay = document.getElementById('coord-mode');
const coord1Label = document.getElementById('target-coord1-label');
const coord2Label = document.getElementById('target-coord2-label');
if (mode === 'gps') {
modeDisplay.textContent = 'GPS';
coord1Label.textContent = 'Latitude';
coord2Label.textContent = 'Longitude';
} else {
modeDisplay.textContent = 'Local';
coord1Label.textContent = 'X (m)';
coord2Label.textContent = 'Y (m)';
}
}
function updateTargetInfo(targetData) {
const coord1El = document.getElementById('target-coord1');
const coord2El = document.getElementById('target-coord2');
if (targetData && targetData.position) {
const pos = targetData.position;
if (pos.lat !== undefined) {
coord1El.textContent = pos.lat.toFixed(6);
coord2El.textContent = pos.lon.toFixed(6);
} else if (pos.x !== undefined) {
coord1El.textContent = pos.x.toFixed(2) + ' m';
coord2El.textContent = pos.y.toFixed(2) + ' m';
} else {
coord1El.textContent = '-';
coord2El.textContent = '-';
}
document.getElementById('target-confidence').textContent = ((targetData.confidence || 0) * 100).toFixed(0) + '%';
document.getElementById('target-age').textContent = (targetData.age_seconds || 0).toFixed(1) + 's ago';
// Store for rendering
target = pos;
} else {
coord1El.textContent = '-';
coord2El.textContent = '-';
document.getElementById('target-confidence').textContent = '-';
document.getElementById('target-age').textContent = '-';
target = null;
}
}
function updateScannerList(scannersData) {
scanners = scannersData || [];
const list = document.getElementById('scanner-list');
document.getElementById('scanner-count').textContent = scanners.length;
if (scanners.length === 0) {
list.innerHTML = '<div class="empty">No scanners active</div>';
return;
}
list.innerHTML = scanners.map(s => {
const pos = s.position || {};
let posStr;
if (pos.lat !== undefined) {
posStr = `(${pos.lat.toFixed(4)}, ${pos.lon.toFixed(4)})`;
} else if (pos.x !== undefined) {
posStr = `(${pos.x.toFixed(1)}m, ${pos.y.toFixed(1)}m)`;
} else {
posStr = '(-, -)';
}
return `
<div class="scanner-item">
<div class="scanner-id">${s.id}</div>
<div class="scanner-details">
Pos: ${posStr} |
RSSI: ${s.last_rssi !== null ? s.last_rssi + ' dBm' : '-'} |
Dist: ${s.estimated_distance !== null ? s.estimated_distance + 'm' : '-'}
</div>
</div>
`;
}).join('');
}
function updateConfig(config) {
if (!config) return;
document.getElementById('config-rssi').value = config.rssi_at_1m || -40;
document.getElementById('config-n').value = config.path_loss_n || 2.5;
document.getElementById('config-smooth').value = config.smoothing_window || 5;
}
// ============================================================
// API Functions
// ============================================================
async function fetchState() {
try {
const res = await fetch('/api/mlat/state');
const state = await res.json();
// Update coordinate mode from server
if (state.coord_mode) {
updateCoordMode(state.coord_mode);
}
updateTargetInfo(state.target);
updateScannerList(state.scanners);
if (state.config) {
updateConfig(state.config);
}
// Update visualization
if (currentView === 'map') {
updateMapMarkers();
} else {
drawPlan();
}
} catch (e) {
console.error('Failed to fetch MLAT state:', e);
}
}
async function saveConfig() {
const config = {
rssi_at_1m: parseFloat(document.getElementById('config-rssi').value),
path_loss_n: parseFloat(document.getElementById('config-n').value),
smoothing_window: parseInt(document.getElementById('config-smooth').value)
};
try {
await fetch('/api/mlat/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
console.log('Config saved');
} catch (e) {
console.error('Failed to save config:', e);
}
}
async function clearData() {
try {
await fetch('/api/mlat/clear', { method: 'POST' });
fetchState();
} catch (e) {
console.error('Failed to clear data:', e);
}
}
// ============================================================
// Initialization
// ============================================================
document.addEventListener('DOMContentLoaded', () => {
// Initialize map view by default
initMap();
initPlanCanvas();
// Initialize displays
updateZoomDisplay();
updateSizeDisplay();
// Start polling
fetchState();
setInterval(fetchState, 2000);
});