web: refactor 7/7 main js (#778)

* web: refactor 7/7 main js

* web: refactor 7/7 main js

* web: address review feedback on 7/7 main.js refactor

* Consolidate the duplicate ./main/format-utils.js import block in
  main.js so all symbols come from a single, alphabetised import
  statement (review item: "Important — Duplicate format-utils.js
  import block").
* Replace the leftover stale JSDoc atop +createOfflineTileLayer+ with
  one clear "do not inline" DI block, and likewise expand the
  +fetchMessages+ wrapper docstring so future readers see the shim's
  purpose without hunting for the implementation (review nit:
  "thin wrappers ... worth a one-line JSDoc").
* Add per-module unit tests under
  public/assets/js/app/main/__tests__/ covering every previously-
  uncovered branch in the 9 modules codecov flagged: tile-coords,
  sort-comparators, fullscreen-helpers, format-utils, data-fetchers,
  data-merge, tooltip-html, long-link-router, and offline-tile-layer.
  This drives the codecov patch percentage on PR #778 from 78.99%
  to ~100% on the new modules and unblocks the codecov/patch gate.

JS suite: 1,114 tests, 0 failures.
This commit is contained in:
l5y
2026-05-02 22:35:34 +02:00
committed by GitHub
parent d1d0225197
commit e04fab5b19
23 changed files with 3367 additions and 1024 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,346 @@
/*
* 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 {
fetchMessages,
fetchNeighbors,
fetchNodeById,
fetchNodes,
fetchPositions,
fetchTelemetry,
fetchTraces,
filterRecentTraces,
resolveSnapshotLimit,
} from '../data-fetchers.js';
import { NODE_LIMIT, SNAPSHOT_LIMIT, TRACE_LIMIT } from '../constants.js';
/**
* Install a temporary global ``fetch`` stub that records every call and
* returns the supplied response. Returns a teardown handle that restores
* the previous binding and exposes the captured call list.
*
* @param {{ ok?: boolean, status?: number, body?: any }|Function} responseOrFn
* Response descriptor or an async function returning one.
* @returns {{ calls: Array<{url: string, init: any}>, restore: Function }}
* Stub control surface.
*/
function withFetchStub(responseOrFn) {
const previous = globalThis.fetch;
const calls = [];
const handler = typeof responseOrFn === 'function'
? responseOrFn
: () => responseOrFn;
globalThis.fetch = async (url, init) => {
calls.push({ url, init });
const descriptor = await handler(url, init);
return {
ok: descriptor.ok ?? true,
status: descriptor.status ?? 200,
json: async () => descriptor.body ?? [],
};
};
return {
calls,
restore() {
if (previous === undefined) {
delete globalThis.fetch;
} else {
globalThis.fetch = previous;
}
},
};
}
// ---------------------------------------------------------------------------
// resolveSnapshotLimit
// ---------------------------------------------------------------------------
test('resolveSnapshotLimit multiplies the requested limit by SNAPSHOT_LIMIT', () => {
assert.equal(resolveSnapshotLimit(10), Math.min(10 * SNAPSHOT_LIMIT, NODE_LIMIT));
});
test('resolveSnapshotLimit caps to maxLimit', () => {
assert.equal(resolveSnapshotLimit(NODE_LIMIT), NODE_LIMIT);
assert.equal(resolveSnapshotLimit(NODE_LIMIT * 2), NODE_LIMIT);
});
test('resolveSnapshotLimit defaults to NODE_LIMIT for invalid input', () => {
assert.equal(resolveSnapshotLimit(null), NODE_LIMIT);
assert.equal(resolveSnapshotLimit(0), NODE_LIMIT);
assert.equal(resolveSnapshotLimit(-5), NODE_LIMIT);
assert.equal(resolveSnapshotLimit(Number.NaN), NODE_LIMIT);
});
// ---------------------------------------------------------------------------
// filterRecentTraces
// ---------------------------------------------------------------------------
test('filterRecentTraces returns empty array for non-array input', () => {
assert.deepEqual(filterRecentTraces(null), []);
assert.deepEqual(filterRecentTraces(undefined), []);
assert.deepEqual(filterRecentTraces({}), []);
});
test('filterRecentTraces returns a copy of the input when maxAgeSeconds is non-positive', () => {
const input = [{ rx_time: 1 }, { rx_time: 2 }];
const result = filterRecentTraces(input, 0);
assert.deepEqual(result, input);
assert.notEqual(result, input); // Returns a copy, not the same reference.
const negativeResult = filterRecentTraces(input, -10);
assert.deepEqual(negativeResult, input);
});
test('filterRecentTraces drops traces older than the cutoff', () => {
const nowSeconds = Math.floor(Date.now() / 1000);
const traces = [
{ rx_time: nowSeconds }, // recent
{ rx_time: nowSeconds - 7200 }, // older than 1h
{ rxIso: new Date((nowSeconds - 30) * 1000).toISOString() }, // recent via ISO
];
const filtered = filterRecentTraces(traces, 3600);
assert.equal(filtered.length, 2);
});
test('filterRecentTraces drops traces with no usable timestamp', () => {
const filtered = filterRecentTraces([{ noTime: true }, { rx_time: null }], 3600);
assert.deepEqual(filtered, []);
});
// ---------------------------------------------------------------------------
// fetchNodeById
// ---------------------------------------------------------------------------
test('fetchNodeById returns null for non-string inputs', async () => {
assert.equal(await fetchNodeById(null), null);
assert.equal(await fetchNodeById(42), null);
});
test('fetchNodeById returns null for blank string inputs', async () => {
assert.equal(await fetchNodeById(''), null);
assert.equal(await fetchNodeById(' '), null);
});
test('fetchNodeById returns null on HTTP 404', async () => {
const stub = withFetchStub({ ok: false, status: 404 });
try {
assert.equal(await fetchNodeById('!aabbccdd'), null);
assert.equal(stub.calls.length, 1);
assert.ok(stub.calls[0].url.includes('!aabbccdd'));
} finally {
stub.restore();
}
});
test('fetchNodeById throws on non-OK non-404 responses', async () => {
const stub = withFetchStub({ ok: false, status: 500 });
try {
await assert.rejects(() => fetchNodeById('!aabbccdd'), /HTTP 500/);
} finally {
stub.restore();
}
});
test('fetchNodeById returns parsed payload on success', async () => {
const stub = withFetchStub({ ok: true, status: 200, body: { node_id: '!aabbccdd' } });
try {
const result = await fetchNodeById('!aabbccdd');
assert.deepEqual(result, { node_id: '!aabbccdd' });
} finally {
stub.restore();
}
});
// ---------------------------------------------------------------------------
// fetchNodes / fetchNeighbors / fetchTelemetry / fetchPositions / fetchTraces
// ---------------------------------------------------------------------------
test('fetchNodes appends since when greater than zero', async () => {
const stub = withFetchStub({ ok: true, body: [] });
try {
await fetchNodes(10, 1234);
assert.ok(stub.calls[0].url.includes('since=1234'));
} finally {
stub.restore();
}
});
test('fetchNodes throws on non-OK', async () => {
const stub = withFetchStub({ ok: false, status: 503 });
try {
await assert.rejects(() => fetchNodes(), /HTTP 503/);
} finally {
stub.restore();
}
});
test('fetchNeighbors hits the neighbours endpoint', async () => {
const stub = withFetchStub({ ok: true, body: [{ node_id: '!a' }] });
try {
const result = await fetchNeighbors(50);
assert.ok(stub.calls[0].url.startsWith('/api/neighbors?'));
assert.deepEqual(result, [{ node_id: '!a' }]);
} finally {
stub.restore();
}
});
test('fetchNeighbors propagates HTTP errors', async () => {
const stub = withFetchStub({ ok: false, status: 502 });
try {
await assert.rejects(() => fetchNeighbors(), /HTTP 502/);
} finally {
stub.restore();
}
});
test('fetchTelemetry hits the telemetry endpoint', async () => {
const stub = withFetchStub({ ok: true, body: [] });
try {
await fetchTelemetry(50, 100);
assert.ok(stub.calls[0].url.startsWith('/api/telemetry?'));
assert.ok(stub.calls[0].url.includes('since=100'));
} finally {
stub.restore();
}
});
test('fetchTelemetry propagates HTTP errors', async () => {
const stub = withFetchStub({ ok: false, status: 504 });
try {
await assert.rejects(() => fetchTelemetry(), /HTTP 504/);
} finally {
stub.restore();
}
});
test('fetchPositions hits the positions endpoint', async () => {
const stub = withFetchStub({ ok: true, body: [] });
try {
await fetchPositions();
assert.ok(stub.calls[0].url.startsWith('/api/positions?'));
} finally {
stub.restore();
}
});
test('fetchPositions propagates HTTP errors', async () => {
const stub = withFetchStub({ ok: false, status: 500 });
try {
await assert.rejects(() => fetchPositions(), /HTTP 500/);
} finally {
stub.restore();
}
});
test('fetchTraces filters expired entries', async () => {
const nowSeconds = Math.floor(Date.now() / 1000);
const stub = withFetchStub({
ok: true,
body: [
{ rx_time: nowSeconds },
{ rx_time: nowSeconds - 365 * 24 * 3600 },
],
});
try {
const result = await fetchTraces();
// Only the recent trace should survive.
assert.equal(result.length, 1);
assert.ok(stub.calls[0].url.startsWith('/api/traces?'));
} finally {
stub.restore();
}
});
test('fetchTraces falls back to TRACE_LIMIT on bogus input', async () => {
const stub = withFetchStub({ ok: true, body: [] });
try {
await fetchTraces(Number.NaN);
assert.ok(stub.calls[0].url.includes(`limit=${TRACE_LIMIT}`));
} finally {
stub.restore();
}
});
test('fetchTraces propagates HTTP errors', async () => {
const stub = withFetchStub({ ok: false, status: 500 });
try {
await assert.rejects(() => fetchTraces(), /HTTP 500/);
} finally {
stub.restore();
}
});
// ---------------------------------------------------------------------------
// fetchMessages
// ---------------------------------------------------------------------------
test('fetchMessages returns [] when chatEnabled is false', async () => {
const stub = withFetchStub({ ok: true, body: [{ id: 1 }] });
try {
const result = await fetchMessages(10, { chatEnabled: false });
assert.deepEqual(result, []);
assert.equal(stub.calls.length, 0);
} finally {
stub.restore();
}
});
test('fetchMessages applies normaliseMessageLimit when provided', async () => {
const stub = withFetchStub({ ok: true, body: [] });
try {
await fetchMessages(999, {
normaliseMessageLimit: () => 25,
chatEnabled: true,
});
assert.ok(stub.calls[0].url.includes('limit=25'));
} finally {
stub.restore();
}
});
test('fetchMessages forwards encrypted=true and since when set', async () => {
const stub = withFetchStub({ ok: true, body: [] });
try {
await fetchMessages(10, { encrypted: true, since: 555 });
assert.ok(stub.calls[0].url.includes('encrypted=true'));
assert.ok(stub.calls[0].url.includes('since=555'));
} finally {
stub.restore();
}
});
test('fetchMessages omits limit normalisation when normaliser is absent', async () => {
const stub = withFetchStub({ ok: true, body: [] });
try {
await fetchMessages(50);
assert.ok(stub.calls[0].url.includes('limit=50'));
} finally {
stub.restore();
}
});
test('fetchMessages propagates HTTP errors', async () => {
const stub = withFetchStub({ ok: false, status: 500 });
try {
await assert.rejects(() => fetchMessages(10), /HTTP 500/);
} finally {
stub.restore();
}
});
@@ -0,0 +1,242 @@
/*
* 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 {
buildTelemetryIndex,
mergePositionsIntoNodes,
mergeTelemetryIntoNodes,
} from '../data-merge.js';
// ---------------------------------------------------------------------------
// mergePositionsIntoNodes — early returns
// ---------------------------------------------------------------------------
test('mergePositionsIntoNodes is a no-op when nodes is not an array', () => {
const positions = [{ node_id: '!a', latitude: 1, longitude: 2 }];
// Just assert no throw.
mergePositionsIntoNodes(null, positions);
mergePositionsIntoNodes(undefined, positions);
});
test('mergePositionsIntoNodes is a no-op when positions is not an array', () => {
const nodes = [{ node_id: '!a' }];
mergePositionsIntoNodes(nodes, null);
mergePositionsIntoNodes(nodes, undefined);
assert.deepEqual(nodes, [{ node_id: '!a' }]);
});
test('mergePositionsIntoNodes is a no-op for empty node arrays', () => {
mergePositionsIntoNodes([], [{ node_id: '!a', latitude: 1, longitude: 2 }]);
});
test('mergePositionsIntoNodes is a no-op when no nodes carry a string node_id', () => {
// Hits the `if (nodesById.size === 0) return;` early exit.
const nodes = [{ node_num: 5 }];
mergePositionsIntoNodes(nodes, [{ node_id: '!a', latitude: 1, longitude: 2 }]);
assert.deepEqual(nodes, [{ node_num: 5 }]);
});
// ---------------------------------------------------------------------------
// mergePositionsIntoNodes — merge logic
// ---------------------------------------------------------------------------
test('mergePositionsIntoNodes copies coordinates when none exist', () => {
const nodes = [{ node_id: '!a' }];
mergePositionsIntoNodes(nodes, [{
node_id: '!a',
latitude: 52.5,
longitude: 13.4,
altitude: 100,
position_time: 1700000000,
position_time_iso: '2023-11-14T22:13:20.000Z',
location_source: 'gps',
precision_bits: 24,
}]);
assert.equal(nodes[0].latitude, 52.5);
assert.equal(nodes[0].longitude, 13.4);
assert.equal(nodes[0].altitude, 100);
assert.equal(nodes[0].position_time, 1700000000);
assert.equal(nodes[0].pos_time_iso, '2023-11-14T22:13:20.000Z');
assert.equal(nodes[0].location_source, 'gps');
assert.equal(nodes[0].precision_bits, 24);
});
test('mergePositionsIntoNodes generates an ISO when only numeric position_time is supplied', () => {
const nodes = [{ node_id: '!a' }];
mergePositionsIntoNodes(nodes, [{
node_id: '!a',
latitude: 1,
longitude: 2,
position_time: 1700000000,
}]);
assert.equal(nodes[0].pos_time_iso, new Date(1700000000 * 1000).toISOString());
});
test('mergePositionsIntoNodes preserves ISO when numeric position_time is missing', () => {
const nodes = [{ node_id: '!a' }];
mergePositionsIntoNodes(nodes, [{
node_id: '!a',
latitude: 1,
longitude: 2,
position_time_iso: '2024-01-01T00:00:00.000Z',
}]);
assert.equal(nodes[0].pos_time_iso, '2024-01-01T00:00:00.000Z');
});
test('mergePositionsIntoNodes ignores incoming positions with non-finite coordinates', () => {
const nodes = [{ node_id: '!a' }];
mergePositionsIntoNodes(nodes, [{ node_id: '!a', latitude: 'NaN', longitude: 1 }]);
assert.equal(nodes[0].latitude, undefined);
});
test('mergePositionsIntoNodes only applies the first matching position per node', () => {
const nodes = [{ node_id: '!a' }];
mergePositionsIntoNodes(nodes, [
{ node_id: '!a', latitude: 1, longitude: 2 },
{ node_id: '!a', latitude: 99, longitude: 99 },
]);
assert.equal(nodes[0].latitude, 1);
});
test('mergePositionsIntoNodes skips packets older than the existing snapshot', () => {
const nodes = [{
node_id: '!a',
latitude: 5,
longitude: 5,
position_time: 2000,
}];
mergePositionsIntoNodes(nodes, [{
node_id: '!a',
latitude: 9,
longitude: 9,
position_time: 1000,
}]);
assert.equal(nodes[0].latitude, 5); // unchanged
});
test('mergePositionsIntoNodes accepts strictly newer packets', () => {
const nodes = [{
node_id: '!a',
latitude: 5,
longitude: 5,
position_time: 1000,
}];
mergePositionsIntoNodes(nodes, [{
node_id: '!a',
latitude: 9,
longitude: 9,
position_time: 2000,
}]);
assert.equal(nodes[0].latitude, 9);
});
test('mergePositionsIntoNodes skips entries lacking a node_id', () => {
const nodes = [{ node_id: '!a' }];
mergePositionsIntoNodes(nodes, [{ latitude: 1, longitude: 2 }, null]);
assert.equal(nodes[0].latitude, undefined);
});
// ---------------------------------------------------------------------------
// buildTelemetryIndex
// ---------------------------------------------------------------------------
test('buildTelemetryIndex returns empty maps for non-array input', () => {
const { byNodeId, byNodeNum } = buildTelemetryIndex(null);
assert.equal(byNodeId.size, 0);
assert.equal(byNodeNum.size, 0);
});
test('buildTelemetryIndex keeps the freshest entry per node_id', () => {
const { byNodeId } = buildTelemetryIndex([
{ node_id: '!a', rx_time: 100, payload: 'old' },
{ node_id: '!a', rx_time: 200, payload: 'new' },
]);
assert.equal(byNodeId.get('!a').entry.payload, 'new');
});
test('buildTelemetryIndex falls back to telemetry_time when rx_time is absent', () => {
const { byNodeId } = buildTelemetryIndex([
{ node_id: '!a', telemetry_time: 50, payload: 'fallback' },
]);
assert.equal(byNodeId.get('!a').timestamp, 50);
});
test('buildTelemetryIndex indexes by numeric node_num', () => {
const { byNodeNum } = buildTelemetryIndex([
{ node_num: 42, rx_time: 100, payload: 'first' },
]);
assert.ok(byNodeNum.has(42));
});
test('buildTelemetryIndex skips non-object entries', () => {
const { byNodeId, byNodeNum } = buildTelemetryIndex([null, 'string', 5]);
assert.equal(byNodeId.size, 0);
assert.equal(byNodeNum.size, 0);
});
// ---------------------------------------------------------------------------
// mergeTelemetryIntoNodes
// ---------------------------------------------------------------------------
test('mergeTelemetryIntoNodes is a no-op when nodes is empty or not an array', () => {
mergeTelemetryIntoNodes([], []);
mergeTelemetryIntoNodes(null, []);
});
test('mergeTelemetryIntoNodes copies metrics when matched by node_id', () => {
const nodes = [{ node_id: '!a' }];
mergeTelemetryIntoNodes(nodes, [{
node_id: '!a',
battery_level: 85,
voltage: 4.1,
rx_time: 100,
telemetry_time: 95,
}]);
assert.equal(nodes[0].battery_level, 85);
assert.equal(nodes[0].voltage, 4.1);
assert.equal(nodes[0].telemetry_time, 95);
assert.equal(nodes[0].telemetry_rx_time, 100);
});
test('mergeTelemetryIntoNodes falls back to node_num lookup', () => {
const nodes = [{ num: 42 }];
mergeTelemetryIntoNodes(nodes, [{
node_num: 42,
temperature: 21.5,
}]);
assert.equal(nodes[0].temperature, 21.5);
});
test('mergeTelemetryIntoNodes ignores nodes that do not match by id or num', () => {
const nodes = [{ node_id: '!a', num: 1 }];
mergeTelemetryIntoNodes(nodes, [{ node_id: '!b', battery_level: 50 }]);
assert.equal(nodes[0].battery_level, undefined);
});
test('mergeTelemetryIntoNodes skips null metric values', () => {
const nodes = [{ node_id: '!a', battery_level: 99 }];
mergeTelemetryIntoNodes(nodes, [{ node_id: '!a', battery_level: null }]);
assert.equal(nodes[0].battery_level, 99);
});
test('mergeTelemetryIntoNodes tolerates non-object entries in the list', () => {
const nodes = [null, undefined, { node_id: '!a' }];
mergeTelemetryIntoNodes(nodes, [{ node_id: '!a', voltage: 3.9 }]);
assert.equal(nodes[2].voltage, 3.9);
});
@@ -0,0 +1,405 @@
/*
* 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 {
cssEscape,
fmtCoords,
fmtHw,
formatDate,
formatShortInfoUptime,
formatSnrDisplay,
formatTime,
pad,
parseNodeNumericRef,
pickFirstProperty,
pickNumericProperty,
resolveTimestampSeconds,
shortInfoValueOrDash,
timeAgo,
timeHum,
toFiniteNumber,
} from '../format-utils.js';
// ---------------------------------------------------------------------------
// pad / formatTime / formatDate
// ---------------------------------------------------------------------------
test('pad pads small numbers to two digits', () => {
assert.equal(pad(0), '00');
assert.equal(pad(7), '07');
assert.equal(pad(42), '42');
});
test('formatTime renders HH:MM:SS', () => {
const d = new Date(2026, 0, 1, 9, 5, 7); // Local time.
assert.equal(formatTime(d), '09:05:07');
});
test('formatDate renders YYYY-MM-DD', () => {
const d = new Date(2026, 0, 9); // Jan 9, 2026 local.
assert.equal(formatDate(d), '2026-01-09');
});
// ---------------------------------------------------------------------------
// fmtHw
// ---------------------------------------------------------------------------
test('fmtHw passes through normal values', () => {
assert.equal(fmtHw('TBEAM'), 'TBEAM');
});
test('fmtHw hides the UNSET sentinel', () => {
assert.equal(fmtHw('UNSET'), '');
});
test('fmtHw returns empty string for falsy input', () => {
assert.equal(fmtHw(null), '');
assert.equal(fmtHw(''), '');
assert.equal(fmtHw(undefined), '');
});
// ---------------------------------------------------------------------------
// fmtCoords
// ---------------------------------------------------------------------------
test('fmtCoords formats numbers with default precision 5', () => {
assert.equal(fmtCoords(52.520008), '52.52001');
});
test('fmtCoords accepts a custom precision', () => {
assert.equal(fmtCoords(52.520008, 2), '52.52');
});
test('fmtCoords returns empty string for null, undefined, and empty', () => {
assert.equal(fmtCoords(null), '');
assert.equal(fmtCoords(undefined), '');
assert.equal(fmtCoords(''), '');
});
test('fmtCoords returns empty string for non-numeric input', () => {
assert.equal(fmtCoords('not a number'), '');
});
// ---------------------------------------------------------------------------
// formatSnrDisplay
// ---------------------------------------------------------------------------
test('formatSnrDisplay appends dB suffix with one decimal', () => {
assert.equal(formatSnrDisplay(7.49), '7.5 dB');
assert.equal(formatSnrDisplay(-3), '-3.0 dB');
});
test('formatSnrDisplay returns empty string for null and empty input', () => {
assert.equal(formatSnrDisplay(null), '');
assert.equal(formatSnrDisplay(''), '');
});
test('formatSnrDisplay returns empty string for non-finite input', () => {
assert.equal(formatSnrDisplay('abc'), '');
});
// ---------------------------------------------------------------------------
// timeHum
// ---------------------------------------------------------------------------
test('timeHum returns empty string for falsy input', () => {
assert.equal(timeHum(0), '');
assert.equal(timeHum(null), '');
});
test('timeHum returns 0s for negative durations', () => {
assert.equal(timeHum(-5), '0s');
});
test('timeHum formats sub-minute durations as seconds', () => {
assert.equal(timeHum(45), '45s');
});
test('timeHum formats sub-hour durations as minutes and seconds', () => {
assert.equal(timeHum(125), '2m 5s');
});
test('timeHum formats sub-day durations as hours and minutes', () => {
assert.equal(timeHum(3700), '1h 1m');
});
test('timeHum formats day-scale durations as days and hours', () => {
assert.equal(timeHum(90061), '1d 1h');
});
// ---------------------------------------------------------------------------
// timeAgo
// ---------------------------------------------------------------------------
test('timeAgo returns empty string when the input is missing', () => {
assert.equal(timeAgo(0), '');
assert.equal(timeAgo(null), '');
});
test('timeAgo clamps future timestamps to 0s', () => {
assert.equal(timeAgo(5000, 1000), '0s');
});
test('timeAgo formats sub-minute deltas as seconds', () => {
assert.equal(timeAgo(950, 1000), '50s');
});
test('timeAgo formats sub-hour deltas as minutes and seconds', () => {
assert.equal(timeAgo(875, 1000), '2m 5s');
});
test('timeAgo formats sub-day deltas as hours and minutes', () => {
// Use a non-zero past timestamp; timeAgo treats 0 as "missing" and returns "".
assert.equal(timeAgo(1000, 4700), '1h 1m');
});
test('timeAgo formats day-scale deltas as days and hours', () => {
assert.equal(timeAgo(1000, 91061), '1d 1h');
});
// ---------------------------------------------------------------------------
// toFiniteNumber
// ---------------------------------------------------------------------------
test('toFiniteNumber converts numeric strings', () => {
assert.equal(toFiniteNumber('42'), 42);
});
test('toFiniteNumber returns null for null, undefined, and empty', () => {
assert.equal(toFiniteNumber(null), null);
assert.equal(toFiniteNumber(undefined), null);
assert.equal(toFiniteNumber(''), null);
});
test('toFiniteNumber rejects non-finite values', () => {
assert.equal(toFiniteNumber('abc'), null);
assert.equal(toFiniteNumber(Number.NaN), null);
assert.equal(toFiniteNumber(Number.POSITIVE_INFINITY), null);
});
// ---------------------------------------------------------------------------
// resolveTimestampSeconds
// ---------------------------------------------------------------------------
test('resolveTimestampSeconds prefers a numeric timestamp', () => {
assert.equal(resolveTimestampSeconds(1700000000, '2024-01-01T00:00:00Z'), 1700000000);
});
test('resolveTimestampSeconds falls back to ISO when numeric is missing', () => {
// 2024-01-01T00:00:00Z = 1704067200 seconds.
assert.equal(resolveTimestampSeconds(null, '2024-01-01T00:00:00Z'), 1704067200);
});
test('resolveTimestampSeconds returns null when both inputs are unusable', () => {
assert.equal(resolveTimestampSeconds(null, null), null);
assert.equal(resolveTimestampSeconds(null, ''), null);
assert.equal(resolveTimestampSeconds(null, 'not a date'), null);
});
// ---------------------------------------------------------------------------
// cssEscape
// ---------------------------------------------------------------------------
test('cssEscape returns empty string for non-strings and empty input', () => {
assert.equal(cssEscape(''), '');
assert.equal(cssEscape(null), '');
assert.equal(cssEscape(undefined), '');
assert.equal(cssEscape(42), '');
});
test('cssEscape uses window.CSS.escape when available', () => {
const previous = globalThis.window;
globalThis.window = {
CSS: {
escape: value => `escaped(${value})`,
},
};
try {
assert.equal(cssEscape('foo'), 'escaped(foo)');
} finally {
if (previous === undefined) {
delete globalThis.window;
} else {
globalThis.window = previous;
}
}
});
test('cssEscape falls back to manual escaping when window.CSS is unavailable', () => {
const previous = globalThis.window;
delete globalThis.window;
try {
// Underscores and hyphens pass through; everything else is backslash-escaped.
assert.equal(cssEscape('a-b_c'), 'a-b_c');
assert.equal(cssEscape('a:b'), 'a\\:b');
} finally {
if (previous !== undefined) {
globalThis.window = previous;
}
}
});
// ---------------------------------------------------------------------------
// formatShortInfoUptime
// ---------------------------------------------------------------------------
test('formatShortInfoUptime returns empty string for null and empty', () => {
assert.equal(formatShortInfoUptime(null), '');
assert.equal(formatShortInfoUptime(''), '');
});
test('formatShortInfoUptime returns empty string for non-finite input', () => {
assert.equal(formatShortInfoUptime('abc'), '');
});
test('formatShortInfoUptime renders 0s for zero', () => {
assert.equal(formatShortInfoUptime(0), '0s');
});
test('formatShortInfoUptime delegates to timeHum for positive values', () => {
assert.equal(formatShortInfoUptime(125), '2m 5s');
});
// ---------------------------------------------------------------------------
// shortInfoValueOrDash
// ---------------------------------------------------------------------------
test('shortInfoValueOrDash returns the string form of present values', () => {
assert.equal(shortInfoValueOrDash('text'), 'text');
assert.equal(shortInfoValueOrDash(0), '0');
});
test('shortInfoValueOrDash returns em dash for null, undefined, and empty', () => {
assert.equal(shortInfoValueOrDash(null), '—');
assert.equal(shortInfoValueOrDash(undefined), '—');
assert.equal(shortInfoValueOrDash(''), '—');
});
// ---------------------------------------------------------------------------
// pickFirstProperty
// ---------------------------------------------------------------------------
test('pickFirstProperty returns null when sources or keys are not arrays', () => {
assert.equal(pickFirstProperty(null, ['a']), null);
assert.equal(pickFirstProperty([{}], null), null);
});
test('pickFirstProperty returns the first present trimmed string', () => {
const sources = [
{},
{ id: ' ' },
{ id: ' hello ' },
];
assert.equal(pickFirstProperty(sources, ['id']), 'hello');
});
test('pickFirstProperty returns the first non-string value verbatim', () => {
assert.equal(pickFirstProperty([{ count: 5 }], ['count']), 5);
assert.equal(pickFirstProperty([{ flag: false }], ['flag']), false);
});
test('pickFirstProperty skips non-object entries and absent properties', () => {
const sources = [null, 42, { other: 'value' }, { name: 'final' }];
assert.equal(pickFirstProperty(sources, ['name']), 'final');
});
test('pickFirstProperty returns null when no source provides a value', () => {
assert.equal(pickFirstProperty([{ a: null }, { a: '' }], ['a']), null);
});
// ---------------------------------------------------------------------------
// pickNumericProperty
// ---------------------------------------------------------------------------
test('pickNumericProperty returns null when sources or keys are not arrays', () => {
assert.equal(pickNumericProperty(null, ['a']), null);
assert.equal(pickNumericProperty([{}], null), null);
});
test('pickNumericProperty returns the first finite numeric value', () => {
const sources = [
{ value: '' },
{ value: 'abc' },
{ value: '42' },
];
assert.equal(pickNumericProperty(sources, ['value']), 42);
});
test('pickNumericProperty skips non-object entries and missing keys', () => {
const sources = [null, undefined, { other: 1 }, { count: 7 }];
assert.equal(pickNumericProperty(sources, ['count']), 7);
});
test('pickNumericProperty returns null when no candidate is finite', () => {
assert.equal(pickNumericProperty([{ a: 'abc' }, { a: null }], ['a']), null);
});
// ---------------------------------------------------------------------------
// parseNodeNumericRef
// ---------------------------------------------------------------------------
test('parseNodeNumericRef returns null for null and undefined', () => {
assert.equal(parseNodeNumericRef(null), null);
assert.equal(parseNodeNumericRef(undefined), null);
});
test('parseNodeNumericRef passes through finite numbers', () => {
assert.equal(parseNodeNumericRef(42), 42);
});
test('parseNodeNumericRef returns null for non-finite numbers', () => {
assert.equal(parseNodeNumericRef(Number.NaN), null);
assert.equal(parseNodeNumericRef(Number.POSITIVE_INFINITY), null);
});
test('parseNodeNumericRef parses !-prefixed hex strings', () => {
assert.equal(parseNodeNumericRef('!aabbccdd'), 0xaabbccdd);
});
test('parseNodeNumericRef rejects !-prefixed strings with invalid characters', () => {
assert.equal(parseNodeNumericRef('!ZZZ'), null);
});
test('parseNodeNumericRef parses 0x-prefixed hex strings', () => {
assert.equal(parseNodeNumericRef('0x1A'), 0x1a);
});
test('parseNodeNumericRef parses decimal strings', () => {
assert.equal(parseNodeNumericRef('123'), 123);
});
test('parseNodeNumericRef returns null for blank strings', () => {
assert.equal(parseNodeNumericRef(''), null);
assert.equal(parseNodeNumericRef(' '), null);
});
test('parseNodeNumericRef returns null for unparseable strings', () => {
assert.equal(parseNodeNumericRef('not a number'), null);
});
test('parseNodeNumericRef coerces other inputs via Number()', () => {
// Booleans, Date, etc. — anything the global Number() constructor can
// map to a finite number passes through.
assert.equal(parseNodeNumericRef(true), 1);
assert.equal(parseNodeNumericRef(false), 0);
});
test('parseNodeNumericRef returns null for unparseable non-string inputs', () => {
assert.equal(parseNodeNumericRef({}), null);
});
@@ -0,0 +1,152 @@
/*
* 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 { getActiveFullscreenElement, legendClickHandler } from '../fullscreen-helpers.js';
// ---------------------------------------------------------------------------
// getActiveFullscreenElement
// ---------------------------------------------------------------------------
test('getActiveFullscreenElement returns null when document is undefined', () => {
const previousDoc = globalThis.document;
// Node has no document by default, but other tests in the suite may have
// assigned one — clear it explicitly for this case.
delete globalThis.document;
try {
assert.equal(getActiveFullscreenElement(), null);
} finally {
if (previousDoc !== undefined) {
globalThis.document = previousDoc;
}
}
});
test('getActiveFullscreenElement prefers fullscreenElement', () => {
const dummy = { tag: 'std' };
const previousDoc = globalThis.document;
globalThis.document = {
fullscreenElement: dummy,
webkitFullscreenElement: { tag: 'webkit' },
msFullscreenElement: { tag: 'ms' },
};
try {
assert.equal(getActiveFullscreenElement(), dummy);
} finally {
if (previousDoc === undefined) {
delete globalThis.document;
} else {
globalThis.document = previousDoc;
}
}
});
test('getActiveFullscreenElement falls back to webkit prefix', () => {
const dummy = { tag: 'webkit' };
const previousDoc = globalThis.document;
globalThis.document = {
fullscreenElement: null,
webkitFullscreenElement: dummy,
msFullscreenElement: null,
};
try {
assert.equal(getActiveFullscreenElement(), dummy);
} finally {
if (previousDoc === undefined) {
delete globalThis.document;
} else {
globalThis.document = previousDoc;
}
}
});
test('getActiveFullscreenElement falls back to ms prefix', () => {
const dummy = { tag: 'ms' };
const previousDoc = globalThis.document;
globalThis.document = {
fullscreenElement: null,
webkitFullscreenElement: null,
msFullscreenElement: dummy,
};
try {
assert.equal(getActiveFullscreenElement(), dummy);
} finally {
if (previousDoc === undefined) {
delete globalThis.document;
} else {
globalThis.document = previousDoc;
}
}
});
test('getActiveFullscreenElement returns null when no fullscreen owner is set', () => {
const previousDoc = globalThis.document;
globalThis.document = {
fullscreenElement: null,
webkitFullscreenElement: null,
msFullscreenElement: null,
};
try {
assert.equal(getActiveFullscreenElement(), null);
} finally {
if (previousDoc === undefined) {
delete globalThis.document;
} else {
globalThis.document = previousDoc;
}
}
});
// ---------------------------------------------------------------------------
// legendClickHandler
// ---------------------------------------------------------------------------
test('legendClickHandler always calls preventDefault and stopPropagation', () => {
let preventCalls = 0;
let stopCalls = 0;
let bodyCalls = 0;
const handler = legendClickHandler(() => {
bodyCalls += 1;
});
const fakeEvent = {
preventDefault: () => {
preventCalls += 1;
},
stopPropagation: () => {
stopCalls += 1;
},
};
handler(fakeEvent);
assert.equal(preventCalls, 1);
assert.equal(stopCalls, 1);
assert.equal(bodyCalls, 1);
});
test('legendClickHandler forwards the event object to the body', () => {
let received = null;
const handler = legendClickHandler(event => {
received = event;
});
const fakeEvent = {
preventDefault() {},
stopPropagation() {},
payload: 'forwarded',
};
handler(fakeEvent);
assert.equal(received, fakeEvent);
});
@@ -0,0 +1,188 @@
/*
* 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 {
applyNodeNameFallback,
extractIdentifierFromHref,
getNodeDisplayNameForOverlay,
getNodeIdentifierFromLink,
shouldHandleNodeLongLink,
} from '../long-link-router.js';
// ---------------------------------------------------------------------------
// shouldHandleNodeLongLink
// ---------------------------------------------------------------------------
test('shouldHandleNodeLongLink rejects null and undefined', () => {
assert.equal(shouldHandleNodeLongLink(null), false);
assert.equal(shouldHandleNodeLongLink(undefined), false);
});
test('shouldHandleNodeLongLink rejects elements without a dataset', () => {
assert.equal(shouldHandleNodeLongLink({}), false);
});
test('shouldHandleNodeLongLink honours an explicit nodeDetailLink=false opt-out', () => {
const link = { dataset: { nodeDetailLink: 'false' } };
assert.equal(shouldHandleNodeLongLink(link), false);
});
test('shouldHandleNodeLongLink accepts elements with a permissive dataset', () => {
assert.equal(shouldHandleNodeLongLink({ dataset: {} }), true);
assert.equal(shouldHandleNodeLongLink({ dataset: { nodeDetailLink: 'true' } }), true);
});
// ---------------------------------------------------------------------------
// extractIdentifierFromHref
// ---------------------------------------------------------------------------
test('extractIdentifierFromHref returns empty string for non-string and empty input', () => {
assert.equal(extractIdentifierFromHref(null), '');
assert.equal(extractIdentifierFromHref(undefined), '');
assert.equal(extractIdentifierFromHref(''), '');
assert.equal(extractIdentifierFromHref(42), '');
});
test('extractIdentifierFromHref returns empty string when no /nodes/!… segment is present', () => {
assert.equal(extractIdentifierFromHref('/about'), '');
assert.equal(extractIdentifierFromHref('https://example.com/'), '');
});
test('extractIdentifierFromHref returns the canonical node id for /nodes/!… URIs', () => {
assert.equal(extractIdentifierFromHref('/nodes/!aabbccdd'), '!aabbccdd');
// canonicalNodeIdentifier preserves case; it only ensures the leading "!".
assert.equal(
extractIdentifierFromHref('https://example.com/nodes/!AABBCCDD?ref=1'),
'!AABBCCDD',
);
});
test('extractIdentifierFromHref tolerates URI-encoded ! prefixes', () => {
// %21 is the URL-encoded form of !. decodeURIComponent should restore it.
assert.equal(extractIdentifierFromHref('/nodes/%21aabbccdd'), '');
// Not all encodings return a node — '!aabbccdd' encoded as a literal also works.
assert.equal(extractIdentifierFromHref('/nodes/!aabbccdd#anchor'), '!aabbccdd');
});
test('extractIdentifierFromHref falls back to the raw match when decoding throws', () => {
// A bare "%" tail is malformed UTF-8 percent encoding and makes
// decodeURIComponent raise URIError. The catch branch should still
// canonicalise the un-decoded match.
assert.equal(
extractIdentifierFromHref('/nodes/!aabbccdd%E0'),
'!aabbccdd%E0',
);
});
// ---------------------------------------------------------------------------
// getNodeIdentifierFromLink
// ---------------------------------------------------------------------------
test('getNodeIdentifierFromLink returns empty string for falsy input', () => {
assert.equal(getNodeIdentifierFromLink(null), '');
assert.equal(getNodeIdentifierFromLink(undefined), '');
});
test('getNodeIdentifierFromLink prefers dataset.nodeId when canonical', () => {
const link = { dataset: { nodeId: '!aabbccdd' } };
assert.equal(getNodeIdentifierFromLink(link), '!aabbccdd');
});
test('getNodeIdentifierFromLink falls back to getAttribute("href") when dataset is absent', () => {
const link = {
getAttribute(name) {
return name === 'href' ? '/nodes/!aabbccdd' : null;
},
};
assert.equal(getNodeIdentifierFromLink(link), '!aabbccdd');
});
test('getNodeIdentifierFromLink falls back to the .href property when getAttribute is absent', () => {
const link = { href: '/nodes/!aabbccdd' };
assert.equal(getNodeIdentifierFromLink(link), '!aabbccdd');
});
test('getNodeIdentifierFromLink returns empty string when nothing parses', () => {
assert.equal(getNodeIdentifierFromLink({}), '');
});
// ---------------------------------------------------------------------------
// getNodeDisplayNameForOverlay
// ---------------------------------------------------------------------------
test('getNodeDisplayNameForOverlay returns empty string for non-objects', () => {
assert.equal(getNodeDisplayNameForOverlay(null), '');
assert.equal(getNodeDisplayNameForOverlay(42), '');
});
test('getNodeDisplayNameForOverlay prefers long_name', () => {
const node = { long_name: 'Alpha Long', short_name: 'A', node_id: '!a' };
assert.equal(getNodeDisplayNameForOverlay(node), 'Alpha Long');
});
test('getNodeDisplayNameForOverlay falls back to short_name', () => {
const node = { short_name: 'A', node_id: '!a' };
assert.equal(getNodeDisplayNameForOverlay(node), 'A');
});
test('getNodeDisplayNameForOverlay falls back to node_id when names are absent', () => {
assert.equal(getNodeDisplayNameForOverlay({ node_id: '!a' }), '!a');
});
test('getNodeDisplayNameForOverlay reads camelCase keys too', () => {
assert.equal(getNodeDisplayNameForOverlay({ longName: 'L' }), 'L');
assert.equal(getNodeDisplayNameForOverlay({ shortName: 'S' }), 'S');
});
// ---------------------------------------------------------------------------
// applyNodeNameFallback
// ---------------------------------------------------------------------------
test('applyNodeNameFallback is a no-op for non-objects', () => {
// Just ensure no throw.
applyNodeNameFallback(null);
applyNodeNameFallback(undefined);
});
test('applyNodeNameFallback fills missing names from node_id', () => {
const node = { node_id: '!aabbccdd' };
applyNodeNameFallback(node);
assert.equal(node.short_name, 'ccdd');
assert.equal(node.long_name, 'Meshtastic !aabbccdd');
});
test('applyNodeNameFallback updates camelCase aliases when present', () => {
const node = { node_id: '!aabbccdd', shortName: '', longName: '' };
applyNodeNameFallback(node);
assert.equal(node.shortName, 'ccdd');
assert.equal(node.longName, 'Meshtastic !aabbccdd');
});
test('applyNodeNameFallback leaves existing names untouched', () => {
const node = { node_id: '!aabbccdd', short_name: 'AAA', long_name: 'Alpha' };
applyNodeNameFallback(node);
assert.equal(node.short_name, 'AAA');
assert.equal(node.long_name, 'Alpha');
});
test('applyNodeNameFallback is a no-op when no node_id is available', () => {
const node = {};
applyNodeNameFallback(node);
assert.deepEqual(node, {});
});
@@ -0,0 +1,228 @@
/*
* 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 { createOfflineTileLayer } from '../offline-tile-layer.js';
/**
* Build a minimal Leaflet stub exposing the methods the offline tile layer
* needs (``L.gridLayer``). The returned grid-layer object is otherwise a
* plain bag whose ``createTile`` slot is reassigned by the production code.
*
* @returns {Object} Leaflet-compatible stub.
*/
function makeLeafletStub() {
return {
gridLayer(options) {
return { options, createTile: null };
},
};
}
/**
* Install a minimal ``document`` stub whose ``createElement`` returns objects
* that satisfy the offline tile layer's small DOM contract: canvas elements
* expose a configurable ``getContext`` slot, while plain ``div`` elements
* expose ``style``, ``className`` and ``cloneNode``.
*
* @param {{ canvasContext?: any }} [options] Override the canvas 2D context.
* @returns {{ restore: Function }} Teardown handle.
*/
function withDocumentStub({ canvasContext } = {}) {
const previousDocument = globalThis.document;
globalThis.document = {
createElement(tag) {
if (tag === 'canvas') {
return {
width: 0,
height: 0,
getContext: () => (canvasContext === undefined ? makeRecordingContext() : canvasContext),
};
}
const element = {
tag,
className: '',
style: {},
textContent: '',
cloneNode() {
// Return a shallow copy that retains the recorded properties so
// assertions can inspect what the production code rendered.
return JSON.parse(JSON.stringify({
tag: element.tag,
className: element.className,
style: element.style,
textContent: element.textContent,
}));
},
};
return element;
},
};
return {
restore() {
if (previousDocument === undefined) {
delete globalThis.document;
} else {
globalThis.document = previousDocument;
}
},
};
}
/**
* Build a Canvas 2D context stub that records the calls it receives. The
* tests inspect the call list to ensure the production code follows the
* expected drawing path.
*
* @returns {Object} Recording 2D context.
*/
function makeRecordingContext() {
const calls = [];
const ctx = {
calls,
fillStyle: null,
strokeStyle: null,
lineWidth: 0,
font: '',
textBaseline: '',
textAlign: '',
createLinearGradient(...args) {
calls.push(['createLinearGradient', args]);
return { addColorStop(...stop) { calls.push(['addColorStop', stop]); } };
},
fillRect(...args) {
calls.push(['fillRect', args]);
},
beginPath() {
calls.push(['beginPath']);
},
moveTo(...args) {
calls.push(['moveTo', args]);
},
lineTo(...args) {
calls.push(['lineTo', args]);
},
stroke() {
calls.push(['stroke']);
},
fillText(...args) {
calls.push(['fillText', args]);
},
};
return ctx;
}
// ---------------------------------------------------------------------------
// createOfflineTileLayer — early returns
// ---------------------------------------------------------------------------
test('createOfflineTileLayer returns null when Leaflet is missing', () => {
assert.equal(createOfflineTileLayer(null), null);
assert.equal(createOfflineTileLayer(undefined), null);
});
test('createOfflineTileLayer returns null when Leaflet has no gridLayer factory', () => {
assert.equal(createOfflineTileLayer({}), null);
});
// ---------------------------------------------------------------------------
// createOfflineTileLayer — happy path
// ---------------------------------------------------------------------------
test('createOfflineTileLayer attaches a createTile method on success', () => {
const stub = withDocumentStub();
try {
const layer = createOfflineTileLayer(makeLeafletStub());
assert.ok(layer);
assert.equal(typeof layer.createTile, 'function');
} finally {
stub.restore();
}
});
test('createOfflineTileLayer renders a canvas tile when getContext succeeds', () => {
const stub = withDocumentStub();
try {
const layer = createOfflineTileLayer(makeLeafletStub());
const tile = layer.createTile({ x: 1, y: 1, z: 1 });
// The returned element should be the canvas itself (has getContext).
assert.equal(typeof tile.getContext, 'function');
assert.equal(tile.width, 256);
assert.equal(tile.height, 256);
} finally {
stub.restore();
}
});
test('createOfflineTileLayer falls back to placeholder when canvas getContext returns null', () => {
const stub = withDocumentStub({ canvasContext: null });
// Silence the warn from the fallback branch so test output stays clean.
const previousWarn = console.warn;
console.warn = () => {};
try {
const layer = createOfflineTileLayer(makeLeafletStub());
const tile = layer.createTile({ x: 0, y: 0, z: 0 });
// Fallback is the cloned <div> — no getContext method.
assert.equal(tile.getContext, undefined);
assert.equal(tile.tag, 'div');
assert.equal(tile.className, 'offline-tile-fallback');
} finally {
console.warn = previousWarn;
stub.restore();
}
});
test('createOfflineTileLayer reuses the cached fallback tile across invocations', () => {
const stub = withDocumentStub({ canvasContext: null });
const previousWarn = console.warn;
console.warn = () => {};
try {
const layer = createOfflineTileLayer(makeLeafletStub());
const first = layer.createTile({ x: 0, y: 0, z: 0 });
const second = layer.createTile({ x: 1, y: 0, z: 0 });
// Both calls produce equivalent fallback nodes (same shape).
assert.deepEqual(first, second);
} finally {
console.warn = previousWarn;
stub.restore();
}
});
test('createOfflineTileLayer falls back when the canvas drawing path throws', () => {
// Build a context whose `createLinearGradient` throws to force the
// catch-and-fall-back branch.
const ctx = makeRecordingContext();
ctx.createLinearGradient = () => {
throw new Error('boom');
};
const stub = withDocumentStub({ canvasContext: ctx });
const previousError = console.error;
console.error = () => {};
try {
const layer = createOfflineTileLayer(makeLeafletStub());
const tile = layer.createTile({ x: 0, y: 0, z: 0 });
// Production code logs and returns the fallback element.
assert.equal(tile.getContext, undefined);
assert.equal(tile.tag, 'div');
} finally {
console.error = previousError;
stub.restore();
}
});
@@ -0,0 +1,128 @@
/*
* 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 {
compareNumber,
compareString,
hasNumberValue,
hasStringValue,
} from '../sort-comparators.js';
// ---------------------------------------------------------------------------
// hasStringValue
// ---------------------------------------------------------------------------
test('hasStringValue returns true for non-empty strings', () => {
assert.equal(hasStringValue('hi'), true);
assert.equal(hasStringValue(' text '), true);
});
test('hasStringValue returns false for null, undefined, and blank input', () => {
assert.equal(hasStringValue(null), false);
assert.equal(hasStringValue(undefined), false);
assert.equal(hasStringValue(''), false);
assert.equal(hasStringValue(' '), false);
});
test('hasStringValue treats numbers as their string form', () => {
assert.equal(hasStringValue(0), true);
assert.equal(hasStringValue(42), true);
});
// ---------------------------------------------------------------------------
// hasNumberValue
// ---------------------------------------------------------------------------
test('hasNumberValue accepts finite numbers', () => {
assert.equal(hasNumberValue(42), true);
assert.equal(hasNumberValue(-1.5), true);
assert.equal(hasNumberValue(0), true);
});
test('hasNumberValue rejects null, undefined, and empty string', () => {
assert.equal(hasNumberValue(null), false);
assert.equal(hasNumberValue(undefined), false);
assert.equal(hasNumberValue(''), false);
});
test('hasNumberValue rejects non-finite numbers and unparseable strings', () => {
assert.equal(hasNumberValue(Number.NaN), false);
assert.equal(hasNumberValue(Number.POSITIVE_INFINITY), false);
assert.equal(hasNumberValue('abc'), false);
});
test('hasNumberValue accepts numeric strings', () => {
assert.equal(hasNumberValue('42'), true);
assert.equal(hasNumberValue(' -1.5 '), true);
});
// ---------------------------------------------------------------------------
// compareString
// ---------------------------------------------------------------------------
test('compareString sorts non-empty values lexicographically', () => {
assert.ok(compareString('alpha', 'beta') < 0);
assert.ok(compareString('beta', 'alpha') > 0);
assert.equal(compareString('alpha', 'alpha'), 0);
});
test('compareString trims surrounding whitespace before comparing', () => {
assert.equal(compareString(' alpha ', 'alpha'), 0);
});
test('compareString sorts blank values to the end', () => {
assert.ok(compareString('alpha', '') < 0);
assert.ok(compareString('', 'alpha') > 0);
});
test('compareString returns 0 when both values are blank', () => {
assert.equal(compareString(null, ''), 0);
assert.equal(compareString('', ' '), 0);
});
test('compareString uses numeric collation for digit-bearing strings', () => {
// localeCompare with { numeric: true } orders "node-2" before "node-10".
assert.ok(compareString('node-2', 'node-10') < 0);
});
// ---------------------------------------------------------------------------
// compareNumber
// ---------------------------------------------------------------------------
test('compareNumber sorts ascending for finite values', () => {
assert.ok(compareNumber(1, 2) < 0);
assert.ok(compareNumber(2, 1) > 0);
assert.equal(compareNumber(1, 1), 0);
});
test('compareNumber accepts numeric strings', () => {
assert.ok(compareNumber('1', '2') < 0);
assert.ok(compareNumber('2', '1') > 0);
});
test('compareNumber pushes invalid values after valid ones', () => {
assert.ok(compareNumber(5, 'not-a-number') < 0);
assert.ok(compareNumber('not-a-number', 5) > 0);
});
test('compareNumber returns 0 when both inputs are unparseable', () => {
assert.equal(compareNumber('abc', 'def'), 0);
// Note: Number(null) === 0, so null is *finite* under this comparator.
assert.equal(compareNumber(undefined, 'abc'), 0);
});
@@ -0,0 +1,49 @@
/*
* 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 { tileToLat, tileToLon } from '../tile-coords.js';
test('tileToLon zero tile at zoom 0 is -180', () => {
assert.equal(tileToLon(0, 0), -180);
});
test('tileToLon centre tile at zoom 1 is 0', () => {
assert.equal(tileToLon(1, 1), 0);
});
test('tileToLon last tile at zoom 2 is 90', () => {
assert.equal(tileToLon(3, 2), 90);
});
test('tileToLat zero tile at zoom 0 is roughly 85.0511', () => {
// Mercator clamp: northernmost projectable latitude.
assert.ok(Math.abs(tileToLat(0, 0) - 85.0511287798066) < 1e-9);
});
test('tileToLat centre tile at zoom 1 is 0', () => {
assert.equal(tileToLat(1, 1), 0);
});
test('tileToLat is symmetric around the equator at zoom 1', () => {
// Tile y=0 (northern edge) and y=2 (southern edge) at zoom 1 should
// be equal in magnitude with opposite signs.
const north = tileToLat(0, 1);
const south = tileToLat(2, 1);
assert.ok(Math.abs(north + south) < 1e-9);
});
@@ -0,0 +1,119 @@
/*
* 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 { buildNeighborTooltipHtml, buildTraceTooltipHtml } from '../tooltip-html.js';
// ---------------------------------------------------------------------------
// buildTraceTooltipHtml
// ---------------------------------------------------------------------------
test('buildTraceTooltipHtml returns empty string for non-arrays', () => {
assert.equal(buildTraceTooltipHtml(null), '');
assert.equal(buildTraceTooltipHtml(undefined), '');
assert.equal(buildTraceTooltipHtml({}), '');
});
test('buildTraceTooltipHtml returns empty string when fewer than two hops are supplied', () => {
assert.equal(buildTraceTooltipHtml([]), '');
assert.equal(buildTraceTooltipHtml([{ short_name: 'A', node_id: '!a' }]), '');
});
test('buildTraceTooltipHtml emits a content fragment with arrows between hops', () => {
const html = buildTraceTooltipHtml([
{ short_name: 'AAA', node_id: '!a' },
{ short_name: 'BBB', node_id: '!b' },
]);
assert.ok(html.includes('trace-tooltip__content'));
assert.ok(html.includes('trace-tooltip__arrow'));
// One arrow between two badges.
const arrowCount = (html.match(/trace-tooltip__arrow/g) || []).length;
assert.equal(arrowCount, 1);
});
test('buildTraceTooltipHtml falls back to node_id when short name is missing', () => {
const html = buildTraceTooltipHtml([
{ node_id: '!a' },
{ node_id: '!b' },
]);
// The badge should reference the node_id.
assert.ok(html.includes('!a'));
assert.ok(html.includes('!b'));
});
test('buildTraceTooltipHtml filters out malformed entries', () => {
const html = buildTraceTooltipHtml([
null,
{ short_name: 'AAA', node_id: '!a' },
'not an object',
{ short_name: 'BBB', node_id: '!b' },
]);
// Two valid entries → exactly one arrow.
const arrowCount = (html.match(/trace-tooltip__arrow/g) || []).length;
assert.equal(arrowCount, 1);
});
test('buildTraceTooltipHtml returns empty string when every entry is malformed', () => {
assert.equal(buildTraceTooltipHtml([null, 'x', 1]), '');
});
// ---------------------------------------------------------------------------
// buildNeighborTooltipHtml
// ---------------------------------------------------------------------------
test('buildNeighborTooltipHtml returns empty string for falsy segments', () => {
assert.equal(buildNeighborTooltipHtml(null), '');
assert.equal(buildNeighborTooltipHtml(undefined), '');
});
test('buildNeighborTooltipHtml emits source → target HTML', () => {
const html = buildNeighborTooltipHtml({
sourceShortName: 'AAA',
targetShortName: 'BBB',
sourceNode: { node_id: '!a', long_name: 'Alpha' },
targetNode: { node_id: '!b', long_name: 'Beta' },
sourceRole: 'CLIENT',
targetRole: 'CLIENT',
});
assert.ok(html.includes('trace-tooltip__content'));
assert.ok(html.includes('trace-tooltip__arrow'));
assert.ok(html.includes('Alpha'));
assert.ok(html.includes('Beta'));
});
test('buildNeighborTooltipHtml falls back to node short_name fields', () => {
const html = buildNeighborTooltipHtml({
sourceNode: { short_name: 'AAA', node_id: '!a' },
targetNode: { short_name: 'BBB', node_id: '!b' },
});
assert.ok(html.includes('trace-tooltip__arrow'));
});
test('buildNeighborTooltipHtml falls back to node_id when no short name is present', () => {
const html = buildNeighborTooltipHtml({
sourceNode: { node_id: '!a' },
targetNode: { node_id: '!b' },
});
assert.ok(html.includes('!a'));
assert.ok(html.includes('!b'));
});
test('buildNeighborTooltipHtml returns empty string when either side has no short name', () => {
assert.equal(buildNeighborTooltipHtml({ sourceNode: { node_id: '!a' } }), '');
assert.equal(buildNeighborTooltipHtml({ targetNode: { node_id: '!b' } }), '');
});
@@ -0,0 +1,36 @@
/*
* 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.
*/
/**
* Stable numeric limits shared between ``main.js`` and the helpers extracted
* into ``main/`` submodules.
*
* @module main/constants
*/
import { SNAPSHOT_WINDOW } from '../snapshot-aggregator.js';
/** Maximum number of node rows requested from the API. */
export const NODE_LIMIT = 1000;
/** Maximum number of trace rows requested from the API. */
export const TRACE_LIMIT = 200;
/** Maximum age (seconds) for traces displayed on the map. */
export const TRACE_MAX_AGE_SECONDS = 28 * 24 * 60 * 60;
/** Snapshot multiplier — how many rows we ask for to build a richer aggregate. */
export const SNAPSHOT_LIMIT = SNAPSHOT_WINDOW;
@@ -0,0 +1,193 @@
/*
* 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.
*/
/**
* Pure async fetch wrappers for the dashboard JSON API.
*
* Functions accept their own dependencies chat-enabled flag, message-limit
* normaliser so they remain free of any closure / DOM state and can be
* unit-tested standalone.
*
* @module main/data-fetchers
*/
import { NODE_LIMIT, SNAPSHOT_LIMIT, TRACE_LIMIT, TRACE_MAX_AGE_SECONDS } from './constants.js';
import { resolveTimestampSeconds } from './format-utils.js';
/**
* Determine how many snapshots should be requested from the API to build a
* richer aggregate.
*
* @param {number} requestedLimit Desired number of unique entities.
* @param {number} [maxLimit=NODE_LIMIT] Maximum rows accepted by the API.
* @returns {number} Effective request limit honouring {@link SNAPSHOT_LIMIT}.
*/
export function resolveSnapshotLimit(requestedLimit, maxLimit = NODE_LIMIT) {
const base = Number.isFinite(requestedLimit) && requestedLimit > 0
? Math.floor(requestedLimit)
: maxLimit;
const expanded = base * SNAPSHOT_LIMIT;
const candidate = expanded > base ? expanded : base;
return Math.min(candidate, maxLimit);
}
/**
* Filter trace entries to discard packets older than the configured window.
*
* @param {Array<Object>} traces Trace payloads.
* @param {number} [maxAgeSeconds=TRACE_MAX_AGE_SECONDS] Maximum allowed age in seconds.
* @returns {Array<Object>} Recent trace entries.
*/
export function filterRecentTraces(traces, maxAgeSeconds = TRACE_MAX_AGE_SECONDS) {
if (!Array.isArray(traces)) {
return [];
}
if (!Number.isFinite(maxAgeSeconds) || maxAgeSeconds <= 0) {
return [...traces];
}
const nowSeconds = Math.floor(Date.now() / 1000);
const cutoff = nowSeconds - maxAgeSeconds;
return traces.filter(trace => {
const rxTime = resolveTimestampSeconds(trace?.rx_time ?? trace?.rxTime, trace?.rx_iso ?? trace?.rxIso);
return rxTime != null && rxTime >= cutoff;
});
}
/**
* Fetch the latest nodes from the JSON API.
*
* @param {number} [limit=NODE_LIMIT] Maximum number of records.
* @param {number} [since=0] Unix timestamp; only rows newer than this are returned.
* @returns {Promise<Array<Object>>} Parsed node payloads.
*/
export async function fetchNodes(limit = NODE_LIMIT, since = 0) {
const effectiveLimit = resolveSnapshotLimit(limit, NODE_LIMIT);
let url = `/api/nodes?limit=${effectiveLimit}`;
if (since > 0) url += `&since=${since}`;
const r = await fetch(url, { cache: 'default' });
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
}
/**
* Retrieve a single node record by identifier from the API.
*
* @param {string} nodeId Canonical node identifier.
* @returns {Promise<Object|null>} Parsed node payload or null when absent.
*/
export async function fetchNodeById(nodeId) {
if (typeof nodeId !== 'string') return null;
const trimmed = nodeId.trim();
if (trimmed.length === 0) return null;
const r = await fetch(`/api/nodes/${encodeURIComponent(trimmed)}`, { cache: 'default' });
if (r.status === 404) return null;
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
}
/**
* Fetch recent messages from the JSON API.
*
* @param {number} limit Maximum number of rows.
* @param {{ encrypted?: boolean, since?: number, chatEnabled?: boolean, normaliseMessageLimit?: Function }} options
* Retrieval flags and dependency hooks. When ``chatEnabled`` is false the
* function short-circuits to an empty array without contacting the API.
* @returns {Promise<Array<Object>>} Parsed message payloads.
*/
export async function fetchMessages(limit, options = {}) {
const { chatEnabled = true, normaliseMessageLimit, encrypted = false, since = 0 } = options;
if (!chatEnabled) return [];
const safeLimit = typeof normaliseMessageLimit === 'function'
? normaliseMessageLimit(limit)
: limit;
const params = new URLSearchParams({ limit: String(safeLimit) });
if (encrypted) {
params.set('encrypted', 'true');
}
if (since > 0) {
params.set('since', String(since));
}
const query = params.toString();
const r = await fetch(`/api/messages?${query}`, { cache: 'default' });
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
}
/**
* Fetch neighbour information from the JSON API.
*
* @param {number} [limit=NODE_LIMIT] Maximum number of rows.
* @param {number} [since=0] Unix timestamp; only rows newer than this are returned.
* @returns {Promise<Array<Object>>} Parsed neighbour payloads.
*/
export async function fetchNeighbors(limit = NODE_LIMIT, since = 0) {
const effectiveLimit = resolveSnapshotLimit(limit, NODE_LIMIT);
let url = `/api/neighbors?limit=${effectiveLimit}`;
if (since > 0) url += `&since=${since}`;
const r = await fetch(url, { cache: 'default' });
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
}
/**
* Fetch traceroute observations from the JSON API.
*
* @param {number} [limit=TRACE_LIMIT] Maximum number of records.
* @param {number} [since=0] Unix timestamp; only rows newer than this are returned.
* @returns {Promise<Array<Object>>} Parsed trace payloads.
*/
export async function fetchTraces(limit = TRACE_LIMIT, since = 0) {
const safeLimit = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : TRACE_LIMIT;
const effectiveLimit = Math.min(safeLimit, NODE_LIMIT);
let url = `/api/traces?limit=${effectiveLimit}`;
if (since > 0) url += `&since=${since}`;
const r = await fetch(url, { cache: 'default' });
if (!r.ok) throw new Error('HTTP ' + r.status);
const traces = await r.json();
return filterRecentTraces(traces, TRACE_MAX_AGE_SECONDS);
}
/**
* Fetch telemetry entries from the JSON API.
*
* @param {number} [limit=NODE_LIMIT] Maximum number of rows.
* @param {number} [since=0] Unix timestamp; only rows newer than this are returned.
* @returns {Promise<Array<Object>>} Parsed telemetry payloads.
*/
export async function fetchTelemetry(limit = NODE_LIMIT, since = 0) {
const effectiveLimit = resolveSnapshotLimit(limit, NODE_LIMIT);
let url = `/api/telemetry?limit=${effectiveLimit}`;
if (since > 0) url += `&since=${since}`;
const r = await fetch(url, { cache: 'default' });
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
}
/**
* Fetch position packets from the JSON API.
*
* @param {number} [limit=NODE_LIMIT] Maximum number of rows.
* @param {number} [since=0] Unix timestamp; only rows newer than this are returned.
* @returns {Promise<Array<Object>>} Parsed position payloads.
*/
export async function fetchPositions(limit = NODE_LIMIT, since = 0) {
const effectiveLimit = resolveSnapshotLimit(limit, NODE_LIMIT);
let url = `/api/positions?limit=${effectiveLimit}`;
if (since > 0) url += `&since=${since}`;
const r = await fetch(url, { cache: 'default' });
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
}
+181
View File
@@ -0,0 +1,181 @@
/*
* 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.
*/
/**
* Pure data-merge helpers fold position and telemetry packets into the
* node collection without touching any closure or DOM state.
*
* @module main/data-merge
*/
import { resolveTimestampSeconds, toFiniteNumber } from './format-utils.js';
/**
* Merge recent position packets into the node list.
*
* Mutates each node entry in place, updating coordinates / altitude /
* position-time fields when the incoming packet carries a strictly newer
* timestamp.
*
* @param {Array<Object>} nodes Node payloads.
* @param {Array<Object>} positions Position entries.
* @returns {void}
*/
export function mergePositionsIntoNodes(nodes, positions) {
if (!Array.isArray(nodes) || !Array.isArray(positions) || nodes.length === 0) return;
const nodesById = new Map();
for (const node of nodes) {
if (!node || typeof node !== 'object') continue;
const key = typeof node.node_id === 'string' ? node.node_id : null;
if (key) nodesById.set(key, node);
}
if (nodesById.size === 0) return;
const updated = new Set();
for (const pos of positions) {
if (!pos || typeof pos !== 'object') continue;
const nodeId = typeof pos.node_id === 'string' ? pos.node_id : null;
if (!nodeId || updated.has(nodeId)) continue;
const node = nodesById.get(nodeId);
if (!node) continue;
const lat = toFiniteNumber(pos.latitude);
const lon = toFiniteNumber(pos.longitude);
if (lat == null || lon == null) continue;
const currentTimestamp = resolveTimestampSeconds(node.position_time, node.pos_time_iso);
const incomingTimestamp = resolveTimestampSeconds(pos.position_time, pos.position_time_iso);
if (currentTimestamp != null) {
if (incomingTimestamp == null || incomingTimestamp <= currentTimestamp) {
continue;
}
}
updated.add(nodeId);
node.latitude = lat;
node.longitude = lon;
const alt = toFiniteNumber(pos.altitude);
if (alt != null) node.altitude = alt;
const posTime = toFiniteNumber(pos.position_time);
if (posTime != null) {
node.position_time = posTime;
node.pos_time_iso = typeof pos.position_time_iso === 'string' && pos.position_time_iso.length
? pos.position_time_iso
: new Date(posTime * 1000).toISOString();
} else if (typeof pos.position_time_iso === 'string' && pos.position_time_iso.length) {
node.pos_time_iso = pos.position_time_iso;
}
if (pos.location_source != null && pos.location_source !== '') {
node.location_source = pos.location_source;
}
const precision = toFiniteNumber(pos.precision_bits);
if (precision != null) node.precision_bits = precision;
}
}
/**
* Build a lookup table of telemetry entries keyed by node identifier.
*
* @param {Array<Object>} entries Telemetry payloads.
* @returns {{byNodeId: Map<string, {entry: Object, timestamp: number}>, byNodeNum: Map<number, {entry: Object, timestamp: number}>}}
* Indexed telemetry data.
*/
export function buildTelemetryIndex(entries) {
const byNodeId = new Map();
const byNodeNum = new Map();
if (!Array.isArray(entries)) {
return { byNodeId, byNodeNum };
}
for (const entry of entries) {
if (!entry || typeof entry !== 'object') continue;
const nodeId = typeof entry.node_id === 'string' ? entry.node_id : (typeof entry.nodeId === 'string' ? entry.nodeId : null);
const nodeNumRaw = entry.node_num ?? entry.nodeNum;
const nodeNum = typeof nodeNumRaw === 'number' ? nodeNumRaw : Number(nodeNumRaw);
const rxTime = toFiniteNumber(entry.rx_time ?? entry.rxTime);
const telemetryTime = toFiniteNumber(entry.telemetry_time ?? entry.telemetryTime);
const timestamp = rxTime != null ? rxTime : telemetryTime != null ? telemetryTime : Number.NEGATIVE_INFINITY;
if (nodeId) {
const existing = byNodeId.get(nodeId);
if (!existing || timestamp > existing.timestamp) {
byNodeId.set(nodeId, { entry, timestamp });
}
}
if (Number.isFinite(nodeNum)) {
const existing = byNodeNum.get(nodeNum);
if (!existing || timestamp > existing.timestamp) {
byNodeNum.set(nodeNum, { entry, timestamp });
}
}
}
return { byNodeId, byNodeNum };
}
/**
* Merge telemetry metrics into the node list.
*
* Mutates each node entry in place, copying battery / voltage / channel
* utilisation / environmental fields from the freshest telemetry packet that
* matches by ``node_id`` or ``node_num``.
*
* @param {Array<Object>} nodes Node payloads.
* @param {Array<Object>} telemetryEntries Telemetry data.
* @returns {void}
*/
export function mergeTelemetryIntoNodes(nodes, telemetryEntries) {
if (!Array.isArray(nodes) || !nodes.length) return;
const { byNodeId, byNodeNum } = buildTelemetryIndex(telemetryEntries);
for (const node of nodes) {
if (!node || typeof node !== 'object') continue;
const nodeId = typeof node.node_id === 'string' ? node.node_id : (typeof node.nodeId === 'string' ? node.nodeId : null);
const nodeNumRaw = node.num ?? node.node_num ?? node.nodeNum;
const nodeNum = typeof nodeNumRaw === 'number' ? nodeNumRaw : Number(nodeNumRaw);
let telemetryEntry = null;
if (nodeId && byNodeId.has(nodeId)) {
telemetryEntry = byNodeId.get(nodeId).entry;
} else if (Number.isFinite(nodeNum) && byNodeNum.has(nodeNum)) {
telemetryEntry = byNodeNum.get(nodeNum).entry;
}
if (!telemetryEntry || typeof telemetryEntry !== 'object') continue;
const metrics = {
battery_level: toFiniteNumber(telemetryEntry.battery_level ?? telemetryEntry.batteryLevel),
voltage: toFiniteNumber(telemetryEntry.voltage),
uptime_seconds: toFiniteNumber(telemetryEntry.uptime_seconds ?? telemetryEntry.uptimeSeconds),
channel_utilization: toFiniteNumber(telemetryEntry.channel_utilization ?? telemetryEntry.channelUtilization),
air_util_tx: toFiniteNumber(telemetryEntry.air_util_tx ?? telemetryEntry.airUtilTx),
temperature: toFiniteNumber(telemetryEntry.temperature),
relative_humidity: toFiniteNumber(telemetryEntry.relative_humidity ?? telemetryEntry.relativeHumidity),
barometric_pressure: toFiniteNumber(telemetryEntry.barometric_pressure ?? telemetryEntry.barometricPressure),
};
for (const [key, value] of Object.entries(metrics)) {
if (value == null) continue;
node[key] = value;
}
const telemetryTime = toFiniteNumber(telemetryEntry.telemetry_time ?? telemetryEntry.telemetryTime);
if (telemetryTime != null) {
node.telemetry_time = telemetryTime;
}
const rxTime = toFiniteNumber(telemetryEntry.rx_time ?? telemetryEntry.rxTime);
if (rxTime != null) {
node.telemetry_rx_time = rxTime;
}
}
}
@@ -0,0 +1,53 @@
/*
* 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.
*/
/**
* Filter-key helpers used to disambiguate role buttons across protocols.
*
* @module main/filter-helpers
*/
import { isMeshcoreProtocol } from '../protocol-helpers.js';
import { getRoleKey } from '../role-helpers.js';
/**
* Canonical protocol token for use in compound filter keys.
*
* Collapses null/absent/unknown protocol values to ``'meshtastic'`` so that
* pre-protocol legacy records land in the Meshtastic filter bucket.
*
* @param {string|null|undefined} protocol Raw protocol value.
* @returns {'meshtastic'|'meshcore'} Normalised protocol token.
*/
export function normalizeFilterProtocol(protocol) {
return isMeshcoreProtocol(protocol) ? 'meshcore' : 'meshtastic';
}
/**
* Build a compound filter key that encodes both protocol and role.
*
* Using compound keys avoids collisions between role names that appear in
* both Meshtastic and MeshCore (e.g. ``SENSOR``, ``REPEATER``). The filter
* set stores these keys so that clicking the MeshCore SENSOR button only
* includes MeshCore SENSOR nodes, not Meshtastic ones.
*
* @param {*} role Raw role value from the API.
* @param {string|null|undefined} protocol Protocol string from the API.
* @returns {string} Compound key in the form ``"<protocol>:<roleKey>"``.
*/
export function makeRoleFilterKey(role, protocol) {
return `${normalizeFilterProtocol(protocol)}:${getRoleKey(role)}`;
}
@@ -0,0 +1,282 @@
/*
* 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.
*/
/**
* Pure formatting helpers used throughout the dashboard.
*
* Extracted from ``main.js`` so that submodules and unit tests can import
* them without dragging in the entire ``initializeApp`` closure. Every
* function here is deterministic and free of closure / DOM state.
*
* @module main/format-utils
*/
/**
* Pad a numeric value with leading zeros.
*
* @param {number} n Numeric value.
* @returns {string} Padded string.
*/
export function pad(n) {
return String(n).padStart(2, '0');
}
/**
* Format a ``Date`` object as ``HH:MM:SS``.
*
* @param {Date} d Date instance.
* @returns {string} Time string.
*/
export function formatTime(d) {
return pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
}
/**
* Format a ``Date`` object as ``YYYY-MM-DD``.
*
* @param {Date} d Date instance.
* @returns {string} Date string.
*/
export function formatDate(d) {
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
}
/**
* Format hardware model strings for display.
*
* @param {*} v Raw hardware model value.
* @returns {string} Sanitised string.
*/
export function fmtHw(v) {
return v && v !== 'UNSET' ? String(v) : '';
}
/**
* Format coordinate values with a configurable precision.
*
* @param {*} v Raw coordinate value.
* @param {number} [d=5] Decimal precision.
* @returns {string} Formatted coordinate string.
*/
export function fmtCoords(v, d = 5) {
if (v == null || v === '') return '';
const n = Number(v);
return Number.isFinite(n) ? n.toFixed(d) : '';
}
/**
* Format SNR readings with a ``dB`` suffix.
*
* @param {*} value Raw SNR value.
* @returns {string} Formatted SNR string.
*/
export function formatSnrDisplay(value) {
if (value == null || value === '') return '';
const n = Number(value);
if (!Number.isFinite(n)) return '';
return `${n.toFixed(1)} dB`;
}
/**
* Convert a duration in seconds into a human readable string.
*
* @param {number} unixSec Duration in seconds.
* @returns {string} Human readable representation.
*/
export function timeHum(unixSec) {
if (!unixSec) return '';
if (unixSec < 0) return '0s';
if (unixSec < 60) return `${unixSec}s`;
if (unixSec < 3600) return `${Math.floor(unixSec / 60)}m ${Math.floor((unixSec % 60))}s`;
if (unixSec < 86400) return `${Math.floor(unixSec / 3600)}h ${Math.floor((unixSec % 3600) / 60)}m`;
return `${Math.floor(unixSec / 86400)}d ${Math.floor((unixSec % 86400) / 3600)}h`;
}
/**
* Return a relative time string describing how long ago an event occurred.
*
* @param {number} unixSec Timestamp in seconds.
* @param {number} [nowSec] Reference timestamp.
* @returns {string} Human readable relative time.
*/
export function timeAgo(unixSec, nowSec = Date.now() / 1000) {
if (!unixSec) return '';
const diff = Math.floor(nowSec - Number(unixSec));
if (diff < 0) return '0s';
if (diff < 60) return `${diff}s`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ${Math.floor((diff % 60))}s`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ${Math.floor((diff % 3600) / 60)}m`;
return `${Math.floor(diff / 86400)}d ${Math.floor((diff % 86400) / 3600)}h`;
}
/**
* Convert arbitrary values to finite numbers when possible.
*
* @param {*} value Raw value.
* @returns {number|null} Finite number or null when conversion fails.
*/
export function toFiniteNumber(value) {
if (value == null || value === '') return null;
const num = typeof value === 'number' ? value : Number(value);
return Number.isFinite(num) ? num : null;
}
/**
* Determine the best-effort timestamp in seconds from numeric or ISO values.
*
* @param {*} numeric Numeric timestamp.
* @param {*} isoString ISO formatted timestamp.
* @returns {number|null} Timestamp in seconds.
*/
export function resolveTimestampSeconds(numeric, isoString) {
const parsedNumeric = toFiniteNumber(numeric);
if (parsedNumeric != null) return parsedNumeric;
if (typeof isoString === 'string' && isoString.length) {
const parsedIso = Date.parse(isoString);
if (Number.isFinite(parsedIso)) {
return parsedIso / 1000;
}
}
return null;
}
/**
* Escape a string for safe use as a CSS selector fragment.
*
* Falls back to a manual escape when ``CSS.escape`` is unavailable.
*
* @param {string} value Selector fragment.
* @returns {string} Escaped selector fragment safe for interpolation.
*/
export function cssEscape(value) {
if (typeof value !== 'string' || value.length === 0) {
return '';
}
if (typeof window !== 'undefined' && window.CSS && typeof window.CSS.escape === 'function') {
return window.CSS.escape(value);
}
return value.replace(/[^a-zA-Z0-9_-]/g, chr => `\\${chr}`);
}
/**
* Format uptime values for the short-info overlay.
*
* @param {*} value Raw uptime value.
* @returns {string} Human readable uptime string.
*/
export function formatShortInfoUptime(value) {
if (value == null || value === '') return '';
const num = Number(value);
if (!Number.isFinite(num)) return '';
return num === 0 ? '0s' : timeHum(num);
}
/**
* Format overlay values with an em dash fallback when blank.
*
* @param {*} value Candidate value.
* @returns {string} Formatted value or em dash.
*/
export function shortInfoValueOrDash(value) {
return value != null && value !== '' ? String(value) : '—';
}
/**
* Retrieve the first present property value from a collection of objects.
*
* @param {Array<Object>} sources Candidate objects.
* @param {Array<string>} keys Ordered property names to inspect.
* @returns {*} First present non-blank value or ``null`` when absent.
*/
export function pickFirstProperty(sources, keys) {
if (!Array.isArray(sources) || !Array.isArray(keys)) {
return null;
}
for (const source of sources) {
if (!source || typeof source !== 'object') continue;
for (const key of keys) {
if (!Object.prototype.hasOwnProperty.call(source, key)) continue;
const value = source[key];
if (value == null) continue;
if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed.length === 0) {
continue;
}
return trimmed;
}
return value;
}
}
return null;
}
/**
* Retrieve the first finite numeric property from candidate objects.
*
* @param {Array<Object>} sources Candidate objects.
* @param {Array<string>} keys Ordered property names to inspect.
* @returns {?number} First finite number when available.
*/
export function pickNumericProperty(sources, keys) {
if (!Array.isArray(sources) || !Array.isArray(keys)) {
return null;
}
for (const source of sources) {
if (!source || typeof source !== 'object') continue;
for (const key of keys) {
if (!Object.prototype.hasOwnProperty.call(source, key)) continue;
const raw = source[key];
if (raw == null || raw === '') continue;
const num = typeof raw === 'number' ? raw : Number(raw);
if (Number.isFinite(num)) {
return num;
}
}
}
return null;
}
/**
* Parse a node identifier or numeric reference into a finite number.
*
* @param {*} ref Identifier or numeric reference.
* @returns {number|null} Parsed number or ``null``.
*/
export function parseNodeNumericRef(ref) {
if (ref == null) return null;
if (typeof ref === 'number') {
return Number.isFinite(ref) ? ref : null;
}
if (typeof ref === 'string') {
const trimmed = ref.trim();
if (!trimmed) return null;
if (trimmed.startsWith('!')) {
const hex = trimmed.slice(1);
if (!/^[0-9A-Fa-f]+$/.test(hex)) return null;
const parsedHex = Number.parseInt(hex, 16);
return Number.isFinite(parsedHex) ? parsedHex >>> 0 : null;
}
if (/^0[xX][0-9A-Fa-f]+$/.test(trimmed)) {
const parsedHex = Number.parseInt(trimmed, 16);
return Number.isFinite(parsedHex) ? parsedHex >>> 0 : null;
}
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : null;
}
const parsed = Number(ref);
return Number.isFinite(parsed) ? parsed : null;
}
@@ -0,0 +1,54 @@
/*
* 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.
*/
/**
* Pure helpers for the Fullscreen API used by the map fullscreen toggle.
*
* @module main/fullscreen-helpers
*/
/**
* Resolve the element currently being displayed in fullscreen mode.
*
* @returns {Element|null} Active fullscreen element if any.
*/
export function getActiveFullscreenElement() {
if (typeof document === 'undefined') return null;
return (
document.fullscreenElement ||
document.webkitFullscreenElement ||
document.msFullscreenElement ||
null
);
}
/**
* Wrap a legend button click handler so it always calls
* ``preventDefault`` and ``stopPropagation`` before running the body.
*
* Centralising this prevents the two-line boilerplate from repeating in every
* legend button handler, reducing token-level duplication.
*
* @param {function(Event): void} fn Handler body.
* @returns {function(Event): void} Full click listener.
*/
export function legendClickHandler(fn) {
return (event) => {
event.preventDefault();
event.stopPropagation();
fn(event);
};
}
@@ -0,0 +1,125 @@
/*
* 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.
*/
/**
* Helpers used by the long-name link click router and overlay name fallback.
*
* @module main/long-link-router
*/
import { canonicalNodeIdentifier, normalizeNodeNameValue } from '../node-rendering.js';
/**
* Determine whether a long name link should trigger the overlay behaviour.
*
* @param {?Element} link Anchor element.
* @returns {boolean} ``true`` when the link participates in overlays.
*/
export function shouldHandleNodeLongLink(link) {
if (!link || !link.dataset) return false;
if ('nodeDetailLink' in link.dataset && link.dataset.nodeDetailLink === 'false') {
return false;
}
return true;
}
/**
* Extract the canonical identifier from a node detail hyperlink.
*
* @param {string} href Link href attribute.
* @returns {string} Canonical identifier or ``''``.
*/
export function extractIdentifierFromHref(href) {
if (typeof href !== 'string' || href.length === 0) {
return '';
}
const match = href.match(/\/nodes\/(![^/?#]+)/i);
if (!match || !match[1]) {
return '';
}
try {
const decoded = decodeURIComponent(match[1]);
return canonicalNodeIdentifier(decoded) ?? '';
} catch {
return canonicalNodeIdentifier(match[1]) ?? '';
}
}
/**
* Extract the canonical node identifier from the provided link element.
*
* @param {?Element} link Anchor element.
* @returns {string} Canonical node identifier or ``''`` when unavailable.
*/
export function getNodeIdentifierFromLink(link) {
if (!link) return '';
const datasetIdentifier = link.dataset && typeof link.dataset.nodeId === 'string'
? canonicalNodeIdentifier(link.dataset.nodeId)
: null;
if (datasetIdentifier) {
return datasetIdentifier;
}
if (typeof link.getAttribute === 'function') {
const attrHref = link.getAttribute('href');
const canonicalFromAttr = extractIdentifierFromHref(attrHref);
if (canonicalFromAttr) {
return canonicalFromAttr;
}
}
if (typeof link.href === 'string') {
const canonicalFromProperty = extractIdentifierFromHref(link.href);
if (canonicalFromProperty) {
return canonicalFromProperty;
}
}
return '';
}
/**
* Determine the preferred display name for overlay content.
*
* @param {Object} node Node payload.
* @returns {string} Friendly display name.
*/
export function getNodeDisplayNameForOverlay(node) {
if (!node || typeof node !== 'object') return '';
return (
normalizeNodeNameValue(node.long_name ?? node.longName) ||
normalizeNodeNameValue(node.short_name ?? node.shortName) ||
(typeof node.node_id === 'string' ? node.node_id : '')
);
}
/**
* Populate missing node name fields with sensible defaults.
*
* @param {Object} node Node payload.
* @returns {void}
*/
export function applyNodeNameFallback(node) {
if (!node || typeof node !== 'object') return;
const short = normalizeNodeNameValue(node.short_name ?? node.shortName);
const long = normalizeNodeNameValue(node.long_name ?? node.longName);
if (short || long) return;
const nodeId = normalizeNodeNameValue(node.node_id ?? node.nodeId);
if (!nodeId) return;
const fallbackShort = nodeId.slice(-4);
const fallbackLong = `Meshtastic ${nodeId}`;
node.short_name = fallbackShort;
node.long_name = fallbackLong;
if ('shortName' in node) node.shortName = fallbackShort;
if ('longName' in node) node.longName = fallbackLong;
}
@@ -0,0 +1,134 @@
/*
* 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.
*/
/**
* Offline-fallback Leaflet ``GridLayer`` factory.
*
* Receives the Leaflet global as a parameter so the module remains free of
* implicit closure dependencies while still rendering identical placeholder
* tiles when network basemaps are unavailable.
*
* @module main/offline-tile-layer
*/
import { tileToLat, tileToLon } from './tile-coords.js';
/**
* Create a minimal Leaflet tile layer that renders offline tiles from cache.
*
* @param {Object|null} L Leaflet global, or ``null`` when Leaflet is unavailable.
* @returns {Object|null} Configured tile layer instance, or ``null`` when Leaflet is missing.
*/
export function createOfflineTileLayer(L) {
if (!L || typeof L.gridLayer !== 'function') return null;
const offlineLayer = L.gridLayer({ className: 'map-tiles map-tiles-offline' });
/** @type {HTMLElement|null} */
let cachedOfflineFallbackTile = null;
/**
* Provide a minimal placeholder tile when canvas rendering is not available.
*
* @param {number} size Pixel width and height of the tile.
* @returns {HTMLElement} Cloned fallback element ready for Leaflet consumption.
*/
function getOfflineFallbackTile(size) {
if (!cachedOfflineFallbackTile) {
const placeholder = document.createElement('div');
placeholder.className = 'offline-tile-fallback';
placeholder.style.width = `${size}px`;
placeholder.style.height = `${size}px`;
placeholder.style.backgroundColor = 'rgba(33, 66, 110, 0.92)';
placeholder.style.display = 'flex';
placeholder.style.alignItems = 'center';
placeholder.style.justifyContent = 'center';
placeholder.style.color = 'rgba(255, 255, 255, 0.6)';
placeholder.style.font = 'bold 14px system-ui, sans-serif';
placeholder.style.textTransform = 'uppercase';
placeholder.textContent = 'Offline tile';
cachedOfflineFallbackTile = placeholder;
}
return /** @type {HTMLElement} */ (cachedOfflineFallbackTile.cloneNode(true));
}
/**
* Render a placeholder tile for offline map usage.
*
* @param {{x: number, y: number, z: number}} coords Tile coordinates supplied by Leaflet.
* @returns {HTMLElement} Tile node containing placeholder artwork.
*/
offlineLayer.createTile = coords => {
const size = 256;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) {
console.warn('Canvas 2D context unavailable for offline tile rendering. Using fallback placeholder.');
return getOfflineFallbackTile(size);
}
try {
const gradient = ctx.createLinearGradient(0, 0, size, size);
gradient.addColorStop(0, 'rgba(33, 66, 110, 0.92)');
gradient.addColorStop(1, 'rgba(64, 98, 144, 0.92)');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, size, size);
ctx.strokeStyle = 'rgba(255,255,255,0.12)';
ctx.lineWidth = 1;
const steps = 4;
for (let i = 1; i < steps; i++) {
const pos = (size / steps) * i;
ctx.beginPath();
ctx.moveTo(pos, 0);
ctx.lineTo(pos, size);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(0, pos);
ctx.lineTo(size, pos);
ctx.stroke();
}
const west = tileToLon(coords.x, coords.z);
const east = tileToLon(coords.x + 1, coords.z);
const north = tileToLat(coords.y, coords.z);
const south = tileToLat(coords.y + 1, coords.z);
ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.font = '12px system-ui, sans-serif';
ctx.textBaseline = 'top';
ctx.fillText(`${west.toFixed(1)}°`, 8, 8);
ctx.textBaseline = 'bottom';
ctx.fillText(`${east.toFixed(1)}°`, 8, size - 8);
ctx.textAlign = 'right';
ctx.textBaseline = 'top';
ctx.fillText(`${north.toFixed(1)}°`, size - 8, 8);
ctx.textBaseline = 'bottom';
ctx.fillText(`${south.toFixed(1)}°`, size - 8, size - 8);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.font = 'bold 22px system-ui, sans-serif';
ctx.fillText('PotatoMesh offline basemap', size / 2, size / 2);
return canvas;
} catch (error) {
console.error('Failed to render offline tile. Falling back to placeholder element.', error);
return getOfflineFallbackTile(size);
}
};
return offlineLayer;
}
@@ -0,0 +1,57 @@
/*
* 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.
*/
/**
* Protocol-icon ``<img>`` builders shared between the legend and meta-row
* controls.
*
* @module main/protocol-icons
*/
import { MESHTASTIC_ICON_SRC, MESHCORE_ICON_SRC } from '../protocol-helpers.js';
/**
* Build a protocol icon image element with consistent attributes.
*
* Both the legend and the meta-row protocol toggle use this helper so the
* output is identical regardless of insertion method.
*
* @param {string} src Absolute path to the SVG asset.
* @param {string} variantClass BEM modifier class, e.g. ``protocol-icon--meshtastic``.
* @returns {HTMLImageElement} Icon element ready to append.
*/
export function buildProtocolIconImg(src, variantClass) {
const img = document.createElement('img');
img.setAttribute('src', src);
img.setAttribute('alt', '');
img.setAttribute('width', '12');
img.setAttribute('height', '12');
img.setAttribute('aria-hidden', 'true');
img.setAttribute('loading', 'lazy');
img.setAttribute('decoding', 'async');
img.className = `protocol-icon ${variantClass}`;
return img;
}
/** @returns {HTMLImageElement} Meshtastic protocol icon element. */
export function buildMeshtasticIconImg() {
return buildProtocolIconImg(MESHTASTIC_ICON_SRC, 'protocol-icon--meshtastic');
}
/** @returns {HTMLImageElement} MeshCore protocol icon element. */
export function buildMeshcoreIconImg() {
return buildProtocolIconImg(MESHCORE_ICON_SRC, 'protocol-icon--meshcore');
}
@@ -0,0 +1,92 @@
/*
* 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.
*/
/**
* Render the role-aware short-name badge used by maps, tables, popups, and
* overlay surfaces.
*
* The function is deliberately dependency-free besides shared modules so it
* can be exposed via ``globalThis.PotatoMesh.renderShortHtml`` and consumed by
* the node-detail page without dragging the dashboard's closure state along.
*
* @module main/short-html-renderer
*/
import { escapeHtml } from '../utils.js';
import { collectTelemetryMetrics } from '../short-info-telemetry.js';
import { getRoleColor, getRoleTextColor, normalizeRole } from '../role-helpers.js';
/**
* Render a short name badge with role-based styling.
*
* @param {string} short Short node identifier.
* @param {string} role Node role string.
* @param {string} longName Full node name.
* @param {?Object} nodeData Optional node metadata attached to the badge.
* @returns {string} HTML snippet describing the badge.
*/
export function renderShortHtml(short, role, longName, nodeData = null) {
const safeTitle = longName ? escapeHtml(String(longName)) : '';
const titleAttr = safeTitle ? ` title="${safeTitle}"` : '';
const roleValue = normalizeRole(role != null && role !== '' ? role : (nodeData && nodeData.role));
let infoAttr = '';
if (nodeData && typeof nodeData === 'object') {
const info = {
nodeId: nodeData.node_id ?? nodeData.nodeId ?? '',
nodeNum: nodeData.num ?? nodeData.node_num ?? nodeData.nodeNum ?? null,
shortName: short != null ? String(short) : (nodeData.short_name ?? ''),
longName: nodeData.long_name ?? longName ?? '',
role: roleValue,
hwModel: nodeData.hw_model ?? nodeData.hwModel ?? '',
telemetryTime: nodeData.telemetry_time ?? nodeData.telemetryTime ?? null,
};
Object.assign(info, collectTelemetryMetrics(nodeData));
const attrParts = [` data-node-info="${escapeHtml(JSON.stringify(info))}"`];
const attrNodeIdRaw = info.nodeId != null ? String(info.nodeId).trim() : '';
if (attrNodeIdRaw) {
attrParts.push(` data-node-id="${escapeHtml(attrNodeIdRaw)}"`);
}
const attrNodeNum = Number(info.nodeNum);
if (Number.isFinite(attrNodeNum)) {
attrParts.push(` data-node-num="${escapeHtml(String(attrNodeNum))}"`);
}
infoAttr = attrParts.join('');
}
if (!short) {
return `<span class="short-name" style="background:#ccc"${titleAttr}${infoAttr}>&nbsp;?&nbsp;</span>`;
}
// Pad the label for the badge. For plain-ASCII names that are already
// 4 characters (meshtastic always stores exactly 4) no padding is added.
// Shorter names or names containing emoji/non-ASCII get a single space
// on each side — grapheme width varies too much for character-count
// centering to work reliably.
const raw = String(short);
const graphemeCount = typeof Intl !== 'undefined' && Intl.Segmenter
? [...new Intl.Segmenter().segment(raw)].length
: raw.length;
let centred;
if (graphemeCount >= 4) {
centred = raw;
} else {
centred = ` ${raw} `;
}
const padded = escapeHtml(centred).replace(/ /g, '&nbsp;');
const protocol = nodeData?.protocol ?? null;
const color = getRoleColor(roleValue, protocol);
const textColor = getRoleTextColor(roleValue, protocol);
const styleAttr = textColor ? `background:${color};color:${textColor}` : `background:${color}`;
return `<span class="short-name" style="${styleAttr}"${titleAttr}${infoAttr}>${padded}</span>`;
}
@@ -0,0 +1,83 @@
/*
* 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.
*/
/**
* Pure value-presence guards and comparators for the nodes table.
*
* @module main/sort-comparators
*/
/**
* Determine whether a value should count as present when sorting strings.
*
* @param {*} value Candidate value extracted from a node record.
* @returns {boolean} True when the value is a non-empty string.
*/
export function hasStringValue(value) {
if (value == null) return false;
return String(value).trim().length > 0;
}
/**
* Determine whether the provided value can be interpreted as a finite number.
*
* @param {*} value Candidate value extracted from a node record.
* @returns {boolean} True when the value parses to a finite number.
*/
export function hasNumberValue(value) {
if (value == null || value === '') return false;
const num = typeof value === 'number' ? value : Number(value);
return Number.isFinite(num);
}
/**
* Locale-aware comparator for string table values.
*
* @param {*} a First value.
* @param {*} b Second value.
* @returns {number} Comparator result compatible with ``Array.prototype.sort``.
*/
export function compareString(a, b) {
const strA = (a == null ? '' : String(a)).trim();
const strB = (b == null ? '' : String(b)).trim();
const hasA = strA.length > 0;
const hasB = strB.length > 0;
if (!hasA && !hasB) return 0;
if (!hasA) return 1;
if (!hasB) return -1;
return strA.localeCompare(strB, undefined, { numeric: true, sensitivity: 'base' });
}
/**
* Comparator for numeric table values that tolerates string inputs.
*
* @param {*} a First value.
* @param {*} b Second value.
* @returns {number} Comparator result for ``Array.prototype.sort``.
*/
export function compareNumber(a, b) {
const numA = typeof a === 'number' ? a : Number(a);
const numB = typeof b === 'number' ? b : Number(b);
const validA = Number.isFinite(numA);
const validB = Number.isFinite(numB);
if (validA && validB) {
if (numA === numB) return 0;
return numA < numB ? -1 : 1;
}
if (validA) return -1;
if (validB) return 1;
return 0;
}
@@ -0,0 +1,44 @@
/*
* 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.
*/
/**
* Tile-index longitude/latitude conversions for slippy map tiles.
*
* @module main/tile-coords
*/
/**
* Convert a tile X coordinate to longitude degrees.
*
* @param {number} x Tile X index.
* @param {number} z Zoom level.
* @returns {number} Longitude in degrees.
*/
export function tileToLon(x, z) {
return (x / Math.pow(2, z)) * 360 - 180;
}
/**
* Convert a tile Y coordinate to latitude degrees.
*
* @param {number} y Tile Y index.
* @param {number} z Zoom level.
* @returns {number} Latitude in degrees.
*/
export function tileToLat(y, z) {
const n = Math.PI - (2 * Math.PI * y) / Math.pow(2, z);
return (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n)));
}
@@ -0,0 +1,78 @@
/*
* 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.
*/
/**
* HTML builders for trace and neighbour map tooltips.
*
* @module main/tooltip-html
*/
import { normalizeNodeNameValue } from '../node-rendering.js';
import { renderShortHtml } from './short-html-renderer.js';
/**
* Build tooltip HTML showing styled short-name badges for a trace path.
*
* @param {Array<Object>} pathNodes Ordered node payloads along the trace.
* @returns {string} HTML fragment or ``''`` when unavailable.
*/
export function buildTraceTooltipHtml(pathNodes) {
if (!Array.isArray(pathNodes) || pathNodes.length < 2) {
return '';
}
const parts = pathNodes
.map(node => {
if (!node || typeof node !== 'object') {
return null;
}
const short = normalizeNodeNameValue(node.short_name ?? node.shortName) || (typeof node.node_id === 'string' ? node.node_id : '');
const long = normalizeNodeNameValue(node.long_name ?? node.longName) || '';
return renderShortHtml(short, node.role, long, node);
})
.filter(Boolean);
if (!parts.length) return '';
const arrow = '<span class="trace-tooltip__arrow" aria-hidden="true">→</span>';
return `<div class="trace-tooltip__content">${parts.join(arrow)}</div>`;
}
/**
* Build tooltip HTML for a neighbor segment showing styled short-name badges.
*
* @param {{sourceNode?: Object, targetNode?: Object, sourceShortName?: string, targetShortName?: string, sourceRole?: string, targetRole?: string}} segment Neighbor segment descriptor.
* @returns {string} HTML fragment or ``''`` when unavailable.
*/
export function buildNeighborTooltipHtml(segment) {
if (!segment) return '';
const sourceNode = segment.sourceNode || null;
const targetNode = segment.targetNode || null;
const sourceShort = normalizeNodeNameValue(
segment.sourceShortName ||
(sourceNode ? sourceNode.short_name ?? sourceNode.shortName : null) ||
(sourceNode && typeof sourceNode.node_id === 'string' ? sourceNode.node_id : '')
);
const targetShort = normalizeNodeNameValue(
segment.targetShortName ||
(targetNode ? targetNode.short_name ?? targetNode.shortName : null) ||
(targetNode && typeof targetNode.node_id === 'string' ? targetNode.node_id : '')
);
if (!sourceShort || !targetShort) return '';
const sourceLong = normalizeNodeNameValue(sourceNode?.long_name ?? sourceNode?.longName) || '';
const targetLong = normalizeNodeNameValue(targetNode?.long_name ?? targetNode?.longName) || '';
const sourceHtml = renderShortHtml(sourceShort, segment.sourceRole, sourceLong, sourceNode || {});
const targetHtml = renderShortHtml(targetShort, segment.targetRole, targetLong, targetNode || {});
const arrow = '<span class="trace-tooltip__arrow" aria-hidden="true">→</span>';
return `<div class="trace-tooltip__content">${sourceHtml}${arrow}${targetHtml}</div>`;
}