MeshDD-Bot/static/js/map.js
ppfeiffer 407addc919 fix(map): Legende nach topleft verschoben (fixes #5)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 17:11:41 +01:00

200 lines
6.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>&lt; 24h</div>
<div class="legend-item"><span class="legend-dot" style="background:rgba(128,128,128,.45)"></span>2448h</div>
<div class="legend-item"><span class="legend-dot" style="background:rgba(128,128,128,.2)"></span>4872h</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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>',
maxZoom: 19
});
}
return L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <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();