// 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). ...emissivePaint(map, 'fill'), 'fill-opacity-transition': { duration: 300 } } }, beforeId); map.addLayer({ id: 'regions-border', type: 'line', source: 'regions', // Same rule as regions-fill above: VECTOR-TILE only. This sibling kept the literal // and so was still dropped by MapLibre after the fill was fixed — // Source layer "country_boundaries" does not exist on source // which surfaces ONLY on the map's error event, never from addLayer(). ...(_isMaplibre ? {} : { '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. ...emissivePaint(map, 'line') } }, 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. ...emissivePaint(map, 'line') } }, '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