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
This commit is contained in:
l5y
2025-10-12 19:22:17 +02:00
committed by GitHub
parent 00444f7611
commit e4c48682b0
3 changed files with 444 additions and 35 deletions
@@ -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');
});
+51 -35
View File
@@ -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 });
}
}
+255
View File
@@ -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
};