Merge branch 'main' of https://git.nuxtbase.app/doxa-life/doxa-maps into bundler-3/work
# Conflicts: # public/dx-maps/doxa-maps-spa/manifest.json # public/dx-maps/gospel-resources/charts/index.html # public/dx-maps/gospel-resources/data-tables/index.html # public/dx-maps/gospel-resources/gospel-dashboard/index.html # public/dx-maps/gospel-resources/gospel-resources.js # public/dx-maps/gospel-resources/resources-list/index.html # public/dx-maps/gospel-resources/resources-map/index.html # public/dx-maps/manifest.json
This commit is contained in:
commit
0f136d2513
149 changed files with 2369 additions and 1533 deletions
|
|
@ -178,6 +178,10 @@ and a change here can break other maps.
|
|||
`import … from '@map/utils/embedBase.js'`
|
||||
- **geoUtils** — geoUtils.js — Geographic / GeoJSON Utility Functions
|
||||
`import … from '@map/utils/geoUtils.js'`
|
||||
- **html** — html.js — the two helpers every HTML string built in library/ must use (CRITIC-3, 2026-09-26).
|
||||
`import … from '@map/utils/html.js'`
|
||||
- **loadPinnedUmd** — loadPinnedUmd — load a PINNED, integrity-checked UMD library and capture it WITHOUT touching the host
|
||||
`import … from '@map/utils/loadPinnedUmd.js'`
|
||||
- **viewportScrim** — viewportScrim.js — make a position:fixed full-screen SCRIM actually cover the VIEWPORT,
|
||||
`import … from '@map/utils/viewportScrim.js'`
|
||||
|
||||
|
|
@ -192,6 +196,8 @@ and a change here can break other maps.
|
|||
|
||||
### Constants (never-change values)
|
||||
|
||||
- **cdn** — cdn.js — EVERY third-party script and stylesheet a plugin loads at runtime, pinned to an EXACT version
|
||||
`import … from '@map/constants/cdn.js'`
|
||||
- **mapDefaults** — mapDefaults.js — Default map initialization settings
|
||||
`import … from '@map/constants/mapDefaults.js'`
|
||||
- **posterDefaults** — posterDefaults.js — default margins, bleed, slot sizes, and typography
|
||||
|
|
|
|||
|
|
@ -148,6 +148,9 @@ the IMB / peoplegroups.org dataset (never a hand-kept roster). Aliases name them
|
|||
from the IMB CSV and the DOXA snapshot that `build-vision-funnel.mjs` saves).
|
||||
- **An unknown list** falls back to `'doxa'` with a console warning. **A list whose membership
|
||||
cannot load** shows no groups (fails closed), never DOXA's whole list under another list's name.
|
||||
- **Showing a list's name:** `listLabel(id, t)` and `listDefinition(id, t)` read the reader's catalogue
|
||||
(`i18n/locales/<lc>/lists.json`, area `lists`) through the caller's translator, and fall back to the
|
||||
registry's English. Never print `lists.json`'s `label` directly: it is the English fallback only.
|
||||
- **Guarded by** `check:universal-props` (LIST-FETCH, LIST-DSM, LIST-KEYS) and
|
||||
`check:list-prop-unit`.
|
||||
|
||||
|
|
|
|||
|
|
@ -8,11 +8,14 @@
|
|||
* - the default list returns the SAME array, untouched, with no fetch at all;
|
||||
* - a list keeps exactly the rows whose slug is in its membership file, fetched from the asset pool;
|
||||
* - a membership that cannot load FAILS CLOSED: no rows, never the whole DOXA list;
|
||||
* - every registry list with an imb filter has a membership file, and its counts match the slugs.
|
||||
* - every registry list with an imb filter has a membership file, and its counts match the slugs;
|
||||
* - listLabel / listDefinition read the reader's catalogue (library/i18n/locales/<lc>/lists.json),
|
||||
* fall back to the registry's English, the English catalogue equals the registry, and every
|
||||
* locale carries a label and a definition for every list.
|
||||
* USAGE node internal/check-list-prop-unit.mjs EXIT 0 pass · 1 fail
|
||||
*/
|
||||
import { register } from 'node:module'
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { readFileSync, existsSync, readdirSync } from 'node:fs'
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
|
|
@ -65,6 +68,27 @@ for (const l of L.peopleGroupLists().filter((x) => x.source === 'imb')) {
|
|||
check(`${l.id}: file matches its registry filter, counts match its slugs`, [JSON.stringify(d.filter) === JSON.stringify(l.filter), d.counts.doxa === d.slugs.length, d.counts.imb >= d.counts.doxa], [true, true, true])
|
||||
}
|
||||
|
||||
console.log('labels in the reader\'s language')
|
||||
const cat = (lc) => JSON.parse(readFileSync(join(ROOT, 'library/i18n/locales', lc, 'lists.json'), 'utf8'))
|
||||
const tFrom = (lc) => (key) => key.split('.').slice(1).reduce((o, k) => (o == null ? o : o[k]), cat(lc)) ?? key
|
||||
check('no translator -> the registry English', L.listLabel('700'), 'Project 700: frontier people groups of 500,000 or more')
|
||||
check('a translator that returns the key -> English', L.listLabel('frontier', (k) => k), 'Project 4700: frontier people groups')
|
||||
check('Arabic catalogue, by alias', L.listLabel('4700', tFrom('ar')), cat('ar').frontier.label)
|
||||
check('definition, German catalogue', L.listDefinition('unreached', tFrom('de')), cat('de').unreached.definition)
|
||||
check('an unknown list -> empty, not a crash', L.listLabel('project-9999', tFrom('en')), '')
|
||||
{ const en = cat('en'), bad = []
|
||||
for (const l of L.peopleGroupLists()) {
|
||||
if (en[l.id]?.label !== l.label) bad.push(`${l.id}.label`)
|
||||
if ((en[l.id]?.definition || '') !== (l.definition || '')) bad.push(`${l.id}.definition`)
|
||||
}
|
||||
check('the English catalogue matches the registry, word for word', bad, []) }
|
||||
{ const missing = []
|
||||
for (const lc of readdirSync(join(ROOT, 'library/i18n/locales'))) {
|
||||
const c = cat(lc)
|
||||
for (const l of L.peopleGroupLists()) for (const f of ['label', 'definition']) if (!c[l.id]?.[f]) missing.push(`${lc}:${l.id}.${f}`)
|
||||
}
|
||||
check('every locale has a label and a definition for every list', missing, []) }
|
||||
|
||||
const failed = results.filter((x) => !x).length
|
||||
console.log(failed ? `LIST-PROP: FAIL — ${failed} case(s)` : `LIST-PROP: PASS — ${results.length} cases: the ladder, aliases, the untouched default, membership filtering, fail-closed, and the membership files`)
|
||||
console.log(failed ? `LIST-PROP: FAIL — ${failed} case(s)` : `LIST-PROP: PASS — ${results.length} cases: the ladder, aliases, the untouched default, membership filtering, fail-closed, the membership files, and labels in every locale`)
|
||||
process.exit(failed ? 1 : 0)
|
||||
|
|
|
|||
32
internal/check-map-attribution.mjs
Normal file
32
internal/check-map-attribution.mjs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-map-attribution — the credit line names the basemap of the engine that actually drew the map.
|
||||
*
|
||||
* WHY (licence credit, rega's row 28 at ccc2b1bb): addMapAttribution always credited "Mapbox ·
|
||||
* © OpenStreetMap". Under MapLibre the tiles are OpenFreeMap's (OpenMapTiles on OpenStreetMap), so
|
||||
* gospel-resources, key-cities and cities-internet credited Mapbox, which served nothing, and omitted
|
||||
* the OpenMapTiles credit. Headless: the real module against stand-in map classes and a minimal DOM.
|
||||
* EXIT 0 pass · 1 fail
|
||||
*/
|
||||
const made = []
|
||||
globalThis.window = globalThis
|
||||
globalThis.document = { createElement: () => { const el = { style: {}, className: '', innerHTML: '' }; made.push(el); return el } }
|
||||
class MapboxMap { addControl(c) { this.ctrl = c.onAdd() } }
|
||||
class MaplibreMap { addControl(c) { this.ctrl = c.onAdd() } }
|
||||
window.mapboxgl = { Map: MapboxMap }
|
||||
window.maplibregl = { Map: MaplibreMap }
|
||||
const { addMapAttribution, attributionText, ATTRIBUTION_TEXT } = await import('../library/composables/useMapAttribution.js')
|
||||
let bad = 0
|
||||
const check = (name, ok, got) => { if (!ok) bad++; console.log(` ${ok ? 'ok ' : 'FAIL'} ${name}${ok ? '' : ' — ' + got}`) }
|
||||
const ml = new MaplibreMap(); addMapAttribution(ml, { boundaries: true })
|
||||
check('MapLibre map credits OpenFreeMap, OpenMapTiles and OSM contributors', /OpenFreeMap/.test(ml.ctrl.innerHTML) && /OpenMapTiles/.test(ml.ctrl.innerHTML) && /OpenStreetMap contributors/.test(ml.ctrl.innerHTML), ml.ctrl.innerHTML)
|
||||
check('MapLibre map does NOT credit Mapbox', !/Mapbox/.test(ml.ctrl.innerHTML), ml.ctrl.innerHTML)
|
||||
const mb = new MapboxMap(); addMapAttribution(mb, { boundaries: true })
|
||||
check('Mapbox map credits Mapbox and OpenStreetMap', /Mapbox/.test(mb.ctrl.innerHTML) && /OpenStreetMap/.test(mb.ctrl.innerHTML), mb.ctrl.innerHTML)
|
||||
check('both credit geoBoundaries', /geoBoundaries/.test(ml.ctrl.innerHTML) && /geoBoundaries/.test(mb.ctrl.innerHTML), '')
|
||||
const nb = new MaplibreMap(); addMapAttribution(nb, { boundaries: true, basemap: false })
|
||||
check('basemap:false leaves only the boundaries credit', !/OpenFreeMap|Mapbox/.test(nb.ctrl.innerHTML), nb.ctrl.innerHTML)
|
||||
check('print text for maplibre names OpenMapTiles, not Mapbox', /OpenMapTiles/.test(attributionText('maplibre')) && !/Mapbox/.test(attributionText('maplibre')), attributionText('maplibre'))
|
||||
check('ATTRIBUTION_TEXT (existing importers) is unchanged', ATTRIBUTION_TEXT === 'Boundaries: geoBoundaries (geoboundaries.org) · © Mapbox © OpenStreetMap', ATTRIBUTION_TEXT)
|
||||
if (bad) { console.log(`MAP-ATTRIBUTION: FAIL — ${bad} case(s).`); process.exit(1) }
|
||||
console.log('MAP-ATTRIBUTION: PASS — the credit names the basemap of the engine that drew the map.')
|
||||
59
internal/check-no-cross-plugin-imports.mjs
Normal file
59
internal/check-no-cross-plugin-imports.mjs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-no-cross-plugin-imports — a plugin imports another plugin only through library/, never by reaching
|
||||
* into its folder.
|
||||
*
|
||||
* WHY (2026-09-26, regions' C1a): research-dashboard and dashboard-plus-grid imported
|
||||
* '../doxa-research-map/src/…' — another plugin's PRIVATE folder. That couples two owners' code invisibly
|
||||
* (a refactor inside doxa-research-map/src breaks a bundle its owner never builds), and it is how two
|
||||
* private copies of the same palette drifted apart. Shared code belongs in library/ (@map/…).
|
||||
*
|
||||
* RULE: in plugin-designs/<bundle>/**, a relative import (static, dynamic, export-from, import.meta.glob)
|
||||
* must not resolve into plugin-designs/<other>/ — except another bundle's PUBLIC profile component (a .vue at
|
||||
* that bundle's root), which is how a capability-4 composite mounts it. Anything deeper (src/, utils) is refused.
|
||||
* --self-test plants a crossing import and requires a FAIL.
|
||||
* EXIT 0 pass · 1 fail
|
||||
*/
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
||||
import { join, resolve, dirname, relative, sep } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const PD = join(ROOT, 'plugin-designs')
|
||||
const SPEC = /(?:import|export)\s[^'"`;]*?from\s*['"]([^'"]+)['"]|import\s*['"]([^'"]+)['"]|import\s*\(\s*['"]([^'"]+)['"]\s*\)|import\.meta\.glob\s*\(\s*['"]([^'"]+)['"]/g
|
||||
|
||||
/** [{file, spec, target}] for every relative import in `src` (at `file`) that lands in another bundle. */
|
||||
export function crossings(file, src) {
|
||||
const own = relative(PD, file).split(sep)[0]
|
||||
const out = []
|
||||
for (const m of src.matchAll(SPEC)) {
|
||||
const spec = m[1] || m[2] || m[3] || m[4]
|
||||
if (!spec || !spec.startsWith('.')) continue
|
||||
const abs = resolve(dirname(file), spec)
|
||||
const rel = relative(PD, abs)
|
||||
if (rel.startsWith('..')) continue // left plugin-designs/ (library/, data/): fine
|
||||
const parts = rel.split(sep)
|
||||
const target = parts[0]
|
||||
if (!target || target === own) continue
|
||||
// A composite mounting another bundle's PUBLIC profile (a .vue at that bundle's root) is composition,
|
||||
// the documented capability-4 pattern. Reaching any deeper (src/, utils, data) is the private coupling.
|
||||
if (parts.length === 2 && /\.vue$/.test(parts[1])) continue
|
||||
out.push({ file: relative(ROOT, file), spec, target })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
if (process.argv.includes('--self-test')) {
|
||||
const f = join(PD, 'research-dashboard', 'x.vue')
|
||||
const hits = crossings(f, "import A from '../doxa-research-map/src/a.js'\nimport B from './src/b.js'\nimport C from '../../library/c.js'\nimport D from '../people-groups-grid/people-groups-grid.vue'")
|
||||
if (hits.length === 1 && hits[0].target === 'doxa-research-map') { console.log('NO-CROSS-PLUGIN-IMPORTS: SELF-TEST PASS — a reach into another plugin\'s src/ is caught; its own src/, library/ and another bundle\'s public profile .vue are not.'); process.exit(0) }
|
||||
console.error('NO-CROSS-PLUGIN-IMPORTS: SELF-TEST FAIL — ' + JSON.stringify(hits)); process.exit(1)
|
||||
}
|
||||
|
||||
const files = []
|
||||
const walk = (d) => { for (const e of readdirSync(d)) { if (e === 'node_modules' || e.startsWith('.')) continue; const p = join(d, e); const st = statSync(p); if (st.isDirectory()) walk(p); else if (/\.(vue|m?js)$/.test(e) && !/\/(scripts|data)\//.test(p.replace(/\\/g, '/'))) files.push(p) } }
|
||||
walk(PD)
|
||||
const bad = files.flatMap((f) => crossings(f, readFileSync(f, 'utf8')))
|
||||
for (const b of bad) console.log(` FAIL ${b.file}: imports '${b.spec}' from another plugin (${b.target}); move the shared code to library/ and import it as @map/…`)
|
||||
if (bad.length) { console.log(`NO-CROSS-PLUGIN-IMPORTS: FAIL — ${bad.length} import(s) reach into another plugin's folder.`); process.exit(1) }
|
||||
console.log(`NO-CROSS-PLUGIN-IMPORTS: PASS — ${files.length} plugin source file(s); none imports from another plugin's folder.`)
|
||||
|
|
@ -80,6 +80,12 @@ const lang = await page.evaluate(() => document.documentElement.lang || 'en')
|
|||
// The ARTEFACT declares its own intent; the env vars below remain as an override for packets
|
||||
// generated before this stamp existed.
|
||||
const build = await page.evaluate(() => document.documentElement.getAttribute('data-packet-build') || '')
|
||||
// ★ AND WHICH LIST IT HOLDS. A packet built under a non-default list is a DIFFERENT DOCUMENT from the
|
||||
// same zone's default packet — different rows, a different funnel denominator, an extra funnel step —
|
||||
// and out/packets/ is gitignored, so a sweep grades whatever files are there. Printing the list makes
|
||||
// 'which packet is this?' answerable from the gate's own output rather than from whoever last ran the
|
||||
// generator. Empty means the default list.
|
||||
const packetList = await page.evaluate(() => document.documentElement.getAttribute('data-packet-list') || '')
|
||||
// RTL languages must actually be laid out RTL. Without dir="rtl" an Arabic packet
|
||||
// renders readable glyphs LEFT-aligned — it looks like a styling quirk rather than
|
||||
// the bug it is, and no other assertion here would notice.
|
||||
|
|
@ -259,6 +265,32 @@ const r = await page.evaluate(() => {
|
|||
.filter((d) => /^0(\.0+)?\s+\D/.test(d.textContent.trim())).length
|
||||
})(),
|
||||
funnelShare: (document.querySelector('.funnel-share')?.textContent || '').trim().slice(0, 80),
|
||||
// ★ LATIN RUNS ON A NON-LATIN SHEET, which is the one gap rega named and I left open: "the gate
|
||||
// passed: it counts rows, not the row's language." It asserted the funnel page's STRUCTURE and
|
||||
// nothing about whether its prose reached the reader's language — my own standing rule turned on
|
||||
// my own gate.
|
||||
//
|
||||
// ⚠ IT ONLY WORKS IN A NON-LATIN-SCRIPT LOCALE, and that is why it is worth having rather than
|
||||
// aspiring to a general "is this translated?" test, which is not decidable. On a hi/ar/ru/zh sheet
|
||||
// a run of Latin letters is a proper name, a number, or an UNTRANSLATED STRING — three
|
||||
// possibilities, two of which are enumerable. In de/fr/es an untranslated string looks like
|
||||
// ordinary text and this measures nothing, so those locales are not covered and the gate does not
|
||||
// pretend otherwise.
|
||||
//
|
||||
// MEASURED across every non-Latin packet in out/ (hi, ar, ru, zh): exactly TWO Latin runs appear,
|
||||
// both the data source's name. Anything else is the defect. Verified against the real one — with
|
||||
// the pre-fix English list label restored on the hi sheet it reports ['Project', 'frontier people
|
||||
// groups'], and an untranslated key reports ['THIS packet'].
|
||||
funnelLatinRuns: (() => {
|
||||
const sheet = document.querySelector('.funnel-title')?.closest('.sheet')
|
||||
if (!sheet) return []
|
||||
const txt = sheet.textContent || ''
|
||||
// Digits break a run, so "Project 4700" surfaces as "Project" — deliberate: the designation is
|
||||
// allowed by name below, and a bare "Project" beside a number is exactly what an untranslated
|
||||
// label looks like.
|
||||
const runs = txt.match(/[A-Za-z][A-Za-z.'/&-]{2,}(?:\s+[A-Za-z][A-Za-z.'/&-]*)*/g) || []
|
||||
return [...new Set(runs.map((r) => r.trim()))]
|
||||
})(),
|
||||
// ★ A CARD MUST NAME ITS PEOPLE GROUP. Counting .card proves the element exists; the spec says
|
||||
// pages 2..n carry "the people groups, 20 per page, with photos" — a card with a photo and a
|
||||
// BLANK NAME satisfies every count here: the total holds, the photo/typographic split holds,
|
||||
|
|
@ -644,6 +676,30 @@ if (r.funnel && r.funnelRows !== r.funnelBars) {
|
|||
// ★ AND NO STEP MAY PRINT A ZERO POPULATION. "0 people" appeared on the reader's row of every packet
|
||||
// because Number(null) is 0 and the renderer's guard tested finiteness rather than PRESENCE. A people
|
||||
// group with no people is not a fact; it is missing data wearing a number's clothes.
|
||||
// ★ AND ON A NON-LATIN SHEET, THE FUNNEL'S PROSE MUST BE IN THE READER'S SCRIPT. See funnelLatinRuns
|
||||
// above for why this is only meaningful in hi/ar/ru/zh. This is the gap rega named after the ?list=
|
||||
// render: "the gate passed: it counts rows, not the row's language", and it was right — the English
|
||||
// list label sat among Devanagari and every assertion here was green.
|
||||
//
|
||||
// ⚠ THE ALLOWLIST IS TWO ENTRIES AND BOTH ARE DECISIONS, NOT BOOKMARKS:
|
||||
// IMB, peoplegroups.org — the publisher's name and its domain. A reader following the citation meets
|
||||
// the English name at the destination, so translating it would hide the thing it identifies. Same
|
||||
// reasoning the i18n allowlist uses for "Open Bible Stories" and "Scripture Earth".
|
||||
// Project — the campaign designation ("Project 4700" / "مشروع 4700" / "4700 计划"). Digits break a run,
|
||||
// so the numeral is not part of the match; the WORD is allowed because it is a name, while the
|
||||
// description beside it ("frontier people groups") is prose and must be translated.
|
||||
// If a third entry is ever needed, say in the entry whether it is settled or deferred — an allowlist
|
||||
// that does not distinguish the two turns an open defect into a verdict.
|
||||
const LATIN_OK = new Set(['IMB', 'peoplegroups.org', 'Project'])
|
||||
const NON_LATIN_LOCALES = new Set(['hi', 'ar', 'ru', 'zh', 'he', 'fa', 'ur', 'bn', 'ta', 'th', 'ko', 'ja'])
|
||||
if (r.funnel && NON_LATIN_LOCALES.has(String(lang).split('-')[0])) {
|
||||
const stray = (r.funnelLatinRuns || []).filter((x) => !LATIN_OK.has(x))
|
||||
if (stray.length) {
|
||||
fails.push(`the funnel page carries Latin-script prose in a ${lang} packet: `
|
||||
+ `${stray.map((x) => JSON.stringify(x)).join(', ')} — an untranslated string, or a name that `
|
||||
+ 'belongs in LATIN_OK with a reason')
|
||||
}
|
||||
}
|
||||
if (r.funnel && r.funnelZeroPop) {
|
||||
fails.push(`${r.funnelZeroPop} funnel row(s) print a ZERO population — absent data must be hidden, `
|
||||
+ 'not rendered as the number 0 (Number(null) === 0, so test presence, not finiteness)')
|
||||
|
|
@ -704,6 +760,11 @@ if (r.ranges.length) {
|
|||
}
|
||||
|
||||
console.log(` build : ${build || '(unstamped — generated before data-packet-build)'}`)
|
||||
console.log(` people-group list: ${packetList || '(default)'}`)
|
||||
if (r.funnel && NON_LATIN_LOCALES.has(String(lang).split('-')[0])) {
|
||||
console.log(` funnel Latin runs: ${(r.funnelLatinRuns || []).length
|
||||
? r.funnelLatinRuns.map((x) => JSON.stringify(x)).join(', ') : '(none)'}`)
|
||||
}
|
||||
console.log(` sheets/PDF pages : ${r.sheets} / ${pages}`)
|
||||
console.log(` printed artefact : ${(pdfBytes / 1024 / 1024).toFixed(1)}MB, ${pdfFonts} embedded font(s), `
|
||||
+ `${pdfImages} image(s) for ${r.distinctImgSrc} distinct source(s)`)
|
||||
|
|
|
|||
|
|
@ -164,6 +164,25 @@ async function shot(name, path, {
|
|||
// need upstream tiles and have no iframe), the iframe-host shot opts out (it needs its local
|
||||
// frame and loads no tiles of its own).
|
||||
if (mapboxReferer) await ctx.setExtraHTTPHeaders({ Referer: 'https://doxa.life/' })
|
||||
// ★ WAIT ON THE MAP ITSELF, NOT A CLOCK (2026-09-26). Every map a page (or any frame in it) creates is
|
||||
// recorded, so the capture can wait until each visible one has its style and tiles loaded AND has
|
||||
// drawn features on OUR layers (sources that are not the basemap's). A fixed 7 s wait shot the
|
||||
// upg-100-list counter over a bare basemap: that leaf paints 9.5-16.5 s in (rega, 3 cold loads at
|
||||
// fc2fa2e4). Same predicate rega measures with (_qa-cycle/rega/upglist-fc2fa2e4/upglist.mjs).
|
||||
await ctx.addInitScript(() => {
|
||||
window.__SHOT_MAPS = []
|
||||
const patch = (x) => {
|
||||
if (!x || !x.Map || x.Map.__shotPatched) return
|
||||
x.Map.__shotPatched = true
|
||||
const ss = x.Map.prototype.setStyle
|
||||
x.Map.prototype.setStyle = function (...a) { if (!window.__SHOT_MAPS.includes(this)) window.__SHOT_MAPS.push(this); return ss.apply(this, a) }
|
||||
}
|
||||
for (const g of ['maplibregl', 'mapboxgl']) {
|
||||
let v = window[g]
|
||||
try { patch(v) } catch (_) {}
|
||||
try { Object.defineProperty(window, g, { configurable: true, get: () => v, set: (x) => { v = x; try { patch(x) } catch (_) {} } }) } catch (_) {}
|
||||
}
|
||||
})
|
||||
const page = await ctx.newPage()
|
||||
// ★ WAIT FOR THE MAP TO FINISH LOADING, NOT JUST FOR THE PAGE TO EXIST. The element-count guard
|
||||
// below proves the UI rendered; it says nothing about map tiles and data, which arrive later. Shots
|
||||
|
|
@ -178,7 +197,33 @@ async function shot(name, path, {
|
|||
await page.goto(url, { waitUntil: 'load', timeout: 45000 })
|
||||
if (waitFor) await page.waitForSelector(waitFor, { timeout: 20000 })
|
||||
if (before) await before(page)
|
||||
await page.waitForTimeout(wait)
|
||||
// `wait` is now a FLOOR for the UI to settle (at most 3 s); the map readiness below does the rest.
|
||||
// A surface with no map (a card, a grid) waits its full `wait`, since there is nothing to ask.
|
||||
await page.waitForTimeout(Math.min(wait, 3000))
|
||||
const mapState = (frame) => frame.evaluate(() => (window.__SHOT_MAPS || []).map((m) => {
|
||||
try {
|
||||
const c = m.getContainer().getBoundingClientRect()
|
||||
if (c.width < 2 || c.height < 2) return { hidden: true }
|
||||
const st = m.getStyle() || {}
|
||||
// OUR data is GeoJSON; the basemap's sources are vector tiles. Type, not name: a source id can say anything.
|
||||
const own = Object.entries(st.sources || {}).filter(([, src]) => src && src.type === 'geojson').map(([k]) => k)
|
||||
const ids = (st.layers || []).filter((l) => own.includes(l.source) && (l.layout?.visibility ?? 'visible') !== 'none').map((l) => l.id)
|
||||
let drawn = 0
|
||||
try { drawn = ids.length ? m.queryRenderedFeatures({ layers: ids }).length : 0 } catch (_) {}
|
||||
return { style: m.isStyleLoaded(), tiles: m.areTilesLoaded(), ownLayers: ids.length, drawn }
|
||||
} catch (e) { return { err: String(e).slice(0, 80) } }
|
||||
})).catch(() => [])
|
||||
const MAP_CAP_MS = 60000, tMap = Date.now()
|
||||
let maps = []
|
||||
for (;;) {
|
||||
maps = []
|
||||
for (const f of page.frames()) maps.push(...(await mapState(f)))
|
||||
const live = maps.filter((m) => !m.hidden && !m.err)
|
||||
if (!live.length) { if (Date.now() - tMap < 4000) { await page.waitForTimeout(500); continue } await page.waitForTimeout(Math.max(0, wait - 3000)); break }
|
||||
if (live.every((m) => m.style && m.tiles && (m.ownLayers === 0 || m.drawn > 0))) break
|
||||
if (Date.now() - tMap > MAP_CAP_MS) { console.log(` WARN ${name}: a map was not ready in ${MAP_CAP_MS / 1000}s ${JSON.stringify(live)} — check the image`); break }
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
const QUIET_MS = 4000, QUIET_MAX_MS = 60000, t0 = Date.now()
|
||||
while (!(inflight <= 0 && Date.now() - lastNet >= QUIET_MS)) {
|
||||
if (Date.now() - t0 > QUIET_MAX_MS) { console.log(` WARN ${name}: network never went quiet in ${QUIET_MAX_MS / 1000}s (${inflight} in flight) — check the image`); break }
|
||||
|
|
@ -255,7 +300,11 @@ await shot('cap3-monolithic-research', `/${TREE_NAME}/doxa-research-map/research
|
|||
// upg-100-mfe (the cap-4 composite) · upg-100-list (the list) · poster-wall.
|
||||
// There are no "list pages" — see the note below the second shot. ----
|
||||
await shot('cap4-upg100-mfe', `/${TREE_NAME}/my-upg-100-list/upg-100-mfe/index.html`, { wait: 8000 })
|
||||
await shot('cap4-upg100-list', `/${TREE_NAME}/my-upg-100-list/upg-100-list/index.html`, { wait: 7000 })
|
||||
// wait 20000: this leaf PAINTS 9.5-16.5 s after load under swiftshader (rega, 3 cold loads at fc2fa2e4,
|
||||
// judged by rendered features on its own layers), and its "195 polys · 27/27 zones · 2107 pins" counter
|
||||
// appears BEFORE the paint, so a 7 s wait shot the counter over a bare basemap. The shot now waits on the
|
||||
// map itself (style + tiles + features drawn on our layers, see shot()); `wait` is only a fallback.
|
||||
await shot('cap4-upg100-list', `/${TREE_NAME}/my-upg-100-list/upg-100-list/index.html`, { wait: 20000 })
|
||||
|
||||
// ---- CAP4's THIRD profile: the poster wall ----
|
||||
// ★ REPLACED 'cap4-upg100-list-page2' 2026-09-21. That shot loaded the SAME URL as page 1 and
|
||||
|
|
@ -396,7 +445,7 @@ await shot('exp-wall-of-people-groups', `/${TREE_NAME}/wall-of-people-groups/wal
|
|||
await shot('exp-prayer-walk-planner', `/${TREE_NAME}/prayer-walk-planner/prayer-walk-planner/index.html`, { wait: 12000, minElements: 40 })
|
||||
|
||||
// ---- LANGUAGES: UPG-100 in Arabic (?lang=ar), right-to-left — the most completely translated map ----
|
||||
await shot('lang-arabic-upg100', `/${TREE_NAME}/my-upg-100-list/upg-100-list/index.html?lang=ar`, { wait: 12000, minElements: 200 })
|
||||
await shot('lang-arabic-upg100', `/${TREE_NAME}/my-upg-100-list/upg-100-list/index.html?lang=ar`, { wait: 20000, minElements: 200 }) // same leaf, same paint time: see cap4-upg100-list
|
||||
|
||||
// ---- DEPLOY2: mobile — the UPG-100 list as the phone app ----
|
||||
// The mobile example is UPG-100 (ruled 2026-09-24; Simple Research was retired as the example because
|
||||
|
|
|
|||
|
|
@ -42,6 +42,14 @@ export const AFFINITY_BLOCK_NAMES = {
|
|||
}
|
||||
|
||||
/** Primary mapping: ROP1 Code → Color */
|
||||
// ★ REJECTED CANDIDATE, RECORDED ABOVE THE PALETTE DELIBERATELY: #AF7AC5 was considered for A019
|
||||
// and scored 2.95 against the Material Light surface, just under WCAG 1.4.11's 3.0:1 non-text floor.
|
||||
// It lives here rather than beside A019 because check:one-palette slices from `export const PALETTE`
|
||||
// to END OF FILE and matches every hex it finds -- so a rejected colour mentioned BELOW the export
|
||||
// would be demanded of every consumer bundle, and the gate would fail on a colour we refused on
|
||||
// accessibility grounds. Keeping the note above the export preserves the reasoning without
|
||||
// creating that obligation. (The gate's own comment says it reads "only the PALETTE export"; its
|
||||
// implementation does not, which is reported separately.)
|
||||
export const PALETTE = {
|
||||
'A001': '#E74C3C', // Arab World - Red/Orange
|
||||
'A002': '#3498DB', // East Asian Peoples - Blue
|
||||
|
|
@ -68,7 +76,6 @@ export const PALETTE = {
|
|||
// A013 #8E44AD 5.28 (unchanged) A018 #6C3483 7.69 A019 #A569BD 3.53
|
||||
// Sibling separation so the three stay tellable apart: A018-vs-A019 2.61,
|
||||
// A013-vs-A018 1.46, A013-vs-A019 1.79.
|
||||
// REJECTED #AF7AC5 for A019 — scored 2.95, just under the 3.0 floor.
|
||||
'A018': '#6C3483', // Tibetan Peoples - deep purple (A013 family, darker)
|
||||
'A019': '#A569BD' // Himalayan Peoples - light purple (A013 family, lighter)
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
* don tsee the you using htes colors on the reigosn dashbarod hwen you shoudl". Two surfaces
|
||||
* disagreeing about what Africa's colour means is a product defect whichever palette is prettier,
|
||||
* and he chose which one wins. Source of truth:
|
||||
* plugin-designs/doxa-research-map/src/colors/doxa-region.js, which the research dashboard and
|
||||
* library/colors/doxa-region.js (it lived at plugin-designs/doxa-research-map/src/colors/ until C1a), which the research dashboard and
|
||||
* three other consumers already paint from.
|
||||
*
|
||||
* WHAT THE CHANGE COST, measured rather than glossed (dataviz validate_palette.js, light mode,
|
||||
|
|
|
|||
|
|
@ -19,33 +19,33 @@
|
|||
<template>
|
||||
<transition name="poster-dlg">
|
||||
<div v-if="visible" ref="scrimRef" :style="scrimStyle" class="poster-dlg-backdrop" @click.self="onClose">
|
||||
<section class="poster-dlg" role="dialog" aria-modal="true" aria-label="Export Poster">
|
||||
<section class="poster-dlg" role="dialog" aria-modal="true" :aria-label="T('poster.export_poster', 'Export Poster')">
|
||||
<header class="poster-dlg__header">
|
||||
<slot name="header">
|
||||
<h2>Export Poster</h2>
|
||||
<h2>{{ T('poster.export_poster', 'Export Poster') }}</h2>
|
||||
</slot>
|
||||
<button class="poster-dlg__close" type="button" aria-label="Close" @click="onClose">×</button>
|
||||
<button class="poster-dlg__close" type="button" :aria-label="T('buttons.close', 'Close')" @click="onClose">×</button>
|
||||
</header>
|
||||
|
||||
<div class="poster-dlg__body">
|
||||
<aside class="poster-dlg__controls">
|
||||
<label class="ctl">
|
||||
<span>Size</span>
|
||||
<span>{{ T('poster.size', 'Size') }}</span>
|
||||
<select v-model="sizeId">
|
||||
<option v-for="s in sizes" :key="s.id" :value="s.id">{{ s.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="ctl">
|
||||
<span>Orientation</span>
|
||||
<span>{{ T('poster.orientation', 'Orientation') }}</span>
|
||||
<select v-model="orientation">
|
||||
<option value="portrait">Portrait</option>
|
||||
<option value="landscape">Landscape</option>
|
||||
<option value="portrait">{{ T('poster.portrait', 'Portrait') }}</option>
|
||||
<option value="landscape">{{ T('poster.landscape', 'Landscape') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="ctl">
|
||||
<span>DPI</span>
|
||||
<span>{{ T('poster.dpi', 'DPI') }}</span>
|
||||
<select v-model.number="dpi">
|
||||
<option :value="150">150 (large posters)</option>
|
||||
<option :value="200">200 (balanced)</option>
|
||||
|
|
@ -54,37 +54,37 @@
|
|||
</label>
|
||||
|
||||
<label class="ctl">
|
||||
<span>Format</span>
|
||||
<span>{{ T('poster.format', 'Format') }}</span>
|
||||
<select v-model="format">
|
||||
<option value="png">PNG (raster)</option>
|
||||
<option value="pdf">PDF (raster on vector page)</option>
|
||||
<option value="png">{{ T('poster.png_raster', 'PNG (raster)') }}</option>
|
||||
<option value="pdf">{{ T('poster.pdf_raster', 'PDF (raster on vector page)') }}</option>
|
||||
</select>
|
||||
<slot name="format-extras" />
|
||||
</label>
|
||||
|
||||
<label class="ctl">
|
||||
<span>Title</span>
|
||||
<input v-model="titleText" type="text" placeholder="Map title" />
|
||||
<span>{{ T('poster.title', 'Title') }}</span>
|
||||
<input v-model="titleText" type="text" :placeholder="T('poster.map_title', 'Map title')" />
|
||||
</label>
|
||||
|
||||
<label class="ctl">
|
||||
<span>Subtitle</span>
|
||||
<span>{{ T('poster.subtitle', 'Subtitle') }}</span>
|
||||
<input v-model="subtitleText" type="text" placeholder="(optional)" />
|
||||
</label>
|
||||
|
||||
<fieldset class="ctl ctl--check">
|
||||
<legend>Slots</legend>
|
||||
<label><input v-model="enableLegend" type="checkbox" /> Legend</label>
|
||||
<label><input v-model="enableScale" type="checkbox" /> Scale bar</label>
|
||||
<label><input v-model="enableNorth" type="checkbox" /> North arrow</label>
|
||||
<label class="muted"><input type="checkbox" checked disabled /> Footer (attribution required)</label>
|
||||
<legend>{{ T('poster.slots', 'Slots') }}</legend>
|
||||
<label><input v-model="enableLegend" type="checkbox" /> {{ T('legend.header.default', 'Legend') }}</label>
|
||||
<label><input v-model="enableScale" type="checkbox" /> {{ T('poster.scale_bar', 'Scale bar') }}</label>
|
||||
<label><input v-model="enableNorth" type="checkbox" /> {{ T('poster.north_arrow', 'North arrow') }}</label>
|
||||
<label class="muted"><input type="checkbox" checked disabled /> {{ T('poster.footer_attribution', 'Footer (attribution required)') }}</label>
|
||||
</fieldset>
|
||||
|
||||
<div class="render-status" v-if="poster.isRendering.value">
|
||||
<strong>{{ poster.progress.value.phase }}</strong>
|
||||
<progress :max="poster.progress.value.total || 1" :value="poster.progress.value.completed" />
|
||||
<small>{{ poster.progress.value.message }}</small>
|
||||
<button class="btn btn--ghost" type="button" @click="poster.cancel">Cancel</button>
|
||||
<button class="btn btn--ghost" type="button" @click="poster.cancel">{{ T('poster.cancel', 'Cancel') }}</button>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
|
|
@ -118,6 +118,13 @@ import { defaultPosterSpec, posterDefaults } from '../../constants/posterDefault
|
|||
// Backdrop geometry — a fixed;inset:0 backdrop covers only the content pane inside the cap-5
|
||||
// shell (contain:layout on .vtl-content). See library/utils/viewportScrim.js.
|
||||
import { useViewportScrim } from '../../utils/viewportScrim.js';
|
||||
import { tStatic } from '../../i18n/index.js';
|
||||
|
||||
// This dialog's chrome, read out of the catalogue. tStatic and not useI18n: these components mount
|
||||
// inside custom elements that install no i18n plugin, and an unguarded useI18n() throws on the way
|
||||
// in and takes the dialog with it. The English stays as the fallback so a missing key degrades to
|
||||
// what shipped rather than to a key name.
|
||||
const T = (key, en) => tStatic(key) || en;
|
||||
|
||||
const props = defineProps({
|
||||
/** Boolean v-model: dialog visibility. */
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import { getColorStrategy, COLOR_MODES } from '../../colors/_registry.js'
|
|||
// (inside the builder, at call time) so registration has already run — a
|
||||
// module-load-time read could see an empty palette.
|
||||
const doxaRegionColors = () => getColorStrategy(COLOR_MODES.DOXA_REGION)?.palette ?? {}
|
||||
// resource is likewise research-only (plugin-designs/doxa-research-map/src/colors/
|
||||
// resource is likewise research-only, and now lives in library/colors/ (C1b) (was plugin-designs/doxa-research-map/src/colors/
|
||||
// resource.js), registry-resolved. Lazy + GUARDED on propertyKey — getColorStrategy
|
||||
// falls back to language-family for unregistered modes, and that palette must not
|
||||
// masquerade as resource colors.
|
||||
|
|
|
|||
|
|
@ -42,9 +42,12 @@ import { computed } from 'vue'
|
|||
// Single source of truth — pins paint with this same palette, keyed by ROP1 code.
|
||||
// Resolving the SAME strategy the pins use keeps legend ↔ map in lock-step (was a
|
||||
// separate name-keyed palette causing pin/legend drift). affinity-block is a per-map
|
||||
// (research-only) strategy that lives in plugin-designs/doxa-research-map/
|
||||
// src/colors/affinity-block.js and is resolved via the registry (registered by
|
||||
// the research bundle's local src/colors glob). Read LAZILY (at call time
|
||||
// strategy, which NOW LIVES IN library/colors/affinity-block.js (C1): it was private to
|
||||
// plugin-designs/doxa-research-map/ and duplicated in country-posters, where the stale copy
|
||||
// covered only A001-A017 and sent 134 Tibetan/Himalayan rows to the grey fallback.
|
||||
// It is resolved via the registry, which now discovers it from library/colors/ for EVERY
|
||||
// bundle rather than from one plugin's local glob -- that is the whole point of the move.
|
||||
// Read LAZILY (at call time
|
||||
// inside blocColor — this composable is only invoked by research surfaces) so
|
||||
// registration has already run, and GUARDED on propertyKey — getColorStrategy
|
||||
// silently falls back to language-family for unregistered modes, and that palette
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ const COLUMNS_SIMPLE = [] // e.g. affinity-blocs — no numeric columns
|
|||
|
||||
// ─── Resource palette/names (research-only strategy) ──────────────────────────
|
||||
// resource is a per-map (research-only) strategy that lives in
|
||||
// plugin-designs/doxa-research-map/src/colors/resource.js and is resolved via
|
||||
// library/colors/resource.js (moved there in C1b) and is resolved via
|
||||
// the registry (registered by the research bundle's local src/colors glob).
|
||||
// Read LAZILY (inside the items computed, at call time) so registration has already
|
||||
// run, and GUARDED on propertyKey — getColorStrategy silently falls back to
|
||||
|
|
@ -42,7 +42,7 @@ function resourceStrategy() {
|
|||
|
||||
// ─── Region / Bloc palettes (registry strategies — single source of truth) ───
|
||||
// doxa-region and affinity-block are PER-MAP (research-only) strategies that
|
||||
// live in plugin-designs/doxa-research-map/src/colors/ and register via the
|
||||
// live in library/colors/ and are discovered by _registry.js for every bundle, not via a
|
||||
// research bundle's local src/colors glob. Read LAZILY (inside the items
|
||||
// computed, at call time) so registration has already run, and GUARDED on
|
||||
// propertyKey — getColorStrategy silently falls back to language-family when a
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ import { computed } from 'vue'
|
|||
// label-keyed lookup falls back to grey for every region except "Asia" (spelled
|
||||
// identically across locales) once the user switches language. The slug is
|
||||
// locale-independent. Mirrors SLUG_PALETTE in the research bundle's
|
||||
// src/colors/doxa-region.js.
|
||||
// library/colors/doxa-region.js (moved there in C1a).
|
||||
const REGION_COLORS = {
|
||||
'africa': '#ff2e2e',
|
||||
'asia': '#1a8cff',
|
||||
|
|
|
|||
|
|
@ -36,8 +36,20 @@ const LINKS = {
|
|||
},
|
||||
mapbox: { label: 'Mapbox', href: 'https://www.mapbox.com/about/maps/' },
|
||||
osm: { label: '© OpenStreetMap', href: 'https://www.openstreetmap.org/copyright' },
|
||||
// Under MapLibre the basemap is OpenFreeMap's, built from OpenMapTiles on OpenStreetMap data — the
|
||||
// credit its own TileJSON asks for. Mapbox serves nothing on those maps and must not be credited.
|
||||
openfreemap: { label: 'OpenFreeMap', href: 'https://openfreemap.org' },
|
||||
openmaptiles: { label: '© OpenMapTiles', href: 'https://www.openmaptiles.org/' },
|
||||
osmContributors: { label: '© OpenStreetMap contributors', href: 'https://www.openstreetmap.org/copyright' },
|
||||
};
|
||||
|
||||
/** The basemap credit for the engine that actually drew THIS map (read from the instance, not config). */
|
||||
function basemapCredit(map) {
|
||||
const ml = typeof window !== 'undefined' ? window.maplibregl : null;
|
||||
const isMaplibre = !!(ml && ml.Map && map instanceof ml.Map);
|
||||
return isMaplibre ? [LINKS.openfreemap, LINKS.openmaptiles, LINKS.osmContributors] : [LINKS.mapbox, LINKS.osm];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the bottom-right attribution bar to a Mapbox map.
|
||||
*
|
||||
|
|
@ -45,7 +57,9 @@ const LINKS = {
|
|||
* @param {Object} [opts]
|
||||
* @param {boolean} [opts.boundaries=true] credit geoBoundaries (set false only if
|
||||
* the map draws NO gb polygons at all)
|
||||
* @param {boolean} [opts.basemap=true] credit Mapbox + OpenStreetMap
|
||||
* @param {boolean} [opts.basemap=true] credit the basemap of the engine that drew this map:
|
||||
* Mapbox + OpenStreetMap, or under MapLibre OpenFreeMap +
|
||||
* OpenMapTiles + OpenStreetMap contributors
|
||||
*/
|
||||
export function addMapAttribution(map, opts = {}) {
|
||||
if (!map || typeof map.addControl !== 'function') return null;
|
||||
|
|
@ -64,7 +78,7 @@ export function addMapAttribution(map, opts = {}) {
|
|||
|
||||
const parts = [];
|
||||
if (boundaries) parts.push(LINKS.boundaries);
|
||||
if (basemap) { parts.push(LINKS.mapbox); parts.push(LINKS.osm); }
|
||||
if (basemap) parts.push(...basemapCredit(map));
|
||||
|
||||
el.innerHTML = parts.map((p) =>
|
||||
`<a href="${p.href}" target="_blank" rel="noopener noreferrer"`
|
||||
|
|
@ -80,5 +94,14 @@ export function addMapAttribution(map, opts = {}) {
|
|||
return control;
|
||||
}
|
||||
|
||||
/** The same credit as plain text — for posters, PDFs and print, which have no DOM. */
|
||||
export const ATTRIBUTION_TEXT = 'Boundaries: geoBoundaries (geoboundaries.org) · © Mapbox © OpenStreetMap';
|
||||
/**
|
||||
* The same credit as plain text — for posters, PDFs and print, which have no DOM — for the engine that
|
||||
* drew the map ('maplibre' → OpenFreeMap / OpenMapTiles / OpenStreetMap contributors).
|
||||
*/
|
||||
export function attributionText(provider = 'mapbox') {
|
||||
return provider === 'maplibre'
|
||||
? 'Boundaries: geoBoundaries (geoboundaries.org) · OpenFreeMap © OpenMapTiles © OpenStreetMap contributors'
|
||||
: 'Boundaries: geoBoundaries (geoboundaries.org) · © Mapbox © OpenStreetMap';
|
||||
}
|
||||
/** @deprecated the Mapbox credit only; use attributionText(provider). Kept for existing importers. */
|
||||
export const ATTRIBUTION_TEXT = attributionText('mapbox');
|
||||
|
|
|
|||
|
|
@ -38,6 +38,31 @@ const ENGINES = {
|
|||
maplibre: { global: 'maplibregl', js: CDN.maplibreGlJs.url, css: CDN.maplibreGlCss.url },
|
||||
}
|
||||
|
||||
// THE ENGINE'S OWN CONTROL LABELS ("Zoom in", "Toggle attribution", the gesture hints…) in the reader's
|
||||
// language. Both engines take a `locale` map of their UI strings at construction; without one they stay
|
||||
// English under ar/es (rega's row 30). Strings live in library/i18n/locales/<lc>/mapui.json. The keys are
|
||||
// the engines' own; an engine ignores the ones it does not use.
|
||||
const ENGINE_UI_KEYS = {
|
||||
'Map.Title': 'map', 'Marker.Title': 'marker', 'Popup.Close': 'closePopup',
|
||||
'NavigationControl.ZoomIn': 'zoomIn', 'NavigationControl.ZoomOut': 'zoomOut', 'NavigationControl.ResetBearing': 'resetBearing',
|
||||
'AttributionControl.ToggleAttribution': 'toggleAttribution', 'AttributionControl.MapFeedback': 'mapFeedback',
|
||||
'FullscreenControl.Enter': 'enterFullscreen', 'FullscreenControl.Exit': 'exitFullscreen',
|
||||
'GeolocateControl.FindMyLocation': 'findMyLocation', 'GeolocateControl.LocationNotAvailable': 'locationNotAvailable',
|
||||
'GlobeControl.Enable': 'enableGlobe', 'GlobeControl.Disable': 'disableGlobe',
|
||||
'CooperativeGesturesHandler.WindowsHelpText': 'ctrlScroll', 'CooperativeGesturesHandler.MacHelpText': 'cmdScroll',
|
||||
'CooperativeGesturesHandler.MobileHelpText': 'twoFingers',
|
||||
// Mapbox's names for the same gesture hints
|
||||
'ScrollZoomBlocker.CtrlMessage': 'ctrlScroll', 'ScrollZoomBlocker.CmdMessage': 'cmdScroll', 'TouchPanBlocker.Message': 'twoFingers',
|
||||
}
|
||||
export function engineUiLocale(lang) {
|
||||
const out = {}
|
||||
for (const [engineKey, ours] of Object.entries(ENGINE_UI_KEYS)) {
|
||||
const v = tStatic(`mapui.${ours}`, lang)
|
||||
if (v) out[engineKey] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ONE MAPBOX WORDMARK PER PAGE: every map instance renders its own logo, so a
|
||||
// page composing several maps stacks duplicates. First live instance keeps the
|
||||
// logo (Mapbox attribution satisfied once per document); later ones hide theirs
|
||||
|
|
@ -72,6 +97,7 @@ import { resolveMapProvider, explicitMapProvider } from './useMapProvider.js'
|
|||
import { CDN, pinElement } from '../constants/cdn.js'
|
||||
import { installRtlTextPlugin } from '../maps/rtl-text.js'
|
||||
import { localizeBasemapLabels } from '../maps/localize-labels.js'
|
||||
import { tStatic } from '../i18n/index.js'
|
||||
|
||||
/**
|
||||
* The live engine global for a provider, or undefined if not yet loaded.
|
||||
|
|
@ -515,6 +541,7 @@ export function useMapInstance({
|
|||
// day/night decision, not a guess made here. (Computed above the branch: the Mapbox
|
||||
// branch's own `initialConfig` is scoped to that branch and is NOT visible here.)
|
||||
style: maplibreStyle(effectiveStyle, _bootPreset),
|
||||
locale: engineUiLocale(readerLang()),
|
||||
center,
|
||||
zoom,
|
||||
minZoom: initialMinZoom,
|
||||
|
|
@ -541,6 +568,7 @@ export function useMapInstance({
|
|||
accessToken: token, // BP #1 from W1A — per-instance token; avoids last-writer-wins on multi-embed pages
|
||||
container: containerRef.value, // real DOM element — never a string ID in shadow DOM
|
||||
style: effectiveStyle,
|
||||
locale: engineUiLocale(readerLang()),
|
||||
// Pins a Standard basemap's lightPreset/theme BEFORE its first frame; without it the
|
||||
// schema default 'day' paints one LIGHT frame before any setConfigProperty can run.
|
||||
...(initialConfig ? { config: initialConfig } : {}),
|
||||
|
|
|
|||
|
|
@ -130,6 +130,61 @@ export function engineFor(map) {
|
|||
* @param {object} map the live map instance
|
||||
* @param {'circle'|'fill'|'line'} [kind='circle']
|
||||
*/
|
||||
/**
|
||||
* ── PROJECTION, PER ENGINE ───────────────────────────────────────────────────────────────────────
|
||||
* ONE place for an API that is shaped differently on each engine, gathered from 12 call sites across
|
||||
* 9 plugins (SPEC-LIBRARY-CURATION.md, candidate C2). The asymmetry:
|
||||
*
|
||||
* Mapbox getProjection() -> { name } setProjection('mercator')
|
||||
* MapLibre getProjection() -> { type } | undefined setProjection({ type: 'mercator' })
|
||||
*
|
||||
* Passing the Mapbox shape to MapLibre THROWS. That is not hypothetical: it was measured on
|
||||
* regions-dashboard, where the throw aborted the camera fly so opening a region dashboard never
|
||||
* framed its region (rega confirmed the fix by eye, rows 13/14).
|
||||
*
|
||||
* WHAT THE 12 SITES DID BEFORE, and why one helper is worth it:
|
||||
* · NINE passed a bare string, so on MapLibre the projection silently never changed. Eight wrapped
|
||||
* it in try/catch and swallowed the throw; doxa-simple-map did NOT, so there it threw uncaught.
|
||||
* · TWO had already solved it independently and differently — pplr-data-maps' setProjectionFor()
|
||||
* (instance-tested, the shape adopted here) and regions-dashboard's pair (config-derived, which
|
||||
* is weaker: it trusts what the page was configured with rather than what the map IS).
|
||||
* · ONE probed by exception, trying the object form and falling back to the string on throw.
|
||||
*
|
||||
* THE ENGINE IS READ FROM THE INSTANCE, never from config. A page can be configured for one engine
|
||||
* and hold a map built by the other (a fallback, a second map, an auto-engine decision), and the
|
||||
* object-vs-string question is a fact about the INSTANCE.
|
||||
*
|
||||
* BOTH RETURN SOMETHING OBSERVABLE, on upg100-plugin's argument: a precondition that bails without
|
||||
* recording that it bailed is indistinguishable, at the rendered layer, from the request never
|
||||
* arriving. setProjectionOn returns whether it applied; projectionOf returns null rather than
|
||||
* throwing. Callers that do not care may ignore it, and all 12 existing sites do.
|
||||
*/
|
||||
|
||||
/** The current projection NAME on either engine, or null. Handles {name}, {type} and undefined. */
|
||||
export function projectionOf(map) {
|
||||
try {
|
||||
const p = map && typeof map.getProjection === 'function' ? map.getProjection() : null
|
||||
return (p && (p.name || p.type)) || null
|
||||
} catch (_) { return null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the projection on either engine. Returns true when it applied, false when it could not —
|
||||
* a style that does not support the projection is a legitimate false, not an error.
|
||||
*/
|
||||
export function setProjectionOn(map, name) {
|
||||
if (!map || !name || typeof map.setProjection !== 'function') return false
|
||||
const ml = typeof window !== 'undefined' ? window.maplibregl : null
|
||||
const wantsObject = !!(ml && ml.Map && map instanceof ml.Map)
|
||||
try {
|
||||
map.setProjection(wantsObject ? { type: name } : name)
|
||||
return true
|
||||
} catch (_) {
|
||||
// The style cannot take it (Mapbox Standard pins some projections): keep the current one.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function emissivePaint(map, kind = 'circle') {
|
||||
return isMapboxMap(map) ? { [`${kind}-emissive-strength`]: 1 } : {}
|
||||
}
|
||||
|
|
|
|||
22
library/i18n/locales/ar/lists.json
Normal file
22
library/i18n/locales/ar/lists.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"doxa": {
|
||||
"label": "المشروع 2000: الشعوب التي تخدمها DOXA",
|
||||
"definition": "الشعوب التي تخدمها DOXA كما تقدّمها قائمتها الحيّة"
|
||||
},
|
||||
"unreached": {
|
||||
"label": "المشروع 7000: الشعوب غير المُبشَّرة",
|
||||
"definition": "أولوية الضياع لدى IMB من 0 إلى 2 (حدودية أو رائدة أو متوسّعة غير مُبشَّرة: أقل من 2% إنجيليون)"
|
||||
},
|
||||
"frontier": {
|
||||
"label": "المشروع 4700: الشعوب الحدودية",
|
||||
"definition": "أولوية الضياع لدى IMB بقيمة 0 (حدودية غير مُبشَّرة: 0.1% إنجيليون أو أقل)"
|
||||
},
|
||||
"frontier-500k": {
|
||||
"label": "المشروع 700: الشعوب الحدودية التي يبلغ عددها 500,000 أو أكثر",
|
||||
"definition": "أولوية الضياع لدى IMB بقيمة 0 وعدد سكان لا يقل عن 500,000"
|
||||
},
|
||||
"unengaged": {
|
||||
"label": "الشعوب غير المُبشَّرة التي لم يُعمَل بينها",
|
||||
"definition": "أولوية الضياع لدى IMB من 0 إلى 2 وحالة العمل: لم يُعمَل بينها"
|
||||
}
|
||||
}
|
||||
19
library/i18n/locales/ar/mapui.json
Normal file
19
library/i18n/locales/ar/mapui.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"map": "الخريطة",
|
||||
"marker": "علامة على الخريطة",
|
||||
"closePopup": "إغلاق النافذة المنبثقة",
|
||||
"zoomIn": "تكبير",
|
||||
"zoomOut": "تصغير",
|
||||
"resetBearing": "اسحب لتدوير الخريطة، وانقر لإعادة الشمال",
|
||||
"toggleAttribution": "إظهار/إخفاء المصادر",
|
||||
"mapFeedback": "ملاحظات على الخريطة",
|
||||
"enterFullscreen": "ملء الشاشة",
|
||||
"exitFullscreen": "الخروج من ملء الشاشة",
|
||||
"findMyLocation": "تحديد موقعي",
|
||||
"locationNotAvailable": "الموقع غير متاح",
|
||||
"enableGlobe": "تفعيل الكرة الأرضية",
|
||||
"disableGlobe": "إيقاف الكرة الأرضية",
|
||||
"ctrlScroll": "استخدم Ctrl مع التمرير لتكبير الخريطة",
|
||||
"cmdScroll": "استخدم ⌘ مع التمرير لتكبير الخريطة",
|
||||
"twoFingers": "استخدم إصبعين لتحريك الخريطة"
|
||||
}
|
||||
19
library/i18n/locales/ar/poster.json
Normal file
19
library/i18n/locales/ar/poster.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"export_poster": "تصدير الملصق",
|
||||
"size": "الحجم",
|
||||
"orientation": "الاتجاه",
|
||||
"portrait": "طولي",
|
||||
"landscape": "عرضي",
|
||||
"dpi": "DPI",
|
||||
"format": "الصيغة",
|
||||
"png_raster": "PNG (نقطي)",
|
||||
"pdf_raster": "PDF (نقطي على صفحة متجهية)",
|
||||
"title": "العنوان",
|
||||
"map_title": "عنوان الخريطة",
|
||||
"subtitle": "العنوان الفرعي",
|
||||
"slots": "العناصر",
|
||||
"scale_bar": "شريط المقياس",
|
||||
"north_arrow": "سهم الشمال",
|
||||
"footer_attribution": "التذييل (الإسناد مطلوب)",
|
||||
"cancel": "إلغاء"
|
||||
}
|
||||
22
library/i18n/locales/de/lists.json
Normal file
22
library/i18n/locales/de/lists.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"doxa": {
|
||||
"label": "Projekt 2000: die Volksgruppen von DOXA",
|
||||
"definition": "Die Volksgruppen, denen DOXA dient, wie seine Live-Liste sie angibt"
|
||||
},
|
||||
"unreached": {
|
||||
"label": "Projekt 7000: unerreichte Volksgruppen",
|
||||
"definition": "IMB Lostness Priority 0–2 (Frontier, Pioneer oder Expanding Unreached: unter 2 % Evangelikale)"
|
||||
},
|
||||
"frontier": {
|
||||
"label": "Projekt 4700: Frontier-Volksgruppen",
|
||||
"definition": "IMB Lostness Priority 0 (Frontier Unreached: 0,1 % Evangelikale oder weniger)"
|
||||
},
|
||||
"frontier-500k": {
|
||||
"label": "Projekt 700: Frontier-Volksgruppen mit 500.000 oder mehr Menschen",
|
||||
"definition": "IMB Lostness Priority 0 und eine Bevölkerung von mindestens 500.000"
|
||||
},
|
||||
"unengaged": {
|
||||
"label": "Unerreichte Volksgruppen ohne Engagement",
|
||||
"definition": "IMB Lostness Priority 0–2 und Engagement-Status: ohne Engagement"
|
||||
}
|
||||
}
|
||||
19
library/i18n/locales/de/mapui.json
Normal file
19
library/i18n/locales/de/mapui.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"map": "Karte",
|
||||
"marker": "Kartenmarkierung",
|
||||
"closePopup": "Popup schließen",
|
||||
"zoomIn": "Vergrößern",
|
||||
"zoomOut": "Verkleinern",
|
||||
"resetBearing": "Ziehen, um die Karte zu drehen; klicken, um nach Norden auszurichten",
|
||||
"toggleAttribution": "Quellenangaben ein-/ausblenden",
|
||||
"mapFeedback": "Feedback zur Karte",
|
||||
"enterFullscreen": "Vollbild",
|
||||
"exitFullscreen": "Vollbild beenden",
|
||||
"findMyLocation": "Meinen Standort finden",
|
||||
"locationNotAvailable": "Standort nicht verfügbar",
|
||||
"enableGlobe": "Globus einschalten",
|
||||
"disableGlobe": "Globus ausschalten",
|
||||
"ctrlScroll": "Strg + Scrollen zum Zoomen der Karte",
|
||||
"cmdScroll": "⌘ + Scrollen zum Zoomen der Karte",
|
||||
"twoFingers": "Mit zwei Fingern die Karte bewegen"
|
||||
}
|
||||
19
library/i18n/locales/de/poster.json
Normal file
19
library/i18n/locales/de/poster.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"export_poster": "Poster exportieren",
|
||||
"size": "Größe",
|
||||
"orientation": "Ausrichtung",
|
||||
"portrait": "Hochformat",
|
||||
"landscape": "Querformat",
|
||||
"dpi": "DPI",
|
||||
"format": "Format",
|
||||
"png_raster": "PNG (Raster)",
|
||||
"pdf_raster": "PDF (Raster auf Vektorseite)",
|
||||
"title": "Titel",
|
||||
"map_title": "Kartentitel",
|
||||
"subtitle": "Untertitel",
|
||||
"slots": "Elemente",
|
||||
"scale_bar": "Maßstabsleiste",
|
||||
"north_arrow": "Nordpfeil",
|
||||
"footer_attribution": "Fußzeile (Quellenangabe erforderlich)",
|
||||
"cancel": "Abbrechen"
|
||||
}
|
||||
22
library/i18n/locales/en/lists.json
Normal file
22
library/i18n/locales/en/lists.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"doxa": {
|
||||
"label": "Project 2000: DOXA's people groups",
|
||||
"definition": "The people groups DOXA serves, as its live list gives them"
|
||||
},
|
||||
"unreached": {
|
||||
"label": "Project 7000: unreached people groups",
|
||||
"definition": "IMB Lostness Priority 0-2 (Frontier, Pioneer or Expanding Unreached: under 2% evangelical)"
|
||||
},
|
||||
"frontier": {
|
||||
"label": "Project 4700: frontier people groups",
|
||||
"definition": "IMB Lostness Priority 0 (Frontier Unreached: 0.1% evangelical or less)"
|
||||
},
|
||||
"frontier-500k": {
|
||||
"label": "Project 700: frontier people groups of 500,000 or more",
|
||||
"definition": "IMB Lostness Priority 0 and Population of at least 500,000"
|
||||
},
|
||||
"unengaged": {
|
||||
"label": "Unengaged unreached people groups",
|
||||
"definition": "IMB Lostness Priority 0-2 and Engagement Status Unengaged"
|
||||
}
|
||||
}
|
||||
19
library/i18n/locales/en/mapui.json
Normal file
19
library/i18n/locales/en/mapui.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"map": "Map",
|
||||
"marker": "Map marker",
|
||||
"closePopup": "Close popup",
|
||||
"zoomIn": "Zoom in",
|
||||
"zoomOut": "Zoom out",
|
||||
"resetBearing": "Drag to rotate map, click to reset north",
|
||||
"toggleAttribution": "Toggle attribution",
|
||||
"mapFeedback": "Map feedback",
|
||||
"enterFullscreen": "Enter fullscreen",
|
||||
"exitFullscreen": "Exit fullscreen",
|
||||
"findMyLocation": "Find my location",
|
||||
"locationNotAvailable": "Location not available",
|
||||
"enableGlobe": "Enable globe",
|
||||
"disableGlobe": "Disable globe",
|
||||
"ctrlScroll": "Use Ctrl + scroll to zoom the map",
|
||||
"cmdScroll": "Use ⌘ + scroll to zoom the map",
|
||||
"twoFingers": "Use two fingers to move the map"
|
||||
}
|
||||
19
library/i18n/locales/en/poster.json
Normal file
19
library/i18n/locales/en/poster.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"export_poster": "Export Poster",
|
||||
"size": "Size",
|
||||
"orientation": "Orientation",
|
||||
"portrait": "Portrait",
|
||||
"landscape": "Landscape",
|
||||
"dpi": "DPI",
|
||||
"format": "Format",
|
||||
"png_raster": "PNG (raster)",
|
||||
"pdf_raster": "PDF (raster on vector page)",
|
||||
"title": "Title",
|
||||
"map_title": "Map title",
|
||||
"subtitle": "Subtitle",
|
||||
"slots": "Slots",
|
||||
"scale_bar": "Scale bar",
|
||||
"north_arrow": "North arrow",
|
||||
"footer_attribution": "Footer (attribution required)",
|
||||
"cancel": "Cancel"
|
||||
}
|
||||
22
library/i18n/locales/es/lists.json
Normal file
22
library/i18n/locales/es/lists.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"doxa": {
|
||||
"label": "Proyecto 2000: los pueblos de DOXA",
|
||||
"definition": "Los pueblos a los que sirve DOXA, tal como los da su lista en vivo"
|
||||
},
|
||||
"unreached": {
|
||||
"label": "Proyecto 7000: pueblos no alcanzados",
|
||||
"definition": "Prioridad de perdición de IMB 0–2 (no alcanzados de frontera, pioneros o en expansión: menos del 2 % evangélicos)"
|
||||
},
|
||||
"frontier": {
|
||||
"label": "Proyecto 4700: pueblos de frontera",
|
||||
"definition": "Prioridad de perdición de IMB 0 (no alcanzados de frontera: 0,1 % evangélicos o menos)"
|
||||
},
|
||||
"frontier-500k": {
|
||||
"label": "Proyecto 700: pueblos de frontera de 500.000 o más",
|
||||
"definition": "Prioridad de perdición de IMB 0 y una población de al menos 500.000"
|
||||
},
|
||||
"unengaged": {
|
||||
"label": "Pueblos no alcanzados y no atendidos",
|
||||
"definition": "Prioridad de perdición de IMB 0–2 y estado de compromiso: no atendido"
|
||||
}
|
||||
}
|
||||
19
library/i18n/locales/es/mapui.json
Normal file
19
library/i18n/locales/es/mapui.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"map": "Mapa",
|
||||
"marker": "Marcador del mapa",
|
||||
"closePopup": "Cerrar ventana",
|
||||
"zoomIn": "Acercar",
|
||||
"zoomOut": "Alejar",
|
||||
"resetBearing": "Arrastra para girar el mapa; haz clic para orientar al norte",
|
||||
"toggleAttribution": "Mostrar u ocultar atribución",
|
||||
"mapFeedback": "Comentarios sobre el mapa",
|
||||
"enterFullscreen": "Pantalla completa",
|
||||
"exitFullscreen": "Salir de pantalla completa",
|
||||
"findMyLocation": "Buscar mi ubicación",
|
||||
"locationNotAvailable": "Ubicación no disponible",
|
||||
"enableGlobe": "Activar globo",
|
||||
"disableGlobe": "Desactivar globo",
|
||||
"ctrlScroll": "Usa Ctrl + desplazamiento para hacer zoom en el mapa",
|
||||
"cmdScroll": "Usa ⌘ + desplazamiento para hacer zoom en el mapa",
|
||||
"twoFingers": "Usa dos dedos para mover el mapa"
|
||||
}
|
||||
19
library/i18n/locales/es/poster.json
Normal file
19
library/i18n/locales/es/poster.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"export_poster": "Exportar póster",
|
||||
"size": "Tamaño",
|
||||
"orientation": "Orientación",
|
||||
"portrait": "Vertical",
|
||||
"landscape": "Horizontal",
|
||||
"dpi": "DPI",
|
||||
"format": "Formato",
|
||||
"png_raster": "PNG (ráster)",
|
||||
"pdf_raster": "PDF (ráster en página vectorial)",
|
||||
"title": "Título",
|
||||
"map_title": "Título del mapa",
|
||||
"subtitle": "Subtítulo",
|
||||
"slots": "Elementos",
|
||||
"scale_bar": "Barra de escala",
|
||||
"north_arrow": "Flecha norte",
|
||||
"footer_attribution": "Pie de página (atribución obligatoria)",
|
||||
"cancel": "Cancelar"
|
||||
}
|
||||
22
library/i18n/locales/fr/lists.json
Normal file
22
library/i18n/locales/fr/lists.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"doxa": {
|
||||
"label": "Projet 2000 : les peuples de DOXA",
|
||||
"definition": "Les peuples que DOXA sert, tels que sa liste en direct les donne"
|
||||
},
|
||||
"unreached": {
|
||||
"label": "Projet 7000 : peuples non atteints",
|
||||
"definition": "Priorité de perdition IMB 0–2 (non atteints frontières, pionniers ou en expansion : moins de 2 % d’évangéliques)"
|
||||
},
|
||||
"frontier": {
|
||||
"label": "Projet 4700 : peuples frontières",
|
||||
"definition": "Priorité de perdition IMB 0 (non atteints frontières : 0,1 % d’évangéliques ou moins)"
|
||||
},
|
||||
"frontier-500k": {
|
||||
"label": "Projet 700 : peuples frontières de 500 000 personnes ou plus",
|
||||
"definition": "Priorité de perdition IMB 0 et une population d’au moins 500 000"
|
||||
},
|
||||
"unengaged": {
|
||||
"label": "Peuples non atteints et non engagés",
|
||||
"definition": "Priorité de perdition IMB 0–2 et statut d’engagement : non engagé"
|
||||
}
|
||||
}
|
||||
19
library/i18n/locales/fr/mapui.json
Normal file
19
library/i18n/locales/fr/mapui.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"map": "Carte",
|
||||
"marker": "Marqueur de carte",
|
||||
"closePopup": "Fermer la fenêtre",
|
||||
"zoomIn": "Zoom avant",
|
||||
"zoomOut": "Zoom arrière",
|
||||
"resetBearing": "Faites glisser pour faire pivoter la carte, cliquez pour revenir au nord",
|
||||
"toggleAttribution": "Afficher/masquer l'attribution",
|
||||
"mapFeedback": "Commentaires sur la carte",
|
||||
"enterFullscreen": "Plein écran",
|
||||
"exitFullscreen": "Quitter le plein écran",
|
||||
"findMyLocation": "Trouver ma position",
|
||||
"locationNotAvailable": "Position non disponible",
|
||||
"enableGlobe": "Activer le globe",
|
||||
"disableGlobe": "Désactiver le globe",
|
||||
"ctrlScroll": "Utilisez Ctrl + molette pour zoomer sur la carte",
|
||||
"cmdScroll": "Utilisez ⌘ + molette pour zoomer sur la carte",
|
||||
"twoFingers": "Utilisez deux doigts pour déplacer la carte"
|
||||
}
|
||||
19
library/i18n/locales/fr/poster.json
Normal file
19
library/i18n/locales/fr/poster.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"export_poster": "Exporter l'affiche",
|
||||
"size": "Taille",
|
||||
"orientation": "Orientation",
|
||||
"portrait": "Portrait",
|
||||
"landscape": "Paysage",
|
||||
"dpi": "DPI",
|
||||
"format": "Format",
|
||||
"png_raster": "PNG (raster)",
|
||||
"pdf_raster": "PDF (raster sur page vectorielle)",
|
||||
"title": "Titre",
|
||||
"map_title": "Titre de la carte",
|
||||
"subtitle": "Sous-titre",
|
||||
"slots": "Éléments",
|
||||
"scale_bar": "Échelle",
|
||||
"north_arrow": "Flèche du nord",
|
||||
"footer_attribution": "Pied de page (attribution requise)",
|
||||
"cancel": "Annuler"
|
||||
}
|
||||
22
library/i18n/locales/hi/lists.json
Normal file
22
library/i18n/locales/hi/lists.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"doxa": {
|
||||
"label": "प्रोजेक्ट 2000: DOXA के जनसमूह",
|
||||
"definition": "वे जनसमूह जिनकी DOXA सेवा करता है, जैसे उसकी लाइव सूची उन्हें देती है"
|
||||
},
|
||||
"unreached": {
|
||||
"label": "प्रोजेक्ट 7000: अपहुँचे जनसमूह",
|
||||
"definition": "IMB लॉस्टनेस प्राथमिकता 0–2 (फ़्रंटियर, पायनियर या एक्सपैंडिंग अपहुँचे: 2% से कम इवेंजेलिकल)"
|
||||
},
|
||||
"frontier": {
|
||||
"label": "प्रोजेक्ट 4700: फ़्रंटियर जनसमूह",
|
||||
"definition": "IMB लॉस्टनेस प्राथमिकता 0 (फ़्रंटियर अपहुँचे: 0.1% या उससे कम इवेंजेलिकल)"
|
||||
},
|
||||
"frontier-500k": {
|
||||
"label": "प्रोजेक्ट 700: 5,00,000 या उससे अधिक के फ़्रंटियर जनसमूह",
|
||||
"definition": "IMB लॉस्टनेस प्राथमिकता 0 और कम से कम 5,00,000 की जनसंख्या"
|
||||
},
|
||||
"unengaged": {
|
||||
"label": "अपहुँचे और असंलग्न जनसमूह",
|
||||
"definition": "IMB लॉस्टनेस प्राथमिकता 0–2 और संलग्नता स्थिति: असंलग्न"
|
||||
}
|
||||
}
|
||||
19
library/i18n/locales/hi/mapui.json
Normal file
19
library/i18n/locales/hi/mapui.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"map": "मानचित्र",
|
||||
"marker": "मानचित्र चिह्न",
|
||||
"closePopup": "पॉपअप बंद करें",
|
||||
"zoomIn": "ज़ूम इन",
|
||||
"zoomOut": "ज़ूम आउट",
|
||||
"resetBearing": "मानचित्र घुमाने के लिए खींचें, उत्तर पर लौटने के लिए क्लिक करें",
|
||||
"toggleAttribution": "श्रेय दिखाएँ/छिपाएँ",
|
||||
"mapFeedback": "मानचित्र पर प्रतिक्रिया",
|
||||
"enterFullscreen": "पूर्ण स्क्रीन",
|
||||
"exitFullscreen": "पूर्ण स्क्रीन से बाहर निकलें",
|
||||
"findMyLocation": "मेरा स्थान खोजें",
|
||||
"locationNotAvailable": "स्थान उपलब्ध नहीं",
|
||||
"enableGlobe": "ग्लोब चालू करें",
|
||||
"disableGlobe": "ग्लोब बंद करें",
|
||||
"ctrlScroll": "मानचित्र ज़ूम करने के लिए Ctrl + स्क्रॉल करें",
|
||||
"cmdScroll": "मानचित्र ज़ूम करने के लिए ⌘ + स्क्रॉल करें",
|
||||
"twoFingers": "मानचित्र हिलाने के लिए दो उँगलियों का उपयोग करें"
|
||||
}
|
||||
19
library/i18n/locales/hi/poster.json
Normal file
19
library/i18n/locales/hi/poster.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"export_poster": "पोस्टर निर्यात करें",
|
||||
"size": "आकार",
|
||||
"orientation": "अभिविन्यास",
|
||||
"portrait": "पोर्ट्रेट",
|
||||
"landscape": "लैंडस्केप",
|
||||
"dpi": "DPI",
|
||||
"format": "प्रारूप",
|
||||
"png_raster": "PNG (रास्टर)",
|
||||
"pdf_raster": "PDF (वेक्टर पृष्ठ पर रास्टर)",
|
||||
"title": "शीर्षक",
|
||||
"map_title": "मानचित्र शीर्षक",
|
||||
"subtitle": "उपशीर्षक",
|
||||
"slots": "स्लॉट",
|
||||
"scale_bar": "स्केल बार",
|
||||
"north_arrow": "उत्तर तीर",
|
||||
"footer_attribution": "फ़ुटर (एट्रिब्यूशन आवश्यक)",
|
||||
"cancel": "रद्द करें"
|
||||
}
|
||||
22
library/i18n/locales/it/lists.json
Normal file
22
library/i18n/locales/it/lists.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"doxa": {
|
||||
"label": "Progetto 2000: i popoli di DOXA",
|
||||
"definition": "I popoli che DOXA serve, come li fornisce la sua lista in tempo reale"
|
||||
},
|
||||
"unreached": {
|
||||
"label": "Progetto 7000: popoli non raggiunti",
|
||||
"definition": "Priorità di perdizione IMB 0–2 (non raggiunti di frontiera, pionieri o in espansione: meno del 2% evangelici)"
|
||||
},
|
||||
"frontier": {
|
||||
"label": "Progetto 4700: popoli di frontiera",
|
||||
"definition": "Priorità di perdizione IMB 0 (non raggiunti di frontiera: 0,1% evangelici o meno)"
|
||||
},
|
||||
"frontier-500k": {
|
||||
"label": "Progetto 700: popoli di frontiera di 500.000 o più",
|
||||
"definition": "Priorità di perdizione IMB 0 e una popolazione di almeno 500.000"
|
||||
},
|
||||
"unengaged": {
|
||||
"label": "Popoli non raggiunti e non coinvolti",
|
||||
"definition": "Priorità di perdizione IMB 0–2 e stato di coinvolgimento: non coinvolto"
|
||||
}
|
||||
}
|
||||
19
library/i18n/locales/it/mapui.json
Normal file
19
library/i18n/locales/it/mapui.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"map": "Mappa",
|
||||
"marker": "Indicatore sulla mappa",
|
||||
"closePopup": "Chiudi popup",
|
||||
"zoomIn": "Ingrandisci",
|
||||
"zoomOut": "Riduci",
|
||||
"resetBearing": "Trascina per ruotare la mappa, fai clic per tornare al nord",
|
||||
"toggleAttribution": "Mostra/nascondi attribuzione",
|
||||
"mapFeedback": "Feedback sulla mappa",
|
||||
"enterFullscreen": "Schermo intero",
|
||||
"exitFullscreen": "Esci da schermo intero",
|
||||
"findMyLocation": "Trova la mia posizione",
|
||||
"locationNotAvailable": "Posizione non disponibile",
|
||||
"enableGlobe": "Attiva globo",
|
||||
"disableGlobe": "Disattiva globo",
|
||||
"ctrlScroll": "Usa Ctrl + scorrimento per ingrandire la mappa",
|
||||
"cmdScroll": "Usa ⌘ + scorrimento per ingrandire la mappa",
|
||||
"twoFingers": "Usa due dita per spostare la mappa"
|
||||
}
|
||||
19
library/i18n/locales/it/poster.json
Normal file
19
library/i18n/locales/it/poster.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"export_poster": "Esporta poster",
|
||||
"size": "Dimensione",
|
||||
"orientation": "Orientamento",
|
||||
"portrait": "Verticale",
|
||||
"landscape": "Orizzontale",
|
||||
"dpi": "DPI",
|
||||
"format": "Formato",
|
||||
"png_raster": "PNG (raster)",
|
||||
"pdf_raster": "PDF (raster su pagina vettoriale)",
|
||||
"title": "Titolo",
|
||||
"map_title": "Titolo della mappa",
|
||||
"subtitle": "Sottotitolo",
|
||||
"slots": "Elementi",
|
||||
"scale_bar": "Barra della scala",
|
||||
"north_arrow": "Freccia nord",
|
||||
"footer_attribution": "Piè di pagina (attribuzione obbligatoria)",
|
||||
"cancel": "Annulla"
|
||||
}
|
||||
22
library/i18n/locales/pt/lists.json
Normal file
22
library/i18n/locales/pt/lists.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"doxa": {
|
||||
"label": "Projeto 2000: os povos da DOXA",
|
||||
"definition": "Os povos que a DOXA serve, como a sua lista ao vivo os apresenta"
|
||||
},
|
||||
"unreached": {
|
||||
"label": "Projeto 7000: povos não alcançados",
|
||||
"definition": "Prioridade de perdição do IMB 0–2 (não alcançados de fronteira, pioneiros ou em expansão: menos de 2% evangélicos)"
|
||||
},
|
||||
"frontier": {
|
||||
"label": "Projeto 4700: povos de fronteira",
|
||||
"definition": "Prioridade de perdição do IMB 0 (não alcançados de fronteira: 0,1% evangélicos ou menos)"
|
||||
},
|
||||
"frontier-500k": {
|
||||
"label": "Projeto 700: povos de fronteira de 500.000 ou mais",
|
||||
"definition": "Prioridade de perdição do IMB 0 e uma população de pelo menos 500.000"
|
||||
},
|
||||
"unengaged": {
|
||||
"label": "Povos não alcançados e não engajados",
|
||||
"definition": "Prioridade de perdição do IMB 0–2 e estado de engajamento: não engajado"
|
||||
}
|
||||
}
|
||||
19
library/i18n/locales/pt/mapui.json
Normal file
19
library/i18n/locales/pt/mapui.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"map": "Mapa",
|
||||
"marker": "Marcador do mapa",
|
||||
"closePopup": "Fechar janela",
|
||||
"zoomIn": "Aumentar zoom",
|
||||
"zoomOut": "Diminuir zoom",
|
||||
"resetBearing": "Arraste para girar o mapa, clique para voltar ao norte",
|
||||
"toggleAttribution": "Mostrar/ocultar atribuição",
|
||||
"mapFeedback": "Comentários sobre o mapa",
|
||||
"enterFullscreen": "Tela cheia",
|
||||
"exitFullscreen": "Sair da tela cheia",
|
||||
"findMyLocation": "Encontrar minha localização",
|
||||
"locationNotAvailable": "Localização indisponível",
|
||||
"enableGlobe": "Ativar globo",
|
||||
"disableGlobe": "Desativar globo",
|
||||
"ctrlScroll": "Use Ctrl + rolagem para ampliar o mapa",
|
||||
"cmdScroll": "Use ⌘ + rolagem para ampliar o mapa",
|
||||
"twoFingers": "Use dois dedos para mover o mapa"
|
||||
}
|
||||
19
library/i18n/locales/pt/poster.json
Normal file
19
library/i18n/locales/pt/poster.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"export_poster": "Exportar pôster",
|
||||
"size": "Tamanho",
|
||||
"orientation": "Orientação",
|
||||
"portrait": "Retrato",
|
||||
"landscape": "Paisagem",
|
||||
"dpi": "DPI",
|
||||
"format": "Formato",
|
||||
"png_raster": "PNG (raster)",
|
||||
"pdf_raster": "PDF (raster em página vetorial)",
|
||||
"title": "Título",
|
||||
"map_title": "Título do mapa",
|
||||
"subtitle": "Subtítulo",
|
||||
"slots": "Elementos",
|
||||
"scale_bar": "Barra de escala",
|
||||
"north_arrow": "Seta norte",
|
||||
"footer_attribution": "Rodapé (atribuição obrigatória)",
|
||||
"cancel": "Cancelar"
|
||||
}
|
||||
22
library/i18n/locales/ro/lists.json
Normal file
22
library/i18n/locales/ro/lists.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"doxa": {
|
||||
"label": "Proiectul 2000: popoarele DOXA",
|
||||
"definition": "Popoarele pe care DOXA le slujește, așa cum le dă lista sa live"
|
||||
},
|
||||
"unreached": {
|
||||
"label": "Proiectul 7000: popoare neatinse",
|
||||
"definition": "Prioritatea de pierzare IMB 0–2 (neatinse de frontieră, pionier sau în expansiune: sub 2% evanghelici)"
|
||||
},
|
||||
"frontier": {
|
||||
"label": "Proiectul 4700: popoare de frontieră",
|
||||
"definition": "Prioritatea de pierzare IMB 0 (neatinse de frontieră: 0,1% evanghelici sau mai puțin)"
|
||||
},
|
||||
"frontier-500k": {
|
||||
"label": "Proiectul 700: popoare de frontieră de 500.000 sau mai mult",
|
||||
"definition": "Prioritatea de pierzare IMB 0 și o populație de cel puțin 500.000"
|
||||
},
|
||||
"unengaged": {
|
||||
"label": "Popoare neatinse și neangajate",
|
||||
"definition": "Prioritatea de pierzare IMB 0–2 și starea de angajare: neangajat"
|
||||
}
|
||||
}
|
||||
19
library/i18n/locales/ro/mapui.json
Normal file
19
library/i18n/locales/ro/mapui.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"map": "Hartă",
|
||||
"marker": "Marcaj pe hartă",
|
||||
"closePopup": "Închide fereastra",
|
||||
"zoomIn": "Mărește",
|
||||
"zoomOut": "Micșorează",
|
||||
"resetBearing": "Trage pentru a roti harta, apasă pentru a reveni la nord",
|
||||
"toggleAttribution": "Afișează/ascunde atribuirea",
|
||||
"mapFeedback": "Feedback despre hartă",
|
||||
"enterFullscreen": "Ecran complet",
|
||||
"exitFullscreen": "Ieși din ecran complet",
|
||||
"findMyLocation": "Găsește-mi locația",
|
||||
"locationNotAvailable": "Locație indisponibilă",
|
||||
"enableGlobe": "Activează globul",
|
||||
"disableGlobe": "Dezactivează globul",
|
||||
"ctrlScroll": "Folosește Ctrl + derulare pentru a mări harta",
|
||||
"cmdScroll": "Folosește ⌘ + derulare pentru a mări harta",
|
||||
"twoFingers": "Folosește două degete pentru a muta harta"
|
||||
}
|
||||
19
library/i18n/locales/ro/poster.json
Normal file
19
library/i18n/locales/ro/poster.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"export_poster": "Exportă posterul",
|
||||
"size": "Dimensiune",
|
||||
"orientation": "Orientare",
|
||||
"portrait": "Portret",
|
||||
"landscape": "Peisaj",
|
||||
"dpi": "DPI",
|
||||
"format": "Format",
|
||||
"png_raster": "PNG (raster)",
|
||||
"pdf_raster": "PDF (raster pe pagină vectorială)",
|
||||
"title": "Titlu",
|
||||
"map_title": "Titlul hărții",
|
||||
"subtitle": "Subtitlu",
|
||||
"slots": "Elemente",
|
||||
"scale_bar": "Bară de scară",
|
||||
"north_arrow": "Săgeata nord",
|
||||
"footer_attribution": "Subsol (atribuire obligatorie)",
|
||||
"cancel": "Anulează"
|
||||
}
|
||||
22
library/i18n/locales/ru/lists.json
Normal file
22
library/i18n/locales/ru/lists.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"doxa": {
|
||||
"label": "Проект 2000: народы DOXA",
|
||||
"definition": "Народы, которым служит DOXA, как их даёт её живой список"
|
||||
},
|
||||
"unreached": {
|
||||
"label": "Проект 7000: недостигнутые народы",
|
||||
"definition": "Приоритет потерянности IMB 0–2 (пограничные, первопроходческие или расширяющиеся недостигнутые: менее 2% евангельских христиан)"
|
||||
},
|
||||
"frontier": {
|
||||
"label": "Проект 4700: пограничные народы",
|
||||
"definition": "Приоритет потерянности IMB 0 (пограничные недостигнутые: 0,1% евангельских христиан или меньше)"
|
||||
},
|
||||
"frontier-500k": {
|
||||
"label": "Проект 700: пограничные народы численностью от 500 000",
|
||||
"definition": "Приоритет потерянности IMB 0 и численность не менее 500 000"
|
||||
},
|
||||
"unengaged": {
|
||||
"label": "Недостигнутые народы без служения",
|
||||
"definition": "Приоритет потерянности IMB 0–2 и статус служения: без служения"
|
||||
}
|
||||
}
|
||||
19
library/i18n/locales/ru/mapui.json
Normal file
19
library/i18n/locales/ru/mapui.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"map": "Карта",
|
||||
"marker": "Метка на карте",
|
||||
"closePopup": "Закрыть окно",
|
||||
"zoomIn": "Приблизить",
|
||||
"zoomOut": "Отдалить",
|
||||
"resetBearing": "Перетащите, чтобы повернуть карту; нажмите, чтобы вернуть север",
|
||||
"toggleAttribution": "Показать/скрыть источники",
|
||||
"mapFeedback": "Отзыв о карте",
|
||||
"enterFullscreen": "Во весь экран",
|
||||
"exitFullscreen": "Выйти из полноэкранного режима",
|
||||
"findMyLocation": "Найти моё местоположение",
|
||||
"locationNotAvailable": "Местоположение недоступно",
|
||||
"enableGlobe": "Включить глобус",
|
||||
"disableGlobe": "Выключить глобус",
|
||||
"ctrlScroll": "Используйте Ctrl + прокрутку для масштабирования карты",
|
||||
"cmdScroll": "Используйте ⌘ + прокрутку для масштабирования карты",
|
||||
"twoFingers": "Используйте два пальца, чтобы перемещать карту"
|
||||
}
|
||||
19
library/i18n/locales/ru/poster.json
Normal file
19
library/i18n/locales/ru/poster.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"export_poster": "Экспорт плаката",
|
||||
"size": "Размер",
|
||||
"orientation": "Ориентация",
|
||||
"portrait": "Портретная",
|
||||
"landscape": "Альбомная",
|
||||
"dpi": "DPI",
|
||||
"format": "Формат",
|
||||
"png_raster": "PNG (растр)",
|
||||
"pdf_raster": "PDF (растр на векторной странице)",
|
||||
"title": "Заголовок",
|
||||
"map_title": "Заголовок карты",
|
||||
"subtitle": "Подзаголовок",
|
||||
"slots": "Элементы",
|
||||
"scale_bar": "Масштабная линейка",
|
||||
"north_arrow": "Указатель севера",
|
||||
"footer_attribution": "Нижний колонтитул (указание источника обязательно)",
|
||||
"cancel": "Отмена"
|
||||
}
|
||||
22
library/i18n/locales/zh/lists.json
Normal file
22
library/i18n/locales/zh/lists.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"doxa": {
|
||||
"label": "2000 计划:DOXA 服侍的族群",
|
||||
"definition": "DOXA 服侍的族群,以其实时名单为准"
|
||||
},
|
||||
"unreached": {
|
||||
"label": "7000 计划:未得之民族群",
|
||||
"definition": "IMB 失丧优先级 0–2(前沿、先锋或扩展中的未得之民:福音派少于 2%)"
|
||||
},
|
||||
"frontier": {
|
||||
"label": "4700 计划:前沿族群",
|
||||
"definition": "IMB 失丧优先级 0(前沿未得之民:福音派 0.1% 或更少)"
|
||||
},
|
||||
"frontier-500k": {
|
||||
"label": "700 计划:人口 50 万或以上的前沿族群",
|
||||
"definition": "IMB 失丧优先级 0,且人口至少 50 万"
|
||||
},
|
||||
"unengaged": {
|
||||
"label": "尚无人参与的未得之民族群",
|
||||
"definition": "IMB 失丧优先级 0–2,且参与状态为:无人参与"
|
||||
}
|
||||
}
|
||||
19
library/i18n/locales/zh/mapui.json
Normal file
19
library/i18n/locales/zh/mapui.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"map": "地图",
|
||||
"marker": "地图标记",
|
||||
"closePopup": "关闭弹窗",
|
||||
"zoomIn": "放大",
|
||||
"zoomOut": "缩小",
|
||||
"resetBearing": "拖动以旋转地图,点击以恢复正北",
|
||||
"toggleAttribution": "显示/隐藏数据来源",
|
||||
"mapFeedback": "地图反馈",
|
||||
"enterFullscreen": "全屏",
|
||||
"exitFullscreen": "退出全屏",
|
||||
"findMyLocation": "查找我的位置",
|
||||
"locationNotAvailable": "无法获取位置",
|
||||
"enableGlobe": "启用地球仪",
|
||||
"disableGlobe": "停用地球仪",
|
||||
"ctrlScroll": "使用 Ctrl + 滚轮缩放地图",
|
||||
"cmdScroll": "使用 ⌘ + 滚轮缩放地图",
|
||||
"twoFingers": "使用两根手指移动地图"
|
||||
}
|
||||
19
library/i18n/locales/zh/poster.json
Normal file
19
library/i18n/locales/zh/poster.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"export_poster": "导出海报",
|
||||
"size": "尺寸",
|
||||
"orientation": "方向",
|
||||
"portrait": "纵向",
|
||||
"landscape": "横向",
|
||||
"dpi": "DPI",
|
||||
"format": "格式",
|
||||
"png_raster": "PNG(位图)",
|
||||
"pdf_raster": "PDF(矢量页面上的位图)",
|
||||
"title": "标题",
|
||||
"map_title": "地图标题",
|
||||
"subtitle": "副标题",
|
||||
"slots": "元素",
|
||||
"scale_bar": "比例尺",
|
||||
"north_arrow": "指北针",
|
||||
"footer_attribution": "页脚(必须署名)",
|
||||
"cancel": "取消"
|
||||
}
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
{
|
||||
"_about": "The people-group LISTS a map can be asked to show (profile-config `list`, ?list=, window.MAP_LIST; see peopleGroupList.js). Each list is a FILTER, never a hand-kept roster: `source: doxa` is DOXA's live list as the API serves it; `source: imb` is a filter over the IMB / peoplegroups.org dataset in data/people-groups/people_groups.csv, using its own columns (LostnessPriority, Population, EngagementStatus). library/scripts/people-groups/build-lists.mjs turns each imb filter into data/people-groups/lists/<id>.json. TIER A (today): a map shows the DOXA groups that belong to the list, because the DOXA API is where every map's rows and prayer fields come from. TIER B (later, per map): the list's groups that DOXA does not carry. Definitions and counts: _qa-cycle/03-deployment-cycle/SPEC-PEOPLE-GROUP-LIST-PROP.md.",
|
||||
"_about": "The people-group LISTS a map can be asked to show (profile-config `list`, ?list=, window.MAP_LIST; see peopleGroupList.js). Each list is a FILTER, never a hand-kept roster: `source: doxa` is DOXA's live list as the API serves it; `source: imb` is a filter over the IMB / peoplegroups.org dataset in data/people-groups/people_groups.csv, using its own columns (LostnessPriority, Population, EngagementStatus). library/scripts/people-groups/build-lists.mjs turns each imb filter into data/people-groups/lists/<id>.json. TIER A (today): a map shows the DOXA groups that belong to the list, because the DOXA API is where every map's rows and prayer fields come from. TIER B (later, per map): the list's groups that DOXA does not carry. Definitions and counts: _qa-cycle/03-deployment-cycle/SPEC-PEOPLE-GROUP-LIST-PROP.md. LABELS HERE ARE THE ENGLISH FALLBACK ONLY: every locale's label and definition live in library/i18n/locales/<lc>/lists.json (read them with listLabel / listDefinition), and check:list-prop-unit fails if the English catalogue and this file disagree.",
|
||||
"default": "doxa",
|
||||
"lists": [
|
||||
{
|
||||
"id": "doxa",
|
||||
"aliases": ["2000", "project-2000"],
|
||||
"label": "Project 2000: DOXA's people groups",
|
||||
"definition": "The people groups DOXA serves, as its live list gives them",
|
||||
"source": "doxa",
|
||||
"filter": null
|
||||
},
|
||||
|
|
|
|||
|
|
@ -42,6 +42,24 @@ export function listInfo(idOrAlias) {
|
|||
return byKey.get(String(idOrAlias ?? '').toLowerCase().trim()) || null
|
||||
}
|
||||
|
||||
// A list's name and definition in the READER'S language. They live in the i18n catalogues
|
||||
// (library/i18n/locales/<lc>/lists.json, area `lists`), not here, so every surface that shows a list
|
||||
// speaks the page's locale. `t` is the caller's translator (useI18n's t, tStatic, or any (key) =>
|
||||
// string); this module takes it as an argument instead of importing the i18n runtime, so it stays
|
||||
// pure. With no translator, or a key the catalogue lacks, the registry's English answers.
|
||||
function listText(idOrAlias, field, t) {
|
||||
const l = listInfo(idOrAlias)
|
||||
if (!l) return ''
|
||||
const key = `lists.${l.id}.${field}`
|
||||
let v = ''
|
||||
if (typeof t === 'function') { try { v = t(key) } catch { v = '' } }
|
||||
return v && v !== key ? v : (l[field] || '')
|
||||
}
|
||||
/** The list's label ("Project 700: …") in the reader's language, English as the fallback. */
|
||||
export function listLabel(idOrAlias, t) { return listText(idOrAlias, 'label', t) }
|
||||
/** The list's one-line definition in the reader's language, English as the fallback. */
|
||||
export function listDefinition(idOrAlias, t) { return listText(idOrAlias, 'definition', t) }
|
||||
|
||||
/** Unwrap a ref/computed to its config object; parse a JSON string; pass plain objects through. */
|
||||
function unwrapConfig(profileConfig) {
|
||||
let cfg = profileConfig
|
||||
|
|
|
|||
|
|
@ -347,8 +347,21 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null })
|
|||
// all 71, so the page rendered "71 -> 71", two identical bars reading as a funnel that narrows to
|
||||
// nothing. A step that repeats its neighbour is not a step.
|
||||
const lastImb = (steps[steps.length - 1] || {}).groups;
|
||||
const ownStep = (funnel.listLabel && total && total !== lastImb && total !== mine)
|
||||
? [{ groups: total, population: null, label: funnel.listLabel, tint: true }]
|
||||
// ⚠ THE LIST'S LABEL IS TRANSLATED HERE, NOT TAKEN FROM THE DATA. rega measured the rendered hi
|
||||
// packet (2026-09-26): "Project 4700: frontier people groups" sat among Devanagari labels in a
|
||||
// Latin serif face, because it comes verbatim from library/lists/lists.json, which carries ONE
|
||||
// label per list and has no locale dimension at all. The same would happen in all ten non-English
|
||||
// locales, and the gate passed because it counts rows, not the row's language.
|
||||
// packet-strings.json is the packet's OWN catalogue and the place this belongs — a new language
|
||||
// is a new block there, never a code change — so funnel_list_<id> supplies it and falls back to
|
||||
// the data's English label for any list this catalogue has not met. The numeric designation stays
|
||||
// ("Project 4700" / "4700 计划"): it names the campaign, while "frontier people groups" is prose.
|
||||
// That is the same line check-i18n-never-translated draws around "UUPGs".
|
||||
const listLabel = funnel.listId
|
||||
? (t(`funnel_list_${funnel.listId}`) ?? funnel.listLabel)
|
||||
: funnel.listLabel;
|
||||
const ownStep = (listLabel && total && total !== lastImb && total !== mine)
|
||||
? [{ groups: total, population: null, label: listLabel, tint: true }]
|
||||
: [];
|
||||
const allRows = [
|
||||
...steps.map((s, i) => ({
|
||||
|
|
|
|||
|
|
@ -177,7 +177,12 @@
|
|||
"funnel_foot": "Counts from {imb_pub}, captured {imb_date}. {unassigned} of the {total} are not yet in any list.",
|
||||
"funnel_people": "people",
|
||||
"funnel_foot_source": "Counts from {imb_pub}, captured {imb_date}.",
|
||||
"funnel_share_pct": "This packet is {pct}% of the {total}"
|
||||
"funnel_share_pct": "This packet is {pct}% of the {total}",
|
||||
"funnel_list_doxa": "Project 2000: this prayer network’s people groups",
|
||||
"funnel_list_unreached": "Project 7000: unreached people groups",
|
||||
"funnel_list_frontier": "Project 4700: frontier people groups",
|
||||
"funnel_list_frontier-500k": "Project 700: frontier people groups of 500,000 or more",
|
||||
"funnel_list_unengaged": "Unengaged unreached people groups"
|
||||
},
|
||||
"es": {
|
||||
"map_page_kicker": "GRUPOS ÉTNICOS NO EVANGELIZADOS",
|
||||
|
|
@ -347,7 +352,12 @@
|
|||
"funnel_foot": "Cifras de {imb_pub}, tomadas el {imb_date}. {unassigned} de los {total} aún no están en ninguna lista.",
|
||||
"funnel_people": "personas",
|
||||
"funnel_foot_source": "Cifras de {imb_pub}, tomadas el {imb_date}.",
|
||||
"funnel_share_pct": "Este cuadernillo es el {pct}% de los {total}"
|
||||
"funnel_share_pct": "Este cuadernillo es el {pct}% de los {total}",
|
||||
"funnel_list_doxa": "Proyecto 2000: los grupos étnicos de esta red de oración",
|
||||
"funnel_list_unreached": "Proyecto 7000: grupos étnicos no alcanzados",
|
||||
"funnel_list_frontier": "Proyecto 4700: grupos étnicos de frontera",
|
||||
"funnel_list_frontier-500k": "Proyecto 700: grupos étnicos de frontera de 500.000 o más",
|
||||
"funnel_list_unengaged": "Grupos étnicos no alcanzados y sin obreros"
|
||||
},
|
||||
"fr": {
|
||||
"map_page_kicker": "GROUPES ETHNIQUES NON ATTEINTS",
|
||||
|
|
@ -517,7 +527,12 @@
|
|||
"funnel_foot": "Chiffres de {imb_pub}, relevés le {imb_date}. {unassigned} des {total} ne figurent encore sur aucune liste.",
|
||||
"funnel_people": "personnes",
|
||||
"funnel_foot_source": "Chiffres de {imb_pub}, relevés le {imb_date}.",
|
||||
"funnel_share_pct": "Ce livret représente {pct}% des {total}"
|
||||
"funnel_share_pct": "Ce livret représente {pct}% des {total}",
|
||||
"funnel_list_doxa": "Projet 2000 : les peuples de ce réseau de prière",
|
||||
"funnel_list_unreached": "Projet 7000 : peuples non atteints",
|
||||
"funnel_list_frontier": "Projet 4700 : peuples frontières",
|
||||
"funnel_list_frontier-500k": "Projet 700 : peuples frontières de 500 000 personnes ou plus",
|
||||
"funnel_list_unengaged": "Peuples non atteints et sans ouvrier"
|
||||
},
|
||||
"pt": {
|
||||
"map_page_kicker": "GRUPOS ÉTNICOS NÃO ALCANÇADOS",
|
||||
|
|
@ -687,7 +702,12 @@
|
|||
"funnel_foot": "Números de {imb_pub}, coletados em {imb_date}. {unassigned} dos {total} ainda não estão em nenhuma lista.",
|
||||
"funnel_people": "pessoas",
|
||||
"funnel_foot_source": "Números de {imb_pub}, coletados em {imb_date}.",
|
||||
"funnel_share_pct": "Este caderno é {pct}% dos {total}"
|
||||
"funnel_share_pct": "Este caderno é {pct}% dos {total}",
|
||||
"funnel_list_doxa": "Projeto 2000: os grupos étnicos desta rede de oração",
|
||||
"funnel_list_unreached": "Projeto 7000: grupos étnicos não alcançados",
|
||||
"funnel_list_frontier": "Projeto 4700: grupos étnicos de fronteira",
|
||||
"funnel_list_frontier-500k": "Projeto 700: grupos étnicos de fronteira de 500.000 ou mais",
|
||||
"funnel_list_unengaged": "Grupos étnicos não alcançados e sem obreiros"
|
||||
},
|
||||
"de": {
|
||||
"map_page_kicker": "UNERREICHTE VOLKSGRUPPEN",
|
||||
|
|
@ -857,7 +877,12 @@
|
|||
"funnel_foot": "Zahlen von {imb_pub}, erhoben am {imb_date}. {unassigned} der {total} sind noch auf keiner Liste.",
|
||||
"funnel_people": "Menschen",
|
||||
"funnel_foot_source": "Zahlen von {imb_pub}, erhoben am {imb_date}.",
|
||||
"funnel_share_pct": "Dieses Heft ist {pct}% der {total}"
|
||||
"funnel_share_pct": "Dieses Heft ist {pct}% der {total}",
|
||||
"funnel_list_doxa": "Projekt 2000: die Volksgruppen dieses Gebetsnetzwerks",
|
||||
"funnel_list_unreached": "Projekt 7000: unerreichte Volksgruppen",
|
||||
"funnel_list_frontier": "Projekt 4700: Frontier-Volksgruppen",
|
||||
"funnel_list_frontier-500k": "Projekt 700: Frontier-Volksgruppen mit 500.000 oder mehr",
|
||||
"funnel_list_unengaged": "Unerreichte Volksgruppen ohne Mitarbeiter"
|
||||
},
|
||||
"it": {
|
||||
"map_page_kicker": "GRUPPI ETNICI NON RAGGIUNTI",
|
||||
|
|
@ -1027,7 +1052,12 @@
|
|||
"funnel_foot": "Dati da {imb_pub}, rilevati il {imb_date}. {unassigned} dei {total} non sono ancora in nessuna lista.",
|
||||
"funnel_people": "persone",
|
||||
"funnel_foot_source": "Dati da {imb_pub}, rilevati il {imb_date}.",
|
||||
"funnel_share_pct": "Questo libretto è il {pct}% dei {total}"
|
||||
"funnel_share_pct": "Questo libretto è il {pct}% dei {total}",
|
||||
"funnel_list_doxa": "Progetto 2000: i gruppi etnici di questa rete di preghiera",
|
||||
"funnel_list_unreached": "Progetto 7000: gruppi etnici non raggiunti",
|
||||
"funnel_list_frontier": "Progetto 4700: gruppi etnici di frontiera",
|
||||
"funnel_list_frontier-500k": "Progetto 700: gruppi etnici di frontiera di 500.000 o più",
|
||||
"funnel_list_unengaged": "Gruppi etnici non raggiunti e senza operai"
|
||||
},
|
||||
"ro": {
|
||||
"map_page_kicker": "GRUPURI ETNICE NEATINSE",
|
||||
|
|
@ -1197,7 +1227,12 @@
|
|||
"funnel_foot": "Cifre de la {imb_pub}, colectate la {imb_date}. {unassigned} din {total} nu sunt încă pe nicio listă.",
|
||||
"funnel_people": "persoane",
|
||||
"funnel_foot_source": "Cifre de la {imb_pub}, colectate la {imb_date}.",
|
||||
"funnel_share_pct": "Această broșură reprezintă {pct}% din {total}"
|
||||
"funnel_share_pct": "Această broșură reprezintă {pct}% din {total}",
|
||||
"funnel_list_doxa": "Proiectul 2000: grupurile etnice ale acestei rețele de rugăciune",
|
||||
"funnel_list_unreached": "Proiectul 7000: grupuri etnice neatinse",
|
||||
"funnel_list_frontier": "Proiectul 4700: grupuri etnice de frontieră",
|
||||
"funnel_list_frontier-500k": "Proiectul 700: grupuri etnice de frontieră de 500.000 sau mai multe",
|
||||
"funnel_list_unengaged": "Grupuri etnice neatinse și fără lucrători"
|
||||
},
|
||||
"ru": {
|
||||
"map_page_kicker": "НЕОХВАЧЕННЫЕ НАРОДЫ",
|
||||
|
|
@ -1367,7 +1402,12 @@
|
|||
"funnel_foot": "Данные {imb_pub}, собраны {imb_date}. {unassigned} из {total} пока не входят ни в один список.",
|
||||
"funnel_people": "человек",
|
||||
"funnel_foot_source": "Данные {imb_pub}, собраны {imb_date}.",
|
||||
"funnel_share_pct": "Этот буклет — {pct}% от {total}"
|
||||
"funnel_share_pct": "Этот буклет — {pct}% от {total}",
|
||||
"funnel_list_doxa": "Проект 2000: народы этой молитвенной сети",
|
||||
"funnel_list_unreached": "Проект 7000: неохваченные народы",
|
||||
"funnel_list_frontier": "Проект 4700: народы-фронтир",
|
||||
"funnel_list_frontier-500k": "Проект 700: народы-фронтир численностью 500 000 и более",
|
||||
"funnel_list_unengaged": "Неохваченные народы без работников"
|
||||
},
|
||||
"ar": {
|
||||
"map_page_kicker": "شعوب غير مبلَّغة",
|
||||
|
|
@ -1537,7 +1577,12 @@
|
|||
"funnel_foot": "الأرقام من {imb_pub}، جُمعت في {imb_date}. {unassigned} من {total} ليست بعد في أي قائمة.",
|
||||
"funnel_people": "نسمة",
|
||||
"funnel_foot_source": "الأرقام من {imb_pub}، جُمعت في {imb_date}.",
|
||||
"funnel_share_pct": "هذا الكُتيّب يمثل {pct}% من {total}"
|
||||
"funnel_share_pct": "هذا الكُتيّب يمثل {pct}% من {total}",
|
||||
"funnel_list_doxa": "مشروع 2000: المجموعات الشعبية في شبكة الصلاة هذه",
|
||||
"funnel_list_unreached": "مشروع 7000: المجموعات الشعبية غير المبلَّغة",
|
||||
"funnel_list_frontier": "مشروع 4700: المجموعات الشعبية الحدودية",
|
||||
"funnel_list_frontier-500k": "مشروع 700: المجموعات الشعبية الحدودية التي يبلغ عددها 500,000 أو أكثر",
|
||||
"funnel_list_unengaged": "مجموعات شعبية غير مبلَّغة وبلا عاملين"
|
||||
},
|
||||
"hi": {
|
||||
"map_page_kicker": "अनछुए जनसमूह",
|
||||
|
|
@ -1707,7 +1752,12 @@
|
|||
"funnel_foot": "आंकड़े {imb_pub} से, {imb_date} को लिए गए। {total} में से {unassigned} अभी किसी सूची में नहीं हैं।",
|
||||
"funnel_people": "लोग",
|
||||
"funnel_foot_source": "आंकड़े {imb_pub} से, {imb_date} को लिए गए।",
|
||||
"funnel_share_pct": "यह पुस्तिका {total} का {pct}% है"
|
||||
"funnel_share_pct": "यह पुस्तिका {total} का {pct}% है",
|
||||
"funnel_list_doxa": "परियोजना 2000: इस प्रार्थना नेटवर्क के जनसमूह",
|
||||
"funnel_list_unreached": "परियोजना 7000: अप्राप्त जनसमूह",
|
||||
"funnel_list_frontier": "परियोजना 4700: सीमांत जनसमूह",
|
||||
"funnel_list_frontier-500k": "परियोजना 700: 500,000 या अधिक के सीमांत जनसमूह",
|
||||
"funnel_list_unengaged": "अप्राप्त जनसमूह जिनमें कोई कार्यकर्ता नहीं"
|
||||
},
|
||||
"zh": {
|
||||
"map_page_kicker": "未得之民",
|
||||
|
|
@ -1877,6 +1927,11 @@
|
|||
"funnel_foot": "数据来自 {imb_pub},采集于 {imb_date}。{total} 中有 {unassigned} 还不在任何名单上。",
|
||||
"funnel_people": "人",
|
||||
"funnel_foot_source": "数据来自 {imb_pub},采集于 {imb_date}。",
|
||||
"funnel_share_pct": "这本祷告册占 {total} 的 {pct}%"
|
||||
"funnel_share_pct": "这本祷告册占 {total} 的 {pct}%",
|
||||
"funnel_list_doxa": "2000 计划:这个祷告网络的族群",
|
||||
"funnel_list_unreached": "7000 计划:未得之民族群",
|
||||
"funnel_list_frontier": "4700 计划:前沿族群",
|
||||
"funnel_list_frontier-500k": "700 计划:50 万人以上的前沿族群",
|
||||
"funnel_list_unengaged": "无人服事的未得之民族群"
|
||||
}
|
||||
}
|
||||
|
|
@ -119,14 +119,14 @@ const clusters = JSON.parse(readFileSync(join(DATA, 'clusters.json'), 'utf8'));
|
|||
//
|
||||
// ★ READ, NEVER COUNTED. The four steps come verbatim from vision-funnel.json, generated by
|
||||
// build-vision-funnel.mjs from the IMB CSV and the live network list, and already driving the
|
||||
// Vision Card. K and the unassigned count ARE measured here — but from the catalogue that defines
|
||||
// Vision Card. K IS measured here — from the catalogue that defines
|
||||
// them, at build time, so no figure on the page is a typed constant.
|
||||
// A missing file yields null, and buildPacket omits page 2 rather than printing numbers it could
|
||||
// not read.
|
||||
// ⚠ A FUNCTION, NOT A CONSTANT, because it depends on PACKET_LIST — which is resolved further down,
|
||||
// after the records load. It mirrors upg-100-list.vue's packetFunnel() exactly: the DEFAULT list is the
|
||||
// only one the zone catalogue describes, so it is the only one that may claim "1 of K lists" and an
|
||||
// unassigned count; a non-default list states its OWN total and goes quiet on the rest.
|
||||
// claim; a non-default list states its OWN total and goes quiet on the rest.
|
||||
function buildFunnel(membership) {
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(join(DATA, '../../../data/people-groups/vision-funnel.json'), 'utf8'));
|
||||
|
|
@ -134,11 +134,17 @@ function buildFunnel(membership) {
|
|||
if (membership) {
|
||||
return { ...raw, listTotal: membership.total, listId: LIST, listLabel: membership.label };
|
||||
}
|
||||
// ⚠ NO `unassigned` — see upg-100-list.vue's packetFunnel() for the full measurement. Short
|
||||
// version: it printed "21 of the 2,107 are not yet in any list", derived from summing DECLARED
|
||||
// count fields, and neither that figure nor the 418 I got by resolving the selectors survives
|
||||
// scrutiny — the records cache lacks wagf_region/wagf_block, so 13 of the 27 selectors cannot
|
||||
// resolve, and clusters.json's two DEAF lists are a second, overlapping partition that
|
||||
// allLists() excludes. "In no list" has no single answer until someone says which partition is
|
||||
// meant. Both paths must agree, so this one stops claiming it too.
|
||||
const lists = [...(clusters.lists || []), ...(clusters.asia_lists || []),
|
||||
...(clusters.uncovered_lists || [])];
|
||||
const listTotal = Number(raw.steps[raw.steps.length - 1].groups) || 0;
|
||||
const assigned = lists.reduce((s, l) => s + (Number(l.count) || 0), 0);
|
||||
return { ...raw, listCount: lists.length, listTotal, unassigned: Math.max(0, listTotal - assigned) };
|
||||
return { ...raw, listCount: lists.length, listTotal };
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
const { evaluate } = await import(join(DATA, 'selector-evaluator.js'));
|
||||
|
|
@ -324,7 +330,15 @@ for (const z of zones) {
|
|||
}
|
||||
|
||||
const { html, pageCount, withPhoto } = await buildPacket(z, groups, mapSvg);
|
||||
const name = LANG === 'en' ? `${z.id}.html` : `${z.id}.${LANG}.html`;
|
||||
// ⚠ THE LIST IS PART OF THE FILENAME, and leaving it out was a real hazard I created while arguing
|
||||
// against exactly this. PACKET_LIST fails closed because "a packet that showed the wrong list is
|
||||
// printed and handed to someone" — and then wrote the frontier packet over the default-list packet of
|
||||
// the same zone and language. rega measured it (2026-09-26): after a list run, out/packets/ held
|
||||
// frontier packets under the DEFAULT names beside an `ar` file that was still the default list, and
|
||||
// nothing in the filename could tell them apart. Guarding the input while leaving the output
|
||||
// ambiguous protects against asking for the wrong list, not against being unable to tell what you got.
|
||||
const listSuffix = LIST ? `.${LIST}` : '';
|
||||
const name = LANG === 'en' ? `${z.id}${listSuffix}.html` : `${z.id}${listSuffix}.${LANG}.html`;
|
||||
// ★ STAMP HOW THIS PACKET WAS BUILT, INTO THE PACKET. out/packets/ is gitignored, so the
|
||||
// sweep gate (check:packets-all) grades whatever .html files happen to be sitting there —
|
||||
// an ACCIDENTAL set, not a current one. MEASURED 2026-09-24: doxa-maps-plugin-studio, which
|
||||
|
|
@ -352,7 +366,13 @@ for (const z of zones) {
|
|||
Number(process.env.PHOTOS_EVERY || 0) > 1 ? 'mixedphotos' : null,
|
||||
mapMiss ? 'nomap' : null]
|
||||
.filter(Boolean).join(' ') || 'full';
|
||||
const stamped = html.replace('<html ', `<html data-packet-build="${provenance}" `);
|
||||
// ★ AND THE LIST IS STAMPED INTO THE DOCUMENT TOO, not only into the filename. A filename can be
|
||||
// renamed, copied or emailed; the packet itself should be able to say which list it holds. Same
|
||||
// reasoning as data-packet-build: out/packets/ is gitignored, so a gate grades whatever files happen
|
||||
// to be sitting there, and a packet that cannot describe its own provenance makes an accidental set
|
||||
// indistinguishable from a current one.
|
||||
const listAttr = LIST ? ` data-packet-list="${LIST}"` : '';
|
||||
const stamped = html.replace('<html ', `<html data-packet-build="${provenance}"${listAttr} `);
|
||||
writeFileSync(join(OUT, name), stamped);
|
||||
made++;
|
||||
if (mapMiss) mapless.push(z.id);
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@
|
|||
"check:gallery-embed:maplibre": "node internal/check-research-gallery-embed.mjs --provider maplibre",
|
||||
"check:gospel-all": "node internal/check-gospel-all.mjs",
|
||||
"check:gospel-tables-staged": "node internal/check-gospel-tables-staged.mjs",
|
||||
"check:map-attribution": "node internal/check-map-attribution.mjs",
|
||||
"check:translator-contacts": "node internal/check-translator-contacts.mjs",
|
||||
"check:translator-contacts:poison": "node internal/check-translator-contacts.mjs --poison",
|
||||
"check:heartbeat": "node internal/check-heartbeat-threshold.mjs",
|
||||
|
|
@ -129,6 +130,8 @@
|
|||
"check:no-codename": "node internal/check-no-internal-codename.mjs",
|
||||
"check:no-conflict-markers": "node internal/check-no-conflict-markers.mjs",
|
||||
"check:no-conflict-markers:poison": "node internal/check-no-conflict-markers.mjs --poison",
|
||||
"check:no-cross-plugin-imports": "node internal/check-no-cross-plugin-imports.mjs",
|
||||
"check:no-cross-plugin-imports:self-test": "node internal/check-no-cross-plugin-imports.mjs --self-test",
|
||||
"check:no-eval": "node internal/check-no-eval.mjs",
|
||||
"check:no-free-identifiers": "node plugin-designs/research-map-plus-grid/scripts/check-no-free-identifiers.mjs",
|
||||
"check:no-orphan-gates": "node internal/check-no-orphan-gates.mjs",
|
||||
|
|
@ -203,7 +206,7 @@
|
|||
"dev": "bun x vite",
|
||||
"fix:sri": "node internal/fix-sri.mjs",
|
||||
"gallery": "node internal/generate-gallery.mjs",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution",
|
||||
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts",
|
||||
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
|
||||
"library-index": "node internal/generate-library-index.mjs",
|
||||
|
|
@ -313,6 +316,7 @@
|
|||
"check:linked-vocabulary": "the two halves of research-map-plus-grid must mean the SAME THING by a religion family. The map filters by the legend's FIRST-LETTER key ('B'), the grid by a DERIVED family name ('Buddhism'), and both read ONE shared table (library/colors/religion.js) in opposite directions - so a wrong link stays invisible: both halves look filtered and show different peoples. Compares the total reached over the composite's wire against the total the grid gives for the same facet on its own, and runs the MUTANT first (the legend key instead of the name), failing as NO-TEETH if the wrong mapping scores the same as the right one.",
|
||||
"check:list-prop-unit": "the people-group LIST prop, measured in Node with no browser: the ladder (profile-config list > ?list= > window.MAP_LIST > doxa) and aliases, the default list untouched and unfetched, a list keeping exactly its membership, an unloadable list failing closed (no rows, never DOXA's whole list), and every imb list having a membership file that matches its registry filter.",
|
||||
"check:manifest-covers": "every DISCOVERED (non-gated) bundle must be in the built manifest",
|
||||
"check:map-attribution": "the map credit line names the basemap of the engine that drew the map: under MapLibre OpenFreeMap + OpenMapTiles + OpenStreetMap contributors (never Mapbox), under Mapbox Mapbox + OpenStreetMap; geoBoundaries on both. Headless.",
|
||||
"check:map-data-painted": "Every DECOUPLED map leaf must have OUR DATA on it, not just a basemap. verify-map-paint asserts a live GL context, attribution and zero console errors — all TRUE of a flawless basemap carrying none of our zones, polygons or pins, so every data-layer regression lands green. Measured 2026-09-26 by starve-probe (abort /assets/{boundaries,tables,people-groups}/ and the people-groups API, leave the basemap alone): 9 of the 13 leaves still report canvas, GL, attribution and 0 errors, so the hole is the MAJORITY of the suite; the other 4 lose their canvas entirely and the existing gate already fails them, so they are deliberately NOT listed — a leaf list is a claim about where a failure would otherwise go unseen. Each leaf is compared AGAINST ITSELF (healthy vs starved) because healthy leaves span 459..2821 colours and any global floor is vacuous or flaky: check:map-paints' 40-colour floor passes a starved upg-100-list at 306. Two measures, pass on EITHER — palette ratio <=0.55 OR >=3% of pixels changed — because each alone false-reds a real leaf (dashboard-plus-grid's 686x340 pane repaints 26.4% of the canvas at palette ratio 0.788; demo-map transforms the palette at ratio 0.196 while repainting 5.0%). A data-free map moves NEITHER. Uses page.screenshot({clip}), not elementHandle.screenshot(), whose stability wait never settles on an animating dashboard canvas (that cost 3 leaves a 20s timeout). Refuses to judge (exit 75) if either run has no canvas, if the starve aborted 0 requests, if the images cannot be compared, or if the gate itself throws. Run check:map-data-painted:poison to prove it can still fail. Browser gate, @browser-slot-max-hold 2700.",
|
||||
"check:map-data-painted:poison": "The mutation arm for check:map-data-painted, and MANDATORY because that gate's assertion is a DISJUNCTION (palette moved OR pixels moved) — a weakened assertion that still passes everything reads as coverage while proving nothing. Starves BOTH runs of our data, so neither measure can move, and requires every leaf to go RED. A green poison run means the bars have drifted into vacuity. Browser gate, @browser-slot-max-hold 2700.",
|
||||
"check:map-embed-cells": "PRODUCTION-MATRIX cells B (iframe) and C (script embed) for the seven Research Gallery maps on a genuinely foreign origin (tree on 127.0.0.1, partner page on localhost). C passes the HOST a distinct token and fails if any Mapbox request uses another. --only bundle/profile,… --cell B|C.",
|
||||
|
|
@ -327,6 +331,7 @@
|
|||
"check:no-abs-paths": "no /home/… or /Users/… in any TRACKED file (a clone must work on any machine)",
|
||||
"check:no-codename": "the development-era INTERNAL CODE NAME may not ship in any TRACKED file. Same hand-off family as check:no-abs-paths: meaningless to the Doxa team who inherit this, and invisible to every other gate (none read for vocabulary). Deliberately does NOT fail on the same letters appearing as the unix USERNAME inside a /home/… path — that is a portability defect check:no-abs-paths already reports better, and two gates disagreeing about one string teaches everyone to ignore both.",
|
||||
"check:no-conflict-markers": "Nothing tracked may carry an unresolved merge. Exists because a merge from this worktree committed a BUILT bundle with 21 conflict markers - both sides concatenated, 1.13MB - and every gospel leaf rendered dead while the SRI hash matched and every request returned 200. The cause was a landing script that resolved one expected file then ran \"git add -A public\", staging every other file including the ones still holding markers. For a built file: take either side, REBUILD, commit the rebuild, then run this before pushing. --poison for the negative test.",
|
||||
"check:no-cross-plugin-imports": "no plugin imports from another plugin's folder (src/, utils, data) by a relative path; shared code goes through library/ (@map/\u2026). A composite may mount another bundle's PUBLIC profile .vue. --self-test plants a crossing import.",
|
||||
"check:no-eval": "no built bundle contains new Function or eval (a strict CSP without 'unsafe-eval' must work), and vue-i18n in JIT mode renders real catalogue messages in en and ar with eval blocked. Headless.",
|
||||
"check:no-free-identifiers": "every name a .vue script uses is imported, declared, a parameter or a known global (scope-aware, across plugin-designs and library). A free identifier compiles and then throws at runtime in exactly the path nobody clicked.",
|
||||
"check:packet-browser": "the packet the READER gets — the legend's Export > Prayer packet path, proving the shared renderer runs in a BROWSER. Asserts the same 12 points / 12 wheel labels / page-1 geography / print layer as check:packet-render, which it did not until 2026-09-24. CONDITIONAL: SKIPs without a server over the repo root (python3 -m http.server 4898) and a reachable records API.",
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
import { inject, ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useMapInstance } from '@map/composables/useMapInstance.js'
|
||||
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
|
||||
import { resolveMapProvider, setProjectionOn } from '@map/composables/useMapProvider.js'
|
||||
import { useMapData } from '@map/composables/useMapData.js'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import { DataSourceManager } from '@map/utils/DataSourceManager.js'
|
||||
|
|
@ -348,7 +348,7 @@ onMounted(async () => {
|
|||
try {
|
||||
const rows = await rowsPromise
|
||||
if (!rows || !map.value) return
|
||||
try { map.value.setProjection('mercator') } catch (_) {}
|
||||
setProjectionOn(map.value, 'mercator') // per-engine shape: library/useMapProvider (C2)
|
||||
addLayer(buildFeatures(rows, new Set())) // every pin, by GSEC — the first paint
|
||||
mapStore?.registerMap?.('all-people-groups', map.value)
|
||||
appReady.value = true
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ import FullscreenButton from '@map/components/map-controls/FullscreenButton.vue
|
|||
import ThemeToggleButton from '@map/components/map-controls/ThemeToggleButton.vue'
|
||||
import HamburgerButton from '@map/components/map-controls/HamburgerButton.vue'
|
||||
import GeocoderComponent from '@map/components/map-controls/GeocoderComponent.vue'
|
||||
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
|
||||
import { resolveMapProvider, setProjectionOn } from '@map/composables/useMapProvider.js'
|
||||
import ShareButton from '@map/components/map-controls/ShareButton.vue'
|
||||
|
||||
// PERF: SideMenuDrawer is unused in the current research-map template (the
|
||||
|
|
@ -1978,7 +1978,7 @@ watch(() => mapStore.showClusters, async (on) => {
|
|||
// ─── Lifecycle ───────────────────────────────────────────────────────────────
|
||||
async function onMapReady(normalizedPeopleGroups) {
|
||||
mapStore.registerMap(mapId, map.value)
|
||||
try { map.value.setProjection('mercator') } catch (_) { /* style may not support */ }
|
||||
setProjectionOn(map.value, 'mercator') // per-engine shape: library/useMapProvider (C2)
|
||||
// Apply the persisted theme's lightPreset now the Standard style has loaded, so a
|
||||
// reload while dark was selected boots the NIGHT basemap (no light-under-dark desync).
|
||||
// (bug-ghost-rings-standard-style — Option B.)
|
||||
|
|
|
|||
|
|
@ -33,22 +33,14 @@ import { defineCustomElement } from 'vue'
|
|||
import { createPinia } from 'pinia'
|
||||
import ProfileLoader from '@map/ProfileLoader.vue'
|
||||
import { createAppI18n } from '@map/i18n/index.js'
|
||||
import { registerStrategies } from '@map/colors/_registry.js'
|
||||
|
||||
// ─── Bundle-private profile registry ─────────────────────────────────────────
|
||||
const profileModules = import.meta.glob('./*.vue')
|
||||
|
||||
// ─── Bundle-private color strategies ─────────────────────────────────────────
|
||||
// COPIED VERBATIM from doxa-research-map/src/colors/ — affinity-block.js,
|
||||
// doxa-region.js, resource.js. countries-map.vue is a clone of research-map.vue,
|
||||
// so it needs the same three research-only strategies; without doxaRegion in
|
||||
// particular the Countries tab would silently fall back to a different palette
|
||||
// (getColorStrategy() fails soft, so the symptom is wrong colours, not an error).
|
||||
//
|
||||
// Keys are auto-derived from filenames and merged OVER the shared library set.
|
||||
// This bundle inlines its own copy of the registry (IIFE build), so registering
|
||||
// here affects THIS bundle only.
|
||||
registerStrategies(import.meta.glob('./src/colors/*.js', { eager: true }))
|
||||
// ★ THE LOCAL COLOUR GLOB IS GONE (C1b). This bundle registered its own src/colors/*.js over the
|
||||
// shared set; all three modules (doxa-region, affinity-block, resource) now live in library/colors/
|
||||
// where _registry.js discovers them for every bundle, and src/colors/ holds no .js at all. The glob
|
||||
// matched nothing, so leaving it would be dead code that reads as a deliberate override.
|
||||
|
||||
// ─── Mapbox RTL text plugin — load ONCE before any map instance ──────────────
|
||||
if (typeof window !== 'undefined' && window.mapboxgl?.setRTLTextPlugin) {
|
||||
|
|
|
|||
|
|
@ -1,164 +0,0 @@
|
|||
/**
|
||||
* Affinity Block Color Strategy
|
||||
*
|
||||
* Colors pins based on their ROP1 code (A001, A002, etc.).
|
||||
* Uses ROP1 code as the primary key for consistency across data sources.
|
||||
*
|
||||
* ARCHITECTURE DECISION:
|
||||
* - Use ROP1 codes (A001, A002, etc.) as the PRIMARY KEY
|
||||
* - Store mappings from ROP1 → Name and ROP1 → Color
|
||||
* - Avoids issues with typos in affinity bloc names
|
||||
*/
|
||||
|
||||
export const PROPERTY_KEY = 'affinityBlock' // This is the ROP1 code
|
||||
|
||||
const DEFAULT_COLOR = '#95a5a6'
|
||||
|
||||
/** Primary mapping: ROP1 Code → Affinity Block Name */
|
||||
export const AFFINITY_BLOCK_NAMES = {
|
||||
'A001': 'Arab World',
|
||||
'A002': 'East Asian Peoples',
|
||||
'A003': 'Eurasian Peoples',
|
||||
'A004': 'Horn of Africa Peoples',
|
||||
'A005': 'Jewish',
|
||||
'A006': 'Malay Peoples',
|
||||
'A007': 'Persian-Median Peoples',
|
||||
'A008': 'South Asian Peoples',
|
||||
'A009': 'North American Peoples',
|
||||
'A010': 'Pacific Islanders',
|
||||
'A011': 'Southeast Asian Peoples',
|
||||
'A012': 'Sub-Saharan Peoples',
|
||||
'A013': 'Tibetan-Himalayan Peoples',
|
||||
'A014': 'Turkic Peoples',
|
||||
'A015': 'Latin-Caribbean Americans',
|
||||
'A017': 'Deaf'
|
||||
}
|
||||
|
||||
/** Primary mapping: ROP1 Code → Color */
|
||||
export const PALETTE = {
|
||||
'A001': '#E74C3C', // Arab World - Red/Orange
|
||||
'A002': '#3498DB', // East Asian Peoples - Blue
|
||||
'A003': '#2ECC71', // Eurasian Peoples - Green
|
||||
'A004': '#F39C12', // Horn of Africa Peoples - Orange
|
||||
'A005': '#9B59B6', // Jewish - Purple
|
||||
'A006': '#1ABC9C', // Malay Peoples - Teal
|
||||
'A007': '#E67E22', // Persian-Median Peoples - Dark orange
|
||||
'A008': '#34495E', // South Asian Peoples - Dark blue-grey
|
||||
'A009': '#95A5A6', // North American Peoples - Grey
|
||||
'A010': '#5DADE2', // Pacific Islanders - Light blue
|
||||
'A011': '#16A085', // Southeast Asian Peoples - Sea green
|
||||
'A012': '#C0392B', // Sub-Saharan Peoples - Deep red
|
||||
'A013': '#8E44AD', // Tibetan-Himalayan Peoples - Purple
|
||||
'A014': '#D35400', // Turkic Peoples - Burnt orange
|
||||
'A015': '#27AE60', // Latin-Caribbean Americans - Forest green
|
||||
'A017': '#000000' // Deaf - Black (universal accessibility)
|
||||
}
|
||||
|
||||
export const AFFINITY_BLOCK_COLORS = PALETTE
|
||||
|
||||
/** Reverse lookup: Name → ROP1 Code */
|
||||
export const AFFINITY_BLOCK_NAME_TO_CODE = Object.entries(AFFINITY_BLOCK_NAMES).reduce((acc, [code, name]) => {
|
||||
acc[name] = code
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
/** Legacy: Name-based color lookup (for backward compatibility) */
|
||||
export const AFFINITY_BLOCK_COLORS_BY_NAME = Object.entries(AFFINITY_BLOCK_NAMES).reduce((acc, [code, name]) => {
|
||||
acc[name] = AFFINITY_BLOCK_COLORS[code]
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
export const NAMES = AFFINITY_BLOCK_NAMES
|
||||
|
||||
export function getAffinityBlockName(code) {
|
||||
return AFFINITY_BLOCK_NAMES[code] || 'Unknown'
|
||||
}
|
||||
|
||||
export function getAffinityBlockColorByCode(code) {
|
||||
return AFFINITY_BLOCK_COLORS[code] || DEFAULT_COLOR
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color for an affinity block (supports both ROP1 code and name).
|
||||
*/
|
||||
export function getAffinityBlockColor(blockNameOrCode) {
|
||||
if (!blockNameOrCode) return DEFAULT_COLOR
|
||||
|
||||
if (typeof blockNameOrCode === 'string' && /^A\d{3}$/.test(blockNameOrCode)) {
|
||||
return AFFINITY_BLOCK_COLORS[blockNameOrCode] || DEFAULT_COLOR
|
||||
}
|
||||
|
||||
return AFFINITY_BLOCK_COLORS_BY_NAME[blockNameOrCode] || DEFAULT_COLOR
|
||||
}
|
||||
|
||||
export function getAffinityBlocksArray() {
|
||||
return Object.entries(AFFINITY_BLOCK_NAMES).map(([code, name]) => ({
|
||||
code,
|
||||
name,
|
||||
color: AFFINITY_BLOCK_COLORS[code]
|
||||
}))
|
||||
}
|
||||
|
||||
export function getAffinityBlockData(code) {
|
||||
return {
|
||||
code,
|
||||
name: AFFINITY_BLOCK_NAMES[code] || 'Unknown',
|
||||
color: AFFINITY_BLOCK_COLORS[code] || DEFAULT_COLOR
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strategy-aligned getter — uniform name across all per-strategy modules.
|
||||
* Expects properties.affinityBlock to contain ROP1 code (e.g., "A007").
|
||||
*/
|
||||
export function getColor(properties) {
|
||||
const rop1Code = properties.affinityBlock || properties._raw?.ROP1
|
||||
return getAffinityBlockColorByCode(rop1Code)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for a feature.
|
||||
*/
|
||||
export function getName(properties) {
|
||||
const rop1Code = properties.affinityBlock || properties._raw?.ROP1
|
||||
return getAffinityBlockName(rop1Code)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Mapbox color expression for affinity blocks.
|
||||
* Uses ROP1 code (A001, A002, etc.) as the match key.
|
||||
*/
|
||||
export function applyColor() {
|
||||
const colorExpression = ['match', ['get', PROPERTY_KEY]]
|
||||
Object.entries(AFFINITY_BLOCK_COLORS).forEach(([code, color]) => {
|
||||
colorExpression.push(code, color)
|
||||
})
|
||||
colorExpression.push(DEFAULT_COLOR)
|
||||
return colorExpression
|
||||
}
|
||||
|
||||
export const buildColorExpression = applyColor
|
||||
|
||||
// Backward-compatibility aliases
|
||||
export const affinityBlockCodeToName = AFFINITY_BLOCK_NAMES
|
||||
export const affinityBlockCodeToColor = AFFINITY_BLOCK_COLORS
|
||||
export const affinityBlockNameToCode = AFFINITY_BLOCK_NAME_TO_CODE
|
||||
export const affinityBlockColors = AFFINITY_BLOCK_COLORS_BY_NAME
|
||||
export const affinityBlockCodeLookup = AFFINITY_BLOCK_NAMES
|
||||
|
||||
export default {
|
||||
name: 'Affinity Block',
|
||||
propertyKey: PROPERTY_KEY,
|
||||
palette: PALETTE,
|
||||
colors: PALETTE,
|
||||
names: NAMES,
|
||||
getColor,
|
||||
applyColor,
|
||||
buildColorExpression,
|
||||
getName,
|
||||
getAffinityBlockName,
|
||||
getAffinityBlockColor,
|
||||
getAffinityBlockColorByCode,
|
||||
getAffinityBlocksArray,
|
||||
getAffinityBlockData
|
||||
}
|
||||
|
|
@ -15,8 +15,13 @@ import { createPinia } from 'pinia'
|
|||
import { createAppI18n } from '@map/i18n/index.js'
|
||||
import { registerStrategies } from '@map/colors/_registry.js'
|
||||
import { installRtlTextPlugin } from '@map/maps/rtl-text.js'
|
||||
import affinityBlock from '../doxa-research-map/src/colors/affinity-block.js'
|
||||
import doxaRegion from '../doxa-research-map/src/colors/doxa-region.js'
|
||||
// ★ WAS A CROSS-PLUGIN RELATIVE IMPORT into another plugin's PRIVATE folder
|
||||
// ('../doxa-research-map/src/colors/...'). Both modules now live in library/colors/ (C1), so this
|
||||
// bundle no longer depends on doxa-research-map's internal layout -- which is the coupling the
|
||||
// library exists to remove, and the reason this migration had four consumers rather than the two
|
||||
// the spec counted.
|
||||
import affinityBlock from '@map/colors/affinity-block.js'
|
||||
import doxaRegion from '@map/colors/doxa-region.js'
|
||||
import DashboardPlusGrid from './dashboard-plus-grid.vue'
|
||||
|
||||
// ─── THE SAME BYPASS AS THE i18n BUG BELOW, SECOND LEG (card BUILD-MAP-STYLE-...) ────────────
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@
|
|||
|
||||
import { inject, provide, ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useMapInstance } from '@map/composables/useMapInstance.js'
|
||||
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
|
||||
import { resolveMapProvider, setProjectionOn } from '@map/composables/useMapProvider.js'
|
||||
import { useMapData } from '@map/composables/useMapData.js'
|
||||
import { useMapLayers } from '@map/composables/useMapLayers.js'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
|
|
@ -201,7 +201,7 @@ onMounted(async () => {
|
|||
try {
|
||||
const { normalizedPeopleGroups } = await dataPromise
|
||||
mapStore?.registerMap?.(mapId, map.value)
|
||||
try { map.value.setProjection('mercator') } catch (e) { /* optional */ }
|
||||
setProjectionOn(map.value, 'mercator') // per-engine shape: library/useMapProvider (C2)
|
||||
if (normalizedPeopleGroups?.length) {
|
||||
mapLayers.addLanguageFamilyLayer(normalizedPeopleGroups, activeTab.value?.colorStrategy)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,21 +21,16 @@ import { defineCustomElement } from 'vue'
|
|||
import { createPinia } from 'pinia'
|
||||
import ProfileLoader from '@map/ProfileLoader.vue'
|
||||
import { createAppI18n } from '@map/i18n/index.js'
|
||||
import { registerStrategies } from '@map/colors/_registry.js'
|
||||
|
||||
// ─── Bundle-private profile registry ─────────────────────────────────────────
|
||||
// import.meta.glob is evaluated by Vite at build time relative to THIS file —
|
||||
// so it captures only this bundle's profiles.
|
||||
const profileModules = import.meta.glob('./*.vue')
|
||||
|
||||
// ─── Bundle-private color strategies ─────────────────────────────────────────
|
||||
// Same seam as profileModules, for colors. Any `<name>.js` a contributor drops in
|
||||
// this bundle's `src/colors/` folder is merged OVER the shared library set
|
||||
// (same mode key = override; new key = profile-private). This bundle inlines its own
|
||||
// copy of the registry (IIFE build), so registering here affects THIS bundle only.
|
||||
// This bundle ships THREE research-only strategies: affinity-block.js,
|
||||
// doxa-region.js, resource.js (shared modes like religion stay in the library).
|
||||
registerStrategies(import.meta.glob('./src/colors/*.js', { eager: true }))
|
||||
// ★ THE LOCAL COLOUR GLOB IS GONE (C1b). This bundle registered its own src/colors/*.js over the
|
||||
// shared set; all three modules (doxa-region, affinity-block, resource) now live in library/colors/
|
||||
// where _registry.js discovers them for every bundle, and src/colors/ holds no .js at all. The glob
|
||||
// matched nothing, so leaving it would be dead code that reads as a deliberate override.
|
||||
|
||||
// ─── Mapbox RTL text plugin — load ONCE before any map instance ──────────────
|
||||
if (typeof window !== 'undefined' && window.mapboxgl?.setRTLTextPlugin) {
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ import { retryableAsyncComponent } from '@map/composables/retryableAsyncComponen
|
|||
|
||||
// ─── Composables (framework) ─────────────────────────────────────────────────
|
||||
import { useMapInstance } from '@map/composables/useMapInstance.js'
|
||||
import { resolveMapProvider, providerLabel } from '@map/composables/useMapProvider.js'
|
||||
import { resolveMapProvider, providerLabel, setProjectionOn } from '@map/composables/useMapProvider.js'
|
||||
import { useMapLayers, PIN_STROKE_WIDTH_LIGHT, buildDarkRingWidth } from '@map/composables/useMapLayers.js'
|
||||
import { useMapTheme, THEME_TOGGLE, FIXED_BASEMAP } from '@map/composables/useMapTheme.js'
|
||||
import { useMapEvents } from '@map/composables/useMapEvents.js'
|
||||
|
|
@ -2168,10 +2168,39 @@ function emitFilterState() {
|
|||
// Report in the vocabulary a HOST speaks: the family NAME, not the legend's letter key. Same
|
||||
// shared RELIGION_FAMILIES table the inbound direction reads, used the other way round.
|
||||
const religionName = RELIGION_FAMILIES[uiStore?.religionFilter] || ''
|
||||
// ★ LANGUAGE TRAVELS AS A CODE AS WELL AS A NAME, because the NAME moves with the locale.
|
||||
// MEASURED against the live API: primary_language's LABEL is fully localized (864 names in en,
|
||||
// 864 in ar, ZERO shared) while its VALUE is the stable ISO 639-3 code (864 shared, identical).
|
||||
// Region already links on a slug and religion on a family letter; language was the one facet
|
||||
// still linking on a display name, so the moment the grid's data followed the URL's language the
|
||||
// link would have matched nothing in Arabic — silently, because an empty intersection looks like
|
||||
// "no results" rather than a broken wire.
|
||||
//
|
||||
// This tree has paid for exactly this once before: deriveLanguageFamily in useMapData carries a
|
||||
// comment about the 2026-06-22 locale colour bug, where deriving a family from the localized
|
||||
// label collapsed every pin onto one bucket. Same trap, one facet over.
|
||||
//
|
||||
// The NAME still goes out because this map's own legend selects by name; the CODE goes out for
|
||||
// anyone matching rows. Both halves get the form they can actually use.
|
||||
const langName = mapStore?.selectedLanguage || ''
|
||||
// Read the records here rather than assuming a `rows` in scope — check:no-free-identifiers caught
|
||||
// exactly that on its first outing against this very edit, which is the class of bug it is for.
|
||||
const langRows = mapData?.normalizedPeopleGroups?.value || []
|
||||
const langCode = langName
|
||||
? String(
|
||||
(langRows.find((r) => {
|
||||
const l = r && (r.language ?? r.primary_language)
|
||||
const lab = l && typeof l === 'object' ? l.label : l
|
||||
return String(lab || '').trim() === String(langName).trim()
|
||||
}) || {})._raw?.primary_language?.value || ''
|
||||
).trim()
|
||||
: ''
|
||||
|
||||
const detail = {
|
||||
region: mapStore?.selectedRegion || '',
|
||||
religion: religionName,
|
||||
language: mapStore?.selectedLanguage || '',
|
||||
language: langName,
|
||||
languageCode: langCode,
|
||||
}
|
||||
host.dispatchEvent(new CustomEvent('map-filtered', { detail, bubbles: true, composed: true }))
|
||||
}
|
||||
|
|
@ -2365,7 +2394,7 @@ watch(() => mapStore.clusteringMode, async () => {
|
|||
// ─── Lifecycle ───────────────────────────────────────────────────────────────
|
||||
async function onMapReady(normalizedPeopleGroups) {
|
||||
mapStore.registerMap(mapId, map.value)
|
||||
try { map.value.setProjection('mercator') } catch (_) { /* style may not support */ }
|
||||
setProjectionOn(map.value, 'mercator') // per-engine shape: library/useMapProvider (C2)
|
||||
// Apply the persisted theme's lightPreset now the Standard style has loaded, so a
|
||||
// reload while dark was selected boots the NIGHT basemap (no light-under-dark desync).
|
||||
// (bug-ghost-rings-standard-style — Option B.)
|
||||
|
|
|
|||
|
|
@ -1,139 +0,0 @@
|
|||
/**
|
||||
* DOXA Region Color Strategy
|
||||
*
|
||||
* Colors pins based on their DOXA / WAGF region.
|
||||
*
|
||||
* Drift note (vs simple-map):
|
||||
* - simple-map's 'No WAGF Region/Bloc' = '#95a5a6' (grey),
|
||||
* research = '#1a1a2e' (deep navy). Research wins.
|
||||
*
|
||||
* generateColorFromString fallback comes from utils/geoUtils.js — sister
|
||||
* subagent 1D owns that file. Cross-folder dep flagged.
|
||||
*/
|
||||
|
||||
import { generateColorFromString } from '@map/utils/geoUtils.js'
|
||||
|
||||
export const PROPERTY_KEY = 'doxaRegion'
|
||||
|
||||
const DEFAULT_COLOR = '#0098FF' // Bright blue for null/undefined region
|
||||
const FALLBACK_COLOR = '#95a5a6'
|
||||
|
||||
/**
|
||||
* DOXA Region Color Palette — defines colors for the 8 DOXA regions.
|
||||
*/
|
||||
export const PALETTE = {
|
||||
'Africa': '#ff2e2e',
|
||||
'Asia': '#1a8cff',
|
||||
'Europe': '#1ee05f',
|
||||
'Latin America & Caribbean': '#FFEB3B',
|
||||
'Middle East': '#a83dff',
|
||||
'No WAGF Region/Bloc': '#1a1a2e',
|
||||
'North America & Non-Spanish Caribbean': '#00e0bd',
|
||||
'Oceania': '#ff7f11'
|
||||
}
|
||||
|
||||
export const DOXA_REGION_COLORS = PALETTE
|
||||
|
||||
/**
|
||||
* SLUG-keyed palette — the AUTHORITATIVE map for coloring.
|
||||
*
|
||||
* The pray-tools API returns wagf_region as { value: <slug>, label: <localized> }.
|
||||
* DSM stores the stable slug in properties.doxaRegion (e.g. 'asia', 'middle_east').
|
||||
* The label is localized per `lang` ("Africa" → "África" → "Afrique"), so keying
|
||||
* colors on the display name silently breaks in every non-English locale (only
|
||||
* "Asia" — spelled identically across es/fr — survives). Key on the slug instead.
|
||||
* Slugs verified against the live API (2026-06-22).
|
||||
*/
|
||||
export const SLUG_PALETTE = {
|
||||
'africa': '#ff2e2e',
|
||||
'asia': '#1a8cff',
|
||||
'europe': '#1ee05f',
|
||||
'latin_america_&_caribbean': '#FFEB3B',
|
||||
'middle_east': '#a83dff',
|
||||
'north_america_&_non-spanish_caribbean': '#00e0bd',
|
||||
'oceania': '#ff7f11',
|
||||
'na': '#1a1a2e' // No WAGF Region/Bloc
|
||||
}
|
||||
|
||||
/** Color for a region by its stable slug (locale-independent). */
|
||||
export function getRegionColorBySlug(slug) {
|
||||
if (!slug) return DEFAULT_COLOR
|
||||
return SLUG_PALETTE[String(slug).toLowerCase()] || FALLBACK_COLOR
|
||||
}
|
||||
|
||||
/** Extended region aliases for flexible matching */
|
||||
export const REGION_COLOR_ALIASES = {
|
||||
'Africa': '#ff2e2e',
|
||||
'East Asia': '#1a8cff',
|
||||
'Asia': '#1a8cff',
|
||||
'Eurasia': '#1ee05f',
|
||||
'Europe': '#1ee05f',
|
||||
'Latin America': '#FFEB3B',
|
||||
'Latin America & Caribbean': '#FFEB3B',
|
||||
'Caribbean': '#FFEB3B',
|
||||
'Middle East': '#a83dff',
|
||||
'North America': '#00e0bd',
|
||||
'North America (non-spanish)': '#00e0bd',
|
||||
'North America & Non-Spanish Caribbean': '#00e0bd',
|
||||
'Pacific': '#ff7f11',
|
||||
'Oceania': '#ff7f11',
|
||||
'South Asia': '#ff7f11',
|
||||
'No WAGF Region/Bloc': '#1a1a2e',
|
||||
'Unknown': '#0098FF'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color for a region — falls back to a deterministic
|
||||
* generateColorFromString() so unmapped regions still render.
|
||||
*/
|
||||
export function getRegionColor(regionName) {
|
||||
if (!regionName) {
|
||||
return DEFAULT_COLOR
|
||||
}
|
||||
|
||||
const color = REGION_COLOR_ALIASES[regionName]
|
||||
if (!color) {
|
||||
return generateColorFromString(regionName)
|
||||
}
|
||||
|
||||
return color
|
||||
}
|
||||
|
||||
export function getColor(properties) {
|
||||
// properties.doxaRegion is the stable slug (e.g. 'asia'), NOT the localized
|
||||
// label — color by slug so it survives language switches.
|
||||
const slug = properties.doxaRegion || properties._normalized?.doxaRegion
|
||||
return getRegionColorBySlug(slug)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Mapbox color expression for DOXA regions.
|
||||
*
|
||||
* Matches on the stable slug stored in `doxaRegion` (e.g. 'asia') so the
|
||||
* expression keeps coloring correctly when the API returns localized labels.
|
||||
*/
|
||||
export function applyColor() {
|
||||
const colorExpression = ['match', ['get', PROPERTY_KEY]]
|
||||
Object.entries(SLUG_PALETTE).forEach(([slug, color]) => {
|
||||
colorExpression.push(slug, color)
|
||||
})
|
||||
colorExpression.push(FALLBACK_COLOR)
|
||||
return colorExpression
|
||||
}
|
||||
|
||||
export const buildColorExpression = applyColor
|
||||
|
||||
export default {
|
||||
name: 'DOXA Region',
|
||||
propertyKey: PROPERTY_KEY,
|
||||
palette: PALETTE,
|
||||
colors: PALETTE,
|
||||
slugColors: SLUG_PALETTE,
|
||||
aliases: REGION_COLOR_ALIASES,
|
||||
fallback: FALLBACK_COLOR,
|
||||
getColor,
|
||||
applyColor,
|
||||
buildColorExpression,
|
||||
getRegionColor,
|
||||
getRegionColorBySlug
|
||||
}
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
/**
|
||||
* Gospel Resources Color Strategy
|
||||
*
|
||||
* Colors pins based on available gospel resources.
|
||||
* DEFAULT: Binary coloring - Green (has ANY resource) / Black (no resources)
|
||||
* LEGEND COUNTS: Show TOTAL for each resource (can overlap)
|
||||
*/
|
||||
|
||||
export const PROPERTY_KEY = 'resourceType'
|
||||
|
||||
/**
|
||||
* Gospel Resources Colors
|
||||
* DEFAULT: Binary coloring - Green (has ANY resource) / Black (no resources)
|
||||
* LEGEND: Show TOTAL count for each resource (INCLUSIVE, can overlap)
|
||||
*/
|
||||
export const PALETTE = {
|
||||
bible: '#3498db', // Blue - Bible available
|
||||
jesusFilm: '#20c997', // Fisher Green (teal-green) - Jesus Film available
|
||||
radio: '#9b59b6', // Purple - Radio broadcast available
|
||||
gospel: '#f39c12', // Orange - Gospel recordings available
|
||||
audio: '#1abc9c', // Teal - Audio resources available
|
||||
stories: '#e74c3c', // Red - Stories available
|
||||
hasResources: '#27ae60', // Green - Has ANY gospel resource (default pin color)
|
||||
noResources: '#2c3e50' // Dark/Black - No gospel resources
|
||||
}
|
||||
|
||||
export const RESOURCE_COLORS = PALETTE
|
||||
|
||||
/** Resource priority order for pin coloring */
|
||||
export const RESOURCE_PRIORITY = ['bible', 'jesusFilm', 'radio', 'gospel', 'audio', 'stories']
|
||||
|
||||
/** Resource display names for legend */
|
||||
export const NAMES = {
|
||||
bible: 'Bible',
|
||||
jesusFilm: 'Jesus Film',
|
||||
radio: 'Radio',
|
||||
gospel: 'Gospel',
|
||||
audio: 'Audio',
|
||||
stories: 'Stories',
|
||||
hasResources: 'Has Resources',
|
||||
noResources: 'No Resources'
|
||||
}
|
||||
|
||||
export const RESOURCE_NAMES = NAMES
|
||||
|
||||
/**
|
||||
* Get CSV column name for a resource key.
|
||||
*/
|
||||
export function getCSVColumn(resourceKey) {
|
||||
const mapping = {
|
||||
bible: 'Bible',
|
||||
jesusFilm: 'Jesus',
|
||||
radio: 'Radio',
|
||||
gospel: 'Gospel',
|
||||
audio: 'Audio',
|
||||
stories: 'Stories'
|
||||
}
|
||||
return mapping[resourceKey]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color for a feature based on resource availability.
|
||||
* Binary logic: Green if has ANY resource, Black if none.
|
||||
*/
|
||||
export function getColor(properties) {
|
||||
for (const resourceKey of RESOURCE_PRIORITY) {
|
||||
const value = properties[resourceKey] || properties._raw?.[getCSVColumn(resourceKey)]
|
||||
if (value === 'Available') {
|
||||
return PALETTE.hasResources // Green
|
||||
}
|
||||
}
|
||||
return PALETTE.noResources // Black
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if feature has ANY resource (for hasResources).
|
||||
*/
|
||||
export function hasAnyResource(properties) {
|
||||
for (const resourceKey of RESOURCE_PRIORITY) {
|
||||
const value = properties[resourceKey] || properties._raw?.[getCSVColumn(resourceKey)]
|
||||
if (value === 'Available') {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the highest priority resource type for a feature.
|
||||
* Returns the resource key that determines the pin color.
|
||||
*/
|
||||
export function getResourceType(properties) {
|
||||
for (const resourceKey of RESOURCE_PRIORITY) {
|
||||
const value = properties[resourceKey] || properties._raw?.[getCSVColumn(resourceKey)]
|
||||
if (value === 'Available') {
|
||||
return resourceKey
|
||||
}
|
||||
}
|
||||
return 'noResources'
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Mapbox color expression for gospel resources.
|
||||
* DEFAULT: Binary coloring - Green if has ANY resource, Black if none.
|
||||
*/
|
||||
export function applyColor() {
|
||||
return [
|
||||
'case',
|
||||
// If ANY resource is Available → Green (hasResources)
|
||||
['any',
|
||||
['==', ['get', 'bible'], 'Available'],
|
||||
['==', ['get', 'jesusFilm'], 'Available'],
|
||||
['==', ['get', 'radio'], 'Available'],
|
||||
['==', ['get', 'gospel'], 'Available'],
|
||||
['==', ['get', 'audio'], 'Available'],
|
||||
['==', ['get', 'stories'], 'Available']
|
||||
],
|
||||
PALETTE.hasResources, // Green
|
||||
// Default: No resources → Black
|
||||
PALETTE.noResources // Black
|
||||
]
|
||||
}
|
||||
|
||||
export const buildColorExpression = applyColor
|
||||
|
||||
export default {
|
||||
name: 'Gospel Resources',
|
||||
propertyKey: PROPERTY_KEY,
|
||||
palette: PALETTE,
|
||||
colors: PALETTE,
|
||||
names: NAMES,
|
||||
priority: RESOURCE_PRIORITY,
|
||||
getColor,
|
||||
applyColor,
|
||||
buildColorExpression,
|
||||
hasAnyResource,
|
||||
getResourceType,
|
||||
getCSVColumn
|
||||
}
|
||||
|
|
@ -34,6 +34,7 @@ import { createPplrInstance, provideInstance } from '@map/composables/usePplrIns
|
|||
import { useLegendData as useFlatLegendData } from '@map/composables/legends/useLegendData.js'
|
||||
import { useMapFly } from '@map/composables/useMapFly.js'
|
||||
import { useCountryOutline, resolveCountryIso } from '@map/composables/useCountryOutline.js'
|
||||
import { setProjectionOn } from '@map/composables/useMapProvider.js'
|
||||
import SideMenuDrawer from '@map/components/SideMenuDrawer.vue'
|
||||
// ─── Map control components ───────────────────────────────────────────────────
|
||||
// Each button is imported individually so the profile can compose any subset
|
||||
|
|
@ -1241,7 +1242,8 @@ async function onMapReady(normalizedPeopleGroups) {
|
|||
|
||||
// Projection — safe guard
|
||||
try {
|
||||
map.value.setProjection('mercator')
|
||||
// ★ WAS UNGUARDED: a bare string THROWS on MapLibre, so this line could error the page.
|
||||
setProjectionOn(map.value, 'mercator') // per-engine shape: library/useMapProvider (C2)
|
||||
} catch (e) {
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1356,16 +1356,28 @@ async function packetFunnel() {
|
|||
const doxaTotal = Number(raw.steps[raw.steps.length - 1].groups) || 0;
|
||||
|
||||
// THE DEFAULT LIST is the one the zone catalogue actually describes, so it is the only one that
|
||||
// can honestly claim "1 of K lists" and an unassigned count.
|
||||
// can honestly claim "1 of K lists".
|
||||
//
|
||||
// ⚠ `unassigned` IS DELIBERATELY NOT SUPPLIED ANY MORE, and taking it out is the point. It printed
|
||||
// "21 of the 2,107 are not yet in any list", derived as doxaTotal minus the sum of the DECLARED
|
||||
// `count` fields — a claim about clusters.json's BOOKKEEPING, not about membership. Measured
|
||||
// 2026-09-26 while trying to verify it, and neither available figure survives:
|
||||
// · resolving every selector against the packet records gives 418 in no geographic list, not 21;
|
||||
// · but that cache carries only 12 fields (no wagf_region, no wagf_block), so 13 of the 27
|
||||
// selectors cannot resolve against it at all, and deaf_predicate matches 0 of the 123 deaf
|
||||
// records that ARE there.
|
||||
// Both numbers describe the FIXTURE, not the product. And the question is ill-posed besides:
|
||||
// clusters.json has FOUR buckets — the two deaf lists (61 + 62 groups) are excluded from
|
||||
// allLists(), and summing all 29 gives 2,209 against a total of 2,106, so the deaf lists OVERLAP
|
||||
// the geographic ones. They are a second, cross-cutting partition, which means "in no list" has no
|
||||
// single answer until someone says which partition is meant.
|
||||
// A confident figure I cannot defend is exactly the defect class I have been removing from other
|
||||
// surfaces all day; this one I put here myself. funnelPage already falls back to the
|
||||
// provenance-only footnote when unassigned is absent, so the page simply stops claiming it.
|
||||
// Carded for the Driver: 27 geographic / 29 counting deaf, and whether "Central Asia + Pakistan"
|
||||
// (55 groups, contiguous, poster-walled as a real region) is a prayer list rather than a residual.
|
||||
if (listId === LIST_DEFAULT) {
|
||||
const lists = allLists();
|
||||
const assigned = lists.reduce((sum, l) => sum + (Number(l.count) || 0), 0);
|
||||
return {
|
||||
...raw,
|
||||
listCount: lists.length,
|
||||
listTotal: doxaTotal,
|
||||
unassigned: Math.max(0, doxaTotal - assigned),
|
||||
};
|
||||
return { ...raw, listCount: allLists().length, listTotal: doxaTotal };
|
||||
}
|
||||
|
||||
// A NON-DEFAULT LIST: read its own DOXA-side total from the membership file the build writes.
|
||||
|
|
@ -1701,22 +1713,19 @@ function applySelectZone(zoneId) {
|
|||
// map's tapestry layer is not up, which at t=4.9s it was not. So the readiness signal was never
|
||||
// legendNodes or mapRef -- it is THE LAYER, and the old code cleared _pendingZoneId here as though
|
||||
// the work were done. Keeping it means ensureLayers() can drain it the moment the layer exists.
|
||||
// ★ ASK THE APPLIER, DO NOT INFER FROM A SIDE EFFECT -- and the inference was WRONG IN BATCH MODE.
|
||||
// upg100-plugin caught this prospectively: onLegendSelect feeds the selection UNION in batch mode
|
||||
// and returns without ever setting selectedKey, because isolating is not what it should do there.
|
||||
// So `!selectedKey.value` read that correct behaviour as a failure, and retaining on it would have
|
||||
// re-applied the zone and toggled it back OUT of the union. Un-isolating (node == null) is the same
|
||||
// shape: the un-isolate IS the action, and selectedKey is null afterwards BY DESIGN.
|
||||
// They have made onLegendSelect return a boolean for exactly this (fb076c8b on packet/vertical),
|
||||
// where false means one thing only: the map was not ready.
|
||||
// FORWARD-COMPATIBLE ON PURPOSE. That change is not on main yet, and a function with no explicit
|
||||
// return yields undefined, which is FALSY -- so depending on it today would read every success as a
|
||||
// failure and retry it, which is the very trap they warned about. So the boolean is authoritative
|
||||
// WHEN IT IS A BOOLEAN, and until then the old inference stands with batch mode excluded.
|
||||
const ok = (typeof applied === 'boolean')
|
||||
? applied
|
||||
: (batchMode.value ? true : !!selectedKey.value);
|
||||
if (!ok) { _pendingZoneId = zoneId; _markZoneState('applied-but-no-selection', zoneId); return false; }
|
||||
// ★ THE COMPATIBILITY FALLBACK IS GONE, and this is the promised cleanup rather than a change of
|
||||
// behaviour. onLegendSelect now returns an EXPLICIT boolean on all four of its paths — false when
|
||||
// the tapestry layer is not up, true for batch mode (it fed the union), true for the un-isolate
|
||||
// (the un-isolate IS the action) and true on success. So there is no path that yields undefined,
|
||||
// and the `typeof applied === 'boolean'` capability check it guarded can never fall through.
|
||||
//
|
||||
// WHY IT EXISTED AT ALL, kept as the reason not to reintroduce the inference: I used to read
|
||||
// !selectedKey.value as "it failed". That is a proxy for "did it ISOLATE", not for "did it work",
|
||||
// and the two differ in exactly the two paths above — batch mode and the un-isolate both leave
|
||||
// selectedKey unset while having succeeded. Retaining on that would have re-applied the zone and
|
||||
// toggled it back OUT of the selection union. upg100-plugin caught that before it bit anyone, and
|
||||
// added the boolean so the applier could be ASKED instead of inferred from.
|
||||
if (!applied) { _pendingZoneId = zoneId; _markZoneState('applied-but-no-selection', zoneId); return false; }
|
||||
_markZoneState('applied', zoneId);
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ import SearchBar from '@map/components/search/SearchBar.vue'
|
|||
import FilterDropdown from '@map/components/search/FilterDropdown.vue'
|
||||
import { resolveAssetsBase } from '@map/utils/embedBase.js'
|
||||
import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
|
||||
import { tStatic } from '@map/i18n/index.js'
|
||||
import { tStatic, localeFromUrl, RTL_LOCALES, getRuntimeLocale, detectLocale } from '@map/i18n/index.js'
|
||||
import { countrySlug } from './src/countries.js'
|
||||
// ── DERIVED family axes (BUG-SEMANTIC-TREE-SIDEBAR-NOT-INTEROPERABLE-WITH-GRID) ──
|
||||
// The Religion/Language trees' TOP TIERS emit DERIVED family names ("Hinduism",
|
||||
|
|
@ -251,7 +251,36 @@ const filtersBackdrop = computed(() => {
|
|||
}
|
||||
})
|
||||
const prayBaseUrl = computed(() => props.prayBaseUrl || cfg.value.prayBaseUrl || 'https://pray.doxa.life')
|
||||
const languageCode = computed(() => props.languageCode || cfg.value.languageCode || '')
|
||||
// ★ THE URL IS A SOURCE OF TRUTH FOR THE DATA TOO, not just for the chrome.
|
||||
// MEASURED by rega at ?lang=ar and ?lang=es: this grid's CHROME translated and its DATA stayed
|
||||
// English — country names, affinity names, religion labels — because the `&lang=` on the fetch was
|
||||
// built from props and profile-config ONLY. A reader who asked for Arabic got Arabic buttons around
|
||||
// English content, which reads as a half-finished translation rather than the wiring bug it is.
|
||||
// localeFromUrl() is the library's own reader, imported rather than re-implemented, so the fetch and
|
||||
// the chrome can never disagree about what the URL asked for. Same ladder regions uses
|
||||
// (region-dashboards.vue:95), and a host that passes languageCode still wins.
|
||||
const languageCode = computed(() =>
|
||||
props.languageCode || cfg.value.languageCode || cfg.value.locale || cfg.value.lang || localeFromUrl() || '')
|
||||
|
||||
// ★ RTL IS A DIRECTION, NOT A TRANSLATION — and this grid had none.
|
||||
// MEASURED in ar: the cards, the total and the filter bar all laid out left-to-right around Arabic
|
||||
// words. createAppI18n reflects an RTL locale onto document.documentElement, which is right for a
|
||||
// page it owns and useless for an element dropped into someone else's, which is this grid's whole
|
||||
// job. Set on the HOST, like both composites: `dir` takes effect through the inherited CSS
|
||||
// `direction`, and inheritance crosses shadow boundaries, so one attribute turns the element and
|
||||
// everything it renders. Only ever SET, never cleared — a host page that declares its own dir owns
|
||||
// that decision.
|
||||
const textDir = computed(() => {
|
||||
const l = languageCode.value || getRuntimeLocale() || detectLocale()
|
||||
return RTL_LOCALES.has(String(l).split('-')[0].toLowerCase()) ? 'rtl' : 'ltr'
|
||||
})
|
||||
function reflectDir() {
|
||||
try {
|
||||
const host = hostEl()
|
||||
if (host && host.setAttribute && textDir.value === 'rtl') host.setAttribute('dir', 'rtl')
|
||||
} catch (_) { /* no host (SSR/tests) — nothing to reflect */ }
|
||||
}
|
||||
watch(textDir, reflectDir)
|
||||
// ★ ABSOLUTE CANONICAL DEFAULT (Driver 2026-09-23, pasted from his own browser:
|
||||
// http://localhost:5174/research/pitaguari/ -- "when I click view more information it does not
|
||||
// send me to the proper doxa.life page"). The old default '/research/' was ROOT-RELATIVE, so it
|
||||
|
|
@ -842,6 +871,9 @@ onMounted(() => {
|
|||
const el = hostEl()
|
||||
if (el) el.__vocabulary = vocabulary
|
||||
if (typeof window !== 'undefined') window.__peopleGroupsGridVocabulary = vocabulary
|
||||
// The watcher above only fires on a CHANGE, and the common case is a page that loads in Arabic
|
||||
// and never changes — so reflect it once on mount as well.
|
||||
reflectDir()
|
||||
getUUPGs()
|
||||
})
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
*/
|
||||
import { CDN, pinElement } from '@map/constants/cdn.js'
|
||||
import { loadPinnedUmd } from '@map/utils/loadPinnedUmd.js'
|
||||
import { setProjectionOn } from '@map/composables/useMapProvider.js'
|
||||
let base = './assets'
|
||||
export function setDataBase(b) { if (b) base = String(b).replace(/\/+$/, '') }
|
||||
export function dataUrl(file) { return base + '/pplr/' + file }
|
||||
|
|
@ -91,12 +92,13 @@ export function whenGeojsonDrawn(map, timeoutMs = 45000) {
|
|||
// ('globe'), MapLibre 5 takes a specification object ({ type: 'globe' }). The views passed the bare
|
||||
// string, so under provider=maplibre the Globe/Mercator toggle did nothing (or threw inside a
|
||||
// style.load handler). Guarded: a style that cannot take the projection must not break the view.
|
||||
// ★ NOW A DELEGATE. This function was the REFERENCE implementation -- the only one of the three
|
||||
// patterns in the tree that was correct on both engines -- and library/useMapProvider's
|
||||
// setProjectionOn is it, lifted verbatim (C2). Kept as a named export so this plugin's three views
|
||||
// (pplr-religion, pplr-language-families, pplr-rop-tree) need no change at all, and so the name they
|
||||
// already read stays meaningful. Delete it when those views import the library helper directly.
|
||||
export function setProjectionFor(map, name) {
|
||||
if (!map || !name) return
|
||||
try {
|
||||
const ml = typeof window !== 'undefined' ? window.maplibregl : null
|
||||
map.setProjection(ml && ml.Map && map instanceof ml.Map ? { type: name } : name)
|
||||
} catch (_) { /* the style does not support it: keep the current projection */ }
|
||||
return setProjectionOn(map, name)
|
||||
}
|
||||
|
||||
// Verification handle (the gospel-resources __grState contract): what the ACTIVE view's map
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ import { useMapInstance } from '@map/composables/useMapInstance.js'
|
|||
// the expression is gone too. Three implementations were compared before swapping (the deleted
|
||||
// copy, my interim expression, and this) and they decide identically in every engine combination:
|
||||
// _qa-cycle/02-builder-cycle/_chk-glfor-equivalence.mjs.
|
||||
import { engineFor } from '@map/composables/useMapProvider.js'
|
||||
import { engineFor, projectionOf, setProjectionOn } from '@map/composables/useMapProvider.js'
|
||||
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
|
||||
import { resolveAssetsBase } from '@map/utils/embedBase.js'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
|
|
@ -549,14 +549,12 @@ function chooserZoom(m) {
|
|||
// fitTheirWay, escaped applySubject() — of which it is the last statement — and the camera was
|
||||
// never framed. Opening a region dashboard under MapLibre therefore left the map wherever it was
|
||||
// instead of flying to the region, which is exactly what the Driver reported.
|
||||
const projectionOf = (m) => {
|
||||
try { const p = m.getProjection && m.getProjection(); return (p && (p.name || p.type)) || null } catch (_) { return null }
|
||||
}
|
||||
// ★ BOTH OF THESE NOW COME FROM library/useMapProvider (C2). Mine read the engine from CONFIG
|
||||
// (isMaplibreProvider), which is weaker than reading it from the INSTANCE: a page configured for one
|
||||
// engine can hold a map built by the other. The library version instance-tests, which is what
|
||||
// pplr-data-maps had been doing correctly all along.
|
||||
// Mapbox takes the bare string; MapLibre v5 takes { type }. Writing the wrong shape is not a
|
||||
// no-op — maplibre reads e?.type and would set undefined.
|
||||
const setProjectionOn = (m, name) => {
|
||||
try { m.setProjection(isMaplibreProvider ? { type: name } : name) } catch (_) {}
|
||||
}
|
||||
|
||||
function fitTheirWay(m, bounds) {
|
||||
let cam = null
|
||||
|
|
|
|||
|
|
@ -25,8 +25,13 @@ import ProfileLoader from '@map/ProfileLoader.vue'
|
|||
import { createAppI18n } from '@map/i18n/index.js'
|
||||
import { registerStrategies } from '@map/colors/_registry.js'
|
||||
import { installRtlTextPlugin } from '@map/maps/rtl-text.js'
|
||||
import affinityBlock from '../doxa-research-map/src/colors/affinity-block.js'
|
||||
import doxaRegion from '../doxa-research-map/src/colors/doxa-region.js'
|
||||
// ★ WAS A CROSS-PLUGIN RELATIVE IMPORT into another plugin's PRIVATE folder
|
||||
// ('../doxa-research-map/src/colors/...'). Both modules now live in library/colors/ (C1), so this
|
||||
// bundle no longer depends on doxa-research-map's internal layout -- which is the coupling the
|
||||
// library exists to remove, and the reason this migration had four consumers rather than the two
|
||||
// the spec counted.
|
||||
import affinityBlock from '@map/colors/affinity-block.js'
|
||||
import doxaRegion from '@map/colors/doxa-region.js'
|
||||
|
||||
// ─── Bundle-private profile registry ─────────────────────────────────────────
|
||||
const profileModules = import.meta.glob('./*.vue')
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
*/
|
||||
import { inject, ref, computed, onMounted, onBeforeUnmount, watch, unref } from 'vue'
|
||||
import { useMapInstance } from '@map/composables/useMapInstance.js'
|
||||
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
|
||||
import { resolveMapProvider, setProjectionOn } from '@map/composables/useMapProvider.js'
|
||||
import { useMapFly } from '@map/composables/useMapFly.js'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import { tStatic } from '@map/i18n/index.js'
|
||||
|
|
@ -1375,9 +1375,10 @@ const projection = ref('globe')
|
|||
// but it now catches a genuine unsupported-engine case instead of masking a shape mismatch.
|
||||
function applyProjection() {
|
||||
const m = map.value
|
||||
if (!m || typeof m.setProjection !== 'function') return
|
||||
try { m.setProjection({ type: projection.value }); return } catch (_) { /* not the object form */ }
|
||||
try { m.setProjection(projection.value) } catch (_) { /* engine has no projection support */ }
|
||||
// ★ WAS PROBING BY EXCEPTION: it tried the object form, let it THROW on Mapbox, then fell back to
|
||||
// the string. That reached the right end state but by throwing on every Mapbox call, and it asked
|
||||
// the engine by trial instead of by identity. The helper instance-tests instead (C2).
|
||||
setProjectionOn(m, projection.value)
|
||||
}
|
||||
function toggleProjection() { projection.value = projection.value === 'globe' ? 'mercator' : 'globe'; applyProjection() }
|
||||
|
||||
|
|
|
|||
|
|
@ -230,7 +230,13 @@ const bundleSrc = (bundle, file) =>
|
|||
// composite that let the grid hold all three would put the two halves into a disagreement the map
|
||||
// cannot resolve and the reader cannot see. So the composite mirrors the map's own exclusivity: a
|
||||
// new facet replaces the previous one, exactly as clicking a second legend row does.
|
||||
const linked = reactive({ region: { value: '', label: '' }, religion: '', language: '' })
|
||||
// ★ LANGUAGE CARRIES TWO FORMS NOW, FOR THE SAME REASON REGION ALWAYS DID.
|
||||
// MEASURED against the live API: primary_language's LABEL is fully localized (zero names shared
|
||||
// between en and ar) while its VALUE is the stable ISO 639-3 code (all 864 identical). The map's
|
||||
// legend selects by NAME; the grid matches rows by VALUE. Carrying one form would have meant the
|
||||
// link matching nothing the moment the grid's data followed the URL's language — and an empty
|
||||
// intersection reads as "no results", not as a broken wire.
|
||||
const linked = reactive({ region: { value: '', label: '' }, religion: '', language: { value: '', label: '' } })
|
||||
|
||||
// ★ WHAT THE LIST IS SHOWING, so the map can go there (BUILD-GRID-SELECTION-FLIES-THE-MAP).
|
||||
// The link ran one way: the map narrowed the grid, and the grid moved nothing. The grid already
|
||||
|
|
@ -248,7 +254,7 @@ const gridSlugs = ref([])
|
|||
function clearExcept(keep) {
|
||||
if (keep !== 'region' && (linked.region.value || linked.region.label)) { linked.region.value = ''; linked.region.label = '' }
|
||||
if (keep !== 'religion' && linked.religion) linked.religion = ''
|
||||
if (keep !== 'language' && linked.language) linked.language = ''
|
||||
if (keep !== 'language' && (linked.language.value || linked.language.label)) { linked.language.value = ''; linked.language.label = '' }
|
||||
}
|
||||
const slugify = (label) => String(label || '').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '')
|
||||
|
||||
|
|
@ -279,8 +285,14 @@ function adopt(next) {
|
|||
if (next.religion !== linked.religion) { linked.religion = next.religion; clearExcept('religion') }
|
||||
return
|
||||
}
|
||||
if (next.language) {
|
||||
if (next.language !== linked.language) { linked.language = next.language; clearExcept('language') }
|
||||
if (next.language && (next.language.value || next.language.label)) {
|
||||
const v = next.language.value || ''
|
||||
const l = next.language.label || ''
|
||||
if (v !== linked.language.value || l !== linked.language.label) {
|
||||
linked.language.value = v
|
||||
linked.language.label = l
|
||||
clearExcept('language')
|
||||
}
|
||||
return
|
||||
}
|
||||
// Nothing set anywhere in this report: the reader cleared whatever was active.
|
||||
|
|
@ -306,7 +318,8 @@ function onGridFiltered(ev) {
|
|||
adopt({
|
||||
region: { value: (entry && (entry.value ?? entry)) || '', label: (entry && entry.label) || '' },
|
||||
religion: nameOf(active.__religion_family),
|
||||
language: nameOf(active.primary_language),
|
||||
// The grid reports {value,label} per facet, which is exactly the two forms the halves need.
|
||||
language: { value: (active.primary_language && active.primary_language.value) || '', label: (active.primary_language && active.primary_language.label) || '' },
|
||||
})
|
||||
}
|
||||
function onMapFiltered(ev) {
|
||||
|
|
@ -318,7 +331,8 @@ function onMapFiltered(ev) {
|
|||
adopt({
|
||||
region: { value: label ? slugify(label) : '', label },
|
||||
religion: ev?.detail?.religion || '',
|
||||
language: ev?.detail?.language || '',
|
||||
// The map sends both: the name its legend selected by, and the locale-stable ISO code.
|
||||
language: { value: ev?.detail?.languageCode || '', label: ev?.detail?.language || '' },
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -339,14 +353,17 @@ function pushToChildren() {
|
|||
if (map) {
|
||||
let cfg = {}
|
||||
try { cfg = JSON.parse(map.getAttribute('profile-config') || '{}') } catch (_) { cfg = {} }
|
||||
cfg.externalFilters = { region: linked.region.value, religion: linked.religion, language: linked.language, fitSlugs: gridSlugs.value }
|
||||
cfg.externalFilters = { region: linked.region.value, religion: linked.religion, language: linked.language.label, fitSlugs: gridSlugs.value }
|
||||
map.setAttribute('profile-config', JSON.stringify(cfg))
|
||||
}
|
||||
const grid = rootEl.querySelector('people-groups-grid')
|
||||
if (grid) {
|
||||
grid.setAttribute('wagf-region', linked.region.value)
|
||||
grid.setAttribute('religion-family', linked.religion)
|
||||
grid.setAttribute('primary-language', linked.language)
|
||||
// ★ THE CODE, NOT THE NAME. valueMatches compares the normalized value as well as the label,
|
||||
// so a stable ISO code matches in every locale while a display name only matches in the one it
|
||||
// was fetched in.
|
||||
grid.setAttribute('primary-language', linked.language.value || linked.language.label)
|
||||
}
|
||||
}
|
||||
watch(linked, pushToChildren, { deep: true })
|
||||
|
|
@ -425,7 +442,7 @@ const sections = computed(() => [
|
|||
...(hostLang.value ? { lang: hostLang.value } : {}),
|
||||
...(hostList.value ? { list: hostList.value } : {}),
|
||||
// The composite's filter state, in the map's inbound shape. Absent keys are ignored there.
|
||||
externalFilters: { region: linked.region.value, religion: linked.religion, language: linked.language, fitSlugs: gridSlugs.value },
|
||||
externalFilters: { region: linked.region.value, religion: linked.religion, language: linked.language.label, fitSlugs: gridSlugs.value },
|
||||
assetsBase: '../../assets',
|
||||
shareUrl: SHARE_URL
|
||||
})
|
||||
|
|
@ -452,7 +469,7 @@ const sections = computed(() => [
|
|||
// reason); driving them from the composite is the same channel, not a new one.
|
||||
'wagf-region': linked.region.value,
|
||||
'religion-family': linked.religion,
|
||||
'primary-language': linked.language,
|
||||
'primary-language': linked.language.value || linked.language.label,
|
||||
}
|
||||
}
|
||||
])
|
||||
|
|
|
|||
|
|
@ -89,11 +89,17 @@ const lines = []
|
|||
// engine — a MapLibre-only paint or layout mistake drops a whole layer silently, which would show
|
||||
// up here as a link that delivers nothing while the Mapbox run stays green.
|
||||
const PROVIDERS = ['mapbox', 'maplibre']
|
||||
// ★ AND PER LOCALE, because the grid's DATA now follows the URL. The labels this link used to carry
|
||||
// are fully localized — zero language names shared between en and ar — so a link proven only in
|
||||
// English proves nothing about the language a reader actually asked for. en is the control; ar is
|
||||
// the case that would have broken silently.
|
||||
const LOCALES = (process.env.LINK_LOCALES || 'en,ar').split(',').map((s) => s.trim()).filter(Boolean)
|
||||
const results = {}
|
||||
try {
|
||||
for (const locale of LOCALES) {
|
||||
for (const engine of PROVIDERS) {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } })
|
||||
await page.goto(`${LEAF}?provider=${engine}`, { waitUntil: 'domcontentloaded', timeout: 60000 })
|
||||
await page.goto(`${LEAF}?provider=${engine}&lang=${locale}`, { waitUntil: 'domcontentloaded', timeout: 60000 })
|
||||
|
||||
// Everything below runs in ONE evaluate: the totals have to be read from the same live page, and
|
||||
// a `groups-filtered` listener installed from Node would miss events fired between round trips.
|
||||
|
|
@ -192,15 +198,52 @@ try {
|
|||
// ── THEN THE REAL THING ───────────────────────────────────────────────────────────────────
|
||||
const linked = await driveLinked(family)
|
||||
|
||||
return { unfiltered, family, letter, direct, linked, mutant, mapVocab, gridVocab: gridVocab.slice(0, 12) }
|
||||
// ★ AND THE LANGUAGE FACET, WHICH IS THE ONE THAT MOVES WITH THE LOCALE.
|
||||
// MEASURED against the live API: primary_language's LABEL is fully localized (zero names shared
|
||||
// between en and ar) while its VALUE is the stable ISO 639-3 code (all identical). So the link
|
||||
// must travel as the CODE. This proves it does, and the poison case proves the code is what is
|
||||
// doing the work rather than a name that happens to match in the locale under test.
|
||||
let lang = null
|
||||
const langVocab = (grid.__vocabulary || window.__peopleGroupsGridVocabulary)?.('primary_language') || []
|
||||
// Find a language whose own facet narrows the grid to a strict subset, and learn its CODE from
|
||||
// the grid's rows rather than from a table.
|
||||
for (const name of langVocab.slice(0, 40)) {
|
||||
await clear()
|
||||
await sleep(1500)
|
||||
grid.setAttribute('primary-language', name)
|
||||
await settle()
|
||||
if (total > 0 && total < unfiltered) { lang = { name, direct: total }; break }
|
||||
}
|
||||
if (lang) {
|
||||
// The map reports both forms on its own wire; drive that wire and read what the grid does.
|
||||
await clear(); await sleep(1500)
|
||||
const map = deep('doxa-research-map') || host
|
||||
map.dispatchEvent(new CustomEvent('map-filtered', {
|
||||
detail: { region: '', religion: '', language: lang.name, languageCode: '' },
|
||||
bubbles: true, composed: true,
|
||||
}))
|
||||
await settle()
|
||||
lang.byName = total
|
||||
// POISON: a code that cannot exist. If the grid still narrows the same way, the wire is not
|
||||
// carrying what this gate thinks it is.
|
||||
await clear(); await sleep(1500)
|
||||
map.dispatchEvent(new CustomEvent('map-filtered', {
|
||||
detail: { region: '', religion: '', language: 'zz-not-a-language', languageCode: 'zzz' },
|
||||
bubbles: true, composed: true,
|
||||
}))
|
||||
await settle()
|
||||
lang.poison = total
|
||||
}
|
||||
|
||||
return { unfiltered, family, letter, direct, linked, mutant, mapVocab, gridVocab: gridVocab.slice(0, 12), lang, langVocabCount: langVocab.length }
|
||||
})
|
||||
|
||||
await page.close()
|
||||
if (out.skip) { await browser.close(); cleanup(); skip(`${out.skip} (under ${engine})`) }
|
||||
results[engine] = out
|
||||
if (out.fail) { fails.push(`[${engine}] ${out.fail}`); continue }
|
||||
if (out.skip) { await browser.close(); cleanup(); skip(`${out.skip} (under ${locale}/${engine})`) }
|
||||
results[`${locale}/${engine}`] = out
|
||||
if (out.fail) { fails.push(`[${locale}/${engine}] ${out.fail}`); continue }
|
||||
|
||||
lines.push(` ── ${engine}`)
|
||||
lines.push(` ── ${locale} / ${engine}`)
|
||||
lines.push(` vocabulary — the map names ${out.mapVocab.length} religion families, the grid names ${out.gridVocab.length}+; '${out.family}' is shared`)
|
||||
lines.push(` direct — religion-family="${out.family}" set on the grid itself: ${out.direct} of ${out.unfiltered}`)
|
||||
lines.push(` linked — the same family over the composite's wire: ${out.linked}`)
|
||||
|
|
@ -212,12 +255,26 @@ try {
|
|||
if (out.linked !== out.direct) {
|
||||
fails.push(`[${engine}] the link does not deliver the facet — '${out.family}' set on the grid gives ${out.direct} rows, the same family over the composite's wire gives ${out.linked}.`)
|
||||
}
|
||||
if (out.lang) {
|
||||
lines.push(` language — '${out.lang.name}' direct ${out.lang.direct}, over the wire ${out.lang.byName}, poison ${out.lang.poison} (of ${out.langVocabCount} names)`)
|
||||
if (out.lang.byName !== out.lang.direct) {
|
||||
fails.push(`[${engine}] the LANGUAGE link does not deliver the facet — '${out.lang.name}' set on the grid gives ${out.lang.direct} rows, the same language over the composite's wire gives ${out.lang.byName}.`)
|
||||
}
|
||||
if (out.lang.poison === out.lang.direct) {
|
||||
fails.push(`[${engine}] NO TEETH on language — a language that does not exist narrowed the grid to the same ${out.lang.direct} rows. The wire is not carrying what this gate thinks it is.`)
|
||||
}
|
||||
} else {
|
||||
lines.push(` language — SKIPPED (no single language narrows this grid to a strict subset)`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★ AND THE TWO ENGINES MUST AGREE. Each run above only proves the link works within one engine;
|
||||
// identical COUNTS is what proves the contract is the same product on both. A per-engine pass with
|
||||
// different numbers would mean the two builds narrow to different peoples for the same choice.
|
||||
const [a, b] = PROVIDERS
|
||||
// ★ THE TWO ENGINES MUST AGREE, WITHIN EACH LOCALE. Each run above only proves the link works in
|
||||
// one build; identical COUNTS is what proves the contract is the same product on both. Comparing
|
||||
// ACROSS locales would compare two different datasets, since the labels themselves change — so the
|
||||
// comparison is per locale, and a per-engine pass with different numbers still fails.
|
||||
for (const locale of LOCALES) {
|
||||
const [a, b] = PROVIDERS.map((e) => `${locale}/${e}`)
|
||||
if (results[a] && results[b] && !results[a].fail && !results[b].fail) {
|
||||
if (results[a].unfiltered !== results[b].unfiltered) {
|
||||
fails.push(`the two engines start from different data — ${a} lists ${results[a].unfiltered} groups, ${b} lists ${results[b].unfiltered}.`)
|
||||
|
|
@ -229,6 +286,7 @@ try {
|
|||
fails.push(`the link delivers ${results[a].linked} rows under ${a} and ${results[b].linked} under ${b} — interoperability that holds on one provider and not the other is not interoperability.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
fails.push(`the measurement itself failed: ${err && err.message}`)
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import { useLanguageFamilyLegendData } from '@map/composables/legends/useLanguag
|
|||
import langFamilyByLanguage from '@map/data/langFamilyByLanguage.json'
|
||||
import bakedReached from '../data/reached-neighbors.json'
|
||||
import { useNeighborT } from '../composables/useNeighborT.js'
|
||||
import { setProjectionOn } from '@map/composables/useMapProvider.js'
|
||||
const nt = useNeighborT()
|
||||
|
||||
const C_RED = '#e11d48', C_RED_DK = '#9f1239', C_NAVY = '#002e6e', C_GOLD = '#f59e0b'
|
||||
|
|
@ -278,7 +279,7 @@ async function fetchDoxa() {
|
|||
onMounted(async () => {
|
||||
await initializeMap()
|
||||
const m = map.value
|
||||
try { m && m.setProjection('mercator') } catch (_) {} // flat mercator, never globe (Driver)
|
||||
setProjectionOn(m, 'mercator') // flat mercator, never globe (Driver); per-engine shape via C2 helper
|
||||
try { doxaFeatures.value = await fetchDoxa() }
|
||||
catch (e) { statusMsg.value = 'Live doxa API unreachable — ' + e.message; appReady.value = true; return }
|
||||
appReady.value = true
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { inject, ref, computed, watch, onMounted, onBeforeUnmount, nextTick } fr
|
|||
import { useMapInstance } from '@map/composables/useMapInstance.js'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import { useCockpitStore, COLORS, redColor, reachedColor, logRadius, fmtPop } from './useCockpit.js'
|
||||
import { setProjectionOn } from '@map/composables/useMapProvider.js'
|
||||
|
||||
const store = useCockpitStore()
|
||||
|
||||
|
|
@ -364,7 +365,7 @@ onMounted(async () => {
|
|||
await initializeMap()
|
||||
const m = map.value
|
||||
if (!m) { return } // no/invalid token — overlay shows the hint, store still drives the rest
|
||||
try { m.setProjection('mercator') } catch (_) {} // flat mercator, never globe (Driver)
|
||||
setProjectionOn(m, 'mercator') // flat mercator, never globe (Driver); per-engine shape via C2 helper
|
||||
|
||||
// Gated test hook (inert in production): lets a harness use the live map.project().
|
||||
if (cfg.value.debugExpose && typeof window !== 'undefined') {
|
||||
|
|
|
|||
|
|
@ -388,6 +388,7 @@ function onMove(ev) { if (tip.value.show) { tip.value.x = ev.clientX; tip.value.
|
|||
// adminUrl/dataUrl/dataBase still override live (loadJSON below).
|
||||
import bakedReached from './data/reached-neighbors.json'
|
||||
import bakedAdmin1 from './data/admin1-membership.json'
|
||||
import { setProjectionOn } from '@map/composables/useMapProvider.js'
|
||||
async function loadJSON(relCandidates) {
|
||||
for (const url of relCandidates) {
|
||||
try { const r = await fetch(url); if (r.ok) return await r.json() } catch (_) {}
|
||||
|
|
@ -410,7 +411,7 @@ async function fetchDoxa() {
|
|||
onMounted(async () => {
|
||||
await initializeMap()
|
||||
const m = map.value
|
||||
try { m && m.setProjection('mercator') } catch (_) {} // flat mercator, never globe (Driver)
|
||||
setProjectionOn(m, 'mercator') // flat mercator, never globe (Driver); per-engine shape via C2 helper
|
||||
|
||||
// baked data (own bundle dir). `base` lets the host say where data/ is served from
|
||||
// (platform deploy serves data/ alongside the bundle, like send-map/flower-mesh).
|
||||
|
|
|
|||
|
|
@ -401,6 +401,7 @@ function flyToSender(n) {
|
|||
// the profile data/ folder, so fetch('./data/...') 404s in the REAL platform (worked in dev only).
|
||||
// profile-config `dataUrl` still overrides live.
|
||||
import bakedReached from './data/reached-neighbors.json'
|
||||
import { setProjectionOn } from '@map/composables/useMapProvider.js'
|
||||
async function loadDataset() {
|
||||
const cfgUrl = profileConfig?.value?.dataUrl
|
||||
if (cfgUrl) {
|
||||
|
|
@ -412,7 +413,7 @@ async function loadDataset() {
|
|||
onMounted(async () => {
|
||||
await initializeMap()
|
||||
const m = map.value
|
||||
try { m && m.setProjection('mercator') } catch (_) {} // flat mercator, never globe (Driver)
|
||||
setProjectionOn(m, 'mercator') // flat mercator, never globe (Driver); per-engine shape via C2 helper
|
||||
|
||||
// Test-only hook (inert in production — platform never sets debugExpose). Lets an
|
||||
// automated harness use the live map.project() to click exact circle positions.
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <all-people-groups> custom element. -->
|
||||
<script src="../all-people-groups.js" integrity="sha384-pZ0D7REIL/9op0LMYxWQLHaBZzh3u3G/9pg8RumYadFpfF07glSFpn9Nl+N70mbq" crossorigin="anonymous"></script>
|
||||
<script src="../all-people-groups.js" integrity="sha384-yda1IvWyb6O5+st/W+s6qZMh+g/CUhXzARi4VZc/KjfXu7p/yzVjbpSsY1gCbMRW" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -43,7 +43,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <bp-big-picture> custom element. -->
|
||||
<script src="../../big-picture.js" integrity="sha384-f0QwbiCCDjTDqiQVxWY5KwZIGQ/eEcHdrSPLR8GqCZfc4r5PkfObe4GMN4lxK8ki" crossorigin="anonymous"></script>
|
||||
<script src="../../big-picture.js" integrity="sha384-ol5VjnttxiwjEl9dlcOzHFYhGqUUFNfDm/FKV3zeR7YQCFOczRHasOGlJtdsRuuy" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <bp-big-picture> custom element. -->
|
||||
<script src="../../big-picture.js" integrity="sha384-f0QwbiCCDjTDqiQVxWY5KwZIGQ/eEcHdrSPLR8GqCZfc4r5PkfObe4GMN4lxK8ki" crossorigin="anonymous"></script>
|
||||
<script src="../../big-picture.js" integrity="sha384-ol5VjnttxiwjEl9dlcOzHFYhGqUUFNfDm/FKV3zeR7YQCFOczRHasOGlJtdsRuuy" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <bp-big-picture> custom element. -->
|
||||
<script src="../big-picture.js" integrity="sha384-f0QwbiCCDjTDqiQVxWY5KwZIGQ/eEcHdrSPLR8GqCZfc4r5PkfObe4GMN4lxK8ki" crossorigin="anonymous"></script>
|
||||
<script src="../big-picture.js" integrity="sha384-ol5VjnttxiwjEl9dlcOzHFYhGqUUFNfDm/FKV3zeR7YQCFOczRHasOGlJtdsRuuy" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -32,7 +32,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <cities-internet-map> custom element. -->
|
||||
<script src="../cities-internet.js" integrity="sha384-NtGXg+UiGpEhiYJ/WjGYFGofeo3cmEag6uO+ORX7RpDO2Zo72mNLu0Ld3q/JSu7y" crossorigin="anonymous"></script>
|
||||
<script src="../cities-internet.js" integrity="sha384-pE5no47osDxGiWc1ES6HhtEhTjKnTs8BqTdZ3OfNoIBDCoZm+6ZeOKWxrHvaCSZT" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <cities-internet-map> custom element. -->
|
||||
<script src="../cities-internet.js" integrity="sha384-NtGXg+UiGpEhiYJ/WjGYFGofeo3cmEag6uO+ORX7RpDO2Zo72mNLu0Ld3q/JSu7y" crossorigin="anonymous"></script>
|
||||
<script src="../cities-internet.js" integrity="sha384-pE5no47osDxGiWc1ES6HhtEhTjKnTs8BqTdZ3OfNoIBDCoZm+6ZeOKWxrHvaCSZT" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <cities-internet-map> custom element. -->
|
||||
<script src="../cities-internet.js" integrity="sha384-NtGXg+UiGpEhiYJ/WjGYFGofeo3cmEag6uO+ORX7RpDO2Zo72mNLu0Ld3q/JSu7y" crossorigin="anonymous"></script>
|
||||
<script src="../cities-internet.js" integrity="sha384-pE5no47osDxGiWc1ES6HhtEhTjKnTs8BqTdZ3OfNoIBDCoZm+6ZeOKWxrHvaCSZT" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <country-posters-map> custom element. -->
|
||||
<script src="../country-posters.js" integrity="sha384-/QfXEYySWC8Ue9jMYSMb62S71Ngfy6jH8vbTQPt/vxXKOIXXxtHBsWjSj/GwbkKH" crossorigin="anonymous"></script>
|
||||
<script src="../country-posters.js" integrity="sha384-P37hAb5gyrk9w9aA4l9HFjM7FwjKlsHjHSeZVDDUZ4KddKM+wlbabTrZCgcVklWS" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <country-posters-map> custom element. -->
|
||||
<script src="../country-posters.js" integrity="sha384-/QfXEYySWC8Ue9jMYSMb62S71Ngfy6jH8vbTQPt/vxXKOIXXxtHBsWjSj/GwbkKH" crossorigin="anonymous"></script>
|
||||
<script src="../country-posters.js" integrity="sha384-P37hAb5gyrk9w9aA4l9HFjM7FwjKlsHjHSeZVDDUZ4KddKM+wlbabTrZCgcVklWS" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <country-posters-map> custom element. -->
|
||||
<script src="../country-posters.js" integrity="sha384-/QfXEYySWC8Ue9jMYSMb62S71Ngfy6jH8vbTQPt/vxXKOIXXxtHBsWjSj/GwbkKH" crossorigin="anonymous"></script>
|
||||
<script src="../country-posters.js" integrity="sha384-P37hAb5gyrk9w9aA4l9HFjM7FwjKlsHjHSeZVDDUZ4KddKM+wlbabTrZCgcVklWS" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -47,7 +47,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <dashboard-plus-grid> custom element. -->
|
||||
<script src="../../dashboard-plus-grid.js" integrity="sha384-dmlUkR/A54stQ9oHUOIrUbiGInFwp/pn8O6fJhlPKG7gfreqLjIQFTP8xkB1FOz9" crossorigin="anonymous"></script>
|
||||
<script src="../../dashboard-plus-grid.js" integrity="sha384-HlacN5eFJy1tUz5BXycPsWdFNXVKpOHxk+s4HCXZs9AndFI4jrZb3H//UwKO51Vh" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <dashboard-plus-grid> custom element. -->
|
||||
<script src="../dashboard-plus-grid.js" integrity="sha384-dmlUkR/A54stQ9oHUOIrUbiGInFwp/pn8O6fJhlPKG7gfreqLjIQFTP8xkB1FOz9" crossorigin="anonymous"></script>
|
||||
<script src="../dashboard-plus-grid.js" integrity="sha384-HlacN5eFJy1tUz5BXycPsWdFNXVKpOHxk+s4HCXZs9AndFI4jrZb3H//UwKO51Vh" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <demo-map> custom element. -->
|
||||
<script src="../demo-map.js" integrity="sha384-rIi1LPoFVi9ISi0DH+d8VvSXxZuoLXC9Hq95VoG/tU1xAvokq3D4iPkwHYTRHx+Y" crossorigin="anonymous"></script>
|
||||
<script src="../demo-map.js" integrity="sha384-51bO174eBhsbKnZ9l9nBOJEEOz2WDFm8Zr+rJWdQ03yoIDE5Y1cFcDhcvlKIDCBg" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue