doxa-maps/library/composables/useMapFly.js

708 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// useMapFly.js - Map Navigation/Fly Composable
// Handles smooth map navigation with fly animations
// Vue 3 Composition API - ES Module with NPM packages
// Import Vue functions from NPM package
import { ref, computed } from 'vue';
// Import zoom configuration (region zoom settings are in zoom.js)
import { getRegionZoomConfig, REGION_ZOOM_CONFIG } from '../constants/zoom.js';
// Import map configuration for initial view defaults
import { mapDefaults } from '../constants/mapDefaults.js';
/**
* useMapFly composable - manages smooth map navigation
*
* Includes:
* - flyTo(center, options)
* - flyToCoords({ longitude, latitude }, zoom?)
* - fitBounds(bounds, padding?)
* - slowFlyToFamily / slowFlyToRegion (domain-aware helpers used by legend clicks)
* - getOffsetCenter — shifts map center to account for a UI panel overlay
*
* @param {Object} options - Configuration options
* @param {Function} options.getMap - Function that returns the map instance
* @param {string} options.mapId - Unique map identifier for logging
* @param {Object} options.mapStore - Pinia mapStore for shared state
* @param {Object} options.regionsGeoData - Regions geo data with polygon features
* @param {Array} options.defaultCenter - [lng, lat] used by resetView()
* @param {number} options.defaultZoom - Zoom for resetView()
*
* @returns {Object} Fly navigation functions
*/
export function useMapFly(options = {}) {
const {
getMap = () => null,
mapId = 'unknown',
mapStore = null,
regionsGeoData = null,
defaultCenter = mapDefaults?.center || [20, 10],
defaultZoom = mapDefaults?.zoom || 2
} = options;
// Default fly animation configuration
const FLY_CONFIG = {
speed: 0.5, // Slow fly speed
essential: true,
curve: 1.2
};
/**
* Calculate center with offset accounting for UI overlays
* This prevents the map center from being hidden behind sidebars/panels
*
* @param {Array} center - [lng, lat] coordinates
* @param {number} zoom - Target zoom level
* @param {Object} offsetOptions - Pixel offsets {x, y}
* @returns {Array} Adjusted [lng, lat] center
*/
function getOffsetCenter(center, zoom, offsetOptions = { x: 150, y: 0 }) {
const map = getMap();
if (!map) return center;
try {
// Get canvas dimensions
const canvas = map.getCanvas();
const width = canvas.width;
const height = canvas.height;
// Project center to pixels
const projected = map.project(center);
// Calculate meters per pixel at this zoom
// Account for device pixel ratio
const dpr = window.devicePixelRatio || 1;
// Apply offset (typically shift left to account for right sidebar)
const offsetX = (offsetOptions.x || 0) * dpr;
const offsetY = (offsetOptions.y || 0) * dpr;
// Create offset point
const offsetPoint = {
x: projected.x - offsetX,
y: projected.y - offsetY
};
// Unproject back to coordinates
const newCenter = map.unproject(offsetPoint);
return [newCenter.lng, newCenter.lat];
} catch (error) {
return center;
}
}
/**
* Slow fly animation to a language family's centroid
* Uses family data to calculate center and zoom
*
* @param {string} familyName - Name of the language family
* @param {Array} peopleGroups - People groups data (optional, for calculating bounds)
*/
function slowFlyToFamily(familyName, peopleGroups = []) {
const map = getMap();
if (!map) {
return;
}
// Try to get family data from mapStore or calculate from people groups
let center = null;
let zoom = 5;
// Calculate centroid from people groups matching this family
const familyGroups = peopleGroups.filter(pg => pg.languageFamily === familyName);
if (familyGroups.length > 0) {
// Calculate bounding box and center
let minLng = Infinity, maxLng = -Infinity;
let minLat = Infinity, maxLat = -Infinity;
familyGroups.forEach(pg => {
if (pg.longitude && pg.latitude) {
minLng = Math.min(minLng, pg.longitude);
maxLng = Math.max(maxLng, pg.longitude);
minLat = Math.min(minLat, pg.latitude);
maxLat = Math.max(maxLat, pg.latitude);
}
});
if (minLng !== Infinity) {
center = [(minLng + maxLng) / 2, (minLat + maxLat) / 2];
// Calculate zoom based on extent
const lngSpan = maxLng - minLng;
const latSpan = maxLat - minLat;
const maxSpan = Math.max(lngSpan, latSpan);
// Rough zoom calculation
if (maxSpan > 50) zoom = 2;
else if (maxSpan > 20) zoom = 3;
else if (maxSpan > 10) zoom = 4;
else if (maxSpan > 5) zoom = 5;
else zoom = 6;
}
}
if (!center) {
return;
}
// Apply offset to account for UI panels
const offsetCenter = getOffsetCenter(center, zoom);
map.flyTo({
center: offsetCenter,
zoom: zoom,
...FLY_CONFIG
});
}
/**
* Slow fly animation to a region
* Uses region config or calculates from polygon data
*
* @param {string} regionName - Name of the region
*/
function slowFlyToRegion(regionName) {
const map = getMap();
if (!map) {
return;
}
// First check REGION_ZOOM_CONFIG from zoom.js for region zoom settings
let center = null;
let zoom = 4;
const regionConfig = getRegionZoomConfig(regionName);
if (regionConfig) {
center = regionConfig.center;
zoom = regionConfig.zoom || 4;
} else if (regionsGeoData?.features) {
// Calculate center from polygon data
const regionFeature = regionsGeoData.features.find(
f => f.properties?.region === regionName || f.properties?.name === regionName
);
if (regionFeature) {
center = calculateRegionCenterFromPolygon(regionFeature);
}
}
if (!center) {
return;
}
// Apply offset to account for UI panels
const offsetCenter = getOffsetCenter(center, zoom);
map.flyTo({
center: offsetCenter,
zoom: zoom,
...FLY_CONFIG
});
}
/**
* Calculate center point from a GeoJSON polygon feature
*
* @param {Object} feature - GeoJSON feature
* @returns {Array|null} [lng, lat] center or null
*/
function calculateRegionCenterFromPolygon(feature) {
if (!feature || !feature.geometry) return null;
const { type, coordinates } = feature.geometry;
let allCoords = [];
if (type === 'Polygon') {
allCoords = coordinates[0] || [];
} else if (type === 'MultiPolygon') {
coordinates.forEach(polygon => {
allCoords = allCoords.concat(polygon[0] || []);
});
}
if (allCoords.length === 0) return null;
// Calculate centroid
let sumLng = 0, sumLat = 0;
allCoords.forEach(coord => {
sumLng += coord[0];
sumLat += coord[1];
});
return [sumLng / allCoords.length, sumLat / allCoords.length];
}
/**
* Fly to specific coordinates with options
*
* @param {Array} center - [lng, lat] coordinates
* @param {Object} flyOptions - Fly animation options
*/
function flyTo(center, flyOptions = {}) {
const map = getMap();
if (!map) return;
const options = {
center,
...FLY_CONFIG,
...flyOptions
};
map.flyTo(options);
}
/**
* Fly to a { longitude, latitude } object (e.g. a people group record).
* @param {Object} coords - { longitude, latitude }
* @param {number} [zoom] - Target zoom (defaults to current zoom + 2)
*/
function flyToCoords(coords, zoom) {
const map = getMap();
if (!map || coords?.longitude == null || coords?.latitude == null) return;
const targetZoom = zoom ?? Math.min(map.getZoom() + 2, 8);
flyTo([coords.longitude, coords.latitude], { zoom: targetZoom });
}
/**
* Fit the map to a LngLatBounds (e.g. to frame a region polygon).
* @param {mapboxgl.LngLatBounds} bounds
* @param {Object} [padding] - { top, right, bottom, left } in px
*/
function fitBounds(bounds, padding = { top: 40, right: 40, bottom: 40, left: 40 }, options = {}) {
const map = getMap();
if (!map || !bounds) return;
// maxZoom defaults to 4 (continent-scale framing). Caller can override.
const maxZoom = options.maxZoom ?? 4;
// `options` is spread LAST so a caller can override any animation field — notably
// `duration: 0`, which an accessibility path needs in order to JUMP instead of ease
// for prefers-reduced-motion users. Previously only `maxZoom` was read and every
// other caller option was silently dropped, so a reduced-motion caller still got
// FLY_CONFIG's easing. Passing no options is byte-for-byte the previous behaviour.
map.fitBounds(bounds, { padding, maxZoom, ...FLY_CONFIG, ...options });
}
/**
* Reset map to initial view
*/
function resetView() {
const map = getMap();
if (!map) return;
map.flyTo({
center: defaultCenter,
zoom: defaultZoom,
...FLY_CONFIG
});
}
// Uniform legend-row camera constants. Single source of truth for how
// tight a row-click zooms — keep these conservative so even sparse rows
// (single pin, single country) don't slam the camera into street-level.
const LEGEND_FIT_PADDING_PX = 80;
const LEGEND_FIT_DURATION_MS = 800;
const LEGEND_FIT_MAX_ZOOM = 5;
const LEGEND_SINGLE_PIN_ZOOM = 5;
// Globe-FILLING selections (e.g. "Islam" reaches every continent, span ~134°):
// a longitude span this wide can't be framed tightly without the camera
// animating the wrong way around the antimeridian (the snap). Past this
// threshold we ease to a stable world overview instead of a literal fitBounds.
// 110° is tuned from live data: catches Islam (134°) / Ethnoreligion (198°) /
// Europe (107°) but leaves every real continent a tight fit (Oceania 76°,
// Middle East 67°, Africa 58°, Asia 51°).
const LEGEND_WIDE_SPAN_DEG = 110;
const LEGEND_WORLD_ZOOM = 1.2;
// Antimeridian-aware longitude span. For scopes that cross ±180° (Oceania,
// east-Russia, Aleutians), naive min/max returns a span of ~360° because
// points sit at -179 and +179. Re-project longitudes into [0, 360) and
// recompute; pick the smaller span. Returns { minLng, maxLng } in the
// representation that yields the tighter bbox. Caller must accept that
// maxLng can exceed 180 — Mapbox's fitBounds accepts that and wraps.
function _tightLngBounds(longitudes) {
if (!longitudes.length) return null;
let aMin = Infinity, aMax = -Infinity; // raw [-180, 180]
let bMin = Infinity, bMax = -Infinity; // shifted [0, 360)
for (const lng of longitudes) {
if (!Number.isFinite(lng)) continue;
if (lng < aMin) aMin = lng;
if (lng > aMax) aMax = lng;
const shifted = lng < 0 ? lng + 360 : lng;
if (shifted < bMin) bMin = shifted;
if (shifted > bMax) bMax = shifted;
}
if (!Number.isFinite(aMin)) return null;
const spanA = aMax - aMin;
const spanB = bMax - bMin;
if (spanB < spanA) {
// The cross-antimeridian representation is tighter. Convert back
// to fitBounds-friendly form: keep minLng in [-180,180] and let
// maxLng exceed 180 (Mapbox wraps it correctly).
const minRaw = bMin > 180 ? bMin - 360 : bMin;
return { minLng: minRaw, maxLng: minRaw + spanB };
}
return { minLng: aMin, maxLng: aMax };
}
// Robust, antimeridian-safe longitude extent + a STABLE deterministic center.
// A circular (atan2) mean is DEGENERATE for near-global sets: the unit vectors
// cancel, so the center collapses to an arbitrary angle and the camera "doesn't
// know where to zoom" (janky ease). Instead:
// 1) unwrap at the largest circular gap so a wrapping set becomes contiguous;
// 2) trim ~2.5% off each tail so a few far outliers (e.g. a "Europe" polygon's
// far-east-Russia tail) don't blow up the extent — lets the dense cluster
// frame tightly;
// 3) center = midpoint of the TRIMMED range → deterministic + stable, even for
// globe-spanning sets, so the world-overview ease always lands predictably.
function _robustLngExtent(lngs) {
const xs = lngs.filter(Number.isFinite).slice().sort((a, b) => a - b);
const n = xs.length;
if (!n) return null;
let gap = -1, gapIdx = 0;
for (let i = 0; i < n; i++) {
const next = (i + 1 < n) ? xs[i + 1] : xs[0] + 360;
const g = next - xs[i];
if (g > gap) { gap = g; gapIdx = i; }
}
const start = (gapIdx + 1) % n;
const u = new Array(n);
for (let k = 0; k < n; k++) {
let v = xs[(start + k) % n];
if (k > 0 && v < u[k - 1]) v += 360; // single unwrap across the seam
u[k] = v;
}
const loI = Math.floor(n * 0.025);
const hiI = Math.max(loI, Math.ceil(n * 0.975) - 1);
const lo = u[loI], hi = u[hiI];
const norm = (x) => (((x + 180) % 360) + 360) % 360 - 180;
const minLng = norm(lo);
// World-overview center = the DENSEST longitude bin (24 × 15°), not the
// trimmed midpoint: a wide set whose mass is in real Europe but whose extent
// is dragged east by a far-east-Russia tail would otherwise center mid-Asia.
// Densest-bin centers on where the data actually concentrates — deterministic
// and stable (no degenerate circular-mean wobble).
const BINS = 24, size = 360 / BINS;
const counts = new Array(BINS).fill(0);
for (const x of xs) { let b = Math.floor(((((x + 180) % 360) + 360) % 360) / size); if (b >= BINS) b = BINS - 1; counts[b]++; }
let bb = 0; for (let i = 1; i < BINS; i++) if (counts[i] > counts[bb]) bb = i;
const center = norm(-180 + (bb + 0.5) * size);
return { center, minLng, maxLng: minLng + (hi - lo), span: hi - lo };
}
// Zoom at which exactly ONE world fills the viewport width (worldPx = 512·2^z).
// The basemap keeps renderWorldCopies:true, so going BELOW this reveals repeated
// world copies. Clamp to the map's own min so we never request out of range.
function _noCopyZoom(map) {
try {
const W = (map.getContainer && map.getContainer().clientWidth) || 0;
const nz = W > 0 ? Math.log2(W / 512) : LEGEND_WORLD_ZOOM;
const mn = typeof map.getMinZoom === 'function' ? map.getMinZoom() : nz;
return Math.max(mn, nz);
} catch (_) { return LEGEND_WORLD_ZOOM; }
}
// Smoothly ease to a stable WORLD OVERVIEW for a selection too wide to frame
// without showing repeated world copies — globe-spanning rows ("Islam"), wide
// region polygons ("Europe" reaching into Russia), or any set whose fitBounds
// would clamp at/below the no-copy floor. Center = wrap-safe circular mean of
// longitudes; zoom = one-world-fills-width; easeTo (NOT flyTo) so the camera
// never arcs out past the floor and flashes the copies.
function _easeWorldOverview(map, centerLng, minLat, maxLat) {
map.easeTo({
center: [centerLng, (minLat + maxLat) / 2],
zoom: _noCopyZoom(map),
duration: LEGEND_FIT_DURATION_MS,
essential: true
});
}
// True when an extent is too wide to frame above the no-copy floor — i.e.
// fitBounds would clamp and/or wrap the wrong way (the snap). Fast path on the
// longitude span, precise check via viewport-aware cameraForBounds.
function _tooWideToFit(map, bounds, span) {
if (span > LEGEND_WIDE_SPAN_DEG) return true;
try {
const probe = typeof map.cameraForBounds === 'function'
? map.cameraForBounds(bounds, { padding: LEGEND_FIT_PADDING_PX, maxZoom: LEGEND_FIT_MAX_ZOOM })
: null;
if (probe == null) return true;
return typeof probe.zoom === 'number' && probe.zoom <= _noCopyZoom(map) + 0.05;
} catch (_) { return false; }
}
// Why: map.querySourceFeatures(sourceId, {filter}) is viewport-clipped —
// pins outside the rendered camera are dropped, so legend rows for off-screen
// regions silently no-op when the user is zoomed in. We evaluate the Mapbox
// filter expression against the full source data instead, so fitBounds can
// zoom OUT to a different region or IN to a tight cluster uniformly.
function _resolveExpr(expr, props) {
if (!Array.isArray(expr)) return expr;
const op = expr[0];
switch (op) {
case 'get': return props?.[expr[1]];
case 'literal': return expr[1];
case 'has': return props != null && expr[1] in props;
case '!has': return props == null || !(expr[1] in props);
case '!': return !_resolveExpr(expr[1], props);
case 'coalesce': {
for (let i = 1; i < expr.length; i++) {
const v = _resolveExpr(expr[i], props);
if (v != null) return v;
}
return null;
}
case 'to-number': {
const v = _resolveExpr(expr[1], props);
const n = Number(v);
if (Number.isFinite(n)) return n;
if (expr.length > 2) {
const fb = Number(_resolveExpr(expr[2], props));
return Number.isFinite(fb) ? fb : 0;
}
return NaN;
}
case 'to-string': {
const v = _resolveExpr(expr[1], props);
return v == null ? '' : String(v);
}
case 'to-boolean': return Boolean(_resolveExpr(expr[1], props));
case 'slice': {
const s = _resolveExpr(expr[1], props);
const i = _resolveExpr(expr[2], props);
const j = expr.length > 3 ? _resolveExpr(expr[3], props) : undefined;
if (typeof s !== 'string') return '';
return j != null ? s.slice(i, j) : s.slice(i);
}
default: return expr;
}
}
function _matchesFilter(filter, props) {
if (filter === true) return true;
if (filter === false) return false;
if (!Array.isArray(filter)) return Boolean(filter);
const op = filter[0];
if (op === 'all') {
for (let i = 1; i < filter.length; i++) {
if (!_matchesFilter(filter[i], props)) return false;
}
return true;
}
if (op === 'any') {
for (let i = 1; i < filter.length; i++) {
if (_matchesFilter(filter[i], props)) return true;
}
return false;
}
if (op === '!') return !_matchesFilter(filter[1], props);
if (op === 'has') return props != null && filter[1] in props;
if (op === '!has') return props == null || !(filter[1] in props);
if (op === '==' || op === '!=' || op === '>' || op === '>=' ||
op === '<' || op === '<=') {
// Legacy form: ['==', 'propName', value] (no ['get', ...])
const left = (typeof filter[1] === 'string')
? props?.[filter[1]]
: _resolveExpr(filter[1], props);
const right = _resolveExpr(filter[2], props);
switch (op) {
case '==': return left === right || (left == null && right == null);
case '!=': return !(left === right || (left == null && right == null));
case '>': return left > right;
case '>=': return left >= right;
case '<': return left < right;
case '<=': return left <= right;
}
}
if (op === 'in') {
const v = _resolveExpr(filter[1], props);
const haystack = _resolveExpr(filter[2], props);
if (Array.isArray(haystack)) return haystack.includes(v);
if (typeof haystack === 'string') return haystack.includes(v);
return false;
}
if (op === 'match') {
const input = _resolveExpr(filter[1], props);
const lastIdx = filter.length - 1;
for (let i = 2; i < lastIdx; i += 2) {
const labelRaw = filter[i];
const labels = Array.isArray(labelRaw) ? labelRaw : [labelRaw];
if (labels.includes(input)) return Boolean(_resolveExpr(filter[i + 1], props));
}
return Boolean(_resolveExpr(filter[lastIdx], props));
}
return false;
}
function _readSourceFeatures(map, sourceId) {
const src = map.getSource(sourceId);
if (!src) return [];
const data = src._data ?? src.serialize?.()?.data;
if (!data) return [];
if (Array.isArray(data?.features)) return data.features;
if (data?.type === 'Feature') return [data];
return [];
}
/**
* zoomToLegendRow — uniform "click a legend row, fit the scope it represents."
*
* Reads the full `language-families` source data via map.getSource()._data
* and evaluates the Mapbox filter expression in JS, bypassing the viewport
* clip that previously caused off-screen regions to silently no-op. fitBounds
* is bidirectional — it zooms OUT to a wider region just as readily as it
* zooms IN to a tight cluster, so Africa-row and North-India-cluster-row
* feel like the same gesture from any starting camera.
*
* count == 0 → no-op (intentional — never blank-out the map)
* count == 1 → flyTo single-pin coords @ LEGEND_SINGLE_PIN_ZOOM
* count >= 2 → fitBounds with padding LEGEND_FIT_PADDING_PX
*
* @param {Object} row - STL legend node { id, label, filter, ... }
* @param {Object} [opts]
* @param {Array} [opts.matchExpr] - Override Mapbox expression for flat tabs
* (prayer/engagement/adoption) where row.filter
* is a placeholder against `_flat_filter`.
* @param {string} [opts.sourceId='language-families'] - Pin source id to query
*/
function zoomToLegendRow(row, opts = {}) {
const map = getMap();
if (!map || !row) return;
// Region rows on the doxa-regions tab represent polygons, not pin clusters.
// The pin-cluster path below would zoom to whatever sparse pins happen to
// overlap the region's bbox — which for sparse regions like Oceania zooms
// OUT to fit a few scattered pins. Fit the polygon directly instead, so
// "click Oceania" zooms TO Oceania.
if (opts.regionPolygonSource && map.getSource(opts.regionPolygonSource)) {
const polyFeatures = _readSourceFeatures(map, opts.regionPolygonSource);
const regionKey = row.id ?? row.label;
const match = polyFeatures.find(f => {
const p = f?.properties || {};
return p.region === regionKey || p.name === regionKey || p.id === regionKey;
});
if (match) {
const lngs = [];
let pMinLat = Infinity, pMaxLat = -Infinity;
const visit = (coords) => {
for (const c of coords) {
if (Array.isArray(c[0])) visit(c);
else if (Number.isFinite(c[0]) && Number.isFinite(c[1])) {
lngs.push(c[0]);
if (c[1] < pMinLat) pMinLat = c[1];
if (c[1] > pMaxLat) pMaxLat = c[1];
}
}
};
if (match.geometry?.coordinates) visit(match.geometry.coordinates);
// Robust extent: trims a wide polygon's sparse tail (e.g. "Europe"
// reaching into far-east Russia) so the DENSE cluster (real Europe)
// frames tightly instead of fitBounds-ing the antimeridian-crossing sprawl.
const ext = _robustLngExtent(lngs);
if (ext && Number.isFinite(pMinLat)) {
const pBounds = [[ext.minLng, pMinLat], [ext.maxLng, pMaxLat]];
// Normal region → fit the polygon tightly (zoom TO the region). A
// too-wide polygon (e.g. "Europe (Region)", whose geometry is dominated
// by far-east Russia) would snap/show copies — ease to a STABLE world
// overview (densest-bin center) instead.
if (_tooWideToFit(map, pBounds, ext.span)) {
_easeWorldOverview(map, ext.center, pMinLat, pMaxLat);
} else {
map.fitBounds(pBounds, {
padding: LEGEND_FIT_PADDING_PX,
maxZoom: LEGEND_FIT_MAX_ZOOM,
duration: LEGEND_FIT_DURATION_MS,
essential: true
});
}
return;
}
}
// Polygon not found — fall through to pin-cluster path below.
}
const sourceId = opts.sourceId ?? 'language-families';
if (!map.getSource(sourceId)) return;
const filter = opts.matchExpr ?? row.filter;
if (!filter) return;
const allFeatures = _readSourceFeatures(map, sourceId);
if (!allFeatures.length) return;
const lngs = [];
let minLat = Infinity, maxLat = -Infinity;
let pointCount = 0;
let firstLng = 0, firstLat = 0;
const seen = new Set();
for (const f of allFeatures) {
const props = f?.properties;
if (!_matchesFilter(filter, props)) continue;
const id = props?.uniqueId;
if (id != null) {
if (seen.has(id)) continue;
seen.add(id);
}
const c = f.geometry?.coordinates;
if (!Array.isArray(c) || c.length < 2) continue;
const lng = c[0], lat = c[1];
if (!Number.isFinite(lng) || !Number.isFinite(lat)) continue;
if (pointCount === 0) { firstLng = lng; firstLat = lat; }
lngs.push(lng);
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
pointCount++;
}
if (pointCount === 0) return;
if (pointCount === 1) {
map.flyTo({
center: [firstLng, firstLat],
zoom: LEGEND_SINGLE_PIN_ZOOM,
duration: LEGEND_FIT_DURATION_MS,
essential: true
});
return;
}
// Robust, antimeridian-safe extent with outlier trim + a STABLE center.
const ext = _robustLngExtent(lngs) || { center: 0, minLng: 0, maxLng: 0, span: 0 };
const bounds = [[ext.minLng, minLat], [ext.maxLng, maxLat]];
// Too wide to frame without revealing repeated world copies — a globe-spanning
// row ("Islam" reaches every continent) or any fit that would clamp at/below the
// no-copy floor (the old snap animated the wrong way and flashed the copies).
// Ease to a STABLE world overview (deterministic trimmed center — NOT a degenerate
// circular mean) so it always lands predictably. Regional rows (Africa, North-India
// cluster) frame tightly via the fitBounds below — no regression.
if (_tooWideToFit(map, bounds, ext.span)) {
_easeWorldOverview(map, ext.center, minLat, maxLat);
return;
}
try {
map.fitBounds(bounds, {
padding: LEGEND_FIT_PADDING_PX,
maxZoom: LEGEND_FIT_MAX_ZOOM,
duration: LEGEND_FIT_DURATION_MS,
essential: true
});
} catch (_) { /* fitBounds throws on degenerate bounds */ }
}
return {
// Core navigation
getOffsetCenter,
slowFlyToFamily,
slowFlyToRegion,
flyTo,
flyToCoords,
fitBounds,
resetView,
zoomToLegendRow,
// Utilities
calculateRegionCenterFromPolygon
};
}
// ES Module export