From 47d96fcdb80fc90634c8b1e5817ed58271c816ec Mon Sep 17 00:00:00 2001 From: Chris Phipps Date: Fri, 29 May 2026 18:28:46 -0400 Subject: [PATCH] fixed remaining test issues, updated live.js to meet requirements --- public/live.js | 494 +++++++++++--------- test-live-anims.js | 28 +- test-pr-1490-live-map-gpu-animations-e2e.js | 18 +- 3 files changed, 302 insertions(+), 238 deletions(-) diff --git a/public/live.js b/public/live.js index 170d0031..6521336c 100644 --- a/public/live.js +++ b/public/live.js @@ -1,4 +1,4 @@ -(function() { +(function () { 'use strict'; // getParsedPath / getParsedDecoded are in shared packet-helpers.js (loaded before this file) @@ -12,8 +12,12 @@ let map, ws, nodesLayer, pathsLayer, animLayer, heatLayer, geoFilterLayer, clickablePathsLayer; // New animation canvas let animCanvas, animCtx; + let _dprMedia = null; + let _dprChangeHandler = null; let activeAnimations = []; let isAnimating = false; + let activeFades = []; + let isFading = false; let canvasTopLeft; let clickablePaths = []; const CLICKABLE_PATH_TTL_MS = 30000; @@ -50,7 +54,7 @@ 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(); }); + 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; @@ -76,7 +80,7 @@ if (!iata) return ''; var esc = (typeof escapeHtml === 'function') ? escapeHtml(iata) - : String(iata).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); + : String(iata).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); return '' + esc + ''; } @@ -191,7 +195,7 @@ // 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) { + btns[i].addEventListener('click', function (e) { e.stopPropagation(); var panelId = this.getAttribute('data-panel'); onCornerClick(panelId); @@ -243,7 +247,7 @@ function initResizeHandler() { let resizeTimer = null; - _onResize = function() { + _onResize = function () { clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { // Set live-page height from JS — most reliable across all mobile browsers @@ -309,7 +313,7 @@ // 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; } + if (_vcrHeightCleanup) { try { _vcrHeightCleanup(); } catch (_) { } _vcrHeightCleanup = null; } var bar = document.getElementById('vcrBar'); var page = document.querySelector('.live-page'); if (!bar || !page) return; @@ -326,8 +330,8 @@ if (window.visualViewport) { window.visualViewport.addEventListener('resize', publish); } - _vcrHeightCleanup = function() { - if (ro) { try { ro.disconnect(); } catch (_) {} ro = null; } + _vcrHeightCleanup = function () { + if (ro) { try { ro.disconnect(); } catch (_) { } ro = null; } window.removeEventListener('resize', publish); if (window.visualViewport) { window.visualViewport.removeEventListener('resize', publish); @@ -393,7 +397,7 @@ const pkts = data.packets || []; return expandToBufferEntriesAsync(pkts); }) - .then(function(replayEntries) { + .then(function (replayEntries) { if (gen !== VCR.replayGen) return; // stale async result — user changed mode if (replayEntries.length === 0) { vcrSetMode('PAUSED'); @@ -457,7 +461,7 @@ const filtered = pkts.filter(p => !existingIds.has(p.id)); return expandToBufferEntriesAsync(filtered); }) - .then(function(newEntries) { + .then(function (newEntries) { if (gen !== VCR.replayGen) return; // stale async result VCR.buffer = [].concat(newEntries, VCR.buffer); VCR.playhead = 0; @@ -466,7 +470,7 @@ startReplay(); updateTimeline(); }) - .catch(() => {}); + .catch(() => { }); } function startReplay() { @@ -484,7 +488,7 @@ } } 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')); + 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() { @@ -527,7 +531,7 @@ .then(data => { const pkts = data.packets || []; if (pkts.length === 0) return false; - return expandToBufferEntriesAsync(pkts).then(function(newEntries) { + return expandToBufferEntriesAsync(pkts).then(function (newEntries) { if (gen !== VCR.replayGen) return false; // stale VCR.buffer = VCR.buffer.concat(newEntries); return true; @@ -558,12 +562,12 @@ 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 (_) {} + try { clickablePaths[i].poly.remove(); } catch (_) { } clickablePaths.splice(i, 1); } } while (clickablePaths.length > CLICKABLE_PATH_MAX) { - try { clickablePaths[0].poly.remove(); } catch (_) {} + try { clickablePaths[0].poly.remove(); } catch (_) { } clickablePaths.shift(); } } @@ -575,7 +579,7 @@ clickablePaths.push(entry); pruneClickablePaths(Date.now()); let dismissTimer = null; - poly.on('click', function(e) { + 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' }) @@ -607,28 +611,28 @@ // 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 + '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; + 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 + 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(); + 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) { @@ -641,13 +645,13 @@ 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++) { @@ -744,7 +748,7 @@ } if (speedBtn) { speedBtn.textContent = speedLabel(VCR.speed); speedBtn.setAttribute('aria-label', 'Speed ' + speedLabel(VCR.speed)); } updateVCRLcd(); - + // WAKE THE ENGINE: If we unpaused or changed speed above 0, kickstart the canvas wakeCanvasEngine(); } @@ -772,7 +776,7 @@ */ var VCR_CHUNK_SIZE = 200; function expandToBufferEntriesAsync(pkts) { - return new Promise(function(resolve) { + return new Promise(function (resolve) { var entries = []; var i = 0; function processChunk() { @@ -873,11 +877,13 @@ 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) }; + 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 { @@ -905,7 +911,7 @@ VCR.timelineTimestamps = timestamps.map(t => new Date(t).getTime()); VCR.timelineFetchedScope = scopeMs; } - } catch(e) { /* ignore */ } + } catch (e) { /* ignore */ } } function updateTimeline() { @@ -1180,12 +1186,12 @@ 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 {} + } catch { } map = L.map('liveMap', { - zoomControl: false, + zoomControl: false, attributionControl: false, - zoomAnimation: true, + zoomAnimation: true, markerZoomAnimation: true, preferCanvas: true }).setView(mapCenter, mapZoom); @@ -1202,7 +1208,7 @@ // We intentionally sandwich this pane at 625 so flying packets draw // visually OVER the static nodes, but safely UNDER tooltips and popups. map.getPane('animationsPane').style.zIndex = 625; - + // Ensure mouse events pass through to the markers/map below map.getPane('animationsPane').style.pointerEvents = 'none'; @@ -1210,44 +1216,55 @@ animCanvas = document.createElement('canvas'); // Leaflet uses translate3d for positioning, so we use absolute positioning but rely on L.DomUtil animCanvas.style.cssText = 'position:absolute; pointer-events:none;'; - + map.getPane('animationsPane').appendChild(animCanvas); animCtx = animCanvas.getContext('2d'); // 3. The Leaflet-native positioning function function updateAnimCanvas() { - if (!animCanvas || !map) return; - - // Add a 20% buffer around the visible screen to prevent clipping during short pans - const size = map.getSize(); - const padX = size.x * 0.2; - const padY = size.y * 0.2; - - const w = size.x + padX * 2; - const h = size.y + padY * 2; - - const dpr = window.devicePixelRatio || 1; - - // Updating width/height automatically clears the canvas - animCanvas.width = w * dpr; - animCanvas.height = h * dpr; - animCanvas.style.width = w + 'px'; - animCanvas.style.height = h + 'px'; - - animCtx.setTransform(dpr, 0, 0, dpr, 0, 0); + if (!animCanvas || !map) return; - // Find the top-left Layer Point (relative to the pane, not the screen) - const centerPt = map.latLngToLayerPoint(map.getCenter()); - canvasTopLeft = L.point(centerPt.x - w/2, centerPt.y - h/2); - - // Let Leaflet position the canvas inside the pane using CSS transforms - L.DomUtil.setPosition(animCanvas, canvasTopLeft); + // Add a 20% buffer around the visible screen to prevent clipping during short pans + const size = map.getSize(); + const padX = size.x * 0.2; + const padY = size.y * 0.2; + + const w = size.x + padX * 2; + const h = size.y + padY * 2; + + const dpr = window.devicePixelRatio || 1; + + // Updating width/height automatically clears the canvas + animCanvas.width = w * dpr; + animCanvas.height = h * dpr; + animCanvas.style.width = w + 'px'; + animCanvas.style.height = h + 'px'; + + animCtx.setTransform(dpr, 0, 0, dpr, 0, 0); + + // Find the top-left Layer Point (relative to the pane, not the screen) + const centerPt = map.latLngToLayerPoint(map.getCenter()); + canvasTopLeft = L.point(centerPt.x - w / 2, centerPt.y - h / 2); + + // Let Leaflet position the canvas inside the pane using CSS transforms + L.DomUtil.setPosition(animCanvas, canvasTopLeft); } // 4. Hook into Leaflet's transition-end events map.on('moveend zoomend resize', updateAnimCanvas); updateAnimCanvas(); + _dprChangeHandler = () => { + updateAnimCanvas(); + if (_dprMedia) { + _dprMedia.removeEventListener('change', _dprChangeHandler); + } + _dprMedia = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`); + _dprMedia.addEventListener('change', _dprChangeHandler); + }; + _dprMedia = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`); + _dprMedia.addEventListener('change', _dprChangeHandler); + const isDark = document.documentElement.getAttribute('data-theme') === 'dark' || (document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches); // #1420 — multi-provider dark-tile picker. Light mode unchanged. @@ -1255,8 +1272,8 @@ function _liveResolveTile(dark) { if (!dark) return { url: TILE_LIGHT, attribution: '© OpenStreetMap © CartoDB', refUrl: null }; const reg = window.MC_TILE_PROVIDERS || {}; - const id = (typeof window.MC_getDarkTileProvider === 'function') ? window.MC_getDarkTileProvider() : 'carto-dark'; - const p = reg[id] || reg['carto-dark'] || {}; + const id = (typeof window.MC_getDarkTileProvider === 'function') ? window.MC_getDarkTileProvider() : 'carto-dark'; + const p = reg[id] || reg['carto-dark'] || {}; return { url: p.url || p.baseUrl || TILE_DARK, attribution: p.attribution || '© OpenStreetMap © CartoDB', @@ -1280,7 +1297,7 @@ if (typeof window.MC_applyTileFilter === 'function') window.MC_applyTileFilter(); // #1420 parity with map.js — refresh visible attribution credit after provider swap. if (map.attributionControl) { - try { map.attributionControl._update && map.attributionControl._update(); } catch (_) {} + try { map.attributionControl._update && map.attributionControl._update(); } catch (_) { } } } const _liveInitTile = _liveResolveTile(isDark); @@ -1341,7 +1358,7 @@ const packets = Array.isArray(parsed) ? parsed : [parsed]; vcrPause(); // suppress live packets setTimeout(() => renderPacketTree(packets, true), 1500); - } catch {} + } catch { } } else { // replayRecent(); // disabled — live page starts empty, fills from WS } @@ -1396,11 +1413,11 @@ // (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) { + 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 */ }); + }).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 */ }); + regionFilterChangeHandler = RegionFilter.onChange(function () { /* selection persisted by RegionFilter; future packets reflect it */ }); })(); // Node filter input — autocomplete-as-you-type (#1110) @@ -1437,7 +1454,7 @@ const qs = params.toString(); const newHash = base + (qs ? '?' + qs : ''); const newUrl = location.pathname + location.search + newHash; - try { history.replaceState(null, '', newUrl); } catch (_) {} + try { history.replaceState(null, '', newUrl); } catch (_) { } } function selectSuggestion(opt) { @@ -1452,7 +1469,7 @@ 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 (_) {} + try { history.replaceState(null, '', newUrl); } catch (_) { } hideDropdown(); } @@ -1553,7 +1570,7 @@ 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 (_) {} + try { history.replaceState(null, '', newUrl); } catch (_) { } }); } @@ -1718,15 +1735,19 @@ // hidden attribute. At wide viewports the bodies are always shown. (function wireLiveCollapseToggles() { var pairs = [ - { rootId: 'liveHeader', togId: 'liveHeaderToggle', bodyId: 'liveHeaderBody', - showLabel: 'Show live stats', hideLabel: 'Hide live stats' }, - { rootId: 'liveControls', togId: 'liveControlsToggle', bodyId: 'liveControlsBody', - showLabel: 'Show live controls', hideLabel: 'Hide live controls' }, + { + rootId: 'liveHeader', togId: 'liveHeaderToggle', bodyId: 'liveHeaderBody', + showLabel: 'Show live stats', hideLabel: 'Hide live stats' + }, + { + rootId: 'liveControls', togId: 'liveControlsToggle', bodyId: 'liveControlsBody', + showLabel: 'Show live controls', hideLabel: 'Hide live controls' + }, ]; var narrowMql = window.matchMedia('(max-width: 768px)'); function setExpanded(p, expanded) { var root = document.getElementById(p.rootId); - var tog = document.getElementById(p.togId); + var tog = document.getElementById(p.togId); var body = document.getElementById(p.bodyId); if (!root || !tog || !body) return; if (expanded) { @@ -1751,10 +1772,10 @@ // Always expanded; no hidden attr; no collapse class var root = document.getElementById(p.rootId); var body = document.getElementById(p.bodyId); - var tog = document.getElementById(p.togId); + var tog = document.getElementById(p.togId); if (body) body.removeAttribute('hidden'); if (root) { root.classList.remove('is-collapsed'); root.classList.remove('is-expanded'); } - if (tog) { tog.setAttribute('aria-expanded', 'true'); } + if (tog) { tog.setAttribute('aria-expanded', 'true'); } } } } @@ -1895,7 +1916,7 @@ // 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 {} + try { const v = JSON.parse(savedView); map.setView([v.lat, v.lng], v.zoom); } catch { } } map.on('moveend', () => { const c = map.getCenter(); @@ -1957,13 +1978,13 @@ e.stopPropagation(); var open = !menu.hasAttribute('hidden'); if (open) { menu.setAttribute('hidden', ''); moreBtn.setAttribute('aria-expanded', 'false'); } - else { menu.removeAttribute('hidden'); moreBtn.setAttribute('aria-expanded', 'true'); } + else { menu.removeAttribute('hidden'); moreBtn.setAttribute('aria-expanded', 'true'); } }); // Click outside closes the menu. document.addEventListener('click', function (e) { if (menu.hasAttribute('hidden')) return; if (e.target === moreBtn || moreBtn.contains(e.target) || - e.target === menu || menu.contains(e.target)) return; + e.target === menu || menu.contains(e.target)) return; menu.setAttribute('hidden', ''); moreBtn.setAttribute('aria-expanded', 'false'); }); @@ -2070,8 +2091,8 @@ _pruneInterval = setInterval(pruneStaleNodes, 60000); // Refresh relative timestamps in feed every 10 seconds (#701) - _feedTimestampInterval = setInterval(function() { - document.querySelectorAll('.feed-time[data-ts]').forEach(function(el) { + _feedTimestampInterval = setInterval(function () { + document.querySelectorAll('.feed-time[data-ts]').forEach(function (el) { el.innerHTML = formatLiveTimestampHtml(Number(el.dataset.ts)); }); }, 10000); @@ -2180,11 +2201,10 @@ - ${'default_scope' in n ? `` : ''} + ${'default_scope' in n ? `` : ''} ${hasLoc ? `` : ''} ${stats.avgSnr != null ? `` : ''} ${stats.avgHops != null ? `` : ''} @@ -2319,8 +2339,8 @@ 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 {} + 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); } @@ -2446,7 +2466,7 @@ for (const op of group.packets) { let opHops = []; if (op.path_json) { - try { opHops = getParsedPath(op); } catch {} + try { opHops = getParsedPath(op); } catch { } } else if (op.decoded?.path?.hops) { opHops = op.decoded.path.hops; } @@ -2512,8 +2532,8 @@ const svgHtml = (window.makeRoleMarkerSVG ? window.makeRoleMarkerSVG(n.role, null, sizePx) : ''); + '">'); const icon = L.divIcon({ html: svgHtml, @@ -2640,8 +2660,8 @@ // WS-only nodes: remove to prevent unbounded memory growth if (marker) { if (nodesLayer) { - try { nodesLayer.removeLayer(marker); } catch (e) {} - if (marker._highlightRing) try { nodesLayer.removeLayer(marker._highlightRing); } catch (e) {} + try { nodesLayer.removeLayer(marker); } catch (e) { } + if (marker._highlightRing) try { nodesLayer.removeLayer(marker._highlightRing); } catch (e) { } } } delete nodeMarkers[key]; @@ -2672,25 +2692,25 @@ window._liveBuildClickablePathPopupHtml = buildClickablePathPopupHtml; window._livePruneClickablePaths = pruneClickablePaths; window._liveClickablePaths = clickablePaths; - window._liveNodeMarkers = function() { return nodeMarkers; }; - window._liveNodeData = function() { return nodeData; }; - window._liveNodeActivity = function() { return nodeActivity; }; + window._liveNodeMarkers = function () { return nodeMarkers; }; + window._liveNodeData = function () { return nodeData; }; + window._liveNodeActivity = function () { return nodeActivity; }; window._vcrFormatTime = vcrFormatTime; window._liveDbPacketToLive = dbPacketToLive; window._liveExpandToBufferEntries = expandToBufferEntries; window._liveExpandToBufferEntriesAsync = expandToBufferEntriesAsync; window._liveSEG_MAP = SEG_MAP; window._liveBufferPacket = bufferPacket; - window._liveVCR = function() { return VCR; }; + window._liveVCR = function () { return VCR; }; window._liveGetFavoritePubkeys = getFavoritePubkeys; window._livePacketInvolvesFavorite = packetInvolvesFavorite; window._liveIsNodeFavorited = isNodeFavorited; window._livePacketInvolvesFilterNode = packetInvolvesFilterNode; - window._liveGetNodeFilterKeys = function() { return nodeFilterKeys; }; + window._liveGetNodeFilterKeys = function () { return nodeFilterKeys; }; window._livePacketMatchesRegion = packetMatchesRegion; window._liveSetObserverIataMap = setObserverIataMap; window._liveBuildObserverIataMap = buildObserverIataMap; - window._liveGetObserverIataMap = function() { return observerIataMap; }; + window._liveGetObserverIataMap = function () { return observerIataMap; }; window._liveSetNodeFilter = setNodeFilter; window._liveFormatLiveTimestampHtml = formatLiveTimestampHtml; window._liveResolveHopPositions = resolveHopPositions; @@ -2701,17 +2721,18 @@ window._liveVcrSetMode = vcrSetMode; // #1207 test seams: expose production feed mutators so E2E can exercise // the real eviction guard / placeholder re-add path (not a test-local copy). - window._liveAddFeedItem = function(icon, typeName, payload, hops, color, pkt) { + window._liveAddFeedItem = function (icon, typeName, payload, hops, color, pkt) { return addFeedItem(icon, typeName, payload, hops, color, pkt); }; - window._liveRebuildFeedList = function() { return rebuildFeedList(); }; + window._liveRebuildFeedList = function () { return rebuildFeedList(); }; // PR #1490 test seams: Expose internal state for Playwright assertions window._liveDrawAnimatedLine = drawAnimatedLine; window._liveTestSeams = { getAnimCount: () => activeAnimations.length, isAnimating: () => isAnimating, - getPathCount: () => (typeof recentPaths !== 'undefined' ? recentPaths.length : 0) + getPathCount: () => (typeof recentPaths !== 'undefined' ? recentPaths.length : 0), + wake: wakeCanvasEngine }; async function replayRecent() { @@ -2757,7 +2778,7 @@ lastTs = groupTs; } updateTimeline(); - } catch {} + } catch { } } function connectWS() { @@ -2767,10 +2788,10 @@ try { const msg = JSON.parse(e.data); if (msg.type === 'packet') bufferPacket(msg.data); - } catch {} + } catch { } }; ws.onclose = () => setTimeout(connectWS, WS_RECONNECT_MS); - ws.onerror = () => {}; + ws.onerror = () => { }; } // === UNIFIED PACKET RENDERER === @@ -2795,12 +2816,12 @@ } // --- Favorites filter --- - if (showOnlyFavorites && !packets.some(function(p) { return packetInvolvesFavorite(p); })) return; + if (showOnlyFavorites && !packets.some(function (p) { return packetInvolvesFavorite(p); })) return; // --- Node filter --- if (nodeFilterKeys.length) { nodeFilterTotal++; - if (!packets.some(function(p) { return packetInvolvesFilterNode(p, nodeFilterKeys); })) return; + if (!packets.some(function (p) { return packetInvolvesFilterNode(p, nodeFilterKeys); })) return; nodeFilterShown++; updateNodeFilterUI(); } @@ -2820,7 +2841,7 @@ if (h.payloadTypeName === 'ADVERT' && p.pubKey) { var key = p.pubKey; if (!nodeMarkers[key] && p.lat != null && p.lon != null && !(p.lat === 0 && p.lon === 0)) { - var n = { public_key: key, name: p.name || key.slice(0,8), role: p.role || 'unknown', lat: p.lat, lon: p.lon, _liveSeen: Date.now() }; + var n = { public_key: key, name: p.name || key.slice(0, 8), role: p.role || 'unknown', lat: p.lat, lon: p.lon, _liveSeen: Date.now() }; nodeData[key] = n; addNodeMarker(n); if (window.HopResolver) HopResolver.init(Object.values(nodeData)); @@ -2844,7 +2865,7 @@ for (const fp of packets) { let fpHops = []; if (fp.path_json) { - try { fpHops = getParsedPath(fp); } catch {} + try { fpHops = getParsedPath(fp); } catch { } } else if (fp.decoded?.path?.hops) { fpHops = fp.decoded.path.hops; } @@ -2865,10 +2886,10 @@ // --- Rain drops: one per observation --- var baseHops = (decoded.path?.hops || []).length || 1; - packets.forEach(function(rp, i) { + packets.forEach(function (rp, i) { if (i === 0) { addRainDrop(rp); return; } var variedHops = Math.max(1, baseHops + Math.floor(Math.random() * 3) - 1); - setTimeout(function() { addRainDrop(rp, variedHops); }, i * 150); + setTimeout(function () { addRainDrop(rp, variedHops); }, i * 150); }); // --- Extract all unique paths from observations --- @@ -2959,11 +2980,11 @@ var ghost = L.circleMarker(hp.pos, { radius: 3, fillColor: ghostColor, fillOpacity: 0.2, color: color, weight: 1, opacity: 0.3 }).addTo(pathsLayer); - setTimeout((function(g) { return function() { if (pathsLayer.hasLayer(g)) pathsLayer.removeLayer(g); }; })(ghost), GHOST_TIMEOUT_MS); + setTimeout((function (g) { return function () { if (pathsLayer.hasLayer(g)) pathsLayer.removeLayer(g); }; })(ghost), GHOST_TIMEOUT_MS); } } // Remove dashed line after timeout - setTimeout((function(l) { return function() { if (pathsLayer.hasLayer(l)) pathsLayer.removeLayer(l); }; })(line), GHOST_TIMEOUT_MS); + setTimeout((function (l) { return function () { if (pathsLayer.hasLayer(l)) pathsLayer.removeLayer(l); }; })(line), GHOST_TIMEOUT_MS); } // Ghost marker for the final unreached hop var last = hopPositions[hopPositions.length - 1]; @@ -2971,7 +2992,7 @@ var ghostEnd = L.circleMarker(last.pos, { radius: 4, fillColor: ghostColor, fillOpacity: 0.25, color: color, weight: 1, opacity: 0.35 }).addTo(pathsLayer); - setTimeout(function() { if (pathsLayer.hasLayer(ghostEnd)) pathsLayer.removeLayer(ghostEnd); }, GHOST_TIMEOUT_MS); + setTimeout(function () { if (pathsLayer.hasLayer(ghostEnd)) pathsLayer.removeLayer(ghostEnd); }, GHOST_TIMEOUT_MS); } } @@ -2987,7 +3008,7 @@ if (resolvedPath && resolvedPath.length === hops.length && window.HopResolver && HopResolver.ready()) { resolvedMap = HopResolver.resolveFromServer(hops, resolvedPath); // Fill in any null entries from client-side fallback, preserving sender GPS context - var nullHops = hops.filter(function(h, i) { return !resolvedPath[i] && !resolvedMap[h]; }); + var nullHops = hops.filter(function (h, i) { return !resolvedPath[i] && !resolvedMap[h]; }); if (nullHops.length) { var fallback = HopResolver.resolve(nullHops, senderLat, senderLon, null, null, null); for (var k in fallback) resolvedMap[k] = fallback[k]; @@ -3069,7 +3090,7 @@ } if (!animLayer) return; // Audio hook: notify per-hop callback - if (onHop) try { onHop(hopIndex, hopPositions.length, hopPositions[hopIndex]); } catch (e) {} + if (onHop) try { onHop(hopIndex, hopPositions.length, hopPositions[hopIndex]); } catch (e) { } const hp = hopPositions[hopIndex]; const isGhost = hp.ghost; @@ -3143,7 +3164,7 @@ function animatePulse(now) { if (!animLayer) return; if (now - pulseStart > 2000) { - try { animLayer.removeLayer(ring); } catch {} + try { animLayer.removeLayer(ring); } catch { } return; } const elapsed = now - lastPulse; @@ -3152,7 +3173,7 @@ r += 1.5 * ticks; op -= 0.03 * ticks; lastPulse = now; if (op <= 0) { - try { animLayer.removeLayer(ring); } catch {} + try { animLayer.removeLayer(ring); } catch { } return; } try { @@ -3177,10 +3198,10 @@ ringHl.setStyle({ color: color, weight: 3, opacity: 0.95, fillOpacity: 0, fill: false }); ringHl.setRadius(baseSize / 2 + 4); setTimeout(() => { - try { ringHl.setStyle({ opacity: 0.4, weight: 2 }); ringHl.setRadius(baseSize / 2 + 8); } catch (e) {} + try { ringHl.setStyle({ opacity: 0.4, weight: 2 }); ringHl.setRadius(baseSize / 2 + 8); } catch (e) { } }, 200); setTimeout(() => { - try { ringHl.setStyle({ opacity: 0, weight: 0 }); } catch (e) {} + try { ringHl.setStyle({ opacity: 0, weight: 0 }); } catch (e) { } }, 700); } catch (e) { /* circleMarker absent — ignore */ } } @@ -3407,7 +3428,7 @@ // Remove old chars beyond trail length while (charMarkers.length > TRAIL_LEN) { const old = charMarkers.shift(); - try { animLayer.removeLayer(old.marker); } catch {} + try { animLayer.removeLayer(old.marker); } catch { } } // Fade existing chars @@ -3448,8 +3469,8 @@ } 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 {} + for (const cm of charMarkers) try { animLayer.removeLayer(cm.marker); } catch { } + try { pathsLayer.removeLayer(trail); } catch { } charMarkers.length = 0; } else { const op = 1 - ft; @@ -3469,35 +3490,35 @@ function renderAnimations(now) { if (!animCtx) return; - + if (activeAnimations.length === 0) { isAnimating = false; animCtx.clearRect(0, 0, animCanvas.clientWidth, animCanvas.clientHeight); return; } - + const isPaused = VCR.mode === 'PAUSED' || VCR.speed === 0; - + // Clear the canvas for this frame animCtx.clearRect(0, 0, animCanvas.clientWidth, animCanvas.clientHeight); - + for (let i = activeAnimations.length - 1; i >= 0; i--) { const anim = activeAnimations[i]; - + // Safely resume without dt time-jumps. // If lastTick is null (because we were paused), reset it to 'now' so dt is 0. if (anim.lastTick === null) anim.lastTick = now; - + const dt = now - anim.lastTick; anim.lastTick = now; - + // Advance progress only if we are not paused if (!isPaused) { anim.progress += (dt / 660) * VCR.speed; } - + const t = Math.min(1, anim.progress); - + // Use LayerPoint math so coordinates lock to the moving pane const fromLayerPt = map.latLngToLayerPoint(anim.from); const toLayerPt = map.latLngToLayerPoint(anim.to); @@ -3512,64 +3533,101 @@ y: toLayerPt.y - canvasTopLeft.y }; - const currentX = fromPt.x + (toPt.x - fromPt.x) * t; - const currentY = fromPt.y + (toPt.y - fromPt.y) * t; - - // Draw Contrail (glow) - animCtx.beginPath(); - animCtx.moveTo(fromPt.x, fromPt.y); - animCtx.lineTo(currentX, currentY); - animCtx.strokeStyle = anim.contrailColor; - animCtx.lineWidth = 6; - animCtx.globalAlpha = anim.opacity * 0.2; - animCtx.lineCap = 'round'; - animCtx.stroke(); - - // Draw Core Line - animCtx.beginPath(); - animCtx.moveTo(fromPt.x, fromPt.y); - animCtx.lineTo(currentX, currentY); - if (anim.isDashed) { + const W = animCanvas.clientWidth; + const H = animCanvas.clientHeight; + const cull = (fromPt.x < 0 && toPt.x < 0) || (fromPt.x > W && toPt.x > W) || + (fromPt.y < 0 && toPt.y < 0) || (fromPt.y > H && toPt.y > H); + + if (!cull) { + const currentX = fromPt.x + (toPt.x - fromPt.x) * t; + const currentY = fromPt.y + (toPt.y - fromPt.y) * t; + + // Draw Contrail (glow) + animCtx.beginPath(); + animCtx.moveTo(fromPt.x, fromPt.y); + animCtx.lineTo(currentX, currentY); + animCtx.strokeStyle = anim.contrailColor; + animCtx.lineWidth = 6; + animCtx.globalAlpha = anim.opacity * 0.2; + animCtx.lineCap = 'round'; + animCtx.stroke(); + + // Draw Core Line + animCtx.beginPath(); + animCtx.moveTo(fromPt.x, fromPt.y); + animCtx.lineTo(currentX, currentY); + if (anim.isDashed) { animCtx.setLineDash([4, 6]); animCtx.lineWidth = 1.5; - } else { + } else { animCtx.lineWidth = 2; + } + animCtx.strokeStyle = anim.lineColor; + animCtx.globalAlpha = anim.opacity; + animCtx.stroke(); + animCtx.setLineDash([]); // Reset for next draw + + // Draw Leading Dot + animCtx.beginPath(); + animCtx.arc(currentX, currentY, 3.5, 0, Math.PI * 2); + animCtx.fillStyle = anim.hashFill; + animCtx.fill(); + animCtx.lineWidth = 1.5; + animCtx.strokeStyle = anim.hashOutline; + animCtx.stroke(); + animCtx.globalAlpha = 1.0; // Reset } - animCtx.strokeStyle = anim.lineColor; - animCtx.globalAlpha = anim.opacity; - animCtx.stroke(); - animCtx.setLineDash([]); // Reset for next draw - - // Draw Leading Dot - animCtx.beginPath(); - animCtx.arc(currentX, currentY, 3.5, 0, Math.PI * 2); - animCtx.fillStyle = anim.hashFill; - animCtx.fill(); - animCtx.lineWidth = 1.5; - animCtx.strokeStyle = anim.hashOutline; - animCtx.stroke(); - animCtx.globalAlpha = 1.0; // Reset - + // Handle completion if (t >= 1) { - createFadingLeafletLine(anim); - if (anim.onComplete) anim.onComplete(); - activeAnimations.splice(i, 1); + createFadingLeafletLine(anim); + if (anim.onComplete) anim.onComplete(); + activeAnimations.splice(i, 1); } } - + // SLEEP LOGIC: If paused, halt the loop and prepare all animations for a clean wake if (isPaused) { isAnimating = false; for (let i = 0; i < activeAnimations.length; i++) { - activeAnimations[i].lastTick = null; + activeAnimations[i].lastTick = null; } return; // Stop requesting frames. GPU goes to sleep. } - + requestAnimationFrame(renderAnimations); } + function renderFades(now) { + if (activeFades.length === 0) { + isFading = false; + return; + } + for (let i = activeFades.length - 1; i >= 0; i--) { + const f = activeFades[i]; + if (!pathsLayer) continue; + const fadeElapsed = now - f.lastFade; + if (fadeElapsed >= 52) { + const fadeTicks = Math.min(Math.floor(fadeElapsed / 52), 4); + f.lastFade = now; + f.opacity -= 0.1 * fadeTicks; + if (f.opacity <= 0) { + if (pathsLayer) { pathsLayer.removeLayer(f.line); pathsLayer.removeLayer(f.contrail); } + recentPaths = recentPaths.filter(p => p.line !== f.line); + activeFades.splice(i, 1); + } else { + f.line.setStyle({ opacity: f.opacity }); + f.contrail.setStyle({ opacity: f.opacity * 0.15 }); + } + } + } + if (activeFades.length > 0) { + requestAnimationFrame(renderFades); + } else { + isFading = false; + } + } + function createFadingLeafletLine(anim) { if (!pathsLayer) return; @@ -3588,40 +3646,31 @@ while (recentPaths.length > 5) { const old = recentPaths.shift(); if (pathsLayer) { pathsLayer.removeLayer(old.line); pathsLayer.removeLayer(old.glowLine); } + activeFades = activeFades.filter(f => f.line !== old.line); } - // Fade out logic - let fadeOp = anim.opacity; - let lastFade = performance.now(); - function animateFade(now) { - if (!pathsLayer) return; - const fadeElapsed = now - lastFade; - if (fadeElapsed >= 52) { - const fadeTicks = Math.min(Math.floor(fadeElapsed / 52), 4); - lastFade = now; - fadeOp -= 0.1 * fadeTicks; - if (fadeOp <= 0) { - if (pathsLayer) { pathsLayer.removeLayer(line); pathsLayer.removeLayer(contrail); } - recentPaths = recentPaths.filter(p => p.line !== line); - return; - } - line.setStyle({ opacity: fadeOp }); - contrail.setStyle({ opacity: fadeOp * 0.15 }); - } - requestAnimationFrame(animateFade); + activeFades.push({ + line: line, + contrail: contrail, + opacity: anim.opacity, + lastFade: performance.now() + }); + + if (!isFading) { + isFading = true; + requestAnimationFrame(renderFades); } - requestAnimationFrame(animateFade); } function drawAnimatedLine(from, to, color, onComplete, overrideOpacity, rawHex, hash) { // GUARD: Prevent stale callbacks from pushing to a destroyed map - if (!map || !animCtx) { - if (onComplete) onComplete(); - return; + if (!map || !animCtx) { + if (onComplete) onComplete(); + return; } - + if (matrixMode) return drawMatrixLine(from, to, color, onComplete, rawHex); - + const mainOpacity = overrideOpacity ?? 0.8; const isDashed = overrideOpacity != null; @@ -3650,7 +3699,7 @@ hashOutline: hashOutline, onComplete: onComplete }); - + // WAKE LOGIC: Kickstart the loop if it is currently sleeping if (!isAnimating) { isAnimating = true; @@ -3680,7 +3729,7 @@ }).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); } + else { setTimeout(function () { if (heatLayer && heatLayer._canvas) heatLayer._canvas.style.opacity = savedOpacity; }, 100); } window._meshcoreLiveHeatLayer = heatLayer; } } @@ -3902,6 +3951,10 @@ } function destroy() { + activeAnimations.length = 0; + activeFades.length = 0; + isAnimating = false; + isFading = false; stopReplay(); if (_timelineRefreshInterval) { clearInterval(_timelineRefreshInterval); _timelineRefreshInterval = null; } if (_lcdClockInterval) { clearInterval(_lcdClockInterval); _lcdClockInterval = null; } @@ -3920,7 +3973,7 @@ window.removeEventListener('orientationchange', _onResize); if (window.visualViewport) window.visualViewport.removeEventListener('resize', _onResize); } - if (_vcrHeightCleanup) { try { _vcrHeightCleanup(); } catch (_) {} _vcrHeightCleanup = null; } + if (_vcrHeightCleanup) { try { _vcrHeightCleanup(); } catch (_) { } _vcrHeightCleanup = null; } // Restore #app height to CSS default const appEl = document.getElementById('app'); if (appEl) appEl.style.height = ''; @@ -3951,9 +4004,19 @@ // CLEANUP: Kill the canvas loop, dump the queue, and clear the screen activeAnimations.length = 0; + activeFades.length = 0; isAnimating = false; + isFading = false; if (animCtx && animCanvas) { animCtx.clearRect(0, 0, animCanvas.clientWidth, animCanvas.clientHeight); + animCanvas.remove(); + animCanvas = null; + animCtx = null; + } + if (_dprMedia && _dprChangeHandler) { + _dprMedia.removeEventListener('change', _dprChangeHandler); + _dprMedia = null; + _dprChangeHandler = null; } } @@ -3966,9 +4029,16 @@ // across re-mounts. window.__liveMQLBindCount is a debug seam consumed by // test-live-mql-leak-1180-e2e.js and otherwise unused. var _liveNarrowMqlBound = false; + window._liveTestSeams = window._liveTestSeams || {}; + window._liveTestSeams.wake = function() { + if (!isAnimating) { + isAnimating = true; + requestAnimationFrame(renderAnimations); + } + }; registerPage('live', { - init: function(app, routeParam) { + init: function (app, routeParam) { _themeRefreshHandler = () => { rebuildFeedList(); if (activeNodeDetailKey) showNodeDetail(activeNodeDetailKey); @@ -3979,7 +4049,7 @@ if (window.ChannelColorPicker) window.ChannelColorPicker.installLiveFeed(); return result; }, - destroy: function() { + destroy: function () { if (_themeRefreshHandler) { window.removeEventListener('theme-refresh', _themeRefreshHandler); _themeRefreshHandler = null; } return destroy(); } diff --git a/test-live-anims.js b/test-live-anims.js index 3e405d73..31b4f4e8 100644 --- a/test-live-anims.js +++ b/test-live-anims.js @@ -23,15 +23,15 @@ test('Canvas array, flag, and loop are implemented', () => { test('Leaflet integration and resizing handles DPR correctly', () => { assert.ok(src.includes('map.latLngToLayerPoint'), 'Map de-sync regression: Canvas not using LayerPoints for CSS transform sync'); assert.ok(src.includes('animCtx.setTransform(dpr, 0, 0, dpr, 0, 0)'), 'HiDPI regression: resizeAnimCanvas does not reset transform matrix'); - assert.ok(src.includes('matchMedia'), 'Monitor drag regression: DPR listener missing'); + assert.ok(src.includes('matchMedia(`(resolution'), 'Monitor drag regression: DPR listener missing'); }); console.log('\n=== Battery / GPU Optimization (State Machine) ==='); test('Engine gracefully sleeps and wakes', () => { assert.ok(src.includes('isAnimating = false'), 'Battery drain regression: Engine does not explicitly sleep on empty queue'); - assert.ok(src.includes('window._wakeCanvasEngine'), 'Wake controller missing: _wakeCanvasEngine not exposed'); - assert.ok(src.includes('anim.lastTick = null'), 'Time-jump regression: Engine does not nullify tick on pause'); + assert.ok(src.includes('_liveTestSeams.wake'), 'Wake controller missing: wake not exposed on _liveTestSeams'); + assert.ok(src.includes('.lastTick = null'), 'Time-jump regression: Engine does not nullify tick on pause'); }); console.log('\n=== Concurrency cap ==='); @@ -64,7 +64,7 @@ test('Destroy function cleanly halts canvas engine', () => { test('Entry points guard against destroyed map', () => { const lineStart = src.indexOf('function drawAnimatedLine('); - const lineBody = src.substring(lineStart, lineStart + 150); + const lineBody = src.substring(lineStart, lineStart + 250); assert.ok(lineBody.includes('!map || !animCtx'), 'Race condition: drawAnimatedLine missing map/ctx null guard'); }); @@ -74,14 +74,14 @@ test('no setInterval remains in animation hot path', () => { // The only acceptable setIntervals are the UI ones (timeline, clock, prune, rate counter) // Count total setInterval occurrences const matches = src.match(/setInterval\(/g) || []; - // Count known OK ones: _timelineRefreshInterval, _lcdClockInterval, _pruneInterval, _rateCounterInterval - const okPatterns = ['_timelineRefreshInterval', '_lcdClockInterval', '_pruneInterval', '_rateCounterInterval']; + // Count known OK ones: _timelineRefreshInterval, _lcdClockInterval, _pruneInterval, _rateCounterInterval, _affinityInterval + const okPatterns = ['_timelineRefreshInterval', '_lcdClockInterval', '_pruneInterval', '_rateCounterInterval', '_affinityInterval']; let okCount = 0; for (const p of okPatterns) { if (src.includes(p + ' = setInterval') || src.includes(p + '= setInterval')) okCount++; } // Allow some non-animation setIntervals (the 4 UI ones above) - assert.ok(matches.length <= okCount + 1, + assert.ok(matches.length <= okCount + 1, `Found ${matches.length} setInterval calls, expected at most ${okCount + 1} (non-animation). Some animation setIntervals may remain.`); }); @@ -111,16 +111,10 @@ test2('animatePath fadeOut() has null guard', () => { assert.ok(fadeOutBody.includes('!animLayer || !pathsLayer'), 'fadeOut() missing animLayer/pathsLayer null guard'); }); -test2('drawAnimatedLine animateLine() has null guard', () => { - const lineStart = src2.indexOf('function animateLine(now)'); - const lineBody = src2.substring(lineStart, lineStart + 200); - assert.ok(lineBody.includes('!animLayer || !pathsLayer'), 'animateLine() missing animLayer/pathsLayer null guard'); -}); - -test2('drawAnimatedLine animateFade() has null guard', () => { - const fadeStart = src2.indexOf('function animateFade(now)'); - const fadeBody = src2.substring(fadeStart, fadeStart + 200); - assert.ok(fadeBody.includes('!pathsLayer'), 'animateFade() missing pathsLayer null guard'); +test2('drawAnimatedLine renderFades() has null guard', () => { + const fadeStart = src2.indexOf('function renderFades(now)'); + const fadeBody = src2.substring(fadeStart, fadeStart + 300); + assert.ok(fadeBody.includes('!pathsLayer'), 'renderFades() missing pathsLayer null guard'); }); test2('pulseNode animatePulse() has null guard', () => { diff --git a/test-pr-1490-live-map-gpu-animations-e2e.js b/test-pr-1490-live-map-gpu-animations-e2e.js index 428d640e..e659842d 100644 --- a/test-pr-1490-live-map-gpu-animations-e2e.js +++ b/test-pr-1490-live-map-gpu-animations-e2e.js @@ -3,7 +3,7 @@ const { test, expect } = require('@playwright/test'); test.describe('Live Map Canvas Animation Engine', () => { test('canvas initializes and animations drain correctly', async ({ page }) => { // 1. Load the map route - await page.goto('/#/map'); + await page.goto('/#/live'); // Ensure the map container has loaded const mapContainer = page.locator('#liveMap'); @@ -19,15 +19,15 @@ test.describe('Live Map Canvas Animation Engine', () => { await page.evaluate((count) => { // Ensure the VCR speed is at standard 1x for predictable timing if (window._liveVcrSetMode) window._liveVcrSetMode('LIVE'); - + for (let i = 0; i < count; i++) { window._liveDrawAnimatedLine( - [37.4, -122.0], - [37.5, -122.1], - '#00ff00', - null, - null, - '00AA', + [37.4, -122.0], + [37.5, -122.1], + '#00ff00', + null, + null, + '00AA', 'test-hash-' + i ); } @@ -48,7 +48,7 @@ test.describe('Live Map Canvas Animation Engine', () => { return await page.evaluate(() => window._liveTestSeams.getAnimCount()); }, { message: 'activeAnimations did not drain to 0 within 2x duration', - timeout: 1500, + timeout: 1500, }).toBe(0); // 5. Assert the engine gracefully went back to sleep
Last Seen${lastSeen}
Adverts${n.advert_count || 0}
Scope${ - n.default_scope === null ? '' - : n.default_scope === '' ? 'unknown scope' - : `${escapeHtml(n.default_scope)}` -}
Scope${n.default_scope === null ? '' + : n.default_scope === '' ? 'unknown scope' + : `${escapeHtml(n.default_scope)}` + }
Location${n.lat.toFixed(5)}, ${n.lon.toFixed(5)}
Avg SNR${stats.avgSnr.toFixed(1)} dB
Avg Hops${stats.avgHops.toFixed(1)}