doxa-maps/library/composables/useMapLayers.js
doxa-maps-88655c70 fc69adbbd5 Country boundaries load on MapLibre in the shared library, not per plugin
main added country-posters-mfe (isMap=true). It inherits the engine swap
correctly -- 14 -> 4 requests to api.mapbox.com, maplibregl loaded -- but
threw

    Fetch API cannot load mapbox://mapbox.country-boundaries-v1.
    URL scheme "mapbox" is not supported

A new map arriving is exactly what the rollout has to cover, so this was
worth chasing rather than noting.

TWO shared library sites, not one, which only measuring found. Fixing
useCountryOutline.js (consumers: doxa-simple-map, countries-map,
research-map) cleaned two of the four leaves and left countries-map -- and
the new element nesting it -- still throwing, from a SECOND site:
useMapLayers.js addRegionsLayer. Both now branch per engine, using the
keyless assets/boundaries/world-adm0.geojson the tree already ships, with
iso3 normalised once to iso_3166_1_alpha_3 so every downstream expression
keeps reading the name it always read, and 'source-layer' set for the
vector-tile path alone.

The engine is read off the LIVE map -- its container carries
maplibregl-map -- rather than from config, because both composables are
called with nothing but a map getter.

MEASURED: 0 mapbox:// errors across country-posters-mfe, countries-map,
doxa-simple-map, research-map and demo-map; the adm0 fetch confirmed firing;
provider sweep 15/17. The Mapbox path is unchanged -- its control screenshot
still shows the coloured WAGF region fills.

Also fixes check:descriptions, which the new profile red-gated by shipping
an empty description. Written from the component's own header, not a
placeholder: 34/34 profiles described.

NOT FIXED, and recorded in the ledger rather than glossed: under MapLibre
countries-map still paints NO coloured region fills. The error is gone and
the geojson loads, but the fill does not appear. That is a separate defect
from the source failing, and claiming this commit finishes it would be
false.
2026-09-24 05:09:12 +00:00

991 lines
45 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.

// useMapLayers.js - Map Layers Composable
// Handles adding/removing Mapbox layers (regions, pins, language families)
// Vue 3 Composition API - ES Module with NPM packages
// Import Vue functions from NPM package
import { ref, toRaw } from 'vue';
import { resolveAssetsBase } from '../utils/embedBase.js'
import { emissivePaint } from './useMapProvider.js';
// Import utilities
import { buildColorExpression } from '../colors/_registry.js';
import { getCircleRadiusInterpolation, getCircleStrokeWidthInterpolation } from '../constants/zoom.js';
import { FULL_PRAYER_THRESHOLD, PALETTE as PRAYER_PALETTE } from '../colors/prayer-progress.js';
import { useMapEvents } from './useMapEvents.js';
import langFamilyByLanguage from '../data/langFamilyByLanguage.json';
// ── Pin ring widths (theme-aware, parameterized) ─────────────────────────────
// LIGHT = elegant data-driven hairline (need-prayer pins thinner than has-prayer).
// DARK = a noticeably THICKER white ring so BLACK / near-black pins stay visible on
// the Standard NIGHT basemap. Black is INTENTIONAL for several buckets —
// unmapped language families (#000000), 'Sign Language' (#212121), 'Unknown'
// (#000000) and the Deaf affinity block (#000000, accessibility) — and the
// hairline ring can't outline them in dark mode. Each profile's
// _updatePinStroke() swaps these on theme/tab change; pin COLOURS are NEVER
// changed — only the ring width, dark-mode only.
const HAS_PRAYER = ['>', ['to-number', ['get', 'peoplePraying'], 0], 0];
const RING_HAIRLINE_HAS = { 0: 0.25, 8: 0.4, 14: 0.5 }; // light has-prayer hairline
const RING_HAIRLINE_NO = { 0: 0.1, 8: 0.18, 14: 0.22 }; // light need-prayer hairline (also the dark EXCEPTION width)
const RING_THICK = { 0: 1.2, 8: 1.6, 14: 2 }; // dark default — outlines black pins
export const PIN_STROKE_WIDTH_LIGHT = [
'interpolate', ['linear'], ['zoom'],
0, ['case', HAS_PRAYER, RING_HAIRLINE_HAS[0], RING_HAIRLINE_NO[0]],
8, ['case', HAS_PRAYER, RING_HAIRLINE_HAS[8], RING_HAIRLINE_NO[8]],
14, ['case', HAS_PRAYER, RING_HAIRLINE_HAS[14], RING_HAIRLINE_NO[14]]
];
export const PIN_STROKE_WIDTH_DARK = [
'interpolate', ['linear'], ['zoom'], 0, RING_THICK[0], 8, RING_THICK[8], 14, RING_THICK[14]
];
// MODULAR dark-ring EXCEPTIONS, keyed by the active colour-strategy. A pin matching an
// exception keeps the THIN hairline instead of the thick default. To add a future
// exception (a pin type / legend row / colour that should stay thin), add a key + match
// expression here — CONFIG ONLY, no code edits. (coder: the prayer map's RED no-prayer
// pins MUST stay thin so RED vs YELLOW don't blur under a thick white ring.)
const NO_PRAYER = ['==', ['to-number', ['get', 'peoplePraying'], 0], 0];
const NOT_ENGAGED = ['all', ['!=', ['get', 'engagementStatus'], true], ['!=', ['get', 'engagementStatus'], 1]];
const NOT_ADOPTED = ['all', ['!=', ['get', 'adoptionStatus'], true], ['!=', ['get', 'adoptionStatus'], 1]];
export const DARK_RING_EXCEPTIONS = {
// Negative-status pins are RED (#e74c3c) on every status map — already visible by
// colour, and a thick white ring blurs RED vs the positive GREEN/YELLOW. Keep them
// thin; positive (has-*) pins keep the thick white ring like every other pin.
prayer: [NO_PRAYER], // simple-map prayer colour-strategy: no-prayer (red) thin
prayerProgress: [NO_PRAYER], // research / countries / clone prayer colour-strategy
engagement: [NOT_ENGAGED], // notEngaged (red) thin
adoption: [NOT_ADOPTED], // notAdopted (red) thin
};
// Build the dark-mode circle-stroke-width for the active colour-strategy: THICK by
// default, but THIN (hairline) for any pin matching that strategy's exceptions.
export function buildDarkRingWidth(colorStrategy) {
const excs = DARK_RING_EXCEPTIONS[colorStrategy] || [];
if (!excs.length) return PIN_STROKE_WIDTH_DARK; // no exception → all thick
const anyExc = excs.length === 1 ? excs[0] : ['any', ...excs];
return [
'interpolate', ['linear'], ['zoom'],
0, ['case', anyExc, RING_HAIRLINE_NO[0], RING_THICK[0]],
8, ['case', anyExc, RING_HAIRLINE_NO[8], RING_THICK[8]],
14, ['case', anyExc, RING_HAIRLINE_NO[14], RING_THICK[14]]
];
}
// ── Client-side language → family derivation ────────────────────────────────
// API field `imb_language_family` is null for all 2,069 records (qa-feedback1
// Round 5 A2). The legend bucketing (useLanguageFamilyLegendData.readFamily)
// derives family client-side via langFamilyByLanguage; mirror the same
// derivation here so pin features carry the correct languageFamily property.
// Without this, the languageFamily color strategy paints pins as 'Unknown'
// (gray/black) and PPLR-style property filters miss them.
const FAMILY_SUFFIXES_PIN = [
[/ sign language$/i, 'Sign Language'],
];
function deriveFamilyFromLanguage(label) {
if (!label || typeof label !== 'string') return null;
const lbl = label.trim();
// Comma-inverted lookup: "Arabic, Sudanese" → "Sudanese Arabic"
const parts = lbl.split(',').map(s => s.trim()).filter(Boolean);
if (parts.length >= 2) {
const fullReversed = [...parts].reverse().join(' ');
if (langFamilyByLanguage[fullReversed]) return langFamilyByLanguage[fullReversed];
if (parts.length >= 3) {
const twoReversed = [parts[1], parts[0]].join(' ');
if (langFamilyByLanguage[twoReversed]) return langFamilyByLanguage[twoReversed];
}
}
if (langFamilyByLanguage[lbl]) return langFamilyByLanguage[lbl];
// Strip parenthetical suffix: "Ainu (China)" → "Ainu"
const stripped = lbl.replace(/\s*\(.*?\)\s*$/, '').trim();
if (stripped !== lbl && langFamilyByLanguage[stripped]) return langFamilyByLanguage[stripped];
// Suffix groups: "Pakistan Sign Language" → "Sign Language"
for (const [re, base] of FAMILY_SUFFIXES_PIN) {
if (re.test(lbl)) return base;
}
return null;
}
/**
* useMapLayers composable - manages Mapbox layer creation and updates
*
* NOTE: Event handlers (click, hover, popups) are handled by useMapEvents composable
* which is automatically initialized and used by this composable.
*
* @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 {Function} options.getLanguageFamilyColor - Color lookup function
* @param {Function} options.getNormalizedPeopleGroups - Returns normalized PG array for region popups
*
* @returns {Object} Layer management functions and state
*/
export function useMapLayers(options = {}) {
const {
getMap: _getMapOption = () => null,
mapId = 'unknown',
getLanguageFamilyColor = () => '#999999',
getNormalizedPeopleGroups = () => []
} = options;
// Mapbox must NEVER run through Vue's reactive Proxy. The profiles hold the Map in
// a plain ref() (deep-reactive), so `getMap()` hands back a Proxy: every
// `this.style…` read inside Mapbox then goes through the Proxy get/set traps, and a
// render scheduled from a proxied call (setPaintProperty → triggerRepaint) draws the
// WHOLE frame through the Proxy — measured with internal/long-frames-probe.mjs
// --profile: Vue's `get`/`set` traps were the two top self-time frames of an IDLE
// prayer map (~2.7 s of a 16 s window), called from Mapbox's updatePaintArrays /
// bind / freshBind. toRaw() unwraps once at this boundary; a raw map passes through.
const getMap = () => { const m = _getMapOption(); return m ? toRaw(m) : null; };
// Initialize events composable for click handlers, popups, cursor changes
const mapEvents = useMapEvents({ getMap, mapId, getLanguageFamilyColor, getNormalizedPeopleGroups });
// Track which layers have been added
const layersAdded = ref({
languageFamilyPins: false,
regions: false,
peopleGroups: false,
familyConnections: false
});
// Populated by addRegionsLayer — maps WAGF region label → Set of ISO alpha-2 codes.
// Used by applyDimFilter to build the Mapbox match expression for polygon highlight.
const regionIsoMap = ref({});
/**
* Add language family pins layer
* Shows people groups as colored circles based on language family
*
* @param {Array} peopleGroups - Array of people group data
* @param {string} colorMode - Color mode ('languageFamily', 'affinityBlock', etc.)
*/
function addLanguageFamilyLayer(peopleGroups, colorMode = 'languageFamily') {
const map = getMap();
if (!map || !peopleGroups?.length) {
return;
}
// Build color expression from properties for all modes.
// peoplePraying is stored in GeoJSON feature properties as a number,
// so the Mapbox ['get', 'peoplePraying'] expression reads directly from properties.
const colorExpression = buildColorExpression(colorMode);
// Create GeoJSON features
const features = peopleGroups.map(pg => ({
type: 'Feature',
properties: {
uniqueId: pg.id || pg.uniqueId,
slug: pg.slug || pg._normalized?.slug || '',
rop3: pg.rop3 || pg.peopleGroupId,
peopleId3: pg.peopleId3 || '',
name: pg.name,
language: pg.language,
// Derive languageFamily client-side when API is null. The api
// field `imb_language_family` is null for all 2,069 PGs; without
// this fallback, every pin's languageFamily is 'Unknown' which
// breaks the languageFamily color strategy AND any property-
// based family filter (the legend's family-bucket only
// catches whatever subset of langs the lookup happened to hit).
languageFamily: pg.languageFamily
|| pg._normalized?.languageFamily
|| deriveFamilyFromLanguage(pg.language)
|| 'Unknown',
affinityBlock: pg.affinityBlock || pg.affbloc || pg.Affbloc || pg._normalized?.affinityBlock || 'Unknown',
affinityBlockLabel: pg.affinityBlockLabel || pg._normalized?.affinityBlockLabel || '',
// Cluster (rop2) — added 2026-05-08 for the affinity-block 4-tier legend.
cluster: pg.cluster || pg._normalized?.cluster || '',
clusterLabel: pg.clusterLabel || pg._normalized?.clusterLabel || '',
// People group (rop25) — added 2026-05-08 for the affinity-block tier-3 filter.
peopleGroup: pg.peopleGroup || pg._normalized?.peopleGroup || '',
peopleGroupLabel: pg.peopleGroupLabel || pg._normalized?.peopleGroupLabel || '',
// Prefer the top-level `pg.doxaRegion` because useMapData's
// backfill loop canonicalizes raw value forms ("africa",
// "latin_america_&_caribbean", "na") onto the DOXA_REGION_COLORS
// keys ("Africa", "Latin America & Caribbean", "No WAGF Region/Bloc").
// Reading `_normalized` first would pin the GeoJSON property to
// the lowercase value form and break Mapbox setFilter matches.
doxaRegion: pg.doxaRegion || pg._normalized?.doxaRegion || 'Unknown',
wagfBlock: pg.wagfBlock || pg._normalized?.wagfBlock || '',
wagfBlockLabel: pg.wagfBlockLabel || pg._normalized?.wagfBlockLabel || '',
wagfRegionLabel: pg.wagfRegionLabel || pg._normalized?.wagfRegionLabel || '',
// Country fields — `countryName` is the unified decoded display label
country: pg.country,
countryIso: pg.countryIso,
countryIsoLabel: pg.countryIsoLabel,
countryName: pg.countryName,
// Religion fields — `religionName` is the unified decoded display label.
religion: pg.religion,
religionCode: pg.religionCode,
religionLabel: pg.religionLabel,
religionName: pg.religionName,
population: pg.population,
status: pg.status,
description: pg.description || '',
imageUrl: pg.imageUrl || '',
// peoplePraying: stored in feature properties for initial load path;
// after polling starts, Mapbox feature-state overrides color via setFeatureState().
peoplePraying: Number(pg.peoplePraying ?? pg._normalized?.peoplePraying ?? 0),
// engagementStatus / adoptionStatus: stored as booleans so Mapbox expressions work
engagementStatus: pg.engagementStatus === true || pg.engagementStatus === 1 || false,
adoptionStatus: pg.adoptionStatus === true || pg.adoptionStatus === 1 || false,
// Gospel resources for resource coloring (all 6 types)
bible: pg.bible || pg._normalized?.bible || 'Not Available',
jesusFilm: pg.jesusFilm || pg._normalized?.jesusFilm || 'Not Available',
radio: pg.radio || pg._normalized?.radio || 'Not Available',
gospel: pg.gospel || pg._normalized?.gospel || 'Not Available',
audio: pg.audio || pg._normalized?.audio || 'Not Available',
stories: pg.stories || pg._normalized?.stories || 'Not Available',
// lng stored as feature property so wave animation expressions can read it
lng: parseFloat(pg.longitude || pg.lng) || 0
},
geometry: {
type: 'Point',
coordinates: [parseFloat(pg.longitude || pg.lng), parseFloat(pg.latitude || pg.lat)]
}
})).filter(f =>
!isNaN(f.geometry.coordinates[0]) &&
!isNaN(f.geometry.coordinates[1])
);
// Offset co-located pins so they fan out in a circle instead of stacking.
// Radius is ~0.04 degrees (~4 km) — visible at zoom ≥ 5, invisible at world view.
const OFFSET_RADIUS_DEG = 0.04;
const coordKey = (f) => `${f.geometry.coordinates[0]},${f.geometry.coordinates[1]}`;
const groups = new Map();
for (const f of features) {
const k = coordKey(f);
if (!groups.has(k)) groups.set(k, []);
groups.get(k).push(f);
}
for (const bucket of groups.values()) {
if (bucket.length < 2) continue;
const [cx, cy] = bucket[0].geometry.coordinates;
const n = bucket.length;
for (let i = 0; i < n; i++) {
const angle = (2 * Math.PI * i) / n;
bucket[i].geometry.coordinates = [
cx + OFFSET_RADIUS_DEG * Math.cos(angle),
cy + OFFSET_RADIUS_DEG * Math.sin(angle)
];
}
}
const geojson = {
type: 'FeatureCollection',
features: features
};
// Add source — promoteId promotes properties.uniqueId → feature.id for setFeatureState()
map.addSource('language-families', {
type: 'geojson',
data: geojson,
promoteId: 'uniqueId'
});
// Shadow layer — rendered BEFORE pins for a subtle drop-shadow effect.
// Uses circle-blur + circle-translate to simulate depth with zero DOM overhead.
map.addLayer({
id: 'language-family-pins-shadow',
type: 'circle',
source: 'language-families',
paint: {
'circle-radius': getCircleRadiusInterpolation('standard'),
'circle-color': 'rgba(0,0,0,0.12)',
'circle-blur': 0.8,
'circle-translate': [0, 1],
'circle-stroke-width': 0,
'circle-opacity': 1
}
});
// Add the pins layer. Pins render fully opaque so they read clearly
// on top of the doxa-regions polygon fill (which is semi-transparent).
map.addLayer({
id: 'language-family-pins',
type: 'circle',
source: 'language-families',
paint: {
'circle-radius': getCircleRadiusInterpolation('standard'),
'circle-color': colorExpression,
// Mapbox Standard style v3 lights custom layers by the lightPreset — on the
// 'night' preset, circles WITHOUT an emissive strength get darkened to
// near-black. Setting it to 1 makes the pins render their OWN colour
// identically in day + night, so pins keep the same colour in dark mode
// (not black). (doxa dark-mode pin-colour fix on Standard style.)
...emissivePaint(map),
// Very thin / subtle hairline ring. Data-driven so the need-prayer (red) pins —
// the only ones that carry the DARK ring in light mode — get a noticeably THINNER
// hairline than the has-prayer (white-ring) pins (coder: "dark lines round the red
// pins are too thick"). Zoom interpolation is kept by putting the 'case' in the
// interpolate OUTPUTS (zoom-and-property — allowed); the reverse (zoom nested inside
// a 'case') is what breaks the layer, so don't do that.
'circle-stroke-width': PIN_STROKE_WIDTH_LIGHT,
// BOOT default = LIGHT-mode ring (has-prayer→white, need-prayer/red→subtle dark
// #1f2937). The profile's _updatePinStroke (theme-aware) overrides this on boot/
// toggle: DARK mode → subtle WHITE ring on ALL pins (no dark line in dark).
'circle-stroke-color': [
'case',
['>', ['to-number', ['get', 'peoplePraying'], 0], 0], '#ffffff',
'#1f2937'
],
'circle-opacity': 1,
'circle-stroke-opacity': 1,
'circle-opacity-transition': { duration: 250 }
}
});
// Invisible hitbox layer — larger transparent circles on top of pins
// for easier touch/click targeting (especially on mobile/tablet).
// Uses 'touchTarget' profile: 22px+ radius at zoom 4-8 for >=44px
// effective tap diameter (Apple HIG / Material minimum).
map.addLayer({
id: 'language-family-pins-hitbox',
type: 'circle',
source: 'language-families',
paint: {
'circle-radius': getCircleRadiusInterpolation('touchTarget'),
'circle-color': 'rgba(0,0,0,0)',
'circle-stroke-width': 0,
'circle-opacity': 0
}
});
layersAdded.value.languageFamilyPins = true;
// Attach event handlers (click for popup, cursor change on hover)
mapEvents.attachLanguageFamilyEvents();
}
/**
* Add regions polygon layer using Mapbox's built-in country boundaries
* vector tileset (mapbox.country-boundaries-v1). No external fetch —
* same CDN as the base map, vector tiles load only the visible viewport.
*
* @param {Object} regionsData - { isoToRegion: { 'AFG': 'asia', … } } (region SLUGs)
* @param {string} colorScheme - 'doxa-regions' | 'none'
*/
function addRegionsLayer(regionsData, colorScheme = 'doxa-regions') {
const map = getMap();
if (!map) return;
// If layers already exist (e.g. after a style reload) remove them first.
if (map.getLayer('regions-fill')) map.removeLayer('regions-fill');
if (map.getLayer('regions-border')) map.removeLayer('regions-border');
if (map.getSource('regions')) map.removeSource('regions');
// Keyed on the STABLE region slug (wagf_region.value) — loadRegionsData
// builds isoToRegion as ISO → slug. A label-keyed palette greys out every
// region except "Asia" once the API returns localized labels (locale bug).
const REGION_COLORS = {
'africa': '#ff2e2e',
'asia': '#1a8cff',
'europe': '#1ee05f',
'latin_america_&_caribbean': '#FFEB3B',
'middle_east': '#a83dff',
'north_america_&_non-spanish_caribbean': '#00e0bd',
'oceania': '#ff7f11',
};
const DEFAULT_COLOR = '#cccccc';
// Build a Mapbox `match` expression on iso_3166_1_alpha_3 (alpha-3).
// The mapbox.country-boundaries-v1 tileset exposes both iso_3166_1 (alpha-2)
// AND iso_3166_1_alpha_3 (alpha-3). The API gives us country_code as alpha-3
// (e.g. 'MYS', 'SDN') so we match on the alpha-3 property to avoid a lookup table.
const isoToRegion = (regionsData && regionsData.isoToRegion) ? regionsData.isoToRegion : {};
const matchPairs = [];
// Build regionIsoMap: region slug → array of ISO alpha-3 codes for that region.
const _regionIsoMap = {};
for (const [iso, region] of Object.entries(isoToRegion)) {
const color = REGION_COLORS[String(region).toLowerCase()];
if (color) matchPairs.push(iso, color);
if (!_regionIsoMap[region]) _regionIsoMap[region] = [];
_regionIsoMap[region].push(iso);
}
regionIsoMap.value = _regionIsoMap;
// If no mapping available fall back to a flat grey — map still renders.
const fillColorExpr = matchPairs.length
? ['match', ['get', 'iso_3166_1_alpha_3'], ...matchPairs, DEFAULT_COLOR]
: DEFAULT_COLOR;
// Insert regions UNDER the pins (and shadow) layer so pins stay on top + clickable.
// Prefer inserting before the shadow layer so z-order is: regions → shadow → pins → hitbox.
const beforeId = map.getLayer('language-family-pins-shadow')
? 'language-family-pins-shadow'
: map.getLayer('language-family-pins')
? 'language-family-pins'
: undefined;
// ── REGION POLYGONS, PER ENGINE ────────────────────────────────────────────────────
// ★ SECOND SITE OF THE SAME DEFECT. mapbox://mapbox.country-boundaries-v1 is a
// Mapbox-hosted VECTOR TILESET; MapLibre cannot fetch a mapbox:// URL and throws
// Fetch API cannot load mapbox://mapbox.country-boundaries-v1. URL scheme "mapbox"
// then renders everything else correctly with this fill simply MISSING. Fixing the
// country-OUTLINE composable cleaned doxa-simple-map and research-map but left
// countries-map (and the new country-posters-mfe that nests it) still throwing —
// measured, which is how this second site was found at all.
// The keyless equivalent already ships: assets/boundaries/world-adm0.geojson.
// Engine read off the live map (its container carries maplibregl-map), not guessed.
const _isMaplibre = (() => {
try { return !!map.getContainer?.()?.classList?.contains('maplibregl-map') } catch (_) { return false }
})();
if (_isMaplibre) {
// EMPTY first so the addLayer below stays synchronous and keeps its z-order slot
// (regions → shadow → pins → hitbox); setData once the fetch lands.
map.addSource('regions', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
fetch(`${resolveAssetsBase(null)}/boundaries/world-adm0.geojson`)
.then((r) => r.json())
.then((gj) => {
// Tileset field is iso_3166_1_alpha_3; the geojson's is iso3. Normalise ONCE
// so every colour/filter expression downstream reads the name it always read.
for (const f of gj.features || []) {
if (f.properties && f.properties.iso3) f.properties.iso_3166_1_alpha_3 = f.properties.iso3;
}
const src = map.getSource('regions');
if (src) src.setData(gj);
})
.catch((e) => console.warn('[map-layers] region boundaries failed to load', e));
} else {
map.addSource('regions', {
type: 'vector',
url: 'mapbox://mapbox.country-boundaries-v1'
});
}
map.addLayer({
id: 'regions-fill',
type: 'fill',
source: 'regions',
// VECTOR-TILE only; MapLibre rejects a layer declaring source-layer on geojson.
...(_isMaplibre ? {} : { 'source-layer': 'country_boundaries' }),
paint: {
'fill-color': fillColorExpr,
'fill-opacity': colorScheme === 'none' ? 0.1 : 0.7,
'fill-antialias': true,
// Standard-style night lighting darkens custom layers — emit full colour
// so region fills keep their colour in dark mode (doxa dark-mode fix).
'fill-emissive-strength': 1,
'fill-opacity-transition': { duration: 300 }
}
}, beforeId);
map.addLayer({
id: 'regions-border',
type: 'line',
source: 'regions',
'source-layer': 'country_boundaries',
paint: {
// Crisp per-region outline: each country border is drawn in its own
// region hue at full opacity. Internal (same-region) borders blend
// into the fill; region-to-region boundaries read as sharp saturated
// edges — this is what makes the fills look vibrant/sharp rather than
// washed-out. Falls back to the same grey as the fill for unmapped ISOs.
'line-color': fillColorExpr,
'line-width': 1,
'line-opacity': 0.9,
// Emit full colour so the border stays visible on the night preset.
'line-emissive-strength': 1
}
}, beforeId);
layersAdded.value.regions = true;
mapEvents.attachRegionEvents();
}
/**
* Add family connection lines (tapestry effect)
* Connects people groups of the same language family
*
* @param {string} familyName - Language family name
* @param {Array} peopleGroups - Array of people groups
*/
function addFamilyConnectionLines(familyName, peopleGroups) {
const map = getMap();
if (!map || !familyName || !peopleGroups?.length) return;
// Remove existing lines first
removeFamilyConnectionLines();
// Get all people groups in this family
const familyGroups = peopleGroups.filter(pg => {
const pgFamily = pg.languageFamily || pg._normalized?.languageFamily || 'Unknown';
return pgFamily === familyName;
});
if (familyGroups.length < 2) return;
// Get the color for this family
const familyColor = getLanguageFamilyColor(familyName);
// Create line features connecting nearby points
const lineFeatures = [];
const maxDistance = 15; // Maximum degrees distance
// Sort by longitude for efficient neighbor finding
const sorted = [...familyGroups].sort((a, b) =>
(a.longitude || a.lng) - (b.longitude || b.lng)
);
for (let i = 0; i < sorted.length; i++) {
const current = sorted[i];
const currLng = current.longitude || current.lng;
const currLat = current.latitude || current.lat;
for (let j = i + 1; j < Math.min(i + 15, sorted.length); j++) {
const other = sorted[j];
const otherLng = other.longitude || other.lng;
const otherLat = other.latitude || other.lat;
const distance = Math.sqrt(
Math.pow(currLng - otherLng, 2) +
Math.pow(currLat - otherLat, 2)
);
if (distance < maxDistance) {
lineFeatures.push({
type: 'Feature',
properties: { family: familyName },
geometry: {
type: 'LineString',
coordinates: [
[currLng, currLat],
[otherLng, otherLat]
]
}
});
}
}
}
// Add source and layer
map.addSource('family-connections', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: lineFeatures
}
});
map.addLayer({
id: 'family-connection-lines',
type: 'line',
source: 'family-connections',
paint: {
'line-color': familyColor,
'line-width': 0.75,
'line-opacity': 0.5,
// Emit full colour so connection lines stay visible on the night preset.
'line-emissive-strength': 1
}
}, 'language-family-pins'); // Add below the pins layer
layersAdded.value.familyConnections = true;
}
/**
* Remove family connection lines
*/
function removeFamilyConnectionLines() {
const map = getMap();
if (!map) return;
if (map.getLayer('family-connection-lines')) {
map.removeLayer('family-connection-lines');
}
if (map.getSource('family-connections')) {
map.removeSource('family-connections');
}
layersAdded.value.familyConnections = false;
}
/**
* Update layer source data
*/
function updateLayerSource(sourceId, data) {
const map = getMap();
if (!map) return;
const source = map.getSource(sourceId);
if (source) {
source.setData(data);
}
}
/**
* Remove a layer and its source
*/
function removeLayer(layerId, sourceId = null) {
const map = getMap();
if (!map) return;
if (map.getLayer(layerId)) {
map.removeLayer(layerId);
}
if (map.getSource(sourceId || layerId)) {
map.removeSource(sourceId || layerId);
}
}
/**
* Check if a layer exists
*/
function hasLayer(layerId) {
const map = getMap();
return map ? !!map.getLayer(layerId) : false;
}
/**
* Set paint property on a layer
*/
function setPaintProperty(layerId, property, value) {
const map = getMap();
if (!map || !map.getLayer(layerId)) return;
map.setPaintProperty(layerId, property, value);
}
/**
* Cleanup: detach all event handlers
* Call this before destroying the map
*/
function cleanup() {
mapEvents.detachAllEventHandlers();
_unwatchGlowVisibility();
}
// ── Prayer ripple animation (continuous WiFi-style waves) ─────────────────
// Solid colored disc behind pin + 4 ring layers in perpetual staggered
// expansion. Each ring is offset by 1/4 of the cycle so there is ALWAYS
// at least 2 rings visible — no gap, no pause. Rings emit continuously
// from the pin center and fade to transparent at max radius, like ripples
// on water or WiFi signal waves radiating outward.
//
// Pin radii: z0=3, z2=3.5, z4=4, z5=5, z6=6.5, z7=8, z8=10, z10=14, z12=18, z14=22
//
// Style-swap safety:
// map.setStyle() removes all custom layers + sources. The host profile
// already owns the post-style.load reconciliation path: it re-adds the
// pin source via addLanguageFamilyLayer, then calls startPrayerGlow()
// again. We DO NOT register our own style.load handler here — having
// two listeners fire on the same style.load creates a remove-then-add
// race against the host's reconciliation, which is the visible
// "flash" on theme toggle. Single source of truth = host's
// map.once('style.load') only. Our job is just to make sure that
// when startPrayerGlow() is called repeatedly we do NOT churn layers
// that are already correctly attached.
let _glowRafId = null;
let _glowActive = false;
// Monotonic counter — incremented each _scheduleGlowTick() invocation.
// Every tick closure remembers the epoch it was created under and
// self-terminates if a newer scheduler has incremented it. Belt to the
// cancelAnimationFrame suspenders inside _scheduleGlowTick().
let _glowEpoch = 0;
const GLOW_BASE = 'prayer-glow-base';
const RING_COUNT = 4;
const _RING_IDS = [];
for (let _ri = 0; _ri < RING_COUNT; _ri++) _RING_IDS.push('prayer-glow-ring-' + _ri);
const _GLOW_IDS = [GLOW_BASE, ..._RING_IDS];
const CYCLE_SEC = 6.0;
const MIN_SCALE = 0.3;
const MAX_SCALE = 2.5;
// Write rate of the ring animation. EVERY paint write makes Mapbox re-render the
// whole Standard style (hundreds of layers, 3D), so writing at rAF rate meant the
// map never idled and every frame was a candidate "[Violation] requestAnimationFrame
// handler took Nms" line (~100 per 15 s measured). 12 steps/s over a 6 s cycle is
// 72 steps per ripple — visually the same slow glow, 5× fewer renders.
const GLOW_FPS = 12;
const GLOW_STEP_MS = 1000 / GLOW_FPS;
// One render per write: the animated ring props opt OUT of Mapbox's default 300 ms
// paint transition, which would otherwise keep the map rendering at 60 fps for
// 300 ms after EVERY write and defeat the throttle above.
const NO_TRANSITION = { duration: 0, delay: 0 };
// Offscreen pause: an embedded map scrolled below the fold must not keep its host
// page's main thread busy. IntersectionObserver on the map container flips
// _glowVisible; the tick skips writes while hidden. No IO (old WebView) → visible.
let _glowVisible = true;
let _glowIO = null;
function _glowColorExpr() {
return [
'case',
['>=', ['get', 'peoplePraying'], FULL_PRAYER_THRESHOLD],
'#15803d',
['>', ['get', 'peoplePraying'], 0],
'#d97706',
'rgba(0,0,0,0)'
];
}
const _BASE_R = [0,5, 2,6, 4,7, 5,9, 6,12, 7,14, 8,18, 10,25, 12,32, 14,40];
function _scaledRadius(base, scale) {
const out = ['interpolate', ['linear'], ['zoom']];
for (let i = 0; i < base.length; i += 2) {
out.push(base[i], base[i+1] * scale);
}
return out;
}
function addPrayerGlowLayers() {
const map = getMap();
if (!map || !map.getSource('language-families')) return;
const prayerFilter = ['>', ['get', 'peoplePraying'], 0];
const color = _glowColorExpr();
// True idempotency: if ALL 5 glow layers already exist, do nothing.
// The previous remove-then-re-add path reset ring stroke-opacity
// and circle-radius to their addLayer defaults on every call,
// producing a visible flash on the next paint until the rAF tick
// wrote them back. Repeated calls to startPrayerGlow() (e.g. from
// tab switches, theme toggles, or a host calling it after style
// re-load) now leave already-attached layers alone — only the rAF
// baseline is reset by _scheduleGlowTick().
const allPresent = _GLOW_IDS.every(id => map.getLayer(id));
if (allPresent) return;
// Partial state: some layers exist, some don't. Tear down what's
// there so the re-add below produces a clean, consistent stack.
for (const id of _GLOW_IDS) {
if (map.getLayer(id)) {
try { map.removeLayer(id); } catch (_) {}
}
}
map.addLayer({
id: GLOW_BASE,
type: 'circle',
source: 'language-families',
filter: prayerFilter,
paint: {
'circle-radius': _scaledRadius(_BASE_R, 1.0),
'circle-color': color,
'circle-blur': 0.3,
'circle-opacity': 0.45,
'circle-stroke-width': 0,
// Standard-style night lighting darkens custom layers — emit full colour
// so the prayer glow stays visible in dark mode. (Lighting prop, NOT an
// animation value: opacity/radius/colour formulae are unchanged.)
...emissivePaint(map),
}
}, 'language-family-pins');
for (const ringId of _RING_IDS) {
map.addLayer({
id: ringId,
type: 'circle',
source: 'language-families',
filter: prayerFilter,
paint: {
'circle-radius': _scaledRadius(_BASE_R, 1.0),
'circle-radius-transition': NO_TRANSITION,
'circle-color': 'rgba(0,0,0,0)',
'circle-blur': 0,
'circle-opacity': 1,
// Constant for the life of the layer — the tick used to rewrite
// stroke-width 2.5 and blur 0 on every frame; set once here instead.
'circle-stroke-width': 2.5,
'circle-stroke-color': color,
'circle-stroke-opacity': 0,
'circle-stroke-opacity-transition': NO_TRANSITION,
// Standard-style night lighting darkens custom layers — emit full
// colour so the ring stroke stays visible in dark mode. (Lighting
// prop, NOT an animation value.)
...emissivePaint(map),
}
}, 'language-family-pins');
}
}
function removePrayerGlowLayers() {
const map = getMap();
if (!map) return;
for (const id of _GLOW_IDS) {
if (map.getLayer(id)) map.removeLayer(id);
}
}
function syncGlowFilter(extraFilter) {
const map = getMap();
if (!map) return;
const base = ['>', ['get', 'peoplePraying'], 0];
const filter = extraFilter ? ['all', base, extraFilter] : base;
for (const id of _GLOW_IDS) {
if (map.getLayer(id)) {
try { map.setFilter(id, filter); } catch (_) {}
}
}
}
function startPrayerGlow() {
const map = getMap();
if (!map) return;
addPrayerGlowLayers();
// Cancel any pending rAF before scheduling a new one. Single source
// of truth for the animation timer ID — guarantees exactly one
// tick loop is ever running, even when the host calls
// startPrayerGlow() multiple times (tab switch, theme toggle,
// initial mount, etc.). No internal style.load listener is
// registered: the host profile's own map.once('style.load') is
// the only place that re-adds the pin source after a setStyle and
// re-invokes startPrayerGlow(). Adding a second listener here
// produced a paint-property race against the host's re-add path,
// visible as a periodic brightness flash on every prayer dot.
if (_glowRafId) {
cancelAnimationFrame(_glowRafId);
_glowRafId = null;
}
_glowActive = true;
if (map.getLayer('language-family-pins')) {
try {
map.setLayoutProperty('language-family-pins', 'circle-sort-key', [
'case',
['>=', ['get', 'peoplePraying'], FULL_PRAYER_THRESHOLD], 1,
['>', ['get', 'peoplePraying'], 0], 1,
3
]);
} catch (_) {}
}
_watchGlowVisibility(map);
_scheduleGlowTick();
}
// Internal helper: spins up a fresh rAF tick with a fresh `t0` baseline.
//
// Concurrency contract:
// Each call cancels any in-flight rAF before scheduling a new one
// and bumps an epoch token; the tick closure self-terminates if a
// newer scheduler has superseded it. Belt-and-suspenders against
// a stray frame that was enqueued before the cancel landed.
function _scheduleGlowTick() {
// (1) Hard cancel any pending rAF — single source of truth for the
// timer ID. Load-bearing for the duplicate-tick-leak fix.
if (_glowRafId) {
cancelAnimationFrame(_glowRafId);
_glowRafId = null;
}
// (2) Epoch stamp — older closures still referencing a stale
// epoch will exit on their next frame instead of writing
// paint properties.
const myEpoch = ++_glowEpoch;
let t0 = performance.now();
let lastWrite = -Infinity;
function tick(now) {
if (!_glowActive) return;
if (myEpoch !== _glowEpoch) return; // superseded by newer scheduler
const m = getMap();
if (!m || !_GLOW_IDS.some(id => m.getLayer(id))) {
// Layers gone (mid style swap). Stop scheduling new frames;
// _glowActive stays true so the host's post-style.load
// call to startPrayerGlow() will restart this tick chain
// once the layers come back.
_glowRafId = null;
return;
}
// Throttle + offscreen pause: no write (and therefore no Mapbox
// render) until the next step is due, or while the map is
// scrolled out of view. The rAF chain itself is kept — a no-op
// callback costs nothing and the browser already pauses it in
// hidden tabs; the phase is clock-based, so a resumed ring lands
// where it would have been, never restarts.
if (now - lastWrite < GLOW_STEP_MS || !_glowVisible) {
_glowRafId = requestAnimationFrame(tick);
return;
}
lastWrite = now;
const elapsed = (now - t0) / 1000;
for (let i = 0; i < RING_COUNT; i++) {
const ringId = _RING_IDS[i];
if (!m.getLayer(ringId)) continue;
const delay = i * (CYCLE_SEC / RING_COUNT);
const t = ((elapsed - delay) % CYCLE_SEC + CYCLE_SEC) % CYCLE_SEC;
const phase = t / CYCLE_SEC;
const radiusScale = MIN_SCALE + phase * (MAX_SCALE - MIN_SCALE);
const opacity = 0.5 * (1.0 - phase);
// Only the two values that actually move; stroke-width/blur are
// constants set in addPrayerGlowLayers().
try {
m.setPaintProperty(ringId, 'circle-radius', _scaledRadius(_BASE_R, radiusScale));
m.setPaintProperty(ringId, 'circle-stroke-opacity', opacity);
} catch (_) {}
}
// Schedule next frame LAST, after work, so _glowRafId always
// reflects the most recently scheduled frame for this chain.
_glowRafId = requestAnimationFrame(tick);
}
_glowRafId = requestAnimationFrame(tick);
}
// Offscreen pause plumbing (see _glowVisible). One observer per composable;
// startPrayerGlow() may be called many times, stopPrayerGlow() disconnects.
function _watchGlowVisibility(map) {
if (_glowIO || typeof IntersectionObserver === 'undefined') return;
const el = typeof map.getContainer === 'function' ? map.getContainer() : null;
if (!el) return;
try {
_glowIO = new IntersectionObserver((entries) => {
for (const e of entries) _glowVisible = e.isIntersecting;
}, { threshold: 0 });
_glowIO.observe(el);
} catch (_) { _glowIO = null; _glowVisible = true; }
}
function _unwatchGlowVisibility() {
if (_glowIO) { try { _glowIO.disconnect(); } catch (_) {} _glowIO = null; }
_glowVisible = true;
}
function stopPrayerGlow() {
_glowActive = false;
if (_glowRafId) { cancelAnimationFrame(_glowRafId); _glowRafId = null; }
_unwatchGlowVisibility();
// No persistent style.load listener to detach — the host profile
// owns the post-style.load reconciliation path.
try {
removePrayerGlowLayers();
const map = getMap();
if (map && map.getLayer('language-family-pins')) {
map.setLayoutProperty('language-family-pins', 'circle-sort-key', undefined);
}
} catch (_) {}
}
return {
// State
layersAdded,
regionIsoMap,
// Layer creation (includes event handler attachment)
addLanguageFamilyLayer,
addRegionsLayer,
addFamilyConnectionLines,
removeFamilyConnectionLines,
// Layer management
updateLayerSource,
removeLayer,
hasLayer,
setPaintProperty,
// Event management (exposed for advanced usage)
mapEvents,
// Prayer glow animation
startPrayerGlow,
stopPrayerGlow,
syncGlowFilter,
// Cleanup
cleanup
};
}
// ES Module export