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
let legendDiv = null;
const LEGEND_LIGHT = { bg: 'var(--tblr-bg-surface, #ffffff)', border: 'var(--tblr-border-color, rgba(0,0,0,.12))', color: 'var(--tblr-body-color, #212529)' };
const LEGEND_DARK = { bg: 'var(--tblr-bg-surface, #1e2128)', border: 'var(--tblr-border-color, rgba(255,255,255,.1))', color: 'var(--tblr-body-color, #c8d3e1)' };
function updateLegendTheme(theme) {
if (!legendDiv) return;
const c = theme === 'dark' ? LEGEND_DARK : LEGEND_LIGHT;
legendDiv.style.background = c.bg;
legendDiv.style.borderColor = c.border;
legendDiv.style.color = c.color;
}
const legend = L.control({ position: 'topleft' });
legend.onAdd = function () {
legendDiv = L.DomUtil.create('div', 'legend');
const hopEntries = [
[0, 'Direkt'],
[1, '1 Hop'],
[2, '2 Hops'],
[3, '3 Hops'],
[4, '4 Hops'],
[5, '5+ Hops'],
[null, 'Unbekannt'],
];
const ageEntries = [
['rgba(80,80,80,.95)', '< 24h'],
['rgba(80,80,80,.45)', '24–48h'],
['rgba(80,80,80,.18)', '48–72h'],
];
legendDiv.innerHTML =
'Hops
' +
hopEntries.map(([hop, label]) => {
const color = hop != null ? (hopColors[hop] || hopColorDefault) : hopColorDefault;
return `${label}
`;
}).join('') +
'Alter
' +
ageEntries.map(([color, label]) =>
`${label}
`
).join('');
updateLegendTheme(localStorage.getItem('theme') || 'dark');
return legendDiv;
};
// 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);
updateLegendTheme(e.detail.theme);
});
// Invalidate size after short delay so sidebar layout settles
setTimeout(() => { map.invalidateSize(); }, 200);
connectWebSocket();