mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-07 01:13:01 +02:00
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:
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
Reference in New Issue
Block a user