Update from source 12a64702
This commit is contained in:
parent
962736af7e
commit
7de15f213d
12 changed files with 693 additions and 135 deletions
250
internal/check-zoning-declared.mjs
Normal file
250
internal/check-zoning-declared.mjs
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-zoning-declared — a project list's ZONING is consistent with what its author DECLARED.
|
||||
*
|
||||
* WHY THIS EXISTS, and why it is a declaration-checker rather than a rule-checker. UPG-100 is becoming a
|
||||
* PATTERN: any project list (7000 / 4700 / 3000 / 2000 / 700) gets its own ~100-group clusters, zoned by
|
||||
* an agent who is not the author of this code. Zoning is human judgement — geography first, balanced with
|
||||
* language — so a gate cannot decide whether a zoning is GOOD. What it can decide is whether the zoning
|
||||
* matches what the author said it would be, which turns "did I zone this right?" from a browser question
|
||||
* into an exit code.
|
||||
*
|
||||
* ★ EVERY OBVIOUS RULE I WOULD HAVE WRITTEN FROM INTUITION FAILED THE REFERENCE IMPLEMENTATION, measured
|
||||
* against the live 28 zones and 2,107 records on 2026-09-27. That is the reason this file is shaped the
|
||||
* way it is:
|
||||
*
|
||||
* "every group in exactly one zone" → 112 groups are in two. 110 are the DEAF BLOC, and that is
|
||||
* design-approved (legend-tree.js:151, slice 08): an affinity
|
||||
* bloc's members also live in a geographic zone. Per-zone
|
||||
* equality holds; only a naive GLOBAL sum over-counts. So the
|
||||
* rule is "at most one GEOGRAPHIC zone", with blocs declared.
|
||||
* "no group is unzoned" → 11 are, all Japan/China/Mongolia, and plausibly deliberate.
|
||||
* A gate cannot tell a deliberate exclusion from a silent drop.
|
||||
* Hence expected_orphans: the author states them, and the gate
|
||||
* fails on a CHANGE rather than on their existence.
|
||||
* "no two zones overlap" → 2 non-bloc overlaps exist (cocos-malays, andorrans) because
|
||||
* "Scattered / Diaspora" overlaps the regions it scatters
|
||||
* across. Declared, pending the agent.
|
||||
*
|
||||
* A gate that fails on the only correct example of the thing it checks teaches people to disable it.
|
||||
*
|
||||
* ⚠ AND TWO OF THESE ASSERTIONS WOULD HAVE CAUGHT THIS CYCLE'S REAL DEFECTS BEFORE A BROWSER DID.
|
||||
* A1 (every zone resolves to ≥1 group) is exactly the state `deaf` and the five wagf zones were in when
|
||||
* the packet generator skipped them with "no groups resolved" — six of 28 zones silently producing
|
||||
* nothing, found only by generating all 28 and reading the log. A4 (every selector type is supported)
|
||||
* is the other: selector-evaluator THROWS on an unknown type, which surfaces at render time on one
|
||||
* zone's page rather than at build time on the whole set.
|
||||
*
|
||||
* Usage: node internal/check-zoning-declared.mjs [--list upg-100] [--records <file>]
|
||||
* node internal/check-zoning-declared.mjs --self-test
|
||||
*
|
||||
* Exit 0 pass · 1 fail · 75 NOT MEASURED (no records source — "found nothing" is not "found nothing wrong")
|
||||
*/
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||
const REPO = process.env.DOXA_REPO || resolve(HERE, '..')
|
||||
const TAG = 'ZONING-DECLARED'
|
||||
|
||||
const argv = process.argv.slice(2)
|
||||
const SELF_TEST = argv.includes('--self-test')
|
||||
const flag = (name, dflt) => {
|
||||
const i = argv.indexOf(`--${name}`)
|
||||
return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : dflt
|
||||
}
|
||||
const LIST = flag('list', 'upg-100')
|
||||
|
||||
// ── the five assertions, as pure functions so --self-test can exercise them without any data ──────────
|
||||
|
||||
/** A1 — a zone that resolves to nothing is a broken selector, not an empty region. */
|
||||
export function findEmptyZones(zoneSlugs) {
|
||||
return [...zoneSlugs].filter(([, slugs]) => !slugs || slugs.length === 0).map(([id]) => id)
|
||||
}
|
||||
|
||||
/** A2 — at most one GEOGRAPHIC zone per group. Bloc zones are a declared second partition. */
|
||||
export function findGeographicOverlaps(zoneSlugs, blocIds) {
|
||||
const bloc = new Set(blocIds || [])
|
||||
const owners = new Map()
|
||||
for (const [id, slugs] of zoneSlugs) {
|
||||
if (bloc.has(id)) continue
|
||||
for (const s of slugs) {
|
||||
if (!owners.has(s)) owners.set(s, [])
|
||||
owners.get(s).push(id)
|
||||
}
|
||||
}
|
||||
return [...owners].filter(([, zs]) => zs.length > 1).map(([slug, zones]) => ({ slug, zones }))
|
||||
}
|
||||
|
||||
/** A3 — the orphan set must EQUAL the declared one. Both directions matter (see below). */
|
||||
export function diffOrphans(actual, declared) {
|
||||
const a = new Set(actual); const d = new Set(declared || [])
|
||||
return {
|
||||
// A new orphan is a group that silently stopped being zoned — the failure this gate exists for.
|
||||
unexpected: [...a].filter((s) => !d.has(s)).sort(),
|
||||
// ★ AND A *RESOLVED* ORPHAN FAILS TOO, which is deliberate and non-obvious. If a declared orphan is
|
||||
// now zoned, that is good news and a STALE DECLARATION: leaving it listed means the next real drop of
|
||||
// that same group passes silently. A declaration that is not maintained stops being evidence.
|
||||
stale: [...d].filter((s) => !a.has(s)).sort(),
|
||||
}
|
||||
}
|
||||
|
||||
/** A4 — every selector type must be one the evaluator supports (it throws at render time otherwise). */
|
||||
export function findUnsupportedSelectors(selectorsById, supported) {
|
||||
const ok = new Set(supported)
|
||||
const bad = []
|
||||
const walk = (id, sel) => {
|
||||
if (!sel || !sel.type) { bad.push({ id, type: '(none)' }); return }
|
||||
if (!ok.has(sel.type)) bad.push({ id, type: sel.type })
|
||||
if (sel.type === 'union') for (const s of sel.selectors || []) walk(id, s)
|
||||
}
|
||||
for (const [id, sel] of selectorsById) walk(id, sel)
|
||||
return bad
|
||||
}
|
||||
|
||||
/** A5 — a zone wildly over the page budget is usually a selector that matched too much. */
|
||||
export function findOversizedZones(zoneSlugs, maxGroups) {
|
||||
if (!(maxGroups > 0)) return []
|
||||
return [...zoneSlugs]
|
||||
.filter(([, slugs]) => (slugs?.length || 0) > maxGroups)
|
||||
.map(([id, slugs]) => ({ id, count: slugs.length }))
|
||||
}
|
||||
|
||||
// ── self-test ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
if (SELF_TEST) {
|
||||
// ★ PROVE EACH ASSERTION FIRES AND THAT IT IGNORES THE DECLARED CASES. A gate whose assertions have
|
||||
// never matched anything is indistinguishable from a gate with an inverted condition, and three of
|
||||
// these five are subtle enough that "it passed on the real data" is not evidence they work.
|
||||
const fails = []
|
||||
const ok = (cond, msg) => { console.log(` ${cond ? 'ok ' : 'FAIL'} ${msg}`); if (!cond) fails.push(msg) }
|
||||
|
||||
ok(findEmptyZones([['a', ['x']], ['b', []]]).join() === 'b', 'A1 flags an empty zone, not a full one')
|
||||
ok(findEmptyZones([['a', ['x']]]).length === 0, 'A1 passes when every zone resolves')
|
||||
|
||||
const zs = [['geo1', ['p', 'q']], ['geo2', ['q']], ['deaf', ['p', 'q']]]
|
||||
ok(findGeographicOverlaps(zs, ['deaf']).map((o) => o.slug).join() === 'q',
|
||||
'A2 flags the geographic overlap and EXEMPTS the declared bloc (this is the deaf double-count)')
|
||||
ok(findGeographicOverlaps(zs, []).length === 2,
|
||||
'A2 WITHOUT the bloc declaration flags both — so the exemption is doing the work, not the regex')
|
||||
|
||||
const d1 = diffOrphans(['new', 'known'], ['known'])
|
||||
ok(d1.unexpected.join() === 'new' && d1.stale.length === 0, 'A3 flags a NEW orphan')
|
||||
const d2 = diffOrphans([], ['known'])
|
||||
ok(d2.stale.join() === 'known' && d2.unexpected.length === 0,
|
||||
'A3 also flags a RESOLVED orphan as a stale declaration — an unmaintained declaration is not evidence')
|
||||
ok(diffOrphans(['known'], ['known']).unexpected.length === 0, 'A3 passes on an exact match')
|
||||
|
||||
const sup = ['country_code', 'union']
|
||||
ok(findUnsupportedSelectors([['z', { type: 'wagf_block' }]], sup)[0].type === 'wagf_block',
|
||||
'A4 flags an unsupported type')
|
||||
ok(findUnsupportedSelectors([['z', { type: 'union', selectors: [{ type: 'nope' }] }]], sup)
|
||||
.some((b) => b.type === 'nope'), 'A4 descends INTO a union — southern-africa-islands is one')
|
||||
ok(findUnsupportedSelectors([['z', null]], sup)[0].type === '(none)', 'A4 flags a missing selector')
|
||||
|
||||
ok(findOversizedZones([['big', new Array(300).fill('x')]], 200)[0].count === 300, 'A5 flags an oversized zone')
|
||||
ok(findOversizedZones([['ok', new Array(120).fill('x')]], 200).length === 0, 'A5 passes a large-but-sane zone')
|
||||
|
||||
if (fails.length) {
|
||||
console.error(`\n${TAG} --self-test: FAIL — ${fails.length} case(s) wrong.`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`\n${TAG} --self-test: PASS — all five assertions fire on a real violation and stay quiet on `
|
||||
+ 'the declared exceptions.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// ── the run ───────────────────────────────────────────────────────────────────────────────────────────
|
||||
const DATA = join(REPO, 'plugin-designs', 'my-upg-100-list', 'data')
|
||||
const declPath = join(DATA, LIST === 'upg-100' ? 'zoning-declarations.json' : `zoning-declarations.${LIST}.json`)
|
||||
if (!existsSync(declPath)) {
|
||||
console.log(`${TAG}: NOT MEASURED — no declarations at ${declPath}.`)
|
||||
console.log(' A list without declarations cannot be checked against them. Copy upg-100\'s and fill it in.')
|
||||
process.exit(75)
|
||||
}
|
||||
const decl = JSON.parse(readFileSync(declPath, 'utf8'))
|
||||
|
||||
// Records: the same source everything else uses. NOT fetched here — a gate that needs the network is a
|
||||
// gate that SKIPs in CI, which is how check:packets-all went unevaluated on every build for weeks.
|
||||
const recordsPath = flag('records', join(REPO, 'library/scripts/posters/out/_records.json'))
|
||||
if (!existsSync(recordsPath)) {
|
||||
console.log(`${TAG}: NOT MEASURED — no records at ${recordsPath}.`)
|
||||
console.log(' Generate them once (OFFLINE=1 or a packet run caches them), or pass --records <file>.')
|
||||
console.log(' Exit 75 rather than 0: "found no records" is not "found nothing wrong".')
|
||||
process.exit(75)
|
||||
}
|
||||
const records = JSON.parse(readFileSync(recordsPath, 'utf8'))
|
||||
|
||||
const clusters = JSON.parse(readFileSync(join(DATA, 'clusters.json'), 'utf8'))
|
||||
const { buildLegendNodes, leafZones } = await import(pathToFileURL(join(DATA, 'legend-tree.js')).href)
|
||||
const { evaluate } = await import(pathToFileURL(join(DATA, 'selector-evaluator.js')).href)
|
||||
|
||||
// Selectors by id, exactly as generate.mjs:2801-2808 and packet.mjs's allZones do it — leaves do not
|
||||
// carry one, and `deaf`'s is implicit.
|
||||
const selectorsById = new Map()
|
||||
for (const k of ['lists', 'asia_lists', 'uncovered_lists']) {
|
||||
for (const z of clusters[k] || []) selectorsById.set(z.id, z.selector)
|
||||
}
|
||||
selectorsById.set('deaf', { type: 'affinity_bloc', rop1: 'A017' })
|
||||
|
||||
const zones = leafZones(buildLegendNodes(clusters, { records: null }))
|
||||
const zoneSlugs = []
|
||||
for (const z of zones) {
|
||||
const sel = selectorsById.get(z.id)
|
||||
let slugs = []
|
||||
if (sel?.type === 'pg_slugs') slugs = sel.values || []
|
||||
else if (sel) { try { slugs = [...evaluate(sel, records)] } catch (_) { slugs = [] } }
|
||||
zoneSlugs.push([z.id, slugs])
|
||||
}
|
||||
|
||||
const SUPPORTED = ['country_code', 'country_codes', 'wagf_block', 'wagf_region', 'union', 'deaf_predicate',
|
||||
'affinity_bloc', 'pg_slugs']
|
||||
const covered = new Set(zoneSlugs.flatMap(([, s]) => s))
|
||||
const actualOrphans = records.map((r) => r.slug).filter((s) => s && !covered.has(s))
|
||||
|
||||
const declaredOverlaps = new Set((decl.known_geographic_overlaps?.slugs || []).map((o) => o.slug || o))
|
||||
const empty = findEmptyZones(zoneSlugs)
|
||||
const overlaps = findGeographicOverlaps(zoneSlugs, decl.bloc_zones?.ids).filter((o) => !declaredOverlaps.has(o.slug))
|
||||
const orphanDiff = diffOrphans(actualOrphans, decl.expected_orphans?.slugs)
|
||||
const unsupported = findUnsupportedSelectors(selectorsById, SUPPORTED)
|
||||
const oversized = findOversizedZones(zoneSlugs, decl.page_budget?.max_groups)
|
||||
|
||||
console.log(TAG)
|
||||
console.log(` list ${LIST} · ${zones.length} zone(s) · ${records.length} record(s) · ${covered.size} covered`)
|
||||
console.log(` declared: ${(decl.bloc_zones?.ids || []).length} bloc zone(s), `
|
||||
+ `${(decl.expected_orphans?.slugs || []).length} expected orphan(s), `
|
||||
+ `${declaredOverlaps.size} known overlap(s)`)
|
||||
|
||||
const problems = []
|
||||
if (empty.length) problems.push(`A1 ${empty.length} zone(s) resolve to NO groups: ${empty.join(', ')}`)
|
||||
if (overlaps.length) {
|
||||
problems.push(`A2 ${overlaps.length} group(s) in two GEOGRAPHIC zones and not declared: `
|
||||
+ overlaps.slice(0, 8).map((o) => `${o.slug} [${o.zones.join(' + ')}]`).join('; '))
|
||||
}
|
||||
if (orphanDiff.unexpected.length) {
|
||||
problems.push(`A3 ${orphanDiff.unexpected.length} NEW orphan(s) — in the list, in no zone, not declared: `
|
||||
+ orphanDiff.unexpected.slice(0, 12).join(', '))
|
||||
}
|
||||
if (orphanDiff.stale.length) {
|
||||
problems.push(`A3 ${orphanDiff.stale.length} declared orphan(s) are now ZONED, so the declaration is `
|
||||
+ `stale — remove them or the next real drop passes silently: ${orphanDiff.stale.slice(0, 12).join(', ')}`)
|
||||
}
|
||||
if (unsupported.length) {
|
||||
problems.push(`A4 ${unsupported.length} zone(s) use a selector type the evaluator does not support: `
|
||||
+ unsupported.map((b) => `${b.id}(${b.type})`).join(', '))
|
||||
}
|
||||
if (oversized.length) {
|
||||
problems.push(`A5 ${oversized.length} zone(s) exceed the declared page budget `
|
||||
+ `(${decl.page_budget?.max_groups}): ` + oversized.map((o) => `${o.id}=${o.count}`).join(', '))
|
||||
}
|
||||
|
||||
if (problems.length) {
|
||||
console.error(`\n${TAG}: FAIL — ${problems.length} problem(s).`)
|
||||
for (const p of problems) console.error(` ✗ ${p}`)
|
||||
console.error('\n Each of these is EITHER a zoning defect OR a declaration that needs updating, and the')
|
||||
console.error(' gate cannot tell which — that is the author\'s call. What it can guarantee is that the')
|
||||
console.error(' zoning and the declaration have not drifted apart silently.')
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`${TAG}: PASS — the zoning matches every declaration: no empty zone, no undeclared geographic `
|
||||
+ 'overlap, the orphan set is exactly as declared, every selector type is supported, no zone over budget.')
|
||||
|
|
@ -236,6 +236,8 @@
|
|||
"check:vision-card-numbers": "node internal/check-vision-card-numbers.mjs",
|
||||
"check:wp-embed": "php deployment/wordpress/tests/wp-embed-contract-test.php",
|
||||
"check:zone-census": "node internal/check-zone-census.mjs",
|
||||
"check:zoning-declared": "node internal/check-zoning-declared.mjs",
|
||||
"check:zoning-declared:self-test": "node internal/check-zoning-declared.mjs --self-test",
|
||||
"clean": "node internal/clean.mjs",
|
||||
"deploy:dev": "bun run build && node internal/sync-dropin.mjs --from-config --prune",
|
||||
"dev": "bun x vite",
|
||||
|
|
@ -434,6 +436,7 @@
|
|||
"check:url-resolver": "cap-5 resolve() must not prefix a rooted URL (the dev-surface 404 class)",
|
||||
"check:vision-card-numbers": "the DOXA Vision Card must show the numbers the DATA gives: independently recomputes all four funnel steps (and DOXA-in-frontier) from people_groups.csv + the saved DOXA list, compares with vision-funnel.json, and with --url also with the numbers painted in the card. A typed-in number, a stale funnel file or a rendering bug fails it.",
|
||||
"check:zone-census": "every zone a USER CAN SELECT must have a poster (asks the LEGEND, not clusters.json — two collapsed deaf lists are unreachable and not a gap). CONDITIONAL: SKIPs without out/posters/, which is gitignored — run `bun run posters` first. It cannot be fixtured like check:packets-all, because it asserts COVERAGE of real generated output and a fixture would only assert the fixture is complete.",
|
||||
"check:zoning-declared": "A project list's ZONING matches what its author DECLARED (data/zoning-declarations.json). Zoning is human judgement — geography first, balanced with language — so a gate cannot decide whether a zoning is GOOD; it can decide whether it still matches what was stated, which is what makes UPG-100 reusable as a PATTERN for any project list (7000/4700/3000/2000/700) zoned by an agent who did not write this code. ★ EVERY OBVIOUS RULE FAILED THE REFERENCE IMPLEMENTATION when measured on the live 28 zones / 2107 records: 'every group in exactly one zone' is wrong (112 double-claims, 110 of them the design-approved deaf bloc double-count, legend-tree.js:151) and 'no group unzoned' is wrong (11 JPN/CHN/MNG groups, plausibly deliberate). So it asserts A1 no zone resolves to zero groups, A2 at most one GEOGRAPHIC zone per group with bloc zones declared as a second partition, A3 the orphan set EQUALS the declared set (a RESOLVED orphan fails too — a stale declaration stops being evidence), A4 every selector type is one selector-evaluator supports, A5 no zone over the declared page budget. A1 and A4 would both have caught this cycle's real defects (deaf + 5 wagf zones resolving to nothing) before a browser did, and A2 caught a third overlap my own hand analysis had missed. Exit 75 without records or declarations. Run :self-test to prove the five still fire.",
|
||||
"deploy:dev": "destination + allowlist come from vite.doxa-maps-build-config.json (dropin.dest / dropin.include); DOXA_DROPIN_DEST=<dir> overrides the destination",
|
||||
"gallery": "writes public/__gallery/index.html — the ARCHIVED bundle-index gallery, from public/doxa-maps/manifest.json. It is NO LONGER the root page: as of 2026-09-22 `/` mounts <plugin-studio> in BOTH trees (the built root page is written by `bun run tree`), and the gallery is the opt-in index at /__gallery. Archived, not deleted — the dev server imports renderGallery()/readBuildConfig() from this module.",
|
||||
"gen:iso3-to-alpha2": "derive library/data/iso3-to-alpha2.json from vendored CLDR (48.2.2, Unicode-3.0) joined against the 242 codes in library/data/countries.json, so Intl.DisplayNames can produce a LOCALIZED country name — it takes alpha-2 and our data speaks alpha-3. Offline, no dependency. GRADES ITS OWN OUTPUT and writes nothing on failure: 242 coverage, injectivity, and KOS -> XK as the one documented exception (Kosovo has no ISO assignment). Cross-checks the 176-country research spine map and REPORTS disagreements without rewriting it — that file drives fetches and is not this generator's to edit.",
|
||||
|
|
|
|||
104
plugin-designs/my-upg-100-list/data/zones-for-list.js
vendored
Normal file
104
plugin-designs/my-upg-100-list/data/zones-for-list.js
vendored
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
/**
|
||||
* zones-for-list — which ZONES a project list is clustered into.
|
||||
*
|
||||
* THE GAP THIS CLOSES. Membership is already parameterised by list: library/lists/lists.json registers
|
||||
* five (doxa = Project 2000, unreached = 7000, frontier = 4700, frontier-500k = 700, unengaged), and
|
||||
* resolvePeopleGroupList(profileConfig) already picks one from `config.list` / `?list=` / window.MAP_LIST
|
||||
* with a default. What NO list has is ZONES: a list file says WHICH people groups, and nothing says how
|
||||
* to cluster them into the ~100-group printable zones a poster needs. clusters.json is UPG-100's, and it
|
||||
* was the only answer available.
|
||||
*
|
||||
* ★ WHY THIS IS A RESOLVER AND NOT A DYNAMIC IMPORT. The two consumers do:
|
||||
* import { lists, asia_lists, uncovered_lists } from './data/clusters.json'
|
||||
* and that NAMED import is load-bearing — its own comment says so: only the zone arrays ship, while
|
||||
* clusters.json's eight bookkeeping fields (version, generated, source_total, validation_run, …) are
|
||||
* tree-shaken out. Swapping it for `await import(path)` would ship the whole file in every bundle, and
|
||||
* would make the zone list asynchronous in code that reads it synchronously at setup. So the default
|
||||
* stays a static named import, and alternates are REGISTERED rather than computed from a path.
|
||||
*
|
||||
* ⚠ AND THE DEFAULT MUST BE THE OLD BEHAVIOUR EXACTLY. A list with no zones of its own gets UPG-100's,
|
||||
* which is what every caller gets today — so this file is inert until someone registers a second set.
|
||||
* That is deliberate: the pattern has to be landable before any second list exists, or it cannot be the
|
||||
* thing the second list is built against.
|
||||
*
|
||||
* ★ WHAT A NEW LIST ACTUALLY SHIPS, and this is the pattern for another agent to follow:
|
||||
* 1. data/zones/<list-id>.json the four zone-bearing keys, same shape as clusters.json:
|
||||
* lists / deaf_lists / asia_lists / uncovered_lists
|
||||
* 2. data/zoning-declarations.<id>.json the facts a gate cannot infer — bloc zones, expected orphans,
|
||||
* known geographic overlaps, page budget
|
||||
* 3. one line in ZONES_BY_LIST below
|
||||
* Then `npm run check:zoning-declared -- --list <id>` grades the zoning, and the legend, poster wall and
|
||||
* packet all follow, because all three already enumerate through leafZones(buildLegendNodes(zones)).
|
||||
*
|
||||
* ZONING RULE, from the agent (2026-09-27), recorded here because it is the judgement the format cannot
|
||||
* capture: cluster by GEOGRAPHY FIRST so the poster maps make sense, balanced with LANGUAGE — languages
|
||||
* usually cluster geographically, so the two mostly agree, and where they disagree geography wins for a
|
||||
* map's sake. UPG-100's own 28 already mix the two: the country_codes zones are geographic, and the 12
|
||||
* India/China pg_slugs zones are hand groupings where language and geography were traded off.
|
||||
*
|
||||
* ⚠ THE DEAF ZONE IS THE KNOWN EXCEPTION AND MUST NOT BE FORCED. `deaf` is affinity bloc A017: 123 groups
|
||||
* spanning every region, global by construction, and the one poster whose zone cannot be a map extent. It
|
||||
* breaks the geography-first rule the rest of the pattern rests on, and the agent has said he has not
|
||||
* solved it. A list whose clusters include an affinity or language bloc inherits the same open question —
|
||||
* declare it in bloc_zones so the gate treats it as a second partition, and do NOT invent a geography for
|
||||
* it to make a validator happy.
|
||||
*
|
||||
* ⚠ THIS MODULE IS BROWSER/VITE-ONLY, BY CONSTRUCTION. The static JSON import below is what keeps the
|
||||
* tree-shaking (see above), and bare `import … from './x.json'` is a bundler feature: plain Node throws
|
||||
* ERR_IMPORT_ASSERTION_TYPE_MISSING without an `with { type: 'json' }` attribute. That is fine and is the
|
||||
* existing convention here — NO Node-side file imports clusters.json as a module; packet.mjs and every
|
||||
* gate read it with readFileSync + JSON.parse (packet.mjs:125). So a Node consumer that wants the same
|
||||
* resolution should read the JSON itself and pass the object on, rather than importing this file.
|
||||
*/
|
||||
import { lists, deaf_lists, asia_lists, uncovered_lists } from './clusters.json'
|
||||
|
||||
/** UPG-100's zones — the default for every list until one registers its own. */
|
||||
const UPG_100_ZONES = { lists, deaf_lists, asia_lists, uncovered_lists }
|
||||
|
||||
/**
|
||||
* list id → its zone set.
|
||||
* ★ A REGISTRY, NOT A DIRECTORY SCAN: each entry is a static import somewhere, so the bundler can see
|
||||
* which zone files a bundle actually reaches and drop the rest. A scan would defeat that and would also
|
||||
* make a missing file a runtime error instead of a build one.
|
||||
*/
|
||||
const ZONES_BY_LIST = {
|
||||
// 'project-3000': PROJECT_3000_ZONES, ← the shape a second list adds
|
||||
}
|
||||
|
||||
/**
|
||||
* The zones for a list id.
|
||||
*
|
||||
* @param {string} [listId] as returned by resolvePeopleGroupList(profileConfig)
|
||||
* @returns {{lists:Array, deaf_lists?:Array, asia_lists:Array, uncovered_lists:Array}}
|
||||
*
|
||||
* ⚠ FALLS BACK RATHER THAN THROWING, and says so once. A list without registered zones is the NORMAL
|
||||
* state right now (five lists, one zone set), so throwing would break every caller for a condition that
|
||||
* is expected. But it is silent-wrong-data territory — a reader would see UPG-100's zones under another
|
||||
* list's name and have no way to tell — so it warns exactly once per id, naming what it did.
|
||||
*/
|
||||
const warned = new Set()
|
||||
export function zonesForList(listId) {
|
||||
const id = String(listId || '')
|
||||
const own = ZONES_BY_LIST[id]
|
||||
if (own) return own
|
||||
if (id && !warned.has(id) && typeof console !== 'undefined') {
|
||||
warned.add(id)
|
||||
console.warn(`[zones] list "${id}" has no zones of its own; showing UPG-100's ${countZones(UPG_100_ZONES)} `
|
||||
+ 'zones. Register a set in data/zones-for-list.js to give it its own poster clusters.')
|
||||
}
|
||||
return UPG_100_ZONES
|
||||
}
|
||||
|
||||
/** Zone count across the zone-bearing keys. NOT a leaf count — buildLegendNodes collapses deaf_lists. */
|
||||
export function countZones(zones) {
|
||||
let n = 0
|
||||
for (const k of ['lists', 'asia_lists', 'uncovered_lists']) n += (zones?.[k] || []).length
|
||||
// ★ deaf_lists is counted as ONE, not as its member count: legend-tree collapses its two entries into a
|
||||
// single unified leaf (legend-tree.js:106, "13 + 12 + 2 + 1 deaf = 28"). Counting its members would
|
||||
// report 29 for a 28-leaf tree — a number that disagrees with every other count in the app.
|
||||
if ((zones?.deaf_lists || []).length) n += 1
|
||||
return n
|
||||
}
|
||||
|
||||
/** Every list id that has its own zones — for a gate or a diagnostic to enumerate. */
|
||||
export function listsWithOwnZones() { return Object.keys(ZONES_BY_LIST) }
|
||||
64
plugin-designs/my-upg-100-list/data/zoning-declarations.json
vendored
Normal file
64
plugin-designs/my-upg-100-list/data/zoning-declarations.json
vendored
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
{
|
||||
"_meta": {
|
||||
"what": "Zoning declarations for the UPG-100 list: the facts about this list's zoning that a gate cannot infer and a reader cannot guess from the data.",
|
||||
"why": "check-zoning-declared.mjs asserts the ACTUAL zoning against these DECLARATIONS. Without them a gate can only test self-consistency, which is satisfied by a zoning that silently dropped groups.",
|
||||
"how_to_use": "A new project list ships its own <id>.json beside this one. Copy the shape, not the values.",
|
||||
"list": "upg-100",
|
||||
"generated_from": "clusters.json + the live records, 2026-09-27"
|
||||
},
|
||||
"bloc_zones": {
|
||||
"_why": "Zones that partition by AFFINITY or LANGUAGE rather than geography. Their members ALSO belong to a geographic zone, so they are exempt from the one-geographic-zone rule. legend-tree.js:151 records the deaf case as design-approved (slice 08): per-zone equality holds; only a naive GLOBAL sum over-counts.",
|
||||
"ids": [
|
||||
"deaf"
|
||||
]
|
||||
},
|
||||
"expected_orphans": {
|
||||
"_why": "Groups in the list that belong to NO zone. An empty list here means 'every group must be zoned'. A gate cannot tell a deliberate exclusion from a silent drop; only this declaration can.",
|
||||
"_status": "PENDING DRIVER — these 11 are TODAY'S ACTUAL orphans, recorded so the gate is green on arrival and any NEW orphan fails. They are all Japan / China / Mongolia and are plausibly deliberate (asia-china-tibet-autonomous-region is labelled 'Mainland China + Mongolia' and carries only 43 groups), but nobody has said so. In DRIVER-DECISIONS-DIGEST; do NOT change the memberships to make this list shorter.",
|
||||
"slugs": [
|
||||
"ainu-japan",
|
||||
"jing",
|
||||
"kalmyk-oirats",
|
||||
"oki-no-erabu",
|
||||
"pana-china",
|
||||
"toku-no-shima",
|
||||
"tsaatan",
|
||||
"tuvinians",
|
||||
"yaeyama",
|
||||
"yonaguni",
|
||||
"yoron"
|
||||
]
|
||||
},
|
||||
"known_geographic_overlaps": {
|
||||
"_why": "Groups that DO sit in two geographic zones, declared so the gate fails on a NEW overlap rather than on these. Not an endorsement — see _status.",
|
||||
"_status": "PENDING DRIVER — uncovered-scatter is country_codes AND,CCK,GUF,USA ('Scattered / Diaspora'), so it overlaps the regional zones it scatters across. Either a scatter zone is a THIRD partition (like a bloc) or these two memberships are wrong. In DRIVER-DECISIONS-DIGEST; do NOT change either membership. ⚠ deaf-cocos-islanders was MISSED by my own hand analysis and found by this gate: I had filtered out every A017 group as 'the design-approved deaf double-count', but its TWO GEOGRAPHIC claims (CCK is in both zones' country lists) are a SEPARATE fact from its bloc membership. Conflating them is exactly what the bloc/geographic split in A2 exists to prevent, and the gate got it right where I did not — which is the best argument for the gate existing.",
|
||||
"slugs": [
|
||||
{
|
||||
"slug": "deaf-cocos-islanders",
|
||||
"zones": [
|
||||
"oceania-indonesia",
|
||||
"uncovered-scatter"
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "cocos-malays",
|
||||
"zones": [
|
||||
"oceania-indonesia",
|
||||
"uncovered-scatter"
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "andorrans",
|
||||
"zones": [
|
||||
"uncovered-scatter",
|
||||
"europe"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"page_budget": {
|
||||
"_why": "Groups per printed page, from packet-render's PER_PAGE. A zone larger than max_groups is not an error — it just prints more sheets — but a zone WILDLY larger is usually a selector that matched too much, which is how the wagf and deaf selector bugs looked before they were found.",
|
||||
"per_page": 20,
|
||||
"max_groups": 200
|
||||
}
|
||||
}
|
||||
|
|
@ -41,11 +41,11 @@
|
|||
import { computed, inject, ref } from 'vue'
|
||||
import { useOptionalT } from '@map/composables/useOptionalT.js'
|
||||
// The page's people-group LIST (library/lists/): every people-group fetch below keeps only its groups.
|
||||
import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
|
||||
import { applyPeopleGroupList, resolvePeopleGroupList } from '@map/lists/peopleGroupList.js'
|
||||
import PosterWall from '@map/components/poster/PosterWall.vue'
|
||||
import { resolveAssetsBase } from '@map/utils/embedBase.js'
|
||||
// Named imports: only the list arrays ship in the bundle; the file's bookkeeping fields stay out.
|
||||
import { lists as clusterLists, asia_lists as clusterAsiaLists, uncovered_lists as clusterUncoveredLists } from './data/clusters.json'
|
||||
import { zonesForList } from './data/zones-for-list.js'
|
||||
// The SAME normalize+evaluate pipeline the sibling map profile uses to turn the live
|
||||
// /people-groups/list payload + a list's `selector` into the exact rows that belong to it.
|
||||
import { normalizeRecords, extractRecords } from './data/normalize.js'
|
||||
|
|
@ -115,12 +115,27 @@ const regionFor = (id) => {
|
|||
if (id.startsWith('asia-china-')) return 'China + Mongolia'
|
||||
return 'Other'
|
||||
}
|
||||
// One flat list from the three cluster groups — the same 27 the map paints.
|
||||
const items = computed(() => [
|
||||
...(clusterLists || []),
|
||||
...(clusterAsiaLists || []),
|
||||
...(clusterUncoveredLists || []),
|
||||
].filter(l => l && l.id))
|
||||
// One flat list of the zones the map paints — for WHICHEVER list is showing.
|
||||
//
|
||||
// ★ THROUGH zonesForList, not a static clusters.json import (pattern work, 2026-09-27). This wall already
|
||||
// filtered its RECORDS by list (applyPeopleGroupList, below), so before this it showed another list's
|
||||
// people groups arranged under UPG-100's zone names — half list-aware, which is the worst of the two
|
||||
// states because it looks right. Now the zones follow the list too.
|
||||
// Safe inside a computed: profileConfig is injected at :54 and this evaluates lazily, after setup —
|
||||
// a module-level call would hit the temporal dead zone (it does in upg-100-list.vue, which is why that
|
||||
// file resolves per call as well).
|
||||
// ⚠ AND THE OLD COMMENT SAID "the same 27", which was already stale: the legend has 28 leaves, because
|
||||
// buildLegendNodes collapses the two deaf_lists into one unified row. This spread still yields 27 because
|
||||
// it reads the three geographic buckets and the deaf row is synthesised — so the number is right for what
|
||||
// this list IS, and the old comment was describing the wrong thing.
|
||||
const items = computed(() => {
|
||||
const z = zonesForList(resolvePeopleGroupList(profileConfig))
|
||||
return [
|
||||
...(z.lists || []),
|
||||
...(z.asia_lists || []),
|
||||
...(z.uncovered_lists || []),
|
||||
].filter(l => l && l.id)
|
||||
})
|
||||
|
||||
// ─── THE RECORDS PROVIDER — the one thing that cannot be shared ──────────────
|
||||
async function fetchListRecords(list) {
|
||||
|
|
@ -177,7 +192,46 @@ async function fetchListRecords(list) {
|
|||
// Product call taken by dx-shepherd (UPGHK, 2026-09-27) under the agent's standing goal that the
|
||||
// production maps be localized.
|
||||
const t = useOptionalT('')
|
||||
const intro = computed(() => t('messages.upg100WallIntro'))
|
||||
|
||||
// ★ THE MARKUP IS COMPOSED HERE, so the CATALOGUE holds plain text (dx-shepherd UPGHTML, 2026-09-27).
|
||||
// vue-i18n warns "[intlify] Detected HTML in '…'" twice per tab pass, and it is right to: a translated
|
||||
// string carrying <strong> is the only one in this tree that does — I measured every en catalogue area
|
||||
// and upg100WallIntro is 1 of 1, so this was not a convention, it was my outlier.
|
||||
//
|
||||
// ⚠ WHY THE OBVIOUS FIXES DO NOT APPLY HERE. The shepherd's suggestion — markup in the template, or
|
||||
// <i18n-t> interpolation — assumes this profile renders the sentence. It does not: `intro` is a STRING
|
||||
// PROP on the library PosterWall, rendered there with v-html (PosterWall.vue:955), and that component
|
||||
// has no slots at all. So the markup has to arrive inside the string, and the only question is who
|
||||
// builds the string. Building it HERE keeps the catalogue clean without a library change.
|
||||
//
|
||||
// ★ AND IT IS STRICTLY SAFER THAN WHAT IT REPLACES, which is the part worth keeping. v-html on a whole
|
||||
// catalogue string TRUSTS THE CATALOGUE: any tag a translator (or a bad merge) puts in any of the 11
|
||||
// locale files renders as markup. Composing here escapes each part and emits exactly one tag I control,
|
||||
// so the trusted surface shrinks from "every translation of this key" to "this line".
|
||||
//
|
||||
// THREE PARTS, NOT A WRAPPER AROUND ONE, because the emphasis is not in the same place in every
|
||||
// language. Measured across all 11 locales: each has exactly one <strong> and no other tags, and de, hi
|
||||
// and zh carry real text AFTER the emphasised run (" beten.", " प्रार्थना करते हुए।", "為他們禱告。")
|
||||
// where the others end on a full stop. A head+emphasis wrapper would have silently dropped those tails.
|
||||
const escHtml = (s) => String(s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
const intro = computed(() => {
|
||||
// The three plain-text keys studio adds (rule 5B — the catalogue is theirs). Until they land, the
|
||||
// single legacy key is still the source of truth, so this renders identically before AND after: no
|
||||
// flag day, and my landing does not have to be ordered against theirs.
|
||||
const a = t('messages.upg100WallIntroA')
|
||||
const b = t('messages.upg100WallIntroEmph')
|
||||
const c = t('messages.upg100WallIntroB')
|
||||
const missing = (v, key) => !v || v === key
|
||||
if (missing(a, 'messages.upg100WallIntroA') || missing(b, 'messages.upg100WallIntroEmph')) {
|
||||
// ⚠ THE LEGACY KEY STILL CONTAINS THE MARKUP, so it is passed through unescaped exactly as before.
|
||||
// Escaping it here would print a literal "<strong>" to the reader — a visible regression traded for
|
||||
// a console warning, which is the wrong way round.
|
||||
return t('messages.upg100WallIntro')
|
||||
}
|
||||
const tail = missing(c, 'messages.upg100WallIntroB') ? '' : escHtml(c)
|
||||
return `${escHtml(a)}<strong>${escHtml(b)}</strong>${tail}`
|
||||
})
|
||||
const heading = computed(() => t('messages.upg100WallHeading'))
|
||||
const wallTag = computed(() => t('messages.upg100WallTag'))
|
||||
|
||||
|
|
|
|||
|
|
@ -77,15 +77,21 @@ import ThemeToggleButton from '@map/components/map-controls/ThemeToggleButton.vu
|
|||
// edits src/ (zero blast radius — md5 unchanged). (research-map.vue:141,1815.)
|
||||
import GeocoderComponent from '@map/components/map-controls/GeocoderComponent.vue'
|
||||
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
|
||||
import { ensureEngineScript, engineGlobal, engineStylesheetUrl } from '@map/composables/useMapInstance.js'
|
||||
import { ensureEngineScript, engineGlobal, ensureEngineCSS, engineStylesheetUrl } from '@map/composables/useMapInstance.js'
|
||||
// Shapes Arabic / Persian / Hebrew BASEMAP labels. A raw map inherits nothing from the bundle
|
||||
// entry, so this profile has to install it itself — see the call site below for why the entry's
|
||||
// registration only ever worked by accident.
|
||||
import { installRtlTextPlugin } from '@map/maps/rtl-text.js'
|
||||
import { resolveNamedStyle } from '@map/styles/registry.js'
|
||||
// Named imports: only the list arrays ship in the bundle; the file's bookkeeping fields stay out.
|
||||
import { lists as clusterLists, asia_lists as clusterAsiaLists, uncovered_lists as clusterUncoveredLists } from './data/clusters.json'
|
||||
const clusters = { lists: clusterLists, asia_lists: clusterAsiaLists, uncovered_lists: clusterUncoveredLists }
|
||||
// ★ NO DIRECT clusters.json IMPORT ANY MORE — every zone read goes through listZones() so the legend, the
|
||||
// list, the selectors and the packet all see the SAME zones for whatever list is showing. The named-import
|
||||
// tree-shaking this line used to protect now lives in zones-for-list.js, which keeps a static named import
|
||||
// for exactly that reason.
|
||||
// ⚠ FOUR SITES READ THE ZONES, not one. allLists()'s own note promised the `list` swap would be "a one-line
|
||||
// change instead of an audit"; it was four, found by grepping the IDENTIFIER rather than trusting the note:
|
||||
// allLists, bootstrapLegend (which also attaches selectors), and two legend rebuilds that pass the whole
|
||||
// object so a `clusters.` grep misses them. Recorded so the next reader greps too.
|
||||
import { zonesForList } from './data/zones-for-list.js'
|
||||
import { normalizeRecords, extractRecords } from './data/normalize.js'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
// The in-place print helper: a hidden same-page iframe, NOT window.open(). A popup
|
||||
|
|
@ -1424,9 +1430,22 @@ async function printPrayerPacket(zoneId) {
|
|||
// universal `list` prop lands (exp-shepherd Tier A: profile-config.list / ?list= / window.MAP_LIST,
|
||||
// default 'doxa') it replaces the body here and nothing else moves. Do not read `clusters` directly
|
||||
// anywhere else; that is what makes the swap a one-line change instead of an audit.
|
||||
// ★ AND THIS IS THAT SWAP, taken 2026-09-27 — the body, exactly as the note above predicted, with the
|
||||
// static `clusters` import left untouched so its tree-shaking survives (zones-for-list.js keeps the named
|
||||
// import for that reason). resolvePeopleGroupList reads config.list / ?list= / window.MAP_LIST, so a
|
||||
// partner already choosing a LIST now also gets that list's ZONES.
|
||||
// ⚠ RESOLVED PER CALL, NOT ONCE AT SETUP. `profileConfig` is injected at :126 — after the import block —
|
||||
// so a module-level `const clusters = zonesForList(...)` throws on the temporal dead zone. It is also more
|
||||
// correct per call: profileConfig is a ref a host can change, and the list can arrive later than setup.
|
||||
// Cheap, because zonesForList is a registry lookup returning a shared object, not a build.
|
||||
function listZones() {
|
||||
return zonesForList(resolvePeopleGroupList(profileConfig));
|
||||
}
|
||||
|
||||
function allLists() {
|
||||
return [...(clusters.lists || []), ...(clusters.asia_lists || []),
|
||||
...(clusters.uncovered_lists || [])];
|
||||
const z = listZones();
|
||||
return [...(z.lists || []), ...(z.asia_lists || []),
|
||||
...(z.uncovered_lists || [])];
|
||||
}
|
||||
|
||||
// ── THE PAGE-2 FUNNEL CONTEXT ───────────────────────────────────────────────
|
||||
|
|
@ -1548,6 +1567,22 @@ function selectedLists() {
|
|||
// HERE — once, against one fetch of the people-groups list — and handed on as the type every
|
||||
// consumer already handles. Resolving per zone would refetch the whole catalogue per zone and
|
||||
// still leave the caller to merge.
|
||||
// The packet's own string block for the reader's locale, per-KEY fallback to English exactly as
|
||||
// packet.mjs and packet-render do it — a half-translated block must not blank a key that en has.
|
||||
let _packetStrings = null;
|
||||
async function packetStrings() {
|
||||
if (!_packetStrings) {
|
||||
const all = await import('@map/scripts/posters/packet-strings.json').then((m) => m.default || m);
|
||||
_packetStrings = { ...(all.en || {}), ...(all[packetLang()] || {}) };
|
||||
}
|
||||
return _packetStrings;
|
||||
}
|
||||
/** `{a}` / `{b}` / `{n}` interpolation; null when the pattern is missing so the caller can default. */
|
||||
function fillJoin(pattern, params) {
|
||||
if (!pattern) return null;
|
||||
return String(pattern).replace(/\{(\w+)\}/g, (m, k) => (params[k] != null ? String(params[k]) : m));
|
||||
}
|
||||
|
||||
async function selectionAsList() {
|
||||
const lists = selectedLists();
|
||||
if (!lists.length) throw new Error('nothing selected');
|
||||
|
|
@ -1568,10 +1603,20 @@ async function selectionAsList() {
|
|||
}
|
||||
// The label is what the reader sees on the cover and in the print dialog's title. Name the
|
||||
// territories they actually chose rather than inventing a title for their selection.
|
||||
// ⚠ THE JOINERS WERE HARDCODED ENGLISH on a cover an Arabic reader sees: ' + ' and ' +N more', printed
|
||||
// straight onto the packet's first page and into the print dialog's title. Found while checking whether
|
||||
// batch selection was already done (dx-shepherd END-DXS-U100B) — the SELECTION path was, but its label
|
||||
// was not localized, so a reader choosing three territories in Arabic got their names in Arabic joined
|
||||
// by an English tail.
|
||||
// zone_join / zone_join_more already exist in all eleven packet-strings blocks (landed with the zone
|
||||
// title generator), translated once for exactly this shape, so this is a reuse rather than new strings.
|
||||
const names = lists.map((l) => l.label || l.id);
|
||||
const S = await packetStrings();
|
||||
const join2 = (a, b) => fillJoin(S.zone_join, { a, b }) ?? `${a} + ${b}`;
|
||||
const label = names.length <= 3
|
||||
? names.join(' + ')
|
||||
: `${names.slice(0, 2).join(' + ')} +${names.length - 2} more`;
|
||||
? names.reduce(join2)
|
||||
: fillJoin(S.zone_join_more, { a: names.slice(0, 2).reduce(join2), n: names.length - 2 })
|
||||
?? `${names.slice(0, 2).join(' + ')} +${names.length - 2} more`;
|
||||
return { id: 'selection', label, count: slugs.size,
|
||||
selector: { type: 'pg_slugs', values: [...slugs] } };
|
||||
}
|
||||
|
|
@ -1855,6 +1900,14 @@ function applySelectZone(zoneId) {
|
|||
// toggled it back OUT of the selection union. upg100-plugin caught that before it bit anyone, and
|
||||
// added the boolean so the applier could be ASKED instead of inferred from.
|
||||
if (!applied) { _pendingZoneId = zoneId; _markZoneState('applied-but-no-selection', zoneId); return false; }
|
||||
// ★ CLEAR THE RETAINED REQUEST ON SUCCESS. Every FAILING path above sets _pendingZoneId and the
|
||||
// no-op path clears it, but this one — the one that WORKED — left it set. So a request that was
|
||||
// retained once stayed outstanding forever, and ensureLayers()'s drain re-applied it on every later
|
||||
// call. For a zone already isolated that re-entry runs onLegendSelect again, which TOGGLES the
|
||||
// isolation back OFF: the reader sees "Show on map" select the zone and then silently lose it, which
|
||||
// is exactly the PRODUCTION-MATRIX symptom ("switches to the Map view but selects no zone").
|
||||
// The retain/drain machinery was right; the release was missing.
|
||||
_pendingZoneId = undefined;
|
||||
_markZoneState('applied', zoneId);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -1893,14 +1946,21 @@ function findLegendNode(nodes, key) {
|
|||
// (both are plain module-scope `let`s, so building them early is free).
|
||||
// Idempotent: re-running it just rebuilds the same tree from the same static input.
|
||||
function bootstrapLegend() {
|
||||
const nodes = buildLegendNodes(clusters, { deafCount: DEAF_COUNT })
|
||||
// ⚠ THE SAME ZONES allLists() USES, via listZones(), and this is the SECOND site — the note on
|
||||
// allLists promised the `list` swap would be "a one-line change instead of an audit" and that was not
|
||||
// quite true: this function reads the cluster buckets DIRECTLY to attach selectors. Left on the static
|
||||
// import it would have built the legend from UPG-100's zones while the list showed another list's, and
|
||||
// the two would disagree with nothing reporting it. Found by grepping for `clusters.` rather than by
|
||||
// trusting the convention comment.
|
||||
const z = listZones()
|
||||
const nodes = buildLegendNodes(z, { deafCount: DEAF_COUNT })
|
||||
legendNodes.value = nodes
|
||||
leafZoneList = leafZones(nodes)
|
||||
const byId = {}
|
||||
for (const l of clusters.lists || []) byId[l.id] = l.selector
|
||||
for (const a of clusters.asia_lists || []) byId[a.id] = a.selector
|
||||
for (const l of z.lists || []) byId[l.id] = l.selector
|
||||
for (const a of z.asia_lists || []) byId[a.id] = a.selector
|
||||
// slice 09b-6 §1a: the 7 Frontier-Asia uncovered zones (country_codes → ADM0).
|
||||
for (const u of clusters.uncovered_lists || []) byId[u.id] = u.selector
|
||||
for (const u of z.uncovered_lists || []) byId[u.id] = u.selector
|
||||
byId['deaf'] = { type: 'affinity_bloc', rop1: 'A017' }
|
||||
for (const z of leafZoneList) z.selector = byId[z.id]
|
||||
matchColorExpr = buildMatchColorExpr(leafZoneList)
|
||||
|
|
@ -1920,7 +1980,7 @@ async function enrichLegendFromRecords() {
|
|||
if (!resp.ok) throw new Error(`list ${resp.status}`)
|
||||
const records = normalizeRecords(await applyPeopleGroupList(extractRecords(await resp.json()), profileConfig))
|
||||
countryNameMap = buildCountryNameMap(records)
|
||||
legendNodes.value = buildLegendNodes(clusters, { deafCount: DEAF_COUNT, records })
|
||||
legendNodes.value = buildLegendNodes(listZones(), { deafCount: DEAF_COUNT, records })
|
||||
} catch (_) { /* counts-only legend is the honest fallback; keep it rendered */ }
|
||||
}
|
||||
|
||||
|
|
@ -2016,6 +2076,29 @@ onMounted(async () => {
|
|||
// mapbox-under-maplibre reference in this profile, carried as a named exemption in the
|
||||
// maplibre-embed gate; guarding it lets that exemption be deleted and enforced instead.
|
||||
// MapLibre ships its own ctrl CSS via ensureEngineScript, so nothing is lost.
|
||||
// ★ THE LIBRARY'S ensureEngineCSS, NOT A HAND-ROLLED COPY (rega END-REGA-SRV / END-DXS-U100C,
|
||||
// 2026-09-27). Mapbox logged "missing CSS declarations" on the BUILT leaf and in the gallery, and the
|
||||
// cause was this block, not a missing <link>:
|
||||
// (a) it only ran when the map element was inside a ShadowRoot — `root instanceof ShadowRoot` — so on
|
||||
// a standalone leaf, where the element is in LIGHT DOM, no stylesheet was ensured at all; and
|
||||
// (b) even in the shadow case it FIRED AND FORGOT, so `new gl.Map` could run before the sheet parsed.
|
||||
// Mapbox checks its declarations AT CONSTRUCTION, which is exactly when the warning appears.
|
||||
// ensureEngineCSS handles both DOM cases (light DOM returns early because the wrapper <link> covers it)
|
||||
// and AWAITS the load/error event with a 2s cap, so construction happens after the sheet is live.
|
||||
// ⚠ AND IT TAKES THE PROVIDER'S OWN URL rather than 'mapbox' hardcoded — the old line asked the registry
|
||||
// for mapbox's stylesheet even under MapLibre, guarded by !isMaplibre. Passing upgProvider means the
|
||||
// guard is unnecessary: a maplibre page gets maplibre's sheet (or none) and never touches
|
||||
// api.mapbox.com, which is the keyless contract the maplibre-embed gate enforces.
|
||||
// MEASURED as the difference from cities-internet, which does not warn: connectivity-map.vue:398-399
|
||||
// calls exactly this pair and this profile called neither.
|
||||
try {
|
||||
const css = engineStylesheetUrl(upgProvider)
|
||||
if (css) await ensureEngineCSS(mapEl.value, css)
|
||||
} catch (_) { /* a stylesheet must never stop the map painting */ }
|
||||
// The legacy shadow-root injection stays ONLY as the belt to that braces: ensureEngineCSS returns early
|
||||
// for light DOM on the assumption a wrapper <link> exists, and a partner embedding the custom element
|
||||
// directly may have no wrapper at all. Harmless when ensureEngineCSS already added the same href — both
|
||||
// paths check for it by href before inserting.
|
||||
try {
|
||||
const shadow = !isMaplibre && mapEl.value && mapEl.value.getRootNode()
|
||||
if (shadow instanceof ShadowRoot) {
|
||||
|
|
@ -2240,7 +2323,7 @@ onMounted(async () => {
|
|||
|
||||
// Re-shape the legend tree WITH records so every node carries `pop` (slice
|
||||
// 04u §1b). Selectors/colors/counts are unchanged; only `pop` is added.
|
||||
legendNodes.value = buildLegendNodes(clusters, { deafCount: DEAF_COUNT, records })
|
||||
legendNodes.value = buildLegendNodes(listZones(), { deafCount: DEAF_COUNT, records })
|
||||
|
||||
|
||||
// ── pins layer (kept from 04b, now stamped with zoneKey for isolate dim) ──
|
||||
|
|
|
|||
|
|
@ -779,7 +779,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-VUNzolsh8VsXhm3gvMWyCnxyX1ZOwLEQaRVJ2lVO3UYLTjlGtHulCjPpGkCDfuWo",
|
||||
"integrity": "sha384-ftT1h7Nfu838vdPavLGsuWr2gLP02j65Yc38tFvoF1RKWgmntfg+khZhBo5E1bNv",
|
||||
"elements": [
|
||||
"my-upg-100-list-map"
|
||||
],
|
||||
|
|
@ -799,7 +799,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-VUNzolsh8VsXhm3gvMWyCnxyX1ZOwLEQaRVJ2lVO3UYLTjlGtHulCjPpGkCDfuWo\" 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-ftT1h7Nfu838vdPavLGsuWr2gLP02j65Yc38tFvoF1RKWgmntfg+khZhBo5E1bNv\" 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>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -813,7 +813,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-VUNzolsh8VsXhm3gvMWyCnxyX1ZOwLEQaRVJ2lVO3UYLTjlGtHulCjPpGkCDfuWo\" 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-ftT1h7Nfu838vdPavLGsuWr2gLP02j65Yc38tFvoF1RKWgmntfg+khZhBo5E1bNv\" 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>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -827,7 +827,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-VUNzolsh8VsXhm3gvMWyCnxyX1ZOwLEQaRVJ2lVO3UYLTjlGtHulCjPpGkCDfuWo\" 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-ftT1h7Nfu838vdPavLGsuWr2gLP02j65Yc38tFvoF1RKWgmntfg+khZhBo5E1bNv\" 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>"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -779,7 +779,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-VUNzolsh8VsXhm3gvMWyCnxyX1ZOwLEQaRVJ2lVO3UYLTjlGtHulCjPpGkCDfuWo",
|
||||
"integrity": "sha384-ftT1h7Nfu838vdPavLGsuWr2gLP02j65Yc38tFvoF1RKWgmntfg+khZhBo5E1bNv",
|
||||
"elements": [
|
||||
"my-upg-100-list-map"
|
||||
],
|
||||
|
|
@ -799,7 +799,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-VUNzolsh8VsXhm3gvMWyCnxyX1ZOwLEQaRVJ2lVO3UYLTjlGtHulCjPpGkCDfuWo\" 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-ftT1h7Nfu838vdPavLGsuWr2gLP02j65Yc38tFvoF1RKWgmntfg+khZhBo5E1bNv\" 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>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -813,7 +813,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-VUNzolsh8VsXhm3gvMWyCnxyX1ZOwLEQaRVJ2lVO3UYLTjlGtHulCjPpGkCDfuWo\" 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-ftT1h7Nfu838vdPavLGsuWr2gLP02j65Yc38tFvoF1RKWgmntfg+khZhBo5E1bNv\" 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>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -827,7 +827,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-VUNzolsh8VsXhm3gvMWyCnxyX1ZOwLEQaRVJ2lVO3UYLTjlGtHulCjPpGkCDfuWo\" 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-ftT1h7Nfu838vdPavLGsuWr2gLP02j65Yc38tFvoF1RKWgmntfg+khZhBo5E1bNv\" 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>"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
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-VUNzolsh8VsXhm3gvMWyCnxyX1ZOwLEQaRVJ2lVO3UYLTjlGtHulCjPpGkCDfuWo" crossorigin="anonymous"></script>
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-ftT1h7Nfu838vdPavLGsuWr2gLP02j65Yc38tFvoF1RKWgmntfg+khZhBo5E1bNv" 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-VUNzolsh8VsXhm3gvMWyCnxyX1ZOwLEQaRVJ2lVO3UYLTjlGtHulCjPpGkCDfuWo" crossorigin="anonymous"></script>
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-ftT1h7Nfu838vdPavLGsuWr2gLP02j65Yc38tFvoF1RKWgmntfg+khZhBo5E1bNv" 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-VUNzolsh8VsXhm3gvMWyCnxyX1ZOwLEQaRVJ2lVO3UYLTjlGtHulCjPpGkCDfuWo" crossorigin="anonymous"></script>
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-ftT1h7Nfu838vdPavLGsuWr2gLP02j65Yc38tFvoF1RKWgmntfg+khZhBo5E1bNv" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
Loading…
Reference in a new issue