(function() {
'use strict';
// getParsedPath / getParsedDecoded are in shared packet-helpers.js (loaded before this file)
var getParsedPath = window.getParsedPath;
var getParsedDecoded = window.getParsedDecoded;
// 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, geoFilterLayer, clickablePathsLayer;
let clickablePaths = [];
const CLICKABLE_PATH_TTL_MS = 30000;
const CLICKABLE_PATH_MAX = 50;
const CLICKABLE_POPUP_DISMISS_MS = 20000;
let nodeMarkers = {};
let nodeData = {};
let packetCount = 0;
let activeAnims = 0;
const MAX_CONCURRENT_ANIMS = 20;
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 colorByHash = localStorage.getItem('meshcore-color-packets-by-hash') !== 'false';
/** Current theme string for hash-color functions. */
function _liveTheme() { return document.documentElement.dataset.theme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); }
let nodeFilterKeys = (localStorage.getItem('live-node-filter') || '').split(',').map(s => s.trim()).filter(Boolean);
let nodeFilterTotal = 0;
let nodeFilterShown = 0;
// Region filter (#1045): observer_id β IATA code, populated from /api/observers
let observerIataMap = {};
let regionFilterChangeHandler = null;
/**
* Returns true if the packet group matches the selected regions.
* - selected null/empty β no filter active, always true.
* - Match if ANY observation's observer maps to an IATA in selected (case-insensitive).
* Pure helper exposed for unit tests.
*/
function packetMatchesRegion(packets, obsMap, selected) {
if (!selected || !selected.length) return true;
if (!packets || !packets.length) return false;
const sel = selected.map(function(s) { return String(s).toUpperCase(); });
for (var i = 0; i < packets.length; i++) {
var oid = packets[i] && packets[i].observer_id;
if (oid == null) continue;
var iata = obsMap && obsMap[oid];
if (!iata) continue;
if (sel.indexOf(String(iata).toUpperCase()) !== -1) return true;
}
return false;
}
function setObserverIataMap(m) { observerIataMap = m || {}; }
// #1189 R2 mesh-operator fix: live feed must show the observer's IATA pill
// alongside the existing π N badge so operators on /live can tell SAME-
// region from CROSS-region reception at a glance (same affordance as the
// /packets table). Mirrors `obsIataBadge` in public/packets.js β kept as a
// local helper for now (live.js and packets.js are separate IIFEs with no
// shared module). TODO: extract `obsIataBadge` into shared packet-helpers.js
// and have both surfaces import it.
function obsIataBadgeHtml(pkt) {
if (!pkt) return '';
var iata = pkt.observer_iata;
if (!iata && pkt.observer_id) iata = observerIataMap && observerIataMap[pkt.observer_id];
if (!iata) return '';
var esc = (typeof escapeHtml === 'function')
? escapeHtml(iata)
: String(iata).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,''');
return '' + esc + '';
}
/**
* Build observer_id β IATA map from the /api/observers response.
* The endpoint returns `{ observers: [...], server_time: "..." }`
* (cmd/server/types.go ObserverListResponse). Defensive: also accepts
* a bare array in case the API shape ever changes back, and ignores
* observers without an IATA. Returns a plain object (used as a hash).
* Exported for tests via window._liveBuildObserverIataMap.
* Fixes #1136 (regression introduced in #1080 which assumed array shape).
*/
function buildObserverIataMap(data) {
var list = null;
if (Array.isArray(data)) list = data;
else if (data && Array.isArray(data.observers)) list = data.observers;
var m = {};
if (!list) return m;
for (var i = 0; i < list.length; i++) {
var o = list[i];
if (o && o.id != null && o.iata) m[o.id] = o.iata;
}
return m;
}
const _savedSpeed = parseFloat(localStorage.getItem('live-vcr-speed'));
const _initialSpeed = [0.25, 0.5, 1, 2, 4, 8].includes(_savedSpeed) ? _savedSpeed : 1;
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;
let _pruneInterval = null;
let _feedTimestampInterval = null;
let activeNodeDetailKey = 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
replayGen: 0, // generation counter β incremented on each replay/rewind to discard stale async results
};
// 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',
ANON_REQ: '#f43f5e', GRP_DATA: '#8b5cf6', MULTIPART: '#0d9488',
CONTROL: '#b45309', RAW_CUSTOM: '#c026d3'
};
const PAYLOAD_ICONS = {
ADVERT: 'π‘', GRP_TXT: 'π¬', TXT_MSG: 'βοΈ', ACK: 'β',
REQUEST: 'β', RESPONSE: 'π¨', TRACE: 'π', PATH: 'π€οΈ'
};
/* ---- Panel Corner Positioning (#608 M0) ---- */
var PANEL_DEFAULTS = { liveFeed: 'bl', liveLegend: 'br', liveNodeDetail: 'tr' };
var CORNER_CYCLE = ['tl', 'tr', 'br', 'bl'];
var CORNER_ARROWS = { tl: 'β', tr: 'β', bl: 'β', br: 'β' };
var CORNER_LABELS = { tl: 'top-left', tr: 'top-right', bl: 'bottom-left', br: 'bottom-right' };
var PANEL_NAMES = { liveFeed: 'Feed', liveLegend: 'Legend', liveNodeDetail: 'Node detail' };
function getPanelPositions() {
var pos = {};
for (var id in PANEL_DEFAULTS) {
try { pos[id] = localStorage.getItem('panel-corner-' + id) || PANEL_DEFAULTS[id]; }
catch (_) { pos[id] = PANEL_DEFAULTS[id]; }
}
return pos;
}
function nextAvailableCorner(panelId, desired, allPositions) {
var idx = CORNER_CYCLE.indexOf(desired);
for (var i = 0; i < 4; i++) {
var candidate = CORNER_CYCLE[(idx + i) % 4];
var occupied = false;
for (var otherId in allPositions) {
if (otherId !== panelId && allPositions[otherId] === candidate) { occupied = true; break; }
}
if (!occupied) return candidate;
}
return desired; // all occupied (impossible with 3 panels, 4 corners)
}
function applyPanelPosition(id, corner) {
var el = document.getElementById(id);
if (!el) return;
el.setAttribute('data-position', corner);
var btn = el.querySelector('.panel-corner-btn');
if (btn) {
btn.textContent = CORNER_ARROWS[corner];
btn.setAttribute('aria-label',
'Move ' + (PANEL_NAMES[id] || 'panel') + ' to next corner (currently ' + CORNER_LABELS[corner] + ')');
}
}
function initPanelPositions() {
var positions = getPanelPositions();
for (var id in positions) {
applyPanelPosition(id, positions[id]);
}
// Wire up click handlers on corner buttons
var btns = document.querySelectorAll('.panel-corner-btn[data-panel]');
for (var i = 0; i < btns.length; i++) {
btns[i].addEventListener('click', function(e) {
e.stopPropagation();
var panelId = this.getAttribute('data-panel');
onCornerClick(panelId);
});
}
}
function onCornerClick(panelId) {
var positions = getPanelPositions();
var current = positions[panelId];
var nextIdx = (CORNER_CYCLE.indexOf(current) + 1) % 4;
var next = nextAvailableCorner(panelId, CORNER_CYCLE[nextIdx], positions);
try { localStorage.setItem('panel-corner-' + panelId, next); } catch (_) { /* quota */ }
applyPanelPosition(panelId, next);
// Announce for screen readers
var announce = document.getElementById('panelPositionAnnounce');
if (announce) announce.textContent = (PANEL_NAMES[panelId] || 'Panel') + ' moved to ' + CORNER_LABELS[next];
}
function resetPanelPositions() {
for (var id in PANEL_DEFAULTS) {
try { localStorage.removeItem('panel-corner-' + id); } catch (_) { /* ignore */ }
applyPanelPosition(id, PANEL_DEFAULTS[id]);
}
}
// Export for testing
if (typeof window !== 'undefined') {
window._panelCorner = {
PANEL_DEFAULTS: PANEL_DEFAULTS, CORNER_CYCLE: CORNER_CYCLE,
getPanelPositions: getPanelPositions, nextAvailableCorner: nextAvailableCorner,
applyPanelPosition: applyPanelPosition, onCornerClick: onCornerClick,
resetPanelPositions: resetPanelPositions
};
}
function formatLiveTimestampHtml(isoLike) {
if (typeof formatTimestampWithTooltip !== 'function' || typeof getTimestampMode !== 'function') {
return escapeHtml(typeof timeAgo === 'function' ? timeAgo(isoLike) : 'β');
}
const d = isoLike ? new Date(isoLike) : null;
const iso = d && isFinite(d.getTime()) ? d.toISOString() : null;
const f = formatTimestampWithTooltip(iso, getTimestampMode());
const warn = f.isFuture
? ' β οΈ'
: '';
return `${escapeHtml(f.text)}${warn}`;
}
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');
// #1267: the CSS rule for .live-page subtracts --bottom-nav-reserve
// (0px desktop, 56px+safe-area at β€768px) so the fixed .bottom-nav
// (z-index 1200) does not occlude the VCR bar (position:absolute;
// bottom:0; z-index 1000). Mirror that subtraction here β otherwise
// this JS override clobbers the CSS height with raw window.innerHeight
// and the VCR bar slides under the bottom-nav (issue #1267).
// Prefer the bottom-nav's measured rendered height so we also cover
// the 1px top border and any visual chrome the --bottom-nav-reserve
// token doesn't account for; fall back to the token-resolved value.
const reserve = (() => {
const bn = document.querySelector('.bottom-nav');
if (bn) {
const cs = getComputedStyle(bn);
if (cs.display !== 'none') {
const r = bn.getBoundingClientRect().height;
if (r > 0) return r;
}
}
const probe = document.createElement('div');
probe.style.cssText = 'position:absolute;visibility:hidden;height:var(--bottom-nav-reserve,0px);pointer-events:none;';
(document.body || document.documentElement).appendChild(probe);
const px = probe.getBoundingClientRect().height || 0;
probe.remove();
return px;
})();
const h = Math.max(0, window.innerHeight - reserve);
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 ===
// #1206: publish the VCR bar's measured height as --vcr-bar-height on the
// .live-page root so bottom-pinned overlays (feed, legend, corner panels)
// can reserve the right amount of space and never get occluded by the bar.
// Cleanup state is captured in module-scoped _vcrHeightCleanup so destroy()
// can disconnect the ResizeObserver + remove the resize/visualViewport
// listeners on SPA page navigation (otherwise re-mounts of /live would
// accumulate observers forever β same leak class as #1180).
var _vcrHeightCleanup = null;
function initVCRHeightTracker() {
// #1206 r1 (adversarial should-fix): guard against double-init β
// if a prior tracker is still active (re-mount race, dev hot-reload),
// tear it down BEFORE overwriting _vcrHeightCleanup so the previous
// ResizeObserver/listeners aren't orphaned.
if (_vcrHeightCleanup) { try { _vcrHeightCleanup(); } catch (_) {} _vcrHeightCleanup = null; }
var bar = document.getElementById('vcrBar');
var page = document.querySelector('.live-page');
if (!bar || !page) return;
function publish() {
var h = Math.ceil(bar.getBoundingClientRect().height) || 58;
page.style.setProperty('--vcr-bar-height', h + 'px');
}
publish();
var ro = null;
if (typeof ResizeObserver === 'function') {
try { ro = new ResizeObserver(publish); ro.observe(bar); } catch (_) { ro = null; }
}
window.addEventListener('resize', publish);
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', publish);
}
_vcrHeightCleanup = function() {
if (ro) { try { ro.disconnect(); } catch (_) {} ro = null; }
window.removeEventListener('resize', publish);
if (window.visualViewport) {
window.visualViewport.removeEventListener('resize', publish);
}
};
}
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.replayGen++; // invalidate any in-flight async chunk processing
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();
VCR.replayGen++;
var gen = VCR.replayGen;
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&expand=observations&since=${encodeURIComponent(fetchFrom)}&order=asc`)
.then(r => r.json())
.then(data => {
const pkts = data.packets || [];
return expandToBufferEntriesAsync(pkts);
})
.then(function(replayEntries) {
if (gen !== VCR.replayGen) return; // stale async result β user changed mode
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();
VCR.replayGen++;
var gen = VCR.replayGen;
// 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&expand=observations&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 filtered = pkts.filter(p => !existingIds.has(p.id));
return expandToBufferEntriesAsync(filtered);
})
.then(function(newEntries) {
if (gen !== VCR.replayGen) return; // stale async result
VCR.buffer = [].concat(newEntries, VCR.buffer);
VCR.playhead = 0;
VCR.speed = 1;
vcrSetMode('REPLAY');
startReplay();
updateTimeline();
})
.catch(() => {});
}
function startReplay() {
stopReplay();
// Pre-aggregate VCR buffer by hash so each tick renders a full tree
const hashGroups = new Map();
for (const entry of VCR.buffer) {
const hash = entry.pkt.hash || ('nohash-' + hashGroups.size);
if (hashGroups.has(hash)) {
hashGroups.get(hash).packets.push(entry.pkt);
if (entry.ts > hashGroups.get(hash).ts) hashGroups.get(hash).ts = entry.ts;
} else {
hashGroups.set(hash, { packets: [entry.pkt], ts: entry.ts });
}
}
const replayGroups = [...hashGroups.values()].sort((a, b) => a.ts - b.ts);
console.log('[vcr] ' + replayGroups.length + ' groups from ' + VCR.buffer.length + ' buffer entries. Top 3:', replayGroups.slice(0,3).map(g => g.packets.length + ' obs'));
let groupIdx = 0;
function tick() {
if (VCR.mode !== 'REPLAY') return;
if (groupIdx >= replayGroups.length) {
fetchNextReplayPage().then(hasMore => {
if (hasMore) vcrResumeLive();
else vcrResumeLive();
});
return;
}
const group = replayGroups[groupIdx];
renderPacketTree(group.packets);
updateVCRClock(group.ts);
updateVCRLcd();
VCR.playhead = Math.min(VCR.buffer.length, VCR.playhead + group.packets.length);
updateVCRUI();
updateTimelinePlayhead();
groupIdx++;
let delay = 300;
if (groupIdx < replayGroups.length) {
const nextGroup = replayGroups[groupIdx];
const realGap = nextGroup.ts - group.ts;
delay = Math.min(2000, Math.max(100, 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);
var gen = VCR.replayGen;
const since = new Date(last.ts + 1).toISOString(); // +1ms to avoid dupe
return fetch(`/api/packets?limit=10000&grouped=false&expand=observations&since=${encodeURIComponent(since)}&order=asc`)
.then(r => r.json())
.then(data => {
const pkts = data.packets || [];
if (pkts.length === 0) return false;
return expandToBufferEntriesAsync(pkts).then(function(newEntries) {
if (gen !== VCR.replayGen) return false; // stale
VCR.buffer = VCR.buffer.concat(newEntries);
return true;
});
})
.catch(() => false);
}
function stopReplay() {
if (VCR.replayTimer) { clearTimeout(VCR.replayTimer); VCR.replayTimer = null; }
}
function buildClickablePathPopupHtml(typeName, color, hopNames, tsMs, hash) {
// tsMs is packet receive time β "ago" is relative to when the packet arrived, not when the animation ended
const secsAgo = Math.round((Date.now() - tsMs) / 1000);
const timeStr = secsAgo < 60 ? secsAgo + 's ago' : Math.round(secsAgo / 60) + 'm ago';
const chain = hopNames.join(' β ');
const link = hash ? `full detail β` : '';
return `
${typeName}
${timeStr}
${chain}
${link ? '
' + link + '
' : ''}
`;
}
function pruneClickablePaths(now) {
const cutoff = now - CLICKABLE_PATH_TTL_MS;
for (let i = clickablePaths.length - 1; i >= 0; i--) {
if (clickablePaths[i].addedAt < cutoff) {
try { clickablePaths[i].poly.remove(); } catch (_) {}
clickablePaths.splice(i, 1);
}
}
while (clickablePaths.length > CLICKABLE_PATH_MAX) {
try { clickablePaths[0].poly.remove(); } catch (_) {}
clickablePaths.shift();
}
}
function registerClickablePath(latLngs, typeName, color, hopNames, tsMs, hash) {
if (!clickablePathsLayer) return;
const poly = L.polyline(latLngs, { weight: 12, opacity: 0, interactive: true }).addTo(clickablePathsLayer);
const entry = { addedAt: Date.now(), poly };
clickablePaths.push(entry);
pruneClickablePaths(Date.now());
let dismissTimer = null;
poly.on('click', function(e) {
if (dismissTimer) clearTimeout(dismissTimer);
const html = buildClickablePathPopupHtml(typeName, color, hopNames, tsMs, hash);
L.popup({ maxWidth: 280, className: 'path-info-popup' })
.setLatLng(e.latlng)
.setContent(html)
.openOn(map);
dismissTimer = setTimeout(() => { if (map) map.closePopup(); }, CLICKABLE_POPUP_DISMISS_MS);
});
}
function speedLabel(s) {
if (s === 0.25) return 'ΒΌx';
if (s === 0.5) return 'Β½x';
return s + 'x';
}
function vcrSpeedCycle() {
const speeds = [0.25, 0.5, 1, 2, 4, 8];
const idx = speeds.indexOf(VCR.speed);
VCR.speed = speeds[(idx + 1) % speeds.length];
localStorage.setItem('live-vcr-speed', VCR.speed);
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 vcrFormatTime(tsMs) {
const d = new Date(tsMs);
const utc = typeof getTimestampTimezone === 'function' && getTimestampTimezone() === 'utc';
const hh = String(utc ? d.getUTCHours() : d.getHours()).padStart(2, '0');
const mm = String(utc ? d.getUTCMinutes() : d.getMinutes()).padStart(2, '0');
const ss = String(utc ? d.getUTCSeconds() : d.getSeconds()).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
}
function updateVCRClock(tsMs) {
drawLcdText(vcrFormatTime(tsMs), 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 = speedLabel(VCR.speed); speedBtn.setAttribute('aria-label', 'Speed ' + speedLabel(VCR.speed)); }
updateVCRLcd();
}
function dbPacketToLive(pkt) {
const raw = getParsedDecoded(pkt);
const hops = getParsedPath(pkt);
const typeName = raw.type || pkt.payload_type_name || 'UNKNOWN';
return {
id: pkt.id, hash: pkt.hash,
raw: pkt.raw_hex,
path_json: pkt.path_json,
resolved_path: pkt.resolved_path,
_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
};
}
// Expand a DB packet (with optional observations[]) into VCR buffer entries
/**
* Process packets into buffer entries in chunks to avoid blocking the main thread.
* Returns a Promise that resolves with the entries array.
* Each chunk processes CHUNK_SIZE packets, then yields to the event loop via setTimeout(0).
*/
var VCR_CHUNK_SIZE = 200;
function expandToBufferEntriesAsync(pkts) {
return new Promise(function(resolve) {
var entries = [];
var i = 0;
function processChunk() {
var end = Math.min(i + VCR_CHUNK_SIZE, pkts.length);
for (; i < end; i++) {
var p = pkts[i];
if (p.observations && p.observations.length > 0) {
for (var j = 0; j < p.observations.length; j++) {
var obs = p.observations[j];
entries.push({
ts: new Date(obs.timestamp || p.timestamp || p.created_at).getTime(),
pkt: dbPacketToLive(Object.assign({}, p, obs, { hash: p.hash, raw_hex: p.raw_hex, decoded_json: p.decoded_json }))
});
}
} else {
entries.push({
ts: new Date(p.timestamp || p.created_at).getTime(),
pkt: dbPacketToLive(p)
});
}
}
if (i < pkts.length) {
setTimeout(processChunk, 0);
} else {
resolve(entries);
}
}
processChunk();
});
}
// Synchronous version kept for small datasets and backward compat (tests)
function expandToBufferEntries(pkts) {
var entries = [];
for (var k = 0; k < pkts.length; k++) {
var p = pkts[k];
if (p.observations && p.observations.length > 0) {
for (var j = 0; j < p.observations.length; j++) {
var obs = p.observations[j];
entries.push({
ts: new Date(obs.timestamp || p.timestamp || p.created_at).getTime(),
pkt: dbPacketToLive(Object.assign({}, p, obs, { hash: p.hash, raw_hex: p.raw_hex, decoded_json: p.decoded_json }))
});
}
} else {
entries.push({
ts: new Date(p.timestamp || p.created_at).getTime(),
pkt: dbPacketToLive(p)
});
}
}
return entries;
}
// 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();
// Batch-update timeline once on restore instead of per-packet while hidden
updateTimeline();
}
});
function packetTimestamp(pkt) {
return new Date(pkt.timestamp || pkt.created_at || Date.now()).getTime();
}
if (typeof window !== 'undefined') window._live_packetTimestamp = packetTimestamp;
function bufferPacket(pkt) {
pkt._ts = packetTimestamp(pkt);
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) {
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) renderPacketTree(buffered.packets);
}, PROPAGATION_BUFFER_MS) };
propagationBuffer.set(hash, entry);
}
} else {
renderPacketTree([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 = `
0 pkts
0 nodes
0 active
0/min
MESH LIVE
Overlay a density heat map on the mesh nodesShow interpolated ghost markers for unknown hopsBuffer packets by hash and animate all paths simultaneouslyColor flying-packet dots and contrails by packet hash for propagation tracingAnimate packet hex bytes flowing along paths like the MatrixMatrix rain overlay β packets fall as hex columnsSonify packets β turn raw bytes into generative musicShow only favorited and claimed nodes
Waiting for packetsβ¦
PACKET TYPES
Advert β Node advertisement
Message β Group text
Direct β Direct message
Request β Data request
Response β Data response
Trace β Route trace
Path β Path discovery
Anon Req β Anonymous request
Grp Data β Group datagram
Multipart β Multi-fragment payload
Control β Control plane
Raw Custom β Application-defined payload
Ack / Other β Acknowledgment or unknown type
NODE ROLES
MARKER STYLES
Bright white ring β repeater
Faded ring β companion / sensor / room
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);
clickablePathsLayer = L.layerGroup().addTo(map);
injectSVGFilters();
AreaFilter.init(document.getElementById('liveAreaFilter'));
AreaFilter.onChange(function () { loadNodes(); });
await loadNodes();
showHeatMap();
connectWS();
initResizeHandler();
initVCRHeightTracker();
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
setTimeout(() => renderPacketTree(packets, true), 1500);
} catch {}
} else {
// replayRecent(); // disabled β live page starts empty, fills from WS
}
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 colorHashToggle = document.getElementById('liveColorHashToggle');
colorHashToggle.checked = colorByHash;
colorHashToggle.addEventListener('change', (e) => {
colorByHash = e.target.checked;
localStorage.setItem('meshcore-color-packets-by-hash', colorByHash);
window.dispatchEvent(new Event('storage'));
});
const favoritesToggle = document.getElementById('liveFavoritesToggle');
favoritesToggle.checked = showOnlyFavorites;
favoritesToggle.addEventListener('change', (e) => {
showOnlyFavorites = e.target.checked;
localStorage.setItem('live-favorites-only', showOnlyFavorites);
applyFavoritesFilter();
});
// Region filter (#1045): dropdown of observer IATA regions
(function initLiveRegionFilter() {
var rfEl = document.getElementById('liveRegionFilter');
if (!rfEl || !window.RegionFilter) return;
// Fetch observer roster to build observer_id β IATA map.
// /api/observers returns `{observers:[...], server_time:"..."}`
// (cmd/server/types.go ObserverListResponse) β NOT a top-level array.
// Bug #1136: previously parsed as array β map empty β region filter
// dropped every packet.
fetch('/api/observers').then(function(r) { return r.json(); }).then(function(data) {
setObserverIataMap(buildObserverIataMap(data));
}).catch(function() { /* leave map empty; filter will hide all when active */ });
RegionFilter.init(rfEl, { dropdown: true });
regionFilterChangeHandler = RegionFilter.onChange(function() { /* selection persisted by RegionFilter; future packets reflect it */ });
})();
// Node filter input β autocomplete-as-you-type (#1110)
const nodeFilterInput = document.getElementById('liveNodeFilterInput');
const nodeFilterClear = document.getElementById('liveNodeFilterClear');
const nodeFilterDropdown = document.getElementById('liveNodeFilterDropdown');
if (nodeFilterInput) {
// Restore from URL param or localStorage
const urlNode = getHashParams && getHashParams().get('node');
if (urlNode) setNodeFilter(urlNode.split(',').map(s => s.trim()).filter(Boolean));
else if (nodeFilterKeys.length) updateNodeFilterUI();
let activeIdx = -1;
function hideDropdown() {
if (!nodeFilterDropdown) return;
nodeFilterDropdown.classList.add('hidden');
nodeFilterDropdown.innerHTML = '';
nodeFilterInput.setAttribute('aria-expanded', 'false');
nodeFilterInput.setAttribute('aria-activedescendant', '');
activeIdx = -1;
}
function applyFilterFromInput(rawValue) {
// Treat input as a single substring query rather than a list of pubkeys.
// setNodeFilter accepts pubkeys/prefixes/names; commit raw for live filtering.
const val = (rawValue || '').trim();
setNodeFilter(val ? [val] : []);
// Update URL without triggering hashchange (which would re-init the page).
const params = getHashParams ? getHashParams() : new URLSearchParams();
if (val) params.set('node', val);
else params.delete('node');
const base = location.hash.split('?')[0] || '#/live';
const qs = params.toString();
const newHash = base + (qs ? '?' + qs : '');
const newUrl = location.pathname + location.search + newHash;
try { history.replaceState(null, '', newUrl); } catch (_) {}
}
function selectSuggestion(opt) {
const key = opt.getAttribute('data-key') || '';
const name = opt.getAttribute('data-name') || key;
nodeFilterInput.value = name;
// Filter by pubkey prefix when available β most precise.
setNodeFilter(key ? [key] : (name ? [name] : []));
const params = getHashParams ? getHashParams() : new URLSearchParams();
if (key) params.set('node', key);
else params.delete('node');
const base = location.hash.split('?')[0] || '#/live';
const qs = params.toString();
const newUrl = location.pathname + location.search + base + (qs ? '?' + qs : '');
try { history.replaceState(null, '', newUrl); } catch (_) {}
hideDropdown();
}
const escapeHtmlLocal = (typeof escapeHtml === 'function') ? escapeHtml : function (s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c];
});
};
async function fetchSuggestions(q) {
if (!nodeFilterDropdown) return;
if (!q || q.length < 1) { hideDropdown(); return; }
try {
const resp = await fetch('/api/nodes/search?q=' + encodeURIComponent(q));
if (!resp.ok) { hideDropdown(); return; }
const data = await resp.json();
const nodes = (data && data.nodes) || [];
if (!nodes.length) { hideDropdown(); return; }
nodeFilterDropdown.innerHTML = nodes.map(function (n, i) {
const name = n.name || (n.public_key ? n.public_key.slice(0, 8) : '?');
const pkShort = n.public_key ? n.public_key.slice(0, 8) : '';
return '