doxa-maps/library/composables/useMapData.js
Doxa Maps a7c9e9557f The prayer map's legends see the pins it draws
rega, row 46 at 835ec1ac: on the prayer map neither the shared-point sentence nor the standing pin note
appeared. research-map and countries-map provide normalizedPeopleGroups to their legends; the prayer map
did not, so its legends fell back to dataStore's cache, which holds the RAW API rows: field names the
co-location index cannot read, and no GeoJSON geometry for the standing-note detector to find.

useMapData now provides the normalized rows it draws, so every map built on it offers them. LegendMobile
and SemanticTreeLegend read them first (the cache stays the fallback), and the detector accepts a row with a
name and a position as well as a GeoJSON point. check:approx-pin-note asserts the provide and the
normalized-row match; the provide check fails on the previous useMapData.
2026-09-27 02:17:35 +00:00

591 lines
26 KiB
JavaScript

// useMapData.js - Map Data Loading Composable
// Handles DataSourceManager integration, data loading, and normalization
// Vue 3 Composition API - ES Module with NPM packages
// Import Vue functions from NPM package
import { ref, computed, provide, getCurrentInstance } from 'vue';
import { applyLocalPrayer } from './usePrayedLocally.js';
// Static lookup: language label (e.g. "Telugu") → language family ("Dravidian").
// Used to derive `languageFamily` for pray-tools rows where the upstream returns
// null for `imb_language_family` (true fo
import langFamilyByLanguage from '../data/langFamilyByLanguage.json';
// ISO 639-3 code → language family. The pray-tools API localizes
// primary_language.label ("Telugu" → "Télugu" → "Télougou"), so deriving the
// family from the display label silently fails in every non-English locale and
// collapses pins onto the 'Unknown' bucket (one flat color). primary_language.value
// is the stable ISO code, so we derive the family from the code first.
// Generated from the EN API + langFamilyByLanguage.json — see data/README if regenerating.
import langFamilyByIso from '../data/langFamilyByIso.json';
import { canonicalFamilyName } from '../colors/language-family.js';
import { getColorStrategy, COLOR_MODES } from '../colors/_registry.js';
// Normalize pray-tools wagf_region values ("asia", "latin_america_&_caribbean",
// "na") onto canonical doxa-region keys so pin GeoJSON properties match the
// legend's row keys for dim-filter expressions.
//
// doxa-region is a PER-MAP (research-only) strategy — it registers into the
// registry via the research bundle's local src/colors glob. Built LAZILY
// so it reads the palette AFTER registration; guarded so on bundles where region
// is NOT registered (simple/template) getColorStrategy's language-family fallback
// doesn't leak wrong keys (propertyKey check), leaving the alias map empty (the
// pre-existing behavior for unmatched regions).
let _regionAliasCache = null;
function regionKeyAliases() {
if (_regionAliasCache) return _regionAliasCache;
const norm = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '');
const map = {};
const strat = getColorStrategy(COLOR_MODES.DOXA_REGION);
if (strat && strat.propertyKey === 'doxaRegion' && strat.palette) {
for (const k of Object.keys(strat.palette)) map[norm(k)] = k;
}
map['na'] = 'No WAGF Region/Bloc';
_regionAliasCache = map;
return map;
}
function canonicalizeRegion(raw) {
if (!raw) return '';
if (typeof raw === 'object') raw = raw.label || raw.value || '';
const normalized = String(raw).toLowerCase().replace(/[^a-z0-9]+/g, '');
return regionKeyAliases()[normalized] || String(raw).trim();
}
function unwrapLabel(v) {
if (v == null) return '';
if (typeof v === 'object') return v.label || v.value || '';
return String(v);
}
// Suffix groups: families derivable from the language name's tail when no
// langFamilyByLanguage entry exists (e.g. "Pakistan Sign Language" → "Sign
// Language" family). Mirrors the legend's FAMILY_SUFFIXES.
const FAMILY_SUFFIXES_DATA = [
[/ sign language$/i, 'Sign Language'],
];
function deriveLanguageFamily(pg) {
const existing = unwrapLabel(pg.languageFamily);
if (existing) return existing;
// Stable ISO 639-3 code path (locale-independent). primary_language.value is
// the ISO code; its label is localized, so the family MUST be derived from the
// code, not the display label, or the languageFamily color strategy breaks on
// every non-English locale (qa: 2026-06-22 locale color bug).
const iso = String(
pg._raw?.primary_language?.value ||
(pg.language && typeof pg.language === 'object' ? pg.language.value : '') ||
''
).trim().toLowerCase();
if (iso && langFamilyByIso[iso]) return langFamilyByIso[iso];
const langLabel = unwrapLabel(pg.language || pg.primary_language);
if (!langLabel || typeof langLabel !== 'string') return '';
const lbl = langLabel.trim();
// Comma-inverted lookup: API returns "Arabic, Sudanese" but
// langFamilyByLanguage keys are "Sudanese Arabic" (standard form). Reverse
// the comma-separated parts and join with space, then look up.
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];
}
}
// Direct lookup (single-word labels like "Bengali")
if (langFamilyByLanguage[lbl]) return langFamilyByLanguage[lbl];
// Strip parenthetical: "Ainu (China)" → "Ainu"
const stripped = lbl.replace(/\s*\(.*?\)\s*$/, '').trim();
if (stripped !== lbl && langFamilyByLanguage[stripped]) return langFamilyByLanguage[stripped];
// Suffix groups (sign languages, etc.)
for (const [re, base] of FAMILY_SUFFIXES_DATA) {
if (re.test(lbl)) return base;
}
return '';
}
/**
* useMapData composable - manages data loading and normalization for maps
*
* NOTE: dataSourceManager must be passed in since it's loaded via ES module import.
*
* @param {Object} options - Configuration options
* @param {string} options.mapId - Unique map identifier for logging
* @param {string} options.dataSourceId - Data source ID (e.g., 'doxa-csv', 'doxa-api')
* @param {Object} options.dataSourceManager - The dataSourceManager instance (required)
* @param {Object} options.dataStore - Optional Pinia dataStore instance for caching
* @param {Function} options.markRaw - Vue markRaw function to prevent reactivity on large arrays
*
* @returns {Object} { loadData, peopleGroups, normalizedPeopleGroups, regionsData, languageFamiliesData, doxaRegionsData, isLoading, error }
*/
export function useMapData(options) {
const {
mapId = 'unknown',
dataSourceId = 'doxa-csv',
dataSourceManager = null, // Must be passed in!
dataStore = null,
markRaw = (x) => x // Default to identity function if not provided
} = options;
// Validate required dependency
if (!dataSourceManager) {
console.error('useMapData: dataSourceManager is required!');
}
// Reactive state
const isLoading = ref(false);
const error = ref(null);
const isInitialized = ref(false);
// Data arrays (will be marked as raw for performance)
const peopleGroups = ref([]);
const normalizedPeopleGroups = ref([]);
// THE ROWS THE MAP DRAWS, offered to the legends below it. research-map and countries-map provided
// this themselves; the prayer map did not, so its legends fell back to the raw API cache, whose field
// names the pin-note co-location index cannot read, and the shared-point sentence and the standing
// pin note never showed there (rega, row 46). Providing it here covers every map built on useMapData;
// a component that also provides it passes this same ref.
if (getCurrentInstance()) provide('normalizedPeopleGroups', normalizedPeopleGroups);
const regionsData = ref(null);
const languageFamiliesData = ref([]);
const doxaRegionsData = ref([]);
const resourcesData = ref([]);
// Statistics
const stats = ref(null);
/**
* Initialize and load all data from the configured source
* This is the main entry point for data loading
*
* @returns {Promise<Object>} Loaded data object
*/
async function loadData() {
if (!dataSourceManager) {
throw new Error('dataSourceManager is required for useMapData');
}
isLoading.value = true;
error.value = null;
try {
// Initialize the data source manager (singleton)
await dataSourceManager.init();
// Load from dataStore cache if available, otherwise from DataSourceManager
if (dataStore && dataStore.hasSourceData && dataStore.hasSourceData(dataSourceId)) {
const cached = dataStore.getSourceData(dataSourceId);
normalizedPeopleGroups.value = markRaw(cached.normalizedData || []);
} else {
// Load fresh data from DataSourceManager
const rawData = await dataSourceManager.setActiveSource(dataSourceId);
// Backfill languageFamily from primary_language label — upstream API
// returns `imb_language_family: null` for every row today.
// Also flatten language to its display label so legend keys
// (label form) match GeoJSON properties used in dim filters.
// canonicalFamilyName() collapses case-different variants onto
// the one proper-case key from LANGUAGE_FAMILY_COLORS so legend
// setFilter('Sign Language') matches pins with 'Sign language'.
for (const pg of rawData) {
const langLabel = unwrapLabel(pg.language || pg.primary_language);
if (langLabel) pg.language = langLabel;
const fam = deriveLanguageFamily(pg);
pg.languageFamily = canonicalFamilyName(fam);
}
normalizedPeopleGroups.value = markRaw(rawData);
// Cache in dataStore if available
if (dataStore && dataStore.cacheSourceData) {
dataStore.cacheSourceData(dataSourceId, {
normalizedData: rawData,
timestamp: Date.now()
});
}
}
// Build backward-compatible peopleGroups array
peopleGroups.value = markRaw(applyLocalPrayer(normalizedPeopleGroups.value.map(pg => ({
id: pg.uniqueId,
slug: pg.slug || '',
rop3: pg.peopleGroupId,
peopleId3: pg.peopleId3,
name: pg.name,
language: pg.language,
languageFamily: pg.languageFamily,
country: pg.country,
population: pg.population,
status: pg.engagementStatus || pg.status,
latitude: pg.latitude,
longitude: pg.longitude,
lng: pg.longitude,
lat: pg.latitude,
description: pg.description,
imageUrl: pg.imageUrl,
doxaRegion: pg.doxaRegion || pg.wagfRegion || '',
peoplePraying: pg.peoplePraying ?? null,
_normalized: pg // Keep reference to normalized data
}))));
// Build language families data from normalized data
const familyGroups = dataSourceManager.getDataByLanguageFamily();
languageFamiliesData.value = Object.entries(familyGroups)
.map(([name, groups]) => {
// Sum population from all people groups in this language family
const totalPopulation = groups.reduce((sum, pg) => {
const pop = parseInt(pg.population) || 0;
return sum + pop;
}, 0);
return {
name,
count: groups.length,
peopleGroupCount: groups.length,
population: totalPopulation,
groups
};
})
.sort((a, b) => b.count - a.count);
// Build DOXA regions data with people group counts and population
const regionGroups = dataSourceManager.getDataByRegion();
doxaRegionsData.value = Object.entries(regionGroups)
.map(([name, groups]) => {
// Sum population from all people groups in this region
const totalPopulation = groups.reduce((sum, pg) => {
const pop = parseInt(pg.population) || 0;
return sum + pop;
}, 0);
return {
name,
count: groups.length,
peopleGroupCount: groups.length,
population: totalPopulation,
groups
};
})
.sort((a, b) => a.name.localeCompare(b.name));
// Build gospel resources data - INCLUSIVE counts (each UPG counted in ALL resources it has)
const resourceCounts = {
bible: { count: 0, population: 0, groups: [] },
jesusFilm: { count: 0, population: 0, groups: [] },
radio: { count: 0, population: 0, groups: [] },
gospel: { count: 0, population: 0, groups: [] },
audio: { count: 0, population: 0, groups: [] },
stories: { count: 0, population: 0, groups: [] },
hasResources: { count: 0, population: 0, groups: [] },
noResources: { count: 0, population: 0, groups: [] }
};
normalizedPeopleGroups.value.forEach(pg => {
const pop = parseInt(pg.population) || 0;
let hasAnyResource = false;
// Check each resource - INCLUSIVE counting (can be in multiple)
if (pg.bible === 'Available') {
resourceCounts.bible.count++;
resourceCounts.bible.population += pop;
resourceCounts.bible.groups.push(pg);
hasAnyResource = true;
}
if (pg.jesusFilm === 'Available') {
resourceCounts.jesusFilm.count++;
resourceCounts.jesusFilm.population += pop;
resourceCounts.jesusFilm.groups.push(pg);
hasAnyResource = true;
}
if (pg.radio === 'Available') {
resourceCounts.radio.count++;
resourceCounts.radio.population += pop;
resourceCounts.radio.groups.push(pg);
hasAnyResource = true;
}
if (pg.gospel === 'Available') {
resourceCounts.gospel.count++;
resourceCounts.gospel.population += pop;
resourceCounts.gospel.groups.push(pg);
hasAnyResource = true;
}
if (pg.audio === 'Available') {
resourceCounts.audio.count++;
resourceCounts.audio.population += pop;
resourceCounts.audio.groups.push(pg);
hasAnyResource = true;
}
if (pg.stories === 'Available') {
resourceCounts.stories.count++;
resourceCounts.stories.population += pop;
resourceCounts.stories.groups.push(pg);
hasAnyResource = true;
}
// Has Resources = has ANY of the above (inverse of noResources)
if (hasAnyResource) {
resourceCounts.hasResources.count++;
resourceCounts.hasResources.population += pop;
resourceCounts.hasResources.groups.push(pg);
} else {
// No resources = has NONE of the above
resourceCounts.noResources.count++;
resourceCounts.noResources.population += pop;
resourceCounts.noResources.groups.push(pg);
}
});
// Build resourcesData as TREE structure with parent/children
const totalUPGs = normalizedPeopleGroups.value.length;
const totalPopulation = normalizedPeopleGroups.value.reduce((sum, pg) => sum + (parseInt(pg.population) || 0), 0);
const resourceNames = {
bible: 'Bible',
jesusFilm: 'Jesus Film',
radio: 'Radio',
gospel: 'Gospel',
audio: 'Audio',
stories: 'Stories',
hasResources: 'Has Resources',
noResources: 'No Resources'
};
// Calculate "without" counts (total - with = without)
const withoutCounts = {
bible: { count: totalUPGs - resourceCounts.bible.count, population: totalPopulation - resourceCounts.bible.population },
jesusFilm: { count: totalUPGs - resourceCounts.jesusFilm.count, population: totalPopulation - resourceCounts.jesusFilm.population },
radio: { count: totalUPGs - resourceCounts.radio.count, population: totalPopulation - resourceCounts.radio.population },
gospel: { count: totalUPGs - resourceCounts.gospel.count, population: totalPopulation - resourceCounts.gospel.population },
audio: { count: totalUPGs - resourceCounts.audio.count, population: totalPopulation - resourceCounts.audio.population },
stories: { count: totalUPGs - resourceCounts.stories.count, population: totalPopulation - resourceCounts.stories.population }
};
// Build tree: 6 resources with "No X" children, then "Has Resources" with "No Resources" child
const BLACK_COLOR = '#2c3e50'; // All "No X" children use black
resourcesData.value = [
// 6 specific resources - each with "No [Resource]" child
{
name: resourceNames.bible,
key: 'bible',
count: resourceCounts.bible.count,
population: resourceCounts.bible.population,
isParent: true,
children: [{
name: 'No Bible',
key: 'noBible',
count: withoutCounts.bible.count,
population: withoutCounts.bible.population,
color: BLACK_COLOR
}]
},
{
name: resourceNames.jesusFilm,
key: 'jesusFilm',
count: resourceCounts.jesusFilm.count,
population: resourceCounts.jesusFilm.population,
isParent: true,
children: [{
name: 'No Jesus Film',
key: 'noJesusFilm',
count: withoutCounts.jesusFilm.count,
population: withoutCounts.jesusFilm.population,
color: BLACK_COLOR
}]
},
{
name: resourceNames.radio,
key: 'radio',
count: resourceCounts.radio.count,
population: resourceCounts.radio.population,
isParent: true,
children: [{
name: 'No Radio',
key: 'noRadio',
count: withoutCounts.radio.count,
population: withoutCounts.radio.population,
color: BLACK_COLOR
}]
},
{
name: resourceNames.gospel,
key: 'gospel',
count: resourceCounts.gospel.count,
population: resourceCounts.gospel.population,
isParent: true,
children: [{
name: 'No Gospel',
key: 'noGospel',
count: withoutCounts.gospel.count,
population: withoutCounts.gospel.population,
color: BLACK_COLOR
}]
},
{
name: resourceNames.audio,
key: 'audio',
count: resourceCounts.audio.count,
population: resourceCounts.audio.population,
isParent: true,
children: [{
name: 'No Audio',
key: 'noAudio',
count: withoutCounts.audio.count,
population: withoutCounts.audio.population,
color: BLACK_COLOR
}]
},
{
name: resourceNames.stories,
key: 'stories',
count: resourceCounts.stories.count,
population: resourceCounts.stories.population,
isParent: true,
children: [{
name: 'No Stories',
key: 'noStories',
count: withoutCounts.stories.count,
population: withoutCounts.stories.population,
color: BLACK_COLOR
}]
},
// "Has Resources" with "No Resources" as child
{
name: resourceNames.hasResources,
key: 'hasResources',
count: resourceCounts.hasResources.count,
population: resourceCounts.hasResources.population,
isParent: true,
children: [{
name: resourceNames.noResources,
key: 'noResources',
count: resourceCounts.noResources.count,
population: resourceCounts.noResources.population,
color: BLACK_COLOR
}]
}
];
// Get stats
stats.value = dataSourceManager.getStats();
isInitialized.value = true;
return {
peopleGroups: peopleGroups.value,
normalizedPeopleGroups: normalizedPeopleGroups.value,
languageFamiliesData: languageFamiliesData.value,
doxaRegionsData: doxaRegionsData.value,
resourcesData: resourcesData.value,
stats: stats.value
};
} catch (err) {
console.error(`Failed to load data for map "${mapId}":`, err);
error.value = err.message || 'Failed to load data';
throw err;
} finally {
isLoading.value = false;
}
}
/**
* Build the country→wagfRegion lookup from already-loaded people-group data.
* Returns a plain object: { 'AF': 'Asia', 'NG': 'Africa', … }
*
* Uses `mapbox.country-boundaries-v1` vector tileset for polygons — no fetch
* needed here. This function just derives the ISO alpha-2 → region label map
* that addRegionsLayer feeds into the Mapbox match expression.
*
* @returns {Object} countryIso → wagfRegion label
*/
async function loadRegionsData() {
try {
// Build iso→region from the people groups that are already in memory.
// countryIso = country_code alpha-3 (e.g. 'MYS').
// addRegionsLayer matches against iso_3166_1_alpha_3 on the Mapbox tileset.
const isoToRegion = {}
const pgs = normalizedPeopleGroups.value || []
for (const pg of pgs) {
const iso = pg.countryIso || pg._raw?.country_code?.value || ''
const _rawRegion = pg._raw?.wagf_region
// Map ISO → stable region SLUG (locale-independent), NOT the
// localized label. addRegionsLayer colors polygons from this map
// via a slug-keyed palette; using the label would grey out every
// region except "Asia" once the user switches language.
const region = pg.doxaRegion || pg.wagfRegion ||
(_rawRegion && typeof _rawRegion === 'object' ? _rawRegion.value : '') || ''
if (iso && region && !isoToRegion[iso]) isoToRegion[iso] = region
}
// Wrap in the shape addRegionsLayer expects.
regionsData.value = { isoToRegion }
return regionsData.value
} catch (err) {
console.error(`Error building regions data for map "${mapId}":`, err)
error.value = err.message || 'Failed to build regions'
throw err
}
}
/**
* Get data grouped by language family from DataSourceManager
*/
function getDataByLanguageFamily() {
if (!dataSourceManager) return {};
return dataSourceManager.getDataByLanguageFamily();
}
/**
* Get data grouped by region from DataSourceManager
*/
function getDataByRegion() {
if (!dataSourceManager) return {};
return dataSourceManager.getDataByRegion();
}
function getPeopleGroupsByFamily(familyName) {
const byFamily = getDataByLanguageFamily();
return byFamily[familyName] || [];
}
function getPeopleGroupsByRegion(regionName) {
const byRegion = getDataByRegion();
return byRegion[regionName] || [];
}
/**
* Clear all loaded data (for cleanup or refresh)
*/
function clearData() {
peopleGroups.value = [];
normalizedPeopleGroups.value = [];
regionsData.value = null;
languageFamiliesData.value = [];
doxaRegionsData.value = [];
resourcesData.value = [];
stats.value = null;
isInitialized.value = false;
error.value = null;
}
return {
// Reactive State
isLoading,
isInitialized,
error,
// Data Arrays (reactive refs, but content is marked as raw)
peopleGroups,
normalizedPeopleGroups,
regionsData,
languageFamiliesData,
doxaRegionsData,
resourcesData,
stats,
// Methods
loadData,
loadRegionsData,
getDataByLanguageFamily,
getDataByRegion,
getPeopleGroupsByFamily,
getPeopleGroupsByRegion,
clearData
};
}
// ES Module export