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

453 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 Direct Links</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; }
.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: #a855f7; margin-bottom: 8px; font-size: 14px; }
.stat-row { margin: 4px 0; color: #aaa; }
.stat-value { color: #fff; 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: #a855f7; }
.legend-item { display: flex; align-items: center; gap: 6px; margin: 4px 0; }
.legend-dot { width: 12px; height: 12px; border-radius: 50%; }
.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: 250px;
max-height: 400px;
overflow-y: auto;
}
.info-panel h3 {
margin-bottom: 10px;
color: #a855f7;
font-size: 14px;
border-bottom: 1px solid #333;
padding-bottom: 8px;
}
.node-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
border-bottom: 1px solid #2a2a4a;
font-size: 11px;
cursor: pointer;
transition: background 0.2s;
}
.node-item:hover { background: rgba(168, 85, 247, 0.2); }
.node-item.active { background: rgba(168, 85, 247, 0.3); }
.node-dot { width: 10px; height: 10px; 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; }
#node-search {
width: 100%;
padding: 6px;
margin-bottom: 10px;
background: #2a2a4a;
border: 1px solid #444;
border-radius: 4px;
color: #fff;
font-size: 12px;
}
.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: #a855f7;
border: none;
color: #fff;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background 0.2s;
}
.playback-btn:hover { background: #9333ea; }
.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: #a855f7; 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: #a855f7; 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>🔗 Direct Links 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">Direct Links: <span class="stat-value" id="link-count">0</span></div>
<div class="stat-row">Max Links: <span class="stat-value" id="max-links">0</span></div>
</div>
<div class="legend">
<div class="legend-title">Link Density</div>
<div class="legend-item"><div class="legend-dot" style="background: #0000ff;"></div><span>1-2 links</span></div>
<div class="legend-item"><div class="legend-dot" style="background: #00ffff;"></div><span>3-5 links</span></div>
<div class="legend-item"><div class="legend-dot" style="background: #00ff00;"></div><span>6-10 links</span></div>
<div class="legend-item"><div class="legend-dot" style="background: #ffff00;"></div><span>11-20 links</span></div>
<div class="legend-item"><div class="legend-dot" style="background: #ff0000;"></div><span>20+ links</span></div>
</div>
<div class="info-panel">
<h3>🔗 Top Connected Nodes</h3>
<input type="text" id="node-search" placeholder="Search nodes..." oninput="filterNodes()">
<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.3, 0], zoom: 9, 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 nodeData = [], linkData = [], heatLayer = null, markers = [], linkLines = [];
let selectedNode = null;
let allSnapshotLinks = []; // Store all links for highlighting regardless of threshold
// Playback state - loaded from server
let snapshots = [], isPlaying = false, playbackIndex = 0, playbackInterval = null, liveMode = true;
const PLAYBACK_SPEED = 400;
function loadSnapshots() {
fetch('/local/meshcore_directlinks_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;
allSnapshotLinks = []; // Clear snapshot links when going live
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_directlinks_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, links, snapshotTimestamp) {
if (!nodes || nodes.length === 0) return { nodes: [], links: [] };
// If threshold is 48+ (ALL), return all data
if (playbackThresholdHours >= 48) {
return { nodes: nodes, links: links || [] };
}
// If nodes don't have last_seen timestamps, return all (old format data)
const hasTimestamps = nodes.some(n => n.last_seen !== undefined && n.last_seen > 0);
if (!hasTimestamps) {
return { nodes: nodes, links: links || [] };
}
const thresholdSeconds = playbackThresholdHours * 3600;
const cutoff = snapshotTimestamp - thresholdSeconds;
const filteredNodes = nodes.filter(n => (n.last_seen || 0) >= cutoff);
const filteredNodeNames = new Set(filteredNodes.map(n => n.name));
// Filter links to only include those between active nodes
const filteredLinks = (links || []).filter(l =>
(l.last_seen || 0) >= cutoff &&
filteredNodeNames.has(l.from_name) &&
filteredNodeNames.has(l.to_name)
);
return { nodes: filteredNodes, links: filteredLinks };
}
function displaySnapshot(index) {
if (index < 0 || index >= snapshots.length) return;
const snapshot = snapshots[index];
const snapshotTime = snapshot.timestamp;
// Store ALL links from snapshot for highlighting
allSnapshotLinks = snapshot.links || [];
// Filter nodes by playback threshold setting
const filtered = filterNodesByThreshold(snapshot.nodes || [], snapshot.links || [], snapshotTime);
nodeData = filtered.nodes;
linkData = filtered.links;
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 filterNodes() {
const searchTerm = document.getElementById('node-search').value.toLowerCase();
document.querySelectorAll('.node-item').forEach(item => {
item.style.display = item.dataset.nodeName.toLowerCase().includes(searchTerm) ? 'flex' : 'none';
});
}
function highlightNodeLinks(nodeName) {
selectedNode = selectedNode === nodeName ? null : nodeName;
document.querySelectorAll('.node-item').forEach(item => {
item.classList.toggle('active', item.dataset.nodeName === selectedNode);
});
linkLines.forEach(line => {
if (selectedNode === null) line.setStyle({ opacity: 0 });
else if (line.fromName === selectedNode || line.toName === selectedNode) { line.setStyle({ opacity: 0.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 = [];
linkLines.forEach(l => map.removeLayer(l));
linkLines = [];
if (nodeData.length === 0) return;
const maxLinks = Math.max(...nodeData.map(d => d.link_count), 1);
document.getElementById('node-count').textContent = nodeData.length;
document.getElementById('link-count').textContent = linkData.length;
document.getElementById('max-links').textContent = maxLinks;
const heatData = nodeData.map(node => [node.lat, node.lon, node.link_count / maxLinks]);
heatLayer = L.heatLayer(heatData, { radius: 45, blur: 30, 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);
// Draw ALL links from snapshot (not just filtered) so highlighting works
const linksToUse = allSnapshotLinks.length > 0 ? allSnapshotLinks : linkData;
if (linksToUse && linksToUse.length > 0) {
linksToUse.forEach(link => {
const polyline = L.polyline([[link.from_lat, link.from_lon], [link.to_lat, link.to_lon]], { color: '#a855f7', weight: 3, opacity: 0, dashArray: '5, 10' }).addTo(map);
polyline.fromName = link.from_name;
polyline.toName = link.to_name;
polyline.bindPopup(`${link.from_name}${link.to_name}<br>Count: ${link.count}`);
linkLines.push(polyline);
});
}
nodeData.forEach(node => {
const ratio = node.link_count / maxLinks;
const marker = L.circleMarker([node.lat, node.lon], { radius: 4, fillColor: getColor(ratio), color: '#000', weight: 1, opacity: 1, fillOpacity: 0.9 }).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.link_count}</span> direct links</div>`);
markers.push(marker);
});
const nodeList = document.getElementById('node-list');
const sortedNodes = [...nodeData].sort((a, b) => b.link_count - a.link_count);
nodeList.innerHTML = sortedNodes.map(node => {
const color = getColor(node.link_count / maxLinks);
return `<div class="node-item" data-node-name="${node.name}" onclick="highlightNodeLinks('${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.link_count}</span></div>`;
}).join('');
if (fitBounds && nodeData.length > 1) map.fitBounds(L.latLngBounds(nodeData.map(d => [d.lat, d.lon])), { padding: [50, 50] });
}
function fetchLiveData() {
fetch('/local/meshcore_directlinks_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) {
nodeData = data.nodes;
linkData = data.links || [];
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_directlinks_data.json')
.then(r => r.json())
.then(data => {
if (data.nodes && Array.isArray(data.nodes)) {
nodeData = data.nodes;
linkData = data.links || [];
if (data.threshold_hours) document.getElementById('threshold-hours').textContent = data.threshold_hours;
updateMap(true);
}
}).catch(e => {});
setInterval(fetchLiveData, 10000);
</script>
</body>
</html>