diff --git a/web/public/assets/js/app/__tests__/node-detail-map.test.js b/web/public/assets/js/app/__tests__/node-detail-map.test.js new file mode 100644 index 00000000..523b883c --- /dev/null +++ b/web/public/assets/js/app/__tests__/node-detail-map.test.js @@ -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; + } +}); diff --git a/web/public/assets/js/app/__tests__/node-page.test.js b/web/public/assets/js/app/__tests__/node-page.test.js index 7779de0f..c67491dc 100644 --- a/web/public/assets/js/app/__tests__/node-page.test.js +++ b/web/public/assets/js/app/__tests__/node-page.test.js @@ -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 => `${short}`, + 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 = []; diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js index 8bff32db..cb280ba5 100644 --- a/web/public/assets/js/app/main.js +++ b/web/public/assets/js/app/main.js @@ -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(); } diff --git a/web/public/assets/js/app/node-detail-map.js b/web/public/assets/js/app/node-detail-map.js new file mode 100644 index 00000000..bbda062e --- /dev/null +++ b/web/public/assets/js/app/node-detail-map.js @@ -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} 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>} 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} 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, +}; diff --git a/web/public/assets/js/app/node-detail-overlay.js b/web/public/assets/js/app/node-detail-overlay.js index 22f4abe1..fe16bbdc 100644 --- a/web/public/assets/js/app/node-detail-overlay.js +++ b/web/public/assets/js/app/node-detail-overlay.js @@ -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(); } diff --git a/web/public/assets/js/app/node-page.js b/web/public/assets/js/app/node-page.js index e31288af..60e34b55 100644 --- a/web/public/assets/js/app/node-page.js +++ b/web/public/assets/js/app/node-page.js @@ -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, - * messages?: Array, + * neighbors?: Array, + * messages?: Array, * traces?: Array, - * 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 + ? ` +
+
+

Position history

+ Loading positions… +
+
+
+ ` + : ''; const sections = []; if (neighborsHtml) { @@ -2346,6 +2360,7 @@ function renderNodeDetailHtml(node, {

${badgeHtml}${nameHtml}${identifierHtml}

+ ${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} ``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); diff --git a/web/public/assets/styles/base.css b/web/public/assets/styles/base.css index d44e2e46..b1d80849 100644 --- a/web/public/assets/styles/base.css +++ b/web/public/assets/styles/base.css @@ -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; diff --git a/web/views/node_detail.erb b/web/views/node_detail.erb index 2e6ba33a..a4e18a1c 100644 --- a/web/views/node_detail.erb +++ b/web/views/node_detail.erb @@ -39,6 +39,9 @@

This page requires JavaScript to display node information.

+