mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-12 03:42:57 +02:00
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:
+98
-1024
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();
|
||||
}
|
||||
@@ -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}> ? </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, ' ');
|
||||
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>`;
|
||||
}
|
||||
Reference in New Issue
Block a user