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