const markers = {}; const nodeData = {}; const statusDot = document.getElementById('statusDot'); const statusText = document.getElementById('statusText'); const nodeCount = document.getElementById('nodeCount'); let currentUser = null; let ws; initPage({ onAuth: (user) => { currentUser = user; } }); const hopColors = { 0: '#2196F3', 1: '#4CAF50', 2: '#FF9800', 3: '#F44336', 4: '#9C27B0', 5: '#795548', }; const hopColorDefault = '#9E9E9E'; function getHopColor(hopCount) { if (hopCount == null) return hopColorDefault; if (hopCount >= 6) return hopColorDefault; return hopColors[hopCount] || hopColorDefault; } function createIcon(color) { return L.divIcon({ className: '', html: ` `, iconSize: [24, 24], iconAnchor: [12, 12], popupAnchor: [0, -14], tooltipAnchor: [14, 0] }); } // Returns opacity based on node age: full <24h, half 24-48h, faint 48-72h, null >72h (hide) function getAgeOpacity(lastSeen) { if (!lastSeen) return 0.9; const age = Date.now() / 1000 - lastSeen; if (age < 86400) return 0.9; if (age < 172800) return 0.45; if (age < 259200) return 0.2; return null; } function nodeTooltip(node) { const name = node.long_name || node.short_name || node.node_id; const hops = node.hop_count != null ? node.hop_count : '?'; const bat = node.battery != null ? `${node.battery}%` : '-'; const snr = node.snr != null ? `${node.snr.toFixed(1)} dB` : '-'; const hw = node.hw_model || '-'; const alt = node.alt != null ? `${Math.round(node.alt)} m` : '-'; const lastSeen = node.last_seen ? new Date(node.last_seen * 1000).toLocaleString('de-DE') : '-'; return `
${escapeHtml(name)}
Hardware: ${escapeHtml(hw)}
Hops: ${hops}
SNR: ${snr}
Batterie: ${bat}
Hoehe: ${alt}
Zuletzt: ${lastSeen}
`; } function updateMarker(node) { if (!node.lat || !node.lon) return; const id = node.node_id; const opacity = getAgeOpacity(node.last_seen); // Node older than 72h: remove from map if (opacity === null) { if (markers[id]) { map.removeLayer(markers[id]); delete markers[id]; delete nodeData[id]; } return; } nodeData[id] = node; const icon = createIcon(getHopColor(node.hop_count)); if (markers[id]) { markers[id].setLatLng([node.lat, node.lon]); markers[id].setIcon(icon); markers[id].setOpacity(opacity); markers[id].setTooltipContent(nodeTooltip(node)); } else { markers[id] = L.marker([node.lat, node.lon], { icon, opacity }) .addTo(map) .bindTooltip(nodeTooltip(node), { direction: 'right', className: 'node-tooltip-wrap' }); } } function fitBounds() { const coords = Object.values(markers).map(m => m.getLatLng()); if (coords.length > 0) { map.invalidateSize(); map.fitBounds(L.latLngBounds(coords).pad(0.1), { maxZoom: 14 }); } } function connectWebSocket() { const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; ws = new WebSocket(`${proto}//${location.host}/ws`); ws.onopen = () => { statusDot.classList.add('connected'); statusText.textContent = 'Verbunden'; }; ws.onclose = () => { statusDot.classList.remove('connected'); statusText.textContent = 'Reconnect...'; setTimeout(connectWebSocket, 3000); }; ws.onerror = () => { ws.close(); }; ws.onmessage = (event) => { const msg = JSON.parse(event.data); switch (msg.type) { case 'initial': msg.data.forEach(node => updateMarker(node)); nodeCount.textContent = `${Object.keys(markers).length} Nodes`; fitBounds(); break; case 'node_update': updateMarker(msg.data); nodeCount.textContent = `${Object.keys(markers).length} Nodes`; break; } }; } // Legend const legend = L.control({ position: 'topleft' }); legend.onAdd = function () { const div = L.DomUtil.create('div', 'legend'); div.innerHTML = 'Hops'; const entries = [ [0, 'Direkt'], [1, '1 Hop'], [2, '2 Hops'], [3, '3 Hops'], [4, '4 Hops'], [5, '5+ Hops'], [null, 'Unbekannt'], ]; entries.forEach(([hop, label]) => { const color = hop != null ? (hopColors[hop] || hopColorDefault) : hopColorDefault; div.innerHTML += `
${label}
`; }); div.innerHTML += `
Alter
< 24h
24–48h
48–72h
`; return div; }; // Init map after layout is ready function getTileLayer(theme) { if (theme === 'dark') { return L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { attribution: '© OpenStreetMap contributors © CARTO', maxZoom: 19 }); } return L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap', maxZoom: 19 }); } let currentTileLayer = null; function setMapTheme(theme) { if (currentTileLayer) map.removeLayer(currentTileLayer); currentTileLayer = getTileLayer(theme).addTo(map); } const map = L.map('map').setView([51.1657, 10.4515], 6); setMapTheme(localStorage.getItem('theme') || 'dark'); legend.addTo(map); document.addEventListener('themechange', (e) => setMapTheme(e.detail.theme)); // Invalidate size after short delay so sidebar layout settles setTimeout(() => { map.invalidateSize(); }, 200); connectWebSocket();