doxa-maps/library/constants/zoom.js
Doxa Maps e19697673d
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Doxa Maps — the map bundler, 7 production plugins and the Experimental shelf
2026-09-25 20:07:59 +00:00

437 lines
12 KiB
JavaScript
Raw Permalink 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.

/**
* zoom.js - Centralized Zoom Configuration
*
* Single source of truth for ALL zoom-related configurations:
* - Region-specific zoom levels (for region selection/fly-to)
* - Map-level zoom constraints (min/max)
* - Zoom interpolation curves (for circle sizes, stroke widths, etc.)
* - Fly animation zoom levels
* - Clustering zoom thresholds
*/
/**
* Region-Specific Zoom Configurations
* Used when clicking on a region in the legend or when flying to a region
* Each region has an optimal zoom level and center point for best viewing
*/
export const REGION_ZOOM_CONFIG = {
'Africa': {
zoom: 1.3,
center: [16.6512, 26.3538]
},
'Asia': {
zoom: 0.8,
center: [98.2654, 49.9282]
},
'Europe': {
zoom: 1.4,
center: [22.7826, 70.1026]
},
'Latin America & Caribbean': {
zoom: 1.8,
center: [-60.4619, 10.1428]
},
'Middle East': {
zoom: 1.7,
center: [22.9056, 39.5752]
},
'No WAGF Region/Bloc': {
zoom: 0.6,
center: [-8.2363, 44.2392]
},
'North America & Non-Spanish Caribbean': {
zoom: 2.1,
center: [-68.1935, 31.4282]
},
'Oceania': {
zoom: 1.5,
center: [161.6257, -0.6680]
}
};
/**
* Global Zoom Constraints
* Default min/max zoom levels for all maps (can be overridden per map)
*/
export const ZOOM_CONSTRAINTS = {
default: {
minZoom: 0.5,
maxZoom: 18
},
// Map-specific overrides
languageFamilies: {
minZoom: 1.0,
maxZoom: 15
},
// Recommended for mobile devices
mobile: {
minZoom: 1.0,
maxZoom: 16
}
};
/**
* Zoom Interpolation Curves
* These define how visual properties change with zoom level
* Used in Mapbox paint properties (circle-radius, circle-stroke-width, etc.)
*
* Format: ['interpolate', ['linear'], ['zoom'], zoom1, value1, zoom2, value2, ...]
*/
export const ZOOM_INTERPOLATIONS = {
/**
* Circle radius interpolations
* Different profiles for different use cases
*/
circleRadius: {
// Standard: Good balance for most maps (used in language-family-pins)
standard: [
'interpolate',
['linear'],
['zoom'],
0, 3, // World view - small
2, 3.5, // Still compact
4, 4, // Good baseline size
5, 5, // Starting to grow
6, 6.5, // Growing more
7, 8, // ~50% bigger than zoom 4
8, 10, // Larger
10, 14, // Big
12, 18, // Bigger when zoomed in
14, 22 // Full size at max zoom
],
// Highlighted: Larger size for selected/highlighted pins
highlighted: [
'interpolate',
['linear'],
['zoom'],
0, 4, // Larger at world view
4, 5,
8, 12,
12, 20
],
// Faded: Smaller size for non-selected pins
faded: [
'interpolate',
['linear'],
['zoom'],
0, 2,
4, 2.5,
8, 5,
12, 8
],
// Compact: Smaller overall for dense maps
compact: [
'interpolate',
['linear'],
['zoom'],
0, 2,
4, 3,
8, 6,
12, 10
],
// Large: Bigger for sparse maps or emphasis
large: [
'interpolate',
['linear'],
['zoom'],
0, 4,
4, 6,
8, 14,
12, 24
],
// Touch target: invisible hitbox layer for mobile/tablet tap targeting.
// Visual pins stay at their normal size — only the transparent hit area grows.
// Trimmed (2026-06-22, coder directive): overlapping pins in a dense cluster
// had hitboxes that fully overlapped, swallowing the gap and making it
// impossible to click *between* them. Radii cut slightly — most at high zoom,
// where you dive into a cluster — while keeping a ~40px tap target at mid zoom.
touchTarget: [
'interpolate',
['linear'],
['zoom'],
0, 12, // 24px diameter at world view
2, 15, // 30px
4, 19, // 38px — comfortable tap target
6, 20, // 40px
8, 21, // 42px
10, 22, // 44px
12, 24, // shrunk from 32 — room to click between dense pins
14, 28 // shrunk from 40 — full-zoom cluster diving
]
},
/**
* Circle stroke width interpolations
*/
circleStrokeWidth: {
standard: [
'interpolate',
['linear'],
['zoom'],
0, 0.5, // Thin stroke when zoomed out
4, 0.75,
8, 1,
12, 1.5
],
thick: [
'interpolate',
['linear'],
['zoom'],
0, 1,
4, 1.5,
8, 2,
12, 3
]
},
/**
* Line width interpolations (for network connections, borders, etc.)
*/
lineWidth: {
thin: [
'interpolate',
['linear'],
['zoom'],
0, 0.5,
4, 1,
8, 1.5,
12, 2
],
standard: [
'interpolate',
['linear'],
['zoom'],
0, 1,
4, 2,
8, 3,
12, 4
],
thick: [
'interpolate',
['linear'],
['zoom'],
0, 2,
4, 3,
8, 5,
12, 8
]
},
/**
* Cluster count text size
*/
textSize: {
standard: [
'interpolate',
['linear'],
['zoom'],
0, 10,
4, 12,
8, 14,
12, 16
]
}
};
/**
* Clustering Zoom Thresholds
* Define when clustering should activate/deactivate
*
* INVARIANT (COMMIT-LOG #8): clusterMaxZoom MUST sit a few levels BELOW the
* map's zoom-in cap, or clusters never break apart within reach and overlapping
* pins stay permanently un-clickable. The research map caps at
* RESEARCH_MAX_ZOOM = 18 (lifted from 10 per coder directive so dense clusters
* can be entered deep enough to click between pins), so the live value is 6 with radius 40 in
* ClusterHelpers.DEFAULT_MAPBOX_CLUSTER_OPTIONS, which is the SOURCE OF TRUTH the
* runtime actually uses. These centralized values are kept in lockstep with it
* so that if/when a map is wired to read clustering config from here, it cannot
* re-introduce COMMIT-LOG #8. Any override below must keep
* maxZoom <= (the consuming map's zoom-in cap − ~2) for declustering headroom.
*/
export const CLUSTERING_ZOOM = {
// Maximum zoom where clustering is active (beyond this, show individual pins).
// Matches ClusterHelpers.DEFAULT_MAPBOX_CLUSTER_OPTIONS.clusterMaxZoom (live).
maxZoom: 6,
// Minimum zoom for cluster expansion
minExpandZoom: 2,
// Cluster radius (pixels) - larger = more aggressive clustering.
// Matches the live clusterRadius (40) from ClusterHelpers.
radius: 40,
// Map-specific overrides — must still decluster BELOW the map's zoom-in cap.
denseData: {
maxZoom: 8, // cluster a little longer for dense data, but stay below the cap
radius: 60
},
sparseData: {
maxZoom: 4, // decluster early — sparse data rarely overlaps
radius: 30
}
};
/**
* Fly Animation Durations (milliseconds)
* Different speeds for auto-fly and manual fly-to operations
*/
export const FLY_DURATIONS = {
// Auto-fly speeds (when flying through multiple regions/families)
autoFly: {
slow: 8000,
normal: 4000,
fast: 2000
},
// Manual fly-to (when clicking on a region/family)
manual: {
slow: 3000,
normal: 2000,
fast: 1000
},
// Zoom button animations
zoomButton: 300,
// Default
default: 2000
};
/**
* Calculate zoom level for a given bounds
* Useful for fitting a set of points within the viewport
*
* @param {Object} bounds - Bounding box { north, south, east, west }
* @param {number} viewportWidth - Viewport width in pixels
* @param {number} viewportHeight - Viewport height in pixels
* @returns {number} Optimal zoom level
*/
export function calculateZoomForBounds(bounds, viewportWidth = 800, viewportHeight = 600) {
const WORLD_DIM = { height: 256, width: 256 };
const ZOOM_MAX = 21;
function latRad(lat) {
const sin = Math.sin(lat * Math.PI / 180);
const radX2 = Math.log((1 + sin) / (1 - sin)) / 2;
return Math.max(Math.min(radX2, Math.PI), -Math.PI) / 2;
}
function zoom(mapPx, worldPx, fraction) {
return Math.floor(Math.log(mapPx / worldPx / fraction) / Math.LN2);
}
const latFraction = (latRad(bounds.north) - latRad(bounds.south)) / Math.PI;
const lngDiff = bounds.east - bounds.west;
const lngFraction = ((lngDiff < 0) ? (lngDiff + 360) : lngDiff) / 360;
const latZoom = zoom(viewportHeight, WORLD_DIM.height, latFraction);
const lngZoom = zoom(viewportWidth, WORLD_DIM.width, lngFraction);
return Math.min(latZoom, lngZoom, ZOOM_MAX);
}
/**
* Get region zoom configuration
* @param {string} regionName - The name of the region
* @returns {Object|null} Object with zoom and center, or null if not found
*/
export function getRegionZoomConfig(regionName) {
return REGION_ZOOM_CONFIG[regionName] || null;
}
/**
* Get zoom constraints for a specific map type
* @param {string} mapType - Map type identifier (default, languageFamilies, mobile)
* @returns {Object} Object with minZoom and maxZoom
*/
export function getZoomConstraints(mapType = 'default') {
return ZOOM_CONSTRAINTS[mapType] || ZOOM_CONSTRAINTS.default;
}
/**
* Get circle radius interpolation expression
* @param {string} profile - Profile name (standard, highlighted, faded, compact, large)
* @returns {Array} Mapbox expression array
*/
export function getCircleRadiusInterpolation(profile = 'standard') {
return ZOOM_INTERPOLATIONS.circleRadius[profile] || ZOOM_INTERPOLATIONS.circleRadius.standard;
}
/**
* Get circle stroke width interpolation expression
* @param {string} profile - Profile name (standard, thick)
* @returns {Array} Mapbox expression array
*/
export function getCircleStrokeWidthInterpolation(profile = 'standard') {
return ZOOM_INTERPOLATIONS.circleStrokeWidth[profile] || ZOOM_INTERPOLATIONS.circleStrokeWidth.standard;
}
/**
* Get line width interpolation expression
* @param {string} profile - Profile name (thin, standard, thick)
* @returns {Array} Mapbox expression array
*/
export function getLineWidthInterpolation(profile = 'standard') {
return ZOOM_INTERPOLATIONS.lineWidth[profile] || ZOOM_INTERPOLATIONS.lineWidth.standard;
}
/**
* Get fly duration for a specific speed and context
* @param {string} context - Context (autoFly, manual, zoomButton)
* @param {string} speed - Speed (slow, normal, fast)
* @returns {number} Duration in milliseconds
*/
export function getFlyDuration(context = 'manual', speed = 'normal') {
if (context === 'autoFly') {
return FLY_DURATIONS.autoFly[speed] || FLY_DURATIONS.autoFly.normal;
} else if (context === 'manual') {
return FLY_DURATIONS.manual[speed] || FLY_DURATIONS.manual.normal;
} else if (context === 'zoomButton') {
return FLY_DURATIONS.zoomButton;
}
return FLY_DURATIONS.default;
}
/**
* Get clustering configuration
* @param {string} datasetType - Dataset type (default, denseData, sparseData)
* @returns {Object} Clustering config with maxZoom and radius
*/
export function getClusteringConfig(datasetType = 'default') {
if (datasetType === 'denseData') {
return CLUSTERING_ZOOM.denseData;
} else if (datasetType === 'sparseData') {
return CLUSTERING_ZOOM.sparseData;
}
return {
maxZoom: CLUSTERING_ZOOM.maxZoom,
radius: CLUSTERING_ZOOM.radius
};
}
export default {
REGION_ZOOM_CONFIG,
ZOOM_CONSTRAINTS,
ZOOM_INTERPOLATIONS,
CLUSTERING_ZOOM,
FLY_DURATIONS,
calculateZoomForBounds,
getRegionZoomConfig,
getZoomConstraints,
getCircleRadiusInterpolation,
getCircleStrokeWidthInterpolation,
getLineWidthInterpolation,
getFlyDuration,
getClusteringConfig
};