mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-04-28 00:55:13 +00:00
4faf5463f4
Only animates the selected packet instead of loading 8 recent ones.
1123 lines
42 KiB
JavaScript
1123 lines
42 KiB
JavaScript
(function() {
|
|
'use strict';
|
|
|
|
let map, ws, nodesLayer, pathsLayer, animLayer, heatLayer;
|
|
let nodeMarkers = {};
|
|
let nodeData = {};
|
|
let packetCount = 0;
|
|
let activeAnims = 0;
|
|
let nodeActivity = {};
|
|
let recentPaths = [];
|
|
let audioCtx = null;
|
|
let soundEnabled = false;
|
|
let showGhostHops = localStorage.getItem('live-ghost-hops') !== 'false';
|
|
let _onResize = null;
|
|
let _navCleanup = null;
|
|
|
|
// === VCR State Machine ===
|
|
const VCR = {
|
|
mode: 'LIVE', // LIVE | PAUSED | REPLAY
|
|
buffer: [], // { ts: Date.now(), pkt } — all packets seen
|
|
playhead: -1, // index in buffer (-1 = live tail)
|
|
missedCount: 0, // packets arrived while paused
|
|
speed: 1, // replay speed: 1, 2, 4, 8
|
|
replayTimer: null,
|
|
timelineScope: 3600000, // 1h default ms
|
|
timelineTimestamps: [], // historical timestamps from DB for sparkline
|
|
timelineFetchedScope: 0, // last fetched scope to avoid redundant fetches
|
|
};
|
|
|
|
const ROLE_COLORS = {
|
|
repeater: '#3b82f6', companion: '#06b6d4', room: '#a855f7',
|
|
sensor: '#f59e0b', unknown: '#6b7280'
|
|
};
|
|
|
|
const TYPE_COLORS = {
|
|
ADVERT: '#22c55e', GRP_TXT: '#3b82f6', TXT_MSG: '#f59e0b', ACK: '#6b7280',
|
|
REQUEST: '#a855f7', RESPONSE: '#06b6d4', TRACE: '#ec4899', PATH: '#14b8a6'
|
|
};
|
|
|
|
const PAYLOAD_ICONS = {
|
|
ADVERT: '📡', GRP_TXT: '💬', TXT_MSG: '✉️', ACK: '✓',
|
|
REQUEST: '❓', RESPONSE: '📨', TRACE: '🔍', PATH: '🛤️'
|
|
};
|
|
|
|
function playSound(typeName) {
|
|
if (!soundEnabled || !audioCtx) return;
|
|
try {
|
|
const osc = audioCtx.createOscillator();
|
|
const gain = audioCtx.createGain();
|
|
osc.connect(gain); gain.connect(audioCtx.destination);
|
|
const freqs = { ADVERT: 880, GRP_TXT: 523, TXT_MSG: 659, ACK: 330, REQUEST: 740, TRACE: 987 };
|
|
osc.frequency.value = freqs[typeName] || 440;
|
|
osc.type = typeName === 'GRP_TXT' ? 'sine' : typeName === 'ADVERT' ? 'triangle' : 'square';
|
|
gain.gain.setValueAtTime(0.03, audioCtx.currentTime);
|
|
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.15);
|
|
osc.start(audioCtx.currentTime); osc.stop(audioCtx.currentTime + 0.15);
|
|
} catch {}
|
|
}
|
|
|
|
function initResizeHandler() {
|
|
let resizeTimer = null;
|
|
_onResize = function() {
|
|
clearTimeout(resizeTimer);
|
|
resizeTimer = setTimeout(() => { if (map) map.invalidateSize({ animate: false }); }, 150);
|
|
};
|
|
window.addEventListener('resize', _onResize);
|
|
window.addEventListener('orientationchange', () => setTimeout(_onResize, 200));
|
|
}
|
|
|
|
// === VCR Controls ===
|
|
|
|
function vcrSetMode(mode) {
|
|
VCR.mode = mode;
|
|
updateVCRUI();
|
|
}
|
|
|
|
function vcrPause() {
|
|
if (VCR.mode === 'PAUSED') return;
|
|
stopReplay();
|
|
VCR.missedCount = 0;
|
|
vcrSetMode('PAUSED');
|
|
}
|
|
|
|
function vcrResumeLive() {
|
|
stopReplay();
|
|
VCR.playhead = -1;
|
|
VCR.speed = 1;
|
|
VCR.missedCount = 0;
|
|
vcrSetMode('LIVE');
|
|
const prompt = document.getElementById('vcrPrompt');
|
|
if (prompt) prompt.classList.add('hidden');
|
|
}
|
|
|
|
function vcrUnpause() {
|
|
if (VCR.mode !== 'PAUSED') return;
|
|
if (VCR.missedCount > 3) {
|
|
// Show Option C prompt
|
|
showVCRPrompt(VCR.missedCount);
|
|
} else if (VCR.missedCount > 0) {
|
|
// Few packets — just replay them quickly
|
|
vcrReplayMissed();
|
|
} else {
|
|
vcrResumeLive();
|
|
}
|
|
}
|
|
|
|
function showVCRPrompt(count) {
|
|
const prompt = document.getElementById('vcrPrompt');
|
|
if (!prompt) return;
|
|
prompt.innerHTML = `
|
|
<span>You missed <strong>${count}</strong> packets.</span>
|
|
<button id="vcrPromptReplay" class="vcr-prompt-btn">▶ Replay</button>
|
|
<button id="vcrPromptSkip" class="vcr-prompt-btn">⏭ Skip to live</button>
|
|
`;
|
|
prompt.classList.remove('hidden');
|
|
document.getElementById('vcrPromptReplay').addEventListener('click', () => {
|
|
prompt.classList.add('hidden');
|
|
vcrReplayMissed();
|
|
});
|
|
document.getElementById('vcrPromptSkip').addEventListener('click', () => {
|
|
prompt.classList.add('hidden');
|
|
vcrResumeLive();
|
|
});
|
|
}
|
|
|
|
function vcrReplayMissed() {
|
|
const startIdx = VCR.buffer.length - VCR.missedCount;
|
|
VCR.playhead = Math.max(0, startIdx);
|
|
VCR.missedCount = 0;
|
|
VCR.speed = 2; // slightly fast
|
|
vcrSetMode('REPLAY');
|
|
startReplay();
|
|
}
|
|
|
|
function vcrRewind(ms) {
|
|
stopReplay();
|
|
// Fetch packets from DB for the time window
|
|
const now = Date.now();
|
|
const from = new Date(now - ms).toISOString();
|
|
fetch(`/api/packets?limit=200&grouped=false&since=${encodeURIComponent(from)}`)
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
const pkts = (data.packets || []).reverse(); // oldest first
|
|
// Prepend to buffer (avoid duplicates by ID)
|
|
const existingIds = new Set(VCR.buffer.map(b => b.pkt.id).filter(Boolean));
|
|
const newEntries = pkts.filter(p => !existingIds.has(p.id)).map(p => ({
|
|
ts: new Date(p.timestamp || p.created_at).getTime(),
|
|
pkt: dbPacketToLive(p)
|
|
}));
|
|
VCR.buffer = [...newEntries, ...VCR.buffer];
|
|
VCR.playhead = 0;
|
|
VCR.speed = 1;
|
|
vcrSetMode('REPLAY');
|
|
startReplay();
|
|
updateTimeline();
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
function startReplay() {
|
|
stopReplay();
|
|
function tick() {
|
|
if (VCR.mode !== 'REPLAY') return;
|
|
if (VCR.playhead >= VCR.buffer.length) {
|
|
// Caught up — go live
|
|
vcrResumeLive();
|
|
return;
|
|
}
|
|
const entry = VCR.buffer[VCR.playhead];
|
|
animatePacket(entry.pkt);
|
|
VCR.playhead++;
|
|
updateVCRUI();
|
|
updateTimelinePlayhead();
|
|
|
|
// Calculate delay to next packet
|
|
let delay = 150; // default
|
|
if (VCR.playhead < VCR.buffer.length) {
|
|
const nextEntry = VCR.buffer[VCR.playhead];
|
|
const realGap = nextEntry.ts - entry.ts;
|
|
delay = Math.min(2000, Math.max(80, realGap)) / VCR.speed;
|
|
}
|
|
VCR.replayTimer = setTimeout(tick, delay);
|
|
}
|
|
tick();
|
|
}
|
|
|
|
function stopReplay() {
|
|
if (VCR.replayTimer) { clearTimeout(VCR.replayTimer); VCR.replayTimer = null; }
|
|
}
|
|
|
|
function vcrSpeedCycle() {
|
|
const speeds = [1, 2, 4, 8];
|
|
const idx = speeds.indexOf(VCR.speed);
|
|
VCR.speed = speeds[(idx + 1) % speeds.length];
|
|
updateVCRUI();
|
|
// If replaying, restart with new speed
|
|
if (VCR.mode === 'REPLAY' && VCR.replayTimer) {
|
|
stopReplay();
|
|
startReplay();
|
|
}
|
|
}
|
|
|
|
function updateVCRUI() {
|
|
const modeEl = document.getElementById('vcrMode');
|
|
const pauseBtn = document.getElementById('vcrPauseBtn');
|
|
const speedBtn = document.getElementById('vcrSpeedBtn');
|
|
const missedEl = document.getElementById('vcrMissed');
|
|
if (!modeEl) return;
|
|
|
|
if (VCR.mode === 'LIVE') {
|
|
modeEl.innerHTML = '<span class="vcr-live-dot"></span> LIVE';
|
|
modeEl.className = 'vcr-mode vcr-mode-live';
|
|
if (pauseBtn) pauseBtn.textContent = '⏸';
|
|
if (missedEl) missedEl.classList.add('hidden');
|
|
} else if (VCR.mode === 'PAUSED') {
|
|
modeEl.textContent = '⏸ PAUSED';
|
|
modeEl.className = 'vcr-mode vcr-mode-paused';
|
|
if (pauseBtn) pauseBtn.textContent = '▶';
|
|
if (missedEl && VCR.missedCount > 0) {
|
|
missedEl.textContent = `+${VCR.missedCount}`;
|
|
missedEl.classList.remove('hidden');
|
|
}
|
|
} else if (VCR.mode === 'REPLAY') {
|
|
modeEl.textContent = `⏪ REPLAY`;
|
|
modeEl.className = 'vcr-mode vcr-mode-replay';
|
|
if (pauseBtn) pauseBtn.textContent = '⏸';
|
|
if (missedEl) missedEl.classList.add('hidden');
|
|
}
|
|
if (speedBtn) speedBtn.textContent = VCR.speed + 'x';
|
|
}
|
|
|
|
function dbPacketToLive(pkt) {
|
|
const raw = JSON.parse(pkt.decoded_json || '{}');
|
|
const hops = JSON.parse(pkt.path_json || '[]');
|
|
const typeName = raw.type || pkt.payload_type_name || 'UNKNOWN';
|
|
return {
|
|
id: pkt.id, hash: pkt.hash,
|
|
_ts: new Date(pkt.timestamp || pkt.created_at).getTime(),
|
|
decoded: { header: { payloadTypeName: typeName }, payload: raw, path: { hops } },
|
|
snr: pkt.snr, rssi: pkt.rssi, observer: pkt.observer_name
|
|
};
|
|
}
|
|
|
|
// Buffer a packet from WS
|
|
function bufferPacket(pkt) {
|
|
pkt._ts = Date.now();
|
|
const entry = { ts: pkt._ts, pkt };
|
|
VCR.buffer.push(entry);
|
|
// Keep buffer capped at ~2000
|
|
if (VCR.buffer.length > 2000) VCR.buffer.splice(0, 500);
|
|
|
|
if (VCR.mode === 'LIVE') {
|
|
animatePacket(pkt);
|
|
updateTimeline();
|
|
} else if (VCR.mode === 'PAUSED') {
|
|
VCR.missedCount++;
|
|
updateVCRUI();
|
|
updateTimeline();
|
|
}
|
|
// In REPLAY mode, new packets just go to buffer, will be reached when playhead catches up
|
|
}
|
|
|
|
// === Timeline ===
|
|
|
|
async function fetchTimelineTimestamps() {
|
|
const scopeMs = VCR.timelineScope;
|
|
if (scopeMs === VCR.timelineFetchedScope) return;
|
|
const since = new Date(Date.now() - scopeMs).toISOString();
|
|
try {
|
|
const resp = await fetch(`/api/packets/timestamps?since=${encodeURIComponent(since)}`);
|
|
if (resp.ok) {
|
|
const timestamps = await resp.json(); // array of ISO strings
|
|
VCR.timelineTimestamps = timestamps.map(t => new Date(t).getTime());
|
|
VCR.timelineFetchedScope = scopeMs;
|
|
}
|
|
} catch(e) { /* ignore */ }
|
|
}
|
|
|
|
function updateTimeline() {
|
|
const canvas = document.getElementById('vcrTimeline');
|
|
if (!canvas) return;
|
|
const ctx = canvas.getContext('2d');
|
|
const w = canvas.width = canvas.offsetWidth * (window.devicePixelRatio || 1);
|
|
const h = canvas.height = canvas.offsetHeight * (window.devicePixelRatio || 1);
|
|
ctx.scale(window.devicePixelRatio || 1, window.devicePixelRatio || 1);
|
|
const cw = canvas.offsetWidth;
|
|
const ch = canvas.offsetHeight;
|
|
|
|
ctx.clearRect(0, 0, cw, ch);
|
|
|
|
const now = Date.now();
|
|
const scopeMs = VCR.timelineScope;
|
|
const startTs = now - scopeMs;
|
|
|
|
// Merge historical DB timestamps with live buffer timestamps
|
|
const allTimestamps = [];
|
|
VCR.timelineTimestamps.forEach(ts => {
|
|
if (ts >= startTs) allTimestamps.push(ts);
|
|
});
|
|
VCR.buffer.forEach(entry => {
|
|
if (entry.ts >= startTs) allTimestamps.push(entry.ts);
|
|
});
|
|
|
|
if (allTimestamps.length === 0) return;
|
|
|
|
// Draw density sparkline
|
|
const buckets = 100;
|
|
const counts = new Array(buckets).fill(0);
|
|
let maxCount = 0;
|
|
allTimestamps.forEach(ts => {
|
|
const bucket = Math.floor((ts - startTs) / scopeMs * buckets);
|
|
if (bucket >= 0 && bucket < buckets) {
|
|
counts[bucket]++;
|
|
if (counts[bucket] > maxCount) maxCount = counts[bucket];
|
|
}
|
|
});
|
|
|
|
if (maxCount === 0) return;
|
|
|
|
const barW = cw / buckets;
|
|
ctx.fillStyle = 'rgba(59, 130, 246, 0.4)';
|
|
counts.forEach((c, i) => {
|
|
if (c === 0) return;
|
|
const barH = (c / maxCount) * (ch - 4);
|
|
ctx.fillRect(i * barW, ch - barH - 2, barW - 1, barH);
|
|
});
|
|
|
|
// Draw playhead
|
|
updateTimelinePlayhead();
|
|
}
|
|
|
|
function updateTimelinePlayhead() {
|
|
const canvas = document.getElementById('vcrTimeline');
|
|
if (!canvas) return;
|
|
const ctx = canvas.getContext('2d');
|
|
const cw = canvas.offsetWidth;
|
|
const ch = canvas.offsetHeight;
|
|
const now = Date.now();
|
|
const scopeMs = VCR.timelineScope;
|
|
const startTs = now - scopeMs;
|
|
|
|
// Redraw sparkline (cheap, avoids double-buffer complexity)
|
|
// Just draw playhead line on top — clear only the line area
|
|
let playTs;
|
|
if (VCR.mode === 'LIVE' || VCR.playhead < 0) {
|
|
playTs = now;
|
|
} else if (VCR.playhead < VCR.buffer.length) {
|
|
playTs = VCR.buffer[VCR.playhead].ts;
|
|
} else {
|
|
playTs = now;
|
|
}
|
|
|
|
const x = ((playTs - startTs) / scopeMs) * cw;
|
|
// We overlay a playhead marker — use a separate tiny canvas or just draw
|
|
const playheadEl = document.getElementById('vcrPlayhead');
|
|
if (playheadEl) {
|
|
playheadEl.style.left = Math.max(0, Math.min(cw - 2, x)) + 'px';
|
|
}
|
|
}
|
|
|
|
function handleTimelineClick(e) {
|
|
const canvas = document.getElementById('vcrTimeline');
|
|
if (!canvas) return;
|
|
const rect = canvas.getBoundingClientRect();
|
|
const x = e.clientX - rect.left;
|
|
const pct = x / rect.width;
|
|
const now = Date.now();
|
|
const targetTs = now - VCR.timelineScope + pct * VCR.timelineScope;
|
|
|
|
// Find closest buffer entry
|
|
let closest = 0;
|
|
let minDist = Infinity;
|
|
VCR.buffer.forEach((entry, i) => {
|
|
const dist = Math.abs(entry.ts - targetTs);
|
|
if (dist < minDist) { minDist = dist; closest = i; }
|
|
});
|
|
|
|
// If click is before our buffer, fetch from DB
|
|
if (VCR.buffer.length === 0 || targetTs < VCR.buffer[0].ts - 5000) {
|
|
vcrRewind(now - targetTs);
|
|
return;
|
|
}
|
|
|
|
stopReplay();
|
|
VCR.playhead = closest;
|
|
vcrSetMode('REPLAY');
|
|
startReplay();
|
|
}
|
|
|
|
async function init(app) {
|
|
app.innerHTML = `
|
|
<div class="live-page">
|
|
<div id="liveMap" style="width:100%;height:100%;position:absolute;top:0;left:0;z-index:1"></div>
|
|
<div class="live-overlay live-header" id="liveHeader">
|
|
<div class="live-title">
|
|
<span class="live-beacon"></span>
|
|
MESH LIVE
|
|
</div>
|
|
<div class="live-stats-row">
|
|
<div class="live-stat-pill"><span id="livePktCount">0</span> pkts</div>
|
|
<div class="live-stat-pill"><span id="liveNodeCount">0</span> nodes</div>
|
|
<div class="live-stat-pill anim-pill"><span id="liveAnimCount">0</span> active</div>
|
|
<div class="live-stat-pill rate-pill"><span id="livePktRate">0</span>/min</div>
|
|
</div>
|
|
<button class="live-sound-btn" id="liveSoundBtn" title="Toggle sound">🔇</button>
|
|
<div class="live-toggles">
|
|
<label><input type="checkbox" id="liveHeatToggle" checked> Heat</label>
|
|
<label><input type="checkbox" id="liveGhostToggle" checked> Ghosts</label>
|
|
</div>
|
|
</div>
|
|
<div class="live-overlay live-feed" id="liveFeed">
|
|
<button class="feed-hide-btn" id="feedHideBtn" title="Hide feed">✕</button>
|
|
</div>
|
|
<button class="feed-show-btn hidden" id="feedShowBtn" title="Show feed">📋</button>
|
|
<div class="live-overlay live-legend">
|
|
<div class="legend-title">PACKET TYPES</div>
|
|
<div><span class="live-dot" style="background:#22c55e"></span> Advert</div>
|
|
<div><span class="live-dot" style="background:#3b82f6"></span> Message</div>
|
|
<div><span class="live-dot" style="background:#f59e0b"></span> Direct</div>
|
|
<div><span class="live-dot" style="background:#a855f7"></span> Request</div>
|
|
<div><span class="live-dot" style="background:#ec4899"></span> Trace</div>
|
|
<div class="legend-title" style="margin-top:8px">NODE ROLES</div>
|
|
<div><span class="live-dot" style="background:#3b82f6"></span> Repeater</div>
|
|
<div><span class="live-dot" style="background:#06b6d4"></span> Companion</div>
|
|
<div><span class="live-dot" style="background:#a855f7"></span> Room</div>
|
|
<div><span class="live-dot" style="background:#f59e0b"></span> Sensor</div>
|
|
</div>
|
|
|
|
<!-- VCR Bar -->
|
|
<div class="vcr-bar" id="vcrBar">
|
|
<div class="vcr-controls">
|
|
<button id="vcrRewindBtn" class="vcr-btn" title="Rewind">⏪</button>
|
|
<button id="vcrPauseBtn" class="vcr-btn" title="Pause/Play">⏸</button>
|
|
<button id="vcrLiveBtn" class="vcr-btn vcr-live-btn" title="Jump to live">LIVE</button>
|
|
<button id="vcrSpeedBtn" class="vcr-btn" title="Playback speed">1x</button>
|
|
<div id="vcrMode" class="vcr-mode vcr-mode-live"><span class="vcr-live-dot"></span> LIVE</div>
|
|
<span id="vcrMissed" class="vcr-missed hidden">+0</span>
|
|
</div>
|
|
<div class="vcr-timeline-wrap">
|
|
<div class="vcr-scope-btns">
|
|
<button class="vcr-scope-btn active" data-scope="3600000">1h</button>
|
|
<button class="vcr-scope-btn" data-scope="21600000">6h</button>
|
|
<button class="vcr-scope-btn" data-scope="43200000">12h</button>
|
|
<button class="vcr-scope-btn" data-scope="86400000">24h</button>
|
|
</div>
|
|
<div class="vcr-timeline-container">
|
|
<canvas id="vcrTimeline" class="vcr-timeline"></canvas>
|
|
<div id="vcrPlayhead" class="vcr-playhead"></div>
|
|
<div id="vcrTimeTooltip" class="vcr-time-tooltip hidden"></div>
|
|
</div>
|
|
</div>
|
|
<div id="vcrPrompt" class="vcr-prompt hidden"></div>
|
|
</div>
|
|
</div>`;
|
|
|
|
map = L.map('liveMap', {
|
|
zoomControl: false, attributionControl: false,
|
|
zoomAnimation: true, markerZoomAnimation: true
|
|
}).setView([37.45, -122.0], 9);
|
|
|
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { maxZoom: 19 }).addTo(map);
|
|
L.control.zoom({ position: 'topright' }).addTo(map);
|
|
|
|
nodesLayer = L.layerGroup().addTo(map);
|
|
pathsLayer = L.layerGroup().addTo(map);
|
|
animLayer = L.layerGroup().addTo(map);
|
|
|
|
injectSVGFilters();
|
|
await loadNodes();
|
|
showHeatMap();
|
|
connectWS();
|
|
initResizeHandler();
|
|
startRateCounter();
|
|
|
|
// Check for single packet replay from packets page
|
|
const replayData = sessionStorage.getItem('replay-packet');
|
|
if (replayData) {
|
|
sessionStorage.removeItem('replay-packet');
|
|
try {
|
|
const pkt = JSON.parse(replayData);
|
|
setTimeout(() => animatePacket(pkt), 1500);
|
|
} catch {}
|
|
} else {
|
|
replayRecent();
|
|
}
|
|
|
|
map.on('zoomend', rescaleMarkers);
|
|
|
|
// Sound toggle
|
|
document.getElementById('liveSoundBtn').addEventListener('click', () => {
|
|
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
|
soundEnabled = !soundEnabled;
|
|
document.getElementById('liveSoundBtn').textContent = soundEnabled ? '🔊' : '🔇';
|
|
});
|
|
|
|
// Heat map toggle
|
|
document.getElementById('liveHeatToggle').addEventListener('change', (e) => {
|
|
if (e.target.checked) showHeatMap(); else hideHeatMap();
|
|
});
|
|
|
|
const ghostToggle = document.getElementById('liveGhostToggle');
|
|
ghostToggle.checked = showGhostHops;
|
|
ghostToggle.addEventListener('change', (e) => {
|
|
showGhostHops = e.target.checked;
|
|
localStorage.setItem('live-ghost-hops', showGhostHops);
|
|
});
|
|
|
|
// Feed show/hide
|
|
const feedEl = document.getElementById('liveFeed');
|
|
const feedHideBtn = document.getElementById('feedHideBtn');
|
|
const feedShowBtn = document.getElementById('feedShowBtn');
|
|
if (localStorage.getItem('live-feed-hidden') === 'true') {
|
|
feedEl.classList.add('hidden');
|
|
feedShowBtn.classList.remove('hidden');
|
|
}
|
|
feedHideBtn.addEventListener('click', () => {
|
|
feedEl.classList.add('hidden'); feedShowBtn.classList.remove('hidden');
|
|
localStorage.setItem('live-feed-hidden', 'true');
|
|
});
|
|
feedShowBtn.addEventListener('click', () => {
|
|
feedEl.classList.remove('hidden'); feedShowBtn.classList.add('hidden');
|
|
localStorage.setItem('live-feed-hidden', 'false');
|
|
});
|
|
|
|
// Save/restore map view
|
|
const savedView = localStorage.getItem('live-map-view');
|
|
if (savedView) {
|
|
try { const v = JSON.parse(savedView); map.setView([v.lat, v.lng], v.zoom); } catch {}
|
|
}
|
|
map.on('moveend', () => {
|
|
const c = map.getCenter();
|
|
localStorage.setItem('live-map-view', JSON.stringify({ lat: c.lat, lng: c.lng, zoom: map.getZoom() }));
|
|
});
|
|
|
|
// === VCR event listeners ===
|
|
document.getElementById('vcrPauseBtn').addEventListener('click', () => {
|
|
if (VCR.mode === 'PAUSED') vcrUnpause();
|
|
else if (VCR.mode === 'REPLAY') { stopReplay(); vcrSetMode('PAUSED'); }
|
|
else vcrPause();
|
|
});
|
|
document.getElementById('vcrLiveBtn').addEventListener('click', vcrResumeLive);
|
|
document.getElementById('vcrSpeedBtn').addEventListener('click', vcrSpeedCycle);
|
|
document.getElementById('vcrRewindBtn').addEventListener('click', () => {
|
|
// Rewind by current scope
|
|
vcrRewind(VCR.timelineScope);
|
|
});
|
|
|
|
// Scope buttons
|
|
document.querySelectorAll('.vcr-scope-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
document.querySelectorAll('.vcr-scope-btn').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
VCR.timelineScope = parseInt(btn.dataset.scope);
|
|
fetchTimelineTimestamps().then(() => updateTimeline());
|
|
});
|
|
});
|
|
|
|
// Timeline click to scrub
|
|
// Timeline click handled by drag (mousedown+mouseup)
|
|
|
|
// Timeline hover — show time tooltip
|
|
const timelineEl = document.getElementById('vcrTimeline');
|
|
const timeTooltip = document.getElementById('vcrTimeTooltip');
|
|
timelineEl.addEventListener('mousemove', (e) => {
|
|
const rect = timelineEl.getBoundingClientRect();
|
|
const pct = (e.clientX - rect.left) / rect.width;
|
|
const ts = Date.now() - VCR.timelineScope + pct * VCR.timelineScope;
|
|
const d = new Date(ts);
|
|
timeTooltip.textContent = d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'});
|
|
timeTooltip.style.left = (e.clientX - rect.left) + 'px';
|
|
timeTooltip.classList.remove('hidden');
|
|
});
|
|
timelineEl.addEventListener('mouseleave', () => { timeTooltip.classList.add('hidden'); });
|
|
|
|
// Drag scrubbing on timeline
|
|
let dragging = false;
|
|
function scrubToX(clientX, isFinal) {
|
|
const rect = timelineEl.getBoundingClientRect();
|
|
const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
|
const now = Date.now();
|
|
const targetTs = now - VCR.timelineScope + pct * VCR.timelineScope;
|
|
|
|
// If buffer is empty or target is before buffer start, fetch from DB on release
|
|
if (VCR.buffer.length === 0 || targetTs < VCR.buffer[0].ts - 5000) {
|
|
if (isFinal) {
|
|
vcrRewind(now - targetTs);
|
|
} else {
|
|
// Update playhead position visually during drag even without buffer
|
|
const playheadEl = document.getElementById('vcrPlayhead');
|
|
if (playheadEl) {
|
|
playheadEl.style.left = (pct * rect.width) + 'px';
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
let closest = 0;
|
|
let minDist = Infinity;
|
|
VCR.buffer.forEach((entry, i) => {
|
|
const dist = Math.abs(entry.ts - targetTs);
|
|
if (dist < minDist) { minDist = dist; closest = i; }
|
|
});
|
|
|
|
stopReplay();
|
|
VCR.playhead = closest;
|
|
vcrSetMode('REPLAY');
|
|
updateTimelinePlayhead();
|
|
updateVCRUI();
|
|
}
|
|
timelineEl.addEventListener('mousedown', (e) => {
|
|
dragging = true;
|
|
scrubToX(e.clientX, false);
|
|
e.preventDefault();
|
|
});
|
|
document.addEventListener('mousemove', (e) => {
|
|
if (!dragging) return;
|
|
scrubToX(e.clientX, false);
|
|
});
|
|
document.addEventListener('mouseup', (e) => {
|
|
if (!dragging) return;
|
|
dragging = false;
|
|
scrubToX(e.clientX, true);
|
|
if (VCR.mode === 'REPLAY') startReplay();
|
|
});
|
|
// Touch support
|
|
timelineEl.addEventListener('touchstart', (e) => {
|
|
dragging = true;
|
|
scrubToX(e.touches[0].clientX, false);
|
|
e.preventDefault();
|
|
}, { passive: false });
|
|
timelineEl.addEventListener('touchmove', (e) => {
|
|
if (!dragging) return;
|
|
scrubToX(e.touches[0].clientX, false);
|
|
});
|
|
timelineEl.addEventListener('touchend', (e) => {
|
|
if (!dragging) return;
|
|
const lastTouch = e.changedTouches[0];
|
|
dragging = false;
|
|
scrubToX(lastTouch.clientX, true);
|
|
if (VCR.mode === 'REPLAY') startReplay();
|
|
});
|
|
|
|
// Fetch historical timestamps for timeline, then start refresh
|
|
fetchTimelineTimestamps().then(() => updateTimeline());
|
|
setInterval(() => {
|
|
// Re-fetch if scope changed or periodically to pick up new data
|
|
VCR.timelineFetchedScope = 0; // force refetch
|
|
fetchTimelineTimestamps().then(() => updateTimeline());
|
|
}, 30000);
|
|
|
|
// Auto-hide nav
|
|
const topNav = document.querySelector('.top-nav');
|
|
if (topNav) { topNav.style.position = 'fixed'; topNav.style.width = '100%'; topNav.style.zIndex = '1100'; }
|
|
_navCleanup = { timeout: null, fn: null };
|
|
function showNav() {
|
|
if (topNav) topNav.classList.remove('nav-autohide');
|
|
clearTimeout(_navCleanup.timeout);
|
|
_navCleanup.timeout = setTimeout(() => { if (topNav) topNav.classList.add('nav-autohide'); }, 4000);
|
|
}
|
|
_navCleanup.fn = showNav;
|
|
const livePage = document.querySelector('.live-page');
|
|
if (livePage) {
|
|
livePage.addEventListener('mousemove', showNav);
|
|
livePage.addEventListener('touchstart', showNav);
|
|
livePage.addEventListener('click', showNav);
|
|
}
|
|
showNav();
|
|
}
|
|
|
|
function injectSVGFilters() {
|
|
if (document.getElementById('live-svg-filters')) return;
|
|
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
svg.id = 'live-svg-filters';
|
|
svg.style.cssText = 'position:absolute;width:0;height:0;';
|
|
svg.innerHTML = `<defs><filter id="glow"><feGaussianBlur stdDeviation="3" result="blur"/><feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs>`;
|
|
document.body.appendChild(svg);
|
|
}
|
|
|
|
let pktTimestamps = [];
|
|
function startRateCounter() {
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
pktTimestamps = pktTimestamps.filter(t => now - t < 60000);
|
|
const el = document.getElementById('livePktRate');
|
|
if (el) el.textContent = pktTimestamps.length;
|
|
}, 2000);
|
|
}
|
|
|
|
async function loadNodes() {
|
|
try {
|
|
const resp = await fetch('/api/nodes?limit=500');
|
|
const nodes = await resp.json();
|
|
const list = Array.isArray(nodes) ? nodes : (nodes.nodes || []);
|
|
list.forEach(n => {
|
|
if (n.lat != null && n.lon != null && !(n.lat === 0 && n.lon === 0)) {
|
|
nodeData[n.public_key] = n;
|
|
addNodeMarker(n);
|
|
}
|
|
});
|
|
document.getElementById('liveNodeCount').textContent = Object.keys(nodeMarkers).length;
|
|
} catch (e) { console.error('Failed to load nodes:', e); }
|
|
}
|
|
|
|
function addNodeMarker(n) {
|
|
if (nodeMarkers[n.public_key]) return nodeMarkers[n.public_key];
|
|
const color = ROLE_COLORS[n.role] || ROLE_COLORS.unknown;
|
|
const isRepeater = n.role === 'repeater';
|
|
const zoom = map ? map.getZoom() : 11;
|
|
const zoomScale = Math.max(0.4, (zoom - 8) / 6);
|
|
const size = Math.round((isRepeater ? 6 : 4) * zoomScale);
|
|
|
|
const glow = L.circleMarker([n.lat, n.lon], {
|
|
radius: size + 4, fillColor: color, fillOpacity: 0.12, stroke: false, interactive: false
|
|
}).addTo(nodesLayer);
|
|
|
|
const marker = L.circleMarker([n.lat, n.lon], {
|
|
radius: size, fillColor: color, fillOpacity: 0.85,
|
|
color: '#fff', weight: isRepeater ? 1.5 : 0.5, opacity: isRepeater ? 0.6 : 0.3
|
|
}).addTo(nodesLayer);
|
|
|
|
marker.bindTooltip(n.name || n.public_key.slice(0, 8), {
|
|
permanent: false, direction: 'top', offset: [0, -10], className: 'live-tooltip'
|
|
});
|
|
|
|
marker._glowMarker = glow;
|
|
marker._baseColor = color;
|
|
marker._baseSize = size;
|
|
nodeMarkers[n.public_key] = marker;
|
|
return marker;
|
|
}
|
|
|
|
function rescaleMarkers() {
|
|
const zoom = map.getZoom();
|
|
const zoomScale = Math.max(0.4, (zoom - 8) / 6);
|
|
for (const [key, marker] of Object.entries(nodeMarkers)) {
|
|
const n = nodeData[key];
|
|
const isRepeater = n && n.role === 'repeater';
|
|
const size = Math.round((isRepeater ? 6 : 4) * zoomScale);
|
|
marker.setRadius(size);
|
|
marker._baseSize = size;
|
|
if (marker._glowMarker) marker._glowMarker.setRadius(size + 4);
|
|
}
|
|
}
|
|
|
|
async function replayRecent() {
|
|
try {
|
|
const resp = await fetch('/api/packets?limit=8&grouped=false');
|
|
const data = await resp.json();
|
|
const pkts = (data.packets || []).reverse();
|
|
pkts.forEach((pkt, i) => {
|
|
const livePkt = dbPacketToLive(pkt);
|
|
livePkt._ts = new Date(pkt.timestamp || pkt.created_at).getTime();
|
|
const ts = livePkt._ts;
|
|
VCR.buffer.push({ ts, pkt: livePkt });
|
|
setTimeout(() => animatePacket(livePkt), i * 400);
|
|
});
|
|
setTimeout(updateTimeline, pkts.length * 400 + 200);
|
|
} catch {}
|
|
}
|
|
|
|
function connectWS() {
|
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
ws = new WebSocket(`${proto}://${location.host}`);
|
|
ws.onmessage = (e) => {
|
|
try {
|
|
const msg = JSON.parse(e.data);
|
|
if (msg.type === 'packet') bufferPacket(msg.data);
|
|
} catch {}
|
|
};
|
|
ws.onclose = () => setTimeout(connectWS, 3000);
|
|
ws.onerror = () => {};
|
|
}
|
|
|
|
function animatePacket(pkt) {
|
|
packetCount++;
|
|
pktTimestamps.push(Date.now());
|
|
document.getElementById('livePktCount').textContent = packetCount;
|
|
|
|
const decoded = pkt.decoded || {};
|
|
const header = decoded.header || {};
|
|
const payload = decoded.payload || {};
|
|
const typeName = header.payloadTypeName || 'UNKNOWN';
|
|
const icon = PAYLOAD_ICONS[typeName] || '📦';
|
|
const hops = decoded.path?.hops || [];
|
|
const color = TYPE_COLORS[typeName] || '#6b7280';
|
|
|
|
playSound(typeName);
|
|
addFeedItem(icon, typeName, payload, hops, color, pkt);
|
|
|
|
const hopPositions = resolveHopPositions(hops, payload);
|
|
if (hopPositions.length === 0) return;
|
|
if (hopPositions.length === 1) { pulseNode(hopPositions[0].key, hopPositions[0].pos, typeName); return; }
|
|
animatePath(hopPositions, typeName, color);
|
|
}
|
|
|
|
function resolveHopPositions(hops, payload) {
|
|
const known = Object.values(nodeData);
|
|
const raw = hops.map(hop => {
|
|
let found = null;
|
|
for (const n of known) {
|
|
if (n.public_key.toLowerCase().startsWith(hop.toLowerCase())) { found = n; break; }
|
|
}
|
|
if (found && !(found.lat === 0 && found.lon === 0)) {
|
|
return { key: found.public_key, pos: [found.lat, found.lon], name: found.name || hop, known: true };
|
|
}
|
|
return { key: 'hop-' + hop, pos: null, name: hop, known: false };
|
|
});
|
|
|
|
if (payload.pubKey && payload.lat != null && !(payload.lat === 0 && payload.lon === 0)) {
|
|
const existing = raw.find(p => p.key === payload.pubKey);
|
|
if (!existing) {
|
|
raw.unshift({ key: payload.pubKey, pos: [payload.lat, payload.lon], name: payload.name || payload.pubKey.slice(0, 8), known: true });
|
|
}
|
|
}
|
|
|
|
if (!showGhostHops) return raw.filter(h => h.known);
|
|
|
|
const knownPositions = raw.filter(h => h.known);
|
|
if (knownPositions.length < 2) return raw.filter(h => h.known);
|
|
|
|
for (let i = 0; i < raw.length; i++) {
|
|
if (raw[i].known) continue;
|
|
let before = null, after = null;
|
|
for (let j = i - 1; j >= 0; j--) { if (raw[j].known || raw[j].pos) { before = raw[j].pos; break; } }
|
|
for (let j = i + 1; j < raw.length; j++) { if (raw[j].known) { after = raw[j].pos; break; } }
|
|
if (before && after) {
|
|
let gapStart = i, gapEnd = i;
|
|
for (let j = i - 1; j >= 0 && !raw[j].known; j--) gapStart = j;
|
|
for (let j = i + 1; j < raw.length && !raw[j].known; j++) gapEnd = j;
|
|
const gapSize = gapEnd - gapStart + 1;
|
|
const t = (i - gapStart + 1) / (gapSize + 1);
|
|
raw[i].pos = [before[0] + (after[0] - before[0]) * t, before[1] + (after[1] - before[1]) * t];
|
|
raw[i].ghost = true;
|
|
}
|
|
}
|
|
return raw.filter(h => h.pos != null);
|
|
}
|
|
|
|
function animatePath(hopPositions, typeName, color) {
|
|
activeAnims++;
|
|
document.getElementById('liveAnimCount').textContent = activeAnims;
|
|
let hopIndex = 0;
|
|
|
|
function nextHop() {
|
|
if (hopIndex >= hopPositions.length) {
|
|
activeAnims = Math.max(0, activeAnims - 1);
|
|
document.getElementById('liveAnimCount').textContent = activeAnims;
|
|
return;
|
|
}
|
|
const hp = hopPositions[hopIndex];
|
|
const isGhost = hp.ghost;
|
|
|
|
if (isGhost) {
|
|
if (!nodeMarkers[hp.key]) {
|
|
const ghost = L.circleMarker(hp.pos, {
|
|
radius: 3, fillColor: '#94a3b8', fillOpacity: 0.35, color: '#94a3b8', weight: 1, opacity: 0.5
|
|
}).addTo(animLayer);
|
|
let pulseUp = true;
|
|
const pulseTimer = setInterval(() => {
|
|
if (!animLayer.hasLayer(ghost)) { clearInterval(pulseTimer); return; }
|
|
ghost.setStyle({ fillOpacity: pulseUp ? 0.6 : 0.25, opacity: pulseUp ? 0.7 : 0.4 });
|
|
pulseUp = !pulseUp;
|
|
}, 600);
|
|
setTimeout(() => { clearInterval(pulseTimer); if (animLayer.hasLayer(ghost)) animLayer.removeLayer(ghost); }, 3000);
|
|
}
|
|
} else {
|
|
pulseNode(hp.key, hp.pos, typeName);
|
|
}
|
|
|
|
if (hopIndex < hopPositions.length - 1) {
|
|
const nextPos = hopPositions[hopIndex + 1].pos;
|
|
const nextGhost = hopPositions[hopIndex + 1].ghost;
|
|
const lineColor = (isGhost || nextGhost) ? '#94a3b8' : color;
|
|
const lineOpacity = (isGhost || nextGhost) ? 0.3 : undefined;
|
|
drawAnimatedLine(hp.pos, nextPos, lineColor, () => { hopIndex++; nextHop(); }, lineOpacity);
|
|
} else {
|
|
if (!isGhost) pulseNode(hp.key, hp.pos, typeName);
|
|
hopIndex++; nextHop();
|
|
}
|
|
}
|
|
nextHop();
|
|
}
|
|
|
|
function pulseNode(key, pos, typeName) {
|
|
if (!nodeMarkers[key]) {
|
|
const ghost = L.circleMarker(pos, {
|
|
radius: 5, fillColor: '#6b7280', fillOpacity: 0.3, color: '#fff', weight: 0.5, opacity: 0.2
|
|
}).addTo(nodesLayer);
|
|
ghost._baseColor = '#6b7280'; ghost._baseSize = 5;
|
|
nodeMarkers[key] = ghost;
|
|
setTimeout(() => {
|
|
nodesLayer.removeLayer(ghost);
|
|
if (ghost._glowMarker) nodesLayer.removeLayer(ghost._glowMarker);
|
|
delete nodeMarkers[key];
|
|
}, 30000);
|
|
}
|
|
|
|
const marker = nodeMarkers[key];
|
|
if (!marker) return;
|
|
const color = TYPE_COLORS[typeName] || '#6b7280';
|
|
|
|
const ring = L.circleMarker(pos, {
|
|
radius: 2, fillColor: 'transparent', fillOpacity: 0, color: color, weight: 3, opacity: 0.9
|
|
}).addTo(animLayer);
|
|
|
|
let r = 2, op = 0.9;
|
|
const iv = setInterval(() => {
|
|
r += 1.5; op -= 0.03;
|
|
if (op <= 0) {
|
|
clearInterval(iv);
|
|
try { animLayer.removeLayer(ring); } catch {}
|
|
return;
|
|
}
|
|
try {
|
|
ring.setRadius(r);
|
|
ring.setStyle({ opacity: op, weight: Math.max(0.3, 3 - r * 0.04) });
|
|
} catch { clearInterval(iv); }
|
|
}, 26);
|
|
// Safety cleanup — never let a ring live longer than 2s
|
|
setTimeout(() => { clearInterval(iv); try { animLayer.removeLayer(ring); } catch {} }, 2000);
|
|
|
|
const baseColor = marker._baseColor || '#6b7280';
|
|
const baseSize = marker._baseSize || 6;
|
|
marker.setStyle({ fillColor: '#fff', fillOpacity: 1, radius: baseSize + 2, color: color, weight: 2 });
|
|
|
|
if (marker._glowMarker) {
|
|
marker._glowMarker.setStyle({ fillColor: color, fillOpacity: 0.2, radius: baseSize + 6 });
|
|
setTimeout(() => marker._glowMarker.setStyle({ fillColor: baseColor, fillOpacity: 0.08, radius: baseSize + 3 }), 500);
|
|
}
|
|
|
|
setTimeout(() => marker.setStyle({ fillColor: color, fillOpacity: 0.95, radius: baseSize + 1, weight: 1.5 }), 150);
|
|
setTimeout(() => marker.setStyle({ fillColor: baseColor, fillOpacity: 0.85, radius: baseSize, color: '#fff', weight: marker._baseSize > 6 ? 1.5 : 0.5 }), 700);
|
|
|
|
nodeActivity[key] = (nodeActivity[key] || 0) + 1;
|
|
}
|
|
|
|
function drawAnimatedLine(from, to, color, onComplete, overrideOpacity) {
|
|
const steps = 20;
|
|
const latStep = (to[0] - from[0]) / steps;
|
|
const lonStep = (to[1] - from[1]) / steps;
|
|
let step = 0;
|
|
let currentCoords = [from];
|
|
const mainOpacity = overrideOpacity ?? 0.8;
|
|
const isDashed = overrideOpacity != null;
|
|
|
|
const contrail = L.polyline([from], {
|
|
color: color, weight: 6, opacity: mainOpacity * 0.2, lineCap: 'round'
|
|
}).addTo(pathsLayer);
|
|
|
|
const line = L.polyline([from], {
|
|
color: color, weight: isDashed ? 1.5 : 2, opacity: mainOpacity, lineCap: 'round',
|
|
dashArray: isDashed ? '4 6' : null
|
|
}).addTo(pathsLayer);
|
|
|
|
const dot = L.circleMarker(from, {
|
|
radius: 3.5, fillColor: '#fff', fillOpacity: 1, color: color, weight: 1.5
|
|
}).addTo(animLayer);
|
|
|
|
const interval = setInterval(() => {
|
|
step++;
|
|
const lat = from[0] + latStep * step;
|
|
const lon = from[1] + lonStep * step;
|
|
currentCoords.push([lat, lon]);
|
|
line.setLatLngs(currentCoords);
|
|
contrail.setLatLngs(currentCoords);
|
|
dot.setLatLng([lat, lon]);
|
|
|
|
if (step >= steps) {
|
|
clearInterval(interval);
|
|
animLayer.removeLayer(dot);
|
|
|
|
recentPaths.push({ line, glowLine: contrail, time: Date.now() });
|
|
while (recentPaths.length > 5) {
|
|
const old = recentPaths.shift();
|
|
pathsLayer.removeLayer(old.line);
|
|
pathsLayer.removeLayer(old.glowLine);
|
|
}
|
|
|
|
setTimeout(() => {
|
|
let fadeOp = mainOpacity;
|
|
const fi = setInterval(() => {
|
|
fadeOp -= 0.1;
|
|
if (fadeOp <= 0) {
|
|
clearInterval(fi);
|
|
pathsLayer.removeLayer(line);
|
|
pathsLayer.removeLayer(contrail);
|
|
recentPaths = recentPaths.filter(p => p.line !== line);
|
|
} else {
|
|
line.setStyle({ opacity: fadeOp });
|
|
contrail.setStyle({ opacity: fadeOp * 0.15 });
|
|
}
|
|
}, 52);
|
|
}, 800);
|
|
|
|
if (onComplete) onComplete();
|
|
}
|
|
}, 33);
|
|
}
|
|
|
|
function showHeatMap() {
|
|
if (heatLayer) { map.removeLayer(heatLayer); heatLayer = null; }
|
|
const points = [];
|
|
Object.values(nodeData).forEach(n => {
|
|
points.push([n.lat, n.lon, nodeActivity[n.public_key] || 1]);
|
|
});
|
|
for (const [key, count] of Object.entries(nodeActivity)) {
|
|
const marker = nodeMarkers[key];
|
|
if (marker && !nodeData[key]) {
|
|
const ll = marker.getLatLng();
|
|
points.push([ll.lat, ll.lng, count]);
|
|
}
|
|
}
|
|
if (points.length && typeof L.heatLayer === 'function') {
|
|
heatLayer = L.heatLayer(points, {
|
|
radius: 25, blur: 15, maxZoom: 14, minOpacity: 0.3,
|
|
gradient: { 0.2: '#0d47a1', 0.4: '#1565c0', 0.6: '#42a5f5', 0.8: '#ffca28', 1.0: '#ff5722' }
|
|
}).addTo(map);
|
|
}
|
|
}
|
|
|
|
function hideHeatMap() {
|
|
if (heatLayer) { map.removeLayer(heatLayer); heatLayer = null; }
|
|
}
|
|
|
|
function addFeedItem(icon, typeName, payload, hops, color, pkt) {
|
|
const feed = document.getElementById('liveFeed');
|
|
if (!feed) return;
|
|
|
|
const text = payload.text || payload.name || '';
|
|
const preview = text ? ' ' + (text.length > 35 ? text.slice(0, 35) + '…' : text) : '';
|
|
const hopStr = hops.length ? `<span class="feed-hops">${hops.length}⇢</span>` : '';
|
|
|
|
const item = document.createElement('div');
|
|
item.className = 'live-feed-item live-feed-enter';
|
|
item.style.cursor = 'pointer';
|
|
item.innerHTML = `
|
|
<span class="feed-icon" style="color:${color}">${icon}</span>
|
|
<span class="feed-type" style="color:${color}">${typeName}</span>
|
|
${hopStr}
|
|
<span class="feed-text">${escapeHtml(preview)}</span>
|
|
<span class="feed-time">${new Date(pkt._ts || Date.now()).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})}</span>
|
|
`;
|
|
item.addEventListener('click', () => showFeedCard(item, pkt, color));
|
|
feed.prepend(item);
|
|
requestAnimationFrame(() => { requestAnimationFrame(() => item.classList.remove('live-feed-enter')); });
|
|
while (feed.children.length > 25) feed.removeChild(feed.lastChild);
|
|
}
|
|
|
|
function showFeedCard(anchor, pkt, color) {
|
|
document.querySelector('.feed-detail-card')?.remove();
|
|
const decoded = pkt.decoded || {};
|
|
const header = decoded.header || {};
|
|
const payload = decoded.payload || {};
|
|
const hops = decoded.path?.hops || [];
|
|
const typeName = header.payloadTypeName || 'UNKNOWN';
|
|
const text = payload.text || '';
|
|
const sender = payload.name || payload.sender || payload.senderName || '';
|
|
const channel = payload.channelName || (payload.channelHash != null ? 'Ch ' + payload.channelHash : '');
|
|
const snr = pkt.SNR ?? pkt.snr ?? null;
|
|
const rssi = pkt.RSSI ?? pkt.rssi ?? null;
|
|
const observer = pkt.observer_name || pkt.observer || '';
|
|
const pktId = pkt.id || '';
|
|
|
|
const card = document.createElement('div');
|
|
card.className = 'feed-detail-card';
|
|
card.innerHTML = `
|
|
<div class="fdc-header" style="border-left:3px solid ${color}">
|
|
<strong>${typeName}</strong>
|
|
${sender ? `<span class="fdc-sender">${escapeHtml(sender)}</span>` : ''}
|
|
<button class="fdc-close">✕</button>
|
|
</div>
|
|
${text ? `<div class="fdc-text">${escapeHtml(text.length > 120 ? text.slice(0, 120) + '…' : text)}</div>` : ''}
|
|
<div class="fdc-meta">
|
|
${channel ? `<span>📻 ${escapeHtml(channel)}</span>` : ''}
|
|
${hops.length ? `<span>🔀 ${hops.length} hops</span>` : ''}
|
|
${snr != null ? `<span>📶 ${Number(snr).toFixed(1)} dB</span>` : ''}
|
|
${rssi != null ? `<span>📡 ${rssi} dBm</span>` : ''}
|
|
${observer ? `<span>👁 ${escapeHtml(observer)}</span>` : ''}
|
|
</div>
|
|
${pktId ? `<a class="fdc-link" href="#/packets/id/${pktId}">View in packets →</a>` : ''}
|
|
<button class="fdc-replay">↻ Replay</button>
|
|
`;
|
|
card.querySelector('.fdc-close').addEventListener('click', (e) => { e.stopPropagation(); card.remove(); });
|
|
card.querySelector('.fdc-replay').addEventListener('click', (e) => { e.stopPropagation(); animatePacket(pkt); });
|
|
document.addEventListener('click', function dismiss(e) {
|
|
if (!card.contains(e.target) && !anchor.contains(e.target)) { card.remove(); document.removeEventListener('click', dismiss); }
|
|
});
|
|
const feedEl = document.getElementById('liveFeed');
|
|
if (feedEl) feedEl.parentElement.appendChild(card);
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
}
|
|
|
|
function destroy() {
|
|
stopReplay();
|
|
if (ws) { ws.onclose = null; ws.close(); ws = null; }
|
|
if (map) { map.remove(); map = null; }
|
|
if (_onResize) { window.removeEventListener('resize', _onResize); window.removeEventListener('orientationchange', _onResize); }
|
|
const topNav = document.querySelector('.top-nav');
|
|
if (topNav) { topNav.classList.remove('nav-autohide'); topNav.style.position = ''; topNav.style.width = ''; topNav.style.zIndex = ''; }
|
|
if (_navCleanup) {
|
|
clearTimeout(_navCleanup.timeout);
|
|
const livePage = document.querySelector('.live-page');
|
|
if (livePage && _navCleanup.fn) {
|
|
livePage.removeEventListener('mousemove', _navCleanup.fn);
|
|
livePage.removeEventListener('touchstart', _navCleanup.fn);
|
|
livePage.removeEventListener('click', _navCleanup.fn);
|
|
}
|
|
_navCleanup = null;
|
|
}
|
|
nodesLayer = pathsLayer = animLayer = heatLayer = null;
|
|
nodeMarkers = {}; nodeData = {};
|
|
recentPaths = [];
|
|
packetCount = 0; activeAnims = 0;
|
|
nodeActivity = {}; pktTimestamps = [];
|
|
VCR.buffer = []; VCR.playhead = -1; VCR.mode = 'LIVE'; VCR.missedCount = 0; VCR.speed = 1;
|
|
}
|
|
|
|
registerPage('live', { init, destroy });
|
|
})();
|