From 9a454303210b8f75106979d423c5608bce3a5d8f Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Thu, 13 Nov 2025 17:23:35 +0100 Subject: [PATCH] Enable map centering from node table coordinates (#439) * Enable map centering from node table coordinates * Replace node coordinate buttons with links --- .../__tests__/nodes-coordinate-links.test.js | 119 ++++++++++++++++++ .../js/app/__tests__/nodes-map-focus.test.js | 112 +++++++++++++++++ web/public/assets/js/app/main.js | 44 ++++++- .../assets/js/app/nodes-coordinate-links.js | 105 ++++++++++++++++ web/public/assets/js/app/nodes-map-focus.js | 119 ++++++++++++++++++ 5 files changed, 496 insertions(+), 3 deletions(-) create mode 100644 web/public/assets/js/app/__tests__/nodes-coordinate-links.test.js create mode 100644 web/public/assets/js/app/__tests__/nodes-map-focus.test.js create mode 100644 web/public/assets/js/app/nodes-coordinate-links.js create mode 100644 web/public/assets/js/app/nodes-map-focus.js diff --git a/web/public/assets/js/app/__tests__/nodes-coordinate-links.test.js b/web/public/assets/js/app/__tests__/nodes-coordinate-links.test.js new file mode 100644 index 0000000..9d66226 --- /dev/null +++ b/web/public/assets/js/app/__tests__/nodes-coordinate-links.test.js @@ -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 { enhanceCoordinateCell, __testUtils } from '../nodes-coordinate-links.js'; + +const { toFiniteCoordinate } = __testUtils; + +test('enhanceCoordinateCell renders an interactive link for valid coordinates', () => { + const cell = { + replacedChildren: null, + replaceChildren(...children) { + this.replacedChildren = children; + } + }; + const linkStub = { + dataset: {}, + attributes: new Map(), + listeners: new Map(), + href: null, + setAttribute(name, value) { + this.attributes.set(name, value); + }, + addEventListener(name, handler) { + this.listeners.set(name, handler); + } + }; + const documentStub = { + createElement(tagName) { + assert.equal(tagName, 'a'); + return linkStub; + } + }; + const activations = []; + const link = enhanceCoordinateCell({ + cell, + document: documentStub, + displayText: '51.50000', + formattedLatitude: '51.50000', + formattedLongitude: '-0.12000', + lat: '51.5', + lon: '-0.12', + nodeName: 'Alpha', + onActivate: (lat, lon) => activations.push({ lat, lon }) + }); + + assert.equal(link, linkStub); + assert.deepEqual(cell.replacedChildren, [linkStub]); + assert.equal(linkStub.textContent, '51.50000'); + assert.equal(linkStub.dataset.lat, '51.5'); + assert.equal(linkStub.dataset.lon, '-0.12'); + assert.equal(linkStub.className, 'nodes-coordinate-link'); + assert.equal(linkStub.attributes.get('aria-label'), 'Center map on Alpha at 51.50000, -0.12000'); + assert.equal(linkStub.attributes.get('href'), '#'); + + const clickHandler = linkStub.listeners.get('click'); + assert.equal(typeof clickHandler, 'function'); + const event = { + prevented: false, + stopped: false, + preventDefault() { + this.prevented = true; + }, + stopPropagation() { + this.stopped = true; + } + }; + clickHandler(event); + assert.equal(event.prevented, true); + assert.equal(event.stopped, true); + assert.deepEqual(activations, [{ lat: 51.5, lon: -0.12 }]); +}); + +test('enhanceCoordinateCell ignores invalid input data', () => { + const cell = { + replaceChildren() { + assert.fail('replaceChildren should not be called for invalid data'); + } + }; + const resultEmpty = enhanceCoordinateCell({ + cell, + document: {}, + displayText: '', + lat: 0, + lon: 0 + }); + assert.equal(resultEmpty, null); + + const resultInvalid = enhanceCoordinateCell({ + cell, + document: {}, + displayText: 'value', + lat: 'north', + lon: 5 + }); + assert.equal(resultInvalid, null); +}); + +test('toFiniteCoordinate returns finite numbers and rejects NaN', () => { + assert.equal(toFiniteCoordinate('12.34'), 12.34); + assert.equal(toFiniteCoordinate(56.78), 56.78); + assert.equal(toFiniteCoordinate('NaN'), null); + assert.equal(toFiniteCoordinate(undefined), null); +}); diff --git a/web/public/assets/js/app/__tests__/nodes-map-focus.test.js b/web/public/assets/js/app/__tests__/nodes-map-focus.test.js new file mode 100644 index 0000000..92f7e1b --- /dev/null +++ b/web/public/assets/js/app/__tests__/nodes-map-focus.test.js @@ -0,0 +1,112 @@ +/* + * 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 { createMapFocusHandler, DEFAULT_NODE_FOCUS_ZOOM, __testUtils } from '../nodes-map-focus.js'; + +const { toFiniteCoordinate } = __testUtils; + +test('createMapFocusHandler recentres the map using Leaflet setView', () => { + let interactions = 0; + const autoFitController = { + handleUserInteraction() { + interactions += 1; + } + }; + const map = { + calls: [], + setView(target, zoom, options) { + this.calls.push({ target, zoom, options }); + } + }; + const centers = []; + const handler = createMapFocusHandler({ + getMap: () => map, + autoFitController, + leaflet: { + latLng(lat, lon) { + return { lat, lng: lon, source: 'leaflet' }; + } + }, + defaultZoom: 11, + setMapCenter: value => centers.push(value) + }); + + const result = handler('51.5', '-0.12'); + + assert.equal(result, true); + assert.equal(interactions, 1); + assert.equal(map.calls.length, 1); + assert.deepEqual(map.calls[0], { target: [51.5, -0.12], zoom: 11, options: { animate: true } }); + assert.deepEqual(centers, [{ lat: 51.5, lng: -0.12, source: 'leaflet' }]); +}); + +test('createMapFocusHandler supports panTo fallback and numeric centres', () => { + const panCalls = []; + const zoomCalls = []; + const map = { + panTo(target, options) { + panCalls.push({ target, options }); + }, + setZoom(value) { + zoomCalls.push(value); + } + }; + const centers = []; + const handler = createMapFocusHandler({ + getMap: () => map, + leaflet: { + latLng() { + throw new Error('Leaflet latLng unavailable'); + } + }, + defaultZoom: DEFAULT_NODE_FOCUS_ZOOM, + setMapCenter: value => centers.push(value) + }); + + const result = handler(40.7128, -74.006, { zoom: 9, animate: false }); + + assert.equal(result, true); + assert.deepEqual(panCalls, [{ target: [40.7128, -74.006], options: { animate: false } }]); + assert.deepEqual(zoomCalls, [9]); + assert.deepEqual(centers, [{ lat: 40.7128, lon: -74.006 }]); +}); + +test('createMapFocusHandler validates inputs and map availability', () => { + assert.throws(() => { + createMapFocusHandler({ getMap: null }); + }, /getMap/); + + const missingMapHandler = createMapFocusHandler({ getMap: () => null }); + assert.equal(missingMapHandler(10, 20), false); + + const map = { + setView() {} + }; + const handler = createMapFocusHandler({ getMap: () => map }); + assert.equal(handler(null, 2), false); + assert.equal(handler(1, undefined), false); + assert.equal(handler(1, 2, { zoom: -5 }), false); +}); + +test('toFiniteCoordinate converts valid strings and rejects invalid values', () => { + assert.equal(toFiniteCoordinate('42.5'), 42.5); + assert.equal(toFiniteCoordinate(19), 19); + assert.equal(toFiniteCoordinate('abc'), null); + assert.equal(toFiniteCoordinate(null), null); +}); diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js index e01dd36..9eb4636 100644 --- a/web/public/assets/js/app/main.js +++ b/web/public/assets/js/app/main.js @@ -18,6 +18,8 @@ import { computeBoundingBox, computeBoundsForPoints, haversineDistanceKm } from import { createMapAutoFitController } from './map-auto-fit-controller.js'; import { resolveAutoFitBoundsConfig } from './map-auto-fit-settings.js'; import { attachNodeInfoRefreshToMarker, overlayToPopupNode } from './map-marker-node-info.js'; +import { createMapFocusHandler, DEFAULT_NODE_FOCUS_ZOOM } from './nodes-map-focus.js'; +import { enhanceCoordinateCell } from './nodes-coordinate-links.js'; import { createShortInfoOverlayStack } from './short-info-overlay-manager.js'; import { refreshNodeInformation } from './node-details.js'; import { extractModemMetadata, formatModemDisplay } from './node-modem-metadata.js'; @@ -421,6 +423,16 @@ let messagesById = new Map(); defaultPaddingPx: AUTO_FIT_PADDING_PX }); + const focusMapOnCoordinates = createMapFocusHandler({ + getMap: () => map, + autoFitController, + leaflet: hasLeaflet ? window.L : null, + defaultZoom: DEFAULT_NODE_FOCUS_ZOOM, + setMapCenter: value => { + mapCenterLatLng = value; + } + }); + /** * Fit the Leaflet map to the provided geographic bounds. * @@ -3306,7 +3318,7 @@ let messagesById = new Map(); * @returns {number|null} Distance in kilometres. */ function distanceFromCenterKm(lat, lon) { - if (hasLeaflet && mapCenterLatLng) { + if (hasLeaflet && mapCenterLatLng && typeof mapCenterLatLng.distanceTo === 'function') { try { return L.latLng(lat, lon).distanceTo(mapCenterLatLng) / 1000; } catch (err) { @@ -3358,6 +3370,9 @@ let messagesById = new Map(); const tr = document.createElement('tr'); const lastPositionTime = toFiniteNumber(n.position_time ?? n.positionTime); const lastPositionCell = lastPositionTime != null ? timeAgo(lastPositionTime, nowSec) : ''; + const latitudeDisplay = fmtCoords(n.latitude); + const longitudeDisplay = fmtCoords(n.longitude); + const nodeDisplayName = getNodeDisplayNameForOverlay(n); tr.innerHTML = ` ${n.node_id || ""} ${renderShortHtml(n.short_name, n.role, n.long_name, n)} @@ -3373,10 +3388,33 @@ let messagesById = new Map(); ${fmtTemperature(n.temperature)} ${fmtHumidity(n.relative_humidity)} ${fmtPressure(n.barometric_pressure)} - ${fmtCoords(n.latitude)} - ${fmtCoords(n.longitude)} + ${latitudeDisplay} + ${longitudeDisplay} ${fmtAlt(n.altitude, "m")} ${lastPositionCell}`; + + enhanceCoordinateCell({ + cell: tr.querySelector('.nodes-col--latitude'), + document, + displayText: latitudeDisplay, + formattedLatitude: latitudeDisplay, + formattedLongitude: longitudeDisplay, + lat: n.latitude, + lon: n.longitude, + nodeName: nodeDisplayName, + onActivate: focusMapOnCoordinates + }); + enhanceCoordinateCell({ + cell: tr.querySelector('.nodes-col--longitude'), + document, + displayText: longitudeDisplay, + formattedLatitude: latitudeDisplay, + formattedLongitude: longitudeDisplay, + lat: n.latitude, + lon: n.longitude, + nodeName: nodeDisplayName, + onActivate: focusMapOnCoordinates + }); frag.appendChild(tr); } tb.replaceChildren(frag); diff --git a/web/public/assets/js/app/nodes-coordinate-links.js b/web/public/assets/js/app/nodes-coordinate-links.js new file mode 100644 index 0000000..258f331 --- /dev/null +++ b/web/public/assets/js/app/nodes-coordinate-links.js @@ -0,0 +1,105 @@ +/* + * 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. + */ + +/** + * Convert raw values to finite numeric coordinates when possible. + * + * @param {*} value Raw coordinate value. + * @returns {number|null} Parsed coordinate or ``null`` when invalid. + */ +function toFiniteCoordinate(value) { + if (value == null || value === '') return null; + const num = typeof value === 'number' ? value : Number(value); + return Number.isFinite(num) ? num : null; +} + +/** + * Enhance a table cell so that it contains a clickable link capable of + * focusing the map on the provided coordinates. + * + * @param {{ + * cell: { replaceChildren?: Function } | null, + * document: { createElement: Function } | Document, + * displayText: string, + * formattedLatitude?: string, + * formattedLongitude?: string, + * lat: *, + * lon: *, + * nodeName?: string, + * onActivate?: (lat: number, lon: number) => boolean | void, + * linkClassName?: string + * }} options Enhancement configuration. + * @returns {HTMLElement|null} The created link when enhancement succeeds. + */ +export function enhanceCoordinateCell({ + cell, + document, + displayText, + formattedLatitude, + formattedLongitude, + lat, + lon, + nodeName, + onActivate, + linkClassName = 'nodes-coordinate-link' +}) { + if (!cell || typeof cell.replaceChildren !== 'function') return null; + if (!displayText) return null; + const latNum = toFiniteCoordinate(lat); + const lonNum = toFiniteCoordinate(lon); + if (latNum == null || lonNum == null) return null; + const doc = document && typeof document.createElement === 'function' ? document : null; + if (!doc) return null; + + const link = doc.createElement('a'); + link.className = linkClassName; + link.textContent = displayText; + if (typeof link.setAttribute === 'function') { + link.setAttribute('href', '#'); + } else { + link.href = '#'; + } + if (!link.dataset) link.dataset = {}; + link.dataset.lat = String(latNum); + link.dataset.lon = String(lonNum); + + const coordsSummary = [formattedLatitude, formattedLongitude].filter(Boolean).join(', '); + const displayName = nodeName ? String(nodeName) : 'node'; + const ariaLabelBase = `Center map on ${displayName}`; + const ariaLabel = coordsSummary ? `${ariaLabelBase} at ${coordsSummary}` : ariaLabelBase; + if (typeof link.setAttribute === 'function') { + link.setAttribute('aria-label', ariaLabel); + } + + link.addEventListener('click', event => { + if (event && typeof event.preventDefault === 'function') { + event.preventDefault(); + } + if (event && typeof event.stopPropagation === 'function') { + event.stopPropagation(); + } + if (typeof onActivate === 'function') { + onActivate(latNum, lonNum); + } + }); + + cell.replaceChildren(link); + return link; +} + +export const __testUtils = { + toFiniteCoordinate +}; diff --git a/web/public/assets/js/app/nodes-map-focus.js b/web/public/assets/js/app/nodes-map-focus.js new file mode 100644 index 0000000..86cf685 --- /dev/null +++ b/web/public/assets/js/app/nodes-map-focus.js @@ -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. + */ + +/** + * Default zoom level used when focusing the map on a specific node. + * + * @type {number} + */ +export const DEFAULT_NODE_FOCUS_ZOOM = 15; + +/** + * Convert arbitrary values to finite coordinates when possible. + * + * @param {*} value Raw coordinate value. + * @returns {number|null} Parsed coordinate or ``null`` when invalid. + */ +function toFiniteCoordinate(value) { + if (value == null || value === '') return null; + const num = typeof value === 'number' ? value : Number(value); + return Number.isFinite(num) ? num : null; +} + +/** + * Build a handler that recentres a map instance on a set of coordinates. + * + * @param {{ + * getMap: () => ({ + * setView?: Function, + * flyTo?: Function, + * panTo?: Function, + * setZoom?: Function + * }) | null, + * autoFitController?: { handleUserInteraction?: Function } | null, + * leaflet?: { latLng?: Function } | null, + * defaultZoom?: number, + * setMapCenter?: (value: unknown) => void + * }} dependencies External services used to reposition the map. + * @returns {(lat: *, lon: *, options?: { zoom?: number, animate?: boolean }) => boolean} + * Map focusing function returning ``true`` when the view changed. + */ +export function createMapFocusHandler({ + getMap, + autoFitController = null, + leaflet = null, + defaultZoom = DEFAULT_NODE_FOCUS_ZOOM, + setMapCenter = () => {} +}) { + if (typeof getMap !== 'function') { + throw new TypeError('getMap must be a function that returns the active map instance.'); + } + + const autoFit = autoFitController && typeof autoFitController.handleUserInteraction === 'function' + ? autoFitController + : null; + const leafletApi = leaflet && typeof leaflet.latLng === 'function' ? leaflet : null; + const zoomDefault = Number.isFinite(defaultZoom) && defaultZoom > 0 ? defaultZoom : DEFAULT_NODE_FOCUS_ZOOM; + const updateCenter = typeof setMapCenter === 'function' ? setMapCenter : () => {}; + + return (lat, lon, options = {}) => { + const map = getMap(); + if (!map) return false; + + const latNum = toFiniteCoordinate(lat); + const lonNum = toFiniteCoordinate(lon); + if (latNum == null || lonNum == null) return false; + + const zoomCandidate = toFiniteCoordinate(options.zoom); + const zoom = zoomCandidate != null ? zoomCandidate : zoomDefault; + if (!Number.isFinite(zoom) || zoom <= 0) return false; + + if (autoFit) { + autoFit.handleUserInteraction(); + } + + const target = [latNum, lonNum]; + const animate = options.animate !== false; + if (typeof map.setView === 'function') { + map.setView(target, zoom, { animate }); + } else if (typeof map.flyTo === 'function') { + map.flyTo(target, zoom, { animate }); + } else if (typeof map.panTo === 'function') { + map.panTo(target, { animate }); + if (typeof map.setZoom === 'function') { + map.setZoom(zoom); + } + } else { + return false; + } + + if (leafletApi) { + try { + const latLng = leafletApi.latLng(latNum, lonNum); + updateCenter(latLng); + return true; + } catch (error) { + // Fall through to the numeric fallback below when Leaflet rejects the coordinates. + } + } + updateCenter({ lat: latNum, lon: lonNum }); + return true; + }; +} + +export const __testUtils = { + toFiniteCoordinate +};