mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-10 10:52:55 +02:00
web: render node positions over time
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
* 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 { initializeNodeDetailMapPanel, __testUtils } from '../node-detail-map.js';
|
||||
|
||||
const { extractPositionEntries, resolveReferenceId, colorForDay, ROLE_BY_DAY } = __testUtils;
|
||||
|
||||
function createClassList() {
|
||||
const values = new Set();
|
||||
return {
|
||||
add(name) {
|
||||
if (name) values.add(name);
|
||||
},
|
||||
remove(name) {
|
||||
values.delete(name);
|
||||
},
|
||||
contains(name) {
|
||||
return values.has(name);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createContainer() {
|
||||
return {
|
||||
childNodes: [],
|
||||
insertBefore(node, before) {
|
||||
const idx = before ? this.childNodes.indexOf(before) : -1;
|
||||
if (idx >= 0) {
|
||||
this.childNodes.splice(idx, 0, node);
|
||||
} else {
|
||||
this.childNodes.push(node);
|
||||
}
|
||||
node.parentNode = this;
|
||||
},
|
||||
appendChild(node) {
|
||||
this.childNodes.push(node);
|
||||
node.parentNode = this;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createElement() {
|
||||
return {
|
||||
hidden: false,
|
||||
textContent: '',
|
||||
classList: createClassList(),
|
||||
parentNode: null,
|
||||
nextSibling: null,
|
||||
};
|
||||
}
|
||||
|
||||
function createRootHarness() {
|
||||
const section = createElement();
|
||||
const slot = createContainer();
|
||||
const status = createElement();
|
||||
const root = {
|
||||
querySelector(selector) {
|
||||
if (selector === '[data-node-map-panel]') return section;
|
||||
if (selector === '[data-node-map-slot]') return slot;
|
||||
if (selector === '[data-node-map-status]') return status;
|
||||
return null;
|
||||
}
|
||||
};
|
||||
return { root, section, slot, status };
|
||||
}
|
||||
|
||||
test('extractPositionEntries filters invalid position entries', () => {
|
||||
const nowSec = 2_000_000;
|
||||
const entries = extractPositionEntries([
|
||||
{ latitude: 10.5, longitude: 20.25, rx_time: nowSec },
|
||||
{ lat: '42.1', lon: '-71.2', position_time: nowSec - 100 },
|
||||
{ latitude: 12, longitude: 24, position_time: nowSec - (86_400 * 11) },
|
||||
{ latitude: null, longitude: 10, rx_time: nowSec },
|
||||
{ latitude: 'bad', longitude: 10, rx_time: nowSec },
|
||||
], nowSec);
|
||||
assert.deepEqual(entries.map(entry => [entry.lat, entry.lon]), [
|
||||
[10.5, 20.25],
|
||||
[42.1, -71.2],
|
||||
]);
|
||||
});
|
||||
|
||||
test('resolveReferenceId prefers node identifiers when present', () => {
|
||||
assert.equal(resolveReferenceId({ nodeId: '!alpha', nodeNum: 10 }), '!alpha');
|
||||
assert.equal(resolveReferenceId({ node_num: 12 }), '12');
|
||||
assert.equal(resolveReferenceId(null), null);
|
||||
});
|
||||
|
||||
test('colorForDay interpolates from red to blue', () => {
|
||||
const getRoleColor = role => `color:${role}`;
|
||||
assert.equal(colorForDay(0, getRoleColor), `color:${ROLE_BY_DAY[0]}`);
|
||||
assert.equal(colorForDay(9, getRoleColor), `color:${ROLE_BY_DAY[9]}`);
|
||||
});
|
||||
|
||||
test('initializeNodeDetailMapPanel hides the panel without shared map data', async () => {
|
||||
const { root, section, status } = createRootHarness();
|
||||
status.hidden = false;
|
||||
section.hidden = false;
|
||||
const result = await initializeNodeDetailMapPanel(root, { nodeId: '!alpha' }, { fetchImpl: async () => ({ ok: true }) });
|
||||
assert.equal(result, null);
|
||||
assert.equal(status.hidden, true);
|
||||
assert.equal(section.hidden, true);
|
||||
});
|
||||
|
||||
test('initializeNodeDetailMapPanel reuses the shared map and restores it', async () => {
|
||||
const originalNow = Date.now;
|
||||
Date.now = () => 1_000_000 * 1000;
|
||||
try {
|
||||
const { root, slot, status } = createRootHarness();
|
||||
const mapPanel = createElement();
|
||||
const originalParent = createContainer();
|
||||
originalParent.appendChild(mapPanel);
|
||||
mapPanel.nextSibling = null;
|
||||
|
||||
const calls = { polyline: null, markers: 0, fitBounds: 0 };
|
||||
const map = {
|
||||
getCenter() {
|
||||
return { lat: 1, lng: 2 };
|
||||
},
|
||||
getZoom() {
|
||||
return 6;
|
||||
},
|
||||
setView() {},
|
||||
fitBounds() {
|
||||
calls.fitBounds += 1;
|
||||
},
|
||||
invalidateSize() {}
|
||||
};
|
||||
const leaflet = {
|
||||
layerGroup() {
|
||||
return {
|
||||
addTo() {
|
||||
return this;
|
||||
},
|
||||
remove() {}
|
||||
};
|
||||
},
|
||||
polyline(latlngs, options) {
|
||||
return {
|
||||
addTo() {
|
||||
calls.polyline = { latlngs, options };
|
||||
}
|
||||
};
|
||||
},
|
||||
circleMarker() {
|
||||
return {
|
||||
addTo() {
|
||||
calls.markers += 1;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
const fitBoundsEl = { checked: true };
|
||||
const fetchImpl = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
async json() {
|
||||
return [
|
||||
{ latitude: 10, longitude: 20, position_time: 1_000_000 },
|
||||
{ latitude: 11, longitude: 22, position_time: 1_000_000 - 86_400 },
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
const cleanup = await initializeNodeDetailMapPanel(root, { nodeId: '!map' }, {
|
||||
fetchImpl,
|
||||
mapPanel,
|
||||
document: {
|
||||
getElementById(id) {
|
||||
return id === 'fitBounds' ? fitBoundsEl : null;
|
||||
}
|
||||
},
|
||||
getMapContext: () => ({ map, leaflet }),
|
||||
getRoleColor: role => `color:${role}`,
|
||||
});
|
||||
|
||||
assert.ok(cleanup);
|
||||
assert.equal(status.textContent, '2 positions');
|
||||
assert.equal(mapPanel.parentNode, slot);
|
||||
assert.equal(mapPanel.classList.contains('map-panel--embedded'), true);
|
||||
assert.equal(calls.polyline.options.color, 'color:LOST_AND_FOUND');
|
||||
assert.equal(calls.markers, 2);
|
||||
assert.equal(fitBoundsEl.checked, false);
|
||||
assert.deepEqual(calls.polyline.latlngs, [[10, 20], [11, 22]]);
|
||||
|
||||
cleanup();
|
||||
assert.equal(mapPanel.parentNode, originalParent);
|
||||
assert.equal(fitBoundsEl.checked, true);
|
||||
} finally {
|
||||
Date.now = originalNow;
|
||||
}
|
||||
});
|
||||
@@ -551,6 +551,23 @@ test('renderNodeDetailHtml embeds telemetry charts when snapshots are present',
|
||||
assert.equal(html.includes('Air quality'), true);
|
||||
});
|
||||
|
||||
test('renderNodeDetailHtml includes a map panel when requested', () => {
|
||||
const node = {
|
||||
shortName: 'NODE',
|
||||
nodeId: '!map',
|
||||
role: 'CLIENT',
|
||||
rawSources: {
|
||||
node: { node_id: '!map', role: 'CLIENT', short_name: 'NODE' },
|
||||
},
|
||||
};
|
||||
const html = renderNodeDetailHtml(node, {
|
||||
renderShortHtml: short => `<span class="short-name">${short}</span>`,
|
||||
includeMapPanel: true,
|
||||
});
|
||||
assert.equal(html.includes('node-detail__map-panel'), true);
|
||||
assert.equal(html.includes('data-node-map-slot'), true);
|
||||
});
|
||||
|
||||
test('fetchNodeDetailHtml renders the node layout for overlays', async () => {
|
||||
const reference = { nodeId: '!alpha' };
|
||||
const calledUrls = [];
|
||||
|
||||
@@ -1804,6 +1804,27 @@ export function initializeApp(config) {
|
||||
potatoMeshNamespace.getRoleColor = getRoleColor;
|
||||
potatoMeshNamespace.getRoleKey = getRoleKey;
|
||||
potatoMeshNamespace.normalizeRole = normalizeRole;
|
||||
potatoMeshNamespace.map = map || null;
|
||||
potatoMeshNamespace.leaflet = typeof L === 'undefined' ? null : L;
|
||||
potatoMeshNamespace.mapLayers = {
|
||||
markersLayer: markersLayer || null,
|
||||
neighborLinesLayer: neighborLinesLayer || null,
|
||||
traceLinesLayer: traceLinesLayer || null,
|
||||
};
|
||||
/**
|
||||
* Expose the active map instance and Leaflet context for shared overlays.
|
||||
*
|
||||
* @returns {{ map: ?Object, leaflet: ?Object, layers: Object }} Map context.
|
||||
*/
|
||||
potatoMeshNamespace.getMapContext = () => ({
|
||||
map: map || null,
|
||||
leaflet: typeof L === 'undefined' ? null : L,
|
||||
layers: {
|
||||
markersLayer: markersLayer || null,
|
||||
neighborLinesLayer: neighborLinesLayer || null,
|
||||
traceLinesLayer: traceLinesLayer || null,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Escape a CSS selector fragment with a defensive fallback for
|
||||
@@ -3873,6 +3894,9 @@ export function initializeApp(config) {
|
||||
if (!map || !markersLayer || !hasLeaflet) {
|
||||
return;
|
||||
}
|
||||
if (mapPanel && mapPanel.classList && mapPanel.classList.contains('map-panel--embedded')) {
|
||||
return;
|
||||
}
|
||||
if (neighborLinesLayer) {
|
||||
neighborLinesLayer.clearLayers();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const DEFAULT_FETCH_OPTIONS = Object.freeze({ cache: 'no-store' });
|
||||
const DAY_SECONDS = 86_400;
|
||||
const MAP_PADDING = [18, 18];
|
||||
const MAX_FIT_ZOOM = 14;
|
||||
const DEFAULT_ZOOM = 12;
|
||||
const MAP_CONTEXT_WAIT_INTERVAL_MS = 50;
|
||||
const MAP_CONTEXT_WAIT_TIMEOUT_MS = 1500;
|
||||
const ROLE_BY_DAY = Object.freeze([
|
||||
'LOST_AND_FOUND',
|
||||
'ROUTER',
|
||||
'ROUTER_LATE',
|
||||
'REPEATER',
|
||||
'CLIENT_BASE',
|
||||
'CLIENT',
|
||||
'CLIENT_MUTE',
|
||||
'TRACKER',
|
||||
'SENSOR',
|
||||
'CLIENT_HIDDEN',
|
||||
]);
|
||||
const MAX_DAYS = ROLE_BY_DAY.length;
|
||||
|
||||
/**
|
||||
* Coerce a candidate coordinate into a finite number.
|
||||
*
|
||||
* @param {*} value Raw coordinate candidate.
|
||||
* @returns {?number} Finite number or ``null`` when invalid.
|
||||
*/
|
||||
function toFiniteNumber(value) {
|
||||
if (value == null || value === '') return null;
|
||||
const numeric = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the node identifier from a reference payload.
|
||||
*
|
||||
* @param {*} reference Node reference payload.
|
||||
* @returns {?string} Canonical identifier or ``null`` when unavailable.
|
||||
*/
|
||||
function resolveReferenceId(reference) {
|
||||
if (!reference || typeof reference !== 'object') return null;
|
||||
const nodeId = reference.nodeId ?? reference.node_id;
|
||||
const nodeNum = reference.nodeNum ?? reference.node_num ?? reference.num;
|
||||
const candidate = nodeId ?? nodeNum;
|
||||
if (candidate == null) return null;
|
||||
const text = String(candidate).trim();
|
||||
return text.length ? text : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the shared map instance exposed by the dashboard.
|
||||
*
|
||||
* @param {Object} options Optional configuration.
|
||||
* @returns {{ map: ?Object, leaflet: ?Object }} Map context.
|
||||
*/
|
||||
function resolveMapContext(options) {
|
||||
if (typeof options.getMapContext === 'function') {
|
||||
return options.getMapContext() || { map: null, leaflet: null };
|
||||
}
|
||||
const namespace = options.namespace ?? globalThis.PotatoMesh ?? null;
|
||||
if (namespace && typeof namespace.getMapContext === 'function') {
|
||||
return namespace.getMapContext() || { map: null, leaflet: null };
|
||||
}
|
||||
return {
|
||||
map: namespace?.map ?? null,
|
||||
leaflet: namespace?.leaflet ?? globalThis.L ?? null,
|
||||
layers: namespace?.mapLayers ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait briefly for the shared map context to become available.
|
||||
*
|
||||
* @param {Object} options Optional configuration.
|
||||
* @returns {Promise<{ map: ?Object, leaflet: ?Object }>} Map context.
|
||||
*/
|
||||
async function waitForMapContext(options) {
|
||||
const deadline = Date.now() + MAP_CONTEXT_WAIT_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
const context = resolveMapContext(options);
|
||||
if (context.map && context.leaflet) {
|
||||
return context;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, MAP_CONTEXT_WAIT_INTERVAL_MS));
|
||||
}
|
||||
return resolveMapContext(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the map panel element for reuse.
|
||||
*
|
||||
* @param {?Document} doc Host document reference.
|
||||
* @param {Object} options Optional overrides.
|
||||
* @returns {?HTMLElement} Map panel element.
|
||||
*/
|
||||
function resolveMapPanel(doc, options) {
|
||||
if (options.mapPanel) return options.mapPanel;
|
||||
if (!doc || typeof doc.getElementById !== 'function') return null;
|
||||
return doc.getElementById('mapPanel');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the map section when no map data is available.
|
||||
*
|
||||
* @param {?HTMLElement} section Map panel section.
|
||||
* @param {?HTMLElement} statusEl Status element.
|
||||
* @returns {void}
|
||||
*/
|
||||
function hidePanel(section, statusEl) {
|
||||
if (statusEl) {
|
||||
statusEl.textContent = '';
|
||||
statusEl.hidden = true;
|
||||
}
|
||||
if (section) {
|
||||
section.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate a color channel.
|
||||
*
|
||||
* @param {number} start Start value.
|
||||
* @param {number} end End value.
|
||||
* @param {number} t Interpolation factor.
|
||||
* @returns {number} Interpolated channel value.
|
||||
*/
|
||||
function colorForDay(dayIndex, getRoleColor) {
|
||||
if (typeof getRoleColor !== 'function') return null;
|
||||
const clampedDay = Math.max(0, Math.min(dayIndex, ROLE_BY_DAY.length - 1));
|
||||
const role = ROLE_BY_DAY[clampedDay];
|
||||
return getRoleColor(role);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract usable position entries from a raw payload.
|
||||
*
|
||||
* @param {Array<Object>} positions Position payload entries.
|
||||
* @param {number} nowSec Reference timestamp in seconds.
|
||||
* @returns {Array<{ lat: number, lon: number, time: number, day: number }>} Parsed entries.
|
||||
*/
|
||||
function extractPositionEntries(positions, nowSec) {
|
||||
if (!Array.isArray(positions)) return [];
|
||||
const entries = [];
|
||||
positions.forEach(entry => {
|
||||
const lat = toFiniteNumber(entry?.latitude ?? entry?.lat);
|
||||
const lon = toFiniteNumber(entry?.longitude ?? entry?.lon ?? entry?.lng);
|
||||
if (lat == null || lon == null) return;
|
||||
const time = toFiniteNumber(entry?.position_time ?? entry?.positionTime) ??
|
||||
toFiniteNumber(entry?.rx_time ?? entry?.rxTime);
|
||||
if (time == null || time <= 0) return;
|
||||
const ageSec = Math.max(0, nowSec - time);
|
||||
const dayIndex = Math.floor(ageSec / DAY_SECONDS);
|
||||
if (dayIndex >= MAX_DAYS) return;
|
||||
entries.push({ lat, lon, time, day: dayIndex });
|
||||
});
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch position history for a node reference.
|
||||
*
|
||||
* @param {string} identifier Canonical node identifier.
|
||||
* @param {Function} fetchFn Fetch implementation.
|
||||
* @returns {Promise<Array<Object>>} Position payloads.
|
||||
*/
|
||||
async function fetchPositions(identifier, fetchFn) {
|
||||
const url = `/api/positions/${encodeURIComponent(identifier)}`;
|
||||
const response = await fetchFn(url, DEFAULT_FETCH_OPTIONS);
|
||||
if (response.status === 404) return [];
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load node positions (HTTP ${response.status})`);
|
||||
}
|
||||
const payload = await response.json();
|
||||
return Array.isArray(payload) ? payload : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the shared map panel into the overlay slot and return a restore handler.
|
||||
*
|
||||
* @param {HTMLElement} mapPanel Shared map panel element.
|
||||
* @param {HTMLElement} slot Target slot element.
|
||||
* @returns {Function} Cleanup handler restoring the original panel placement.
|
||||
*/
|
||||
function moveMapPanel(mapPanel, slot) {
|
||||
if (!mapPanel || !slot) {
|
||||
return () => {};
|
||||
}
|
||||
if (mapPanel.parentNode === slot) {
|
||||
return () => {};
|
||||
}
|
||||
const parent = mapPanel.parentNode;
|
||||
const nextSibling = mapPanel.nextSibling;
|
||||
slot.appendChild(mapPanel);
|
||||
if (mapPanel.classList) {
|
||||
mapPanel.classList.add('map-panel--embedded');
|
||||
}
|
||||
return () => {
|
||||
if (mapPanel.classList) {
|
||||
mapPanel.classList.remove('map-panel--embedded');
|
||||
}
|
||||
if (parent && typeof parent.insertBefore === 'function') {
|
||||
parent.insertBefore(mapPanel, nextSibling);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the node detail map panel using the shared map instance.
|
||||
*
|
||||
* @param {Element} root Root element containing the map panel.
|
||||
* @param {Object} reference Node reference payload.
|
||||
* @param {{
|
||||
* fetchImpl?: Function,
|
||||
* leaflet?: Object,
|
||||
* logger?: Console,
|
||||
* document?: Document,
|
||||
* mapPanel?: HTMLElement,
|
||||
* getMapContext?: Function,
|
||||
* namespace?: Object,
|
||||
* }} [options] Optional overrides.
|
||||
* @returns {Promise<Function|null>} Cleanup handler when the panel is shown.
|
||||
*/
|
||||
export async function initializeNodeDetailMapPanel(root, reference, options = {}) {
|
||||
const section = root?.querySelector?.('[data-node-map-panel]') ?? null;
|
||||
const slot = root?.querySelector?.('[data-node-map-slot]') ?? null;
|
||||
if (!section || !slot) return null;
|
||||
|
||||
const statusEl = root?.querySelector?.('[data-node-map-status]') ?? null;
|
||||
const identifier = resolveReferenceId(reference);
|
||||
if (!identifier) {
|
||||
hidePanel(section, statusEl);
|
||||
return null;
|
||||
}
|
||||
|
||||
const fetchFn = typeof options.fetchImpl === 'function' ? options.fetchImpl : globalThis.fetch;
|
||||
if (typeof fetchFn !== 'function') {
|
||||
hidePanel(section, statusEl);
|
||||
return null;
|
||||
}
|
||||
|
||||
const mapPanel = resolveMapPanel(options.document ?? globalThis.document, options);
|
||||
if (!mapPanel) {
|
||||
hidePanel(section, statusEl);
|
||||
return null;
|
||||
}
|
||||
const { map, leaflet, layers } = await waitForMapContext(options);
|
||||
if (!map || !leaflet) {
|
||||
hidePanel(section, statusEl);
|
||||
return null;
|
||||
}
|
||||
|
||||
let restorePanel = null;
|
||||
let restoreFitBounds = null;
|
||||
try {
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const positions = await fetchPositions(identifier, fetchFn);
|
||||
const entries = extractPositionEntries(positions, nowSec);
|
||||
if (entries.length === 0) {
|
||||
hidePanel(section, statusEl);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (statusEl) {
|
||||
statusEl.hidden = false;
|
||||
statusEl.textContent = `${entries.length} position${entries.length === 1 ? '' : 's'}`;
|
||||
}
|
||||
section.hidden = false;
|
||||
|
||||
restorePanel = moveMapPanel(mapPanel, slot);
|
||||
const fitBoundsEl = options.document?.getElementById?.('fitBounds') ?? null;
|
||||
if (fitBoundsEl && typeof fitBoundsEl.checked === 'boolean') {
|
||||
const previous = fitBoundsEl.checked;
|
||||
fitBoundsEl.checked = false;
|
||||
restoreFitBounds = () => {
|
||||
fitBoundsEl.checked = previous;
|
||||
};
|
||||
}
|
||||
|
||||
const prevCenter = typeof map.getCenter === 'function' ? map.getCenter() : null;
|
||||
const prevZoom = typeof map.getZoom === 'function' ? map.getZoom() : null;
|
||||
|
||||
const getRoleColor =
|
||||
typeof options.getRoleColor === 'function'
|
||||
? options.getRoleColor
|
||||
: options.namespace?.getRoleColor ?? globalThis.PotatoMesh?.getRoleColor;
|
||||
const latest = entries.reduce((acc, entry) => (entry.time > acc.time ? entry : acc), entries[0]);
|
||||
const latestColor = colorForDay(latest.day, getRoleColor) ?? '#2b6cb0';
|
||||
|
||||
const ordered = entries.slice().sort((a, b) => b.time - a.time);
|
||||
const pathLatLngs = ordered.map(entry => [entry.lat, entry.lon]);
|
||||
|
||||
const layerGroup = leaflet.layerGroup().addTo(map);
|
||||
if (layers?.markersLayer && typeof layers.markersLayer.clearLayers === 'function') {
|
||||
layers.markersLayer.clearLayers();
|
||||
}
|
||||
if (layers?.neighborLinesLayer && typeof layers.neighborLinesLayer.clearLayers === 'function') {
|
||||
layers.neighborLinesLayer.clearLayers();
|
||||
}
|
||||
if (layers?.traceLinesLayer && typeof layers.traceLinesLayer.clearLayers === 'function') {
|
||||
layers.traceLinesLayer.clearLayers();
|
||||
}
|
||||
const polyline = leaflet.polyline(pathLatLngs, {
|
||||
color: latestColor,
|
||||
weight: 2,
|
||||
opacity: 0.42,
|
||||
className: 'neighbor-connection-line node-detail-path-line'
|
||||
});
|
||||
polyline.addTo(layerGroup);
|
||||
|
||||
ordered.forEach(entry => {
|
||||
const color = colorForDay(entry.day, getRoleColor) ?? '#2b6cb0';
|
||||
const marker = leaflet.circleMarker([entry.lat, entry.lon], {
|
||||
radius: 9,
|
||||
color: '#000',
|
||||
weight: 1,
|
||||
fillColor: color,
|
||||
fillOpacity: 0.7,
|
||||
opacity: 0.7
|
||||
});
|
||||
marker.addTo(layerGroup);
|
||||
});
|
||||
|
||||
if (pathLatLngs.length > 1 && typeof map.fitBounds === 'function') {
|
||||
map.fitBounds(pathLatLngs, { padding: MAP_PADDING, maxZoom: MAX_FIT_ZOOM });
|
||||
} else if (typeof map.setView === 'function') {
|
||||
map.setView(pathLatLngs[pathLatLngs.length - 1], DEFAULT_ZOOM);
|
||||
}
|
||||
|
||||
if (typeof map.invalidateSize === 'function') {
|
||||
map.invalidateSize(true);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (layerGroup && typeof map.removeLayer === 'function') {
|
||||
map.removeLayer(layerGroup);
|
||||
} else if (layerGroup && typeof layerGroup.remove === 'function') {
|
||||
layerGroup.remove();
|
||||
}
|
||||
restorePanel();
|
||||
if (restoreFitBounds) {
|
||||
restoreFitBounds();
|
||||
}
|
||||
if (prevCenter && typeof map.setView === 'function') {
|
||||
map.setView(prevCenter, prevZoom ?? map.getZoom?.());
|
||||
}
|
||||
if (typeof map.invalidateSize === 'function') {
|
||||
map.invalidateSize(true);
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
if (restorePanel) {
|
||||
restorePanel();
|
||||
}
|
||||
if (restoreFitBounds) {
|
||||
restoreFitBounds();
|
||||
}
|
||||
if (options.logger && typeof options.logger.error === 'function') {
|
||||
options.logger.error('Failed to load node positions', error);
|
||||
}
|
||||
hidePanel(section, statusEl);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const __testUtils = {
|
||||
toFiniteNumber,
|
||||
resolveReferenceId,
|
||||
extractPositionEntries,
|
||||
colorForDay,
|
||||
ROLE_BY_DAY,
|
||||
};
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import { fetchNodeDetailHtml } from './node-page.js';
|
||||
import { initializeNodeDetailMapPanel } from './node-detail-map.js';
|
||||
|
||||
/**
|
||||
* Escape a string for safe HTML injection.
|
||||
@@ -106,6 +107,7 @@ export function createNodeDetailOverlayManager(options = {}) {
|
||||
let lastTrigger = null;
|
||||
let isVisible = false;
|
||||
let keydownHandler = null;
|
||||
let mapCleanup = null;
|
||||
|
||||
function lockBodyScroll(lock) {
|
||||
if (!documentRef.body || !documentRef.body.style) {
|
||||
@@ -154,6 +156,10 @@ export function createNodeDetailOverlayManager(options = {}) {
|
||||
lockBodyScroll(false);
|
||||
detachKeydown();
|
||||
requestToken += 1;
|
||||
if (typeof mapCleanup === 'function') {
|
||||
mapCleanup();
|
||||
mapCleanup = null;
|
||||
}
|
||||
const trigger = lastTrigger;
|
||||
lastTrigger = null;
|
||||
if (trigger && typeof trigger.focus === 'function') {
|
||||
@@ -203,11 +209,21 @@ export function createNodeDetailOverlayManager(options = {}) {
|
||||
refreshImpl,
|
||||
renderShortHtml,
|
||||
privateMode,
|
||||
includeMapPanel: true,
|
||||
});
|
||||
if (currentToken !== requestToken) {
|
||||
return;
|
||||
}
|
||||
content.innerHTML = html;
|
||||
if (typeof mapCleanup === 'function') {
|
||||
mapCleanup();
|
||||
mapCleanup = null;
|
||||
}
|
||||
mapCleanup = await initializeNodeDetailMapPanel(content, reference, {
|
||||
fetchImpl,
|
||||
logger,
|
||||
document: documentRef,
|
||||
});
|
||||
if (typeof closeButton.focus === 'function') {
|
||||
closeButton.focus();
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
fmtTemperature,
|
||||
fmtTx,
|
||||
} from './short-info-telemetry.js';
|
||||
import { initializeNodeDetailMapPanel } from './node-detail-map.js';
|
||||
|
||||
const DEFAULT_FETCH_OPTIONS = Object.freeze({ cache: 'no-store' });
|
||||
const MESSAGE_LIMIT = 50;
|
||||
@@ -2294,10 +2295,11 @@ function renderTraceroutes(traces, renderShortHtml, { roleIndex = null, node = n
|
||||
*
|
||||
* @param {Object} node Normalised node payload.
|
||||
* @param {{
|
||||
* neighbors?: Array<Object>,
|
||||
* messages?: Array<Object>,
|
||||
* neighbors?: Array<Object>,
|
||||
* messages?: Array<Object>,
|
||||
* traces?: Array<Object>,
|
||||
* renderShortHtml: Function,
|
||||
* includeMapPanel?: boolean,
|
||||
* renderShortHtml: Function,
|
||||
* }} options Rendering options.
|
||||
* @returns {string} HTML fragment representing the detail view.
|
||||
*/
|
||||
@@ -2305,6 +2307,7 @@ function renderNodeDetailHtml(node, {
|
||||
neighbors = [],
|
||||
messages = [],
|
||||
traces = [],
|
||||
includeMapPanel = false,
|
||||
renderShortHtml,
|
||||
roleIndex = null,
|
||||
chartNowMs = Date.now(),
|
||||
@@ -2324,6 +2327,17 @@ function renderNodeDetailHtml(node, {
|
||||
const neighborsHtml = renderNeighborGroups(node, neighbors, renderShortHtml, { roleIndex });
|
||||
const tracesHtml = renderTraceroutes(traces, renderShortHtml, { roleIndex, node });
|
||||
const messagesHtml = renderMessages(messages, renderShortHtml, node);
|
||||
const mapPanelHtml = includeMapPanel
|
||||
? `
|
||||
<section class="node-detail__section node-detail__map-panel" data-node-map-panel>
|
||||
<div class="node-detail__map-header">
|
||||
<h3>Position history</h3>
|
||||
<span class="node-detail__map-status" data-node-map-status>Loading positions…</span>
|
||||
</div>
|
||||
<div class="node-detail__map-slot" data-node-map-slot></div>
|
||||
</section>
|
||||
`
|
||||
: '';
|
||||
|
||||
const sections = [];
|
||||
if (neighborsHtml) {
|
||||
@@ -2346,6 +2360,7 @@ function renderNodeDetailHtml(node, {
|
||||
<header class="node-detail__header">
|
||||
<h2 class="node-detail__title">${badgeHtml}${nameHtml}${identifierHtml}</h2>
|
||||
</header>
|
||||
${mapPanelHtml}
|
||||
${chartsHtml ?? ''}
|
||||
${tableSection}
|
||||
${contentHtml}
|
||||
@@ -2467,6 +2482,7 @@ async function fetchTracesForNode(identifier, { fetchImpl } = {}) {
|
||||
* fetchImpl?: Function,
|
||||
* refreshImpl?: Function,
|
||||
* renderShortHtml?: Function,
|
||||
* includeMapPanel?: boolean,
|
||||
* }} options Optional overrides for testing.
|
||||
* @returns {Promise<boolean>} ``true`` when the node was rendered successfully.
|
||||
*/
|
||||
@@ -2504,6 +2520,7 @@ export async function fetchNodeDetailHtml(referenceData, options = {}) {
|
||||
traces,
|
||||
renderShortHtml,
|
||||
roleIndex,
|
||||
includeMapPanel: options.includeMapPanel === true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2548,8 +2565,13 @@ export async function initializeNodeDetailPage(options = {}) {
|
||||
refreshImpl,
|
||||
renderShortHtml: options.renderShortHtml,
|
||||
privateMode,
|
||||
includeMapPanel: true,
|
||||
});
|
||||
root.innerHTML = html;
|
||||
await initializeNodeDetailMapPanel(root, referenceData, {
|
||||
fetchImpl: options.fetchImpl,
|
||||
document: documentRef,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to render node detail page', error);
|
||||
|
||||
@@ -1199,6 +1199,61 @@ body.dark .node-detail-overlay__close:hover {
|
||||
margin: 12px 0 24px;
|
||||
}
|
||||
|
||||
.node-detail__map-panel {
|
||||
padding: 0 20px;
|
||||
margin: 12px 0 24px;
|
||||
}
|
||||
|
||||
.node-detail__map-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.node-detail__map-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.node-detail__map-staging {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.node-detail__map-status {
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.node-detail__map-slot .map-panel {
|
||||
height: 220px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.map-panel--embedded {
|
||||
flex: none;
|
||||
height: 220px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.map-panel--embedded #map {
|
||||
height: 220px;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.map-panel--embedded #mapLegend {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.map-panel--embedded .map-toolbar {
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
.node-detail__charts-grid {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
|
||||
@@ -39,6 +39,9 @@
|
||||
<p class="node-detail__noscript">This page requires JavaScript to display node information.</p>
|
||||
</noscript>
|
||||
</section>
|
||||
<div class="node-detail__map-staging" aria-hidden="true">
|
||||
<%= erb :"shared/_map_panel", locals: { full_screen: false, legend_collapsed: true } %>
|
||||
</div>
|
||||
<script type="module">
|
||||
import { initializeNodeDetailPage } from '/assets/js/app/node-page.js';
|
||||
initializeNodeDetailPage();
|
||||
|
||||
Reference in New Issue
Block a user