document.addEventListener('DOMContentLoaded', async () => { try { await fetchNodes(); } catch (e) { console.error('Failed to load nodes', e); return; } const elements = { longName: document.getElementById('node-long-name'), nodeId: document.getElementById('node-id'), nodeIdHex: document.getElementById('node-id-hex'), shortName: document.getElementById('node-short-name'), role: document.getElementById('node-role'), hardware: document.getElementById('node-hardware'), firmware: document.getElementById('node-fw'), maxHops: document.getElementById('node-max-hops'), backbone: document.getElementById('node-backbone'), okMqtt: document.getElementById('node-ok-mqtt'), battery: document.getElementById('node-battery'), voltage: document.getElementById('node-voltage'), cu: document.getElementById('node-cu'), air: document.getElementById('node-air'), uptime: document.getElementById('node-uptime'), temp: document.getElementById('node-temp'), humidity: document.getElementById('node-humidity'), pressure: document.getElementById('node-pressure'), iaq: document.getElementById('node-iaq'), deviceMetricsUpdated: document.getElementById('node-device-metrics-updated'), environmentMetricsUpdated: document.getElementById('node-environment-metrics-updated'), position: document.getElementById('node-position'), altitude: document.getElementById('node-altitude'), precision: document.getElementById('node-precision'), updated: document.getElementById('node-updated'), neighbours: document.getElementById('node-neighbours'), neighboursUpdated: document.getElementById('neighbours-updated'), nodeSearch: document.getElementById('nodeSearch'), suggestions: document.getElementById('suggestions'), clearBtn: document.getElementById('clearFilterBtn'), searchContainer: document.getElementById('searchContainer'), nodeContent: document.getElementById('nodeContent'), }; function setText(el, value) { if (!el) return; el.textContent = value ?? '–'; } function formatBool(b) { if (b === null || b === undefined) return '–'; return b ? 'Ja' : 'Nej'; } function formatNumber(num, fractionDigits = 2) { if (num === null || num === undefined || isNaN(num)) return '–'; return Number(num).toFixed(fractionDigits); } function formatUptime(seconds) { const s = Number(seconds); if (!isFinite(s) || s <= 0) return '–'; const days = Math.floor(s / 86400); const hours = Math.floor((s % 86400) / 3600); const minutes = Math.floor((s % 3600) / 60); return `${days}d ${hours}h ${minutes}m`; } function formatDate(iso) { if (!iso) return '–'; try { return new Date(iso).toLocaleString('sv-SE', { hour12: false }); } catch { return '–'; } } function degreesFromInt(value) { if (value === null || value === undefined) return null; return Number(value) / 1e7; } function renderNeighbours(node) { if (!elements.neighbours) return; const neighbours = Array.isArray(node?.neighbours) ? node.neighbours : []; if (neighbours.length === 0) { elements.neighbours.innerHTML = ''; setText(elements.neighboursUpdated, formatDate(node?.neighbours_updated_at)); return; } const items = neighbours .slice() .sort((a, b) => (b.snr ?? -999) - (a.snr ?? -999)) .map(n => { const neighbourNode = nodes.find(x => String(x.node_id) === String(n.node_id)); const label = neighbourNode?.long_name || `#${n.node_id}`; const snr = n.snr === null || n.snr === undefined ? '–' : n.snr; return `${label} (${snr} dB)`; }) .join(' '); elements.neighbours.innerHTML = items; setText(elements.neighboursUpdated, formatDate(node?.neighbours_updated_at)); } function renderNodeDetails(node) { setText(elements.longName, node?.long_name); setText(elements.nodeId, node?.node_id); setText(elements.nodeIdHex, node?.node_id_hex ?? '–'); setText(elements.shortName, node?.short_name); setText(elements.role, node?.role_name ?? node?.role); setText(elements.hardware, node?.hardware_model_name ?? node?.hardware_model); setText(elements.firmware, node?.firmware_version); setText(elements.maxHops, node?.max_hops); setText(elements.backbone, formatBool(node?.is_backbone)); setText(elements.okMqtt, formatBool(node?.ok_to_mqtt)); setText(elements.battery, node?.battery_level != null ? `${Math.round(Number(node.battery_level))}%` : '–'); setText(elements.voltage, node?.voltage != null ? `${formatNumber(node.voltage, 3)} V` : '–'); setText(elements.cu, node?.channel_utilization != null ? `${formatNumber(node.channel_utilization, 2)}%` : '–'); setText(elements.air, node?.air_util_tx != null ? `${formatNumber(node.air_util_tx, 2)}%` : '–'); setText(elements.uptime, formatUptime(node?.uptime_seconds)); setText(elements.temp, node?.temperature != null ? `${formatNumber(node.temperature, 1)} °C` : '–'); setText(elements.humidity, node?.relative_humidity != null ? `${formatNumber(node.relative_humidity, 1)} %` : '–'); setText(elements.pressure, node?.barometric_pressure != null ? `${formatNumber(node.barometric_pressure, 1)} hPa` : '–'); setText(elements.iaq, node?.iaq != null ? `${formatNumber(node.iaq, 0)}` : '–'); setText(elements.deviceMetricsUpdated, formatDate(node?.device_metrics_updated_at)); setText(elements.environmentMetricsUpdated, formatDate(node?.environment_metrics_updated_at)); const lat = degreesFromInt(node?.latitude); const lon = degreesFromInt(node?.longitude); const alt = node?.altitude; const positionText = lat != null && lon != null ? `${lat.toFixed(5)}, ${lon.toFixed(5)}` : '–'; setText(elements.position, positionText); setText(elements.altitude, alt != null ? `${alt} m` : '–'); // Map position_precision using helper ranges from status page function precisionToMeters(precision) { switch(Number(precision)){ case 2: return 5976446; case 3: return 2988223; case 4: return 1494111; case 5: return 747055; case 6: return 373527; case 7: return 186763; case 8: return 93381; case 9: return 46690; case 10: return 23345; case 11: return 11672; case 12: return 5836; case 13: return 2918; case 14: return 1459; case 15: return 729; case 16: return 364; case 17: return 182; case 18: return 91; case 19: return 45; case 20: return 22; case 21: return 11; case 22: return 5; case 23: return 2; case 24: return 1; case 32: return 0; default: return null; } } const ppm = precisionToMeters(node?.position_precision); const precisionText = ppm == null ? '–' : (ppm > 1000 ? `±${Math.ceil(ppm/1000)}km` : `±${ppm}m`); setText(elements.precision, precisionText); setText(elements.updated, formatDate(node?.updated_at)); renderNeighbours(node); } function updateQueryParam(nodeIdOrNull) { const url = new URL(window.location.href); if (nodeIdOrNull) { url.searchParams.set('node_id', String(nodeIdOrNull)); } else { url.searchParams.delete('node_id'); } window.history.replaceState({}, '', url); } async function applyNodeSelection(nodeId) { const node = nodes.find(n => String(n.node_id) === String(nodeId)); renderNodeDetails(node || null); updateQueryParam(node ? node.node_id : null); if (elements.nodeSearch && node?.long_name) { elements.nodeSearch.value = node.long_name; } // Update the chart try { portnumDistributionChart(node ? node.node_id : null); } catch (e) { console.warn('Chart not ready', e); } // Show search always; hide the rest of the content if no node if (elements.nodeContent) { elements.nodeContent.style.display = node ? '' : 'none'; } // Load metric charts when a node is selected if (node) { try { const [deviceData, envData] = await Promise.all([ fetchDeviceMetricsData(node.node_id), fetchEnvironmentMetricsData(node.node_id), ]); await renderDeviceMetricsChart(node.node_id, deviceData); await renderEnvironmentMetricsChart(node.node_id, envData); } catch (e) { console.warn('Metric charts failed', e); } } else { destroyChartById('deviceMetricsChart'); destroyChartById('environmentMetricsChart'); } } // Intercept suggestion clicks (capture) to also update node details and URL if (elements.suggestions) { elements.suggestions.addEventListener('click', (e) => { const target = e.target; if (target && target.classList && target.classList.contains('dropdown-item')) { e.preventDefault(); e.stopPropagation(); const text = target.textContent; const node = nodes.find(n => n.long_name === text); if (node) { if (elements.nodeSearch) elements.nodeSearch.value = node.long_name; elements.suggestions.style.display = 'none'; applyNodeSelection(node.node_id); } } }, true); } // Clear button should also clear node details and URL param if (elements.clearBtn) { elements.clearBtn.addEventListener('click', () => { renderNodeDetails(null); updateQueryParam(null); if (elements.nodeContent) elements.nodeContent.style.display = 'none'; if (elements.suggestions) elements.suggestions.style.display = 'none'; try { portnumDistributionChart(null); } catch (e) { /* noop */ } destroyChartById('deviceMetricsChart'); destroyChartById('environmentMetricsChart'); }); } // Neighbour badge click selects that neighbour if (elements.neighbours) { elements.neighbours.addEventListener('click', (e) => { const target = e.target.closest('[data-node-id]'); if (!target) return; const nid = target.getAttribute('data-node-id'); applyNodeSelection(nid); }); } // Initial load: check URL const params = new URLSearchParams(window.location.search); const nodeIdParam = params.get('node_id'); if (nodeIdParam) { applyNodeSelection(nodeIdParam); } else { renderNodeDetails(null); if (elements.nodeContent) elements.nodeContent.style.display = 'none'; destroyChartById('deviceMetricsChart'); destroyChartById('environmentMetricsChart'); } }); function destroyChartById(canvasId) { try { const existing = window.Chart?.getChart(canvasId); if (existing) existing.destroy(); } catch (_) {} } function firstArrayIn(obj, keys = []) { if (Array.isArray(obj)) return obj; if (obj && typeof obj === 'object') { for (const key of keys) { const candidate = obj[key]; if (Array.isArray(candidate)) return candidate; } } return []; } async function fetchDeviceMetricsData(nodeId) { const url = `https://map.sthlm-mesh.se/api/v1/nodes/${nodeId}/device-metrics`; let arr = []; try { const res = await fetch(url); const data = await res.json(); arr = firstArrayIn(data, ['device_metrics']); } catch (e) { console.warn('Failed to fetch device metrics', e); } return arr.slice().reverse(); } async function renderDeviceMetricsChart(nodeId, arr) { const canvas = document.getElementById('deviceMetricsChart'); if (!canvas) return; destroyChartById('deviceMetricsChart'); const ctx = canvas.getContext('2d'); ctx.font = '16px Arial'; ctx.fillStyle = 'gray'; ctx.textAlign = 'center'; ctx.fillText('Loading data...', canvas.width / 2, canvas.height / 2); if (!arr || arr.length === 0) { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = 'gray'; ctx.fillText('No data', canvas.width / 2, canvas.height / 2); return; } const labels = arr.map(d => d.created_at); const battery = arr.map(d => d.battery_level); const cu = arr.map(d => d.channel_utilization); const air = arr.map(d => d.air_util_tx); new Chart(canvas, { type: 'line', data: { labels, datasets: [ { label: 'Battery Level', borderColor: '#3b82f6', backgroundColor: '#3b82f6', pointStyle: false, fill: false, data: battery }, { label: 'Channel Util', borderColor: '#22c55e', backgroundColor: '#22c55e', showLine: false, fill: false, data: cu }, { label: 'Air Util TX', borderColor: '#f97316', backgroundColor: '#f97316', showLine: false, fill: false, data: air }, ], }, options: { responsive: true, borderWidth: 2, elements: { point: { radius: 2 } }, scales: { x: { position: 'top', type: 'time', time: { unit: 'day', displayFormats: { day: 'MMM dd' } } }, y: { min: 0, max: 101, ticks: { callback: (v) => `${v}%` } }, }, plugins: { legend: { display: false }, tooltip: { mode: 'index', intersect: false, callbacks: { label: (item) => `${item.dataset.label}: ${item.formattedValue}%` } }, }, }, }); } async function fetchEnvironmentMetricsData(nodeId) { const url = `https://map.sthlm-mesh.se/api/v1/nodes/${nodeId}/environment-metrics`; let arr = []; try { const res = await fetch(url); const data = await res.json(); arr = firstArrayIn(data, ['environment_metrics']); } catch (e) { console.warn('Failed to fetch environment metrics', e); } return arr.slice().reverse(); } async function renderEnvironmentMetricsChart(nodeId, arr) { const canvas = document.getElementById('environmentMetricsChart'); if (!canvas) return; destroyChartById('environmentMetricsChart'); const ctx = canvas.getContext('2d'); ctx.font = '16px Arial'; ctx.fillStyle = 'gray'; ctx.textAlign = 'center'; ctx.fillText('Loading data...', canvas.width / 2, canvas.height / 2); if (!arr || arr.length === 0) { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = 'gray'; ctx.fillText('No data', canvas.width / 2, canvas.height / 2); return; } const labels = arr.map(d => d.created_at); const temperature = arr.map(d => d.temperature); const humidity = arr.map(d => d.relative_humidity); const pressure = arr.map(d => d.barometric_pressure); const iaq = arr.map(d => d.iaq); new Chart(canvas, { type: 'line', data: { labels, datasets: [ { label: 'Temperature', suffix: '°C', borderColor: '#3b82f6', backgroundColor: '#3b82f6', pointStyle: false, fill: false, data: temperature, yAxisID: 'y' }, { label: 'Humidity', suffix: '%', borderColor: '#22c55e', backgroundColor: '#22c55e', pointStyle: false, fill: false, data: humidity, yAxisID: 'y' }, { label: 'Pressure', suffix: 'hPa', borderColor: '#f97316', backgroundColor: '#f97316', pointStyle: false, fill: false, data: pressure, yAxisID: 'y1' }, { label: 'IAQ', suffix: 'IAQ', borderColor: '#f472b6', backgroundColor: '#f472b6', pointStyle: false, fill: false, data: iaq, yAxisID: 'yIAQ' }, ], }, options: { responsive: true, borderWidth: 2, spanGaps: 1000 * 60 * 60 * 24, elements: { point: { radius: 2 } }, scales: { x: { position: 'top', type: 'time', time: { unit: 'day', displayFormats: { day: 'MMM dd' } } }, y: { min: -20, max: 100 }, y1: { min: 800, max: 1100, ticks: { stepSize: 10, callback: (v) => `${v} hPa` }, position: 'right', grid: { drawOnChartArea: false } }, yIAQ: { type: 'linear', display: false }, }, plugins: { legend: { display: false }, tooltip: { mode: 'index', intersect: false, callbacks: { label: (item) => `${item.dataset.label}: ${item.formattedValue}${item.dataset.suffix}` } }, }, }, }); }