895 lines
39 KiB
JavaScript
895 lines
39 KiB
JavaScript
// 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 } from 'vue';
|
|
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 = () => null,
|
|
mapId = 'unknown',
|
|
getLanguageFamilyColor = () => '#999999',
|
|
getNormalizedPeopleGroups = () => []
|
|
} = options;
|
|
|
|
// 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;
|
|
|
|
map.addSource('regions', {
|
|
type: 'vector',
|
|
url: 'mapbox://mapbox.country-boundaries-v1'
|
|
});
|
|
|
|
map.addLayer({
|
|
id: 'regions-fill',
|
|
type: 'fill',
|
|
source: 'regions',
|
|
'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();
|
|
}
|
|
|
|
// ── 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;
|
|
|
|
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-color': 'rgba(0,0,0,0)',
|
|
'circle-blur': 0,
|
|
'circle-opacity': 1,
|
|
'circle-stroke-width': 2,
|
|
'circle-stroke-color': color,
|
|
'circle-stroke-opacity': 0,
|
|
// 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 (_) {}
|
|
}
|
|
|
|
_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();
|
|
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;
|
|
}
|
|
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);
|
|
|
|
try {
|
|
m.setPaintProperty(ringId, 'circle-radius', _scaledRadius(_BASE_R, radiusScale));
|
|
m.setPaintProperty(ringId, 'circle-stroke-opacity', opacity);
|
|
m.setPaintProperty(ringId, 'circle-stroke-width', 2.5);
|
|
m.setPaintProperty(ringId, 'circle-blur', 0);
|
|
} 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);
|
|
}
|
|
|
|
function stopPrayerGlow() {
|
|
_glowActive = false;
|
|
if (_glowRafId) { cancelAnimationFrame(_glowRafId); _glowRafId = null; }
|
|
// 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
|