web: keep map overlays open and repaint only what changed (#881) (#988)

This commit is contained in:
Afri authored and GitHub committed 2026-10-07 10:57:58 +02:00
1 parent aeabeec7e3
commit eae3f75162
11 files changed
+2537 -52

No files matched your search

+1
View File
@@ -20,6 +20,7 @@
* Matrix: the bridge crate declares its crates.io package metadata, and its README screenshot no longer links outside the crate (#879)
* Data: `TRANSPORT=udp` listens on both Meshtastic multicast groups by default, `239.0.0.69` (firmware 2.8+) and `224.0.0.69`; `MESH_UDP_GROUP` takes a comma-separated list. If you set `MESH_UDP_GROUP=224.0.0.69` before, remove it to also hear firmware 2.8+ nodes (#903)
* Data: `MESHCORE_TELEMETRY_POLL_24H_EXEMPT` reaches every packaged deployment - `docker-compose.yml`, both `data/Dockerfile` stages and the NixOS module (`meshcoreTelemetryPoll24hExempt`); setting it in `.env` had no effect under Compose or NixOS (SPEC PX5)
* Web: live refreshes keep map overlays, line tooltips and waypoint cards open, and repaint only the table, map or chat whose data changed (#881, SPEC DR2/DR4)
* Docs: the README documents `MESHTASTIC_PSK_B64` (SPEC PX7)
* CI: the env-surface check runs in pytest whenever a deployment surface changes, and also checks names with digits, names read through helpers, and retired names (SPEC PX6/PX8)
* CI: test results reach Codecov also when tests fail; workflows use codecov-action v7 and checkout v7; Dependabot opens up to 20 GitHub Actions update PRs at a time (SPEC DW1/DW2/DW4)
@@ -0,0 +1,348 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Harness for the #881 map and refresh tests (SPEC DR2/DR4).
*
* Boots `initializeApp` over a fake `EventSource` and a stub `fetch` with the
* three surfaces a refresh can repaint: a `#nodes tbody` that counts its
* rebuilds, a `#chat` container, and a Leaflet stub whose markers, pins and
* lines own element stand-ins that leave the document when their layer group
* is cleared — the browser behaviour that closes an overlay anchored to them.
* The overlay template yields tracked overlay elements, so a test sees which
* short-info overlays are open and can press their close buttons.
*
* Kept beside the two suites that use it instead of widening the shared
* `main-app-leaflet-stub.js` / `sse-app-harness.js` helpers, which it wraps.
*
* @module __tests__/live-map-harness
*/
import { createDomEnvironment } from './dom-environment.js';
import { makeLeafletStub } from './main-app-leaflet-stub.js';
import { SSE_BASE_CONFIG, makeFakeEventSource } from './sse-app-harness.js';
import { initializeApp } from '../main.js';
/** Unix seconds at module load; fixtures are stamped relative to it. */
export const NOW = Math.floor(Date.now() / 1000);
/**
* Resolve after ``ms`` milliseconds, letting overlay positioning timers and
* coalesced repaints run.
*
* @param {number} [ms=40] Delay.
* @returns {Promise<void>}
*/
export function settle(ms = 40) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Give a Leaflet stub layer an element stand-in that counts as in the
* document while the layer sits in a layer group (cleared groups detach it).
*
* @param {Object} layer Stub marker or polyline.
* @returns {Object} The same layer.
*/
function attachElement(layer) {
const element = {
layer,
getBoundingClientRect: () => ({ left: 10, top: 10, width: 8, height: 8 }),
__connected: () => Boolean(layer._addedTo && layer._addedTo._layers.includes(layer)),
};
layer.getElement = () => element;
// Leaflet 1.9's tooltip surface on a layer with no tooltip bound:
// getTooltip() is undefined, openTooltip() is a no-op, and isTooltipOpen()
// dereferences the missing tooltip and throws.
layer.getTooltip = () => undefined;
layer.openTooltip = () => layer;
layer.isTooltipOpen = () => {
throw new TypeError("Cannot read properties of undefined (reading 'isOpen')");
};
return layer;
}
/**
* Give a stub polyline Leaflet's event and tooltip surface.
*
* @param {Object} line Stub polyline.
* @returns {Object} The same line.
*/
function addLineSurface(line) {
const handlers = new Map();
let tooltip = null;
line._eventHandlers = handlers;
line.on = (event, handler) => {
if (!handlers.has(event)) handlers.set(event, []);
handlers.get(event).push(handler);
return line;
};
line.bindTooltip = (html, options) => {
tooltip = { html, options, open: false, latLng: null };
tooltip.getLatLng = () => tooltip.latLng;
return line;
};
line.getTooltip = () => tooltip || undefined;
// As in Leaflet, asking a line with no bound tooltip throws.
line.isTooltipOpen = () => tooltip.open;
line.openTooltip = latLng => {
if (!tooltip) return line;
tooltip.open = true;
tooltip.latLng = latLng || { lat: 0, lng: 0, centre: true };
return line;
};
line.closeTooltip = () => {
tooltip.open = false;
return line;
};
return line;
}
/**
* Build a Leaflet stub whose layers carry elements and line tooltips.
*
* @returns {Object} Leaflet stub (``_recorded`` lists every created layer).
*/
function makeLiveLeaflet() {
const leaflet = makeLeafletStub();
const basePolyline = leaflet.polyline;
const baseCircle = leaflet.circleMarker;
const baseMarker = leaflet.marker;
leaflet.polyline = (latLngs, options) => addLineSurface(attachElement(basePolyline(latLngs, options)));
leaflet.circleMarker = (latLng, options) => attachElement(baseCircle(latLng, options));
leaflet.marker = (latLng, options) => attachElement(baseMarker(latLng, options));
return leaflet;
}
/**
* Replace the overlay template with a factory of tracked overlay elements.
*
* @param {Object} env DOM environment from {@link createDomEnvironment}.
* @returns {Array<Object>} Every overlay element created, in order.
*/
function installOverlayTemplate(env) {
const body = env.document.body;
const created = [];
/**
* One overlay element with a close button and a content node.
*
* @returns {Object} Overlay element stand-in.
*/
function makeOverlay() {
const closeButton = {
listeners: [],
addEventListener(type, handler) {
if (type === 'click') closeButton.listeners.push(handler);
},
};
const content = { innerHTML: '' };
const overlay = {
style: {},
removed: false,
parentNode: null,
attributes: new Map(),
closeButton,
content,
querySelector: selector => {
if (selector === '.short-info-close') return closeButton;
if (selector === '.short-info-content') return content;
return null;
},
setAttribute(name, value) {
overlay.attributes.set(name, String(value));
},
removeAttribute(name) {
overlay.attributes.delete(name);
},
getBoundingClientRect: () => ({ left: 0, top: 0, width: 120, height: 60 }),
contains: node => node === overlay || node === closeButton || node === content,
remove() {
overlay.removed = true;
overlay.parentNode = null;
const index = body.childNodes.indexOf(overlay);
if (index >= 0) body.childNodes.splice(index, 1);
},
};
created.push(overlay);
return overlay;
}
env.registerElement('shortInfoOverlayTemplate', {
content: { firstElementChild: { cloneNode: () => makeOverlay() } },
});
// Appending sets parentNode, as the DOM does, so the stack attaches once.
const baseAppend = body.appendChild.bind(body);
body.appendChild = node => {
if (node && typeof node === 'object') node.parentNode = body;
return baseAppend(node);
};
// A layer's element is in the document while its layer is on the map.
body.contains = node => {
if (!node) return false;
if (typeof node.__connected === 'function') return node.__connected();
return body.childNodes.includes(node);
};
return created;
}
/**
* A stub ``fetch`` that answers from a URL-substring map and, like the
* network, hands out a fresh parse of the body on every request: the shared
* stub returns the body object itself, so a test editing a response would
* edit the dashboard's merged state behind its back.
*
* @param {Object<string, *>} responses URL substring to JSON body, read at
* request time.
* @param {?Function} fetchOverride Hook answering first when it returns a
* promise.
* @returns {{ fetch: Function, calls: Array<{ url: string }> }} The stub and
* its request log.
*/
function buildCloningFetch(responses, fetchOverride) {
const calls = [];
/** @param {*} body JSON body. @returns {Promise<Object>} A 200 response parsing a fresh copy. */
const respond = body => Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(JSON.parse(JSON.stringify(body))),
});
return {
calls,
fetch(url) {
calls.push({ url });
const answer = typeof fetchOverride === 'function' ? fetchOverride(url) : undefined;
if (answer !== undefined) return answer;
for (const [prefix, body] of Object.entries(responses)) {
if (url.includes(prefix)) return respond(body);
}
return respond([]);
},
};
}
/**
* Invoke a stub Leaflet layer's ``click`` handlers with a Leaflet-shaped event.
*
* @param {Object} layer Marker or polyline from the stub.
* @returns {number} How many handlers ran.
*/
export function clickLayer(layer) {
const handlers = (layer._eventHandlers && layer._eventHandlers.get('click')) || [];
const event = { originalEvent: { preventDefault() {}, stopPropagation() {}, target: null } };
for (const handler of handlers) handler(event);
return handlers.length;
}
/**
* Boot the dashboard with a map, a nodes table and a chat, run ``fn``, then
* tear everything down.
*
* @param {{
* responses: Object<string, *>,
* fetchOverride?: (url: string) => (Promise<Object>|undefined),
* configOverrides?: Object,
* beforeBoot?: (env: Object) => void,
* }} options Stub-fetch payloads (URL substring to JSON body, read at request
* time so a test can edit them between refreshes), an optional fetch hook
* that answers first, config overrides, and a hook that registers extra
* elements before the app boots.
* @param {(ctx: Object) => Promise<void>} fn Test body; receives ``testUtils``,
* ``leaflet``, ``tbody``, ``chat``, ``calls``, ``stream``, ``overlays()``
* (open overlay elements), ``tableRepaints()`` and ``ping(...collections)``.
* @returns {Promise<void>}
*/
export async function runMapApp({ responses, fetchOverride, configOverrides = {}, beforeBoot }, fn) {
const env = createDomEnvironment({ includeBody: true });
env.window.matchMedia = () => ({ matches: false, media: '', addEventListener() {}, removeEventListener() {} });
if (typeof beforeBoot === 'function') beforeBoot(env);
const overlaysCreated = installOverlayTemplate(env);
env.registerElement('map', env.createElement('div', 'map'));
const chat = env.createElement('div', 'chat');
env.registerElement('chat', chat);
const tbody = env.document.createElement('tbody');
let tableRepaints = 0;
const baseReplace = tbody.replaceChildren.bind(tbody);
tbody.replaceChildren = (...nodes) => {
tableRepaints += 1;
return baseReplace(...nodes);
};
const table = env.document.createElement('table');
table.querySelector = selector => (selector === 'tbody' ? tbody : null);
table.querySelectorAll = () => [];
env.registerElement('nodes', table);
env.document.querySelector = selector => (selector === '#nodes tbody' ? tbody : null);
const leaflet = makeLiveLeaflet();
const saved = {
fetch: globalThis.fetch,
EventSource: globalThis.EventSource,
indexedDB: globalThis.indexedDB,
L: globalThis.L,
};
const stub = buildCloningFetch(responses, fetchOverride);
globalThis.fetch = stub.fetch;
globalThis.indexedDB = undefined;
const FakeEventSource = makeFakeEventSource();
globalThis.EventSource = FakeEventSource;
env.window.L = leaflet;
globalThis.L = leaflet;
let testUtils = null;
try {
({ _testUtils: testUtils } = initializeApp({ ...SSE_BASE_CONFIG, ...configOverrides }));
await testUtils.initialLoad;
await testUtils.flushBackfill();
await settle();
const stream = FakeEventSource.instances[0];
await fn({
env,
testUtils,
leaflet,
tbody,
chat,
stream,
calls: stub.calls,
overlays: () => overlaysCreated.filter(overlay => !overlay.removed),
tableRepaints: () => tableRepaints,
/**
* Deliver SSE change pings and wait for their refresh and timers.
*
* @param {...string} collections Changed collection names.
* @returns {Promise<void>}
*/
async ping(...collections) {
for (const collection of collections) {
stream.dispatch('change', { data: JSON.stringify({ collection }) });
}
await testUtils.flushLiveRefresh();
await settle();
},
});
} finally {
if (testUtils) {
// Let fire-and-forget tails (the stats callback, the cache write) run
// against the live DOM before it is torn down.
await testUtils.flushCacheWrites();
await settle(20);
testUtils.stopAutoRefresh();
}
globalThis.fetch = saved.fetch;
if (saved.EventSource === undefined) delete globalThis.EventSource;
else globalThis.EventSource = saved.EventSource;
globalThis.indexedDB = saved.indexedDB;
if (saved.L === undefined) delete globalThis.L;
else globalThis.L = saved.L;
env.cleanup();
}
}
@@ -0,0 +1,280 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* SPEC DR2 (#881): the map may rebuild, but every open overlay or tooltip
* moves to its rebuilt anchor, keyed by node id, segment key or waypoint key.
*
* Each test opens something on the map, lands a live refresh that changes
* node data (so the map really rebuilds: every marker, line and pin is a new
* layer with a new element, and the old elements leave the document), and
* checks that what the reader opened is still open on the new layer. Before
* DR2 only marker overlays survived (LD-A3): a neighbour-line overlay, a line
* tooltip and a waypoint card closed, the close button of a moved marker
* overlay did nothing, and node details that arrived after a rebuild never
* replaced "Loading…".
*
* @module app/__tests__/main-map-overlay-anchors
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { NOW, clickLayer, runMapApp, settle } from './live-map-harness.js';
/**
* Two positioned nodes, a neighbour link and a trace between them, and one
* waypoint.
*
* @returns {Object<string, *>} Stub-fetch responses a test may edit.
*/
function mapResponses() {
return {
'encrypted=true': [],
'/api/nodes': [
{ node_id: '!a', short_name: 'A', long_name: 'Node A', role: 'CLIENT', protocol: 'meshtastic', last_heard: NOW - 30, latitude: 52.5, longitude: 13.4 },
{ node_id: '!b', short_name: 'B', long_name: 'Node B', role: 'ROUTER', protocol: 'meshtastic', last_heard: NOW - 60, latitude: 52.51, longitude: 13.42 },
],
'/api/messages': [],
'/api/neighbors': [{ node_id: '!a', neighbor_id: '!b', snr: 6.5, rx_time: NOW - 40 }],
'/api/traces': [{ id: 77, src: '!a', dest: '!b', hops: [], rx_time: NOW - 50 }],
'/api/waypoints': [
{ id: 5, protocol: 'meshtastic', name: 'Probe POI', node_id: '!a', latitude: 52.52, longitude: 13.41, expire: 0, rx_time: NOW - 45 },
],
};
}
/**
* Change node B so the next nodes ping rebuilds the map.
*
* @param {Object<string, *>} responses Stub-fetch responses.
* @param {number} battery New battery level.
* @returns {void}
*/
function touchNodeB(responses, battery) {
responses['/api/nodes'][1] = { ...responses['/api/nodes'][1], battery_level: battery };
}
/**
* The newest polyline the map drew with ``className``.
*
* @param {Object} leaflet Leaflet stub.
* @param {string} className Exact class list of the line.
* @returns {Object} Stub polyline.
*/
function newestLine(leaflet, className) {
return leaflet._recorded.polylines.filter(line => line.options.className === className).at(-1);
}
/**
* The newest node marker drawn at ``lat``/``lon``.
*
* @param {Object} leaflet Leaflet stub.
* @param {number} lat Latitude.
* @param {number} lon Longitude.
* @returns {Object} Stub circle marker.
*/
function newestMarkerAt(leaflet, lat, lon) {
return leaflet._recorded.circleMarkers.filter(marker => marker._latLng[0] === lat && marker._latLng[1] === lon).at(-1);
}
/**
* The newest waypoint pin (pins stack above node markers with offset 500).
*
* @param {Object} leaflet Leaflet stub.
* @returns {Object} Stub marker.
*/
function newestPin(leaflet) {
return leaflet._recorded.markers.filter(marker => marker.options.zIndexOffset === 500).at(-1);
}
/**
* Press an overlay's close (×) button.
*
* @param {Object} overlay Tracked overlay element.
* @returns {void}
*/
function pressClose(overlay) {
for (const handler of overlay.closeButton.listeners) handler({ preventDefault() {}, stopPropagation() {} });
}
const NEIGHBOR_LINE = 'neighbor-connection-line';
const TRACE_LINE = 'neighbor-connection-line trace-connection-line';
test('an open neighbour-line overlay and its tooltip follow the rebuilt line (DR2)', async () => {
const responses = mapResponses();
await runMapApp({ responses }, async ctx => {
const line = newestLine(ctx.leaflet, NEIGHBOR_LINE);
clickLayer(line);
await settle();
const [overlay] = ctx.overlays();
assert.ok(overlay, 'clicking the line opens its overlay');
assert.equal(line.isTooltipOpen(), true, 'and its tooltip');
touchNodeB(responses, 55);
await ctx.ping('nodes');
const rebuiltLine = newestLine(ctx.leaflet, NEIGHBOR_LINE);
assert.notEqual(rebuiltLine, line, 'the map rebuilt the line');
assert.equal(line.getElement().__connected(), false, 'the old line left the document');
assert.equal(ctx.overlays().length, 1, 'the overlay stays open');
assert.equal(ctx.overlays()[0], overlay, 'the same overlay, not a new one');
assert.equal(rebuiltLine.isTooltipOpen(), true, 'the tooltip reopens on the rebuilt line');
assert.deepEqual(rebuiltLine.getTooltip().latLng, line.getTooltip().latLng, 'where it stood');
// The overlay is anchored to the rebuilt line: clicking it toggles it shut.
clickLayer(rebuiltLine);
assert.equal(ctx.overlays().length, 0, 'clicking the rebuilt line toggles its overlay shut');
});
});
test('an open trace-line tooltip follows its rebuilt hop line (DR2)', async () => {
const responses = mapResponses();
await runMapApp({ responses }, async ctx => {
const line = newestLine(ctx.leaflet, TRACE_LINE);
clickLayer(line);
assert.equal(line.isTooltipOpen(), true);
touchNodeB(responses, 56);
await ctx.ping('nodes');
const rebuiltLine = newestLine(ctx.leaflet, TRACE_LINE);
assert.notEqual(rebuiltLine, line);
assert.equal(rebuiltLine.isTooltipOpen(), true, 'the trace tooltip reopens on the rebuilt hop');
});
});
test('an open waypoint card follows the rebuilt pin (DR2)', async () => {
const responses = mapResponses();
await runMapApp({ responses }, async ctx => {
const pin = newestPin(ctx.leaflet);
clickLayer(pin);
await settle();
const [card] = ctx.overlays();
assert.ok(card && card.content.innerHTML.includes('Probe POI'), 'clicking the pin opens its card');
touchNodeB(responses, 57);
await ctx.ping('nodes');
const rebuiltPin = newestPin(ctx.leaflet);
assert.notEqual(rebuiltPin, pin, 'the map rebuilt the pin');
assert.equal(ctx.overlays().length, 1, 'the card stays open');
assert.equal(ctx.overlays()[0], card, 'the same card, not a new one');
clickLayer(rebuiltPin);
assert.equal(ctx.overlays().length, 0, 'clicking the rebuilt pin toggles its card shut');
});
});
test('node details that arrive after a rebuild land on the moved marker overlay (DR2, LD-A3)', async () => {
const responses = mapResponses();
let release;
const held = new Promise(resolve => {
release = resolve;
});
/** Hold the marker's node lookup until after the rebuild. @param {string} url Request URL. */
const fetchOverride = url => (String(url).startsWith('/api/nodes/!a')
? held.then(() => ({ ok: true, status: 200, json: () => Promise.resolve([{ ...responses['/api/nodes'][0] }]) }))
: undefined);
await runMapApp({ responses, fetchOverride }, async ctx => {
const marker = newestMarkerAt(ctx.leaflet, 52.5, 13.4);
clickLayer(marker);
await settle();
assert.match(ctx.overlays()[0].content.innerHTML, /Loading/);
touchNodeB(responses, 58);
await ctx.ping('nodes');
assert.notEqual(newestMarkerAt(ctx.leaflet, 52.5, 13.4), marker, 'the map rebuilt the marker');
assert.equal(ctx.overlays().length, 1, 'LD-A3 keeps the loading overlay open');
release();
await settle(60);
const open = ctx.overlays();
assert.equal(open.length, 1);
assert.doesNotMatch(open[0].content.innerHTML, /Loading/, 'the details replaced "Loading…"');
assert.match(open[0].content.innerHTML, /Node A/);
// ... on the rebuilt marker, in its freshness pane (PD3).
const rebuiltMarker = newestMarkerAt(ctx.leaflet, 52.5, 13.4);
assert.equal(rebuiltMarker.options.pane, 'markers-live');
clickLayer(rebuiltMarker);
assert.equal(ctx.overlays().length, 0, 'clicking the rebuilt marker toggles its overlay shut');
});
});
test('the close button closes a marker overlay that a rebuild moved (DR2)', async () => {
const responses = mapResponses();
await runMapApp({ responses }, async ctx => {
const marker = newestMarkerAt(ctx.leaflet, 52.5, 13.4);
clickLayer(marker);
await settle(60);
touchNodeB(responses, 59);
await ctx.ping('nodes');
assert.notEqual(newestMarkerAt(ctx.leaflet, 52.5, 13.4), marker, 'the map rebuilt the marker');
const [overlay] = ctx.overlays();
assert.ok(overlay, 'the marker overlay survived the rebuild (LD-A3)');
pressClose(overlay);
assert.equal(ctx.overlays().length, 0, 'the × closes it where it now stands');
});
});
test('an overlay on a fanned-out colocated marker follows the rebuilt member (DR2)', async () => {
const responses = mapResponses();
// B joins A's coordinate: a two-node colocated group behind a hub badge.
responses['/api/nodes'][1] = { ...responses['/api/nodes'][1], latitude: 52.5, longitude: 13.4 };
await runMapApp({ responses }, async ctx => {
const hub = ctx.leaflet._recorded.markers.filter(marker => marker.options.bubblingMouseEvents === false).at(-1);
clickLayer(hub);
const fanned = ctx.leaflet._recorded.circleMarkers.slice(-2);
assert.equal(fanned.length, 2, 'the expanded hub fans both members out');
const member = fanned[0];
clickLayer(member);
await settle(60);
assert.equal(ctx.overlays().length, 1);
touchNodeB(responses, 60);
await ctx.ping('nodes');
const rebuiltMembers = ctx.leaflet._recorded.circleMarkers.slice(-2);
assert.notEqual(rebuiltMembers[0], member, 'the members were rebuilt, still fanned out');
assert.equal(ctx.overlays().length, 1, 'the overlay followed its member');
const rebuiltSame = rebuiltMembers.find(marker => marker.options.fillColor === member.options.fillColor);
clickLayer(rebuiltSame);
assert.equal(ctx.overlays().length, 0, 'clicking the rebuilt member toggles its overlay shut');
});
});
test('an overlay on a line the rebuild drops is closed (DR2)', async () => {
const responses = mapResponses();
await runMapApp({ responses }, async ctx => {
clickLayer(newestLine(ctx.leaflet, NEIGHBOR_LINE));
await settle();
assert.equal(ctx.overlays().length, 1);
// B moves out of reach of any line: the neighbour row stays but its
// target loses its position, so the line is not drawn again.
responses['/api/nodes'][1] = { ...responses['/api/nodes'][1], latitude: null, longitude: null };
await ctx.ping('nodes');
assert.equal(ctx.overlays().length, 0, 'nothing to anchor to: cleanupOrphans closes it');
});
});
test('the live-flash wave starts from the rebuilt marker (DR2, LV5)', async () => {
const responses = mapResponses();
await runMapApp({ responses }, async ctx => {
const marker = newestMarkerAt(ctx.leaflet, 52.51, 13.42);
touchNodeB(responses, 61);
await ctx.ping('nodes');
const rebuilt = newestMarkerAt(ctx.leaflet, 52.51, 13.42);
assert.notEqual(rebuilt, marker, 'the map rebuilt the marker');
const waves = ctx.leaflet._recorded.markers.filter(
layer => layer.options.icon && layer.options.icon.options.className === 'live-flash-wave-icon',
);
assert.ok(waves.some(wave => wave.getLatLng() === rebuilt.getLatLng()), 'a wave rises from the rebuilt marker');
});
});
@@ -0,0 +1,427 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* SPEC DR4 (#881): a refresh repaints only the surfaces whose data changed.
*
* Each test boots the dashboard with a nodes table, a map and a chat, lands
* one refresh and counts what was rebuilt: `#nodes tbody` rebuilds, node
* markers and lines the map created, and what the chat shows. Before DR4 every
* refresh rebuilt all three, so a `messages` ping replaced every table row and
* every map layer, and ten destination pages repainted the table ten times.
*
* @module app/__tests__/main-repaint-scope
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { NOW, runMapApp, settle } from './live-map-harness.js';
/**
* Two meshtastic nodes with positions, one chat message, no neighbours.
*
* @returns {Object<string, *>} Stub-fetch responses a test may edit.
*/
function baseResponses() {
return {
'encrypted=true': [],
'/api/nodes': [
{ node_id: '!a', short_name: 'A', long_name: 'Node A', role: 'CLIENT', protocol: 'meshtastic', last_heard: NOW - 30, latitude: 52.5, longitude: 13.4 },
{ node_id: '!b', short_name: 'B', long_name: 'Node B', role: 'ROUTER', protocol: 'meshtastic', last_heard: NOW - 60, latitude: 52.51, longitude: 13.42 },
],
'/api/messages': [
{ id: 1, channel: 0, from_id: '!a', to_id: '^all', text: 'first message', rx_time: NOW - 20, protocol: 'meshtastic' },
],
'/api/neighbors': [],
};
}
/**
* Snapshot what has been rebuilt so far: ``#nodes tbody`` rebuilds, node
* markers and lines the map created, and the chat repaints the planner
* handed out (read last, so a missing planner fails on the DOM counts first).
*
* @param {Object} ctx Harness context.
* @returns {{ table: number, markers: number, lines: number, chat: (number|undefined) }}
*/
function rebuilt(ctx) {
return {
table: ctx.tableRepaints(),
markers: ctx.leaflet._recorded.circleMarkers.length,
lines: ctx.leaflet._recorded.polylines.length,
chat: ctx.testUtils.getSurfaceRenderCounts?.().chat,
};
}
/**
* Assert what a refresh rebuilt since ``start``: the DOM counts first, then
* the chat repaints.
*
* @param {Object} ctx Harness context.
* @param {Object} start Snapshot from {@link rebuilt}.
* @param {{ table: number, markers: number, lines: number, chat: number }} expected Deltas.
* @param {string} [label] Assertion context.
* @returns {void}
*/
function assertRebuilt(ctx, start, expected, label = '') {
const now = rebuilt(ctx);
const { chat, ...dom } = expected;
assert.deepEqual(
{ table: now.table - start.table, markers: now.markers - start.markers, lines: now.lines - start.lines },
dom,
`${label} table rebuilds, map markers and lines created`,
);
assert.equal(now.chat - start.chat, chat, `${label} chat repaints`);
}
test('a messages ping that changes no node, position or neighbour data repaints the chat alone (DR4)', async () => {
const responses = baseResponses();
await runMapApp({ responses }, async ctx => {
const start = rebuilt(ctx);
responses['/api/messages'].push(
{ id: 2, channel: 0, from_id: '!a', to_id: '^all', text: 'second message', rx_time: NOW, protocol: 'meshtastic' },
);
// A message ingest publishes messages and nodes; the node rows come back unchanged.
await ctx.ping('messages', 'nodes');
assert.ok(ctx.chat.innerHTML.includes('second message'), 'the chat shows the new message');
assertRebuilt(ctx, start, { table: 0, markers: 0, lines: 0, chat: 1 }, 'a messages-only refresh:');
// The live feedback still lands on the elements already on screen (VF4).
assert.deepEqual([...ctx.testUtils.getLastFlashedMessageIds()].sort(), ['1', '2']);
assert.ok(ctx.testUtils.getLastFlashedNodeIds().includes('!a'));
});
});
test('a refresh that changes nothing repaints nothing: ping, safety poll, resync (DR4)', async () => {
await runMapApp({ responses: baseResponses() }, async ctx => {
const start = rebuilt(ctx);
await ctx.ping('nodes');
await ctx.ping('messages');
await ctx.testUtils.refresh();
ctx.stream.dispatch('open', {});
await ctx.testUtils.flushLiveRefresh();
await settle();
assertRebuilt(ctx, start, { table: 0, markers: 0, lines: 0, chat: 0 }, 'four idle refreshes:');
});
});
test('a node change repaints the table, the map and the chat (DR4)', async () => {
const responses = baseResponses();
await runMapApp({ responses }, async ctx => {
const start = rebuilt(ctx);
responses['/api/nodes'][0] = { ...responses['/api/nodes'][0], last_heard: NOW, battery_level: 77 };
await ctx.ping('nodes');
assertRebuilt(ctx, start, { table: 1, markers: 2, lines: 0, chat: 1 });
});
});
test('a neighbour change repaints the map and the chat, not the table (DR4)', async () => {
const responses = baseResponses();
await runMapApp({ responses }, async ctx => {
const start = rebuilt(ctx);
responses['/api/neighbors'].push({ node_id: '!a', neighbor_id: '!b', snr: 4.5, rx_time: NOW });
await ctx.ping('neighbors');
assertRebuilt(ctx, start, { table: 0, markers: 2, lines: 1, chat: 1 });
});
});
test('a filter edit repaints every surface without any new data (DR4)', async () => {
let filterInput = null;
/** Register the filter input before boot. @param {Object} env DOM environment. */
const beforeBoot = env => {
filterInput = env.createElement('input', 'filterInput');
filterInput.value = '';
env.registerElement('filterInput', filterInput);
};
await runMapApp({ responses: baseResponses(), beforeBoot }, async ctx => {
const start = rebuilt(ctx);
filterInput.value = 'node';
for (const handler of filterInput._listeners.get('input')) handler({ type: 'input' });
assertRebuilt(ctx, start, { table: 1, markers: 2, lines: 0, chat: 1 });
});
});
test('the map repaints once the clock moves a marker into its next freshness bucket (DR4)', async () => {
const realNow = Date.now;
await runMapApp({ responses: baseResponses() }, async ctx => {
const start = rebuilt(ctx);
await ctx.testUtils.refresh();
assertRebuilt(ctx, start, { table: 0, markers: 0, lines: 0, chat: 0 }, 'no bucket moved yet:');
try {
// Node A was heard 30 s before NOW; three hours on it leaves the live bucket.
Date.now = () => (NOW + 3 * 3600) * 1000;
await ctx.testUtils.refresh();
} finally {
Date.now = realNow;
}
assertRebuilt(ctx, start, { table: 0, markers: 2, lines: 0, chat: 0 }, 'three hours on:');
});
});
test('destination pages coalesce into one table repaint, then refresh the counts (DR4)', async () => {
const identity = '!27716218';
const responses = baseResponses();
responses['/api/nodes'].push({
node_id: identity, short_name: '2771', long_name: 'Department of Decentralization', role: 'NODE',
protocol: 'reticulum', last_heard: NOW - 90,
});
/** One destination row of the identity. @param {number} i Row index. @returns {Object} Row. */
const destination = i => ({
id: `d${i}`, node_id: identity, aspect: 'lxmf.delivery', role: 'PEER', name: null, last_heard: NOW - 100 - i,
});
// Two full pages and a short one: the walk takes all three, then stops.
const pages = [
Array.from({ length: 250 }, (_, i) => destination(i)),
Array.from({ length: 250 }, (_, i) => destination(250 + i)),
Array.from({ length: 10 }, (_, i) => destination(500 + i)),
];
let release;
const gate = new Promise(resolve => {
release = resolve;
});
let served = 0;
/** Serve the destination pages, held until released. @param {string} url Request URL. */
const fetchOverride = url => {
if (!String(url).startsWith('/api/destinations')) return undefined;
const page = pages[served] || [];
served += 1;
return gate.then(() => ({ ok: true, status: 200, json: () => Promise.resolve(page) }));
};
let toggleCount = null;
/** Register the Reticulum toggle count before boot. @param {Object} env DOM environment. */
const beforeBoot = env => {
toggleCount = env.createElement('span', 'protocolToggleReticulumCount');
env.registerElement('protocolToggleReticulumCount', toggleCount);
};
await runMapApp({ responses, fetchOverride, beforeBoot }, async ctx => {
const start = rebuilt(ctx);
release();
await settle(120);
assert.equal(served, 3, 'the walk fetched all three pages');
assertRebuilt(ctx, start, { table: 1, markers: 0, lines: 0, chat: 0 }, 'three destination pages:');
// The coalesced flush still refreshes the stats-driven counts, from the
// complete index (SPEC RA3).
assert.match(toggleCount.textContent, /\(510\)$/);
});
});
test('each MS-A4 refresh path still repaints the chat when its data changed, and keeps the select (DR4, MS-A4)', async () => {
const responses = baseResponses();
/**
* The first ``<select>`` under ``node`` (the channel select; the mock DOM
* has no tag selectors).
*
* @param {Object} node Mock element.
* @returns {?Object} The select, or ``null``.
*/
const findSelect = node => {
if (!node || typeof node !== 'object') return null;
if (node.tagName === 'SELECT') return node;
for (const child of node.childNodes || []) {
const found = findSelect(child);
if (found) return found;
}
return null;
};
await runMapApp({ responses }, async ctx => {
/** @returns {?Object} The channel select now in the chat. */
const select = () => findSelect(ctx.chat);
const original = select();
assert.ok(original, 'the chat renders its channel select');
let next = 10;
/** Queue one more channel-0 message for the next fetch. */
const addMessage = () => {
next += 1;
responses['/api/messages'].push({
id: next, channel: 0, from_id: '!a', to_id: '^all', text: `path message ${next}`, rx_time: NOW + next, protocol: 'meshtastic',
});
return `path message ${next}`;
};
const paths = [
['a messages ping', () => ctx.ping('messages')],
['a (re)connect resync', async () => {
ctx.stream.dispatch('open', {});
await ctx.testUtils.flushLiveRefresh();
}],
['the safety poll', () => ctx.testUtils.refresh()],
];
for (const [label, run] of paths) {
const text = addMessage();
const chatBefore = ctx.testUtils.getSurfaceRenderCounts().chat;
await run();
assert.ok(ctx.chat.innerHTML.includes(text), `${label} shows the new message`);
assert.equal(ctx.testUtils.getSurfaceRenderCounts().chat - chatBefore, 1, `${label} repaints the chat once`);
assert.equal(select(), original, `${label} keeps the select element`);
}
// A nodes ping repaints the chat when a node it shows changed.
responses['/api/nodes'][0] = { ...responses['/api/nodes'][0], long_name: 'Node A renamed' };
const chatBefore = ctx.testUtils.getSurfaceRenderCounts().chat;
await ctx.ping('nodes');
assert.equal(ctx.testUtils.getSurfaceRenderCounts().chat - chatBefore, 1, 'a node change repaints the chat');
assert.equal(select(), original, 'a nodes ping keeps the select element');
});
});
/** The chat's Log window: entries older than this leave the chat (7 days). */
const CHAT_WINDOW_SECONDS = 7 * 24 * 3600;
/**
* Run ``fn`` with ``Date.now`` pinned to ``atSeconds``.
*
* @param {number} atSeconds Unix seconds the clock should read.
* @param {() => Promise<void>} fn Body.
* @returns {Promise<void>}
*/
async function withClockAt(atSeconds, fn) {
const realNow = Date.now;
Date.now = () => atSeconds * 1000;
try {
await fn();
} finally {
Date.now = realNow;
}
}
/**
* Count the occurrences of ``needle`` in the chat's markup.
*
* @param {Object} ctx Harness context.
* @param {string} needle Text to count.
* @returns {number} Occurrences.
*/
function chatCount(ctx, needle) {
return ctx.chat.innerHTML.split(needle).length - 1;
}
test('a Log entry the clock moves out of the 7-day window leaves the chat on the next refresh (DR4)', async () => {
const responses = baseResponses();
responses['/api/messages'] = [];
// 6 d 23 h 58 min old: inside the chat's window and inside the 28-day trim,
// so no merge removes it; only the clock does.
responses['/api/neighbors'] = [{ node_id: '!a', neighbor_id: '!b', snr: 4.5, rx_time: NOW - CHAT_WINDOW_SECONDS + 120 }];
await runMapApp({ responses }, async ctx => {
assert.equal(chatCount(ctx, 'Broadcasted neighbor info'), 1, 'setup: the neighbour Log entry is shown');
const start = rebuilt(ctx);
// Ten minutes on the entry is 7 d + 8 min old.
await withClockAt(NOW + 600, () => ctx.testUtils.refresh());
assert.equal(chatCount(ctx, 'Broadcasted neighbor info'), 0, 'the aged-out entry left the Log');
assertRebuilt(ctx, start, { table: 0, markers: 0, lines: 0, chat: 1 }, 'a clock-only chat change:');
});
});
test('a waypoint Log entry updates its Expires text on the next refresh (DR4)', async () => {
const responses = baseResponses();
responses['/api/messages'] = [];
responses['/api/waypoints'] = [
{ id: 5, protocol: 'meshtastic', name: 'Probe POI', node_id: '!a', from_id: '!a', latitude: 52.52, longitude: 13.41, expire: NOW + 120, rx_time: NOW - 45 },
];
await runMapApp({ responses }, async ctx => {
assert.equal(chatCount(ctx, 'expired'), 0, 'setup: the waypoint has not expired');
const start = rebuilt(ctx);
// Ten minutes on: past its expiry.
await withClockAt(NOW + 600, () => ctx.testUtils.refresh());
assert.equal(chatCount(ctx, 'expired'), 1, 'the Log labels the broadcast expired');
assertRebuilt(ctx, start, { table: 0, markers: 2, lines: 0, chat: 1 }, 'the pin expired too:');
});
});
test('node first/last-heard, trace and encrypted Log entries leave the chat as the clock passes them (DR4)', async () => {
const responses = baseResponses();
responses['/api/messages'] = [];
// Node B: first heard, then last heard, inside the window by 100 s and 400 s.
responses['/api/nodes'][1] = {
...responses['/api/nodes'][1],
first_heard: NOW - CHAT_WINDOW_SECONDS + 100,
last_heard: NOW - CHAT_WINDOW_SECONDS + 400,
};
responses['/api/traces'] = [{ id: 9, src: '!a', dest: '!b', hops: [], rx_time: NOW - CHAT_WINDOW_SECONDS + 200 }];
responses['encrypted=true'] = [
{ id: 77, encrypted: 'c2VjcmV0', from_id: '!a', to_id: '^all', channel: 0, rx_time: NOW - CHAT_WINDOW_SECONDS + 300, protocol: 'meshtastic' },
];
await runMapApp({ responses }, async ctx => {
const seen = () => ({
newNode: chatCount(ctx, 'New node:'),
trace: chatCount(ctx, 'Caught trace'),
encrypted: chatCount(ctx, 'encrypted message'),
adverts: chatCount(ctx, 'Updated node info (advert)'),
});
assert.deepEqual(seen(), { newNode: 1, trace: 1, encrypted: 1, adverts: 2 }, 'setup: every entry is shown');
const start = rebuilt(ctx);
const steps = [
[150, { newNode: 0, trace: 1, encrypted: 1, adverts: 2 }],
[250, { newNode: 0, trace: 0, encrypted: 1, adverts: 2 }],
[350, { newNode: 0, trace: 0, encrypted: 0, adverts: 2 }],
[450, { newNode: 0, trace: 0, encrypted: 0, adverts: 1 }],
];
for (const [offset, expected] of steps) {
await withClockAt(NOW + offset, () => ctx.testUtils.refresh());
assert.deepEqual(seen(), expected, `${offset} s on`);
}
assertRebuilt(ctx, start, { table: 0, markers: 0, lines: 0, chat: 4 }, 'four clock-only chat changes:');
});
});
test('with the chat turned off, the clock schedules no chat repaint (DR4)', async () => {
const responses = baseResponses();
responses['/api/messages'] = [];
// Node B's last heard would leave a chat's window 100 s from now.
responses['/api/nodes'][1] = { ...responses['/api/nodes'][1], last_heard: NOW - CHAT_WINDOW_SECONDS + 100 };
await runMapApp({ responses, configOverrides: { chatEnabled: false } }, async ctx => {
const start = rebuilt(ctx);
await withClockAt(NOW + 200, () => ctx.testUtils.refresh());
assertRebuilt(ctx, start, { table: 0, markers: 0, lines: 0, chat: 0 }, 'chat off:');
});
});
/**
* Boot the dashboard with ``Date.now`` pinned to ``startSec`` and hand the
* body a setter that moves the pinned clock.
*
* @param {number} startSec Unix seconds the clock reads at boot.
* @param {Object} options Options for {@link runMapApp}.
* @param {(ctx: Object, setClock: (sec: number) => void) => Promise<void>} fn Body.
* @returns {Promise<void>}
*/
async function runWithPinnedClock(startSec, options, fn) {
const realNow = Date.now;
let clock = startSec;
Date.now = () => clock * 1000;
try {
await runMapApp(options, ctx => fn(ctx, sec => {
clock = sec;
}));
} finally {
Date.now = realNow;
}
}
for (const [unit, left, before, after] of [['seconds', 120, '2m 0s', '1m 59s'], ['minutes', 7200, '2h 0m', '1h 59m']]) {
test(`a refresh half a second after the Expires text changes repaints the chat (${unit}, DR4)`, async () => {
const responses = baseResponses();
responses['/api/messages'] = [];
responses['/api/waypoints'] = [
{ id: 5, protocol: 'meshtastic', name: 'Probe POI', node_id: '!a', from_id: '!a', latitude: 52.52, longitude: 13.41, expire: NOW + left, rx_time: NOW - 45 },
];
await runWithPinnedClock(NOW, { responses }, async (ctx, setClock) => {
assert.equal(chatCount(ctx, `Expires: ${before}`), 1, `setup: the Log reads ${before}`);
const chatBefore = ctx.testUtils.getSurfaceRenderCounts().chat;
setClock(NOW + 0.5);
await ctx.testUtils.refresh();
assert.equal(chatCount(ctx, `Expires: ${after}`), 1, `the Log reads ${after}`);
assert.equal(ctx.testUtils.getSurfaceRenderCounts().chat - chatBefore, 1, 'one chat repaint');
});
});
}
+207 -20
View File
@@ -234,6 +234,13 @@ import { getActiveFullscreenElement, legendClickHandler } from './main/fullscree
import { createEventStream } from './main/event-stream.js';
import { flashNodeTargets, flashMessageTargets, flashElement, emitNodeWaves } from './main/flash.js';
import { captureOpenMarkerOverlays, restoreMarkerOverlays } from './main/marker-overlay-preservation.js';
import {
captureKeyedAnchors,
restoreKeyedAnchors,
neighborSegmentKey,
traceSegmentKey,
} from './main/map-overlay-anchors.js';
import { createRepaintPlanner, nextChatRepaintAt, nextMapRepaintAt } from './main/repaint-planner.js';
import { collectNodeIds, collectMessageIds, entryMessageId } from './main/flash-targets.js';
import {
nodeAgeBucket,
@@ -487,6 +494,24 @@ export function initializeApp(config) {
* repaints rather than one repaint per page (issue: frontend perf regression).
*/
let renderFilteredOutputsCount = 0;
/**
* Which surfaces (table, map, chat) the next repaint covers: a refresh marks
* the collections whose rows changed, and only the surfaces that render one
* of them repaint (SPEC DR4).
*/
const repaintPlanner = createRepaintPlanner();
/**
* Derived node records as of the last {@link rebuildNodeDerivedState}; the
* next derivation compares against them, so re-sent but unchanged nodes do
* not repaint the table or the map (SPEC DR4).
* @type {Array<Object>}
*/
let lastDerivedNodes = [];
/**
* True while a destinations page waits in the coalesced backfill repaint;
* the flush then refreshes the stats-driven counts once (SPEC DR4).
*/
let destinationStatsPending = false;
/**
* True once the user clicked "show all" to lift the node-table render cap
* ({@link NODE_TABLE_RENDER_CAP}); persists for the session so subsequent
@@ -705,6 +730,10 @@ export function initializeApp(config) {
lastTraceTimestamp = maxRecordTimestamp(allTraces, ['rx_time']);
lastWaypointTimestamp = maxRecordTimestamp(allWaypoints, ['rx_time']);
initialFetchDone = true;
// The cached rows are already derived; the next refresh's derivation
// compares against them (SPEC DR4). Everything is new, so every surface
// paints.
lastDerivedNodes = allNodes;
applyFilter();
return true;
}
@@ -1210,6 +1239,9 @@ export function initializeApp(config) {
// Per-render map of canonical node id → its Leaflet marker, so a live update
// can flash the marker for a changed node (SPEC VF3). Rebuilt every renderMap.
let markerByNodeId = new Map();
// Per-render map of segment key → its neighbour or trace polyline, so an open
// line overlay or tooltip follows its line across a rebuild (SPEC DR2).
let lineBySegmentKey = new Map();
// Per-render record of the offset markers we created so the zoom event
// handlers can re-project them and keep the on-screen pixel gap constant
// regardless of zoom level. Each entry is
@@ -4035,7 +4067,9 @@ export function initializeApp(config) {
* {@link refresh} and the background collection backfill (issue #832) so a
* streamed history page derives the rendered node state identically to a full
* refresh. Does not touch ``allMessages`` / ``allEncryptedMessages`` (hydrated
* separately) or ``allTraces`` (not node-derived).
* separately) or ``allTraces`` (not node-derived). The new records are
* compared with the previous derivation's, and the repaint planner marks the
* table, the map and the chat only when one of them changed (SPEC DR4).
*
* @returns {void}
*/
@@ -4054,6 +4088,10 @@ export function initializeApp(config) {
// Rebuild lookup maps so marker updates and message hydration always resolve
// to the latest node objects.
rebuildNodeIndex(allNodes);
// SPEC DR4: the table, the map and the chat render these records; mark
// them only when a record really changed (a delta re-sends its overlap).
repaintPlanner.noteRows('nodes', lastDerivedNodes, allNodes);
lastDerivedNodes = allNodes;
// The per-packet accumulators (allTelemetryEntries / allPositionEntries /
// allNeighbors) are deliberately left RAW — the aggregated forms above are
// locals used only to enrich the node records. Writing an aggregate back into
@@ -4068,6 +4106,24 @@ export function initializeApp(config) {
// (bugfix A1).
}
/**
* The raw per-packet collections, by the names the repaint planner maps onto
* surfaces (SPEC DR4). Taken before and after a merge so the planner can
* tell which of them changed.
*
* @returns {{ positions: Array<Object>, telemetry: Array<Object>, neighbors: Array<Object>,
* traces: Array<Object>, waypoints: Array<Object> }} Current rows per collection.
*/
function rawCollections() {
return {
positions: allPositionEntries,
telemetry: allTelemetryEntries,
neighbors: allNeighbors,
traces: allTraces,
waypoints: allWaypoints,
};
}
/** Floor (unix s) below which backfilled positions/telemetry are dropped (FC3: 7 d). */
const recentBackfillFloor = () => Math.floor(Date.now() / 1000) - CHAT_RECENT_WINDOW_SECONDS;
/** Floor (unix s) below which backfilled neighbors/traces are dropped (FC3: 28 d). */
@@ -4217,7 +4273,9 @@ export function initializeApp(config) {
/**
* Run the coalesced backfill re-derive(s) and a single repaint, then clear the
* pending state. Idempotent: a no-op when nothing is pending, so calling it
* again after a flush (or after the trailing idle callback) is harmless.
* again after a flush (or after the trailing idle callback) is harmless. The
* repaint covers the surfaces the merged pages changed (SPEC DR4); queued
* destination pages also refresh the stats-driven counts, once.
*
* @returns {void}
*/
@@ -4237,6 +4295,27 @@ export function initializeApp(config) {
backfillRepaintDirty = false;
renderFilteredOutputs();
}
if (destinationStatsPending) {
// The legend and the protocol toggle count destinations (SPEC RA3).
destinationStatsPending = false;
refreshStatsDisplays();
}
}
/**
* Fold one Reticulum destinations page into the coalesced backfill repaint
* (SPEC DR4, the FP-A2 pattern). The background walk delivers up to 40
* pages; repainting the table and fetching ``/api/stats`` for each one
* rebuilt the table once per page. The index is already current when this
* runs; only the repaint waits for idle time.
*
* @returns {void}
*/
function queueDestinationRepaint() {
repaintPlanner.markCollections(['destinations']);
destinationStatsPending = true;
backfillRepaintDirty = true;
scheduleBackfillRepaint();
}
/**
@@ -4259,7 +4338,8 @@ export function initializeApp(config) {
* queue a coalesced re-derive + repaint (see the coalescing note above). The
* merge is synchronous so it cannot interleave with a concurrent refresh or
* another collection's commit and so ``getLoaded*Count`` always reflects every
* paged-in row; the repaint is deferred to idle time. The stats fetch is
* paged-in row; the repaint is deferred to idle time and covers only the
* surfaces whose rows the page changed (SPEC DR4). The stats fetch is
* skipped (the authoritative count is server-computed and unchanged by how many
* rows the client has paged in).
*
@@ -4269,7 +4349,11 @@ export function initializeApp(config) {
* @returns {void}
*/
function commitBackfillPage(spec, batch) {
const before = rawCollections();
spec.merge(batch);
// SPEC DR4: mark the raw collection the page changed; node records are
// compared when the coalesced refine re-derives them.
repaintPlanner.noteCollections(before, rawCollections());
pendingBackfillRefines.add(spec.refine);
backfillRepaintDirty = true;
scheduleBackfillRepaint();
@@ -4847,6 +4931,11 @@ export function initializeApp(config) {
/**
* Render the Leaflet map markers and neighbour connections.
*
* Every layer is rebuilt; an open overlay or tooltip on a marker, a line or
* a waypoint pin moves to the rebuilt layer with the same node id, segment
* key or waypoint key (SPEC DR2, LD-A3). The render also schedules the map's
* next clock-driven repaint with the repaint planner (SPEC DR4).
*
* @param {Array<Object>} nodes Node payloads.
* @param {number} nowSec Reference timestamp.
* @returns {void}
@@ -4855,6 +4944,13 @@ export function initializeApp(config) {
if (!map || !markersLayer || !hasLeaflet) {
return;
}
// SPEC DR2: snapshot each open overlay or tooltip on a line or a waypoint
// pin, by segment or waypoint key, before those layers are cleared; it is
// carried onto the rebuilt layer with the same key at the end. Markers
// keep their own LD-A3 snapshot below.
const preservedLineAnchors = captureKeyedAnchors(overlayStack, lineBySegmentKey);
const preservedWaypointAnchors = captureKeyedAnchors(overlayStack, waypointMarkerByKey);
lineBySegmentKey = new Map();
if (neighborLinesLayer) {
neighborLinesLayer.clearLayers();
}
@@ -4982,6 +5078,9 @@ export function initializeApp(config) {
opacity: 0.42,
className: 'neighbor-connection-line'
}).addTo(neighborLinesLayer);
// Register the line by its direction so DR2 can move an open
// overlay or tooltip onto it after the next rebuild.
lineBySegmentKey.set(neighborSegmentKey(segment.sourceId, segment.targetId), polyline);
if (polyline && typeof polyline.bindTooltip === 'function') {
const tooltipHtml = buildNeighborTooltipHtml({
...segment,
@@ -5033,6 +5132,9 @@ export function initializeApp(config) {
}
if (traceLinesLayer && traceSegments.length) {
// Hop position of each segment within its trace (the stable sort keeps a
// trace's hops in path order), for the DR2 segment key.
const traceHopIndex = new Map();
traceSegments
.sort((a, b) => {
const rxA = Number.isFinite(a.rxTime) ? a.rxTime : -Infinity;
@@ -5048,6 +5150,10 @@ export function initializeApp(config) {
dashArray: '6 6',
className: 'neighbor-connection-line trace-connection-line'
}).addTo(traceLinesLayer);
const hop = traceHopIndex.get(segment.traceId) ?? 0;
traceHopIndex.set(segment.traceId, hop + 1);
const traceKey = traceSegmentKey(segment.traceId, hop);
if (traceKey) lineBySegmentKey.set(traceKey, polyline);
if (polyline && typeof polyline.bindTooltip === 'function') {
const tooltipHtml = buildTraceTooltipHtml(segment.pathNodes);
if (tooltipHtml) {
@@ -5285,9 +5391,15 @@ export function initializeApp(config) {
}
// Re-anchor any overlay preserved above onto its rebuilt marker so it
// stays open across the re-render instead of being closed by
// cleanupOrphans (item 7).
// cleanupOrphans (item 7), and do the same for line overlays and
// tooltips and waypoint cards (SPEC DR2).
restoreMarkerOverlays(overlayStack, preservedMarkerOverlays, markerByNodeId);
restoreKeyedAnchors(overlayStack, preservedLineAnchors, lineBySegmentKey);
restoreKeyedAnchors(overlayStack, preservedWaypointAnchors, waypointMarkerByKey);
overlayStack.cleanupOrphans();
// SPEC DR4: freshness buckets and waypoint expiry move with the clock, not
// with data; repaint the map on the first refresh past the next such step.
repaintPlanner.expireAt('map', nextMapRepaintAt(nodes, allWaypoints, nowSec));
}
/**
@@ -5412,11 +5524,16 @@ export function initializeApp(config) {
* (issue #802) so each streamed page repaints the chat without re-running the
* full filter pipeline (node table, map, ``/api/stats`` fetch).
*
* The paint also schedules the chat's next clock-driven repaint (SPEC DR4):
* the chat drops entries older than its 7-day window and counts waypoint
* entries down to their expiry, which no data change announces.
*
* @param {string} [filterQuery] Raw filter text for substring highlighting;
* defaults to the current filter input value.
* @returns {void}
*/
function rerenderChatLog(filterQuery = filterInput ? filterInput.value : '') {
const paintedAt = Date.now() / 1000;
renderChatLog({
nodes: allNodes,
messages: allMessages,
@@ -5428,47 +5545,95 @@ export function initializeApp(config) {
waypointEntries: allWaypoints,
filterQuery
});
// No chat on this page (or chat disabled): nothing to age.
const nextChatChange = CHAT_ENABLED && chatEl
? nextChatRepaintAt({
nodes: allNodes,
telemetry: allTelemetryEntries,
positions: allPositionEntries,
neighbors: allNeighbors,
traces: allTraces,
waypoints: allWaypoints,
messages: allMessages,
encrypted: allEncryptedMessages,
}, paintedAt, CHAT_RECENT_WINDOW_SECONDS)
: Infinity;
repaintPlanner.expireAt('chat', nextChatChange);
}
/**
* Render the filter-dependent outputs — node table, map markers, sort
* indicators, and chat log — from the current in-memory state, **without** the
* ``/api/stats`` fetch. {@link applyFilter} composes this with the stats
* ``/api/stats`` fetch. {@link applyDataChanges} composes this with the stats
* refresh; the background collection backfill (issue #832) calls it directly so
* streaming a history page repaints the table/map without firing a redundant
* authoritative-count request per page — the ``/api/stats`` count is
* server-computed and unaffected by how many rows the client has paged in.
*
* Only the surfaces the repaint planner hands out repaint: those whose
* collections changed since they last painted, those a user action marked,
* and the map once the clock moved a marker's freshness bucket (SPEC DR4).
* Nothing changed means nothing repaints.
*
* @param {string} [filterQuery] Raw filter text for substring highlighting;
* defaults to the current filter input value.
* @returns {void}
*/
function renderFilteredOutputs(filterQuery = filterInput ? filterInput.value : '') {
const nowSec = Date.now() / 1000;
const surfaces = repaintPlanner.take(nowSec);
if (surfaces.size === 0) return;
// Instrumentation for the backfill de-jank guard (see
// {@link renderFilteredOutputsCount}); a plain increment, no behaviour change.
// {@link renderFilteredOutputsCount}): one count per repaint that paints.
renderFilteredOutputsCount += 1;
// Text and role filters apply only to the node table and map; the chat log
// always receives the full node collection so reply-thread lookups succeed
// even for nodes that are currently hidden by the active filter.
const sortedNodes = getFilteredSortedNodes();
const nowSec = Date.now() / 1000;
renderTable(sortedNodes, nowSec);
renderMap(sortedNodes, nowSec);
updateSortIndicators();
const sortedNodes = surfaces.has('table') || surfaces.has('map') ? getFilteredSortedNodes() : null;
if (surfaces.has('table')) renderTable(sortedNodes, nowSec);
if (surfaces.has('map')) renderMap(sortedNodes, nowSec);
if (surfaces.has('table')) updateSortIndicators();
// Pass the raw filterQuery (not the normalised form) so the chat log can
// highlight matching substrings in their original case.
rerenderChatLog(filterQuery);
if (surfaces.has('chat')) rerenderChatLog(filterQuery);
}
/**
* Apply text and role filters to the node list and re-render outputs.
*
* The entry point for user actions — the filter input, sorting, the role
* and protocol toggles, the identity caret — and for the cache seed and a
* protocol un-strand: each changes what every surface shows, so all of them
* repaint (SPEC DR4).
*
* @returns {void}
*/
function applyFilter() {
repaintPlanner.markAllSurfaces();
applyDataChanges();
}
/**
* Repaint the surfaces whose data changed, then refresh the stats-driven
* displays. The entry point for a refresh: an SSE ping, the reconnect
* resync and the safety poll all end here (SPEC DR4).
*
* @returns {void}
*/
function applyDataChanges() {
updateFilterClearVisibility();
const filterQuery = filterInput ? filterInput.value : '';
renderFilteredOutputs(filterQuery);
refreshStatsDisplays();
}
/**
* Refresh the title, legend counts, protocol toggles, footer and mesh
* activity card from a local estimate first and then from ``/api/stats``.
*
* @returns {void}
*/
function refreshStatsDisplays() {
// Show an immediate local estimate for the title so it doesn't flicker
// to (0) while waiting for the async /api/stats response.
const nowSec = Date.now() / 1000;
@@ -5679,6 +5844,9 @@ export function initializeApp(config) {
const messageWindowFloor = nowSeconds - CHAT_RECENT_WINDOW_SECONDS;
const recentWindowFloor = nowSeconds - CHAT_RECENT_WINDOW_SECONDS;
const longWindowFloor = nowSeconds - TRACE_MAX_AGE_SECONDS;
// SPEC DR4: the raw collections before this merge, so the planner can
// tell which ones the delta (or a window trim) actually changed.
const rawBefore = rawCollections();
allNodes = useSince ? mergeById(allNodes, incomingNodes, 'node_id') : incomingNodes;
allPositionEntries = useSince
? trimToWindow(mergeById(allPositionEntries, incomingPositions, 'id'), recentWindowFloor)
@@ -5716,6 +5884,7 @@ export function initializeApp(config) {
// (allPositionEntries/allTelemetryEntries/allNeighbors) are left RAW so the
// Log keeps a stable entry per packet — re-storing the aggregated form would
// erode history on the next tick (bugfix A1).
repaintPlanner.noteCollections(rawBefore, rawCollections());
rebuildNodeDerivedState();
// Hydrate messages with node metadata in parallel; the node index has just
// been rebuilt (inside rebuildNodeDerivedState) so lookups find the freshly
@@ -5731,14 +5900,19 @@ export function initializeApp(config) {
// The read-modify-write is synchronous, so it cannot interleave with a
// backfill commit. First load has no backfill yet, so it just takes the
// newest page as-is.
const chatBefore = { messages: allMessages, encrypted: allEncryptedMessages };
allMessages = useSince
? trimToWindow(mergeById(allMessages, hydratedChat, 'id'), messageWindowFloor)
: hydratedChat;
allEncryptedMessages = Array.isArray(encryptedChatMessages) ? encryptedChatMessages : [];
repaintPlanner.noteCollections(chatBefore, { messages: allMessages, encrypted: allEncryptedMessages });
initialFetchDone = true;
applyFilter();
// SPEC DR4: repaint only the surfaces whose rows this refresh changed; a
// messages ping that changes no node, position or neighbour data
// repaints the chat alone, and an idle refresh repaints nothing.
applyDataChanges();
// SPEC VF2/VF3/VF4: only an SSE-ping refresh flashes (refreshOptions.flash),
// and only after the table + map have rendered (applyFilter above), so the
// and only after the table + map have rendered (applyDataChanges above), so the
// highlight lands on the final, placed element. useSince excludes the
// initial fill. A node/position/telemetry delta flashes the changed node.
if (refreshOptions.flash && useSince) {
@@ -6027,8 +6201,9 @@ export function initializeApp(config) {
}
// Reticulum destinations load in the background after first paint (SPEC RA8).
// The table is already rendering from /api/nodes by now; each page that lands
// repaints it with more groups, and a failing or slow walk simply leaves the
// The table is already rendering from /api/nodes by now; the pages that land
// repaint it with more groups, coalesced into idle-time repaints like the
// collection backfill (SPEC DR4), and a failing or slow walk simply leaves the
// table ungrouped rather than blocking it. `loadDestinationIndex` never
// rejects, so this needs no catch of its own.
// Gated on the same +runsOwnPageModule+ guard as the rest of the data
@@ -6038,8 +6213,12 @@ export function initializeApp(config) {
void loadDestinationIndex({
onUpdate: index => {
destinationIndex = index;
applyFilter();
// Pages coalesce into one idle repaint of the table (SPEC DR4, FP-A2).
queueDestinationRepaint();
},
}).then(() => {
// The walk is done: paint the final groups now rather than at idle time.
flushBackfillRepaint();
});
}
@@ -6203,11 +6382,19 @@ export function initializeApp(config) {
/** Whether the node-table render cap has been lifted (test use only). */
isNodeTableExpanded: () => nodeTableExpanded,
/**
* Cumulative count of full {@link renderFilteredOutputs} repaints (test
* use only) — the backfill de-jank guard resets this after first paint and
* asserts the streamed backfill coalesces into a bounded repaint count.
* Cumulative count of {@link renderFilteredOutputs} repaints that painted
* at least one surface (test use only) — the backfill de-jank guard resets
* this after first paint and asserts the streamed backfill coalesces into a
* bounded repaint count.
*/
getRenderCount: () => renderFilteredOutputsCount,
/**
* Repaints per surface handed out by the repaint planner (SPEC DR4; test
* use only): ``{ table, map, chat }``.
*
* @returns {{ table: number, map: number, chat: number }}
*/
getSurfaceRenderCounts: () => repaintPlanner.renderCounts(),
/** Reset the repaint counter (test use only). */
resetRenderCount: () => {
renderFilteredOutputsCount = 0;
@@ -0,0 +1,214 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import {
captureKeyedAnchors,
restoreKeyedAnchors,
neighborSegmentKey,
traceSegmentKey,
} from '../map-overlay-anchors.js';
/**
* Overlay-stack double: tracks open anchors and records reanchor calls.
*
* @returns {{ open: Set, reanchored: Array, isOpen: Function, reanchor: Function }}
*/
function fakeStack() {
const open = new Set();
const reanchored = [];
return {
open,
reanchored,
isOpen: anchor => open.has(anchor),
reanchor: (oldAnchor, newAnchor) => {
if (!open.has(oldAnchor)) return false;
open.delete(oldAnchor);
open.add(newAnchor);
reanchored.push([oldAnchor, newAnchor]);
return true;
},
};
}
/**
* Leaflet-layer double with an element and an optional bound tooltip.
*
* @param {Object} element The layer's DOM element stand-in.
* @param {{ tooltipOpen?: boolean, latLng?: Object }} [options] Tooltip state.
* @returns {Object} Layer double recording ``openTooltip`` calls.
*/
function fakeLayer(element, { tooltipOpen = false, latLng = null } = {}) {
const layer = {
opened: [],
tooltipOpen,
getElement: () => element,
isTooltipOpen: () => layer.tooltipOpen,
getTooltip: () => ({ getLatLng: () => latLng }),
openTooltip(at) {
layer.opened.push(at);
layer.tooltipOpen = true;
return layer;
},
};
return layer;
}
test('captureKeyedAnchors records open overlays and open tooltips by key', () => {
const stack = fakeStack();
const lineEl = { id: 'line' };
const pinEl = { id: 'pin' };
stack.open.add(lineEl);
const at = { lat: 1, lng: 2 };
const layers = new Map([
['neighbor:!a→!b', fakeLayer(lineEl, { tooltipOpen: true, latLng: at })],
['trace:7#0', fakeLayer({ id: 'trace' }, { tooltipOpen: true })],
['wp:1', fakeLayer(pinEl)],
['wp:2', fakeLayer({ id: 'closed' })],
]);
stack.open.add(pinEl);
assert.deepEqual(captureKeyedAnchors(stack, layers), [
{ key: 'neighbor:!a→!b', anchor: lineEl, tooltipOpen: true, tooltipLatLng: at },
{ key: 'trace:7#0', anchor: null, tooltipOpen: true, tooltipLatLng: null },
{ key: 'wp:1', anchor: pinEl, tooltipOpen: false, tooltipLatLng: null },
]);
});
test('captureKeyedAnchors tolerates partial layers, a stack without isOpen and bad args', () => {
const stack = fakeStack();
assert.deepEqual(captureKeyedAnchors(stack, null), []);
assert.deepEqual(captureKeyedAnchors(stack, [['k', fakeLayer({})]]), []);
// No getElement, a null element, and a tooltip without a position.
const noTooltipPosition = { getElement: () => null, isTooltipOpen: () => true, getTooltip: () => ({}) };
const noGetTooltip = { isTooltipOpen: () => true };
const nullLatLng = { isTooltipOpen: () => true, getTooltip: () => ({ getLatLng: () => undefined }) };
// Leaflet 1.9: a layer without a bound tooltip throws from isTooltipOpen().
const unbound = {
getElement: () => ({ id: 'pin' }),
getTooltip: () => undefined,
isTooltipOpen: () => {
throw new TypeError("Cannot read properties of undefined (reading 'isOpen')");
},
};
assert.deepEqual(
captureKeyedAnchors(
stack,
new Map([['a', {}], ['b', noTooltipPosition], ['c', noGetTooltip], ['d', nullLatLng], ['e', null], ['f', unbound]]),
),
[
{ key: 'b', anchor: null, tooltipOpen: true, tooltipLatLng: null },
{ key: 'd', anchor: null, tooltipOpen: true, tooltipLatLng: null },
],
);
// Without isOpen only tooltips are captured.
const el = { id: 'x' };
assert.deepEqual(captureKeyedAnchors({}, new Map([['x', fakeLayer(el)]])), []);
assert.deepEqual(captureKeyedAnchors(null, new Map([['x', fakeLayer(el, { tooltipOpen: true })]])), [
{ key: 'x', anchor: null, tooltipOpen: true, tooltipLatLng: null },
]);
});
test('restoreKeyedAnchors moves overlays and reopens tooltips on the rebuilt layer with the same key', () => {
const stack = fakeStack();
const oldLine = { id: 'old-line' };
const oldPin = { id: 'old-pin' };
stack.open.add(oldLine);
stack.open.add(oldPin);
const at = { lat: 52.5, lng: 13.4 };
const before = new Map([
['neighbor:!a→!b', fakeLayer(oldLine, { tooltipOpen: true, latLng: at })],
['wp:1', fakeLayer(oldPin)],
['trace:7#1', fakeLayer({ id: 'old-trace' }, { tooltipOpen: true })],
]);
const captured = captureKeyedAnchors(stack, before);
// The rebuild: fresh layers and elements for the same keys.
const newLine = { id: 'new-line' };
const newPin = { id: 'new-pin' };
const rebuiltLine = fakeLayer(newLine);
const rebuiltTrace = fakeLayer({ id: 'new-trace' });
const after = new Map([
['neighbor:!a→!b', rebuiltLine],
['wp:1', fakeLayer(newPin)],
['trace:7#1', rebuiltTrace],
]);
assert.deepEqual(restoreKeyedAnchors(stack, captured, after), { overlays: 2, tooltips: 2 });
assert.deepEqual(stack.reanchored, [[oldLine, newLine], [oldPin, newPin]]);
assert.equal(stack.isOpen(newLine), true);
assert.equal(stack.isOpen(oldLine), false);
// A sticky tooltip reopens where it stood; one without a position at the layer default.
assert.deepEqual(rebuiltLine.opened, [at]);
assert.deepEqual(rebuiltTrace.opened, [undefined]);
});
test('restoreKeyedAnchors leaves a key the rebuild dropped for cleanup', () => {
const stack = fakeStack();
const oldEl = { id: 'old' };
stack.open.add(oldEl);
const captured = [
{ key: 'gone', anchor: oldEl, tooltipOpen: true, tooltipLatLng: null },
null,
];
assert.deepEqual(restoreKeyedAnchors(stack, captured, new Map()), { overlays: 0, tooltips: 0 });
assert.equal(stack.isOpen(oldEl), true, 'the overlay is left on its detached anchor for cleanupOrphans');
});
test('restoreKeyedAnchors skips what the rebuilt layer or the stack cannot take', () => {
const stack = fakeStack();
const oldEl = { id: 'old' };
stack.open.add(oldEl);
const entry = { key: 'k', anchor: oldEl, tooltipOpen: true, tooltipLatLng: null };
// A rebuilt layer without an element or openTooltip restores nothing.
assert.deepEqual(restoreKeyedAnchors(stack, [entry], new Map([['k', { getElement: () => null }]])), {
overlays: 0,
tooltips: 0,
});
// A refused reanchor is not counted.
const refusing = { ...stack, reanchor: () => false };
assert.deepEqual(restoreKeyedAnchors(refusing, [entry], new Map([['k', fakeLayer({ id: 'n' })]])), {
overlays: 0,
tooltips: 1,
});
// A stack without reanchor still reopens tooltips.
const tooltipOnly = fakeLayer({ id: 't' });
assert.deepEqual(restoreKeyedAnchors(null, [entry], new Map([['k', tooltipOnly]])), { overlays: 0, tooltips: 1 });
// A rebuilt layer with no tooltip bound gets no reopen.
const unbound = { getElement: () => ({ id: 'u' }), getTooltip: () => undefined, openTooltip: () => assert.fail('no tooltip to open') };
assert.deepEqual(restoreKeyedAnchors(null, [entry], new Map([['k', unbound]])), { overlays: 0, tooltips: 0 });
assert.deepEqual(restoreKeyedAnchors(stack, null, new Map()), { overlays: 0, tooltips: 0 });
assert.deepEqual(restoreKeyedAnchors(stack, [entry], null), { overlays: 0, tooltips: 0 });
});
test('neighborSegmentKey names a directed neighbour line, or nothing without both ids', () => {
assert.equal(neighborSegmentKey('!a', '!b'), 'neighbor:!a→!b');
assert.notEqual(neighborSegmentKey('!a', '!b'), neighborSegmentKey('!b', '!a'));
assert.equal(neighborSegmentKey('', '!b'), null);
assert.equal(neighborSegmentKey('!a', null), null);
assert.equal(neighborSegmentKey(1, '!b'), null);
});
test('traceSegmentKey names a trace hop by trace id and position, or nothing without them', () => {
assert.equal(traceSegmentKey(42, 0), 'trace:42#0');
assert.equal(traceSegmentKey('abc', 3), 'trace:abc#3');
assert.equal(traceSegmentKey(0, 1), 'trace:0#1');
assert.equal(traceSegmentKey(null, 0), null);
assert.equal(traceSegmentKey(undefined, 0), null);
assert.equal(traceSegmentKey('', 0), null);
assert.equal(traceSegmentKey(42, -1), null);
assert.equal(traceSegmentKey(42, 1.5), null);
});
@@ -66,6 +66,12 @@ test('captureOpenMarkerOverlays ignores markers without getElement and bad args'
assert.deepEqual(captureOpenMarkerOverlays(stack, new Map([['!x', {}]])), []);
});
test('captureOpenMarkerOverlays skips a marker whose only open thing is a tooltip (DR2)', () => {
const stack = fakeStack();
const withTooltip = { getElement: () => ({ id: 'T' }), isTooltipOpen: () => true };
assert.deepEqual(captureOpenMarkerOverlays(stack, new Map([['!t', withTooltip]])), []);
});
test('restoreMarkerOverlays re-anchors captured overlays onto rebuilt markers', () => {
const stack = fakeStack();
const oldEl = { id: 'old' };
@@ -0,0 +1,402 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import {
REPAINT_SURFACES,
SURFACE_INPUTS,
createRepaintPlanner,
keyedRowsChanged,
nextChatRepaintAt,
nextExpiryTextChangeAt,
nextMapRepaintAt,
recordsEqual,
surfacesForCollections,
} from '../repaint-planner.js';
import { nodeAgeBucket } from '../age-bucket.js';
import { isWaypointExpired, waypointExpiryOpacity } from '../waypoint-layer.js';
import { buildChatTabModel } from '../../chat-log-tabs.js';
import { timeHum } from '../format-utils.js';
const NOW = 1_800_000_000;
test('recordsEqual compares data, not identity', () => {
assert.equal(recordsEqual(1, 1), true);
assert.equal(recordsEqual(NaN, NaN), true);
assert.equal(recordsEqual(0, -0), true);
assert.equal(recordsEqual(NaN, 1), false);
assert.equal(recordsEqual('a', 'b'), false);
assert.equal(recordsEqual(null, {}), false);
assert.equal(recordsEqual({}, null), false);
assert.equal(recordsEqual(1, { a: 1 }), false);
assert.equal(recordsEqual([1, { a: [2] }], [1, { a: [2] }]), true);
assert.equal(recordsEqual([1, 2], [1, 2, 3]), false);
assert.equal(recordsEqual([1, 2], [1, 3]), false);
assert.equal(recordsEqual([1], { 0: 1 }), false);
assert.equal(recordsEqual({ a: 1, b: { c: 2 } }, { b: { c: 2 }, a: 1 }), true);
assert.equal(recordsEqual({ a: 1 }, { a: 1, b: 2 }), false);
assert.equal(recordsEqual({ a: 1, b: undefined }, { a: 1, c: undefined }), false);
assert.equal(recordsEqual({ a: { c: 2 } }, { a: { c: 3 } }), false);
// Hidden bookkeeping (the aggregator's snapshot history) is never rendered.
const withHistory = { a: 1 };
Object.defineProperty(withHistory, 'snapshots', { value: [1, 2, 3], enumerable: false });
assert.equal(recordsEqual(withHistory, { a: 1 }), true);
});
test('keyedRowsChanged finds an added, removed or edited row and ignores re-sent ones', () => {
/** @param {Object} row Row. @returns {*} Its id. */
const keyOf = row => row.id;
const rows = [{ id: 1, v: 'a' }, { id: 2, v: 'b' }];
assert.equal(keyedRowsChanged(rows, rows, keyOf), false, 'the same array');
assert.equal(keyedRowsChanged(rows, [rows[0], { id: 2, v: 'b' }], keyOf), false, 'an overlap row re-sent unchanged');
assert.equal(keyedRowsChanged(rows, [rows[0], { id: 2, v: 'c' }], keyOf), true, 'an edited row');
assert.equal(keyedRowsChanged(rows, [rows[0], { id: 3, v: 'b' }], keyOf), true, 'a row swapped for a new one');
assert.equal(keyedRowsChanged(rows, [...rows, { id: 3 }], keyOf), true, 'an added row');
assert.equal(keyedRowsChanged(rows, [rows[0]], keyOf), true, 'a trimmed row');
assert.equal(keyedRowsChanged(null, [], keyOf), false);
assert.equal(keyedRowsChanged([], undefined, keyOf), false);
assert.equal(keyedRowsChanged(undefined, [{ id: 1 }], keyOf), true);
// Rows that moved are matched by key: the same rows reordered are no change,
// reordered with an edit or a swapped row are.
const reordered = [{ id: 2, v: 'b' }, { id: 1, v: 'a' }];
assert.equal(keyedRowsChanged(rows, reordered, keyOf), false, 'the same rows in another order');
assert.equal(keyedRowsChanged(rows, [{ id: 2, v: 'b' }, { id: 1, v: 'z' }], keyOf), true, 'moved and edited');
assert.equal(keyedRowsChanged(rows, [{ id: 3, v: 'b' }, rows[0]], keyOf), true, 'moved and swapped');
});
test('surfacesForCollections maps each collection onto the surfaces that render it (DR4)', () => {
/** @param {Array<string>} collections Changed collections. @returns {Array<string>} Surfaces, in order. */
const as = collections => [...surfacesForCollections(collections)];
assert.deepEqual(as(['messages']), ['chat']);
assert.deepEqual(as(['encrypted']), ['chat']);
assert.deepEqual(as(['positions']), ['chat']);
assert.deepEqual(as(['telemetry']), ['chat']);
assert.deepEqual(as(['nodes']), ['table', 'map', 'chat']);
assert.deepEqual(as(['destinations']), ['table']);
assert.deepEqual(as(['neighbors']), ['map', 'chat']);
assert.deepEqual(as(['traces']), ['map', 'chat']);
assert.deepEqual(as(['waypoints']), ['map', 'chat']);
assert.deepEqual(as(['messages', 'destinations']), ['table', 'chat']);
assert.deepEqual(as([]), []);
assert.deepEqual(REPAINT_SURFACES, ['table', 'map', 'chat']);
assert.equal(Object.isFrozen(SURFACE_INPUTS.chat), true);
});
test('nextMapRepaintAt finds the next freshness-bucket crossing of a drawn marker', () => {
const heardRecently = NOW - 100;
assert.equal(nextMapRepaintAt([{ last_heard: heardRecently }], [], NOW), heardRecently + 3 * 3600);
const heardHoursAgo = NOW - 4 * 3600;
assert.equal(nextMapRepaintAt([{ last_heard: heardHoursAgo }], [], NOW), heardHoursAgo + 24 * 3600);
assert.equal(nextMapRepaintAt([{ last_heard: NOW - 25 * 3600 }], [], NOW), Infinity, 'a stale marker never moves');
assert.equal(nextMapRepaintAt([{ last_heard: null }, null, { last_heard: 'x' }], null, NOW), Infinity);
assert.equal(nextMapRepaintAt(null, undefined, NOW), Infinity, 'nothing drawn, nothing ages');
assert.equal(
nextMapRepaintAt([{ last_heard: NOW - 4 * 3600 }, { last_heard: NOW - 100 }], [], NOW),
NOW - 100 + 3 * 3600,
'the earliest crossing wins',
);
// The moment returned is where the bucket really changes.
const at = nextMapRepaintAt([{ last_heard: heardRecently }], [], NOW);
assert.notEqual(nodeAgeBucket(heardRecently, at), nodeAgeBucket(heardRecently, at - 0.5));
});
test('nextMapRepaintAt finds the next waypoint dimming step or expiry, matching the layer', () => {
/**
* Walk the repaint moments of one waypoint and check each is a visible change.
*
* @param {number} expire Expiry, unix seconds.
* @returns {Array<number>} Each moment relative to the expiry.
*/
const steps = expire => {
const seen = [];
let now = NOW;
for (let at = nextMapRepaintAt([], [{ expire }], now); at !== Infinity; at = nextMapRepaintAt([], [{ expire }], now)) {
seen.push(Math.round((at - expire) * 1000) / 1000);
// Each moment is a real change of what the pin layer draws, and the
// change is no older than 2 ms when the moment comes.
const before = [waypointExpiryOpacity(expire, at - 0.002), isWaypointExpired(expire, at - 0.002)];
const after = [waypointExpiryOpacity(expire, at), isWaypointExpired(expire, at)];
assert.notDeepEqual(after, before, `no visible change at expire${at - expire}`);
now = at;
}
return seen;
};
assert.deepEqual(steps(NOW + 3 * 24 * 3600), [-24 * 3600 + 0.001, -3600 + 0.001, 0]);
assert.deepEqual(steps(NOW + 1800), [0], 'inside the last hour only expiry is left');
assert.equal(nextMapRepaintAt([], [{ expire: NOW - 10 }], NOW), Infinity, 'an expired pin is gone already');
assert.equal(nextMapRepaintAt([], [{ expire: 0 }, { expire: null }, null, { expire: -5 }], NOW), Infinity);
});
test('a new planner paints every surface once, then only what is marked', () => {
const planner = createRepaintPlanner();
assert.deepEqual([...planner.pending()], ['table', 'map', 'chat'], 'every surface starts unpainted');
assert.deepEqual([...planner.take(NOW)], ['table', 'map', 'chat']);
assert.deepEqual([...planner.take(NOW)], [], 'nothing changed since');
planner.markCollections(['messages']);
assert.deepEqual([...planner.take(NOW)], ['chat']);
planner.markAllSurfaces();
assert.deepEqual([...planner.take(NOW)], ['table', 'map', 'chat']);
assert.deepEqual(planner.renderCounts(), { table: 2, map: 2, chat: 3 });
const pending = planner.pending();
pending.add('table');
assert.deepEqual([...planner.pending()], [], 'pending() hands out a copy');
});
test('noteRows marks the surfaces of a collection only when its rows changed', () => {
const planner = createRepaintPlanner();
planner.take(NOW);
const node = { node_id: '!a', last_heard: NOW, short_name: 'A' };
assert.equal(planner.noteRows('nodes', [node], [{ ...node }]), false, 'a re-derived but equal record');
assert.deepEqual([...planner.take(NOW)], []);
assert.equal(planner.noteRows('nodes', [node], [{ ...node, last_heard: NOW + 5 }]), true);
assert.deepEqual([...planner.take(NOW)], ['table', 'map', 'chat']);
const waypoint = { id: 9, protocol: 'meshtastic', name: 'POI' };
assert.equal(planner.noteRows('waypoints', [waypoint], [{ ...waypoint, name: 'Moved' }]), true);
assert.deepEqual([...planner.take(NOW)], ['map', 'chat']);
planner.noteCollections(
{ neighbors: [], messages: [{ id: 1 }], positions: [] },
{ neighbors: [], messages: [{ id: 1 }, { id: 2 }], positions: [] },
);
assert.deepEqual([...planner.take(NOW)], ['chat'], 'only the collection that changed counts');
planner.noteCollections({}, { neighbors: [{ node_id: '!a', neighbor_id: '!b' }], traces: [{ id: 4 }] });
assert.deepEqual([...planner.take(NOW)], ['map', 'chat']);
planner.noteCollections({ telemetry: [], encrypted: [] }, { telemetry: [{ id: 3 }], encrypted: [] });
assert.deepEqual([...planner.take(NOW)], ['chat']);
});
test('expireAt repaints a surface on the first take at or after its moment', () => {
const planner = createRepaintPlanner();
planner.take(NOW);
planner.expireAt('map', NOW + 10);
assert.deepEqual([...planner.take(NOW + 9)], []);
assert.deepEqual([...planner.take(NOW + 10)], ['map']);
assert.deepEqual([...planner.take(NOW + 20)], [], 'a repaint clears the schedule');
planner.expireAt('map', NOW + 30);
planner.expireAt('map', Infinity);
assert.deepEqual([...planner.take(NOW + 40)], [], 'Infinity clears it too');
});
test('noteRows keys each collection by the key it merges on', () => {
const rows = {
nodes: [{ node_id: '!a', v: 1 }, { node_id: '!b', v: 1 }],
positions: [{ id: 1, v: 1 }, { id: 2, v: 1 }],
telemetry: [{ id: 1, v: 1 }, { id: 2, v: 1 }],
neighbors: [{ node_id: '!a', neighbor_id: '!b', v: 1 }, { node_id: '!b', neighbor_id: '!a', v: 1 }],
traces: [{ id: 1, v: 1 }, { id: 2, v: 1 }],
waypoints: [{ id: 1, protocol: 'meshtastic', v: 1 }, { id: 1, protocol: 'meshcore', v: 1 }],
messages: [{ id: 1, v: 1 }, { id: 2, v: 1 }],
encrypted: [{ id: 1, v: 1 }, { id: 2, v: 1 }],
};
for (const [collection, before] of Object.entries(rows)) {
const planner = createRepaintPlanner();
planner.take(NOW);
// The same rows re-sent in the other order are no change...
const resent = before.map(row => ({ ...row })).reverse();
assert.equal(planner.noteRows(collection, before, resent), false, `${collection}: re-sent rows`);
// ... an edit to one of them is.
const edited = [before[0], { ...before[1], v: 2 }];
assert.equal(planner.noteRows(collection, before, edited), true, `${collection}: an edited row`);
}
});
/** The chat's window, as the dashboard passes it (7 days). */
const WEEK = 7 * 24 * 3600;
/**
* The moment ``buildChatTabModel`` stops showing its earliest entry: the first
* whole second past ts + window, found by asking the model itself.
*
* @param {Object} sources Chat inputs by collection.
* @param {number} nowSec Render time, unix seconds.
* @returns {number} Unix seconds, or Infinity when the model shows nothing.
*/
function modelLeavesAt(sources, nowSec) {
const model = buildChatTabModel({
nodes: sources.nodes,
telemetry: sources.telemetry,
positions: sources.positions,
neighbors: sources.neighbors,
traces: sources.traces,
waypoints: sources.waypoints,
messages: sources.messages,
logOnlyMessages: sources.encrypted,
nowSeconds: Math.floor(nowSec),
windowSeconds: WEEK,
});
const times = [
...model.logEntries.map(entry => entry.ts),
...model.channels.flatMap(channel => channel.entries.map(entry => entry.ts)),
];
if (!times.length) return Infinity;
const leavesAt = Math.floor(Math.min(...times) + WEEK) + 1;
// Cross-check the moment against the model: still shown a second before it,
// gone at it.
const shownAt = at => {
const later = buildChatTabModel({
nodes: sources.nodes, telemetry: sources.telemetry, positions: sources.positions,
neighbors: sources.neighbors, traces: sources.traces, waypoints: sources.waypoints,
messages: sources.messages, logOnlyMessages: sources.encrypted,
nowSeconds: Math.floor(at), windowSeconds: WEEK,
});
return later.logEntries.length + later.channels.reduce((sum, channel) => sum + channel.entries.length, 0);
};
assert.ok(shownAt(leavesAt) < shownAt(leavesAt - 1), 'the earliest entry leaves at that moment');
return leavesAt;
}
test('nextChatRepaintAt matches the chat model for every Log and channel entry source (DR4)', () => {
const now = NOW;
const old = now - WEEK + 50;
const isoOf = ts => new Date(ts * 1000).toISOString();
const telemetryWithHistory = { id: 3, node_id: '!t', rx_time: now - 10 };
Object.defineProperty(telemetryWithHistory, 'snapshots', {
value: [{ node_id: '!t', telemetry_time: old + 7 }, { node_id: '!t', rx_time: now - 10 }],
enumerable: false,
});
const cases = {
nodes: [{ node_id: '!n', first_heard: old + 1, last_heard: now - 5 }],
'nodes (last heard, ISO)': [{ node_id: '!n', last_seen_iso: isoOf(old + 2) }],
telemetry: [telemetryWithHistory],
positions: [{ id: 4, node_id: '!p', position_time: old + 3 }],
neighbors: [{ node_id: '!a', neighbor_id: '!b', rx_iso: isoOf(old + 4) }],
traces: [{ id: 5, src: '!a', dest: '!b', hops: [], rx_time: old + 5 }],
waypoints: [{ id: 6, protocol: 'meshtastic', node_id: '!w', rx_time: old + 6, expire: 0 }],
messages: [{ id: 7, channel: 0, from_id: '!m', text: 'hi', rx_time: old + 8 }],
encrypted: [{ id: 8, encrypted: 'AbC=', from_id: '!e', rx_time: old + 9 }, { id: 9, rx_time: old }],
};
for (const [label, rows] of Object.entries(cases)) {
const collection = label.split(' ')[0];
const sources = { [collection]: rows };
const expected = modelLeavesAt(sources, now);
assert.ok(Number.isFinite(expected), `${label}: the model shows the entry`);
assert.equal(nextChatRepaintAt(sources, now, WEEK), expected, label);
}
// All together, the earliest entry wins: the node's first heard.
const all = Object.fromEntries(Object.entries(cases).filter(([label]) => !label.includes(' ')));
assert.equal(nextChatRepaintAt(all, now, WEEK), modelLeavesAt(all, now));
assert.equal(nextChatRepaintAt(all, now, WEEK), Math.floor(old + 1 + WEEK) + 1);
});
test('nextChatRepaintAt ignores what has left the window, unrendered rows and bad input (DR4)', () => {
const now = NOW;
assert.equal(nextChatRepaintAt(null, now, WEEK), Infinity);
assert.equal(nextChatRepaintAt({ nodes: 'x', messages: [null, 7, {}] }, now, WEEK), Infinity);
// Out of the window already, or an unencrypted row in the encrypted list.
assert.equal(nextChatRepaintAt({ messages: [{ id: 1, rx_time: now - WEEK - 5 }] }, now, WEEK), Infinity);
assert.equal(nextChatRepaintAt({ encrypted: [{ id: 2, rx_time: now - 10 }] }, now, WEEK), Infinity);
// A fractional (ISO) timestamp leaves at the next whole second past ts + window.
assert.equal(nextChatRepaintAt({ traces: [{ id: 3, rx_time: now - WEEK + 9.5 }] }, now, WEEK), now + 10);
});
test('nextExpiryTextChangeAt finds the next change of the Log\'s "Expires" text (DR4)', () => {
/**
* The Log's expiry text, as buildWaypointChatEntryParts writes it.
*
* @param {number} expire Expiry, unix seconds.
* @param {number} at Render time, unix seconds.
* @returns {string} Remaining time, or "expired".
*/
const text = (expire, at) => {
const remaining = Math.floor(expire - at);
return remaining > 0 ? timeHum(remaining) : 'expired';
};
for (const remaining of [1, 30, 59, 60, 61, 3599, 3600, 3661, 7200, 86399, 86400, 90061, 3 * 86400]) {
const expire = NOW + remaining;
const at = nextExpiryTextChangeAt(expire, NOW);
assert.notEqual(text(expire, at), text(expire, NOW), `${remaining} s left: changes at the moment`);
assert.equal(text(expire, at - 0.002), text(expire, NOW), `${remaining} s left: not 2 ms earlier`);
}
// At NOW + 0.4 the Log shows 29 s; that changes as soon as NOW + 1 passes.
assert.ok(Math.abs(nextExpiryTextChangeAt(NOW + 30, NOW + 0.4) - (NOW + 1.001)) < 1e-6);
assert.equal(nextExpiryTextChangeAt(0, NOW), Infinity, 'never expires');
assert.equal(nextExpiryTextChangeAt(null, NOW), Infinity);
assert.equal(nextExpiryTextChangeAt('', NOW), Infinity);
assert.equal(nextExpiryTextChangeAt(NOW, NOW), Infinity, 'expired already');
assert.equal(nextExpiryTextChangeAt(NOW - 60, NOW), Infinity);
});
test('nextChatRepaintAt counts a waypoint entry down only while the Log shows it (DR4)', () => {
const now = NOW;
const shown = { id: 1, protocol: 'meshtastic', rx_time: now - 60, expire: now + 7230 };
// "2h 0m" left until fewer than 7200 s remain, long before the entry
// leaves the window.
assert.ok(Math.abs(nextChatRepaintAt({ waypoints: [shown] }, now, WEEK) - (now + 30.001)) < 1e-6);
// Past the window the entry is gone, and so is its countdown.
const gone = { ...shown, rx_time: now - WEEK - 60 };
assert.equal(nextChatRepaintAt({ waypoints: [gone] }, now, WEEK), Infinity);
// Expired: only the window is left.
const expired = { ...shown, expire: now - 1 };
assert.equal(nextChatRepaintAt({ waypoints: [expired] }, now, WEEK), Math.floor(now - 60 + WEEK) + 1);
});
test('nextChatRepaintAt reads every timestamp field the chat model reads (DR4)', () => {
const now = NOW;
const at = now - WEEK + 40;
const iso = new Date(at * 1000).toISOString();
// One field per row, so each fallback the model follows is exercised alone.
const fields = {
nodes: ['first_heard', 'firstHeard', 'last_heard', 'lastHeard', ['first_heard_iso', iso], ['firstHeardIso', iso], ['last_seen_iso', iso], ['lastSeenIso', iso]],
telemetry: ['rx_time', 'rxTime', 'telemetry_time', 'telemetryTime', ['rx_iso', iso], ['rxIso', iso], ['telemetry_time_iso', iso], ['telemetryTimeIso', iso]],
positions: ['rx_time', 'rxTime', 'position_time', 'positionTime', ['rx_iso', iso], ['rxIso', iso], ['position_time_iso', iso], ['positionTimeIso', iso]],
neighbors: ['rx_time', 'rxTime', ['rx_iso', iso], ['rxIso', iso]],
waypoints: ['rx_time', 'rxTime', ['rx_iso', iso], ['rxIso', iso]],
traces: ['rx_time', 'rxTime', ['rx_iso', iso], ['rxIso', iso]],
messages: ['rx_time', 'rxTime', ['rx_iso', iso], ['rxIso', iso]],
encrypted: ['rx_time', 'rxTime', ['rx_iso', iso], ['rxIso', iso]],
};
const base = {
nodes: { node_id: '!n' },
telemetry: { id: 1, node_id: '!t' },
positions: { id: 2, node_id: '!p' },
neighbors: { node_id: '!a', neighbor_id: '!b' },
waypoints: { id: 3, protocol: 'meshtastic', node_id: '!w' },
traces: { id: 4, src: '!a', dest: '!b', hops: [] },
messages: { id: 5, channel: 0, from_id: '!m', text: 'hi' },
encrypted: { id: 6, encrypted: 'AbC=', from_id: '!e' },
};
for (const [collection, list] of Object.entries(fields)) {
for (const field of list) {
const [name, value] = Array.isArray(field) ? field : [field, at];
const sources = { [collection]: [{ ...base[collection], [name]: value }] };
const expected = modelLeavesAt(sources, now);
assert.equal(expected, Math.floor(at + WEEK) + 1, `${collection}.${name}: the model shows the entry`);
assert.equal(nextChatRepaintAt(sources, now, WEEK), expected, `${collection}.${name}`);
}
}
});
test('a refresh half a second after an Expires or dimming step is past its moment (DR4)', () => {
// The Log's text changes as soon as the step's boundary passes.
assert.ok(nextExpiryTextChangeAt(NOW + 120, NOW) <= NOW + 0.5, '2m 0s reads 1m 59s at NOW + 0.5');
assert.ok(nextExpiryTextChangeAt(NOW + 7200, NOW) <= NOW + 0.5, '2h 0m reads 1h 59m at NOW + 0.5');
// So does the pin's opacity: 0.7 until 3600 s remain, then 0.4.
const expire = NOW + 3600 + 10;
assert.equal(waypointExpiryOpacity(expire, NOW + 10.5), 0.4);
assert.ok(nextMapRepaintAt([], [{ expire }], NOW) <= NOW + 10.5, 'the pin dims at NOW + 10.5');
});
test('nextChatRepaintAt skips a null snapshot, as the chat model does (DR4)', () => {
const row = { id: 1, node_id: '!t', rx_time: NOW - 10 };
Object.defineProperty(row, 'snapshots', {
value: [null, { node_id: '!t', rx_time: NOW - WEEK + 30 }],
enumerable: false,
});
const sources = { telemetry: [row] };
assert.equal(nextChatRepaintAt(sources, NOW, WEEK), modelLeavesAt(sources, NOW));
assert.equal(nextChatRepaintAt(sources, NOW, WEEK), NOW + 31);
});
@@ -0,0 +1,183 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Keep open map overlays and line tooltips on their anchors across a map
* rebuild (SPEC DR2, extends LD-A3).
*
* `renderMap` clears and rebuilds the node markers, the neighbour and trace
* lines and the waypoint pins whenever their data changes. A rebuild destroys
* the DOM element an open short-info overlay is anchored to, so
* `cleanupOrphans` would close the overlay, and removing a Leaflet layer
* closes its open tooltip. These helpers snapshot, per layer key, which layer
* hosts an open overlay or an open tooltip before the rebuild, and carry each
* one onto the rebuilt layer with the same key afterwards.
*
* Keys identify what a layer depicts rather than the layer object: the node id
* for a marker, the segment key for a line ({@link neighborSegmentKey},
* {@link traceSegmentKey}) and the waypoint key for a pin. A key the rebuild
* no longer draws has no layer to move to; its overlay is left for
* `cleanupOrphans` to close, which is the intended outcome.
*
* The helpers take the overlay stack and the key-to-layer maps as arguments
* and touch nothing else, so they unit-test without Leaflet or a DOM.
*
* @module main/map-overlay-anchors
*/
/**
* One layer's open overlay and tooltip state, captured before a rebuild.
*
* @typedef {Object} AnchorSnapshot
* @property {string} key Key of the layer in its registry.
* @property {?Object} anchor The old layer's element when it hosts an open
* overlay, otherwise ``null``.
* @property {boolean} tooltipOpen Whether the old layer's tooltip was open.
* @property {?Object} tooltipLatLng Where the open tooltip stood, so a sticky
* tooltip reopens where the reader saw it; ``null`` when unknown.
*/
/**
* Resolve a Leaflet layer's DOM element.
*
* @param {?Object} layer Leaflet marker or path.
* @returns {?Object} The element, or ``null`` when the layer has none.
*/
function layerElement(layer) {
if (!layer || typeof layer.getElement !== 'function') return null;
return layer.getElement() || null;
}
/**
* The tooltip bound to a layer, if any.
*
* @param {?Object} layer Leaflet layer.
* @returns {?Object} The bound tooltip, or ``null``.
*/
function boundTooltip(layer) {
return layer && typeof layer.getTooltip === 'function' ? layer.getTooltip() || null : null;
}
/**
* Report whether a layer's tooltip is open and where it stands.
*
* @param {?Object} layer Leaflet layer that may carry a bound tooltip.
* @returns {{ open: boolean, latLng: ?Object }} Tooltip state.
*/
function tooltipState(layer) {
const tooltip = boundTooltip(layer);
// Leaflet's isTooltipOpen() dereferences the bound tooltip and throws on a
// layer without one (a marker, a waypoint pin), so ask only when bound.
if (!tooltip || typeof layer.isTooltipOpen !== 'function' || !layer.isTooltipOpen()) {
return { open: false, latLng: null };
}
const latLng = typeof tooltip.getLatLng === 'function' ? tooltip.getLatLng() : null;
return { open: true, latLng: latLng || null };
}
/**
* Snapshot every keyed layer that hosts an open overlay or an open tooltip.
*
* @param {?{ isOpen?: Function }} overlayStack Short-info overlay stack; a
* stack without ``isOpen`` captures tooltips only.
* @param {?Map<string, Object>} layersByKey Key to Leaflet layer map of the
* render about to be replaced.
* @returns {Array<AnchorSnapshot>} One entry per layer with something open, in
* map order; empty when the map is missing.
*/
export function captureKeyedAnchors(overlayStack, layersByKey) {
const captured = [];
if (!(layersByKey instanceof Map)) return captured;
const canCheckOverlays = Boolean(overlayStack) && typeof overlayStack.isOpen === 'function';
for (const [key, layer] of layersByKey) {
const element = layerElement(layer);
const anchor = canCheckOverlays && element && overlayStack.isOpen(element) ? element : null;
const tooltip = tooltipState(layer);
if (anchor || tooltip.open) {
captured.push({ key, anchor, tooltipOpen: tooltip.open, tooltipLatLng: tooltip.latLng });
}
}
return captured;
}
/**
* Carry captured overlays and tooltips onto the rebuilt layers.
*
* An overlay is re-anchored from its detached element to the element of the
* rebuilt layer with the same key; a tooltip is reopened on that layer at the
* captured position. A key without a rebuilt layer is skipped.
*
* @param {?{ reanchor?: Function }} overlayStack Short-info overlay stack; a
* stack without ``reanchor`` restores tooltips only.
* @param {?Array<AnchorSnapshot>} captured Snapshot from
* {@link captureKeyedAnchors}.
* @param {?Map<string, Object>} layersByKey Key to Leaflet layer map of the
* rebuilt render.
* @returns {{ overlays: number, tooltips: number }} How many overlays were
* re-anchored and how many tooltips reopened.
*/
export function restoreKeyedAnchors(overlayStack, captured, layersByKey) {
const restored = { overlays: 0, tooltips: 0 };
if (!Array.isArray(captured) || !(layersByKey instanceof Map)) return restored;
const canReanchor = Boolean(overlayStack) && typeof overlayStack.reanchor === 'function';
for (const entry of captured) {
const layer = entry && layersByKey.get(entry.key);
if (!layer) continue;
if (entry.anchor && canReanchor) {
const element = layerElement(layer);
if (element && overlayStack.reanchor(entry.anchor, element)) restored.overlays += 1;
}
if (entry.tooltipOpen && boundTooltip(layer) && typeof layer.openTooltip === 'function') {
// Leaflet anchors a tooltip opened without a position at the layer's
// centre; a sticky line tooltip reopens where the reader last saw it.
layer.openTooltip(entry.tooltipLatLng || undefined);
restored.tooltips += 1;
}
}
return restored;
}
/**
* Segment key of a directed neighbour line (the map draws one line per
* direction, deduplicated on the same pair).
*
* @param {*} sourceId Canonical id of the reporting node.
* @param {*} targetId Canonical id of the neighbour it heard.
* @returns {?string} ``neighbor:<source>→<target>``, or ``null`` when either
* id is missing.
*/
export function neighborSegmentKey(sourceId, targetId) {
if (typeof sourceId !== 'string' || !sourceId || typeof targetId !== 'string' || !targetId) {
return null;
}
return `neighbor:${sourceId}→${targetId}`;
}
/**
* Segment key of one hop line of a traceroute: the trace id plus the hop's
* position among that trace's drawn segments. Every segment of a trace shows
* the same path tooltip, so the position is a stable enough anchor even when a
* hop gains or loses its coordinates.
*
* @param {*} traceId Trace (packet) id; traces without one get no key.
* @param {*} index Zero-based position of the segment within its trace.
* @returns {?string} ``trace:<id>#<index>``, or ``null`` without a usable id
* or index.
*/
export function traceSegmentKey(traceId, index) {
if (traceId == null || traceId === '' || !Number.isInteger(index) || index < 0) return null;
return `trace:${traceId}#${index}`;
}
@@ -27,9 +27,15 @@
* overlay stays open while updates fire. They take the overlay stack and the
* node->marker map as arguments, so they unit-test without a real map or DOM.
*
* Both delegate to the keyed helpers in `map-overlay-anchors.js` (SPEC DR2),
* which carry overlays and tooltips across a rebuild for every map layer; these
* keep the node-id-keyed shape the marker path and LD-A3 use.
*
* @module main/marker-overlay-preservation
*/
import { captureKeyedAnchors, restoreKeyedAnchors } from './map-overlay-anchors.js';
/**
* Snapshot the open marker overlays, keyed by node id, before a map rebuild.
*
@@ -40,17 +46,10 @@
* preserve; empty when the stack/map is missing or nothing is open.
*/
export function captureOpenMarkerOverlays(overlayStack, markerByNodeId) {
const captured = [];
if (!overlayStack || typeof overlayStack.isOpen !== 'function' || !markerByNodeId) {
return captured;
}
for (const [nodeId, marker] of markerByNodeId) {
const anchor = marker && typeof marker.getElement === 'function' ? marker.getElement() : null;
if (anchor && overlayStack.isOpen(anchor)) {
captured.push({ nodeId, anchor });
}
}
return captured;
// Markers bind no tooltip, so only entries that host an overlay matter here.
return captureKeyedAnchors(overlayStack, markerByNodeId)
.filter(entry => entry.anchor)
.map(entry => ({ nodeId: entry.key, anchor: entry.anchor }));
}
/**
@@ -69,25 +68,9 @@ export function captureOpenMarkerOverlays(overlayStack, markerByNodeId) {
* @returns {number} count of overlays re-anchored.
*/
export function restoreMarkerOverlays(overlayStack, captured, markerByNodeId) {
if (
!overlayStack ||
typeof overlayStack.reanchor !== 'function' ||
!Array.isArray(captured) ||
!markerByNodeId ||
typeof markerByNodeId.get !== 'function'
) {
return 0;
}
let restored = 0;
for (const entry of captured) {
if (!entry || !entry.nodeId) {
continue;
}
const marker = markerByNodeId.get(entry.nodeId);
const newAnchor = marker && typeof marker.getElement === 'function' ? marker.getElement() : null;
if (newAnchor && overlayStack.reanchor(entry.anchor, newAnchor)) {
restored += 1;
}
}
return restored;
if (!Array.isArray(captured)) return 0;
const snapshots = captured
.filter(entry => entry && entry.nodeId)
.map(entry => ({ key: entry.nodeId, anchor: entry.anchor, tooltipOpen: false, tooltipLatLng: null }));
return restoreKeyedAnchors(overlayStack, snapshots, markerByNodeId).overlays;
}
@@ -0,0 +1,454 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Decide which dashboard surfaces a refresh repaints (SPEC DR4).
*
* Every refresh path (an SSE ping, the reconnect resync, the safety poll, the
* cache seed, a backfill flush, a destinations page) merges rows into the
* dashboard state and then repaints. Before DR4 that repaint rebuilt the nodes
* table, the map and the chat whatever had changed, so a `messages` ping also
* replaced every table row and every map layer. The planner records which
* collections actually changed and maps them onto the surfaces that render
* them ({@link SURFACE_INPUTS}): a `messages` ping that changes no node,
* position or neighbour data repaints the chat alone, and a refresh that
* changes nothing repaints nothing.
*
* Changes are found by comparing rows, not by trusting which collection a
* refresh fetched: a delta fetch re-sends the rows of its one-second overlap,
* and a `messages` ingest always publishes `nodes` as well, so "fetched" is
* not "changed". User actions (filter, sort, role and protocol toggles, the
* identity caret) change what every surface shows and mark all of them.
*
* The map and the chat also render the clock. On the map a marker's freshness
* bucket (SPEC UX5/PD3) and a waypoint's expiry step (SPEC W6) change without
* any data changing; in the chat an entry leaves the 7-day window and a
* waypoint Log entry counts down to its expiry. {@link nextMapRepaintAt} and
* {@link nextChatRepaintAt} give the first such moment after a render, and the
* planner repaints that surface on the first refresh past it.
*
* @module main/repaint-planner
*/
import { AGE_BUCKET_LIVE_MAX_SECONDS, AGE_BUCKET_TODAY_MAX_SECONDS } from './age-bucket.js';
import { waypointKey } from './waypoint-layer.js';
import { resolveTimestampSeconds } from '../chat-log-tabs.js';
import { toFiniteNumber } from './format-utils.js';
/** The surfaces a refresh can repaint, in paint order. */
export const REPAINT_SURFACES = Object.freeze(['table', 'map', 'chat']);
/**
* Collections each surface renders. `nodes` is the derived node record set
* (nodes with their latest position and telemetry merged in), so a position or
* telemetry row reaches the table and the map only when it changes a record;
* the raw `positions` and `telemetry` rows feed the chat's Log tab. The table
* also renders the Reticulum destinations (identity groups, SPEC RA1); the map
* draws neighbour and trace lines and waypoint pins; the chat shows messages,
* encrypted notices and a Log entry per packet of every other collection.
*
* @type {Readonly<Record<string, ReadonlyArray<string>>>}
*/
export const SURFACE_INPUTS = Object.freeze({
table: Object.freeze(['nodes', 'destinations']),
map: Object.freeze(['nodes', 'neighbors', 'traces', 'waypoints']),
chat: Object.freeze(['nodes', 'messages', 'encrypted', 'positions', 'telemetry', 'neighbors', 'traces', 'waypoints']),
});
/**
* Row identity per collection: the key each collection merges on.
*
* @type {Readonly<Record<string, Function>>}
*/
const COLLECTION_KEYS = Object.freeze({
nodes: row => row && row.node_id,
positions: row => row && row.id,
telemetry: row => row && row.id,
neighbors: row => row && `${row.node_id}|${row.neighbor_id}`,
traces: row => row && row.id,
waypoints: row => waypointKey(row),
messages: row => row && row.id,
encrypted: row => row && row.id,
});
/**
* A waypoint's opacity drops to 0.7 inside its last day and to 0.4 inside its
* last hour (`waypointExpiryOpacity`); it leaves the map at `expire`.
*/
const WAYPOINT_EXPIRY_STEPS_SECONDS = Object.freeze([24 * 3600, 3600]);
/**
* How far past a strict boundary (one that applies once it has passed) a
* clock step is scheduled: a millisecond, the resolution of ``Date.now()``.
*/
const STEP_EPSILON_SECONDS = 0.001;
/**
* Compare two values field by field: primitives with ``===`` (and ``NaN``
* equal to itself), arrays element-wise, plain objects over their own
* enumerable keys. Non-enumerable bookkeeping such as the snapshot history the
* aggregator attaches is ignored, as it is never rendered.
*
* @param {*} a First value.
* @param {*} b Second value.
* @returns {boolean} Whether the two hold the same data.
*/
export function recordsEqual(a, b) {
if (a === b || (Number.isNaN(a) && Number.isNaN(b))) return true;
if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false;
if (Array.isArray(a) !== Array.isArray(b)) return false;
if (Array.isArray(a)) {
if (a.length !== b.length) return false;
return a.every((value, index) => recordsEqual(value, b[index]));
}
const keys = Object.keys(a);
if (keys.length !== Object.keys(b).length) return false;
return keys.every(key => Object.prototype.hasOwnProperty.call(b, key) && recordsEqual(a[key], b[key]));
}
/**
* Whether a collection's rows changed between two snapshots: a row added,
* removed (a window trim) or carrying different data. Rows the merge kept are
* the same objects and cost one identity check.
*
* @param {?Array<Object>} before Rows before the merge.
* @param {?Array<Object>} after Rows after the merge.
* @param {Function} keyOf Row to merge key.
* @returns {boolean} ``true`` when anything a surface could render differs.
*/
export function keyedRowsChanged(before, after, keyOf) {
if (before === after) return false;
const previous = Array.isArray(before) ? before : [];
const next = Array.isArray(after) ? after : [];
if (previous.length !== next.length) return true;
// The merges keep surviving rows in place, so compare position by position
// first; only rows that moved need the keyed lookup below.
let aligned = true;
for (let index = 0; index < next.length; index += 1) {
const old = previous[index];
const row = next[index];
if (old === row) continue;
if (keyOf(old) !== keyOf(row)) {
aligned = false;
break;
}
if (!recordsEqual(old, row)) return true;
}
if (aligned) return false;
const byKey = new Map();
for (const row of previous) byKey.set(keyOf(row), row);
for (const row of next) {
const key = keyOf(row);
if (!byKey.has(key)) return true;
const old = byKey.get(key);
if (old !== row && !recordsEqual(old, row)) return true;
}
return false;
}
/**
* The surfaces that render any of ``collections``.
*
* @param {Iterable<string>} collections Changed collection names.
* @returns {Set<string>} Surfaces to repaint, from {@link REPAINT_SURFACES}.
*/
export function surfacesForCollections(collections) {
const changed = new Set(collections);
const surfaces = new Set();
for (const surface of REPAINT_SURFACES) {
if (SURFACE_INPUTS[surface].some(collection => changed.has(collection))) surfaces.add(surface);
}
return surfaces;
}
/**
* First moment after ``nowSec`` at which the map drawn at ``nowSec`` goes
* stale without any data changing: a marker crossing a freshness bucket (3 h,
* 24 h after it was last heard; SPEC UX5/PD3), or a waypoint stepping down its
* expiry ladder or expiring (SPEC W6).
*
* @param {?Array<Object>} nodes Node records the map drew.
* @param {?Array<Object>} waypoints Waypoint rows the map was given.
* @param {number} nowSec Render time, unix seconds.
* @returns {number} Unix seconds of the next clock-driven change, or
* ``Infinity`` when nothing on the map ages.
*/
export function nextMapRepaintAt(nodes, waypoints, nowSec) {
const moments = [];
for (const node of Array.isArray(nodes) ? nodes : []) {
const heard = Number(node && node.last_heard);
// An unknown age reads as stale for good (nodeAgeBucket); it never moves.
if (!heard || !Number.isFinite(heard)) continue;
moments.push(heard + AGE_BUCKET_LIVE_MAX_SECONDS, heard + AGE_BUCKET_TODAY_MAX_SECONDS);
}
for (const waypoint of Array.isArray(waypoints) ? waypoints : []) {
const expire = Number(waypoint && waypoint.expire);
if (!expire || !Number.isFinite(expire) || expire <= 0) continue;
// A dim step applies as soon as its boundary passes (strictly after it),
// so repaint a millisecond in; expiry itself is inclusive
// (isWaypointExpired).
for (const step of WAYPOINT_EXPIRY_STEPS_SECONDS) moments.push(expire - step + STEP_EPSILON_SECONDS);
moments.push(expire);
}
let next = Infinity;
for (const at of moments) {
if (at > nowSec && at < next) next = at;
}
return next;
}
/**
* The per-packet snapshots the chat model walks for one row: the aggregator's
* hidden ``snapshots`` history when present, otherwise the row itself. Mirrors
* ``resolveSnapshotList`` in ``chat-log-tabs.js``.
*
* @param {Object} row Collection row ({@link nextChatRepaintAt} skips non-objects).
* @returns {Array<Object>} Snapshots to read timestamps from.
*/
function chatSnapshots(row) {
const { snapshots } = row;
const list = Array.isArray(snapshots) && snapshots.length > 0 ? snapshots : [row];
// The model skips an empty snapshot (``if (!snapshot) continue``).
return list.filter(Boolean);
}
/**
* Timestamps the chat renders an entry at, per source, with the field
* precedence ``buildChatTabModel`` uses: a node yields its first-heard
* ("New node") and last-heard ("Updated node info") entries; telemetry,
* positions and neighbours one entry per snapshot; waypoints, traces and
* messages one entry per row; the encrypted list only its encrypted rows.
*
* @type {Readonly<Record<string, (row: Object) => Array<?number>>>}
*/
const CHAT_ENTRY_TIMESTAMPS = Object.freeze({
nodes: node => [
resolveTimestampSeconds(node.first_heard ?? node.firstHeard, node.first_heard_iso ?? node.firstHeardIso),
resolveTimestampSeconds(node.last_heard ?? node.lastHeard, node.last_seen_iso ?? node.lastSeenIso),
],
telemetry: row => chatSnapshots(row).map(snapshot => resolveTimestampSeconds(
snapshot.rx_time ?? snapshot.rxTime ?? snapshot.telemetry_time ?? snapshot.telemetryTime,
snapshot.rx_iso ?? snapshot.rxIso ?? snapshot.telemetry_time_iso ?? snapshot.telemetryTimeIso,
)),
positions: row => chatSnapshots(row).map(snapshot => resolveTimestampSeconds(
snapshot.rx_time ?? snapshot.rxTime ?? snapshot.position_time ?? snapshot.positionTime,
snapshot.rx_iso ?? snapshot.rxIso ?? snapshot.position_time_iso ?? snapshot.positionTimeIso,
)),
neighbors: row => chatSnapshots(row).map(snapshot => resolveTimestampSeconds(
snapshot.rx_time ?? snapshot.rxTime,
snapshot.rx_iso ?? snapshot.rxIso,
)),
waypoints: row => [resolveTimestampSeconds(row.rx_time ?? row.rxTime, row.rx_iso ?? row.rxIso)],
traces: row => [resolveTimestampSeconds(row.rx_time ?? row.rxTime, row.rx_iso ?? row.rxIso)],
messages: row => [resolveTimestampSeconds(row.rx_time ?? row.rxTime, row.rx_iso ?? row.rxIso)],
encrypted: row => (row.encrypted
? [resolveTimestampSeconds(row.rx_time ?? row.rxTime, row.rx_iso ?? row.rxIso)]
: []),
});
/**
* First moment after ``nowSec`` at which a waypoint Log entry's
* "Expires: <remaining>" text changes. The Log shows ``timeHum`` of the whole
* seconds left, or "expired": hours while a day or more remains, minutes
* while an hour or more remains, seconds in the last hour.
*
* @param {*} expire Raw ``expire`` unix timestamp (absent or 0: never).
* @param {number} nowSec Render time, unix seconds.
* @returns {number} Unix seconds of the next text change, or ``Infinity``
* for "never" and "expired", which do not change.
*/
export function nextExpiryTextChangeAt(expire, nowSec) {
const at = toFiniteNumber(expire);
if (at == null || at <= 0) return Infinity;
const remaining = Math.floor(at - nowSec);
if (remaining <= 0) return Infinity;
let unit = 3600;
if (remaining < 3600) unit = 1;
else if (remaining < 86400) unit = 60;
// The text changes once fewer whole seconds remain than its shown amount,
// that is as soon as the boundary passes; repaint a millisecond after it,
// as for the map's dim steps. The next boundary is never earlier than the
// paint, so the schedule cannot loop.
return at - Math.floor(remaining / unit) * unit + STEP_EPSILON_SECONDS;
}
/**
* First moment after ``nowSec`` at which the chat painted at ``nowSec`` goes
* stale without any data changing: an entry leaving the window (the chat
* keeps entries no older than ``windowSeconds``, counted in whole seconds),
* or a waypoint Log entry's expiry text changing.
*
* @param {Object<string, ?Array<Object>>} sources Chat inputs by collection:
* ``nodes``, ``telemetry``, ``positions``, ``neighbors``, ``waypoints``,
* ``traces``, ``messages`` and ``encrypted``.
* @param {number} nowSec Time the paint started, unix seconds.
* @param {number} windowSeconds The chat's window (7 days).
* @returns {number} Unix seconds of the next clock-driven change, or
* ``Infinity`` when nothing in the chat ages.
*/
export function nextChatRepaintAt(sources, nowSec, windowSeconds) {
const moments = [];
for (const [collection, timestampsOf] of Object.entries(CHAT_ENTRY_TIMESTAMPS)) {
const rows = sources && Array.isArray(sources[collection]) ? sources[collection] : [];
for (const row of rows) {
if (!row || typeof row !== 'object') continue;
for (const ts of timestampsOf(row)) {
if (ts == null) continue;
// The model keeps ts >= floor(now) - window, so the entry leaves the
// first whole second past ts + window.
const leavesAt = Math.floor(ts + windowSeconds) + 1;
moments.push(leavesAt);
if (collection === 'waypoints' && leavesAt > nowSec) {
moments.push(nextExpiryTextChangeAt(row.expire, nowSec));
}
}
}
}
let next = Infinity;
for (const at of moments) {
if (at > nowSec && at < next) next = at;
}
return next;
}
/**
* Create the planner one dashboard instance uses.
*
* Every surface starts dirty, so the first repaint paints all of them.
*
* @returns {{
* markCollections: (collections: Iterable<string>) => void,
* markAllSurfaces: () => void,
* noteRows: (collection: string, before: ?Array<Object>, after: ?Array<Object>) => boolean,
* noteCollections: (before: Object<string, ?Array<Object>>, after: Object<string, ?Array<Object>>) => void,
* expireAt: (surface: string, at: number) => void,
* take: (nowSec: number) => Set<string>,
* pending: () => Set<string>,
* renderCounts: () => { table: number, map: number, chat: number },
* }} Planner handle.
*/
export function createRepaintPlanner() {
const dirty = new Set(REPAINT_SURFACES);
const expiries = new Map();
const counts = { table: 0, map: 0, chat: 0 };
/**
* Mark every surface that renders one of ``collections``.
*
* @param {Iterable<string>} collections Changed collection names.
* @returns {void}
*/
function markCollections(collections) {
for (const surface of surfacesForCollections(collections)) dirty.add(surface);
}
/**
* Mark every surface, for a user action that changes what each one shows.
*
* @returns {void}
*/
function markAllSurfaces() {
for (const surface of REPAINT_SURFACES) dirty.add(surface);
}
/**
* Compare one collection's rows and mark its surfaces when they changed.
*
* @param {string} collection Collection name (a {@link COLLECTION_KEYS} key).
* @param {?Array<Object>} before Rows before the merge.
* @param {?Array<Object>} after Rows after the merge.
* @returns {boolean} Whether the collection changed.
*/
function noteRows(collection, before, after) {
const changed = keyedRowsChanged(before, after, COLLECTION_KEYS[collection]);
if (changed) markCollections([collection]);
return changed;
}
/**
* {@link noteRows} for each collection named in ``after``.
*
* @param {Object<string, ?Array<Object>>} before Collection name to rows before.
* @param {Object<string, ?Array<Object>>} after Collection name to rows after.
* @returns {void}
*/
function noteCollections(before, after) {
for (const collection of Object.keys(after)) noteRows(collection, before[collection], after[collection]);
}
/**
* Repaint ``surface`` on the first {@link take} at or after ``at``.
*
* @param {string} surface Surface name.
* @param {number} at Unix seconds; ``Infinity`` clears the schedule.
* @returns {void}
*/
function expireAt(surface, at) {
if (Number.isFinite(at)) expiries.set(surface, at);
else expiries.delete(surface);
}
/**
* Hand out the surfaces due for a repaint and clear them. The caller must
* repaint every surface returned; each is counted as repainted.
*
* @param {number} nowSec Current time, unix seconds.
* @returns {Set<string>} Surfaces to repaint, in paint order.
*/
function take(nowSec) {
const due = new Set();
for (const surface of REPAINT_SURFACES) {
const expiry = expiries.get(surface);
if (dirty.has(surface) || (expiry !== undefined && nowSec >= expiry)) due.add(surface);
}
for (const surface of due) {
dirty.delete(surface);
expiries.delete(surface);
counts[surface] += 1;
}
return due;
}
/**
* The surfaces currently marked for a repaint (diagnostics and tests).
*
* @returns {Set<string>} A copy of the marked set.
*/
function pending() {
return new Set(dirty);
}
/**
* How many repaints {@link take} has handed out per surface (the DR4
* instrumentation tests assert on).
*
* @returns {{ table: number, map: number, chat: number }} A copy of the counts.
*/
function renderCounts() {
return { ...counts };
}
return {
markCollections,
markAllSurfaces,
noteRows,
noteCollections,
expireAt,
take,
pending,
renderCounts,
};
}