MeshDD-Bot/static/js/map.js
ppfeiffer cbe934ef6e fix(map): Kartenlegende theme-aware (closes #5)
- style.css: .legend nutzt CSS-Variablen (--tblr-bg-surface, --tblr-border-color,
  --tblr-body-color) statt hardcodierter Hex-Farben
- map.js: legendDiv-Referenz, updateLegendTheme() setzt Inline-Styles;
  wird beim onAdd (Init) und im themechange-Listener aufgerufen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 22:26:53 +01:00

223 lines
7.3 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
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)', '2448h'],
['rgba(80,80,80,.18)', '4872h'],
];
legendDiv.innerHTML =
'<div class="legend-section">Hops</div>' +
hopEntries.map(([hop, label]) => {
const color = hop != null ? (hopColors[hop] || hopColorDefault) : hopColorDefault;
return `<div class="legend-item"><span class="legend-dot" style="background:${color}"></span>${label}</div>`;
}).join('') +
'<div class="legend-section">Alter</div>' +
ageEntries.map(([color, label]) =>
`<div class="legend-item"><span class="legend-dot" style="background:${color}"></span>${label}</div>`
).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: '&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);
updateLegendTheme(e.detail.theme);
});
// Invalidate size after short delay so sidebar layout settles
setTimeout(() => { map.invalidateSize(); }, 200);
connectWebSocket();