(function() { 'use strict'; // Status color helpers (read from CSS variables for theme support) function cssVar(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); } function statusGreen() { return cssVar('--status-green') || '#22c55e'; } let map, ws, nodesLayer, pathsLayer, animLayer, heatLayer; let nodeMarkers = {}; let nodeData = {}; let packetCount = 0; let activeAnims = 0; let nodeActivity = {}; let recentPaths = []; let showGhostHops = localStorage.getItem('live-ghost-hops') !== 'false'; let realisticPropagation = localStorage.getItem('live-realistic-propagation') === 'true'; let showOnlyFavorites = localStorage.getItem('live-favorites-only') === 'true'; let matrixMode = localStorage.getItem('live-matrix-mode') === 'true'; let matrixRain = localStorage.getItem('live-matrix-rain') === 'true'; let rainCanvas = null, rainCtx = null, rainDrops = [], rainRAF = null; const propagationBuffer = new Map(); // hash -> {timer, packets[]} 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 }; // ROLE_COLORS loaded from shared roles.js (includes 'unknown') const TYPE_COLORS = window.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 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 = ` You missed ${count} packets. `; 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}`, statusGreen()); } 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 = ' 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, raw: pkt.raw_hex, _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 let _tabHidden = false; document.addEventListener('visibilitychange', () => { if (document.hidden) { _tabHidden = true; } else { // Tab restored β€” skip animating anything that queued while away _tabHidden = false; // Clear any pending propagation buffers so they don't all fire at once for (const [hash, entry] of propagationBuffer) { clearTimeout(entry.timer); } propagationBuffer.clear(); } }); 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') { // Skip animations when tab is backgrounded β€” just buffer for VCR timeline if (_tabHidden) { updateTimeline(); return; } if (realisticPropagation && pkt.hash) { const hash = pkt.hash; if (propagationBuffer.has(hash)) { propagationBuffer.get(hash).packets.push(pkt); } else { const entry = { packets: [pkt], timer: setTimeout(() => { const buffered = propagationBuffer.get(hash); propagationBuffer.delete(hash); if (buffered) animateRealisticPropagation(buffered.packets); }, PROPAGATION_BUFFER_MS) }; propagationBuffer.set(hash, entry); } } else { 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 = `
MESH LIVE
0 pkts
0 nodes
0 active
0/min
Overlay a density heat map on the mesh nodes Show interpolated ghost markers for unknown hops Buffer packets by hash and animate all paths simultaneously Animate packet hex bytes flowing along paths like the Matrix Matrix rain overlay β€” packets fall as hex columns Sonify packets β€” turn raw bytes into generative music Show only favorited and claimed nodes

PACKET TYPES

NODE ROLES

LIVE
LIVE
`; // Fetch configurable map defaults (#115) let mapCenter = [37.45, -122.0]; let mapZoom = 9; try { const mapCfg = await (await fetch('/api/config/map')).json(); if (Array.isArray(mapCfg.center) && mapCfg.center.length === 2) mapCenter = mapCfg.center; if (typeof mapCfg.zoom === 'number') mapZoom = mapCfg.zoom; } catch {} map = L.map('liveMap', { zoomControl: false, attributionControl: false, zoomAnimation: true, markerZoomAnimation: true }).setView(mapCenter, mapZoom); const isDark = document.documentElement.getAttribute('data-theme') === 'dark' || (document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches); let tileLayer = L.tileLayer(isDark ? TILE_DARK : TILE_LIGHT, { 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 ? TILE_DARK : TILE_LIGHT); }); _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 packet replay from packets page (single or array of observations) const replayData = sessionStorage.getItem('replay-packet'); if (replayData) { sessionStorage.removeItem('replay-packet'); try { const parsed = JSON.parse(replayData); const packets = Array.isArray(parsed) ? parsed : [parsed]; vcrPause(); // suppress live packets if (packets.length > 1 && packets[0].hash) { // Multiple observations β€” use realistic propagation (animate all paths at once) setTimeout(() => { if (typeof animateRealisticPropagation === 'function') { animateRealisticPropagation(packets); } else { // Fallback: stagger animations packets.forEach((p, i) => setTimeout(() => animatePacket(p), i * 400)); } }, 1500); } else { setTimeout(() => animatePacket(packets[0]), 1500); } } catch {} } else { replayRecent(); } map.on('zoomend', rescaleMarkers); // Heat map toggle β€” persist in localStorage const liveHeatEl = document.getElementById('liveHeatToggle'); if (localStorage.getItem('meshcore-live-heatmap') === 'false') { liveHeatEl.checked = false; hideHeatMap(); } else if (localStorage.getItem('meshcore-live-heatmap') === 'true') { liveHeatEl.checked = true; } liveHeatEl.addEventListener('change', (e) => { localStorage.setItem('meshcore-live-heatmap', e.target.checked); 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); }); const realisticToggle = document.getElementById('liveRealisticToggle'); realisticToggle.checked = realisticPropagation; realisticToggle.addEventListener('change', (e) => { realisticPropagation = e.target.checked; localStorage.setItem('live-realistic-propagation', realisticPropagation); }); const favoritesToggle = document.getElementById('liveFavoritesToggle'); favoritesToggle.checked = showOnlyFavorites; favoritesToggle.addEventListener('change', (e) => { showOnlyFavorites = e.target.checked; localStorage.setItem('live-favorites-only', showOnlyFavorites); applyFavoritesFilter(); }); const matrixToggle = document.getElementById('liveMatrixToggle'); matrixToggle.checked = matrixMode; matrixToggle.addEventListener('change', (e) => { matrixMode = e.target.checked; localStorage.setItem('live-matrix-mode', matrixMode); applyMatrixTheme(matrixMode); if (matrixMode) { hideHeatMap(); const ht = document.getElementById('liveHeatToggle'); if (ht) { ht.checked = false; ht.disabled = true; } } else { const ht = document.getElementById('liveHeatToggle'); if (ht) { ht.disabled = false; } } }); applyMatrixTheme(matrixMode); if (matrixMode) { hideHeatMap(); const ht = document.getElementById('liveHeatToggle'); if (ht) { ht.checked = false; ht.disabled = true; } } else { // Ensure heat toggle is enabled if matrix mode is off (recover from stale state) const ht = document.getElementById('liveHeatToggle'); if (ht) { ht.disabled = false; } } const rainToggle = document.getElementById('liveMatrixRainToggle'); rainToggle.checked = matrixRain; rainToggle.addEventListener('change', (e) => { matrixRain = e.target.checked; localStorage.setItem('live-matrix-rain', matrixRain); if (matrixRain) startMatrixRain(); else stopMatrixRain(); }); if (matrixRain) startMatrixRain(); // Audio toggle const audioToggle = document.getElementById('liveAudioToggle'); const audioControls = document.getElementById('audioControls'); const bpmSlider = document.getElementById('audioBpmSlider'); const bpmVal = document.getElementById('audioBpmVal'); const volSlider = document.getElementById('audioVolSlider'); const volVal = document.getElementById('audioVolVal'); if (window.MeshAudio) { MeshAudio.restore(); audioToggle.checked = MeshAudio.isEnabled(); if (MeshAudio.isEnabled()) audioControls.classList.remove('hidden'); bpmSlider.value = MeshAudio.getBPM(); bpmVal.textContent = MeshAudio.getBPM(); volSlider.value = Math.round(MeshAudio.getVolume() * 100); volVal.textContent = Math.round(MeshAudio.getVolume() * 100); // Populate voice selector const voiceSelect = document.getElementById('audioVoiceSelect'); const voices = MeshAudio.getVoiceNames(); voices.forEach(v => { const opt = document.createElement('option'); opt.value = v; opt.textContent = v; voiceSelect.appendChild(opt); }); voiceSelect.value = MeshAudio.getVoiceName() || voices[0] || ''; voiceSelect.addEventListener('change', (e) => MeshAudio.setVoice(e.target.value)); } audioToggle.addEventListener('change', (e) => { if (window.MeshAudio) { MeshAudio.setEnabled(e.target.checked); audioControls.classList.toggle('hidden', !e.target.checked); } }); bpmSlider.addEventListener('input', (e) => { const v = parseInt(e.target.value, 10); bpmVal.textContent = v; if (window.MeshAudio) MeshAudio.setBPM(v); }); volSlider.addEventListener('input', (e) => { const v = parseInt(e.target.value, 10); volVal.textContent = v; if (window.MeshAudio) MeshAudio.setVolume(v / 100); }); // 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 ? 'βœ•' : '🎨'; }); } // Populate role legend from shared roles.js const roleLegendList = document.getElementById('roleLegendList'); if (roleLegendList) { for (const role of (window.ROLE_SORT || ['repeater', 'companion', 'room', 'sensor', 'observer'])) { const li = document.createElement('li'); li.innerHTML = ` ${(ROLE_LABELS[role] || role).replace(/s$/, '')}`; roleLegendList.appendChild(li); } } // Node detail panel const nodeDetailPanel = document.getElementById('liveNodeDetail'); const nodeDetailContent = document.getElementById('nodeDetailContent'); document.getElementById('nodeDetailClose').addEventListener('click', () => { nodeDetailPanel.classList.add('hidden'); }); // 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 (guard against duplicate) if (topNav && !document.getElementById('navPinBtn')) { 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 = ``; 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 showNodeDetail(pubkey) { const panel = document.getElementById('liveNodeDetail'); const content = document.getElementById('nodeDetailContent'); panel.classList.remove('hidden'); content.innerHTML = '
Loading…
'; try { const [data, healthData] = await Promise.all([ api('/nodes/' + encodeURIComponent(pubkey), { ttl: 30 }), api('/nodes/' + encodeURIComponent(pubkey) + '/health', { ttl: 30 }).catch(() => null) ]); const n = data.node; const h = healthData || {}; const stats = h.stats || {}; const observers = h.observers || []; const recent = h.recentPackets || []; const roleColor = ROLE_COLORS[n.role] || '#6b7280'; const roleLabel = (ROLE_LABELS[n.role] || n.role || 'unknown').replace(/s$/, ''); const hasLoc = n.lat != null && n.lon != null; const lastSeen = n.last_seen ? timeAgo(n.last_seen) : 'β€”'; const thresholds = window.getHealthThresholds ? getHealthThresholds(n.role) : { degradedMs: 3600000, silentMs: 86400000 }; const ageMs = n.last_seen ? Date.now() - new Date(n.last_seen).getTime() : Infinity; const statusDot = ageMs < thresholds.degradedMs ? 'health-green' : ageMs < thresholds.silentMs ? 'health-yellow' : 'health-red'; const statusLabel = ageMs < thresholds.degradedMs ? 'Online' : ageMs < thresholds.silentMs ? 'Degraded' : 'Offline'; let html = `
●

${escapeHtml(n.name || 'Unknown')}

${roleLabel.toUpperCase()} ${statusLabel}
${escapeHtml(n.public_key)}
${hasLoc ? `` : ''} ${stats.avgSnr != null ? `` : ''} ${stats.avgHops != null ? `` : ''} ${stats.totalTransmissions || stats.totalPackets ? `` : ''}
Last Seen${lastSeen}
Adverts${n.advert_count || 0}
Location${n.lat.toFixed(5)}, ${n.lon.toFixed(5)}
Avg SNR${stats.avgSnr.toFixed(1)} dB
Avg Hops${stats.avgHops.toFixed(1)}
Total Packets${stats.totalTransmissions || stats.totalPackets}
`; if (observers.length) { const regions = [...new Set(observers.map(o => o.iata).filter(Boolean))]; html += `

Heard By${regions.length ? ' β€” Regions: ' + regions.join(', ') : ''}

` + observers.map(o => `
${escapeHtml(o.observer_name || o.observer_id.slice(0, 12))}${o.iata ? ' (' + escapeHtml(o.iata) + ')' : ''} β€” ${o.packetCount || o.count || 0} pkts
`).join('') + '
'; } if (recent.length) { html += `

Recent Packets

` + recent.slice(0, 10).map(p => `
${escapeHtml(p.payload_type || '?')}${p.observation_count > 1 ? ' πŸ‘ ' + p.observation_count + '' : ''} ${p.timestamp ? timeAgo(p.timestamp) : 'β€”'}
`).join('') + '
'; } html += `
Loading paths…
`; html += `
Full Detail β†’ πŸ“Š Analytics
`; content.innerHTML = html; // Fetch paths asynchronously api('/nodes/' + encodeURIComponent(n.public_key) + '/paths', { ttl: 300 }).then(pathData => { const pathEl = document.getElementById('liveNodePaths'); if (!pathEl) return; if (!pathData || !pathData.paths || !pathData.paths.length) { pathEl.innerHTML = ''; return; } const COLLAPSE = 5; function renderPathList(paths) { return paths.map(p => { const chain = p.hops.map(h => { const isThis = h.pubkey === n.public_key || (h.prefix && n.public_key.toLowerCase().startsWith(h.prefix.toLowerCase())); const name = escapeHtml(h.name || h.prefix); if (isThis) return `${name}`; return h.pubkey ? `${name}` : name; }).join(' β†’ '); return `
${chain} (${p.count}Γ—)
`; }).join(''); } pathEl.innerHTML = `

Paths Through (${pathData.totalPaths})

` + `
` + renderPathList(pathData.paths.slice(0, COLLAPSE)) + (pathData.paths.length > COLLAPSE ? `` : '') + '
'; const moreBtn = document.getElementById('showMorePaths'); if (moreBtn) moreBtn.addEventListener('click', () => { document.getElementById('livePathsList').innerHTML = renderPathList(pathData.paths); }); }).catch(() => { const pathEl = document.getElementById('liveNodePaths'); if (pathEl) pathEl.innerHTML = ''; }); } catch (e) { content.innerHTML = `
Error: ${e.message}
`; } } 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); } }); const _el2 = document.getElementById('liveNodeCount'); if (_el2) _el2.textContent = Object.keys(nodeMarkers).length; // Initialize shared HopResolver with loaded nodes if (window.HopResolver) HopResolver.init(list); } catch (e) { console.error('Failed to load nodes:', e); } } function clearNodeMarkers() { if (nodesLayer) nodesLayer.clearLayers(); if (animLayer) animLayer.clearLayers(); nodeMarkers = {}; nodeData = {}; nodeActivity = {}; if (window.HopResolver) HopResolver.init([]); if (heatLayer) { map.removeLayer(heatLayer); heatLayer = null; } } function getFavoritePubkeys() { let favs = []; try { favs = favs.concat(JSON.parse(localStorage.getItem('meshcore-favorites') || '[]')); } catch {} try { favs = favs.concat(JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]').map(n => n.pubkey)); } catch {} return favs.filter(Boolean); } function packetInvolvesFavorite(pkt) { const favs = getFavoritePubkeys(); if (favs.length === 0) return false; const decoded = pkt.decoded || {}; const payload = decoded.payload || {}; const hops = decoded.path?.hops || []; // Full pubkeys: sender if (payload.pubKey && favs.some(f => f === payload.pubKey)) return true; // Observer: may be name or pubkey const obs = pkt.observer_name || pkt.observer || ''; if (obs) { if (favs.some(f => f === obs)) return true; for (const nd of Object.values(nodeData)) { if ((nd.name === obs || nd.public_key === obs) && favs.some(f => f === nd.public_key)) return true; } } // Hops are truncated hex prefixes β€” match by prefix in either direction for (const hop of hops) { const h = (hop.id || hop.public_key || hop).toString().toLowerCase(); if (favs.some(f => f.toLowerCase().startsWith(h) || h.startsWith(f.toLowerCase()))) return true; } return false; } function isNodeFavorited(pubkey) { return getFavoritePubkeys().some(f => f === pubkey); } function rebuildFeedList() { const feed = document.getElementById('liveFeed'); if (!feed) return; // Remove all feed items but keep the hide button and resize handle feed.querySelectorAll('.live-feed-item').forEach(el => el.remove()); // Re-add from VCR buffer (most recent first, up to 25) const entries = VCR.buffer.slice(-100).reverse(); let count = 0; for (const entry of entries) { if (count >= 25) break; const pkt = entry.pkt; if (showOnlyFavorites && !packetInvolvesFavorite(pkt)) continue; 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'; addFeedItemDOM(icon, typeName, payload, hops, color, pkt, feed); count++; } } function applyFavoritesFilter() { // Node markers always stay visible β€” only rebuild the feed list rebuildFeedList(); } 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.on('click', () => showNodeDetail(n.public_key)); marker._glowMarker = glow; marker._baseColor = color; marker._baseSize = size; nodeMarkers[n.public_key] = marker; // Apply matrix tint if active if (matrixMode) { marker._matrixPrevColor = color; marker._baseColor = '#008a22'; marker.setStyle({ fillColor: '#008a22', color: '#008a22', fillOpacity: 0.5, opacity: 0.5 }); glow.setStyle({ fillColor: '#008a22', fillOpacity: 0.15 }); } 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, WS_RECONNECT_MS); ws.onerror = () => {}; } function animatePacket(pkt) { packetCount++; pktTimestamps.push(Date.now()); const _el = document.getElementById('livePktCount'); if (_el) _el.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'; if (window.MeshAudio) MeshAudio.sonifyPacket(pkt); addFeedItem(icon, typeName, payload, hops, color, pkt); addRainDrop(pkt); // Spawn extra rain columns for multiple observations with varied hop counts const obsCount = pkt.observation_count || (pkt.packet && pkt.packet.observation_count) || 1; const baseHops = (pkt.decoded?.path?.hops || []).length || 1; for (let i = 1; i < obsCount; i++) { const variedHops = Math.max(1, baseHops + Math.floor(Math.random() * 3) - 1); // Β±1 hop setTimeout(() => addRainDrop(pkt, variedHops), i * 150); } // Favorites filter: skip animation if packet doesn't involve a favorited node if (showOnlyFavorites && !packetInvolvesFavorite(pkt)) return; // 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); if (window.HopResolver) HopResolver.init(Object.values(nodeData)); const _el2 = document.getElementById('liveNodeCount'); if (_el2) _el2.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, pkt.raw); } function animateRealisticPropagation(packets) { if (!packets.length) return; const first = packets[0]; const decoded = first.decoded || {}; const header = decoded.header || {}; const typeName = header.payloadTypeName || 'UNKNOWN'; const color = TYPE_COLORS[typeName] || '#6b7280'; const icon = PAYLOAD_ICONS[typeName] || 'πŸ“¦'; const payload = decoded.payload || {}; packetCount += packets.length; pktTimestamps.push(Date.now()); const _el = document.getElementById('livePktCount'); if (_el) _el.textContent = packetCount; // Favorites filter: skip if none of the packets involve a favorite if (showOnlyFavorites && !packets.some(p => packetInvolvesFavorite(p))) return; const consolidated = Object.assign({}, first, { observation_count: packets.length }); if (window.MeshAudio) MeshAudio.sonifyPacket(consolidated); // Add single consolidated feed item for the group const allHops = (decoded.path?.hops) || []; addFeedItem(icon, typeName, payload, allHops, color, consolidated); // Rain drop per observation in the group packets.forEach((p, i) => setTimeout(() => addRainDrop(p), i * 150)); // Ensure ADVERT nodes appear for (const pkt of packets) { const d = pkt.decoded || {}; const h = d.header || {}; const p = d.payload || {}; if (h.payloadTypeName === 'ADVERT' && p.pubKey) { const key = p.pubKey; if (!nodeMarkers[key] && p.lat != null && p.lon != null && !(p.lat === 0 && p.lon === 0)) { const n = { public_key: key, name: p.name || key.slice(0,8), role: p.role || 'unknown', lat: p.lat, lon: p.lon }; nodeData[key] = n; addNodeMarker(n); if (window.HopResolver) HopResolver.init(Object.values(nodeData)); } } } const _el2 = document.getElementById('liveNodeCount'); if (_el2) _el2.textContent = Object.keys(nodeMarkers).length; // Resolve all unique paths const allPaths = []; const seenPathKeys = new Set(); const observers = new Set(); for (const pkt of packets) { const d = pkt.decoded || {}; const p = d.payload || {}; const hops = d.path?.hops || []; if (pkt.observer) observers.add(pkt.observer); const pathKey = hops.join(','); if (seenPathKeys.has(pathKey)) continue; seenPathKeys.add(pathKey); const hopPositions = resolveHopPositions(hops, p); if (hopPositions.length >= 2) allPaths.push(hopPositions); } // Consolidated feed item const hops0 = decoded.path?.hops || []; const text = payload.text || payload.name || ''; const preview = text ? ' ' + (text.length > 35 ? text.slice(0, 35) + '…' : text) : ''; const feed = document.getElementById('liveFeed'); if (feed) { 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 = ` ${icon} ${typeName} ${allPaths.length}β‡’ ${observers.size}πŸ‘ ${escapeHtml(preview)} ${new Date(first._ts || Date.now()).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})} `; item.addEventListener('click', () => showFeedCard(item, first, color)); feed.prepend(item); requestAnimationFrame(() => { requestAnimationFrame(() => item.classList.remove('live-feed-enter')); }); while (feed.children.length > 25) feed.removeChild(feed.lastChild); } if (allPaths.length === 0) { // Single hop or unresolvable β€” just pulse origin if possible const hp0 = resolveHopPositions(decoded.path?.hops || [], payload); if (hp0.length >= 1) pulseNode(hp0[0].key, hp0[0].pos, typeName); return; } // Animate all paths simultaneously for (const hopPositions of allPaths) { animatePath(hopPositions, typeName, color, first.raw); } } function resolveHopPositions(hops, payload) { // Delegate to shared HopResolver (from hop-resolver.js) instead of reimplementing const originLat = payload.lat != null && !(payload.lat === 0 && payload.lon === 0) ? payload.lat : null; const originLon = payload.lon != null && !(payload.lon === 0 && payload.lon === 0) ? payload.lon : null; // Use HopResolver if available and initialized, otherwise fall back to simple lookup const resolvedMap = (window.HopResolver && HopResolver.ready()) ? HopResolver.resolve(hops, originLat, originLon, null, null, null) : {}; // Convert HopResolver's map format to the array format live.js expects: {key, pos, name, known} const raw = hops.map(hop => { const r = resolvedMap[hop]; if (r && r.name && r.pubkey && !r.unreliable) { // Look up coordinates from nodeData (HopResolver resolves name/pubkey but doesn't return lat/lon directly) const node = nodeData[r.pubkey]; if (node && node.lat != null && node.lon != null && !(node.lat === 0 && node.lon === 0)) { return { key: r.pubkey, pos: [node.lat, node.lon], name: r.name, known: true }; } return { key: r.pubkey, pos: null, name: r.name, known: false }; } return { key: 'hop-' + hop, pos: null, name: hop, known: false }; }); // Add sender position as anchor if available if (payload.pubKey && originLat != null) { 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 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, rawHex) { if (!animLayer || !pathsLayer) return; 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, rawHex); } else { if (!isGhost) pulseNode(hp.key, hp.pos, typeName); hopIndex++; nextHop(); } } nextHop(); } function pulseNode(key, pos, typeName) { if (!animLayer || !nodesLayer) return; 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; } // === Matrix Rain System === function startMatrixRain() { const container = document.getElementById('liveMap'); if (!container || rainCanvas) return; rainCanvas = document.createElement('canvas'); rainCanvas.id = 'matrixRainCanvas'; rainCanvas.style.cssText = 'position:absolute;inset:0;z-index:9998;pointer-events:none;'; rainCanvas.width = container.clientWidth; rainCanvas.height = container.clientHeight; container.appendChild(rainCanvas); rainCtx = rainCanvas.getContext('2d'); rainDrops = []; // Resize handler rainCanvas._resizeHandler = () => { if (rainCanvas) { rainCanvas.width = container.clientWidth; rainCanvas.height = container.clientHeight; } }; window.addEventListener('resize', rainCanvas._resizeHandler); function renderRain(now) { if (!rainCanvas || !rainCtx) return; const W = rainCanvas.width, H = rainCanvas.height; rainCtx.clearRect(0, 0, W, H); for (let i = rainDrops.length - 1; i >= 0; i--) { const drop = rainDrops[i]; const elapsed = now - drop.startTime; const progress = Math.min(1, elapsed / drop.duration); // Head position const headY = progress * drop.maxY; // Trail shows all packet bytes, scrolling through them const CHAR_H = 18; const VISIBLE_CHARS = drop.bytes.length; // show all bytes const trailPx = VISIBLE_CHARS * CHAR_H; // Scroll offset β€” cycles through all bytes over the drop lifetime const scrollOffset = Math.floor(progress * drop.bytes.length); for (let c = 0; c < VISIBLE_CHARS; c++) { const charY = headY - c * CHAR_H; if (charY < -CHAR_H || charY > H) continue; const byteIdx = (scrollOffset + c) % drop.bytes.length; // Fade: head is bright, tail fades const fadeFactor = 1 - (c / VISIBLE_CHARS); // Also fade entire drop near end of life const lifeFade = progress > 0.7 ? 1 - (progress - 0.7) / 0.3 : 1; const alpha = Math.max(0, fadeFactor * lifeFade); if (c === 0) { rainCtx.font = 'bold 16px "Courier New", monospace'; rainCtx.fillStyle = `rgba(255, 255, 255, ${alpha})`; rainCtx.shadowColor = '#00ff41'; rainCtx.shadowBlur = 12; } else { rainCtx.font = '14px "Courier New", monospace'; rainCtx.fillStyle = `rgba(0, 255, 65, ${alpha * 0.8})`; rainCtx.shadowColor = '#00ff41'; rainCtx.shadowBlur = 4; } rainCtx.fillText(drop.bytes[byteIdx], drop.x, charY); } // Remove finished drops if (progress >= 1) { rainDrops.splice(i, 1); } } rainCtx.shadowBlur = 0; // reset rainRAF = requestAnimationFrame(renderRain); } rainRAF = requestAnimationFrame(renderRain); } function stopMatrixRain() { if (rainRAF) { cancelAnimationFrame(rainRAF); rainRAF = null; } if (rainCanvas) { window.removeEventListener('resize', rainCanvas._resizeHandler); rainCanvas.remove(); rainCanvas = null; rainCtx = null; } rainDrops = []; } function addRainDrop(pkt, hopOverride) { if (!rainCanvas || !matrixRain) return; const rawHex = pkt.raw || pkt.raw_hex || (pkt.packet && pkt.packet.raw_hex) || ''; if (!rawHex) return; const decoded = pkt.decoded || {}; const hops = decoded.path?.hops || []; const hopCount = hopOverride || Math.max(1, hops.length); const bytes = []; for (let i = 0; i < rawHex.length; i += 2) { bytes.push(rawHex.slice(i, i + 2).toUpperCase()); } if (bytes.length === 0) return; const W = rainCanvas.width; const H = rainCanvas.height; // Fall distance proportional to hops: 8+ hops = full height const maxY = H * Math.min(1, hopCount / 4); // Duration: 5s for full height, proportional for shorter const duration = 5000 * (maxY / H); // Random x position, avoid edges const x = 20 + Math.random() * (W - 40); rainDrops.push({ x, maxY, duration, bytes, hops: hopCount, startTime: performance.now() }); } function applyMatrixTheme(on) { const container = document.getElementById('liveMap'); if (!container) return; if (on) { // Force dark mode, save previous theme to restore later const currentTheme = document.documentElement.getAttribute('data-theme'); if (currentTheme !== 'dark') { container.dataset.matrixPrevTheme = currentTheme || 'light'; document.documentElement.setAttribute('data-theme', 'dark'); const dt = document.getElementById('darkModeToggle'); if (dt) { dt.textContent = 'πŸŒ™'; dt.disabled = true; } } else { const dt = document.getElementById('darkModeToggle'); if (dt) dt.disabled = true; } container.classList.add('matrix-theme'); if (!document.getElementById('matrixScanlines')) { const scanlines = document.createElement('div'); scanlines.id = 'matrixScanlines'; scanlines.className = 'matrix-scanlines'; container.appendChild(scanlines); } for (const [key, marker] of Object.entries(nodeMarkers)) { marker._matrixPrevColor = marker._baseColor; marker._baseColor = '#008a22'; marker.setStyle({ fillColor: '#008a22', color: '#008a22', fillOpacity: 0.5, opacity: 0.5 }); if (marker._glowMarker) marker._glowMarker.setStyle({ fillColor: '#008a22', fillOpacity: 0.15 }); } } else { container.classList.remove('matrix-theme'); const scanlines = document.getElementById('matrixScanlines'); if (scanlines) scanlines.remove(); // Restore previous theme const prevTheme = container.dataset.matrixPrevTheme; if (prevTheme) { document.documentElement.setAttribute('data-theme', prevTheme); localStorage.setItem('meshcore-theme', prevTheme); const dt = document.getElementById('darkModeToggle'); if (dt) { dt.textContent = prevTheme === 'dark' ? 'πŸŒ™' : 'β˜€οΈ'; dt.disabled = false; } delete container.dataset.matrixPrevTheme; } else { const dt = document.getElementById('darkModeToggle'); if (dt) dt.disabled = false; } for (const [key, marker] of Object.entries(nodeMarkers)) { if (marker._matrixPrevColor) { marker._baseColor = marker._matrixPrevColor; marker.setStyle({ fillColor: marker._matrixPrevColor, color: '#fff', fillOpacity: 0.85, opacity: 1 }); if (marker._glowMarker) marker._glowMarker.setStyle({ fillColor: marker._matrixPrevColor }); delete marker._matrixPrevColor; } } } } function drawMatrixLine(from, to, color, onComplete, rawHex) { if (!animLayer || !pathsLayer) { if (onComplete) onComplete(); return; } const hexStr = rawHex || ''; const bytes = []; for (let i = 0; i < hexStr.length; i += 2) { bytes.push(hexStr.slice(i, i + 2).toUpperCase()); } if (bytes.length === 0) { for (let i = 0; i < 16; i++) bytes.push(((Math.random() * 256) | 0).toString(16).padStart(2, '0').toUpperCase()); } const matrixGreen = '#00ff41'; const TRAIL_LEN = Math.min(6, bytes.length); const DURATION_MS = 1100; // total hop duration const CHAR_INTERVAL = 0.06; // spawn a char every 6% of progress const charMarkers = []; let nextCharAt = CHAR_INTERVAL; let byteIdx = 0; const trail = L.polyline([from], { color: matrixGreen, weight: 1.5, opacity: 0.2, lineCap: 'round' }).addTo(pathsLayer); const trailCoords = [from]; const startTime = performance.now(); function tick(now) { const elapsed = now - startTime; const t = Math.min(1, elapsed / DURATION_MS); const lat = from[0] + (to[0] - from[0]) * t; const lon = from[1] + (to[1] - from[1]) * t; trailCoords.push([lat, lon]); trail.setLatLngs(trailCoords); // Remove old chars beyond trail length while (charMarkers.length > TRAIL_LEN) { const old = charMarkers.shift(); try { animLayer.removeLayer(old.marker); } catch {} } // Fade existing chars for (let i = 0; i < charMarkers.length; i++) { const age = charMarkers.length - i; const op = Math.max(0.15, 1 - (age / TRAIL_LEN) * 0.7); const size = Math.max(10, 16 - age * 1.5); const el = charMarkers[i].marker.getElement(); if (el) { el.style.opacity = op; el.style.fontSize = size + 'px'; } } // Spawn new char at intervals if (t >= nextCharAt && t < 1) { nextCharAt += CHAR_INTERVAL; const charEl = L.marker([lat, lon], { icon: L.divIcon({ className: 'matrix-char', html: `${bytes[byteIdx % bytes.length]}`, iconSize: [24, 18], iconAnchor: [12, 9] }), interactive: false }).addTo(animLayer); charMarkers.push({ marker: charEl }); byteIdx++; } if (t < 1) { requestAnimationFrame(tick); } else { // Fade out const fadeStart = performance.now(); function fadeOut(now) { const ft = Math.min(1, (now - fadeStart) / 300); if (ft >= 1) { for (const cm of charMarkers) try { animLayer.removeLayer(cm.marker); } catch {} try { pathsLayer.removeLayer(trail); } catch {} charMarkers.length = 0; } else { const op = 1 - ft; for (const cm of charMarkers) { const el = cm.marker.getElement(); if (el) el.style.opacity = op * 0.5; } trail.setStyle({ opacity: op * 0.15 }); requestAnimationFrame(fadeOut); } } setTimeout(() => requestAnimationFrame(fadeOut), 150); if (onComplete) onComplete(); } } requestAnimationFrame(tick); } function drawAnimatedLine(from, to, color, onComplete, overrideOpacity, rawHex) { if (!animLayer || !pathsLayer) { if (onComplete) onComplete(); return; } if (matrixMode) return drawMatrixLine(from, to, color, onComplete, rawHex); 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); if (animLayer) animLayer.removeLayer(dot); recentPaths.push({ line, glowLine: contrail, time: Date.now() }); while (recentPaths.length > 5) { const old = recentPaths.shift(); if (pathsLayer) { pathsLayer.removeLayer(old.line); pathsLayer.removeLayer(old.glowLine); } } setTimeout(() => { let fadeOp = mainOpacity; const fi = setInterval(() => { fadeOp -= 0.1; if (fadeOp <= 0) { clearInterval(fi); if (pathsLayer) { 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') { var savedOpacity = parseFloat(localStorage.getItem('meshcore-live-heatmap-opacity')); if (isNaN(savedOpacity)) savedOpacity = 0.3; heatLayer = L.heatLayer(points, { radius: 25, blur: 15, maxZoom: 14, minOpacity: 0.05, gradient: { 0.2: '#0d47a1', 0.4: '#1565c0', 0.6: '#42a5f5', 0.8: '#ffca28', 1.0: '#ff5722' } }).addTo(map); // Set overall layer opacity via canvas element if (heatLayer._canvas) { heatLayer._canvas.style.opacity = savedOpacity; } else { setTimeout(function() { if (heatLayer && heatLayer._canvas) heatLayer._canvas.style.opacity = savedOpacity; }, 100); } window._meshcoreLiveHeatLayer = heatLayer; } } function hideHeatMap() { if (heatLayer) { map.removeLayer(heatLayer); heatLayer = null; } } function addFeedItemDOM(icon, typeName, payload, hops, color, pkt, feed) { const text = payload.text || payload.name || ''; const preview = text ? ' ' + (text.length > 35 ? text.slice(0, 35) + '…' : text) : ''; const hopStr = hops.length ? `${hops.length}β‡’` : ''; const obsBadge = pkt.observation_count > 1 ? `πŸ‘ ${pkt.observation_count}` : ''; const item = document.createElement('div'); item.className = 'live-feed-item'; item.setAttribute('tabindex', '0'); item.setAttribute('role', 'button'); item.style.cursor = 'pointer'; item.innerHTML = ` ${icon} ${typeName} ${hopStr}${obsBadge} ${escapeHtml(preview)} ${new Date(pkt._ts || Date.now()).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})} `; item.addEventListener('click', () => showFeedCard(item, pkt, color)); feed.appendChild(item); } // Track recent feed items by hash for deduplication (hash -> {element, count, pkt}) const feedHashMap = new Map(); const FEED_DEDUP_WINDOW_MS = 30000; // merge duplicates within 30s function addFeedItem(icon, typeName, payload, hops, color, pkt) { const feed = document.getElementById('liveFeed'); if (!feed) return; // Favorites filter: skip feed item if packet doesn't involve a favorite if (showOnlyFavorites && !packetInvolvesFavorite(pkt)) return; const hash = pkt.hash; // Hash-based deduplication: if a feed item with the same hash exists and is recent, update it if (hash && feedHashMap.has(hash)) { const entry = feedHashMap.get(hash); if (entry.element.parentNode && (Date.now() - entry.addedAt) < FEED_DEDUP_WINDOW_MS) { entry.count++; // Update the observation badge let badge = entry.element.querySelector('.badge-obs'); if (!badge) { badge = document.createElement('span'); badge.className = 'badge badge-obs'; badge.style.cssText = 'font-size:10px;margin-left:4px'; // Insert after feed-hops or feed-type const anchor = entry.element.querySelector('.feed-hops') || entry.element.querySelector('.feed-type'); if (anchor) anchor.after(badge); else entry.element.prepend(badge); } badge.textContent = 'πŸ‘ ' + entry.count; // Flash the item to indicate update entry.element.classList.add('live-feed-enter'); requestAnimationFrame(() => { requestAnimationFrame(() => entry.element.classList.remove('live-feed-enter')); }); // Move to top of feed feed.prepend(entry.element); // Update stored pkt with merged observation count entry.pkt.observation_count = entry.count; return; } } const text = payload.text || payload.name || ''; const preview = text ? ' ' + (text.length > 35 ? text.slice(0, 35) + '…' : text) : ''; const hopStr = hops.length ? `${hops.length}β‡’` : ''; const obsCount = pkt.observation_count || 1; const obsBadge = obsCount > 1 ? `πŸ‘ ${obsCount}` : ''; const item = document.createElement('div'); item.className = 'live-feed-item live-feed-enter'; item.setAttribute('tabindex', '0'); item.setAttribute('role', 'button'); if (hash) item.setAttribute('data-hash', hash); item.style.cursor = 'pointer'; item.innerHTML = ` ${icon} ${typeName} ${hopStr}${obsBadge} ${escapeHtml(preview)} ${new Date(pkt._ts || Date.now()).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})} `; 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); // Register in dedup map if (hash) { feedHashMap.set(hash, { element: item, count: obsCount, pkt, addedAt: Date.now() }); // Prune old entries periodically if (feedHashMap.size > 50) { const cutoff = Date.now() - FEED_DEDUP_WINDOW_MS; for (const [k, v] of feedHashMap) { if (v.addedAt < cutoff) feedHashMap.delete(k); } } } } 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 = `
${typeName} ${sender ? `${escapeHtml(sender)}` : ''}
${text ? `
${escapeHtml(text.length > 120 ? text.slice(0, 120) + '…' : text)}
` : ''}
${channel ? `πŸ“» ${escapeHtml(channel)}` : ''} ${hops.length ? `πŸ”€ ${hops.length} hops` : ''} ${snr != null ? `πŸ“Ά ${Number(snr).toFixed(1)} dB` : ''} ${rssi != null ? `πŸ“‘ ${rssi} dBm` : ''} ${observer ? `πŸ‘ ${escapeHtml(observer)}` : ''}
${pkt.hash ? `View in packets β†’` : ''} `; 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 = ''; } const existingPin = document.getElementById('navPinBtn'); if (existingPin) existingPin.remove(); 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; stopMatrixRain(); nodeMarkers = {}; nodeData = {}; recentPaths = []; packetCount = 0; activeAnims = 0; nodeActivity = {}; pktTimestamps = []; feedHashMap.clear(); VCR.buffer = []; VCR.playhead = -1; VCR.mode = 'LIVE'; VCR.missedCount = 0; VCR.speed = 1; } let _themeRefreshHandler = null; registerPage('live', { init: function(app, routeParam) { _themeRefreshHandler = () => { /* live map rebuilds on next packet */ }; window.addEventListener('theme-refresh', _themeRefreshHandler); return init(app, routeParam); }, destroy: function() { if (_themeRefreshHandler) { window.removeEventListener('theme-refresh', _themeRefreshHandler); _themeRefreshHandler = null; } return destroy(); } }); })();