Merge main (studio landing 6) into packet/vertical
★ THE TWO INSTRUCTIONS I WAS GIVEN CONTRADICTED EACH OTHER ON ONE LINE, so this records how it was
resolved rather than leaving the next reader to guess. UPGHK said delete humanizeKey; UPGW3 said merge
1bd5f109 and "keep regions' version" — and regions' version KEEPS humanizeKey
(`useOptionalT('', { fallback: (k) => humanizeKey(k) })`). Read as structure-vs-fallback they agree:
UPGHK's reasoning is about what a reader SEES ("a real English or translated string beats a title-cased
key segment"), so "keep regions' version" is about the SHAPE of the call. Kept theirs in every respect
but the fallback:
- useOptionalT('') with the EMPTY namespace, theirs, not my 'messages'/'tabs'. Verified to be a
behavioural no-op here (every key this code passes is already fully qualified, so qualify() returns
it unchanged either way) — and '' is the more honest of two identical options, because it says
"these keys arrive qualified" instead of implying a prefix is being added.
- their "one behaviour gain" note, which is a real gain I had not documented: the old path was the
bare useI18n().t, so on a host WITH i18n a missing key rendered the raw dotted key.
- the fallback deleted, per UPGHK.
AND THE REGISTER KEEPS BOTH WAVES. Their side of the conflict added a wave-3d row for
all-people-groups that mine would have dropped — it stays, with its own reading intact, which is the
OPPOSITE of the call taken for my two files (its inline EN map stays because the file is UNOWNED and an
unowned file is the wrong place to make a product decision by default). Both readings are right for
their own file; that is the point.
⚠ AND I FIXED A FALSE SENTENCE THE GATE WAS ABOUT TO PRINT. With a null third column the register said
"takes the CATALOGUE fallback, as it always did" — true for the two composables that always did, false
for my two, which used humanizeKey until today. That branch already existed to stop exactly this for
gospel's four; my files needed the same treatment, so the "moved" list now names both owners and says
what each moved FROM. A register can be right about its assertion and still teach the next reader
something untrue in the line it prints, and a gate message is the one place a reader has no reason to
doubt.
Verified: check:optional-t-equivalence PASS on all sites, including gospel's unchanged message.
This commit is contained in:
commit
2f260ab700
65 changed files with 1302 additions and 900 deletions
|
|
@ -117,6 +117,8 @@ and a change here can break other maps.
|
|||
`import … from '@map/composables/useAvatarGenerator.js'`
|
||||
- **useCountryOutline** — useCountryOutline.js — country boundary OUTLINE overlay on country search.
|
||||
`import … from '@map/composables/useCountryOutline.js'`
|
||||
- **useCsvExport** — useCsvExport.js — ★ SHARED LIBRARY CODE (dashboard-kit landing 3). Two parameters make it
|
||||
`import … from '@map/composables/useCsvExport.js'`
|
||||
- **useDashboardLayout** — useDashboardLayout.js — reusable drag-resize + persistence engine for the CMV dashboard grid.
|
||||
`import … from '@map/composables/useDashboardLayout.js'`
|
||||
- **useDocumentFont** — useDocumentFont.js — register a self-hosted @font-face at DOCUMENT level, once per page.
|
||||
|
|
@ -163,10 +165,14 @@ and a change here can break other maps.
|
|||
`import … from '@map/composables/useSelectedPin.js'`
|
||||
- **useSelectionBus** — useSelectionBus.js — hand-rolled pub/sub brushing-and-linking bus.
|
||||
`import … from '@map/composables/useSelectionBus.js'`
|
||||
- **useSelectionCamera** — useSelectionCamera.js — "select rows in the table, the map zooms to that selection."
|
||||
`import … from '@map/composables/useSelectionCamera.js'`
|
||||
- **useShadowStyles** — useShadowStyles.js — Shadow DOM Style Injection
|
||||
`import … from '@map/composables/useShadowStyles.js'`
|
||||
- **useSharedMapToken** — useSharedMapToken.js — ONE page, ONE map token, shared by every element on it.
|
||||
`import … from '@map/composables/useSharedMapToken.js'`
|
||||
- **useTextHighlight** — highlightRanges(value, indices) — wrap each matched index range in
|
||||
`import … from '@map/composables/useTextHighlight.js'`
|
||||
|
||||
### Utilities (pure helper functions)
|
||||
|
||||
|
|
|
|||
44
internal/_published-base.mjs
Normal file
44
internal/_published-base.mjs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* _published-base.mjs — where the PUBLISHED tree lives, for the few tools that measure the live catalogue.
|
||||
*
|
||||
* The repo names no host (white-label: it ships to people who publish somewhere else). A tool that
|
||||
* compares against the live tree reads its location from DOXA_PUBLISHED_BASE: the URL the tree folders sit
|
||||
* under, e.g. https://<your-host>/plugin-assets (the tree name is appended here, from _tree-name.mjs). Set it
|
||||
* in the environment, or in the gitignored .env / .env.local at the repo root.
|
||||
*
|
||||
* With nothing set, such a tool has nothing to measure: it says NOT MEASURED and exits 75, the fleet's
|
||||
* third bucket, instead of passing or failing against a host it was never told about.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { TREE_NAME } from './_tree-name.mjs'
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
function fromEnvFiles() {
|
||||
for (const f of ['.env.local', '.env']) {
|
||||
try {
|
||||
const m = readFileSync(join(ROOT, f), 'utf8').match(/^\s*DOXA_PUBLISHED_BASE\s*=\s*["']?([^"'\s#]+)/m)
|
||||
if (m) return m[1]
|
||||
} catch { /* no such file */ }
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** The published tree's URL (…/<TREE_NAME>, no trailing slash), or '' when none is configured. */
|
||||
export function publishedTreeUrl() {
|
||||
const base = (process.env.DOXA_PUBLISHED_BASE || fromEnvFiles()).replace(/\/+$/, '')
|
||||
return base ? `${base}/${TREE_NAME}` : ''
|
||||
}
|
||||
|
||||
/** For a tool that cannot run without it: the tree URL, or NOT MEASURED (exit 75) naming what to set. */
|
||||
export function requirePublishedTreeUrl(label) {
|
||||
const url = publishedTreeUrl()
|
||||
if (!url) {
|
||||
console.log(`${label}: NOT MEASURED — no published tree to measure. Set DOXA_PUBLISHED_BASE (environment or .env)`)
|
||||
console.log(` to the URL your tree folders sit under, e.g. https://<your-host>/plugin-assets`)
|
||||
process.exit(75)
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
|
@ -34,11 +34,12 @@ import { createHash } from 'node:crypto'
|
|||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { TREE_NAME } from './_tree-name.mjs'
|
||||
import { requirePublishedTreeUrl } from './_published-base.mjs'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const args = process.argv.slice(2)
|
||||
const argOf = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d }
|
||||
const BASE = (argOf('--base', `https://nuxtbase.app/plugin-assets/${TREE_NAME}`)).replace(/\/+$/, '')
|
||||
const BASE = (argOf('--base', '') || requirePublishedTreeUrl('CATALOG-CURRENCY')).replace(/\/+$/, '')
|
||||
const TIMEOUT = Number(argOf('--timeout', 25000))
|
||||
|
||||
const sha384 = (buf) => 'sha384-' + createHash('sha384').update(buf).digest('base64')
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
* Card 1 (IDEA-MAPS-BUNDLER-PUBLIC-FORGEJO-PIPELINE) asks that ONE public Forgejo repo be the live
|
||||
* catalog source, and that the merge lose nothing. The tempting verification is `curl -o /dev/null
|
||||
* -w '%{http_code}'` over a few URLs. That proves only that SOMETHING answered:
|
||||
* · https://nuxtbase.app/app/doxa-maps/manifest.json returns **200** and is a 2,872-byte HTML
|
||||
* · the host platform's /app/doxa-maps/manifest.json returns **200** and is a 2,872-byte HTML
|
||||
* SIGN-IN PAGE. An http-status check reads it as healthy.
|
||||
* · a stale CDN copy also returns 200, with the wrong bytes.
|
||||
*
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
* additionally require that it matches a cloned catalog checkout.
|
||||
*
|
||||
* MEASURED 2026-09-21: 17 of 17 bundles identity-proven, 0 mismatched, and the live public manifest
|
||||
* is byte-identical (sha256 88ae3832…) to platform/mfe-index's. The catalog IS what nuxtbase serves.
|
||||
* is byte-identical (sha256 88ae3832…) to platform/mfe-index's. The catalog IS what the host platform serves.
|
||||
*
|
||||
* USAGE
|
||||
* bun run check:catalog-identity # live public door vs its own manifest
|
||||
|
|
@ -35,8 +35,9 @@ import { readFileSync, existsSync } from 'node:fs'
|
|||
import { createHash } from 'node:crypto'
|
||||
import { join } from 'node:path'
|
||||
import { TREE_NAME } from './_tree-name.mjs'
|
||||
import { requirePublishedTreeUrl } from './_published-base.mjs'
|
||||
|
||||
const BASE = (process.env.BASE || `https://nuxtbase.app/plugin-assets/${TREE_NAME}/`).replace(/\/?$/, '/')
|
||||
const BASE = (process.env.BASE || requirePublishedTreeUrl('CATALOG-IDENTITY')).replace(/\/?$/, '/')
|
||||
const CATALOG = process.env.CATALOG || ''
|
||||
|
||||
const sha384 = (buf) => 'sha384-' + createHash('sha384').update(buf).digest('base64')
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ for (const b of manifest.bundles || []) {
|
|||
const byCompose = composes.some((c) => mapBundles.has(c))
|
||||
const profile = (b.profiles || []).find((p) => p.element && (byCompose || p.type === 'nested-elements-mfe'))
|
||||
if (!profile || !b.scriptUrl) continue
|
||||
targets.push({ bundle: b.bundle, element: profile.element, script: `${b.bundle}/${b.bundle}.js` })
|
||||
targets.push({ bundle: b.bundle, element: profile.element, profile: profile.profile, script: `${b.bundle}/${b.bundle}.js` })
|
||||
}
|
||||
if (!targets.length) {
|
||||
console.log('COMPOSITE-EMBED-KNOBS: SKIP — the manifest declares no composite that nests a map.')
|
||||
|
|
@ -153,7 +153,10 @@ try {
|
|||
lines.push(` · ${t.element} [${c.knob}] SKIPPED — no baked token in this tree to hand the host`)
|
||||
continue
|
||||
}
|
||||
const cfg = JSON.stringify(c.config(bakedToken)).replace(/'/g, ''')
|
||||
// The composite's OWN profile rides with the knob under test (rega, 1a761741: gospel-resources,
|
||||
// my-upg-100-list, regions-dashboard and two more drew no map under a knob-only config, because a
|
||||
// multi-profile element needs to be told which profile is the composite). A knob still wins.
|
||||
const cfg = JSON.stringify({ profile: t.profile, ...c.config(bakedToken) }).replace(/'/g, ''')
|
||||
pageHtml = `<!doctype html><html><head><meta charset=utf-8><title>partner</title></head><body style="margin:0">
|
||||
<header style="height:48px;background:#eee">HOST HEADER</header>
|
||||
<script>window.MAP_APP_API_URL="https://pray.doxa.life";</script>
|
||||
|
|
|
|||
|
|
@ -44,7 +44,13 @@ import { resolve, dirname, join } from 'node:path'
|
|||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const SRC = join(ROOT, 'plugin-designs/research-dashboard/src/composables/useCsvExport.js')
|
||||
// ★ FOLLOWS THE FILE (dashboard-kit landing 3). This gate does not import useCsvExport — it READS
|
||||
// the source and extracts esc() out of it. Left pointing at the plugin path after the move it would
|
||||
// have read the one-line re-export shim, found no esc() to extract, and failed with "could not
|
||||
// extract esc() from useCsvExport.js" — a gate accusing a working exporter because its subject moved.
|
||||
// That is the same defect landing 1 nearly shipped with check-facet-cascade's BUS_SRC, which is why
|
||||
// looking for path-reading gates is now the FIRST step of a landing rather than a discovery during it.
|
||||
const SRC = join(ROOT, 'library/composables/useCsvExport.js')
|
||||
|
||||
if (!existsSync(SRC)) {
|
||||
console.error('CSV-SHAPE: FAIL — useCsvExport.js not found at ' + SRC.replace(ROOT + '/', ''))
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ try {
|
|||
// The leaf's own arithmetic is the oracle — computed here rather than hardcoded, so the
|
||||
// expectation cannot drift from what the generated HTML actually means.
|
||||
const TREES = [
|
||||
['live catalog', `https://nuxtbase.app/plugin-assets/${TREE_NAME}`],
|
||||
['live catalog', `https://maps.example.org/plugin-assets/${TREE_NAME}`],
|
||||
['dev tree', `http://127.0.0.1:4173/${TREE_NAME}`],
|
||||
['doxa.life drop-in', `https://doxa.life/js/doxa-maps-build/${TREE_NAME}`],
|
||||
['deep subpath', `https://cdn.example.org/a/b/c/${TREE_NAME}`],
|
||||
|
|
|
|||
|
|
@ -138,21 +138,29 @@ const SITES = [
|
|||
['plugin-designs/cities-internet/connectivity-list.vue', 'useOptionalT', null],
|
||||
['plugin-designs/cities-internet/connectivity-map.vue', 'useOptionalT', null],
|
||||
['plugin-designs/cities-internet/data-tables.vue', 'useOptionalT', null],
|
||||
// wave 3c — my-upg-100-list (owner: upg100-plugin). NOTE THE THIRD COLUMN: these two arrive with NO
|
||||
// fallback, not with humanizeKey(k). regions-plugin's 4c05e589 had them as 'humanizeKey(k)' — correct
|
||||
// for the migration it was written against, and wrong the moment the fallback was DELETED.
|
||||
// ★ WHY THEY MOVED TO THE CATALOGUE. humanizeKey rendered a key's last segment title-cased, which
|
||||
// failed two different ways: on the messages keys it printed "Upg100 Wall Intro" where a whole
|
||||
// sentence exists (wrong even in English), and on the tabs keys it printed the RIGHT English by
|
||||
// coincidence — the key is named after the word — so it looked correct forever while every
|
||||
// non-English reader silently got English. A fallback that agrees with en is indistinguishable from
|
||||
// a working translation until someone changes locale.
|
||||
// The premise for keeping it ("these keys are not fully catalogued") was mine and was FALSE: seven
|
||||
// wave 3d — all-people-groups. Treated as UNOWNED (studio agreed), so the conservative reading wins:
|
||||
// its inline EN map stays the fallback rather than switching to the catalogue, because nobody is
|
||||
// available to decide that this bundle should start honouring the locale on a bare host. An unowned
|
||||
// file is the wrong place to make a product decision by default.
|
||||
// First site on the {t, te, locale} form: it needs locale for toLocaleString.
|
||||
['plugin-designs/all-people-groups/all-people-groups.vue', 'useOptionalI18n', 'fallback: (full) =>'],
|
||||
// wave 3c — my-upg-100-list (owner: upg100-plugin). No namespace, since callers pass full keys.
|
||||
// ⚠ THE THIRD COLUMN IS null, NOT 'humanizeKey(k)', and that is a DEPARTURE from 4c05e589, which
|
||||
// kept the fallback. Correct as regions-plugin wrote it; wrong the moment dx-shepherd took the
|
||||
// product call (UPGHK, 2026-09-27) to delete it. A row asserting a fallback the code no longer
|
||||
// passes fails by design — the same hole regions flagged to me in the opposite direction.
|
||||
// ★ WHY IT WAS DELETED. humanizeKey rendered a key's last segment title-cased, failing two ways: on
|
||||
// the messages keys it printed "Upg100 Wall Intro" where a whole sentence exists (wrong even in
|
||||
// English), and on the tabs keys it printed the RIGHT English by coincidence — those keys are named
|
||||
// after the words — so it looked correct forever while every non-English reader silently got
|
||||
// English. A fallback that agrees with en is indistinguishable from a working translation until
|
||||
// someone changes locale.
|
||||
// The premise for keeping it ("these keys are not fully catalogued") was MINE and was FALSE: seven
|
||||
// keys across both files, all string literals, every one present in all eleven locales, 0 gaps.
|
||||
// Product call taken by dx-shepherd (UPGHK, 2026-09-27) because it changes what seven vue-i18n-less
|
||||
// bundles render — the same class of decision gospel-plugin took for its four sites above.
|
||||
['plugin-designs/my-upg-100-list/poster-wall.vue', 'useOptionalT', null],
|
||||
['plugin-designs/my-upg-100-list/upg-100-mfe.vue', 'useOptionalT', null],
|
||||
// Same class of decision gospel-plugin took for its four sites above, and the OPPOSITE of the 3d
|
||||
// reading directly above — which is right, because that file is unowned and these two are not.
|
||||
['plugin-designs/my-upg-100-list/poster-wall.vue', 'useOptionalT', null],
|
||||
['plugin-designs/my-upg-100-list/upg-100-mfe.vue', 'useOptionalT', null],
|
||||
]
|
||||
// ★ ONE FILE KEEPS TWO BARE useI18n CALLS ON PURPOSE, and the count is asserted rather than waived.
|
||||
// resources-map.vue reads the LOCALE twice (`useI18n().locale.value`) to decide isEnglish and the
|
||||
|
|
@ -192,9 +200,18 @@ for (const [file, helper, fallbackFrag] of SITES) {
|
|||
// catalogue; gospel's four used to resolve through enLookup's ENGLISH and were MOVED here by their
|
||||
// owner's decision. Saying "as it always did" for those four would be a false statement inside a
|
||||
// gate message, which is the one place a reader has no reason to doubt.
|
||||
const moved = file.includes('gospel-resources')
|
||||
// ⚠ AND my-upg-100-list's TWO ARE ALSO "moved", added 2026-09-27. They used humanizeKey until
|
||||
// dx-shepherd took the product call (UPGHK) to delete it, so "as it always did" would have been
|
||||
// false for them the moment they were registered — the exact failure this branch already guards
|
||||
// against for gospel's four. A register that is right about the assertion and wrong in the sentence
|
||||
// it prints is still teaching the next reader something untrue.
|
||||
const MOVED_TO_CATALOGUE = ['gospel-resources', 'my-upg-100-list']
|
||||
const moved = MOVED_TO_CATALOGUE.some((m) => file.includes(m))
|
||||
const who = file.includes('gospel-resources')
|
||||
? "gospel-plugin moved it here from enLookup's English"
|
||||
: 'upg100-plugin moved it here from humanizeKey, a title-cased key segment'
|
||||
ok(!/fallback:/.test(code), moved
|
||||
? `${short}: takes the locale-honouring CATALOGUE fallback (gospel-plugin moved it here from enLookup's English)`
|
||||
? `${short}: takes the locale-honouring CATALOGUE fallback (${who})`
|
||||
: `${short}: takes the CATALOGUE fallback, as it always did`)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,11 +38,12 @@ import { createHash } from 'node:crypto'
|
|||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { TREE_NAME } from './_tree-name.mjs'
|
||||
import { requirePublishedTreeUrl } from './_published-base.mjs'
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||
const REPO = process.env.DOXA_REPO || resolve(HERE, '..')
|
||||
const PUBLISHED_BASE = (process.env.PUBLISHED_BASE
|
||||
|| `https://nuxtbase.app/plugin-assets/${TREE_NAME}`).replace(/\/$/, '')
|
||||
|| requirePublishedTreeUrl('PUBLISHED-MATCHES-BUILD')).replace(/\/$/, '')
|
||||
const TREE = resolve(REPO, `public/${TREE_NAME}`)
|
||||
const MANIFEST = join(TREE, 'manifest.json')
|
||||
|
||||
|
|
|
|||
|
|
@ -86,14 +86,14 @@ function readBuildConfig() {
|
|||
// The data API every generated page points at, baked as window.MAP_APP_API_URL before the
|
||||
// bundle loads — the HOT-SWAPPABLE backend seam. Empty = same-origin. Set it to the live API
|
||||
// now (https://pray.doxa.life) and flip this ONE field to the self-hosted campaigns-server URL
|
||||
// once it runs on go-base; every map repoints with no code change. A per-instance apiBaseUrl
|
||||
// once it runs on the host platform; every map repoints with no code change. A per-instance apiBaseUrl
|
||||
// in a *.instances.json still overrides this for that instance.
|
||||
const apiBaseUrl = (typeof cfg.apiBaseUrl === 'string' ? cfg.apiBaseUrl.trim() : '').replace(/\/+$/, '')
|
||||
// GATED profiles: bundle folder names withheld from the generated tree, staging pages and
|
||||
// manifest (governance — e.g. key-cities). Their .js still BUILDS via vite; they simply never
|
||||
// appear on any public surface the generator emits. convert.py gates the plugin index too.
|
||||
const gatedProfiles = Array.isArray(cfg.gatedProfiles) ? cfg.gatedProfiles.filter((x) => typeof x === 'string' && x.trim()).map((x) => x.trim()) : []
|
||||
// QA corner widget baked into generated embed pages (the go-base plugin surface). Default true.
|
||||
// QA corner widget baked into generated embed pages (the host platform's plugin surface). Default true.
|
||||
// Partner embeds use the copy-embed SNIPPET with their own config, so they never get this.
|
||||
const qaWidget = cfg.qaWidget !== false
|
||||
// Ship the 42 MB print-resolution posters/ inside the tree? DEFAULT FALSE. The wall renders
|
||||
|
|
@ -758,7 +758,7 @@ function mapPageHtml(opts) {
|
|||
const apiScript = apiBaseUrl
|
||||
? `\n <script>window.MAP_APP_API_URL = ${JSON.stringify(apiBaseUrl)};</script>`
|
||||
: ''
|
||||
// Bake the QA corner widget onto the go-base embed surface (opt-out via build-config qaWidget:false).
|
||||
// Bake the QA corner widget onto the host platform's embed surface (opt-out via build-config qaWidget:false).
|
||||
const pageConfig = DEST_CONFIG.qaWidget ? { ...config, qa: true } : config
|
||||
// PROVIDER-AWARE PAGE (see library/composables/useMapProvider.js): a profile or
|
||||
// instance whose config says provider:'maplibre' gets the MapLibre peer (token-
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ for (const layout of new Set(nested)) {
|
|||
}
|
||||
|
||||
// SUBPATH-PORTABLE imports: `../<layout>/` resolves only at the web-root (../ cannot escape
|
||||
// root); served under a subpath (e.g. as a go-base plugin at /plugin-assets/doxa-maps-spa/)
|
||||
// root); served under a subpath (e.g. as a host-platform plugin at /plugin-assets/doxa-maps-spa/)
|
||||
// it 404s. The layouts are copied as SIBLINGS of index.html, so `./<layout>/` is correct at
|
||||
// EVERY depth. Rewrite the copied entry so the shell works both at root and under any subpath.
|
||||
{
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ const LEAK_STRICT = [
|
|||
[/doxa-life\/doxa-maps/, 'private repo name'],
|
||||
[/\bdoxa-life\b/, 'private org name'],
|
||||
[/\bforgejo\b/i, 'self-hosted forge name'],
|
||||
[/\bnuxtbase\b/, 'internal platform name'],
|
||||
[new RegExp('\\b' + ['nuxt', 'base'].join('') + '\\b'), 'internal platform name'], // built from parts so this file does not itself carry the name
|
||||
[/\bpersecution\b/, 'operational-context vocabulary (allowed only inside data CSVs)'],
|
||||
[/\.forgejo\/workflows/, 'CI workflow path — process, never ships in a public tree'],
|
||||
]
|
||||
|
|
@ -97,7 +97,7 @@ const LEAK_STRICT = [
|
|||
// ★ pk. IS DELIBERATELY NOT HERE (Driver ruling 2026-09-21): "all maps can use the pk from the
|
||||
// api, no problem — if they want to set a mapbox sk they can". A Mapbox `pk.` is a PUBLIC key: it
|
||||
// is designed to ship in client code and is protected by URL RESTRICTIONS, not by secrecy
|
||||
// (measured: the same pk. serves tiles 200 with a doxa.life Referer and 403s with nuxtbase.app).
|
||||
// (measured: the same pk. serves tiles 200 with a doxa.life Referer and 403s with the live test host's Referer).
|
||||
// Flagging it made the token ladder's rung-3 baked token UNBUILDABLE — this gate runs inside
|
||||
// build:unlocked, so `pk.` in public/ failed the build that is supposed to produce it.
|
||||
// `sk.` (SECRET key) and `tk.` (temporary) stay flagged: those are real credentials.
|
||||
|
|
|
|||
|
|
@ -148,7 +148,7 @@ async function shot(name, path, {
|
|||
// Verified in-browser before wiring: 8/8 tile responses 200 with the header, 403 without.
|
||||
// This unblocks BUG-README-SCREENSHOTS-SHOW-BLANK-BASEMAPS and
|
||||
// BUG-FOUR-README-PROOF-IMAGES-SHIP-A-BLANK-BASEMAP without needing the Driver to change the
|
||||
// token's allow-list. That change is still worth making for LIVE nuxtbase.app rendering, which
|
||||
// token's allow-list. That change is still worth making for rendering on the LIVE test host, which
|
||||
// this does not address — a browser sets its own Referer and cannot be overridden this way.
|
||||
//
|
||||
// ★ WHY A CONTEXT-WIDE HEADER AND NOT ROUTE INTERCEPTION, measured both ways 2026-09-22:
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
*
|
||||
* ★ THE DEFECT THIS EXISTS FOR, hit live 2026-09-23. `bun run serve` was
|
||||
* `python3 -m http.server`, which resolves a DIRECTORY request to its index.html. The live
|
||||
* nuxtbase.app static layer does NOT:
|
||||
* test host's static layer does NOT:
|
||||
*
|
||||
* /plugin-assets/doxa-maps/dashboard-plus-grid/dashboard-plus-grid/ -> 404 live, 200 local
|
||||
* /plugin-assets/doxa-maps/dashboard-plus-grid/dashboard-plus-grid/index.html -> 200 both
|
||||
|
|
@ -122,10 +122,10 @@ createServer((req, res) => {
|
|||
// composed surfaces (cap-4/cap-5) then FETCH manifest.json + assets/ -- and fetch()
|
||||
// enforces CORS, so on any third-party host the shell mounted and every manifest-composed
|
||||
// tab died with 'Could not load ...' (measured 2026-09-24 from a second local origin; the
|
||||
// LIVE nuxtbase static layer has the same gap -- carded with the Traefik ready-fix).
|
||||
// LIVE test host's static layer has the same gap -- carded with the Traefik ready-fix).
|
||||
// ACAO:* is the standard for credential-less public assets; production parity is the
|
||||
// whole point of this server, so it must teach the same requirement.
|
||||
// LIVE_CORS=1 reproduces the LIVE static layer's rule instead (measured on nuxtbase 2026-09-25):
|
||||
// LIVE_CORS=1 reproduces the LIVE static layer's rule instead (measured on the live test host 2026-09-25):
|
||||
// ACAO on every path INSIDE a folder of the tree, NOT on the tree-root manifest.json. Off by
|
||||
// default; check:gallery-embed --live-cors uses it to prove the gallery still boots cross-origin
|
||||
// under the rule partners actually get.
|
||||
|
|
|
|||
|
|
@ -37,9 +37,10 @@ import { requireThisTree } from './_serves-this-tree.mjs'
|
|||
import { resolve as _resolvePath, dirname as _dirnamePath } from 'node:path'
|
||||
import { fileURLToPath as _fileURLToPath } from 'node:url'
|
||||
import { TREE_NAME } from './_tree-name.mjs'
|
||||
import { requirePublishedTreeUrl } from './_published-base.mjs'
|
||||
|
||||
const GL=['--use-gl=swiftshader','--enable-unsafe-swiftshader','--ignore-gpu-blocklist','--enable-webgl','--no-sandbox']
|
||||
const B=(process.env.BASE || `https://nuxtbase.app/plugin-assets/${TREE_NAME}/`).replace(/\/?$/,'/')
|
||||
const B=(process.env.BASE || requirePublishedTreeUrl('VERIFY-5-CAPABILITIES')).replace(/\/?$/,'/')
|
||||
const ROOT = _resolvePath(_dirnamePath(_fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
// ★ MEASURE THIS BUILD, OR MEASURE NOTHING. Several worktrees run on one machine and a default port
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import { requireThisTree } from './_serves-this-tree.mjs'
|
|||
import { resolve as _resolvePath, dirname as _dirnamePath } from 'node:path'
|
||||
import { fileURLToPath as _fileURLToPath } from 'node:url'
|
||||
import { TREE_NAME } from './_tree-name.mjs'
|
||||
import { requirePublishedTreeUrl } from './_published-base.mjs'
|
||||
|
||||
// The SwiftShader launch. Without these a headless Chromium here has NO GL context at all.
|
||||
const GL_ARGS = [
|
||||
|
|
@ -44,7 +45,7 @@ const GL_ARGS = [
|
|||
'--no-sandbox',
|
||||
]
|
||||
|
||||
const BASE = process.env.BASE || `https://nuxtbase.app/plugin-assets/${TREE_NAME}`
|
||||
const BASE = process.env.BASE || requirePublishedTreeUrl('VERIFY-MAP-PAINT')
|
||||
const ROOT = _resolvePath(_dirnamePath(_fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
// ★ MEASURE THIS BUILD, OR MEASURE NOTHING. Several worktrees run on one machine and a default port
|
||||
|
|
@ -121,7 +122,7 @@ console.log('MAP-PAINT: GL ok —', gl.version, '/', gl.renderer)
|
|||
* ★ SAY WHICH TREE WAS MEASURED (2026-09-25) — this gate defaults to the DEPLOYED site, and a
|
||||
* failure line that does not say so reads as "your code is broken" when it means "your code is
|
||||
* not deployed yet". MEASURED TWICE IN ONE DAY, both times wrongly: resources-map and
|
||||
* connectivity-map both reported FAIL ("the stylesheet is ABSENT here") against nuxtbase.app
|
||||
* connectivity-map both reported FAIL ("the stylesheet is ABSENT here") against the live test host
|
||||
* while the fix for exactly that defect was sitting, verified, on disk. Served locally, the
|
||||
* same gate passed all 13 leaves.
|
||||
*
|
||||
|
|
|
|||
258
library/composables/useCsvExport.js
Normal file
258
library/composables/useCsvExport.js
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
/**
|
||||
* useCsvExport.js — ★ SHARED LIBRARY CODE (dashboard-kit landing 3). Two parameters make it
|
||||
* generic: `exportLabel` (what the file calls itself) and `sources` (who to credit), both
|
||||
* defaulting to what research-dashboard shipped. Everything else is unchanged.
|
||||
*
|
||||
* useCsvExport.js — RDASH phase 4: export-any-selection to CSV (native Blob, 0-dep).
|
||||
*
|
||||
* Priority model (plan §INTERACTION): selectionSet (explicit picks) > filterSet result (in scope) >
|
||||
* full dataset. Serializes the FULL stored record per row (flattening {value,label} -> label),
|
||||
* with a '#' provenance header line of the active chips, RFC-ish quoting, and CSV-injection
|
||||
* neutralization (leading = + - @ prefixed with a single quote). Shadow-DOM is a non-issue —
|
||||
* export touches no rendered DOM/styles.
|
||||
*
|
||||
* ── ALL-FIELDS EXPORT (card BUILD-CSV-EXPORT-MOVE-TO-TABLE-HEADER-ALL-FIELDS) ──
|
||||
* The export must carry EVERY field the API holds, not just the 8 table columns. The limit was
|
||||
* never in this file — flatten() already walks Object.keys(record). It was UPSTREAM: the store
|
||||
* loads `?fields=<sources.json defaultFields>` (24 keys, ~2.3MB) because that is all the map /
|
||||
* table / charts render. The detail endpoint exposes 67.
|
||||
*
|
||||
* So the wide record is fetched LAZILY, here, on the first export click — not at mount:
|
||||
* - widening sources.json defaultFields would triple the payload (2.3MB -> 6.8MB) for EVERY
|
||||
* bundle that shares that file, to serve one button;
|
||||
* - the list endpoint honors an explicit `fields=` list, so one request returns all 67 keys
|
||||
* for all rows (verified live: 2,107 rows x 67 keys).
|
||||
* The result is cached for the session; if the fetch fails we still export the loaded record
|
||||
* (24 fields) rather than producing nothing — degraded, never broken.
|
||||
*/
|
||||
import { getApiBaseUrl } from '@map/api/apiBaseUrl.js'
|
||||
import { inject } from 'vue'
|
||||
import sourcesConfig from '@map/api/sources.json'
|
||||
import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
|
||||
|
||||
/**
|
||||
* The full field set for the export, in a stable, human-readable order: identity first, then
|
||||
* place, people/language/religion, then the engagement + IMB research columns. Sourced from the
|
||||
* live /detail record shape (67 keys); `exportFields` in sources.json overrides it per-source.
|
||||
*/
|
||||
export const EXPORT_FIELDS = [
|
||||
// identity
|
||||
'slug', 'name', 'imb_alternate_name', 'doxa_wagf_uid', 'imb_uid', 'imb_peid', 'imb_pgid',
|
||||
'joshua_project_id', 'imb_affinity_code',
|
||||
// place
|
||||
'latitude', 'longitude', 'country_code', 'region', 'wagf_region', 'wagf_block', 'wagf_member',
|
||||
'imb_subregion', 'location_description',
|
||||
// people / language
|
||||
'rop1', 'imb_reg_of_people_2', 'imb_reg_of_people_25', 'imb_reg_of_people_3',
|
||||
'primary_language', 'imb_language_family', 'imb_language_class', 'imb_language_speakers',
|
||||
'work_in_local_language', 'imb_is_indigenous',
|
||||
// religion
|
||||
'religion', 'imb_reg_of_religion_3', 'imb_reg_of_religion_4',
|
||||
// population / believers
|
||||
'population', 'imb_population_class', 'believers_count', 'evangelical_pct',
|
||||
'imb_evangelical_level', 'imb_congregation_existing',
|
||||
// prayer + engagement
|
||||
'people_praying', 'people_committed', 'committed_duration', 'adopted_by_churches',
|
||||
'adopted_by_count', 'adopted_by_names', 'engagement_status', 'status', 'reason_engaged',
|
||||
'reason_unlisted', 'global_start_date', 'workers_long_term', 'imb_church_planting',
|
||||
'disciple_and_church_multiplication',
|
||||
// scripture + media resources
|
||||
'imb_bible_available', 'imb_bible_translation_level', 'imb_bible_year_published',
|
||||
'imb_bible_stories_available', 'imb_audio_scripture_available', 'imb_gospel_recordings_available',
|
||||
'imb_jesus_film_available', 'imb_radio_broadcast_available', 'imb_total_resources_available',
|
||||
// priority indices
|
||||
'imb_gsec', 'imb_lostness_priority_index', 'imb_strategic_priority_index',
|
||||
// media / description
|
||||
'has_photo', 'image_url', 'picture_credit', 'imb_people_description',
|
||||
]
|
||||
|
||||
/**
|
||||
* @param {(facetKey:string, value:*) => *} [labelFor] display-only projection for the provenance
|
||||
* stamp. The People facets key by CODE (dashboard.config.js:54-57), so without it the header
|
||||
* reads `affinity_bloc=A012` — a code on a user-facing surface, the same bug the filter chip had.
|
||||
* Optional: omitted, the stamp falls back to the raw value, so existing callers keep working.
|
||||
* It is applied to the COMMENT LINE ONLY — never to a data column (see buildCsv).
|
||||
*/
|
||||
export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_ACC, dataOpts = {}, labelFor = null,
|
||||
// What this export calls itself, and who to credit for the data. Defaults keep every existing
|
||||
// caller's file identical to the byte.
|
||||
exportLabel = 'Research Dashboard', sources = '',
|
||||
// Defaulted so a caller that predates D1 keeps working exactly as before rather than throwing.
|
||||
rowMatches = (r, ACC) => Object.keys(filterSet).every((k) => { const a = ACC[k]; return a ? a(r) === filterSet[k] : true }) }) {
|
||||
// slug -> wide API record, filled by the lazy all-fields fetch below.
|
||||
let wideStore = null
|
||||
let widePromise = null
|
||||
// The map's people-group LIST (library/lists/). Every caller runs in setup, where inject works.
|
||||
let profileConfig = dataOpts.profileConfig || null
|
||||
if (!profileConfig) { try { profileConfig = inject('profileConfig', null) } catch (_) { /* outside setup */ } }
|
||||
|
||||
/**
|
||||
* Fetch the FULL field set for every row, once per session. Same endpoint + base-URL
|
||||
* resolution the row store uses (shared sources.json / getApiBaseUrl), only with an explicit
|
||||
* `fields=` covering all of EXPORT_FIELDS instead of the render-sized defaultFields.
|
||||
*/
|
||||
function fetchWideRows() {
|
||||
if (widePromise) return widePromise
|
||||
widePromise = (async () => {
|
||||
const sourceId = dataOpts.source || sourcesConfig.activeSource
|
||||
const src = sourcesConfig?.sources?.[sourceId] || {}
|
||||
const fields = (Array.isArray(src.exportFields) && src.exportFields.length) ? src.exportFields : EXPORT_FIELDS
|
||||
const bulk = src.endpoints?.bulk || '/api/people-groups/list'
|
||||
const lang = src.queryParams?.lang || 'en'
|
||||
const url = `${getApiBaseUrl()}${bulk}?fields=${fields.join(',')}&lang=${lang}`
|
||||
const res = await fetch(url, { cache: 'no-store' })
|
||||
if (!res.ok) throw new Error(`export list HTTP ${res.status}`)
|
||||
const json = await res.json()
|
||||
// Only the groups of this page's people-group LIST (library/lists/) are exported.
|
||||
const raw = await applyPeopleGroupList(Array.isArray(json) ? json : (json.posts || json.data || json.results || []), profileConfig)
|
||||
const map = new Map()
|
||||
for (const r of raw) { const k = r && (r.slug || r.id); if (k) map.set(k, r) }
|
||||
wideStore = map
|
||||
return map
|
||||
})().catch((e) => { widePromise = null; throw e }) // let a later click retry
|
||||
return widePromise
|
||||
}
|
||||
|
||||
// Swap a loaded (narrow) row for its wide twin when the all-fields fetch has landed.
|
||||
function widen(r) {
|
||||
if (!wideStore) return r
|
||||
const w = wideStore.get(r && (r.slug || r.id))
|
||||
return w ? { ...r, ...w } : r
|
||||
}
|
||||
|
||||
function pickRows() {
|
||||
if (selectionSet && selectionSet.size) {
|
||||
return [...selectionSet].map((s) => rowStore.value.get(s)).filter(Boolean)
|
||||
}
|
||||
const keys = Object.keys(filterSet)
|
||||
if (keys.length) {
|
||||
// The bus's own predicate, handed in by the shell — the exporter must select the same rows
|
||||
// the reader is looking at, and a private copy of the rule is how an export starts
|
||||
// disagreeing with the screen it was taken from.
|
||||
return (rows.value || []).filter((r) => rowMatches(r, FACET_ACC))
|
||||
}
|
||||
return rows.value || []
|
||||
}
|
||||
function scopeLabel() {
|
||||
if (selectionSet && selectionSet.size) return `selection (${selectionSet.size})`
|
||||
return Object.keys(filterSet).length ? 'filter result' : 'all'
|
||||
}
|
||||
// {value,label} -> label; arrays -> ' | '-joined cell (picture_credit / adopted_by_names arrive as
|
||||
// arrays of objects, so a bare JSON.stringify would dump unreadable braces into the sheet).
|
||||
function cell(v) {
|
||||
if (v === null || v === undefined) return v
|
||||
if (Array.isArray(v)) return v.map((x) => cell(x)).filter((x) => x !== '' && x !== null && x !== undefined).join(' | ')
|
||||
// `link` last: a picture_credit entry can be {link,text} with a null text — fall through to the
|
||||
// URL rather than dumping raw JSON braces into the cell.
|
||||
if (typeof v === 'object') return v.label ?? v.value ?? v.text ?? v.name ?? v.link ?? JSON.stringify(v)
|
||||
return v
|
||||
}
|
||||
function flatten(r) {
|
||||
const o = {}
|
||||
for (const k of Object.keys(r)) o[k] = cell(r[k])
|
||||
return o
|
||||
}
|
||||
// Quote if the field has , " CR LF; double internal quotes; neutralize leading = + - @ (CSV injection).
|
||||
function esc(v) {
|
||||
let s = v === null || v === undefined ? '' : String(v)
|
||||
if (/^[=+\-@]/.test(s)) s = "'" + s
|
||||
if (/[",\n\r]/.test(s)) s = '"' + s.replace(/"/g, '""') + '"'
|
||||
return s
|
||||
}
|
||||
function buildCsv() {
|
||||
const picked = pickRows().map(widen).map(flatten)
|
||||
// Column order: the canonical EXPORT_FIELDS order first (so the sheet reads identity → place →
|
||||
// people → engagement → resources), then any key the API returned that the list doesn't name.
|
||||
// Union across rows, because a null-valued field can be absent on an individual record.
|
||||
const present = picked.reduce((set, r) => { Object.keys(r).forEach((k) => set.add(k)); return set }, new Set())
|
||||
const cols = [...EXPORT_FIELDS.filter((k) => present.has(k)), ...[...present].filter((k) => !EXPORT_FIELDS.includes(k))]
|
||||
// Provenance stamp — a HUMAN-READABLE COMMENT line, so People codes are resolved to names
|
||||
// here exactly as the filter chips do. This is the ONLY place labelFor() is applied: the data
|
||||
// columns below are built from the API record via flatten()/cell() and are untouched, because
|
||||
// a consumer's formulas and downstream joins key on whatever those columns already emit.
|
||||
const chips = Object.entries(filterSet)
|
||||
.map(([k, v]) => `${k}=${labelFor ? labelFor(k, v) : v}`)
|
||||
.join(' | ') || 'none'
|
||||
// ★ THE PROVENANCE LINE IS PARAMETERISED (dashboard-kit landing 3), and it is the one piece of
|
||||
// this exporter that was never generic. "Research Dashboard" is this dashboard's name, and the
|
||||
// line said nothing about WHERE the data came from — which is the thing a provenance line is for.
|
||||
// A partner opening a CSV can see the scope and the filters and still not know who to cite.
|
||||
// `sources` is appended only when a caller supplies it, so today's output is byte-for-byte
|
||||
// unchanged; go-int's spec asks for exactly this (its tables carry their own meta.sources).
|
||||
const provenance = `# ${exportLabel} export — scope: ${scopeLabel()}; filters: ${chips}; rows: ${picked.length}`
|
||||
+ (sources ? `; sources: ${sources}` : '')
|
||||
// ★ THE PROVENANCE LINE GOES LAST, NOT FIRST — that one position was the whole "glitched CSV".
|
||||
// Driver-reported 2026-09-22: "this is me exporting the CSV directly without making any edits...
|
||||
// it does look a little bit glitched out."
|
||||
//
|
||||
// MEASURED with a strict RFC-4180 parser against this exact generator. EVERYTHING ELSE IS CORRECT
|
||||
// and must not be "fixed" on suspicion:
|
||||
// line 0 ... 1 column "# Research Dashboard export — scope: ..."
|
||||
// line 1 ... N columns slug,name,religion,...
|
||||
// rows ... N columns each, ZERO with a wrong column count
|
||||
// embedded commas, embedded double quotes AND embedded newlines all survive the round trip, so
|
||||
// esc() is genuinely RFC-4180 (quote when , " CR LF appear; double the internal quotes). The
|
||||
// file is comma-separated, not tab-separated — the tabs in the Driver's paste were his terminal
|
||||
// rendering, not the bytes.
|
||||
//
|
||||
// The defect is purely POSITIONAL: every spreadsheet importer treats the FIRST line as the header.
|
||||
// So the scope sentence became the column names, the real header became data row 1, and a 1-column
|
||||
// line above a 68-column table is exactly what "glitched" looks like on screen.
|
||||
//
|
||||
// Moving it BELOW the data keeps the provenance — which is deliberate and load-bearing, since it
|
||||
// records WHICH FILTERS produced the file, the difference between a dataset and an anecdote —
|
||||
// while making line 1 the header. A trailing 1-column line lands as a final row with one populated
|
||||
// cell: obvious to a human, harmless to a formula, and it cannot destroy the column mapping.
|
||||
const lines = [cols.map(esc).join(','), ...picked.map((r) => cols.map((c) => esc(r[c])).join(',')), provenance]
|
||||
return lines.join('\r\n')
|
||||
}
|
||||
// Last-export status for UI feedback (toast). The platform gem-frame iframe is sandboxed WITHOUT
|
||||
// `allow-downloads`, so the native <a download> is silently blocked there — CLIPBOARD is the robust
|
||||
// primary path (the iframe `allow`s clipboard-write); native download still works outside a sandbox.
|
||||
const lastExport = { rows: 0, clipboard: false, downloaded: false, at: 0, fields: 0, wide: false }
|
||||
function exportCsv() {
|
||||
const csv = buildCsv()
|
||||
const rows = pickRows().length
|
||||
// The header is now line 1 — the '#' provenance comment moved to the END of the file so that a
|
||||
// spreadsheet reads the real column row first (see buildCsv). This index had to move WITH it:
|
||||
// reading line 2 would have counted the columns of DATA ROW 1, which is usually the same number,
|
||||
// so it would have looked correct and been wrong — the quiet kind of breakage a positional change
|
||||
// leaves behind.
|
||||
const fields = (csv.split('\r\n')[0] || '').split(',').length
|
||||
try { if (typeof window !== 'undefined') window.__rdashLastCsv = csv } catch (_) { /* verify hook */ }
|
||||
let clipboard = false, downloaded = false
|
||||
// 1) clipboard — works inside the sandboxed gem-frame iframe (the guaranteed path)
|
||||
try { if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(csv).catch(() => {}); clipboard = true } } catch (_) { /* no clipboard */ }
|
||||
// 2) native file download — works when NOT in a download-blocked sandbox
|
||||
try {
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a'); a.href = url; a.download = 'research-dashboard.csv'; a.style.display = 'none'
|
||||
document.body.appendChild(a); a.click(); downloaded = true
|
||||
setTimeout(() => { try { URL.revokeObjectURL(url); a.remove() } catch (_) {} }, 200)
|
||||
} catch (_) { /* download blocked — clipboard above already has it */ }
|
||||
Object.assign(lastExport, { rows, clipboard, downloaded, fields, wide: !!wideStore, at: (typeof performance !== 'undefined' ? performance.now() : 0) })
|
||||
try { window.__rdashExportStatus = { rows, clipboard, downloaded, fields, wide: !!wideStore } } catch (_) {}
|
||||
return { csv, rows, clipboard, downloaded, fields, wide: !!wideStore }
|
||||
}
|
||||
|
||||
/**
|
||||
* The UI entry point: guarantee the ALL-FIELDS record before writing the file.
|
||||
*
|
||||
* Why this is async but exportCsv() stays sync — navigator.clipboard.writeText() needs the
|
||||
* TRANSIENT USER ACTIVATION of the click, and awaiting a network round-trip spends it. So the
|
||||
* first click on a cold cache resolves the wide rows and then writes: the download path always
|
||||
* works, and the clipboard path is re-attempted. Once cached (every later click) the await
|
||||
* settles in a microtask, activation survives, and clipboard works as before.
|
||||
* A failed fetch still exports the loaded 24-field record — degraded, never nothing.
|
||||
*/
|
||||
async function exportCsvAsync() {
|
||||
try { await fetchWideRows() } catch (_) { /* fall back to the loaded record */ }
|
||||
return exportCsv()
|
||||
}
|
||||
|
||||
// Let the UI warm the cache (e.g. on hover/mount) so the first real click is already wide.
|
||||
function prefetchExportFields() { try { return fetchWideRows() } catch (_) { return null } }
|
||||
|
||||
return { exportCsv, exportCsvAsync, buildCsv, pickRows, scopeLabel, lastExport, prefetchExportFields, EXPORT_FIELDS }
|
||||
}
|
||||
272
library/composables/useSelectionCamera.js
Normal file
272
library/composables/useSelectionCamera.js
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
/**
|
||||
* useSelectionCamera.js — "select rows in the table, the map zooms to that selection."
|
||||
*
|
||||
* Subscribes to the selection bus and drives the map camera ONCE per committed selection
|
||||
* change. The table never talks to the map: it mutates `bus.selectionSet` and bumps
|
||||
* `bus.selectionVersion`; this composable watches that version. That is exactly the
|
||||
* decoupling the bus exists for — the table panel owns no camera code, and this file owns
|
||||
* no table code.
|
||||
*
|
||||
* ── MOTION SAFETY (BUG-RESIZE-STROBE class; the Driver is photosensitive) ──────────────
|
||||
* The strobe bug fixed in @map/composables/useMapInstance.js was caused by a resize handler
|
||||
* re-snapping the camera on EVERY frame at 60fps. The rule learned there is applied here:
|
||||
*
|
||||
* 1. ONE animation per committed selection change — never per frame, never per row.
|
||||
* `selectionVersion` is bumped once per BATCH (useSelectionBus's performance contract),
|
||||
* so selecting 2,107 rows emits ONE version bump, not 2,107.
|
||||
* 2. DEBOUNCED (CAMERA_DEBOUNCE_MS). Rapid shift+click range-dragging bumps the version
|
||||
* many times in a few hundred ms; only the final state moves the camera. Each new bump
|
||||
* CANCELS the pending move, so intermediate states are never animated.
|
||||
* 3. REDUCED MOTION — when the user asks for less motion we JUMP (duration 0) instead of
|
||||
* easing. Same destination, zero traversal, no optical flow across the viewport.
|
||||
* 4. NOTHING here runs in a render path. The bbox is computed inside the debounced
|
||||
* callback, i.e. at most once per settled selection, never inside a computed/watcher
|
||||
* that the map redraw depends on.
|
||||
*
|
||||
* ── COST ──────────────────────────────────────────────────────────────────────────────
|
||||
* One O(n) pass over the selected rows per settled change (n = selection size), using the
|
||||
* slug-keyed rowStore for O(1) lookups rather than re-scanning all 2,107 rows per slug.
|
||||
* Selecting everything is a single ~2,107-element numeric pass — sub-millisecond — and it
|
||||
* happens once, after the debounce, off the render path.
|
||||
*/
|
||||
import { watch } from 'vue'
|
||||
|
||||
// ── Camera policy constants ───────────────────────────────────────────────────────────
|
||||
// Debounce window. Long enough to swallow a shift+click range drag or a "select all"
|
||||
// cascade, short enough that a single checkbox click still feels immediate.
|
||||
const CAMERA_DEBOUNCE_MS = 150
|
||||
// One eased move. Matches the panel's existing fitBounds duration so the selection camera
|
||||
// and the filter camera feel like the same gesture.
|
||||
const FLY_DURATION_MS = 1200
|
||||
// Viewport padding so selected pins never sit flush against the panel edge.
|
||||
const FIT_PADDING_PX = 70
|
||||
// Ceiling for a MULTI-row fit. Without it, two rows in the same city fit at street level.
|
||||
const FIT_MAX_ZOOM = 5.5
|
||||
// A SINGLE selected row has a degenerate (zero-area) bbox — fitBounds would resolve it at
|
||||
// maxZoom, slamming the camera to street level for a people-group whose coordinate is a
|
||||
// country-scale centroid. Fly to the point at a deliberately regional zoom instead.
|
||||
const SINGLE_ROW_ZOOM = 6
|
||||
// Degenerate-bbox guard for the multi-row path: several rows can share one coordinate (or
|
||||
// sit within metres of each other), which is a zero-area bbox by another route. Below this
|
||||
// span we treat the cluster as a single point and use SINGLE_ROW_ZOOM.
|
||||
const DEGENERATE_SPAN_DEG = 0.01
|
||||
// Past this longitude span a selection effectively wraps the globe; framing it literally
|
||||
// makes the camera animate the wrong way around the antimeridian. Ease to a world overview
|
||||
// instead. Tuned to match the LEGEND_WIDE_SPAN_DEG threshold in @map/composables/useMapFly.js.
|
||||
const WIDE_SPAN_DEG = 110
|
||||
const WORLD_ZOOM = 1.45 // the panel's own initial zoom — a stable, familiar overview
|
||||
|
||||
/**
|
||||
* ★ THE CAMERA'S POLICY IN ONE NAMED PLACE (dashboard-kit landing 3).
|
||||
*
|
||||
* The eight constants above are not magic numbers; together they are a decision about how this
|
||||
* camera behaves, and the reason for each is in the comment beside it. Collecting them here is what
|
||||
* the kit spec asked for, and the failure it prevents is specific: a second dashboard re-picking
|
||||
* them one at a time, arriving at a camera that feels different from Big Picture's for no reason
|
||||
* anybody wrote down. A consumer that cites CAMERA_POLICY.FLY_DURATION_MS can be asked why it
|
||||
* differs; a consumer that retypes 1200 has made an invisible decision.
|
||||
*
|
||||
* ★ READ-ONLY, AND DELIBERATELY NOT YET OVERRIDABLE. Making each value a per-caller option means
|
||||
* threading eight of them through every use, and no consumer has asked for a different camera.
|
||||
* Exporting the decision so it can be READ and CITED is the half that has a use today; the override
|
||||
* belongs with the first consumer that needs one, who will also be able to say which value and why.
|
||||
* Saying that here is better than shipping eight options nobody passes.
|
||||
*/
|
||||
export const CAMERA_POLICY = Object.freeze({
|
||||
CAMERA_DEBOUNCE_MS, FLY_DURATION_MS, FIT_PADDING_PX, FIT_MAX_ZOOM,
|
||||
SINGLE_ROW_ZOOM, DEGENERATE_SPAN_DEG, WIDE_SPAN_DEG, WORLD_ZOOM,
|
||||
})
|
||||
|
||||
/** True when the user has asked the OS for reduced motion. Read live (never cached): the
|
||||
* preference can be toggled mid-session, and a stale `false` would animate at them. */
|
||||
function prefersReducedMotion() {
|
||||
try {
|
||||
return typeof window !== 'undefined'
|
||||
&& typeof window.matchMedia === 'function'
|
||||
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
} catch (_) { return false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Numeric coordinate pair for a row, or null when unusable.
|
||||
*
|
||||
* The live API returns coordinates as STRINGS ("5.68736400", verified against 2,107 live
|
||||
* records on pray.doxa.life), so every read must go through Number() — a raw string compare
|
||||
* would order "9.5" above "78.2" lexically and silently produce a wrong bbox.
|
||||
*
|
||||
* Number.isFinite (not truthiness): `Number(r.latitude) && …` rejects a legitimate 0°
|
||||
* coordinate. We accept 0 per-axis and reject only the exact 0,0 pair — the null-island
|
||||
* sentinel that a missing geocode degrades into. Flying there would show the camera an
|
||||
* empty patch of the Gulf of Guinea.
|
||||
*/
|
||||
// ★ HOW a row carries its coordinate is a parameter; WHAT makes a coordinate usable is not.
|
||||
// `row.longitude` / `row.latitude` is pray-tools' spelling, not a universal one — the next
|
||||
// dashboard's rows put the same idea under different keys. So only the READ is configurable, and
|
||||
// every guard below it (Number() on API strings, 0 accepted per-axis, the exact 0,0 null-island
|
||||
// sentinel rejected, the out-of-range check) applies to every consumer. A kit that let each caller
|
||||
// re-implement the validation would hand the next dashboard the same four bugs to rediscover.
|
||||
const DEFAULT_COORD_READ = (row) => [row.longitude, row.latitude]
|
||||
function coordsOf(row, readPair = DEFAULT_COORD_READ) {
|
||||
if (!row) return null
|
||||
const pair = readPair(row) || []
|
||||
const lng = Number(pair[0])
|
||||
const lat = Number(pair[1])
|
||||
if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null
|
||||
if (lng === 0 && lat === 0) return null // null island — a missing geocode, not a place
|
||||
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return null // out-of-range guard
|
||||
return [lng, lat]
|
||||
}
|
||||
|
||||
/**
|
||||
* Antimeridian-aware longitude extent.
|
||||
*
|
||||
* A Pacific selection holds longitudes near -176 and +178 (both present in the live data:
|
||||
* the full range is -176.18 .. +178.48). Naive min/max calls that a ~355° span and frames
|
||||
* the whole globe — or worse, animates the long way round. Re-projecting into [0,360) makes
|
||||
* the same set contiguous with a ~6° span. We compute both and keep the tighter one.
|
||||
*
|
||||
* Returns { minLng, maxLng, span }; maxLng may exceed 180, which Mapbox's fitBounds accepts
|
||||
* and wraps correctly.
|
||||
*/
|
||||
function lngExtent(longitudes) {
|
||||
let aMin = Infinity, aMax = -Infinity // raw [-180, 180]
|
||||
let bMin = Infinity, bMax = -Infinity // shifted [0, 360)
|
||||
for (const lng of longitudes) {
|
||||
if (lng < aMin) aMin = lng
|
||||
if (lng > aMax) aMax = lng
|
||||
const shifted = lng < 0 ? lng + 360 : lng
|
||||
if (shifted < bMin) bMin = shifted
|
||||
if (shifted > bMax) bMax = shifted
|
||||
}
|
||||
if (!Number.isFinite(aMin)) return null
|
||||
const spanA = aMax - aMin
|
||||
const spanB = bMax - bMin
|
||||
if (spanB < spanA) {
|
||||
const minRaw = bMin > 180 ? bMin - 360 : bMin
|
||||
return { minLng: minRaw, maxLng: minRaw + spanB, span: spanB }
|
||||
}
|
||||
return { minLng: aMin, maxLng: aMax, span: spanA }
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounding box of the selected rows.
|
||||
*
|
||||
* Resolves each slug through the slug-keyed rowStore (O(1)) instead of filtering the full
|
||||
* row array per slug, so a 2,107-row selection stays one linear pass.
|
||||
*
|
||||
* @returns {null | { kind:'point', center:[lng,lat] }
|
||||
* | { kind:'bounds', bounds:[[w,s],[e,n]], span:number }}
|
||||
* null when NOTHING is selected or no selected row has usable coordinates —
|
||||
* the caller must then leave the camera alone.
|
||||
*/
|
||||
export function selectionBounds(selectedSlugs, rowStore, readPair = DEFAULT_COORD_READ) {
|
||||
if (!selectedSlugs || !selectedSlugs.size || !rowStore) return null
|
||||
|
||||
const lngs = []
|
||||
let minLat = Infinity, maxLat = -Infinity
|
||||
let first = null
|
||||
let count = 0
|
||||
|
||||
for (const slug of selectedSlugs) {
|
||||
const c = coordsOf(rowStore.get(slug), readPair)
|
||||
if (!c) continue // missing/null/invalid coords — skip the row
|
||||
if (count === 0) first = c
|
||||
lngs.push(c[0])
|
||||
if (c[1] < minLat) minLat = c[1]
|
||||
if (c[1] > maxLat) maxLat = c[1]
|
||||
count++
|
||||
}
|
||||
|
||||
// Every selected row lacked coordinates (or the selection is empty) — no camera move.
|
||||
if (count === 0) return null
|
||||
if (count === 1) return { kind: 'point', center: first }
|
||||
|
||||
const ext = lngExtent(lngs)
|
||||
if (!ext) return { kind: 'point', center: first }
|
||||
|
||||
// Co-located cluster: a zero-area bbox by another route. fitBounds would hit maxZoom.
|
||||
if (ext.span < DEGENERATE_SPAN_DEG && (maxLat - minLat) < DEGENERATE_SPAN_DEG) {
|
||||
return { kind: 'point', center: [(ext.minLng + ext.maxLng) / 2, (minLat + maxLat) / 2] }
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'bounds',
|
||||
bounds: [[ext.minLng, minLat], [ext.maxLng, maxLat]],
|
||||
span: ext.span,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire selection -> camera. Call once from the map panel's setup.
|
||||
*
|
||||
* @param {Object} o
|
||||
* @param {Object} o.bus - the selection bus (selectionSet + selectionVersion)
|
||||
* @param {Function} o.getMap - () => the live map instance (null before ready)
|
||||
* @param {Function} o.getRowStore - () => Map<slug, row>
|
||||
* @param {Object} o.mapFly - a useMapFly instance (shared library helper)
|
||||
* @param {Function} [o.isEnabled] - () => boolean; skip while false (e.g. map not ready)
|
||||
* @returns {Function} teardown — clears the pending timer and stops the watcher
|
||||
*/
|
||||
export function useSelectionCamera({ bus, getMap, getRowStore, mapFly, isEnabled = () => true,
|
||||
// `coords` is the one data-shape assumption this file makes. Default = the fields
|
||||
// research-dashboard has always used, so a caller that passes nothing is byte-for-byte unchanged.
|
||||
coords = DEFAULT_COORD_READ }) {
|
||||
if (!bus || !bus.selectionVersion) return () => {}
|
||||
|
||||
let timer = null
|
||||
|
||||
function moveCamera() {
|
||||
timer = null
|
||||
const map = getMap && getMap()
|
||||
if (!map || !isEnabled()) return
|
||||
|
||||
// ★ `coords` IS PASSED HERE, and leaving it out was a real bug I nearly shipped: the option was
|
||||
// accepted in the signature and never reached selectionBounds, so a caller could set it, type-
|
||||
// check clean, build clean, and watch it do nothing. That is the presence-vs-reachability trap
|
||||
// this tree has paid for before (a defineProps on a component the custom element never binds).
|
||||
// An option that cannot be observed to work is worse than no option: it reads as supported.
|
||||
const target = selectionBounds(bus.selectionSet, getRowStore && getRowStore(), coords)
|
||||
// EDGE CASE — selection cleared, or no selected row has coordinates: hold position.
|
||||
// Deliberately NOT a reset-to-world: an unrequested snap-back is both jarring and, for
|
||||
// a photosensitive user, motion they did not ask for. Deselecting is not a navigation.
|
||||
if (!target) return
|
||||
|
||||
// Reduced motion: same destination, zero traversal.
|
||||
const duration = prefersReducedMotion() ? 0 : FLY_DURATION_MS
|
||||
|
||||
try {
|
||||
if (target.kind === 'point') {
|
||||
// Single row (or a co-located cluster). fitBounds on a degenerate bbox resolves at
|
||||
// maxZoom; a fixed regional zoom is the honest framing for a country-scale centroid.
|
||||
mapFly.flyTo(target.center, { zoom: SINGLE_ROW_ZOOM, duration, essential: true })
|
||||
return
|
||||
}
|
||||
if (target.span > WIDE_SPAN_DEG) {
|
||||
// Globe-spanning selection (e.g. "select all"). Framing it literally makes the
|
||||
// camera swing the wrong way around the antimeridian — the visible "snap".
|
||||
mapFly.flyTo(
|
||||
[(target.bounds[0][0] + target.bounds[1][0]) / 2,
|
||||
(target.bounds[0][1] + target.bounds[1][1]) / 2],
|
||||
{ zoom: WORLD_ZOOM, duration, essential: true },
|
||||
)
|
||||
return
|
||||
}
|
||||
mapFly.fitBounds(target.bounds, FIT_PADDING_PX, {
|
||||
maxZoom: FIT_MAX_ZOOM, duration, essential: true,
|
||||
})
|
||||
} catch (_) { /* fitBounds throws on degenerate bounds — never break the dashboard */ }
|
||||
}
|
||||
|
||||
// ONE watcher on ONE reactive handle. `selectionVersion` is bumped once per committed
|
||||
// batch (see useSelectionBus's performance contract), so a 2,107-row "select all" runs
|
||||
// this callback ONCE — and the debounce collapses even that into a single camera move.
|
||||
const stop = watch(() => bus.selectionVersion.value, () => {
|
||||
if (timer) clearTimeout(timer) // a newer selection supersedes the pending move
|
||||
timer = setTimeout(moveCamera, CAMERA_DEBOUNCE_MS)
|
||||
})
|
||||
|
||||
return function teardown() {
|
||||
if (timer) { clearTimeout(timer); timer = null }
|
||||
try { stop() } catch (_) {}
|
||||
}
|
||||
}
|
||||
48
library/composables/useTextHighlight.js
Normal file
48
library/composables/useTextHighlight.js
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* highlightRanges(value, indices) — wrap each matched index range in
|
||||
* <span class="search-highlight">, escaping every segment around it.
|
||||
*
|
||||
* WHY THIS IS SHARED. Two plugins built the same construction independently, line for line apart from
|
||||
* variable names: regions-dashboard/src/UupgsList.vue and people-groups-grid/people-groups-grid.vue.
|
||||
* Both consume the same Fuse-compatible shim (library/components/search/fuzzy.js), both read
|
||||
* `match.indices`, both wrap with the same class, and both append the tail only on the last range.
|
||||
* RULE 2 is satisfied by a NAMED second consumer, not by a guess that one might appear.
|
||||
*
|
||||
* The construction is dx-life's (their UupgsList.vue:336-348) and is ported rather than invented: walk
|
||||
* the ranges in order, escape the gap before each hit, wrap the hit, and append the remaining tail once
|
||||
* at the end. Indices come from the SEARCHED corpus (which may be accent-stripped) and are applied to
|
||||
* the ORIGINAL string, which is why the caller passes both rather than re-deriving one from the other.
|
||||
*
|
||||
* ★ ESCAPING COMES FROM library/utils/html.js, AND THAT IS A DELIBERATE CHANGE FOR BOTH CALLERS.
|
||||
* Each plugin had hand-rolled its own escapeHtml, and the three differed in ways worth naming:
|
||||
* library/utils/html.js escapes & < > " ' AND backtick, and returns '' for null/undefined
|
||||
* UupgsList escapes & < > " ' (no backtick), guards null via String(v ?? '')
|
||||
* people-groups-grid escapes & < > " ' (no backtick), and has NO NULL GUARD AT ALL —
|
||||
* `s.replace(...)` on null/undefined THROWS
|
||||
* Both deltas move in the safe direction: one more character escaped, and a throw removed. Neither
|
||||
* changes the output for any value that renders today. Stated here because "I swapped your escaper"
|
||||
* is exactly the kind of quiet substitution that should never be discovered by a reader later.
|
||||
*
|
||||
* @param {string} value the ORIGINAL text to render
|
||||
* @param {Array<[number, number]>} indices inclusive [start, end] pairs, in order
|
||||
* @returns {string} HTML: escaped text with <span class="search-highlight"> around each range
|
||||
*/
|
||||
import { escapeHtml } from '../utils/html.js'
|
||||
|
||||
export function highlightRanges(value, indices) {
|
||||
const original = String(value ?? '')
|
||||
if (!Array.isArray(indices) || indices.length === 0) return escapeHtml(original)
|
||||
let cursor = 0
|
||||
let out = ''
|
||||
indices.forEach((idx, i) => {
|
||||
const [start, end] = idx
|
||||
const isLast = i === indices.length - 1
|
||||
out += escapeHtml(original.slice(cursor, start))
|
||||
+ '<span class="search-highlight">' + escapeHtml(original.slice(start, end + 1)) + '</span>'
|
||||
+ (isLast ? escapeHtml(original.slice(end + 1)) : '')
|
||||
cursor = end + 1
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
export default highlightRanges
|
||||
|
|
@ -105,6 +105,7 @@
|
|||
"check:i18n-never-translated": "node internal/check-i18n-never-translated.mjs",
|
||||
"check:i18n-parity": "node internal/check-i18n-parity.mjs",
|
||||
"check:i18n-treeshake": "node internal/check-i18n-treeshake.mjs",
|
||||
"check:kit-options-reachable": "node plugin-designs/research-dashboard/scripts/check-kit-options-reachable.mjs",
|
||||
"check:kpi-unit": "node internal/check-kpi-unit-once.mjs",
|
||||
"check:label-joins": "node internal/check-label-joins.mjs",
|
||||
"check:language-code-link": "node plugin-designs/doxa-research-map/scripts/check-language-code-link.mjs",
|
||||
|
|
@ -210,6 +211,7 @@
|
|||
"check:synced-css:self-test": "node internal/check-synced-css-copies.mjs --self-test",
|
||||
"check:tab-radius": "node internal/check-tab-radius-token.mjs",
|
||||
"check:tab-radius:self-test": "node internal/check-tab-radius-token.mjs --self-test",
|
||||
"check:text-highlight": "node plugin-designs/regions-dashboard/scripts/check-text-highlight.mjs",
|
||||
"check:tabs-localized": "node internal/check-tabs-localized.mjs",
|
||||
"check:tauri": "node internal/check-tauri-config.mjs",
|
||||
"check:template-literal": "node internal/template-literal-check.mjs",
|
||||
|
|
@ -225,7 +227,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 check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence",
|
||||
"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 check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight",
|
||||
"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",
|
||||
|
|
@ -331,6 +333,7 @@
|
|||
"check:i18n-keys-exist": "every literal catalogue key named in code exists in library/i18n/locales/en/. A localized CALL SITE is not a localized STRING: the dashboard's five resize handles all read T('aria.resizeFiltersRail', ...) and look correct in review, while aria.json held eleven keys and not one was a resize key - so every handle announced itself in English in all eleven locales, and the fallback that makes a missing key degrade to readable English is exactly what kept it silent. No other gate asks this: i18n-parity is satisfied because the key is missing from ALL locales equally, and english-leaks and composite-locale-leaks both need the catalogue to HOLD the string before an appearance counts as a leak. Matches tStatic/$t/t and the house-pattern T( alias, all of which take a full dotted key; prefix helpers like L( and any concatenated or template-literal key are COUNTED as unresolved and never guessed at. Found 22 missing keys on its first run.",
|
||||
"check:i18n-never-translated": "a key PRESENT in all 11 catalogs but IDENTICAL in all of them is copied, not translated. check:i18n-parity asserts presence; presence and translatedness are different properties. Carries an allowlist for keys correctly identical everywhere (pure format strings, brand names, ISO designations) — each with a reason, so the list records decisions rather than silencing findings.",
|
||||
"check:i18n-treeshake": "each built bundle carries every translation area its code asks for: every area-prefixed key literal in <bundle>.js belongs to an area the build included (the build narrows library/i18n's catalogue glob per bundle, internal/i18n-areas.mjs). Headless.",
|
||||
"check:kit-options-reachable": "every option a shared kit composable ACCEPTS must be honoured somewhere in its body. Written because I nearly shipped one: kit landing 3 gave useSelectionCamera a `coords` option so a dashboard whose rows spell their coordinate differently could say so, and it was accepted in the signature and never passed to selectionBounds - a caller could set it, type-check clean, build clean and watch it do nothing. That is the worst shape a missing feature takes, because it READS as supported and nobody files a bug against a knob they assume they misused; this tree already paid for it once with a defineProps on a component the custom element never binds. It matters more for the kit than for one dashboard: the whole promise of SPEC-DASHBOARD-KIT is that a config produces a working dashboard, and a parameter that is merely present turns that promise into a guess. Strips comments from BOTH the signature and the body before searching - the first version was satisfied by the comment beside the fixed call site, and the second read comment prose as option names. Not a type checker: it catches a completely unused option, which is the failure invisible from outside.",
|
||||
"check:language-code-link": "the map's language selection reaches the grid as a locale-stable CODE, or narrows nothing at all. rega measured Languages > Language > \"Arabic\" taking the grid 2,107 -> 0 in BOTH locales: the emitter looked for a row whose language LABEL equalled the selected name, but a language tier's label is derived by splitting the API label at the comma, and no row reads \"Arabic\" - they read \"Arabic, Sudanese\", \"Arabic, Levantine\", 22 codes in all. Lifts the emitter's match rule and the composite's gridLanguage() from the shipping source (never re-typed) and runs them against a committed fixture of the 864 distinct language label/value pairs: the emitter's rule must agree with the legend's own three-arm filter on every (label, tier) pair, every tier must select at least one row, a tier covering one code must emit it, a tier covering several must emit NONE and leave the axis unfiltered. No network, no browser; --refresh-fixture re-takes the fixture.",
|
||||
"check:layout-key-namespaced": "two dashboards on one page must not share a saved layout. useDashboardLayout persists the four divider positions to localStorage, and until kit landing 2 the key was a single constant — invisible while there was exactly one dashboard, and SHARING THE ENGINE IS WHAT MAKES A SECOND ONE POSSIBLE, so SPEC-DASHBOARD-KIT section 8 named it as that landing's risk. The failure is quiet rather than loud: drag a divider in one dashboard, reload, and the other has moved. Asserts the shell passes no bare literal, EVALUATES the real expression with two different dashboard ids to prove the key varies (never a retyped copy of the rule), and that the pre-namespacing key is READ and never written — namespacing must not silently discard a reader's layout, and a migration that also deletes cannot be rolled back. Static; no DOM, no browser.",
|
||||
"check:leaf-engine-per-provider": "every leaf in the manifest at ?provider=maplibre makes ZERO requests to any *.mapbox.com host and a map leaf still draws; at its default every map leaf draws with the engine it drew with before (read from the canvas that drew). --baseline <tree> compares against another tree (e.g. main). Browser gate, @browser-slot-max-hold 2700.",
|
||||
|
|
@ -399,6 +402,7 @@
|
|||
"check:stale-sweep-claim": "a caveat that has stopped being true is a wrong number. The gospel surfaces carry a paragraph saying which pools were searched; while the GRN sweep ran they truthfully said part of it was unread, the sweep finished, and the sentence stayed for four more commits. The DATA decides here: undecided gap codes mean the claim is allowed, all-decided means it must go, so it re-arms itself for the next slow pool. Does NOT flag a claim rendered under a v-if on the pending count — that form expires on its own. Self-test: `--poison`. Static, sub-second.",
|
||||
"check:surfaces-read-the-tier": "a surface may READ the tier the pin index computed, never re-derive 'this group has nothing' from the raw resource fields. That hand-rolled field list went stale every time a source was added: 7 codes drawn as having nothing while the map showed them a link, then 28 when Scripture Earth landed. Static, sub-second. Self-test: `bun run check:surfaces-read-the-tier:poison` re-injects the old formula into every surface and each must be caught.",
|
||||
"check:tabs-localized": "a chosen locale must CHANGE the rendered tab text. check:i18n-parity compares catalogs to each OTHER, so it cannot tell a shipped translation from a dead one; tabs.json is reached only via a DYNAMIC key (SemanticTreeLegend.vue:40), invisible to any grep. Renders it and reads the result.",
|
||||
"check:text-highlight": "useTextHighlight (library/composables) matches the inline search-highlight construction it replaced in regions-dashboard UupgsList and people-groups-grid across six range shapes, and pins the two deliberate escaping deltas (backtick escaped; null returns '' instead of throwing) with controls proving each is real.",
|
||||
"check:tree-weight": "the tree we publish has a weight and nothing was watching it. A publish was ALREADY BLOCKED once at a 74MB asset tree (Forgejo rejected the pack); it has since grown to 132MB unnoticed. Reports the SPLIT, because a bare total invites deleting the wrong half: the 176 city posters are the heavy half AND the half that cannot be regenerated (they need OSM tiles). Raw bytes, not packed — see publish-html.mjs for why a raw saving is not a shipped one.",
|
||||
"check:upg-map-data": "the UPG-100 map must carry OUR polygons/zones/pins, not just a basemap — reads the counter the profile itself writes. Pass live URLs to audit what is published.",
|
||||
"check:url-resolver": "cap-5 resolve() must not prefix a rooted URL (the dev-surface 404 class)",
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@
|
|||
* answers. A slow or unreachable API costs the black layer, never the map.
|
||||
*/
|
||||
import { inject, ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useOptionalI18n } from '@map/composables/useOptionalT.js'
|
||||
import { useMapInstance, engineGlobal } from '@map/composables/useMapInstance.js'
|
||||
import { resolveMapProvider, setProjectionOn } from '@map/composables/useMapProvider.js'
|
||||
import { useMapData } from '@map/composables/useMapData.js'
|
||||
|
|
@ -108,18 +108,26 @@ const EN = { title: 'All People Groups', locations: '{n} group–country locatio
|
|||
frontier: 'Frontier — nothing started', unreached: 'Unreached — work beginning', reached: 'Reached — church is there',
|
||||
hint: 'Click a row to see all of them · click again for everything', loading: 'Loading every people group…',
|
||||
loadError: 'The map could not be loaded.', pray: 'Pray for them →', population: 'pop {n}' }
|
||||
let i18n = null
|
||||
try { i18n = useI18n() } catch (_) { /* no i18n on this host: English below */ }
|
||||
function tr(key, vars) {
|
||||
const full = 'allPeopleGroups.' + key
|
||||
// vue-i18n treats {n} as ITS placeholder: pass the values to t(), or it blanks them out before any
|
||||
// later substitution sees them (the legend read "group–country locations" with no number).
|
||||
if (i18n && i18n.te && i18n.te(full)) return i18n.t(full, vars || {})
|
||||
let v = EN[key]
|
||||
if (vars) for (const k in vars) v = String(v).split('{' + k + '}').join(String(vars[k]))
|
||||
return v
|
||||
}
|
||||
const uiLocale = computed(() => (i18n && i18n.locale && i18n.locale.value) || 'en')
|
||||
// C4 wave 3d: the guard is library/composables/useOptionalI18n now — this site needs more than `t`,
|
||||
// so it takes the {t, te, locale} form rather than useOptionalT.
|
||||
// ★ EN STAYS THE FALLBACK, passed in. It is an inline English map, not the catalogue, so routing this
|
||||
// to tStatic would change what a bare host renders. gospel-plugin and exp-shepherd each CHOSE the
|
||||
// locale-honouring default for their own bundles; all-people-groups has no owner to ask
|
||||
// (studio agreed to treat it as unowned), so the conservative reading wins: preserve what it renders.
|
||||
// The keys arrive BARE here ('hint', 'pray'), and qualify() adds the 'allPeopleGroups.' prefix, which
|
||||
// is exactly what the hand-written `const full = 'allPeopleGroups.' + key` did.
|
||||
// ★ ONE EQUIVALENCE WORTH STATING: the old code asked te() first and only then t(). The helper calls
|
||||
// t() and falls back when it returns the key unchanged. Same outcome both ways — present key: the
|
||||
// translation; absent key: EN — and it no longer depends on te() being implemented.
|
||||
const { t: tr, locale: i18nLocale } = useOptionalI18n('allPeopleGroups', {
|
||||
fallback: (full) => EN[String(full).replace(/^allPeopleGroups\./, '')]
|
||||
})
|
||||
// locale arrives as a REF when the host installed i18n and a plain string when it did not
|
||||
// (PosterWall.vue records the same variance), so unwrap both shapes.
|
||||
const uiLocale = computed(() => {
|
||||
const l = i18nLocale
|
||||
return (l && typeof l === 'object' && 'value' in l ? l.value : l) || 'en'
|
||||
})
|
||||
const fmt = (n) => { try { return Number(n).toLocaleString(uiLocale.value) } catch (_) { return Number(n).toLocaleString() } }
|
||||
|
||||
// ─── The four categories (the Driver's palette) ──────────────────────────────
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ import { useLegendData } from '@map/composables/legends/useLegendData.js'
|
|||
// ─── Config + utils ──────────────────────────────────────────────────────────
|
||||
import { mapDefaults } from '@map/constants/mapDefaults.js'
|
||||
import { getColorStrategy } from '@map/colors/_registry.js'
|
||||
import { RELIGION_FAMILIES } from '@map/colors/religion.js'
|
||||
import { RELIGION_FAMILIES, religionFamilyLabel } from '@map/colors/religion.js'
|
||||
import { DataSourceManager } from '@map/utils/DataSourceManager.js'
|
||||
import sourcesConfig from '@map/api/sources.json'
|
||||
|
||||
|
|
@ -188,11 +188,14 @@ useShadowStyles(`
|
|||
400/360/280 px responsive widths the simple-map mfe uses. The
|
||||
--map-legend-width / --map-btn-size / --map-toolbar-gap / --map-font-md
|
||||
/ --map-font-sm CSS vars are set by applyContainerScale() below. ── */
|
||||
/* Geocoder column matches the SemanticTreeLegend's .stl-panel — left:8px,
|
||||
/* Geocoder column matches the SemanticTreeLegend's .stl-panel — 8px from the START edge (logical, so it
|
||||
moves right under ar with the legend; a physical left:8px kept it left),
|
||||
width:380px — so the search bar and the legend stack as one column at
|
||||
the same x-edge (PPLR alignment, qa: 2026-05-02). */
|
||||
.mapboxgl-ctrl-top-left { top:12px!important;left:8px!important;width:380px!important;max-width:380px!important;z-index:1200!important; }
|
||||
.mapboxgl-ctrl-top-left .mapboxgl-ctrl { margin:0!important;float:none!important; }
|
||||
/* Both engines' container class: MapLibre names it .maplibregl-ctrl-top-left, and with only the Mapbox
|
||||
selector the column got no position or width at all under MapLibre. */
|
||||
.mapboxgl-ctrl-top-left, .maplibregl-ctrl-top-left { top:12px!important;inset-inline-start:8px!important;width:380px!important;max-width:380px!important;z-index:1200!important; }
|
||||
.mapboxgl-ctrl-top-left .mapboxgl-ctrl, .maplibregl-ctrl-top-left .maplibregl-ctrl { margin:0!important;float:none!important; }
|
||||
/* Pill geometry + PPLR-matched focus ring (qa: 2026-05-02). border-radius
|
||||
999px matches PPLR's .pgc-input-wrap; focus-within ring uses PPLR's
|
||||
accent rgba(59,70,61,0.18) so the search bar reads as one column with
|
||||
|
|
@ -224,7 +227,7 @@ useShadowStyles(`
|
|||
.dg-main { font-weight:500;font-size:13px;color:#222;line-height:1.3;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:center;gap:6px; }
|
||||
.dg-main strong { font-weight:700;overflow:hidden;text-overflow:ellipsis; }
|
||||
.dg-meta { margin-top:2px;display:flex;flex-wrap:wrap;gap:4px 10px;font-size:11px;color:#555;line-height:1.35; }
|
||||
.dg-field strong { font-weight:600;color:#333;margin-right:2px; }
|
||||
.dg-field strong { font-weight:600;color:#333;margin-inline-end:2px; }
|
||||
/* Round 16 — labeled category tag replacing the old emoji prefixes. */
|
||||
.dg-tag { flex:none;font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:0.4px;color:#3b463d;background:rgba(59,70,61,0.10);border-radius:4px;padding:1px 5px;line-height:1.5;white-space:nowrap; }
|
||||
.dg-agg-label { overflow:hidden;text-overflow:ellipsis; }
|
||||
|
|
@ -236,7 +239,7 @@ useShadowStyles(`
|
|||
.dg-section-header-item { font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:0.6px;color:#9ca3af;padding:2px 0; }
|
||||
.dsm-dark .dg-section-header-item { color:rgba(243,243,241,0.45); }
|
||||
@media(max-width:767px){
|
||||
.mapboxgl-ctrl-top-left { top:10px!important;left:3px!important;right:3px!important;width:calc(100% - 6px)!important;max-width:none!important; }
|
||||
.mapboxgl-ctrl-top-left, .maplibregl-ctrl-top-left { top:10px!important;left:3px!important;right:3px!important;width:calc(100% - 6px)!important;max-width:none!important; }
|
||||
/* Search-bar height (exactly 35px, desktop AND mobile) is now owned by the shared
|
||||
GeocoderComponent.vue injection 'geocoder-height-35' — no per-profile @media
|
||||
height hack needed (replaces bug-searchbar-mobile-height). */
|
||||
|
|
@ -1807,7 +1810,8 @@ function routeReligion(evt, { map: m, filterLayerId }) {
|
|||
try { m.setFilter(filterLayerId, null) } catch (_) {}
|
||||
const famLetter = Object.keys(RELIGION_FAMILIES).find(k => RELIGION_FAMILIES[k] === value)
|
||||
if (famLetter) {
|
||||
const node = { id: famLetter, label: value, depth: 0 }
|
||||
// The label is display only (the reader's language); the filter keys on the family letter.
|
||||
const node = { id: famLetter, label: religionFamilyLabel(famLetter), depth: 0 }
|
||||
const applyReligionSel = () => {
|
||||
try { m.setFilter(filterLayerId, null) } catch (_) {}
|
||||
onSemanticTreeSelect(node)
|
||||
|
|
|
|||
|
|
@ -161,21 +161,22 @@ async function fetchListRecords(list) {
|
|||
// translated string so each language can emphasise its own words, which is not something a
|
||||
// wrapper around an English sentence could do.
|
||||
// Guarded like every other useI18n() here: it throws on a host without vue-i18n. useOptionalT stores
|
||||
// that guard once (host i18n -> tStatic -> the key itself) instead of a fourth hand-rolled copy.
|
||||
// that guard once (host i18n -> catalogue -> the key) instead of a hand-rolled copy.
|
||||
// No namespace: callers pass FULL keys here, exactly as the raw useI18n().t they used to get.
|
||||
//
|
||||
// ★ NO humanizeKey FALLBACK, and the reason is a MEASUREMENT, not a preference. That fallback rendered
|
||||
// a key's last segment title-cased, so a bare host showed "Upg100 Wall Intro" where a real sentence
|
||||
// exists. It was kept on the theory that these keys were not fully catalogued — I said so to
|
||||
// regions-plugin, and it was FALSE: this file's three keys are present in all ELEVEN locales, 0 gaps
|
||||
// (enumerated 2026-09-27; they are the only three, all string literals, no computed keys).
|
||||
// So the fallback was shielding nothing and costing the translation. tStatic honours the READER'S
|
||||
// locale with English as the last resort, which is strictly better than an English-ish word derived
|
||||
// from an identifier.
|
||||
// ⚠ IT GUARDS A BARE HOST, NOT A MISSING KEY — those are different failures and I had them confused.
|
||||
// Switching changes what seven vue-i18n-less bundles render, so it is a product decision: taken by
|
||||
// dx-shepherd (UPGHK, 2026-09-27) as carrying out the Driver's standing goal that the production maps
|
||||
// be localized. Not a consolidation I was entitled to make on my own.
|
||||
const t = useOptionalT('messages')
|
||||
// ★ ONE BEHAVIOUR GAIN FROM WAVE 3c (regions-plugin, 4c05e589): the old path was the bare
|
||||
// useI18n().t, so on a host WITH i18n a missing key rendered the raw dotted key. It now falls through.
|
||||
//
|
||||
// ★ AND THE humanizeKey FALLBACK IS GONE — the one deliberate departure from 4c05e589, which kept it.
|
||||
// It rendered a key's last segment title-cased, so a bare host showed "Upg100 Wall Intro" where a
|
||||
// whole SENTENCE exists: wrong even in English, and untranslatable because it comes from the
|
||||
// identifier rather than a catalogue. It was kept on the theory that these keys were not fully
|
||||
// catalogued — I told regions-plugin exactly that, and it was FALSE: this file's three keys are
|
||||
// present in all ELEVEN locales, 0 gaps (enumerated 2026-09-27; the only three, all string literals).
|
||||
// ⚠ IT GUARDS A BARE HOST, NOT A MISSING KEY. Those are different failures and I had them confused.
|
||||
// Product call taken by dx-shepherd (UPGHK, 2026-09-27) under the Driver's standing goal that the
|
||||
// production maps be localized.
|
||||
const t = useOptionalT('')
|
||||
const intro = computed(() => t('messages.upg100WallIntro'))
|
||||
const heading = computed(() => t('messages.upg100WallHeading'))
|
||||
const wallTag = computed(() => t('messages.upg100WallTag'))
|
||||
|
|
|
|||
|
|
@ -48,17 +48,24 @@ import ShareButton from '@map/components/map-controls/ShareButton.vue'
|
|||
// ⚠ GUARDED, like every other useI18n() call in this codebase: it THROWS on a host that has not
|
||||
// installed vue-i18n, and this component is mounted by hosts we do not control. useOptionalT holds
|
||||
// that guard once — host i18n first, then tStatic, then the key — instead of a hand-rolled copy here.
|
||||
// No namespace: callers pass FULL keys here, exactly as the raw useI18n().t they used to get.
|
||||
//
|
||||
// ★ THE humanizeKey FALLBACK IS GONE ON PURPOSE. It rendered a key's last segment title-cased, so a
|
||||
// bare host showed "Select View" — which reads fine in English and is simply WRONG for every other
|
||||
// reader, because it cannot be translated: it is derived from the identifier, not from a catalogue.
|
||||
// tStatic honours the reader's locale and still ends at English. The four keys this file uses
|
||||
// (tabs.map / prayerResources / selectView / views) are present in all ELEVEN locales, 0 gaps,
|
||||
// enumerated 2026-09-27 — so nothing here was relying on the fallback.
|
||||
// Product decision, taken by dx-shepherd (UPGHK) under the Driver's standing goal that the production
|
||||
// maps be localized: switching changes what seven vue-i18n-less bundles render, which is not a call a
|
||||
// consolidation gets to make for itself.
|
||||
const t = useOptionalT('tabs')
|
||||
// ★ ONE BEHAVIOUR GAIN FROM WAVE 3c, stated rather than smuggled (regions-plugin, 4c05e589): the old
|
||||
// i18n path was the bare useI18n().t, so on a host WITH i18n a missing key rendered the raw dotted key.
|
||||
// It now falls through to the catalogue instead.
|
||||
//
|
||||
// ★ AND THE humanizeKey FALLBACK IS GONE — the one deliberate departure from 4c05e589, which kept it.
|
||||
// It rendered a key's last segment title-cased, so a bare host showed "Select View": fine in English
|
||||
// and simply WRONG for every other reader, because it is derived from the identifier and cannot be
|
||||
// translated. Worse, it AGREED with en on these four keys (they are named after the words), so it
|
||||
// looked correct forever while every non-English reader silently got English. tStatic honours the
|
||||
// reader's locale and still ends at English.
|
||||
// The four keys this file uses (tabs.map / prayerResources / selectView / views) are present in all
|
||||
// ELEVEN locales, 0 gaps, enumerated 2026-09-27 — nothing here was relying on the fallback.
|
||||
// Product call taken by dx-shepherd (UPGHK, 2026-09-27) under the Driver's standing goal that the
|
||||
// production maps be localized; it changes what seven vue-i18n-less bundles render, which is not a
|
||||
// decision a migration gets to make for itself.
|
||||
const t = useOptionalT('')
|
||||
|
||||
const cfg = inject('profileConfig', ref(null))
|
||||
const tk = inject('mapboxToken', ref(''))
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@
|
|||
import { ref, computed, watch, onMounted, getCurrentInstance } from 'vue'
|
||||
// SHARED search primitives (one source of truth for BOTH cap-2 grid + cap-1 dashboard).
|
||||
import Fuse from '@map/components/search/fuzzy.js'
|
||||
import { highlightRanges } from '@map/composables/useTextHighlight.js'
|
||||
import SearchBar from '@map/components/search/SearchBar.vue'
|
||||
import FilterDropdown from '@map/components/search/FilterDropdown.vue'
|
||||
import { resolveAssetsBase } from '@map/utils/embedBase.js'
|
||||
|
|
@ -474,10 +475,6 @@ const visibleFilterCount = computed(() => Object.keys(activeFilters.value).lengt
|
|||
// ref is always empty there and only the prop is real.
|
||||
const isNarrowed = computed(() => activeFilterCount.value > 0 || !!(effSearchTerm.value || '').trim())
|
||||
|
||||
function escapeHtml(s) {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function debounce(fn, ms = 500) {
|
||||
let timeout
|
||||
|
|
@ -731,18 +728,9 @@ function filterUUPGs() {
|
|||
value = newItem[String(matchKey)]
|
||||
}
|
||||
if (value && typeof value === 'string') {
|
||||
let currentIndex = 0
|
||||
let highlightedValue = ''
|
||||
match.indices.forEach((idx, i) => {
|
||||
const start = idx[0]
|
||||
const end = idx[1]
|
||||
const isLast = match.indices.length - 1 === i
|
||||
highlightedValue
|
||||
+= escapeHtml(value.slice(currentIndex, start))
|
||||
+ `<span class="search-highlight">${escapeHtml(value.slice(start, end + 1))}</span>`
|
||||
+ (isLast ? escapeHtml(value.slice(end + 1)) : '')
|
||||
currentIndex = end + 1
|
||||
})
|
||||
// The same construction regions-dashboard's UupgsList had, character for character apart from
|
||||
// variable names, which is what earned it a place in library/ under RULE 2. One fact, one copy.
|
||||
const highlightedValue = highlightRanges(value, match.indices)
|
||||
if (String(matchKey).includes('imb_alternate_name')) {
|
||||
newItem.matches.push({ key: t.value.alternate_name ?? 'Also known as', label: highlightedValue })
|
||||
} else if (String(matchKey).includes('.')) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
#!/usr/bin/env node
|
||||
/* check-text-highlight.mjs — `bun run check:text-highlight`. Source-only, headless, ~0.1s.
|
||||
*
|
||||
* library/composables/useTextHighlight.js was extracted because TWO plugins had built the same
|
||||
* search-highlight construction independently, line for line apart from variable names:
|
||||
* regions-dashboard/src/UupgsList.vue and people-groups-grid/people-groups-grid.vue. This asserts the
|
||||
* shared version reproduces the construction it replaced, and pins the two escaping deltas that the
|
||||
* extraction deliberately introduces — so neither is discovered later by a reader wondering why their
|
||||
* output changed.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { highlightRanges } from '../../../library/composables/useTextHighlight.js'
|
||||
import { codeOnly } from './strip-code.mjs'
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = resolve(HERE, '..', '..', '..')
|
||||
let fails = 0
|
||||
const ok = (c, m) => { console.log(` ${c ? 'ok ' : 'FAIL'} - ${m}`); if (!c) fails++ }
|
||||
|
||||
// ── the OLD inline construction, verbatim in shape from UupgsList before the extraction ──────────
|
||||
const oldEscape = (v) => String(v ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''')
|
||||
const oldHighlight = (original, indices) => {
|
||||
let cursor = 0, out = ''
|
||||
indices.forEach((idx, i) => {
|
||||
const [start, end] = idx
|
||||
const isLast = i === indices.length - 1
|
||||
out += oldEscape(original.slice(cursor, start))
|
||||
+ '<span class="search-highlight">' + oldEscape(original.slice(start, end + 1)) + '</span>'
|
||||
+ (isLast ? oldEscape(original.slice(end + 1)) : '')
|
||||
cursor = end + 1
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// ── 1. EQUIVALENCE on every range shape, for text with no special characters ─────────────────────
|
||||
const CASES = [
|
||||
['one range in the middle', 'Cameroon', [[2, 4]]],
|
||||
['range at the very start', 'Cameroon', [[0, 2]]],
|
||||
['range at the very end', 'Cameroon', [[5, 7]]],
|
||||
['two ranges', 'Cameroon Republic', [[0, 2], [9, 12]]],
|
||||
['three ranges, adjacent-ish', 'abcdefghij', [[0, 0], [2, 3], [8, 9]]],
|
||||
['whole string', 'Chad', [[0, 3]]],
|
||||
]
|
||||
for (const [label, value, indices] of CASES) {
|
||||
ok(oldHighlight(value, indices) === highlightRanges(value, indices),
|
||||
`${label}: shared output is byte-identical to the construction it replaced`)
|
||||
}
|
||||
|
||||
// ── 2. THE TWO DELTAS, asserted so they are documented behaviour rather than a surprise ──────────
|
||||
ok(highlightRanges('a`b', [[0, 0]]).includes('`') || highlightRanges('a`b', [[0, 0]]).includes('`'),
|
||||
'DELTA 1: a backtick IS now escaped (library/utils/html.js escapes it; neither plugin copy did)')
|
||||
ok(!oldHighlight('a`b', [[0, 0]]).includes('`'),
|
||||
'CONTROL: and the old copy did NOT escape it, so delta 1 is a real difference and not a no-op')
|
||||
ok(highlightRanges(null, [[0, 1]]) === '' || typeof highlightRanges(null, [[0, 1]]) === 'string',
|
||||
'DELTA 2: a null value returns a string rather than throwing (people-groups-grid\'s copy has no null guard)')
|
||||
const pggEscape = (s) => s.replace(/&/g, '&') // their shape: no guard at all
|
||||
let threw = false
|
||||
try { pggEscape(null) } catch (_) { threw = true }
|
||||
ok(threw, 'CONTROL: people-groups-grid\'s escaper really does throw on null, so delta 2 removes a real crash')
|
||||
|
||||
// ── 3. escaping still happens on EVERY segment, which is the security-relevant part ──────────────
|
||||
const hostile = '<img onerror=x>'
|
||||
const outH = highlightRanges(hostile, [[0, 3]])
|
||||
ok(!outH.includes('<img'), 'the hit itself is escaped, not injected as markup')
|
||||
ok(!/<(?!\/?span)/.test(outH), 'the ONLY unescaped tags in the output are the span wrappers')
|
||||
ok(highlightRanges('<b>x</b>', []).includes('<b>'),
|
||||
'with NO ranges the whole value is still escaped (the early return does not skip escaping)')
|
||||
|
||||
// ── 4. NEG CONTROLS: the comparison must be capable of failing ───────────────────────────────────
|
||||
const broken = (v) => String(v) // an "escaper" that escapes nothing
|
||||
ok(broken('<b>') !== highlightRanges('<b>', []),
|
||||
'NEG CONTROL: an implementation that escaped nothing would FAIL this gate')
|
||||
ok(highlightRanges('Cameroon', [[2, 4]]) !== highlightRanges('Cameroon', [[0, 2]]),
|
||||
'NEG CONTROL: the output actually depends on the indices')
|
||||
|
||||
// ── 5. the consumers, in source ──────────────────────────────────────────────────────────────────
|
||||
const upg = codeOnly(readFileSync(resolve(ROOT, 'plugin-designs/regions-dashboard/src/UupgsList.vue'), 'utf8'))
|
||||
ok(/highlightRanges/.test(upg), 'UupgsList routes through highlightRanges()')
|
||||
ok(!/function escapeHtml\(/.test(upg), 'and no longer hand-rolls escapeHtml (its only callers were inside highlight())')
|
||||
// people-groups-grid is the SECOND consumer that justified the extraction under RULE 2. studio agreed
|
||||
// to treat it as unowned and asked for it to be cut, so it is migrated now and asserted here.
|
||||
const pgg = codeOnly(readFileSync(resolve(ROOT, 'plugin-designs/people-groups-grid/people-groups-grid.vue'), 'utf8'))
|
||||
ok(/highlightRanges/.test(pgg), 'people-groups-grid routes through highlightRanges() too -- the duplicate is gone')
|
||||
ok(!/function escapeHtml\(/.test(pgg),
|
||||
'and its own escapeHtml is gone: after the loop moved, it had ZERO callers left')
|
||||
ok(!/currentIndex = end \+ 1/.test(pgg),
|
||||
'the hand-rolled range walk is really removed, not merely shadowed by the import')
|
||||
|
||||
console.log(fails ? `\nTEXT-HIGHLIGHT: ${fails} FAIL(s)` : '\nTEXT-HIGHLIGHT: the shared highlighter matches what it replaced, and both escaping deltas are pinned')
|
||||
process.exit(fails ? 1 : 0)
|
||||
|
|
@ -63,6 +63,7 @@ import { useRegionsI18n } from './useRegionsI18n.js'
|
|||
// would not; the shortfall is always on the fuzzy side.
|
||||
// Also unimplemented: useExtendedSearch's operator syntax (^prefix, !exclude, 'literal, |or).
|
||||
import Fuse from '@map/components/search/fuzzy.js'
|
||||
import { highlightRanges } from '@map/composables/useTextHighlight.js'
|
||||
// DX-life's spacing/type scales under THEIR token names. Prepended to this
|
||||
// component's shadow CSS so every var() below resolves inside the shadow root.
|
||||
import { DXLIFE_TOKENS } from '../dxlife-tokens.js'
|
||||
|
|
@ -189,10 +190,6 @@ const activeChips = computed(() =>
|
|||
function stripDiacritics(v) {
|
||||
return String(v ?? '').normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
||||
}
|
||||
function escapeHtml(v) {
|
||||
return String(v ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''')
|
||||
}
|
||||
|
||||
// Their key list, verbatim (UupgsList.vue:307-315).
|
||||
const SEARCH_KEYS = ['name', 'imb_alternate_name', 'country_code.label', 'rop1.label',
|
||||
|
|
@ -228,19 +225,7 @@ const preFiltered = computed(() => rows.value.filter((p) => {
|
|||
* (UupgsList.vue:336-348): escape the gap, wrap the hit, and append the tail only on the last
|
||||
* range. Indices come from the stripped corpus and are applied to the original string.
|
||||
*/
|
||||
function highlight(original, indices) {
|
||||
let cursor = 0
|
||||
let out = ''
|
||||
indices.forEach((idx, i) => {
|
||||
const [start, end] = idx
|
||||
const isLast = i === indices.length - 1
|
||||
out += escapeHtml(original.slice(cursor, start))
|
||||
+ '<span class="search-highlight">' + escapeHtml(original.slice(start, end + 1)) + '</span>'
|
||||
+ (isLast ? escapeHtml(original.slice(end + 1)) : '')
|
||||
cursor = end + 1
|
||||
})
|
||||
return out
|
||||
}
|
||||
const highlight = (original, indices) => highlightRanges(original, indices)
|
||||
|
||||
const filtered = computed(() => {
|
||||
const term = searchTerm.value.trim()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-kit-options-reachable.mjs — an option a kit composable accepts must actually be USED.
|
||||
*
|
||||
* WRITTEN BECAUSE I NEARLY SHIPPED ONE. Kit landing 3 gave useSelectionCamera a `coords` option so
|
||||
* a dashboard whose rows spell their coordinate differently could say so. It was accepted in the
|
||||
* destructured signature and never passed on to selectionBounds — so a caller could set it,
|
||||
* type-check clean, build clean, and watch it do nothing at all.
|
||||
*
|
||||
* THAT IS THE WORST SHAPE A MISSING FEATURE CAN TAKE, and this tree has paid for it before: a
|
||||
* defineProps on a component the custom element never binds, documented at length in
|
||||
* research-dashboard.vue. An option that cannot be observed to work is worse than no option,
|
||||
* because it READS as supported. Nobody files a bug against a knob they assume they misused.
|
||||
*
|
||||
* It matters more for the kit than it did for one dashboard. The whole promise of SPEC-DASHBOARD-KIT
|
||||
* is "a config produces a working dashboard", and every landing adds parameters whose only job is to
|
||||
* be honoured. A parameter that is merely present turns that promise into a guess.
|
||||
*
|
||||
* WHAT IT CHECKS: for each shared composable below, every name destructured from the options object
|
||||
* must appear somewhere in the function body other than its own declaration. Renamed and defaulted
|
||||
* forms are handled; a name used only inside its own default expression does not count.
|
||||
*
|
||||
* NOT A TYPE CHECKER. It cannot tell a wrongly-used option from a rightly-used one — only a
|
||||
* completely unused one, which is the failure that is invisible from outside.
|
||||
*
|
||||
* NEGATIVE-TESTED: deleting the `coords` argument from the selectionBounds call fails it naming the
|
||||
* option and the composable; deleting the option itself passes (an option that does not exist makes
|
||||
* no promise).
|
||||
*/
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const TAG = 'KIT-OPTIONS-REACHABLE'
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
|
||||
// The kit's shared composables, in extraction order. Each new landing adds its file here.
|
||||
const FILES = [
|
||||
'library/composables/useSelectionBus.js',
|
||||
'library/composables/useDashboardLayout.js',
|
||||
'library/composables/useSelectionCamera.js',
|
||||
'library/composables/useCsvExport.js',
|
||||
]
|
||||
const fails = []
|
||||
const notes = []
|
||||
let checked = 0
|
||||
|
||||
for (const rel of FILES) {
|
||||
const p = join(ROOT, rel)
|
||||
if (!existsSync(p)) { notes.push(`${rel}: not present yet (its landing has not happened) — skipped`); continue }
|
||||
const src = readFileSync(p, 'utf8')
|
||||
// Every exported function whose single parameter is a destructured options object.
|
||||
for (const m of src.matchAll(/export function (\w+)\(\s*\{([\s\S]*?)\}\s*\)\s*\{/g)) {
|
||||
const [, fn, params] = m
|
||||
// The body: from the end of the signature to the end of the file is enough — a name used
|
||||
// anywhere after the signature is used. Narrower slicing risks a false accusation.
|
||||
// ★ COMMENTS STRIPPED, AND THIS IS THE FOURTH TIME I HAVE WRITTEN THIS HOLE. The first version
|
||||
// read the raw body, and the comment I had just written next to the fixed call site says
|
||||
// "`coords` IS PASSED HERE" — so the mutant that removed the actual argument left the gate
|
||||
// green. Every check I write that searches source for a token has to strip comments first,
|
||||
// because the comment explaining the code is the most likely place for that token to also
|
||||
// appear. A check its own documentation can satisfy is not a check.
|
||||
const body = src.slice(m.index + m[0].length)
|
||||
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
||||
.replace(/(^|[^:])\/\/[^\n]*/g, '$1')
|
||||
// ★ COMMENTS OUT OF THE SIGNATURE FIRST, THEN SPLIT. Splitting the raw text on commas cut
|
||||
// through the PROSE of the comments documenting each option, and the fragments came back as
|
||||
// option names — the gate solemnly reported that useCsvExport accepts `and` and never uses it.
|
||||
// A parser that reads its own documentation as data produces findings nobody can act on, which
|
||||
// is a different way of being unusable from the comment-satisfies-the-assertion hole two lines
|
||||
// up. Both are the same root cause: comments are text, and every text search has to say so.
|
||||
const paramSrc = params
|
||||
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
||||
.replace(/(^|[^:])\/\/[^\n]*/g, '$1')
|
||||
// Split the destructuring at top-level commas only, so a default like `() => true` survives.
|
||||
const parts = []
|
||||
let depth = 0, cur = ''
|
||||
for (const ch of paramSrc) {
|
||||
if ('([{'.includes(ch)) depth++
|
||||
else if (')]}'.includes(ch)) depth--
|
||||
if (ch === ',' && depth === 0) { parts.push(cur); cur = '' } else cur += ch
|
||||
}
|
||||
parts.push(cur)
|
||||
for (const raw of parts) {
|
||||
// strip comments, then read the local binding name: `a`, `a = d`, `a: b`, `a: b = d`
|
||||
const decl = raw.replace(/\/\/[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '').trim()
|
||||
if (!decl) continue
|
||||
const nameM = /^([A-Za-z_$][\w$]*)\s*(?::\s*([A-Za-z_$][\w$]*))?/.exec(decl)
|
||||
if (!nameM) continue
|
||||
const local = nameM[2] || nameM[1]
|
||||
checked++
|
||||
if (new RegExp(`(?<![\\w$.])${local.replace('$', '\\$')}(?![\\w$])`).test(body)) continue
|
||||
fails.push(`${rel}: ${fn}() accepts \`${local}\` and never uses it. A caller can set it, type-check clean, build clean, and watch it do nothing — which reads as supported. Either honour it or remove it.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` · ${checked} option(s) across ${FILES.filter((f) => existsSync(join(ROOT, f))).length} shared composable(s)`)
|
||||
for (const n of notes) console.log(' · ' + n)
|
||||
if (fails.length) {
|
||||
console.error(`\n${TAG}: FAIL`)
|
||||
for (const f of fails) console.error(' ✗ ' + f)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`${TAG}: PASS — every option the kit accepts is honoured somewhere.`)
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
import { inject, ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import { buildSearchAccessors, makeSearchMatcher, filterRowsBySearch } from '../../composables/useDashboardShell.js'
|
||||
import { useCsvExport } from '../../composables/useCsvExport.js'
|
||||
import { useCsvExport } from '@map/composables/useCsvExport.js'
|
||||
import { useTreeLegendLabels } from '../../composables/useTreeLegendLabels.js'
|
||||
import { tStatic } from '@map/i18n/index.js'
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import { tStatic } from '@map/i18n/index.js'
|
|||
// what shipped rather than to a key name.
|
||||
const T = (key, en) => tStatic(key) || en
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import { useCsvExport } from '../../composables/useCsvExport.js'
|
||||
import { useCsvExport } from '@map/composables/useCsvExport.js'
|
||||
import { useTreeLegendLabels } from '../../composables/useTreeLegendLabels.js'
|
||||
import { lbl } from '../../composables/useDashboardAggregations.js'
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import { tStatic } from '@map/i18n/index.js'
|
|||
const T = (key, en) => tStatic(key) || en
|
||||
// Table selection -> map camera. The table never touches the map: it commits to the bus,
|
||||
// this composable watches bus.selectionVersion and flies ONCE per settled change.
|
||||
import { useSelectionCamera } from '../../composables/useSelectionCamera.js'
|
||||
import { useSelectionCamera } from '@map/composables/useSelectionCamera.js'
|
||||
// ── Map controls — REUSED from the shared library, not hand-rolled. MapToolbar is a
|
||||
// layout-only positioned column; each button binds :map explicitly so it can only ever
|
||||
// drive THIS panel's instance (the multi-map contract in map-controls/README.md).
|
||||
|
|
|
|||
|
|
@ -1,244 +1,14 @@
|
|||
/**
|
||||
* useCsvExport.js — RDASH phase 4: export-any-selection to CSV (native Blob, 0-dep).
|
||||
* useCsvExport.js — RE-EXPORT SHIM (dashboard-kit landing 3).
|
||||
*
|
||||
* Priority model (plan §INTERACTION): selectionSet (explicit picks) > filterSet result (in scope) >
|
||||
* full dataset. Serializes the FULL stored record per row (flattening {value,label} -> label),
|
||||
* with a '#' provenance header line of the active chips, RFC-ish quoting, and CSV-injection
|
||||
* neutralization (leading = + - @ prefixed with a single quote). Shadow-DOM is a non-issue —
|
||||
* export touches no rendered DOM/styles.
|
||||
* The real thing now lives at library/composables/useCsvExport.js. Same one-cycle contract as
|
||||
* landings 1 and 2: every importer keeps working unchanged while the move is verified on its own,
|
||||
* and this file is deleted in the next landing after a measured check that nothing still imports it.
|
||||
*
|
||||
* ── ALL-FIELDS EXPORT (card BUILD-CSV-EXPORT-MOVE-TO-TABLE-HEADER-ALL-FIELDS) ──
|
||||
* The export must carry EVERY field the API holds, not just the 8 table columns. The limit was
|
||||
* never in this file — flatten() already walks Object.keys(record). It was UPSTREAM: the store
|
||||
* loads `?fields=<sources.json defaultFields>` (24 keys, ~2.3MB) because that is all the map /
|
||||
* table / charts render. The detail endpoint exposes 67.
|
||||
* EXPORT_FIELDS travels with it. Re-exporting only the composable would have been a silent break:
|
||||
* the field list is imported separately by the gate that checks the CSV's shape.
|
||||
*
|
||||
* So the wide record is fetched LAZILY, here, on the first export click — not at mount:
|
||||
* - widening sources.json defaultFields would triple the payload (2.3MB -> 6.8MB) for EVERY
|
||||
* bundle that shares that file, to serve one button;
|
||||
* - the list endpoint honors an explicit `fields=` list, so one request returns all 67 keys
|
||||
* for all rows (verified live: 2,107 rows x 67 keys).
|
||||
* The result is cached for the session; if the fetch fails we still export the loaded record
|
||||
* (24 fields) rather than producing nothing — degraded, never broken.
|
||||
* It re-exports; it does not duplicate. A second copy would drift silently while both halves kept
|
||||
* passing their gates.
|
||||
*/
|
||||
import { getApiBaseUrl } from '@map/api/apiBaseUrl.js'
|
||||
import { inject } from 'vue'
|
||||
import sourcesConfig from '@map/api/sources.json'
|
||||
import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
|
||||
|
||||
/**
|
||||
* The full field set for the export, in a stable, human-readable order: identity first, then
|
||||
* place, people/language/religion, then the engagement + IMB research columns. Sourced from the
|
||||
* live /detail record shape (67 keys); `exportFields` in sources.json overrides it per-source.
|
||||
*/
|
||||
export const EXPORT_FIELDS = [
|
||||
// identity
|
||||
'slug', 'name', 'imb_alternate_name', 'doxa_wagf_uid', 'imb_uid', 'imb_peid', 'imb_pgid',
|
||||
'joshua_project_id', 'imb_affinity_code',
|
||||
// place
|
||||
'latitude', 'longitude', 'country_code', 'region', 'wagf_region', 'wagf_block', 'wagf_member',
|
||||
'imb_subregion', 'location_description',
|
||||
// people / language
|
||||
'rop1', 'imb_reg_of_people_2', 'imb_reg_of_people_25', 'imb_reg_of_people_3',
|
||||
'primary_language', 'imb_language_family', 'imb_language_class', 'imb_language_speakers',
|
||||
'work_in_local_language', 'imb_is_indigenous',
|
||||
// religion
|
||||
'religion', 'imb_reg_of_religion_3', 'imb_reg_of_religion_4',
|
||||
// population / believers
|
||||
'population', 'imb_population_class', 'believers_count', 'evangelical_pct',
|
||||
'imb_evangelical_level', 'imb_congregation_existing',
|
||||
// prayer + engagement
|
||||
'people_praying', 'people_committed', 'committed_duration', 'adopted_by_churches',
|
||||
'adopted_by_count', 'adopted_by_names', 'engagement_status', 'status', 'reason_engaged',
|
||||
'reason_unlisted', 'global_start_date', 'workers_long_term', 'imb_church_planting',
|
||||
'disciple_and_church_multiplication',
|
||||
// scripture + media resources
|
||||
'imb_bible_available', 'imb_bible_translation_level', 'imb_bible_year_published',
|
||||
'imb_bible_stories_available', 'imb_audio_scripture_available', 'imb_gospel_recordings_available',
|
||||
'imb_jesus_film_available', 'imb_radio_broadcast_available', 'imb_total_resources_available',
|
||||
// priority indices
|
||||
'imb_gsec', 'imb_lostness_priority_index', 'imb_strategic_priority_index',
|
||||
// media / description
|
||||
'has_photo', 'image_url', 'picture_credit', 'imb_people_description',
|
||||
]
|
||||
|
||||
/**
|
||||
* @param {(facetKey:string, value:*) => *} [labelFor] display-only projection for the provenance
|
||||
* stamp. The People facets key by CODE (dashboard.config.js:54-57), so without it the header
|
||||
* reads `affinity_bloc=A012` — a code on a user-facing surface, the same bug the filter chip had.
|
||||
* Optional: omitted, the stamp falls back to the raw value, so existing callers keep working.
|
||||
* It is applied to the COMMENT LINE ONLY — never to a data column (see buildCsv).
|
||||
*/
|
||||
export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_ACC, dataOpts = {}, labelFor = null,
|
||||
// Defaulted so a caller that predates D1 keeps working exactly as before rather than throwing.
|
||||
rowMatches = (r, ACC) => Object.keys(filterSet).every((k) => { const a = ACC[k]; return a ? a(r) === filterSet[k] : true }) }) {
|
||||
// slug -> wide API record, filled by the lazy all-fields fetch below.
|
||||
let wideStore = null
|
||||
let widePromise = null
|
||||
// The map's people-group LIST (library/lists/). Every caller runs in setup, where inject works.
|
||||
let profileConfig = dataOpts.profileConfig || null
|
||||
if (!profileConfig) { try { profileConfig = inject('profileConfig', null) } catch (_) { /* outside setup */ } }
|
||||
|
||||
/**
|
||||
* Fetch the FULL field set for every row, once per session. Same endpoint + base-URL
|
||||
* resolution the row store uses (shared sources.json / getApiBaseUrl), only with an explicit
|
||||
* `fields=` covering all of EXPORT_FIELDS instead of the render-sized defaultFields.
|
||||
*/
|
||||
function fetchWideRows() {
|
||||
if (widePromise) return widePromise
|
||||
widePromise = (async () => {
|
||||
const sourceId = dataOpts.source || sourcesConfig.activeSource
|
||||
const src = sourcesConfig?.sources?.[sourceId] || {}
|
||||
const fields = (Array.isArray(src.exportFields) && src.exportFields.length) ? src.exportFields : EXPORT_FIELDS
|
||||
const bulk = src.endpoints?.bulk || '/api/people-groups/list'
|
||||
const lang = src.queryParams?.lang || 'en'
|
||||
const url = `${getApiBaseUrl()}${bulk}?fields=${fields.join(',')}&lang=${lang}`
|
||||
const res = await fetch(url, { cache: 'no-store' })
|
||||
if (!res.ok) throw new Error(`export list HTTP ${res.status}`)
|
||||
const json = await res.json()
|
||||
// Only the groups of this page's people-group LIST (library/lists/) are exported.
|
||||
const raw = await applyPeopleGroupList(Array.isArray(json) ? json : (json.posts || json.data || json.results || []), profileConfig)
|
||||
const map = new Map()
|
||||
for (const r of raw) { const k = r && (r.slug || r.id); if (k) map.set(k, r) }
|
||||
wideStore = map
|
||||
return map
|
||||
})().catch((e) => { widePromise = null; throw e }) // let a later click retry
|
||||
return widePromise
|
||||
}
|
||||
|
||||
// Swap a loaded (narrow) row for its wide twin when the all-fields fetch has landed.
|
||||
function widen(r) {
|
||||
if (!wideStore) return r
|
||||
const w = wideStore.get(r && (r.slug || r.id))
|
||||
return w ? { ...r, ...w } : r
|
||||
}
|
||||
|
||||
function pickRows() {
|
||||
if (selectionSet && selectionSet.size) {
|
||||
return [...selectionSet].map((s) => rowStore.value.get(s)).filter(Boolean)
|
||||
}
|
||||
const keys = Object.keys(filterSet)
|
||||
if (keys.length) {
|
||||
// The bus's own predicate, handed in by the shell — the exporter must select the same rows
|
||||
// the reader is looking at, and a private copy of the rule is how an export starts
|
||||
// disagreeing with the screen it was taken from.
|
||||
return (rows.value || []).filter((r) => rowMatches(r, FACET_ACC))
|
||||
}
|
||||
return rows.value || []
|
||||
}
|
||||
function scopeLabel() {
|
||||
if (selectionSet && selectionSet.size) return `selection (${selectionSet.size})`
|
||||
return Object.keys(filterSet).length ? 'filter result' : 'all'
|
||||
}
|
||||
// {value,label} -> label; arrays -> ' | '-joined cell (picture_credit / adopted_by_names arrive as
|
||||
// arrays of objects, so a bare JSON.stringify would dump unreadable braces into the sheet).
|
||||
function cell(v) {
|
||||
if (v === null || v === undefined) return v
|
||||
if (Array.isArray(v)) return v.map((x) => cell(x)).filter((x) => x !== '' && x !== null && x !== undefined).join(' | ')
|
||||
// `link` last: a picture_credit entry can be {link,text} with a null text — fall through to the
|
||||
// URL rather than dumping raw JSON braces into the cell.
|
||||
if (typeof v === 'object') return v.label ?? v.value ?? v.text ?? v.name ?? v.link ?? JSON.stringify(v)
|
||||
return v
|
||||
}
|
||||
function flatten(r) {
|
||||
const o = {}
|
||||
for (const k of Object.keys(r)) o[k] = cell(r[k])
|
||||
return o
|
||||
}
|
||||
// Quote if the field has , " CR LF; double internal quotes; neutralize leading = + - @ (CSV injection).
|
||||
function esc(v) {
|
||||
let s = v === null || v === undefined ? '' : String(v)
|
||||
if (/^[=+\-@]/.test(s)) s = "'" + s
|
||||
if (/[",\n\r]/.test(s)) s = '"' + s.replace(/"/g, '""') + '"'
|
||||
return s
|
||||
}
|
||||
function buildCsv() {
|
||||
const picked = pickRows().map(widen).map(flatten)
|
||||
// Column order: the canonical EXPORT_FIELDS order first (so the sheet reads identity → place →
|
||||
// people → engagement → resources), then any key the API returned that the list doesn't name.
|
||||
// Union across rows, because a null-valued field can be absent on an individual record.
|
||||
const present = picked.reduce((set, r) => { Object.keys(r).forEach((k) => set.add(k)); return set }, new Set())
|
||||
const cols = [...EXPORT_FIELDS.filter((k) => present.has(k)), ...[...present].filter((k) => !EXPORT_FIELDS.includes(k))]
|
||||
// Provenance stamp — a HUMAN-READABLE COMMENT line, so People codes are resolved to names
|
||||
// here exactly as the filter chips do. This is the ONLY place labelFor() is applied: the data
|
||||
// columns below are built from the API record via flatten()/cell() and are untouched, because
|
||||
// a consumer's formulas and downstream joins key on whatever those columns already emit.
|
||||
const chips = Object.entries(filterSet)
|
||||
.map(([k, v]) => `${k}=${labelFor ? labelFor(k, v) : v}`)
|
||||
.join(' | ') || 'none'
|
||||
const provenance = `# Research Dashboard export — scope: ${scopeLabel()}; filters: ${chips}; rows: ${picked.length}`
|
||||
// ★ THE PROVENANCE LINE GOES LAST, NOT FIRST — that one position was the whole "glitched CSV".
|
||||
// Driver-reported 2026-09-22: "this is me exporting the CSV directly without making any edits...
|
||||
// it does look a little bit glitched out."
|
||||
//
|
||||
// MEASURED with a strict RFC-4180 parser against this exact generator. EVERYTHING ELSE IS CORRECT
|
||||
// and must not be "fixed" on suspicion:
|
||||
// line 0 ... 1 column "# Research Dashboard export — scope: ..."
|
||||
// line 1 ... N columns slug,name,religion,...
|
||||
// rows ... N columns each, ZERO with a wrong column count
|
||||
// embedded commas, embedded double quotes AND embedded newlines all survive the round trip, so
|
||||
// esc() is genuinely RFC-4180 (quote when , " CR LF appear; double the internal quotes). The
|
||||
// file is comma-separated, not tab-separated — the tabs in the Driver's paste were his terminal
|
||||
// rendering, not the bytes.
|
||||
//
|
||||
// The defect is purely POSITIONAL: every spreadsheet importer treats the FIRST line as the header.
|
||||
// So the scope sentence became the column names, the real header became data row 1, and a 1-column
|
||||
// line above a 68-column table is exactly what "glitched" looks like on screen.
|
||||
//
|
||||
// Moving it BELOW the data keeps the provenance — which is deliberate and load-bearing, since it
|
||||
// records WHICH FILTERS produced the file, the difference between a dataset and an anecdote —
|
||||
// while making line 1 the header. A trailing 1-column line lands as a final row with one populated
|
||||
// cell: obvious to a human, harmless to a formula, and it cannot destroy the column mapping.
|
||||
const lines = [cols.map(esc).join(','), ...picked.map((r) => cols.map((c) => esc(r[c])).join(',')), provenance]
|
||||
return lines.join('\r\n')
|
||||
}
|
||||
// Last-export status for UI feedback (toast). The platform gem-frame iframe is sandboxed WITHOUT
|
||||
// `allow-downloads`, so the native <a download> is silently blocked there — CLIPBOARD is the robust
|
||||
// primary path (the iframe `allow`s clipboard-write); native download still works outside a sandbox.
|
||||
const lastExport = { rows: 0, clipboard: false, downloaded: false, at: 0, fields: 0, wide: false }
|
||||
function exportCsv() {
|
||||
const csv = buildCsv()
|
||||
const rows = pickRows().length
|
||||
// The header is now line 1 — the '#' provenance comment moved to the END of the file so that a
|
||||
// spreadsheet reads the real column row first (see buildCsv). This index had to move WITH it:
|
||||
// reading line 2 would have counted the columns of DATA ROW 1, which is usually the same number,
|
||||
// so it would have looked correct and been wrong — the quiet kind of breakage a positional change
|
||||
// leaves behind.
|
||||
const fields = (csv.split('\r\n')[0] || '').split(',').length
|
||||
try { if (typeof window !== 'undefined') window.__rdashLastCsv = csv } catch (_) { /* verify hook */ }
|
||||
let clipboard = false, downloaded = false
|
||||
// 1) clipboard — works inside the sandboxed gem-frame iframe (the guaranteed path)
|
||||
try { if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(csv).catch(() => {}); clipboard = true } } catch (_) { /* no clipboard */ }
|
||||
// 2) native file download — works when NOT in a download-blocked sandbox
|
||||
try {
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a'); a.href = url; a.download = 'research-dashboard.csv'; a.style.display = 'none'
|
||||
document.body.appendChild(a); a.click(); downloaded = true
|
||||
setTimeout(() => { try { URL.revokeObjectURL(url); a.remove() } catch (_) {} }, 200)
|
||||
} catch (_) { /* download blocked — clipboard above already has it */ }
|
||||
Object.assign(lastExport, { rows, clipboard, downloaded, fields, wide: !!wideStore, at: (typeof performance !== 'undefined' ? performance.now() : 0) })
|
||||
try { window.__rdashExportStatus = { rows, clipboard, downloaded, fields, wide: !!wideStore } } catch (_) {}
|
||||
return { csv, rows, clipboard, downloaded, fields, wide: !!wideStore }
|
||||
}
|
||||
|
||||
/**
|
||||
* The UI entry point: guarantee the ALL-FIELDS record before writing the file.
|
||||
*
|
||||
* Why this is async but exportCsv() stays sync — navigator.clipboard.writeText() needs the
|
||||
* TRANSIENT USER ACTIVATION of the click, and awaiting a network round-trip spends it. So the
|
||||
* first click on a cold cache resolves the wide rows and then writes: the download path always
|
||||
* works, and the clipboard path is re-attempted. Once cached (every later click) the await
|
||||
* settles in a microtask, activation survives, and clipboard works as before.
|
||||
* A failed fetch still exports the loaded 24-field record — degraded, never nothing.
|
||||
*/
|
||||
async function exportCsvAsync() {
|
||||
try { await fetchWideRows() } catch (_) { /* fall back to the loaded record */ }
|
||||
return exportCsv()
|
||||
}
|
||||
|
||||
// Let the UI warm the cache (e.g. on hover/mount) so the first real click is already wide.
|
||||
function prefetchExportFields() { try { return fetchWideRows() } catch (_) { return null } }
|
||||
|
||||
return { exportCsv, exportCsvAsync, buildCsv, pickRows, scopeLabel, lastExport, prefetchExportFields, EXPORT_FIELDS }
|
||||
}
|
||||
export { useCsvExport, EXPORT_FIELDS } from '@map/composables/useCsvExport.js'
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import { exactMatches, fuzzyMatches, resolveFuzzyValues } from '@map/components/
|
|||
import { provide, inject, ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { createSelectionBus } from '@map/composables/useSelectionBus.js'
|
||||
import { useDashboardData } from './useDashboardData.js'
|
||||
import { useCsvExport } from './useCsvExport.js'
|
||||
import { useCsvExport } from '@map/composables/useCsvExport.js'
|
||||
import { useTreeLegendLabels } from './useTreeLegendLabels.js'
|
||||
import { DataSourceManager } from '@map/api/DataSourceManager.js'
|
||||
import sourcesConfig from '@map/api/sources.json'
|
||||
|
|
|
|||
|
|
@ -1,235 +1,11 @@
|
|||
/**
|
||||
* useSelectionCamera.js — "select rows in the table, the map zooms to that selection."
|
||||
* useSelectionCamera.js — RE-EXPORT SHIM (dashboard-kit landing 3).
|
||||
*
|
||||
* Subscribes to the selection bus and drives the map camera ONCE per committed selection
|
||||
* change. The table never talks to the map: it mutates `bus.selectionSet` and bumps
|
||||
* `bus.selectionVersion`; this composable watches that version. That is exactly the
|
||||
* decoupling the bus exists for — the table panel owns no camera code, and this file owns
|
||||
* no table code.
|
||||
* The real thing now lives at library/composables/useSelectionCamera.js. Same one-cycle contract as landings 1
|
||||
* and 2: every importer keeps working unchanged while the move is verified on its own, and this
|
||||
* file is deleted in the next landing after a measured check that nothing still imports it.
|
||||
*
|
||||
* ── MOTION SAFETY (BUG-RESIZE-STROBE class; the Driver is photosensitive) ──────────────
|
||||
* The strobe bug fixed in @map/composables/useMapInstance.js was caused by a resize handler
|
||||
* re-snapping the camera on EVERY frame at 60fps. The rule learned there is applied here:
|
||||
*
|
||||
* 1. ONE animation per committed selection change — never per frame, never per row.
|
||||
* `selectionVersion` is bumped once per BATCH (useSelectionBus's performance contract),
|
||||
* so selecting 2,107 rows emits ONE version bump, not 2,107.
|
||||
* 2. DEBOUNCED (CAMERA_DEBOUNCE_MS). Rapid shift+click range-dragging bumps the version
|
||||
* many times in a few hundred ms; only the final state moves the camera. Each new bump
|
||||
* CANCELS the pending move, so intermediate states are never animated.
|
||||
* 3. REDUCED MOTION — when the user asks for less motion we JUMP (duration 0) instead of
|
||||
* easing. Same destination, zero traversal, no optical flow across the viewport.
|
||||
* 4. NOTHING here runs in a render path. The bbox is computed inside the debounced
|
||||
* callback, i.e. at most once per settled selection, never inside a computed/watcher
|
||||
* that the map redraw depends on.
|
||||
*
|
||||
* ── COST ──────────────────────────────────────────────────────────────────────────────
|
||||
* One O(n) pass over the selected rows per settled change (n = selection size), using the
|
||||
* slug-keyed rowStore for O(1) lookups rather than re-scanning all 2,107 rows per slug.
|
||||
* Selecting everything is a single ~2,107-element numeric pass — sub-millisecond — and it
|
||||
* happens once, after the debounce, off the render path.
|
||||
* It re-exports; it does not duplicate. A second copy would drift silently while both halves kept
|
||||
* passing their gates.
|
||||
*/
|
||||
import { watch } from 'vue'
|
||||
|
||||
// ── Camera policy constants ───────────────────────────────────────────────────────────
|
||||
// Debounce window. Long enough to swallow a shift+click range drag or a "select all"
|
||||
// cascade, short enough that a single checkbox click still feels immediate.
|
||||
const CAMERA_DEBOUNCE_MS = 150
|
||||
// One eased move. Matches the panel's existing fitBounds duration so the selection camera
|
||||
// and the filter camera feel like the same gesture.
|
||||
const FLY_DURATION_MS = 1200
|
||||
// Viewport padding so selected pins never sit flush against the panel edge.
|
||||
const FIT_PADDING_PX = 70
|
||||
// Ceiling for a MULTI-row fit. Without it, two rows in the same city fit at street level.
|
||||
const FIT_MAX_ZOOM = 5.5
|
||||
// A SINGLE selected row has a degenerate (zero-area) bbox — fitBounds would resolve it at
|
||||
// maxZoom, slamming the camera to street level for a people-group whose coordinate is a
|
||||
// country-scale centroid. Fly to the point at a deliberately regional zoom instead.
|
||||
const SINGLE_ROW_ZOOM = 6
|
||||
// Degenerate-bbox guard for the multi-row path: several rows can share one coordinate (or
|
||||
// sit within metres of each other), which is a zero-area bbox by another route. Below this
|
||||
// span we treat the cluster as a single point and use SINGLE_ROW_ZOOM.
|
||||
const DEGENERATE_SPAN_DEG = 0.01
|
||||
// Past this longitude span a selection effectively wraps the globe; framing it literally
|
||||
// makes the camera animate the wrong way around the antimeridian. Ease to a world overview
|
||||
// instead. Tuned to match the LEGEND_WIDE_SPAN_DEG threshold in @map/composables/useMapFly.js.
|
||||
const WIDE_SPAN_DEG = 110
|
||||
const WORLD_ZOOM = 1.45 // the panel's own initial zoom — a stable, familiar overview
|
||||
|
||||
/** True when the user has asked the OS for reduced motion. Read live (never cached): the
|
||||
* preference can be toggled mid-session, and a stale `false` would animate at them. */
|
||||
function prefersReducedMotion() {
|
||||
try {
|
||||
return typeof window !== 'undefined'
|
||||
&& typeof window.matchMedia === 'function'
|
||||
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
} catch (_) { return false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Numeric coordinate pair for a row, or null when unusable.
|
||||
*
|
||||
* The live API returns coordinates as STRINGS ("5.68736400", verified against 2,107 live
|
||||
* records on pray.doxa.life), so every read must go through Number() — a raw string compare
|
||||
* would order "9.5" above "78.2" lexically and silently produce a wrong bbox.
|
||||
*
|
||||
* Number.isFinite (not truthiness): `Number(r.latitude) && …` rejects a legitimate 0°
|
||||
* coordinate. We accept 0 per-axis and reject only the exact 0,0 pair — the null-island
|
||||
* sentinel that a missing geocode degrades into. Flying there would show the camera an
|
||||
* empty patch of the Gulf of Guinea.
|
||||
*/
|
||||
function coordsOf(row) {
|
||||
if (!row) return null
|
||||
const lng = Number(row.longitude)
|
||||
const lat = Number(row.latitude)
|
||||
if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null
|
||||
if (lng === 0 && lat === 0) return null // null island — a missing geocode, not a place
|
||||
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return null // out-of-range guard
|
||||
return [lng, lat]
|
||||
}
|
||||
|
||||
/**
|
||||
* Antimeridian-aware longitude extent.
|
||||
*
|
||||
* A Pacific selection holds longitudes near -176 and +178 (both present in the live data:
|
||||
* the full range is -176.18 .. +178.48). Naive min/max calls that a ~355° span and frames
|
||||
* the whole globe — or worse, animates the long way round. Re-projecting into [0,360) makes
|
||||
* the same set contiguous with a ~6° span. We compute both and keep the tighter one.
|
||||
*
|
||||
* Returns { minLng, maxLng, span }; maxLng may exceed 180, which Mapbox's fitBounds accepts
|
||||
* and wraps correctly.
|
||||
*/
|
||||
function lngExtent(longitudes) {
|
||||
let aMin = Infinity, aMax = -Infinity // raw [-180, 180]
|
||||
let bMin = Infinity, bMax = -Infinity // shifted [0, 360)
|
||||
for (const lng of longitudes) {
|
||||
if (lng < aMin) aMin = lng
|
||||
if (lng > aMax) aMax = lng
|
||||
const shifted = lng < 0 ? lng + 360 : lng
|
||||
if (shifted < bMin) bMin = shifted
|
||||
if (shifted > bMax) bMax = shifted
|
||||
}
|
||||
if (!Number.isFinite(aMin)) return null
|
||||
const spanA = aMax - aMin
|
||||
const spanB = bMax - bMin
|
||||
if (spanB < spanA) {
|
||||
const minRaw = bMin > 180 ? bMin - 360 : bMin
|
||||
return { minLng: minRaw, maxLng: minRaw + spanB, span: spanB }
|
||||
}
|
||||
return { minLng: aMin, maxLng: aMax, span: spanA }
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounding box of the selected rows.
|
||||
*
|
||||
* Resolves each slug through the slug-keyed rowStore (O(1)) instead of filtering the full
|
||||
* row array per slug, so a 2,107-row selection stays one linear pass.
|
||||
*
|
||||
* @returns {null | { kind:'point', center:[lng,lat] }
|
||||
* | { kind:'bounds', bounds:[[w,s],[e,n]], span:number }}
|
||||
* null when NOTHING is selected or no selected row has usable coordinates —
|
||||
* the caller must then leave the camera alone.
|
||||
*/
|
||||
export function selectionBounds(selectedSlugs, rowStore) {
|
||||
if (!selectedSlugs || !selectedSlugs.size || !rowStore) return null
|
||||
|
||||
const lngs = []
|
||||
let minLat = Infinity, maxLat = -Infinity
|
||||
let first = null
|
||||
let count = 0
|
||||
|
||||
for (const slug of selectedSlugs) {
|
||||
const c = coordsOf(rowStore.get(slug))
|
||||
if (!c) continue // missing/null/invalid coords — skip the row
|
||||
if (count === 0) first = c
|
||||
lngs.push(c[0])
|
||||
if (c[1] < minLat) minLat = c[1]
|
||||
if (c[1] > maxLat) maxLat = c[1]
|
||||
count++
|
||||
}
|
||||
|
||||
// Every selected row lacked coordinates (or the selection is empty) — no camera move.
|
||||
if (count === 0) return null
|
||||
if (count === 1) return { kind: 'point', center: first }
|
||||
|
||||
const ext = lngExtent(lngs)
|
||||
if (!ext) return { kind: 'point', center: first }
|
||||
|
||||
// Co-located cluster: a zero-area bbox by another route. fitBounds would hit maxZoom.
|
||||
if (ext.span < DEGENERATE_SPAN_DEG && (maxLat - minLat) < DEGENERATE_SPAN_DEG) {
|
||||
return { kind: 'point', center: [(ext.minLng + ext.maxLng) / 2, (minLat + maxLat) / 2] }
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'bounds',
|
||||
bounds: [[ext.minLng, minLat], [ext.maxLng, maxLat]],
|
||||
span: ext.span,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire selection -> camera. Call once from the map panel's setup.
|
||||
*
|
||||
* @param {Object} o
|
||||
* @param {Object} o.bus - the selection bus (selectionSet + selectionVersion)
|
||||
* @param {Function} o.getMap - () => the live map instance (null before ready)
|
||||
* @param {Function} o.getRowStore - () => Map<slug, row>
|
||||
* @param {Object} o.mapFly - a useMapFly instance (shared library helper)
|
||||
* @param {Function} [o.isEnabled] - () => boolean; skip while false (e.g. map not ready)
|
||||
* @returns {Function} teardown — clears the pending timer and stops the watcher
|
||||
*/
|
||||
export function useSelectionCamera({ bus, getMap, getRowStore, mapFly, isEnabled = () => true }) {
|
||||
if (!bus || !bus.selectionVersion) return () => {}
|
||||
|
||||
let timer = null
|
||||
|
||||
function moveCamera() {
|
||||
timer = null
|
||||
const map = getMap && getMap()
|
||||
if (!map || !isEnabled()) return
|
||||
|
||||
const target = selectionBounds(bus.selectionSet, getRowStore && getRowStore())
|
||||
// EDGE CASE — selection cleared, or no selected row has coordinates: hold position.
|
||||
// Deliberately NOT a reset-to-world: an unrequested snap-back is both jarring and, for
|
||||
// a photosensitive user, motion they did not ask for. Deselecting is not a navigation.
|
||||
if (!target) return
|
||||
|
||||
// Reduced motion: same destination, zero traversal.
|
||||
const duration = prefersReducedMotion() ? 0 : FLY_DURATION_MS
|
||||
|
||||
try {
|
||||
if (target.kind === 'point') {
|
||||
// Single row (or a co-located cluster). fitBounds on a degenerate bbox resolves at
|
||||
// maxZoom; a fixed regional zoom is the honest framing for a country-scale centroid.
|
||||
mapFly.flyTo(target.center, { zoom: SINGLE_ROW_ZOOM, duration, essential: true })
|
||||
return
|
||||
}
|
||||
if (target.span > WIDE_SPAN_DEG) {
|
||||
// Globe-spanning selection (e.g. "select all"). Framing it literally makes the
|
||||
// camera swing the wrong way around the antimeridian — the visible "snap".
|
||||
mapFly.flyTo(
|
||||
[(target.bounds[0][0] + target.bounds[1][0]) / 2,
|
||||
(target.bounds[0][1] + target.bounds[1][1]) / 2],
|
||||
{ zoom: WORLD_ZOOM, duration, essential: true },
|
||||
)
|
||||
return
|
||||
}
|
||||
mapFly.fitBounds(target.bounds, FIT_PADDING_PX, {
|
||||
maxZoom: FIT_MAX_ZOOM, duration, essential: true,
|
||||
})
|
||||
} catch (_) { /* fitBounds throws on degenerate bounds — never break the dashboard */ }
|
||||
}
|
||||
|
||||
// ONE watcher on ONE reactive handle. `selectionVersion` is bumped once per committed
|
||||
// batch (see useSelectionBus's performance contract), so a 2,107-row "select all" runs
|
||||
// this callback ONCE — and the debounce collapses even that into a single camera move.
|
||||
const stop = watch(() => bus.selectionVersion.value, () => {
|
||||
if (timer) clearTimeout(timer) // a newer selection supersedes the pending move
|
||||
timer = setTimeout(moveCamera, CAMERA_DEBOUNCE_MS)
|
||||
})
|
||||
|
||||
return function teardown() {
|
||||
if (timer) { clearTimeout(timer); timer = null }
|
||||
try { stop() } catch (_) {}
|
||||
}
|
||||
}
|
||||
export { useSelectionCamera, selectionBounds, CAMERA_POLICY } from '@map/composables/useSelectionCamera.js'
|
||||
|
|
|
|||
|
|
@ -89,7 +89,10 @@ const { createSelectionBus } = await import(BUS)
|
|||
{
|
||||
const PANELS = [
|
||||
'plugin-designs/research-dashboard/src/composables/useDashboardShell.js',
|
||||
'plugin-designs/research-dashboard/src/composables/useCsvExport.js',
|
||||
// Follows the file (kit landing 3). Left at the plugin path this would have read the
|
||||
// re-export shim, found no predicate to object to, and PASSED VACUOUSLY — a gate going quiet
|
||||
// about the thing it guards, which is worse than one that fails.
|
||||
'library/composables/useCsvExport.js',
|
||||
'plugin-designs/research-dashboard/src/components/dashboards/DashboardFacetRail.vue',
|
||||
'plugin-designs/research-dashboard/src/components/dashboards/DashboardMapPanel.vue',
|
||||
'plugin-designs/research-dashboard/src/components/dashboards/DashboardChartsRail.vue',
|
||||
|
|
|
|||
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-rSiGAdaMvKiztkyUT3aLSCSByInhwoYwovf6HttfZ9VmNvWQJfScdSHba+8zs8kK" crossorigin="anonymous"></script>
|
||||
<script src="../all-people-groups.js" integrity="sha384-cS1k9GMXjMtOHgjVZCY+H+wjIHDCPGiWTEIFNQH7LBIzt4C1PfGQTwE1HveFgpVb" 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-CxP2odnbw+jO7dkZ9M7NfxIdcHkWBCCYX1QqWXFwiLELflTBcgjyyWvmKOF+Jaxm" crossorigin="anonymous"></script>
|
||||
<script src="../../big-picture.js" integrity="sha384-SftV2pzJtasZnTIxHPqlL4QTf6juY2qb+eoUEwSsjtU/ldg7qPmbjAjr+uNrD7Cf" 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-CxP2odnbw+jO7dkZ9M7NfxIdcHkWBCCYX1QqWXFwiLELflTBcgjyyWvmKOF+Jaxm" crossorigin="anonymous"></script>
|
||||
<script src="../../big-picture.js" integrity="sha384-SftV2pzJtasZnTIxHPqlL4QTf6juY2qb+eoUEwSsjtU/ldg7qPmbjAjr+uNrD7Cf" 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-CxP2odnbw+jO7dkZ9M7NfxIdcHkWBCCYX1QqWXFwiLELflTBcgjyyWvmKOF+Jaxm" crossorigin="anonymous"></script>
|
||||
<script src="../big-picture.js" integrity="sha384-SftV2pzJtasZnTIxHPqlL4QTf6juY2qb+eoUEwSsjtU/ldg7qPmbjAjr+uNrD7Cf" 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-XqivEnF5+3wchsfbGo8j7pnw+XoHWgx3NxPafWB/d4EQjI3QrG5UPU1w+OpjPVSt" crossorigin="anonymous"></script>
|
||||
<script src="../country-posters.js" integrity="sha384-TF/dxm3IkwgiPRQr2f07T6lz2DTIO6Nf7xGXOOgbV/W5eE0i0FVzjokpxIC/fnTh" 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-XqivEnF5+3wchsfbGo8j7pnw+XoHWgx3NxPafWB/d4EQjI3QrG5UPU1w+OpjPVSt" crossorigin="anonymous"></script>
|
||||
<script src="../country-posters.js" integrity="sha384-TF/dxm3IkwgiPRQr2f07T6lz2DTIO6Nf7xGXOOgbV/W5eE0i0FVzjokpxIC/fnTh" 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-XqivEnF5+3wchsfbGo8j7pnw+XoHWgx3NxPafWB/d4EQjI3QrG5UPU1w+OpjPVSt" crossorigin="anonymous"></script>
|
||||
<script src="../country-posters.js" integrity="sha384-TF/dxm3IkwgiPRQr2f07T6lz2DTIO6Nf7xGXOOgbV/W5eE0i0FVzjokpxIC/fnTh" 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-ILM6z2i4Q9M6GDfQ22b8IW1ZfAM/zajTXWMwYzQeYni25jlQAWlzvnLmXdI715/4" crossorigin="anonymous"></script>
|
||||
<script src="../../dashboard-plus-grid.js" integrity="sha384-loz2bnoioFdiQTWwjGA2O85kOboqZorN717h+a0zneEDSrv3uAD972Ivk6htt1gC" 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-ILM6z2i4Q9M6GDfQ22b8IW1ZfAM/zajTXWMwYzQeYni25jlQAWlzvnLmXdI715/4" crossorigin="anonymous"></script>
|
||||
<script src="../dashboard-plus-grid.js" integrity="sha384-loz2bnoioFdiQTWwjGA2O85kOboqZorN717h+a0zneEDSrv3uAD972Ivk6htt1gC" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
"bundle": "all-people-groups",
|
||||
"scriptUrl": "./all-people-groups/all-people-groups.js",
|
||||
"output": "dx-maps/all-people-groups/all-people-groups.js",
|
||||
"integrity": "sha384-rSiGAdaMvKiztkyUT3aLSCSByInhwoYwovf6HttfZ9VmNvWQJfScdSHba+8zs8kK",
|
||||
"integrity": "sha384-cS1k9GMXjMtOHgjVZCY+H+wjIHDCPGiWTEIFNQH7LBIzt4C1PfGQTwE1HveFgpVb",
|
||||
"elements": [
|
||||
"all-people-groups"
|
||||
],
|
||||
|
|
@ -28,7 +28,7 @@
|
|||
"scriptUrl": "./all-people-groups/all-people-groups.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./all-people-groups/all-people-groups/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./all-people-groups/all-people-groups.js\" integrity=\"sha384-rSiGAdaMvKiztkyUT3aLSCSByInhwoYwovf6HttfZ9VmNvWQJfScdSHba+8zs8kK\" crossorigin=\"anonymous\"></script>\n<all-people-groups profile-config='{\"profile\":\"all-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></all-people-groups>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./all-people-groups/all-people-groups.js\" integrity=\"sha384-cS1k9GMXjMtOHgjVZCY+H+wjIHDCPGiWTEIFNQH7LBIzt4C1PfGQTwE1HveFgpVb\" crossorigin=\"anonymous\"></script>\n<all-people-groups profile-config='{\"profile\":\"all-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></all-people-groups>"
|
||||
},
|
||||
"bundle": "all-people-groups"
|
||||
}
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
"bundle": "big-picture",
|
||||
"scriptUrl": "./big-picture/big-picture.js",
|
||||
"output": "dx-maps/big-picture/big-picture.js",
|
||||
"integrity": "sha384-CxP2odnbw+jO7dkZ9M7NfxIdcHkWBCCYX1QqWXFwiLELflTBcgjyyWvmKOF+Jaxm",
|
||||
"integrity": "sha384-SftV2pzJtasZnTIxHPqlL4QTf6juY2qb+eoUEwSsjtU/ldg7qPmbjAjr+uNrD7Cf",
|
||||
"elements": [
|
||||
"bp-big-picture",
|
||||
"bp-research-header",
|
||||
|
|
@ -60,7 +60,7 @@
|
|||
"scriptUrl": "./big-picture/big-picture.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./big-picture/big-picture/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./big-picture/big-picture.js\" integrity=\"sha384-CxP2odnbw+jO7dkZ9M7NfxIdcHkWBCCYX1QqWXFwiLELflTBcgjyyWvmKOF+Jaxm\" crossorigin=\"anonymous\"></script>\n<bp-big-picture profile-config='{\"profile\":\"big-picture\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></bp-big-picture>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./big-picture/big-picture.js\" integrity=\"sha384-SftV2pzJtasZnTIxHPqlL4QTf6juY2qb+eoUEwSsjtU/ldg7qPmbjAjr+uNrD7Cf\" crossorigin=\"anonymous\"></script>\n<bp-big-picture profile-config='{\"profile\":\"big-picture\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></bp-big-picture>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -157,7 +157,7 @@
|
|||
"bundle": "country-posters",
|
||||
"scriptUrl": "./country-posters/country-posters.js",
|
||||
"output": "dx-maps/country-posters/country-posters.js",
|
||||
"integrity": "sha384-XqivEnF5+3wchsfbGo8j7pnw+XoHWgx3NxPafWB/d4EQjI3QrG5UPU1w+OpjPVSt",
|
||||
"integrity": "sha384-TF/dxm3IkwgiPRQr2f07T6lz2DTIO6Nf7xGXOOgbV/W5eE0i0FVzjokpxIC/fnTh",
|
||||
"elements": [
|
||||
"country-posters-map"
|
||||
],
|
||||
|
|
@ -177,7 +177,7 @@
|
|||
"scriptUrl": "./country-posters/country-posters.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./country-posters/countries-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-XqivEnF5+3wchsfbGo8j7pnw+XoHWgx3NxPafWB/d4EQjI3QrG5UPU1w+OpjPVSt\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"countries-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-TF/dxm3IkwgiPRQr2f07T6lz2DTIO6Nf7xGXOOgbV/W5eE0i0FVzjokpxIC/fnTh\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"countries-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -191,7 +191,7 @@
|
|||
"scriptUrl": "./country-posters/country-posters.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./country-posters/country-poster-wall/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-XqivEnF5+3wchsfbGo8j7pnw+XoHWgx3NxPafWB/d4EQjI3QrG5UPU1w+OpjPVSt\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-TF/dxm3IkwgiPRQr2f07T6lz2DTIO6Nf7xGXOOgbV/W5eE0i0FVzjokpxIC/fnTh\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -205,7 +205,7 @@
|
|||
"scriptUrl": "./country-posters/country-posters.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./country-posters/country-posters-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-XqivEnF5+3wchsfbGo8j7pnw+XoHWgx3NxPafWB/d4EQjI3QrG5UPU1w+OpjPVSt\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-posters-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-TF/dxm3IkwgiPRQr2f07T6lz2DTIO6Nf7xGXOOgbV/W5eE0i0FVzjokpxIC/fnTh\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-posters-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -214,7 +214,7 @@
|
|||
"bundle": "dashboard-plus-grid",
|
||||
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
|
||||
"output": "dx-maps/dashboard-plus-grid/dashboard-plus-grid.js",
|
||||
"integrity": "sha384-ILM6z2i4Q9M6GDfQ22b8IW1ZfAM/zajTXWMwYzQeYni25jlQAWlzvnLmXdI715/4",
|
||||
"integrity": "sha384-loz2bnoioFdiQTWwjGA2O85kOboqZorN717h+a0zneEDSrv3uAD972Ivk6htt1gC",
|
||||
"elements": [
|
||||
"dashboard-plus-grid"
|
||||
],
|
||||
|
|
@ -234,7 +234,7 @@
|
|||
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./dashboard-plus-grid/dashboard-plus-grid/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-ILM6z2i4Q9M6GDfQ22b8IW1ZfAM/zajTXWMwYzQeYni25jlQAWlzvnLmXdI715/4\" crossorigin=\"anonymous\"></script>\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-loz2bnoioFdiQTWwjGA2O85kOboqZorN717h+a0zneEDSrv3uAD972Ivk6htt1gC\" crossorigin=\"anonymous\"></script>\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -663,7 +663,7 @@
|
|||
"bundle": "my-upg-100-list",
|
||||
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
|
||||
"output": "dx-maps/my-upg-100-list/my-upg-100-list.js",
|
||||
"integrity": "sha384-5toYvgs+JoirXTuwLCKpww2fU3ExYaV4qvQYY1HU/vq8sk2zvWIDqrRvsJOB0feH",
|
||||
"integrity": "sha384-aN/2qoWx/R3L4jLqfHwqilkn/zEbdb+hSr5MMfCjkzckAjpyCPRWmclB5xdY2Ysd",
|
||||
"elements": [
|
||||
"my-upg-100-list-map"
|
||||
],
|
||||
|
|
@ -683,7 +683,7 @@
|
|||
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./my-upg-100-list/poster-wall/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-5toYvgs+JoirXTuwLCKpww2fU3ExYaV4qvQYY1HU/vq8sk2zvWIDqrRvsJOB0feH\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-aN/2qoWx/R3L4jLqfHwqilkn/zEbdb+hSr5MMfCjkzckAjpyCPRWmclB5xdY2Ysd\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -697,7 +697,7 @@
|
|||
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-5toYvgs+JoirXTuwLCKpww2fU3ExYaV4qvQYY1HU/vq8sk2zvWIDqrRvsJOB0feH\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-aN/2qoWx/R3L4jLqfHwqilkn/zEbdb+hSr5MMfCjkzckAjpyCPRWmclB5xdY2Ysd\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -711,7 +711,7 @@
|
|||
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-5toYvgs+JoirXTuwLCKpww2fU3ExYaV4qvQYY1HU/vq8sk2zvWIDqrRvsJOB0feH\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-aN/2qoWx/R3L4jLqfHwqilkn/zEbdb+hSr5MMfCjkzckAjpyCPRWmclB5xdY2Ysd\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -720,7 +720,7 @@
|
|||
"bundle": "people-groups-grid",
|
||||
"scriptUrl": "./people-groups-grid/people-groups-grid.js",
|
||||
"output": "dx-maps/people-groups-grid/people-groups-grid.js",
|
||||
"integrity": "sha384-DXo4RPMd0fbp2DWquXcbx5JAUkaDhN60k7vrBsbGU4vfIDS8d8PqjvCdVwDeSv25",
|
||||
"integrity": "sha384-Oz37OCp7FfJyAzSIaCYmse5VhkiwC2dai2wkwLMagEV6WGl5QMaUVFlsK4KqVsCC",
|
||||
"elements": [
|
||||
"people-groups-grid"
|
||||
],
|
||||
|
|
@ -736,7 +736,7 @@
|
|||
"scriptUrl": "./people-groups-grid/people-groups-grid.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./people-groups-grid/people-groups-grid/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./people-groups-grid/people-groups-grid.js\" integrity=\"sha384-DXo4RPMd0fbp2DWquXcbx5JAUkaDhN60k7vrBsbGU4vfIDS8d8PqjvCdVwDeSv25\" crossorigin=\"anonymous\"></script>\n<people-groups-grid profile-config='{\"profile\":\"people-groups-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></people-groups-grid>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./people-groups-grid/people-groups-grid.js\" integrity=\"sha384-Oz37OCp7FfJyAzSIaCYmse5VhkiwC2dai2wkwLMagEV6WGl5QMaUVFlsK4KqVsCC\" crossorigin=\"anonymous\"></script>\n<people-groups-grid profile-config='{\"profile\":\"people-groups-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></people-groups-grid>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -887,7 +887,7 @@
|
|||
"bundle": "regions-dashboard",
|
||||
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
|
||||
"output": "dx-maps/regions-dashboard/regions-dashboard.js",
|
||||
"integrity": "sha384-cKWilWSiw+XD5TN+qo5hewg9vQRiliR5ib8oWfo7Sy1UBYiqBNtDStiZLYG26VyJ",
|
||||
"integrity": "sha384-vLXnlpRWI2i4RTBu4FCFjb137mpAtwwbZhocYKfV6KmLVhVDLt0LybssFJwX9uF4",
|
||||
"elements": [
|
||||
"regions-dashboard"
|
||||
],
|
||||
|
|
@ -907,7 +907,7 @@
|
|||
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./regions-dashboard/region-dashboards/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-cKWilWSiw+XD5TN+qo5hewg9vQRiliR5ib8oWfo7Sy1UBYiqBNtDStiZLYG26VyJ\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-vLXnlpRWI2i4RTBu4FCFjb137mpAtwwbZhocYKfV6KmLVhVDLt0LybssFJwX9uF4\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -921,7 +921,7 @@
|
|||
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./regions-dashboard/regions-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-cKWilWSiw+XD5TN+qo5hewg9vQRiliR5ib8oWfo7Sy1UBYiqBNtDStiZLYG26VyJ\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-vLXnlpRWI2i4RTBu4FCFjb137mpAtwwbZhocYKfV6KmLVhVDLt0LybssFJwX9uF4\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -935,7 +935,7 @@
|
|||
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./regions-dashboard/regions-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-cKWilWSiw+XD5TN+qo5hewg9vQRiliR5ib8oWfo7Sy1UBYiqBNtDStiZLYG26VyJ\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-vLXnlpRWI2i4RTBu4FCFjb137mpAtwwbZhocYKfV6KmLVhVDLt0LybssFJwX9uF4\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -944,7 +944,7 @@
|
|||
"bundle": "research-dashboard",
|
||||
"scriptUrl": "./research-dashboard/research-dashboard.js",
|
||||
"output": "dx-maps/research-dashboard/research-dashboard.js",
|
||||
"integrity": "sha384-k4iRD8oLkkWDE9J63+QecuEqx0WBLiAZfRGpapwNiV5Wxu2NyJt4uELG/UjdMmwu",
|
||||
"integrity": "sha384-oWk6JuQ5uvqU3CADet4quxkCWzDiEXnknzPjDhD1vN52mEKe6UXeX0PNIKg/T5z5",
|
||||
"elements": [
|
||||
"research-dashboard-map"
|
||||
],
|
||||
|
|
@ -960,7 +960,7 @@
|
|||
"scriptUrl": "./research-dashboard/research-dashboard.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./research-dashboard/research-dashboard/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-k4iRD8oLkkWDE9J63+QecuEqx0WBLiAZfRGpapwNiV5Wxu2NyJt4uELG/UjdMmwu\" crossorigin=\"anonymous\"></script>\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-oWk6JuQ5uvqU3CADet4quxkCWzDiEXnknzPjDhD1vN52mEKe6UXeX0PNIKg/T5z5\" crossorigin=\"anonymous\"></script>\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>"
|
||||
},
|
||||
"bundle": "research-dashboard"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
"bundle": "all-people-groups",
|
||||
"scriptUrl": "./all-people-groups/all-people-groups.js",
|
||||
"output": "dx-maps/all-people-groups/all-people-groups.js",
|
||||
"integrity": "sha384-rSiGAdaMvKiztkyUT3aLSCSByInhwoYwovf6HttfZ9VmNvWQJfScdSHba+8zs8kK",
|
||||
"integrity": "sha384-cS1k9GMXjMtOHgjVZCY+H+wjIHDCPGiWTEIFNQH7LBIzt4C1PfGQTwE1HveFgpVb",
|
||||
"elements": [
|
||||
"all-people-groups"
|
||||
],
|
||||
|
|
@ -28,7 +28,7 @@
|
|||
"scriptUrl": "./all-people-groups/all-people-groups.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./all-people-groups/all-people-groups/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./all-people-groups/all-people-groups.js\" integrity=\"sha384-rSiGAdaMvKiztkyUT3aLSCSByInhwoYwovf6HttfZ9VmNvWQJfScdSHba+8zs8kK\" crossorigin=\"anonymous\"></script>\n<all-people-groups profile-config='{\"profile\":\"all-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></all-people-groups>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./all-people-groups/all-people-groups.js\" integrity=\"sha384-cS1k9GMXjMtOHgjVZCY+H+wjIHDCPGiWTEIFNQH7LBIzt4C1PfGQTwE1HveFgpVb\" crossorigin=\"anonymous\"></script>\n<all-people-groups profile-config='{\"profile\":\"all-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></all-people-groups>"
|
||||
},
|
||||
"bundle": "all-people-groups"
|
||||
}
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
"bundle": "big-picture",
|
||||
"scriptUrl": "./big-picture/big-picture.js",
|
||||
"output": "dx-maps/big-picture/big-picture.js",
|
||||
"integrity": "sha384-CxP2odnbw+jO7dkZ9M7NfxIdcHkWBCCYX1QqWXFwiLELflTBcgjyyWvmKOF+Jaxm",
|
||||
"integrity": "sha384-SftV2pzJtasZnTIxHPqlL4QTf6juY2qb+eoUEwSsjtU/ldg7qPmbjAjr+uNrD7Cf",
|
||||
"elements": [
|
||||
"bp-big-picture",
|
||||
"bp-research-header",
|
||||
|
|
@ -60,7 +60,7 @@
|
|||
"scriptUrl": "./big-picture/big-picture.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./big-picture/big-picture/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./big-picture/big-picture.js\" integrity=\"sha384-CxP2odnbw+jO7dkZ9M7NfxIdcHkWBCCYX1QqWXFwiLELflTBcgjyyWvmKOF+Jaxm\" crossorigin=\"anonymous\"></script>\n<bp-big-picture profile-config='{\"profile\":\"big-picture\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></bp-big-picture>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./big-picture/big-picture.js\" integrity=\"sha384-SftV2pzJtasZnTIxHPqlL4QTf6juY2qb+eoUEwSsjtU/ldg7qPmbjAjr+uNrD7Cf\" crossorigin=\"anonymous\"></script>\n<bp-big-picture profile-config='{\"profile\":\"big-picture\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></bp-big-picture>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -157,7 +157,7 @@
|
|||
"bundle": "country-posters",
|
||||
"scriptUrl": "./country-posters/country-posters.js",
|
||||
"output": "dx-maps/country-posters/country-posters.js",
|
||||
"integrity": "sha384-XqivEnF5+3wchsfbGo8j7pnw+XoHWgx3NxPafWB/d4EQjI3QrG5UPU1w+OpjPVSt",
|
||||
"integrity": "sha384-TF/dxm3IkwgiPRQr2f07T6lz2DTIO6Nf7xGXOOgbV/W5eE0i0FVzjokpxIC/fnTh",
|
||||
"elements": [
|
||||
"country-posters-map"
|
||||
],
|
||||
|
|
@ -177,7 +177,7 @@
|
|||
"scriptUrl": "./country-posters/country-posters.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./country-posters/countries-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-XqivEnF5+3wchsfbGo8j7pnw+XoHWgx3NxPafWB/d4EQjI3QrG5UPU1w+OpjPVSt\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"countries-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-TF/dxm3IkwgiPRQr2f07T6lz2DTIO6Nf7xGXOOgbV/W5eE0i0FVzjokpxIC/fnTh\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"countries-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -191,7 +191,7 @@
|
|||
"scriptUrl": "./country-posters/country-posters.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./country-posters/country-poster-wall/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-XqivEnF5+3wchsfbGo8j7pnw+XoHWgx3NxPafWB/d4EQjI3QrG5UPU1w+OpjPVSt\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-TF/dxm3IkwgiPRQr2f07T6lz2DTIO6Nf7xGXOOgbV/W5eE0i0FVzjokpxIC/fnTh\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -205,7 +205,7 @@
|
|||
"scriptUrl": "./country-posters/country-posters.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./country-posters/country-posters-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-XqivEnF5+3wchsfbGo8j7pnw+XoHWgx3NxPafWB/d4EQjI3QrG5UPU1w+OpjPVSt\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-posters-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-TF/dxm3IkwgiPRQr2f07T6lz2DTIO6Nf7xGXOOgbV/W5eE0i0FVzjokpxIC/fnTh\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-posters-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -214,7 +214,7 @@
|
|||
"bundle": "dashboard-plus-grid",
|
||||
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
|
||||
"output": "dx-maps/dashboard-plus-grid/dashboard-plus-grid.js",
|
||||
"integrity": "sha384-ILM6z2i4Q9M6GDfQ22b8IW1ZfAM/zajTXWMwYzQeYni25jlQAWlzvnLmXdI715/4",
|
||||
"integrity": "sha384-loz2bnoioFdiQTWwjGA2O85kOboqZorN717h+a0zneEDSrv3uAD972Ivk6htt1gC",
|
||||
"elements": [
|
||||
"dashboard-plus-grid"
|
||||
],
|
||||
|
|
@ -234,7 +234,7 @@
|
|||
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./dashboard-plus-grid/dashboard-plus-grid/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-ILM6z2i4Q9M6GDfQ22b8IW1ZfAM/zajTXWMwYzQeYni25jlQAWlzvnLmXdI715/4\" crossorigin=\"anonymous\"></script>\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-loz2bnoioFdiQTWwjGA2O85kOboqZorN717h+a0zneEDSrv3uAD972Ivk6htt1gC\" crossorigin=\"anonymous\"></script>\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -663,7 +663,7 @@
|
|||
"bundle": "my-upg-100-list",
|
||||
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
|
||||
"output": "dx-maps/my-upg-100-list/my-upg-100-list.js",
|
||||
"integrity": "sha384-5toYvgs+JoirXTuwLCKpww2fU3ExYaV4qvQYY1HU/vq8sk2zvWIDqrRvsJOB0feH",
|
||||
"integrity": "sha384-aN/2qoWx/R3L4jLqfHwqilkn/zEbdb+hSr5MMfCjkzckAjpyCPRWmclB5xdY2Ysd",
|
||||
"elements": [
|
||||
"my-upg-100-list-map"
|
||||
],
|
||||
|
|
@ -683,7 +683,7 @@
|
|||
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./my-upg-100-list/poster-wall/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-5toYvgs+JoirXTuwLCKpww2fU3ExYaV4qvQYY1HU/vq8sk2zvWIDqrRvsJOB0feH\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-aN/2qoWx/R3L4jLqfHwqilkn/zEbdb+hSr5MMfCjkzckAjpyCPRWmclB5xdY2Ysd\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -697,7 +697,7 @@
|
|||
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-5toYvgs+JoirXTuwLCKpww2fU3ExYaV4qvQYY1HU/vq8sk2zvWIDqrRvsJOB0feH\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-aN/2qoWx/R3L4jLqfHwqilkn/zEbdb+hSr5MMfCjkzckAjpyCPRWmclB5xdY2Ysd\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -711,7 +711,7 @@
|
|||
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-5toYvgs+JoirXTuwLCKpww2fU3ExYaV4qvQYY1HU/vq8sk2zvWIDqrRvsJOB0feH\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-aN/2qoWx/R3L4jLqfHwqilkn/zEbdb+hSr5MMfCjkzckAjpyCPRWmclB5xdY2Ysd\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -720,7 +720,7 @@
|
|||
"bundle": "people-groups-grid",
|
||||
"scriptUrl": "./people-groups-grid/people-groups-grid.js",
|
||||
"output": "dx-maps/people-groups-grid/people-groups-grid.js",
|
||||
"integrity": "sha384-DXo4RPMd0fbp2DWquXcbx5JAUkaDhN60k7vrBsbGU4vfIDS8d8PqjvCdVwDeSv25",
|
||||
"integrity": "sha384-Oz37OCp7FfJyAzSIaCYmse5VhkiwC2dai2wkwLMagEV6WGl5QMaUVFlsK4KqVsCC",
|
||||
"elements": [
|
||||
"people-groups-grid"
|
||||
],
|
||||
|
|
@ -736,7 +736,7 @@
|
|||
"scriptUrl": "./people-groups-grid/people-groups-grid.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./people-groups-grid/people-groups-grid/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./people-groups-grid/people-groups-grid.js\" integrity=\"sha384-DXo4RPMd0fbp2DWquXcbx5JAUkaDhN60k7vrBsbGU4vfIDS8d8PqjvCdVwDeSv25\" crossorigin=\"anonymous\"></script>\n<people-groups-grid profile-config='{\"profile\":\"people-groups-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></people-groups-grid>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./people-groups-grid/people-groups-grid.js\" integrity=\"sha384-Oz37OCp7FfJyAzSIaCYmse5VhkiwC2dai2wkwLMagEV6WGl5QMaUVFlsK4KqVsCC\" crossorigin=\"anonymous\"></script>\n<people-groups-grid profile-config='{\"profile\":\"people-groups-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></people-groups-grid>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -887,7 +887,7 @@
|
|||
"bundle": "regions-dashboard",
|
||||
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
|
||||
"output": "dx-maps/regions-dashboard/regions-dashboard.js",
|
||||
"integrity": "sha384-cKWilWSiw+XD5TN+qo5hewg9vQRiliR5ib8oWfo7Sy1UBYiqBNtDStiZLYG26VyJ",
|
||||
"integrity": "sha384-vLXnlpRWI2i4RTBu4FCFjb137mpAtwwbZhocYKfV6KmLVhVDLt0LybssFJwX9uF4",
|
||||
"elements": [
|
||||
"regions-dashboard"
|
||||
],
|
||||
|
|
@ -907,7 +907,7 @@
|
|||
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./regions-dashboard/region-dashboards/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-cKWilWSiw+XD5TN+qo5hewg9vQRiliR5ib8oWfo7Sy1UBYiqBNtDStiZLYG26VyJ\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-vLXnlpRWI2i4RTBu4FCFjb137mpAtwwbZhocYKfV6KmLVhVDLt0LybssFJwX9uF4\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -921,7 +921,7 @@
|
|||
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./regions-dashboard/regions-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-cKWilWSiw+XD5TN+qo5hewg9vQRiliR5ib8oWfo7Sy1UBYiqBNtDStiZLYG26VyJ\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-vLXnlpRWI2i4RTBu4FCFjb137mpAtwwbZhocYKfV6KmLVhVDLt0LybssFJwX9uF4\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -935,7 +935,7 @@
|
|||
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./regions-dashboard/regions-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-cKWilWSiw+XD5TN+qo5hewg9vQRiliR5ib8oWfo7Sy1UBYiqBNtDStiZLYG26VyJ\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-vLXnlpRWI2i4RTBu4FCFjb137mpAtwwbZhocYKfV6KmLVhVDLt0LybssFJwX9uF4\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -944,7 +944,7 @@
|
|||
"bundle": "research-dashboard",
|
||||
"scriptUrl": "./research-dashboard/research-dashboard.js",
|
||||
"output": "dx-maps/research-dashboard/research-dashboard.js",
|
||||
"integrity": "sha384-k4iRD8oLkkWDE9J63+QecuEqx0WBLiAZfRGpapwNiV5Wxu2NyJt4uELG/UjdMmwu",
|
||||
"integrity": "sha384-oWk6JuQ5uvqU3CADet4quxkCWzDiEXnknzPjDhD1vN52mEKe6UXeX0PNIKg/T5z5",
|
||||
"elements": [
|
||||
"research-dashboard-map"
|
||||
],
|
||||
|
|
@ -960,7 +960,7 @@
|
|||
"scriptUrl": "./research-dashboard/research-dashboard.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./research-dashboard/research-dashboard/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-k4iRD8oLkkWDE9J63+QecuEqx0WBLiAZfRGpapwNiV5Wxu2NyJt4uELG/UjdMmwu\" crossorigin=\"anonymous\"></script>\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>"
|
||||
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-oWk6JuQ5uvqU3CADet4quxkCWzDiEXnknzPjDhD1vN52mEKe6UXeX0PNIKg/T5z5\" crossorigin=\"anonymous\"></script>\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>"
|
||||
},
|
||||
"bundle": "research-dashboard"
|
||||
}
|
||||
|
|
|
|||
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 <my-upg-100-list-map> custom element. -->
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-5toYvgs+JoirXTuwLCKpww2fU3ExYaV4qvQYY1HU/vq8sk2zvWIDqrRvsJOB0feH" crossorigin="anonymous"></script>
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-aN/2qoWx/R3L4jLqfHwqilkn/zEbdb+hSr5MMfCjkzckAjpyCPRWmclB5xdY2Ysd" 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 <my-upg-100-list-map> custom element. -->
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-5toYvgs+JoirXTuwLCKpww2fU3ExYaV4qvQYY1HU/vq8sk2zvWIDqrRvsJOB0feH" crossorigin="anonymous"></script>
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-aN/2qoWx/R3L4jLqfHwqilkn/zEbdb+hSr5MMfCjkzckAjpyCPRWmclB5xdY2Ysd" 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 <my-upg-100-list-map> custom element. -->
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-5toYvgs+JoirXTuwLCKpww2fU3ExYaV4qvQYY1HU/vq8sk2zvWIDqrRvsJOB0feH" crossorigin="anonymous"></script>
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-aN/2qoWx/R3L4jLqfHwqilkn/zEbdb+hSr5MMfCjkzckAjpyCPRWmclB5xdY2Ysd" 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 <people-groups-grid> custom element. -->
|
||||
<script src="../../people-groups-grid.js" integrity="sha384-DXo4RPMd0fbp2DWquXcbx5JAUkaDhN60k7vrBsbGU4vfIDS8d8PqjvCdVwDeSv25" crossorigin="anonymous"></script>
|
||||
<script src="../../people-groups-grid.js" integrity="sha384-Oz37OCp7FfJyAzSIaCYmse5VhkiwC2dai2wkwLMagEV6WGl5QMaUVFlsK4KqVsCC" 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 <people-groups-grid> custom element. -->
|
||||
<script src="../people-groups-grid.js" integrity="sha384-DXo4RPMd0fbp2DWquXcbx5JAUkaDhN60k7vrBsbGU4vfIDS8d8PqjvCdVwDeSv25" crossorigin="anonymous"></script>
|
||||
<script src="../people-groups-grid.js" integrity="sha384-Oz37OCp7FfJyAzSIaCYmse5VhkiwC2dai2wkwLMagEV6WGl5QMaUVFlsK4KqVsCC" 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 <people-groups-grid> custom element. -->
|
||||
<script src="../../people-groups-grid.js" integrity="sha384-DXo4RPMd0fbp2DWquXcbx5JAUkaDhN60k7vrBsbGU4vfIDS8d8PqjvCdVwDeSv25" crossorigin="anonymous"></script>
|
||||
<script src="../../people-groups-grid.js" integrity="sha384-Oz37OCp7FfJyAzSIaCYmse5VhkiwC2dai2wkwLMagEV6WGl5QMaUVFlsK4KqVsCC" 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 <regions-dashboard> custom element. -->
|
||||
<script src="../regions-dashboard.js" integrity="sha384-cKWilWSiw+XD5TN+qo5hewg9vQRiliR5ib8oWfo7Sy1UBYiqBNtDStiZLYG26VyJ" crossorigin="anonymous"></script>
|
||||
<script src="../regions-dashboard.js" integrity="sha384-vLXnlpRWI2i4RTBu4FCFjb137mpAtwwbZhocYKfV6KmLVhVDLt0LybssFJwX9uF4" 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 <regions-dashboard> custom element. -->
|
||||
<script src="../regions-dashboard.js" integrity="sha384-cKWilWSiw+XD5TN+qo5hewg9vQRiliR5ib8oWfo7Sy1UBYiqBNtDStiZLYG26VyJ" crossorigin="anonymous"></script>
|
||||
<script src="../regions-dashboard.js" integrity="sha384-vLXnlpRWI2i4RTBu4FCFjb137mpAtwwbZhocYKfV6KmLVhVDLt0LybssFJwX9uF4" 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 <regions-dashboard> custom element. -->
|
||||
<script src="../regions-dashboard.js" integrity="sha384-cKWilWSiw+XD5TN+qo5hewg9vQRiliR5ib8oWfo7Sy1UBYiqBNtDStiZLYG26VyJ" crossorigin="anonymous"></script>
|
||||
<script src="../regions-dashboard.js" integrity="sha384-vLXnlpRWI2i4RTBu4FCFjb137mpAtwwbZhocYKfV6KmLVhVDLt0LybssFJwX9uF4" 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 <research-dashboard-map> custom element. -->
|
||||
<script src="../research-dashboard.js" integrity="sha384-k4iRD8oLkkWDE9J63+QecuEqx0WBLiAZfRGpapwNiV5Wxu2NyJt4uELG/UjdMmwu" crossorigin="anonymous"></script>
|
||||
<script src="../research-dashboard.js" integrity="sha384-oWk6JuQ5uvqU3CADet4quxkCWzDiEXnknzPjDhD1vN52mEKe6UXeX0PNIKg/T5z5" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -28,11 +28,11 @@
|
|||
"from the api, no problem \u2014 if they want to set a mapbox sk they can'. This is token-ladder",
|
||||
"RUNG 3 (see internal/generate-tree.mjs:595): rungs 1-3 are used DIRECTLY with no network call,",
|
||||
"so baking it makes every map work out of the box on ANY host \u2014 including one with no",
|
||||
"/api/maps/token endpoint and no CORS on the fallback, which is exactly why nuxtbase.app maps",
|
||||
"/api/maps/token endpoint and no CORS on the fallback, which is exactly why maps on the live test host",
|
||||
"were blank. SOURCE OF TRUTH: https://doxa.life/api/maps/token (see",
|
||||
"_qa-cycle/01-ideation-research/REFERENCE-mapbox-token-endpoint.md). Re-fetch from there rather",
|
||||
"than copying this literal around. A pk. is a PUBLIC key protected by URL restrictions, not by",
|
||||
"secrecy \u2014 never put an sk. here; check:public-hygiene still fails the build on sk./tk."
|
||||
],
|
||||
"//tokenUrl-2026-09-22": "EMPTIED 2026-09-22. It pointed at https://doxa.life/api/maps/token, which serves a URL-RESTRICTED token \u2014 tiles 403 from every origin except doxa.life, so the basemap rendered as an empty void while pins drew fine (the Driver's exact report: 'it's loading the pins, but it's not loading the actual underlying map'). That endpoint sits ABOVE the baked token in the ladder, so leaving it set would fetch the broken token and overwrite the good one. Measured: the baked token = 200 from no-Referer, nuxtbase.app AND localhost; the domain-restricted token = 403 from all three."
|
||||
"//tokenUrl-2026-09-22": "EMPTIED 2026-09-22. It pointed at https://doxa.life/api/maps/token, which serves a URL-RESTRICTED token \u2014 tiles 403 from every origin except doxa.life, so the basemap rendered as an empty void while pins drew fine (the Driver's exact report: 'it's loading the pins, but it's not loading the actual underlying map'). That endpoint sits ABOVE the baked token in the ladder, so leaving it set would fetch the broken token and overwrite the good one. Measured: the baked token = 200 from no-Referer, the live test host AND localhost; the domain-restricted token = 403 from all three."
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue