Every map showed DOXA's people groups, and only those. The same maps must serve other projects'
lists: Project 7000 (unreached), 4700 (frontier), 700 (frontier of 500,000 or more), 2000 (DOXA).
The list is now chosen the way the engine is:
profile-config `list` > ?list= > window.MAP_LIST > 'doxa' (exactly today's behaviour)
with ids or project aliases ('700', 'project-7000').
- library/lists/lists.json: each list is a FILTER over the IMB / peoplegroups.org dataset already
in the repo (Lostness Priority, Population, Engagement Status), never a hand-kept roster.
- library/scripts/people-groups/build-lists.mjs (`bun run lists`): writes each list's DOXA members
to data/people-groups/lists/<id>.json, joined the way the Vision Card funnel joins, so the
counts agree with it: unreached 7,341 (2,082 DOXA), frontier 4,754 (1,745), frontier-500k 737
(71), unengaged 2,982 (1,929).
- library/lists/peopleGroupList.js: resolvePeopleGroupList and applyPeopleGroupList(rows, cfg).
The default list returns the same array, unfetched. A list whose membership cannot load shows no
groups (fails closed) rather than DOXA's whole list under another list's name.
- Tier A, every production map: DataSourceManager applies it (and the maps that build one pass
their profileConfig), and so does every direct people-group fetch: people-groups-grid, the
Big Picture header, research-dashboard (data, CSV export; the DOXA-wide /statistics baselines
are skipped under another list), Regions (list, dashboards, their table), UPG-100 (4 fetches +
the poster wall), Gospel's roster, and the Experimental upg-neighbor and Wall. The composites
thread `list` to their children like `provider`.
Gates: check:universal-props gains a `list` column (LIST-FETCH, LIST-DSM, LIST-KEYS; its
self-test now plants 9 bypasses) and passes across 18 bundles, with all-people-groups allowed:
it draws the IMB dataset, so its list is Tier B. New check:list-prop-unit (Node, 17 cases, in
gates:post); poisoning the ladder order or deleting a membership file fails it. Documented in
documentation/reference-universal-props.md section 5.
Headless: all 14 touched bundles build; check:composites-mount 20/20 and check:composite-interop
17 links pass on this tree; no-orphan-gates, script-flags, quoted-scripts, doc-scripts pass. Not
rendered in a browser.
465 lines
18 KiB
JavaScript
465 lines
18 KiB
JavaScript
/**
|
|
* DataSourceManager.js
|
|
*
|
|
* Loads people-group data from one of three source types declared in
|
|
* api/sources.json:
|
|
* - csv → fetch + parse a local CSV file
|
|
* - api → fetch a single JSON URL
|
|
* - rest-api → fetch a versioned REST endpoint (pray-tools shape) with
|
|
* live query params, no-store cache, and cache-buster
|
|
*
|
|
* Normalizes raw rows to system field names so map/legend/detail components
|
|
* stay source-agnostic. Keeps a per-source cache so tab switches don't
|
|
* re-parse / re-fetch.
|
|
*
|
|
* sources.json is inlined via static import at build time — no runtime
|
|
* fetch is needed for config (works inside an IIFE bundle that has no /src/).
|
|
*
|
|
* NOTE — framework version: this file is the template's lean port of the
|
|
* production DataSourceManager from doxa-map-mfe. The doxa-map-mfe variant
|
|
* additionally imports a vue-i18n locale module and ISO3/ROR lookup tables
|
|
* for label decoding. The template intentionally omits both — apps that
|
|
* need locale-aware labels can wrap this class or replace it.
|
|
*/
|
|
|
|
import sourcesConfig from './sources.json'
|
|
import { getApiBaseUrl } from './apiBaseUrl.js'
|
|
import { applyPeopleGroupList } from '../lists/peopleGroupList.js'
|
|
|
|
export class DataSourceManager {
|
|
constructor(options = {}) {
|
|
this.sourcesConfig = null;
|
|
this.activeSource = null;
|
|
this.rawData = [];
|
|
this.normalizedData = [];
|
|
this.dataByUniqueId = new Map();
|
|
this.baseUrl = (typeof window !== 'undefined' && window.MAP_APP_BASE_URL) || './';
|
|
|
|
// Active UI locale (e.g. 'fr'). When set, overrides a rest-api source's
|
|
// static `queryParams.lang` so the API returns translated field values.
|
|
// Unset → the source's own `lang` (sources.json defaults it to 'en').
|
|
this.locale = options.locale || null;
|
|
|
|
// The map's profile-config (object or ref), for its people-group LIST (library/lists/). Unset
|
|
// still honours ?list= and window.MAP_LIST; the default list keeps every row, unfetched.
|
|
this.profileConfig = options.profileConfig || null;
|
|
|
|
// CACHE: Store normalized data per source to avoid re-parsing on tab switch
|
|
this.cache = new Map();
|
|
this.cacheEnabled = true;
|
|
this.cacheTTL = 5 * 60 * 1000;
|
|
}
|
|
|
|
/**
|
|
* Initialize — config is inlined at build time via static import.
|
|
*/
|
|
async init() {
|
|
this.sourcesConfig = sourcesConfig
|
|
return this.sourcesConfig
|
|
}
|
|
|
|
getActiveSourceId() {
|
|
return this.sourcesConfig?.activeSource || 'doxa-csv';
|
|
}
|
|
|
|
getSourceConfig(sourceId) {
|
|
return this.sourcesConfig?.sources?.[sourceId];
|
|
}
|
|
|
|
/**
|
|
* Set the active data source and load its data
|
|
*/
|
|
async setActiveSource(sourceId) {
|
|
const sourceConfig = this.getSourceConfig(sourceId);
|
|
if (!sourceConfig) {
|
|
throw new Error(`Source not found: ${sourceId}`);
|
|
}
|
|
|
|
this.activeSource = sourceConfig;
|
|
|
|
// CHECK CACHE FIRST
|
|
if (this.cacheEnabled && this.cache.has(sourceId)) {
|
|
const cached = this.cache.get(sourceId);
|
|
this.normalizedData = cached.normalizedData;
|
|
this.dataByUniqueId = cached.dataByUniqueId;
|
|
return this.normalizedData;
|
|
}
|
|
|
|
if (sourceConfig.type === 'csv') {
|
|
await this.loadCSVData(sourceConfig);
|
|
} else if (sourceConfig.type === 'api') {
|
|
await this.loadAPIData(sourceConfig);
|
|
} else if (sourceConfig.type === 'rest-api') {
|
|
await this.loadRestApiData(sourceConfig);
|
|
}
|
|
|
|
if (this.cacheEnabled) {
|
|
this.cache.set(sourceId, {
|
|
normalizedData: this.normalizedData,
|
|
dataByUniqueId: this.dataByUniqueId,
|
|
timestamp: Date.now()
|
|
});
|
|
}
|
|
|
|
return this.normalizedData;
|
|
}
|
|
|
|
clearCache(sourceId = null) {
|
|
if (sourceId) {
|
|
this.cache.delete(sourceId);
|
|
} else {
|
|
this.cache.clear();
|
|
}
|
|
}
|
|
|
|
isCached(sourceId) {
|
|
return this.cache.has(sourceId);
|
|
}
|
|
|
|
async loadActiveSource() {
|
|
if (!this.sourcesConfig) {
|
|
await this.init();
|
|
}
|
|
return this.setActiveSource(this.getActiveSourceId());
|
|
}
|
|
|
|
/**
|
|
* Parse CSV text handling quoted (multi-line) fields.
|
|
*/
|
|
parseCSV(csvText) {
|
|
const results = [];
|
|
const headers = [];
|
|
let currentField = '';
|
|
let inQuotes = false;
|
|
let row = [];
|
|
let isHeader = true;
|
|
|
|
for (let i = 0; i < csvText.length; i++) {
|
|
const char = csvText[i];
|
|
const nextChar = csvText[i + 1];
|
|
|
|
if (char === '"') {
|
|
if (inQuotes && nextChar === '"') {
|
|
currentField += '"';
|
|
i++;
|
|
} else {
|
|
inQuotes = !inQuotes;
|
|
}
|
|
} else if (char === ',' && !inQuotes) {
|
|
row.push(currentField.trim());
|
|
currentField = '';
|
|
} else if ((char === '\n' || char === '\r') && !inQuotes) {
|
|
if (char === '\r' && nextChar === '\n') {
|
|
i++;
|
|
}
|
|
if (currentField || row.length > 0) {
|
|
row.push(currentField.trim());
|
|
currentField = '';
|
|
|
|
if (isHeader) {
|
|
headers.push(...row);
|
|
isHeader = false;
|
|
} else if (row.length > 1) {
|
|
const obj = {};
|
|
headers.forEach((header, index) => {
|
|
obj[header] = row[index] || '';
|
|
});
|
|
results.push(obj);
|
|
}
|
|
row = [];
|
|
}
|
|
} else {
|
|
currentField += char;
|
|
}
|
|
}
|
|
|
|
if (currentField || row.length > 0) {
|
|
row.push(currentField.trim());
|
|
if (!isHeader && row.length > 1) {
|
|
const obj = {};
|
|
headers.forEach((header, index) => {
|
|
obj[header] = row[index] || '';
|
|
});
|
|
results.push(obj);
|
|
}
|
|
}
|
|
|
|
return { headers, data: results };
|
|
}
|
|
|
|
async loadCSVData(sourceConfig) {
|
|
try {
|
|
const response = await fetch(this.baseUrl + sourceConfig.path);
|
|
const csvText = await response.text();
|
|
const { headers, data } = this.parseCSV(csvText);
|
|
this.rawData = data;
|
|
this.validateFieldMappings(sourceConfig.fieldMappings, headers, sourceConfig.name);
|
|
this.normalizeData(sourceConfig);
|
|
return this.normalizedData;
|
|
} catch (error) {
|
|
console.error('Failed to load CSV data:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate that all source field mappings exist in the CSV headers
|
|
*/
|
|
validateFieldMappings(fieldMappings, headers, sourceName) {
|
|
const headerSet = new Set(headers.map(h => h.trim()));
|
|
const missingFields = [];
|
|
for (const [systemField, sourceField] of Object.entries(fieldMappings || {})) {
|
|
if (Array.isArray(sourceField)) {
|
|
for (const field of sourceField) {
|
|
if (!headerSet.has(field)) {
|
|
missingFields.push({ systemField, sourceField: field, isComposite: true });
|
|
}
|
|
}
|
|
} else if (sourceField && !headerSet.has(sourceField)) {
|
|
missingFields.push({ systemField, sourceField, isComposite: false });
|
|
}
|
|
}
|
|
if (missingFields.length > 0) {
|
|
console.warn(`[DataSourceManager] ${sourceName}: missing CSV columns:`, missingFields);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load data from a generic JSON URL (sourceConfig.url).
|
|
*/
|
|
async loadAPIData(sourceConfig) {
|
|
try {
|
|
const response = await fetch(sourceConfig.url || sourceConfig.endpoint);
|
|
const data = await response.json();
|
|
this.rawData = await applyPeopleGroupList(Array.isArray(data) ? data : (data.posts || data.data || data.peopleGroups || data.results || []), this.profileConfig);
|
|
this.normalizeData(sourceConfig);
|
|
return this.normalizedData;
|
|
} catch (error) {
|
|
console.error('Failed to load API data:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load data from a REST API source (type: 'rest-api').
|
|
* Reads the runtime base URL via getApiBaseUrl(); appends defaultFields and
|
|
* any static queryParams. The request URL is stable so the response is
|
|
* cacheable by the browser and any CDN edge in front of the API. On
|
|
* HTTP/content-type failure, throws a descriptive error so callers can
|
|
* surface a usable banner.
|
|
*/
|
|
async loadRestApiData(sourceConfig) {
|
|
const baseUrl = getApiBaseUrl();
|
|
const bulkEndpoint = sourceConfig.endpoints?.bulk || '/api/people-groups/list';
|
|
const fields = sourceConfig.defaultFields?.join(',') || '';
|
|
|
|
const queryParts = [];
|
|
if (fields) queryParts.push(`fields=${fields}`);
|
|
const extraParams = { ...(sourceConfig.queryParams || {}) };
|
|
// Layer-1 i18n: the active UI locale wins over the source's frozen
|
|
// `lang` so /fr/research etc. get translated API field values.
|
|
if (this.locale) extraParams.lang = this.locale;
|
|
for (const [k, v] of Object.entries(extraParams)) {
|
|
queryParts.push(`${k}=${encodeURIComponent(v)}`);
|
|
}
|
|
const url = `${baseUrl}${bulkEndpoint}?${queryParts.join('&')}`;
|
|
|
|
try {
|
|
const response = await fetch(url);
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
const contentType = response.headers.get('content-type') || '';
|
|
if (!contentType.includes('application/json')) {
|
|
throw new Error(
|
|
`API returned "${contentType}" instead of JSON. ` +
|
|
`Server at ${baseUrl} may be down or misconfigured.`
|
|
);
|
|
}
|
|
const data = await response.json();
|
|
this.rawData = await applyPeopleGroupList(Array.isArray(data) ? data : (data.posts || data.data || data.results || []), this.profileConfig);
|
|
this.normalizeData(sourceConfig);
|
|
return this.normalizedData;
|
|
} catch (error) {
|
|
console.error(`[rest-api] Failed to load from ${sourceConfig.id}:`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Normalize raw data to use system field names declared in fieldMappings.
|
|
* Handles composite keys (arrays), {value,label} objects (rest-api shape),
|
|
* boolean coercion for engagement / adoption status, and image URL fallbacks.
|
|
*/
|
|
normalizeData(sourceConfig) {
|
|
const mappings = sourceConfig.fieldMappings || {};
|
|
this.normalizedData = [];
|
|
this.dataByUniqueId = new Map();
|
|
|
|
for (const row of this.rawData) {
|
|
const normalized = {};
|
|
|
|
for (const [systemField, sourceField] of Object.entries(mappings)) {
|
|
if (Array.isArray(sourceField)) {
|
|
const values = sourceField.map(f => this.getFieldValue(row, f));
|
|
normalized[systemField] = values.join('_');
|
|
} else {
|
|
normalized[systemField] = this.getFieldValue(row, sourceField);
|
|
}
|
|
}
|
|
|
|
normalized.latitude = parseFloat(normalized.latitude) || 0;
|
|
normalized.longitude = parseFloat(normalized.longitude) || 0;
|
|
if (normalized.latitude === 0 && normalized.longitude === 0) continue;
|
|
|
|
normalized.populationNum = this.parsePopulation(normalized.population);
|
|
normalized.imageUrl = this.getImageUrl(normalized, sourceConfig.imageConfig);
|
|
|
|
// engagementStatus → boolean; preserve label if {value,label} object
|
|
const es = normalized.engagementStatus;
|
|
if (es != null) {
|
|
if (typeof es === 'object' && 'value' in es) {
|
|
normalized.engagementStatusLabel = es.label || es.value;
|
|
normalized.engagementStatus = es.value === 'engaged';
|
|
} else {
|
|
normalized.engagementStatus = es === true || es === 1 || es === '1' || es === 'true'
|
|
|| (typeof es === 'number' && es > 0)
|
|
|| (typeof es === 'string' && parseInt(es, 10) > 0);
|
|
}
|
|
}
|
|
|
|
// adoptionStatus → boolean (count > 0 means adopted)
|
|
const as = normalized.adoptionStatus;
|
|
if (as != null) {
|
|
normalized.adoptionStatus = as === true || as === 1 || as === '1' || as === 'true'
|
|
|| (typeof as === 'object' && !!as?.value)
|
|
|| (typeof as === 'number' && as > 0)
|
|
|| (typeof as === 'string' && parseInt(as, 10) > 0);
|
|
}
|
|
|
|
// {value,label} → extract .value, preserve label as `${field}Label`
|
|
// Plain strings → use as both value and label so popups always have a label to display
|
|
const objectFields = ['doxaRegion', 'wagfRegion', 'wagfBlock', 'wagfMember', 'affinityBlock', 'cluster', 'peopleGroup', 'countryIso', 'religion', 'religionCode'];
|
|
for (const field of objectFields) {
|
|
const val = normalized[field];
|
|
if (val != null && typeof val === 'object' && 'value' in val) {
|
|
normalized[field + 'Label'] = val.label || val.value;
|
|
normalized[field] = val.value;
|
|
} else if (val != null && typeof val === 'string' && val !== '') {
|
|
// Plain string — use as label too (no slug/label distinction available)
|
|
if (!normalized[field + 'Label']) {
|
|
normalized[field + 'Label'] = val;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Best-effort countryName / religionName for display (no lookup tables in template)
|
|
normalized.countryName = normalized.countryIsoLabel || normalized.country || normalized.countryIso || '';
|
|
normalized.religionName = normalized.religionLabel || normalized.religion || '';
|
|
|
|
normalized._raw = row;
|
|
this.normalizedData.push(normalized);
|
|
this.dataByUniqueId.set(normalized.uniqueId, normalized);
|
|
}
|
|
|
|
// FREE MEMORY after normalization
|
|
this.rawData = null;
|
|
}
|
|
|
|
/**
|
|
* Tolerant field lookup — direct, trimmed, then partial match.
|
|
*/
|
|
getFieldValue(row, fieldName) {
|
|
if (!fieldName) return '';
|
|
if (row[fieldName] !== undefined) return row[fieldName];
|
|
const trimmed = fieldName.trim();
|
|
if (row[trimmed] !== undefined) return row[trimmed];
|
|
for (const key of Object.keys(row)) {
|
|
if (key.trim() === trimmed) return row[key];
|
|
if (key.includes(trimmed) || trimmed.includes(key.trim())) return row[key];
|
|
}
|
|
return '';
|
|
}
|
|
|
|
parsePopulation(popValue) {
|
|
if (!popValue) return 0;
|
|
const cleaned = String(popValue).replace(/,/g, '').trim();
|
|
const num = parseInt(cleaned, 10);
|
|
return isNaN(num) ? 0 : num;
|
|
}
|
|
|
|
/**
|
|
* Generate image URL — direct field, then placeholder.
|
|
*/
|
|
getImageUrl(normalized, imageConfig) {
|
|
if (!imageConfig) return '';
|
|
const placeholderUrl = imageConfig.placeholderUrl || '';
|
|
const directUrl = normalized[imageConfig.useField];
|
|
if (directUrl && String(directUrl).trim() && !String(directUrl).includes('NoImageAvailable')) {
|
|
return directUrl;
|
|
}
|
|
return placeholderUrl;
|
|
}
|
|
|
|
getData() {
|
|
return this.normalizedData;
|
|
}
|
|
|
|
getByUniqueId(uniqueId) {
|
|
return this.dataByUniqueId.get(uniqueId);
|
|
}
|
|
|
|
getByCoordinates(lat, lng, tolerance = 0.0001) {
|
|
return this.normalizedData.find(d =>
|
|
Math.abs(d.latitude - lat) < tolerance &&
|
|
Math.abs(d.longitude - lng) < tolerance
|
|
);
|
|
}
|
|
|
|
getByPeopleGroupId(rop3) {
|
|
return this.normalizedData.filter(d => d.peopleGroupId === rop3);
|
|
}
|
|
|
|
getLanguageFamilies() {
|
|
const families = new Set();
|
|
this.normalizedData.forEach(d => {
|
|
if (d.languageFamily) families.add(d.languageFamily);
|
|
});
|
|
return Array.from(families).sort();
|
|
}
|
|
|
|
getDataByLanguageFamily() {
|
|
const grouped = {};
|
|
this.normalizedData.forEach(d => {
|
|
const family = d.languageFamily || 'Unknown';
|
|
if (!grouped[family]) grouped[family] = [];
|
|
grouped[family].push(d);
|
|
});
|
|
return grouped;
|
|
}
|
|
|
|
getDataByRegion() {
|
|
const grouped = {};
|
|
this.normalizedData.forEach(d => {
|
|
const region = d.doxaRegion || 'Unknown';
|
|
if (!grouped[region]) grouped[region] = [];
|
|
grouped[region].push(d);
|
|
});
|
|
return grouped;
|
|
}
|
|
|
|
getStats() {
|
|
return {
|
|
totalRecords: this.normalizedData.length,
|
|
withPeopleId3: this.normalizedData.filter(d => d.peopleId3).length,
|
|
withPhotos: this.normalizedData.filter(d => d.imageUrl && !String(d.imageUrl).includes('NoImageAvailable')).length,
|
|
languageFamilies: this.getLanguageFamilies().length,
|
|
countries: new Set(this.normalizedData.map(d => d.country)).size,
|
|
regions: Object.keys(this.getDataByRegion()).length
|
|
};
|
|
}
|
|
}
|
|
|
|
// Singleton (legacy import shape — prefer `new DataSourceManager()` per <doxa-map>)
|
|
export const dataSourceManager = new DataSourceManager();
|
|
export default dataSourceManager;
|