mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-04-26 12:27:19 +00:00
1489 lines
59 KiB
JavaScript
1489 lines
59 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;
|
|
let _timelineRefreshInterval = null;
|
|
let _lcdClockInterval = null;
|
|
let _rateCounterInterval = 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(() => {
|
|
// Set live-page height from JS — most reliable across all mobile browsers
|
|
const page = document.querySelector('.live-page');
|
|
const appEl = document.getElementById('app');
|
|
const h = window.innerHeight;
|
|
if (page) page.style.height = h + 'px';
|
|
if (appEl) appEl.style.height = h + 'px';
|
|
if (map) {
|
|
map.invalidateSize({ animate: false, pan: false });
|
|
}
|
|
}, 50);
|
|
};
|
|
// Run immediately to set correct initial height
|
|
_onResize();
|
|
window.addEventListener('resize', _onResize);
|
|
window.addEventListener('orientationchange', () => {
|
|
// Orientation change is async — viewport dimensions settle late
|
|
[50, 200, 500, 1000, 2000].forEach(ms => setTimeout(_onResize, ms));
|
|
});
|
|
if (window.visualViewport) {
|
|
window.visualViewport.addEventListener('resize', _onResize);
|
|
}
|
|
}
|
|
|
|
// === VCR Controls ===
|
|
|
|
function vcrSetMode(mode) {
|
|
VCR.mode = mode;
|
|
if (mode !== 'LIVE' && !VCR.frozenNow) VCR.frozenNow = Date.now();
|
|
if (mode === 'LIVE') VCR.frozenNow = null;
|
|
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;
|
|
VCR.scrubEnd = null;
|
|
VCR.dragPct = null;
|
|
VCR.scrubTs = null;
|
|
vcrSetMode('LIVE');
|
|
// Reload all nodes (no time filter)
|
|
clearNodeMarkers();
|
|
loadNodes();
|
|
const prompt = document.getElementById('vcrPrompt');
|
|
if (prompt) prompt.classList.add('hidden');
|
|
}
|
|
|
|
function vcrUnpause() {
|
|
if (VCR.mode !== 'PAUSED') return;
|
|
if (VCR.scrubTs != null) {
|
|
vcrReplayFromTs(VCR.scrubTs);
|
|
} else {
|
|
vcrResumeLive();
|
|
}
|
|
}
|
|
|
|
function vcrReplayFromTs(targetTs) {
|
|
const fetchFrom = new Date(targetTs).toISOString();
|
|
stopReplay();
|
|
vcrSetMode('REPLAY');
|
|
|
|
// Reload map nodes to match the replay time
|
|
clearNodeMarkers();
|
|
loadNodes(targetTs);
|
|
|
|
// Fetch packets from scrub point forward (ASC order, no limit clipping from the wrong end)
|
|
fetch(`/api/packets?limit=10000&grouped=false&since=${encodeURIComponent(fetchFrom)}&order=asc`)
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
const pkts = data.packets || []; // already ASC from server
|
|
const replayEntries = pkts.map(p => ({
|
|
ts: new Date(p.timestamp || p.created_at).getTime(),
|
|
pkt: dbPacketToLive(p)
|
|
}));
|
|
if (replayEntries.length === 0) {
|
|
vcrSetMode('PAUSED');
|
|
return;
|
|
}
|
|
VCR.buffer = replayEntries;
|
|
VCR.playhead = 0;
|
|
VCR.scrubEnd = null;
|
|
VCR.scrubTs = null;
|
|
VCR.dragPct = null;
|
|
startReplay();
|
|
})
|
|
.catch(() => { vcrResumeLive(); });
|
|
}
|
|
|
|
function showVCRPrompt(count) {
|
|
const prompt = document.getElementById('vcrPrompt');
|
|
if (!prompt) return;
|
|
prompt.setAttribute('role', 'alertdialog');
|
|
prompt.setAttribute('aria-label', 'Missed packets prompt');
|
|
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();
|
|
});
|
|
// Focus first button for keyboard users (#59)
|
|
document.getElementById('vcrPromptReplay').focus();
|
|
}
|
|
|
|
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=2000&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) {
|
|
// Try to fetch the next page before going live
|
|
fetchNextReplayPage().then(hasMore => {
|
|
if (hasMore) tick();
|
|
else vcrResumeLive();
|
|
});
|
|
return;
|
|
}
|
|
const entry = VCR.buffer[VCR.playhead];
|
|
animatePacket(entry.pkt);
|
|
updateVCRClock(entry.ts);
|
|
updateVCRLcd();
|
|
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 fetchNextReplayPage() {
|
|
// Get timestamp of last packet in buffer to fetch the next page
|
|
const last = VCR.buffer[VCR.buffer.length - 1];
|
|
if (!last) return Promise.resolve(false);
|
|
const since = new Date(last.ts + 1).toISOString(); // +1ms to avoid dupe
|
|
return fetch(`/api/packets?limit=10000&grouped=false&since=${encodeURIComponent(since)}&order=asc`)
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
const pkts = data.packets || [];
|
|
if (pkts.length === 0) return false;
|
|
const newEntries = pkts.map(p => ({
|
|
ts: new Date(p.timestamp || p.created_at).getTime(),
|
|
pkt: dbPacketToLive(p)
|
|
}));
|
|
// Append to buffer, playhead stays where it is (at the end, about to read new entries)
|
|
VCR.buffer = VCR.buffer.concat(newEntries);
|
|
return true;
|
|
})
|
|
.catch(() => false);
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
// 7-segment LCD renderer
|
|
const SEG_MAP = {
|
|
'0':0x7E,'1':0x30,'2':0x6D,'3':0x79,'4':0x33,'5':0x5B,'6':0x5F,'7':0x70,
|
|
'8':0x7F,'9':0x7B,'-':0x01,':':0x80,' ':0x00,'P':0x67,'A':0x77,'U':0x3E,
|
|
'S':0x5B,'E':0x4F,'L':0x0E,'I':0x30,'V':0x3E,'+':0x01
|
|
};
|
|
function drawSegDigit(ctx, x, y, w, h, bits, color) {
|
|
const t = Math.max(2, h * 0.12); // segment thickness
|
|
const g = 1; // gap
|
|
const hw = w - 2*g, hh = (h - 3*g) / 2;
|
|
ctx.fillStyle = color;
|
|
// a=top, b=top-right, c=bot-right, d=bot, e=bot-left, f=top-left, g=mid
|
|
if (bits & 0x40) ctx.fillRect(x+g+t/2, y, hw-t, t); // a
|
|
if (bits & 0x20) ctx.fillRect(x+w-t, y+g+t/2, t, hh-t/2); // b
|
|
if (bits & 0x10) ctx.fillRect(x+w-t, y+hh+2*g+t/2, t, hh-t/2);// c
|
|
if (bits & 0x08) ctx.fillRect(x+g+t/2, y+h-t, hw-t, t); // d
|
|
if (bits & 0x04) ctx.fillRect(x, y+hh+2*g+t/2, t, hh-t/2); // e
|
|
if (bits & 0x02) ctx.fillRect(x, y+g+t/2, t, hh-t/2); // f
|
|
if (bits & 0x01) ctx.fillRect(x+g+t/2, y+hh+g-t/2, hw-t, t); // g
|
|
// colon
|
|
if (bits & 0x80) {
|
|
const r = t * 0.6;
|
|
ctx.beginPath(); ctx.arc(x+w/2, y+h*0.33, r, 0, Math.PI*2); ctx.fill();
|
|
ctx.beginPath(); ctx.arc(x+w/2, y+h*0.67, r, 0, Math.PI*2); ctx.fill();
|
|
}
|
|
}
|
|
function drawLcdText(text, color) {
|
|
const canvas = document.getElementById('vcrLcdCanvas');
|
|
if (!canvas) return;
|
|
canvas.setAttribute('aria-label', 'VCR time: ' + text);
|
|
const ctx = canvas.getContext('2d');
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const cw = canvas.offsetWidth, ch = canvas.offsetHeight;
|
|
canvas.width = cw * dpr; canvas.height = ch * dpr;
|
|
ctx.scale(dpr, dpr);
|
|
ctx.clearRect(0, 0, cw, ch);
|
|
|
|
const digitW = Math.min(16, (cw - 10) / text.length);
|
|
const digitH = ch - 4;
|
|
const totalW = digitW * text.length;
|
|
let x = (cw - totalW) / 2;
|
|
const y = 2;
|
|
|
|
// Draw ghost segments (dim background) — hardcoded to match LCD green
|
|
const ghostColor = 'rgba(74,222,128,0.07)';
|
|
for (let i = 0; i < text.length; i++) {
|
|
const ch2 = text[i];
|
|
if (ch2 === ':') {
|
|
drawSegDigit(ctx, x, y, digitW * 0.5, digitH, 0x80, ghostColor);
|
|
x += digitW * 0.5;
|
|
} else {
|
|
drawSegDigit(ctx, x, y, digitW, digitH, 0x7F, ghostColor);
|
|
x += digitW + 1;
|
|
}
|
|
}
|
|
// Draw active segments
|
|
x = (cw - totalW) / 2;
|
|
for (let i = 0; i < text.length; i++) {
|
|
const ch2 = text[i];
|
|
const bits = SEG_MAP[ch2] || 0;
|
|
if (ch2 === ':') {
|
|
drawSegDigit(ctx, x, y, digitW * 0.5, digitH, bits, color);
|
|
x += digitW * 0.5;
|
|
} else {
|
|
drawSegDigit(ctx, x, y, digitW, digitH, bits, color);
|
|
x += digitW + 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
function updateVCRClock(tsMs) {
|
|
const d = new Date(tsMs);
|
|
const hh = String(d.getHours()).padStart(2, '0');
|
|
const mm = String(d.getMinutes()).padStart(2, '0');
|
|
const ss = String(d.getSeconds()).padStart(2, '0');
|
|
drawLcdText(`${hh}:${mm}:${ss}`, '#4ade80');
|
|
}
|
|
|
|
function updateVCRLcd() {
|
|
const modeEl = document.getElementById('vcrLcdMode');
|
|
const pktsEl = document.getElementById('vcrLcdPkts');
|
|
if (modeEl) {
|
|
if (VCR.mode === 'LIVE') modeEl.textContent = 'LIVE';
|
|
else if (VCR.mode === 'PAUSED') modeEl.textContent = 'PAUSE';
|
|
else if (VCR.mode === 'REPLAY') modeEl.textContent = `PLAY ${VCR.speed}x`;
|
|
}
|
|
if (pktsEl) {
|
|
if (VCR.mode === 'PAUSED' && VCR.missedCount > 0) {
|
|
pktsEl.textContent = `+${VCR.missedCount} PKTS`;
|
|
} else {
|
|
pktsEl.textContent = '';
|
|
}
|
|
}
|
|
}
|
|
|
|
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 = '⏸'; pauseBtn.setAttribute('aria-label', 'Pause'); }
|
|
if (missedEl) missedEl.classList.add('hidden');
|
|
updateVCRClock(Date.now());
|
|
} else if (VCR.mode === 'PAUSED') {
|
|
modeEl.textContent = '⏸ PAUSED';
|
|
modeEl.className = 'vcr-mode vcr-mode-paused';
|
|
if (pauseBtn) { pauseBtn.textContent = '▶'; pauseBtn.setAttribute('aria-label', 'Play'); }
|
|
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 = '⏸'; pauseBtn.setAttribute('aria-label', 'Pause'); }
|
|
if (missedEl) missedEl.classList.add('hidden');
|
|
}
|
|
if (speedBtn) { speedBtn.textContent = VCR.speed + 'x'; speedBtn.setAttribute('aria-label', 'Speed ' + VCR.speed + 'x'); }
|
|
updateVCRLcd();
|
|
}
|
|
|
|
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 — adjust playhead to avoid stale indices (#63)
|
|
if (VCR.buffer.length > 2000) {
|
|
const trimCount = 500;
|
|
VCR.buffer.splice(0, trimCount);
|
|
if (VCR.playhead >= 0) {
|
|
VCR.playhead = Math.max(0, VCR.playhead - trimCount);
|
|
}
|
|
}
|
|
|
|
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 = VCR.frozenNow || 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() {
|
|
if (VCR.dragging) return;
|
|
const playheadEl = document.getElementById('vcrPlayhead');
|
|
if (!playheadEl) return;
|
|
const canvas = document.getElementById('vcrTimeline');
|
|
if (!canvas) return;
|
|
const cw = canvas.offsetWidth;
|
|
const now = VCR.frozenNow || Date.now();
|
|
const scopeMs = VCR.timelineScope;
|
|
const startTs = now - scopeMs;
|
|
|
|
let x;
|
|
if (VCR.mode === 'LIVE') {
|
|
x = cw;
|
|
} else if (VCR.scrubTs != null) {
|
|
// Scrubbed to a specific time — hold there
|
|
x = ((VCR.scrubTs - startTs) / scopeMs) * cw;
|
|
} else if (VCR.playhead >= 0 && VCR.playhead < VCR.buffer.length) {
|
|
const playTs = VCR.buffer[VCR.playhead].ts;
|
|
x = ((playTs - startTs) / scopeMs) * cw;
|
|
} else {
|
|
x = cw;
|
|
}
|
|
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 aria-describedby="heatDesc"> Heat</label>
|
|
<span id="heatDesc" class="sr-only">Overlay a density heat map on the mesh nodes</span>
|
|
<label><input type="checkbox" id="liveGhostToggle" checked aria-describedby="ghostDesc"> Ghosts</label>
|
|
<span id="ghostDesc" class="sr-only">Show interpolated ghost markers for unknown hops</span>
|
|
</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>
|
|
<button class="legend-toggle-btn hidden" id="legendToggleBtn" aria-label="Show legend" title="Show legend">🎨</button>
|
|
<div class="live-overlay live-legend" id="liveLegend" role="region" aria-label="Map legend">
|
|
<h3 class="legend-title">PACKET TYPES</h3>
|
|
<ul class="legend-list">
|
|
<li><span class="live-dot" style="background:#22c55e" aria-hidden="true"></span> Advert — Node advertisement</li>
|
|
<li><span class="live-dot" style="background:#3b82f6" aria-hidden="true"></span> Message — Group text</li>
|
|
<li><span class="live-dot" style="background:#f59e0b" aria-hidden="true"></span> Direct — Direct message</li>
|
|
<li><span class="live-dot" style="background:#a855f7" aria-hidden="true"></span> Request — Data request</li>
|
|
<li><span class="live-dot" style="background:#ec4899" aria-hidden="true"></span> Trace — Route trace</li>
|
|
</ul>
|
|
<h3 class="legend-title" style="margin-top:8px">NODE ROLES</h3>
|
|
<ul class="legend-list">
|
|
<li><span class="live-dot" style="background:#3b82f6" aria-hidden="true"></span> Repeater</li>
|
|
<li><span class="live-dot" style="background:#06b6d4" aria-hidden="true"></span> Companion</li>
|
|
<li><span class="live-dot" style="background:#a855f7" aria-hidden="true"></span> Room</li>
|
|
<li><span class="live-dot" style="background:#f59e0b" aria-hidden="true"></span> Sensor</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- VCR Bar -->
|
|
<div class="vcr-bar" id="vcrBar">
|
|
<div class="vcr-controls">
|
|
<button id="vcrRewindBtn" class="vcr-btn" title="Rewind" aria-label="Rewind">⏪</button>
|
|
<button id="vcrPauseBtn" class="vcr-btn" title="Pause/Play" aria-label="Pause">⏸</button>
|
|
<button id="vcrLiveBtn" class="vcr-btn vcr-live-btn" title="Jump to live" aria-label="Snap to Live">LIVE</button>
|
|
<button id="vcrSpeedBtn" class="vcr-btn" title="Playback speed" aria-label="Speed 1x">1x</button>
|
|
<div id="vcrMode" class="vcr-mode vcr-mode-live"><span class="vcr-live-dot"></span> LIVE</div>
|
|
</div>
|
|
<div class="vcr-scope-btns" role="radiogroup" aria-label="Timeline scope">
|
|
<button class="vcr-scope-btn active" data-scope="3600000" role="radio" aria-checked="true" aria-label="Scope 1 hour">1h</button>
|
|
<button class="vcr-scope-btn" data-scope="21600000" role="radio" aria-checked="false" aria-label="Scope 6 hours">6h</button>
|
|
<button class="vcr-scope-btn" data-scope="43200000" role="radio" aria-checked="false" aria-label="Scope 12 hours">12h</button>
|
|
<button class="vcr-scope-btn" data-scope="86400000" role="radio" aria-checked="false" aria-label="Scope 24 hours">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 class="vcr-lcd">
|
|
<div class="vcr-lcd-row vcr-lcd-mode" id="vcrLcdMode">LIVE</div>
|
|
<canvas id="vcrLcdCanvas" class="vcr-lcd-canvas" width="200" height="32" role="img" aria-label="VCR time display"></canvas>
|
|
<div class="vcr-lcd-row vcr-lcd-pkts" id="vcrLcdPkts"></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);
|
|
|
|
const isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
|
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
|
const DARK_TILES = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
|
|
const LIGHT_TILES = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
|
|
let tileLayer = L.tileLayer(isDark ? DARK_TILES : LIGHT_TILES, { maxZoom: 19 }).addTo(map);
|
|
|
|
// Swap tiles when theme changes
|
|
const _themeObs = new MutationObserver(function () {
|
|
const dark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
|
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
|
tileLayer.setUrl(dark ? DARK_TILES : LIGHT_TILES);
|
|
});
|
|
_themeObs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
|
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);
|
|
vcrPause(); // suppress live packets
|
|
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');
|
|
// Keyboard support for feed items (event delegation)
|
|
feedEl.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
const item = e.target.closest('.live-feed-item');
|
|
if (item) { e.preventDefault(); item.click(); }
|
|
}
|
|
});
|
|
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');
|
|
});
|
|
|
|
// Legend toggle for mobile (#60)
|
|
const legendEl = document.getElementById('liveLegend');
|
|
const legendToggleBtn = document.getElementById('legendToggleBtn');
|
|
if (legendToggleBtn && legendEl) {
|
|
legendToggleBtn.addEventListener('click', () => {
|
|
const isVisible = legendEl.classList.toggle('legend-mobile-visible');
|
|
legendToggleBtn.setAttribute('aria-label', isVisible ? 'Hide legend' : 'Show legend');
|
|
legendToggleBtn.textContent = isVisible ? '✕' : '🎨';
|
|
});
|
|
}
|
|
|
|
// Feed panel resize handle (#27)
|
|
const savedFeedWidth = localStorage.getItem('live-feed-width');
|
|
if (savedFeedWidth) feedEl.style.width = savedFeedWidth + 'px';
|
|
const resizeHandle = document.createElement('div');
|
|
resizeHandle.className = 'feed-resize-handle';
|
|
resizeHandle.setAttribute('aria-label', 'Resize feed panel');
|
|
feedEl.appendChild(resizeHandle);
|
|
let feedResizing = false;
|
|
resizeHandle.addEventListener('mousedown', (e) => {
|
|
feedResizing = true; e.preventDefault();
|
|
});
|
|
document.addEventListener('mousemove', (e) => {
|
|
if (!feedResizing) return;
|
|
const newWidth = Math.max(200, Math.min(800, e.clientX - feedEl.getBoundingClientRect().left));
|
|
feedEl.style.width = newWidth + 'px';
|
|
});
|
|
document.addEventListener('mouseup', () => {
|
|
if (!feedResizing) return;
|
|
feedResizing = false;
|
|
localStorage.setItem('live-feed-width', parseInt(feedEl.style.width));
|
|
});
|
|
|
|
// 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'); b.setAttribute('aria-checked', 'false'); });
|
|
btn.classList.add('active');
|
|
btn.setAttribute('aria-checked', 'true');
|
|
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'); });
|
|
|
|
// Touch tooltip for timeline (#19)
|
|
timelineEl.addEventListener('touchmove', (e) => {
|
|
if (!VCR.dragging) return;
|
|
const touch = e.touches[0];
|
|
const rect = timelineEl.getBoundingClientRect();
|
|
const pct = Math.max(0, Math.min(1, (touch.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 = (touch.clientX - rect.left) + 'px';
|
|
timeTooltip.classList.remove('hidden');
|
|
});
|
|
timelineEl.addEventListener('touchend', () => { timeTooltip.classList.add('hidden'); });
|
|
|
|
// Drag scrubbing on timeline
|
|
VCR.dragging = false;
|
|
VCR.dragPct = 0;
|
|
|
|
function scrubVisual(clientX) {
|
|
const rect = timelineEl.getBoundingClientRect();
|
|
VCR.dragPct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
|
const playheadEl = document.getElementById('vcrPlayhead');
|
|
if (playheadEl) playheadEl.style.left = (VCR.dragPct * rect.width) + 'px';
|
|
const now = VCR.frozenNow || Date.now();
|
|
const targetTs = now - VCR.timelineScope + VCR.dragPct * VCR.timelineScope;
|
|
updateVCRClock(targetTs);
|
|
}
|
|
|
|
function scrubRelease() {
|
|
VCR.dragging = false;
|
|
VCR.frozenNow = Date.now();
|
|
const targetTs = VCR.frozenNow - VCR.timelineScope + VCR.dragPct * VCR.timelineScope;
|
|
VCR.scrubTs = targetTs;
|
|
updateVCRClock(targetTs);
|
|
vcrReplayFromTs(targetTs);
|
|
}
|
|
|
|
timelineEl.addEventListener('mousedown', (e) => {
|
|
VCR.dragging = true;
|
|
VCR.scrubTs = null;
|
|
stopReplay();
|
|
if (!VCR.frozenNow) VCR.frozenNow = Date.now();
|
|
scrubVisual(e.clientX);
|
|
e.preventDefault();
|
|
});
|
|
document.addEventListener('mousemove', (e) => {
|
|
if (!VCR.dragging) return;
|
|
scrubVisual(e.clientX);
|
|
});
|
|
document.addEventListener('mouseup', () => {
|
|
if (!VCR.dragging) return;
|
|
scrubRelease();
|
|
});
|
|
timelineEl.addEventListener('touchstart', (e) => {
|
|
VCR.dragging = true;
|
|
VCR.scrubTs = null;
|
|
stopReplay();
|
|
if (!VCR.frozenNow) VCR.frozenNow = Date.now();
|
|
scrubVisual(e.touches[0].clientX);
|
|
e.preventDefault();
|
|
}, { passive: false });
|
|
timelineEl.addEventListener('touchmove', (e) => {
|
|
if (!VCR.dragging) return;
|
|
scrubVisual(e.touches[0].clientX);
|
|
});
|
|
timelineEl.addEventListener('touchend', () => {
|
|
if (!VCR.dragging) return;
|
|
scrubRelease();
|
|
});
|
|
|
|
// Fetch historical timestamps for timeline, then start refresh
|
|
fetchTimelineTimestamps().then(() => updateTimeline());
|
|
_timelineRefreshInterval = setInterval(() => {
|
|
VCR.timelineFetchedScope = 0; // force refetch
|
|
fetchTimelineTimestamps().then(() => updateTimeline());
|
|
}, 30000);
|
|
|
|
// Live clock tick — update LCD every second when in LIVE mode
|
|
_lcdClockInterval = setInterval(() => {
|
|
if (VCR.mode === 'LIVE') updateVCRClock(Date.now());
|
|
}, 1000);
|
|
|
|
// Auto-hide nav with pin toggle (#62)
|
|
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, pinned: false };
|
|
// Add pin button to nav
|
|
if (topNav) {
|
|
const pinBtn = document.createElement('button');
|
|
pinBtn.id = 'navPinBtn';
|
|
pinBtn.className = 'nav-pin-btn';
|
|
pinBtn.setAttribute('aria-label', 'Pin navigation open');
|
|
pinBtn.setAttribute('title', 'Pin navigation open');
|
|
pinBtn.textContent = '📌';
|
|
pinBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
_navCleanup.pinned = !_navCleanup.pinned;
|
|
pinBtn.classList.toggle('pinned', _navCleanup.pinned);
|
|
pinBtn.setAttribute('aria-pressed', _navCleanup.pinned);
|
|
if (_navCleanup.pinned) {
|
|
clearTimeout(_navCleanup.timeout);
|
|
topNav.classList.remove('nav-autohide');
|
|
} else {
|
|
_navCleanup.timeout = setTimeout(() => { topNav.classList.add('nav-autohide'); }, 4000);
|
|
}
|
|
});
|
|
topNav.appendChild(pinBtn);
|
|
}
|
|
function showNav() {
|
|
if (topNav) topNav.classList.remove('nav-autohide');
|
|
clearTimeout(_navCleanup.timeout);
|
|
if (!_navCleanup.pinned) {
|
|
_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() {
|
|
_rateCounterInterval = 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(beforeTs) {
|
|
try {
|
|
const url = beforeTs
|
|
? `/api/nodes?limit=2000&before=${encodeURIComponent(new Date(beforeTs).toISOString())}`
|
|
: '/api/nodes?limit=2000';
|
|
const resp = await fetch(url);
|
|
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 clearNodeMarkers() {
|
|
if (nodesLayer) nodesLayer.clearLayers();
|
|
if (animLayer) animLayer.clearLayers();
|
|
nodeMarkers = {};
|
|
nodeData = {};
|
|
nodeActivity = {};
|
|
if (heatLayer) { map.removeLayer(heatLayer); heatLayer = null; }
|
|
}
|
|
|
|
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);
|
|
|
|
// If ADVERT, ensure node appears on map
|
|
if (typeName === 'ADVERT' && payload.pubKey) {
|
|
const key = payload.pubKey;
|
|
if (!nodeMarkers[key] && payload.lat != null && payload.lon != null && !(payload.lat === 0 && payload.lon === 0)) {
|
|
const n = { public_key: key, name: payload.name || key.slice(0,8), role: payload.role || 'unknown', lat: payload.lat, lon: payload.lon };
|
|
nodeData[key] = n;
|
|
addNodeMarker(n);
|
|
document.getElementById('liveNodeCount').textContent = Object.keys(nodeMarkers).length;
|
|
}
|
|
}
|
|
|
|
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);
|
|
|
|
// First pass: find all candidates per hop
|
|
const raw = hops.map(hop => {
|
|
const hopLower = hop.toLowerCase();
|
|
const candidates = known.filter(n =>
|
|
n.public_key.toLowerCase().startsWith(hopLower) &&
|
|
n.lat != null && n.lon != null && !(n.lat === 0 && n.lon === 0)
|
|
);
|
|
if (candidates.length === 1) {
|
|
return { key: candidates[0].public_key, pos: [candidates[0].lat, candidates[0].lon], name: candidates[0].name || hop, known: true };
|
|
} else if (candidates.length > 1) {
|
|
return { key: 'ambig-' + hop, pos: null, name: hop, known: false, candidates };
|
|
}
|
|
return { key: 'hop-' + hop, pos: null, name: hop, known: false };
|
|
});
|
|
|
|
// Add sender position if available
|
|
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 });
|
|
}
|
|
}
|
|
|
|
// Sequential disambiguation: each hop nearest to previous (like server-side)
|
|
const dist = (lat1, lon1, lat2, lon2) => Math.sqrt((lat1 - lat2) ** 2 + (lon1 - lon2) ** 2);
|
|
|
|
// Forward pass: resolve ambiguous hops using previous hop's position
|
|
let lastPos = null;
|
|
for (const hop of raw) {
|
|
if (hop.known && hop.pos) { lastPos = hop.pos; continue; }
|
|
if (!hop.candidates) continue;
|
|
if (lastPos) {
|
|
hop.candidates.sort((a, b) => dist(a.lat, a.lon, lastPos[0], lastPos[1]) - dist(b.lat, b.lon, lastPos[0], lastPos[1]));
|
|
}
|
|
const best = hop.candidates[0];
|
|
hop.key = best.public_key; hop.pos = [best.lat, best.lon];
|
|
hop.name = best.name || best.public_key.slice(0, 8);
|
|
hop.known = true; lastPos = hop.pos;
|
|
}
|
|
|
|
// Backward pass: catch any remaining from the tail
|
|
let nextPos = null;
|
|
for (let i = raw.length - 1; i >= 0; i--) {
|
|
const hop = raw[i];
|
|
if (hop.known && hop.pos) { nextPos = hop.pos; continue; }
|
|
if (!hop.candidates || !nextPos) continue;
|
|
hop.candidates.sort((a, b) => dist(a.lat, a.lon, nextPos[0], nextPos[1]) - dist(b.lat, b.lon, nextPos[0], nextPos[1]));
|
|
const best = hop.candidates[0];
|
|
hop.key = best.public_key; hop.pos = [best.lat, best.lon];
|
|
hop.name = best.name || best.public_key.slice(0, 8);
|
|
hop.known = true; nextPos = hop.pos;
|
|
}
|
|
|
|
// Sanity check: drop hops that are impossibly far from both neighbors (>200km ≈ 1.8°)
|
|
// These are almost certainly 1-byte prefix collisions with distant nodes
|
|
const MAX_HOP_DIST = 1.8;
|
|
for (let i = 0; i < raw.length; i++) {
|
|
if (!raw[i].known || !raw[i].pos) continue;
|
|
const prev = i > 0 && raw[i-1].known && raw[i-1].pos ? raw[i-1].pos : null;
|
|
const next = i < raw.length-1 && raw[i+1].known && raw[i+1].pos ? raw[i+1].pos : null;
|
|
if (!prev && !next) continue; // lone hop, keep it
|
|
const dPrev = prev ? dist(raw[i].pos[0], raw[i].pos[1], prev[0], prev[1]) : 0;
|
|
const dNext = next ? dist(raw[i].pos[0], raw[i].pos[1], next[0], next[1]) : 0;
|
|
if ((prev && dPrev > MAX_HOP_DIST) && (next && dNext > MAX_HOP_DIST)) {
|
|
raw[i].known = false; raw[i].pos = null; // too far from both neighbors
|
|
} else if (prev && !next && dPrev > MAX_HOP_DIST) {
|
|
raw[i].known = false; raw[i].pos = null; // first/last with only one neighbor, too far
|
|
} else if (!prev && next && dNext > MAX_HOP_DIST) {
|
|
raw[i].known = false; raw[i].pos = null;
|
|
}
|
|
}
|
|
|
|
if (!showGhostHops) return raw.filter(h => h.known);
|
|
|
|
const knownPos2 = raw.filter(h => h.known);
|
|
if (knownPos2.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.setAttribute('tabindex', '0');
|
|
item.setAttribute('role', 'button');
|
|
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 destroy() {
|
|
stopReplay();
|
|
if (_timelineRefreshInterval) { clearInterval(_timelineRefreshInterval); _timelineRefreshInterval = null; }
|
|
if (_lcdClockInterval) { clearInterval(_lcdClockInterval); _lcdClockInterval = null; }
|
|
if (_rateCounterInterval) { clearInterval(_rateCounterInterval); _rateCounterInterval = null; }
|
|
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);
|
|
if (window.visualViewport) window.visualViewport.removeEventListener('resize', _onResize);
|
|
}
|
|
// Restore #app height to CSS default
|
|
const appEl = document.getElementById('app');
|
|
if (appEl) appEl.style.height = '';
|
|
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 });
|
|
})();
|