From e4c48682b056e3dc5627a366398d2e292bc35a70 Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Sun, 12 Oct 2025 19:22:17 +0200 Subject: [PATCH] Fix map initialization bounds and add coverage (#305) * Fix map initialization bounds and add coverage * Handle antimeridian bounds when clustering map points * Fix dateline-aware map bounds --- .../js/app/__tests__/map-bounds.test.js | 138 ++++++++++ web/public/assets/js/app/main.js | 86 +++--- web/public/assets/js/app/map-bounds.js | 255 ++++++++++++++++++ 3 files changed, 444 insertions(+), 35 deletions(-) create mode 100644 web/public/assets/js/app/__tests__/map-bounds.test.js create mode 100644 web/public/assets/js/app/map-bounds.js diff --git a/web/public/assets/js/app/__tests__/map-bounds.test.js b/web/public/assets/js/app/__tests__/map-bounds.test.js new file mode 100644 index 0000000..992655f --- /dev/null +++ b/web/public/assets/js/app/__tests__/map-bounds.test.js @@ -0,0 +1,138 @@ +/* + * Copyright (C) 2025 l5yth + * + * 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 { + computeBoundingBox, + computeBoundsForPoints, + haversineDistanceKm, + __testUtils +} from '../map-bounds.js'; + +const { clampLatitude, clampLongitude, normaliseRange, normaliseLongitudeAround } = __testUtils; + +function approximatelyEqual(actual, expected, epsilon = 1e-3) { + assert.ok(Math.abs(actual - expected) <= epsilon, `${actual} is not within ${epsilon} of ${expected}`); +} + +test('clamp helpers bound invalid coordinates', () => { + assert.equal(clampLatitude(120), 90); + assert.equal(clampLatitude(-95), -90); + assert.equal(clampLatitude(Number.POSITIVE_INFINITY), 90); + assert.equal(clampLatitude(Number.NEGATIVE_INFINITY), -90); + + assert.equal(clampLongitude(200), 180); + assert.equal(clampLongitude(-220), -180); + assert.equal(clampLongitude(Number.POSITIVE_INFINITY), 180); + assert.equal(clampLongitude(Number.NEGATIVE_INFINITY), -180); +}); + + +test('normaliseRange enforces minimum distance for invalid inputs', () => { + assert.equal(normaliseRange(-1, 2), 2); + assert.equal(normaliseRange(Number.NaN, 3), 3); + assert.equal(normaliseRange(0, 1), 1); + assert.equal(normaliseRange(4, 2), 4); +}); + + +test('computeBoundingBox returns null for invalid centres', () => { + assert.equal(computeBoundingBox(null, 10), null); + assert.equal(computeBoundingBox({ lat: 'x', lon: 0 }, 5), null); + assert.equal(computeBoundingBox({ lat: 0, lon: NaN }, 5), null); +}); + + +test('computeBoundingBox returns symmetric bounds for mid-latitude centre', () => { + const bounds = computeBoundingBox({ lat: 0, lon: 0 }, 10); + assert.ok(bounds); + const [[south, west], [north, east]] = bounds; + approximatelyEqual(north, -south, 1e-4); + approximatelyEqual(east, -west, 1e-4); + assert.ok(north > 0 && east > 0); +}); + + +test('computeBoundingBox clamps longitude span near the poles', () => { + const bounds = computeBoundingBox({ lat: 89.9, lon: 45 }, 2000); + assert.ok(bounds); + const [[south, west], [north, east]] = bounds; + approximatelyEqual(south, 72.0, 1e-1); + assert.equal(west, -180); + assert.equal(east, 180); + assert.equal(north, 90); +}); + + +test('haversineDistanceKm matches known city distance', () => { + // Approximate distance between Paris (48.8566, 2.3522) and Berlin (52.52, 13.4050) + const distance = haversineDistanceKm(48.8566, 2.3522, 52.52, 13.405); + approximatelyEqual(distance, 878.8, 2); +}); + + +test('computeBoundsForPoints returns null when no valid points exist', () => { + assert.equal(computeBoundsForPoints([]), null); + assert.equal(computeBoundsForPoints([[Number.NaN, 0]]), null); +}); + + +test('computeBoundsForPoints expands bounds with padding and minimum radius', () => { + const bounds = computeBoundsForPoints( + [ + [38.0, -27.1], + [38.05, -27.08] + ], + { paddingFraction: 0.2, minimumRangeKm: 2 } + ); + assert.ok(bounds); + const [[south, west], [north, east]] = bounds; + assert.ok(north > 38.05); + assert.ok(south < 38.0); + assert.ok(east > -27.08); + assert.ok(west < -27.1); +}); + + +test('computeBoundsForPoints respects the configured minimum range for single points', () => { + const bounds = computeBoundsForPoints([[12.34, 56.78]], { minimumRangeKm: 5 }); + assert.ok(bounds); + const [[south], [north]] = bounds; + assert.ok(north - south > 0.05); +}); + + +test('computeBoundsForPoints preserves tight bounds across the antimeridian', () => { + const points = [ + [10.0, 179.5], + [11.2, -179.7], + [9.5, 179.2] + ]; + const bounds = computeBoundsForPoints(points, { paddingFraction: 0.1 }); + assert.ok(bounds); + const [[south, west], [north, east]] = bounds; + assert.ok(north - south < 10, 'latitude span should remain tight'); + const lonSpan = Math.abs(east - west); + const normalizedSpan = lonSpan > 180 ? 360 - lonSpan : lonSpan; + assert.ok(normalizedSpan < 40, 'longitude span should wrap tightly around the dateline'); + for (const [, lon] of points) { + const adjustedLon = normaliseLongitudeAround(lon, (west + east) / 2); + assert.ok(adjustedLon >= west - 1e-6 && adjustedLon <= east + 1e-6, 'point longitude should lie within bounds'); + } + assert.ok(east > 180 || west < -180, 'bounds should extend beyond the canonical range when necessary'); +}); diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js index 8c07bea..d5adb28 100644 --- a/web/public/assets/js/app/main.js +++ b/web/public/assets/js/app/main.js @@ -14,6 +14,8 @@ * limitations under the License. */ +import { computeBoundingBox, computeBoundsForPoints, haversineDistanceKm } from './map-bounds.js'; + /** * Entry point for the interactive dashboard. Wires up event listeners, * initializes the map, and triggers the first data refresh cycle. @@ -278,7 +280,12 @@ export function initializeApp(config) { let tiles = null; let offlineTiles = null; let usingOfflineTiles = false; - const MAX_NODE_DISTANCE_KM = config.maxNodeDistanceKm; + const MAX_NODE_DISTANCE_KM = Number.isFinite(config.maxNodeDistanceKm) && config.maxNodeDistanceKm > 0 + ? config.maxNodeDistanceKm + : 1; + const INITIAL_VIEW_PADDING_PX = 48; + const AUTO_FIT_PADDING_PX = 56; + const MAX_INITIAL_ZOOM = 12; let neighborLinesLayer = null; let neighborLinesVisible = true; let neighborLinesToggleButton = null; @@ -292,6 +299,26 @@ export function initializeApp(config) { 'msfullscreenchange' ]; + /** + * Fit the Leaflet map to the provided geographic bounds. + * + * @param {[[number, number], [number, number]]|null} bounds Lat/lon bounds tuple. + * @param {{ animate?: boolean, paddingPx?: number, maxZoom?: number }} [options] Fit options. + * @returns {void} + */ + function fitMapToBounds(bounds, options = {}) { + if (!map || !bounds) return; + const padding = Number.isFinite(options.paddingPx) && options.paddingPx >= 0 ? options.paddingPx : 32; + const fitOptions = { + animate: Boolean(options.animate), + padding: [padding, padding] + }; + if (Number.isFinite(options.maxZoom) && options.maxZoom > 0) { + fitOptions.maxZoom = options.maxZoom; + } + map.fitBounds(bounds, fitOptions); + } + /** * Determine whether the browser supports fullscreen requests on the map container. * @@ -926,8 +953,24 @@ export function initializeApp(config) { tiles.addTo(map); observeTileContainer(tiles); - map.setView(mapCenterLatLng || [MAP_CENTER_COORDS.lat, MAP_CENTER_COORDS.lon], 10); - applyFiltersToAllTiles(); + + const initialBounds = computeBoundingBox(MAP_CENTER_COORDS, MAX_NODE_DISTANCE_KM, { minimumRangeKm: 1 }); + if (initialBounds) { + fitMapToBounds(initialBounds, { animate: false, paddingPx: INITIAL_VIEW_PADDING_PX, maxZoom: MAX_INITIAL_ZOOM }); + } else if (mapCenterLatLng) { + map.setView(mapCenterLatLng, 10); + } else { + map.setView([MAP_CENTER_COORDS.lat, MAP_CENTER_COORDS.lon], 10); + } + + if (typeof map.whenReady === 'function') { + map.whenReady(() => { + applyFiltersToAllTiles(); + refreshMapSize(); + }); + } else { + applyFiltersToAllTiles(); + } map.on('moveend', applyFiltersToAllTiles); map.on('zoomend', applyFiltersToAllTiles); @@ -2164,36 +2207,6 @@ export function initializeApp(config) { } } - /** - * Convert degrees to radians. - * - * @param {number} deg Degrees. - * @returns {number} Radians. - */ - function toRadians(deg) { - return (deg * Math.PI) / 180; - } - - /** - * Compute distance between two coordinates using the haversine formula. - * - * @param {number} lat1 Latitude of the first point. - * @param {number} lon1 Longitude of the first point. - * @param {number} lat2 Latitude of the second point. - * @param {number} lon2 Longitude of the second point. - * @returns {number} Distance in kilometres. - */ - function haversineDistanceKm(lat1, lon1, lat2, lon2) { - const R = 6371; - const dLat = toRadians(lat2 - lat1); - const dLon = toRadians(lon2 - lon1); - const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + - Math.cos(toRadians(lat1)) * Math.cos(toRadians(lat2)) * - Math.sin(dLon / 2) * Math.sin(dLon / 2); - const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); - return R * c; - } - /** * Compute distance from the configured map center. * @@ -2486,8 +2499,11 @@ export function initializeApp(config) { pts.push([lat, lon]); } if (pts.length && fitBoundsEl && fitBoundsEl.checked) { - const b = L.latLngBounds(pts); - map.fitBounds(b.pad(0.2), { animate: false }); + const bounds = computeBoundsForPoints(pts, { + paddingFraction: 0.2, + minimumRangeKm: Math.min(Math.max(MAX_NODE_DISTANCE_KM * 0.1, 1), MAX_NODE_DISTANCE_KM) + }); + fitMapToBounds(bounds, { animate: false, paddingPx: AUTO_FIT_PADDING_PX }); } } diff --git a/web/public/assets/js/app/map-bounds.js b/web/public/assets/js/app/map-bounds.js new file mode 100644 index 0000000..88baf6a --- /dev/null +++ b/web/public/assets/js/app/map-bounds.js @@ -0,0 +1,255 @@ +/* + * Copyright (C) 2025 l5yth + * + * 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 EARTH_RADIUS_KM = 6371; +const RAD_TO_DEG = 180 / Math.PI; +const DEFAULT_MIN_RANGE_KM = 0.5; +const POLE_LONGITUDE_SPAN_DEGREES = 180; +const COS_EPSILON = 1e-6; + +/** + * Clamp a latitude value to the valid WGS84 range. + * + * @param {number} latitude Latitude in degrees. + * @returns {number} Latitude clamped to [-90, 90]. + */ +function clampLatitude(latitude) { + if (!Number.isFinite(latitude)) { + return latitude < 0 ? -90 : 90; + } + return Math.max(-90, Math.min(90, latitude)); +} + +/** + * Clamp a longitude value to the valid WGS84 range. + * + * @param {number} longitude Longitude in degrees. + * @returns {number} Longitude clamped to [-180, 180]. + */ +function clampLongitude(longitude) { + if (!Number.isFinite(longitude)) { + return longitude < 0 ? -180 : 180; + } + if (longitude < -180) return -180; + if (longitude > 180) return 180; + return longitude; +} + +/** + * Normalise a longitude so it remains close to a reference meridian. + * + * @param {number} longitude Longitude in degrees to normalise. + * @param {number} referenceMeridian Reference longitude in degrees. + * @returns {number} Longitude adjusted by multiples of 360° so the + * difference from ``referenceMeridian`` lies within ``[-180, 180)``. + */ +function normaliseLongitudeAround(longitude, referenceMeridian) { + if (!Number.isFinite(longitude) || !Number.isFinite(referenceMeridian)) { + return longitude; + } + const delta = ((longitude - referenceMeridian + 540) % 360) - 180; + return referenceMeridian + delta; +} + +/** + * Convert degrees to radians. + * + * @param {number} degrees Angle in degrees. + * @returns {number} Angle in radians. + */ +export function toRadians(degrees) { + return (degrees * Math.PI) / 180; +} + +/** + * Compute the great-circle distance between two coordinates using the + * haversine formula. + * + * @param {number} lat1 Latitude of the first point in degrees. + * @param {number} lon1 Longitude of the first point in degrees. + * @param {number} lat2 Latitude of the second point in degrees. + * @param {number} lon2 Longitude of the second point in degrees. + * @returns {number} Distance in kilometres. + */ +export function haversineDistanceKm(lat1, lon1, lat2, lon2) { + const dLat = toRadians(lat2 - lat1); + const dLon = toRadians(lon2 - lon1); + const sinLat = Math.sin(dLat / 2); + const sinLon = Math.sin(dLon / 2); + const a = sinLat * sinLat + Math.cos(toRadians(lat1)) * Math.cos(toRadians(lat2)) * sinLon * sinLon; + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return EARTH_RADIUS_KM * c; +} + +/** + * Normalise range inputs to a safe, positive value. + * + * @param {number} rangeKm Requested range in kilometres. + * @param {number} minimumRangeKm Minimum permitted range in kilometres. + * @returns {number} Normalised range in kilometres. + */ +function normaliseRange(rangeKm, minimumRangeKm) { + const minRange = Number.isFinite(minimumRangeKm) && minimumRangeKm > 0 ? minimumRangeKm : DEFAULT_MIN_RANGE_KM; + if (!Number.isFinite(rangeKm) || rangeKm <= 0) { + return minRange; + } + return Math.max(rangeKm, minRange); +} + +/** + * Compute a geographic bounding box for a circular range centred on a point. + * + * The resulting bounds are suitable for use with Leaflet ``fitBounds`` and + * similar APIs that accept a ``[[south, west], [north, east]]`` tuple. + * + * @param {{lat: number, lon: number}} center Map centre coordinate. + * @param {number} rangeKm Desired radius from the centre in kilometres. + * @param {{ minimumRangeKm?: number }} [options] Optional configuration. + * @returns {[[number, number], [number, number]] | null} Bounding box tuple or + * ``null`` when the inputs are invalid. + */ +export function computeBoundingBox(center, rangeKm, options = {}) { + if (!center || !Number.isFinite(center.lat) || !Number.isFinite(center.lon)) { + return null; + } + const minRange = Number.isFinite(options.minimumRangeKm) && options.minimumRangeKm > 0 + ? options.minimumRangeKm + : DEFAULT_MIN_RANGE_KM; + const radiusKm = normaliseRange(rangeKm, minRange); + const angularDistance = radiusKm / EARTH_RADIUS_KM; + const latDelta = angularDistance * RAD_TO_DEG; + const minLat = clampLatitude(center.lat - latDelta); + const maxLat = clampLatitude(center.lat + latDelta); + + const cosLat = Math.cos(toRadians(center.lat)); + let lonDelta; + if (Math.abs(cosLat) < COS_EPSILON) { + lonDelta = POLE_LONGITUDE_SPAN_DEGREES; + } else { + lonDelta = Math.min(POLE_LONGITUDE_SPAN_DEGREES, (angularDistance * RAD_TO_DEG) / Math.max(Math.abs(cosLat), COS_EPSILON)); + } + if (!Number.isFinite(lonDelta) || lonDelta >= POLE_LONGITUDE_SPAN_DEGREES) { + return [[minLat, -POLE_LONGITUDE_SPAN_DEGREES], [maxLat, POLE_LONGITUDE_SPAN_DEGREES]]; + } + + const minLon = clampLongitude(center.lon - lonDelta); + const maxLon = clampLongitude(center.lon + lonDelta); + return [[minLat, minLon], [maxLat, maxLon]]; +} + +/** + * Determine a bounding box that encloses the provided coordinates with a + * configurable safety margin. + * + * @param {Array<[number, number]>} points Collection of ``[lat, lon]`` pairs. + * @param {{ + * paddingFraction?: number, + * minimumRangeKm?: number + * }} [options] Optional configuration controlling the computed bounds. + * @returns {[[number, number], [number, number]] | null} Bounding box tuple or + * ``null`` when the input list is empty or invalid. Longitudes may extend + * beyond the canonical ``[-180, 180]`` range when a dateline-spanning span is + * required. + */ +export function computeBoundsForPoints(points, options = {}) { + if (!Array.isArray(points) || !points.length) { + return null; + } + const validPoints = points.filter(point => Array.isArray(point) && Number.isFinite(point[0]) && Number.isFinite(point[1])); + if (!validPoints.length) { + return null; + } + + let xSum = 0; + let ySum = 0; + let zSum = 0; + let latSum = 0; + let lonSum = 0; + for (const [lat, lon] of validPoints) { + const latRad = toRadians(lat); + const lonRad = toRadians(lon); + const cosLat = Math.cos(latRad); + xSum += cosLat * Math.cos(lonRad); + ySum += cosLat * Math.sin(lonRad); + zSum += Math.sin(latRad); + latSum += lat; + lonSum += lon; + } + + const vectorMagnitude = Math.sqrt(xSum * xSum + ySum * ySum + zSum * zSum); + let centre; + if (vectorMagnitude > COS_EPSILON) { + const lat = Math.atan2(zSum, Math.sqrt(xSum * xSum + ySum * ySum)) * RAD_TO_DEG; + const lon = Math.atan2(ySum, xSum) * RAD_TO_DEG; + centre = { lat, lon }; + } else { + centre = { + lat: latSum / validPoints.length, + lon: lonSum / validPoints.length + }; + } + + let maxDistanceKm = 0; + for (const [lat, lon] of validPoints) { + const distance = haversineDistanceKm(centre.lat, centre.lon, lat, lon); + if (distance > maxDistanceKm) { + maxDistanceKm = distance; + } + } + + const paddingFraction = Number.isFinite(options.paddingFraction) && options.paddingFraction >= 0 + ? options.paddingFraction + : 0.15; + const minimumRangeKm = Number.isFinite(options.minimumRangeKm) && options.minimumRangeKm > 0 + ? options.minimumRangeKm + : DEFAULT_MIN_RANGE_KM; + const paddedRangeKm = Math.max(minimumRangeKm, maxDistanceKm * (1 + paddingFraction)); + const angularDistance = paddedRangeKm / EARTH_RADIUS_KM; + const latDelta = angularDistance * RAD_TO_DEG; + const minLat = clampLatitude(centre.lat - latDelta); + const maxLat = clampLatitude(centre.lat + latDelta); + + const cosLat = Math.cos(toRadians(centre.lat)); + const maxProjectedLonDelta = Math.min( + POLE_LONGITUDE_SPAN_DEGREES, + Math.abs(cosLat) < COS_EPSILON + ? POLE_LONGITUDE_SPAN_DEGREES + : (angularDistance * RAD_TO_DEG) / Math.max(Math.abs(cosLat), COS_EPSILON) + ); + + const normalisedLongitudes = validPoints.map(point => normaliseLongitudeAround(point[1], centre.lon)); + let west = Math.min(...normalisedLongitudes, centre.lon - maxProjectedLonDelta); + let east = Math.max(...normalisedLongitudes, centre.lon + maxProjectedLonDelta); + + if (!Number.isFinite(west) || !Number.isFinite(east)) { + west = centre.lon - maxProjectedLonDelta; + east = centre.lon + maxProjectedLonDelta; + } + + if (east - west >= 360) { + west = -POLE_LONGITUDE_SPAN_DEGREES; + east = POLE_LONGITUDE_SPAN_DEGREES; + } + + return [[minLat, west], [maxLat, east]]; +} + +export const __testUtils = { + clampLatitude, + clampLongitude, + normaliseRange, + normaliseLongitudeAround +};