Take main
# Conflicts: # package.json
18
README.md
|
|
@ -73,6 +73,20 @@ the Research Gallery.
|
|||
|
||||
<sub>✅ works today · 🧪 proven, not yet in production. Every plugin builds into the same folder, and that folder is what all five methods ship — so a capability never limits where a plugin can go.</sub>
|
||||
|
||||
### The Experimental shelf
|
||||
|
||||
**New plugins the Research Gallery picks up by itself** — each builds and embeds like any other,
|
||||
and graduates to a named tab when it's ready.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top"><img src="documentation/readme-images/exp-wall-of-people-groups.png" alt="Wall of People Groups"><br><b>Wall of People Groups</b><br><sub>all 2,107 Doxa people groups as photo cards on one zoomable prayer wall, with a pray dialog</sub></td>
|
||||
<td width="50%" valign="top"><img src="documentation/readme-images/exp-prayer-walk-planner.png" alt="Prayer Walk Planner"><br><b>Prayer Walk Planner</b><br><sub>plan a prayer walk through a zone, open it full screen from any embed, and print a walking map that stays sharp when zoomed</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<sub>Also on the shelf: UPG neighbor, all people groups, country posters, the vision card, and the PPLR data maps.</sub>
|
||||
|
||||
<details>
|
||||
<summary><b>How one build becomes five ways to ship — the "map cruncher"</b></summary>
|
||||
|
||||
|
|
@ -149,7 +163,9 @@ Research Gallery <doxa-maps-spa> capability 5
|
|||
├─ UPG neighbor upg-neighbor capability 3
|
||||
├─ all people groups all-people-groups capability 1
|
||||
├─ country posters country-posters capability 4
|
||||
└─ vision card doxa-vision-card capability 2
|
||||
├─ vision card doxa-vision-card capability 2
|
||||
├─ PPLR data maps pplr-data-maps capability 3
|
||||
└─ Wall of People Groups wall-of-people-groups capability 3
|
||||
```
|
||||
|
||||
</details>
|
||||
|
|
|
|||
|
|
@ -49,13 +49,13 @@ The scripts these commands are grounded in (from this branch's `package.json`):
|
|||
"build": "bash internal/build-lock.sh",
|
||||
"build:unlocked": "bun run gates:pre && bun run library-index && bun run check:library-index && bun run clean && bun run token && bun run build:bundles && bun run tree && bun run gates:post && bun run gallery",
|
||||
"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:wp-embed check:doc-scripts",
|
||||
"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:legend-source check:backlog-refs check:composite-embed-knobs",
|
||||
"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:legend-source check:backlog-refs check:composite-embed-knobs check:linked-vocabulary",
|
||||
"build:bundles": "node internal/run-parallel.mjs --bundles", // the 22 vite builds, 4 at a time
|
||||
"preview": "bun x vite preview",
|
||||
"serve": "node internal/serve-strict.mjs", // 404s a directory, like production
|
||||
"cap:sync": "./node_modules/.bin/cap sync android",
|
||||
"cap:verify": "test -f deployment/capacitor/android/app/src/main/assets/public/index.html && echo \"OK webDir copied\" || (echo \"FAIL: empty assets/public -> blank white screen. Check webDir.\" && exit 1)",
|
||||
"apk:debug": "bun run build && bun run cap:sync && bun run cap:verify && cd deployment/capacitor/android && ./gradlew assembleDebug",
|
||||
"apk:debug": "bun run build && bun run cap:sync && bun run app:start-page && bun run cap:verify && cd deployment/capacitor/android && ./gradlew assembleDebug",
|
||||
"apk:install": "adb install -r deployment/capacitor/android/app/build/outputs/apk/debug/app-debug.apk",
|
||||
"phone": "bun run apk:debug && bun run apk:install"
|
||||
```
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.8 MiB |
BIN
documentation/readme-images/exp-prayer-walk-planner.png
Normal file
|
After Width: | Height: | Size: 2 MiB |
BIN
documentation/readme-images/exp-wall-of-people-groups.png
Normal file
|
After Width: | Height: | Size: 2 MiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 425 KiB After Width: | Height: | Size: 425 KiB |
|
Before Width: | Height: | Size: 891 KiB After Width: | Height: | Size: 891 KiB |
|
Before Width: | Height: | Size: 758 KiB After Width: | Height: | Size: 751 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 205 KiB After Width: | Height: | Size: 205 KiB |
|
Before Width: | Height: | Size: 700 KiB After Width: | Height: | Size: 676 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.7 MiB |
|
|
@ -1,23 +1,23 @@
|
|||
{
|
||||
"cap1-single-element": {
|
||||
"bundle": "all-people-groups",
|
||||
"sha256": "0ede35b98a92ea2a56fa5b6302eb3dd827d9de4c636f82f663f9c0f4d8b24890"
|
||||
"sha256": "c990929277d9aa5fd0d4ef78362f7b43e328a7c073ac6bf97aadfde5cb942de9"
|
||||
},
|
||||
"cap2-adoption": {
|
||||
"bundle": "doxa-simple-map",
|
||||
"sha256": "6aa091deb995128b21dbeaa49bd4988c60fe61092d1859b1bdb83dba49d7ce20"
|
||||
"sha256": "0e11603ed79a26b16456f23d582549830365a2c5095ba9ad7ad363c7d1ba9a90"
|
||||
},
|
||||
"cap2-engagement": {
|
||||
"bundle": "doxa-simple-map",
|
||||
"sha256": "6aa091deb995128b21dbeaa49bd4988c60fe61092d1859b1bdb83dba49d7ce20"
|
||||
"sha256": "0e11603ed79a26b16456f23d582549830365a2c5095ba9ad7ad363c7d1ba9a90"
|
||||
},
|
||||
"cap2-prayer": {
|
||||
"bundle": "doxa-simple-map",
|
||||
"sha256": "6aa091deb995128b21dbeaa49bd4988c60fe61092d1859b1bdb83dba49d7ce20"
|
||||
"sha256": "0e11603ed79a26b16456f23d582549830365a2c5095ba9ad7ad363c7d1ba9a90"
|
||||
},
|
||||
"cap3-monolithic-research": {
|
||||
"bundle": "doxa-research-map",
|
||||
"sha256": "3391075b32dd710b7f810e3097074a4d453c7720a6963e101ddec99ad4186d29"
|
||||
"sha256": "b849fb71c4a96810d840d9f2dfc208c3e13648429e0ae1a1c5784693e6e5774f"
|
||||
},
|
||||
"cap4-upg100-list": {
|
||||
"bundle": "my-upg-100-list",
|
||||
|
|
@ -43,17 +43,25 @@
|
|||
"bundle": "my-upg-100-list",
|
||||
"sha256": "fab621d68bdad964da2be67135d0385d40417c6180f008f36cbc0f50076f5e4a"
|
||||
},
|
||||
"exp-prayer-walk-planner": {
|
||||
"bundle": "prayer-walk-planner",
|
||||
"sha256": "cee5f2806bd5cc8a7fa4c49db200df643d07cf303fd75ee739e606dd9ee93aee"
|
||||
},
|
||||
"exp-wall-of-people-groups": {
|
||||
"bundle": "wall-of-people-groups",
|
||||
"sha256": "ba718e082f77f50d5775ebf43007385b7293f8099e2ca4d79e8ad1753ac4133f"
|
||||
},
|
||||
"prod-big-picture": {
|
||||
"bundle": "dashboard-plus-grid",
|
||||
"sha256": "d3eb3468e6f42c3346885a665102dd10965a98a5e045f50b0d15c14f52a05e56"
|
||||
"sha256": "e90739f622678db4c421c76855a5c39882aabd4ed8dbdd65cbe1385a45c67edc"
|
||||
},
|
||||
"prod-cities-internet": {
|
||||
"bundle": "cities-internet",
|
||||
"sha256": "1372e30e723497c6164c409b2e41e095f5f18f38caba606edb4ca58293640d39"
|
||||
"sha256": "ad145b5a63e7d4b7468a54db53f2f71f397216956fec698d357ae5b841fa8e3b"
|
||||
},
|
||||
"prod-cities-internet-phone": {
|
||||
"bundle": "cities-internet",
|
||||
"sha256": "1372e30e723497c6164c409b2e41e095f5f18f38caba606edb4ca58293640d39"
|
||||
"sha256": "ad145b5a63e7d4b7468a54db53f2f71f397216956fec698d357ae5b841fa8e3b"
|
||||
},
|
||||
"prod-gospel-resources": {
|
||||
"bundle": "gospel-resources",
|
||||
|
|
@ -65,15 +73,15 @@
|
|||
},
|
||||
"prod-prayer-phone": {
|
||||
"bundle": "doxa-simple-map",
|
||||
"sha256": "6aa091deb995128b21dbeaa49bd4988c60fe61092d1859b1bdb83dba49d7ce20"
|
||||
"sha256": "0e11603ed79a26b16456f23d582549830365a2c5095ba9ad7ad363c7d1ba9a90"
|
||||
},
|
||||
"prod-regions": {
|
||||
"bundle": "regions-dashboard",
|
||||
"sha256": "a9b91cd0428834cdbdc711f5be2921c08fc2f2b057081f26c21812e56cfa114e"
|
||||
"sha256": "8420509dde0ea1390e0d4ae8d49f80a303d17fa8a38c37755a82b07ab09567f5"
|
||||
},
|
||||
"prod-regions-phone": {
|
||||
"bundle": "regions-dashboard",
|
||||
"sha256": "a9b91cd0428834cdbdc711f5be2921c08fc2f2b057081f26c21812e56cfa114e"
|
||||
"sha256": "8420509dde0ea1390e0d4ae8d49f80a303d17fa8a38c37755a82b07ab09567f5"
|
||||
},
|
||||
"prod-research-gallery-phone": {
|
||||
"bundle": "doxa-maps-spa",
|
||||
|
|
@ -81,10 +89,10 @@
|
|||
},
|
||||
"prod-simple-research": {
|
||||
"bundle": "research-map-plus-grid",
|
||||
"sha256": "458139bb4b1da0e79fefeb3c671b78d81da4ef832e411e54789915ca07007ee7"
|
||||
"sha256": "9e081907bd697b5ac63531cedbdb34d3f47a09d1d5aee3634722128af1136d0f"
|
||||
},
|
||||
"prod-simple-research-phone": {
|
||||
"bundle": "research-map-plus-grid",
|
||||
"sha256": "458139bb4b1da0e79fefeb3c671b78d81da4ef832e411e54789915ca07007ee7"
|
||||
"sha256": "9e081907bd697b5ac63531cedbdb34d3f47a09d1d5aee3634722128af1136d0f"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -168,3 +168,42 @@ export function requirePathInThisCheckout(file, repo, label) {
|
|||
console.log(`${label}: note — ${file} is outside any checkout, so which build produced it cannot be checked.`)
|
||||
return { ok: true, why: 'outside any checkout' }
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The same guard for gates aimed at a VITE DEV SERVER, which has no built manifest to fingerprint.
|
||||
*
|
||||
* A dev server serves this repo's SOURCE, so identity is the source itself: ask it for a few of this
|
||||
* checkout's library files with Vite's `?raw` query (the file's exact text, as an ES module string)
|
||||
* and compare byte for byte. A dev server rooted in another worktree with different code answers
|
||||
* differently, or 403s the path — either way it is NOT MEASURED (exit 75). One serving identical code
|
||||
* is, by definition, measuring the same thing. Unreachable is left to the gate's own handling.
|
||||
* Remote (non-loopback) targets are not checked, as in requireThisTree.
|
||||
*/
|
||||
const DEV_FINGERPRINT = ['library/layouts/VerticalTabsLayout.vue', 'library/composables/useMapInstance.js', 'library/composables/useShadowStyles.js']
|
||||
export async function requireDevServesThisCheckout(base, repo, label, files = DEV_FINGERPRINT) {
|
||||
let origin
|
||||
try { origin = new URL(String(base)).origin } catch { return { ok: true, why: 'not a URL' } }
|
||||
if (!/^(localhost|127\.|0\.0\.0\.0$|\[?::1\]?$)/.test(new URL(origin).hostname)) return { ok: true, why: 'remote target' }
|
||||
const refuse = (why) => {
|
||||
console.log(`${label}: NOT MEASURED — the dev server at ${origin} is not serving THIS checkout's source (${why}).`)
|
||||
console.log(' Start `bun x vite --port <yours> --strictPort` in this worktree and pass that URL.')
|
||||
process.exit(75)
|
||||
}
|
||||
for (const rel of files) {
|
||||
let local
|
||||
try { local = readFileSync(resolve(repo, rel), 'utf8') } catch { continue }
|
||||
const r = await fetch(`${origin}/${rel}?raw`, { signal: AbortSignal.timeout(8000) }).catch(() => null)
|
||||
if (!r) return { ok: false, why: 'unreachable' }
|
||||
if (!r.ok) refuse(`${rel} → HTTP ${r.status}`)
|
||||
// A plain fetch (not a module import) gets the file's TEXT served directly; an import-style
|
||||
// request gets it wrapped as `export default "…"`. Accept both shapes — the first version only
|
||||
// parsed the wrapped one and refused this very checkout's own dev server (measured 2026-09-25).
|
||||
const body = await r.text()
|
||||
const m = body.match(/^export default ("(?:[^"\\]|\\.)*")/s)
|
||||
let served = body
|
||||
if (m) { try { served = JSON.parse(m[1]) } catch { served = null } }
|
||||
if (served !== local) refuse(`${rel} differs`)
|
||||
}
|
||||
return { ok: true, why: 'same source' }
|
||||
}
|
||||
|
|
|
|||
97
internal/check-basemap-labels-localized.mjs
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-basemap-labels-localized — the BASEMAP's place names follow the reader's language, on BOTH
|
||||
* engines (card BUILD-LOCALIZATION-SURVIVES-THE-PROVIDER-SWAP; the code under test is
|
||||
* library/maps/localize-labels.js, 319064a1 / 4839f879).
|
||||
*
|
||||
* USAGE: node internal/check-basemap-labels-localized.mjs [--dir <tree root>] [--leaf bundle/profile]
|
||||
* Serves THIS checkout's public/ on an OS-assigned port (so it cannot measure another tree),
|
||||
* or --dir (used for the negative control: a tree whose bundle predates the fix).
|
||||
* Exit 0 = both engines localize · 1 = an engine does not · 2 = could not measure.
|
||||
*
|
||||
* WHAT IS READ — the RENDERED label, not our own guess at an expression:
|
||||
* Mapbox Standard keeps its labels in the IMPORTED basemap, invisible to a plain
|
||||
* queryRenderedFeatures. The 'place-labels' featureset of the 'basemap' import returns
|
||||
* each country label with the `name` the engine drew (setLanguage localizes it).
|
||||
* MapLibre queryRenderedFeatures returns each symbol's EVALUATED text-field, i.e. the text drawn.
|
||||
* WHAT IS ASSERTED, per engine, between ?lang=en and ?lang=ru on the same view (30°E 45°N, z3):
|
||||
* Mapbox known countries flip: Germany→Германия, Ukraine→Украина, Turkey→Турция, Egypt→Египет
|
||||
* (at least two of them seen in both runs).
|
||||
* MapLibre for the country labels (identified by their name:en property), the ru run draws the
|
||||
* feature's name:ru, and the en run does not. (English on MapLibre restores the style's
|
||||
* ORIGINAL label, OSM's Latin name — 'Deutschland', not 'Germany' — so no English word
|
||||
* is expected there; the flip to Cyrillic is the evidence.)
|
||||
*
|
||||
* NEGATIVE-TESTED 2026-09-25: the same leaf built from 319064a1's parent (before localize-labels
|
||||
* existed), served with --dir, FAILS on both engines.
|
||||
*/
|
||||
import { chromium } from 'playwright'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createServer } from 'node:http'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const argv = process.argv.slice(2)
|
||||
const arg = (k, d) => { const i = argv.indexOf(k); return i >= 0 && argv[i + 1] ? argv[i + 1] : d }
|
||||
const DIR = resolve(arg('--dir', resolve(ROOT, 'public')))
|
||||
const LEAF = arg('--leaf', 'doxa-simple-map/doxa-simple-map')
|
||||
const PAIRS = { Germany: 'Германия', Ukraine: 'Украина', Turkey: 'Турция', Egypt: 'Египет', Kazakhstan: 'Казахстан', Poland: 'Польша' }
|
||||
|
||||
const port = await new Promise((ok) => { const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) }) })
|
||||
const srv = spawn(process.execPath, [resolve(ROOT, 'internal/serve-strict.mjs'), '--port', String(port), '--directory', DIR], { stdio: 'ignore' })
|
||||
await new Promise((r) => setTimeout(r, 900))
|
||||
const up = await fetch(`http://127.0.0.1:${port}/doxa-maps/${LEAF}/index.html`).then((r) => r.ok).catch(() => false)
|
||||
if (!up) { srv.kill(); console.log(`BASEMAP-LABELS: NOT MEASURED — could not serve ${DIR}`); process.exit(2) }
|
||||
|
||||
const browser = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist'] })
|
||||
async function labels(provider, lang) {
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } })
|
||||
await page.goto(`http://127.0.0.1:${port}/doxa-maps/${LEAF}/index.html?lang=${lang}${provider === 'maplibre' ? '&provider=maplibre' : ''}`, { waitUntil: 'networkidle' })
|
||||
const FIND = `(() => { const f = (r) => { for (const e of r.querySelectorAll('*')) { if (e.__doxaMap) return e.__doxaMap; if (e.shadowRoot) { const m = f(e.shadowRoot); if (m) return m } } return null }; return f(document) })()`
|
||||
let ok = false
|
||||
for (let i = 0; i < 60 && !ok; i++) { await page.waitForTimeout(500); ok = await page.evaluate(`(() => { const m = ${FIND}; return !!(m && m.isStyleLoaded && m.isStyleLoaded()) })()`).catch(() => false) }
|
||||
if (!ok) { await page.close(); return null }
|
||||
await page.evaluate(`(() => { const m = ${FIND}; m.jumpTo({ center: [30, 45], zoom: 3 }) })()`)
|
||||
await page.waitForTimeout(7000)
|
||||
const out = await page.evaluate(`(() => { const m = ${FIND}
|
||||
if (${JSON.stringify(provider)} === 'mapbox') {
|
||||
let fs = []; try { fs = m.queryRenderedFeatures({ target: { featuresetId: 'place-labels', importId: 'basemap' } }) } catch (e) { return { error: e.message } }
|
||||
return { names: fs.filter((f) => f.properties && f.properties.class === 'country').map((f) => f.properties.name) }
|
||||
}
|
||||
const text = (tf) => (tf && Array.isArray(tf.sections)) ? tf.sections.map((s) => s.text).join('') : (typeof tf === 'string' ? tf : '')
|
||||
const feats = m.queryRenderedFeatures().filter((f) => f.layer && f.layer.type === 'symbol' && /country/.test(f.layer.id) && f.properties['name:en'])
|
||||
return { countries: feats.map((f) => ({ en: f.properties['name:en'], ru: f.properties['name:ru'] || '', drawn: text(f.layer.layout && f.layer.layout['text-field']) })) }
|
||||
})()`)
|
||||
await page.close()
|
||||
return out
|
||||
}
|
||||
|
||||
const results = []
|
||||
for (const provider of ['mapbox', 'maplibre']) {
|
||||
const en = await labels(provider, 'en'), ru = await labels(provider, 'ru')
|
||||
if (!en || !ru || en.error || ru.error) { results.push({ provider, measured: false, why: (en && en.error) || (ru && ru.error) || 'map never finished its style' }); continue }
|
||||
if (provider === 'mapbox') {
|
||||
const seen = Object.keys(PAIRS).filter((k) => en.names.includes(k))
|
||||
const flipped = seen.filter((k) => ru.names.includes(PAIRS[k]))
|
||||
// MEASURED = the engine drew enough country labels to judge. Not "saw two known English names":
|
||||
// without localization Mapbox Standard draws a different default form, and that absence IS the
|
||||
// defect — the first version of this line reported the pre-fix build as NOT MEASURED instead of
|
||||
// FAIL (68 country labels drawn, none English), which would let the gate pass by abstaining.
|
||||
results.push({ provider, measured: en.names.length >= 5 && ru.names.length >= 5, ok: seen.length >= 2 && flipped.length === seen.length,
|
||||
detail: `en saw ${seen.join(', ') || '(none)'}; ru drew ${flipped.map((k) => PAIRS[k]).join(', ') || '(none of them)'} (${en.names.length}/${ru.names.length} country labels)` })
|
||||
} else {
|
||||
const byEn = (arr) => new Map(arr.map((c) => [c.en, c]))
|
||||
const E = byEn(en.countries), R = byEn(ru.countries)
|
||||
const common = [...R.keys()].filter((k) => E.has(k) && R.get(k).ru)
|
||||
const localized = common.filter((k) => R.get(k).drawn === R.get(k).ru && E.get(k).drawn !== R.get(k).ru)
|
||||
results.push({ provider, measured: common.length >= 2, ok: common.length >= 2 && localized.length === common.length,
|
||||
detail: `${localized.length}/${common.length} countries drawn in Russian under ru and not under en` + (common[0] ? ` — e.g. ${common[0]}: en "${E.get(common[0]).drawn}" → ru "${R.get(common[0]).drawn}"` : '') })
|
||||
}
|
||||
}
|
||||
await browser.close(); srv.kill()
|
||||
|
||||
for (const r of results) console.log(` ${r.provider.padEnd(9)} ${!r.measured ? 'NOT MEASURED' : r.ok ? 'ok ' : 'FAIL'} ${r.detail || r.why}`)
|
||||
if (results.some((r) => r.measured && !r.ok)) { console.log('BASEMAP-LABELS: FAIL — a basemap does not follow the reader\'s language on every engine.'); process.exit(1) }
|
||||
if (results.some((r) => !r.measured)) { console.log('BASEMAP-LABELS: NOT MEASURED — an engine gave too few labels to judge.'); process.exit(2) }
|
||||
console.log(`BASEMAP-LABELS: PASS — ${LEAF}'s basemap place names follow the reader's language on Mapbox AND MapLibre.`)
|
||||
168
internal/check-poster-wall-localised.mjs
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-poster-wall-localised — the SHARED poster wall's own chrome must be translated
|
||||
* on every leaf that mounts it, in every locale.
|
||||
*
|
||||
* ★ WHY THIS CANNOT BE CHECKED IN ENGLISH, which is the whole reason the gap survived.
|
||||
* PosterWall guards useI18n() (it THROWS on a host that never installed vue-i18n, which blanked
|
||||
* people-groups-grid once via library FilterDropdown). The fallback HUMANIZES the key:
|
||||
* buttons.prayerList -> "Prayer List" buttons.printOptions -> "Print Options"
|
||||
* That is deliberate and good — a bare host shows readable English rather than a raw dotted key
|
||||
* or a blank button. But it means "the button says Prayer List" is TRUE both when i18n worked and
|
||||
* when it threw. An en-only check passes forever while every other locale leaks English.
|
||||
*
|
||||
* So this gate runs in NON-ENGLISH locales only. There, the translated string differs from the
|
||||
* humanized key, so seeing the humanized key IS the defect, with no ambiguity.
|
||||
*
|
||||
* ⚠ IT CHECKS THE RENDERED PAGE, NOT THE SOURCE. A static scan can see `useI18n()` wrapped in a
|
||||
* try/catch and conclude either "guarded, fine" or "might throw, bad" — neither is a measurement.
|
||||
* Whether the catch FIRES depends on what the host installed at the mount point, which only
|
||||
* exists at runtime. A scoreboard entry for this was open with nothing able to confirm or refute
|
||||
* it; that is what this closes.
|
||||
*
|
||||
* Usage: node internal/check-poster-wall-localised.mjs [baseUrl]
|
||||
* SKIPs when nothing is served; exits 2 (NOT MEASURED) when the server is not this tree.
|
||||
*/
|
||||
import { chromium } from 'playwright'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { servesThisTree } from './_serves-this-tree.mjs'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const BASE = process.argv[2] || process.env.BASE_URL || 'http://127.0.0.1:4173'
|
||||
|
||||
// EVERY leaf that mounts the shared wall. key-cities and regions-dashboard are here because they
|
||||
// consume PosterWall too — the original report named only three, and a gate that copies a
|
||||
// report's sampling inherits its blind spot.
|
||||
const LEAVES = [
|
||||
'my-upg-100-list/upg-100-mfe',
|
||||
'my-upg-100-list/poster-wall',
|
||||
'country-posters/country-poster-wall',
|
||||
'key-cities/city-poster-wall',
|
||||
'regions-dashboard/regions-list',
|
||||
]
|
||||
// ro is the widest-rendering Latin locale, ar is RTL, zh is double-width CJK: three different
|
||||
// ways a translation can be wrong, and all three differ from the humanized English key.
|
||||
const LANGS = (process.env.PW_LANG || 'ro,ar,zh').split(',').map((s) => s.trim()).filter(Boolean)
|
||||
|
||||
// The humanizeKey outputs for every key the wall renders. Seeing ONE of these in a non-English
|
||||
// locale means the fallback fired. Derived from the same transform the component uses, so the
|
||||
// two cannot drift: last path segment, camelCase split, first letter upper.
|
||||
const KEYS = ['buttons.prayerList', 'buttons.posterMap', 'buttons.cardDeck', 'buttons.printOptions',
|
||||
'buttons.postersPerRow', 'buttons.showOnMap', 'buttons.clearSelection', 'buttons.cardStockAria']
|
||||
const humanizeKey = (k) => String(k).split('.').pop()
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase())
|
||||
const FALLBACKS = new Set(KEYS.map(humanizeKey))
|
||||
|
||||
let reachable = false
|
||||
try { const r = await fetch(`${BASE}/doxa-maps/manifest.json`, { signal: AbortSignal.timeout(4000) }); reachable = r.ok } catch { /* precondition */ }
|
||||
if (!reachable) {
|
||||
console.log(`POSTER-WALL-LOCALISED: SKIP — nothing served at ${BASE}.`)
|
||||
console.log(' Serve the built tree: bun run serve (or set BASE_URL).')
|
||||
process.exit(0)
|
||||
}
|
||||
const treeCheck = await servesThisTree(BASE, ROOT)
|
||||
if (!treeCheck.ok) {
|
||||
console.error('POSTER-WALL-LOCALISED: NOT MEASURED — the server is not serving this checkout.')
|
||||
console.error(` ${treeCheck.why}`)
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
const WALK = `(root) => { const o=[]; const rec=(r)=>{ for (const e of r.querySelectorAll('*')) { o.push(e); if (e.shadowRoot) rec(e.shadowRoot) } }; rec(root); return o }`
|
||||
const browser = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] })
|
||||
const rows = []
|
||||
const fails = []
|
||||
|
||||
for (const leaf of LEAVES) {
|
||||
for (const lang of LANGS) {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } })
|
||||
const errs = []
|
||||
page.on('pageerror', (e) => errs.push(String(e).slice(0, 120)))
|
||||
let r = null
|
||||
try {
|
||||
const res = await page.goto(`${BASE}/doxa-maps/${leaf}/index.html?lang=${lang}`,
|
||||
{ waitUntil: 'load', timeout: 45000 })
|
||||
if (!res || !res.ok()) { rows.push({ leaf, lang, skipped: `HTTP ${res ? res.status() : '?'}` }); await page.close(); continue }
|
||||
await page.waitForTimeout(13000)
|
||||
r = await page.evaluate(`(() => {
|
||||
const all = (${WALK})(document)
|
||||
const cls = (e, n) => new RegExp('\\\\b' + n + '\\\\b').test(e.className || '')
|
||||
const texts = (n) => [...new Set(all.filter((e) => cls(e, n)).map((e) => e.textContent.trim()).filter(Boolean))]
|
||||
const legend = all.find((e) => e.tagName === 'LEGEND' && e.parentElement && /\\bpfs\\b/.test(e.parentElement.className || ''))
|
||||
const perrow = all.find((e) => cls(e, 'perrow__label'))
|
||||
return {
|
||||
cards: all.filter((e) => cls(e, 'card')).length,
|
||||
chrome: [
|
||||
...texts('pbtn'),
|
||||
...(legend ? [legend.textContent.trim()] : []),
|
||||
...(perrow ? [perrow.textContent.trim()] : []),
|
||||
...texts('pstock'),
|
||||
],
|
||||
showOnMap: texts('tape-h'),
|
||||
}
|
||||
})()`)
|
||||
} catch (e) {
|
||||
rows.push({ leaf, lang, skipped: String(e.message || e).slice(0, 60) })
|
||||
await page.close(); continue
|
||||
}
|
||||
await page.close()
|
||||
|
||||
// ⚠ TWO WAYS THIS GATE CAN PASS WITHOUT CHECKING ANYTHING, and both were REAL on the first
|
||||
// run — a gate that cannot tell "clean" from "saw nothing" is the same failure it exists to
|
||||
// catch. A wall with no cards renders no chrome; and regions-list rendered 7 cards with
|
||||
// chrome 0, because that profile mounts the wall WITHOUT the print row, so there was no
|
||||
// translatable string on the page at all and "all translated" was vacuously true.
|
||||
if (!r.cards) { rows.push({ leaf, lang, skipped: 'no poster cards rendered' }); continue }
|
||||
if (!r.chrome.length && !r.showOnMap.length) {
|
||||
rows.push({ leaf, lang, skipped: `${r.cards} card(s) but ZERO wall chrome — nothing to check` })
|
||||
continue
|
||||
}
|
||||
|
||||
// "Show on map" is a COMPOSED string ("116 UPG·Show on map"), so compare by containment.
|
||||
const leaked = [
|
||||
...r.chrome.filter((t) => FALLBACKS.has(t)),
|
||||
...r.showOnMap.filter((t) => [...FALLBACKS].some((f) => t.includes(f))),
|
||||
]
|
||||
rows.push({ leaf, lang, cards: r.cards, chrome: r.chrome.length, leaked, errs: errs.length })
|
||||
if (leaked.length) {
|
||||
fails.push(`${leaf} @ ${lang}: ${leaked.length} untranslated string(s) — ${[...new Set(leaked)].join(' · ')}`)
|
||||
}
|
||||
if (errs.length) fails.push(`${leaf} @ ${lang}: ${errs.length} page error(s) — ${errs[0]}`)
|
||||
}
|
||||
}
|
||||
await browser.close()
|
||||
|
||||
console.log('POSTER-WALL-LOCALISED')
|
||||
console.log(` base : ${BASE}`)
|
||||
for (const row of rows) {
|
||||
if (row.skipped) { console.log(` ${row.leaf} @ ${row.lang} SKIP — ${row.skipped}`); continue }
|
||||
console.log(` ${row.leaf} @ ${row.lang} cards ${row.cards} · chrome ${row.chrome} string(s) · `
|
||||
+ `${row.leaked.length ? `LEAKED ${row.leaked.length}` : 'all translated'} · ${row.errs} error(s)`)
|
||||
}
|
||||
const measured = rows.filter((x) => !x.skipped).length
|
||||
const skipped = rows.filter((x) => x.skipped)
|
||||
if (!measured) {
|
||||
console.error('\nPOSTER-WALL-LOCALISED: NOT MEASURED — every leaf skipped, so nothing was checked.')
|
||||
process.exit(2)
|
||||
}
|
||||
// ★ NO SILENT CAPS. A leaf that never got measured is not a leaf that passed, and a summary that
|
||||
// counts only the green ones reads as full coverage. Name what was dropped and why, every run —
|
||||
// key-cities ships NO leaf pages (bundle JS only), so it can never be reached by URL, and that
|
||||
// is a coverage fact a reader of this output needs rather than a 404 buried mid-list.
|
||||
if (skipped.length) {
|
||||
const byLeaf = new Map()
|
||||
for (const s of skipped) byLeaf.set(s.leaf, s.skipped)
|
||||
console.log(`\n NOT COVERED (${skipped.length} combination(s)):`)
|
||||
for (const [leaf, why] of byLeaf) console.log(` ${leaf} — ${why}`)
|
||||
}
|
||||
if (fails.length) {
|
||||
console.error(`\nPOSTER-WALL-LOCALISED: FAIL — ${fails.length} problem(s)`)
|
||||
for (const f of fails) console.error(` - ${f}`)
|
||||
console.error('\n The shared wall humanizes its i18n keys when useI18n() throws, so the fallback')
|
||||
console.error(' reads as fluent English ("Prayer List"). That is why this can only be seen in a')
|
||||
console.error(' non-English locale, and why an en-only check would pass while every other')
|
||||
console.error(' locale leaked.')
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`\nPOSTER-WALL-LOCALISED: PASS — ${measured} leaf x locale combination(s), `
|
||||
+ `${LANGS.join('/')}. The shared wall's own chrome is translated everywhere it mounts.`)
|
||||
|
|
@ -13,6 +13,8 @@
|
|||
* each way) that is not blank: many distinct colours, mostly non-white. The image is saved so a
|
||||
* human can READ it: streets, building footprints, the zone outline on top.
|
||||
* 3. LOCALE — ?lang=es gives a Spanish toolbar. 4. 0 page errors on the print page.
|
||||
* 5. FULL SCREEN — from a 520px embed, the whole planner takes the viewport.
|
||||
* 6. PRINT RESOLUTION — the printed map measures >= 300 DPI at the sheet's printable width.
|
||||
*
|
||||
* Starts its own servers on OS-assigned ports; needs only a built tree. Run it inside the shared
|
||||
* browser locks, like every browser gate. OUT=<dir> chooses where the print PNG is written.
|
||||
|
|
@ -69,6 +71,17 @@ try {
|
|||
console.log(`${ok ? 'PASS' : 'FAIL'} size ${where} ${W}x${H}: ${JSON.stringify(m)} ${errs.length ? 'ERR ' + errs.join(' | ') : '0 errors'}`)
|
||||
await p.close()
|
||||
}
|
||||
// 1b. FULL SCREEN from inside a small embed: the whole planner (toolbar + map) takes the viewport.
|
||||
{
|
||||
const p = await b.newPage({ viewport: { width: 1280, height: 800 } })
|
||||
const fsHost = createServer((q, r) => { r.writeHead(200, { 'content-type': 'text/html' }); r.end(`<!doctype html><body style="margin:0"><h1 style="height:80px;margin:0">host</h1><script src="${TREE}/doxa-maps-spa/doxa-maps-spa.js"></script><doxa-maps-spa tab="prayer-walk-planner" style="height:520px"></doxa-maps-spa></body>`) })
|
||||
const FP = await new Promise(ok => fsHost.listen(0, 'localhost', () => ok(fsHost.address().port)))
|
||||
await p.goto(`http://localhost:${FP}/`); await p.waitForTimeout(9000)
|
||||
const fs = await p.evaluate(`(async () => { const el = (${findEl.toString()})(); const btn = el.shadowRoot.querySelector('.pwp-map-controls button'); if (!btn) return null; const before = Math.round(el.shadowRoot.querySelector('canvas').getBoundingClientRect().height); btn.click(); await new Promise(r => setTimeout(r, 2500)); const r = el.getBoundingClientRect(); return { before, after: Math.round(el.shadowRoot.querySelector('canvas').getBoundingClientRect().height), host: [Math.round(r.width), Math.round(r.height)] } })()`)
|
||||
const fok = !!fs && fs.host[0] >= 1270 && fs.host[1] >= 790 && fs.after > fs.before
|
||||
console.log(`${fok ? 'PASS' : 'FAIL'} full screen from a 520px embed: ${JSON.stringify(fs)}`); results.push({ check: 'full screen', ok: fok })
|
||||
await p.close(); fsHost.close()
|
||||
}
|
||||
// 2. PRINT MAP of a real zone (Balat, Istanbul: dense houses on small streets) + 4. LOCALE (es)
|
||||
const p = await b.newPage({ viewport: { width: 1280, height: 800 } }); const errs = []
|
||||
p.on('pageerror', e => errs.push(String(e).slice(0, 140)))
|
||||
|
|
@ -86,6 +99,24 @@ try {
|
|||
console.log(`${pok ? 'PASS' : 'FAIL'} print map: ${JSON.stringify(st)} -> ${OUT}/pwp-print-balat.png`); results.push({ check: 'print map', ok: pok, st })
|
||||
} else { console.log('FAIL print map: renderZoneMap returned null'); results.push({ check: 'print map', ok: false }) }
|
||||
console.log(`${errs.length ? 'FAIL' : 'PASS'} page errors on print page: ${errs.length} ${errs.join(' | ')}`); results.push({ check: 'errors', ok: !errs.length })
|
||||
|
||||
// 5. PRINT RESOLUTION, MEASURED: the real printed sheet laid out as PRINT at the printable width
|
||||
// (8.5in - 2 x 12mm @page margins = 725 CSS px; emulateMedia alone does NOT apply @page margins,
|
||||
// which is how a first measurement read the frame as 8.48in wide). DPI = natural px / printed in.
|
||||
// The Driver: "a print that stays sharp when zoomed" — at >= 300 DPI a zoomed PDF shows real
|
||||
// street names and house outlines, not an upscaled screen grab.
|
||||
await p.evaluate(() => { new MutationObserver(ms => { for (const m of ms) for (const n of m.addedNodes) if (n.tagName === 'IFRAME') { try { n.contentWindow.print = () => {} } catch (_) {} window.__pwpFrame = n } }).observe(document.body, { childList: true }) })
|
||||
await p.evaluate(`(async () => { const el = (${findEl.toString()})(); const z = el.listPolygons().find(x => x.title === 'Balat'); el.selectPolygon(z.id, true); await el.print() })()`)
|
||||
await p.waitForTimeout(3000)
|
||||
const sheet = await p.evaluate(() => window.__pwpFrame ? window.__pwpFrame.contentDocument.documentElement.outerHTML : '')
|
||||
if (sheet) {
|
||||
const q = await b.newPage(); await q.emulateMedia({ media: 'print' }); await q.setViewportSize({ width: 725, height: 965 })
|
||||
await q.setContent('<!DOCTYPE html>' + sheet.replace(/<script>[\s\S]*?<\/script>/g, ''), { waitUntil: 'load' }); await q.waitForTimeout(1200)
|
||||
const dpi = await q.evaluate(() => { const i = document.querySelector('.snapshot'); if (!i) return null; const r = i.getBoundingClientRect(); return { naturalPx: [i.naturalWidth, i.naturalHeight], printedInches: [+(r.width / 96).toFixed(2), +(r.height / 96).toFixed(2)], dpi: [Math.round(i.naturalWidth / (r.width / 96)), Math.round(i.naturalHeight / (r.height / 96))] } })
|
||||
const dok = !!dpi && dpi.dpi[0] >= 300 && dpi.dpi[1] >= 300
|
||||
console.log(`${dok ? 'PASS' : 'FAIL'} print resolution: ${JSON.stringify(dpi)}`); results.push({ check: 'print dpi', ok: dok })
|
||||
await q.close()
|
||||
} else { console.log('FAIL print resolution: the print frame never appeared'); results.push({ check: 'print dpi', ok: false }) }
|
||||
await p.close()
|
||||
} finally { await b.close(); host.close(); srv.kill() }
|
||||
const bad = results.filter(r => !r.ok).length
|
||||
|
|
|
|||
|
|
@ -32,8 +32,14 @@
|
|||
* actual side by side.
|
||||
*/
|
||||
import { chromium } from 'playwright'
|
||||
import { requireDevServesThisCheckout as __devGuard, requireThisTree as __treeGuard } from './_serves-this-tree.mjs'
|
||||
import { fileURLToPath as __f2p } from 'node:url'
|
||||
import { dirname as __dn, resolve as __rs } from 'node:path'
|
||||
const __ROOT = __rs(__dn(__f2p(import.meta.url)), '..')
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://127.0.0.1:4173/doxa-maps'
|
||||
// Measure THIS checkout, or measure nothing (exit 75 = NOT MEASURED; see _serves-this-tree.mjs).
|
||||
await __treeGuard(BASE, __ROOT, 'SPA-TAB-ORDER')
|
||||
|
||||
// The Driver's sequence, verbatim from the open-work brief. Prefix-matched (case-insensitive,
|
||||
// whitespace-collapsed) so a row may carry its subtitle.
|
||||
|
|
|
|||
|
|
@ -14,8 +14,14 @@
|
|||
* Plugin gallery dev server: --url http://localhost:5174/plugin-designs/doxa-simple-map/index.html
|
||||
*/
|
||||
import { launch, TELEMETRY } from './lib/browser.mjs'
|
||||
import { requireDevServesThisCheckout } from './_serves-this-tree.mjs'
|
||||
import { fileURLToPath as __f2p } from 'node:url'
|
||||
import { dirname as __dn, resolve as __rs } from 'node:path'
|
||||
const args = process.argv.slice(2); const argOf = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d }
|
||||
const URL = argOf('--url', 'http://localhost:3001/pray'), TAG = argOf('--tag', 'doxa-map'), W = +argOf('--width', 1440), H = +argOf('--height', 900), ROWS = +argOf('--rows', 8)
|
||||
// Guard ONLY when pointed at OUR dev gallery (/plugin-designs/…). The default target is the DX-life
|
||||
// site (a different app this probe compares against on purpose), which has no source of ours to match.
|
||||
if (/\/plugin-designs\//.test(URL)) await requireDevServesThisCheckout(URL, __rs(__dn(__f2p(import.meta.url)), '..'), 'LEGEND-COLUMNS')
|
||||
const browser = await launch('firefox', { label: 'legend-columns' })
|
||||
const ctx = await browser.newContext({ viewport: { width: W, height: H }, hasTouch: true, isMobile: W < 600 }); const page = await ctx.newPage()
|
||||
await page.route(TELEMETRY, r => r.abort())
|
||||
|
|
|
|||
|
|
@ -56,12 +56,18 @@
|
|||
import { writeFileSync, mkdirSync } from 'node:fs'
|
||||
import { dirname } from 'node:path'
|
||||
import { launch, DEEP_FIND_SRC, deepEvaluate, TELEMETRY } from './lib/browser.mjs'
|
||||
import { requireDevServesThisCheckout as __devGuard, requireThisTree as __treeGuard } from './_serves-this-tree.mjs'
|
||||
import { fileURLToPath as __f2p } from 'node:url'
|
||||
import { dirname as __dn, resolve as __rs } from 'node:path'
|
||||
const __ROOT = __rs(__dn(__f2p(import.meta.url)), '..')
|
||||
|
||||
const TOOL = 'long-frames-probe'
|
||||
const args = process.argv.slice(2)
|
||||
const argOf = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d }
|
||||
const argAll = (k) => args.flatMap((a, i) => a === k && args[i + 1] ? [args[i + 1]] : [])
|
||||
const BASE = String(argOf('--base', 'http://localhost:5174')).replace(/\/+$/, '')
|
||||
// Measure THIS checkout, or measure nothing (exit 75 = NOT MEASURED; see _serves-this-tree.mjs).
|
||||
await __devGuard(BASE, __ROOT, 'LONG-FRAMES')
|
||||
const URLS = argAll('--url')
|
||||
const TAGS = argAll('--tag')
|
||||
const GREP = argOf('--grep', '')
|
||||
|
|
|
|||
|
|
@ -61,11 +61,17 @@
|
|||
import { writeFileSync, mkdirSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { launch, TELEMETRY } from './lib/browser.mjs'
|
||||
import { requireDevServesThisCheckout as __devGuard, requireThisTree as __treeGuard } from './_serves-this-tree.mjs'
|
||||
import { fileURLToPath as __f2p } from 'node:url'
|
||||
import { dirname as __dn, resolve as __rs } from 'node:path'
|
||||
const __ROOT = __rs(__dn(__f2p(import.meta.url)), '..')
|
||||
|
||||
const TOOL = 'mobile-sheet-probe'
|
||||
const args = process.argv.slice(2)
|
||||
const argOf = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d }
|
||||
const URL = String(argOf('--url', 'http://localhost:5174/plugin-designs/doxa-simple-map/index.html'))
|
||||
// Measure THIS checkout, or measure nothing (exit 75 = NOT MEASURED; see _serves-this-tree.mjs).
|
||||
await __devGuard(URL, __ROOT, 'MOBILE-SHEET')
|
||||
const TAG = argOf('--tag', 'doxa-map')
|
||||
const NTH = Number(argOf('--nth', 0))
|
||||
const INSTANCE = argOf('--instance', '')
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@
|
|||
*/
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { launch, deepEvaluate, TELEMETRY } from './lib/browser.mjs'
|
||||
import { requireDevServesThisCheckout as __devGuard, requireThisTree as __treeGuard } from './_serves-this-tree.mjs'
|
||||
import { fileURLToPath as __f2p } from 'node:url'
|
||||
import { dirname as __dn, resolve as __rs } from 'node:path'
|
||||
const __ROOT = __rs(__dn(__f2p(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 }
|
||||
|
|
@ -40,6 +44,8 @@ const argOf = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1]
|
|||
// cannot reach its subject has not passed and has not failed: it has not run, and the message
|
||||
// blames the user for not starting a server they had started.
|
||||
const BASE = String(argOf('--url', process.env.DEV_URL || 'http://localhost:5173')).replace(/\/+$/, '')
|
||||
// Measure THIS checkout, or measure nothing (exit 75 = NOT MEASURED; see _serves-this-tree.mjs).
|
||||
await __devGuard(BASE, __ROOT, 'PLUGIN-GALLERY-SMOKE')
|
||||
|
||||
// Paint needs a GPU this build box does not have; see the long note at the canvas assertion below.
|
||||
const EXPECT_CANVAS = process.env.EXPECT_CANVAS === '1'
|
||||
|
|
|
|||
|
|
@ -385,6 +385,10 @@ await shot('prod-prayer-phone', '/doxa-maps/doxa-simple-map/doxa-simple-map/pray
|
|||
await shot('prod-cities-internet-phone', '/doxa-maps/cities-internet/connectivity-map/index.html', { vp: { width: 390, height: 844 }, dsf: 2, wait: 10000, minElements: 60 })
|
||||
await shot('prod-gospel-resources-phone', '/doxa-maps/gospel-resources/resources-map/index.html', { vp: { width: 390, height: 844 }, dsf: 2, wait: 10000, minElements: 60 })
|
||||
|
||||
// ---- EXPERIMENTAL SHELF: the plugins the README pictures there ----
|
||||
await shot('exp-wall-of-people-groups', '/doxa-maps/wall-of-people-groups/wall-of-people-groups/index.html', { wait: 12000, minElements: 40 })
|
||||
await shot('exp-prayer-walk-planner', '/doxa-maps/prayer-walk-planner/prayer-walk-planner/index.html', { wait: 12000, minElements: 40 })
|
||||
|
||||
// ---- DEPLOY2: mobile — the UPG-100 list as the phone app ----
|
||||
// The mobile example is UPG-100 (ruled 2026-09-24; Simple Research was retired as the example because
|
||||
// it was never built for phones). Shot once its owner marked the phone layout ready at 88a566f:
|
||||
|
|
|
|||
|
|
@ -44,6 +44,10 @@
|
|||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { requireDevServesThisCheckout as __devGuard, requireThisTree as __treeGuard } from './_serves-this-tree.mjs'
|
||||
import { fileURLToPath as __f2p } from 'node:url'
|
||||
import { dirname as __dn, resolve as __rs } from 'node:path'
|
||||
const __ROOT = __rs(__dn(__f2p(import.meta.url)), '..')
|
||||
import { launch, deepEvaluate, TELEMETRY } from './lib/browser.mjs'
|
||||
|
||||
const TOOL = 'share-smoke'
|
||||
|
|
@ -63,6 +67,8 @@ const GREP = argOf('--grep', '')
|
|||
const GALLERY = has('--plugin-gallery')
|
||||
? String((() => { const v = args[args.indexOf('--plugin-gallery') + 1]; return v && !v.startsWith('--') ? v : 'http://localhost:5174' })()).replace(/\/+$/, '')
|
||||
: null
|
||||
// Measure THIS checkout, or measure nothing (exit 75 = NOT MEASURED; see _serves-this-tree.mjs).
|
||||
if (GALLERY) await __devGuard(GALLERY, __ROOT, 'SHARE-ROUNDTRIP')
|
||||
|
||||
if (!URLS.length && !GALLERY) {
|
||||
console.error(`[${TOOL}] usage: --url <host page URL> [--url ...] [--tag <tag>] | --plugin-gallery [base] (see header)`)
|
||||
|
|
|
|||
|
|
@ -47,12 +47,18 @@
|
|||
* removing tree-base makes it FAIL on the empty tab list. A gate that cannot fail is worse than none.
|
||||
*/
|
||||
import { chromium } from 'playwright'
|
||||
import { requireDevServesThisCheckout as __devGuard, requireThisTree as __treeGuard } from './_serves-this-tree.mjs'
|
||||
import { fileURLToPath as __f2p } from 'node:url'
|
||||
import { dirname as __dn, resolve as __rs } from 'node:path'
|
||||
const __ROOT = __rs(__dn(__f2p(import.meta.url)), '..')
|
||||
|
||||
const argOf = (flag, dflt) => {
|
||||
const i = process.argv.indexOf(flag)
|
||||
return i > -1 && process.argv[i + 1] ? process.argv[i + 1] : dflt
|
||||
}
|
||||
const BASE = String(argOf('--url', 'http://localhost:5173')).replace(/\/+$/, '')
|
||||
// Measure THIS checkout, or measure nothing (exit 75 = NOT MEASURED; see _serves-this-tree.mjs).
|
||||
await __devGuard(BASE, __ROOT, 'SMOKE-STUDIO')
|
||||
const BUDGET_MS = Number(process.env.SMOKE_BUDGET_MS || 20000)
|
||||
|
||||
// Deep walker — SHADOW DOM DOES NOT PIERCE. A flat querySelectorAll lies about this component, and a
|
||||
|
|
|
|||
|
|
@ -119,11 +119,17 @@
|
|||
import { writeFileSync, mkdirSync, readFileSync, rmSync, mkdtempSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { requireDevServesThisCheckout as __devGuard, requireThisTree as __treeGuard } from './_serves-this-tree.mjs'
|
||||
import { fileURLToPath as __f2p } from 'node:url'
|
||||
import { dirname as __dn, resolve as __rs } from 'node:path'
|
||||
const __ROOT = __rs(__dn(__f2p(import.meta.url)), '..')
|
||||
import { launch, DEEP_FIND_SRC, deepEvaluate, TELEMETRY } from './lib/browser.mjs'
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const argOf = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d }
|
||||
const BASE = String(argOf('--url', 'http://localhost:5174')).replace(/\/+$/, '')
|
||||
// Measure THIS checkout, or measure nothing (exit 75 = NOT MEASURED; see _serves-this-tree.mjs).
|
||||
await __devGuard(BASE, __ROOT, 'THEME-FLASH')
|
||||
const GREP = argOf('--grep', '')
|
||||
const TARGET = argOf('--target', '')
|
||||
const TAG = argOf('--tag', '')
|
||||
|
|
|
|||
11
package.json
|
|
@ -125,6 +125,7 @@
|
|||
"check:sfc-parse": "node internal/check-sfc-script-parses.mjs",
|
||||
"check:url-resolver": "node internal/check-url-resolver.mjs",
|
||||
"check:studio-all": "node internal/check-studio-every-plugin.mjs",
|
||||
"check:basemap-labels": "node internal/check-basemap-labels-localized.mjs",
|
||||
"check:vision-card-numbers": "node internal/check-vision-card-numbers.mjs",
|
||||
"check:map-embed-cells": "node internal/check-map-embed-cells.mjs",
|
||||
"check:map-unmount-race": "node internal/check-map-unmount-guard.mjs",
|
||||
|
|
@ -178,9 +179,10 @@
|
|||
"check:doc-scripts:self-test": "node internal/check-doc-quotes-scripts.mjs --self-test",
|
||||
"check:legend-source": "node internal/check-legend-matches-source.mjs",
|
||||
"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:wp-embed check:doc-scripts",
|
||||
"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:legend-source check:backlog-refs check:composite-embed-knobs",
|
||||
"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:legend-source check:backlog-refs check:composite-embed-knobs check:linked-vocabulary",
|
||||
"build:bundles": "node internal/run-parallel.mjs --bundles",
|
||||
"check:composite-embed-knobs": "node internal/check-composite-embed-knobs.mjs",
|
||||
"check:linked-vocabulary": "node plugin-designs/research-map-plus-grid/scripts/check-linked-vocabulary.mjs",
|
||||
"check:maplibre-drops": "node internal/check-maplibre-layer-drops.mjs",
|
||||
"check:tabs-localized": "node internal/check-tabs-localized.mjs",
|
||||
"check:packets-all": "node internal/check-packets-all.mjs",
|
||||
|
|
@ -201,7 +203,8 @@
|
|||
"check:gospel-all": "node internal/check-gospel-all.mjs",
|
||||
"check:no-conflict-markers": "node internal/check-no-conflict-markers.mjs",
|
||||
"check:no-conflict-markers:poison": "node internal/check-no-conflict-markers.mjs --poison",
|
||||
"check:english-leaks": "node internal/check-gospel-english-leaks.mjs"
|
||||
"check:english-leaks": "node internal/check-gospel-english-leaks.mjs",
|
||||
"check:poster-wall-localised": "node internal/check-poster-wall-localised.mjs"
|
||||
},
|
||||
"//scripts": {
|
||||
"_": "What each script does. These notes used to sit inside the command strings as trailing \"# …\" comments, which made the shell discard any arguments passed after \"--\" (so a gate given --url silently measured its hardcoded default port — possibly another agent's server). Keep notes here, never in a command.",
|
||||
|
|
@ -282,6 +285,7 @@
|
|||
"check:embed-basemap": "the gospel map's BASEMAP must paint inside a genuinely cross-origin iframe on the token the page BAKES, not one a probe forces on it. Every other browser gate here forces a restricted token (u=doxalife) that 403s on the basemap, then counts PINS — which come from our own GeoJSON and paint with no token at all — so 'the map renders' had never been measured. Runs a negative control every time by rewriting the token on the wire; if the control passes it reports PROVED NOTHING rather than a pass. ~60s, needs a browser.",
|
||||
"serve:permissive": "LOOPBACK ONLY. This was --bind 0.0.0.0, which published the built tree on every interface of whatever machine ran a gate; measured on the build VPS it answered HTTP 200 on the box's PUBLIC IP while a browser gate was running. Every consumer (verify-browser-all, check:a11y-button-names, check:nav-reachable, smoke:share-all) already defaults to 127.0.0.1, so nothing needed the wider bind. Override with BASE_URL/BASE if you deliberately want to reach it from another host.",
|
||||
"check:legend-source": "the numbers the maps SHOW must match the numbers the live SOURCE has. Every other gate checks INTERNAL consistency — a page can agree with itself perfectly while its legend has stopped describing reality. Offline SKIPs (exit 0); only a reachable endpoint that disagrees fails.",
|
||||
"check:linked-vocabulary": "the two halves of research-map-plus-grid must mean the SAME THING by a religion family. The map filters by the legend's FIRST-LETTER key ('B'), the grid by a DERIVED family name ('Buddhism'), and both read ONE shared table (library/colors/religion.js) in opposite directions - so a wrong link stays invisible: both halves look filtered and show different peoples. Compares the total reached over the composite's wire against the total the grid gives for the same facet on its own, and runs the MUTANT first (the legend key instead of the name), failing as NO-TEETH if the wrong mapping scores the same as the right one.",
|
||||
"check:composite-embed-knobs": "a composite must pass the HOST's tk and provider down to the map it nests, proven on a genuinely foreign origin (tree served on one port, partner page on another). Our own leaves bake a token and assign window.MAP_TOKEN, so this only ever breaks where there is no leaf - the partner page the embed snippet is written for, and the case nothing else tested. Keyless maplibre asserts ZERO requests to api.mapbox.com, because 'it rendered' would pass on a build that quietly fell back with a baked key.",
|
||||
"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:packets-all": "runs check:packet-render over EVERY generated packet, not one hand-named file. The single-file gate SKIPs with no argument, so it was in package.json, passing in all 11 locales, and in neither gates:pre nor gates:post — it only ran when someone typed a filename. SKIPs when no packets are generated.",
|
||||
|
|
@ -304,7 +308,8 @@
|
|||
"check:leaves-mount": "DID THE ELEMENT ACTUALLY MOUNT, on all five gospel leaves. Exists because a ReferenceError thrown during setup dies INSIDE the custom-element boundary: the page loads, every asset returns 200, pageerror fires zero times, and the element sits there with an empty shadow root. A healthy-looking dead page. This asks the cheap unambiguous question the rich gates are too slow to ask on every leaf — is anything there — in five steps that each name their own failure: tag defined, host present, shadowRoot attached, shadow root non-empty, visible text rendered. It does NOT trust the absence of console errors; zero errors is not the same as working. --poison for the negative test.",
|
||||
"check:gospel-all": "EVERYTHING gospel-resources owns, in one command. Its ten gates were all hand-run, which is how a dead bundle shipped: a duplicate module killed all five leaves and the one gate run that day was pointed at the map. check:no-orphan-gates was right that they were reachable — reachable is not run. Deliberately NOT in gates:pre/post: three need a browser, the shared wave holds browser gates under load, and a bundle owner should not be able to slow everyone else's build by adding to a list they do not own. Static gates run first and stop the run on failure, so a scarce browser slot is never spent confirming a table is inconsistent. Exit 75 = NOT MEASURED, never folded into a pass.",
|
||||
"check:no-conflict-markers": "Nothing tracked may carry an unresolved merge. Exists because a merge from this worktree committed a BUILT bundle with 21 conflict markers - both sides concatenated, 1.13MB - and every gospel leaf rendered dead while the SRI hash matched and every request returned 200. The cause was a landing script that resolved one expected file then ran \"git add -A public\", staging every other file including the ones still holding markers. For a built file: take either side, REBUILD, commit the rebuild, then run this before pushing. --poison for the negative test.",
|
||||
"check:english-leaks": "A translated key must not render in English. rega's method: a string is a LEAK when the catalogue has a DIFFERENT value for the locale and the page still shows the English one - a render/wiring fault, not a missing translation. Third-party attributions, data names, placeholder-only strings and the prose this bundle deliberately keeps in English are all excluded, the last by simply not being in the catalogue. Scoped to the LEAF on purpose: six keys were reported leaking in the gallery and the leaf is clean in ar/zh/ru/hi - the gallery builds its iframe src with no lang parameter, so the embed never learns the locale. Embedder defect, not a bundle one."
|
||||
"check:english-leaks": "A translated key must not render in English. rega's method: a string is a LEAK when the catalogue has a DIFFERENT value for the locale and the page still shows the English one - a render/wiring fault, not a missing translation. Third-party attributions, data names, placeholder-only strings and the prose this bundle deliberately keeps in English are all excluded, the last by simply not being in the catalogue. Scoped to the LEAF on purpose: six keys were reported leaking in the gallery and the leaf is clean in ar/zh/ru/hi - the gallery builds its iframe src with no lang parameter, so the embed never learns the locale. Embedder defect, not a bundle one.",
|
||||
"check:basemap-labels": "the BASEMAP's place names follow the reader's language on BOTH engines (library/maps/localize-labels.js): reads the RENDERED country labels at ?lang=en and ?lang=ru — Mapbox via the Standard basemap's place-labels featureset, MapLibre via each symbol's evaluated text — and requires them to flip. Serves this checkout on an OS-assigned port; --dir <tree> for a negative control. Negative-tested: a bundle built before 319064a1 FAILS on both engines."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/android": "^7.6.9",
|
||||
|
|
|
|||
|
|
@ -1964,6 +1964,48 @@ watch(() => profileConfig?.value?.externalFilters, (raw) => {
|
|||
applyingExternal = true
|
||||
try {
|
||||
if ('region' in raw) mapStore.selectRegion?.(raw.region || null)
|
||||
|
||||
// ★ RELIGION LINKS AT THE FAMILY, AND THE KEY↔NAME MAPPING IS THE ONE BOTH HALVES ALREADY USE.
|
||||
// MEASURED, and the measurement changed the design twice:
|
||||
// 1. This map's records carry religion as a 1-3 char CODE (BLA, CPR, E, MSN) and a people-group
|
||||
// grid filters by a display name, so the two vocabularies do not meet at the raw religion.
|
||||
// 2. `mapStore.filters.religions`, which the filter panel writes, is read by NOTHING — the
|
||||
// `mapLayers.applyFilters?.()` it was meant to reach is called but never implemented, so a
|
||||
// link routed through it would have set state and dimmed no pin. Verified by searching the
|
||||
// tree for a definition: there is none.
|
||||
// What DOES filter this map by religion is the legend's own flat tab, keyed by the FIRST LETTER
|
||||
// of the code — `uiStore.religionFilter`, watched below into applyDimFilter. And that letter's
|
||||
// display name comes from RELIGION_FAMILIES in library/colors/religion.js, the same module the
|
||||
// people-groups grid imports to derive its own `__religion_family` axis. So the canonical name
|
||||
// and the key are two views of ONE shared table that neither half copies: the grid turns a code
|
||||
// into a family name through it, and this reverse lookup turns that family name back into the
|
||||
// key this map filters by. No table is written here, and a change to that module moves both
|
||||
// halves at once. `routeReligion` below already does this exact lookup for geocoder searches.
|
||||
if ('religion' in raw) {
|
||||
const name = String(raw.religion || '').trim()
|
||||
const letter = name
|
||||
? Object.keys(RELIGION_FAMILIES).find((k) => String(RELIGION_FAMILIES[k]).toLowerCase() === name.toLowerCase())
|
||||
: ''
|
||||
if (!name) {
|
||||
if (uiStore.religionFilter) uiStore.setReligionFilter(null)
|
||||
} else if (letter) {
|
||||
// Switch first so the legend shows the selected row, then set the filter on the next tick —
|
||||
// the tab watcher clears highlights on its way in, and routeReligion defers for the same reason.
|
||||
if (activeTabId.value !== 'religion') switchTab('religion')
|
||||
nextTick(() => uiStore.setReligionFilter(letter))
|
||||
}
|
||||
// A name that is not one of the ten families is left alone rather than guessed at: dimming
|
||||
// every pin because the host said something this map cannot express is worse than doing nothing.
|
||||
}
|
||||
|
||||
// Language needs no translation — MEASURED, this map's own vocabulary is display names
|
||||
// ('Abai Sungai', 'Afar', 'Afrikaans'), the same strings a people-group grid shows for
|
||||
// primary_language. Verified rather than assumed, because religion just above is the counter-example.
|
||||
//
|
||||
// ★ THE MAP'S SELECTIONS ARE MUTUALLY EXCLUSIVE AND THAT IS NOT OURS TO OVERRIDE. selectLanguage
|
||||
// nulls selectedRegion by design (Bug 13), so region and language cannot both be lit here. The
|
||||
// composite mirrors that exclusivity in its own state rather than letting the two halves disagree.
|
||||
if ('language' in raw) mapStore.selectLanguage?.(raw.language || null)
|
||||
} finally {
|
||||
// Release on the NEXT tick, not synchronously: the watchers below fire after this one, and
|
||||
// releasing too early lets our own echo travel back out as if the reader had filtered here.
|
||||
|
|
@ -1979,7 +2021,30 @@ if (typeof window !== 'undefined') {
|
|||
window.__researchMapFilterState = () => ({
|
||||
selectedRegion: mapStore?.selectedRegion || '',
|
||||
externalRegion: profileConfig?.value?.externalFilters?.region || '',
|
||||
religionFamily: RELIGION_FAMILIES[uiStore?.religionFilter] || '',
|
||||
selectedLanguage: mapStore?.selectedLanguage || '',
|
||||
})
|
||||
// The VOCABULARY this map filters by, read off its own normalized records. A composite that wants
|
||||
// to drive this map has to speak these exact strings, and the only honest way to know them is to
|
||||
// ask the data rather than to write a table beside it and hope the two stay in step.
|
||||
// `religionFamily` is DERIVED, not stored: the family is the first letter of the code, named by
|
||||
// RELIGION_FAMILIES — the same table, run the same direction, as the grid's `__religion_family`
|
||||
// accessor. That is what makes the two vocabularies comparable, and comparing them is how the
|
||||
// linking is checked rather than assumed (see scripts/check-linked-vocabulary.mjs).
|
||||
window.__researchMapVocabulary = (field) => {
|
||||
const rows = mapData?.normalizedPeopleGroups?.value || []
|
||||
const seen = new Set()
|
||||
for (const r of rows) {
|
||||
if (field === 'religionFamily') {
|
||||
const name = RELIGION_FAMILIES[String(r?.religion || '').charAt(0)]
|
||||
if (name) seen.add(name)
|
||||
continue
|
||||
}
|
||||
const v = r && r[field]
|
||||
if (typeof v === 'string' && v) seen.add(v)
|
||||
}
|
||||
return [...seen].sort()
|
||||
}
|
||||
}
|
||||
|
||||
// Report OUR filter state upward, so a composite can drive its other half with it.
|
||||
|
|
@ -1987,10 +2052,18 @@ function emitFilterState() {
|
|||
if (applyingExternal || typeof window === 'undefined') return
|
||||
const host = getCurrentInstance()?.proxy?.$el?.getRootNode?.()?.host
|
||||
if (!host) return
|
||||
const detail = { region: mapStore?.selectedRegion || '' }
|
||||
// Report in the vocabulary a HOST speaks: the family NAME, not the legend's letter key. Same
|
||||
// shared RELIGION_FAMILIES table the inbound direction reads, used the other way round.
|
||||
const religionName = RELIGION_FAMILIES[uiStore?.religionFilter] || ''
|
||||
const detail = {
|
||||
region: mapStore?.selectedRegion || '',
|
||||
religion: religionName,
|
||||
language: mapStore?.selectedLanguage || '',
|
||||
}
|
||||
host.dispatchEvent(new CustomEvent('map-filtered', { detail, bubbles: true, composed: true }))
|
||||
}
|
||||
watch(() => mapStore?.selectedRegion, () => emitFilterState())
|
||||
watch(() => [mapStore?.selectedRegion, mapStore?.selectedLanguage, uiStore?.religionFilter],
|
||||
() => emitFilterState())
|
||||
|
||||
// ─── Universal-legend (LegendRows) → applyDimFilter bridge ───────────────────
|
||||
// LegendRows / useLegendData routes row-clicks through mapStore.selectFamily()
|
||||
|
|
|
|||
|
|
@ -378,6 +378,52 @@ watch(() => props.wagfRegion, (v) => {
|
|||
}
|
||||
})
|
||||
|
||||
// RELIGION AND LANGUAGE, THE SAME WAY AND FOR THE SAME REASON (BUILD-LINKED-FILTERING cell D).
|
||||
// Both are host INPUTS that must also survive local-control mode — see the wagfRegion note above,
|
||||
// which this repeats rather than generalises, because the RESOLUTION differs per facet and a shared
|
||||
// loop would have to encode that difference as a table.
|
||||
//
|
||||
// ★ RELIGION LINKS AT THE FAMILY, NOT AT THE CODE, AND THE MAPPING IS ALREADY SHARED.
|
||||
// MEASURED: a research map filters religion through its legend's flat tab, keyed by the FIRST LETTER
|
||||
// of the religion code, and turns that letter into a name with RELIGION_FAMILIES — the very module
|
||||
// this file imports at the top to derive `__religion_family`. So the two halves already share one
|
||||
// canonical↔key table and neither holds a copy of it: the map reverses the lookup, this grid runs it
|
||||
// forward per row. The wire carries the family NAME because that is the form the shared table names
|
||||
// and the form a reader sees on both sides.
|
||||
//
|
||||
// The code-level `religion` prop stays exactly as it was, un-linked: a map cannot express 'BLA'
|
||||
// distinctly from 'BMA' (both dim as Buddhism), so linking at the code would promise a precision one
|
||||
// half cannot keep. An honest gap beats a wrong link.
|
||||
watch(() => props.religionFamily, (v) => {
|
||||
if (!useFilters.value) return
|
||||
const cur = localFilters.value.__religion_family
|
||||
if (v) {
|
||||
if (cur && norm(cur.value) === norm(v)) return
|
||||
localFilters.value = { ...localFilters.value, __religion_family: { value: v, label: v } }
|
||||
} else if (cur) {
|
||||
const next = { ...localFilters.value }
|
||||
delete next.__religion_family
|
||||
localFilters.value = next
|
||||
}
|
||||
})
|
||||
|
||||
// Language needs no translation at all — MEASURED, the map's language vocabulary is already display
|
||||
// names ('Afar', 'Afrikaans'), the same strings this grid's primary_language labels carry, and
|
||||
// valueMatches compares the normalized label as well as the value. That is a measurement, not an
|
||||
// assumption; religion above is the counter-example that made it worth measuring.
|
||||
watch(() => props.primaryLanguage, (v) => {
|
||||
if (!useFilters.value) return
|
||||
const cur = localFilters.value.primary_language
|
||||
if (v) {
|
||||
if (cur && norm(cur.value) === norm(v)) return
|
||||
localFilters.value = { ...localFilters.value, primary_language: { value: v, label: v } }
|
||||
} else if (cur) {
|
||||
const next = { ...localFilters.value }
|
||||
delete next.primary_language
|
||||
localFilters.value = next
|
||||
}
|
||||
})
|
||||
|
||||
// How many filters are ACTUALLY narrowing the list. Counts only entries carrying a real value —
|
||||
// an empty/cleared filter still occupies a key, so Object.keys().length would over-report and the
|
||||
// badge would lie. Drives the toggle's count badge, so a collapsed panel still says what it hides.
|
||||
|
|
@ -765,7 +811,34 @@ watch(() => [props.query, propFilters.value,
|
|||
filterUUPGs()
|
||||
})
|
||||
|
||||
onMounted(getUUPGs)
|
||||
// ★ THE VOCABULARY THIS GRID FILTERS BY, READ OFF ITS OWN ROWS.
|
||||
// A composite linking this grid to another element has to know which strings the two halves share,
|
||||
// and the only answer that cannot go stale is the one the data gives. Symmetric with the research
|
||||
// map's `__researchMapVocabulary`, deliberately: a check can ask both halves the same question and
|
||||
// compare the two answers, which is what makes the linking VERIFIABLE instead of merely plausible.
|
||||
// Returns display names, because that is the form both halves can read.
|
||||
//
|
||||
// Exposed on the ELEMENT first — one page may hold several grids and a window global would answer
|
||||
// for whichever mounted last — with the global kept as the convenience a plain host page reaches
|
||||
// for, matching `__researchMapFilterState`'s existing shape.
|
||||
function vocabulary(field) {
|
||||
const seen = new Set()
|
||||
for (const u of (uupgs.value || [])) {
|
||||
// rowAxisValue, not u[field] — so a DERIVED axis (`__religion_family`) answers through the same
|
||||
// accessor the filters use. Asking the raw field would report codes for a facet that links by name.
|
||||
const vl = rowAxisValue(u, field)
|
||||
const name = vl && typeof vl === 'object' ? vl.label : vl
|
||||
if (typeof name === 'string' && name) seen.add(name)
|
||||
}
|
||||
return [...seen].sort()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const el = hostEl()
|
||||
if (el) el.__vocabulary = vocabulary
|
||||
if (typeof window !== 'undefined') window.__peopleGroupsGridVocabulary = vocabulary
|
||||
getUUPGs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
|
|||
|
|
@ -46,11 +46,12 @@
|
|||
* source of truth (the `polygons` ref), so a human clicking buttons and an agent
|
||||
* calling methods can freely interleave.
|
||||
*/
|
||||
import { ref, shallowRef, reactive, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { ref, shallowRef, reactive, computed, onMounted, onBeforeUnmount, nextTick, inject, watch } from 'vue'
|
||||
import { useMapInstance } from '@map/composables/useMapInstance.js'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import { openPrintFrame } from '@map/composables/printInPlace.js'
|
||||
import ShareButton from '@map/components/map-controls/ShareButton.vue'
|
||||
import FullscreenButton from '@map/components/map-controls/FullscreenButton.vue'
|
||||
import { resolveNamedStyle } from '@map/styles/registry.js'
|
||||
import { ensureRtlTextPlugin } from '@map/maps/rtl-text.js'
|
||||
import { localizeBasemapLabels } from '@map/maps/localize-labels.js'
|
||||
|
|
@ -102,6 +103,14 @@ const uiLocale = computed(() => {
|
|||
const uiDir = computed(() => (uiLocale.value === 'ar' ? 'rtl' : 'ltr'))
|
||||
const tr = (key, params) => translate(uiLocale.value, key, params)
|
||||
|
||||
// The shared controls (Share, Full screen) label themselves through the vue-i18n instance index.js
|
||||
// installs, which reads only the PAGE language. Inside the Research Gallery the language arrives in
|
||||
// profile-config instead, so keep that instance on the planner's own locale.
|
||||
const appI18n = inject('appI18n', null)
|
||||
watch(uiLocale, (l) => {
|
||||
try { if (appI18n && appI18n.global) { if (typeof appI18n.global.locale === 'object') appI18n.global.locale.value = l; else appI18n.global.locale = l } } catch (_) { /* labels stay in the page language */ }
|
||||
}, { immediate: true })
|
||||
|
||||
const INSTANCE_ID = props.instanceId || ('pwp-' + Math.random().toString(36).slice(2, 10))
|
||||
const STORAGE_KEY = 'prayer-walk-planner:v1:' + INSTANCE_ID
|
||||
|
||||
|
|
@ -612,6 +621,8 @@ useShadowStyles(
|
|||
'.pwp-body{position:relative;flex:1;min-height:0;display:flex}' +
|
||||
'.pwp-map{position:relative;flex:1;min-width:0}' +
|
||||
'.pwp-map-el{position:absolute;inset:0}' +
|
||||
'.pwp-map-controls{position:absolute;top:10px;right:10px;z-index:4}' +
|
||||
'.pwp[dir=rtl] .pwp-map-controls{right:auto;left:10px}' +
|
||||
'.pwp-list{width:260px;flex-shrink:0;overflow-y:auto;background:#fff;border-left:1px solid #d1d4cd;padding:8px}' +
|
||||
'.pwp-list-empty{padding:16px 10px;font-size:12px;color:#6d766e}' +
|
||||
'.pwp-item{display:flex;align-items:center;gap:8px;padding:8px;border-radius:6px;margin-bottom:4px;' +
|
||||
|
|
@ -622,14 +633,26 @@ useShadowStyles(
|
|||
'.pwp-item-title-input{flex:1;font-size:13px;font-weight:600;padding:2px 4px;border:1px solid #92b195;border-radius:4px}' +
|
||||
'.pwp-item-del{border:none;background:transparent;color:#8a3b3b;cursor:pointer;font-size:14px;padding:2px 6px;border-radius:4px}' +
|
||||
'.pwp-item-del:hover{background:rgba(138,59,59,.12)}' +
|
||||
'@media (max-width:640px){.pwp-list{display:none}}'
|
||||
'@media (max-width:640px){.pwp-list{display:none}}' +
|
||||
// PHONE: ONE ROW, SCROLLED SIDEWAYS, instead of a toolbar that wrapped to three rows (139px of a
|
||||
// 844px screen, measured 2026-09-25) above the map a walker is trying to read. Nothing is clipped:
|
||||
// the row scrolls, labels never truncate. The two actions a walk needs, Draw and Print, lead the
|
||||
// row so they are always on screen; the list-management buttons follow.
|
||||
'@media (max-width:640px){' +
|
||||
'.pwp-toolbar{flex-wrap:nowrap;overflow-x:auto;overscroll-behavior-x:contain;-webkit-overflow-scrolling:touch;scrollbar-width:none;padding:8px 10px;gap:6px}' +
|
||||
'.pwp-toolbar::-webkit-scrollbar{display:none}' +
|
||||
'.pwp-toolbar>*{flex:0 0 auto}' +
|
||||
'.pwp-btn{white-space:nowrap;padding:8px 12px}' +
|
||||
'.pwp-spacer{display:none}' +
|
||||
'.pwp-draw{order:-2}.pwp-print{order:-1}' +
|
||||
'}'
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pwp" :dir="uiDir" :lang="uiLocale">
|
||||
<div class="pwp-toolbar">
|
||||
<button type="button" class="pwp-btn" :class="{ 'is-active': drawing }"
|
||||
<button type="button" class="pwp-btn pwp-draw" :class="{ 'is-active': drawing }"
|
||||
:aria-pressed="drawing" @click="onDrawClick">
|
||||
{{ drawing ? tr('drawing') : tr('draw') }}
|
||||
</button>
|
||||
|
|
@ -649,7 +672,7 @@ useShadowStyles(
|
|||
</button>
|
||||
<div class="pwp-spacer"></div>
|
||||
<span class="pwp-count">{{ tr('countSelected', { n: selectedCount, total: polygons.length }) }}</span>
|
||||
<button type="button" class="pwp-btn is-accent" :disabled="selectedCount === 0"
|
||||
<button type="button" class="pwp-btn is-accent pwp-print" :disabled="selectedCount === 0"
|
||||
@click="onPrintClick" :aria-label="tr('printAria')">
|
||||
{{ tr('printSelected') }}
|
||||
</button>
|
||||
|
|
@ -673,6 +696,11 @@ useShadowStyles(
|
|||
<div class="pwp-body">
|
||||
<div class="pwp-map">
|
||||
<div ref="mapContainer" class="pwp-map-el"></div>
|
||||
<!-- FULL SCREEN for the WHOLE planner (toolbar + map), not just the canvas: the shared
|
||||
FullscreenButton requests fullscreen on this element's custom-element host, and falls
|
||||
back to a CSS pseudo-fullscreen where the API does not exist (iOS Safari). A planner
|
||||
embedded in a partner page or the gallery can take the whole screen for drawing. -->
|
||||
<div class="pwp-map-controls"><FullscreenButton :map-container="mapContainer" /></div>
|
||||
</div>
|
||||
<div class="pwp-list">
|
||||
<div v-if="polygons.length === 0" class="pwp-list-empty">
|
||||
|
|
|
|||
|
|
@ -173,7 +173,26 @@ const bundleSrc = (bundle, file) =>
|
|||
// legend tree node ids are `region:<slug>`), while the LABEL is what a human-facing surface would
|
||||
// show. Keeping value and label together means neither half has to guess, and the reverse direction
|
||||
// can hand the grid a slug derived from the label the map reports.
|
||||
const linked = reactive({ region: { value: '', label: '' } })
|
||||
// ★ REGION CARRIES TWO FORMS AND THE OTHER TWO CARRY ONE — because that is what the halves
|
||||
// actually speak, not because uniformity was worth the extra field. Region's slug is a THIRD string
|
||||
// ('africa') that neither half's data carries as a label, so both forms have to travel. Religion
|
||||
// and language link on the display NAME alone, and each half resolves that name itself: the map
|
||||
// reverses RELIGION_FAMILIES to get its legend key, the grid runs the same shared table forward per
|
||||
// row to derive `__religion_family`. The mapping lives in library/colors/religion.js and is copied
|
||||
// nowhere, least of all here — a table beside the data is the thing that goes stale.
|
||||
//
|
||||
// ★ ONE SELECTION AT A TIME, BECAUSE THE MAP HAS ONE. MEASURED in mapStore: selectLanguage() nulls
|
||||
// selectedRegion by design (Bug 13, mutually exclusive legend selections), and religion/language/
|
||||
// region all end at the same applyDimFilter, so the map can light exactly one of the three. A
|
||||
// composite that let the grid hold all three would put the two halves into a disagreement the map
|
||||
// cannot resolve and the reader cannot see. So the composite mirrors the map's own exclusivity: a
|
||||
// new facet replaces the previous one, exactly as clicking a second legend row does.
|
||||
const linked = reactive({ region: { value: '', label: '' }, religion: '', language: '' })
|
||||
function clearExcept(keep) {
|
||||
if (keep !== 'region' && (linked.region.value || linked.region.label)) { linked.region.value = ''; linked.region.label = '' }
|
||||
if (keep !== 'religion' && linked.religion) linked.religion = ''
|
||||
if (keep !== 'language' && linked.language) linked.language = ''
|
||||
}
|
||||
const slugify = (label) => String(label || '').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '')
|
||||
|
||||
// Guard against the echo: grid -> composite -> map -> composite -> grid. Each direction ignores an
|
||||
|
|
@ -187,11 +206,28 @@ const slugify = (label) => String(label || '').toLowerCase().replace(/[^a-z0-9]+
|
|||
let pushing = false
|
||||
function adopt(next) {
|
||||
if (!next) return
|
||||
const value = next.value || ''
|
||||
const label = next.label || ''
|
||||
if (value === linked.region.value && label === linked.region.label) return
|
||||
linked.region.value = value
|
||||
linked.region.label = label
|
||||
// A PATCH, not a replacement: each half reports only the facets it owns a control for, so an
|
||||
// absent key means "no opinion" and must not clear a selection the other half just made.
|
||||
if (next.region && (next.region.value || next.region.label)) {
|
||||
const value = next.region.value || ''
|
||||
const label = next.region.label || ''
|
||||
if (value !== linked.region.value || label !== linked.region.label) {
|
||||
linked.region.value = value
|
||||
linked.region.label = label
|
||||
clearExcept('region')
|
||||
}
|
||||
return
|
||||
}
|
||||
if (next.religion) {
|
||||
if (next.religion !== linked.religion) { linked.religion = next.religion; clearExcept('religion') }
|
||||
return
|
||||
}
|
||||
if (next.language) {
|
||||
if (next.language !== linked.language) { linked.language = next.language; clearExcept('language') }
|
||||
return
|
||||
}
|
||||
// Nothing set anywhere in this report: the reader cleared whatever was active.
|
||||
if ('region' in next || 'religion' in next || 'language' in next) clearExcept(null)
|
||||
}
|
||||
|
||||
// The grid already emits `groups-filtered` as a bubbling, composed CustomEvent — built for exactly
|
||||
|
|
@ -200,14 +236,28 @@ function onGridFiltered(ev) {
|
|||
if (pushing) return
|
||||
// The grid's activeFilters carry { value, label } per facet — exactly the two forms the two
|
||||
// halves need, which is why this reads both rather than picking one.
|
||||
const entry = (ev?.detail?.activeFilters || {}).wagf_region
|
||||
adopt({ value: (entry && (entry.value ?? entry)) || '', label: (entry && entry.label) || '' })
|
||||
const active = ev?.detail?.activeFilters || {}
|
||||
const entry = active.wagf_region
|
||||
// Religion and language travel as the NAME, which for this grid is the option's label — the same
|
||||
// string the map reports for the same row. See the `linked` note for why the code stays home.
|
||||
const nameOf = (f) => (f && (f.label || f.value)) || ''
|
||||
adopt({
|
||||
region: { value: (entry && (entry.value ?? entry)) || '', label: (entry && entry.label) || '' },
|
||||
religion: nameOf(active.__religion_family),
|
||||
language: nameOf(active.primary_language),
|
||||
})
|
||||
}
|
||||
function onMapFiltered(ev) {
|
||||
if (pushing) return
|
||||
// The map reports the canonical label; the grid needs the slug it filters by.
|
||||
const label = ev?.detail?.region || ''
|
||||
adopt({ value: label ? slugify(label) : '', label })
|
||||
// The map reports religion as a NAME too (it decodes its own code before emitting), so both of
|
||||
// these cross unchanged — the translation happens inside the half that owns the vocabulary.
|
||||
adopt({
|
||||
region: { value: label ? slugify(label) : '', label },
|
||||
religion: ev?.detail?.religion || '',
|
||||
language: ev?.detail?.language || '',
|
||||
})
|
||||
}
|
||||
|
||||
let rootEl = null
|
||||
|
|
@ -227,12 +277,14 @@ function pushToChildren() {
|
|||
if (map) {
|
||||
let cfg = {}
|
||||
try { cfg = JSON.parse(map.getAttribute('profile-config') || '{}') } catch (_) { cfg = {} }
|
||||
cfg.externalFilters = { region: linked.region.value }
|
||||
cfg.externalFilters = { region: linked.region.value, religion: linked.religion, language: linked.language }
|
||||
map.setAttribute('profile-config', JSON.stringify(cfg))
|
||||
}
|
||||
const grid = rootEl.querySelector('people-groups-grid')
|
||||
if (grid) {
|
||||
grid.setAttribute('wagf-region', linked.region.value)
|
||||
grid.setAttribute('religion-family', linked.religion)
|
||||
grid.setAttribute('primary-language', linked.language)
|
||||
}
|
||||
}
|
||||
watch(linked, pushToChildren, { deep: true })
|
||||
|
|
@ -306,7 +358,7 @@ const sections = computed(() => [
|
|||
...(hostToken.value ? { tk: hostToken.value } : {}),
|
||||
...(hostProvider.value ? { provider: hostProvider.value } : {}),
|
||||
// The composite's filter state, in the map's inbound shape. Absent keys are ignored there.
|
||||
externalFilters: { region: linked.region.value },
|
||||
externalFilters: { region: linked.region.value, religion: linked.religion, language: linked.language },
|
||||
assetsBase: '../../assets',
|
||||
shareUrl: SHARE_URL
|
||||
})
|
||||
|
|
@ -331,6 +383,8 @@ const sections = computed(() => [
|
|||
// The grid already accepts these as INPUTS (its header documents countryCode for exactly this
|
||||
// reason); driving them from the composite is the same channel, not a new one.
|
||||
'wagf-region': linked.region.value,
|
||||
'religion-family': linked.religion,
|
||||
'primary-language': linked.language,
|
||||
}
|
||||
}
|
||||
])
|
||||
|
|
|
|||
|
|
@ -0,0 +1,219 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-linked-vocabulary.mjs — the two halves of this composite must mean the SAME THING by a
|
||||
* religion family, and this proves it by measuring, on the built tree, that the link delivers
|
||||
* exactly what the facet delivers on its own.
|
||||
*
|
||||
* WHY THIS GATE EXISTS. Linking a facet across two elements is the one place in this repo where a
|
||||
* bug is INVISIBLE: both halves keep working, both look filtered, and they are simply showing
|
||||
* different peoples. Region survived that risk because the two halves share a slug. Religion does
|
||||
* not:
|
||||
*
|
||||
* the map — filters religion through its legend's flat tab, keyed by the FIRST LETTER of the
|
||||
* religion code ('B'), named by RELIGION_FAMILIES ('Buddhism')
|
||||
* the grid — filters religion through a DERIVED axis, `__religion_family`, which runs that same
|
||||
* RELIGION_FAMILIES table forward per row (code 'BLA' -> 'Buddhism')
|
||||
*
|
||||
* One shared table in library/colors/religion.js, read in opposite directions, copied by neither.
|
||||
* That is the mapping under test. A composite that sent the letter instead of the name, or the raw
|
||||
* code instead of the family, would look exactly as healthy — and show a grid of nothing, or a grid
|
||||
* of everything, beside a correctly dimmed map.
|
||||
*
|
||||
* MEASURED, AND IT CHANGED THE DESIGN: `mapStore.filters.religions`, which the map's filter panel
|
||||
* writes, is read by NOTHING — `mapLayers.applyFilters()` is called and never implemented. A link
|
||||
* routed through it would have passed every plausible smoke test and dimmed no pin. That is why
|
||||
* this gate compares COUNTS reached two different ways rather than asserting that state was set.
|
||||
*
|
||||
* HOW. Serves the built tree on a free port, loads the composite's own leaf, and for one religion
|
||||
* family that the data actually contains:
|
||||
*
|
||||
* DIRECT set `religion-family` on the nested grid itself — the element's own documented prop,
|
||||
* no link involved — and record the total it reports. Must be >0 and < unfiltered.
|
||||
* LINKED clear, then drive the composite's published wire instead: a `map-filtered` event
|
||||
* carrying the family NAME, exactly as the map emits it. Record the total again.
|
||||
* ASSERT linked === direct. The link delivered the facet, not something adjacent to it.
|
||||
*
|
||||
* ★ THE MUTANT RUNS FIRST, AND THIS GATE FAILS IF THE MUTANT PASSES. Before the real name is sent,
|
||||
* the same wire is driven with the legend's LETTER KEY ('B') — the single most likely wrong version
|
||||
* of this mapping, and the one a refactor would reach for. A grid that narrows to the same total on
|
||||
* 'B' as on 'Buddhism' is a grid whose linking cannot tell the two apart, so the gate reports
|
||||
* NO-TEETH and fails. A check that has never been seen to fail is not evidence.
|
||||
*
|
||||
* Exit: 0 pass or SKIP (no built tree, no browser, no data) · 1 fail.
|
||||
*/
|
||||
import { createServer } from 'node:http'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const TAG = 'LINKED-VOCABULARY'
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
|
||||
const MANIFEST = join(ROOT, 'public', 'doxa-maps', 'manifest.json')
|
||||
const skip = (why) => { console.log(`${TAG}: SKIP — ${why}`); process.exit(0) }
|
||||
|
||||
if (!existsSync(MANIFEST)) skip('no built manifest (run `bun run tree` first).')
|
||||
let chromium
|
||||
try { ({ chromium } = await import('playwright')) } catch { skip('playwright is not installed.') }
|
||||
|
||||
const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8'))
|
||||
const bundle = (manifest.bundles || []).find((b) => b.bundle === 'research-map-plus-grid')
|
||||
const profile = bundle && (bundle.profiles || []).find((p) => p.iframeUrl)
|
||||
if (!profile) skip('the manifest declares no research-map-plus-grid leaf to load.')
|
||||
|
||||
const freePort = () => new Promise((ok) => {
|
||||
const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) })
|
||||
})
|
||||
const PORT = await freePort()
|
||||
const TREE = `http://127.0.0.1:${PORT}/doxa-maps`
|
||||
const LEAF = `${TREE}/${String(profile.iframeUrl).replace(/^\.\//, '')}`
|
||||
|
||||
const tree = spawn(process.execPath, ['internal/serve-strict.mjs', '--port', String(PORT)], { cwd: ROOT, stdio: 'ignore' })
|
||||
const cleanup = () => { try { tree.kill() } catch { /* already gone */ } }
|
||||
await new Promise((r) => setTimeout(r, 900))
|
||||
const reachable = await fetch(`${TREE}/manifest.json`, { signal: AbortSignal.timeout(4000) }).then((r) => r.ok).catch(() => false)
|
||||
if (!reachable) { cleanup(); skip('could not serve the built tree locally.') }
|
||||
|
||||
// The tree we just started is the tree we built — but say so out loud rather than trust the port.
|
||||
const { requireThisTree } = await import(join(ROOT, 'internal', '_serves-this-tree.mjs'))
|
||||
await requireThisTree(TREE, ROOT, TAG)
|
||||
|
||||
const browser = await chromium.launch({ args: ['--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'] })
|
||||
const fails = []
|
||||
const lines = []
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } })
|
||||
await page.goto(LEAF, { waitUntil: 'domcontentloaded', timeout: 60000 })
|
||||
|
||||
// Everything below runs in ONE evaluate: the totals have to be read from the same live page, and
|
||||
// a `groups-filtered` listener installed from Node would miss events fired between round trips.
|
||||
const out = await page.evaluate(async () => {
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||
const deep = (sel) => {
|
||||
const seen = new Set()
|
||||
const walk = (root) => {
|
||||
if (!root || seen.has(root)) return null
|
||||
seen.add(root)
|
||||
const hit = root.querySelector?.(sel)
|
||||
if (hit) return hit
|
||||
for (const el of root.querySelectorAll?.('*') || []) {
|
||||
if (el.shadowRoot) { const r = walk(el.shadowRoot); if (r) return r }
|
||||
}
|
||||
return null
|
||||
}
|
||||
return walk(document)
|
||||
}
|
||||
|
||||
const host = document.querySelector('research-map-plus-grid')
|
||||
if (!host) return { skip: 'the composite element never appeared on its own leaf' }
|
||||
const root = host.shadowRoot || host
|
||||
let grid = null
|
||||
for (let i = 0; i < 120 && !grid; i++) { grid = deep('people-groups-grid'); if (!grid) await sleep(500) }
|
||||
if (!grid) return { skip: 'the nested grid never mounted' }
|
||||
|
||||
// The grid reports its total on every filter pass. Listening is how a total is read here: the
|
||||
// rendered count line is localized and the DOM shape is not this gate's business.
|
||||
let total = null
|
||||
const seen = []
|
||||
document.addEventListener('groups-filtered', (e) => { total = e.detail?.total ?? null; seen.push(total) }, true)
|
||||
|
||||
// Wait for the first fetch to settle — a total of null means the data never arrived, which is a
|
||||
// SKIP (no network in the sandbox), not a failure of the mapping.
|
||||
for (let i = 0; i < 120 && total === null; i++) await sleep(500)
|
||||
if (total === null) return { skip: 'the grid never reported a total (no data reached it)' }
|
||||
const unfiltered = total
|
||||
|
||||
// WHICH FAMILY TO TEST: ask both halves what they can name, and take one they agree on. A
|
||||
// hard-coded 'Islam' would be a table of exactly the kind this whole design refuses.
|
||||
const gridVocab = (grid.__vocabulary || window.__peopleGroupsGridVocabulary)?.('__religion_family') || []
|
||||
// The map loads its own records independently of the grid's fetch, and in this composite it is
|
||||
// the slower half. Waiting for its vocabulary to be non-empty is waiting for its DATA — asking
|
||||
// before then reports "the two halves share nothing", which is a lie about the mapping.
|
||||
let mapVocab = []
|
||||
for (let i = 0; i < 160; i++) {
|
||||
mapVocab = window.__researchMapVocabulary?.('religionFamily') || []
|
||||
if (mapVocab.length) break
|
||||
await sleep(500)
|
||||
}
|
||||
if (!mapVocab.length) return { skip: 'the nested map never reported a religion vocabulary (its data never arrived)' }
|
||||
const shared = gridVocab.filter((n) => mapVocab.includes(n))
|
||||
if (!shared.length) {
|
||||
return { fail: `the two halves share NO religion family name. map=[${mapVocab.join(', ')}] grid=[${gridVocab.join(', ')}]` }
|
||||
}
|
||||
|
||||
const settle = async () => { const before = seen.length; for (let i = 0; i < 40 && seen.length === before; i++) await sleep(250); await sleep(400) }
|
||||
const clear = async () => {
|
||||
grid.setAttribute('religion-family', '')
|
||||
host.dispatchEvent(new CustomEvent('map-filtered', { detail: { region: '', religion: '', language: '' }, bubbles: true, composed: true }))
|
||||
await settle()
|
||||
}
|
||||
const driveLinked = async (value) => {
|
||||
await clear()
|
||||
// ★ WAIT OUT THE ECHO WINDOW, OR MEASURE AN ARTIFACT. The composite ignores child reports for
|
||||
// 1200ms after it pushes (its `pushing` guard, which exists so a push does not read back as a
|
||||
// user action). A drive fired inside that window is DROPPED, and the grid then reports its
|
||||
// previous total — which for the mutant looks exactly like "the wrong mapping matched
|
||||
// nothing". MEASURED: the first green here had the mutant at 2107 (untouched), not 0
|
||||
// (rejected), and the two are indistinguishable from the outside.
|
||||
await sleep(1500)
|
||||
// The composite listens on its own root for the map's published event. Firing it from the
|
||||
// MAP element is what the map itself does; firing it from the host reaches the same listener.
|
||||
const map = deep('doxa-research-map') || host
|
||||
map.dispatchEvent(new CustomEvent('map-filtered', { detail: { region: '', religion: value, language: '' }, bubbles: true, composed: true }))
|
||||
await settle()
|
||||
return total
|
||||
}
|
||||
|
||||
// Pick a family whose own filtered total is a real subset — a family matching everything or
|
||||
// nothing proves nothing about the link.
|
||||
let family = null, direct = null
|
||||
for (const name of shared) {
|
||||
await clear()
|
||||
grid.setAttribute('religion-family', name)
|
||||
await settle()
|
||||
if (total > 0 && total < unfiltered) { family = name; direct = total; break }
|
||||
}
|
||||
if (!family) return { skip: 'no religion family narrows this grid to a strict subset' }
|
||||
|
||||
const letter = family.charAt(0).toUpperCase()
|
||||
|
||||
// ── MUTANT FIRST ──────────────────────────────────────────────────────────────────────────
|
||||
const mutant = await driveLinked(letter)
|
||||
// ── THEN THE REAL THING ───────────────────────────────────────────────────────────────────
|
||||
const linked = await driveLinked(family)
|
||||
|
||||
return { unfiltered, family, letter, direct, linked, mutant, mapVocab, gridVocab: gridVocab.slice(0, 12) }
|
||||
})
|
||||
|
||||
if (out.skip) { await browser.close(); cleanup(); skip(out.skip) }
|
||||
if (out.fail) fails.push(out.fail)
|
||||
else {
|
||||
lines.push(` · vocabulary — the map names ${out.mapVocab.length} religion families, the grid names ${out.gridVocab.length}+; '${out.family}' is shared`)
|
||||
lines.push(` · direct — religion-family="${out.family}" set on the grid itself: ${out.direct} of ${out.unfiltered}`)
|
||||
lines.push(` · linked — the same family over the composite's wire: ${out.linked}`)
|
||||
lines.push(` · mutant — the legend KEY '${out.letter}' over the same wire: ${out.mutant}`)
|
||||
|
||||
if (out.mutant === out.direct) {
|
||||
fails.push(`NO TEETH — sending the legend key '${out.letter}' narrowed the grid to the same ${out.direct} rows as the family name '${out.family}'. This gate cannot tell a right mapping from a wrong one, so its pass would mean nothing.`)
|
||||
}
|
||||
if (out.linked !== out.direct) {
|
||||
fails.push(`the link does not deliver the facet — '${out.family}' set on the grid gives ${out.direct} rows, the same family over the composite's wire gives ${out.linked}.`)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
fails.push(`the measurement itself failed: ${err && err.message}`)
|
||||
} finally {
|
||||
await browser.close().catch(() => {})
|
||||
cleanup()
|
||||
}
|
||||
|
||||
for (const l of lines) console.log(l)
|
||||
if (fails.length) {
|
||||
console.log(`\n${TAG}: FAIL`)
|
||||
for (const f of fails) console.log(` ✗ ${f}`)
|
||||
console.log('\n The religion link is one shared table (library/colors/religion.js) read in two')
|
||||
console.log(' directions: the map reverses family NAME -> legend letter, the grid runs code -> family')
|
||||
console.log(' NAME per row. Both halves must speak the NAME on the wire.')
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`${TAG}: PASS — the composite's religion link delivers exactly the facet the grid delivers on its own, and a wrong mapping does not.`)
|
||||
14
plugin-designs/wall-of-people-groups/index.html
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>wall-of-people-groups (dev)</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<style>*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}html,body{height:100%;background:#f3f3f1}wall-of-people-groups{position:fixed;inset:0;display:block}</style>
|
||||
</head>
|
||||
<body>
|
||||
<wall-of-people-groups profile-config='{"profile":"wall-of-people-groups"}'></wall-of-people-groups>
|
||||
<script type="module" src="./index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
28
plugin-designs/wall-of-people-groups/index.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/**
|
||||
* wall-of-people-groups / index.js — bundle entry (emits wall-of-people-groups.js), defines
|
||||
* <wall-of-people-groups>. The standard ProfileLoader shape: the glob captures this folder's one
|
||||
* profile; its components live in src/. The wall's components read the store as this.$store (the
|
||||
* original app's global property), so it is created here, per element, on the element's Pinia.
|
||||
*/
|
||||
import { defineCustomElement } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ProfileLoader from '@map/ProfileLoader.vue'
|
||||
import { createAppI18n } from '@map/i18n/index.js'
|
||||
import { usePeopleGroupsStore } from './src/stores/peopleGroupsStore.js'
|
||||
|
||||
const profileModules = import.meta.glob('./*.vue')
|
||||
|
||||
const El = defineCustomElement(ProfileLoader, {
|
||||
configureApp(app) {
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
const i18n = createAppI18n()
|
||||
app.use(i18n)
|
||||
app.provide('appI18n', i18n)
|
||||
app.provide('profileModules', profileModules)
|
||||
const store = usePeopleGroupsStore(pinia)
|
||||
store.loadFromLocalStorage()
|
||||
app.config.globalProperties.$store = store
|
||||
}
|
||||
})
|
||||
if (typeof customElements !== 'undefined' && !customElements.get('wall-of-people-groups')) customElements.define('wall-of-people-groups', El)
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
<template>
|
||||
<div v-if="visible" class="dialog-overlay" @click="closeDialog">
|
||||
<div class="dialog-container" @click.stop>
|
||||
<!-- Dialog Header -->
|
||||
<div class="dialog-header">
|
||||
<h2>People Group Chats</h2>
|
||||
<button class="close-btn" @click="closeDialog">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- People Group Image (Instagram style) -->
|
||||
<div class="group-image-container">
|
||||
<img
|
||||
:src="getImageUrl()"
|
||||
:alt="group?.name || 'People Group'"
|
||||
class="group-image"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="group-name-dialog">{{ group?.name || 'Unknown Group' }}</div>
|
||||
|
||||
<!-- People Group Info -->
|
||||
<div class="dialog-content">
|
||||
<div class="group-info">
|
||||
<div class="info-row">
|
||||
<strong>Status:</strong> {{ group?.status || 'Unknown' }}
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<strong>ID:</strong> {{ group?.ID || 'N/A' }}
|
||||
</div>
|
||||
<div class="info-row" v-if="group?.lat && group?.lng">
|
||||
<strong>Location:</strong> {{ group.lat }}, {{ group.lng }}
|
||||
</div>
|
||||
<div class="info-row prayer-stat">
|
||||
<span class="heart-icon">❤️</span>
|
||||
<strong>{{ prayerCount }}</strong> {{ prayerCount === 1 ? 'person has' : 'people have' }} prayed
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Prayer Button -->
|
||||
<div class="prayer-action">
|
||||
<button
|
||||
class="prayer-btn"
|
||||
@click="addPrayer"
|
||||
:class="{ active: justPrayed }"
|
||||
>
|
||||
{{ justPrayed ? '✓ You prayed!' : '🙏 Pray for this group' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Comments Section -->
|
||||
<div class="comments-section">
|
||||
<h3>Prayers & Comments</h3>
|
||||
|
||||
<!-- Comment List -->
|
||||
<div class="comments-list">
|
||||
<div
|
||||
v-if="comments.length === 0"
|
||||
class="no-comments"
|
||||
>
|
||||
No prayers or comments yet. Be the first!
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="comment in comments"
|
||||
:key="comment.id"
|
||||
class="comment-item"
|
||||
>
|
||||
<div class="comment-header">
|
||||
<span class="comment-icon">💭</span>
|
||||
<span class="comment-date">{{ formatDate(comment.timestamp) }}</span>
|
||||
</div>
|
||||
<div class="comment-text">{{ comment.text }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Comment Form -->
|
||||
<div class="add-comment">
|
||||
<textarea
|
||||
v-model="newComment"
|
||||
placeholder="Share your prayer or thoughts..."
|
||||
rows="3"
|
||||
maxlength="500"
|
||||
></textarea>
|
||||
<div class="comment-actions">
|
||||
<span class="char-count">{{ newComment.length }}/500</span>
|
||||
<button
|
||||
@click="submitComment"
|
||||
:disabled="!newComment.trim()"
|
||||
class="submit-btn"
|
||||
>
|
||||
Post Comment
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// No third-party placeholder service: a missing photo is drawn inline.
|
||||
const NO_IMAGE = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='600' height='400'%3E%3Crect width='100%25' height='100%25' fill='%23f3f3f1'/%3E%3Ctext x='50%25' y='50%25' fill='%236d766e' font-family='sans-serif' font-size='22' text-anchor='middle' dominant-baseline='middle'%3ENo image available%3C/text%3E%3C/svg%3E"
|
||||
// Comments and Prayers Dialog Component
|
||||
export default {
|
||||
name: 'CommentsDialog',
|
||||
props: {
|
||||
group: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
newComment: '',
|
||||
justPrayed: false,
|
||||
imageError: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
prayerCount() {
|
||||
if (!this.group || !this.$store) return 0;
|
||||
// Access the prayers state directly
|
||||
return this.$store.prayers?.[this.group.ID] || 0;
|
||||
},
|
||||
|
||||
comments() {
|
||||
if (!this.group || !this.$store) return [];
|
||||
// Access the comments state directly
|
||||
return this.$store.comments?.[this.group.ID] || [];
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
closeDialog() {
|
||||
this.$emit('close');
|
||||
this.newComment = '';
|
||||
this.justPrayed = false;
|
||||
},
|
||||
|
||||
addPrayer() {
|
||||
if (this.group && this.$store) {
|
||||
this.$store.addPrayer(this.group.ID);
|
||||
this.justPrayed = true;
|
||||
|
||||
// Reset after 2 seconds
|
||||
setTimeout(() => {
|
||||
this.justPrayed = false;
|
||||
}, 2000);
|
||||
}
|
||||
},
|
||||
|
||||
submitComment() {
|
||||
if (this.newComment.trim() && this.group && this.$store) {
|
||||
this.$store.addComment(
|
||||
this.group.ID,
|
||||
this.newComment.trim()
|
||||
);
|
||||
this.newComment = '';
|
||||
}
|
||||
},
|
||||
|
||||
formatDate(timestamp) {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
|
||||
return date.toLocaleDateString();
|
||||
},
|
||||
|
||||
getImageUrl() {
|
||||
if (this.imageError) {
|
||||
return NO_IMAGE;
|
||||
}
|
||||
|
||||
// The card's own photo (the live API's image_url); uupg.doxa.life no longer answers.
|
||||
return this.group?.imageUrl || NO_IMAGE;
|
||||
},
|
||||
|
||||
handleImageError(event) {
|
||||
this.imageError = true;
|
||||
event.target.src = NO_IMAGE;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
visible(newVal) {
|
||||
if (newVal) {
|
||||
// Reset state when dialog opens
|
||||
this.justPrayed = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
<template>
|
||||
<div
|
||||
class="people-group-card"
|
||||
:style="{ transform: 'translate(' + x + 'px, ' + y + 'px)' }"
|
||||
>
|
||||
<!-- Header with People Group Name -->
|
||||
<div class="card-header">
|
||||
<h3 class="group-name">
|
||||
<a
|
||||
v-if="hasJoshuaProjectLink()"
|
||||
:href="getJoshuaProjectUrl()"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="group-name-link"
|
||||
>
|
||||
{{ group.name || group.post_title || 'Unknown Group' }}
|
||||
</a>
|
||||
<span v-else class="group-name-no-link" title="This people group doesn't have a PeopleID3">
|
||||
{{ group.name || group.post_title || 'Unknown Group' }}
|
||||
</span>
|
||||
</h3>
|
||||
<span class="group-status">{{ group.status || 'Status Unknown' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Image Placeholder -->
|
||||
<div class="card-image" @click="$emit('view-detail', group)">
|
||||
<img
|
||||
:src="getImageUrl()"
|
||||
:alt="group.name"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons (Instagram style) -->
|
||||
<div class="card-actions">
|
||||
<button
|
||||
class="action-btn heart-btn"
|
||||
@click="handlePrayer"
|
||||
:class="{ active: hasPrayed }"
|
||||
>
|
||||
<span class="icon">{{ hasPrayed ? '❤️' : '🤍' }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="action-btn comment-btn"
|
||||
@click="$emit('open-comments', group)"
|
||||
>
|
||||
<span class="icon">💬</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="action-btn share-btn"
|
||||
@click="handleShare"
|
||||
>
|
||||
<span class="icon">📤</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Prayer Count -->
|
||||
<div class="card-footer">
|
||||
<p class="prayer-count">
|
||||
<strong>{{ prayerCount }}</strong> {{ prayerCount === 1 ? 'person' : 'people' }}
|
||||
prayed that they would receive God's love and the love of a missionary
|
||||
</p>
|
||||
|
||||
<button
|
||||
class="view-comments-btn"
|
||||
@click="$emit('open-comments', group)"
|
||||
>
|
||||
View prayers and comments
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// No third-party placeholder service: a missing photo is drawn inline.
|
||||
const NO_IMAGE = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='600' height='400'%3E%3Crect width='100%25' height='100%25' fill='%23f3f3f1'/%3E%3Ctext x='50%25' y='50%25' fill='%236d766e' font-family='sans-serif' font-size='22' text-anchor='middle' dominant-baseline='middle'%3ENo image available%3C/text%3E%3C/svg%3E"
|
||||
// People Group Card Component - Instagram style
|
||||
export default {
|
||||
name: 'PeopleGroupCard',
|
||||
props: {
|
||||
group: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
x: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
y: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
imageError: false,
|
||||
hasPrayed: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
prayerCount() {
|
||||
// Access the prayers state directly
|
||||
const groupId = this.group.id || this.group.PeopleID3;
|
||||
return this.$store?.prayers?.[groupId] || 0;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
hasJoshuaProjectLink() {
|
||||
const peopleID3 = this.group.peopleID3 || this.group.PeopleID3;
|
||||
return peopleID3 && peopleID3.trim() !== '';
|
||||
},
|
||||
|
||||
getJoshuaProjectUrl() {
|
||||
const peopleID3 = this.group.peopleID3 || this.group.PeopleID3;
|
||||
if (!peopleID3 || peopleID3.trim() === '') {
|
||||
return null;
|
||||
}
|
||||
return `https://joshuaproject.net/people_groups/${peopleID3}`;
|
||||
},
|
||||
|
||||
getImageUrl() {
|
||||
if (this.imageError) {
|
||||
return NO_IMAGE;
|
||||
}
|
||||
|
||||
// The live API's image_url; uupg.doxa.life no longer answers.
|
||||
return this.group.imageUrl || NO_IMAGE;
|
||||
},
|
||||
|
||||
handleImageError(event) {
|
||||
this.imageError = true;
|
||||
event.target.src = NO_IMAGE;
|
||||
},
|
||||
|
||||
handlePrayer() {
|
||||
this.hasPrayed = !this.hasPrayed;
|
||||
if (this.hasPrayed) {
|
||||
this.$emit('add-prayer', this.group.id || this.group.PeopleID3);
|
||||
}
|
||||
},
|
||||
|
||||
handleShare() {
|
||||
alert('Sharing functionality coming later!');
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
@ -0,0 +1,509 @@
|
|||
<template>
|
||||
<div class="wall-container">
|
||||
<div class="wall-header">
|
||||
<h1>Wall of People Groups</h1>
|
||||
<div class="wall-controls">
|
||||
<button @click="toggleOrientation" class="control-btn orientation-btn">
|
||||
<span class="btn-label">{{ isLandscape ? '📱 Switch to Portrait' : '🖼️ Switch to Landscape' }}</span>
|
||||
<span class="current-mode">Current: {{ isLandscape ? 'Landscape (4ft×3ft)' : 'Portrait (3ft×4ft)' }}</span>
|
||||
</button>
|
||||
<button @click="resetZoom" class="control-btn">Reset Zoom</button>
|
||||
<span class="group-count">{{ totalGroups }} Groups Loaded</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref="canvas" class="wall-canvas"></div>
|
||||
|
||||
<div v-if="loading" class="loading-overlay">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading people groups...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// Wall of People Groups - D3 Zoomable Whiteboard
|
||||
export default {
|
||||
name: 'PeopleGroupsWall',
|
||||
data() {
|
||||
return {
|
||||
svg: null,
|
||||
zoom: null,
|
||||
cardWidth: 50,
|
||||
cardHeight: 70,
|
||||
cardSpacing: 8, // Increased horizontal spacing (was 2)
|
||||
cardVerticalSpacing: 3, // Separate vertical spacing
|
||||
canvasWidth: 0,
|
||||
canvasHeight: 0,
|
||||
isLandscape: true
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
loading() {
|
||||
return this.$store?.loading || false;
|
||||
},
|
||||
|
||||
totalGroups() {
|
||||
return this.$store?.totalGroups || 0;
|
||||
},
|
||||
|
||||
peopleGroups() {
|
||||
return this.$store?.peopleGroups || [];
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.initializeCanvas();
|
||||
this.fetchData();
|
||||
},
|
||||
methods: {
|
||||
async fetchData() {
|
||||
console.log('📊 Wall component fetching data...');
|
||||
if (this.$store) {
|
||||
await this.$store.fetchPeopleGroups();
|
||||
console.log('🎨 Data fetched, rendering cards...');
|
||||
this.renderCards();
|
||||
} else {
|
||||
console.error('❌ Store not available!');
|
||||
}
|
||||
},
|
||||
|
||||
initializeCanvas() {
|
||||
// Set dimensions based on orientation
|
||||
if (this.isLandscape) {
|
||||
// 4ft x 3ft aspect ratio = 4:3 ratio (width:height) - LANDSCAPE
|
||||
this.canvasWidth = 4000; // 4ft wide
|
||||
this.canvasHeight = 3000; // 3ft tall
|
||||
} else {
|
||||
// 3ft x 4ft aspect ratio = 3:4 ratio (width:height) - PORTRAIT
|
||||
this.canvasWidth = 3000; // 3ft wide
|
||||
this.canvasHeight = 4000; // 4ft tall
|
||||
}
|
||||
|
||||
const container = d3.select(this.$refs.canvas);
|
||||
|
||||
// Create SVG
|
||||
this.svg = container
|
||||
.append('svg')
|
||||
.attr('width', '100%')
|
||||
.attr('height', '100%')
|
||||
.attr('viewBox', `0 0 ${this.canvasWidth} ${this.canvasHeight}`)
|
||||
.attr('preserveAspectRatio', 'xMidYMid meet');
|
||||
|
||||
// Create zoom behavior - start zoomed out to see everything
|
||||
this.zoom = d3.zoom()
|
||||
.scaleExtent([0.1, 20])
|
||||
.filter(function(event) {
|
||||
// Allow zoom/pan except when trying to select text
|
||||
// Prevent zoom/pan when clicking on text elements with user-select enabled
|
||||
const target = event.target;
|
||||
|
||||
// Allow text selection on text elements
|
||||
if (target.tagName === 'text' || target.tagName === 'tspan') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Allow normal zoom/pan for everything else
|
||||
return !event.ctrlKey && !event.button;
|
||||
})
|
||||
.on('zoom', (event) => {
|
||||
this.svg.select('.cards-container')
|
||||
.attr('transform', event.transform);
|
||||
});
|
||||
|
||||
this.svg.call(this.zoom);
|
||||
|
||||
// Add background
|
||||
this.svg.append('rect')
|
||||
.attr('width', this.canvasWidth)
|
||||
.attr('height', this.canvasHeight)
|
||||
.attr('fill', '#fafafa');
|
||||
|
||||
// Create container for cards
|
||||
this.svg.append('g')
|
||||
.attr('class', 'cards-container');
|
||||
},
|
||||
|
||||
renderCards() {
|
||||
console.log('🎨 Rendering cards...');
|
||||
console.log('People groups available:', this.peopleGroups?.length || 0);
|
||||
|
||||
if (!this.peopleGroups || this.peopleGroups.length === 0) {
|
||||
console.warn('⚠️ No people groups to render');
|
||||
return;
|
||||
}
|
||||
|
||||
const cardsContainer = this.svg.select('.cards-container');
|
||||
const totalGroups = this.peopleGroups.length;
|
||||
|
||||
// Calculate optimal grid layout based on 4:3 aspect ratio (LANDSCAPE)
|
||||
// Canvas is 4000x3000 (4:3 ratio - 4ft wide × 3ft tall)
|
||||
// We need to fit totalGroups cards
|
||||
|
||||
// Card aspect ratio (roughly portrait, like Instagram)
|
||||
const cardAspectRatio = this.cardWidth / this.cardHeight; // ~0.71
|
||||
|
||||
// Canvas aspect ratio
|
||||
const canvasAspectRatio = this.canvasWidth / this.canvasHeight; // 1.33 (4:3)
|
||||
|
||||
// Calculate columns and rows to maintain aspect ratio and fit all cards
|
||||
// cols / rows should approximate canvasAspectRatio
|
||||
// cols * rows should equal totalGroups
|
||||
|
||||
const cols = Math.ceil(Math.sqrt(totalGroups * canvasAspectRatio));
|
||||
const rows = Math.ceil(totalGroups / cols);
|
||||
|
||||
console.log(`📐 Grid Layout: ${cols} columns × ${rows} rows = ${cols * rows} slots for ${totalGroups} groups`);
|
||||
console.log(`📏 Canvas: ${this.canvasWidth}×${this.canvasHeight} (aspect: ${canvasAspectRatio.toFixed(2)})`);
|
||||
|
||||
// Calculate card size to fit the grid
|
||||
const availableWidth = this.canvasWidth - (this.cardSpacing * (cols + 1));
|
||||
const availableHeight = this.canvasHeight - (this.cardSpacing * (rows + 1));
|
||||
|
||||
const calculatedCardWidth = availableWidth / cols;
|
||||
const calculatedCardHeight = availableHeight / rows;
|
||||
|
||||
// Use the smaller of the two to ensure cards fit
|
||||
this.cardWidth = Math.min(calculatedCardWidth, calculatedCardHeight * 0.7);
|
||||
this.cardHeight = this.cardWidth / 0.7; // Maintain card aspect ratio
|
||||
|
||||
console.log(`🎴 Card size: ${this.cardWidth.toFixed(1)}×${this.cardHeight.toFixed(1)}`);
|
||||
console.log('📦 Sample group data:', this.peopleGroups[0]);
|
||||
|
||||
const totalWidth = this.cardWidth + this.cardSpacing;
|
||||
const totalHeight = this.cardHeight + this.cardVerticalSpacing;
|
||||
|
||||
// Create card groups with better spacing
|
||||
const cardGroups = cardsContainer
|
||||
.selectAll('.card-group')
|
||||
.data(this.peopleGroups)
|
||||
.enter()
|
||||
.append('g')
|
||||
.attr('class', 'card-group')
|
||||
.attr('transform', (d, i) => {
|
||||
const col = i % cols;
|
||||
const row = Math.floor(i / cols);
|
||||
const x = col * totalWidth + this.cardSpacing;
|
||||
const y = row * totalHeight + this.cardVerticalSpacing;
|
||||
return `translate(${x}, ${y})`;
|
||||
});
|
||||
|
||||
// Add card background
|
||||
cardGroups
|
||||
.append('rect')
|
||||
.attr('width', this.cardWidth)
|
||||
.attr('height', this.cardHeight)
|
||||
.attr('fill', 'white')
|
||||
.attr('stroke', '#dbdbdb')
|
||||
.attr('stroke-width', 0.5)
|
||||
.attr('rx', 0)
|
||||
.style('cursor', 'pointer')
|
||||
.style('pointer-events', 'none'); // Don't block text selection
|
||||
|
||||
// Calculate proportional font sizes - smaller for names
|
||||
const nameFontSize = Math.max(3.5, this.cardWidth * 0.07);
|
||||
const statusFontSize = Math.max(3, this.cardWidth * 0.055);
|
||||
const iconSize = Math.max(8, this.cardWidth * 0.12);
|
||||
const prayerFontSize = Math.max(3.5, this.cardWidth * 0.065);
|
||||
const cardWidth = this.cardWidth;
|
||||
const cardHeight = this.cardHeight;
|
||||
|
||||
// Add people group name - NO TRUNCATION, smaller font, with clickable link
|
||||
cardGroups.each(function(d) {
|
||||
const group = d3.select(this);
|
||||
const peopleID3 = d.peopleID3 || d.PeopleID3;
|
||||
const name = d.name || d.post_title || 'Unknown Group';
|
||||
|
||||
let nameElement;
|
||||
|
||||
// If has peopleID3, wrap in a link
|
||||
if (peopleID3 && peopleID3.trim() !== '') {
|
||||
const link = group.append('a')
|
||||
.attr('href', `https://joshuaproject.net/people_groups/${peopleID3}`)
|
||||
.attr('target', '_blank')
|
||||
.attr('rel', 'noopener noreferrer')
|
||||
.style('cursor', 'pointer');
|
||||
|
||||
nameElement = link.append('text')
|
||||
.attr('x', cardWidth / 2)
|
||||
.attr('y', cardHeight * 0.06)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('font-size', nameFontSize)
|
||||
.attr('font-weight', 'bold')
|
||||
.attr('fill', '#0095f6') // Blue for links
|
||||
.attr('text-decoration', 'underline')
|
||||
.style('user-select', 'text')
|
||||
.style('pointer-events', 'all');
|
||||
} else {
|
||||
// No link, just text
|
||||
nameElement = group.append('text')
|
||||
.attr('x', cardWidth / 2)
|
||||
.attr('y', cardHeight * 0.06)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('font-size', nameFontSize)
|
||||
.attr('font-weight', 'bold')
|
||||
.attr('fill', '#262626') // Dark gray for non-links
|
||||
.style('user-select', 'text')
|
||||
.style('pointer-events', 'all');
|
||||
}
|
||||
|
||||
// Try to fit in one line first
|
||||
nameElement.text(name);
|
||||
|
||||
// If too long, wrap to two lines
|
||||
if (nameElement.node().getComputedTextLength() > cardWidth * 0.95) {
|
||||
nameElement.text('');
|
||||
const words = name.split(' ');
|
||||
const mid = Math.ceil(words.length / 2);
|
||||
const line1 = words.slice(0, mid).join(' ');
|
||||
const line2 = words.slice(mid).join(' ');
|
||||
|
||||
nameElement.append('tspan')
|
||||
.attr('x', cardWidth / 2)
|
||||
.attr('dy', 0)
|
||||
.text(line1);
|
||||
|
||||
nameElement.append('tspan')
|
||||
.attr('x', cardWidth / 2)
|
||||
.attr('dy', nameFontSize * 1.1)
|
||||
.text(line2);
|
||||
}
|
||||
});
|
||||
|
||||
// Add status (moved down to accommodate 2-line names)
|
||||
cardGroups
|
||||
.append('text')
|
||||
.attr('x', cardWidth / 2)
|
||||
.attr('y', this.cardHeight * 0.14)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('font-size', statusFontSize)
|
||||
.attr('fill', '#8e8e8e')
|
||||
.style('user-select', 'text')
|
||||
.style('pointer-events', 'all')
|
||||
.text(d => d.status || '');
|
||||
|
||||
// Add image or placeholder - FULL WIDTH like Instagram
|
||||
const imageY = this.cardHeight * 0.18;
|
||||
const imageHeight = this.cardHeight * 0.50; // Larger image
|
||||
const imageWidth = this.cardWidth; // FULL WIDTH
|
||||
|
||||
// Background rectangle for image area
|
||||
cardGroups
|
||||
.append('rect')
|
||||
.attr('x', 0)
|
||||
.attr('y', imageY)
|
||||
.attr('width', imageWidth)
|
||||
.attr('height', imageHeight)
|
||||
.attr('fill', '#fafafa')
|
||||
.attr('stroke', '#efefef')
|
||||
.attr('stroke-width', 0.5)
|
||||
.attr('rx', 0); // NO rounded corners
|
||||
|
||||
// Add images or placeholders
|
||||
cardGroups.each(function(d) {
|
||||
const group = d3.select(this);
|
||||
|
||||
if (d.imageUrl) {
|
||||
// Add SVG image element with proper aspect ratio
|
||||
group
|
||||
.append('image')
|
||||
.attr('x', 0)
|
||||
.attr('y', imageY)
|
||||
.attr('width', imageWidth)
|
||||
.attr('height', imageHeight)
|
||||
.attr('href', d.imageUrl)
|
||||
.attr('preserveAspectRatio', 'xMidYMid slice')
|
||||
.style('pointer-events', 'none')
|
||||
.on('error', function() {
|
||||
console.log('Image failed to load:', d.imageUrl);
|
||||
});
|
||||
} else {
|
||||
// Add placeholder icon/text if no image URL
|
||||
group
|
||||
.append('text')
|
||||
.attr('x', cardWidth / 2)
|
||||
.attr('y', imageY + imageHeight / 2)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('font-size', iconSize * 1.5)
|
||||
.attr('fill', '#c7c7c7')
|
||||
.text('📷')
|
||||
.style('pointer-events', 'none');
|
||||
}
|
||||
});
|
||||
|
||||
// Add action buttons row with FontAwesome icons - Instagram style
|
||||
const buttonY = this.cardHeight * 0.72;
|
||||
const buttonSize = iconSize * 0.7; // Make buttons smaller (was iconSize)
|
||||
const buttonSpacing = buttonSize * 1.6; // Tighter spacing
|
||||
const totalButtonWidth = (buttonSize * 3) + (buttonSpacing * 2);
|
||||
const startX = (cardWidth - totalButtonWidth) / 2; // Center the button group
|
||||
|
||||
// Heart button (using foreignObject for FontAwesome)
|
||||
const heartButton = cardGroups
|
||||
.append('foreignObject')
|
||||
.attr('x', startX)
|
||||
.attr('y', buttonY)
|
||||
.attr('width', buttonSize)
|
||||
.attr('height', buttonSize)
|
||||
.style('cursor', 'pointer')
|
||||
.style('overflow', 'visible')
|
||||
.on('click', (event, d) => {
|
||||
event.stopPropagation();
|
||||
this.openPrayerDialog(d);
|
||||
});
|
||||
|
||||
heartButton
|
||||
.append('xhtml:div')
|
||||
.attr('xmlns', 'http://www.w3.org/1999/xhtml')
|
||||
.style('width', '100%')
|
||||
.style('height', '100%')
|
||||
.style('display', 'flex')
|
||||
.style('align-items', 'center')
|
||||
.style('justify-content', 'center')
|
||||
.html(function(d) {
|
||||
// Combine API prayer count with any local session prayers
|
||||
const apiCount = Number(d.peoplePraying || 0);
|
||||
const localCount = self.$store?.prayers?.[d.ID] || 0;
|
||||
const prayerCount = apiCount + localCount;
|
||||
const iconClass = prayerCount > 0 ? 'fas fa-heart' : 'far fa-heart';
|
||||
const iconColor = prayerCount > 0 ? '#ed4956' : '#262626';
|
||||
return '<i class="' + iconClass + '" style="font-size: ' + buttonSize + 'px; color: ' + iconColor + ';"></i>';
|
||||
});
|
||||
|
||||
// Comment button
|
||||
const commentButton = cardGroups
|
||||
.append('foreignObject')
|
||||
.attr('x', startX + buttonSpacing)
|
||||
.attr('y', buttonY)
|
||||
.attr('width', buttonSize)
|
||||
.attr('height', buttonSize)
|
||||
.style('cursor', 'pointer')
|
||||
.style('overflow', 'visible')
|
||||
.on('click', (event, d) => {
|
||||
event.stopPropagation();
|
||||
this.openComments(d);
|
||||
});
|
||||
|
||||
commentButton
|
||||
.append('xhtml:div')
|
||||
.attr('xmlns', 'http://www.w3.org/1999/xhtml')
|
||||
.style('width', '100%')
|
||||
.style('height', '100%')
|
||||
.style('display', 'flex')
|
||||
.style('align-items', 'center')
|
||||
.style('justify-content', 'center')
|
||||
.html('<i class="far fa-comment" style="font-size: ' + buttonSize + 'px; color: #262626;"></i>');
|
||||
|
||||
// Share button
|
||||
const shareButton = cardGroups
|
||||
.append('foreignObject')
|
||||
.attr('x', startX + (buttonSpacing * 2))
|
||||
.attr('y', buttonY)
|
||||
.attr('width', buttonSize)
|
||||
.attr('height', buttonSize)
|
||||
.style('cursor', 'pointer')
|
||||
.style('overflow', 'visible')
|
||||
.on('click', (event, d) => {
|
||||
event.stopPropagation();
|
||||
alert('Sharing functionality coming later!');
|
||||
});
|
||||
|
||||
shareButton
|
||||
.append('xhtml:div')
|
||||
.attr('xmlns', 'http://www.w3.org/1999/xhtml')
|
||||
.style('width', '100%')
|
||||
.style('height', '100%')
|
||||
.style('display', 'flex')
|
||||
.style('align-items', 'center')
|
||||
.style('justify-content', 'center')
|
||||
.html('<i class="far fa-paper-plane" style="font-size: ' + buttonSize + 'px; color: #262626;"></i>');
|
||||
|
||||
// Add prayer count text
|
||||
cardGroups
|
||||
.append('text')
|
||||
.attr('x', startX)
|
||||
.attr('y', buttonY + buttonSize + prayerFontSize + 2)
|
||||
.attr('text-anchor', 'start')
|
||||
.attr('font-size', prayerFontSize)
|
||||
.attr('fill', '#262626')
|
||||
.attr('font-weight', '600')
|
||||
.attr('class', 'prayer-text')
|
||||
.style('user-select', 'text')
|
||||
.style('pointer-events', 'all')
|
||||
.text(d => {
|
||||
// Combine API prayer count with any local session prayers
|
||||
const apiCount = Number(d.peoplePraying || 0);
|
||||
const localCount = this.$store?.prayers?.[d.ID] || 0;
|
||||
const count = apiCount + localCount;
|
||||
if (count === 0) return '0 prayed';
|
||||
return `${count} ${count === 1 ? 'person' : 'people'} prayed`;
|
||||
});
|
||||
|
||||
// Add "View prayers" button
|
||||
cardGroups
|
||||
.append('text')
|
||||
.attr('x', startX)
|
||||
.attr('y', buttonY + buttonSize + (prayerFontSize * 2) + 4)
|
||||
.attr('text-anchor', 'start')
|
||||
.attr('font-size', prayerFontSize)
|
||||
.attr('fill', '#8e8e8e')
|
||||
.attr('cursor', 'pointer')
|
||||
.attr('class', 'view-comments-text')
|
||||
.style('user-select', 'none')
|
||||
.style('pointer-events', 'all')
|
||||
.text('View prayers')
|
||||
.on('click', (event, d) => {
|
||||
event.stopPropagation();
|
||||
this.openComments(d);
|
||||
});
|
||||
|
||||
console.log(`✅ Rendered ${totalGroups} cards in ${cols}×${rows} grid`);
|
||||
},
|
||||
|
||||
handlePrayer(group) {
|
||||
// This method is deprecated - now using openPrayerDialog
|
||||
this.openPrayerDialog(group);
|
||||
},
|
||||
|
||||
openPrayerDialog(group) {
|
||||
this.$emit('open-prayer', group);
|
||||
},
|
||||
|
||||
openComments(group) {
|
||||
this.$emit('open-comments', group);
|
||||
},
|
||||
|
||||
truncateText(text, maxLength) {
|
||||
if (!text) return '';
|
||||
return text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
|
||||
},
|
||||
|
||||
resetZoom() {
|
||||
this.svg.transition()
|
||||
.duration(750)
|
||||
.call(this.zoom.transform, d3.zoomIdentity);
|
||||
},
|
||||
|
||||
toggleOrientation() {
|
||||
this.isLandscape = !this.isLandscape;
|
||||
|
||||
// Clear existing canvas
|
||||
d3.select(this.$refs.canvas).selectAll('*').remove();
|
||||
|
||||
// Reinitialize with new orientation
|
||||
this.initializeCanvas();
|
||||
|
||||
// Re-render cards
|
||||
if (this.peopleGroups && this.peopleGroups.length > 0) {
|
||||
this.renderCards();
|
||||
}
|
||||
|
||||
console.log(`📐 Switched to ${this.isLandscape ? 'LANDSCAPE (4ft×3ft)' : 'PORTRAIT (3ft×4ft)'}`);
|
||||
},
|
||||
|
||||
printWall() {
|
||||
alert('Print functionality coming later!');
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
<template>
|
||||
<div v-if="isOpen" class="dialog-overlay" @click.self="closeDialog">
|
||||
<div class="dialog-container prayer-dialog">
|
||||
<div class="dialog-header">
|
||||
<h2>Prayer Tools</h2>
|
||||
<button @click="closeDialog" class="close-btn">×</button>
|
||||
</div>
|
||||
|
||||
<div class="group-image-container">
|
||||
<img
|
||||
:src="getImageUrl()"
|
||||
:alt="group?.name || 'People Group'"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="group-name-dialog">
|
||||
{{ group?.name || 'Unknown' }}
|
||||
</div>
|
||||
|
||||
<div class="dialog-content">
|
||||
<div class="group-info">
|
||||
<div class="info-row">
|
||||
<strong>Status:</strong> {{ group?.status || 'unengaged' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!hasPrayed" class="prayer-prompt">
|
||||
<h3>Are you praying right now for this people group?</h3>
|
||||
<button @click="confirmPrayer" class="prayer-confirm-btn">
|
||||
<i class="far fa-heart"></i>
|
||||
Yes, I'm Praying
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="prayer-confirmed">
|
||||
<div class="confirmed-message">
|
||||
<i class="fas fa-heart" style="color: #ed4956;"></i>
|
||||
<span>Thank you for praying!</span>
|
||||
</div>
|
||||
|
||||
<div class="prayer-input-section">
|
||||
<h4>Share your prayer (optional)</h4>
|
||||
<textarea
|
||||
v-model="prayerText"
|
||||
placeholder="Type your prayer here..."
|
||||
rows="4"
|
||||
></textarea>
|
||||
<button
|
||||
@click="sendPrayer"
|
||||
:disabled="!prayerText.trim()"
|
||||
class="send-prayer-btn"
|
||||
>
|
||||
Send Prayer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// No third-party placeholder service: a missing photo is drawn inline.
|
||||
const NO_IMAGE = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='600' height='400'%3E%3Crect width='100%25' height='100%25' fill='%23f3f3f1'/%3E%3Ctext x='50%25' y='50%25' fill='%236d766e' font-family='sans-serif' font-size='22' text-anchor='middle' dominant-baseline='middle'%3ENo image available%3C/text%3E%3C/svg%3E"
|
||||
export default {
|
||||
name: 'PrayerDialog',
|
||||
props: {
|
||||
isOpen: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
group: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hasPrayed: false,
|
||||
prayerText: '',
|
||||
imageError: false
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
isOpen(newVal) {
|
||||
if (newVal) {
|
||||
// Check if user already prayed for this group
|
||||
// Access the prayers state directly
|
||||
const prayerCount = this.$store?.prayers?.[this.group?.ID] || 0;
|
||||
this.hasPrayed = prayerCount > 0;
|
||||
this.prayerText = '';
|
||||
this.imageError = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
closeDialog() {
|
||||
this.$emit('close');
|
||||
},
|
||||
|
||||
confirmPrayer() {
|
||||
if (this.group?.ID) {
|
||||
this.$store.addPrayer(this.group.ID);
|
||||
this.hasPrayed = true;
|
||||
|
||||
// Emit event to update the card's heart icon
|
||||
this.$emit('prayer-confirmed', this.group.ID);
|
||||
}
|
||||
},
|
||||
|
||||
sendPrayer() {
|
||||
if (this.prayerText.trim() && this.group?.ID) {
|
||||
// Add prayer to comments as a special type
|
||||
const pseudonym = this.generatePseudonym();
|
||||
const prayerComment = `🙏 ${pseudonym} prayed: ${this.prayerText.trim()}`;
|
||||
|
||||
this.$store.addComment(this.group.ID, prayerComment);
|
||||
|
||||
this.prayerText = '';
|
||||
|
||||
// Close dialog after sending
|
||||
setTimeout(() => {
|
||||
this.closeDialog();
|
||||
}, 500);
|
||||
}
|
||||
},
|
||||
|
||||
generatePseudonym() {
|
||||
const adjectives = ['Faithful', 'Devoted', 'Humble', 'Earnest', 'Hopeful', 'Grateful', 'Compassionate'];
|
||||
const nouns = ['Servant', 'Believer', 'Intercessor', 'Warrior', 'Witness', 'Disciple', 'Friend'];
|
||||
|
||||
const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
|
||||
const noun = nouns[Math.floor(Math.random() * nouns.length)];
|
||||
const num = Math.floor(Math.random() * 999) + 1;
|
||||
|
||||
return `${adj} ${noun} ${num}`;
|
||||
},
|
||||
|
||||
getImageUrl() {
|
||||
if (this.imageError) {
|
||||
return NO_IMAGE;
|
||||
}
|
||||
|
||||
// The same photo the card shows (the live API's image_url). This used to build a URL on
|
||||
// uupg.doxa.life, which no longer answers, so every dialog fell back to "No image".
|
||||
return this.group?.imageUrl || NO_IMAGE;
|
||||
},
|
||||
|
||||
handleImageError(event) {
|
||||
this.imageError = true;
|
||||
event.target.src = NO_IMAGE;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
<template>
|
||||
<div v-if="sources.length > 1">
|
||||
<div class="source-selector">
|
||||
<div class="source-header">
|
||||
<h3>📊 Data Source</h3>
|
||||
<span class="current-source-label">Current: {{ currentSource?.name }}</span>
|
||||
</div>
|
||||
|
||||
<div class="source-buttons">
|
||||
<button
|
||||
v-for="source in availableSources"
|
||||
:key="source.id"
|
||||
@click="selectSource(source.id)"
|
||||
:class="['source-btn', { active: dataSource === source.id }]"
|
||||
>
|
||||
<span class="source-icon">{{ getSourceIcon(source.id) }}</span>
|
||||
<span class="source-name">{{ source.name }}</span>
|
||||
<span v-if="dataSource === source.id" class="active-indicator">✓</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="source-info">
|
||||
<p v-if="currentSource">
|
||||
<strong>Source URL:</strong><br>
|
||||
<code>{{ currentSource.url }}</code>
|
||||
</p>
|
||||
<p v-if="dataSource === 'CSV'" class="csv-info">
|
||||
<i class="fas fa-info-circle"></i> CSV includes ~1,439 groups with images from Joshua Project
|
||||
</p>
|
||||
<p v-if="dataSource === 'DOXA_API'" class="api-info">
|
||||
<i class="fas fa-info-circle"></i> WordPress API — descriptions & photos, no prayer data
|
||||
</p>
|
||||
<p v-if="dataSource === 'PRAY_API'" class="api-info">
|
||||
<i class="fas fa-info-circle"></i> Live prayer data — people praying, committed workers, adoption, images
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// Source Selector Component - Switch between API and CSV data sources
|
||||
export default {
|
||||
name: 'SourceSelector',
|
||||
computed: {
|
||||
dataSource() {
|
||||
return this.$store?.dataSource || 'API';
|
||||
},
|
||||
|
||||
availableSources() {
|
||||
return this.$store?.availableSources || [];
|
||||
},
|
||||
|
||||
currentSource() {
|
||||
return this.$store?.currentSource || null;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
selectSource(sourceId) {
|
||||
console.log('🔄 Switching to source:', sourceId);
|
||||
if (this.$store) {
|
||||
this.$store.setDataSource(sourceId);
|
||||
}
|
||||
},
|
||||
|
||||
getSourceIcon(sourceId) {
|
||||
switch(sourceId) {
|
||||
case 'PRAY_API':
|
||||
return '🙏';
|
||||
case 'DOXA_API':
|
||||
return '🌐';
|
||||
case 'API':
|
||||
return '🌐';
|
||||
case 'CSV':
|
||||
return '📄';
|
||||
default:
|
||||
return '📊';
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
@ -0,0 +1,635 @@
|
|||
// People Groups Store - Manages data from the Doxa prayer API
|
||||
// Moved 2026-09-25 from the wall-of-people-groups no-build SPA: Pinia now comes from the package
|
||||
// (it was the Pinia CDN global), localStorage keys carry a 'wall-pg:' prefix so an embedded wall
|
||||
// cannot collide with the host page's own keys, and the source list is the one live API.
|
||||
import { defineStore } from 'pinia'
|
||||
const LS = (k) => 'wall-pg:' + k
|
||||
|
||||
export const usePeopleGroupsStore = (pinia) => {
|
||||
|
||||
return defineStore('peopleGroups', {
|
||||
state: () => ({
|
||||
peopleGroups: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
prayers: {}, // Track prayers per people group ID
|
||||
comments: {}, // Track comments per people group ID
|
||||
dataSource: 'PRAY_API', // 'PRAY_API', 'DOXA_API', or 'CSV'
|
||||
// The Doxa WordPress endpoint (uupg.doxa.life) no longer answers, and the CSV fallback was a
|
||||
// 2.6 MB copy of what the live API already serves, so neither ships.
|
||||
availableSources: [
|
||||
{ id: 'PRAY_API', name: 'Prayer.Tools API (Live)', url: 'https://pray.doxa.life/api/people-groups/list', description: 'Live prayer data with images' }
|
||||
]
|
||||
}),
|
||||
|
||||
getters: {
|
||||
totalGroups: (state) => state.peopleGroups.length,
|
||||
|
||||
getPrayerCount: (state) => (groupId) => {
|
||||
return state.prayers[groupId] || 0;
|
||||
},
|
||||
|
||||
getComments: (state) => (groupId) => {
|
||||
return state.comments[groupId] || [];
|
||||
},
|
||||
|
||||
currentSource: (state) => {
|
||||
return state.availableSources.find(s => s.id === state.dataSource);
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
async fetchPeopleGroups() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
console.log(`🔄 Fetching people groups from ${this.dataSource}...`);
|
||||
|
||||
try {
|
||||
if (this.dataSource === 'PRAY_API') {
|
||||
await this.fetchFromPrayAPI();
|
||||
} else if (this.dataSource === 'DOXA_API') {
|
||||
await this.fetchFromAPI();
|
||||
} else if (this.dataSource === 'CSV') {
|
||||
await this.fetchFromCSV();
|
||||
}
|
||||
|
||||
console.log(`✅ Successfully loaded ${this.peopleGroups.length} people groups from ${this.dataSource}!`);
|
||||
console.log('First 3 groups:', this.peopleGroups.slice(0, 3));
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ Error fetching people groups from ${this.dataSource}:`, error);
|
||||
this.error = error.message;
|
||||
this.peopleGroups = [];
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async fetchFromPrayAPI() {
|
||||
const source = this.availableSources.find(s => s.id === 'PRAY_API');
|
||||
const apiUrl = source.url;
|
||||
console.log('🙏 Prayer.Tools API URL:', apiUrl);
|
||||
|
||||
const response = await fetch(apiUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
// Guard against HTML responses (bot protection, Nuxt SPA shell)
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (contentType.includes('text/html')) {
|
||||
throw new Error('Server returned HTML instead of JSON — the API endpoint may be blocked or unavailable');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const posts = data.posts && Array.isArray(data.posts) ? data.posts : (Array.isArray(data) ? data : []);
|
||||
|
||||
if (posts.length === 0) {
|
||||
console.error('Unexpected API response format:', data);
|
||||
this.peopleGroups = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// Placeholder for groups without photos
|
||||
const noPhotoPlaceholder = 'https://www.peoplegroups.org/images/pgphotosearch/NoImageAvailable_search.jpg';
|
||||
|
||||
this.peopleGroups = posts.map(group => {
|
||||
// Extract labels from nested value/label objects
|
||||
const regionLabel = group.wagf_region?.label || group.wagf_region || '';
|
||||
const blockLabel = group.wagf_block?.label || group.wagf_block || '';
|
||||
const countryLabel = group.country_code?.label || group.country_code || '';
|
||||
const countryCode = group.country_code?.value || group.country_code || '';
|
||||
const religionLabel = group.religion?.label || group.religion || '';
|
||||
const religionCode = group.religion?.value || '';
|
||||
const affinityLabel = group.rop1?.label || '';
|
||||
|
||||
// Image URL — API provides direct URLs, with regional fallbacks for no-photo groups
|
||||
const hasPhoto = group.has_photo === true || group.has_photo === 'true' || group.has_photo === 1;
|
||||
const imageUrl = group.image_url || noPhotoPlaceholder;
|
||||
|
||||
return {
|
||||
// Standard fields
|
||||
ID: group.slug || `pray-${Math.random().toString(36).slice(2)}`,
|
||||
post_title: group.name || 'Unknown',
|
||||
name: group.name || 'Unknown',
|
||||
slug: group.slug || '',
|
||||
status: '',
|
||||
country: countryLabel,
|
||||
countryCode: countryCode,
|
||||
region: regionLabel,
|
||||
wagfBlock: blockLabel,
|
||||
population: group.population || '',
|
||||
religion: religionLabel,
|
||||
religionCode: religionCode,
|
||||
affinityBlock: affinityLabel,
|
||||
latitude: group.latitude || null,
|
||||
longitude: group.longitude || null,
|
||||
description: group.location_description || '',
|
||||
|
||||
// Prayer & engagement data (THE KEY FIELDS!)
|
||||
peoplePraying: Number(group.people_praying || 0),
|
||||
peopleCommitted: Number(group.people_committed || 0),
|
||||
adoptedByChurches: Number(group.adopted_by_churches || 0),
|
||||
|
||||
// Image fields
|
||||
imageUrl: imageUrl,
|
||||
hasPhoto: hasPhoto,
|
||||
photoCredit: group.picture_credit || null,
|
||||
imageStatus: hasPhoto ? 'pending' : 'placeholder',
|
||||
|
||||
// Keep all original API data
|
||||
...group
|
||||
};
|
||||
});
|
||||
|
||||
// Log prayer & engagement statistics
|
||||
const withPrayer = this.peopleGroups.filter(g => g.peoplePraying > 0).length;
|
||||
const withCommitted = this.peopleGroups.filter(g => g.peopleCommitted > 0).length;
|
||||
const withAdoption = this.peopleGroups.filter(g => g.adoptedByChurches > 0).length;
|
||||
const withImages = this.peopleGroups.filter(g => g.hasPhoto).length;
|
||||
|
||||
console.log(`📊 Prayer.Tools API stats:`);
|
||||
console.log(` Total groups: ${this.peopleGroups.length}`);
|
||||
console.log(` 🙏 With prayer: ${withPrayer}`);
|
||||
console.log(` 👥 With committed workers: ${withCommitted}`);
|
||||
console.log(` ⛪ Adopted by churches: ${withAdoption}`);
|
||||
console.log(` 📸 With photos: ${withImages}`);
|
||||
|
||||
// Start background image testing
|
||||
this.testImagesInBackground();
|
||||
},
|
||||
|
||||
async fetchFromAPI() {
|
||||
const source = this.availableSources.find(s => s.id === 'DOXA_API');
|
||||
const apiUrl = source.url;
|
||||
console.log('API URL:', apiUrl);
|
||||
|
||||
const response = await fetch(apiUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
// Guard against HTML responses (bot protection, error pages)
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (contentType.includes('text/html')) {
|
||||
throw new Error('Server returned HTML instead of JSON — the API endpoint may be blocked or unavailable');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Placeholder image for groups without photos
|
||||
const placeholderUrl = 'https://www.peoplegroups.org/images/pgphotosearch/NoImageAvailable_search.jpg';
|
||||
|
||||
// The API returns { "posts": [...] }
|
||||
const posts = data.posts && Array.isArray(data.posts) ? data.posts : (Array.isArray(data) ? data : []);
|
||||
|
||||
if (posts.length === 0) {
|
||||
console.error('Unexpected API response format:', data);
|
||||
this.peopleGroups = [];
|
||||
return;
|
||||
}
|
||||
|
||||
this.peopleGroups = posts.map(group => {
|
||||
// Determine image URL from API data
|
||||
const hasPhoto = group.imb_has_photo === '1' || group.imb_has_photo === 1 || group.imb_has_photo === true;
|
||||
const apiImageUrl = group.imb_picture_url || null;
|
||||
const imageUrl = (hasPhoto && apiImageUrl) ? apiImageUrl : placeholderUrl;
|
||||
|
||||
return {
|
||||
// Standard fields mapped from API
|
||||
ID: group.dt_id || group.ID || `api-${Math.random().toString(36).slice(2)}`,
|
||||
post_title: group.imb_display_name || group.post_title || 'Unknown',
|
||||
name: group.imb_display_name || group.post_title || 'Unknown',
|
||||
slug: group.slug || '',
|
||||
status: group.status || '',
|
||||
country: group.imb_isoalpha3 || '',
|
||||
region: group.doxa_wagf_region || '',
|
||||
wagfBlock: group.doxa_wagf_block || '',
|
||||
population: group.imb_population || '',
|
||||
religion: group.imb_reg_of_religion || '',
|
||||
religionCode: group.imb_reg_of_religion_3 || '',
|
||||
latitude: group.imb_lat || null,
|
||||
longitude: group.imb_lng || null,
|
||||
description: group.imb_people_description || '',
|
||||
|
||||
// Image fields
|
||||
imageUrl: imageUrl,
|
||||
hasPhoto: hasPhoto,
|
||||
photoCredit: group.imb_picture_credit_html || '',
|
||||
imageStatus: hasPhoto && apiImageUrl ? 'pending' : 'placeholder',
|
||||
|
||||
// Keep all original API data
|
||||
...group
|
||||
};
|
||||
});
|
||||
|
||||
// Log image statistics
|
||||
const withImages = this.peopleGroups.filter(g => g.hasPhoto && g.imb_picture_url).length;
|
||||
const withPlaceholder = this.peopleGroups.length - withImages;
|
||||
console.log(`📸 Image stats: ${withImages} with photos, ${withPlaceholder} using placeholder`);
|
||||
|
||||
// Start background image testing for groups that have URLs
|
||||
this.testImagesInBackground();
|
||||
},
|
||||
|
||||
async fetchFromCSV() {
|
||||
const source = this.availableSources.find(s => s.id === 'CSV');
|
||||
const csvUrl = source.url;
|
||||
console.log('CSV URL:', csvUrl);
|
||||
|
||||
const response = await fetch(csvUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const csvText = await response.text();
|
||||
const parsedData = this.parseCSV(csvText);
|
||||
|
||||
// Map all groups to standard format
|
||||
const allGroups = parsedData.map(group => ({
|
||||
ID: group.PEID || group.PeopleID3 || `csv-${Math.random()}`,
|
||||
post_title: group.Name || group.PplNm,
|
||||
name: group.Name || group.PplNm,
|
||||
status: group.Status || 'Active',
|
||||
country: group.Ctry,
|
||||
region: group.Regn,
|
||||
population: group.Pop,
|
||||
language: group.Lang,
|
||||
religion: group.Rlgn,
|
||||
peopleID3: group.PeopleID3 && group.PeopleID3.trim() !== '' ? group.PeopleID3.trim() : null,
|
||||
imageUrl: group.PeopleID3 && group.PeopleID3.trim() !== '' ? this.generateImageUrl(group.PeopleID3.trim()) : null,
|
||||
imageStatus: 'pending', // 'pending', 'loading', 'loaded', 'failed', 'error'
|
||||
// Include all original CSV data
|
||||
...group
|
||||
}));
|
||||
|
||||
// Count cluster statistics
|
||||
const clusterStats = {};
|
||||
allGroups.forEach(group => {
|
||||
if (group.peopleID3) {
|
||||
if (!clusterStats[group.peopleID3]) {
|
||||
clusterStats[group.peopleID3] = { total: 0 };
|
||||
}
|
||||
clusterStats[group.peopleID3].total++;
|
||||
}
|
||||
});
|
||||
|
||||
// Initial sort by cluster size
|
||||
this.peopleGroups = allGroups.sort((a, b) => {
|
||||
const aHasID = a.peopleID3 ? 1 : 0;
|
||||
const bHasID = b.peopleID3 ? 1 : 0;
|
||||
|
||||
// Groups with PeopleID3 come before groups without
|
||||
if (aHasID !== bHasID) {
|
||||
return bHasID - aHasID;
|
||||
}
|
||||
|
||||
// Both have PeopleID3: sort by cluster size
|
||||
if (aHasID && bHasID) {
|
||||
const aCluster = clusterStats[a.peopleID3];
|
||||
const bCluster = clusterStats[b.peopleID3];
|
||||
|
||||
if (aCluster.total !== bCluster.total) {
|
||||
return bCluster.total - aCluster.total;
|
||||
}
|
||||
|
||||
return a.peopleID3.localeCompare(b.peopleID3);
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
const withPeopleID3 = this.peopleGroups.filter(g => g.peopleID3).length;
|
||||
const withoutPeopleID3 = this.peopleGroups.length - withPeopleID3;
|
||||
const uniqueClusters = Object.keys(clusterStats).length;
|
||||
|
||||
console.log(`✅ Parsed ${parsedData.length} total rows`);
|
||||
console.log(` ${withPeopleID3} have PeopleID3, ${withoutPeopleID3} without`);
|
||||
console.log(` ${uniqueClusters} unique PeopleID3 clusters`);
|
||||
console.log('Top 5 largest clusters:',
|
||||
Object.entries(clusterStats)
|
||||
.sort((a, b) => b[1].total - a[1].total)
|
||||
.slice(0, 5)
|
||||
.map(([id, stats]) => `PeopleID3 ${id}: ${stats.total} groups`));
|
||||
|
||||
// Start background image testing (non-blocking)
|
||||
this.testImagesInBackground();
|
||||
},
|
||||
|
||||
async testImagesInBackground() {
|
||||
console.log('🔍 Starting background image verification...');
|
||||
|
||||
// Get groups that need image testing (have a real imageUrl, not placeholder)
|
||||
const groupsToTest = this.peopleGroups.filter(g =>
|
||||
g.imageUrl && g.imageStatus === 'pending'
|
||||
);
|
||||
|
||||
// Deduplicate by imageUrl
|
||||
const uniqueTests = new Map();
|
||||
groupsToTest.forEach(group => {
|
||||
if (!uniqueTests.has(group.imageUrl)) {
|
||||
uniqueTests.set(group.imageUrl, {
|
||||
url: group.imageUrl,
|
||||
groupIds: [group.ID],
|
||||
status: 'pending'
|
||||
});
|
||||
} else {
|
||||
uniqueTests.get(group.imageUrl).groupIds.push(group.ID);
|
||||
}
|
||||
});
|
||||
|
||||
const totalToTest = uniqueTests.size;
|
||||
let tested = 0;
|
||||
let loaded = 0;
|
||||
let failed = 0;
|
||||
let errors = 0;
|
||||
|
||||
console.log(` Testing ${totalToTest} unique images...`);
|
||||
|
||||
// Test images SLOWLY - only 3 at a time with delays
|
||||
const batchSize = 3; // Small batch size to avoid rate limiting
|
||||
const delayBetweenBatches = 1000; // 1 second delay between batches
|
||||
const maxRetries = 3; // Retry failed images up to 3 times
|
||||
|
||||
const entries = Array.from(uniqueTests.entries());
|
||||
|
||||
const placeholderUrl = 'https://www.peoplegroups.org/images/pgphotosearch/NoImageAvailable_search.jpg';
|
||||
|
||||
for (let i = 0; i < entries.length; i += batchSize) {
|
||||
const batch = entries.slice(i, i + batchSize);
|
||||
const promises = batch.map(([imageUrl, info]) =>
|
||||
this.testImageWithRetry(info.url, null, maxRetries).then(result => ({
|
||||
imageUrl,
|
||||
groupIds: info.groupIds,
|
||||
...result
|
||||
}))
|
||||
);
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
// Update groups with results
|
||||
results.forEach(result => {
|
||||
const { groupIds, status, attempts } = result;
|
||||
|
||||
this.peopleGroups.forEach(group => {
|
||||
if (groupIds.includes(group.ID)) {
|
||||
group.imageStatus = status;
|
||||
group.imageAttempts = attempts;
|
||||
if (status === 'failed' || status === 'error') {
|
||||
// Fall back to placeholder
|
||||
group.imageUrl = placeholderUrl;
|
||||
group.imageStatus = 'placeholder';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tested++;
|
||||
if (status === 'loaded') {
|
||||
loaded++;
|
||||
} else if (status === 'failed') {
|
||||
failed++;
|
||||
console.warn(` ❌ 404: ${result.imageUrl} (tried ${attempts} times)`);
|
||||
} else if (status === 'error') {
|
||||
errors++;
|
||||
console.error(` ⚠️ Error: ${result.imageUrl} (tried ${attempts} times)`);
|
||||
}
|
||||
});
|
||||
|
||||
// Log progress every 10 batches
|
||||
if ((i / batchSize) % 10 === 0) {
|
||||
console.log(` Progress: ${tested}/${totalToTest} tested (${loaded} ✅, ${failed} ❌, ${errors} ⚠️)`);
|
||||
}
|
||||
|
||||
// Delay between batches to avoid rate limiting
|
||||
if (i + batchSize < entries.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, delayBetweenBatches));
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`🎉 Image testing complete!`);
|
||||
console.log(` ✅ ${loaded} images loaded successfully`);
|
||||
console.log(` ❌ ${failed} images not found (404) after retries`);
|
||||
console.log(` ⚠️ ${errors} network/timeout errors after retries`);
|
||||
console.log(` Note: Errors may be due to rate limiting or slow network`);
|
||||
},
|
||||
|
||||
async testImageWithRetry(url, peopleID3, maxRetries = 3) {
|
||||
let lastResult = null;
|
||||
|
||||
// First, try the primary URL (media.joshuaproject.net with /public/)
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
const result = await this.testImageLoadDetailed(url);
|
||||
|
||||
// If loaded successfully, return immediately
|
||||
if (result.status === 'loaded') {
|
||||
return { ...result, attempts: attempt, urlFormat: 'primary' };
|
||||
}
|
||||
|
||||
lastResult = result;
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
// Wait before retrying (exponential backoff)
|
||||
const delay = 500 * attempt; // 500ms, 1000ms, 1500ms...
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
|
||||
// Primary URL failed, try fallback URL format
|
||||
const fallbackUrl = `https://joshuaproject.net/assets/media/profiles/photos/p${peopleID3}.jpg`;
|
||||
console.log(` 🔄 Trying fallback URL for PeopleID3 ${peopleID3}...`);
|
||||
|
||||
const fallbackResult = await this.testImageLoadDetailed(fallbackUrl);
|
||||
|
||||
if (fallbackResult.status === 'loaded') {
|
||||
// Update the group's imageUrl to use the fallback
|
||||
this.peopleGroups.forEach(group => {
|
||||
if (group.peopleID3 === peopleID3) {
|
||||
group.imageUrl = fallbackUrl;
|
||||
}
|
||||
});
|
||||
return { ...fallbackResult, attempts: maxRetries + 1, urlFormat: 'fallback' };
|
||||
}
|
||||
|
||||
// Both primary and fallback failed
|
||||
return { ...lastResult, attempts: maxRetries + 1, urlFormat: 'none' };
|
||||
},
|
||||
|
||||
testImageLoadDetailed(url) {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
const timeout = setTimeout(() => {
|
||||
img.src = ''; // Cancel loading
|
||||
resolve({ status: 'error', statusCode: 'timeout', url });
|
||||
}, 10000); // 10 second timeout (longer for slow networks)
|
||||
|
||||
img.onload = () => {
|
||||
clearTimeout(timeout);
|
||||
resolve({ status: 'loaded', statusCode: 200, url });
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
clearTimeout(timeout);
|
||||
// Assume failed (could be 404 or network error)
|
||||
resolve({ status: 'failed', statusCode: 404, url });
|
||||
};
|
||||
|
||||
img.src = url;
|
||||
});
|
||||
},
|
||||
|
||||
parseCSV(csvText) {
|
||||
// Split by newline but handle quoted fields with embedded newlines
|
||||
const lines = [];
|
||||
let currentLine = '';
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < csvText.length; i++) {
|
||||
const char = csvText[i];
|
||||
|
||||
if (char === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
currentLine += char;
|
||||
} else if (char === '\n' && !inQuotes) {
|
||||
if (currentLine.trim()) {
|
||||
lines.push(currentLine);
|
||||
}
|
||||
currentLine = '';
|
||||
} else {
|
||||
currentLine += char;
|
||||
}
|
||||
}
|
||||
|
||||
// Add last line if exists
|
||||
if (currentLine.trim()) {
|
||||
lines.push(currentLine);
|
||||
}
|
||||
|
||||
if (lines.length === 0) return [];
|
||||
|
||||
// Get headers from first line - parse CSV properly
|
||||
const headers = this.parseCSVLine(lines[0]);
|
||||
console.log('CSV Headers found:', headers.length, 'columns');
|
||||
console.log('Key columns - PEID:', headers.indexOf('PEID'), 'Name:', headers.indexOf('Name'), 'PeopleID3:', headers.indexOf('PeopleID3'));
|
||||
|
||||
const data = [];
|
||||
|
||||
// Parse each line
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
|
||||
const values = this.parseCSVLine(line);
|
||||
|
||||
const row = {};
|
||||
headers.forEach((header, index) => {
|
||||
if (values[index] !== undefined) {
|
||||
row[header] = values[index];
|
||||
}
|
||||
});
|
||||
|
||||
data.push(row);
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
parseCSVLine(line) {
|
||||
const result = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i];
|
||||
|
||||
if (char === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
} else if (char === ',' && !inQuotes) {
|
||||
result.push(current.trim());
|
||||
current = '';
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
|
||||
// Add last field
|
||||
result.push(current.trim());
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
generateImageUrl(peopleID3) {
|
||||
if (!peopleID3 || peopleID3.trim() === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Primary URL format (works better): https://media.joshuaproject.net/public/assets/media/profiles/photos/p19007.jpg
|
||||
// Fallback URL format: https://joshuaproject.net/assets/media/profiles/photos/p18194.jpg
|
||||
return `https://media.joshuaproject.net/public/assets/media/profiles/photos/p${peopleID3}.jpg`;
|
||||
},
|
||||
|
||||
setDataSource(sourceId) {
|
||||
if (this.dataSource !== sourceId) {
|
||||
this.dataSource = sourceId;
|
||||
// Save preference to localStorage
|
||||
localStorage.setItem(LS('dataSource'), sourceId);
|
||||
// Refetch data from new source
|
||||
this.fetchPeopleGroups();
|
||||
}
|
||||
},
|
||||
|
||||
addPrayer(groupId) {
|
||||
if (!this.prayers[groupId]) {
|
||||
this.prayers[groupId] = 0;
|
||||
}
|
||||
this.prayers[groupId]++;
|
||||
|
||||
// Save to localStorage for persistence
|
||||
localStorage.setItem(LS('prayers'), JSON.stringify(this.prayers));
|
||||
},
|
||||
|
||||
addComment(groupId, comment) {
|
||||
if (!this.comments[groupId]) {
|
||||
this.comments[groupId] = [];
|
||||
}
|
||||
|
||||
this.comments[groupId].push({
|
||||
id: Date.now(),
|
||||
text: comment,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Save to localStorage for persistence
|
||||
localStorage.setItem(LS('comments'), JSON.stringify(this.comments));
|
||||
},
|
||||
|
||||
loadFromLocalStorage() {
|
||||
try {
|
||||
const savedPrayers = localStorage.getItem(LS('prayers'));
|
||||
const savedComments = localStorage.getItem(LS('comments'));
|
||||
const savedSource = localStorage.getItem(LS('dataSource'));
|
||||
|
||||
if (savedPrayers) {
|
||||
this.prayers = JSON.parse(savedPrayers);
|
||||
}
|
||||
|
||||
if (savedComments) {
|
||||
this.comments = JSON.parse(savedComments);
|
||||
}
|
||||
|
||||
if (savedSource && ['PRAY_API'].includes(savedSource)) {
|
||||
this.dataSource = savedSource;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading from localStorage:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
})(pinia);
|
||||
};
|
||||
|
||||
|
||||
|
||||
1162
plugin-designs/wall-of-people-groups/src/wall.css
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"type": "monolithic",
|
||||
"label": "Wall of People Groups",
|
||||
"note": "Monolithic composite (capability 3): ONE root .vue nests the zoomable d3 wall, its cards, and the prayer and comments dialogs. index.js registers exactly ONE tag (wall-of-people-groups); none of the inner components is independently embeddable."
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
<script>
|
||||
/**
|
||||
* wall-of-people-groups.vue — Wall of People Groups: every Doxa people group on one zoomable wall.
|
||||
*
|
||||
* @description Every Doxa people group as a card on one zoomable prayer wall
|
||||
* @profile wall-of-people-groups
|
||||
* @bundle wall-of-people-groups
|
||||
* @element wall-of-people-groups
|
||||
*
|
||||
* MOVED 2026-09-25 from the wall-of-people-groups no-build SPA (far-fast-tools, read-only). Its
|
||||
* components were plain objects with template strings compiled in the browser; they are now SFCs
|
||||
* in src/components. This file is the app container from its main.js, without the router (the
|
||||
* only other route was an API developer panel, which does not ship).
|
||||
*
|
||||
* Data: the live pray.doxa.life people-group list (2,107 groups, with photos and prayer counts).
|
||||
* Prayers and comments stay in this browser's localStorage, under a 'wall-pg:' prefix.
|
||||
* d3 and Font Awesome are CDN globals, loaded on first mount.
|
||||
*/
|
||||
import PeopleGroupsWall from './src/components/PeopleGroupsWall.vue'
|
||||
import CommentsDialog from './src/components/CommentsDialog.vue'
|
||||
import PrayerDialog from './src/components/PrayerDialog.vue'
|
||||
import SourceSelector from './src/components/SourceSelector.vue'
|
||||
|
||||
const D3_JS = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js'
|
||||
const FA_CSS = 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css'
|
||||
let d3Promise = null
|
||||
function ensureD3() {
|
||||
if (typeof window === 'undefined' || window.d3) return Promise.resolve()
|
||||
if (!d3Promise) d3Promise = new Promise((res, rej) => {
|
||||
const s = document.createElement('script'); s.src = D3_JS
|
||||
s.onload = () => res(); s.onerror = () => { d3Promise = null; rej(new Error('d3 failed to load')) }
|
||||
document.head.appendChild(s)
|
||||
})
|
||||
return d3Promise
|
||||
}
|
||||
// Font Awesome's @font-face only registers from the DOCUMENT; its classes must also be inside the
|
||||
// shadow root. So the stylesheet goes in both places.
|
||||
function ensureFontAwesome(root) {
|
||||
if (typeof document === 'undefined') return
|
||||
if (!document.head.querySelector(`link[href="${FA_CSS}"]`)) {
|
||||
const l = document.createElement('link'); l.rel = 'stylesheet'; l.href = FA_CSS; document.head.appendChild(l)
|
||||
}
|
||||
if (root && root !== document && !root.querySelector(`link[href="${FA_CSS}"]`)) {
|
||||
const l = document.createElement('link'); l.rel = 'stylesheet'; l.href = FA_CSS; root.prepend(l)
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'WallOfPeopleGroups',
|
||||
components: { PeopleGroupsWall, CommentsDialog, PrayerDialog, SourceSelector },
|
||||
data() {
|
||||
return { ready: false, error: null, commentsDialogVisible: false, prayerDialogVisible: false, selectedGroup: null }
|
||||
},
|
||||
mounted() {
|
||||
ensureFontAwesome(this.$el?.getRootNode?.())
|
||||
ensureD3().then(() => { this.ready = true }, (e) => { this.error = e.message })
|
||||
},
|
||||
methods: {
|
||||
openCommentsDialog(group) { this.selectedGroup = group; this.commentsDialogVisible = true },
|
||||
closeCommentsDialog() {
|
||||
this.commentsDialogVisible = false
|
||||
setTimeout(() => { this.selectedGroup = null }, 300)
|
||||
},
|
||||
openPrayerDialog(group) { this.selectedGroup = group; this.prayerDialogVisible = true },
|
||||
closePrayerDialog() {
|
||||
this.prayerDialogVisible = false
|
||||
setTimeout(() => { if (!this.commentsDialogVisible) this.selectedGroup = null }, 300)
|
||||
},
|
||||
handlePrayerConfirmed() { /* PrayerDialog already updated the store */ },
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<source-selector />
|
||||
<people-groups-wall v-if="ready" @open-comments="openCommentsDialog" @open-prayer="openPrayerDialog" />
|
||||
<div v-else-if="error" class="wall-error">The wall could not load its drawing library: {{ error }}</div>
|
||||
<comments-dialog :visible="commentsDialogVisible" :group="selectedGroup" @close="closeCommentsDialog" />
|
||||
<prayer-dialog :is-open="prayerDialogVisible" :group="selectedGroup" @close="closePrayerDialog" @prayer-confirmed="handlePrayerConfirmed" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style src="./src/wall.css"></style>
|
||||
<style>
|
||||
:host { display: block; position: relative; min-height: 600px; }
|
||||
.app-container { position: relative; width: 100%; height: 100%; }
|
||||
.wall-error { padding: 24px; color: #3b463d; font: 14px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
|
||||
</style>
|
||||
22
public/doxa-maps/big-picture/big-picture.js
generated
|
|
@ -37,7 +37,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-AGFs5KcwxgY8QHHMGDyC9H3eZXCrL32t2mMrBXkTV7IdCqdcY9vHOZymJ7oUUfq2" crossorigin="anonymous"></script>
|
||||
<script src="../../big-picture.js" integrity="sha384-fLOTR6xYgvjETQGZchu/ceLXqZiVfyw7AjCjZmhe6MPNHfyVZ0j1jXu2f49CQujM" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -37,7 +37,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-AGFs5KcwxgY8QHHMGDyC9H3eZXCrL32t2mMrBXkTV7IdCqdcY9vHOZymJ7oUUfq2" crossorigin="anonymous"></script>
|
||||
<script src="../../big-picture.js" integrity="sha384-fLOTR6xYgvjETQGZchu/ceLXqZiVfyw7AjCjZmhe6MPNHfyVZ0j1jXu2f49CQujM" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -37,7 +37,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-AGFs5KcwxgY8QHHMGDyC9H3eZXCrL32t2mMrBXkTV7IdCqdcY9vHOZymJ7oUUfq2" crossorigin="anonymous"></script>
|
||||
<script src="../big-picture.js" integrity="sha384-fLOTR6xYgvjETQGZchu/ceLXqZiVfyw7AjCjZmhe6MPNHfyVZ0j1jXu2f49CQujM" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -40,7 +40,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-neDXieUP4BQiC0yam5g7W4c5d+JOiVaxck//TEhnxBZgDOLNQkYvfOlBTTZwPOBi" crossorigin="anonymous"></script>
|
||||
<script src="../../dashboard-plus-grid.js" integrity="sha384-Rc4Fi2DHGSwNMZf91ng3ejXA5JBPzCKgQi9DJ1sQcr9PUq819LAhBHvFU/NbyMwR" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -40,7 +40,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-neDXieUP4BQiC0yam5g7W4c5d+JOiVaxck//TEhnxBZgDOLNQkYvfOlBTTZwPOBi" crossorigin="anonymous"></script>
|
||||
<script src="../dashboard-plus-grid.js" integrity="sha384-Rc4Fi2DHGSwNMZf91ng3ejXA5JBPzCKgQi9DJ1sQcr9PUq819LAhBHvFU/NbyMwR" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
54
public/doxa-maps/doxa-maps-spa/manifest.json
generated
|
|
@ -2,8 +2,8 @@
|
|||
"schema": "doxa-maps-manifest@1",
|
||||
"generatedFrom": "plugin-designs/*/*.vue",
|
||||
"embedBase": ".",
|
||||
"bundleCount": 22,
|
||||
"profileCount": 34,
|
||||
"bundleCount": 23,
|
||||
"profileCount": 35,
|
||||
"runtime": {
|
||||
"apiBaseUrl": "https://pray.doxa.life",
|
||||
"token": "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ"
|
||||
|
|
@ -39,7 +39,7 @@
|
|||
"bundle": "big-picture",
|
||||
"scriptUrl": "./big-picture/big-picture.js",
|
||||
"output": "doxa-maps/big-picture/big-picture.js",
|
||||
"integrity": "sha384-AGFs5KcwxgY8QHHMGDyC9H3eZXCrL32t2mMrBXkTV7IdCqdcY9vHOZymJ7oUUfq2",
|
||||
"integrity": "sha384-fLOTR6xYgvjETQGZchu/ceLXqZiVfyw7AjCjZmhe6MPNHfyVZ0j1jXu2f49CQujM",
|
||||
"elements": [
|
||||
"bp-big-picture",
|
||||
"bp-research-header",
|
||||
|
|
@ -61,7 +61,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-AGFs5KcwxgY8QHHMGDyC9H3eZXCrL32t2mMrBXkTV7IdCqdcY9vHOZymJ7oUUfq2\" 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-fLOTR6xYgvjETQGZchu/ceLXqZiVfyw7AjCjZmhe6MPNHfyVZ0j1jXu2f49CQujM\" 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": [
|
||||
{
|
||||
|
|
@ -215,7 +215,7 @@
|
|||
"bundle": "dashboard-plus-grid",
|
||||
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
|
||||
"output": "doxa-maps/dashboard-plus-grid/dashboard-plus-grid.js",
|
||||
"integrity": "sha384-neDXieUP4BQiC0yam5g7W4c5d+JOiVaxck//TEhnxBZgDOLNQkYvfOlBTTZwPOBi",
|
||||
"integrity": "sha384-Rc4Fi2DHGSwNMZf91ng3ejXA5JBPzCKgQi9DJ1sQcr9PUq819LAhBHvFU/NbyMwR",
|
||||
"elements": [
|
||||
"dashboard-plus-grid"
|
||||
],
|
||||
|
|
@ -235,7 +235,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; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-neDXieUP4BQiC0yam5g7W4c5d+JOiVaxck//TEhnxBZgDOLNQkYvfOlBTTZwPOBi\" 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; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-Rc4Fi2DHGSwNMZf91ng3ejXA5JBPzCKgQi9DJ1sQcr9PUq819LAhBHvFU/NbyMwR\" 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": [
|
||||
{
|
||||
|
|
@ -354,7 +354,7 @@
|
|||
"bundle": "doxa-research-map",
|
||||
"scriptUrl": "./doxa-research-map/doxa-research-map.js",
|
||||
"output": "doxa-maps/doxa-research-map/doxa-research-map.js",
|
||||
"integrity": "sha384-mCpa55tjv4oxjE8D/jJ0opyMqnZZPqBNSwaEr8C0vRlubYjqOJgx7kxjamu56E2k",
|
||||
"integrity": "sha384-anX4V4pEfRqe+f9rN213v39mEAh0xxI0lfKOPD+mnaIk6+xV+RX1Y5wKcDgfcK6d",
|
||||
"elements": [
|
||||
"doxa-research-map"
|
||||
],
|
||||
|
|
@ -370,7 +370,7 @@
|
|||
"scriptUrl": "./doxa-research-map/doxa-research-map.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./doxa-research-map/research-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-research-map/doxa-research-map.js\" integrity=\"sha384-mCpa55tjv4oxjE8D/jJ0opyMqnZZPqBNSwaEr8C0vRlubYjqOJgx7kxjamu56E2k\" crossorigin=\"anonymous\"></script>\n<doxa-research-map profile-config='{\"profile\":\"research-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-research-map>"
|
||||
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-research-map/doxa-research-map.js\" integrity=\"sha384-anX4V4pEfRqe+f9rN213v39mEAh0xxI0lfKOPD+mnaIk6+xV+RX1Y5wKcDgfcK6d\" crossorigin=\"anonymous\"></script>\n<doxa-research-map profile-config='{\"profile\":\"research-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-research-map>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -721,7 +721,7 @@
|
|||
"bundle": "people-groups-grid",
|
||||
"scriptUrl": "./people-groups-grid/people-groups-grid.js",
|
||||
"output": "doxa-maps/people-groups-grid/people-groups-grid.js",
|
||||
"integrity": "sha384-ziaGuqTpW4HvR1P76Nzu6OByBHUzs1E1C4l4m+bC0I8C7KivdbTLEFN/36ZCT/mF",
|
||||
"integrity": "sha384-LMab8CAeKjHRnOkUn3HsykTYv1v/4wnF1av7AzqvbsjmakArA2dGd8ZeliFMkxzs",
|
||||
"elements": [
|
||||
"people-groups-grid"
|
||||
],
|
||||
|
|
@ -737,7 +737,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-ziaGuqTpW4HvR1P76Nzu6OByBHUzs1E1C4l4m+bC0I8C7KivdbTLEFN/36ZCT/mF\" 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-LMab8CAeKjHRnOkUn3HsykTYv1v/4wnF1av7AzqvbsjmakArA2dGd8ZeliFMkxzs\" 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": [
|
||||
{
|
||||
|
|
@ -863,7 +863,7 @@
|
|||
"bundle": "prayer-walk-planner",
|
||||
"scriptUrl": "./prayer-walk-planner/prayer-walk-planner.js",
|
||||
"output": "doxa-maps/prayer-walk-planner/prayer-walk-planner.js",
|
||||
"integrity": "sha384-C6y6vsEGwkvIVSs0s8SKEh1LeEuEOs0KRPg/grFh7BMo9n4BDTJDq7/Sfy/V/VqY",
|
||||
"integrity": "sha384-rI097ecm/idym9j/Od/aOtgqhk0kB/1CwMpal9jTLm/ZyNKdlo6eYRMwV+J0rJyV",
|
||||
"elements": [
|
||||
"prayer-walk-planner"
|
||||
],
|
||||
|
|
@ -879,7 +879,7 @@
|
|||
"scriptUrl": "./prayer-walk-planner/prayer-walk-planner.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./prayer-walk-planner/prayer-walk-planner/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./prayer-walk-planner/prayer-walk-planner.js\" integrity=\"sha384-C6y6vsEGwkvIVSs0s8SKEh1LeEuEOs0KRPg/grFh7BMo9n4BDTJDq7/Sfy/V/VqY\" crossorigin=\"anonymous\"></script>\n<prayer-walk-planner profile-config='{\"profile\":\"prayer-walk-planner\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></prayer-walk-planner>"
|
||||
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./prayer-walk-planner/prayer-walk-planner.js\" integrity=\"sha384-rI097ecm/idym9j/Od/aOtgqhk0kB/1CwMpal9jTLm/ZyNKdlo6eYRMwV+J0rJyV\" crossorigin=\"anonymous\"></script>\n<prayer-walk-planner profile-config='{\"profile\":\"prayer-walk-planner\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></prayer-walk-planner>"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -971,7 +971,7 @@
|
|||
"bundle": "research-map-plus-grid",
|
||||
"scriptUrl": "./research-map-plus-grid/research-map-plus-grid.js",
|
||||
"output": "doxa-maps/research-map-plus-grid/research-map-plus-grid.js",
|
||||
"integrity": "sha384-hETnSGVenHXQXaI+EUClxIoaB0xyA9jHa3AaZE6grq4UHxTuX0QNZj93mAUi3H7b",
|
||||
"integrity": "sha384-t6tBUjmfjY8cMNakP20wiAraSH/bCaDW/WjXB+nm1dptLXjbTg4aqvKaXeYFjNvD",
|
||||
"elements": [
|
||||
"research-map-plus-grid"
|
||||
],
|
||||
|
|
@ -991,7 +991,7 @@
|
|||
"scriptUrl": "./research-map-plus-grid/research-map-plus-grid.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./research-map-plus-grid/research-map-plus-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=\"./research-map-plus-grid/research-map-plus-grid.js\" integrity=\"sha384-hETnSGVenHXQXaI+EUClxIoaB0xyA9jHa3AaZE6grq4UHxTuX0QNZj93mAUi3H7b\" crossorigin=\"anonymous\"></script>\n<research-map-plus-grid profile-config='{\"profile\":\"research-map-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-map-plus-grid>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-map-plus-grid/research-map-plus-grid.js\" integrity=\"sha384-t6tBUjmfjY8cMNakP20wiAraSH/bCaDW/WjXB+nm1dptLXjbTg4aqvKaXeYFjNvD\" crossorigin=\"anonymous\"></script>\n<research-map-plus-grid profile-config='{\"profile\":\"research-map-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-map-plus-grid>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -1152,6 +1152,32 @@
|
|||
"bundle": "upg-neighbor"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"bundle": "wall-of-people-groups",
|
||||
"scriptUrl": "./wall-of-people-groups/wall-of-people-groups.js",
|
||||
"output": "doxa-maps/wall-of-people-groups/wall-of-people-groups.js",
|
||||
"integrity": "sha384-YPFNNohoobgb+Dvpie0KY661J5Y6ggXDDYmAeGt2ZqEwzvon8gKh3po0I/RPxAZR",
|
||||
"elements": [
|
||||
"wall-of-people-groups"
|
||||
],
|
||||
"profiles": [
|
||||
{
|
||||
"profile": "wall-of-people-groups",
|
||||
"file": "wall-of-people-groups.vue",
|
||||
"description": "Every Doxa people group as a card on one zoomable prayer wall",
|
||||
"element": "wall-of-people-groups",
|
||||
"type": "monolithic",
|
||||
"isMap": false,
|
||||
"iframeUrl": "./wall-of-people-groups/wall-of-people-groups/index.html",
|
||||
"scriptUrl": "./wall-of-people-groups/wall-of-people-groups.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./wall-of-people-groups/wall-of-people-groups/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=\"./wall-of-people-groups/wall-of-people-groups.js\" integrity=\"sha384-YPFNNohoobgb+Dvpie0KY661J5Y6ggXDDYmAeGt2ZqEwzvon8gKh3po0I/RPxAZR\" crossorigin=\"anonymous\"></script>\n<wall-of-people-groups profile-config='{\"profile\":\"wall-of-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></wall-of-people-groups>"
|
||||
},
|
||||
"bundle": "wall-of-people-groups"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-research-map> custom element. -->
|
||||
<script src="../doxa-research-map.js" integrity="sha384-mCpa55tjv4oxjE8D/jJ0opyMqnZZPqBNSwaEr8C0vRlubYjqOJgx7kxjamu56E2k" crossorigin="anonymous"></script>
|
||||
<script src="../doxa-research-map.js" integrity="sha384-anX4V4pEfRqe+f9rN213v39mEAh0xxI0lfKOPD+mnaIk6+xV+RX1Y5wKcDgfcK6d" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-research-map> custom element. -->
|
||||
<script src="../../doxa-research-map.js" integrity="sha384-mCpa55tjv4oxjE8D/jJ0opyMqnZZPqBNSwaEr8C0vRlubYjqOJgx7kxjamu56E2k" crossorigin="anonymous"></script>
|
||||
<script src="../../doxa-research-map.js" integrity="sha384-anX4V4pEfRqe+f9rN213v39mEAh0xxI0lfKOPD+mnaIk6+xV+RX1Y5wKcDgfcK6d" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
2
public/doxa-maps/index.html
generated
54
public/doxa-maps/manifest.json
generated
|
|
@ -2,8 +2,8 @@
|
|||
"schema": "doxa-maps-manifest@1",
|
||||
"generatedFrom": "plugin-designs/*/*.vue",
|
||||
"embedBase": ".",
|
||||
"bundleCount": 22,
|
||||
"profileCount": 34,
|
||||
"bundleCount": 23,
|
||||
"profileCount": 35,
|
||||
"runtime": {
|
||||
"apiBaseUrl": "https://pray.doxa.life",
|
||||
"token": "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ"
|
||||
|
|
@ -39,7 +39,7 @@
|
|||
"bundle": "big-picture",
|
||||
"scriptUrl": "./big-picture/big-picture.js",
|
||||
"output": "doxa-maps/big-picture/big-picture.js",
|
||||
"integrity": "sha384-AGFs5KcwxgY8QHHMGDyC9H3eZXCrL32t2mMrBXkTV7IdCqdcY9vHOZymJ7oUUfq2",
|
||||
"integrity": "sha384-fLOTR6xYgvjETQGZchu/ceLXqZiVfyw7AjCjZmhe6MPNHfyVZ0j1jXu2f49CQujM",
|
||||
"elements": [
|
||||
"bp-big-picture",
|
||||
"bp-research-header",
|
||||
|
|
@ -61,7 +61,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-AGFs5KcwxgY8QHHMGDyC9H3eZXCrL32t2mMrBXkTV7IdCqdcY9vHOZymJ7oUUfq2\" 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-fLOTR6xYgvjETQGZchu/ceLXqZiVfyw7AjCjZmhe6MPNHfyVZ0j1jXu2f49CQujM\" 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": [
|
||||
{
|
||||
|
|
@ -215,7 +215,7 @@
|
|||
"bundle": "dashboard-plus-grid",
|
||||
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
|
||||
"output": "doxa-maps/dashboard-plus-grid/dashboard-plus-grid.js",
|
||||
"integrity": "sha384-neDXieUP4BQiC0yam5g7W4c5d+JOiVaxck//TEhnxBZgDOLNQkYvfOlBTTZwPOBi",
|
||||
"integrity": "sha384-Rc4Fi2DHGSwNMZf91ng3ejXA5JBPzCKgQi9DJ1sQcr9PUq819LAhBHvFU/NbyMwR",
|
||||
"elements": [
|
||||
"dashboard-plus-grid"
|
||||
],
|
||||
|
|
@ -235,7 +235,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; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-neDXieUP4BQiC0yam5g7W4c5d+JOiVaxck//TEhnxBZgDOLNQkYvfOlBTTZwPOBi\" 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; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-Rc4Fi2DHGSwNMZf91ng3ejXA5JBPzCKgQi9DJ1sQcr9PUq819LAhBHvFU/NbyMwR\" 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": [
|
||||
{
|
||||
|
|
@ -354,7 +354,7 @@
|
|||
"bundle": "doxa-research-map",
|
||||
"scriptUrl": "./doxa-research-map/doxa-research-map.js",
|
||||
"output": "doxa-maps/doxa-research-map/doxa-research-map.js",
|
||||
"integrity": "sha384-mCpa55tjv4oxjE8D/jJ0opyMqnZZPqBNSwaEr8C0vRlubYjqOJgx7kxjamu56E2k",
|
||||
"integrity": "sha384-anX4V4pEfRqe+f9rN213v39mEAh0xxI0lfKOPD+mnaIk6+xV+RX1Y5wKcDgfcK6d",
|
||||
"elements": [
|
||||
"doxa-research-map"
|
||||
],
|
||||
|
|
@ -370,7 +370,7 @@
|
|||
"scriptUrl": "./doxa-research-map/doxa-research-map.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./doxa-research-map/research-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-research-map/doxa-research-map.js\" integrity=\"sha384-mCpa55tjv4oxjE8D/jJ0opyMqnZZPqBNSwaEr8C0vRlubYjqOJgx7kxjamu56E2k\" crossorigin=\"anonymous\"></script>\n<doxa-research-map profile-config='{\"profile\":\"research-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-research-map>"
|
||||
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-research-map/doxa-research-map.js\" integrity=\"sha384-anX4V4pEfRqe+f9rN213v39mEAh0xxI0lfKOPD+mnaIk6+xV+RX1Y5wKcDgfcK6d\" crossorigin=\"anonymous\"></script>\n<doxa-research-map profile-config='{\"profile\":\"research-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-research-map>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -721,7 +721,7 @@
|
|||
"bundle": "people-groups-grid",
|
||||
"scriptUrl": "./people-groups-grid/people-groups-grid.js",
|
||||
"output": "doxa-maps/people-groups-grid/people-groups-grid.js",
|
||||
"integrity": "sha384-ziaGuqTpW4HvR1P76Nzu6OByBHUzs1E1C4l4m+bC0I8C7KivdbTLEFN/36ZCT/mF",
|
||||
"integrity": "sha384-LMab8CAeKjHRnOkUn3HsykTYv1v/4wnF1av7AzqvbsjmakArA2dGd8ZeliFMkxzs",
|
||||
"elements": [
|
||||
"people-groups-grid"
|
||||
],
|
||||
|
|
@ -737,7 +737,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-ziaGuqTpW4HvR1P76Nzu6OByBHUzs1E1C4l4m+bC0I8C7KivdbTLEFN/36ZCT/mF\" 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-LMab8CAeKjHRnOkUn3HsykTYv1v/4wnF1av7AzqvbsjmakArA2dGd8ZeliFMkxzs\" 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": [
|
||||
{
|
||||
|
|
@ -863,7 +863,7 @@
|
|||
"bundle": "prayer-walk-planner",
|
||||
"scriptUrl": "./prayer-walk-planner/prayer-walk-planner.js",
|
||||
"output": "doxa-maps/prayer-walk-planner/prayer-walk-planner.js",
|
||||
"integrity": "sha384-C6y6vsEGwkvIVSs0s8SKEh1LeEuEOs0KRPg/grFh7BMo9n4BDTJDq7/Sfy/V/VqY",
|
||||
"integrity": "sha384-rI097ecm/idym9j/Od/aOtgqhk0kB/1CwMpal9jTLm/ZyNKdlo6eYRMwV+J0rJyV",
|
||||
"elements": [
|
||||
"prayer-walk-planner"
|
||||
],
|
||||
|
|
@ -879,7 +879,7 @@
|
|||
"scriptUrl": "./prayer-walk-planner/prayer-walk-planner.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./prayer-walk-planner/prayer-walk-planner/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./prayer-walk-planner/prayer-walk-planner.js\" integrity=\"sha384-C6y6vsEGwkvIVSs0s8SKEh1LeEuEOs0KRPg/grFh7BMo9n4BDTJDq7/Sfy/V/VqY\" crossorigin=\"anonymous\"></script>\n<prayer-walk-planner profile-config='{\"profile\":\"prayer-walk-planner\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></prayer-walk-planner>"
|
||||
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./prayer-walk-planner/prayer-walk-planner.js\" integrity=\"sha384-rI097ecm/idym9j/Od/aOtgqhk0kB/1CwMpal9jTLm/ZyNKdlo6eYRMwV+J0rJyV\" crossorigin=\"anonymous\"></script>\n<prayer-walk-planner profile-config='{\"profile\":\"prayer-walk-planner\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></prayer-walk-planner>"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -971,7 +971,7 @@
|
|||
"bundle": "research-map-plus-grid",
|
||||
"scriptUrl": "./research-map-plus-grid/research-map-plus-grid.js",
|
||||
"output": "doxa-maps/research-map-plus-grid/research-map-plus-grid.js",
|
||||
"integrity": "sha384-hETnSGVenHXQXaI+EUClxIoaB0xyA9jHa3AaZE6grq4UHxTuX0QNZj93mAUi3H7b",
|
||||
"integrity": "sha384-t6tBUjmfjY8cMNakP20wiAraSH/bCaDW/WjXB+nm1dptLXjbTg4aqvKaXeYFjNvD",
|
||||
"elements": [
|
||||
"research-map-plus-grid"
|
||||
],
|
||||
|
|
@ -991,7 +991,7 @@
|
|||
"scriptUrl": "./research-map-plus-grid/research-map-plus-grid.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./research-map-plus-grid/research-map-plus-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=\"./research-map-plus-grid/research-map-plus-grid.js\" integrity=\"sha384-hETnSGVenHXQXaI+EUClxIoaB0xyA9jHa3AaZE6grq4UHxTuX0QNZj93mAUi3H7b\" crossorigin=\"anonymous\"></script>\n<research-map-plus-grid profile-config='{\"profile\":\"research-map-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-map-plus-grid>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-map-plus-grid/research-map-plus-grid.js\" integrity=\"sha384-t6tBUjmfjY8cMNakP20wiAraSH/bCaDW/WjXB+nm1dptLXjbTg4aqvKaXeYFjNvD\" crossorigin=\"anonymous\"></script>\n<research-map-plus-grid profile-config='{\"profile\":\"research-map-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-map-plus-grid>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -1152,6 +1152,32 @@
|
|||
"bundle": "upg-neighbor"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"bundle": "wall-of-people-groups",
|
||||
"scriptUrl": "./wall-of-people-groups/wall-of-people-groups.js",
|
||||
"output": "doxa-maps/wall-of-people-groups/wall-of-people-groups.js",
|
||||
"integrity": "sha384-YPFNNohoobgb+Dvpie0KY661J5Y6ggXDDYmAeGt2ZqEwzvon8gKh3po0I/RPxAZR",
|
||||
"elements": [
|
||||
"wall-of-people-groups"
|
||||
],
|
||||
"profiles": [
|
||||
{
|
||||
"profile": "wall-of-people-groups",
|
||||
"file": "wall-of-people-groups.vue",
|
||||
"description": "Every Doxa people group as a card on one zoomable prayer wall",
|
||||
"element": "wall-of-people-groups",
|
||||
"type": "monolithic",
|
||||
"isMap": false,
|
||||
"iframeUrl": "./wall-of-people-groups/wall-of-people-groups/index.html",
|
||||
"scriptUrl": "./wall-of-people-groups/wall-of-people-groups.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./wall-of-people-groups/wall-of-people-groups/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=\"./wall-of-people-groups/wall-of-people-groups.js\" integrity=\"sha384-YPFNNohoobgb+Dvpie0KY661J5Y6ggXDDYmAeGt2ZqEwzvon8gKh3po0I/RPxAZR\" crossorigin=\"anonymous\"></script>\n<wall-of-people-groups profile-config='{\"profile\":\"wall-of-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></wall-of-people-groups>"
|
||||
},
|
||||
"bundle": "wall-of-people-groups"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,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-ziaGuqTpW4HvR1P76Nzu6OByBHUzs1E1C4l4m+bC0I8C7KivdbTLEFN/36ZCT/mF" crossorigin="anonymous"></script>
|
||||
<script src="../../people-groups-grid.js" integrity="sha384-LMab8CAeKjHRnOkUn3HsykTYv1v/4wnF1av7AzqvbsjmakArA2dGd8ZeliFMkxzs" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -26,7 +26,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-ziaGuqTpW4HvR1P76Nzu6OByBHUzs1E1C4l4m+bC0I8C7KivdbTLEFN/36ZCT/mF" crossorigin="anonymous"></script>
|
||||
<script src="../people-groups-grid.js" integrity="sha384-LMab8CAeKjHRnOkUn3HsykTYv1v/4wnF1av7AzqvbsjmakArA2dGd8ZeliFMkxzs" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -26,7 +26,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-ziaGuqTpW4HvR1P76Nzu6OByBHUzs1E1C4l4m+bC0I8C7KivdbTLEFN/36ZCT/mF" crossorigin="anonymous"></script>
|
||||
<script src="../../people-groups-grid.js" integrity="sha384-LMab8CAeKjHRnOkUn3HsykTYv1v/4wnF1av7AzqvbsjmakArA2dGd8ZeliFMkxzs" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <prayer-walk-planner> custom element. -->
|
||||
<script src="../prayer-walk-planner.js" integrity="sha384-C6y6vsEGwkvIVSs0s8SKEh1LeEuEOs0KRPg/grFh7BMo9n4BDTJDq7/Sfy/V/VqY" crossorigin="anonymous"></script>
|
||||
<script src="../prayer-walk-planner.js" integrity="sha384-rI097ecm/idym9j/Od/aOtgqhk0kB/1CwMpal9jTLm/ZyNKdlo6eYRMwV+J0rJyV" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <research-map-plus-grid> custom element. -->
|
||||
<script src="../../research-map-plus-grid.js" integrity="sha384-hETnSGVenHXQXaI+EUClxIoaB0xyA9jHa3AaZE6grq4UHxTuX0QNZj93mAUi3H7b" crossorigin="anonymous"></script>
|
||||
<script src="../../research-map-plus-grid.js" integrity="sha384-t6tBUjmfjY8cMNakP20wiAraSH/bCaDW/WjXB+nm1dptLXjbTg4aqvKaXeYFjNvD" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <research-map-plus-grid> custom element. -->
|
||||
<script src="../research-map-plus-grid.js" integrity="sha384-hETnSGVenHXQXaI+EUClxIoaB0xyA9jHa3AaZE6grq4UHxTuX0QNZj93mAUi3H7b" crossorigin="anonymous"></script>
|
||||
<script src="../research-map-plus-grid.js" integrity="sha384-t6tBUjmfjY8cMNakP20wiAraSH/bCaDW/WjXB+nm1dptLXjbTg4aqvKaXeYFjNvD" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
75
public/doxa-maps/wall-of-people-groups/wall-of-people-groups.js
generated
Normal file
192
public/doxa-maps/wall-of-people-groups/wall-of-people-groups/index.html
generated
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>wall-of-people-groups · wall-of-people-groups</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
|
||||
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
|
||||
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
|
||||
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->
|
||||
<style>
|
||||
/* BARE full-page map — no chrome. The custom element IS the page, so the
|
||||
browser URL shares the map itself. */
|
||||
html, body { height: 100%; margin: 0; background: #0a0e15; }
|
||||
#slot, wall-of-people-groups { position: fixed; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
#status { position: fixed; inset: 0; z-index: 2; display: grid; place-items: center;
|
||||
padding: 24px; text-align: center; color: #9aa7b8;
|
||||
font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
#status.err { color: #f87171; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="slot"></div>
|
||||
<div id="status">loading…</div>
|
||||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <wall-of-people-groups> custom element. -->
|
||||
<script src="../wall-of-people-groups.js" integrity="sha384-YPFNNohoobgb+Dvpie0KY661J5Y6ggXDDYmAeGt2ZqEwzvon8gKh3po0I/RPxAZR" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
// 1. prop — a token hardcoded into this page's profile-config (config.tk)
|
||||
// 2. ?tk= URL param — token carried in the shared link
|
||||
// 3. baked token — opt-in literal token inlined at build time (build-config.token)
|
||||
// A token from rungs 1-3 is used DIRECTLY (no network). Otherwise a token
|
||||
// *endpoint* is resolved and fetched:
|
||||
// 4. window.MAP_TOKEN_URL override 5. configured TOKEN_SRC URL 6. same-origin /api/maps/token
|
||||
// TOKEN_SRC is treated as DATA, never eval'd: the literal "window.MAP_TOKEN_URL"
|
||||
// means "read that global"; anything else is used as a URL — so both a global
|
||||
// name and a plain URL are safe (no SyntaxError footgun).
|
||||
(function () {
|
||||
var slot = document.getElementById('slot');
|
||||
var status = document.getElementById('status');
|
||||
var BASE_CONFIG = {"profile":"wall-of-people-groups","dataSource":"pray-tools","instanceId":"wall-of-people-groups-wall-of-people-groups","assetsBase":"../../assets"};
|
||||
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
|
||||
// the ladder below is skipped entirely and the element mounts immediately.
|
||||
var TOKEN_FREE = true;
|
||||
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
|
||||
// rather than this page's profile-config. With no baked token (the public copy), it must get
|
||||
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.
|
||||
var NESTS_MAPS = false;
|
||||
var TOKEN_SRC = "window.MAP_TOKEN_URL";
|
||||
var BAKED_TOKEN = "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ";
|
||||
// Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts
|
||||
// a map component directly, with no ProfileLoader/profile-config wrapper) still finds a token —
|
||||
// useMapInstance falls back to window.MAP_TOKEN. No effect when BAKED_TOKEN is empty.
|
||||
if (BAKED_TOKEN && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = BAKED_TOKEN; }
|
||||
var DEFAULT_TOKEN_URL = '/api/maps/token';
|
||||
|
||||
function fail(msg) { if (status) { status.className = 'err'; status.textContent = '⚠ ' + msg; } }
|
||||
// No token anywhere on the ladder → ask ONCE, store locally, remount. No dead ends.
|
||||
function askToken() {
|
||||
if (!status) return;
|
||||
status.className = 'err';
|
||||
status.innerHTML = 'map needs a Mapbox token — ' +
|
||||
'<input id="tkIn" type="password" placeholder="paste pk.… (stays in this browser)" ' +
|
||||
'style="font:12px ui-monospace,monospace;padding:6px 8px;border-radius:6px;border:1px solid #223047;background:#0e1623;color:#eef2f7;width:min(320px,70vw)"> ' +
|
||||
'<button id="tkGo" style="font:700 11px ui-monospace,monospace;padding:7px 12px;border-radius:999px;border:0;cursor:pointer;background:linear-gradient(90deg,#46d4ff,#b9a6f5);color:#0a0e15">use it</button>';
|
||||
var go = document.getElementById('tkGo');
|
||||
if (go) go.addEventListener('click', function () {
|
||||
var v = (document.getElementById('tkIn').value || '').trim();
|
||||
if (!v) return;
|
||||
try { localStorage.setItem('MAPS_STAGING_TK', v); } catch (e) {}
|
||||
mount(v);
|
||||
});
|
||||
}
|
||||
/* ★ RELEASE THE LOAD BACKDROP WHEN THE ELEMENT MOUNTS (BUG-GOSPEL-MAP-BLACK-RECTANGLE).
|
||||
html/body carry background:#0a0e15 so the light #status text (#9aa7b8) is readable while the
|
||||
bundle loads. That backdrop is LOAD-STATE CHROME, but it outlived its moment: any gap the
|
||||
mounted element does not cover shows it through, because every layer above body is
|
||||
transparent. On gospel-resources/resources-map that produced a 352x126 DARK RECTANGLE between
|
||||
the legend card and the detail panel — MEASURED rgb(8,12,18), i.e. the page background, NOT an
|
||||
unpainted canvas as first supposed. Clearing it here (the one place we already know the
|
||||
element is up) is a one-shot style write — no resize, no repaint loop. Keep it a one-shot
|
||||
write: do NOT turn this into a per-frame or per-event handler (a resize loop strobes the screen). */
|
||||
function done() {
|
||||
if (status && status.parentNode) { status.parentNode.removeChild(status); }
|
||||
document.documentElement.style.background = '#f3f3f1';
|
||||
document.body.style.background = '#f3f3f1';
|
||||
}
|
||||
|
||||
function mount(token) {
|
||||
if (!token && !TOKEN_FREE) { return askToken(); }
|
||||
// Nested maps (a composite's children) read window.MAP_TOKEN, not this element's config.
|
||||
if (token && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = token; }
|
||||
try {
|
||||
var cfg = token ? Object.assign({}, BASE_CONFIG, { tk: token }) : BASE_CONFIG;
|
||||
var el = document.createElement("wall-of-people-groups");
|
||||
el.setAttribute('profile-config', JSON.stringify(cfg));
|
||||
slot.innerHTML = '';
|
||||
slot.appendChild(el);
|
||||
done();
|
||||
} catch (e) { fail(e && e.message ? e.message : String(e)); }
|
||||
}
|
||||
|
||||
function qp(name) {
|
||||
try { return new URLSearchParams(location.search).get(name) || ''; }
|
||||
catch (e) { return ''; }
|
||||
}
|
||||
|
||||
function extractToken(data) {
|
||||
if (typeof data === 'string') return data.trim();
|
||||
if (data && typeof data === 'object') return data.token || data.tk || data.accessToken || '';
|
||||
return '';
|
||||
}
|
||||
|
||||
// Rungs 1-3b: an actual token → mount directly, no fetch.
|
||||
// 2b = localStorage: the paste-once store shared with the staging pages
|
||||
// (MAPS_STAGING_TK) — paste a token ONCE anywhere in the tree, every page uses it.
|
||||
var stored = '';
|
||||
try { stored = localStorage.getItem('MAPS_STAGING_TK') || ''; } catch (e) {}
|
||||
var urlTk = qp('tk');
|
||||
if (urlTk) { try { localStorage.setItem('MAPS_STAGING_TK', urlTk); } catch (e) {} }
|
||||
var directToken = (BASE_CONFIG && BASE_CONFIG.tk) || urlTk || stored || BAKED_TOKEN;
|
||||
if (TOKEN_FREE) {
|
||||
if (directToken && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = directToken; }
|
||||
mount('');
|
||||
// A token-free composite with no token anywhere: its content renders, but its nested maps
|
||||
// cannot. Ask once; the token is stored for the whole tree and the page reloads with it.
|
||||
if (NESTS_MAPS && !directToken) {
|
||||
var bar = document.createElement('div');
|
||||
bar.id = 'status';
|
||||
bar.setAttribute('style', 'position:fixed;left:12px;right:12px;bottom:12px;z-index:2147483000;padding:10px 12px;border-radius:10px;background:#0e1623;color:#eef2f7;font:13px system-ui,sans-serif;box-shadow:0 6px 24px rgba(0,0,0,.25)');
|
||||
document.body.appendChild(bar);
|
||||
status = bar;
|
||||
askToken();
|
||||
var goBtn = document.getElementById('tkGo');
|
||||
if (goBtn) goBtn.addEventListener('click', function () {
|
||||
var v = (document.getElementById('tkIn').value || '').trim();
|
||||
if (v) { location.reload(); }
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (directToken) { return mount(directToken); }
|
||||
|
||||
// Rungs 4-6: resolve the token ENDPOINT candidates and try them IN ORDER — the first
|
||||
// one that answers with a token wins. Order: window.MAP_TOKEN_URL override → the
|
||||
// configured TOKEN_SRC (an ABSOLUTE url, so a tree hosted on a CDN away from the API
|
||||
// still finds it) → same-origin /api/maps/token (dev, or the site hosting both).
|
||||
// A single hard-coded pick used to kill the page when its one url 404'd or was
|
||||
// blocked by CORS; a shared link must work wherever the tree is hosted.
|
||||
var candidates = [];
|
||||
function addCandidate(u) { if (u && candidates.indexOf(u) === -1) candidates.push(u); }
|
||||
addCandidate(window.MAP_TOKEN_URL);
|
||||
addCandidate(TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL' ? TOKEN_SRC : '');
|
||||
addCandidate(DEFAULT_TOKEN_URL);
|
||||
|
||||
function fetchToken(i, errors) {
|
||||
if (i >= candidates.length) {
|
||||
// Every endpoint failed: say which, and still offer the paste-once box (no dead ends).
|
||||
askToken();
|
||||
if (status) {
|
||||
var d = document.createElement('div');
|
||||
d.textContent = '⚠ Token fetch failed from ' + candidates.join(', ') + ' — set window.MAP_TOKEN_URL or pass ?tk=. (' + errors.join(' | ') + ')';
|
||||
status.insertBefore(d, status.firstChild);
|
||||
}
|
||||
return;
|
||||
}
|
||||
var url = candidates[i];
|
||||
return fetch(url)
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error(url + ' → HTTP ' + r.status);
|
||||
var ct = r.headers.get('content-type') || '';
|
||||
return ct.indexOf('json') !== -1 ? r.json() : r.text();
|
||||
})
|
||||
.then(function (data) {
|
||||
var tk = extractToken(data);
|
||||
if (!tk) throw new Error(url + ' → no token in response');
|
||||
mount(tk);
|
||||
})
|
||||
.catch(function (e) {
|
||||
errors.push(e && e.message ? e.message : String(e));
|
||||
return fetchToken(i + 1, errors);
|
||||
});
|
||||
}
|
||||
fetchToken(0, []);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||