Merge studio/mirror and rebuild: library/ changed, so the bundle had to follow

studio/mirror (c85eeee7) rebuilt every bundle and touched library/ —
PinHint, LegendDesktop, LegendTools, MapControlMenu, ShareButton, PosterDialog
and others — so unlike the previous merge this one genuinely required rebuilding
my bundle rather than just re-hashing it. Checked rather than assumed: the
decision of whether to rebuild turns on `git diff --name-only <base> <main> |
grep '^library/'`, and last time that was empty and this time it is not.

ALL SIX CONFLICTS WERE DERIVED ARTIFACTS — my bundle, my three leaf HTMLs and the
two manifests — and ZERO source files conflicted. Resolved to main's freshly built
versions and then regenerated from the merged source, because picking a side of a
generated file is meaningless: neither side is authoritative, the source is.

VERIFIED BY HASHING, which is the step whose absence shipped a blank page three
commits ago. fix:sri rewrote files this time (it reported nothing to do last time
only because `tree` had already fixed them), so the summary alone would have been
even less informative. Recomputed sha384 over the actual bundle: upg-100-list,
upg-100-mfe and poster-wall all MATCH, and a whole-tree sweep is 57 integrity
attributes with 0 mismatches.
Used fix:sri and NOT tree, per the gate's own advice — tree re-reads .vue source
and on a shared branch can bake in another agent's half-finished work. studio had
already regenerated the pages, so there was nothing to regenerate.

BLAST RADIUS: the only bundle differing from main is mine. Pins-first source
confirmed intact after the merge (boundaryPromises, paintPinsEarly, the deferred
boundary await).
This commit is contained in:
Doxa Maps 2026-09-26 12:38:51 +00:00
commit a6273dc905
150 changed files with 1625 additions and 1174 deletions

View file

@ -3,10 +3,12 @@
"title": "Gospel access by province — India and China",
"why": "These two countries hold 47.9% of Doxa's roster (855 + 154 of 2,107). No other country is close; the third is Sudan at 105. A national fill cannot answer \"where would a team go\" for half the map.",
"generated": "2026-09-26",
"provinces_with_groups": 44,
"provinces_available": 46,
"groups_assigned": 998,
"groups_unassigned": 11,
"provinces_with_groups": 46,
"provinces_available": 48,
"groups_assigned": 1005,
"groups_unassigned": 4,
"india_note": "India is read from the COMPLETE ADM1 (36 states, geoBoundaries via DataMeet India, CC BY 2.5 IN). The first build of this table read gb-IND-ADM1.geojson, which is the UPG-100 list's curated 34-unit partition, and eleven groups came out with no province because they stood in states that partition does not draw. Switching the file moved 998 assigned to 1,005 and 44 province rows to 46, and turned the province names into India's own states.",
"unassigned_note": "Four remain, each one legible rather than lost. Great Andamanese and the Sentinelese sit on small islands whose simplified coastline the point falls just outside — Andaman and Nicobar IS in the file, so this is geometry precision, not a missing province. Jing and Pana are on China's Vietnam and Laos borders, outside the twelve-province subset. They are listed in `unassigned` with their coordinates.",
"china_note": "China's ADM1 file carries 12 provinces, not 31+. Measured rather than assumed: 152 of 154 Chinese groups fall inside them and all twelve are used, so the file is cut to the provinces where the unreached peoples are.",
"ways_in_are_country_level": "The ways in are chosen for the COUNTRY, and going below the border is what exposes the cost of that. Arunachal Pradesh, in the far north-east, is offered TELUGU — a South Indian language — because the curated bridge table names it for those groups and a curated bridge outranks the computed one. India's computed default is Hindi (41%, official), which is defensible nationally and still wrong for much of the north-east. This table does not fix that: it makes it visible. Until a bridge can be chosen per province, read the ways-in column as \"a language with Scripture and film somewhere in this country\", not as a recommendation for this place.",
"approximate": "A province is assigned by point-in-polygon on the group's coordinate, and that coordinate marks where a PEOPLE is found, not a settlement — 108 groups across the roster even share a coordinate with another group. Near a border the point can fall on the wrong side. Every row is flagged approximate and the unassigned are counted, never dropped. No surface may present a province as a fact about a group."
@ -279,8 +281,8 @@
"country": "India",
"iso3": "IND",
"province": "Mahārāshtra",
"groups": 53,
"people": 2858050,
"groups": 54,
"people": 2860150,
"zero_access_groups": 0,
"zero_access_people": 0,
"zero_access_examples": "",
@ -467,6 +469,18 @@
"ways_in": "",
"approximate": true
},
{
"country": "India",
"iso3": "IND",
"province": "Puducherry",
"groups": 5,
"people": 41625,
"zero_access_groups": 0,
"zero_access_people": 0,
"zero_access_examples": "",
"ways_in": "",
"approximate": true
},
{
"country": "India",
"iso3": "IND",
@ -566,9 +580,21 @@
{
"country": "India",
"iso3": "IND",
"province": "Puducherry",
"province": "Lakshadweep",
"groups": 1,
"people": 20500,
"people": 74500,
"zero_access_groups": 0,
"zero_access_people": 0,
"zero_access_examples": "",
"ways_in": "",
"approximate": true
},
{
"country": "India",
"iso3": "IND",
"province": "Dādra and Nagar Haveli and Damān and Diu",
"groups": 1,
"people": 125,
"zero_access_groups": 0,
"zero_access_people": 0,
"zero_access_examples": "",
@ -589,13 +615,6 @@
}
],
"unassigned": [
{
"country": "India",
"iso3": "IND",
"people_group": "Bharwad",
"lat": 20.322175,
"lng": 72.968458
},
{
"country": "India",
"iso3": "IND",
@ -603,34 +622,6 @@
"lat": 12.211728,
"lng": 92.833491
},
{
"country": "India",
"iso3": "IND",
"people_group": "Indo-French",
"lat": 11.930182,
"lng": 79.797144
},
{
"country": "India",
"iso3": "IND",
"people_group": "Indo-Vietnamese",
"lat": 11.912456,
"lng": 79.815389
},
{
"country": "India",
"iso3": "IND",
"people_group": "Laccadive",
"lat": 10.0675,
"lng": 73.6422
},
{
"country": "India",
"iso3": "IND",
"people_group": "Sagharias",
"lat": 20.71815,
"lng": 70.988939
},
{
"country": "India",
"iso3": "IND",
@ -638,20 +629,6 @@
"lat": 11.557662,
"lng": 92.267154
},
{
"country": "India",
"iso3": "IND",
"people_group": "Vettalaikarans",
"lat": 11.916308,
"lng": 79.807019
},
{
"country": "India",
"iso3": "IND",
"people_group": "Vettiyan",
"lat": 11.91386,
"lng": 79.814472
},
{
"country": "China",
"iso3": "CHN",

View file

@ -234,6 +234,46 @@ snippets (leave it empty for relative URLs that work wherever the folder is drop
---
## Hosting the dx-maps folder: caching, compression, CORS
**Why it matters.** On a cold load a map's layers draw about 10–17 s after the page opens (measured
on the UPG-100 list page). The bundle and the map engine take under 2 s of that; the rest is basemap
tiles and data files such as the country-boundary file (1,866 KB raw, 597 KB gzipped). Compression
cuts every visit; caching means a returning reader doesn't download the same files again.
**Every file in `dx-maps/` has a fixed name today.** Nothing is content-hashed, so a new release
overwrites files in place: bundles (`<bundle>/<bundle>.js`), their pages (`index.html`) and
`manifest.json` change whenever the build changes them. Images, fonts and data files change rarely,
but under the same names.
| Files | `Cache-Control` | Why |
|---|---|---|
| everything in `dx-maps/`, as it ships today | `public, max-age=86400, stale-while-revalidate=604800`, with an `ETag` | a revisit revalidates cheaply and never re-downloads unchanged bytes, and a release shows up within a day |
| content-hashed names, if you add them (e.g. `world-adm0.3f9a1c2e.geojson`) | `public, max-age=31536000, immutable` | a changed file gets a new name, so a year-long cache is always safe |
**Never send `immutable` for a fixed name**: an update would stay invisible for a year.
**Compress:** serve `br` or `gzip`, and give data files a JSON content type (`application/json`, or
`application/geo+json` for `.geojson`) so CDNs compress them. Many servers send `.geojson` as
`application/octet-stream`, which CDNs don't compress.
**CORS for script embeds:** every `embed.script` snippet loads the bundle with an `integrity` hash
and `crossorigin="anonymous"`. For that to work on another site, the host must send
`Access-Control-Allow-Origin: *`; without it the browser can't check the hash across origins and refuses
the script. iframe embeds need no CORS.
On Cloudflare Pages or Netlify, a `_headers` file at the folder's root sets the CORS and cache headers:
```
/*
Access-Control-Allow-Origin: *
Cache-Control: public, max-age=86400, stale-while-revalidate=604800
```
Check the content type your host gives `.geojson` files (`curl -sI <url>`) before relying on compression.
---
## Compose maps into a SPA (the app shell)
Several maps + one layout = a single-page app, served from the same `public/` tree.

View file

@ -81,6 +81,24 @@ for (const rel of wanted) {
console.log(` ok ${rel.padEnd(42)} ${String(bytes).padStart(9)} bytes`)
}
/* ★ THE MAP AND THE TABLE MUST READ THE SAME GEOMETRY (2026-09-26). The province layer is
coloured in the leaf and counted by scripts/build-gospel-by-province.mjs, from two independent
mentions of the same filenames — so a change to one silently draws provinces that disagree with
the rows describing them. It already happened in the other direction: both read
gb-IND-ADM1.geojson, the UPG-100 list's curated 34-unit partition, when what this table means
by "province" is a state. Staging alone cannot catch it; both files are staged. */
const boundaryMentions = (src, re) => [...src.matchAll(re)].map((m) => m[m.length - 1]).sort()
try {
const leafSrc = readFileSync(LEAF, 'utf8')
const buildSrc = readFileSync(resolve(ROOT, 'plugin-designs/gospel-resources/scripts/build-gospel-by-province.mjs'), 'utf8')
const inLeaf = boundaryMentions(leafSrc, /(?:IND|CHN): '(gb-[A-Z]{3}-ADM1[^']*\.geojson)'/g)
const inBuild = boundaryMentions(buildSrc, /file: '(gb-[A-Z]{3}-ADM1[^']*\.geojson)'/g)
if (!inLeaf.length || !inBuild.length) missing.push('province boundary filenames not found in the leaf or the builder — this arm would pass vacuously')
else if (JSON.stringify(inLeaf) !== JSON.stringify(inBuild)) {
missing.push(`the map draws provinces from [${inLeaf.join(', ')}] while the table counts them from [${inBuild.join(', ')}]`)
} else console.log(` ok province geometry agrees ${inLeaf.join(', ')}`)
} catch (e) { missing.push(`could not compare province geometry: ${e.message}`) }
if (missing.length) {
console.error(`GOSPEL-TABLES-STAGED: FAIL — ${missing.length} of ${wanted.length} asset(s) the map fetches are not in the tree:`)
for (const m of missing) console.error(` ${m}`)

View file

@ -58,6 +58,13 @@ const ALLOWED = new Map([
'proper noun — Open Bible Stories is the publication\'s name (unfoldingWord), not a description'],
['gospel.json:res.scriptureEarth',
'proper noun — Scripture Earth is the organisation\'s name; the destination site is English'],
// ── UNITS ───────────────────────────────────────────────────────────────────
['tables.json:csv',
'file-format name — "CSV" names the format the button downloads; the file extension is the same in every locale'],
['tables.json:json',
'file-format name — "JSON" names the format the button downloads; the file extension is the same in every locale'],
['poster.json:dpi',
'unit symbol — "DPI" is how print resolution is labelled in every one of these locales\' print dialogs; kept as-is by project decision (the poster-strings landing)'],
// ── DRIVER-GATED, NOT RESOLVED ──────────────────────────────────────────────
// ⚠ THESE ARE ALLOWED SO THE GATE CAN GO GREEN ON THE THINGS IT HAS ACTUALLY SETTLED, NOT
// because the defect is fixed. "UUPGs" really is an English acronym wedged into an otherwise

View file

@ -0,0 +1,67 @@
#!/usr/bin/env node
/**
* check-studio-rail — the Plugin Studio's rail is laid out like plugin-designs/. Headless.
*
* The Driver's ask (2026-09-26): one FOLDER per BUNDLE with its ELEMENTS inside, bundles sorted by
* capability, highest first. Runs the studio's REAL treeSections / compositionTrees / rowsFromManifest
* (loaded through Vite, no browser) against the BUILT manifest and asserts:
* - sections run CAP 5 → 1, and each bundle sits in the section of its HIGHEST capability, once;
* - bundles within a section are in name order;
* - a bundle with several elements is ONE folder holding all of them; a single-element bundle is its element;
* - every studio row has exactly ONE canonical node (one identity per plugin; parts only target it).
* EXIT 0 pass · 1 fail · 0 with SKIP when there is no built manifest
*/
import { readFileSync, existsSync } from 'node:fs'
import { join, resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { TREE_NAME } from './_tree-name.mjs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const MANIFEST = join(ROOT, 'public', TREE_NAME, 'manifest.json')
if (!existsSync(MANIFEST)) { console.log('STUDIO-RAIL: SKIP — no built manifest'); process.exit(0) }
const { createServer } = await import('vite')
const { default: vue } = await import('@vitejs/plugin-vue')
const server = await createServer({ root: ROOT, configFile: false, logLevel: 'error', appType: 'custom',
server: { middlewareMode: true, hmr: false, watch: null }, plugins: [vue({ customElement: true })],
resolve: { alias: { '@map': join(ROOT, 'library') } }, define: { __DOXA_TREE_NAME__: JSON.stringify(TREE_NAME) } })
globalThis.window = globalThis
globalThis.localStorage = { getItem: () => null, setItem() {} }
let bad = 0
const fail = (m) => { bad++; console.log(' FAIL ' + m) }
try {
const comp = (await server.ssrLoadModule('/plugin-designs/plugin-studio/plugin-studio.vue')).default
const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8'))
const ctx = { manifestBundles: manifest.bundles, tr: (k) => k, cfg: {}, base: '' }
ctx.rows = comp.methods.rowsFromManifest.call(ctx, manifest)
ctx.resolveRef = (ref, fb) => comp.methods.resolveRef.call(ctx, ref, fb)
ctx.compositionTrees = comp.computed.compositionTrees.call(ctx)
const secs = comp.computed.treeSections.call(ctx)
const caps = secs.map((s) => s.cap)
if (caps.join() !== [...caps].sort((a, b) => b - a).join()) fail(`sections not CAP 5 → 1: ${caps.join(',')}`)
const byBundle = new Map()
for (const r of ctx.rows) { if (!byBundle.has(r.bundle)) byBundle.set(r.bundle, []); byBundle.get(r.bundle).push(r) }
const seen = new Map()
for (const s of secs) {
const tops = s.nodes.filter((n) => n.depth === 0)
const names = tops.map((n) => (n.folder ? n.label : ctx.rows.find((r) => r.key === n.target)?.bundle))
if (names.join() !== [...names].sort((a, b) => a.localeCompare(b)).join()) fail(`CAP ${s.cap}: bundles not in name order (${names.join(', ')})`)
for (const [i, n] of tops.entries()) {
const b = names[i]; const rows = byBundle.get(b) || []
if (seen.has(b)) fail(`bundle ${b} appears twice (CAP ${seen.get(b)} and CAP ${s.cap})`)
seen.set(b, s.cap)
const top = Math.max(...rows.map((r) => r.cap))
if (top !== s.cap) fail(`bundle ${b} (highest CAP ${top}) filed under CAP ${s.cap}`)
if (rows.length > 1) {
if (!n.folder) fail(`bundle ${b} has ${rows.length} elements but is not a folder`)
const kids = s.nodes.filter((k) => k.parent === n.id && k.canonical).map((k) => k.target).sort()
if (kids.join() !== rows.map((r) => r.key).sort().join()) fail(`folder ${b} holds ${kids.length} of its ${rows.length} elements`)
} else if (n.folder) fail(`single-element bundle ${b} is shown as a folder`)
}
}
for (const b of byBundle.keys()) if (!seen.has(b)) fail(`bundle ${b} is missing from the rail`)
const canon = secs.flatMap((s) => s.nodes.filter((n) => n.canonical))
for (const r of ctx.rows) { const c = canon.filter((n) => n.target === r.key).length; if (c !== 1) fail(`${r.key} has ${c} canonical nodes (want 1)`) }
console.log(`STUDIO-RAIL: ${byBundle.size} bundle(s), ${ctx.rows.length} element(s), ${secs.length} capability section(s); folders: ${secs.flatMap((s) => s.nodes.filter((n) => n.folder).map((n) => n.label + '(' + n.kids + ')')).join(', ')}`)
} finally { await server.close() }
if (bad) { console.log(`STUDIO-RAIL: FAIL — ${bad} problem(s).`); process.exit(1) }
console.log('STUDIO-RAIL: PASS — one folder per bundle with its elements inside, bundles grouped by their highest capability (5 → 1) and in name order, one identity per plugin.')

View file

@ -38,6 +38,12 @@
* dropped from the custom element's shadow DOM (see WARNING.md).
*/
import { ref, inject, computed, onMounted, onBeforeUnmount, getCurrentInstance, nextTick } from 'vue'
import { tStatic } from '@map/i18n/index.js'
// This component's chrome, read out of the catalogue. tStatic and not useI18n: these mount inside
// custom elements that install no i18n plugin, and an unguarded useI18n() throws on the way in and
// takes the subtree with it. The English stays as the fallback.
const T = (key, en) => tStatic(key) || en
import { useI18n } from 'vue-i18n'
import { useShadowStyles } from '../../composables/useShadowStyles.js'
@ -374,7 +380,7 @@ useShadowStyles(`
<span class="pin-hint-pill">
<span class="pin-hint-dot" aria-hidden="true"></span>
{{ label }}
<button class="pin-hint-x" type="button" aria-label="Dismiss hint" @click="dismiss">×</button>
<button class="pin-hint-x" type="button" :aria-label="T('aria.dismissHint', 'Dismiss hint')" @click="dismiss">×</button>
</span>
</div>
</template>

View file

@ -1,5 +1,11 @@
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, watch, inject, toRef } from 'vue'
import { tStatic } from '@map/i18n/index.js'
// This component's chrome, read out of the catalogue. tStatic and not useI18n: these mount inside
// custom elements that install no i18n plugin, and an unguarded useI18n() throws on the way in and
// takes the subtree with it. The English stays as the fallback.
const T = (key, en) => tStatic(key) || en
import { useI18n } from 'vue-i18n'
import { useShadowStyles } from '../../composables/useShadowStyles.js'
import { useLegendData } from '../../composables/legends/useLegendData.js'
@ -357,7 +363,7 @@ onBeforeUnmount(() => {
<!-- Collapsed pill — identical to STL's .stl-reopen -->
<button v-else-if="isCollapsed"
:class="['legend-reopen', { 'legend-reopen-light': !isDark }]"
title="Expand legend"
:title="T('aria.expandLegend', 'Expand legend')"
@click="isCollapsed = false">
<svg class="legend-reopen-icon" width="11" height="11" viewBox="0 0 12 12" fill="none">
<path d="M2 4h8M2 6h8M2 8h5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>

View file

@ -38,6 +38,12 @@ export function showsFlyTools(legendType) {
* parent profile listens through mapStore).
*/
import { ref, computed, inject, onBeforeUnmount, toRef } from 'vue'
import { tStatic } from '@map/i18n/index.js'
// This component's chrome, read out of the catalogue. tStatic and not useI18n: these mount inside
// custom elements that install no i18n plugin, and an unguarded useI18n() throws on the way in and
// takes the subtree with it. The English stays as the fallback.
const T = (key, en) => tStatic(key) || en
import { useI18n } from 'vue-i18n'
import { useShadowStyles } from '../../composables/useShadowStyles.js'
import { useLegendData } from '../../composables/legends/useLegendData.js'
@ -249,7 +255,7 @@ onBeforeUnmount(() => {
<path d="M2 6l3 3 5-6" stroke="#3498db" stroke-width="2" fill="none"/>
</svg>
</span>
<span class="lt-btn-text">Clusters</span>
<span class="lt-btn-text">{{ T('controls.clusters', 'Clusters') }}</span>
</button>
<button
class="lt-side-btn"
@ -261,7 +267,7 @@ onBeforeUnmount(() => {
<div v-if="showClusterSettings" class="lt-popup" @click.stop>
<div class="lt-popup-row active" @click="setClusterMode('mst')">
<span class="lt-popup-icon">⌬</span>
<span>Minimum Spanning Tree<br><small style="opacity:0.7;font-weight:400">click a legend row to cluster only that group</small></span>
<span>{{ T('controls.mst_full_name', 'Minimum Spanning Tree') }}<br><small style="opacity:0.7;font-weight:400">{{ T('controls.mst_hint', 'click a legend row to cluster only that group') }}</small></span>
</div>
</div>
<div v-if="showClusterSettings" ref="scrimRef" :style="scrimStyle" class="lt-overlay" @click.stop="showClusterSettings = false"></div>

View file

@ -27,6 +27,12 @@
-->
<script setup>
import { ref, inject, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { tStatic } from '@map/i18n/index.js'
// This component's chrome, read out of the catalogue. tStatic and not useI18n: these mount inside
// custom elements that install no i18n plugin, and an unguarded useI18n() throws on the way in and
// takes the subtree with it. The English stays as the fallback.
const T = (key, en) => tStatic(key) || en
import { useShadowStyles } from '../../composables/useShadowStyles.js'
useShadowStyles(`
@ -140,7 +146,7 @@ onBeforeUnmount(() => {
<div v-if="open" ref="el" class="mcm" :class="{ light: !isDark }" :style="style" role="dialog" :aria-label="title">
<div class="mcm-header">
<span class="mcm-title">{{ title }}</span>
<button class="mcm-close" type="button" aria-label="Close" @click="emit('close')">
<button class="mcm-close" type="button" :aria-label="T('buttons.close', 'Close')" @click="emit('close')">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<line x1="2" y1="2" x2="12" y2="12"/><line x1="12" y1="2" x2="2" y2="12"/>
</svg>

View file

@ -22,6 +22,12 @@
-->
<script setup>
import { ref, computed, inject, unref, provide } from 'vue'
import { tStatic } from '@map/i18n/index.js'
// This component's chrome, read out of the catalogue. tStatic and not useI18n: these mount inside
// custom elements that install no i18n plugin, and an unguarded useI18n() throws on the way in and
// takes the subtree with it. The English stays as the fallback.
const T = (key, en) => tStatic(key) || en
import { useI18n } from 'vue-i18n'
import { useShadowStyles } from '../../composables/useShadowStyles.js'
import { resolveShareUrl } from '../../utils/embedBase.js'
@ -538,12 +544,12 @@ function bindAnchor(el) {
class="share-tab"
:class="{ active: activeTab === 'link' }"
@click="activeTab = 'link'"
>Embed link</button>
>{{ T('share.embed_link', 'Embed link') }}</button>
<button
class="share-tab"
:class="{ active: activeTab === 'embed' }"
@click="activeTab = 'embed'"
>Add to Your Website</button>
>{{ T('share.add_to_website', 'Add to Your Website') }}</button>
</div>
<!-- ===== Tab 1: Share Link ===== -->
@ -579,7 +585,7 @@ function bindAnchor(el) {
<div v-if="activeTab === 'embed'" class="share-body">
<!-- Friendly description -->
<div class="share-embed-desc">
Paste this code into your website builder (WordPress, Squarespace, Wix, or any site that accepts HTML widgets).
{{ T('share.embed_paste_hint', 'Paste this code into your website builder (WordPress, Squarespace, Wix, or any site that accepts HTML widgets).') }}
</div>
<!-- Size presets -->

View file

@ -88,7 +88,7 @@
</div>
<div class="actions">
<button class="btn btn--ghost" type="button" @click="onClose">Close</button>
<button class="btn btn--ghost" type="button" @click="onClose">{{ T('buttons.close', 'Close') }}</button>
<button class="btn btn--primary" type="button" :disabled="poster.isRendering.value" @click="onRender">
{{ poster.isRendering.value ? 'Rendering…' : `Render ${format.toUpperCase()}` }}
</button>

View file

@ -33,6 +33,12 @@
* ADD to declared widths and columns drift.
*/
import { ref, shallowRef, onMounted, onBeforeUnmount, watch, computed } from 'vue'
import { tStatic } from '@map/i18n/index.js'
// This component's chrome, read out of the catalogue. tStatic and not useI18n: these mount inside
// custom elements that install no i18n plugin, and an unguarded useI18n() throws on the way in and
// takes the subtree with it. The English stays as the fallback.
const T = (key, en) => tStatic(key) || en
import { useShadowStyles } from '../../composables/useShadowStyles.js'
// A fixed;inset:0 scrim covers only the CONTENT PANE inside the cap-5 shell (contain:layout on
// .vtl-content captures the fixed containing block). Measured here: 1024x793 @ (256,107) at a
@ -297,22 +303,22 @@ useShadowStyles(
<span class="dt-count">{{ rowCount.toLocaleString() }} rows</span>
<span v-if="meta.grain" class="dt-grain">· {{ meta.grain }}</span>
<span class="dt-actions">
<button type="button" class="dt-btn" @click="download('csv')">CSV</button>
<button type="button" class="dt-btn" @click="download('json')">JSON</button>
<button type="button" class="dt-btn" @click="download('csv')">{{ T('tables.csv', 'CSV') }}</button>
<button type="button" class="dt-btn" @click="download('json')">{{ T('tables.json', 'JSON') }}</button>
</span>
</div>
<div v-if="err" class="dt-err">{{ err }}</div>
<div class="dt-wrap"><div ref="host"></div></div>
<p class="dt-hint">Tap any row for its full detail{{ isMobile ? '' : ' — every field, including the lists the table flattens' }}.</p>
<p class="dt-hint">{{ T('tables.tap_row_hint', 'Tap any row for its full detail') }}{{ isMobile ? '' : ' — every field, including the lists the table flattens' }}.</p>
<!-- ROW DETAIL — a bottom sheet on mobile, a panel on desktop. Carries EVERY
field, including the ones the list view has to hide or flatten. -->
<div v-if="detail" ref="scrimRef" :style="scrimStyle" class="dt-scrim" @click.self="detail = null">
<div class="dt-sheet" role="dialog" aria-label="Row detail">
<div class="dt-sheet" role="dialog" :aria-label="T('aria.rowDetail', 'Row detail')">
<div class="dt-sheet-head">
<strong>{{ detail[(table?.columns || [])[0]?.field] || 'Detail' }}</strong>
<button type="button" class="dt-close" @click="detail = null">Close</button>
<button type="button" class="dt-close" @click="detail = null">{{ T('buttons.close', 'Close') }}</button>
</div>
<dl class="dt-dl">
<template v-for="[k, v] in detailRows" :key="k">

View file

@ -7,5 +7,7 @@
"searchPeopleGroups": "ابحث عن الشعوب",
"resetAllFilters": "إعادة تعيين كل عوامل التصفية والبحث",
"selectAllRows": "تحديد كل الصفوف",
"deselectAllRows": "إلغاء تحديد كل الصفوف"
"deselectAllRows": "إلغاء تحديد كل الصفوف",
"rowDetail": "تفاصيل الصف",
"dismissHint": "إخفاء التلميح"
}

View file

@ -34,5 +34,8 @@
"prayer_status": "حالة الصلاة",
"close": "إغلاق",
"clear": "مسح",
"map_controls": "أدوات التحكم بالخريطة"
"map_controls": "أدوات التحكم بالخريطة",
"clusters": "التجمّعات",
"mst_full_name": "الشجرة الممتدة الصغرى",
"mst_hint": "اضغط على صف في مفتاح الخريطة لتجميع تلك المجموعة وحدها"
}

View file

@ -0,0 +1,5 @@
{
"embed_link": "رابط التضمين",
"add_to_website": "أضِفها إلى موقعك",
"embed_paste_hint": "الصق هذا الرمز في أداة إنشاء موقعك (WordPress أو Squarespace أو Wix أو أي موقع يقبل أدوات HTML)."
}

View file

@ -48,5 +48,7 @@
"engineMaplibreNote": "لا يحتاج إلى رمز",
"tokenNotNeeded": "تم اختيار MapLibre: خرائطه لا تحتاج إلى رمز Mapbox",
"hideHeader": "إخفاء الشريط العلوي",
"showHeader": "إظهار الشريط العلوي"
"showHeader": "إظهار الشريط العلوي",
"elementsCount": "{n} عناصر",
"elements": "عناصر"
}

View file

@ -0,0 +1,5 @@
{
"csv": "CSV",
"json": "JSON",
"tap_row_hint": "اضغط على أي صف لعرض تفاصيله الكاملة"
}

View file

@ -7,5 +7,7 @@
"searchPeopleGroups": "Volksgruppen suchen",
"resetAllFilters": "Alle Filter und die Suche zurücksetzen",
"selectAllRows": "Alle Zeilen auswählen",
"deselectAllRows": "Auswahl aller Zeilen aufheben"
"deselectAllRows": "Auswahl aller Zeilen aufheben",
"rowDetail": "Zeilendetails",
"dismissHint": "Hinweis schließen"
}

View file

@ -34,5 +34,8 @@
"prayer_status": "Gebetsstatus",
"close": "Schließen",
"clear": "Zurücksetzen",
"map_controls": "Kartensteuerung"
"map_controls": "Kartensteuerung",
"clusters": "Cluster",
"mst_full_name": "Minimaler Spannbaum",
"mst_hint": "Klicken Sie auf eine Legendenzeile, um nur diese Gruppe zu clustern"
}

View file

@ -0,0 +1,5 @@
{
"embed_link": "Einbettungslink",
"add_to_website": "Zu Ihrer Website hinzufügen",
"embed_paste_hint": "Fügen Sie diesen Code in Ihren Website-Baukasten ein (WordPress, Squarespace, Wix oder jede Website, die HTML-Widgets akzeptiert)."
}

View file

@ -48,5 +48,7 @@
"engineMaplibreNote": "kein Token nötig",
"tokenNotNeeded": "MapLibre ist gewählt: Seine Karten brauchen keinen Mapbox-Token",
"hideHeader": "Kopfzeile ausblenden",
"showHeader": "Kopfzeile einblenden"
"showHeader": "Kopfzeile einblenden",
"elementsCount": "{n} Elemente",
"elements": "Elemente"
}

View file

@ -0,0 +1,5 @@
{
"csv": "CSV",
"json": "JSON",
"tap_row_hint": "Tippen Sie auf eine Zeile für alle Details"
}

View file

@ -7,5 +7,7 @@
"searchPeopleGroups": "Search people groups",
"resetAllFilters": "Reset all filters and search",
"selectAllRows": "Select all rows",
"deselectAllRows": "Deselect all rows"
"deselectAllRows": "Deselect all rows",
"rowDetail": "Row detail",
"dismissHint": "Dismiss hint"
}

View file

@ -34,5 +34,8 @@
"prayer_status": "Prayer Status",
"close": "Close",
"clear": "Clear",
"map_controls": "Research map controls"
"map_controls": "Research map controls",
"clusters": "Clusters",
"mst_full_name": "Minimum Spanning Tree",
"mst_hint": "click a legend row to cluster only that group"
}

View file

@ -0,0 +1,5 @@
{
"embed_link": "Embed link",
"add_to_website": "Add to Your Website",
"embed_paste_hint": "Paste this code into your website builder (WordPress, Squarespace, Wix, or any site that accepts HTML widgets)."
}

View file

@ -48,5 +48,7 @@
"engineMaplibreNote": "no token needed",
"tokenNotNeeded": "MapLibre is selected: its maps need no Mapbox token",
"hideHeader": "Hide header",
"showHeader": "Show header"
"showHeader": "Show header",
"elementsCount": "{n} elements",
"elements": "elements"
}

View file

@ -0,0 +1,5 @@
{
"csv": "CSV",
"json": "JSON",
"tap_row_hint": "Tap any row for its full detail"
}

View file

@ -7,5 +7,7 @@
"searchPeopleGroups": "Buscar pueblos",
"resetAllFilters": "Restablecer todos los filtros y la búsqueda",
"selectAllRows": "Seleccionar todas las filas",
"deselectAllRows": "Deseleccionar todas las filas"
"deselectAllRows": "Deseleccionar todas las filas",
"rowDetail": "Detalle de la fila",
"dismissHint": "Descartar la sugerencia"
}

View file

@ -34,5 +34,8 @@
"prayer_status": "Estado de oración",
"close": "Cerrar",
"clear": "Borrar",
"map_controls": "Controles del mapa"
"map_controls": "Controles del mapa",
"clusters": "Clústeres",
"mst_full_name": "Árbol de expansión mínima",
"mst_hint": "haz clic en una fila de la leyenda para agrupar solo ese grupo"
}

View file

@ -0,0 +1,5 @@
{
"embed_link": "Enlace para insertar",
"add_to_website": "Añádelo a tu sitio web",
"embed_paste_hint": "Pega este código en tu creador de sitios web (WordPress, Squarespace, Wix o cualquier sitio que acepte widgets HTML)."
}

View file

@ -48,5 +48,7 @@
"engineMaplibreNote": "no necesita token",
"tokenNotNeeded": "MapLibre está seleccionado: sus mapas no necesitan token de Mapbox",
"hideHeader": "Ocultar encabezado",
"showHeader": "Mostrar encabezado"
"showHeader": "Mostrar encabezado",
"elementsCount": "{n} elementos",
"elements": "elementos"
}

View file

@ -0,0 +1,5 @@
{
"csv": "CSV",
"json": "JSON",
"tap_row_hint": "Toca cualquier fila para ver su detalle completo"
}

View file

@ -7,5 +7,7 @@
"searchPeopleGroups": "Rechercher des peuples",
"resetAllFilters": "Réinitialiser tous les filtres et la recherche",
"selectAllRows": "Sélectionner toutes les lignes",
"deselectAllRows": "Désélectionner toutes les lignes"
"deselectAllRows": "Désélectionner toutes les lignes",
"rowDetail": "Détail de la ligne",
"dismissHint": "Masquer l'astuce"
}

View file

@ -34,5 +34,8 @@
"prayer_status": "État de prière",
"close": "Fermer",
"clear": "Effacer",
"map_controls": "Commandes de la carte"
"map_controls": "Commandes de la carte",
"clusters": "Groupes",
"mst_full_name": "Arbre couvrant minimal",
"mst_hint": "cliquez sur une ligne de la légende pour ne regrouper que ce groupe"
}

View file

@ -0,0 +1,5 @@
{
"embed_link": "Lien d'intégration",
"add_to_website": "Ajouter à votre site web",
"embed_paste_hint": "Collez ce code dans votre créateur de site web (WordPress, Squarespace, Wix ou tout site acceptant les widgets HTML)."
}

View file

@ -48,5 +48,7 @@
"engineMaplibreNote": "aucun jeton requis",
"tokenNotNeeded": "MapLibre est sélectionné : ses cartes n’ont pas besoin de jeton Mapbox",
"hideHeader": "Masquer l'en-tête",
"showHeader": "Afficher l'en-tête"
"showHeader": "Afficher l'en-tête",
"elementsCount": "{n} éléments",
"elements": "éléments"
}

View file

@ -0,0 +1,5 @@
{
"csv": "CSV",
"json": "JSON",
"tap_row_hint": "Touchez une ligne pour voir tout son détail"
}

View file

@ -7,5 +7,7 @@
"searchPeopleGroups": "जनसमूह खोजें",
"resetAllFilters": "सभी फ़िल्टर और खोज रीसेट करें",
"selectAllRows": "सभी पंक्तियाँ चुनें",
"deselectAllRows": "सभी पंक्तियाँ अचयनित करें"
"deselectAllRows": "सभी पंक्तियाँ अचयनित करें",
"rowDetail": "पंक्ति विवरण",
"dismissHint": "संकेत हटाएँ"
}

View file

@ -34,5 +34,8 @@
"prayer_status": "प्रार्थना स्थिति",
"close": "बंद करें",
"clear": "साफ़ करें",
"map_controls": "मानचित्र नियंत्रण"
"map_controls": "मानचित्र नियंत्रण",
"clusters": "क्लस्टर",
"mst_full_name": "न्यूनतम विस्तारित वृक्ष",
"mst_hint": "केवल उस समूह को क्लस्टर करने के लिए लेजेंड की पंक्ति पर क्लिक करें"
}

View file

@ -0,0 +1,5 @@
{
"embed_link": "एम्बेड लिंक",
"add_to_website": "अपनी वेबसाइट में जोड़ें",
"embed_paste_hint": "इस कोड को अपने वेबसाइट बिल्डर में पेस्ट करें (WordPress, Squarespace, Wix, या कोई भी साइट जो HTML विजेट स्वीकार करती है)।"
}

View file

@ -48,5 +48,7 @@
"engineMaplibreNote": "टोकन की ज़रूरत नहीं",
"tokenNotNeeded": "MapLibre चुना गया है: इसके मानचित्रों को Mapbox टोकन नहीं चाहिए",
"hideHeader": "हेडर छिपाएँ",
"showHeader": "हेडर दिखाएँ"
"showHeader": "हेडर दिखाएँ",
"elementsCount": "{n} तत्व",
"elements": "तत्व"
}

View file

@ -0,0 +1,5 @@
{
"csv": "CSV",
"json": "JSON",
"tap_row_hint": "पूरा विवरण देखने के लिए किसी भी पंक्ति पर टैप करें"
}

View file

@ -7,5 +7,7 @@
"searchPeopleGroups": "Cerca popoli",
"resetAllFilters": "Reimposta tutti i filtri e la ricerca",
"selectAllRows": "Seleziona tutte le righe",
"deselectAllRows": "Deseleziona tutte le righe"
"deselectAllRows": "Deseleziona tutte le righe",
"rowDetail": "Dettaglio riga",
"dismissHint": "Ignora il suggerimento"
}

View file

@ -34,5 +34,8 @@
"prayer_status": "Stato di preghiera",
"close": "Chiudi",
"clear": "Cancella",
"map_controls": "Controlli della mappa"
"map_controls": "Controlli della mappa",
"clusters": "Cluster",
"mst_full_name": "Albero ricoprente minimo",
"mst_hint": "fai clic su una riga della legenda per raggruppare solo quel gruppo"
}

View file

@ -0,0 +1,5 @@
{
"embed_link": "Link di incorporamento",
"add_to_website": "Aggiungi al tuo sito web",
"embed_paste_hint": "Incolla questo codice nel tuo costruttore di siti web (WordPress, Squarespace, Wix o qualsiasi sito che accetti widget HTML)."
}

View file

@ -48,5 +48,7 @@
"engineMaplibreNote": "nessun token necessario",
"tokenNotNeeded": "MapLibre è selezionato: le sue mappe non richiedono un token Mapbox",
"hideHeader": "Nascondi intestazione",
"showHeader": "Mostra intestazione"
"showHeader": "Mostra intestazione",
"elementsCount": "{n} elementi",
"elements": "elementi"
}

View file

@ -0,0 +1,5 @@
{
"csv": "CSV",
"json": "JSON",
"tap_row_hint": "Tocca una riga per vederne il dettaglio completo"
}

View file

@ -7,5 +7,7 @@
"searchPeopleGroups": "Pesquisar povos",
"resetAllFilters": "Redefinir todos os filtros e a pesquisa",
"selectAllRows": "Selecionar todas as linhas",
"deselectAllRows": "Desmarcar todas as linhas"
"deselectAllRows": "Desmarcar todas as linhas",
"rowDetail": "Detalhe da linha",
"dismissHint": "Dispensar a dica"
}

View file

@ -34,5 +34,8 @@
"prayer_status": "Estado de oração",
"close": "Fechar",
"clear": "Limpar",
"map_controls": "Controlos do mapa"
"map_controls": "Controlos do mapa",
"clusters": "Agrupamentos",
"mst_full_name": "Árvore geradora mínima",
"mst_hint": "clique numa linha da legenda para agrupar apenas esse grupo"
}

View file

@ -0,0 +1,5 @@
{
"embed_link": "Link de incorporação",
"add_to_website": "Adicione ao seu site",
"embed_paste_hint": "Cole este código no seu construtor de sites (WordPress, Squarespace, Wix ou qualquer site que aceite widgets HTML)."
}

View file

@ -48,5 +48,7 @@
"engineMaplibreNote": "não precisa de token",
"tokenNotNeeded": "MapLibre está selecionado: os seus mapas não precisam de token do Mapbox",
"hideHeader": "Ocultar cabeçalho",
"showHeader": "Mostrar cabeçalho"
"showHeader": "Mostrar cabeçalho",
"elementsCount": "{n} elementos",
"elements": "elementos"
}

View file

@ -0,0 +1,5 @@
{
"csv": "CSV",
"json": "JSON",
"tap_row_hint": "Toque em qualquer linha para ver o detalhe completo"
}

View file

@ -7,5 +7,7 @@
"searchPeopleGroups": "Caută popoare",
"resetAllFilters": "Resetează toate filtrele și căutarea",
"selectAllRows": "Selectează toate rândurile",
"deselectAllRows": "Deselectează toate rândurile"
"deselectAllRows": "Deselectează toate rândurile",
"rowDetail": "Detaliu rând",
"dismissHint": "Închide sugestia"
}

View file

@ -34,5 +34,8 @@
"prayer_status": "Stare de rugăciune",
"close": "Închide",
"clear": "Șterge",
"map_controls": "Comenzile hărții"
"map_controls": "Comenzile hărții",
"clusters": "Grupuri",
"mst_full_name": "Arbore de acoperire minim",
"mst_hint": "dă clic pe un rând din legendă pentru a grupa doar acel grup"
}

View file

@ -0,0 +1,5 @@
{
"embed_link": "Link de încorporare",
"add_to_website": "Adaugă pe site-ul tău",
"embed_paste_hint": "Lipește acest cod în constructorul site-ului tău (WordPress, Squarespace, Wix sau orice site care acceptă widget-uri HTML)."
}

View file

@ -48,5 +48,7 @@
"engineMaplibreNote": "nu necesită token",
"tokenNotNeeded": "MapLibre este selectat: hărțile sale nu necesită token Mapbox",
"hideHeader": "Ascunde antetul",
"showHeader": "Afișează antetul"
"showHeader": "Afișează antetul",
"elementsCount": "{n} elemente",
"elements": "elemente"
}

View file

@ -0,0 +1,5 @@
{
"csv": "CSV",
"json": "JSON",
"tap_row_hint": "Atinge orice rând pentru detaliile complete"
}

View file

@ -7,5 +7,7 @@
"searchPeopleGroups": "Поиск народов",
"resetAllFilters": "Сбросить все фильтры и поиск",
"selectAllRows": "Выбрать все строки",
"deselectAllRows": "Снять выделение со всех строк"
"deselectAllRows": "Снять выделение со всех строк",
"rowDetail": "Подробности строки",
"dismissHint": "Скрыть подсказку"
}

View file

@ -34,5 +34,8 @@
"prayer_status": "Статус молитвы",
"close": "Закрыть",
"clear": "Очистить",
"map_controls": "Элементы управления картой"
"map_controls": "Элементы управления картой",
"clusters": "Кластеры",
"mst_full_name": "Минимальное остовное дерево",
"mst_hint": "нажмите строку легенды, чтобы сгруппировать только эту группу"
}

View file

@ -0,0 +1,5 @@
{
"embed_link": "Ссылка для встраивания",
"add_to_website": "Добавить на ваш сайт",
"embed_paste_hint": "Вставьте этот код в конструктор вашего сайта (WordPress, Squarespace, Wix или любой сайт, принимающий HTML-виджеты)."
}

View file

@ -48,5 +48,7 @@
"engineMaplibreNote": "токен не нужен",
"tokenNotNeeded": "Выбран MapLibre: его картам не нужен токен Mapbox",
"hideHeader": "Скрыть заголовок",
"showHeader": "Показать заголовок"
"showHeader": "Показать заголовок",
"elementsCount": "{n} элементов",
"elements": "элементов"
}

View file

@ -0,0 +1,5 @@
{
"csv": "CSV",
"json": "JSON",
"tap_row_hint": "Нажмите на любую строку, чтобы увидеть все подробности"
}

View file

@ -7,5 +7,7 @@
"searchPeopleGroups": "搜索民族",
"resetAllFilters": "重置所有筛选和搜索",
"selectAllRows": "选择所有行",
"deselectAllRows": "取消选择所有行"
"deselectAllRows": "取消选择所有行",
"rowDetail": "行详情",
"dismissHint": "关闭提示"
}

View file

@ -34,5 +34,8 @@
"prayer_status": "代祷状态",
"close": "关闭",
"clear": "清除",
"map_controls": "地图控件"
"map_controls": "地图控件",
"clusters": "聚类",
"mst_full_name": "最小生成树",
"mst_hint": "点击图例中的一行,仅对该组进行聚类"
}

View file

@ -0,0 +1,5 @@
{
"embed_link": "嵌入链接",
"add_to_website": "添加到您的网站",
"embed_paste_hint": "将此代码粘贴到您的网站构建器中(WordPress、Squarespace、Wix,或任何接受 HTML 小部件的网站)。"
}

View file

@ -48,5 +48,7 @@
"engineMaplibreNote": "无需令牌",
"tokenNotNeeded": "已选择 MapLibre:其地图无需 Mapbox 令牌",
"hideHeader": "隐藏标题栏",
"showHeader": "显示标题栏"
"showHeader": "显示标题栏",
"elementsCount": "{n} 个元素",
"elements": "元素"
}

View file

@ -0,0 +1,5 @@
{
"csv": "CSV",
"json": "JSON",
"tap_row_hint": "点按任一行查看完整详情"
}

View file

@ -25,6 +25,7 @@ const P = {
pin: '<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0z"/><circle cx="12" cy="10" r="3"/>',
monitor: '<rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>',
chart: '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>',
folder: '<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
layers: '<polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/>',
flask: '<path d="M9 3h6v5l5 11a2 2 0 0 1-2 3H6a2 2 0 0 1-2-3l5-11z"/><line x1="9" y1="3" x2="15" y2="3"/><line x1="6" y1="15" x2="18" y2="15"/>',
share: '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.6" y1="13.5" x2="15.4" y2="17.5"/><line x1="15.4" y1="6.5" x2="8.6" y2="10.5"/>',

View file

@ -87,6 +87,7 @@
"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:studio-rail": "node internal/check-studio-rail.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",
@ -206,7 +207,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 check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution",
"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 check:studio-rail",
"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",
@ -358,6 +359,7 @@
"check:shot-fidelity": "warns when THIS machine cannot RENDER what the shots must SHOW. `bun run shots` printed 13x OK while every emoji rendered as a tofu box — a glyph fallback loads perfectly (0 console errors, 0 HTTP>=400), so the generator cannot see it. Reports by default; the `check:shot-fidelity:strict` script to block once an emoji font is installed.",
"check:shot-fonts": "can THIS machine render the emoji the UI uses? Run BEFORE `bun run shots` — no font = tofu boxes in every committed PNG, which every other shot gate passes. Deliberately NOT in build:unlocked: it reports an ENVIRONMENT condition (this box has no emoji font), and a gate that always fails in the build makes the build useless.",
"check:shots": "a README screenshot that predates the bundle it shows is a picture telling a lie — and it looks IDENTICAL to a current one. Compares each shot's PNG against the BUILT BUNDLE it renders (not raw sources: an unbuilt edit is not yet in any leaf). Fix: node internal/readme-shots.mjs",
"check:studio-rail": "the Plugin Studio rail is laid out like plugin-designs/: one folder per bundle with its elements inside, bundles in the section of their highest capability (5 \u2192 1) and in name order, one canonical node per plugin. Runs the studio's real rail code on the built manifest, headless.",
"check:translator-contacts": "the credit line LINKS the translating organisation now, and a contact URL is a link this map stands behind. Reads ALLOWED_CONTACT out of internal/verify-gospel-map.mjs rather than restating it (two copies of an allowlist drift, and the drifting copy is the one nobody reads), then asserts every cr.u is on that list, was recorded `verified` by a real fetch, is never also serving a RESOURCE (wycliffe.org is somewhere to write to, not a publisher this map reads text from), and is actually RENDERED as an anchor \u2014 the data can carry 838 URLs while the template shows none. Static, sub-second. Self-test: `bun run check:translator-contacts:poison`.",
"check:snapshot-guard": "every gospel-resources catalogue fetcher must write through scripts/_snapshot-guard.mjs, which refuses a write that loses more than a tenth of the previous snapshot. Written after a rate-limited FCBH run silently replaced 1,666 video scopes with 1,103. Static, sub-second. Self-test: `bun run check:snapshot-guard:poison` injects a raw write and the check must fail on it.",
"check:spa-tab-order": "the cap-5 SPA must open on Big Picture and keep the Driver's order (Big Picture, UPG-100, Prayer Progress, Gospel Resources) — asserted as PRESENCE + tab-1 + RELATIVE order on the RENDERED rail, because the rail gained three sections and now renders 12 rows with others between them. ⚠ NOT WAVE-WIRED: it FAILS rather than skips when the SPA is unreachable, deliberately (\"a gate that cannot reach its subject has not passed, it has not run\") — correct standalone, but gates:post runs on checkouts with no server. Needs `bun run serve` on 4173.",

View file

@ -920,6 +920,45 @@ const countryStats = computed(() => {
}
})
/**
* ═══ PRAYER IS A MOVE, NOT A STATISTIC ════════════════════════════════════════════════════
*
* A country click ended in numbers. Every one of them describes work somebody else has to fund,
* staff or translate — and the one thing the reader of a country can do THIS MINUTE was reachable
* only by guessing which pin to tap. So the country sheet now ends in a move: the largest group
* here that has nothing AND nobody praying, with its prayer page one tap away.
*
* ⚠ THE DENOMINATOR IS STATED BECAUSE IT IS NOT THE COUNTRY. doxa.life publishes a page for
* 1,723 of the 12,379 groups on this map, so "nobody praying" can only ever be said of the
* groups that HAVE a page — never of the country. Rendering a bare count would read as "14 groups
* in this country have nobody praying", which this data cannot support. It is shown as N of M.
*
* ⚠ ZERO IS ZERO ON doxa.life's COUNTER, which is what the gap tab already says in full. It is
* not a claim that nobody in the world prays for these people — an unknowable thing.
*/
const countryPrayer = computed(() => {
const g = groupsForCountry.value
let pages = 0, noPray = 0, noPrayPeople = 0
let firstMove = null, firstMovePop = -1
for (const r of g) {
const e = prayIndex.value[prayKey(r.people_group, r._iso3 || selected.value?.iso3 || '')]
if (!e) continue
pages++
if (e.praying > 0) continue
noPray++
noPrayPeople += r.population || 0
/* NEED FIRST, THEN SIZE, and India shows what that costs: of its 601 groups with nobody
praying only FIVE have nothing at all, so this offers Malai Pandaram at 8,700 people ahead
of Badhai at 851,000 — who already have Scripture and film. That is the map's own ladder
applied to prayer rather than a second, quieter ranking, and the sub-label says "nothing
found" so the reader can see which rung they were handed. Where every unprayed-for group
here has something, the largest of those is offered instead. */
const rank = (hasAny(r) ? 0 : 1e12) + (r.population || 0)
if (rank > firstMovePop) { firstMovePop = rank; firstMove = { ...e, group: r } }
}
return { pages, noPray, noPrayPeople, firstMove }
})
/* ITEM 2 — resources in a group's OWN language, joined on the EXACT ISO 639-3 code.
* Never a name match: language names collide and drift ("Arabic" is dozens of
* distinct codes), and a fuzzy hit here would put the wrong scripture in someone's
@ -1141,7 +1180,7 @@ async function ensureProvinceTable() {
return provinceRows.value
}
const PROVINCE_FILE = { IND: 'gb-IND-ADM1.geojson', CHN: 'gb-CHN-ADM1.geojson' }
const PROVINCE_FILE = { IND: 'gb-IND-ADM1-full.geojson', CHN: 'gb-CHN-ADM1.geojson' }
async function ensureProvinces(iso3) {
const map = mapRef.value
@ -2630,6 +2669,12 @@ useShadowStyles(
rows underneath already showed the third. -->
<div class="gm-stat"><span>{{ t('gospel.stat.haveAudio') }}</span><b>{{ fmt(countryStats.audio) }}</b></div>
<div class="gm-stat" v-if="countryStats.sign"><span>{{ t('gospel.stat.signLanguages') }}</span><b>{{ fmt(countryStats.sign) }}</b></div>
<!-- N of M, never a bare N: the denominator is the groups doxa.life publishes a prayer
page for, not the country. See countryPrayer. -->
<div class="gm-stat" v-if="countryPrayer.pages">
<span>{{ t('gospel.tabs.noprayer') }}</span>
<b>{{ fmt(countryPrayer.noPray) }} / {{ fmt(countryPrayer.pages) }}</b>
</div>
<!-- These two are NOT derivable from the pins: they come from the curated country
analysis, over its own narrower set of groups. Labelled so the different basis is
visible rather than implied.
@ -2648,6 +2693,18 @@ useShadowStyles(
</p>
</template>
<!-- ═══ THE MOVE ═══════════════════════════════════════════════════════════════════
Everything above this line is work for somebody else to fund, staff or translate.
This is the one a reader can do before closing the tab, and it names the group
rather than making them choose: the largest here with nothing and nobody praying. -->
<a v-if="countryPrayer.firstMove" class="gm-pray" :href="countryPrayer.firstMove.u"
target="_blank" rel="noopener noreferrer"
:title="'Open the prayer page for ' + countryPrayer.firstMove.n + ' on doxa.life.'">
{{ t('gospel.sheet.pray') }} {{ countryPrayer.firstMove.n }}
<span class="gm-pray-n" dir="ltr">{{ fmt(countryPrayer.firstMove.group.population || 0) }}
people<template v-if="!countryPrayer.firstMove.group._tier">, nothing found</template></span>
</a>
<!-- Same split as the language link above: a filter when the tables are already on
screen beneath this map, a link when they are not. This is the brief's SECOND
requirement — click a country, see its resources — and in the composite it was

View file

@ -82,8 +82,14 @@ const wayIn = (code, iso3, name) => {
return b ? b.l : ''
}
/* ⚠ INDIA READS THE -full FILE, NOT gb-IND-ADM1.geojson. The unsuffixed file is the UPG-100
list's own curated partition — 34 listId-tagged units — and this table was built on it for one
build, which is why eleven groups had no province: they were standing in states the partition
does not draw. gb-IND-ADM1-full.geojson is the complete ADM1 (36 states, geoBoundaries via
DataMeet India, CC BY 2.5 IN). A partition of somebody else's list is not a country's states,
even when the filename looks like it is. */
const COUNTRIES = [
{ iso3: 'IND', file: 'gb-IND-ADM1.geojson', country: 'India' },
{ iso3: 'IND', file: 'gb-IND-ADM1-full.geojson', country: 'India' },
{ iso3: 'CHN', file: 'gb-CHN-ADM1.geojson', country: 'China' },
]
@ -146,6 +152,17 @@ if (!assigned || assigned < 900) {
provinces_available: provincesAvailable,
groups_assigned: assigned,
groups_unassigned: unassigned.length,
india_note: 'India is read from the COMPLETE ADM1 (36 states, geoBoundaries via DataMeet ' +
'India, CC BY 2.5 IN). The first build of this table read gb-IND-ADM1.geojson, which is ' +
'the UPG-100 list\'s curated 34-unit partition, and eleven groups came out with no ' +
'province because they stood in states that partition does not draw. Switching the file ' +
'moved 998 assigned to 1,005 and 44 province rows to 46, and turned the province names ' +
'into India\'s own states.',
unassigned_note: 'Four remain, each one legible rather than lost. Great Andamanese and ' +
'the Sentinelese sit on small islands whose simplified coastline the point falls just ' +
'outside — Andaman and Nicobar IS in the file, so this is geometry precision, not a ' +
'missing province. Jing and Pana are on China\'s Vietnam and Laos borders, outside the ' +
'twelve-province subset. They are listed in `unassigned` with their coordinates.',
china_note: 'China\'s ADM1 file carries 12 provinces, not 31+. Measured rather than ' +
'assumed: 152 of 154 Chinese groups fall inside them and all twelve are used, so the ' +
'file is cut to the provinces where the unreached peoples are.',

View file

@ -567,21 +567,30 @@ export default defineComponent({
// plugin left no single name to report it by; a node that only TARGETS the canonical row has no
// identity of its own, so nesting and the one-name rule coexist.
treeSections() {
// ★ LAID OUT LIKE plugin-designs/ (Driver, 2026-09-26): one FOLDER per BUNDLE, its ELEMENTS inside,
// bundles grouped by their HIGHEST capability (5 → 1) and sorted by name within a group. A bundle
// with one element is shown as that element (a folder of one is noise). A composite's parts still
// hang under its element, folded, each TARGETING its canonical row (one identity per plugin).
const treeOf = new Map(this.compositionTrees.map(t => [t.nodes[0].selects, t]))
return this.groups.map(g => {
const byBundle = new Map()
for (const r of this.rows) { if (!byBundle.has(r.bundle)) byBundle.set(r.bundle, []); byBundle.get(r.bundle).push(r) }
const bundles = [...byBundle.entries()].map(([bundle, rows]) => ({ bundle, rows, cap: Math.max(...rows.map(r => r.cap)) }))
return [5, 4, 3, 2, 1].map(cap => {
const group = bundles.filter(b => b.cap === cap).sort((x, y) => x.bundle.localeCompare(y.bundle))
if (!group.length) return null
const nodes = []
for (const r of g.rows) {
const pushElement = (r, depth, parent) => {
const t = treeOf.get(r.key)
nodes.push({
id: r.key, parent: '', depth: 0, label: r.label, desc: r.tag, icon: r.icon, target: r.key,
id: r.key, parent, depth, label: r.label, desc: r.tag, icon: r.icon, target: r.key, canonical: true,
kids: t ? t.nodes[0].kids : 0, title: r.label + ' · <' + r.tag + '> · CAP ' + r.cap + ' ' + this.tr('cap' + r.cap)
})
if (!t) continue
if (!t) return
const nid = (id) => (id === 0 ? r.key : r.key + '>' + id)
for (const n of t.nodes.slice(1)) {
nodes.push({
id: nid(n.id), parent: nid(n.ancestors[n.ancestors.length - 1]), depth: n.depth,
label: n.label, part: !!n.part, target: n.selects || '',
id: nid(n.id), parent: nid(n.ancestors[n.ancestors.length - 1]), depth: depth + n.depth,
label: n.label, part: !!n.part, target: n.selects || '', canonical: false,
icon: n.part ? 'dots' : (CAP_ICON[n.cap] || 'embed'),
desc: n.part ? this.tr('noStandalone') : 'cap ' + n.cap + ' · ' + n.tag,
kids: n.kids || 0,
@ -590,10 +599,19 @@ export default defineComponent({
})
}
}
// Short section names: the header shares a 256px rail with its CAP badge and count, and the
// full "App shell · nested-mfe-spa" truncated to "APP SHELL · NEST…". The long name is the tooltip.
return { id: 'cap-' + g.cap, cap: g.cap, label: this.tr('cap' + g.cap), title: this.tr('cap' + g.cap) + ' · ' + (MACHINE_TYPE[g.cap] || ''), icon: CAP_ICON[g.cap] || 'embed', nodes }
})
for (const b of group) {
const rows = [...b.rows].sort((x, y) => y.cap - x.cap || x.label.localeCompare(y.label))
if (rows.length === 1) { pushElement(rows[0], 0, ''); continue }
const fid = 'bundle:' + b.bundle
nodes.push({
id: fid, parent: '', depth: 0, label: b.bundle, desc: this.tr('elementsCount', { n: rows.length }),
icon: 'folder', target: rows[0].key, canonical: false, folder: true, kids: rows.length,
title: b.bundle + ' · plugin-designs/' + b.bundle + '/ · ' + rows.length + ' ' + this.tr('elements')
})
for (const r of rows) pushElement(r, 1, fid)
}
return { id: 'cap-' + cap, cap, label: this.tr('cap' + cap), title: this.tr('cap' + cap) + ' · ' + (MACHINE_TYPE[cap] || ''), icon: CAP_ICON[cap] || 'embed', nodes }
}).filter(Boolean)
},
// ★ ONE TREE PER COMPOSITE, built ENTIRELY from the manifest's `composes` edges — no bundle names
// or child lists live in this file, so a new composite appears here with zero edits.

View file

@ -146,6 +146,11 @@ const EN = {
}
const CHEV = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 6 15 12 9 18"/></svg>'
// A node is the CANONICAL row of a plugin when the rail says so (canonical: true); older models without
// the flag used depth 0 for that. A bundle FOLDER is not a plugin, and neither is a repeated part.
const isCanonical = (n) => (n.canonical !== undefined ? !!n.canonical : n.depth === 0)
const isAlias = (n) => (n.canonical !== undefined ? !n.canonical && !n.folder : n.depth > 0)
export default defineComponent({
name: 'StudioTree',
props: {
@ -209,13 +214,13 @@ export default defineComponent({
if (!this.kept && this.secClosed[s.id]) continue
for (const n of nodes) {
if (!this.kept && !this.ancestorsOpen(n)) continue
out.push({ ...n, sectionId: s.id, level: n.depth + 2, expandable: !!n.kids, alias: n.depth > 0 })
out.push({ ...n, sectionId: s.id, level: n.depth + 2, expandable: !!n.kids, alias: isAlias(n) })
}
}
return out
},
activeSectionId() {
for (const s of this.sections) if (s.nodes.some(n => n.depth === 0 && n.target === this.active)) return s.id
for (const s of this.sections) if (s.nodes.some(n => isCanonical(n) && n.target === this.active)) return s.id
return ''
}
},
@ -255,7 +260,7 @@ export default defineComponent({
collapseAll() {
this.nodeOpen = {}
this.saveState()
const a = this.visible.find(i => !i.section && i.target === this.active && i.depth === 0)
const a = this.visible.find(i => !i.section && i.target === this.active && isCanonical(i))
if (a) this.focusId = a.id
},
rowClass(item) {
@ -325,9 +330,15 @@ export default defineComponent({
if (!this.active || !this.sections.length) return
let row = null
for (const s of this.sections) {
row = s.nodes.find(n => n.depth === 0 && n.target === this.active)
row = s.nodes.find(n => isCanonical(n) && n.target === this.active)
if (row) { if (this.secClosed[s.id]) { this.secClosed = { ...this.secClosed, [s.id]: false }; this.saveState() } break }
}
// A plugin inside a BUNDLE FOLDER: open the folder(s) above it, or it cannot be seen.
if (row) {
let p = row.parent, opened = false
while (p) { if (!this.nodeOpen[p]) { this.nodeOpen = { ...this.nodeOpen, [p]: true }; opened = true } p = this.byId.get(p) && this.byId.get(p).parent }
if (opened) this.saveState()
}
if (!row) return
if (!this.focusId || !this.byId.has(this.focusId) || this.byId.get(this.focusId).target !== this.active) this.focusId = row.id
this.$nextTick(() => { const el = this._rows && this._rows[row.id]; if (el && el.scrollIntoView) el.scrollIntoView({ block: 'nearest' }) })

View file

@ -434,6 +434,11 @@ onMounted(async () => {
map.value.addControl(new FullscreenToggleControl(profileRoot.value), 'bottom-right')
}
map.value.on('load', async () => {
// The constructor's projection:'mercator' is only a request: on Mapbox the style's own projection
// (globe) wins, so the map booted as a globe while this view's state and button said Mercator, and
// the first "Switch to Globe" click did nothing (rega, C2). The style is ready here, on both
// engines, so apply the state to the map.
setProjectionFor(map.value, projection.value)
try {
// Shift logical center right of the 380px legend so the globe / pin
// density isn't hidden behind the column.

View file

@ -252,6 +252,11 @@ onMounted(async () => {
map.value.addControl(new FullscreenToggleControl(profileRoot.value), 'bottom-right')
}
map.value.on('load', async () => {
// The constructor's projection:'mercator' is only a request: on Mapbox the style's own projection
// (globe) wins, so the map booted as a globe while this view's state and button said Mercator, and
// the first "Switch to Globe" click did nothing (rega, C2). The style is ready here, on both
// engines, so apply the state to the map.
setProjectionFor(map.value, projection.value)
try {
// Legend now sits top-left under the search bar (~320px wide). Shift the
// logical center right of that column so the world / globe isn't drawn

View file

@ -410,6 +410,11 @@ onMounted(async () => {
treeData.value = await treeResp.json()
map.value.on('load', async () => {
// The constructor's projection:'mercator' is only a request: on Mapbox the style's own projection
// (globe) wins, so the map booted as a globe while this view's state and button said Mercator, and
// the first "Switch to Globe" click did nothing (rega, C2). The style is ready here, on both
// engines, so apply the state to the map.
setProjectionFor(map.value, projection.value)
try {
// Globe centering: shift the map's logical center right of the legend
// so the globe doesn't render beneath the 380px legend column.

View file

@ -33,6 +33,7 @@ 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'
import { whenStyleReady } from '../whenStyleReady.js'
const nt = useNeighborT()
const C_RED = '#e11d48', C_RED_DK = '#9f1239', C_NAVY = '#002e6e', C_GOLD = '#f59e0b'
@ -279,7 +280,8 @@ async function fetchDoxa() {
onMounted(async () => {
await initializeMap()
const m = map.value
setProjectionOn(m, 'mercator') // flat mercator, never globe (Driver); per-engine shape via C2 helper
// flat mercator, never globe (Driver); applied once the style is ready (MapLibre throws before)
whenStyleReady(m, () => setProjectionOn(m, 'mercator'))
try { doxaFeatures.value = await fetchDoxa() }
catch (e) { statusMsg.value = 'Live doxa API unreachable — ' + e.message; appReady.value = true; return }
appReady.value = true

View file

@ -23,6 +23,7 @@ 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'
import { whenStyleReady } from '../whenStyleReady.js'
const store = useCockpitStore()
@ -365,7 +366,8 @@ onMounted(async () => {
await initializeMap()
const m = map.value
if (!m) { return } // no/invalid token — overlay shows the hint, store still drives the rest
setProjectionOn(m, 'mercator') // flat mercator, never globe (Driver); per-engine shape via C2 helper
// flat mercator, never globe (Driver); applied once the style is ready (MapLibre throws before)
whenStyleReady(m, () => setProjectionOn(m, 'mercator'))
// Gated test hook (inert in production): lets a harness use the live map.project().
if (cfg.value.debugExpose && typeof window !== 'undefined') {

View file

@ -389,6 +389,7 @@ function onMove(ev) { if (tip.value.show) { tip.value.x = ev.clientX; tip.value.
import bakedReached from './data/reached-neighbors.json'
import bakedAdmin1 from './data/admin1-membership.json'
import { setProjectionOn } from '@map/composables/useMapProvider.js'
import { whenStyleReady } from './whenStyleReady.js'
async function loadJSON(relCandidates) {
for (const url of relCandidates) {
try { const r = await fetch(url); if (r.ok) return await r.json() } catch (_) {}
@ -411,7 +412,8 @@ async function fetchDoxa() {
onMounted(async () => {
await initializeMap()
const m = map.value
setProjectionOn(m, 'mercator') // flat mercator, never globe (Driver); per-engine shape via C2 helper
// flat mercator, never globe (Driver); applied once the style is ready (MapLibre throws before)
whenStyleReady(m, () => setProjectionOn(m, 'mercator'))
// 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).

View file

@ -402,6 +402,7 @@ function flyToSender(n) {
// profile-config `dataUrl` still overrides live.
import bakedReached from './data/reached-neighbors.json'
import { setProjectionOn } from '@map/composables/useMapProvider.js'
import { whenStyleReady } from './whenStyleReady.js'
async function loadDataset() {
const cfgUrl = profileConfig?.value?.dataUrl
if (cfgUrl) {
@ -413,7 +414,8 @@ async function loadDataset() {
onMounted(async () => {
await initializeMap()
const m = map.value
setProjectionOn(m, 'mercator') // flat mercator, never globe (Driver); per-engine shape via C2 helper
// flat mercator, never globe (Driver); applied once the style is ready (MapLibre throws before)
whenStyleReady(m, () => setProjectionOn(m, 'mercator'))
// Test-only hook (inert in production — platform never sets debugExpose). Lets an
// automated harness use the live map.project() to click exact circle positions.

View file

@ -0,0 +1,13 @@
/**
* whenStyleReady.js — run once the map's style has loaded (immediately if it already has).
*
* MapLibre throws "Style is not done loading" from setProjection before that, and setProjectionOn
* swallows the throw, so the lenses that set 'mercator' right after initializeMap() never got it on
* MapLibre (rega, C2: lang-cluster, send-map, the bloom cockpit's MapCell). Mapbox accepts it early,
* which is why this only showed on one engine.
*/
export function whenStyleReady(map, fn) {
if (!map) return
if (typeof map.isStyleLoaded === 'function' && map.isStyleLoaded()) fn()
else map.once('style.load', fn)
}

File diff suppressed because one or more lines are too long

View file

@ -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-yda1IvWyb6O5+st/W+s6qZMh+g/CUhXzARi4VZc/KjfXu7p/yzVjbpSsY1gCbMRW" crossorigin="anonymous"></script>
<script src="../all-people-groups.js" integrity="sha384-8rqwLEQ0MCPH6srSqGcrf0r9Ca2UxpnHyrLiF0lKZJlXPHdnhMc0Dl6nY6cCeBmN" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -3,10 +3,12 @@
"title": "Gospel access by province — India and China",
"why": "These two countries hold 47.9% of Doxa's roster (855 + 154 of 2,107). No other country is close; the third is Sudan at 105. A national fill cannot answer \"where would a team go\" for half the map.",
"generated": "2026-09-26",
"provinces_with_groups": 44,
"provinces_available": 46,
"groups_assigned": 998,
"groups_unassigned": 11,
"provinces_with_groups": 46,
"provinces_available": 48,
"groups_assigned": 1005,
"groups_unassigned": 4,
"india_note": "India is read from the COMPLETE ADM1 (36 states, geoBoundaries via DataMeet India, CC BY 2.5 IN). The first build of this table read gb-IND-ADM1.geojson, which is the UPG-100 list's curated 34-unit partition, and eleven groups came out with no province because they stood in states that partition does not draw. Switching the file moved 998 assigned to 1,005 and 44 province rows to 46, and turned the province names into India's own states.",
"unassigned_note": "Four remain, each one legible rather than lost. Great Andamanese and the Sentinelese sit on small islands whose simplified coastline the point falls just outside — Andaman and Nicobar IS in the file, so this is geometry precision, not a missing province. Jing and Pana are on China's Vietnam and Laos borders, outside the twelve-province subset. They are listed in `unassigned` with their coordinates.",
"china_note": "China's ADM1 file carries 12 provinces, not 31+. Measured rather than assumed: 152 of 154 Chinese groups fall inside them and all twelve are used, so the file is cut to the provinces where the unreached peoples are.",
"ways_in_are_country_level": "The ways in are chosen for the COUNTRY, and going below the border is what exposes the cost of that. Arunachal Pradesh, in the far north-east, is offered TELUGU — a South Indian language — because the curated bridge table names it for those groups and a curated bridge outranks the computed one. India's computed default is Hindi (41%, official), which is defensible nationally and still wrong for much of the north-east. This table does not fix that: it makes it visible. Until a bridge can be chosen per province, read the ways-in column as \"a language with Scripture and film somewhere in this country\", not as a recommendation for this place.",
"approximate": "A province is assigned by point-in-polygon on the group's coordinate, and that coordinate marks where a PEOPLE is found, not a settlement — 108 groups across the roster even share a coordinate with another group. Near a border the point can fall on the wrong side. Every row is flagged approximate and the unassigned are counted, never dropped. No surface may present a province as a fact about a group."
@ -279,8 +281,8 @@
"country": "India",
"iso3": "IND",
"province": "Mahārāshtra",
"groups": 53,
"people": 2858050,
"groups": 54,
"people": 2860150,
"zero_access_groups": 0,
"zero_access_people": 0,
"zero_access_examples": "",
@ -467,6 +469,18 @@
"ways_in": "",
"approximate": true
},
{
"country": "India",
"iso3": "IND",
"province": "Puducherry",
"groups": 5,
"people": 41625,
"zero_access_groups": 0,
"zero_access_people": 0,
"zero_access_examples": "",
"ways_in": "",
"approximate": true
},
{
"country": "India",
"iso3": "IND",
@ -566,9 +580,21 @@
{
"country": "India",
"iso3": "IND",
"province": "Puducherry",
"province": "Lakshadweep",
"groups": 1,
"people": 20500,
"people": 74500,
"zero_access_groups": 0,
"zero_access_people": 0,
"zero_access_examples": "",
"ways_in": "",
"approximate": true
},
{
"country": "India",
"iso3": "IND",
"province": "Dādra and Nagar Haveli and Damān and Diu",
"groups": 1,
"people": 125,
"zero_access_groups": 0,
"zero_access_people": 0,
"zero_access_examples": "",
@ -589,13 +615,6 @@
}
],
"unassigned": [
{
"country": "India",
"iso3": "IND",
"people_group": "Bharwad",
"lat": 20.322175,
"lng": 72.968458
},
{
"country": "India",
"iso3": "IND",
@ -603,34 +622,6 @@
"lat": 12.211728,
"lng": 92.833491
},
{
"country": "India",
"iso3": "IND",
"people_group": "Indo-French",
"lat": 11.930182,
"lng": 79.797144
},
{
"country": "India",
"iso3": "IND",
"people_group": "Indo-Vietnamese",
"lat": 11.912456,
"lng": 79.815389
},
{
"country": "India",
"iso3": "IND",
"people_group": "Laccadive",
"lat": 10.0675,
"lng": 73.6422
},
{
"country": "India",
"iso3": "IND",
"people_group": "Sagharias",
"lat": 20.71815,
"lng": 70.988939
},
{
"country": "India",
"iso3": "IND",
@ -638,20 +629,6 @@
"lat": 11.557662,
"lng": 92.267154
},
{
"country": "India",
"iso3": "IND",
"people_group": "Vettalaikarans",
"lat": 11.916308,
"lng": 79.807019
},
{
"country": "India",
"iso3": "IND",
"people_group": "Vettiyan",
"lat": 11.91386,
"lng": 79.814472
},
{
"country": "China",
"iso3": "CHN",

File diff suppressed because one or more lines are too long

View file

@ -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-ol5VjnttxiwjEl9dlcOzHFYhGqUUFNfDm/FKV3zeR7YQCFOczRHasOGlJtdsRuuy" crossorigin="anonymous"></script>
<script src="../../big-picture.js" integrity="sha384-Ii8bDqkNqNyPoL4R6n7nz0C7hBM4DggWRXcaFRSHrcKTjSgFYebrpeBfmbAKLCzx" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -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-ol5VjnttxiwjEl9dlcOzHFYhGqUUFNfDm/FKV3zeR7YQCFOczRHasOGlJtdsRuuy" crossorigin="anonymous"></script>
<script src="../../big-picture.js" integrity="sha384-Ii8bDqkNqNyPoL4R6n7nz0C7hBM4DggWRXcaFRSHrcKTjSgFYebrpeBfmbAKLCzx" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -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-ol5VjnttxiwjEl9dlcOzHFYhGqUUFNfDm/FKV3zeR7YQCFOczRHasOGlJtdsRuuy" crossorigin="anonymous"></script>
<script src="../big-picture.js" integrity="sha384-Ii8bDqkNqNyPoL4R6n7nz0C7hBM4DggWRXcaFRSHrcKTjSgFYebrpeBfmbAKLCzx" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -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-pE5no47osDxGiWc1ES6HhtEhTjKnTs8BqTdZ3OfNoIBDCoZm+6ZeOKWxrHvaCSZT" crossorigin="anonymous"></script>
<script src="../cities-internet.js" integrity="sha384-fn/XqzAvikU3wkpcVg0TllWNs4sP9V5ahWKYodEhmXO+eFTWfUkR+Qx+5Sz2moN9" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -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-pE5no47osDxGiWc1ES6HhtEhTjKnTs8BqTdZ3OfNoIBDCoZm+6ZeOKWxrHvaCSZT" crossorigin="anonymous"></script>
<script src="../cities-internet.js" integrity="sha384-fn/XqzAvikU3wkpcVg0TllWNs4sP9V5ahWKYodEhmXO+eFTWfUkR+Qx+5Sz2moN9" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -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-pE5no47osDxGiWc1ES6HhtEhTjKnTs8BqTdZ3OfNoIBDCoZm+6ZeOKWxrHvaCSZT" crossorigin="anonymous"></script>
<script src="../cities-internet.js" integrity="sha384-fn/XqzAvikU3wkpcVg0TllWNs4sP9V5ahWKYodEhmXO+eFTWfUkR+Qx+5Sz2moN9" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -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-P37hAb5gyrk9w9aA4l9HFjM7FwjKlsHjHSeZVDDUZ4KddKM+wlbabTrZCgcVklWS" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-pSEjrRVgGp3ArV0+sMjER5pDVl/MZIQntQAc/DfrK7ENXykRDug54abbm3wcjOoI" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -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-P37hAb5gyrk9w9aA4l9HFjM7FwjKlsHjHSeZVDDUZ4KddKM+wlbabTrZCgcVklWS" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-pSEjrRVgGp3ArV0+sMjER5pDVl/MZIQntQAc/DfrK7ENXykRDug54abbm3wcjOoI" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -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-P37hAb5gyrk9w9aA4l9HFjM7FwjKlsHjHSeZVDDUZ4KddKM+wlbabTrZCgcVklWS" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-pSEjrRVgGp3ArV0+sMjER5pDVl/MZIQntQAc/DfrK7ENXykRDug54abbm3wcjOoI" 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

View file

@ -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-HlacN5eFJy1tUz5BXycPsWdFNXVKpOHxk+s4HCXZs9AndFI4jrZb3H//UwKO51Vh" crossorigin="anonymous"></script>
<script src="../../dashboard-plus-grid.js" integrity="sha384-5Yvna6wDcKVmPHD5Gc+ed3n+AAxx4a2hM4IwHIb08bus3WNjC5Eq9mC4lxH+T2VM" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -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-HlacN5eFJy1tUz5BXycPsWdFNXVKpOHxk+s4HCXZs9AndFI4jrZb3H//UwKO51Vh" crossorigin="anonymous"></script>
<script src="../dashboard-plus-grid.js" integrity="sha384-5Yvna6wDcKVmPHD5Gc+ed3n+AAxx4a2hM4IwHIb08bus3WNjC5Eq9mC4lxH+T2VM" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show more