A reader whose OS asks for reduced motion (prefers-reduced-motion: reduce) still got flying and easing
maps: callers passed { duration: 0 } themselves, and flyToCoords took no options argument, so theirs was
silently dropped. useMapFly now routes every camera move (flyTo, easeTo, fitBounds: 12 call sites)
through one guard: jumpTo, or fitBounds with animate:false, under reduced motion. flyToCoords takes and
honours caller options. check:map-fly-reduced-motion runs the real composable against a recording map,
headless: 7/7 PASS here; 5 of them FAIL on the previous code.
745 lines
32 KiB
JavaScript
745 lines
32 KiB
JavaScript
// 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?, options?)
|
||
* - 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;
|
||
|
||
// ★ REDUCED MOTION IS DECIDED HERE, NOT BY EACH CALLER (2026-09-26). A reader who asks the OS for
|
||
// reduced motion (prefers-reduced-motion: reduce) gets every camera move in this composable as an
|
||
// instant jump. Callers used to pass { duration: 0 } themselves, and flyToCoords took no options, so
|
||
// theirs was silently dropped and the map still flew: a photosensitivity issue, not a style choice.
|
||
// Every animated call below goes through cam(map), so no caller can miss it.
|
||
const prefersReducedMotion = () => {
|
||
try { return typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
|
||
catch { return false; }
|
||
};
|
||
function cam(map) {
|
||
const still = prefersReducedMotion();
|
||
const jump = (o = {}) => {
|
||
const view = {};
|
||
for (const k of ['center', 'zoom', 'bearing', 'pitch', 'padding']) if (o[k] !== undefined) view[k] = o[k];
|
||
return map.jumpTo(view);
|
||
};
|
||
return {
|
||
flyTo: (o) => (still ? jump(o) : map.flyTo(o)),
|
||
easeTo: (o) => (still ? jump(o) : map.easeTo(o)),
|
||
fitBounds: (b, o = {}) => map.fitBounds(b, still ? { ...o, animate: false, duration: 0 } : o),
|
||
};
|
||
}
|
||
|
||
// 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);
|
||
|
||
|
||
cam(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);
|
||
|
||
|
||
cam(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
|
||
};
|
||
|
||
cam(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, flyOptions = {}) {
|
||
const map = getMap();
|
||
if (!map || coords?.longitude == null || coords?.latitude == null) return;
|
||
const targetZoom = zoom ?? Math.min(map.getZoom() + 2, 8);
|
||
// Caller options (e.g. { duration: 0 }) are honoured; they used to be dropped (no third argument).
|
||
flyTo([coords.longitude, coords.latitude], { ...flyOptions, 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;
|
||
// ★ fitBounds NEEDS `duration`; FLY_CONFIG alone leaves it INSTANT (2026-09-23).
|
||
// FLY_CONFIG carries `speed` and `curve`, which flyTo understands and fitBounds IGNORES —
|
||
// so this call looked animated and was a hard cut. MEASURED in the cap-4: selecting a row
|
||
// moved the camera 1.45 -> 6 with only TWO distinct zoom values across 14 samples over
|
||
// ~1.7s, where an eased move shows a curve of intermediates. That is the Driver's
|
||
// "transitions too snappy, not smooth" report, and it is specific to fitBounds: the flyTo
|
||
// paths in this same file were always eased.
|
||
//
|
||
// 900ms matches the feel of the flyTo paths at speed 0.5 without making a selection feel
|
||
// sluggish. `options` still spreads LAST, so an accessibility caller passing `duration: 0`
|
||
// still jumps — that path is unchanged and is why the default lives here rather than in
|
||
// FLY_CONFIG, which is shared with callers that must not inherit it.
|
||
const duration = options.duration ?? 900;
|
||
// `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.
|
||
cam(map).fitBounds(bounds, { padding, maxZoom, duration, ...FLY_CONFIG, ...options });
|
||
}
|
||
|
||
/**
|
||
* Reset map to initial view
|
||
*/
|
||
function resetView() {
|
||
const map = getMap();
|
||
if (!map) return;
|
||
|
||
cam(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) {
|
||
cam(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 {
|
||
cam(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) {
|
||
cam(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 {
|
||
cam(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
|