Enable map centering from node table coordinates (#439)

* Enable map centering from node table coordinates

* Replace node coordinate buttons with links
This commit is contained in:
l5y
2025-11-13 17:23:35 +01:00
committed by GitHub
parent cb843d5774
commit 9a45430321
5 changed files with 496 additions and 3 deletions
@@ -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);
});
@@ -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);
});
+41 -3
View File
@@ -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 = `
<td class="mono nodes-col nodes-col--node-id">${n.node_id || ""}</td>
<td class="nodes-col nodes-col--short-name">${renderShortHtml(n.short_name, n.role, n.long_name, n)}</td>
@@ -3373,10 +3388,33 @@ let messagesById = new Map();
<td class="nodes-col nodes-col--temperature">${fmtTemperature(n.temperature)}</td>
<td class="nodes-col nodes-col--humidity">${fmtHumidity(n.relative_humidity)}</td>
<td class="nodes-col nodes-col--pressure">${fmtPressure(n.barometric_pressure)}</td>
<td class="nodes-col nodes-col--latitude">${fmtCoords(n.latitude)}</td>
<td class="nodes-col nodes-col--longitude">${fmtCoords(n.longitude)}</td>
<td class="nodes-col nodes-col--latitude">${latitudeDisplay}</td>
<td class="nodes-col nodes-col--longitude">${longitudeDisplay}</td>
<td class="nodes-col nodes-col--altitude">${fmtAlt(n.altitude, "m")}</td>
<td class="mono nodes-col nodes-col--last-position">${lastPositionCell}</td>`;
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);
@@ -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
};
+119
View File
@@ -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
};