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 = `