200 lines
6.6 KiB
JavaScript
200 lines
6.6 KiB
JavaScript
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: `<svg width="24" height="24" viewBox="0 0 24 24">
|
||
<circle cx="12" cy="12" r="8" fill="${color}" stroke="#fff" stroke-width="2"/>
|
||
</svg>`,
|
||
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 `<div class="node-tooltip">
|
||
<strong>${escapeHtml(name)}</strong><br>
|
||
<span class="label">Hardware:</span> ${escapeHtml(hw)}<br>
|
||
<span class="label">Hops:</span> ${hops}<br>
|
||
<span class="label">SNR:</span> ${snr}<br>
|
||
<span class="label">Batterie:</span> ${bat}<br>
|
||
<span class="label">Hoehe:</span> ${alt}<br>
|
||
<span class="label">Zuletzt:</span> ${lastSeen}
|
||
</div>`;
|
||
}
|
||
|
||
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 = '<strong>Hops</strong>';
|
||
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 += `<div class="legend-item"><span class="legend-dot" style="background:${color}"></span>${label}</div>`;
|
||
});
|
||
div.innerHTML += `<hr style="margin:5px 0;border-color:#aaa"><strong>Alter</strong>
|
||
<div class="legend-item"><span class="legend-dot" style="background:rgba(128,128,128,.9)"></span>< 24h</div>
|
||
<div class="legend-item"><span class="legend-dot" style="background:rgba(128,128,128,.45)"></span>24–48h</div>
|
||
<div class="legend-item"><span class="legend-dot" style="background:rgba(128,128,128,.2)"></span>48–72h</div>`;
|
||
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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
|
||
maxZoom: 19
|
||
});
|
||
}
|
||
return L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||
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();
|