Files
hassos_config/www/meshcore_heatmap_playback.html
2026-03-26 12:10:21 +01:00

439 lines
18 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MeshCore Hop Frequency Heatmap</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e;
color: #fff;
}
#map { width: 100%; height: 100vh; }
.info-panel {
position: absolute;
top: 10px;
right: 10px;
background: rgba(30, 30, 50, 0.95);
padding: 15px;
border-radius: 8px;
z-index: 1000;
max-width: 280px;
max-height: 400px;
overflow-y: auto;
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
}
.info-panel h3 {
margin-bottom: 10px;
color: #60a5fa;
font-size: 14px;
border-bottom: 1px solid #333;
padding-bottom: 8px;
cursor: pointer;
}
.node-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
border-bottom: 1px solid #2a2a4a;
font-size: 12px;
cursor: pointer;
transition: background 0.2s;
}
.node-item:hover { background: rgba(96, 165, 250, 0.2); }
.node-item.active { background: rgba(96, 165, 250, 0.3); }
.node-item:last-child { border-bottom: none; }
.node-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
.node-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.node-count { color: #888; font-weight: bold; }
.legend {
position: absolute;
bottom: 80px;
left: 10px;
background: rgba(30, 30, 50, 0.95);
padding: 12px;
border-radius: 8px;
z-index: 1000;
font-size: 11px;
}
.legend-title { margin-bottom: 8px; font-weight: bold; color: #60a5fa; }
.legend-item { display: flex; align-items: center; gap: 6px; margin: 4px 0; }
.legend-dot { width: 14px; height: 14px; border-radius: 50%; }
.stats {
position: absolute;
top: 10px;
left: 10px;
background: rgba(30, 30, 50, 0.95);
padding: 12px;
border-radius: 8px;
z-index: 1000;
font-size: 12px;
}
.stats h3 { color: #60a5fa; margin-bottom: 8px; font-size: 14px; }
.stat-row { margin: 4px 0; color: #aaa; }
.stat-value { color: #fff; font-weight: bold; }
.playback-panel {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(30, 30, 50, 0.95);
padding: 12px 20px;
border-radius: 8px;
z-index: 1000;
display: flex;
align-items: center;
gap: 15px;
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
}
.playback-btn {
background: #60a5fa;
border: none;
color: #fff;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background 0.2s;
}
.playback-btn:hover { background: #3b82f6; }
.playback-btn:disabled { background: #444; cursor: not-allowed; }
.playback-btn.live { background: #ef4444; }
.playback-slider { width: 200px; cursor: pointer; }
.playback-time { color: #fff; font-size: 12px; min-width: 110px; text-align: center; }
.playback-time.live { color: #ef4444; font-weight: bold; }
.playback-info { color: #888; font-size: 11px; }
.snapshot-count { color: #60a5fa; font-weight: bold; }
.threshold-control {
display: flex;
align-items: center;
gap: 6px;
border-left: 1px solid #444;
padding-left: 12px;
margin-left: 5px;
}
.threshold-control label { font-size: 10px; color: #888; }
.threshold-slider { width: 70px; cursor: pointer; }
.threshold-value { font-size: 11px; color: #60a5fa; min-width: 32px; }
.leaflet-tile-pane { filter: invert(1) hue-rotate(180deg) brightness(0.8) contrast(1.2); }
.leaflet-control-attribution { background: rgba(30, 30, 50, 0.8) !important; color: #666 !important; }
.leaflet-control-attribution a { color: #888 !important; }
</style>
</head>
<body>
<div id="map"></div>
<div class="stats">
<h3>📡 Hop Frequency Heatmap</h3>
<div class="stat-row">Time Window: <span class="stat-value" id="threshold-hours">--</span>h</div>
<div class="stat-row">Nodes: <span class="stat-value" id="node-count">0</span></div>
<div class="stat-row">Paths: <span class="stat-value" id="path-count">0</span></div>
<div class="stat-row">Total Traffic: <span class="stat-value" id="total-traffic">0</span></div>
<div class="stat-row">Max Uses: <span class="stat-value" id="max-uses">0</span></div>
</div>
<div class="legend">
<div class="legend-title">Traffic Intensity</div>
<div class="legend-item"><div class="legend-dot" style="background: #0000ff;"></div><span>Low</span></div>
<div class="legend-item"><div class="legend-dot" style="background: #00ffff;"></div><span>Light</span></div>
<div class="legend-item"><div class="legend-dot" style="background: #00ff00;"></div><span>Medium</span></div>
<div class="legend-item"><div class="legend-dot" style="background: #ffff00;"></div><span>High</span></div>
<div class="legend-item"><div class="legend-dot" style="background: #ff0000;"></div><span>Very High</span></div>
</div>
<div class="info-panel">
<h3 id="toggle-paths">🔥 Top Hop Nodes <span id="paths-status">(visible)</span></h3>
<div id="node-list"></div>
</div>
<div class="playback-panel">
<button class="playback-btn" id="btn-play" title="Play history">▶️</button>
<button class="playback-btn live" id="btn-live" title="Go live">🔴 LIVE</button>
<input type="range" class="playback-slider" id="timeline" min="0" max="0" value="0">
<div class="playback-time live" id="playback-time">● LIVE</div>
<div class="playback-info"><span class="snapshot-count" id="snapshot-count">0</span> snaps</div>
<div class="threshold-control">
<label>Filter:</label>
<input type="range" class="threshold-slider" id="playback-threshold" min="1" max="48" value="48">
<span class="threshold-value" id="playback-threshold-value">ALL</span>
</div>
</div>
<script>
const map = L.map('map', { center: [52.5, -1], zoom: 7, zoomControl: true, zoomSnap: 0.25, zoomDelta: 0.5, wheelPxPerZoomLevel: 120 });
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap' }).addTo(map);
let hopData = [], pathData = [], heatLayer = null, markers = [], pathLines = [];
let selectedNode = null, markersVisible = true;
// Playback state - loaded from server
let snapshots = [], isPlaying = false, playbackIndex = 0, playbackInterval = null, liveMode = true;
const PLAYBACK_SPEED = 400;
function loadSnapshots() {
fetch('/local/meshcore_heatmap_history.json?t=' + Date.now())
.then(r => r.json())
.then(data => {
if (data.snapshots && Array.isArray(data.snapshots)) {
snapshots = data.snapshots;
updateSnapshotCount();
}
})
.catch(e => {});
}
function updateSnapshotCount() {
document.getElementById('snapshot-count').textContent = snapshots.length;
document.getElementById('timeline').max = Math.max(0, snapshots.length - 1);
if (liveMode && snapshots.length > 0) {
document.getElementById('timeline').value = snapshots.length - 1;
}
}
function formatTime(ts) {
const d = new Date(ts * 1000);
return d.toLocaleString('en-GB', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
}
document.getElementById('btn-play').addEventListener('click', function() {
if (snapshots.length === 0) return;
if (isPlaying) {
isPlaying = false;
clearInterval(playbackInterval);
this.textContent = '▶️';
} else {
isPlaying = true;
liveMode = false;
this.textContent = '⏸️';
document.getElementById('btn-live').classList.remove('live');
document.getElementById('playback-time').classList.remove('live');
if (playbackIndex >= snapshots.length - 1) playbackIndex = 0;
playbackInterval = setInterval(() => {
playbackIndex++;
if (playbackIndex >= snapshots.length) {
isPlaying = false;
clearInterval(playbackInterval);
document.getElementById('btn-play').textContent = '▶️';
playbackIndex = snapshots.length - 1;
}
document.getElementById('timeline').value = playbackIndex;
displaySnapshot(playbackIndex);
}, PLAYBACK_SPEED);
}
});
document.getElementById('btn-live').addEventListener('click', goLive);
function goLive() {
isPlaying = false;
liveMode = true;
clearInterval(playbackInterval);
document.getElementById('btn-play').textContent = '▶️';
document.getElementById('btn-live').classList.add('live');
document.getElementById('playback-time').textContent = '● LIVE';
document.getElementById('playback-time').classList.add('live');
if (snapshots.length > 0) document.getElementById('timeline').value = snapshots.length - 1;
fetchLiveData();
}
document.getElementById('timeline').addEventListener('input', function() {
if (snapshots.length === 0) return;
liveMode = false;
isPlaying = false;
clearInterval(playbackInterval);
document.getElementById('btn-play').textContent = '▶️';
document.getElementById('btn-live').classList.remove('live');
document.getElementById('playback-time').classList.remove('live');
playbackIndex = parseInt(this.value);
displaySnapshot(playbackIndex);
});
// Get threshold from Home Assistant (read from live data file)
let currentThresholdHours = 4; // Default for live mode
let playbackThresholdHours = 48; // Default for playback (48 = show all)
function loadThreshold() {
fetch('/local/meshcore_heatmap_data.json?t=' + Date.now())
.then(r => r.json())
.then(data => {
if (data.threshold_hours) {
currentThresholdHours = data.threshold_hours;
if (liveMode) {
document.getElementById('threshold-hours').textContent = currentThresholdHours;
}
}
}).catch(e => {});
}
// Playback threshold slider
document.getElementById('playback-threshold').addEventListener('input', function() {
playbackThresholdHours = parseInt(this.value);
const display = playbackThresholdHours >= 48 ? 'ALL' : playbackThresholdHours + 'h';
document.getElementById('playback-threshold-value').textContent = display;
if (!liveMode && snapshots.length > 0) {
displaySnapshot(playbackIndex);
}
});
function filterNodesByThreshold(nodes, snapshotTimestamp) {
if (!nodes || nodes.length === 0) return [];
// If threshold is 48+ (ALL), return all nodes
if (playbackThresholdHours >= 48) {
return nodes;
}
// If nodes don't have last_used timestamps, return all (old format data)
const hasTimestamps = nodes.some(n => n.last_used !== undefined && n.last_used > 0);
if (!hasTimestamps) {
return nodes;
}
const thresholdSeconds = playbackThresholdHours * 3600;
const cutoff = snapshotTimestamp - thresholdSeconds;
return nodes.filter(n => (n.last_used || 0) >= cutoff);
}
function displaySnapshot(index) {
if (index < 0 || index >= snapshots.length) return;
const snapshot = snapshots[index];
const snapshotTime = snapshot.timestamp;
// Filter nodes by playback threshold setting
hopData = filterNodesByThreshold(snapshot.nodes || [], snapshotTime);
pathData = snapshot.paths || [];
document.getElementById('playback-time').textContent = formatTime(snapshotTime);
const thresholdDisplay = playbackThresholdHours >= 48 ? 'ALL' : playbackThresholdHours;
document.getElementById('threshold-hours').textContent = thresholdDisplay;
updateMap(false);
}
function getColor(ratio) {
if (ratio < 0.2) return '#0000ff';
if (ratio < 0.4) return '#00ffff';
if (ratio < 0.6) return '#00ff00';
if (ratio < 0.8) return '#ffff00';
return '#ff0000';
}
function toggleMarkers() {
markersVisible = !markersVisible;
markers.forEach(m => m.setStyle({ fillOpacity: markersVisible ? 0.9 : 0, opacity: markersVisible ? 1 : 0 }));
document.getElementById('paths-status').textContent = markersVisible ? '(visible)' : '(hidden)';
}
document.getElementById('toggle-paths').addEventListener('click', toggleMarkers);
function highlightNodePaths(nodeName) {
selectedNode = selectedNode === nodeName ? null : nodeName;
document.querySelectorAll('.node-item').forEach(item => {
item.classList.toggle('active', item.dataset.nodeName === selectedNode);
});
pathLines.forEach(line => {
if (selectedNode === null) line.setStyle({ opacity: 0 });
else if (line.pathNodes && line.pathNodes.includes(selectedNode)) {
line.setStyle({ opacity: 0.8, dashArray: '8, 8', weight: 3 });
line.bringToFront();
}
else line.setStyle({ opacity: 0 });
});
}
function updateMap(fitBounds = false) {
if (heatLayer) map.removeLayer(heatLayer);
markers.forEach(m => map.removeLayer(m));
markers = [];
pathLines.forEach(l => map.removeLayer(l));
pathLines = [];
if (hopData.length === 0) return;
const maxUses = Math.max(...hopData.map(d => d.use_count), 1);
const totalTraffic = hopData.reduce((sum, d) => sum + d.use_count, 0);
document.getElementById('node-count').textContent = hopData.length;
document.getElementById('path-count').textContent = pathData.length;
document.getElementById('total-traffic').textContent = totalTraffic;
document.getElementById('max-uses').textContent = maxUses;
// Draw paths
if (pathData && pathData.length > 0) {
const maxHops = Math.max(...pathData.map(p => p.hops || 1), 1);
pathData.forEach(path => {
const coords = path.coords || path.coordinates;
if (!coords || coords.length < 2) return;
const latlngs = coords.map(c => [c.lat, c.lon]);
const ratio = 1 - ((path.hops - 2) / Math.max(maxHops - 2, 1));
const color = getColor(Math.max(0, Math.min(1, ratio)));
const polyline = L.polyline(latlngs, { color: color, weight: 3, opacity: 0, dashArray: '8, 8' }).addTo(map);
polyline.pathNodes = coords.map(c => c.name);
polyline.sender = path.sender;
polyline.bindPopup(`<div style="text-align:center;"><strong>${path.sender}</strong><br><span style="color:${color};font-size:18px;font-weight:bold;">${path.hops}</span> hops</div>`);
pathLines.push(polyline);
});
}
const heatData = hopData.map(node => [node.lat, node.lon, node.use_count / maxUses]);
heatLayer = L.heatLayer(heatData, { radius: 50, blur: 35, maxZoom: 10, max: 1.0, minOpacity: 0.3,
gradient: { 0.0: '#0000ff', 0.25: '#00ffff', 0.5: '#00ff00', 0.75: '#ffff00', 1.0: '#ff0000' }
}).addTo(map);
hopData.forEach(node => {
const ratio = node.use_count / maxUses;
const marker = L.circleMarker([node.lat, node.lon], { radius: 4, fillColor: getColor(ratio), color: '#000', weight: 1, opacity: markersVisible ? 1 : 0, fillOpacity: markersVisible ? 0.9 : 0 }).addTo(map);
marker.bindPopup(`<div style="text-align:center;"><strong>${node.name}</strong><br><span style="color:${getColor(ratio)};font-size:18px;font-weight:bold;">${node.use_count}</span> uses</div>`);
markers.push(marker);
});
const nodeList = document.getElementById('node-list');
const sortedNodes = [...hopData].sort((a, b) => b.use_count - a.use_count);
nodeList.innerHTML = sortedNodes.map(node => {
const color = getColor(node.use_count / maxUses);
return `<div class="node-item" data-node-name="${node.name}" onclick="highlightNodePaths('${node.name.replace(/'/g, "\\'")}')"><div class="node-dot" style="background:${color};"></div><span class="node-name" title="${node.name}">${node.name}</span><span class="node-count">${node.use_count}</span></div>`;
}).join('');
if (fitBounds && hopData.length > 1) map.fitBounds(L.latLngBounds(hopData.map(d => [d.lat, d.lon])), { padding: [50, 50] });
}
function fetchLiveData() {
fetch('/local/meshcore_heatmap_data.json?t=' + Date.now())
.then(r => r.json())
.then(data => {
if (data.nodes && Array.isArray(data.nodes)) {
if (data.threshold_hours) document.getElementById('threshold-hours').textContent = data.threshold_hours;
if (liveMode) {
hopData = data.nodes;
pathData = data.paths || [];
updateMap(markers.length === 0);
}
}
}).catch(e => {});
}
// Load snapshots from server
loadSnapshots();
loadThreshold();
setInterval(loadSnapshots, 30000); // Check every 30 seconds
setInterval(loadThreshold, 60000); // Update threshold every minute
// Initial live data
fetch('/local/meshcore_heatmap_data.json')
.then(r => r.json())
.then(data => {
if (data.nodes && Array.isArray(data.nodes)) {
hopData = data.nodes;
pathData = data.paths || [];
if (data.threshold_hours) document.getElementById('threshold-hours').textContent = data.threshold_hours;
updateMap(true);
}
}).catch(e => {});
setInterval(fetchLiveData, 10000);
</script>
</body>
</html>