Merge development into repo/readme-desktop
This commit is contained in:
commit
8fcb784556
195 changed files with 6182 additions and 1933 deletions
|
|
@ -237,6 +237,8 @@ and a change here can break other maps.
|
|||
`import … from '@map/composables/useSharedMapToken.js'`
|
||||
- **useTextHighlight** — highlightRanges(value, indices) — wrap each matched index range in
|
||||
`import … from '@map/composables/useTextHighlight.js'`
|
||||
- **useViewAddress** — useViewAddress.js — the page's ADDRESS for the view a reader is on: which app tab, which view inside it, which
|
||||
`import … from '@map/composables/useViewAddress.js'`
|
||||
|
||||
### Utilities (pure helper functions)
|
||||
|
||||
|
|
|
|||
136
internal/check-dev-leaf-escape.mjs
Normal file
136
internal/check-dev-leaf-escape.mjs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-dev-leaf-escape.mjs — a dev leaf URL cannot write markup into the page it opens.
|
||||
*
|
||||
* THE DEFECT. The dev server's leaf route, /__leaf/<bundle>/<profile>[/<instance>] (and a production embed URL,
|
||||
* <bundle>/<profile>[/<instance>]/index.html, rewritten onto it), writes the URL's parts into the page it serves: the
|
||||
* <title> and the element's profile-config='…' attribute. It refused only `..` and `/`, so a quote or a tag in the path
|
||||
* was written raw, and a crafted link broke out of the attribute on a publicly reachable dev server.
|
||||
* internal/plugin-gallery/dev-plugin.mjs now refuses any part outside LEAF_PART (the shape every bundle folder, profile
|
||||
* id and instance id has) before either sink, and escapes the <title> as well.
|
||||
*
|
||||
* The plugin's own middleware answers each request (no server, no browser):
|
||||
* A. every leaf the dev manifest advertises (galleryModel), as /__leaf/… AND as its production embed URL → 200 with
|
||||
* its element: the rule refuses no real page;
|
||||
* B. a hostile part (a quote, a double quote, a tag, an ampersand, a space, a backtick, an encoded slash or
|
||||
* backslash, `..`, `.`, an attribute break-out) in each position, both URL forms → refused here (400) or left to
|
||||
* Vite, never a page;
|
||||
* C. escHtml leaves none of & < > " ' raw;
|
||||
* D. with the part rule set back to the old one (`..` and `/` only), a tag in the profile still reaches the <title>
|
||||
* escaped: the title has its own escaping, not only the rule in front of it.
|
||||
* Response bodies are never printed: a leaf page carries the build config's token.
|
||||
* `--self-test`: the old guard, a rule too strict for a real profile, an unescaped <title> and an escHtml that misses
|
||||
* the quote must each FAIL.
|
||||
*/
|
||||
import { readFileSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { discoverSpaRegistry } from './plugin-gallery/discover.mjs'
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||
const PLUGIN = join(HERE, 'plugin-gallery/dev-plugin.mjs')
|
||||
const SRC = readFileSync(PLUGIN, 'utf8')
|
||||
const GUARD = 'parts.some(s => !LEAF_PART.test(s))'
|
||||
const OLD_GUARD = "parts.some(s => !s || s.includes('..') || s.includes('/'))"
|
||||
const RULE = 'export const LEAF_PART = /^[A-Za-z0-9][\\w-]{0,63}$/'
|
||||
const TITLE = '${escHtml(bundle)} · ${escHtml(profile)}'
|
||||
const ESC_CLASS = `/[&<>"']/g`
|
||||
|
||||
// A copy of the plugin, altered or not, imported from beside the original so its relative imports resolve; the
|
||||
// file is removed as soon as it is loaded.
|
||||
let seq = 0
|
||||
async function load(src) {
|
||||
if (src === SRC) return import(pathToFileURL(PLUGIN).href)
|
||||
const tmp = join(HERE, 'plugin-gallery', `.tmp-check-dev-leaf-escape-${process.pid}-${++seq}.mjs`)
|
||||
writeFileSync(tmp, src)
|
||||
try { return await import(pathToFileURL(tmp).href) } finally { rmSync(tmp, { force: true }) }
|
||||
}
|
||||
|
||||
// The plugin's middleware, answering one GET: { status, body } ('next' = not answered here, Vite would).
|
||||
function client(mod) {
|
||||
let mw = null
|
||||
mod.autoSpaDevPlugin('').configureServer({ middlewares: { use: (f) => { if (!mw) mw = f } } })
|
||||
return (url) => new Promise((done) => {
|
||||
const res = { statusCode: 200, setHeader() {}, end: (b) => done({ status: res.statusCode, body: String(b ?? '') }) }
|
||||
try { mw({ url, method: 'GET', headers: {} }, res, () => done({ status: 'next', body: '' })) }
|
||||
catch (e) { done({ status: 'threw', body: String(e && e.message) }) }
|
||||
})
|
||||
}
|
||||
|
||||
// Every /__leaf/ URL the dev manifest advertises, with the tag its page must mount.
|
||||
function advertised(mod) {
|
||||
const out = []
|
||||
const walk = (o, tag) => {
|
||||
if (!o || typeof o !== 'object') return
|
||||
for (const v of Object.values(o)) {
|
||||
if (typeof v === 'string' && v.startsWith('/__leaf/')) out.push({ url: v.split(/[?#]/)[0], tag })
|
||||
else walk(v, tag)
|
||||
}
|
||||
}
|
||||
for (const b of mod.galleryModel(discoverSpaRegistry()).bundles) walk(b, (b.elements || [])[0])
|
||||
return [...new Map(out.map((x) => [x.url, x])).values()]
|
||||
}
|
||||
|
||||
const HOSTILE = ["a'b", 'a"b', 'a<b', 'a>b', 'a&b', 'a b', 'a`b', 'a/b', 'a\\b', '..', '.', "x'><img src=x onerror=alert(1)>"]
|
||||
|
||||
async function audit(src) {
|
||||
const f = []
|
||||
let mod
|
||||
try { mod = await load(src) } catch (e) { return [`load: the plugin does not import (${e.message})`] }
|
||||
const get = client(mod)
|
||||
const leaves = advertised(mod)
|
||||
if (leaves.length < 20) f.push(`A: only ${leaves.length} advertised leaves found (the manifest walk is broken)`)
|
||||
for (const { url, tag } of leaves) {
|
||||
for (const u of [url, url.replace('/__leaf/', '/') + '/index.html']) {
|
||||
const r = await get(u)
|
||||
if (r.status !== 200 || !r.body.includes(`<${tag} `)) f.push(`A: ${u} → ${r.status}${r.status === 200 ? `, no <${tag}>` : ''} (a real page refused)`)
|
||||
}
|
||||
}
|
||||
const [b0, p0] = (leaves[0] ? leaves[0].url : '/__leaf/x/x').slice('/__leaf/'.length).split('/')
|
||||
for (const h of HOSTILE) {
|
||||
const e = encodeURIComponent(h)
|
||||
for (const parts of [[e, p0], [b0, e], [b0, p0, e]]) {
|
||||
const leaf = '/__leaf/' + parts.join('/')
|
||||
const r = await get(leaf)
|
||||
if (r.status !== 400) f.push(`B: ${leaf} → ${r.status} (want 400)`)
|
||||
const embed = '/' + parts.join('/') + '/index.html'
|
||||
const r2 = await get(embed)
|
||||
if (r2.status !== 400 && r2.status !== 'next') f.push(`B: ${embed} → ${r2.status} (want 400, or left to Vite)`)
|
||||
}
|
||||
}
|
||||
const esc = mod.escHtml ? mod.escHtml(`&<>"'`) : null
|
||||
if (esc !== '&<>"'') f.push(`C: escHtml(& < > " ') = ${JSON.stringify(esc)}`)
|
||||
if (!src.includes(GUARD)) f.push('D: the leaf guard is not the LEAF_PART rule, so the title cannot be tested behind the old one')
|
||||
else {
|
||||
let loose
|
||||
try { loose = await load(src.replace(GUARD, OLD_GUARD)) } catch (e) { f.push(`D: ${e.message}`) }
|
||||
if (loose) {
|
||||
const r = await client(loose)(`/__leaf/${b0}/${encodeURIComponent('<b>x')}`)
|
||||
const title = (/<title>([^]*?)<\/title>/.exec(r.body) || [])[1]
|
||||
if (r.status !== 200 || title === undefined) f.push(`D: behind the old rule the page did not render (${r.status})`)
|
||||
else if (title.includes('<b>') || !title.includes('<b>')) f.push('D: behind the old rule a tag in the profile reached the <title> raw')
|
||||
}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
if (process.argv.includes('--self-test')) {
|
||||
const poisons = [
|
||||
['the old guard (`..` and `/` only)', SRC.replace(GUARD, OLD_GUARD), /^B: /],
|
||||
['a rule too strict for a real profile', SRC.replace(RULE, 'export const LEAF_PART = /^[a-z]{1,63}$/'), /^A: /],
|
||||
['an unescaped <title>', SRC.replace(TITLE, '${bundle} · ${profile}'), /^D: /],
|
||||
['an escHtml that misses the quote', SRC.replace(ESC_CLASS, `/[&<>"]/g`), /^C: /],
|
||||
]
|
||||
let missed = 0
|
||||
for (const [name, src, rx] of poisons) {
|
||||
if (src === SRC) { console.log(`✗ MISSED: ${name} (not planted: its source line was not found)`); missed++; continue }
|
||||
const f = await audit(src); const hit = f.find((x) => rx.test(x))
|
||||
console.log(hit ? `✓ caught: ${name} -> ${hit}` : `✗ MISSED: ${name}${f.length ? ` (failed instead: ${f[0]})` : ''}`)
|
||||
if (!hit) missed++
|
||||
}
|
||||
if (missed) process.exitCode = 1
|
||||
} else {
|
||||
const f = await audit(SRC)
|
||||
if (f.length) { for (const x of f.slice(0, 40)) console.log('✗ ' + x); console.log(`FAIL check:dev-leaf-escape (${f.length})`); process.exitCode = 1 }
|
||||
else console.log(`PASS check:dev-leaf-escape — every advertised dev leaf is served in both URL forms; ${HOSTILE.length} hostile parts are refused in every position of both forms; the <title> escapes on its own`)
|
||||
}
|
||||
|
|
@ -68,6 +68,10 @@ const areas = readdirSync(join(ROOT, 'en')).filter((f) => f.endsWith('.json'))
|
|||
// translations, add the locale here with its count and lower it as they arrive.
|
||||
// Never raise a number; never add a locale that is already complete.
|
||||
const DEBT = {
|
||||
// 2026-10-03, the Share menu's 10 new share.json keys (shareable pages P3): localization is English-only for now.
|
||||
// tStatic falls back to the English text, so these locales show English until the translations arrive;
|
||||
// lower each to 0 then, and empty the map again.
|
||||
ar: 10, de: 10, es: 10, fr: 10, hi: 10, it: 10, pt: 10, ro: 10, ru: 10, zh: 10,
|
||||
}
|
||||
|
||||
const fails = []
|
||||
|
|
|
|||
|
|
@ -39,7 +39,19 @@ const m = code.match(/host\.value\s*=\s*([^\n]+)/)
|
|||
if (!m) {
|
||||
fails.push('the area resolution moved — this gate cannot find `host.value = …` and would pass blind.')
|
||||
} else {
|
||||
const expr = m[1]
|
||||
let expr = m[1]
|
||||
// The resolution may live in a named function (resolveHost(), shareable pages P3: a standalone Share button
|
||||
// re-resolves at open time). Then judge THAT function's body, and fail if it cannot be found.
|
||||
const fn = expr.match(/^\s*(\w+)\(\)/)
|
||||
if (fn) {
|
||||
const body = code.match(new RegExp('function\\s+' + fn[1] + '\\s*\\(\\)\\s*\\{([\\s\\S]*?)\\n\\}'))
|
||||
// every return that resolves a named area must carry its structural fallback on the SAME expression
|
||||
// a same-line comment is not a fallback: strip `//…` before testing each return
|
||||
const rets = body ? [...body[1].matchAll(/return\s+([^\n]*closest\([^\n]*)/g)].map((r) => r[1].replace(/\s*\/\/.*$/, '')) : []
|
||||
expr = rets.length && rets.every((r) => /offsetParent|parentElement|document\.body/.test(r)) ? rets.join(' ;; ') : (rets.find((r) => !/offsetParent|parentElement|document\.body/.test(r)) || '')
|
||||
if (!body) fails.push(`the area resolution calls ${fn[1]}(), and this gate cannot find that function to read it.`)
|
||||
else if (!rets.length) fails.push(`${fn[1]}() returns no named-area resolution, so this gate would pass blind.`)
|
||||
}
|
||||
if (!/offsetParent|parentElement|document\.body/.test(expr)) {
|
||||
fails.push('MapControlMenu resolves its anchor from a class allowlist with no structural fallback:\n'
|
||||
+ ` ${expr.trim()}\n`
|
||||
|
|
|
|||
237
internal/check-view-address.mjs
Normal file
237
internal/check-view-address.mjs
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
#!/usr/bin/env node
|
||||
// check:view-address — the page's address (library/composables/useViewAddress.js) and its ONE history write.
|
||||
// A. the pure helpers: the id whitelist + 64 cap; an unknown or malformed value reads as absent (the default opens);
|
||||
// the level rule (a tab change drops pane and place, a pane change drops place, unless the change sets them);
|
||||
// foreign params keep their exact text.
|
||||
// B. the writes, on a simulated browser history: an 'app' owner pushes once and keeps history.state with `current`
|
||||
// and the hash; an unchanged address writes nothing; a partner page (no owner) writes nothing; a 'host' owner
|
||||
// writes nothing and hands the host one dxm-address event; nested elements announce dxm-view; Back/Forward is
|
||||
// only read, never written.
|
||||
// C. COEXISTENCE with the filters (useShareableView, which writes through the same commitUrl): a filter write keeps
|
||||
// tab/pane/place, the hash or the query it does not own, and the router's history.state; a tab/pane/place write
|
||||
// keeps the filters (query AND hash form); both survive Back and Forward, and re-applying the old tab after Back
|
||||
// writes nothing, so Forward still exists.
|
||||
// D. THE CENSUS of history writers, enumerated from the tree: every history.pushState/replaceState call in library/
|
||||
// and plugin-designs/ is the helper's or a listed writer. A new one fails until it is listed here with its reason.
|
||||
// --self-test: each poison below must FAIL its arm. Pure node: no Vite, no build, no browser.
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
// The history writers that exist today besides the helper, by call count. Adding one means a row here with a reason.
|
||||
const KNOWN = {
|
||||
'library/composables/useViewAddress.js': [1, 'THE write (commitUrl)'],
|
||||
'library/utils/languageRequest.js': [1, '?lang= on a generated page only (MAP_OWNS_PAGE); keeps state and every other param'],
|
||||
'library/layouts/app-wrapper-material/index.html': [1, "the layout page's own #/<map> hash; a fragment-only URL keeps the query"],
|
||||
'plugin-designs/gospel-internet/src/useBoard.js': [1, 'its own keys on its own leaves (writeUrl); keeps state, foreign params and the hash'],
|
||||
'plugin-designs/gospel-resources/data-tables.vue': [1, "clearFilter: replaceState({}, '', pathname) wipes the whole query and hash; reported to its owner"],
|
||||
'plugin-designs/regions-dashboard/region-dashboards.vue': [2, '#<slug> on its own leaf (URL_SYNC); P2 moves it to `place`'],
|
||||
}
|
||||
// SOURCE files only: gates and node scripts may simulate history. A write is `.pushState(` / `.replaceState(` (an aliased
|
||||
// history included) or a computed `history[… 'pushState' …]`.
|
||||
const WRITE = /\.(?:push|replace)State\s*\(|\[[^\]\n]*['"](?:push|replace)State['"]/g
|
||||
function census() {
|
||||
const out = execFileSync('git', ['grep', '--untracked', '-cE', '(push|replace)State', '--', 'library', 'plugin-designs',
|
||||
':!*.md', ':!*.mjs', ':!**/scripts/**', ':!**/test/**', ':!**/*.test.*', ':!**/*.spec.*'], { cwd: ROOT, encoding: 'utf8' }).trim()
|
||||
const counts = {}
|
||||
for (const line of out.split('\n').filter(Boolean)) {
|
||||
const i = line.lastIndexOf(':')
|
||||
const file = line.slice(0, i)
|
||||
// -c counts LINES; count the calls themselves (a ternary writes twice on one line).
|
||||
const n = (readFileSync(join(ROOT, file), 'utf8').match(WRITE) || []).length
|
||||
if (n) counts[file] = n
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
// ── a browser's history, simulated: entries + index, popstate on back/forward, write counters ──────────────────────
|
||||
function browser(href, state = null) {
|
||||
const entries = [{ url: new URL(href), state }]
|
||||
let i = 0
|
||||
const writes = { push: 0, replace: 0 }
|
||||
const listeners = new Set()
|
||||
const loc = {
|
||||
get href() { return entries[i].url.href },
|
||||
get pathname() { return entries[i].url.pathname },
|
||||
get search() { return entries[i].url.search },
|
||||
get hash() { return entries[i].url.hash },
|
||||
}
|
||||
const copy = (s) => (s == null ? s : JSON.parse(JSON.stringify(s)))
|
||||
const hist = {
|
||||
get state() { return entries[i].state },
|
||||
get length() { return entries.length },
|
||||
pushState(s, _, url) { writes.push++; entries.splice(i + 1); entries.push({ url: new URL(url, entries[i].url), state: copy(s) }); i++ },
|
||||
replaceState(s, _, url) { writes.replace++; entries[i] = { url: new URL(url, entries[i].url), state: copy(s) } },
|
||||
back() { if (i > 0) { i--; listeners.forEach((h) => h()) } },
|
||||
forward() { if (i < entries.length - 1) { i++; listeners.forEach((h) => h()) } },
|
||||
}
|
||||
globalThis.location = loc; globalThis.history = hist
|
||||
globalThis.addEventListener = (t, h) => t === 'popstate' && listeners.add(h)
|
||||
globalThis.removeEventListener = (t, h) => t === 'popstate' && listeners.delete(h)
|
||||
const at = () => loc.pathname + loc.search + loc.hash
|
||||
const q = () => new URLSearchParams(loc.search)
|
||||
return { loc, hist, writes, at, q, listeners }
|
||||
}
|
||||
const unset = () => { for (const k of ['location', 'history', 'addEventListener', 'removeEventListener']) delete globalThis[k] }
|
||||
|
||||
async function arms({ va, sv, counts }) {
|
||||
const fails = []
|
||||
const eq = (tag, got, want) => { if (JSON.stringify(got) !== JSON.stringify(want)) fails.push(`${tag}: got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`) }
|
||||
try {
|
||||
// ── A. pure helpers ──
|
||||
eq('A: a plain id is kept', va.cleanKey('regions-dashboard'), 'regions-dashboard')
|
||||
eq('A: a 64-char id is kept', va.cleanKey('a'.repeat(64)), 'a'.repeat(64))
|
||||
for (const bad of ['a'.repeat(65), '<b>', 'a b', '-lead', '../x', 'x?y', 'x#y', '']) {
|
||||
if (va.cleanKey(bad) !== '') fails.push(`A: the whitelist lets ${JSON.stringify(bad.slice(0, 20))} through`)
|
||||
}
|
||||
eq('A: an unknown or malformed value reads as absent', va.readViewKeys('?tab=a%20b&pane=%E0%A4%A&place=angola&tab=second'), { tab: '', pane: '', place: 'angola' })
|
||||
eq('A: a tab change drops pane and place', va.viewSearch('?tab=a&pane=b&place=c&lang=ar', { tab: 'd' }), '?tab=d&lang=ar')
|
||||
eq('A: a pane change drops place, keeps tab', va.viewSearch('?tab=a&pane=b&place=c', { pane: 'e' }), '?tab=a&pane=e')
|
||||
eq('A: a place change keeps tab and pane', va.viewSearch('?tab=a&pane=b&place=c', { place: 'f' }), '?tab=a&pane=b&place=f')
|
||||
eq('A: a change that sets the deeper level keeps it', va.viewSearch('?tab=a&pane=b&place=c', { tab: 'd', pane: 'p' }), '?tab=d&pane=p')
|
||||
eq('A: the same tab changes nothing', va.viewSearch('?tab=a&pane=b&place=c', { tab: 'a' }), '?tab=a&pane=b&place=c')
|
||||
eq('A: foreign params keep their exact text', va.viewSearch('?lang=ar&access=yes%7Cpartial&q=rice+%26+beans&tab=a', { tab: 'b' }), '?tab=b&lang=ar&access=yes%7Cpartial&q=rice+%26+beans')
|
||||
eq('A: removing the last key leaves no "?"', va.viewSearch('?tab=a', { tab: '' }), '')
|
||||
eq('A: urlOwner', ['app', 'host', 'App', true, undefined, 'partner'].map((o) => va.urlOwnerOf({ urlOwner: o })), ['app', 'host', '', '', '', ''])
|
||||
|
||||
// ── B. writes ──
|
||||
{
|
||||
const b = browser('https://x.test/deep-research?lang=ar#top', { back: '/', current: '/deep-research?lang=ar#top', position: 3 })
|
||||
const r = va.writeViewKeys({ tab: 'regions' }, { owner: 'app', push: true })
|
||||
if (r !== 'written' || b.writes.push !== 1) fails.push(`B: an app owner's tab change is not ONE push (${r}, ${b.writes.push} pushes)`)
|
||||
if (b.at() !== '/deep-research?tab=regions&lang=ar#top') fails.push(`B: the write lost the hash or a foreign param: ${b.at()}`)
|
||||
const s = b.hist.state || {}
|
||||
if (s.position !== 3 || s.back !== '/') fails.push(`B: history.state was not kept: ${JSON.stringify(s)}`)
|
||||
if (s.current !== b.at()) fails.push(`B: history.state.current is not the new URL (${s.current}): a Vue Router host loses the link`)
|
||||
const r2 = va.writeViewKeys({ tab: 'regions' }, { owner: 'app', push: true })
|
||||
if (r2 !== 'unchanged' || b.writes.push + b.writes.replace !== 1) fails.push(`B: an unchanged address wrote (${r2})`)
|
||||
if (va.commitUrl({}) !== false || b.writes.push + b.writes.replace !== 1) fails.push('B: commitUrl wrote an identical URL')
|
||||
}
|
||||
{
|
||||
const b = browser('https://partner.test/page?tab=theirs')
|
||||
for (const owner of ['', undefined, 'partner']) va.writeViewKeys({ tab: 'regions' }, { owner, push: true })
|
||||
if (b.writes.push + b.writes.replace) fails.push(`B: a partner page (no owner) was written ${b.writes.push + b.writes.replace}×`)
|
||||
}
|
||||
{
|
||||
const b = browser('https://dx.test/deep-research?lang=ar')
|
||||
const el = new EventTarget(); const got = []
|
||||
el.addEventListener(va.ADDRESS_EVENT, (e) => got.push(e))
|
||||
const r = va.writeViewKeys({ tab: 'posters' }, { owner: 'host', push: true, el })
|
||||
if (b.writes.push + b.writes.replace) fails.push("B: a 'host' owner wrote history itself (the host's router must)")
|
||||
if (r !== 'announced' || got.length !== 1) fails.push(`B: a 'host' owner did not hand the host ONE ${va.ADDRESS_EVENT} (${r}, ${got.length})`)
|
||||
else {
|
||||
const e = got[0]
|
||||
if (!e.bubbles || !e.composed) fails.push(`B: ${va.ADDRESS_EVENT} cannot leave a shadow root (bubbles ${e.bubbles}, composed ${e.composed})`)
|
||||
if (e.detail?.keys?.tab !== 'posters' || e.detail?.push !== true || e.detail?.href !== '/deep-research?tab=posters&lang=ar') fails.push(`B: ${va.ADDRESS_EVENT} detail is wrong: ${JSON.stringify(e.detail)}`)
|
||||
}
|
||||
// the host has not navigated yet; once it has, the same choice again must not ask it to navigate twice
|
||||
b.hist.replaceState(b.hist.state, '', '/deep-research?tab=posters&lang=ar'); b.writes.replace--
|
||||
const r2 = va.writeViewKeys({ tab: 'posters' }, { owner: 'host', push: true, el })
|
||||
if (r2 !== 'unchanged' || got.length !== 1) fails.push(`B: a 'host' owner announced an unchanged address (${r2}, ${got.length} events)`)
|
||||
}
|
||||
{
|
||||
browser('https://x.test/p')
|
||||
const el = new EventTarget(); const got = []
|
||||
el.addEventListener(va.VIEW_EVENT, (e) => got.push(e))
|
||||
va.announceView(el, 'pane', 'dashboard')
|
||||
if (va.announceView(el, 'filters', 'x') !== false) fails.push('B: announceView accepts a key that is not tab/pane/place')
|
||||
if (got.length !== 1 || !got[0].bubbles || !got[0].composed || got[0].detail?.key !== 'pane' || got[0].detail?.value !== 'dashboard') fails.push(`B: a nested element's ${va.VIEW_EVENT} is wrong or cannot leave its shadow root`)
|
||||
}
|
||||
|
||||
// ── C. coexistence with the filters, and Back/Forward ──
|
||||
{
|
||||
const b = browser('https://x.test/board?tab=a&pane=b&place=c#top', { current: '/board?tab=a&pane=b&place=c#top', position: 2 })
|
||||
sv.writeFilterView({ access_level: 'yes' }, 'rice', { mode: 'query', facetKeys: { access: 'access_level' } })
|
||||
const q = b.q()
|
||||
if (q.get('tab') !== 'a' || q.get('pane') !== 'b' || q.get('place') !== 'c') fails.push(`C: a filter write (query) dropped tab/pane/place: ${b.at()}`)
|
||||
if (q.get('access') !== 'yes' || q.get('q') !== 'rice' || b.loc.hash !== '#top') fails.push(`C: a filter write (query) lost its own params or the hash: ${b.at()}`)
|
||||
if (b.hist.state?.position !== 2 || b.hist.state?.current !== b.at()) fails.push(`C: a filter write dropped the router's history.state or its current: ${JSON.stringify(b.hist.state)}`)
|
||||
if (b.writes.push) fails.push('C: a filter write pushed (filters replace)')
|
||||
}
|
||||
{
|
||||
const b = browser('https://x.test/research?tab=a&pane=b')
|
||||
sv.writeFilterView({ region: 'asia' }, '', {})
|
||||
if (b.loc.search !== '?tab=a&pane=b' || !b.loc.hash.startsWith('#f=region:asia')) fails.push(`C: a filter write (hash) dropped tab/pane/place: ${b.at()}`)
|
||||
}
|
||||
{
|
||||
const b = browser('https://x.test/board?access=yes&q=rice#f=region:asia')
|
||||
va.writeViewKeys({ tab: 'map' }, { owner: 'app', push: true })
|
||||
const q = b.q()
|
||||
if (q.get('access') !== 'yes' || q.get('q') !== 'rice' || b.loc.hash !== '#f=region:asia') fails.push(`C: a tab write dropped the filters: ${b.at()}`)
|
||||
va.writeViewKeys({ place: 'angola' }, { owner: 'app', push: true })
|
||||
if (b.q().get('access') !== 'yes' || b.loc.hash !== '#f=region:asia') fails.push(`C: a place write dropped the filters: ${b.at()}`)
|
||||
}
|
||||
{
|
||||
// A shell with the filters inside: first paint, a filter, a tab change, another filter, then Back and Forward.
|
||||
// The app re-applies the tab on popstate the way an owner does: it sets its tab, and its tab watcher writes.
|
||||
const b = browser('https://x.test/deep-research')
|
||||
const app = { tab: '' }
|
||||
const setTab = (t, push) => { app.tab = t; va.writeViewKeys({ tab: t }, { owner: 'app', push }) }
|
||||
const off = va.onAddressChange((keys) => setTab(keys.tab || 'home', true))
|
||||
setTab('a', false)
|
||||
sv.writeFilterView({ access_level: 'yes' }, '', { mode: 'query', facetKeys: { access: 'access_level' } })
|
||||
setTab('b', true)
|
||||
sv.writeFilterView({ access_level: 'no' }, '', { mode: 'query', facetKeys: { access: 'access_level' } })
|
||||
const len = b.hist.length; const w = b.writes.push + b.writes.replace
|
||||
b.hist.back()
|
||||
if (b.q().get('tab') !== 'a' || b.q().get('access') !== 'yes') fails.push(`C: after Back the entry lost its tab or its filters: ${b.at()}`)
|
||||
if (app.tab !== 'a') fails.push('C: Back did not re-apply the tab')
|
||||
if (b.writes.push + b.writes.replace !== w) fails.push('C: re-applying the tab after Back WROTE history (Forward is lost)')
|
||||
b.hist.forward()
|
||||
if (b.hist.length !== len || b.q().get('tab') !== 'b' || b.q().get('access') !== 'no') fails.push(`C: after Forward the entry lost its tab or its filters, or Forward was gone: ${b.at()} (${b.hist.length}/${len})`)
|
||||
off()
|
||||
if (b.listeners.size) fails.push('C: onAddressChange off() left its popstate listener')
|
||||
}
|
||||
} finally { unset() }
|
||||
|
||||
// ── D. the census ──
|
||||
for (const [file, n] of Object.entries(counts)) {
|
||||
if (!KNOWN[file]) fails.push(`D: a new history writer: ${file} (${n}). Route it through useViewAddress (commitUrl), or list it in KNOWN with its reason`)
|
||||
else if (KNOWN[file][0] !== n) fails.push(`D: ${file} now has ${n} history writes, the census says ${KNOWN[file][0]}`)
|
||||
}
|
||||
for (const file of Object.keys(KNOWN)) if (!counts[file]) fails.push(`D: ${file} is listed as a writer but no longer writes: drop its row`)
|
||||
return fails
|
||||
}
|
||||
|
||||
const va = await import(join(ROOT, 'library/composables/useViewAddress.js'))
|
||||
const sv = await import(join(ROOT, 'library/composables/useShareableView.js'))
|
||||
const counts = census()
|
||||
const real = { va, sv, counts }
|
||||
let code = 0
|
||||
if (process.argv.includes('--self-test')) {
|
||||
// The old filter write, as it was: its own replaceState(null, …) of the whole URL.
|
||||
const oldFilterWrite = (fs, term, o = {}) => {
|
||||
const url = o.mode === 'query' ? sv.serializeQuery(fs, term, location.search, o) + location.hash : location.search + sv.serializeView(fs, term, o)
|
||||
history.replaceState(null, '', location.pathname + url); return true
|
||||
}
|
||||
const poisons = [
|
||||
['a write that drops history.state.current', { ...real, va: { ...va, writeViewKeys: (c, o) => { const s = va.viewSearch(location.search, c); history[o.push ? 'pushState' : 'replaceState']({ ...history.state }, '', location.pathname + s + location.hash); return 'written' } } }, /^B: history\.state\.current/],
|
||||
['a write when not the owner', { ...real, va: { ...va, writeViewKeys: (c, o) => va.writeViewKeys(c, { ...o, owner: 'app' }) } }, /^B: a partner page/],
|
||||
['a tab change that keeps a stale place', { ...real, va: { ...va, viewSearch: (s, c) => { const k = { ...va.readViewKeys(s), ...Object.fromEntries(Object.entries(c).map(([a, b]) => [a, va.cleanKey(b)])) }; const f = s.replace(/^\?/, '').split('&').filter((p) => p && !va.VIEW_KEYS.includes(p.split('=')[0])); const all = [...va.VIEW_KEYS.filter((x) => k[x]).map((x) => `${x}=${k[x]}`), ...f]; return all.length ? '?' + all.join('&') : '' } } }, /^A: a tab change drops pane and place/],
|
||||
['a whitelist with no length cap', { ...real, va: { ...va, cleanKey: (v) => (/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(String(v ?? '')) ? String(v) : '') } }, /^A: the whitelist lets/],
|
||||
['a write when the address is unchanged', { ...real, va: { ...va, writeViewKeys: (c, o) => { const s = va.viewSearch(location.search, c); history[o.push ? 'pushState' : 'replaceState']({ ...(history.state || {}), current: location.pathname + s + location.hash }, '', location.pathname + s + location.hash); return 'written' } } }, /^B: an unchanged address wrote|^C: re-applying the tab after Back WROTE/],
|
||||
["a 'host' owner that writes history itself", { ...real, va: { ...va, writeViewKeys: (c, o) => va.writeViewKeys(c, { ...o, owner: o.owner === 'host' ? 'app' : o.owner }) } }, /^B: a 'host' owner wrote/],
|
||||
['a dxm-address that cannot leave a shadow root', { ...real, va: { ...va, writeViewKeys: (c, o) => { if (o.owner !== 'host') return va.writeViewKeys(c, o); const s = va.viewSearch(location.search, c); o.el.dispatchEvent(new CustomEvent(va.ADDRESS_EVENT, { bubbles: true, detail: { keys: va.readViewKeys(s), href: location.pathname + s, push: o.push } })); return 'announced' } } }, /^B: dxm-address cannot leave/],
|
||||
// the three coexistence poisons
|
||||
['a FILTER write that drops tab/pane/place (no foreign params kept)', { ...real, sv: { ...sv, writeFilterView: (fs, term, o = {}) => va.commitUrl(o.mode === 'query' ? { search: sv.serializeQuery(fs, term, '', o) } : { search: '', hash: sv.serializeView(fs, term, o) }) } }, /^C: a filter write \((query|hash)\) dropped tab\/pane\/place/],
|
||||
['the old filter write: its own replaceState(null) (the router state is wiped)', { ...real, sv: { ...sv, writeFilterView: oldFilterWrite } }, /^C: a filter write dropped the router's history\.state/],
|
||||
['a TAB write that drops the filters (foreign params and hash)', { ...real, va: { ...va, writeViewKeys: (c, o) => { if (o.owner !== 'app') return va.writeViewKeys(c, o); const k = va.readViewKeys(va.viewSearch(location.search, c)); const s = va.VIEW_KEYS.filter((x) => k[x]).map((x) => `${x}=${k[x]}`).join('&'); return va.commitUrl({ search: s ? '?' + s : '', hash: '' }, { push: o.push }) ? 'written' : 'unchanged' } } }, /^C: a (tab|place) write dropped the filters/],
|
||||
['a tab change written as a REPLACE (Back leaves the page)', { ...real, va: { ...va, writeViewKeys: (c, o) => va.writeViewKeys(c, { ...o, push: false }) } }, /^B: an app owner's tab change is not ONE push|^C: after Back/],
|
||||
['a popstate handler that writes the address', { ...real, va: { ...va, onAddressChange: (cb) => va.onAddressChange((k) => { cb(k); history.pushState(history.state, '', location.href) }) } }, /^C: re-applying the tab after Back WROTE|^C: after Forward/],
|
||||
// the census
|
||||
['a new history writer in the tree', { ...real, counts: { ...counts, 'plugin-designs/some-plugin/page.vue': 1 } }, /^D: a new history writer/],
|
||||
['useShareableView writing history itself again', { ...real, counts: { ...counts, 'library/composables/useShareableView.js': 1 } }, /^D: a new history writer: library\/composables\/useShareableView\.js/],
|
||||
]
|
||||
for (const [name, inp, rx] of poisons) {
|
||||
const f = await arms(inp); const ok = f.some((x) => rx.test(x))
|
||||
console.log(ok ? `✓ caught: ${name} -> ${f.find((x) => rx.test(x))}` : `✗ MISSED: ${name}${f.length ? ' (failed instead: ' + f[0] + ')' : ''}`)
|
||||
if (!ok) code = 1
|
||||
}
|
||||
} else {
|
||||
const f = await arms(real)
|
||||
if (f.length) { f.forEach((x) => console.log('FAIL ' + x)); code = 1 }
|
||||
else console.log(`PASS check:view-address — the id whitelist and the level rule; one push per tab change with history.state + current kept; nothing written when unchanged, on a partner page, or by a 'host' owner (one dxm-address instead); filters and tab/pane/place keep each other through writes, Back and Forward; ${Object.keys(counts).length} history writers in the tree, all accounted for`)
|
||||
}
|
||||
process.exit(code)
|
||||
|
|
@ -925,7 +925,10 @@ function mapPageHtml(opts) {
|
|||
? `\n <script>window.MAP_APP_API_URL = ${JSON.stringify(apiBaseUrl)};</script>`
|
||||
: ''
|
||||
// Bake the QA corner widget onto the host platform's embed surface (opt-out via build-config qaWidget:false).
|
||||
const pageConfig = DEST_CONFIG.qaWidget ? { ...config, qa: true } : config
|
||||
// urlOwner 'app' (library/composables/useViewAddress.js): this generated page is OURS, so its shell may write the
|
||||
// view address (?tab=…) for a reload and a sent link. Only the page's own config gets it, never an embed snippet
|
||||
// (embedCfg), so a partner's page is never written to; and a FRAMED page drops it at runtime (below).
|
||||
const pageConfig = { ...(DEST_CONFIG.qaWidget ? { ...config, qa: true } : config), urlOwner: 'app' }
|
||||
// PROVIDER-AWARE PAGE (see library/composables/useMapProvider.js): a profile or
|
||||
// instance whose config says provider:'maplibre' gets the MapLibre peer (token-
|
||||
// free, no Mapbox account) and SKIPS the token ladder entirely — the map mounts
|
||||
|
|
@ -1099,6 +1102,9 @@ ${apiScript}
|
|||
var slot = document.getElementById('slot');
|
||||
var status = document.getElementById('status');
|
||||
var BASE_CONFIG = ${JSON.stringify(pageConfig)};
|
||||
// Framed (the iframe embed on a partner's page): READ the address, never write it. A push inside a frame adds
|
||||
// entries to the partner page's own Back button (the joint session history).
|
||||
try { if (window.self !== window.top) delete BASE_CONFIG.urlOwner } catch (e) { delete BASE_CONFIG.urlOwner }
|
||||
// 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.
|
||||
// (A leaf whose engine is MapLibre at RUNTIME joins them — see PROVIDER below.)
|
||||
|
|
|
|||
|
|
@ -145,6 +145,18 @@ export function galleryModel(reg) {
|
|||
}
|
||||
}
|
||||
|
||||
// ── ONE PART OF A LEAF URL ───────────────────────────────────────────────────────────────────
|
||||
// /__leaf/<bundle>/<profile>[/<instance>] parts are written INTO the leaf page: its <title> and the
|
||||
// element's profile-config='…' attribute. Refusing only `..` and `/` let a quote or a tag through, so a
|
||||
// crafted link broke out of the attribute on a publicly reachable dev server. Every bundle folder,
|
||||
// profile id and instance id in the tree has this shape (the address keys' own pattern,
|
||||
// library/composables/useViewAddress.js), so anything else is refused before it reaches either sink;
|
||||
// it also rules out `..` and `/`. check:dev-leaf-escape proves both directions.
|
||||
export const LEAF_PART = /^[A-Za-z0-9][\w-]{0,63}$/
|
||||
|
||||
// For an HTML text node or a quoted attribute value.
|
||||
export const escHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c])
|
||||
|
||||
// ── THE PRODUCTION EMBED URL, ANSWERED BY THE DEV SERVER ─────────────────────────────────────
|
||||
// The manifest advertises, and a person copies, tree-relative embed URLs of the shape
|
||||
// `<bundle>/<profile>/index.html` and `<bundle>/<profile>/<instance>/index.html` — served in
|
||||
|
|
@ -197,6 +209,16 @@ export function leafRewrite(path, treeName = TREE_NAME) {
|
|||
return '/__leaf/' + segs.join('/')
|
||||
}
|
||||
|
||||
// urlOwner 'app' (library/composables/useViewAddress.js) for a dev page that is OURS, the rule generate-tree bakes
|
||||
// into the built leaf: its shell writes ?tab=, so a clicked tab survives a reload and a copied link opens it. A
|
||||
// FRAMED page drops it: a partner page may iframe a leaf (the iframe embed), and a history push from an iframe
|
||||
// lands in the top page's Back stack. (Plugin Studio mounts its rows directly; its panes are unowned by paneConfig.) Write the element unowned and put this classic script right after it; it runs
|
||||
// before the deferred module script upgrades the element. `<` is escaped: profile/instance come from the URL.
|
||||
export function ownIfTopScript(cfg = {}) {
|
||||
const owned = JSON.stringify(JSON.stringify({ ...cfg, urlOwner: 'app' })).replace(/</g, '\\u003c')
|
||||
return `<script>try{if(window.self===window.top)document.currentScript.previousElementSibling.setAttribute('profile-config',${owned})}catch(e){}</script>`
|
||||
}
|
||||
|
||||
export function autoSpaDevPlugin(token = '') {
|
||||
// One generator for both galleries (internal/generate-gallery.mjs), driven by the same build
|
||||
// config the built page bakes. What dev changes: the model comes from discovery, the live
|
||||
|
|
@ -337,8 +359,8 @@ export function autoSpaDevPlugin(token = '') {
|
|||
try { return decodeURIComponent(s) } catch { return '' }
|
||||
})
|
||||
const [bundle, profile, instance] = parts
|
||||
// Path traversal guard: these land in a filesystem-ish URL below.
|
||||
if (!bundle || !profile || parts.some(s => !s || s.includes('..') || s.includes('/'))) {
|
||||
// These land in a filesystem-ish URL and in the page's HTML below: LEAF_PART shapes only.
|
||||
if (!bundle || !profile || parts.some(s => !LEAF_PART.test(s))) {
|
||||
res.statusCode = 400; return res.end('bad leaf path')
|
||||
}
|
||||
let reg
|
||||
|
|
@ -365,7 +387,8 @@ export function autoSpaDevPlugin(token = '') {
|
|||
// renders the SAME view here as in production rather than the bundle's default.
|
||||
// assetsBase too, as the built leaf's BASE_CONFIG carries it ("../../assets" there; root-absolute
|
||||
// here, because a /__leaf/ URL's depth differs from the tree's and the pool is served at /assets).
|
||||
const pc = JSON.stringify(instance ? { profile, instanceId: instance, assetsBase: '/assets' } : { profile, assetsBase: '/assets' })
|
||||
const leafPc = instance ? { profile, instanceId: instance, assetsBase: '/assets' } : { profile, assetsBase: '/assets' }
|
||||
const pc = JSON.stringify(leafPc)
|
||||
// The composition tier of THIS profile, read from the same discovery the gallery uses.
|
||||
// `nested-elements-mfe` (cap-4) is the StackLayout scroll-stack tier — see the style note
|
||||
// below. An unknown profile falls through to the fixed-viewport leaf, the safe default.
|
||||
|
|
@ -376,7 +399,7 @@ export function autoSpaDevPlugin(token = '') {
|
|||
res.setHeader('Cache-Control', 'no-cache')
|
||||
return res.end([
|
||||
'<!DOCTYPE html>',
|
||||
`<html lang="en"><head><meta charset="UTF-8" /><title>${bundle} · ${profile}</title>`,
|
||||
`<html lang="en"><head><meta charset="UTF-8" /><title>${escHtml(bundle)} · ${escHtml(profile)}</title>`,
|
||||
'<meta name="viewport" content="width=device-width, initial-scale=1.0" />',
|
||||
'<link rel="icon" href="data:,">',
|
||||
leafTok ? `<script>window.MAP_TOKEN=${JSON.stringify(leafTok)};</script>` : '',
|
||||
|
|
@ -430,6 +453,7 @@ export function autoSpaDevPlugin(token = '') {
|
|||
// live 19-bundle tree. Passing the base makes a nested shell see exactly what the top
|
||||
// level sees. Elements that ignore tree-base simply drop an unknown attribute.
|
||||
`<${tag} tree-base="/__plugin-gallery" profile-config='${pc}'></${tag}>`,
|
||||
ownIfTopScript(leafPc),
|
||||
`<script type="module" src="/plugin-designs/${bundle}/index.js"></script>`,
|
||||
'</body></html>',
|
||||
].filter(Boolean).join('\n'))
|
||||
|
|
@ -499,6 +523,7 @@ export function autoSpaDevPlugin(token = '') {
|
|||
// manifest-shaped model the built tree bakes (19 bundles / 30 profiles), so pointing the
|
||||
// studio at it gives dev the same tab list production has.
|
||||
'<plugin-studio tree-base="/__plugin-gallery"></plugin-studio>',
|
||||
ownIfTopScript(),
|
||||
'<script type="module" src="/plugin-designs/plugin-studio/index.js"></script>',
|
||||
'</body></html>',
|
||||
].filter(Boolean).join('\n'))
|
||||
|
|
|
|||
|
|
@ -12,9 +12,42 @@
|
|||
* posterDesign { family, name, size, mobileFriendly, labelKey?, instance? } or an array of them: the poster
|
||||
* design(s) this profile shows, listed by Poster Design's Styles. Carried as written, ONLY when present
|
||||
* (every other manifest keeps its bytes); check:poster-design-catalogue validates it.
|
||||
* posterDesignLabels GENERATED beside it: { <design name>: { <locale>: title } } for every block with a labelKey,
|
||||
* resolved here from library/i18n/locales/<locale>/<area>.json. Poster Design's bundle ships only the
|
||||
* catalogue areas its own code names, so a plugin's own area (upgMap, …) would be missing there and the card
|
||||
* would read English; carrying the titles in the manifest works for any area, with no Poster Design rebuild.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { readFileSync, readdirSync, existsSync } from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const LOCALES = join(dirname(fileURLToPath(import.meta.url)), '..', 'library', 'i18n', 'locales')
|
||||
// A catalogue key ("area.a.b") in every locale that has it: { en: '…', ar: '…' }; {} when no locale does.
|
||||
function catalogueTitles(key) {
|
||||
const [area, ...path] = String(key || '').split('.')
|
||||
const out = {}
|
||||
if (!area || !path.length || !existsSync(LOCALES)) return out
|
||||
for (const loc of readdirSync(LOCALES)) {
|
||||
const f = join(LOCALES, loc, `${area}.json`)
|
||||
if (!existsSync(f)) continue
|
||||
try {
|
||||
let o = JSON.parse(readFileSync(f, 'utf8'))
|
||||
for (const p of path) o = o && typeof o === 'object' ? o[p] : undefined
|
||||
if (typeof o === 'string' && o.trim()) out[loc] = o
|
||||
} catch { /* a locale file that does not parse names nothing */ }
|
||||
}
|
||||
return out
|
||||
}
|
||||
function posterDesignLabels(pd) {
|
||||
const out = {}
|
||||
for (const b of Array.isArray(pd) ? pd : [pd]) {
|
||||
if (b && typeof b.name === 'string' && typeof b.labelKey === 'string') {
|
||||
const titles = catalogueTitles(b.labelKey)
|
||||
if (Object.keys(titles).length) out[b.name] = titles
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const CORNERS = ['bottom-right', 'bottom-left', 'top-right', 'top-left']
|
||||
|
||||
|
|
@ -82,7 +115,7 @@ export function profileMetaFields(dir, profileName) {
|
|||
const m = readProfileMeta(dir, profileName)
|
||||
// visibility is emitted ONLY when it is "public": absent means team, so every manifest in the
|
||||
// tree today keeps the bytes it has and nothing becomes visible by upgrading the generator.
|
||||
return { stage: m.stage, ...(m.visibility === 'public' ? { visibility: 'public' } : {}), ...(m.label ? { label: m.label } : {}), ...(m.targets ? { targets: m.targets } : {}), ...(m.heavy ? { heavy: true } : {}), ...(m.widget ? { widget: m.widget } : {}), ...(m.mobile ? { mobile: m.mobile } : {}), ...(m.posterDesign ? { posterDesign: m.posterDesign } : {}) }
|
||||
return { stage: m.stage, ...(m.visibility === 'public' ? { visibility: 'public' } : {}), ...(m.label ? { label: m.label } : {}), ...(m.targets ? { targets: m.targets } : {}), ...(m.heavy ? { heavy: true } : {}), ...(m.widget ? { widget: m.widget } : {}), ...(m.mobile ? { mobile: m.mobile } : {}), ...(m.posterDesign ? { posterDesign: m.posterDesign } : {}), ...(m.posterDesign && Object.keys(posterDesignLabels(m.posterDesign)).length ? { posterDesignLabels: posterDesignLabels(m.posterDesign) } : {}) }
|
||||
}
|
||||
|
||||
/** A bundle is production if ANY of its profiles says so. */
|
||||
|
|
|
|||
|
|
@ -14,6 +14,11 @@
|
|||
RIGHT of left-hand controls. The side is measured, so a right-to-left page that
|
||||
mirrors the toolbar mirrors the menu as well.
|
||||
• Width: as wide as fits between the toolbar and the opposite edge, capped at `width`.
|
||||
• A STANDALONE anchor (ShareButton in a page header, no map toolbar: `standalone` on the
|
||||
provided anchor) opens BELOW its button instead, toward the side with room, inside the
|
||||
VIEWPORT, at `width` (the maps' readable 340) or the viewport minus its edges, whichever is
|
||||
less. Where it lives and where it goes are resolved when it OPENS, and its coordinates are
|
||||
converted into the block that really contains it (see placeBeside).
|
||||
• One z tier for menus (--z-menu, default 3000); only one menu is open per map at a
|
||||
time; closes on Escape or on a tap on the bare map canvas (never on legend/search).
|
||||
|
||||
|
|
@ -95,23 +100,17 @@ const AREA = '.dsm-map-area, .rm-map-area, .dm-map-area, [data-map-area]'
|
|||
function place() {
|
||||
const tb = toolbar?.el?.value, area = host.value
|
||||
if (!tb || !area) { style.value = {}; return }
|
||||
if (toolbar.standalone) return placeBeside(tb)
|
||||
const ar = area.getBoundingClientRect(), tr = tb.getBoundingClientRect()
|
||||
const onRight = (tr.left + tr.right) / 2 > (ar.left + ar.right) / 2
|
||||
const gap = 8, edge = 12
|
||||
const offset = onRight ? (ar.right - tr.left + gap) : (tr.right - ar.left + gap)
|
||||
const width = Math.max(200, Math.min(props.width, ar.width - offset - edge))
|
||||
const top = Math.max(0, tr.top - ar.top)
|
||||
// "Fit inside the map" is the rule ONLY for a real map area. When ShareButton mounted in a
|
||||
// page HEADER, its fallback self-tagged that header band as the area (data-map-area=
|
||||
// "share-fallback", see ShareButton.bindAnchor) -- and this line then dutifully clamped the
|
||||
// menu INSIDE a ~56px band: measured on upg-100-mfe, a 291px share menu rendered with a 69px
|
||||
// scrolling body (BUG-SHARE-MENU-HAS-A-SCROLLBAR-SHOULD-BE-FULLY-EXPANDED). A menu must never
|
||||
// outgrow a MAP's walls, but a header band is not a wall -- nothing clips below it -- so the
|
||||
// fallback area bounds against the VIEWPORT instead and the menu opens at natural height.
|
||||
const isFallbackArea = area.getAttribute && area.getAttribute('data-map-area') === 'share-fallback'
|
||||
let maxHeight = isFallbackArea
|
||||
? Math.max(120, (window.innerHeight || 900) - tr.top - edge) // header host: the page below is available
|
||||
: Math.max(120, ar.height - top - edge) // map host: fit inside the map
|
||||
// A menu never outgrows the MAP's walls. (A header button is not placed here: placeBeside bounds it by the
|
||||
// viewport, so it opens at natural height; measured on upg-100-mfe, a header band as the wall gave a 291px
|
||||
// menu a 69px scrolling body, BUG-SHARE-MENU-HAS-A-SCROLLBAR-SHOULD-BE-FULLY-EXPANDED.)
|
||||
let maxHeight = Math.max(120, ar.height - top - edge)
|
||||
if (props.matchToolbar) maxHeight = Math.max(120, Math.min(maxHeight, tr.height))
|
||||
if (props.maxHeight) maxHeight = Math.min(maxHeight, props.maxHeight)
|
||||
style.value = {
|
||||
|
|
@ -123,6 +122,45 @@ function place() {
|
|||
}
|
||||
}
|
||||
|
||||
// ★ BESIDE ITS BUTTON, INSIDE THE VIEWPORT (a standalone anchor: a Share button in a page header). Below the
|
||||
// button, toward the side with room (a button right of centre opens leftward, its end edges aligned; RTL headers
|
||||
// mirror with no special case, because the side is measured), at the readable width the maps use, clamped inside
|
||||
// the viewport, as tall as its content up to the viewport's bottom edge.
|
||||
// ★ THE BLOCK THAT CONTAINS IT IS MEASURED, NOT ASSUMED. The menu is position:absolute, so its coordinates are
|
||||
// relative to its containing block, which the HOST's markup decides: an unpositioned wrapper hands it to the
|
||||
// header above. Measuring against one element and positioning against another is what sent the header menu far
|
||||
// to the left (the UPG-100 list page, 2026-10-03). So the target point is computed in viewport coordinates and converted into
|
||||
// the containing block's own (its padding edge, scrolled), whatever element that turns out to be.
|
||||
function placeBeside(tb) {
|
||||
const vw = document.documentElement.clientWidth || window.innerWidth || 1024
|
||||
const vh = window.innerHeight || 800
|
||||
const r = tb.getBoundingClientRect(), gap = 8, edge = 12
|
||||
const width = Math.min(props.width, Math.max(160, vw - 2 * edge))
|
||||
const onRight = (r.left + r.right) / 2 > vw / 2
|
||||
const x = Math.max(edge, Math.min(onRight ? r.right - width : r.left, vw - edge - width))
|
||||
const y = r.bottom + gap
|
||||
let maxHeight = Math.max(120, vh - y - edge)
|
||||
if (props.maxHeight) maxHeight = Math.min(maxHeight, props.maxHeight)
|
||||
const o = blockOrigin(el.value)
|
||||
style.value = {
|
||||
top: `${Math.round(y - o.y)}px`,
|
||||
left: `${Math.round(x - o.x)}px`,
|
||||
width: `${Math.round(width)}px`,
|
||||
maxHeight: `${Math.round(maxHeight)}px`,
|
||||
...(props.height ? { height: `${Math.round(Math.min(props.height, maxHeight))}px` } : {}),
|
||||
}
|
||||
}
|
||||
// The viewport position of the origin an absolutely positioned element is placed from: its offsetParent's padding
|
||||
// edge, scrolled. A static body (or none) means the initial containing block: the document origin.
|
||||
function blockOrigin(m) {
|
||||
const cb = m && m.offsetParent
|
||||
if (!cb || ((cb === document.body || cb === document.documentElement) && getComputedStyle(cb).position === 'static')) {
|
||||
return { x: -(window.scrollX || 0), y: -(window.scrollY || 0) }
|
||||
}
|
||||
const b = cb.getBoundingClientRect()
|
||||
return { x: b.left + cb.clientLeft - cb.scrollLeft, y: b.top + cb.clientTop - cb.scrollTop }
|
||||
}
|
||||
|
||||
// Only one menu open per map: opening this one closes any other.
|
||||
const OPEN_EVENT = 'doxa-map-menu-open'
|
||||
function onOtherOpened(e) { if (props.open && e.detail !== el.value) emit('close') }
|
||||
|
|
@ -139,6 +177,7 @@ const announceClose = () => { try { host.value?.dispatchEvent(new CustomEvent(PA
|
|||
|
||||
watch(() => props.open, async (open, was) => {
|
||||
if (!open) { if (was) announceClose(); return }
|
||||
if (toolbar?.standalone) host.value = resolveHost() // layout is known now: where the menu lives
|
||||
await nextTick()
|
||||
place()
|
||||
try { host.value?.dispatchEvent(new CustomEvent(OPEN_EVENT, { detail: el.value })) } catch (_) {}
|
||||
|
|
@ -156,15 +195,27 @@ function onClickOutside(e) {
|
|||
if (path.some(n => n?.classList?.contains?.('mapboxgl-canvas') || n?.classList?.contains?.('maplibregl-canvas'))) emit('close')
|
||||
}
|
||||
|
||||
// Where the menu lives (the Teleport target). A map control: the map area, else the toolbar's offset parent. A
|
||||
// standalone button: a named area if its host declares one, else its offset parent, kept in the button's own
|
||||
// (shadow) root so the menu keeps this root's styles; else the root's top element.
|
||||
function resolveHost() {
|
||||
const tbEl = toolbar?.el?.value || null
|
||||
if (!tbEl) return null
|
||||
if (!toolbar.standalone) return tbEl.closest(AREA) || tbEl.offsetParent || tbEl.parentElement || null
|
||||
const root = tbEl.getRootNode(), mine = (n) => n && n.getRootNode() === root ? n : null
|
||||
let top = tbEl; while (top.parentElement) top = top.parentElement
|
||||
return mine(tbEl.closest(AREA)) || mine(tbEl.offsetParent) || (root === document ? document.body : top)
|
||||
}
|
||||
// The "one menu at a time" listener follows the host, which a standalone button re-resolves on every open.
|
||||
watch(host, (h, was) => { was?.removeEventListener(OPEN_EVENT, onOtherOpened); h?.addEventListener(OPEN_EVENT, onOtherOpened) })
|
||||
|
||||
onMounted(() => {
|
||||
// ★ NEVER null. A named area is still preferred -- it is the MAP's walls, and a menu must not outgrow
|
||||
// them -- but when a plugin has not tagged one, the toolbar's own offsetParent is the containing block
|
||||
// this menu is absolutely positioned against, so placing against it is geometrically correct by
|
||||
// definition rather than by convention. A plugin that adopts a class or data-map-area later simply
|
||||
// gets the better anchor; nothing has to be registered anywhere for the menu to be on screen.
|
||||
const tbEl = toolbar?.el?.value || null
|
||||
host.value = tbEl?.closest(AREA) || tbEl?.offsetParent || tbEl?.parentElement || null
|
||||
host.value?.addEventListener(OPEN_EVENT, onOtherOpened)
|
||||
host.value = resolveHost()
|
||||
window.addEventListener('resize', onResize)
|
||||
document.addEventListener('click', onClickOutside, true)
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
|
|
|
|||
|
|
@ -2,10 +2,13 @@
|
|||
ShareButton.vue — Non-technical share modal for the map toolbar.
|
||||
|
||||
Redesigned to match consumer-grade UX from YouTube, Spotify, Canva, and
|
||||
Google Maps. Two tabs:
|
||||
Google Maps. Two tabs, share FIRST (shareable pages P3):
|
||||
|
||||
1. "Share Link" — clean URL display + one-click copy + social share icons
|
||||
2. "Add to Your Website" — friendly embed guidance with size presets
|
||||
1. "Share this page" — the link to exactly what the reader is looking at (the page's own
|
||||
address, with its tab, where the page owns its address) + one-click copy. It is what a
|
||||
person who presses Share expects: something to send to someone.
|
||||
2. "Embed" — for putting the map on ANOTHER website: what embedding is and why it helps, the
|
||||
multiplication steps, then the code with size presets.
|
||||
|
||||
The word "iframe" never appears in any user-visible text. The embed tab
|
||||
leads with a plain-English description, then shows the code below.
|
||||
|
|
@ -31,6 +34,7 @@ const T = (key, en) => tStatic(key) || en
|
|||
import { useI18n } from 'vue-i18n'
|
||||
import { useShadowStyles } from '../../composables/useShadowStyles.js'
|
||||
import { resolveShareUrl } from '../../utils/embedBase.js'
|
||||
import { urlOwnerOf } from '../../composables/useViewAddress.js'
|
||||
import MapControlButton from './MapControlButton.vue'
|
||||
import MapControlMenu from './MapControlMenu.vue'
|
||||
|
||||
|
|
@ -51,7 +55,7 @@ useShadowStyles(`
|
|||
/* The ring itself rotates, so the colours travel around the button in every browser. */
|
||||
@keyframes share-ring-turn{to{transform:rotate(360deg);}}
|
||||
@media(prefers-reduced-motion:reduce){.share-wrap::before{animation:none;}}
|
||||
.share-steps{margin:10px 14px 6px;padding:0 0 0 15px;list-style:'- ';font:14px/1.55 system-ui,sans-serif;color:#c9d1d9;}
|
||||
.share-steps{margin:0 0 12px;padding:0 0 0 15px;list-style:'- ';font:14px/1.55 system-ui,sans-serif;color:#c9d1d9;}
|
||||
.share-steps li{margin:0 0 3px;padding-inline-start:3px;}
|
||||
.share-steps li:last-child{font-weight:600;color:#92b195;}
|
||||
.share-pop.light .share-steps{color:#3b463d;}
|
||||
|
|
@ -348,7 +352,8 @@ const rootEl = ref(null)
|
|||
const isOpen = ref(false)
|
||||
const popoverEl = ref(null)
|
||||
const btnEl = ref(null)
|
||||
const activeTab = ref('link') // 'link' | 'embed'
|
||||
const activeTab = ref('page') // 'page' | 'embed'
|
||||
const pageUrl = ref('') // "Share this page": read when the menu opens (see pageLink)
|
||||
const linkCopied = ref(false)
|
||||
const embedCopied = ref(false)
|
||||
const embedSize = ref('medium') // 'small' | 'medium' | 'large'
|
||||
|
|
@ -359,6 +364,9 @@ const sizePresets = {
|
|||
large: { label: 'Large', height: 800 }
|
||||
}
|
||||
|
||||
// The size names, each key written out so the catalogue gate sees it.
|
||||
const sizeLabel = (k) => ({ small: T('share.size_small', 'Small'), medium: T('share.size_medium', 'Medium'), large: T('share.size_large', 'Large') })[k] || k
|
||||
|
||||
/* ---- computed ---- */
|
||||
/* The standalone embed page this map is shared/iframed as — resolved by
|
||||
utils/embedBase.js (its header documents the full ladder). In short:
|
||||
|
|
@ -387,11 +395,42 @@ const embedSnippet = computed(() => {
|
|||
return '<iframe src="' + shareUrl.value + '" width="100%" height="' + h + '" style="border:none; border-radius:12px;" allowfullscreen></iframe>'
|
||||
})
|
||||
|
||||
/* ★ SHARE THIS PAGE. Where the page OWNS its address (useViewAddress: urlOwner 'app' on our own pages,
|
||||
'host' on DX Life), the address bar already names the exact view (the tab, and the filters), so the
|
||||
link is the page's own URL, read when the menu opens and again at copy time, since the reader can
|
||||
switch tabs in between. The owner is this map's own profile-config, or the nearest element above it,
|
||||
across shadow roots, whose profile-config names one: a map nested in the gallery is told nothing, and
|
||||
the gallery is the page. On a partner's page (no owner) the address is the partner's, so the link is
|
||||
this map's own page, as before. */
|
||||
const cfgOf = (c) => { if (typeof c !== 'string') return c || null; try { return JSON.parse(c) } catch { return null } }
|
||||
function pageOwner() {
|
||||
if (urlOwnerOf(cfgOf(unref(profileConfig)))) return true
|
||||
for (let n = rootEl.value; n; ) {
|
||||
n = n.parentNode || n.host
|
||||
if (n && n.nodeType === 1 && urlOwnerOf(cfgOf(n.profileConfig ?? n.getAttribute('profile-config')))) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
// A leaf page reads a token from ?tk= and leaves it in its address, so the page link drops it: a reader must
|
||||
// never send a credential by pressing Share. The URL is rewritten only when tk is there, so every other link is
|
||||
// the address exactly as it stands.
|
||||
function pageLink() {
|
||||
try {
|
||||
if (pageOwner() && window.location.href) {
|
||||
const u = new URL(window.location.href)
|
||||
if (u.searchParams.has('tk')) { u.searchParams.delete('tk'); return u.href }
|
||||
return window.location.href
|
||||
}
|
||||
} catch { /* no window */ }
|
||||
return shareUrl.value
|
||||
}
|
||||
|
||||
/* ---- actions ---- */
|
||||
function toggle() {
|
||||
isOpen.value = !isOpen.value
|
||||
if (isOpen.value) {
|
||||
activeTab.value = 'link'
|
||||
activeTab.value = 'page'
|
||||
pageUrl.value = pageLink()
|
||||
linkCopied.value = false
|
||||
embedCopied.value = false
|
||||
}
|
||||
|
|
@ -425,7 +464,8 @@ async function copyText(text) {
|
|||
}
|
||||
|
||||
async function copyLink() {
|
||||
if (await copyText(shareUrl.value)) {
|
||||
pageUrl.value = pageLink()
|
||||
if (await copyText(pageUrl.value)) {
|
||||
linkCopied.value = true
|
||||
setTimeout(() => { linkCopied.value = false }, 2000)
|
||||
}
|
||||
|
|
@ -477,39 +517,24 @@ async function copyEmbed() {
|
|||
const providedToolbar = inject('mapToolbar', null)
|
||||
const selfAnchor = ref(null)
|
||||
// Re-provide UNCONDITIONALLY so the value is stable for the child's inject(); `el` stays null when
|
||||
// a real toolbar was found, and MapControlMenu keeps using that toolbar instead.
|
||||
provide('mapToolbar', providedToolbar || { el: selfAnchor })
|
||||
// a real toolbar was found, and MapControlMenu keeps using that toolbar instead. `standalone` tells
|
||||
// MapControlMenu this anchor is ONE button outside any map (a page header), placed beside the button
|
||||
// inside the viewport, at open time (MapControlMenu.placeBeside).
|
||||
provide('mapToolbar', providedToolbar || { el: selfAnchor, standalone: true })
|
||||
const hasOwnAnchor = !providedToolbar
|
||||
|
||||
/*
|
||||
★ THIS IS A FUNCTION REF, AND onMounted WOULD NOT HAVE WORKED — the hazard is worth naming,
|
||||
because the fix silently does NOTHING if it is written the obvious way.
|
||||
|
||||
MapControlMenu latches its area ONCE, in its own onMounted:
|
||||
host.value = toolbar?.el?.value?.closest(AREA) || null
|
||||
and place() bails forever after if that is null. MapControlMenu is a CHILD of this component, and
|
||||
Vue runs a child's mounted hook BEFORE its parent's. So an onMounted here would set the anchor
|
||||
one tick too late: the child would have already read `null`, latched `host = null`, and no later
|
||||
assignment would revive it — the menu would go on opening at its static position and the fix
|
||||
would look applied while changing nothing.
|
||||
|
||||
A function ref is invoked during RENDER, before any mounted hook on either component, so the
|
||||
anchor and the area tag are in place by the time the child looks for them.
|
||||
|
||||
`data-map-area` is what MapControlMenu.AREA finds via closest(). It is tagged on the OFFSET
|
||||
PARENT, not on .share-wrap: the wrapper is a 32px button, and an area that small would clamp the
|
||||
menu to its 200px minimum jammed against the edge. The offset parent is the top bar, which spans
|
||||
the row, so the menu has the full width to open leftward into.
|
||||
★ THE ANCHOR IS THE BUTTON, AND NOTHING IS DECIDED AT RENDER TIME (shareable pages P3). This used to tag
|
||||
`el.offsetParent || el.parentElement` as the menu's area DURING RENDER (a function ref, so it ran before
|
||||
MapControlMenu latched its area). Before layout offsetParent is null, so the tag fell on the button's
|
||||
immediate parent: in a header with a narrow, unpositioned wrapper (measured on the UPG-100 list page: .hdr-btns) the menu
|
||||
measured against that wrapper, clamped to its 200px floor, and was positioned against the header above
|
||||
it: far to the left, and narrow (reported 2026-10-03). Now the button only names itself;
|
||||
MapControlMenu resolves where the menu lives and where it goes when it OPENS, with layout known.
|
||||
*/
|
||||
function bindAnchor(el) {
|
||||
rootEl.value = el || null
|
||||
if (!hasOwnAnchor || !el) { if (!el) selfAnchor.value = null; return }
|
||||
const area = el.offsetParent || el.parentElement
|
||||
// Never re-tag a host that already declares itself an area — that host owns the decision.
|
||||
if (area && !area.closest('.dsm-map-area, .rm-map-area, .dm-map-area, [data-map-area]')) {
|
||||
area.setAttribute('data-map-area', 'share-fallback')
|
||||
}
|
||||
selfAnchor.value = el
|
||||
selfAnchor.value = hasOwnAnchor && el ? el : null
|
||||
}
|
||||
|
||||
/* Placement, stacking, outside-click and Escape live in MapControlMenu (shared by every
|
||||
|
|
@ -517,8 +542,8 @@ function bindAnchor(el) {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<!-- :ref is a FUNCTION, deliberately — it must run during render, before MapControlMenu's
|
||||
mounted hook latches its area. See bindAnchor() in <script> for why onMounted is too late. -->
|
||||
<!-- :ref is a FUNCTION, deliberately: the anchor must be set before MapControlMenu's mounted hook
|
||||
reads it (a child mounts before its parent). See bindAnchor() in <script>. -->
|
||||
<div :ref="bindAnchor" class="share-wrap" :class="{ 'share-wrap--rounded-square': shape === 'rounded-square' }" data-share-root>
|
||||
<MapControlButton
|
||||
ref="btnEl"
|
||||
|
|
@ -543,29 +568,26 @@ function bindAnchor(el) {
|
|||
<!-- =================== Menu (shared MapControlMenu owns placement + chrome) =================== -->
|
||||
<MapControlMenu :open="isOpen" :title="t('buttons.share')" :is-dark="isDark" @close="close">
|
||||
<div ref="popoverEl" class="share-pop" :class="{ light: !isDark }">
|
||||
<!-- The multiplication path — one line each, top of the menu -->
|
||||
<ul class="share-steps">
|
||||
<li v-for="(step, i) in tm('buttons.shareSteps')" :key="i">{{ rt(step) }}</li>
|
||||
</ul>
|
||||
<!-- Tabs -->
|
||||
<div class="share-tabs">
|
||||
<!-- Tabs: sharing the PAGE first (what a person pressing Share expects), Embed second -->
|
||||
<div class="share-tabs" role="tablist">
|
||||
<button
|
||||
class="share-tab"
|
||||
:class="{ active: activeTab === 'link' }"
|
||||
@click="activeTab = 'link'"
|
||||
>{{ T('share.embed_link', 'Embed link') }}</button>
|
||||
class="share-tab" role="tab" data-share-tab="page"
|
||||
:class="{ active: activeTab === 'page' }" :aria-selected="String(activeTab === 'page')"
|
||||
@click="activeTab = 'page'"
|
||||
>{{ T('share.tab_page', 'Share this page') }}</button>
|
||||
<button
|
||||
class="share-tab"
|
||||
:class="{ active: activeTab === 'embed' }"
|
||||
class="share-tab" role="tab" data-share-tab="embed"
|
||||
:class="{ active: activeTab === 'embed' }" :aria-selected="String(activeTab === 'embed')"
|
||||
@click="activeTab = 'embed'"
|
||||
>{{ T('share.add_to_website', 'Add to Your Website') }}</button>
|
||||
>{{ T('share.tab_embed', 'Embed') }}</button>
|
||||
</div>
|
||||
|
||||
<!-- ===== Tab 1: Share Link ===== -->
|
||||
<div v-if="activeTab === 'link'" class="share-body">
|
||||
<!-- ===== Tab 1: Share this page ===== -->
|
||||
<div v-if="activeTab === 'page'" class="share-body">
|
||||
<div class="share-embed-desc">{{ T('share.page_hint', 'A link that opens this exact page for whoever you send it to.') }}</div>
|
||||
<!-- URL display -->
|
||||
<div class="share-url-box">
|
||||
<div class="share-url-text">{{ shareUrl }}</div>
|
||||
<div class="share-url-text" data-share-url>{{ pageUrl }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Copy Link primary CTA -->
|
||||
|
|
@ -586,13 +608,20 @@ function bindAnchor(el) {
|
|||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
{{ linkCopied ? 'Copied!' : 'Copy Link' }}
|
||||
{{ linkCopied ? T('share.copied', 'Copied!') : T('share.copy_link', 'Copy link') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ===== Tab 2: Add to Your Website ===== -->
|
||||
<!-- ===== Tab 2: Embed (for another website) ===== -->
|
||||
<div v-if="activeTab === 'embed'" class="share-body">
|
||||
<!-- Friendly description -->
|
||||
<!-- What embedding is and why it helps, in plain words, before any code -->
|
||||
<div class="share-embed-desc">
|
||||
{{ T('share.embed_what', 'Embedding puts this live map inside your own website, so your visitors can explore it and pray without leaving your site. It stays up to date by itself.') }}
|
||||
</div>
|
||||
<!-- The multiplication path, one line each -->
|
||||
<ul class="share-steps">
|
||||
<li v-for="(step, i) in tm('buttons.shareSteps')" :key="i">{{ rt(step) }}</li>
|
||||
</ul>
|
||||
<div class="share-embed-desc">
|
||||
{{ T('share.embed_paste_hint', 'Paste this code into your website builder (WordPress, Squarespace, Wix, or any site that accepts HTML widgets).') }}
|
||||
</div>
|
||||
|
|
@ -605,7 +634,7 @@ function bindAnchor(el) {
|
|||
class="share-size-pill"
|
||||
:class="{ active: embedSize === key }"
|
||||
@click="embedSize = key"
|
||||
>{{ preset.label }}</button>
|
||||
>{{ sizeLabel(key) }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Code box -->
|
||||
|
|
@ -628,7 +657,7 @@ function bindAnchor(el) {
|
|||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
{{ embedCopied ? 'Copied!' : 'Copy Code' }}
|
||||
{{ embedCopied ? T('share.copied', 'Copied!') : T('share.copy_code', 'Copy code') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@
|
|||
* stays unconditional, because restoring a link someone sent must work everywhere.
|
||||
*/
|
||||
import { watch, onMounted } from 'vue'
|
||||
import { commitUrl } from './useViewAddress.js'
|
||||
|
||||
const slugify = (s) => String(s ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40)
|
||||
// Minimal escaping, maximal legibility: reserve our delimiters + %, keep spaces as `+` (the
|
||||
|
|
@ -177,17 +178,25 @@ export function parseQuery(search, facetKeys = [], { searchKey = 'q' } = {}) {
|
|||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The filter write. It goes through useViewAddress's commitUrl, the page's ONE history write, so it can never undo
|
||||
* a tab/pane/place write and keeps history.state (+ `current`) on a Vue Router host. It used to write
|
||||
* replaceState(null, …) itself, which wiped the router's state there. Query mode replaces only its own params; hash
|
||||
* mode replaces only the hash. Everything else in the URL stays. Exported so check:view-address drives this exact
|
||||
* write. Returns whether it wrote.
|
||||
*/
|
||||
export function writeFilterView(filterSet, searchTerm, options = {}) {
|
||||
try {
|
||||
if (typeof location === 'undefined') return false
|
||||
return options.mode === 'query'
|
||||
? commitUrl({ search: serializeQuery(filterSet, searchTerm, location.search, options) })
|
||||
: commitUrl({ hash: serializeView(filterSet, searchTerm, options) })
|
||||
} catch (_) { return false }
|
||||
}
|
||||
|
||||
export function useShareableView(bus, options = {}) {
|
||||
const { mode = 'hash', write: shouldWrite = true, facetKeys } = options
|
||||
const push = () => {
|
||||
try {
|
||||
if (typeof location === 'undefined' || !shouldWrite) return
|
||||
const url = mode === 'query'
|
||||
? serializeQuery(bus.filterSet, bus.searchTerm?.value, location.search, { ...options, facetKeys }) + location.hash
|
||||
: location.search + serializeView(bus.filterSet, bus.searchTerm?.value, options)
|
||||
history.replaceState(null, '', location.pathname + url)
|
||||
} catch (_) { /* sandboxed */ }
|
||||
}
|
||||
const push = () => { if (shouldWrite) writeFilterView(bus.filterSet, bus.searchTerm?.value, options) }
|
||||
watch(() => ({ ...bus.filterSet, __q: bus.searchTerm?.value }), push, { deep: true })
|
||||
onMounted(() => {
|
||||
try {
|
||||
|
|
|
|||
111
library/composables/useViewAddress.js
Normal file
111
library/composables/useViewAddress.js
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
/**
|
||||
* useViewAddress.js — the page's ADDRESS for the view a reader is on: which app tab, which view inside it, which
|
||||
* place. One shared vocabulary and ONE history writer, so a tab, a view or a selection can be reloaded and sent as a
|
||||
* link on every host, while a partner's page is never written to.
|
||||
*
|
||||
* ★ THE SCHEME (query, not hash: a hash is a host page's anchor, and filters already live there).
|
||||
* ?tab=<app tab>&pane=<view inside the tab>&place=<selection>
|
||||
* Levels: changing `tab` drops `pane` and `place`; changing `pane` drops `place`. Every other parameter (a host's
|
||||
* own, the filters useShareableView writes) and the hash are kept as they are. A value that is not a plain id
|
||||
* (letters, digits, `-`, `_`, at most 64) reads as absent, so a stale or hand-edited link falls back to the default.
|
||||
* `pane`, not `view`: `?view=` is already published by four of our own pages (gospel-internet's board/map, Gospel
|
||||
* Resources' map ?view=zero, pplr-data-maps and upg-neighbor lenses), and one of them sits inside a shell tab.
|
||||
*
|
||||
* ★ ONE WRITER PER PAGE. Only the OUTERMOST element that owns the address writes it; profile-config `urlOwner` says
|
||||
* who that is:
|
||||
* 'app' our own pages (a generated leaf, Plugin Studio): writeViewKeys() writes history itself;
|
||||
* 'host' a host with its own router (DX Life): writeViewKeys() dispatches ADDRESS_EVENT and the HOST writes;
|
||||
* absent a partner's page: nothing is written; links are still READ (readViewKeys works everywhere).
|
||||
* A nested element never writes: it announces its choice upward (announceView) and the owner records it.
|
||||
*
|
||||
* ★ THE ONE HISTORY WRITE: commitUrl(). The view keys and useShareableView's filters both write through it, so those
|
||||
* two can never undo each other. The tree's other history writers are pinned by check:view-address's census and move
|
||||
* onto commitUrl in later phases. On a push, commitUrl copies the previous entry's state (Vue Router's `position` and
|
||||
* `back` included). That is correct for a replace. A Vue Router page is a 'host' owner, so its router does its own
|
||||
* pushes. It keeps `history.state` and sets its `current` to the
|
||||
* new URL. MEASURED on a Vue Router host (2026-09-30): a pushState that drops Router's state fields makes the next
|
||||
* in-app navigation rewrite the entry back to Router's last-known URL, and the link is lost. It writes nothing when
|
||||
* the URL would not change. A user's tab/pane/place change is a push (one Back step); first paint, link
|
||||
* normalisation and filters are a replace.
|
||||
*
|
||||
* Pure helpers, no dependency. Safe under node and in a sandboxed frame (every access is guarded).
|
||||
*/
|
||||
|
||||
export const VIEW_KEYS = ['tab', 'pane', 'place']
|
||||
/** nested element → the address owner: { key, value, push }. Composed + bubbling, so it leaves shadow roots. */
|
||||
export const VIEW_EVENT = 'dxm-view'
|
||||
/** an owner with urlOwner 'host' → the host's router: { keys, href, push }; the host does router[push ? 'push' : 'replace'](href). */
|
||||
export const ADDRESS_EVENT = 'dxm-address'
|
||||
|
||||
/** A plain id (letters, digits, `-`, `_`, at most 64), or '' for anything else. */
|
||||
export const cleanKey = (v) => { const s = String(v ?? '').trim(); return /^[A-Za-z0-9][\w-]{0,63}$/.test(s) ? s : '' }
|
||||
|
||||
/** { tab, pane, place } from a search string; '' for a key that is absent or not a plain id. The FIRST of a repeated
|
||||
* key decides, as URLSearchParams.get does for the shells that already read ?tab=; a malformed escape reads as absent. */
|
||||
export function readViewKeys(search) {
|
||||
const q = new URLSearchParams(search || '')
|
||||
return Object.fromEntries(VIEW_KEYS.map((k) => [k, cleanKey(q.get(k))]))
|
||||
}
|
||||
|
||||
// Bubbling + composed, so the event leaves every shadow root on its way to the owner or the host.
|
||||
const send = (el, type, detail) => !!el?.dispatchEvent && (el.dispatchEvent(new CustomEvent(type, { bubbles: true, composed: true, detail })), true)
|
||||
|
||||
/**
|
||||
* The search string after `changes` ({ tab?, pane?, place? }; '' or null removes a key), with the level rule: a
|
||||
* changed level drops the deeper levels that `changes` does not set itself. Foreign parameters keep their place and
|
||||
* their exact text (useShareableView's legible `,` and unicode are not re-encoded). Returns '' or '?…'.
|
||||
*/
|
||||
export function viewSearch(search, changes = {}) {
|
||||
const next = readViewKeys(search)
|
||||
VIEW_KEYS.forEach((k, level) => {
|
||||
if (!(k in changes) || cleanKey(changes[k]) === next[k]) return
|
||||
next[k] = cleanKey(changes[k])
|
||||
VIEW_KEYS.slice(level + 1).forEach((d) => { if (!(d in changes)) next[d] = '' })
|
||||
})
|
||||
const all = VIEW_KEYS.filter((k) => next[k]).map((k) => k + '=' + next[k])
|
||||
.concat((search || '').replace(/^\?/, '').split('&').filter((p) => p && !VIEW_KEYS.includes(p.split('=')[0])))
|
||||
return all.length ? '?' + all.join('&') : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* THE one write to the address bar. `search` ('' or '?…') and `hash` ('' or '#…') default to the current ones, which
|
||||
* is the shape viewSearch, serializeQuery and serializeView all return. Keeps history.state and sets its
|
||||
* `current` (Vue Router's field) to the new URL. Returns true when it wrote; false when the URL was already that, or
|
||||
* the page cannot be written (no history, a sandbox).
|
||||
*/
|
||||
export function commitUrl({ search, hash } = {}, { push = false } = {}) {
|
||||
try {
|
||||
const p = location.pathname
|
||||
const url = p + (search ?? location.search) + (hash ?? location.hash)
|
||||
if (url === p + location.search + location.hash) return false
|
||||
history[push ? 'pushState' : 'replaceState']({ ...(history.state || {}), current: url }, '', url)
|
||||
return true
|
||||
} catch (_) { return false }
|
||||
}
|
||||
|
||||
/** 'app' | 'host' | '' from a profile-config. Anything else is '' (a partner's page: read only). */
|
||||
export const urlOwnerOf = (cfg) => (cfg && (cfg.urlOwner === 'app' || cfg.urlOwner === 'host') ? cfg.urlOwner : '')
|
||||
|
||||
/**
|
||||
* The owner's write for tab/pane/place. owner 'app': commitUrl (push for a reader's choice). owner 'host': dispatch
|
||||
* ADDRESS_EVENT from `el` for the host's router; nothing is written here. Anything else: nothing. Returns what it did:
|
||||
* 'written' | 'announced' | 'unchanged' | 'none'.
|
||||
*/
|
||||
export function writeViewKeys(changes, { owner = '', push = true, el = null } = {}) {
|
||||
if ((owner !== 'app' && owner !== 'host') || !globalThis.location) return 'none'
|
||||
const search = viewSearch(location.search, changes)
|
||||
if (search === location.search) return 'unchanged'
|
||||
if (owner === 'app') return commitUrl({ search }, { push }) ? 'written' : 'unchanged'
|
||||
return send(el, ADDRESS_EVENT, { keys: readViewKeys(search), href: location.pathname + search + location.hash, push }) ? 'announced' : 'none'
|
||||
}
|
||||
|
||||
/** A nested element reports a reader's choice to whoever owns the address (it never writes itself). */
|
||||
export const announceView = (el, key, value, { push = true } = {}) =>
|
||||
VIEW_KEYS.includes(key) && send(el, VIEW_EVENT, { key, value: cleanKey(value), push })
|
||||
|
||||
/** Back / Forward: cb(readViewKeys(location.search)). Re-apply state only; never write from here. Returns off(). */
|
||||
export function onAddressChange(cb) {
|
||||
const h = () => cb(readViewKeys(location.search))
|
||||
globalThis.addEventListener?.('popstate', h)
|
||||
return () => globalThis.removeEventListener?.('popstate', h)
|
||||
}
|
||||
|
|
@ -23,5 +23,8 @@
|
|||
"1CO": "كورنثوس الأولى",
|
||||
"1PE": "بطرس الأولى",
|
||||
"JOS": "يشوع"
|
||||
},
|
||||
"posterDesign": {
|
||||
"label": "ملصق سفر من الكتاب المقدس"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,11 @@
|
|||
"no": "لا",
|
||||
"printHome": "أي طابعة منزلية",
|
||||
"printShop": "مطبعة (ملف PDF، صفحة لكل ورقة)",
|
||||
"layoutNote": "تعرض الأوراق تخطيط ملصق الدولة بمقاس 3 × 3 أقدام في كل مقاس: الترويسة والخريطة والأسماء والجدول وتذييل الآية. سيظهر الملصق المرسوم بكل مقاس في الأنماط عند توفره."
|
||||
"layoutNote": "تعرض الأوراق تخطيط ملصق الدولة بمقاس 3 × 3 أقدام في كل مقاس: الترويسة والخريطة والأسماء والجدول وتذييل الآية. سيظهر الملصق المرسوم بكل مقاس في الأنماط عند توفره.",
|
||||
"sideLanes": "{dims}، أعمدة جانبية",
|
||||
"packetPage1": "{paper} (الصفحة 1 من حزمة الصلاة)",
|
||||
"panels": "{dims}، عدد الألواح: {n}",
|
||||
"anySize": "أي حجم من جدول الأحجام المشترك"
|
||||
},
|
||||
"family": {
|
||||
"mapSheet": "صفحات الخرائط",
|
||||
|
|
@ -36,7 +40,9 @@
|
|||
"cards": "بطاقات الصلاة",
|
||||
"wall": "ألواح الجدار",
|
||||
"mapPrint": "طباعة الخريطة",
|
||||
"plugins": "من إضافات أخرى"
|
||||
"plugins": "من إضافات أخرى",
|
||||
"experimental": "تجريبية (قيد العمل)",
|
||||
"upgZone": "ملصقات مناطق UPG100"
|
||||
},
|
||||
"tpl": {
|
||||
"mapSheetCountry": "صفحة خريطة الدولة",
|
||||
|
|
@ -45,7 +51,9 @@
|
|||
"packetLightweight": "حزمة الصلاة، مختصرة",
|
||||
"cards": "بطاقات الصلاة",
|
||||
"blessPanels": "ألواح جدار البركة",
|
||||
"mapPrint": "طباعة الخريطة"
|
||||
"mapPrint": "طباعة الخريطة",
|
||||
"upgZonePoster": "ملصق منطقة UPG100",
|
||||
"upgZonePacket": "حزمة صلاة UPG100، الصفحة 1"
|
||||
},
|
||||
"missing": {
|
||||
"bless": "الأوراق وشرائط اللصق ومواضع صفوف البطاقات مأخوذة من هندسة الألواح المشتركة؛ وتُعرض البطاقات كتخطيط، لأن جدار البركة يرسم بطاقاته في إضافته الخاصة.",
|
||||
|
|
|
|||
|
|
@ -19,5 +19,6 @@
|
|||
"displayName": "ملصقات UPG100",
|
||||
"wallHeading": "ملصقات UPG100",
|
||||
"wallIntroA": "كيف تُطعم جمعًا من خمسة آلاف؟ أجلسهم يسوع جماعاتٍ من خمسين ومن مئة، فصار المستحيل قابلًا للمشاركة (لوقا ٩:١٤). فعلنا المثل مع {n} شعوب غير مبلَّغة: ٢٧ قائمة من نحو خمسين إلى مئة. تُطبع كل قائمة حزمةَ صلاة وملصقًا، ليحمل ٢٧ تلميذًا أو كنيسة أو قائدًا محليًا كلٌّ واحدة — مصلّين لأجلهم ",
|
||||
"peopleStillLoading": "لا تزال الشعوب قيد التحميل، حاول مرة أخرى بعد لحظة"
|
||||
"peopleStillLoading": "لا تزال الشعوب قيد التحميل، حاول مرة أخرى بعد لحظة",
|
||||
"posterDesignTerritory": "ملصق منطقة UPG-100"
|
||||
}
|
||||
|
|
|
|||
4
library/i18n/locales/ar/wallSocial.json
Normal file
4
library/i18n/locales/ar/wallSocial.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"signUpToPray": "سجّل للصلاة من أجل {name}",
|
||||
"commentsSoon": "التعليقات قادمة قريبًا."
|
||||
}
|
||||
|
|
@ -23,5 +23,8 @@
|
|||
"1CO": "1. Korinther",
|
||||
"1PE": "1. Petrus",
|
||||
"JOS": "Josua"
|
||||
},
|
||||
"posterDesign": {
|
||||
"label": "Bibelbuch-Poster"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,11 @@
|
|||
"no": "Nein",
|
||||
"printHome": "Jeder Drucker zu Hause",
|
||||
"printShop": "Eine Druckerei (ein PDF, eine Seite pro Bogen)",
|
||||
"layoutNote": "Die Bögen zeigen das Layout des 3 × 3 ft Länderposters in jeder Größe: Kopfzeile, Karte, Namen, Tabelle und die Fußzeile mit dem Bibelvers. Das in jeder Größe gezeichnete Poster erscheint unter Stile, sobald es verfügbar ist."
|
||||
"layoutNote": "Die Bögen zeigen das Layout des 3 × 3 ft Länderposters in jeder Größe: Kopfzeile, Karte, Namen, Tabelle und die Fußzeile mit dem Bibelvers. Das in jeder Größe gezeichnete Poster erscheint unter Stile, sobald es verfügbar ist.",
|
||||
"sideLanes": "{dims}, seitliche Namensspalten",
|
||||
"packetPage1": "{paper} (Seite 1 der Gebetsmappe)",
|
||||
"panels": "{dims}, Tafeln: {n}",
|
||||
"anySize": "Jede Größe aus der gemeinsamen Größentabelle"
|
||||
},
|
||||
"family": {
|
||||
"mapSheet": "Kartenblätter",
|
||||
|
|
@ -36,7 +40,9 @@
|
|||
"cards": "Gebetskarten",
|
||||
"wall": "Wandtafeln",
|
||||
"mapPrint": "Kartendruck",
|
||||
"plugins": "Aus anderen Plugins"
|
||||
"plugins": "Aus anderen Plugins",
|
||||
"experimental": "Experimentell (in Arbeit)",
|
||||
"upgZone": "UPG100-Gebietsplakate"
|
||||
},
|
||||
"tpl": {
|
||||
"mapSheetCountry": "Länder-Kartenblatt",
|
||||
|
|
@ -45,7 +51,9 @@
|
|||
"packetLightweight": "Gebetsmappe, kompakt",
|
||||
"cards": "Gebetskarten",
|
||||
"blessPanels": "Tafeln der Segnen-Wand",
|
||||
"mapPrint": "Kartendruck"
|
||||
"mapPrint": "Kartendruck",
|
||||
"upgZonePoster": "UPG100-Gebietsplakat",
|
||||
"upgZonePacket": "UPG100-Gebetsmappe, Seite 1"
|
||||
},
|
||||
"missing": {
|
||||
"bless": "Die Bögen, die Klebestreifen und die Lage der Kartenreihen stammen aus der gemeinsamen Tafelgeometrie; die Karten sind als Layout dargestellt, weil die Segnen-Wand ihre Karten in ihrem eigenen Plugin zeichnet.",
|
||||
|
|
|
|||
|
|
@ -19,5 +19,6 @@
|
|||
"displayName": "UPG100-Poster",
|
||||
"wallHeading": "UPG100-Poster",
|
||||
"wallIntroA": "Wie würden Sie eine Menge von fünftausend speisen? Jesus ließ sie in Gruppen zu fünfzig und hundert lagern, und das Unmögliche wurde teilbar (Lukas 9,14). Dasselbe haben wir mit den {n} unerreichten Volksgruppen getan: 27 Listen von etwa fünfzig bis hundert. Jede Liste druckt als Gebetspaket und als Poster, damit 27 Jünger, Gemeinden oder einheimische Leiter je eine tragen können — und für sie beten ",
|
||||
"peopleStillLoading": "die Volksgruppen werden noch geladen; versuchen Sie es gleich noch einmal"
|
||||
"peopleStillLoading": "die Volksgruppen werden noch geladen; versuchen Sie es gleich noch einmal",
|
||||
"posterDesignTerritory": "UPG-100-Gebietsplakat"
|
||||
}
|
||||
|
|
|
|||
4
library/i18n/locales/de/wallSocial.json
Normal file
4
library/i18n/locales/de/wallSocial.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"signUpToPray": "Melden Sie sich an, um für {name} zu beten",
|
||||
"commentsSoon": "Kommentare folgen in Kürze."
|
||||
}
|
||||
|
|
@ -23,5 +23,8 @@
|
|||
"1CO": "1 Corinthians",
|
||||
"1PE": "1 Peter",
|
||||
"JOS": "Joshua"
|
||||
},
|
||||
"posterDesign": {
|
||||
"label": "Bible book poster"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,11 @@
|
|||
"no": "No",
|
||||
"printHome": "Any home printer",
|
||||
"printShop": "A print shop (a PDF, one page per sheet)",
|
||||
"layoutNote": "The sheets show the layout of the 3 × 3 ft country poster at each size: the header, the map, the names, the table and the scripture footer. The poster drawn at each size appears in Styles as it becomes available."
|
||||
"layoutNote": "The sheets show the layout of the 3 × 3 ft country poster at each size: the header, the map, the names, the table and the scripture footer. The poster drawn at each size appears in Styles as it becomes available.",
|
||||
"sideLanes": "{dims}, side lanes",
|
||||
"packetPage1": "{paper} (packet page 1)",
|
||||
"panels": "{dims}, {n} panels",
|
||||
"anySize": "Any size from the shared size table"
|
||||
},
|
||||
"family": {
|
||||
"mapSheet": "Map sheets",
|
||||
|
|
@ -36,7 +40,9 @@
|
|||
"cards": "Prayer cards",
|
||||
"wall": "Wall panels",
|
||||
"mapPrint": "Map print",
|
||||
"plugins": "From other plugins"
|
||||
"plugins": "From other plugins",
|
||||
"experimental": "Experimental (in progress)",
|
||||
"upgZone": "UPG100 zone posters"
|
||||
},
|
||||
"tpl": {
|
||||
"mapSheetCountry": "Country map sheet",
|
||||
|
|
@ -45,7 +51,9 @@
|
|||
"packetLightweight": "Prayer packet, lightweight",
|
||||
"cards": "Prayer cards",
|
||||
"blessPanels": "Bless wall panels",
|
||||
"mapPrint": "Map print"
|
||||
"mapPrint": "Map print",
|
||||
"upgZonePoster": "UPG100 zone poster",
|
||||
"upgZonePacket": "UPG100 prayer packet, page 1"
|
||||
},
|
||||
"missing": {
|
||||
"bless": "The sheets, the glue strips and where the card rows go come from the shared panel geometry; the cards are shown as a layout, because the Bless wall draws its cards in its own plugin.",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,15 @@
|
|||
{
|
||||
"embed_link": "Embed link",
|
||||
"add_to_website": "Add to Your Website",
|
||||
"embed_paste_hint": "Paste this code into your website builder (WordPress, Squarespace, Wix, or any site that accepts HTML widgets)."
|
||||
"embed_paste_hint": "Paste this code into your website builder (WordPress, Squarespace, Wix, or any site that accepts HTML widgets).",
|
||||
"tab_page": "Share this page",
|
||||
"tab_embed": "Embed",
|
||||
"page_hint": "A link that opens this exact page for whoever you send it to.",
|
||||
"embed_what": "Embedding puts this live map inside your own website, so your visitors can explore it and pray without leaving your site. It stays up to date by itself.",
|
||||
"copy_link": "Copy link",
|
||||
"copy_code": "Copy code",
|
||||
"copied": "Copied!",
|
||||
"size_small": "Small",
|
||||
"size_medium": "Medium",
|
||||
"size_large": "Large"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,5 +19,6 @@
|
|||
"displayName": "UPG100 Posters",
|
||||
"wallHeading": "UPG100 Posters",
|
||||
"wallIntroA": "How would you feed a crowd of five thousand? Jesus sat them down in groups of fifty and a hundred, and the impossible became shareable (Luke 9:14). We did the same with the {n} unreached people groups: 27 lists of about fifty to a hundred. Each list prints as a prayer packet and a poster, so 27 disciples, churches or local leaders can each carry one — praying for them ",
|
||||
"peopleStillLoading": "the people groups are still loading, try again in a moment"
|
||||
"peopleStillLoading": "the people groups are still loading, try again in a moment",
|
||||
"posterDesignTerritory": "UPG-100 territory poster"
|
||||
}
|
||||
|
|
|
|||
4
library/i18n/locales/en/wallSocial.json
Normal file
4
library/i18n/locales/en/wallSocial.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"signUpToPray": "Sign up to pray for {name}",
|
||||
"commentsSoon": "Comments are coming soon."
|
||||
}
|
||||
|
|
@ -23,5 +23,8 @@
|
|||
"1CO": "1 Corintios",
|
||||
"1PE": "1 Pedro",
|
||||
"JOS": "Josué"
|
||||
},
|
||||
"posterDesign": {
|
||||
"label": "Cartel de un libro de la Biblia"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,11 @@
|
|||
"no": "No",
|
||||
"printHome": "Cualquier impresora doméstica",
|
||||
"printShop": "Una imprenta (un PDF, una página por hoja)",
|
||||
"layoutNote": "Las hojas muestran el diseño del póster de país de 3 × 3 ft en cada tamaño: el encabezado, el mapa, los nombres, la tabla y el pie con la Escritura. El póster dibujado en cada tamaño aparecerá en Estilos a medida que esté disponible."
|
||||
"layoutNote": "Las hojas muestran el diseño del póster de país de 3 × 3 ft en cada tamaño: el encabezado, el mapa, los nombres, la tabla y el pie con la Escritura. El póster dibujado en cada tamaño aparecerá en Estilos a medida que esté disponible.",
|
||||
"sideLanes": "{dims}, columnas laterales",
|
||||
"packetPage1": "{paper} (página 1 del paquete de oración)",
|
||||
"panels": "{dims}, paneles: {n}",
|
||||
"anySize": "Cualquier tamaño de la tabla de tamaños compartida"
|
||||
},
|
||||
"family": {
|
||||
"mapSheet": "Hojas de mapa",
|
||||
|
|
@ -36,7 +40,9 @@
|
|||
"cards": "Tarjetas de oración",
|
||||
"wall": "Paneles de pared",
|
||||
"mapPrint": "Impresión del mapa",
|
||||
"plugins": "De otros complementos"
|
||||
"plugins": "De otros complementos",
|
||||
"experimental": "Experimentales (en desarrollo)",
|
||||
"upgZone": "Carteles de zonas UPG100"
|
||||
},
|
||||
"tpl": {
|
||||
"mapSheetCountry": "Hoja de mapa del país",
|
||||
|
|
@ -45,7 +51,9 @@
|
|||
"packetLightweight": "Paquete de oración, ligero",
|
||||
"cards": "Tarjetas de oración",
|
||||
"blessPanels": "Paneles del muro Bendecir",
|
||||
"mapPrint": "Impresión del mapa"
|
||||
"mapPrint": "Impresión del mapa",
|
||||
"upgZonePoster": "Cartel de zona UPG100",
|
||||
"upgZonePacket": "Paquete de oración UPG100, página 1"
|
||||
},
|
||||
"missing": {
|
||||
"bless": "Las hojas, las franjas de pegado y el lugar de las filas de tarjetas vienen de la geometría de paneles compartida; las tarjetas se muestran como un diseño, porque el muro Bendecir dibuja sus tarjetas en su propio complemento.",
|
||||
|
|
|
|||
|
|
@ -19,5 +19,6 @@
|
|||
"displayName": "Carteles UPG100",
|
||||
"wallHeading": "Carteles UPG100",
|
||||
"wallIntroA": "¿Cómo alimentarías a una multitud de cinco mil? Jesús los sentó en grupos de cincuenta y de cien, y lo imposible se volvió compartible (Lucas 9:14). Hicimos lo mismo con los {n} pueblos no alcanzados: 27 listas de unos cincuenta a cien. Cada lista se imprime como paquete de oración y cartel, para que 27 discípulos, iglesias o líderes locales lleven una cada uno — orando por ellos ",
|
||||
"peopleStillLoading": "los pueblos aún se están cargando; inténtalo de nuevo en un momento"
|
||||
"peopleStillLoading": "los pueblos aún se están cargando; inténtalo de nuevo en un momento",
|
||||
"posterDesignTerritory": "Cartel de territorio UPG-100"
|
||||
}
|
||||
|
|
|
|||
4
library/i18n/locales/es/wallSocial.json
Normal file
4
library/i18n/locales/es/wallSocial.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"signUpToPray": "Inscríbase para orar por {name}",
|
||||
"commentsSoon": "Los comentarios llegarán pronto."
|
||||
}
|
||||
|
|
@ -23,5 +23,8 @@
|
|||
"1CO": "1 Corinthiens",
|
||||
"1PE": "1 Pierre",
|
||||
"JOS": "Josué"
|
||||
},
|
||||
"posterDesign": {
|
||||
"label": "Affiche d'un livre de la Bible"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,11 @@
|
|||
"no": "Non",
|
||||
"printHome": "N'importe quelle imprimante",
|
||||
"printShop": "Un imprimeur (un PDF, une page par feuille)",
|
||||
"layoutNote": "Les feuilles montrent la mise en page de l'affiche pays de 3 × 3 ft à chaque format : l'en-tête, la carte, les noms, le tableau et le pied avec l'Écriture. L'affiche dessinée à chaque format apparaîtra dans Styles dès qu'elle sera disponible."
|
||||
"layoutNote": "Les feuilles montrent la mise en page de l'affiche pays de 3 × 3 ft à chaque format : l'en-tête, la carte, les noms, le tableau et le pied avec l'Écriture. L'affiche dessinée à chaque format apparaîtra dans Styles dès qu'elle sera disponible.",
|
||||
"sideLanes": "{dims}, colonnes latérales",
|
||||
"packetPage1": "{paper} (page 1 du dossier de prière)",
|
||||
"panels": "{dims}, panneaux : {n}",
|
||||
"anySize": "Toute taille du tableau de tailles commun"
|
||||
},
|
||||
"family": {
|
||||
"mapSheet": "Feuilles de carte",
|
||||
|
|
@ -36,7 +40,9 @@
|
|||
"cards": "Cartes de prière",
|
||||
"wall": "Panneaux muraux",
|
||||
"mapPrint": "Impression de la carte",
|
||||
"plugins": "D’autres plugins"
|
||||
"plugins": "D’autres plugins",
|
||||
"experimental": "Expérimentaux (en cours)",
|
||||
"upgZone": "Affiches de zones UPG100"
|
||||
},
|
||||
"tpl": {
|
||||
"mapSheetCountry": "Feuille de carte du pays",
|
||||
|
|
@ -45,7 +51,9 @@
|
|||
"packetLightweight": "Dossier de prière, allégé",
|
||||
"cards": "Cartes de prière",
|
||||
"blessPanels": "Panneaux du mur Bénir",
|
||||
"mapPrint": "Impression de la carte"
|
||||
"mapPrint": "Impression de la carte",
|
||||
"upgZonePoster": "Affiche de zone UPG100",
|
||||
"upgZonePacket": "Dossier de prière UPG100, page 1"
|
||||
},
|
||||
"missing": {
|
||||
"bless": "Les feuilles, les bandes de collage et l'emplacement des rangées de cartes viennent de la géométrie de panneaux partagée ; les cartes sont montrées comme une mise en page, car le mur Bénir dessine ses cartes dans son propre module.",
|
||||
|
|
|
|||
|
|
@ -19,5 +19,6 @@
|
|||
"displayName": "Affiches UPG100",
|
||||
"wallHeading": "Affiches UPG100",
|
||||
"wallIntroA": "Comment nourririez-vous une foule de cinq mille personnes ? Jésus les a fait asseoir par groupes de cinquante et de cent, et l’impossible est devenu partageable (Luc 9.14). Nous avons fait de même avec les {n} peuples non atteints : 27 listes d’environ cinquante à cent. Chaque liste s’imprime en dossier de prière et en affiche, pour que 27 disciples, Églises ou responsables locaux en portent chacun une — en priant pour eux ",
|
||||
"peopleStillLoading": "les peuples sont encore en cours de chargement ; réessayez dans un instant"
|
||||
"peopleStillLoading": "les peuples sont encore en cours de chargement ; réessayez dans un instant",
|
||||
"posterDesignTerritory": "Affiche de territoire UPG-100"
|
||||
}
|
||||
|
|
|
|||
4
library/i18n/locales/fr/wallSocial.json
Normal file
4
library/i18n/locales/fr/wallSocial.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"signUpToPray": "Inscrivez-vous pour prier pour {name}",
|
||||
"commentsSoon": "Les commentaires arrivent bientôt."
|
||||
}
|
||||
|
|
@ -23,5 +23,8 @@
|
|||
"1CO": "1 कुरिन्थियों",
|
||||
"1PE": "1 पतरस",
|
||||
"JOS": "यहोशू"
|
||||
},
|
||||
"posterDesign": {
|
||||
"label": "बाइबल पुस्तक पोस्टर"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,11 @@
|
|||
"no": "नहीं",
|
||||
"printHome": "कोई भी घरेलू प्रिंटर",
|
||||
"printShop": "प्रिंटिंग प्रेस (PDF, हर शीट के लिए एक पेज)",
|
||||
"layoutNote": "शीटें हर आकार में 3 × 3 फ़ुट देश पोस्टर का लेआउट दिखाती हैं: शीर्षक, मानचित्र, नाम, तालिका और पवित्रशास्त्र वाला फ़ुटर। हर आकार में बना असली पोस्टर उपलब्ध होते ही शैलियाँ में दिखेगा।"
|
||||
"layoutNote": "शीटें हर आकार में 3 × 3 फ़ुट देश पोस्टर का लेआउट दिखाती हैं: शीर्षक, मानचित्र, नाम, तालिका और पवित्रशास्त्र वाला फ़ुटर। हर आकार में बना असली पोस्टर उपलब्ध होते ही शैलियाँ में दिखेगा।",
|
||||
"sideLanes": "{dims}, किनारे के कॉलम",
|
||||
"packetPage1": "{paper} (प्रार्थना पैकेट का पेज 1)",
|
||||
"panels": "{dims}, पैनल: {n}",
|
||||
"anySize": "साझा आकार तालिका का कोई भी आकार"
|
||||
},
|
||||
"family": {
|
||||
"mapSheet": "मानचित्र पृष्ठ",
|
||||
|
|
@ -36,7 +40,9 @@
|
|||
"cards": "प्रार्थना कार्ड",
|
||||
"wall": "दीवार पैनल",
|
||||
"mapPrint": "मानचित्र प्रिंट",
|
||||
"plugins": "अन्य प्लगइन से"
|
||||
"plugins": "अन्य प्लगइन से",
|
||||
"experimental": "प्रयोगात्मक (काम जारी)",
|
||||
"upgZone": "UPG100 क्षेत्र पोस्टर"
|
||||
},
|
||||
"tpl": {
|
||||
"mapSheetCountry": "देश का मानचित्र पृष्ठ",
|
||||
|
|
@ -45,7 +51,9 @@
|
|||
"packetLightweight": "प्रार्थना पैकेट, हल्का",
|
||||
"cards": "प्रार्थना कार्ड",
|
||||
"blessPanels": "आशीष दीवार के पैनल",
|
||||
"mapPrint": "मानचित्र प्रिंट"
|
||||
"mapPrint": "मानचित्र प्रिंट",
|
||||
"upgZonePoster": "UPG100 क्षेत्र का पोस्टर",
|
||||
"upgZonePacket": "UPG100 प्रार्थना पैकेट, पेज 1"
|
||||
},
|
||||
"missing": {
|
||||
"bless": "शीटें, चिपकाने की पट्टियाँ और कार्ड-पंक्तियों की जगह साझा पैनल ज्यामिति से आती हैं; कार्ड लेआउट के रूप में दिखाए गए हैं, क्योंकि आशीष दीवार अपने कार्ड अपने प्लगइन में बनाती है।",
|
||||
|
|
|
|||
|
|
@ -19,5 +19,6 @@
|
|||
"displayName": "UPG100 पोस्टर",
|
||||
"wallHeading": "UPG100 पोस्टर",
|
||||
"wallIntroA": "पाँच हज़ार की भीड़ को आप कैसे खिलाते? यीशु ने उन्हें पचास-पचास और सौ-सौ के समूहों में बिठाया, और असंभव बाँटने योग्य हो गया (लूका 9:14). हमने {n} अनपहुँचे जनसमूहों के साथ वही किया: लगभग पचास से सौ तक की 27 सूचियाँ. हर सूची प्रार्थना पैकेट और पोस्टर के रूप में छपती है, ताकि 27 शिष्य, कलीसियाएँ या स्थानीय अगुवे एक-एक ले सकें — उनके लिए प्रार्थना करते हुए ",
|
||||
"peopleStillLoading": "जनसमूह अभी लोड हो रहे हैं, थोड़ी देर में फिर कोशिश करें"
|
||||
"peopleStillLoading": "जनसमूह अभी लोड हो रहे हैं, थोड़ी देर में फिर कोशिश करें",
|
||||
"posterDesignTerritory": "UPG-100 क्षेत्र पोस्टर"
|
||||
}
|
||||
|
|
|
|||
4
library/i18n/locales/hi/wallSocial.json
Normal file
4
library/i18n/locales/hi/wallSocial.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"signUpToPray": "{name} के लिए प्रार्थना करने हेतु पंजीकरण करें",
|
||||
"commentsSoon": "टिप्पणियाँ जल्द ही आ रही हैं।"
|
||||
}
|
||||
|
|
@ -23,5 +23,8 @@
|
|||
"1CO": "1 Corinzi",
|
||||
"1PE": "1 Pietro",
|
||||
"JOS": "Giosuè"
|
||||
},
|
||||
"posterDesign": {
|
||||
"label": "Poster di un libro della Bibbia"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,11 @@
|
|||
"no": "No",
|
||||
"printHome": "Qualsiasi stampante di casa",
|
||||
"printShop": "Una tipografia (un PDF, una pagina per foglio)",
|
||||
"layoutNote": "I fogli mostrano l'impaginazione del poster di paese da 3 × 3 ft in ogni formato: intestazione, mappa, nomi, tabella e il piè di pagina con la Scrittura. Il poster disegnato in ogni formato comparirà in Stili appena disponibile."
|
||||
"layoutNote": "I fogli mostrano l'impaginazione del poster di paese da 3 × 3 ft in ogni formato: intestazione, mappa, nomi, tabella e il piè di pagina con la Scrittura. Il poster disegnato in ogni formato comparirà in Stili appena disponibile.",
|
||||
"sideLanes": "{dims}, colonne laterali",
|
||||
"packetPage1": "{paper} (pagina 1 del fascicolo di preghiera)",
|
||||
"panels": "{dims}, pannelli: {n}",
|
||||
"anySize": "Qualsiasi formato dalla tabella dei formati comune"
|
||||
},
|
||||
"family": {
|
||||
"mapSheet": "Fogli mappa",
|
||||
|
|
@ -36,7 +40,9 @@
|
|||
"cards": "Schede di preghiera",
|
||||
"wall": "Pannelli da parete",
|
||||
"mapPrint": "Stampa della mappa",
|
||||
"plugins": "Da altri plugin"
|
||||
"plugins": "Da altri plugin",
|
||||
"experimental": "Sperimentali (in corso)",
|
||||
"upgZone": "Poster delle zone UPG100"
|
||||
},
|
||||
"tpl": {
|
||||
"mapSheetCountry": "Foglio mappa del paese",
|
||||
|
|
@ -45,7 +51,9 @@
|
|||
"packetLightweight": "Fascicolo di preghiera, leggero",
|
||||
"cards": "Schede di preghiera",
|
||||
"blessPanels": "Pannelli del muro Benedici",
|
||||
"mapPrint": "Stampa della mappa"
|
||||
"mapPrint": "Stampa della mappa",
|
||||
"upgZonePoster": "Poster di zona UPG100",
|
||||
"upgZonePacket": "Fascicolo di preghiera UPG100, pagina 1"
|
||||
},
|
||||
"missing": {
|
||||
"bless": "I fogli, le strisce da incollare e la posizione delle file di schede vengono dalla geometria dei pannelli condivisa; le schede sono mostrate come impaginazione, perché il muro Benedici disegna le sue schede nel proprio plugin.",
|
||||
|
|
|
|||
|
|
@ -19,5 +19,6 @@
|
|||
"displayName": "Poster UPG100",
|
||||
"wallHeading": "Poster UPG100",
|
||||
"wallIntroA": "Come sfameresti una folla di cinquemila? Gesù li fece sedere a gruppi di cinquanta e di cento, e l’impossibile divenne condivisibile (Luca 9:14). Abbiamo fatto lo stesso con i {n} popoli non raggiunti: 27 liste di circa cinquanta-cento. Ogni lista si stampa come pacchetto di preghiera e manifesto, così 27 discepoli, chiese o responsabili locali ne portano una ciascuno — pregando per loro ",
|
||||
"peopleStillLoading": "i popoli si stanno ancora caricando; riprova tra un momento"
|
||||
"peopleStillLoading": "i popoli si stanno ancora caricando; riprova tra un momento",
|
||||
"posterDesignTerritory": "Manifesto di territorio UPG-100"
|
||||
}
|
||||
|
|
|
|||
4
library/i18n/locales/it/wallSocial.json
Normal file
4
library/i18n/locales/it/wallSocial.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"signUpToPray": "Iscriviti per pregare per {name}",
|
||||
"commentsSoon": "I commenti arriveranno presto."
|
||||
}
|
||||
|
|
@ -23,5 +23,8 @@
|
|||
"1CO": "1 Coríntios",
|
||||
"1PE": "1 Pedro",
|
||||
"JOS": "Josué"
|
||||
},
|
||||
"posterDesign": {
|
||||
"label": "Cartaz de um livro da Bíblia"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,11 @@
|
|||
"no": "Não",
|
||||
"printHome": "Qualquer impressora doméstica",
|
||||
"printShop": "Uma gráfica (um PDF, uma página por folha)",
|
||||
"layoutNote": "As folhas mostram o layout do pôster de país de 3 × 3 ft em cada tamanho: o cabeçalho, o mapa, os nomes, a tabela e o rodapé com a Escritura. O pôster desenhado em cada tamanho aparecerá em Estilos quando estiver disponível."
|
||||
"layoutNote": "As folhas mostram o layout do pôster de país de 3 × 3 ft em cada tamanho: o cabeçalho, o mapa, os nomes, a tabela e o rodapé com a Escritura. O pôster desenhado em cada tamanho aparecerá em Estilos quando estiver disponível.",
|
||||
"sideLanes": "{dims}, colunas laterais",
|
||||
"packetPage1": "{paper} (página 1 do pacote de oração)",
|
||||
"panels": "{dims}, painéis: {n}",
|
||||
"anySize": "Qualquer tamanho da tabela de tamanhos compartilhada"
|
||||
},
|
||||
"family": {
|
||||
"mapSheet": "Folhas de mapa",
|
||||
|
|
@ -36,7 +40,9 @@
|
|||
"cards": "Cartões de oração",
|
||||
"wall": "Painéis de parede",
|
||||
"mapPrint": "Impressão do mapa",
|
||||
"plugins": "De outros plugins"
|
||||
"plugins": "De outros plugins",
|
||||
"experimental": "Experimentais (em andamento)",
|
||||
"upgZone": "Cartazes de zonas UPG100"
|
||||
},
|
||||
"tpl": {
|
||||
"mapSheetCountry": "Folha de mapa do país",
|
||||
|
|
@ -45,7 +51,9 @@
|
|||
"packetLightweight": "Pacote de oração, leve",
|
||||
"cards": "Cartões de oração",
|
||||
"blessPanels": "Painéis do mural Abençoar",
|
||||
"mapPrint": "Impressão do mapa"
|
||||
"mapPrint": "Impressão do mapa",
|
||||
"upgZonePoster": "Cartaz de zona UPG100",
|
||||
"upgZonePacket": "Pacote de oração UPG100, página 1"
|
||||
},
|
||||
"missing": {
|
||||
"bless": "As folhas, as faixas de colagem e a posição das fileiras de cartões vêm da geometria de painéis compartilhada; os cartões aparecem como um layout, porque o mural Abençoar desenha os seus cartões no seu próprio plugin.",
|
||||
|
|
|
|||
|
|
@ -19,5 +19,6 @@
|
|||
"displayName": "Cartazes UPG100",
|
||||
"wallHeading": "Cartazes UPG100",
|
||||
"wallIntroA": "Como você alimentaria uma multidão de cinco mil? Jesus os sentou em grupos de cinquenta e de cem, e o impossível tornou-se compartilhável (Lucas 9.14). Fizemos o mesmo com os {n} povos não alcançados: 27 listas de cerca de cinquenta a cem. Cada lista imprime como pacote de oração e cartaz, para que 27 discípulos, igrejas ou líderes locais levem uma cada um — orando por eles ",
|
||||
"peopleStillLoading": "os povos ainda estão carregando; tente de novo em um instante"
|
||||
"peopleStillLoading": "os povos ainda estão carregando; tente de novo em um instante",
|
||||
"posterDesignTerritory": "Cartaz de território UPG-100"
|
||||
}
|
||||
|
|
|
|||
4
library/i18n/locales/pt/wallSocial.json
Normal file
4
library/i18n/locales/pt/wallSocial.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"signUpToPray": "Inscreva-se para orar por {name}",
|
||||
"commentsSoon": "Os comentários chegarão em breve."
|
||||
}
|
||||
|
|
@ -23,5 +23,8 @@
|
|||
"1CO": "1 Corinteni",
|
||||
"1PE": "1 Petru",
|
||||
"JOS": "Iosua"
|
||||
},
|
||||
"posterDesign": {
|
||||
"label": "Poster cu o carte a Bibliei"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,11 @@
|
|||
"no": "Nu",
|
||||
"printHome": "Orice imprimantă de acasă",
|
||||
"printShop": "O tipografie (un PDF, o pagină pe coală)",
|
||||
"layoutNote": "Colile arată machetarea afișului de țară de 3 × 3 ft la fiecare dimensiune: antetul, harta, numele, tabelul și subsolul cu versetul din Scriptură. Afișul desenat la fiecare dimensiune va apărea în Stiluri când va fi disponibil."
|
||||
"layoutNote": "Colile arată machetarea afișului de țară de 3 × 3 ft la fiecare dimensiune: antetul, harta, numele, tabelul și subsolul cu versetul din Scriptură. Afișul desenat la fiecare dimensiune va apărea în Stiluri când va fi disponibil.",
|
||||
"sideLanes": "{dims}, coloane laterale",
|
||||
"packetPage1": "{paper} (pagina 1 a pachetului de rugăciune)",
|
||||
"panels": "{dims}, panouri: {n}",
|
||||
"anySize": "Orice dimensiune din tabelul comun de dimensiuni"
|
||||
},
|
||||
"family": {
|
||||
"mapSheet": "Foi de hartă",
|
||||
|
|
@ -36,7 +40,9 @@
|
|||
"cards": "Cartonașe de rugăciune",
|
||||
"wall": "Panouri de perete",
|
||||
"mapPrint": "Tipărirea hărții",
|
||||
"plugins": "Din alte pluginuri"
|
||||
"plugins": "Din alte pluginuri",
|
||||
"experimental": "Experimentale (în lucru)",
|
||||
"upgZone": "Postere de zone UPG100"
|
||||
},
|
||||
"tpl": {
|
||||
"mapSheetCountry": "Foaie de hartă a țării",
|
||||
|
|
@ -45,7 +51,9 @@
|
|||
"packetLightweight": "Pachet de rugăciune, ușor",
|
||||
"cards": "Cartonașe de rugăciune",
|
||||
"blessPanels": "Panourile peretelui Binecuvântează",
|
||||
"mapPrint": "Tipărirea hărții"
|
||||
"mapPrint": "Tipărirea hărții",
|
||||
"upgZonePoster": "Poster de zonă UPG100",
|
||||
"upgZonePacket": "Pachet de rugăciune UPG100, pagina 1"
|
||||
},
|
||||
"missing": {
|
||||
"bless": "Colile, benzile de lipire și locul rândurilor de cartonașe vin din geometria comună a panourilor; cartonașele sunt arătate ca machetă, pentru că peretele Binecuvântează își desenează cartonașele în propriul modul.",
|
||||
|
|
|
|||
|
|
@ -19,5 +19,6 @@
|
|||
"displayName": "Afișe UPG100",
|
||||
"wallHeading": "Afișe UPG100",
|
||||
"wallIntroA": "Cum ai hrăni o mulțime de cinci mii? Isus i-a așezat în grupuri de cincizeci și de o sută, iar imposibilul a devenit împărțibil (Luca 9:14). Am făcut la fel cu cele {n} popoare neatinse: 27 de liste de aproximativ cincizeci până la o sută. Fiecare listă se tipărește ca pachet de rugăciune și afiș, ca 27 de ucenici, biserici sau lideri locali să poarte fiecare câte una — rugându-se pentru ei ",
|
||||
"peopleStillLoading": "popoarele încă se încarcă; încearcă din nou peste o clipă"
|
||||
"peopleStillLoading": "popoarele încă se încarcă; încearcă din nou peste o clipă",
|
||||
"posterDesignTerritory": "Afiș de teritoriu UPG-100"
|
||||
}
|
||||
|
|
|
|||
4
library/i18n/locales/ro/wallSocial.json
Normal file
4
library/i18n/locales/ro/wallSocial.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"signUpToPray": "Înscrieți-vă să vă rugați pentru {name}",
|
||||
"commentsSoon": "Comentariile vor fi disponibile în curând."
|
||||
}
|
||||
|
|
@ -23,5 +23,8 @@
|
|||
"1CO": "1 Коринфянам",
|
||||
"1PE": "1 Петра",
|
||||
"JOS": "Иисус Навин"
|
||||
},
|
||||
"posterDesign": {
|
||||
"label": "Плакат книги Библии"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,11 @@
|
|||
"no": "Нет",
|
||||
"printHome": "Любой домашний принтер",
|
||||
"printShop": "Типография (PDF, одна страница на лист)",
|
||||
"layoutNote": "На листах показан макет плаката страны 3 × 3 фута в каждом размере: заголовок, карта, названия, таблица и нижняя полоса со стихом из Писания. Плакат, нарисованный в каждом размере, появится в разделе «Стили», когда будет готов."
|
||||
"layoutNote": "На листах показан макет плаката страны 3 × 3 фута в каждом размере: заголовок, карта, названия, таблица и нижняя полоса со стихом из Писания. Плакат, нарисованный в каждом размере, появится в разделе «Стили», когда будет готов.",
|
||||
"sideLanes": "{dims}, боковые колонки",
|
||||
"packetPage1": "{paper} (страница 1 молитвенного пакета)",
|
||||
"panels": "{dims}, панелей: {n}",
|
||||
"anySize": "Любой размер из общей таблицы размеров"
|
||||
},
|
||||
"family": {
|
||||
"mapSheet": "Листы карт",
|
||||
|
|
@ -36,7 +40,9 @@
|
|||
"cards": "Молитвенные карточки",
|
||||
"wall": "Настенные панели",
|
||||
"mapPrint": "Печать карты",
|
||||
"plugins": "Из других плагинов"
|
||||
"plugins": "Из других плагинов",
|
||||
"experimental": "Экспериментальные (в работе)",
|
||||
"upgZone": "Плакаты зон UPG100"
|
||||
},
|
||||
"tpl": {
|
||||
"mapSheetCountry": "Лист карты страны",
|
||||
|
|
@ -45,7 +51,9 @@
|
|||
"packetLightweight": "Молитвенный пакет, облегчённый",
|
||||
"cards": "Молитвенные карточки",
|
||||
"blessPanels": "Панели стены «Благословить»",
|
||||
"mapPrint": "Печать карты"
|
||||
"mapPrint": "Печать карты",
|
||||
"upgZonePoster": "Плакат зоны UPG100",
|
||||
"upgZonePacket": "Молитвенный пакет UPG100, страница 1"
|
||||
},
|
||||
"missing": {
|
||||
"bless": "Листы, полосы для склейки и места рядов карточек взяты из общей геометрии панелей; карточки показаны как макет, потому что стена «Благословить» рисует свои карточки в собственном плагине.",
|
||||
|
|
|
|||
|
|
@ -19,5 +19,6 @@
|
|||
"displayName": "Плакаты UPG100",
|
||||
"wallHeading": "Плакаты UPG100",
|
||||
"wallIntroA": "Как накормить толпу в пять тысяч человек? Иисус рассадил их группами по пятьдесят и по сто, и невозможное стало посильным (Лк. 9:14). Мы поступили так же с {n} неохваченными народами: 27 списков примерно по пятьдесят-сто. Каждый список печатается как молитвенный пакет и плакат, чтобы 27 учеников, церквей или местных служителей взяли по одному — молясь о них ",
|
||||
"peopleStillLoading": "народы ещё загружаются; попробуйте ещё раз через мгновение"
|
||||
"peopleStillLoading": "народы ещё загружаются; попробуйте ещё раз через мгновение",
|
||||
"posterDesignTerritory": "Плакат территории UPG-100"
|
||||
}
|
||||
|
|
|
|||
4
library/i18n/locales/ru/wallSocial.json
Normal file
4
library/i18n/locales/ru/wallSocial.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"signUpToPray": "Запишитесь, чтобы молиться за {name}",
|
||||
"commentsSoon": "Комментарии скоро появятся."
|
||||
}
|
||||
|
|
@ -23,5 +23,8 @@
|
|||
"1CO": "哥林多前书",
|
||||
"1PE": "彼得前书",
|
||||
"JOS": "约书亚记"
|
||||
},
|
||||
"posterDesign": {
|
||||
"label": "圣经书卷海报"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,11 @@
|
|||
"no": "否",
|
||||
"printHome": "任何家用打印机",
|
||||
"printShop": "印刷店(PDF,每张纸一页)",
|
||||
"layoutNote": "纸张显示的是 3 × 3 英尺国家海报在各尺寸下的版式:标题、地图、名称、表格和经文页脚。各尺寸实际绘制的海报完成后会出现在“样式”中。"
|
||||
"layoutNote": "纸张显示的是 3 × 3 英尺国家海报在各尺寸下的版式:标题、地图、名称、表格和经文页脚。各尺寸实际绘制的海报完成后会出现在“样式”中。",
|
||||
"sideLanes": "{dims},两侧名单栏",
|
||||
"packetPage1": "{paper}(祷告资料包第 1 页)",
|
||||
"panels": "{dims},展板数:{n}",
|
||||
"anySize": "共享尺寸表中的任意尺寸"
|
||||
},
|
||||
"family": {
|
||||
"mapSheet": "地图页",
|
||||
|
|
@ -36,7 +40,9 @@
|
|||
"cards": "祷告卡",
|
||||
"wall": "墙面展板",
|
||||
"mapPrint": "地图打印",
|
||||
"plugins": "来自其他插件"
|
||||
"plugins": "来自其他插件",
|
||||
"experimental": "实验性(进行中)",
|
||||
"upgZone": "UPG100 区域海报"
|
||||
},
|
||||
"tpl": {
|
||||
"mapSheetCountry": "国家地图页",
|
||||
|
|
@ -45,7 +51,9 @@
|
|||
"packetLightweight": "祷告资料包(精简版)",
|
||||
"cards": "祷告卡",
|
||||
"blessPanels": "祝福墙展板",
|
||||
"mapPrint": "地图打印"
|
||||
"mapPrint": "地图打印",
|
||||
"upgZonePoster": "UPG100 区域海报(单张)",
|
||||
"upgZonePacket": "UPG100 祷告资料包,第 1 页"
|
||||
},
|
||||
"missing": {
|
||||
"bless": "纸张、粘贴条以及卡片行的位置都来自共享的拼板几何;卡片以版式示意,因为祝福墙在其自己的插件中绘制卡片。",
|
||||
|
|
|
|||
|
|
@ -19,5 +19,6 @@
|
|||
"displayName": "UPG100 海报",
|
||||
"wallHeading": "UPG100 海报",
|
||||
"wallIntroA": "你会怎样喂饱五千人的群众?耶稣让他们一排五十、一排一百地坐下,不可能的事就变得可以分担了(路加福音 9:14)。我们对 {n} 个未得之民做了同样的事:27 份名单,每份约五十到一百。每份名单都能印成祷告手册和海报,让 27 位门徒、教会或当地领袖各领一份 — 为他们祷告 ",
|
||||
"peopleStillLoading": "族群仍在加载中,请稍后再试"
|
||||
"peopleStillLoading": "族群仍在加载中,请稍后再试",
|
||||
"posterDesignTerritory": "UPG-100 区域海报"
|
||||
}
|
||||
|
|
|
|||
4
library/i18n/locales/zh/wallSocial.json
Normal file
4
library/i18n/locales/zh/wallSocial.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"signUpToPray": "报名为{name}祷告",
|
||||
"commentsSoon": "评论功能即将推出。"
|
||||
}
|
||||
1687
library/packet/upg100Zones/clusters.json
Normal file
1687
library/packet/upg100Zones/clusters.json
Normal file
File diff suppressed because it is too large
Load diff
401
library/packet/upg100Zones/legend-tree.js
Normal file
401
library/packet/upg100Zones/legend-tree.js
Normal file
|
|
@ -0,0 +1,401 @@
|
|||
// legend-tree.js — slice 04t §1(a)
|
||||
//
|
||||
// Pure shaper: buildLegendNodes(clusters) -> TreeNode[]
|
||||
//
|
||||
// Turns clusters.json (13 regional `lists` + 2 `deaf_lists` + 12 `asia_lists`)
|
||||
// into the NESTED `nodes` tree consumed by the forge-shared
|
||||
// src/components/SemanticTreeLegend.vue (S04t-D4: nested). It does NOT rebuild a
|
||||
// legend — it only shapes data.
|
||||
//
|
||||
// Tree node shape (per SemanticTreeLegend.vue:13-30):
|
||||
// { id, label, color?, count?, pop?, filter?, info?, children?: TreeNode[] }
|
||||
//
|
||||
// Output shape (26 LEAF rows total):
|
||||
// - 13 Region leaves (top level)
|
||||
// - 1 Deaf leaf (top level; the two deaf_lists collapse into ONE
|
||||
// A017 row, count 123 — R1.1 design correction)
|
||||
// - 1 Asia parent ▸ India (10 state-cluster leaves) / China (2 leaves)
|
||||
//
|
||||
// Parents (Asia / India / China) carry rolled-up count. A stable zoneKey === leaf
|
||||
// id is assigned so the paint layer's ['match', ['get','zoneKey'], …] and the
|
||||
// legend's `select` emit share one key space.
|
||||
|
||||
// Deterministic per-leaf color: even hue spread across the 26 leaves, comma-HSL
|
||||
// (Mapbox paint expressions reject the modern slash hsl() syntax). Mirrors the
|
||||
// slice-04b color rule (profiles/upg-100-list.vue:77).
|
||||
export function leafColor(i, n) {
|
||||
return `hsl(${Math.round((i * 360) / (n || 1))}, 72%, 56%)`;
|
||||
}
|
||||
|
||||
import { evaluate } from './selector-evaluator.js';
|
||||
|
||||
// Count for one leaf: regional/asia carry `count`, deaf carries the A017 total.
|
||||
function leafCount(row) {
|
||||
if (typeof row.count === 'number') return row.count;
|
||||
if (typeof row.expected_count === 'number') return row.expected_count;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ── slice 04u §1(b): population per leaf ─────────────────────────────────────
|
||||
// Sum parseInt(member.population) over a zone's MEMBER records — the same member
|
||||
// set used for `count`. population is a flat string on the live record (full
|
||||
// recache parses it to int; parseInt here is belt-and-suspenders for either form).
|
||||
function popOf(record) {
|
||||
const n = parseInt(record && record.population, 10);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
// Resolve a zone's member records, then sum their population.
|
||||
// - regional + deaf rows use the selector-evaluator (country_code / wagf_* /
|
||||
// union / affinity_bloc) — same path zone-geometry uses for `count`.
|
||||
// - ASIA rows carry a `pg_slugs` selector the evaluator intentionally REJECTS
|
||||
// (it resolves geometry by pre-tagged listId, not by evaluating slugs). For
|
||||
// population we sum records whose slug ∈ selector.values directly — no
|
||||
// evaluator change, no shared-src edit.
|
||||
function leafPopulation(row, records) {
|
||||
if (!Array.isArray(records) || records.length === 0) return undefined;
|
||||
const sel = row && row.selector;
|
||||
if (sel == null) return undefined;
|
||||
|
||||
// Asia state-clusters: explicit pg_slugs member list.
|
||||
if (sel.type === 'pg_slugs') {
|
||||
const want = new Set(Array.isArray(sel.values) ? sel.values : []);
|
||||
let sum = 0;
|
||||
for (const r of records) if (r && want.has(r.slug)) sum += popOf(r);
|
||||
return sum;
|
||||
}
|
||||
|
||||
// Everything else the evaluator handles → Set<slug> → sum member population.
|
||||
const slugs = evaluate(sel, records);
|
||||
let sum = 0;
|
||||
for (const r of records) if (r && slugs.has(r.slug)) sum += popOf(r);
|
||||
return sum;
|
||||
}
|
||||
|
||||
/**
|
||||
* buildLegendNodes(clusters, opts?) -> TreeNode[]
|
||||
* @param {object} clusters parsed clusters.json
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.deafCount=123] A017 PG count for the unified deaf leaf
|
||||
* @param {Array} [opts.records] normalized live records — when supplied, every
|
||||
* node also gets `pop` (Σ member population; parents roll up).
|
||||
* Omit to keep the prior count-only behaviour (pop undefined).
|
||||
* @returns {Array} nested TreeNode[] (3 top-level groups, 26 leaves total)
|
||||
*/
|
||||
// ★ THE UNIFIED DEAF LEAF'S SELECTOR, DEFINED ONCE. The legend collapses the two deaf_lists into one
|
||||
// leaf (affinity bloc A017) whose selector exists nowhere in clusters.json, so every consumer that
|
||||
// RESOLVES zones had to know it — and each typed its own copy of the literal (the map in
|
||||
// upg-100-list.vue, generate.mjs, packet.mjs). The browser packet path had no copy at all, which is why
|
||||
// the Deaf packet could not be printed from the UI: the zone lookup found nothing and returned silently.
|
||||
export const DEAF_SELECTOR = { type: 'affinity_bloc', rop1: 'A017' };
|
||||
|
||||
// The catalogue key (poster.<key>) that names each REGION_OF grouping in the reader's language. The
|
||||
// English values stay the grouping identity (ids are derived from them), so the heading is translated
|
||||
// at render: here for the legend via opts.regionTitle, and by poster-wall.vue for the wall's sections.
|
||||
// One table for both surfaces, so a region added here cannot be translated on one and English on the other.
|
||||
export const REGION_KEY = {
|
||||
'Africa': 'region_africa',
|
||||
'Middle East': 'region_middle_east',
|
||||
'Central Asia': 'region_central_asia',
|
||||
'Southeast Asia + Oceania': 'region_southeast_asia_oceania',
|
||||
'China + Mongolia': 'region_china_mongolia',
|
||||
'India': 'region_india',
|
||||
'Other': 'region_other',
|
||||
'Americas': 'region_americas',
|
||||
'Europe': 'region_europe',
|
||||
'Deaf': 'region_deaf',
|
||||
};
|
||||
|
||||
export function buildLegendNodes(clusters, opts = {}) {
|
||||
const deafCount = opts.deafCount ?? 123;
|
||||
const records = Array.isArray(opts.records) ? opts.records : null;
|
||||
// ★ OPTIONAL GENERATED TITLE (2026-09-27). `opts.title(zone)` returns a localized title
|
||||
// composed from already-translated parts, or NULL for a zone whose title cannot be generated — see
|
||||
// data/zone-title.js. Passing nothing keeps every hand-written English label exactly as it was, which
|
||||
// is why this is an option rather than a rewrite: the legend, the poster and the packet adopt it
|
||||
// independently, and a caller that has no records yet (records:null) still builds a correct tree.
|
||||
// ⚠ NULL MEANS "KEEP YOURS", NOT "NO TITLE". 18 of 28 zones decline (their labels are sub-national or
|
||||
// taxonomic, and Intl has no subdivisions), so the fallback is the normal path, not the error path.
|
||||
const titleOf = typeof opts.title === 'function'
|
||||
? (zone, fallback) => opts.title(zone) || fallback
|
||||
: (_zone, fallback) => fallback;
|
||||
// `opts.regionTitle(english)` names a region section in the reader's language (see REGION_KEY); the
|
||||
// node id stays derived from the English grouping key, so selection and ids never depend on a locale.
|
||||
const regionTitleOf = typeof opts.regionTitle === 'function'
|
||||
? (english) => opts.regionTitle(english) || english
|
||||
: (english) => english;
|
||||
const lists = clusters.lists || [];
|
||||
const asia = clusters.asia_lists || [];
|
||||
// slice 09b-6 §1(a): wire the 7 uncovered "Frontier Asia" zones (Nepal,
|
||||
// Himalaya, SA-West, Central-Asia, Mongolia, Japan, Scatter). They carry a
|
||||
// `country_codes` selector → resolved by zone-geometry's REGIONAL/ADM0 path
|
||||
// (same as the 13 regional rows), NOT the asia ADM1 path. Default empty so
|
||||
// pre-09b-6 clusters (no uncovered_lists) keep the prior 26-leaf shape.
|
||||
const uncovered = clusters.uncovered_lists || [];
|
||||
|
||||
// Deaf row's selector is implicit (affinity bloc A017), so population can be
|
||||
// summed the same way as the regional rows. Module-level and exported — see DEAF_SELECTOR below.
|
||||
|
||||
// ── ordered leaf list (drives color index space) ───────────────────────────
|
||||
// order: 13 regional, 1 deaf, asia (10 India + 2 China), then the Frontier-Asia
|
||||
// uncovered rows — matches the legend reading order and the paint match arms.
|
||||
// ★ COUNTS ARE DERIVED FROM clusters.json, never asserted here (2026-09-21). This comment
|
||||
// used to claim "33 leaves" and "7 Frontier-Asia uncovered"; the shipped clusters.json
|
||||
// carries 13 + 12 + 2 + 1 deaf = 28, with exactly TWO uncovered rows
|
||||
// (uncovered-scatter, uncovered-nas-a). The stale 26-vs-reality gap also shipped as a
|
||||
// "27/26 zones" badge in upg-100-list.vue. A hardcoded count next to data that grows is a
|
||||
// drift waiting to happen — read the arrays instead.
|
||||
const indiaRows = asia.filter((a) => a.id.startsWith('asia-india'));
|
||||
const chinaRows = asia.filter((a) => a.id.startsWith('asia-china'));
|
||||
const orderedLeafIds = [
|
||||
...lists.map((l) => l.id),
|
||||
'deaf',
|
||||
...indiaRows.map((r) => r.id),
|
||||
...chinaRows.map((r) => r.id),
|
||||
...uncovered.map((u) => u.id),
|
||||
];
|
||||
const N = orderedLeafIds.length; // 33 (26 + 7 uncovered)
|
||||
const colorOf = (id) => leafColor(orderedLeafIds.indexOf(id), N);
|
||||
|
||||
// `pop` is attached only when records were supplied; undefined otherwise so the
|
||||
// prior count-only callers are unaffected. `withPop` adds the key conditionally.
|
||||
const withPop = (node, row, selector) => {
|
||||
if (!records) return node;
|
||||
const sel = selector ?? (row && row.selector);
|
||||
node.pop = leafPopulation({ selector: sel }, records);
|
||||
return node;
|
||||
};
|
||||
// Sum child pops (parent roll-up, S04u-D3). Returns undefined when no records.
|
||||
const sumPop = (children) =>
|
||||
records ? children.reduce((s, c) => s + (c.pop || 0), 0) : undefined;
|
||||
|
||||
// ── 13 Region leaves ───────────────────────────────────────────────────────
|
||||
const regionLeaves = lists.map((l) =>
|
||||
withPop(
|
||||
{
|
||||
id: l.id,
|
||||
zoneKey: l.id,
|
||||
label: titleOf(l, l.label),
|
||||
// Carry the province enumeration the region-name label replaced.
|
||||
...(l.provinceLabel ? { provinceLabel: l.provinceLabel } : {}),
|
||||
color: colorOf(l.id),
|
||||
count: leafCount(l),
|
||||
},
|
||||
l,
|
||||
),
|
||||
);
|
||||
|
||||
// ── 1 Deaf leaf (unified A017; R1.1) ───────────────────────────────────────
|
||||
// D2: deaf pop = Σ population of the 123 A017 members. This WILL double-count
|
||||
// vs the geographic rows (those members also fall inside a regional zone) —
|
||||
// the design-approved deaf double-count (slice 08). Per-zone equality still
|
||||
// holds; only a naïve global sum would over-count, which the proof avoids.
|
||||
const deafLeaf = withPop(
|
||||
{
|
||||
id: 'deaf',
|
||||
zoneKey: 'deaf',
|
||||
// Named like every other leaf: opts.title reads the bloc's name from the strings it was given
|
||||
// (zone-title.js, zone_affinity_A017); the English label is only the no-title fallback.
|
||||
label: titleOf({ id: 'deaf', selector: DEAF_SELECTOR }, 'Deaf (affinity bloc A017)'),
|
||||
color: colorOf('deaf'),
|
||||
count: deafCount,
|
||||
info: 'Unified deaf row — affinity bloc rop1=A017 (collapses the two deaf_lists; zero-delta vs old deaf_predicate). Population double-counts vs geographic rows (design-approved, slice 08).',
|
||||
},
|
||||
null,
|
||||
DEAF_SELECTOR,
|
||||
);
|
||||
|
||||
// ── Asia parent ▸ India / China ────────────────────────────────────────────
|
||||
const mkAsiaLeaf = (r) =>
|
||||
withPop(
|
||||
{
|
||||
id: r.id,
|
||||
zoneKey: r.id,
|
||||
label: titleOf(r, r.label),
|
||||
// The province enumeration the region-name label replaced. Carried through
|
||||
// like adm0/provinces above — a node rebuilt field-by-field silently drops
|
||||
// anything not named, which is exactly how adm0 and population were lost.
|
||||
...(r.provinceLabel ? { provinceLabel: r.provinceLabel } : {}),
|
||||
color: colorOf(r.id),
|
||||
count: leafCount(r),
|
||||
// MIXED GEOMETRY: carry the optional `adm0` ISO3 list through to the node.
|
||||
// zone-geometry.js ALREADY supports it — an Asia zone may union whole ADM0
|
||||
// countries alongside its ADM1 provinces, and its comment names the Nepal
|
||||
// case explicitly — but the node was rebuilt field-by-field here and
|
||||
// silently dropped adm0, so the hook could never fire.
|
||||
...(Array.isArray(r.adm0) && r.adm0.length ? { adm0: r.adm0 } : {}),
|
||||
// Same passthrough reason as adm0: the province enumeration is data the
|
||||
// poster header prints, and rebuilding the node field-by-field drops it.
|
||||
...(Array.isArray(r.provinces) && r.provinces.length ? { provinces: r.provinces } : {}),
|
||||
},
|
||||
r,
|
||||
);
|
||||
const indiaChildren = indiaRows.map(mkAsiaLeaf);
|
||||
const chinaChildren = chinaRows.map(mkAsiaLeaf);
|
||||
|
||||
// (The India / China / "Asia (admin-1)" and "Frontier Asia (uncovered)" PARENT nodes that used to be
|
||||
// built here are gone: since the regional grouping below, the tree is assembled from the LEAVES, so those
|
||||
// parents were constructed and never returned — dead code whose English labels read as reader text.)
|
||||
|
||||
// ── Frontier Asia parent ▸ 7 uncovered zones (09b-6 §1a, W-D1) ─────────────
|
||||
// Each uncovered row carries a `country_codes` selector → ADM0 polygons
|
||||
// (Nepal→NPL, SA-West→AFG+PAK, …). Same leaf shape as regional rows so the
|
||||
// tapestry's single ['match',['get','zoneKey'],…] paints them with the rest.
|
||||
const mkUncoveredLeaf = (u) =>
|
||||
withPop(
|
||||
{
|
||||
id: u.id,
|
||||
zoneKey: u.id,
|
||||
label: titleOf(u, u.label),
|
||||
// Carry the province enumeration the region-name label replaced.
|
||||
...(u.provinceLabel ? { provinceLabel: u.provinceLabel } : {}),
|
||||
color: colorOf(u.id),
|
||||
count: leafCount(u),
|
||||
},
|
||||
u,
|
||||
);
|
||||
const uncoveredChildren = uncovered.map(mkUncoveredLeaf);
|
||||
|
||||
// ── REGIONAL GROUPING — the SAME sections the poster page uses ─────────────
|
||||
// The legend used to emit 14 LOOSE LEAVES at top level (West Africa, Sudan,
|
||||
// Brazil, Laos, …) beside two odd parents, "Asia (admin-1)" and "Frontier Asia
|
||||
// (uncovered)". That grouping read poorly beside the poster page, so the legend
|
||||
// now uses the poster page's own sections.
|
||||
//
|
||||
// So the same 28 zones now group into the poster page's nine regions. The
|
||||
// mapping is duplicated from poster-wall.vue rather than imported because this
|
||||
// file is a PURE SHAPER with no imports by design — but the two lists must stay
|
||||
// in step, so any edit here belongs in poster-wall.vue's REGION_OF too.
|
||||
const REGION_OF = {
|
||||
'west-africa': 'Africa', 'sudan': 'Africa', 'east-africa': 'Africa',
|
||||
'central-africa': 'Africa', 'southern-africa-islands': 'Africa',
|
||||
'maghreb-egypt': 'Middle East', 'middle-east-core': 'Middle East',
|
||||
'uncovered-nas-a': 'Central Asia',
|
||||
'laos': 'Southeast Asia + Oceania', 'oceania-indonesia': 'Southeast Asia + Oceania',
|
||||
'mainland-se-asia-philippines': 'Southeast Asia + Oceania',
|
||||
'brazil': 'Americas', 'andean-caribbean': 'Americas', 'uncovered-scatter': 'Americas',
|
||||
'europe': 'Europe', 'deaf': 'Deaf', 'deaf-old-world': 'Deaf',
|
||||
};
|
||||
const regionFor = (id) =>
|
||||
REGION_OF[id]
|
||||
|| (String(id).startsWith('asia-india-') ? 'India'
|
||||
: String(id).startsWith('asia-china-') ? 'China + Mongolia'
|
||||
: 'Other');
|
||||
|
||||
// Every leaf the tree can paint, flattened out of its source block. India and
|
||||
// China leaves are indiaChildren / chinaChildren; the rest are already flat.
|
||||
const allLeaves = [
|
||||
...regionLeaves,
|
||||
deafLeaf,
|
||||
...indiaChildren,
|
||||
...chinaChildren,
|
||||
...uncoveredChildren,
|
||||
].filter(Boolean);
|
||||
|
||||
const byRegion = new Map();
|
||||
for (const leaf of allLeaves) {
|
||||
const r = regionFor(leaf.id || leaf.zoneKey);
|
||||
if (!byRegion.has(r)) byRegion.set(r, []);
|
||||
byRegion.get(r).push(leaf);
|
||||
}
|
||||
|
||||
// Sections ordered by TOTAL people groups (largest first), leaves within a
|
||||
// section by their own count — the densest prayer need leads at both levels,
|
||||
// matching the poster page exactly.
|
||||
const tops = [...byRegion.entries()]
|
||||
.map(([label, children]) => {
|
||||
children.sort((a, b) => (b.count || 0) - (a.count || 0));
|
||||
const node = {
|
||||
// ★ FOLD DIACRITICS BEFORE STRIPPING, or the mark is DELETED and the id silently becomes a
|
||||
// different id: Gujarāt -> 'region-gujar-t', not 'region-gujarat'. Today's labels are the
|
||||
// fixed ASCII set from regionFor(), so nothing is broken — but the whole point is that
|
||||
// a fork brings its OWN people-group set and names its own regions, and the first accented
|
||||
// label would split one region into two with no error and no empty state. Same class as a measured
|
||||
// join of admin-to-list.json: a naive fold matched 514 of 1009 rows and lost THREE
|
||||
// whole zones, while every surviving zone still looked populated. Fix the class, not the six
|
||||
// spellings we happen to know.
|
||||
id: 'region-' + label.normalize('NFD').replace(/\p{Mn}/gu, '').toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
||||
label: regionTitleOf(label),
|
||||
count: children.reduce((n, c) => n + (c.count || 0), 0),
|
||||
children,
|
||||
};
|
||||
if (records) node.pop = sumPop(children);
|
||||
return node;
|
||||
})
|
||||
.sort((a, b) => (b.count || 0) - (a.count || 0));
|
||||
|
||||
// ── L15 consumer-shape assertion (09b-6 §1b) ───────────────────────────────
|
||||
// Every RENDERED LEAF — regardless of source block (lists / asia_lists /
|
||||
// uncovered_lists) — must carry the same required fields so zone-geometry and
|
||||
// the paint match expression read a uniform shape. Fail loud (throw) rather
|
||||
// than silently paint a malformed zone.
|
||||
assertUniformLeafShape(leafZones(tops), { withPop: !!records });
|
||||
|
||||
return tops;
|
||||
}
|
||||
|
||||
// L15 (09b-6 §1b): assert every leaf zone has the uniform consumer shape.
|
||||
// Required on ALL leaves: id, zoneKey, label, color (string), count (number).
|
||||
// When records were supplied, `pop` must also be a number on every leaf.
|
||||
// Parents (asia/india/china/frontier-asia) are intentionally NOT leaves and are
|
||||
// excluded by leafZones() — they carry roll-ups, not a zoneKey/selector.
|
||||
export function assertUniformLeafShape(leaves, { withPop = false } = {}) {
|
||||
const bad = [];
|
||||
for (const z of leaves || []) {
|
||||
const errs = [];
|
||||
if (typeof z.id !== 'string' || !z.id) errs.push('id');
|
||||
if (typeof z.zoneKey !== 'string' || !z.zoneKey) errs.push('zoneKey');
|
||||
if (typeof z.label !== 'string' || !z.label) errs.push('label');
|
||||
if (typeof z.color !== 'string' || !z.color) errs.push('color');
|
||||
if (typeof z.count !== 'number') errs.push('count');
|
||||
if (withPop && typeof z.pop !== 'number') errs.push('pop');
|
||||
if (errs.length) bad.push({ id: z.id || '(no-id)', errs });
|
||||
}
|
||||
if (bad.length) {
|
||||
// A developer assertion: the text is composed here, inside the throw, so it is plainly not reader copy.
|
||||
throw new Error(
|
||||
`legend-tree L15: ${bad.length} leaf node(s) violate the uniform consumer shape:\n ` +
|
||||
bad.map((b) => `${b.id}: missing/!type [${b.errs.join(', ')}]`).join('\n '),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Flatten a nodes tree to its LEAF nodes (rows that own a zoneKey/paint zone).
|
||||
export function leafZones(nodes) {
|
||||
const out = [];
|
||||
const walk = (arr) => {
|
||||
for (const n of arr) {
|
||||
if (n.children && n.children.length) walk(n.children);
|
||||
else out.push(n);
|
||||
}
|
||||
};
|
||||
walk(nodes || []);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every printable zone — the legend's leaves — each carrying the selector that resolves its groups.
|
||||
*
|
||||
* ★ THE ENUMERATION IS THE LEGEND'S, NOT clusters.json's BUCKETS. Spreading lists/asia_lists/
|
||||
* uncovered_lists gives 27 zones and silently drops `deaf`, which the legend shows as one row; a
|
||||
* packet lookup built that way cannot print Deaf, and a count built that way prints "one of 27" on
|
||||
* page 1 and at the triangle's tip while 28 packets exist. Leaves do not carry selectors, so they are
|
||||
* attached here by id, the unified deaf leaf taking DEAF_SELECTOR.
|
||||
* Returns COPIES; the legend's own node objects are left untouched.
|
||||
*
|
||||
* @param {object} clusters the zone catalogue (clusters.json shape)
|
||||
* @param {object} [opts] passed through to buildLegendNodes
|
||||
* @returns {Array<{id:string, zoneKey:string, label:string, selector:object}>}
|
||||
*/
|
||||
export function zonesWithSelectors(clusters, opts = {}) {
|
||||
const byId = {};
|
||||
for (const k of ['lists', 'asia_lists', 'uncovered_lists']) {
|
||||
for (const z of (clusters && clusters[k]) || []) byId[z.id] = z.selector;
|
||||
}
|
||||
byId.deaf = DEAF_SELECTOR;
|
||||
return leafZones(buildLegendNodes(clusters, opts))
|
||||
.map((z) => ({ ...z, zoneKey: z.zoneKey || z.id, selector: byId[z.id] }));
|
||||
}
|
||||
|
||||
export default buildLegendNodes;
|
||||
165
library/packet/upg100Zones/selector-evaluator.js
Normal file
165
library/packet/upg100Zones/selector-evaluator.js
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
// selector-evaluator.js — slice 01 (my-upg-100-list)
|
||||
//
|
||||
// Pure function: evaluate(selector, records) -> Set<slug>
|
||||
//
|
||||
// `records` are assumed ALREADY NORMALIZED by normalize.js, i.e. the nested
|
||||
// { value, label } fields (country_code, wagf_region, wagf_block, primary_language)
|
||||
// have been flattened to flat `.value` strings. The evaluator therefore compares
|
||||
// flat record fields against flat selector values — no .value access here.
|
||||
//
|
||||
// Handles the 6 selector types documented in data/clusters.source.md + the qa file:
|
||||
// 1. { type: "country_code", value: "SDN" }
|
||||
// -> records whose country_code === value
|
||||
// 2. { type: "country_codes", values: [...] } (note plural `values`)
|
||||
// -> records whose country_code ∈ values
|
||||
// 3. { type: "wagf_block", value: "west_africa" }
|
||||
// -> records whose wagf_block === value
|
||||
// 4. { type: "wagf_region", value: "europe" }
|
||||
// -> records whose wagf_region === value
|
||||
// 5. { type: "union", selectors: [ <sel>, <sel>, ... ] }
|
||||
// -> union of evaluating each sub-selector
|
||||
// 6. { type: "deaf_predicate", regions: [...] }
|
||||
// -> records where (slug OR name contains "deaf", case-insensitive)
|
||||
// AND wagf_region ∈ regions
|
||||
// 7. { type: "affinity_bloc", rop1: "A017" } (slice 04t §1g)
|
||||
// -> records where r.rop1.value === rop1
|
||||
// NOTE: rop1 is NOT in normalize.js NESTED_FIELDS, so it stays a nested
|
||||
// { value, label } dict on the record. This selector reads `.value`
|
||||
// directly (and tolerates an already-flat rop1 string). The Deaf row's
|
||||
// bloc code is "A017" (123 PGs, == the old deaf_predicate slug set, zero
|
||||
// delta but field-authoritative). 📁 affinity-block.js:36, sources.json:68
|
||||
//
|
||||
// Design contract:
|
||||
// - returns a Set<slug> (always; never null/undefined)
|
||||
// - a bogus VALUE (e.g. country_code "ZZZ") -> empty Set, never a crash
|
||||
// - an UNKNOWN selector type -> throw a clear Error (never silently empty)
|
||||
|
||||
// Collect the slug of a record. Records without a slug are skipped (cannot key a Set entry).
|
||||
function slugOf(record) {
|
||||
if (record == null || typeof record !== 'object') return null;
|
||||
return record.slug ?? null;
|
||||
}
|
||||
|
||||
// True if the haystack string contains "deaf" (case-insensitive).
|
||||
function containsDeaf(s) {
|
||||
return typeof s === 'string' && s.toLowerCase().includes('deaf');
|
||||
}
|
||||
|
||||
// Read the affinity-bloc code off a record's `rop1` field. rop1 is intentionally
|
||||
// NOT flattened by normalize.js, so it normally arrives as { value, label }; this
|
||||
// also tolerates an already-flat string (defensive).
|
||||
function rop1Code(record) {
|
||||
const f = record == null ? null : record.rop1;
|
||||
if (f == null) return null;
|
||||
if (typeof f === 'object') return f.value ?? null;
|
||||
return f; // already-flat string
|
||||
}
|
||||
|
||||
/**
|
||||
* evaluate(selector, records) -> Set<slug>
|
||||
* @param {object} selector one of the 6 documented selector shapes
|
||||
* @param {Array<object>} records ALREADY-normalized UPG records
|
||||
* @returns {Set<string>} set of matching slugs
|
||||
* @throws {Error} on an unknown / malformed selector type
|
||||
*/
|
||||
export function evaluate(selector, records) {
|
||||
const out = new Set();
|
||||
|
||||
if (selector == null || typeof selector !== 'object' || typeof selector.type !== 'string') {
|
||||
throw new Error(
|
||||
`selector-evaluator: malformed selector (expected an object with a string \`type\`), got: ${JSON.stringify(selector)}`,
|
||||
);
|
||||
}
|
||||
const rows = Array.isArray(records) ? records : [];
|
||||
|
||||
switch (selector.type) {
|
||||
case 'country_code': {
|
||||
const want = selector.value;
|
||||
for (const r of rows) {
|
||||
if (r && r.country_code === want) {
|
||||
const slug = slugOf(r);
|
||||
if (slug != null) out.add(slug);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case 'country_codes': {
|
||||
// plural: match against the `values` array
|
||||
const wanted = new Set(Array.isArray(selector.values) ? selector.values : []);
|
||||
for (const r of rows) {
|
||||
if (r && wanted.has(r.country_code)) {
|
||||
const slug = slugOf(r);
|
||||
if (slug != null) out.add(slug);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case 'wagf_block': {
|
||||
const want = selector.value;
|
||||
for (const r of rows) {
|
||||
if (r && r.wagf_block === want) {
|
||||
const slug = slugOf(r);
|
||||
if (slug != null) out.add(slug);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case 'wagf_region': {
|
||||
const want = selector.value;
|
||||
for (const r of rows) {
|
||||
if (r && r.wagf_region === want) {
|
||||
const slug = slugOf(r);
|
||||
if (slug != null) out.add(slug);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case 'union': {
|
||||
const subs = Array.isArray(selector.selectors) ? selector.selectors : [];
|
||||
for (const sub of subs) {
|
||||
// recurse; sub-selector errors (unknown type) propagate up by design
|
||||
const subSet = evaluate(sub, rows);
|
||||
for (const slug of subSet) out.add(slug);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case 'deaf_predicate': {
|
||||
const regions = new Set(Array.isArray(selector.regions) ? selector.regions : []);
|
||||
for (const r of rows) {
|
||||
if (!r) continue;
|
||||
const isDeaf = containsDeaf(r.slug) || containsDeaf(r.name);
|
||||
if (isDeaf && regions.has(r.wagf_region)) {
|
||||
const slug = slugOf(r);
|
||||
if (slug != null) out.add(slug);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case 'affinity_bloc': {
|
||||
// slice 04t §1g — Deaf row: records whose rop1.value === selector.rop1 (e.g. "A017").
|
||||
const want = selector.rop1;
|
||||
for (const r of rows) {
|
||||
if (!r) continue;
|
||||
if (rop1Code(r) === want) {
|
||||
const slug = slugOf(r);
|
||||
if (slug != null) out.add(slug);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(
|
||||
`selector-evaluator: unknown selector type "${selector.type}" ` +
|
||||
`(supported: country_code, country_codes, wagf_block, wagf_region, union, deaf_predicate, affinity_bloc)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default evaluate;
|
||||
138
library/packet/upg100Zones/zone-geometry.js
Normal file
138
library/packet/upg100Zones/zone-geometry.js
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
// zone-geometry.js — slice 04t §1(b)
|
||||
//
|
||||
// resolveZoneGeometry(zone, deps) -> FeatureCollection
|
||||
//
|
||||
// Per legend row ("zone"), returns the polygons it needs, each feature stamped
|
||||
// with the row's `zoneKey` + `zoneColor` so the tapestry's single Mapbox
|
||||
// ['match', ['get','zoneKey'], …] fill expression can paint all 26 at once.
|
||||
//
|
||||
// Resolution rules (S04t-D1/D2/D3):
|
||||
// - REGIONAL (13) + DEAF (1): ADM0 country polygons.
|
||||
// member countries = evaluate(zone.selector, records) -> slugs
|
||||
// -> distinct country_code over those member records
|
||||
// -> ADM0 features whose props.iso3 ∈ that set.
|
||||
// (deaf's selector is affinity_bloc/A017 → its 123 members' country_codes.)
|
||||
// - ASIA (12): ADM1 features. Asia rows are state-CLUSTERS, so SELECT-MANY:
|
||||
// every ADM1 feature whose props.listId === zone.id (the on-disk
|
||||
// gb-IND-ADM1 / gb-CHN-ADM1 files are pre-tagged with listId).
|
||||
//
|
||||
// Design contract:
|
||||
// - always returns a FeatureCollection (never null); empty features ⇒ a zone
|
||||
// the proof will FLAG (every zone must resolve ≥1 polygon).
|
||||
// - separate polygons sharing one zone color (S04t-D3); no dissolve here.
|
||||
// - pure: no Mapbox / DOM; testable in plain Node.
|
||||
|
||||
import { evaluate } from './selector-evaluator.js';
|
||||
|
||||
const EMPTY_FC = () => ({ type: 'FeatureCollection', features: [] });
|
||||
|
||||
// Distinct country_code (ISO3) set for a zone's member UPGs.
|
||||
// `records` are normalized (country_code flat); rop1 stays nested (selector reads it).
|
||||
export function memberCountryCodes(zone, records) {
|
||||
const slugs = evaluate(zone.selector, records);
|
||||
const iso = new Set();
|
||||
for (const r of records) {
|
||||
if (r && slugs.has(r.slug) && r.country_code) iso.add(r.country_code);
|
||||
}
|
||||
return iso;
|
||||
}
|
||||
|
||||
// True for the 12 Asia rows (admin-1 resolution).
|
||||
export function isAsiaZone(zone) {
|
||||
return typeof zone.id === 'string' && zone.id.startsWith('asia-');
|
||||
}
|
||||
|
||||
function stamp(feature, zone) {
|
||||
return {
|
||||
type: 'Feature',
|
||||
properties: {
|
||||
...(feature.properties || {}),
|
||||
zoneKey: zone.zoneKey ?? zone.id,
|
||||
zoneColor: zone.color ?? zone.zoneColor ?? '#888',
|
||||
zoneLabel: zone.label,
|
||||
...(zone.id === 'deaf' ? { hatch: true } : {}),
|
||||
},
|
||||
geometry: feature.geometry,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* resolveZoneGeometry(zone, deps) -> FeatureCollection
|
||||
* @param {object} zone a leaf zone { id, zoneKey, label, color, selector? }
|
||||
* @param {object} deps
|
||||
* @param {Array} deps.records normalized UPG records (rop1-bearing for deaf)
|
||||
* @param {object} deps.adm0 FeatureCollection of ADM0 (features props.iso3)
|
||||
* @param {object} deps.adm1Ind FeatureCollection gb-IND-ADM1 (props.listId)
|
||||
* @param {object} deps.adm1Chn FeatureCollection gb-CHN-ADM1 (props.listId)
|
||||
*/
|
||||
export function resolveZoneGeometry(zone, deps) {
|
||||
const { records = [], adm0, adm1Ind, adm1Chn } = deps || {};
|
||||
const fc = EMPTY_FC();
|
||||
|
||||
if (isAsiaZone(zone)) {
|
||||
// ADM1 select-many: every feature pre-tagged with this row's listId.
|
||||
const pools = [adm1Ind, adm1Chn].filter(Boolean);
|
||||
for (const pool of pools) {
|
||||
for (const f of pool.features || []) {
|
||||
if (f.properties && f.properties.listId === zone.id) fc.features.push(stamp(f, zone));
|
||||
}
|
||||
}
|
||||
// slice 09b-8 §1(c): the MIXED-GEOMETRY hook. An Asia zone may ALSO carry an
|
||||
// optional `adm0` array of ISO3 codes (the Nepal-frontier cluster: ['NPL']).
|
||||
// When present, union the matching ADM0 country polygons into the SAME zone so
|
||||
// a single legend row paints both its India states (ADM1) and Nepal (ADM0) as
|
||||
// one zone. Additive + opt-in: every other Asia zone lacks `adm0`, so this loop
|
||||
// is skipped and their resolution is byte-identical to 09b-7.
|
||||
if (Array.isArray(zone.adm0) && zone.adm0.length && adm0) {
|
||||
const want = new Set(zone.adm0);
|
||||
for (const f of adm0.features || []) {
|
||||
if (f.properties && want.has(f.properties.iso3)) fc.features.push(stamp(f, zone));
|
||||
}
|
||||
}
|
||||
return fc;
|
||||
}
|
||||
|
||||
// Regional / deaf: ADM0 by member country_codes.
|
||||
if (!zone.selector) return fc; // a parent (asia/india/china) — no geometry of its own
|
||||
const iso = memberCountryCodes(zone, records);
|
||||
if (adm0 && iso.size) {
|
||||
for (const f of adm0.features || []) {
|
||||
if (f.properties && iso.has(f.properties.iso3)) fc.features.push(stamp(f, zone));
|
||||
}
|
||||
}
|
||||
return fc;
|
||||
}
|
||||
|
||||
/**
|
||||
* resolveAllZones(zones, deps) -> { tapestry: FeatureCollection, perZone: Map }
|
||||
* Builds ONE FeatureCollection (all zones, each feature carrying its zoneKey) for
|
||||
* the single-source tapestry paint, plus a per-zone resolved-count map for the proof.
|
||||
*/
|
||||
export function resolveAllZones(zones, deps) {
|
||||
const tapestry = EMPTY_FC();
|
||||
const perZone = new Map();
|
||||
// deaf-polygon removal (design fix, an earlier fix): the `deaf` affinity overlay is an
|
||||
// ADM0 double-stamp — its A017 selector re-paints land already owned by the 13 geographic
|
||||
// zones (16 of oceania-indonesia's 18 Pacific ISO3 alone). That double-stamp showed deaf
|
||||
// TWICE and underlapped every other region in blue (#18ffff), and the overlapping deaf land
|
||||
// didn't color correctly until click-isolate re-filtered the source to one zone. The earlier
|
||||
// 09b draw-order mitigation (resolve deaf FIRST) only re-stacked the same overlapping geometry;
|
||||
// the directive is to REMOVE the deaf polygon/region layer entirely. So we EXCLUDE
|
||||
// the deaf zone from the tapestry (region-fill) FeatureCollection here. deaf stays fully in
|
||||
// the LIST/legend (built by legend-tree.js buildLegendNodes — untouched) and in the PINS
|
||||
// (built from slugToZone selectors in upg-100-list.vue — untouched). Only the polygon/region
|
||||
// fill loses deaf, which removes the overlap and the until-click coloring bug.
|
||||
for (const zone of zones) {
|
||||
if (zone.id === 'deaf') {
|
||||
// record 0 polygons for the proof's per-zone map; deaf intentionally has no region fill now.
|
||||
perZone.set(zone.zoneKey ?? zone.id, 0);
|
||||
continue;
|
||||
}
|
||||
const fc = resolveZoneGeometry(zone, deps);
|
||||
perZone.set(zone.zoneKey ?? zone.id, fc.features.length);
|
||||
for (const f of fc.features) tapestry.features.push(f);
|
||||
}
|
||||
return { tapestry, perZone };
|
||||
}
|
||||
|
||||
export default resolveZoneGeometry;
|
||||
41
library/packet/upg100Zones/zone-sheet-print.js
Normal file
41
library/packet/upg100Zones/zone-sheet-print.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* zone-sheet-print.js — a UPG-100 zone's map sheet, GENERATED in the browser at the click (the print
|
||||
* standard: generate, store only previews). One helper for every surface that prints a zone (the map's
|
||||
* legend and packets, the poster wall), so they cannot draw the "same" sheet two ways.
|
||||
*
|
||||
* The zone and its pins come from zone-sheet.js — the SAME assignment generate.mjs makes — the geometry
|
||||
* from the full-resolution boundaries generate.mjs draws, and the drawing from the library's generator.
|
||||
*
|
||||
* const zoneSheet = makeZoneSheet({ allRecords, assetsBase: () => base, profileConfig })
|
||||
* usePosterPrints({ …, sheet: zoneSheet }) // (item, format) -> html
|
||||
*
|
||||
* Library imports are RELATIVE, not `@map/…`: the same file runs under the bundler, Node and the
|
||||
* browser-equivalence check (bun), and only a relative path resolves identically in all three.
|
||||
*/
|
||||
import { upg100Zones, assignPins, seriesFacts } from './zone-sheet.js'
|
||||
import { resolveZoneGeometry } from './zone-geometry.js'
|
||||
import { zonesForList } from './zones-for-list.js'
|
||||
import { resolvePeopleGroupList } from '../../lists/peopleGroupList.js'
|
||||
|
||||
// `date` pins the stamp printed on the sheet (default: today) — how a check reproduces a sheet exactly.
|
||||
export function makeZoneSheet({ allRecords, assetsBase, profileConfig, date = null }) {
|
||||
return async function zoneSheet(item, format) {
|
||||
const { generateSheet, loadBoundaries } = await import('../../packet/posterSheets.js')
|
||||
const [{ ISO3_NAME }, records, bounds] = await Promise.all([
|
||||
import('../../data/iso3-name.js'), allRecords(), loadBoundaries(assetsBase()),
|
||||
])
|
||||
const zones = upg100Zones(zonesForList(resolvePeopleGroupList(profileConfig)), records)
|
||||
const zone = zones.find((z) => z.id === (item && item.id))
|
||||
if (!zone) return '' // a selection or an unknown id has no zone sheet; the caller falls through
|
||||
const pins = assignPins(zones, records).get(zone.zoneKey) || []
|
||||
const zoneFC = resolveZoneGeometry(zone, { records, ...bounds })
|
||||
// ★ THE PACKET'S PAGE 1 IS THE FULL UPG-100 POSTER (2026-10-02), not the map-only sheet: the letter-size
|
||||
// poster with its title, the scripture banner, the three-column header and the footer. The packet asks
|
||||
// for 'packet'; this series answers with the full letter sheet, which is the same 1500x1941 page.
|
||||
const page1 = format === 'packet'
|
||||
return generateSheet({
|
||||
zone, pins, zoneFC, format: page1 ? 'letter' : format, ...(page1 ? { sparse: false } : {}), seriesId: 'upg100', ISO3_NAME, adm0: bounds.adm0, adm1Ind: bounds.adm1Ind, adm1Chn: bounds.adm1Chn,
|
||||
series: { ...seriesFacts(zones), index: zones.indexOf(zone) + 1 }, date, assetsBase: assetsBase(),
|
||||
})
|
||||
}
|
||||
}
|
||||
58
library/packet/upg100Zones/zone-sheet.js
Normal file
58
library/packet/upg100Zones/zone-sheet.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* zone-sheet.js — which people groups a map sheet pins, decided ONCE for the Node generator and the
|
||||
* browser alike.
|
||||
*
|
||||
* generate.mjs used to hold this in its main routine: attach each leaf zone's selector, then give every
|
||||
* people group to the FIRST zone that claims it and pin it there. A browser that draws the same sheet
|
||||
* must make exactly the same assignment, or the "same" poster pins a group in a different zone
|
||||
* depending on where it was printed. So both import this module; neither keeps its own copy.
|
||||
*
|
||||
* Pure: no fs, no fetch, no DOM. The caller supplies the records (with latitude/longitude).
|
||||
*/
|
||||
import { buildLegendNodes, leafZones, DEAF_SELECTOR } from './legend-tree.js';
|
||||
import { evaluate } from './selector-evaluator.js';
|
||||
|
||||
/** The UPG-100 leaf zones, each carrying its selector (leaves do not carry it; the catalogue does). */
|
||||
export function upg100Zones(clusters, records) {
|
||||
const zones = leafZones(buildLegendNodes(clusters, { records }));
|
||||
const byId = {};
|
||||
for (const l of clusters.lists || []) byId[l.id] = l.selector;
|
||||
for (const a of clusters.asia_lists || []) byId[a.id] = a.selector;
|
||||
for (const u of clusters.uncovered_lists || []) byId[u.id] = u.selector;
|
||||
byId['deaf'] = DEAF_SELECTOR; // defined once in legend-tree.js
|
||||
for (const z of zones) z.selector = byId[z.id];
|
||||
return zones;
|
||||
}
|
||||
|
||||
/**
|
||||
* zoneKey -> the pins of that zone. A group belongs to the FIRST zone (in `zones` order) whose selector
|
||||
* claims it; groups without coordinates are not pinned. Pins keep the records' own order.
|
||||
* `onSkip(zone, error)` hears about a selector that could not be evaluated (the zone then pins nothing).
|
||||
*/
|
||||
export function assignPins(zones, records, { onSkip = null } = {}) {
|
||||
const slugToZone = {};
|
||||
for (const z of zones) {
|
||||
if (z.selector && z.selector.type === 'pg_slugs') {
|
||||
for (const s of (z.selector.values || [])) if (!(s in slugToZone)) slugToZone[s] = z.zoneKey;
|
||||
} else if (z.selector) {
|
||||
try { for (const s of evaluate(z.selector, records)) if (!(s in slugToZone)) slugToZone[s] = z.zoneKey; }
|
||||
catch (e) { if (onSkip) onSkip(z, e); }
|
||||
}
|
||||
}
|
||||
const pinsByZone = new Map(zones.map((z) => [z.zoneKey, []]));
|
||||
for (const r of records) {
|
||||
const lat = Number(r.latitude), lng = Number(r.longitude);
|
||||
if (!isFinite(lat) || !isFinite(lng)) continue;
|
||||
const zk = slugToZone[r.slug];
|
||||
if (zk && pinsByZone.has(zk)) pinsByZone.get(zk).push({
|
||||
name: r.name || r.slug || '', lat, lng, slug: r.slug, country_code: r.country_code,
|
||||
population: r.population,
|
||||
});
|
||||
}
|
||||
return pinsByZone;
|
||||
}
|
||||
|
||||
/** "Map N of M" and the total the poster states, from the zones being generated. */
|
||||
export function seriesFacts(zones) {
|
||||
return { totalGroups: zones.reduce((a, z) => a + (z.count || 0), 0), totalLists: zones.length, index: 1 };
|
||||
}
|
||||
104
library/packet/upg100Zones/zones-for-list.js
Normal file
104
library/packet/upg100Zones/zones-for-list.js
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
/**
|
||||
* zones-for-list — which ZONES a project list is clustered into.
|
||||
*
|
||||
* THE GAP THIS CLOSES. Membership is already parameterised by list: library/lists/lists.json registers
|
||||
* five (doxa = Project 2000, unreached = 7000, frontier = 4700, frontier-500k = 700, unengaged), and
|
||||
* resolvePeopleGroupList(profileConfig) already picks one from `config.list` / `?list=` / window.MAP_LIST
|
||||
* with a default. What NO list has is ZONES: a list file says WHICH people groups, and nothing says how
|
||||
* to cluster them into the ~100-group printable zones a poster needs. clusters.json is UPG-100's, and it
|
||||
* was the only answer available.
|
||||
*
|
||||
* ★ WHY THIS IS A RESOLVER AND NOT A DYNAMIC IMPORT. The two consumers do:
|
||||
* import { lists, asia_lists, uncovered_lists } from './data/clusters.json'
|
||||
* and that NAMED import is load-bearing — its own comment says so: only the zone arrays ship, while
|
||||
* clusters.json's eight bookkeeping fields (version, generated, source_total, validation_run, …) are
|
||||
* tree-shaken out. Swapping it for `await import(path)` would ship the whole file in every bundle, and
|
||||
* would make the zone list asynchronous in code that reads it synchronously at setup. So the default
|
||||
* stays a static named import, and alternates are REGISTERED rather than computed from a path.
|
||||
*
|
||||
* ⚠ AND THE DEFAULT MUST BE THE OLD BEHAVIOUR EXACTLY. A list with no zones of its own gets UPG-100's,
|
||||
* which is what every caller gets today — so this file is inert until someone registers a second set.
|
||||
* That is deliberate: the pattern has to be landable before any second list exists, or it cannot be the
|
||||
* thing the second list is built against.
|
||||
*
|
||||
* ★ WHAT A NEW LIST ACTUALLY SHIPS, and this is the pattern for another agent to follow:
|
||||
* 1. data/zones/<list-id>.json the four zone-bearing keys, same shape as clusters.json:
|
||||
* lists / deaf_lists / asia_lists / uncovered_lists
|
||||
* 2. data/zoning-declarations.<id>.json the facts a gate cannot infer — bloc zones, expected orphans,
|
||||
* known geographic overlaps, page budget
|
||||
* 3. one line in ZONES_BY_LIST below
|
||||
* Then `npm run check:zoning-declared -- --list <id>` grades the zoning, and the legend, poster wall and
|
||||
* packet all follow, because all three already enumerate through leafZones(buildLegendNodes(zones)).
|
||||
*
|
||||
* ZONING RULE (2026-09-27), recorded here because it is the judgement the format cannot
|
||||
* capture: cluster by GEOGRAPHY FIRST so the poster maps make sense, balanced with LANGUAGE — languages
|
||||
* usually cluster geographically, so the two mostly agree, and where they disagree geography wins for a
|
||||
* map's sake. UPG-100's own 28 already mix the two: the country_codes zones are geographic, and the 12
|
||||
* India/China pg_slugs zones are hand groupings where language and geography were traded off.
|
||||
*
|
||||
* ⚠ THE DEAF ZONE IS THE KNOWN EXCEPTION AND MUST NOT BE FORCED. `deaf` is affinity bloc A017: 123 groups
|
||||
* spanning every region, global by construction, and the one poster whose zone cannot be a map extent. It
|
||||
* breaks the geography-first rule the rest of the pattern rests on, and it is recorded as not yet
|
||||
* solved. A list whose clusters include an affinity or language bloc inherits the same open question —
|
||||
* declare it in bloc_zones so the gate treats it as a second partition, and do NOT invent a geography for
|
||||
* it to make a validator happy.
|
||||
*
|
||||
* ⚠ THIS MODULE IS BROWSER/VITE-ONLY, BY CONSTRUCTION. The static JSON import below is what keeps the
|
||||
* tree-shaking (see above), and bare `import … from './x.json'` is a bundler feature: plain Node throws
|
||||
* ERR_IMPORT_ASSERTION_TYPE_MISSING without an `with { type: 'json' }` attribute. That is fine and is the
|
||||
* existing convention here — NO Node-side file imports clusters.json as a module; packet.mjs and every
|
||||
* gate read it with readFileSync + JSON.parse (packet.mjs:125). So a Node consumer that wants the same
|
||||
* resolution should read the JSON itself and pass the object on, rather than importing this file.
|
||||
*/
|
||||
import { lists, deaf_lists, asia_lists, uncovered_lists } from './clusters.json'
|
||||
|
||||
/** UPG-100's zones — the default for every list until one registers its own. */
|
||||
const UPG_100_ZONES = { lists, deaf_lists, asia_lists, uncovered_lists }
|
||||
|
||||
/**
|
||||
* list id → its zone set.
|
||||
* ★ A REGISTRY, NOT A DIRECTORY SCAN: each entry is a static import somewhere, so the bundler can see
|
||||
* which zone files a bundle actually reaches and drop the rest. A scan would defeat that and would also
|
||||
* make a missing file a runtime error instead of a build one.
|
||||
*/
|
||||
const ZONES_BY_LIST = {
|
||||
// 'project-3000': PROJECT_3000_ZONES, ← the shape a second list adds
|
||||
}
|
||||
|
||||
/**
|
||||
* The zones for a list id.
|
||||
*
|
||||
* @param {string} [listId] as returned by resolvePeopleGroupList(profileConfig)
|
||||
* @returns {{lists:Array, deaf_lists?:Array, asia_lists:Array, uncovered_lists:Array}}
|
||||
*
|
||||
* ⚠ FALLS BACK RATHER THAN THROWING, and says so once. A list without registered zones is the NORMAL
|
||||
* state right now (five lists, one zone set), so throwing would break every caller for a condition that
|
||||
* is expected. But it is silent-wrong-data territory — a reader would see UPG-100's zones under another
|
||||
* list's name and have no way to tell — so it warns exactly once per id, naming what it did.
|
||||
*/
|
||||
const warned = new Set()
|
||||
export function zonesForList(listId) {
|
||||
const id = String(listId || '')
|
||||
const own = ZONES_BY_LIST[id]
|
||||
if (own) return own
|
||||
if (id && !warned.has(id) && typeof console !== 'undefined') {
|
||||
warned.add(id)
|
||||
console.warn(`[zones] list "${id}" has no zones of its own; showing UPG-100's ${countZones(UPG_100_ZONES)} `
|
||||
+ 'zones. Register a set in data/zones-for-list.js to give it its own poster clusters.')
|
||||
}
|
||||
return UPG_100_ZONES
|
||||
}
|
||||
|
||||
/** Zone count across the zone-bearing keys. NOT a leaf count — buildLegendNodes collapses deaf_lists. */
|
||||
export function countZones(zones) {
|
||||
let n = 0
|
||||
for (const k of ['lists', 'asia_lists', 'uncovered_lists']) n += (zones?.[k] || []).length
|
||||
// ★ deaf_lists is counted as ONE, not as its member count: legend-tree collapses its two entries into a
|
||||
// single unified leaf (legend-tree.js:106, "13 + 12 + 2 + 1 deaf = 28"). Counting its members would
|
||||
// report 29 for a 28-leaf tree — a number that disagrees with every other count in the app.
|
||||
if ((zones?.deaf_lists || []).length) n += 1
|
||||
return n
|
||||
}
|
||||
|
||||
/** Every list id that has its own zones — for a gate or a diagnostic to enumerate. */
|
||||
export function listsWithOwnZones() { return Object.keys(ZONES_BY_LIST) }
|
||||
14
package.json
14
package.json
|
|
@ -69,6 +69,8 @@
|
|||
"check:describe-group": "node internal/check-describe-group.mjs",
|
||||
"check:descriptions": "node internal/check-profile-descriptions.mjs",
|
||||
"check:dev-assets": "node internal/check-dev-assets.mjs",
|
||||
"check:dev-leaf-escape": "node internal/check-dev-leaf-escape.mjs",
|
||||
"check:dev-leaf-escape:self-test": "node internal/check-dev-leaf-escape.mjs --self-test",
|
||||
"check:doc-scripts": "node internal/check-doc-quotes-scripts.mjs",
|
||||
"check:readme-size": "node internal/check-readme-web-folder-size.mjs",
|
||||
"check:doc-scripts:self-test": "node internal/check-doc-quotes-scripts.mjs --self-test",
|
||||
|
|
@ -262,6 +264,8 @@
|
|||
"check:poster-degrades": "node internal/check-poster-degrades-honestly.mjs",
|
||||
"check:poster-design-catalogue": "node plugin-designs/poster-design/scripts/check-catalogue.mjs",
|
||||
"check:poster-design-catalogue:self-test": "node plugin-designs/poster-design/scripts/check-catalogue.mjs --self-test",
|
||||
"check:upg100-zone-copy": "node plugin-designs/poster-design/scripts/check-upg100-zone-copy.mjs",
|
||||
"check:upg100-zone-copy:self-test": "node plugin-designs/poster-design/scripts/check-upg100-zone-copy.mjs --self-test",
|
||||
"check:poster-fetch-guard": "node internal/check-poster-fetch-guard.mjs",
|
||||
"check:poster-print-fallback": "node internal/check-poster-print-fallback.mjs",
|
||||
"check:posters-exportable": "node internal/check-posters-exportable.mjs",
|
||||
|
|
@ -357,7 +361,7 @@
|
|||
"fix:sri": "node internal/fix-sri.mjs",
|
||||
"gallery": "node internal/generate-gallery.mjs",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived 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:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:script-flags check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:auto-engine check:engine-contract check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:no-silent-engine:self-test check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:region-paint-single-source check:poster-print-fallback check:posters-exportable check:country-names check:reduced-motion check:no-dead-library check:universal-props check:readme-size check:tree-relative check:leaf-hints check:snippet-engine check:snippet-engine:self-test check:embed-base check:link-coverage check:no-public-binds check:shared-reached check:tree-weight check:no-fleet-names:self-test check:pin-hint-floor:self-test check:poster-sizes:self-test check:shell-header:self-test check:preview-flag-dev-only:self-test check:poster-type-registry:self-test check:host-floor:self-test check:phone-print-keep:self-test check:pinned-fetch-fresh:self-test",
|
||||
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:pins-in-state check:fork-leaders check:circular-layout check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:india-states check:batch-selection-export check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-internal-role-nouns check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:country-sheet-one-path check:country-sheet-one-path:self-test check:side-lanes check:side-lanes:self-test check:poster-section check:poster-section:self-test check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch check:panel-sheets check:poster-wall-host-seams check:poster-sections check:poster-sections:self-test check:poster-design-catalogue check:poster-design-catalogue:self-test check:wall-photo-weight check:no-fleet-names check:pin-hint-floor check:poster-sizes check:upg-count check:shell-header check:pg-name-gate check:preview-flag-dev-only check:poster-type-registry check:host-floor check:phone-print-keep check:pinned-fetch-fresh",
|
||||
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:pins-in-state check:fork-leaders check:circular-layout check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:india-states check:batch-selection-export check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-internal-role-nouns check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:country-sheet-one-path check:country-sheet-one-path:self-test check:side-lanes check:side-lanes:self-test check:poster-section check:poster-section:self-test check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch check:panel-sheets check:poster-wall-host-seams check:poster-sections check:poster-sections:self-test check:poster-design-catalogue check:poster-design-catalogue:self-test check:upg100-zone-copy check:upg100-zone-copy:self-test check:wall-photo-weight check:no-fleet-names check:pin-hint-floor check:poster-sizes check:upg-count check:shell-header check:pg-name-gate check:preview-flag-dev-only check:poster-type-registry check:host-floor check:phone-print-keep check:pinned-fetch-fresh check:view-address check:view-address:self-test check:dev-leaf-escape check:dev-leaf-escape:self-test",
|
||||
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
|
||||
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
|
||||
"library-index": "node internal/generate-library-index.mjs",
|
||||
|
|
@ -426,6 +430,8 @@
|
|||
"check:side-lanes:self-test": "node plugin-designs/country-posters/scripts/check-side-lanes.mjs --self-test",
|
||||
"check:poster-section": "node plugin-designs/country-posters/scripts/check-poster-section.mjs",
|
||||
"check:poster-section:self-test": "node plugin-designs/country-posters/scripts/check-poster-section.mjs --self-test",
|
||||
"check:view-address": "node internal/check-view-address.mjs",
|
||||
"check:view-address:self-test": "node internal/check-view-address.mjs --self-test",
|
||||
"check:poster-sections": "node plugin-designs/poster-sections/scripts/check-poster-sections.mjs",
|
||||
"check:poster-sections:self-test": "node plugin-designs/poster-sections/scripts/check-poster-sections.mjs --self-test",
|
||||
"gen:poster-sections-presets": "node plugin-designs/poster-sections/scripts/gen-presets.mjs",
|
||||
|
|
@ -501,6 +507,8 @@
|
|||
"check:window-globals-released": "a component that puts something on window must take it back. Heap snapshots (leak-722ac7789) measured SR+APG mounted twice at 54.2 -> 65.1 MB with live Vue apps 6 -> 8: every unmounted app stayed in memory, each pinned by one window key holding a closure, which keeps the component, its shadow root and the whole app. Flags only values that RETAIN (closures and object references), not warn-once latches or page-wide caches keyed off themselves. NOT in gates:pre: 19 sites remain across other owners' plugins. Run it directly.",
|
||||
"check:describe-group": "the prayer packet's generated profile sentence (library/packets/describeGroup.js): each GSEC level states its own fact, and an index-ordered language name (\"Arabic, Moroccan\") is un-inverted by library/utils/languageLabel.js without shredding a comma inside parentheses, the 13 names the old split printed reversed. A catalogue arm renders every GSEC level from the REAL packet-strings.json in en and ar and fails on a blank sentence. Headless; in gates:pre.",
|
||||
"check:descriptions": "every shipped profile must say what it is (reads the BUILT manifest)",
|
||||
"check:dev-leaf-escape": "a dev leaf URL cannot write markup into the page it opens. The dev server's /__leaf/<bundle>/<profile>[/<instance>] route (and a production embed URL rewritten onto it) writes the URL's parts into the page's <title> and the element's profile-config attribute; it refused only .. and /, so a quote or a tag broke out of the attribute on a publicly reachable dev server. internal/plugin-gallery/dev-plugin.mjs refuses any part outside LEAF_PART and escapes the <title>. Drives the plugin's own middleware (no server): every leaf the dev manifest advertises is served in both URL forms; 12 hostile parts are refused in every position; escHtml escapes & < > \" '; and behind the OLD rule the <title> still escapes. Bodies are never printed (a leaf page carries the token). Sub-5s.",
|
||||
"check:dev-leaf-escape:self-test": "4 poisons must each make check:dev-leaf-escape fail: the old guard (.. and / only), a part rule too strict for a real profile, an unescaped <title>, an escHtml that misses the quote.",
|
||||
"check:element-census-vs-live": "what is on ONE side and not the other, ours vs the live doxa.life page we clone: harvests every visible TEXT NODE through every shadow root on both sides and diffs the SETS. OURS-ONLY unaccounted = a scope violation (FAIL), THEIRS-ONLY = a clone omission (FAIL), SANCTIONED = an allowlist entry carrying the ruling + date + card (refused without them), DATA DRIFT = same label, different digits (reported). The negative control is MANDATORY: a known ours-only string must land in the ours-only set or it prints CENSUS IS BLIND and exits 75; an unreachable live page is also 75. Presets: internal/element-census/<name>.json (--preset <name>, default all); ad hoc: --leaf <bundle/profile/index.html[#hash]> --live <url> --neg <text> [--sanctioned <file>]. Promoted from regions' probe; regions passes (list 229 vs 228, the only difference its sanctioned poster toggle). Through browser-slot.sh.",
|
||||
"check:embed-base": "the shared URL resolver, pinned by ARITHMETIC — no browser, no network, no served tree, so it cannot SKIP itself into irrelevance. It computes what a leaf's own \"../../assets\" means and asserts the resolver agrees. Caught nothing for fifteen ticks because it did not exist: resolveAssetsBase computed one segment too high, every local gate passed (the dev web-root has assets BOTH inside and beside the tree), and the byte-level publish gates went green on the broken publish.",
|
||||
"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.",
|
||||
|
|
@ -620,6 +628,8 @@
|
|||
"check:poster-degrades": "with NO stored poster, NO source and NO generator (forced in the browser: every posters/*.webp and *.html answers 404, lib/poster-render.js is aborted), nothing must print and the reader must be told why. Measures any served tree; NOT MEASURED without a wall url.",
|
||||
"check:poster-design-catalogue": "Poster Design's configuration catalogue (plugin-designs/poster-design/src/templates.js) says only true things about the library: its sheet formats are exactly poster-render.js FORMATS at the same pixel sizes (the generator is a pinned asset, so the table cannot be imported); every configuration name is unique and well formed; the card entries are exactly card-render STOCK; every catalogue key it names exists in en/posterDesign.json; the sample country can be drawn. Six mutants (px drift, a missing format, a duplicate name, a bad key, an undrawable sample, a dropped stock) each FAIL. It also validates every plugin's posterDesign block (plugin-designs/<plugin>/<profile>.meta.json: family, name, size, mobileFriendly, optional labelKey and instance), keeps names unique across templates.js and all blocks, and fails when a name in the names ledger (src/catalogue-names.json) was renamed or removed.",
|
||||
"check:poster-design-catalogue:self-test": "an invalid posterDesign block, a name that clashes with templates.js, a renamed ledger name a block naming a preset that does not exist, and a sample that exists but cannot be drawn (India with withholding off) must each make check:poster-design-catalogue fail, and the documented example block must pass.",
|
||||
"check:upg100-zone-copy": "library/packet/upg100Zones/ is UPG100's zone-poster code copied into the library (so Poster Design draws UPG100 zone posters). COPY mode while UPG100's originals exist: each file equals its original beyond its import specifiers, clusters.json byte for byte. ONE-PATH mode once UPG100 switches and deletes its originals: no plugin file defines the zone code again. Half switched fails.",
|
||||
"check:upg100-zone-copy:self-test": "a one-character edit in a function body, a changed clusters.json, a missing copy, a half switch, and (one-path) a plugin re-defining makeZoneSheet must each fail; an import-specifier-only change and a clean one-path tree must pass.",
|
||||
"check:poster-fetch-guard": "a host's 200 fallback page is never taken for a poster. On a drop-in dev host a missing posters/<id>.webp answered 200 text/html, and export handed that page to the print dialog as the image. Asserts library/packet/poster-fetch.js accepts a raster only if it is an image (by type or by its first bytes) and a source only if it is a poster document (class=\"stage\"), and that neither usePosterPrints nor PosterWall fetches posters/ directly. Node only, no server.",
|
||||
"check:poster-layout-optin": "the composed poster sheet (header, map, legend beside it, footer) is OPT-IN: spec.layout === 'poster' asks for it, and every caller that does not gets the full-page map exactly as before, asserted by object identity, not just by value. Drives the real layout decisions with real spec objects. Headless, no browser.",
|
||||
"check:poster-print-fallback": "clicks the REAL poster print control with the served poster generator and every posters/*.webp aborted, and asserts the preview RASTERISES from posters/<id>.html instead of opening on \"Poster not available yet\". Needs a served tree built with posterSources:true; NOT MEASURED otherwise.",
|
||||
|
|
@ -715,6 +725,8 @@
|
|||
"check:side-lanes:self-test": "a poster-render.js copy WITHOUT the lane clamp must make check:side-lanes' crossing arm FAIL on the same seeded data (it does: 36 crossings), so the arm is shown able to fail.",
|
||||
"check:poster-section": "the reusable poster section (<dxm-poster-section>): the type registry has 'country'; DX Life's /regions slugs (and every other spelling of a region) each show their countries, every country once; a withheld whole-country poster stays withheld through the type; the element's attributes map to its config; PosterSection.vue injects every name its template uses.",
|
||||
"check:poster-section:self-test": "a registry without 'country', a region key without \"&\" → \"and\", and a section that uses a name it does not inject must each make check:poster-section fail.",
|
||||
"check:view-address": "the page's address (library/composables/useViewAddress.js: ?tab= &pane= &place=) and its ONE history write, on a simulated browser history: the id whitelist + 64 cap and the level rule; an app owner's tab change is one push that keeps history.state with current (Vue Router's field) and the hash; nothing is written when unchanged, on a partner page (no urlOwner), or by a host owner (it hands the host one composed dxm-address). Coexistence with the filters (useShareableView writes through the same commitUrl): a filter write keeps tab/pane/place, a tab/pane/place write keeps the filters, both survive Back and Forward, and re-applying the tab after Back writes nothing. Plus the census: every history.pushState/replaceState in library/ and plugin-designs/ source is the helper or a listed writer. Pure node, no build.",
|
||||
"check:view-address:self-test": "14 poisons must each make check:view-address fail: a write without history.state.current, a write when not the owner, a tab change that keeps a stale place, no length cap, a write when unchanged, a host owner writing history, a dxm-address that cannot leave a shadow root, a filter write that drops tab/pane/place, the old replaceState(null) filter write, a tab write that drops the filters, a tab change written as a replace, a popstate handler that writes, a new history writer in the tree, useShareableView writing history itself again.",
|
||||
"check:poster-sections": "the Poster Sections page (plugin-designs/poster-sections): every panel choice (each region or All regions, each language, open or collapsed, 1-5 per row) builds <dxm-poster-section> tags that the element's own attribute reader turns back into exactly those choices; All regions is one section per region; Copy embed is the bundle script plus those same tags. The presets file holds, per poster type, exactly an all-regions preset plus one per region its adapter's scopes() returns, so a region the data drops (or gains) cannot drift from the presets; a poster type not on the tree has no presets (its pages could show no posters); and the built preset pages are exactly the presets (no stale page).",
|
||||
"check:poster-sections:self-test": "a tag builder that drops columns, one that drops collapsed, one that repeats a region under All regions, a preset for a region the type does not have, and a built page for a preset that no longer exists must each make check:poster-sections fail.",
|
||||
"gen:poster-sections-presets": "writes plugin-designs/poster-sections/poster-sections.instances.json from each poster type's own scopes() (all regions + one preset per region, titled by the type's own section title, then configure); the list check:poster-sections holds the file to.",
|
||||
|
|
|
|||
|
|
@ -3,5 +3,12 @@
|
|||
"type": "single",
|
||||
"label": "Bible Posters",
|
||||
"targets": ["web"],
|
||||
"note": "A generator that shows an ENTIRE BOOK of the Bible on one poster, in the languages of the top unreached people groups. Text is fetched at render time from the Free Use Bible API (public-domain / openly-licensed only); no Bibles are stored in the repo. Lives in the Posterverse Experimental shelf."
|
||||
"posterDesign": {
|
||||
"family": "biblePoster",
|
||||
"name": "bible-poster.36x60",
|
||||
"size": "36x60",
|
||||
"mobileFriendly": false,
|
||||
"labelKey": "biblePosters.posterDesign.label"
|
||||
},
|
||||
"note": "A generator that shows an ENTIRE BOOK of the Bible on one poster, in the languages of the top unreached people groups. Text is fetched at render time from the Free Use Bible API (public-domain / openly-licensed only); no Bibles are stored in the repo. Lives in the Posterverse Experimental shelf. posterDesign: the whole-book poster is a large-format print (even standard paper is too small for a whole book), so mobileFriendly is false; the card/preview still renders on a phone."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
{
|
||||
"type": "single",
|
||||
"label": "Country Posters — poster wall",
|
||||
"note": "The printable poster wall pane of the Country Posters element (country-posters-mfe composes it); also embeddable alone. profile-config `countries` (ISO-3 list or {code,name,count} records) or `region` (a WAGF region label) shows only those, flat, under a host's own region heading (DX Life's /regions, 2026-09-30); without them the wall is unchanged."
|
||||
"note": "The printable poster wall pane of the Country Posters element (country-posters-mfe composes it); also embeddable alone. profile-config `countries` (ISO-3 list or {code,name,count} records) or `region` (a WAGF region label) shows only those, flat, under a host's own region heading (DX Life's /regions, 2026-09-30); without them the wall is unchanged.",
|
||||
"posterDesign": {
|
||||
"family": "countryPoster",
|
||||
"name": "country-poster.letter",
|
||||
"size": "letter",
|
||||
"mobileFriendly": true,
|
||||
"labelKey": "tabs.countryPosters"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@
|
|||
:header="showHeader"
|
||||
:groups="navGroups"
|
||||
:active="activeKey"
|
||||
@update:active="onSelect">
|
||||
@update:active="onRailSelect">
|
||||
|
||||
<!-- #brand: the DX-Maps logo mark, left of the title (a compass-star glyph, self-drawn SVG — no
|
||||
external asset, offline/F-Droid safe; the accent green matches the admin shell). -->
|
||||
|
|
@ -216,6 +216,7 @@ import { helpFor } from './src/experimentalHelp.js'
|
|||
import { TAB_INFO } from './src/tabInfo.js'
|
||||
import MapInfo from '@map/components/info/MapInfo.vue'
|
||||
import { loadScriptOnce, whenTagDefined } from '@map/utils/embedScript.js'
|
||||
import { readViewKeys, writeViewKeys, onAddressChange, urlOwnerOf } from '@map/composables/useViewAddress.js'
|
||||
|
||||
// THE HOST PAGE'S LANGUAGE, by the same ladder every nested map already uses (library/i18n
|
||||
// detectLocale): ?lang= / ?locale= on the page URL, then <html lang>. '' when the page says nothing.
|
||||
|
|
@ -1130,6 +1131,11 @@ export default defineComponent({
|
|||
// A map's own "?" asks for its help with a composed doxa:help event (library HelpButton).
|
||||
this._onHostedHelp = (e) => this.onHostedHelp(e)
|
||||
this.$el.addEventListener('doxa:help', this._onHostedHelp)
|
||||
// Back / Forward re-open the tab the address names (useViewAddress), and never write. Only where a page OWNS
|
||||
// its address: on a partner's page a popstate is the partner's own navigation, and re-reading the URL there
|
||||
// would snap a host's fixed `tab` attribute back to the default. The owner is read per event: a host may set
|
||||
// profile-config after mount.
|
||||
this._offAddress = onAddressChange(({ tab }) => { if (urlOwnerOf(this.cfg)) this.openFromAddress(tab) })
|
||||
},
|
||||
// Release the media-query listener. This element is a custom element a host can add and REMOVE at
|
||||
// will (a cap-5 SPA is mounted and unmounted by shells, tabs and the studio), and a live listener
|
||||
|
|
@ -1137,6 +1143,7 @@ export default defineComponent({
|
|||
beforeUnmount() {
|
||||
if (this._langObs) { this._langObs.disconnect(); this._langObs = null }
|
||||
if (this._onHostedHelp) { this.$el.removeEventListener('doxa:help', this._onHostedHelp); this._onHostedHelp = null }
|
||||
if (this._offAddress) { this._offAddress(); this._offAddress = null }
|
||||
if (!this._mql || !this._onMedia) return
|
||||
if (this._mql.removeEventListener) this._mql.removeEventListener('change', this._onMedia)
|
||||
else if (this._mql.removeListener) this._mql.removeListener(this._onMedia)
|
||||
|
|
@ -1440,10 +1447,11 @@ export default defineComponent({
|
|||
this._honouredTab = true
|
||||
},
|
||||
// The tab a HOST asked to open first: the `tab` attribute, then profile-config.tab, then ?tab= on
|
||||
// the page URL (so an iframe src or a shared link can deep-link). '' when nobody asked.
|
||||
// the page URL (so an iframe src or a shared link can deep-link). '' when nobody asked. The URL is read
|
||||
// through useViewAddress: the first ?tab= wins, and a value that is not a plain id reads as absent.
|
||||
requestedTab() {
|
||||
let q = ''
|
||||
try { q = new URLSearchParams(location.search).get('tab') || '' } catch { /* non-browser */ }
|
||||
try { q = readViewKeys(location.search).tab } catch { /* non-browser */ }
|
||||
return String(this.tab || this.cfg.tab || q || '').trim()
|
||||
},
|
||||
// The index to open on mount: the requested tab when it exists and is enabled at this width,
|
||||
|
|
@ -1496,6 +1504,26 @@ export default defineComponent({
|
|||
const i = this.tabs.findIndex(t => t.id === key)
|
||||
if (i !== -1) this.select(i)
|
||||
},
|
||||
// ★ THE PAGE ADDRESS (shareable pages, useViewAddress). A READER's pick in the rail is the one switch that
|
||||
// writes ?tab=, as a push, so a reload keeps the tab, a sent link opens it, and Back returns to the previous one.
|
||||
// Who writes is the page's urlOwner: 'app' (our own leaf page) writes history, 'host' (DX Life) gets a
|
||||
// dxm-address event and its router writes, and a partner's page is never written. Every other switch is not a
|
||||
// choice and writes nothing: the manifest's re-select, the device fallback, a host's `tab` attribute, Back and
|
||||
// Forward. Nor is there a first-paint write: a bare URL means the device's default tab, and pinning a phone's
|
||||
// default into the address would send a desktop reader to it.
|
||||
onRailSelect(key) {
|
||||
const before = this.activeKey
|
||||
this.onSelect(key)
|
||||
if (this.activeKey !== before) writeViewKeys({ tab: this.activeKey }, { owner: urlOwnerOf(this.cfg), push: true, el: this.$el })
|
||||
},
|
||||
// Back / Forward: the tab the address names, else the page's configured first tab (profile-config.tab), else the
|
||||
// device default. Not the `tab` attribute: a host with a router re-points it from this same popstate, and until
|
||||
// it does, it still names the tab being left.
|
||||
openFromAddress(tab) {
|
||||
const want = tab || String(this.cfg.tab || '').trim()
|
||||
const i = want ? this.tabs.findIndex(t => t.id === want) : -1
|
||||
this.select(i >= 0 && !this.isDesktopOnly(want) ? i : this.firstVisibleIndex())
|
||||
},
|
||||
// Resolve a tree-relative ref ("./x" / "x") against the tree base. If base is '' we keep it
|
||||
// relative to THIS document — but this bundle's own folder is <base>/doxa-maps-spa/, so a
|
||||
// co-located leaf at <base>/big-picture/... is one level UP: prefix '../'.
|
||||
|
|
|
|||
|
|
@ -35,7 +35,9 @@
|
|||
with no page reload; each map tab loads lazily on first open.</p>
|
||||
</div>
|
||||
<div class="frame">
|
||||
<doxa-maps-spa id="dms" heading="Deep Research"></doxa-maps-spa>
|
||||
<!-- urlOwner 'app': this page is ours (the dev server serves it), so a tab pick writes ?tab= and a reload or a
|
||||
sent link opens that tab (useViewAddress). A partner's page leaves it out and is never written. -->
|
||||
<doxa-maps-spa id="dms" heading="Deep Research" profile-config='{"urlOwner":"app"}'></doxa-maps-spa>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,27 @@
|
|||
{
|
||||
"_comment": "The printable half of UPG-100: a wall of prayer-card posters, one per 100-list entry, with the three print paths. Declared so the Studio rail names it rather than falling back to a humanized id (check:studio-rail).",
|
||||
"label": "UPG100 Posters — Poster Wall"
|
||||
"label": "UPG100 Posters — Poster Wall",
|
||||
"posterDesign": [
|
||||
{
|
||||
"family": "upgList",
|
||||
"name": "upg100-territory-poster.letter",
|
||||
"size": "letter",
|
||||
"mobileFriendly": true,
|
||||
"labelKey": "upgMap.posterDesignTerritory"
|
||||
},
|
||||
{
|
||||
"family": "upgList",
|
||||
"name": "upg100-territory-poster.3x3",
|
||||
"size": "3x3",
|
||||
"mobileFriendly": false,
|
||||
"labelKey": "upgMap.posterDesignTerritory"
|
||||
},
|
||||
{
|
||||
"family": "upgList",
|
||||
"name": "upg100-territory-poster.3x4",
|
||||
"size": "3x4",
|
||||
"mobileFriendly": false,
|
||||
"labelKey": "upgMap.posterDesignTerritory"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,9 @@
|
|||
real tag; map elements nest as their own SPA. Switch with no page reload.</p>
|
||||
</div>
|
||||
<div class="frame">
|
||||
<plugin-studio id="ps" title="Plugin Studio"></plugin-studio>
|
||||
<!-- This page is the app, so Studio owns the address: a clicked tab is written to ?tab=,
|
||||
a reload keeps it and a sent link opens it. The built leaf page bakes the same key. -->
|
||||
<plugin-studio id="ps" title="Plugin Studio" profile-config='{"urlOwner":"app"}'></plugin-studio>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -225,6 +225,7 @@ import StudioTree from './src/StudioTree.vue'
|
|||
import { LANGUAGES, loadLocale, saveLocale, getLanguage, normaliseLocale } from '@map/layouts/app-wrapper-material/languages.js'
|
||||
import { publishMapToken, clearMapToken } from '@map/composables/useSharedMapToken.js'
|
||||
import { loadScriptOnce, whenTagDefined } from '@map/utils/embedScript.js'
|
||||
import { readViewKeys, writeViewKeys, onAddressChange, urlOwnerOf, commitUrl } from '@map/composables/useViewAddress.js'
|
||||
|
||||
// The reader's own map token / token-service URL. localStorage only: never written into the build,
|
||||
// never sent anywhere but Mapbox (the token) or the URL the reader typed (the service).
|
||||
|
|
@ -762,11 +763,20 @@ export default defineComponent({
|
|||
.catch(() => {})
|
||||
}
|
||||
this.loadManifest()
|
||||
// Back / Forward re-open the tab the address names; they never write (useViewAddress.js says why).
|
||||
this._offAddress = onAddressChange(({ tab }) => {
|
||||
const key = this.keyOfTab(tab)
|
||||
if (key) this.select(key, { fromHistory: true })
|
||||
else if (this.rows.length) this.select(this.rows[0].key, { fromHistory: true })
|
||||
})
|
||||
},
|
||||
// The corner-widget hosts live on window.__doxaStudioWidgets so a HOT UPDATE remounts Studio onto
|
||||
// the same hosts and a live widget (Rudder) keeps its state. A real unmount must release them, or
|
||||
// the hosts and every widget element in them stay pinned on the page after Studio is gone.
|
||||
beforeUnmount() {
|
||||
// FIRST, before the hot-update return below: a remounted Studio adds its own listener, and a kept one would
|
||||
// re-apply every Back twice.
|
||||
if (this._offAddress) { this._offAddress(); this._offAddress = null }
|
||||
if (import.meta.hot) return
|
||||
const W = window.__doxaStudioWidgets
|
||||
if (W && W.hosts) for (const h of Object.values(W.hosts)) { try { h.remove() } catch (_) {} }
|
||||
|
|
@ -1003,20 +1013,45 @@ export default defineComponent({
|
|||
// list" instead of a tab. It opens the first tab, remembers in localStorage the tab you were on,
|
||||
// and a reload goes back to that tab.
|
||||
//
|
||||
// 1. the URL hash — an explicit, shareable request always wins over a remembered one
|
||||
// 2. localStorage — what you were last looking at, so a reload is not a fresh start
|
||||
// 3. the first row — never leave the pane empty
|
||||
// 1. ?tab= — the view address (useViewAddress.js): a reload and a SENT link open it
|
||||
// 2. the URL hash — the old shareable form (#bundle/profile); rewritten to ?tab= on first paint
|
||||
// 3. localStorage — what you were last looking at, so a reload is not a fresh start
|
||||
// 4. the first row — never leave the pane empty
|
||||
//
|
||||
// The first select() is a REPLACE (first paint); a reader's click is a PUSH (one Back step), written only
|
||||
// where this page owns its address (urlOwner 'app', baked into our own leaf page; a partner's page and a
|
||||
// framed leaf only read).
|
||||
//
|
||||
// Called once after the manifest lands, so `rows` is populated and every candidate can be
|
||||
// checked against it. A remembered key whose bundle was renamed or removed simply fails that
|
||||
// check and falls through to the first row — a stale key must never render an empty pane, which
|
||||
// is the very state this replaces.
|
||||
route() {
|
||||
const first = { firstPaint: true }
|
||||
const asked = this.keyOfTab(readViewKeys(typeof location !== 'undefined' ? location.search : '').tab)
|
||||
if (asked) { this.select(asked, first); return }
|
||||
const h = (typeof location !== 'undefined' && location.hash || '').replace(/^#\/?/, '')
|
||||
if (h && this.rows.some(r => r.key === h)) { this.select(h); return }
|
||||
if (h && this.rows.some(r => r.key === h)) {
|
||||
this.select(h, first)
|
||||
// The old #bundle/profile link now reads as ?tab=; its hash goes, so the address has ONE form.
|
||||
if (urlOwnerOf(this.cfg) === 'app') commitUrl({ hash: '' })
|
||||
return
|
||||
}
|
||||
const remembered = this.readLastTab()
|
||||
if (remembered && this.rows.some(r => r.key === remembered)) { this.select(remembered); return }
|
||||
if (this.rows.length) this.select(this.rows[0].key)
|
||||
if (remembered && this.rows.some(r => r.key === remembered)) { this.select(remembered, first); return }
|
||||
if (this.rows.length) this.select(this.rows[0].key, first)
|
||||
},
|
||||
// ★ A ROW'S TAB ID. A row key is "<bundle>/<profile>", and the address takes plain ids only (no "/"), so the tab
|
||||
// WRITTEN is "<bundle>__<profile>": stable, since a profile name another bundle adopts later cannot make a sent
|
||||
// link mean a different row. READ: that form, or a bare profile name while it names exactly one row (a typed
|
||||
// ?tab=poster-styles), else '' (the default tab opens).
|
||||
tabIdOf(key) { return String(key || '').replace('/', '__') },
|
||||
keyOfTab(id) {
|
||||
if (!id) return ''
|
||||
const full = id.includes('__') ? id.replace('__', '/') : ''
|
||||
if (full && this.rows.some(r => r.key === full)) return full
|
||||
const byProfile = this.rows.filter(r => r.key.split('/')[1] === id)
|
||||
return byProfile.length === 1 ? byProfile[0].key : ''
|
||||
},
|
||||
// Storage is keyed per HOST (pathname), because two cap-5 shells served from one origin would
|
||||
// otherwise fight over a single key and each reload would yank the other's tab.
|
||||
|
|
@ -1034,7 +1069,7 @@ export default defineComponent({
|
|||
writeLastTab(key) {
|
||||
try { window.localStorage.setItem(this.storageKey(), key) } catch (_) { /* storage unavailable */ }
|
||||
},
|
||||
select(key) {
|
||||
select(key, { fromHistory = false, firstPaint = false } = {}) {
|
||||
// ★ BAIL BEFORE TOUCHING STATE if `key` names no real row. A composition-folder's TOP row can be
|
||||
// a SYNTHETIC head (see compositionTrees) for a composite that cannot list itself as one of its own
|
||||
// tabs (plugin-studio nesting <plugin-studio> would be infinite regress — same reasoning
|
||||
|
|
@ -1062,6 +1097,8 @@ export default defineComponent({
|
|||
// Remember it AFTER confirming the row exists, so a bad key is never written back — otherwise
|
||||
// one stray select() would poison the next reload with a key that fails route()'s check.
|
||||
this.writeLastTab(key)
|
||||
// The view address: a reader's choice is a push, the first paint a replace, Back/Forward never writes.
|
||||
if (!fromHistory) writeViewKeys({ tab: this.tabIdOf(key) }, { owner: urlOwnerOf(this.cfg), push: !firstPaint, el: this.$el })
|
||||
// A heavy row's slot only exists after the v-if subtree renders, so wait a tick before
|
||||
// mounting into it — on the first activation the ref is null at this point.
|
||||
if (!row.mounted) this.$nextTick(() => { if (this.activeKey === row.key) this.mountElement(row) })
|
||||
|
|
|
|||
|
|
@ -20,19 +20,14 @@ import { POSTER_LADDER, POSTER_LADDER_ORDER, paperFor } from '@map/constants/pos
|
|||
import { useOptionalT, useOptionalI18n } from '@map/composables/useOptionalT.js'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import { layoutSvgUrl } from './src/layoutSvg.js'
|
||||
import { num, dimsText } from './src/sizeText.js'
|
||||
|
||||
const t = useOptionalT('')
|
||||
const { locale } = useOptionalI18n('')
|
||||
|
||||
// A number as the sizes are spoken: whole, or one decimal place (5.5).
|
||||
const n = (x) => (Number.isInteger(x) ? String(x) : String(Math.round(x * 10) / 10))
|
||||
// Feet once a side reaches a yard and both sides are whole or half feet (3 × 5.5 ft); inches below that (18 × 24 in).
|
||||
function dims(w, h) {
|
||||
const ft = (x) => x / 12
|
||||
const halfFeet = (x) => Number.isInteger(ft(x) * 2)
|
||||
if (Math.min(w, h) >= 36 && halfFeet(w) && halfFeet(h)) return t('posterDesign.sizes.dimsFt', { w: n(ft(w)), h: n(ft(h)) })
|
||||
return t('posterDesign.sizes.dimsIn', { w: n(w), h: n(h) })
|
||||
}
|
||||
// Sizes are named the one way Poster Design names them (src/sizeText.js, shared with Styles).
|
||||
const n = num
|
||||
const dims = (w, h) => dimsText(w, h, t)
|
||||
// Standard printer paper is Letter or A4 by the reader's region, as the print picker decides it.
|
||||
const paperIsA4 = computed(() => {
|
||||
const l = locale && (locale.value ?? locale)
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import { resolvePosterSize } from '@map/constants/posterSizes.js'
|
|||
import { listConfigurations, FAMILY_KEYS, SAMPLE_COUNTRY_ID } from './src/templates.js'
|
||||
import FullDetailsDialog from './src/FullDetailsDialog.vue'
|
||||
import { loadPluginDesigns } from './src/pluginDesigns.js'
|
||||
import { sizeText, declaredSizeText } from './src/sizeText.js'
|
||||
|
||||
const profileConfig = inject('profileConfig', ref(null))
|
||||
const instanceId = computed(() => profileConfig.value?.instanceId || 'poster-design-styles')
|
||||
|
|
@ -49,7 +50,7 @@ function stockLabel(s) {
|
|||
const dims = `${s.w} × ${s.h} in`
|
||||
return s.side ? `${noun} ${dims} (${t('wall.stockLandscape')})` : `${noun} ${dims}`
|
||||
}
|
||||
const labelOf = (c) => (c.stock ? `${t(c.labelKey)}: ${stockLabel(c.stock)}` : `${t(c.labelKey)}: ${c.size}`)
|
||||
const labelOf = (c) => (c.stock ? `${t(c.labelKey)}: ${stockLabel(c.stock)}` : `${t(c.labelKey)}: ${sizeText(c.size, t)}`)
|
||||
|
||||
// Designs other plugins declare (their meta's posterDesign, via manifest.json): the built tree's manifest, or the dev
|
||||
// server's. A name already in templates.js stays the library's (the check refuses the clash; the page never doubles it).
|
||||
|
|
@ -60,17 +61,34 @@ onMounted(async () => {
|
|||
const { designs } = await loadPluginDesigns(urls)
|
||||
pluginDesigns.value = designs.filter((d) => !byName.has(d.name))
|
||||
})
|
||||
const pluginLabel = (d) => {
|
||||
// The design's title in the reader's language: the build's per-locale titles (posterDesignLabels; any catalogue
|
||||
// area), then this bundle's own catalogue, then English, then the profile's label.
|
||||
const pluginTitle = (d) => {
|
||||
const lang = String(getRuntimeLocale() || detectLocale() || 'en').split('-')[0]
|
||||
if (d.titles && d.titles[lang]) return d.titles[lang]
|
||||
const v = d.labelKey ? t(d.labelKey) : ''
|
||||
return `${v && v !== d.labelKey ? v : d.label}: ${d.size}`
|
||||
if (v && v !== d.labelKey) return v
|
||||
return (d.titles && d.titles.en) || d.label
|
||||
}
|
||||
const pluginLabel = (d) => {
|
||||
return `${pluginTitle(d)}: ${declaredSizeText(d.size, t)}`
|
||||
}
|
||||
|
||||
// PRODUCTION designs first, by family. EXPERIMENTAL ones (marked in templates.js: still being designed, or no
|
||||
// preview) and every other plugin's design (made by its own plugin, so drawn nowhere here) go last, in ONE
|
||||
// Experimental group that starts collapsed.
|
||||
const isExperimental = (id) => byPluginName.value.has(id) || byName.get(id)?.experimental === true
|
||||
const experimentalHeading = computed(() => t('posterDesign.family.experimental'))
|
||||
// No `count`: these are templates, not lists of people, so the sticker reads "Show full details" alone.
|
||||
const items = computed(() => [
|
||||
...configs.map((c) => ({ id: c.name, label: labelOf(c) })),
|
||||
...pluginDesigns.value.map((d) => ({ id: d.name, label: pluginLabel(d) })),
|
||||
])
|
||||
const groupBy = (item) => (byPluginName.value.has(item.id) ? t('posterDesign.family.plugins') : t(FAMILY_KEYS[byName.get(item.id)?.family] || FAMILY_KEYS.mapSheet))
|
||||
const items = computed(() => {
|
||||
const all = [
|
||||
...configs.map((c) => ({ id: c.name, label: labelOf(c) })),
|
||||
...pluginDesigns.value.map((d) => ({ id: d.name, label: pluginLabel(d) })),
|
||||
]
|
||||
return [...all.filter((x) => !isExperimental(x.id)), ...all.filter((x) => isExperimental(x.id))]
|
||||
})
|
||||
const groupBy = (item) => (isExperimental(item.id) ? experimentalHeading.value : t(FAMILY_KEYS[byName.get(item.id)?.family] || FAMILY_KEYS.mapSheet))
|
||||
const collapsedBy = (sec) => (sec && sec.name) === experimentalHeading.value // PosterWall passes the section
|
||||
|
||||
// ── drawing a configuration from the library ──────────────────────────────────────────────────
|
||||
const countryById = (id) => (countryData.countries || []).find((c) => c.id === id)
|
||||
|
|
@ -133,7 +151,17 @@ function panels(sizeKey) {
|
|||
const size = resolvePosterSize(sizeKey)
|
||||
return size ? { image: panelsSvgUrl(size.sheets) } : null
|
||||
}
|
||||
const ctx = { countrySheet, cardDeck, packetDocument, panels }
|
||||
// A UPG100 zone's sheet, from the library copy of UPG100's zone code: ALL the records (a zone's pins depend on which
|
||||
// earlier zone claimed a group first, as on UPG100's own wall), the list from this page's config. Loaded on first use.
|
||||
let zoneSheetFn = null
|
||||
async function zoneSheet(format, zoneId) {
|
||||
if (!zoneSheetFn) {
|
||||
const { makeZoneSheet } = await import('@map/packet/upg100Zones/zone-sheet-print.js')
|
||||
zoneSheetFn = makeZoneSheet({ allRecords: () => recordsFor.all(), assetsBase: () => assetsBase.value, profileConfig })
|
||||
}
|
||||
return zoneSheetFn({ id: zoneId }, format)
|
||||
}
|
||||
const ctx = { countrySheet, cardDeck, packetDocument, panels, zoneSheet }
|
||||
|
||||
// The printed document's size, measured when it is drawn: what "file size" means in the details.
|
||||
// render() answers a poster's HTML (a map sheet) or a document { html, page, width, height } (a deck, a packet).
|
||||
|
|
@ -175,15 +203,15 @@ async function sheet(item) {
|
|||
|
||||
// ── "Show full details" ───────────────────────────────────────────────────────────────────────
|
||||
const open = ref(null)
|
||||
const openLabel = computed(() => (!open.value ? '' : open.value.design ? pluginLabel(open.value.design) : labelOf(open.value)))
|
||||
const openLabel = computed(() => (!open.value ? '' : open.value.design ? pluginLabel(open.value.design) : labelOf(byName.get(open.value.name))))
|
||||
// A plugin's design opens in the same dialog, as a configuration: its renderer is the plugin's page, and the dialog
|
||||
// shows that page live.
|
||||
function showDetails(item) {
|
||||
const d = byPluginName.value.get(item.id)
|
||||
open.value = d
|
||||
? { name: d.name, size: d.size, settings: { family: d.family, mobileFriendly: d.mobileFriendly, ...(d.instance ? { preset: d.instance } : {}) },
|
||||
? { name: d.name, size: declaredSizeText(d.size, t), settings: { family: d.family, mobileFriendly: d.mobileFriendly, ...(d.instance ? { preset: d.instance } : {}) },
|
||||
renderer: `plugin-designs/${d.bundle}/${d.profile}.vue`, pageUrl: d.pageUrl, design: d }
|
||||
: byName.get(item.id) || null
|
||||
: byName.has(item.id) ? { ...byName.get(item.id), size: sizeText(byName.get(item.id).size, t) } : null
|
||||
}
|
||||
|
||||
const heading = computed(() => t('posterDesign.styles.heading'))
|
||||
|
|
@ -231,6 +259,7 @@ useShadowStyles(`
|
|||
unit=""
|
||||
item-count-key="posterDesign.templateCount"
|
||||
:collapsible="true"
|
||||
:collapsed-by="collapsedBy"
|
||||
:header-action="{ labelKey: 'posterDesign.showFullDetails', ariaKey: 'posterDesign.showFullDetailsAria' }"
|
||||
:preview-src="previewSrc"
|
||||
:unsupported="withheld"
|
||||
|
|
|
|||
|
|
@ -102,9 +102,12 @@ for (const c of configs) if (!cat.FAMILY_KEYS[c.family]) fail(`'${c.name}' has f
|
|||
// 5. the samples
|
||||
const data = JSON.parse(readFileSync(join(ROOT, 'library/data/country-posters.json'), 'utf8'))
|
||||
const samples = [...new Set([cat.SAMPLE_COUNTRY_ID, ...configs.map((c) => c.sample).filter(Boolean)])]
|
||||
// A UPG100 zone sample (a template with `plugin: 'my-upg-100-list'`) is one of the zones in the library copy's catalogue.
|
||||
const ZONE_IDS = (() => { try { const z = JSON.parse(readFileSync(join(ROOT, 'library/packet/upg100Zones/clusters.json'), 'utf8')); return new Set(['lists', 'asia_lists', 'uncovered_lists'].flatMap((k) => (z[k] || []).map((x) => x.id))) } catch { return new Set() } })()
|
||||
function sampleArm(ids, countries, withheld) {
|
||||
const fs = []
|
||||
for (const id of ids) {
|
||||
if (ZONE_IDS.has(id)) continue // a real UPG100 zone (drawn from the library copy)
|
||||
const s = countries.find((c) => c.id === id)
|
||||
if (!s) fs.push(`5: sample '${id}' is not in library/data/country-posters.json`)
|
||||
else if (withheld(s)) continue // its card shows the withheld sentence; it is never drawn here
|
||||
|
|
@ -134,7 +137,7 @@ function gatherBlocks() {
|
|||
}
|
||||
return out
|
||||
}
|
||||
function designArms({ names, blocks, ledger }) {
|
||||
function designArms({ names, blocks, ledger, drawnFor = new Map() }) {
|
||||
const fs = []
|
||||
for (const { where, block, presets } of blocks) {
|
||||
const probs = PD.blockProblems(block)
|
||||
|
|
@ -143,9 +146,15 @@ function designArms({ names, blocks, ledger }) {
|
|||
if (block.labelKey && !enKey(block.labelKey)) fs.push(`6: ${where} labelKey '${block.labelKey}' is not in the English catalogue`)
|
||||
if (block.instance && !presets.includes(block.instance)) fs.push(`6: ${where} instance '${block.instance}' is not one of its presets`)
|
||||
}
|
||||
const all = [...names, ...blocks.filter((b) => !PD.blockProblems(b.block).length).map((b) => b.block.name)]
|
||||
const seen = new Set()
|
||||
for (const n of all) { if (seen.has(n)) fs.push(`7: two designs are named '${n}'`); seen.add(n) }
|
||||
// A template that DRAWS a plugin's design (`plugin: '<dir>'`) carries that plugin's own name, so the same name in that
|
||||
// plugin's block is the same design, counted once. Anywhere else a repeated name is a clash.
|
||||
const seen = new Set(names)
|
||||
for (const n of names.filter((x, i) => names.indexOf(x) !== i)) fs.push(`7: two templates are named '${n}'`)
|
||||
for (const b of blocks.filter((x) => !PD.blockProblems(x.block).length)) {
|
||||
const n = b.block.name, plugin = String(b.where).split('/')[0]
|
||||
if (seen.has(n) && drawnFor.get(n) !== plugin) fs.push(`7: two designs are named '${n}'`)
|
||||
seen.add(n)
|
||||
}
|
||||
for (const n of ledger) if (!seen.has(n)) fs.push(`8: '${n}' is in the names ledger and no longer exists: a shipped name is never renamed or removed (keep it, as a legacy entry if retired)`)
|
||||
return { fails: fs, fresh: [...seen].filter((n) => !ledger.includes(n)) }
|
||||
}
|
||||
|
|
@ -153,7 +162,7 @@ const LEDGER_FILE = join(ROOT, 'plugin-designs/poster-design/src/catalogue-names
|
|||
const ledger = existsSync(LEDGER_FILE) ? JSON.parse(readFileSync(LEDGER_FILE, 'utf8')).names || [] : []
|
||||
if (!ledger.length) fail('8: the names ledger (src/catalogue-names.json) is missing or empty')
|
||||
const blocks = gatherBlocks()
|
||||
const real = { names: configs.map((c) => c.name), blocks, ledger }
|
||||
const real = { names: configs.map((c) => c.name), blocks, ledger, drawnFor: new Map(configs.filter((c) => c.plugin).map((c) => [c.name, c.plugin])) }
|
||||
|
||||
if (process.argv.includes('--self-test')) {
|
||||
const example = { family: 'regionPoster', name: 'region-poster.66x66', size: '66x66', mobileFriendly: true }
|
||||
|
|
@ -162,6 +171,7 @@ if (process.argv.includes('--self-test')) {
|
|||
['an invalid block (mobileFriendly missing)', { ...real, blocks: [...blocks, B({ family: 'regionPoster', name: 'region-poster.x', size: '66x66' })] }, /^6: .*mobileFriendly/],
|
||||
['a name clash with templates.js', { ...real, blocks: [...blocks, B({ ...example, name: real.names[0] })] }, /^7: /],
|
||||
['a renamed ledger name', { ...real, names: real.names.map((n, i) => (i === 0 ? `${n}-renamed` : n)) }, /^8: /],
|
||||
['another plugin reusing a name a template draws for UPG100', { ...real, blocks: [...blocks, B({ ...example, name: 'upg100-territory-poster.3x3' })] }, /^7: /],
|
||||
['a block naming a preset that does not exist', { ...real, blocks: [...blocks, B({ ...example, instance: 'nowhere' }, ['africa'])] }, /^6: .*instance/],
|
||||
]
|
||||
let ok = true
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check:upg100-zone-copy — library/packet/upg100Zones/ is UPG100's zone-poster code, copied so Poster Design (and any
|
||||
* page) can draw a UPG100 zone poster from the library. Two modes, chosen by what is on the tree:
|
||||
*
|
||||
* COPY while UPG100's originals (plugin-designs/my-upg-100-list/data/<file>) exist: every library file equals
|
||||
* its original with ONLY the import specifiers normalised (the copy's library paths are shorter), and
|
||||
* clusters.json byte for byte. A changed literal, a renamed variable, any edit in a function body FAILS:
|
||||
* change both, or change the plugin and copy it again.
|
||||
* ONE-PATH once UPG100 has switched to the library copy and deleted its originals: no plugin file may define the
|
||||
* zone functions again (one definition, one owner), as check:country-sheet-one-path does for countries.
|
||||
* Half the originals present = FAIL (a switch left halfway).
|
||||
*
|
||||
* --self-test: a one-character edit inside a function body, a changed clusters.json and a missing copy each FAIL; a
|
||||
* change to an import specifier alone PASSES (the normalisation is no wider than the specifiers).
|
||||
*/
|
||||
import { readFileSync, existsSync, readdirSync } from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
|
||||
const LIB = join(ROOT, 'library/packet/upg100Zones')
|
||||
const PLUGIN = join(ROOT, 'plugin-designs/my-upg-100-list')
|
||||
const FILES = ['zone-sheet-print.js', 'zone-sheet.js', 'legend-tree.js', 'selector-evaluator.js', 'zone-geometry.js', 'zones-for-list.js', 'clusters.json']
|
||||
const DEFINES = /export\s+(?:async\s+)?function\s+(makeZoneSheet|upg100Zones|assignPins|seriesFacts|resolveZoneGeometry|zonesForList|buildLegendNodes)\b/
|
||||
|
||||
// Only the quoted specifier of a static import/export-from, a dynamic import() or a bare import is replaced.
|
||||
export const normalise = (s) => s
|
||||
.replace(/(\bfrom\s*)(['"])[^'"\n]+\2/g, '$1$2§$2')
|
||||
.replace(/(\bimport\(\s*)(['"])[^'"\n]+\2/g, '$1$2§$2')
|
||||
.replace(/(^\s*import\s*)(['"])[^'"\n]+\2/gm, '$1$2§$2')
|
||||
|
||||
export function arms(read) {
|
||||
const fails = []
|
||||
const originals = FILES.filter((f) => read('plugin', f) != null)
|
||||
if (originals.length && originals.length < FILES.length) fails.push(`half switched: UPG100 still has ${originals.join(', ')} but not ${FILES.filter((f) => !originals.includes(f)).join(', ')}`)
|
||||
for (const f of FILES) if (read('lib', f) == null) fails.push(`the library copy has no ${f}`)
|
||||
if (originals.length === FILES.length) {
|
||||
for (const f of FILES) {
|
||||
const a = read('plugin', f), b = read('lib', f)
|
||||
if (b == null) continue
|
||||
const same = f.endsWith('.json') ? a === b : normalise(a) === normalise(b)
|
||||
if (!same) fails.push(`${f}: the library copy differs from UPG100's original beyond its import paths (change both, or copy again)`)
|
||||
}
|
||||
return { mode: 'COPY', fails }
|
||||
}
|
||||
if (!originals.length) {
|
||||
for (const f of read('pluginFiles')) { const m = (read('pluginAbs', f) || '').match(DEFINES); if (m) fails.push(`one path: ${f} defines ${m[1]} again; the library copy is the only one`) }
|
||||
return { mode: 'ONE-PATH', fails }
|
||||
}
|
||||
return { mode: 'HALF', fails }
|
||||
}
|
||||
|
||||
const disk = (overrides = {}) => (where, f) => {
|
||||
const key = `${where}:${f}`
|
||||
if (key in overrides) return overrides[key]
|
||||
if (where === 'pluginFiles') {
|
||||
const out = []; const walk = (d) => { for (const e of readdirSync(d, { withFileTypes: true })) { const p = join(d, e.name); if (e.isDirectory()) { if (!/node_modules|assets/.test(e.name)) walk(p) } else if (/\.(m?js|vue)$/.test(e.name)) out.push(p) } }
|
||||
walk(PLUGIN); return out
|
||||
}
|
||||
const p = where === 'pluginAbs' ? f : join(where === 'lib' ? LIB : join(PLUGIN, 'data'), f)
|
||||
return existsSync(p) ? readFileSync(p, 'utf8') : null
|
||||
}
|
||||
|
||||
if (process.argv.includes('--self-test')) {
|
||||
const real = disk()
|
||||
const body = real('lib', 'zone-sheet.js')
|
||||
const at = body.indexOf('return ', body.indexOf('export function upg100Zones'))
|
||||
const spec = real('lib', 'zone-sheet-print.js')
|
||||
const cases = [
|
||||
['a one-character edit inside a function body', { 'lib:zone-sheet.js': body.slice(0, at) + 'return ' + body.slice(at + 7) }, true],
|
||||
['a changed clusters.json', { 'lib:clusters.json': real('lib', 'clusters.json').replace('"id"', '"id "') }, true],
|
||||
['a missing copy', { 'lib:selector-evaluator.js': null }, true],
|
||||
// ONE-PATH (after UPG100 switches): no originals, and a plugin file that defines the zone code again must fail
|
||||
['one path: a plugin file defining makeZoneSheet again', { ...Object.fromEntries(FILES.map((f) => [`plugin:${f}`, null])), 'pluginFiles:undefined': ['/fake/again.js'], 'pluginAbs:/fake/again.js': 'export function makeZoneSheet() {}' }, true],
|
||||
['one path: no originals and no second definition', { ...Object.fromEntries(FILES.map((f) => [`plugin:${f}`, null])), 'pluginFiles:undefined': [] }, false],
|
||||
['half switched: some originals deleted', { 'plugin:zone-geometry.js': null }, true],
|
||||
['an import specifier changed alone', { 'lib:zone-sheet-print.js': spec.replace("from './zone-sheet.js'", "from './zone-sheet.js?v=2'") }, false],
|
||||
]
|
||||
let ok = true
|
||||
for (const [name, ov, shouldFail] of cases) {
|
||||
const { fails } = arms(disk(ov))
|
||||
const good = shouldFail ? fails.length > 0 : fails.length === 0
|
||||
console.log(` ${good ? '✓' : '✗'} ${name}: ${fails.length ? 'FAILS' : 'passes'} (${shouldFail ? 'must fail' : 'must pass'})${fails[0] ? ' -> ' + fails[0] : ''}`)
|
||||
if (!good) ok = false
|
||||
}
|
||||
console.log(ok ? 'UPG100-ZONE-COPY SELF-TEST: PASS' : 'UPG100-ZONE-COPY SELF-TEST: FAIL'); process.exit(ok ? 0 : 1)
|
||||
}
|
||||
const { mode, fails } = arms(disk())
|
||||
if (fails.length) { console.log(`UPG100-ZONE-COPY: FAIL (${mode}, ${fails.length})`); fails.forEach((f) => console.log(` - ${f}`)); process.exit(1) }
|
||||
console.log(`UPG100-ZONE-COPY: PASS (${mode}) — the ${FILES.length} files of library/packet/upg100Zones/ ${mode === 'COPY' ? "equal UPG100's originals beyond their import paths" : 'are the only definition of the zone code'}.`)
|
||||
|
|
@ -63,7 +63,7 @@ async function copyName() {
|
|||
<dd><code dir="ltr" class="pd-dialog__name">{{ config.name }}</code>
|
||||
<button type="button" class="pd-dialog__copy" @click="copyName">{{ t('posterDesign.details.copyName') }}</button></dd>
|
||||
<dt>{{ t('posterDesign.details.size') }}</dt>
|
||||
<dd dir="ltr">{{ config.size }}<span v-if="config.px"> · {{ config.px[0] }} × {{ config.px[1] }} px</span></dd>
|
||||
<dd>{{ config.size }}<span v-if="config.px"> · {{ ltr(`${config.px[0]} × ${config.px[1]} px`) }}</span></dd>
|
||||
<template v-if="!config.pageUrl">
|
||||
<dt>{{ t('posterDesign.details.fileSize') }}</dt>
|
||||
<dd>{{ fileSize }}</dd>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,16 @@
|
|||
"cards.postcardLandscape",
|
||||
"cards.a6",
|
||||
"wall.bless-panels",
|
||||
"map-print.dialog"
|
||||
"map-print.dialog",
|
||||
"country-poster.letter",
|
||||
"upg100-territory-poster.letter",
|
||||
"upg100-territory-poster.3x3",
|
||||
"upg100-territory-poster.3x4",
|
||||
"wall.bless-36x48",
|
||||
"wall.bless-24x36",
|
||||
"wall.bless-60x96",
|
||||
"bible-poster.36x60",
|
||||
"upg100-territory-packet.page-1",
|
||||
"bless-india.range-badge-60x96"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,9 @@ export function designsFromManifest(manifest, manifestUrl) {
|
|||
const rel = inst ? inst.iframeUrl : p.iframeUrl
|
||||
let pageUrl = rel
|
||||
try { pageUrl = new URL(rel, manifestUrl).href } catch { /* keep it as written */ }
|
||||
// titles: the labelKey resolved per locale by the build (posterDesignLabels), so any area reads translated
|
||||
out.push({ name: d.name, family: d.family, size: d.size, mobileFriendly: d.mobileFriendly, labelKey: d.labelKey || '',
|
||||
titles: (p.posterDesignLabels && p.posterDesignLabels[d.name]) || {},
|
||||
label: (inst && inst.title) || p.label || p.profile, bundle: b.bundle, profile: p.profile, instance: d.instance || '', pageUrl })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
39
plugin-designs/poster-design/src/sizeText.js
Normal file
39
plugin-designs/poster-design/src/sizeText.js
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* sizeText.js — a poster's size in the reader's language, the ONE way Poster Design names sizes (Styles and Sizes).
|
||||
* Numbers are spoken whole or to one decimal (5.5); a side of a yard or more in whole or half feet reads in feet
|
||||
* (3 × 5.5 ft), anything smaller in inches (18 × 24 in). Every word comes from the posterDesign.sizes catalogue.
|
||||
*
|
||||
* spec = { dims: [w, h] } inches · { dims, lanes: true } · { dims, panels: n } · { paper: 'letter', packetPage? } ·
|
||||
* { any: true } (a template that prints at whatever size its reader picks)
|
||||
*/
|
||||
import { resolvePosterSize } from '@map/constants/posterSizes.js'
|
||||
|
||||
export const num = (x) => (Number.isInteger(x) ? String(x) : String(Math.round(x * 10) / 10))
|
||||
|
||||
export function dimsText(w, h, t) {
|
||||
const ft = (x) => x / 12
|
||||
const halfFeet = (x) => Number.isInteger(ft(x) * 2)
|
||||
if (Math.min(w, h) >= 36 && halfFeet(w) && halfFeet(h)) return t('posterDesign.sizes.dimsFt', { w: num(ft(w)), h: num(ft(h)) })
|
||||
return t('posterDesign.sizes.dimsIn', { w: num(w), h: num(h) })
|
||||
}
|
||||
|
||||
export function sizeText(spec, t) {
|
||||
if (!spec) return ''
|
||||
if (spec.any) return t('posterDesign.sizes.anySize')
|
||||
if (spec.paper) {
|
||||
const paper = t('posterDesign.sizes.paperLetter')
|
||||
return spec.packetPage ? t('posterDesign.sizes.packetPage1', { paper }) : paper
|
||||
}
|
||||
const dims = dimsText(spec.dims[0], spec.dims[1], t)
|
||||
if (spec.lanes) return t('posterDesign.sizes.sideLanes', { dims })
|
||||
if (spec.panels) return t('posterDesign.sizes.panels', { dims, n: num(spec.panels) })
|
||||
return dims
|
||||
}
|
||||
|
||||
/** A plugin's declared size: a ladder key ("66x66") through the shared ladder, letter / a4 by name; anything else as written. */
|
||||
export function declaredSizeText(size, t) {
|
||||
if (size === 'letter') return t('posterDesign.sizes.paperLetter')
|
||||
if (size === 'a4') return t('posterDesign.sizes.paperA4')
|
||||
const s = resolvePosterSize(size)
|
||||
return s && s.inches && !s.paper && s.id !== 'paper' ? dimsText(s.inches.w, s.inches.h, t) : String(size || '')
|
||||
}
|
||||
|
|
@ -32,19 +32,22 @@ export const SAMPLE_COUNTRY_ID = 'country-npl'
|
|||
// country prints at 66x66 or 36x60 today: those two are drawn with the largest drawable countries (China, Sudan) to
|
||||
// show the template, and their details say so (missing: noCountryAtSize). Names never change (src/catalogue-names.json).
|
||||
export const SHEET_FORMATS = {
|
||||
'3x3': { page: '3 × 3', px: [1500, 1500] },
|
||||
'60x96': { page: '5 × 8 ft, side lanes', px: [2500, 4000], sample: 'country-ind' },
|
||||
'66x66': { page: '5.5 × 5.5 ft, side lanes', px: [2750, 2750], sample: 'country-chn', missing: 'posterDesign.missing.noCountryAtSize' },
|
||||
'36x60': { page: '3 × 5 ft, side lanes', px: [1500, 2500], sample: 'country-sdn', missing: 'posterDesign.missing.noCountryAtSize' },
|
||||
'3x4': { page: '3 × 4', px: [1500, 2000], legacy: true },
|
||||
letter: { page: 'Letter, portrait', px: [1500, 1941] },
|
||||
packet: { page: 'Letter, portrait (packet page 1)', px: [1500, 1941] },
|
||||
// `size` is the printed size as data (src/sizeText.js names it in the reader's language): inches, side lanes, paper.
|
||||
'3x3': { size: { dims: [36, 36] }, px: [1500, 1500] },
|
||||
// experimental: still being designed, or no drawable preview; Styles lists these in a collapsed Experimental group.
|
||||
'60x96': { size: { dims: [60, 96], lanes: true }, px: [2500, 4000], sample: 'country-ind', experimental: true },
|
||||
'66x66': { size: { dims: [66, 66], lanes: true }, px: [2750, 2750], sample: 'country-chn', missing: 'posterDesign.missing.noCountryAtSize', experimental: true },
|
||||
'36x60': { size: { dims: [36, 60], lanes: true }, px: [1500, 2500], sample: 'country-sdn', missing: 'posterDesign.missing.noCountryAtSize', experimental: true },
|
||||
'3x4': { size: { dims: [36, 48] }, px: [1500, 2000], legacy: true },
|
||||
letter: { size: { paper: 'letter' }, px: [1500, 1941] },
|
||||
packet: { size: { paper: 'letter', packetPage: true }, px: [1500, 1941] },
|
||||
}
|
||||
|
||||
// Families are the sections of the Styles tab, in reading order. The keys are written out, never
|
||||
// assembled, so the catalogue checks and the i18n area build can see each one.
|
||||
export const FAMILY_KEYS = {
|
||||
mapSheet: 'posterDesign.family.mapSheet',
|
||||
upgZone: 'posterDesign.family.upgZone',
|
||||
packet: 'posterDesign.family.packet',
|
||||
cards: 'posterDesign.family.cards',
|
||||
wall: 'posterDesign.family.wall',
|
||||
|
|
@ -57,9 +60,10 @@ function sheetEntry(format) {
|
|||
name: format === 'packet' ? 'map-sheet.packet-page' : `map-sheet.country.${format}`,
|
||||
family: 'mapSheet',
|
||||
labelKey: format === 'packet' ? 'posterDesign.tpl.mapSheetPacketPage' : 'posterDesign.tpl.mapSheetCountry',
|
||||
size: f.page,
|
||||
size: f.size,
|
||||
px: f.px,
|
||||
legacy: !!f.legacy,
|
||||
...(f.experimental ? { experimental: true } : {}),
|
||||
...(f.missing ? { missing: f.missing } : {}),
|
||||
renderer: 'library/packet/posterSheets.js generateCountrySheet → library/packet/poster-render.js',
|
||||
settings: { format },
|
||||
|
|
@ -68,15 +72,38 @@ function sheetEntry(format) {
|
|||
}
|
||||
}
|
||||
|
||||
// UPG100's zone posters, drawn from the LIBRARY COPY of UPG100's zone code (library/packet/upg100Zones/, kept equal to
|
||||
// the plugin's own by check:upg100-zone-copy). They carry UPG100's own design names (`plugin`), so a drawn card here
|
||||
// replaces that plugin's no-preview card, and the names never change. The sample zone is one of UPG100's real zones.
|
||||
export const SAMPLE_ZONE_ID = 'middle-east-core'
|
||||
const ZONE_RENDERER = 'library/packet/upg100Zones/zone-sheet-print.js makeZoneSheet → library/packet/posterSheets.js generateSheet'
|
||||
function zoneEntry(format) {
|
||||
const f = SHEET_FORMATS[format]
|
||||
const page1 = format === 'packet'
|
||||
return {
|
||||
name: page1 ? 'upg100-territory-packet.page-1' : `upg100-territory-poster.${format}`,
|
||||
family: 'upgZone',
|
||||
labelKey: page1 ? 'posterDesign.tpl.upgZonePacket' : 'posterDesign.tpl.upgZonePoster',
|
||||
plugin: 'my-upg-100-list',
|
||||
size: f.size,
|
||||
px: f.px,
|
||||
renderer: ZONE_RENDERER,
|
||||
settings: { format },
|
||||
sample: SAMPLE_ZONE_ID,
|
||||
render: (ctx) => ctx.zoneSheet(format, SAMPLE_ZONE_ID),
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {Array<object>} every configuration, in the gallery's order. */
|
||||
export function listConfigurations() {
|
||||
return [
|
||||
...Object.keys(SHEET_FORMATS).map(sheetEntry),
|
||||
...['letter', '3x3', '3x4', 'packet'].map(zoneEntry),
|
||||
...['full', 'lightweight'].map((variant) => ({
|
||||
name: `packet.${variant}`,
|
||||
family: 'packet',
|
||||
labelKey: variant === 'full' ? 'posterDesign.tpl.packetFull' : 'posterDesign.tpl.packetLightweight',
|
||||
size: 'Letter, portrait',
|
||||
size: { paper: 'letter' },
|
||||
renderer: 'library/packet/packet-render.js buildPacket (VARIANTS)',
|
||||
settings: { variant },
|
||||
sample: SAMPLE_COUNTRY_ID,
|
||||
|
|
@ -88,7 +115,7 @@ export function listConfigurations() {
|
|||
family: 'cards',
|
||||
labelKey: 'posterDesign.tpl.cards',
|
||||
stock: s,
|
||||
size: `${s.w} × ${s.h} in`,
|
||||
size: { dims: [s.w, s.h] },
|
||||
renderer: 'library/packet/card-render.js createCardRenderer (STOCK)',
|
||||
settings: { stock: id },
|
||||
sample: SAMPLE_COUNTRY_ID,
|
||||
|
|
@ -99,7 +126,7 @@ export function listConfigurations() {
|
|||
name: 'wall.bless-panels',
|
||||
family: 'wall',
|
||||
labelKey: 'posterDesign.tpl.blessPanels',
|
||||
size: '5 × 8 ft, 3 panels',
|
||||
size: { dims: [60, 96], panels: 3 },
|
||||
renderer: 'library/components/poster/panelSheets.js planPanels (the sheets, joins and content boxes); the cards are drawn by plugin-designs/wall-of-people-groups/src/blessPoster.js',
|
||||
settings: { size: '60x96', joinFill: 'glue' },
|
||||
sample: null,
|
||||
|
|
@ -111,11 +138,12 @@ export function listConfigurations() {
|
|||
name: 'map-print.dialog',
|
||||
family: 'mapPrint',
|
||||
labelKey: 'posterDesign.tpl.mapPrint',
|
||||
size: 'library/constants/posterSizes.js',
|
||||
size: { any: true }, // the reader picks one from the shared ladder (library/constants/posterSizes.js)
|
||||
renderer: 'library/components/poster/PosterDialog.vue + library/composables/useMapPoster.js',
|
||||
settings: {},
|
||||
sample: null,
|
||||
render: null,
|
||||
experimental: true, // no preview: it prints the live map the reader is looking at
|
||||
missing: 'posterDesign.missing.mapPrint',
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@
|
|||
<body>
|
||||
<!-- Capability 5, the app shell: one element, <posterverse-app>, is a vertical-tab rail of every
|
||||
poster plugin. Each tab loads its plugin's own script lazily on first open. -->
|
||||
<posterverse-app></posterverse-app>
|
||||
<!-- This page is our own (the dev server's staging page), so the shell owns the address: the open tab is
|
||||
written to ?tab=, a reload keeps it and a copied link opens it. -->
|
||||
<posterverse-app profile-config='{"urlOwner":"app"}'></posterverse-app>
|
||||
<script type="module" src="./index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ import BackToTopButton from '@map/components/BackToTopButton.vue'
|
|||
import ShellLanguageMenu from '@map/components/ShellLanguageMenu.vue'
|
||||
import { saveLocale } from '@map/layouts/app-wrapper-material/languages.js'
|
||||
import { loadScriptOnce, whenTagDefined } from '@map/utils/embedScript.js'
|
||||
import { readViewKeys, writeViewKeys, onAddressChange, urlOwnerOf } from '@map/composables/useViewAddress.js'
|
||||
import { translate, LOCALES } from './src/strings.js'
|
||||
import { SECTIONS, TABS, resolveTabs } from './src/tabs.js'
|
||||
|
||||
|
|
@ -226,11 +227,22 @@ export default defineComponent({
|
|||
mounted() {
|
||||
this.watchHostLang()
|
||||
// Paint the rail at once; the manifest completes the rows in the background and re-drives the mount.
|
||||
this.select(this.initialIndex())
|
||||
// First paint is a REPLACE, so a reload or a shared link keeps the tab without adding a Back step. If the
|
||||
// address names a tab that only arrives with the manifest (a provisional row), write nothing yet: writing
|
||||
// tab 0 here would overwrite the link, and applyManifest's own first-paint replace writes the honoured tab.
|
||||
const want = this.requestedTab()
|
||||
const known = !want || this.tabs.some((t) => t.id === want)
|
||||
this.select(this.initialIndex(), { firstPaint: true, fromHistory: !known })
|
||||
// Back / Forward re-apply the tab from the address and never write (writing would lose Forward).
|
||||
this._offAddress = onAddressChange(({ tab }) => {
|
||||
const i = this.tabs.findIndex((t) => t.id === tab)
|
||||
this.select(i >= 0 ? i : 0, { fromHistory: true })
|
||||
})
|
||||
this.loadManifest()
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this._langObs) { this._langObs.disconnect(); this._langObs = null }
|
||||
if (this._offAddress) { this._offAddress(); this._offAddress = null }
|
||||
},
|
||||
methods: {
|
||||
tr(key, params) { return translate(this.uiLocale, key, params) },
|
||||
|
|
@ -250,8 +262,11 @@ export default defineComponent({
|
|||
},
|
||||
requestedTab() {
|
||||
let q = ''
|
||||
try { q = new URLSearchParams(location.search).get('tab') || '' } catch { /* non-browser */ }
|
||||
return String(this.tab || this.cfg.tab || q || '').trim()
|
||||
try { q = readViewKeys(location.search).tab } catch { /* non-browser */ }
|
||||
// On a page whose address this shell owns, the URL (a reload, a shared link) beats a baked cfg.tab, or the
|
||||
// first-paint replace would overwrite the link with the default. Elsewhere the host's config leads.
|
||||
const order = urlOwnerOf(this.cfg) ? [this.tab, q, this.cfg.tab] : [this.tab, this.cfg.tab, q]
|
||||
return String(order.find((v) => v) || '').trim()
|
||||
},
|
||||
initialIndex() {
|
||||
const i = this.tabs.findIndex((t) => t.id === this.requestedTab())
|
||||
|
|
@ -306,7 +321,7 @@ export default defineComponent({
|
|||
const w = !this._honouredTab && want ? this.tabs.findIndex((t) => t.id === want) : -1
|
||||
const i = this.tabs.findIndex((t) => t.id === activeId)
|
||||
this._honouredTab = true
|
||||
this.select(w >= 0 ? w : (i >= 0 ? i : 0))
|
||||
this.select(w >= 0 ? w : (i >= 0 ? i : 0), { firstPaint: true })
|
||||
},
|
||||
onSelect(key) {
|
||||
const i = this.tabs.findIndex((t) => t.id === key)
|
||||
|
|
@ -322,7 +337,10 @@ export default defineComponent({
|
|||
if (this.base) return this.base.replace(/\/+$/, '') + '/' + r
|
||||
return '../' + r
|
||||
},
|
||||
select(i) {
|
||||
// The tab is written to the address by the ONE owner (urlOwner in profile-config: 'app' on our own pages,
|
||||
// 'host' when a host's router writes, absent on a partner's page = read only). A reader's choice is a push;
|
||||
// first paint is a replace; Back / Forward never write.
|
||||
select(i, { fromHistory = false, firstPaint = false } = {}) {
|
||||
const prevIndex = this.activeIndex
|
||||
this.activeIndex = i
|
||||
// Release the outgoing heavy pane first, so at most one live map context exists at a time.
|
||||
|
|
@ -334,6 +352,7 @@ export default defineComponent({
|
|||
if (!t) return
|
||||
// Tell the host which plugin is showing (CustomEvent 'tab-change', detail[0] = { id, label }).
|
||||
if (prevIndex !== i || !this._announced) { this._announced = true; this.$emit('tab-change', { id: t.id, label: this.tabLabel(t) }) }
|
||||
if (!fromHistory) writeViewKeys({ tab: t.id }, { owner: urlOwnerOf(this.cfg), push: !firstPaint, el: this.$el })
|
||||
if (!t.scriptUrl) return // applyManifest selects again once the manifest lands
|
||||
// A heavy tab's slot exists only after its v-if subtree renders, so mount on the next tick.
|
||||
if (!t.mounted) this.$nextTick(() => { if (this.activeIndex === i) this.mountElement(i) })
|
||||
|
|
|
|||
|
|
@ -55,12 +55,12 @@ export function planPoster(presetId, n) {
|
|||
const P = PRESETS[presetId] || PRESETS[DEFAULT_PRESET]
|
||||
const b = P.border
|
||||
const W = P.sheetW - 2 * b
|
||||
// Top of sheet 1: the title + DOXA line, then the prayer-themes table. Bottom of the last sheet: the
|
||||
// priestly blessing, the journey from Abraham to the church and the closing line. All scale with the width.
|
||||
// Top of sheet 1: the title and the DOXA line, then the prayer-themes table. Bottom of the last sheet: the
|
||||
// call to action (a ruled band), the journey's table, a rule, and the benediction (Numbers 6:24-26). All scale with the width.
|
||||
// gapH: air between the chart and the first row of cards, and between the last row and the footer (the grid
|
||||
// used to butt against the chart's border).
|
||||
const headH = W * 0.075, tableH = W * 0.17, gapH = W * 0.012
|
||||
const bannerH = headH + tableH + gapH, footerH = footerHeight(W)
|
||||
const titleH = W * 0.075, headH = titleH, tableH = W * 0.17, gapH = W * 0.012
|
||||
const bannerH = headH + tableH + gapH, footerH = footerHeight(W, BENEDICTION_FILL[PRESETS[presetId] ? presetId : DEFAULT_PRESET] ?? 1)
|
||||
// Each sheet's content band, in the sheet's own coordinates: y0..y1. The panels give each sheet's content box:
|
||||
// sheet 1 starts below its outer border, the others below their glue band; only the last has a bottom border
|
||||
// (the others' bottom edge is trimmed flat and glued over the next sheet's band).
|
||||
|
|
@ -84,16 +84,18 @@ export function planPoster(presetId, n) {
|
|||
const stripLayout = versePtOf(grid.cw) < MIN_READABLE_PT
|
||||
if (stripLayout) grid = fit((STRIP_PT * 1.35 + 4) * PT) // one line at STRIP_PT, plus air
|
||||
const { cols, cw, ch, rowsPer, strip } = grid
|
||||
// Place cards band by band, row by row; in the strip layout each row's strip sits under it. The rows a band
|
||||
// actually uses are centred in it, so its spare height is shared equally above and below the grid (it used
|
||||
// to pool as one wide gap above the footer).
|
||||
// Place cards band by band, row by row; in the strip layout each row's strip sits under it.
|
||||
const cards = [], strips = []
|
||||
let i = 0, row = 0
|
||||
for (let s = 0; s < bands.length && i < n; s++) {
|
||||
// The spare height goes BETWEEN the rows first (up to a quarter of a row each), so the photos spread to
|
||||
// fill the sheet, and what remains is shared equally above and below.
|
||||
const used = Math.min(rowsPer[s], Math.ceil((n - i) / cols))
|
||||
const top = bands[s].y0 + Math.max(0, (bands[s].y1 - bands[s].y0) - used * (ch + strip)) / 2
|
||||
const slack = Math.max(0, (bands[s].y1 - bands[s].y0) - used * (ch + strip))
|
||||
const rowGap = used > 1 ? Math.min(slack / (used + 1), (ch + strip) * 0.25) : 0
|
||||
const top = bands[s].y0 + (slack - rowGap * (used - 1)) / 2
|
||||
for (let r = 0; r < rowsPer[s] && i < n; r++, row++) {
|
||||
const y = top + r * (ch + strip)
|
||||
const y = top + r * (ch + strip + rowGap)
|
||||
for (let c = 0; c < cols && i < n; c++, i++) cards.push({ i, s, x: b + c * cw, y, w: cw, h: ch })
|
||||
if (strip) strips.push({ row, s, x: b, y: y + ch, w: W, h: strip })
|
||||
}
|
||||
|
|
@ -102,39 +104,54 @@ export function planPoster(presetId, n) {
|
|||
return {
|
||||
preset: P, presetId: PRESETS[presetId] ? presetId : DEFAULT_PRESET, n, cols, rowsPer, cw, ch, bands, cards,
|
||||
layout: stripLayout ? 'strip' : 'cards', strips,
|
||||
bannerH, footerH, headH, tableH, gapH, contentW: W,
|
||||
bannerH, footerH, headH, titleH, tableH, gapH, contentW: W,
|
||||
posterW: panels.finished.w, posterH: panels.finished.h, // after gluing: 60 × 96 for 5x8ft
|
||||
text: { versePt: +versePt.toFixed(1), namePt: +(versePt * 1.25).toFixed(1), readable: !stripLayout, stripPt: STRIP_PT },
|
||||
}
|
||||
}
|
||||
|
||||
// The footer's height at content width W (inches), FROM ITS TEXT: the priestly blessing (the small
|
||||
// instruction, three large lines, the small promise), the journey's seven columns (as tall as its longest
|
||||
// passage wraps), and the closing line. Characters are counted at ≈ 0.5 em each, with 12% slack for
|
||||
// word breaks; the footer is never shallower than a fifth of the width. The same font sizes as the CSS.
|
||||
// The footer's height at content width W (inches), FROM ITS TEXT: the journey's seven columns (as tall as
|
||||
// its longest passage wraps), the rule, the benediction's two lines (sized to fill the width), its reference
|
||||
// and the scripture credit. Characters are counted at ≈ 0.5 em each, with 12% slack for word breaks. The
|
||||
// same font sizes as the CSS.
|
||||
// The journey's passages are the long text: never below 6 pt (2 × 3 ft), ~12 pt on the 5 × 8 ft poster, which
|
||||
// keeps the 5 × 8's verse under every card at 6 pt (a larger footer would shrink the cards below it).
|
||||
// ONE inner padding for every block (header, chart cells, footer), so all the text shares the same left and
|
||||
// right edges across the full content width.
|
||||
const padOf = (W) => W * 0.008
|
||||
export const FOOTER_FONTS = (W) => ({ small: W * 0.0065, line: W * 0.012, ref: W * 0.0075, name: W * 0.0062, station: Math.max(6 / 72, W * 0.0034), close: W * 0.0085 })
|
||||
function footerHeight(W) {
|
||||
const F = FOOTER_FONTS(W), gap = W * 0.006
|
||||
// THE BENEDICTION'S WIDTH, per size: the share of the footer's text width its longer clause fills. 1 = the full
|
||||
// width (the brief's "two lines, large, filling the footer width"). The trade-off at 5 × 8 ft, measured with
|
||||
// 2,107 groups: at 1 the footer is 17.3 in deep and the cards 0.87 in, one verse per ROW; at 0.3 the cards
|
||||
// keep a verse UNDER EVERY CARD (1.04 in). Change a size's number here to choose; nothing else needs to move.
|
||||
export const BENEDICTION_FILL = { '3x4ft': 1, '2x3ft': 1, '5x8ft': 1 }
|
||||
// Georgia bold italic, em per character of the longer line, MEASURED on the rendered print of these two lines:
|
||||
// 0.61 filled 0.911 of the width, so ≈ 0.555; 0.56 fills ~0.99 and leaves a hair of the padding clear.
|
||||
const BENEDICTION_EM = 0.56
|
||||
// MEASURED: at 0.47 the English line needed 1.057 of its band (it wrapped), so ≈ 0.50; 0.51 keeps it on one line
|
||||
const CTA_EM = 0.51
|
||||
export const FOOTER_FONTS = (W, fill = 1) => ({
|
||||
ref: W * 0.0075, name: W * 0.0062, station: Math.max(6 / 72, W * 0.0034), credit: Math.max(6 / 72, W * 0.0042),
|
||||
// the call to action fills its band on one line in English (CTA_EM, Georgia italic); a longer
|
||||
// translation wraps to a second line, which footerHeight() budgets for
|
||||
cta: (W - 2 * padOf(W)) / (CLOSING_EN.length * CTA_EM),
|
||||
verse: (fill * (W - 2 * padOf(W))) / (Math.max(...POSTER.benediction.lines.map((l) => l.length)) * BENEDICTION_EM),
|
||||
})
|
||||
function footerHeight(W, fill) {
|
||||
const F = FOOTER_FONTS(W, fill), gap = W * 0.006
|
||||
const lines = (text, width, fs) => Math.max(1, Math.ceil((String(text).length * 0.5 * fs * 1.12) / width))
|
||||
const B = POSTER.blessing
|
||||
const blessH = [...B.intro, ...B.outro].reduce((a, t) => a + lines(t, W - 2 * padOf(W), F.small) * F.small * 1.35, 0)
|
||||
+ B.lines.reduce((a, t) => a + lines(t, W, F.line) * F.line * 1.25, 0) + F.ref * 1.6
|
||||
const colW = (W - 6 * gap) / 7 - W * 0.008
|
||||
const stationH = Math.max(...POSTER.journey.map((v) => lines(v.text, colW, F.station) * F.station * 1.3)) + F.name * 1.6 + F.ref * 1.6 + W * 0.006
|
||||
// the closing line's English sizes its row (its translations run within a few words of it); CLOSING_EN is
|
||||
// declared below, which is fine: this runs when a poster is planned, long after the module has loaded
|
||||
const closeH = lines(CLOSING_EN, W - 2 * padOf(W), F.close) * F.close * 1.35
|
||||
return Math.max(W * 0.2, (blessH + stationH + closeH + W * 0.03) * 1.05)
|
||||
// the call to action's band (two lines budgeted), the seven stations, the rule, the benediction's two lines,
|
||||
// its reference and the credit line
|
||||
const ctaH = 2 * F.cta * 1.3 + W * 0.02
|
||||
const verseH = POSTER.benediction.lines.length * F.verse * 1.12 + F.ref * 1.8 + F.credit * 1.6
|
||||
return (ctaH + stationH + verseH + W * 0.04) * 1.05
|
||||
}
|
||||
|
||||
// The closing line under the footer (agent-written); translated via texts.labels.closing when available.
|
||||
const CLOSING_EN = 'Jesus Christ is the cornerstone, and we are living stones. These people groups are the reward of his suffering. Take up our priestly calling: bless them in his name, and join the global family in prayer.'
|
||||
// The journey's station names (agent-written); translated via texts.labels.journey_<id> when available.
|
||||
// The call to action above the journey (agent-written; translated via texts.labels.closing): the poster's one line
|
||||
// of its own words, in a ruled band as the UPG 100 List posters set theirs, while the footer itself is scripture.
|
||||
const CLOSING_EN = 'Jesus Christ is the cornerstone, and we are living stones. These people groups are the reward of his suffering. Take up our priestly calling: bless them in his name, and join the global family in prayer.'
|
||||
const JOURNEY_EN = { called: 'Called', promised: 'Promised', redeemed: 'Redeemed', cornerstone: 'The cornerstone', children: 'Children of Abraham', priesthood: 'A royal priesthood', sent: 'Sent to the lost' }
|
||||
const THEME_EN = { treasure: 'Treasure', mountain: 'Mountain', sea: 'Sea' }
|
||||
// Three simple line icons for the prayer themes (stroke = currentColor).
|
||||
|
|
@ -231,7 +248,7 @@ function posterParts(groups, presetId, texts, withCards = true) {
|
|||
const upright = /^(zh|ja|ko)/.test(String(texts.lang || '')) // CJK sidebar labels stand upright
|
||||
const glueHere = texts.glueHere || 'glue here'
|
||||
const f = (v) => v.toFixed(3) + 'in'
|
||||
const W = plan.contentW, FF = FOOTER_FONTS(W) // the footer's sizes: the same numbers footerHeight() plans with
|
||||
const W = plan.contentW, FF = FOOTER_FONTS(W, BENEDICTION_FILL[plan.presetId] ?? 1) // the footer's sizes: the same numbers footerHeight() plans with
|
||||
const vPt = plan.text.versePt, rPt = cardText(plan, '').refPt
|
||||
// The rotated sidebar labels of the chart are sized from their text: the longest word must fit the row's
|
||||
// height (a CJK label stands upright, one em a character; other scripts ≈ 0.84 em a capital: 0.72 for the
|
||||
|
|
@ -268,10 +285,10 @@ function posterParts(groups, presetId, texts, withCards = true) {
|
|||
const s = bd.s
|
||||
const marks = panelChromeHtml(panels, s, { glueHere }) // the glue band (before), the trim line + s / N tag (after)
|
||||
// THE STORY, top to bottom: in Jesus' name → three prayer themes (OT + NT) → every people group →
|
||||
// the priestly blessing → the calling to bless, Abraham to the church with Christ at its centre →
|
||||
// the closing line, at the foundation. Each station's text takes dir="auto": the grid runs right to left
|
||||
// in an RTL locale, while an English verse inside it keeps its punctuation at the end. The same holds for
|
||||
// every scripture, name and reference on the sheet (cards, chart, blessing, closing).
|
||||
// the calling to bless, Abraham to the church with Christ at its centre → the benediction, at the
|
||||
// foundation. Each station's text takes dir="auto": the grid runs right to left in an RTL locale, while an
|
||||
// English verse inside it keeps its punctuation at the end. The same holds for every scripture, name and
|
||||
// reference on the sheet; the benediction and its credit are set left to right, as the English they are.
|
||||
const cell = (v) => `<p class="tv" dir="auto">${esc(v.text)}</p><p class="tr" dir="auto">${esc(v.ref)}</p>`
|
||||
const table = `<div class="themes" dir="${dir}">`
|
||||
+ `<div class="rl rl-h">${railLabel(lab('themesLabel', 'Prayer themes'))}</div>`
|
||||
|
|
@ -285,14 +302,15 @@ function posterParts(groups, presetId, texts, withCards = true) {
|
|||
? `<header class="ban" dir="${dir}" style="top:${f(bd.bannerY)};height:${f(plan.bannerH)}">`
|
||||
+ `<div class="hd" style="height:${f(plan.headH)}"><h1>${esc(banner)}</h1>`
|
||||
+ (subtitle ? `<p class="subt">${esc(subtitle)}</p>` : '') + `</div>`
|
||||
+ `<div style="height:${f(plan.tableH)}">${table}</div></header>` : ''
|
||||
+ `<div class="tblw" style="height:${f(plan.tableH)}">${table}</div></header>` : ''
|
||||
const foot = bd.footerY != null
|
||||
? `<footer class="ftr" dir="${dir}" style="top:${f(bd.footerY)};height:${f(plan.footerH)}">`
|
||||
+ `<div class="bless">${POSTER.blessing.intro.map((l) => `<p class="bi" dir="auto">${esc(l)}</p>`).join('')}`
|
||||
+ `${POSTER.blessing.lines.map((l) => `<p dir="auto">${esc(l)}</p>`).join('')}`
|
||||
+ `${POSTER.blessing.outro.map((l) => `<p class="bi" dir="auto">${esc(l)}</p>`).join('')}<p class="br" dir="auto">${esc(POSTER.blessing.ref)}</p></div>`
|
||||
+ `<div class="cta"><p class="ctal" dir="auto">${esc(lab('closing', CLOSING_EN))}</p></div>`
|
||||
+ `<div class="jny">${POSTER.journey.map((v) => `<div class="st${v.centre ? ' ctr' : ''}"><p class="sn" dir="auto">${esc(lab('journey_' + v.id, JOURNEY_EN[v.id]))}</p><p class="sv" dir="auto">${esc(v.text)}</p><p class="sr" dir="auto">${esc(v.ref)}</p></div>`).join('')}</div>`
|
||||
+ `<p class="close" dir="auto">${esc(lab('closing', CLOSING_EN))}</p>`
|
||||
+ `<div class="frule"></div>`
|
||||
+ `<p class="fv" dir="ltr" lang="en">${POSTER.benediction.lines.map(esc).join('<br>')}</p>`
|
||||
+ `<p class="fvref" dir="ltr">${esc(POSTER.benediction.ref)}</p>`
|
||||
+ `<p class="fcredit" dir="ltr" lang="en">${esc(POSTER.benediction.credit)}</p>`
|
||||
+ `</footer>` : ''
|
||||
// STRIP layout: one blessing verse per row, on a strip under the row (the same rotation as the cards').
|
||||
const strips = plan.strips.filter((t) => t.s === s).map((t) => { const v = blessingFor(t.row)
|
||||
|
|
@ -303,6 +321,9 @@ function posterParts(groups, presetId, texts, withCards = true) {
|
|||
// warm paper, rules instead of boxes, Georgia for titles and scripture, the sans for labels and names,
|
||||
// tracked capitals for the small labels, and the purple ink for references and emphasis.
|
||||
const PAD = padOf(W)
|
||||
// SECTION DIVIDERS run the page's width, as on the UPG 100 List posters: XR past the content column on each
|
||||
// side, stopping a quarter of the border short of the sheet's edge. The text stays in the content column.
|
||||
const XR = b * 0.75
|
||||
const SANS = "Lato,-apple-system,'Segoe UI',system-ui,Arial,sans-serif"
|
||||
const css = `@page{size:${P.sheetW}in ${P.sheetH}in;margin:0}
|
||||
*{box-sizing:border-box}html,body{margin:0;padding:0}
|
||||
|
|
@ -310,10 +331,13 @@ body,.poster{font-family:Georgia,'Times New Roman',serif;color:${INK};-webkit-pr
|
|||
.sheet{position:relative;width:${P.sheetW}in;height:${P.sheetH}in;overflow:hidden;page-break-after:always;break-after:page;background:${PAPER}}
|
||||
.ban{position:absolute;left:${f(b)};width:${f(W)};text-align:center}
|
||||
.hd{padding:0 ${f(PAD)}}
|
||||
.hd{display:flex;flex-direction:column;justify-content:center;align-items:center;border-bottom:.015in solid #C9BFA4}
|
||||
.hd h1{margin:0;font-size:${f(plan.headH * 0.44)};line-height:1.15;font-weight:700;letter-spacing:-.02em;color:#1c1c17}
|
||||
.hd .subt{margin:${f(plan.headH * 0.06)} 0 0;font:${f(plan.headH * 0.085)}/1.35 ${SANS};color:#7E7562;letter-spacing:.01em}
|
||||
.themes{display:grid;grid-template-columns:${f(W * 0.06)} repeat(3,1fr);grid-template-rows:34% 33% 33%;height:100%;border-bottom:.02in solid #C9BFA4;overflow:hidden}
|
||||
.hd{position:relative;display:flex;flex-direction:column;justify-content:center;align-items:center}
|
||||
.hd::after,.tblw::after,.cta::before,.cta::after{content:'';position:absolute;border-top:.015in solid #C9BFA4}
|
||||
.hd::after{left:${f(-XR)};right:${f(-XR)};bottom:0}
|
||||
.tblw{position:relative}.tblw::after{left:${f(-XR)};right:${f(-XR)};bottom:0;border-top-width:.02in}
|
||||
.hd h1{margin:0;font-size:${f(plan.titleH * 0.44)};line-height:1.15;font-weight:700;letter-spacing:-.02em;color:#1c1c17}
|
||||
.hd .subt{margin:${f(plan.titleH * 0.06)} 0 0;font:${f(plan.titleH * 0.085)}/1.35 ${SANS};color:#7E7562;letter-spacing:.01em}
|
||||
.themes{display:grid;grid-template-columns:${f(W * 0.06)} repeat(3,1fr);grid-template-rows:34% 33% 33%;height:100%;overflow:hidden}
|
||||
.themes > div{padding:${f(W * 0.006)} ${f(PAD)};overflow:hidden;border-inline-start:.01in solid #D6CCB4}
|
||||
.themes > .rl{display:flex;align-items:center;justify-content:center;color:#8a8470;padding:${f(W * 0.002)};border-inline-start:0}
|
||||
.themes > .td,.themes > .rl:not(.rl-h){border-top:.01in solid #D6CCB4}
|
||||
|
|
@ -324,19 +348,23 @@ body,.poster{font-family:Georgia,'Times New Roman',serif;color:${INK};-webkit-pr
|
|||
.td{display:flex;flex-direction:column;justify-content:center;text-align:center}
|
||||
.td .tv{margin:0;font-size:${f(tvFs)};line-height:1.3;font-style:italic;color:#3a362c}
|
||||
.td .tr{margin:${f(W * 0.002)} 0 0;font:700 ${f(W * 0.0055)} ${SANS};letter-spacing:.12em;text-transform:uppercase;color:${ACCENT}}
|
||||
.ftr{position:absolute;left:${f(b)};width:${f(W)};padding:0 ${f(PAD)};display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;border-top:.02in solid #C9BFA4}
|
||||
.bless p{margin:0;font-size:${f(FF.line)};line-height:1.25;font-weight:700;font-style:italic;color:#2A2721}
|
||||
.bless p.bi{margin:${f(W * 0.001)} 0;font-size:${f(FF.small)};line-height:1.35;font-weight:400;font-style:italic;color:#6a6450}
|
||||
.bless .br{margin-top:${f(W * 0.002)};font:700 ${f(FF.ref)} ${SANS};letter-spacing:.14em;text-transform:uppercase;color:${ACCENT};font-style:normal}
|
||||
.ftr{position:absolute;left:${f(b)};width:${f(W)};padding:0 ${f(PAD)};display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center}
|
||||
/* The call to action, in a band ruled across the page (the UPG posters' goal line), above the journey's table. */
|
||||
.cta{position:relative;width:100%;padding:${f(W * 0.009)} 0}
|
||||
.cta::before{left:${f(-PAD - XR)};right:${f(-PAD - XR)};top:0}.cta::after{left:${f(-PAD - XR)};right:${f(-PAD - XR)};bottom:0}
|
||||
.ctal{margin:0;font:italic ${f(FF.cta)}/1.3 Georgia,'Times New Roman',serif;color:#4a4636}
|
||||
/* Rules run the full content width, like the chart's; the padding belongs to the text. */
|
||||
.jny{display:grid;grid-template-columns:repeat(7,1fr);gap:${f(W * 0.006)};margin:${f(W * 0.01)} ${f(-PAD)} 0;width:${f(W)};align-items:stretch;border-top:.01in solid #D6CCB4;padding-top:${f(W * 0.004)}}
|
||||
.jny{display:grid;grid-template-columns:repeat(7,1fr);gap:${f(W * 0.006)};margin:${f(W * 0.01)} ${f(-PAD)} 0;width:${f(W)};align-items:stretch}
|
||||
.st{display:flex;flex-direction:column;justify-content:flex-start;padding:${f(W * 0.003)} ${f(W * 0.004)}}
|
||||
.st + .st{border-inline-start:.01in solid #D6CCB4}
|
||||
.st .sn{margin:0 0 ${f(W * 0.002)};font:700 ${f(FF.name)} ${SANS};letter-spacing:.08em;text-transform:uppercase;color:#8a8470}
|
||||
.st .sv{margin:0;font-size:${f(FF.station)};font-style:italic;line-height:1.3;color:#4a4636}
|
||||
.st .sr{margin:${f(W * 0.002)} 0 0;font:700 ${f(Math.max(6 / 72, W * 0.0048))} ${SANS};letter-spacing:.1em;text-transform:uppercase;color:${ACCENT}}
|
||||
.st.ctr{background:#F2EFE6;border:.02in solid #cdbf9e}.st.ctr + .st{border-inline-start:0}.st.ctr .sv{font-weight:600;color:#2A2721}.st.ctr .sn{color:${ACCENT}}
|
||||
.close{margin:${f(W * 0.008)} 0 0;font:italic 700 ${f(FF.close)}/1.35 Georgia,'Times New Roman',serif;color:#2A2721}
|
||||
.frule{width:${f(W + 2 * XR)};margin:${f(W * 0.012)} ${f(-PAD - XR)} ${f(W * 0.01)};border-top:.015in solid #C9BFA4}
|
||||
.fv{margin:0;font:italic 700 ${f(FF.verse)}/1.12 Georgia,'Times New Roman',serif;color:#2A2721;letter-spacing:-.01em;white-space:nowrap}
|
||||
.fvref{margin:${f(W * 0.004)} 0 0;font:700 ${f(FF.ref)} ${SANS};letter-spacing:.14em;text-transform:uppercase;color:${ACCENT}}
|
||||
.fcredit{margin:${f(W * 0.004)} 0 0;font:${f(FF.credit)}/1.3 ${SANS};color:#8a8470}
|
||||
.vstrip{position:absolute;display:flex;align-items:center;justify-content:center;gap:.6em;font-size:${STRIP_PT}pt;font-style:italic;
|
||||
color:#3a362c;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;border-top:.01in solid #D6CCB4}
|
||||
.vstrip .vref{font:700 .8em ${SANS};letter-spacing:.12em;text-transform:uppercase;color:${ACCENT}}
|
||||
|
|
|
|||
|
|
@ -2,17 +2,18 @@
|
|||
* blessings.js — the verses the Wall's BLESS mode prints under each people group, and the poster's
|
||||
* banner / sub-banner / footer text.
|
||||
*
|
||||
* SOURCE: the World English Bible, PUBLIC DOMAIN ("The World English Bible is in the Public Domain. You
|
||||
* may copy and share it freely." — ebible.org/eng-web). Every text below was extracted mechanically
|
||||
* from ebible.org/eng-web chapter pages (2026-09-28), never retyped or paraphrased. A card quotes a
|
||||
* SOURCE: the World English Bible British Edition, PUBLIC DOMAIN (ebible.org/eng-webbe: "a Public Domain
|
||||
* translation"), which reads "the LORD" where the WEB reads "Yahweh". The texts were first extracted
|
||||
* mechanically from the WEB (ebible.org/eng-web, 2026-09-28) and moved to the British Edition on 2026-10-03:
|
||||
* every one was re-checked verbatim against bible.helloao.org eng_webpb (its British spellings, e.g.
|
||||
* "towards", "honour", "amongst"), never retyped or paraphrased. A card quotes a
|
||||
* whole verse, or one whole sentence of it (marked "a" = the first sentence, "b" = the last, in the reference); cards hold at most 4 lines, so longer verses are quoted by one sentence, always with its reference.
|
||||
*
|
||||
* THE DIVINE NAME IS ONE SWITCH. The WEB prints "Yahweh"; the WEB British Edition (also public domain)
|
||||
* prints "the LORD" / "The LORD" in the same places. Texts carry {NAME} (mid-sentence) and {Name}
|
||||
* (sentence start); change DIVINE_NAME below to switch every verse at once. (If switching, re-verify the
|
||||
* handful of verses against the British Edition text: it also uses British spelling.)
|
||||
* THE DIVINE NAME: texts carry {NAME} (mid-sentence) and {Name} (sentence start), filled from DIVINE_NAME. The
|
||||
* poster uses "the LORD" (the British Edition's reading, and the one the brief prefers). Going back to the WEB's
|
||||
* "Yahweh" means switching the texts' spellings back too, verse by verse against the WEB.
|
||||
*/
|
||||
export const DIVINE_NAME = { mid: 'Yahweh', start: 'Yahweh' } // British Edition: { mid: 'the LORD', start: 'The LORD' }
|
||||
export const DIVINE_NAME = { mid: 'the LORD', start: 'The LORD' }
|
||||
|
||||
const withName = (s) => s.replace(/\{Name\}/g, DIVINE_NAME.start).replace(/\{NAME\}/g, DIVINE_NAME.mid)
|
||||
|
||||
|
|
@ -21,7 +22,7 @@ const withName = (s) => s.replace(/\{Name\}/g, DIVINE_NAME.start).replace(/\{NAM
|
|||
const VERSES = [
|
||||
{ ref: 'Numbers 6:24', text: '{Name} bless you, and keep you.' },
|
||||
{ ref: 'Numbers 6:25', text: '{Name} make his face to shine on you, and be gracious to you.' },
|
||||
{ ref: 'Numbers 6:26', text: '{Name} lift up his face toward you, and give you peace.' },
|
||||
{ ref: 'Numbers 6:26', text: '{Name} lift up his face towards you, and give you peace.' },
|
||||
{ ref: 'Psalm 134:3', text: 'May {NAME} bless you from Zion, even he who made heaven and earth.' },
|
||||
{ ref: 'Psalm 115:15', text: 'Blessed are you by {NAME}, who made heaven and earth.' },
|
||||
{ ref: 'Psalm 29:11', text: '{Name} will give strength to his people. {Name} will bless his people with peace.' },
|
||||
|
|
@ -46,11 +47,11 @@ export const BLESSING_COUNT = VERSES.length
|
|||
// Jesus' name", a short line on what DOXA means, a table of THREE PRAYER THEMES (treasure, mountain, sea),
|
||||
// each with an Old Testament and a New Testament verse, then every people group, then the FOOTER: the
|
||||
// priestly blessing (Numbers 6:24-26, the blessing words only) and the priesthood of believers (1 Peter 2).
|
||||
// Verses are WEB, verbatim; {NAME}/{Name} follow DIVINE_NAME. The sea's New Testament verse is from the
|
||||
// Verses are the WEB British Edition, verbatim; {NAME}/{Name} follow DIVINE_NAME. The sea's New Testament verse is from the
|
||||
// storm on the sea (Matthew 8:23-27): the verse where he rebukes the wind and the sea.
|
||||
const THEMES_RAW = [
|
||||
{ id: 'treasure',
|
||||
ot: { ref: 'Haggai 2:7', text: 'and I will shake all nations. The treasure of all nations will come, and I will fill this house with glory, says {Name} of Armies.' },
|
||||
ot: { ref: 'Haggai 2:7', text: 'and I will shake all nations. The treasure of all nations will come, and I will fill this house with glory, says {NAME} of Armies.' },
|
||||
nt: { ref: 'Matthew 13:44', text: 'Again, the Kingdom of Heaven is like treasure hidden in the field, which a man found and hid. In his joy, he goes and sells all that he has and buys that field.' } },
|
||||
{ id: 'mountain',
|
||||
ot: { ref: 'Isaiah 2:2', text: 'It shall happen in the latter days, that the mountain of {NAME}’s house shall be established on the top of the mountains, and shall be raised above the hills; and all nations shall flow to it.' },
|
||||
|
|
@ -64,21 +65,15 @@ export const THEMES = THEMES_RAW.map((t) => ({ id: t.id, ot: verse(t.ot), nt: ve
|
|||
|
||||
export const POSTER = {
|
||||
banner: 'Bless the nations in Jesus’ name',
|
||||
// The large footer: the priestly blessing, the WHOLE passage (Numbers 6:22-27, Aaron and Israel
|
||||
// included): the Lord's instruction to Aaron in small type, the three blessing lines large, and the
|
||||
// promise that closes it. Verbatim WEB, its own speech marks kept.
|
||||
blessing: {
|
||||
ref: 'Numbers 6:22–27',
|
||||
intro: [
|
||||
withName('{Name} spoke to Moses, saying,'),
|
||||
'“Speak to Aaron and to his sons, saying, ‘This is how you shall bless the children of Israel.’ You shall tell them,',
|
||||
],
|
||||
lines: [
|
||||
withName('‘{Name} bless you, and keep you.'),
|
||||
withName('{Name} make his face to shine on you, and be gracious to you.'),
|
||||
withName('{Name} lift up his face toward you, and give you peace.’'),
|
||||
],
|
||||
outro: ['“So they shall put my name on the children of Israel; and I will bless them.”'],
|
||||
// The closing benediction, the poster's footer across its full width: the priestly blessing, Numbers 6:24-26,
|
||||
// in the World English Bible British Edition (public domain, ebible.org/eng-webbe; read via bible.helloao.org
|
||||
// eng_webpb), verbatim, set as two lines broken at verse 25's comma. Every verse on the poster is from the same
|
||||
// translation, so the credit line names it once.
|
||||
benediction: {
|
||||
lines: ['The LORD bless you, and keep you. The LORD make his face to shine on you,',
|
||||
'and be gracious to you. The LORD lift up his face towards you, and give you peace.'],
|
||||
ref: 'Numbers 6:24–26',
|
||||
credit: 'Scripture: World English Bible British Edition (public domain).',
|
||||
},
|
||||
// The second footer: the calling to bless the nations as a journey, Abraham to the church, with Christ
|
||||
// the cornerstone at its centre (the `centre` station). Each station quotes its WHOLE passage, from the
|
||||
|
|
@ -86,12 +81,12 @@ export const POSTER = {
|
|||
// Luke 19:9-10): 1 Peter 2:4-10 is split at verse 9 into the cornerstone and the royal priesthood, and
|
||||
// Galatians 3:5-7 and 3:28-29 are one station, joined by " … ". Verbatim WEB, speech marks kept.
|
||||
journey: [
|
||||
{ id: 'called', ref: 'Genesis 12:1–4', text: 'Now {Name} said to Abram, “Leave your country, and your relatives, and your father’s house, and go to the land that I will show you. I will make of you a great nation. I will bless you and make your name great. You will be a blessing. I will bless those who bless you, and I will curse him who treats you with contempt. All the families of the earth will be blessed through you.” So Abram went, as {Name} had told him. Lot went with him. Abram was seventy-five years old when he departed from Haran.' },
|
||||
{ id: 'promised', ref: 'Genesis 15:5–6', text: '{Name} brought him outside, and said, “Look now toward the sky, and count the stars, if you are able to count them.” He said to Abram, “So your offspring will be.” He believed in {NAME}, who credited it to him for righteousness.' },
|
||||
{ id: 'called', ref: 'Genesis 12:1–4', text: 'Now {NAME} said to Abram, “Leave your country, and your relatives, and your father’s house, and go to the land that I will show you. I will make of you a great nation. I will bless you and make your name great. You will be a blessing. I will bless those who bless you, and I will curse him who treats you with contempt. All the families of the earth will be blessed through you.” So Abram went, as {NAME} had told him. Lot went with him. Abram was seventy-five years old when he departed from Haran.' },
|
||||
{ id: 'promised', ref: 'Genesis 15:5–6', text: '{Name} brought him outside, and said, “Look now towards the sky, and count the stars, if you are able to count them.” He said to Abram, “So your offspring will be.” He believed in {NAME}, who credited it to him for righteousness.' },
|
||||
{ id: 'redeemed', ref: 'Galatians 3:13–14', text: 'Christ redeemed us from the curse of the law, having become a curse for us. For it is written, “Cursed is everyone who hangs on a tree,” that the blessing of Abraham might come on the Gentiles through Christ Jesus, that we might receive the promise of the Spirit through faith.' },
|
||||
{ id: 'cornerstone', centre: true, ref: '1 Peter 2:4–8', text: 'Come to him, a living stone, rejected indeed by men, but chosen by God, precious. You also as living stones are built up as a spiritual house, to be a holy priesthood, to offer up spiritual sacrifices, acceptable to God through Jesus Christ. Because it is contained in Scripture, “Behold, I lay in Zion a chief cornerstone, chosen and precious. He who believes in him will not be disappointed.” For you who believe therefore is the honor, but for those who are disobedient, “The stone which the builders rejected has become the chief cornerstone,” and, “a stumbling stone and a rock of offense.” For they stumble at the word, being disobedient, to which also they were appointed.' },
|
||||
{ id: 'children', ref: 'Galatians 3:5–7, 28–29', text: 'He therefore who supplies the Spirit to you and does miracles among you, does he do it by the works of the law, or by hearing of faith? Even so, Abraham “believed God, and it was counted to him for righteousness.” Know therefore that those who are of faith are children of Abraham. … There is neither Jew nor Greek, there is neither slave nor free man, there is neither male nor female; for you are all one in Christ Jesus. If you are Christ’s, then you are Abraham’s offspring and heirs according to promise.' },
|
||||
{ id: 'priesthood', ref: '1 Peter 2:9–10', text: 'But you are a chosen race, a royal priesthood, a holy nation, a people for God’s own possession, that you may proclaim the excellence of him who called you out of darkness into his marvelous light. In the past, you were not a people, but now are God’s people, who had not obtained mercy, but now have obtained mercy.' },
|
||||
{ id: 'cornerstone', centre: true, ref: '1 Peter 2:4–8', text: 'Come to him, a living stone, rejected indeed by men, but chosen by God, precious. You also as living stones are built up as a spiritual house, to be a holy priesthood, to offer up spiritual sacrifices, acceptable to God through Jesus Christ. Because it is contained in Scripture, “Behold, I lay in Zion a chief cornerstone, chosen and precious. He who believes in him will not be disappointed.” For you who believe therefore is the honour, but for those who are disobedient, “The stone which the builders rejected has become the chief cornerstone,” and, “a stumbling stone and a rock of offence.” For they stumble at the word, being disobedient, to which also they were appointed.' },
|
||||
{ id: 'children', ref: 'Galatians 3:5–7, 28–29', text: 'He therefore who supplies the Spirit to you and does miracles amongst you, does he do it by the works of the law, or by hearing of faith? Even so, Abraham “believed God, and it was counted to him for righteousness.” Know therefore that those who are of faith are children of Abraham. … There is neither Jew nor Greek, there is neither slave nor free man, there is neither male nor female; for you are all one in Christ Jesus. If you are Christ’s, then you are Abraham’s offspring and heirs according to promise.' },
|
||||
{ id: 'priesthood', ref: '1 Peter 2:9–10', text: 'But you are a chosen race, a royal priesthood, a holy nation, a people for God’s own possession, that you may proclaim the excellence of him who called you out of darkness into his marvellous light. In the past, you were not a people, but now are God’s people, who had not obtained mercy, but now have obtained mercy.' },
|
||||
{ id: 'sent', ref: 'Luke 19:9–10', text: 'Jesus said to him, “Today, salvation has come to this house, because he also is a son of Abraham. For the Son of Man came to seek and to save that which was lost.”' },
|
||||
].map((v) => ({ ...v, text: withName(v.text) })),
|
||||
// Kept for reference: the earlier footer candidates (sheet row 99), superseded by the chosen footer.
|
||||
|
|
|
|||
|
|
@ -1,217 +0,0 @@
|
|||
<template>
|
||||
<div v-if="visible" class="dialog-overlay" @click="closeDialog">
|
||||
<div class="dialog-container" @click.stop ref="dialogBox" role="dialog" aria-modal="true" :aria-label="$t('wall.chatsTitle')" tabindex="-1">
|
||||
<!-- Dialog Header -->
|
||||
<div class="dialog-header">
|
||||
<h2>{{ $t('wall.chatsTitle') }}</h2>
|
||||
<button class="close-btn" :aria-label="$t('wall.close')" @click="closeDialog">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- People Group Image (Instagram style) -->
|
||||
<div class="group-image-container">
|
||||
<img
|
||||
:src="getImageUrl()"
|
||||
:alt="group?.name || $t('wall.peopleGroup')"
|
||||
class="group-image"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="group-name-dialog">{{ group?.name || $t('wall.unknownGroup') }}</div>
|
||||
|
||||
<!-- People Group Info -->
|
||||
<div class="dialog-content">
|
||||
<div class="group-info">
|
||||
<div class="info-row">
|
||||
<strong>{{ $t('wall.status') }}</strong> {{ group?.status || $t('wall.unknown') }}
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<strong>{{ $t('wall.id') }}</strong> {{ group?.ID || $t('wall.na') }}
|
||||
</div>
|
||||
<div class="info-row" v-if="group?.lat && group?.lng">
|
||||
<strong>{{ $t('wall.location') }}</strong> {{ group.lat }}, {{ group.lng }}
|
||||
</div>
|
||||
<div class="info-row prayer-stat">
|
||||
<span class="heart-icon">❤️</span>
|
||||
{{ $t('wall.havePrayed', { n: prayerCount }, prayerCount) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Prayer Button -->
|
||||
<div class="prayer-action">
|
||||
<button
|
||||
class="prayer-btn"
|
||||
@click="addPrayer"
|
||||
:class="{ active: justPrayed }"
|
||||
>
|
||||
{{ justPrayed ? $t('wall.youPrayed') : $t('wall.prayForGroup') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Comments Section -->
|
||||
<div class="comments-section">
|
||||
<h3>{{ $t('wall.prayersComments') }}</h3>
|
||||
|
||||
<!-- Comment List -->
|
||||
<div class="comments-list">
|
||||
<div
|
||||
v-if="comments.length === 0"
|
||||
class="no-comments"
|
||||
>
|
||||
{{ $t('wall.noComments') }}
|
||||
</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="$t('wall.commentPlaceholder')"
|
||||
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"
|
||||
>
|
||||
{{ $t('wall.postComment') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { trapDialog } from '../trapDialog.js'
|
||||
// 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] || [];
|
||||
}
|
||||
},
|
||||
beforeUnmount() { this._releaseTrap?.(); this._releaseTrap = null; },
|
||||
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);
|
||||
|
||||
// The reader's language, from the platform: "just now" / "5 minutes ago" / "hace 5 minutos" …
|
||||
// (Intl.RelativeTimeFormat), so no hand-built English "m ago" and no catalogue keys to drift.
|
||||
const locale = String(this.$i18n?.locale || 'en');
|
||||
let rtf = null;
|
||||
try { rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }); } catch (_) { /* old engine: fall back below */ }
|
||||
if (rtf) {
|
||||
if (diffMins < 1) return rtf.format(0, 'second');
|
||||
if (diffMins < 60) return rtf.format(-diffMins, 'minute');
|
||||
if (diffHours < 24) return rtf.format(-diffHours, 'hour');
|
||||
if (diffDays < 7) return rtf.format(-diffDays, 'day');
|
||||
}
|
||||
return date.toLocaleDateString(locale);
|
||||
},
|
||||
|
||||
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) this.$nextTick(() => { this._releaseTrap?.(); this._releaseTrap = trapDialog(this.$refs.dialogBox, () => this.closeDialog()); });
|
||||
else { this._releaseTrap?.(); this._releaseTrap = null; }
|
||||
if (newVal) {
|
||||
// Reset state when dialog opens
|
||||
this.justPrayed = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
@ -295,8 +295,9 @@ export default {
|
|||
this.$emit('open-prayer', group);
|
||||
},
|
||||
|
||||
openComments(group) {
|
||||
this.$emit('open-comments', group);
|
||||
// Comments are not built yet: the comment and "view prayers" taps say so instead of opening an empty board.
|
||||
openComments() {
|
||||
this.flash(this.$t('wallSocial.commentsSoon'), 3000);
|
||||
},
|
||||
|
||||
truncateText(text, maxLength) {
|
||||
|
|
|
|||
|
|
@ -33,7 +33,10 @@
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="prayer-confirmed">
|
||||
<!-- The group's own sign-up on pray.doxa.life (its #signup-section): commit to pray for it there. -->
|
||||
<a v-if="signUpUrl" :href="signUpUrl" target="_blank" rel="noopener noreferrer" class="signup-link">{{ $t('wallSocial.signUpToPray', { name: group?.name || '' }) }}</a>
|
||||
|
||||
<div v-if="hasPrayed" class="prayer-confirmed">
|
||||
<div class="confirmed-message">
|
||||
<svg class="heart-icon" width="1em" height="1em" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 20.5s-7.2-4.4-9.2-8.8C1.3 8.3 3.2 4.6 6.7 4.6c2 0 3.5 1.1 4.3 2.4.8-1.3 2.3-2.4 4.3-2.4 3.5 0 5.4 3.7 3.9 7.1-2 4.4-9.2 8.8-9.2 8.8z" fill="#ed4956" stroke="#ed4956" stroke-width="1.8" stroke-linejoin="round"/></svg>
|
||||
<span>{{ $t('wall.thankYou') }}</span>
|
||||
|
|
@ -64,6 +67,8 @@
|
|||
<script>
|
||||
import { trapDialog } from '../trapDialog.js'
|
||||
// No third-party placeholder service: a missing photo is drawn inline.
|
||||
// The locales pray.doxa.life serves under a path prefix (English is unprefixed).
|
||||
const PRAY_LOCALES = new Set(['ar', 'de', 'es', 'fr', 'hi', 'it', 'pt', 'ro', 'ru', 'zh'])
|
||||
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',
|
||||
|
|
@ -84,6 +89,18 @@ export default {
|
|||
imageError: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
// The group's prayer sign-up on pray.doxa.life (the #signup-section of its page), in the reader's language:
|
||||
// the site serves its group pages under /<lc>/ for these ten locales and English unprefixed (read from its
|
||||
// routes; /ar/<slug> carries the same section). None without a slug.
|
||||
signUpUrl() {
|
||||
const slug = this.group && this.group.slug
|
||||
if (!slug) return ''
|
||||
const lc = String(this.$i18n?.locale || 'en').split('-')[0].toLowerCase()
|
||||
const prefix = PRAY_LOCALES.has(lc) ? '/' + lc : ''
|
||||
return `https://pray.doxa.life${prefix}/${encodeURIComponent(slug)}#signup-section`
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
isOpen(newVal) {
|
||||
if (newVal) this.$nextTick(() => { this._releaseTrap?.(); this._releaseTrap = trapDialog(this.$refs.dialogBox, () => this.closeDialog()); });
|
||||
|
|
|
|||
|
|
@ -275,6 +275,12 @@ body {
|
|||
}
|
||||
|
||||
/* Group Image in Dialog */
|
||||
/* The prayer dialog SCROLLS when it is taller than the window: .dialog-container clips at 90vh, which hid
|
||||
everything under the photo on a laptop screen (the sign-up link, and the prayer box after "Yes"). The photo
|
||||
also gives way, so the prompt and its actions show without scrolling at 900 px. */
|
||||
.dialog-container.prayer-dialog { overflow-y: auto; overscroll-behavior: contain; }
|
||||
.prayer-dialog .group-image-container img { max-height: min(400px, 38vh); }
|
||||
|
||||
.group-image-container {
|
||||
width: 100%;
|
||||
background-color: #fafafa;
|
||||
|
|
@ -513,6 +519,20 @@ body {
|
|||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* The group's sign-up on pray.doxa.life, under the prayer prompt: a quiet full-width link. */
|
||||
.signup-link {
|
||||
display: block;
|
||||
margin: 12px 0 4px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #c9bfa4;
|
||||
border-radius: 8px;
|
||||
color: #6b4a78;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
.signup-link:hover, .signup-link:focus-visible { background: #f7f4ec; }
|
||||
|
||||
.prayer-confirm-btn {
|
||||
padding: 14px 28px;
|
||||
background: var(--heart-color);
|
||||
|
|
|
|||
|
|
@ -5,5 +5,10 @@
|
|||
"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.",
|
||||
"config": {
|
||||
"pinHint": false
|
||||
}
|
||||
},
|
||||
"posterDesign": [
|
||||
{"family": "wall", "name": "wall.bless-36x48", "size": "36x48", "mobileFriendly": false, "labelKey": "wall.blessBanner"},
|
||||
{"family": "wall", "name": "wall.bless-24x36", "size": "24x36", "mobileFriendly": false, "labelKey": "wall.blessBanner"},
|
||||
{"family": "wall", "name": "wall.bless-60x96", "size": "60x96", "mobileFriendly": false, "labelKey": "wall.blessBanner"}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,29 +18,23 @@
|
|||
* (src/wallD3.js). Font Awesome is no longer used: the canvas draws its own icons.
|
||||
*/
|
||||
import PeopleGroupsWall from './src/components/PeopleGroupsWall.vue'
|
||||
import CommentsDialog from './src/components/CommentsDialog.vue'
|
||||
import PrayerDialog from './src/components/PrayerDialog.vue'
|
||||
import { ensureD3 } from './src/wallD3.js'
|
||||
|
||||
export default {
|
||||
name: 'WallOfPeopleGroups',
|
||||
components: { PeopleGroupsWall, CommentsDialog, PrayerDialog },
|
||||
components: { PeopleGroupsWall, PrayerDialog },
|
||||
data() {
|
||||
return { ready: false, error: null, commentsDialogVisible: false, prayerDialogVisible: false, selectedGroup: null }
|
||||
return { ready: false, error: null, prayerDialogVisible: false, selectedGroup: null }
|
||||
},
|
||||
mounted() {
|
||||
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)
|
||||
setTimeout(() => { this.selectedGroup = null }, 300)
|
||||
},
|
||||
handlePrayerConfirmed() { /* PrayerDialog already updated the store */ },
|
||||
},
|
||||
|
|
@ -49,9 +43,8 @@ export default {
|
|||
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<people-groups-wall v-if="ready" @open-comments="openCommentsDialog" @open-prayer="openPrayerDialog" />
|
||||
<people-groups-wall v-if="ready" @open-prayer="openPrayerDialog" />
|
||||
<div v-else-if="error" class="wall-error">{{ $t('wall.drawLibError') }} {{ error }}</div>
|
||||
<comments-dialog :visible="commentsDialogVisible" :group="selectedGroup" @close="closeCommentsDialog" />
|
||||
<prayer-dialog :is-open="prayerDialogVisible" :group="selectedGroup" @close="closePrayerDialog" @prayer-confirmed="handlePrayerConfirmed" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -76,7 +76,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <all-people-groups> custom element. -->
|
||||
<script src="../all-people-groups.js" integrity="sha384-XZupcGRo5bafMWWJsYcOJVOhT4Mw4Vv/v4hgVKMrQU5X8IoRLP11Z9PoGkbxbxLS" crossorigin="anonymous"></script>
|
||||
<script src="../all-people-groups.js" integrity="sha384-kCCtSdpWfJmCi6wtGplAHul5Vo+0Et2h4HMZLXHIsHuBtFn/0FOJqsTZk+/VfPka" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
@ -92,7 +92,10 @@
|
|||
(function () {
|
||||
var slot = document.getElementById('slot');
|
||||
var status = document.getElementById('status');
|
||||
var BASE_CONFIG = {"profile":"all-people-groups","dataSource":"pray-tools","instanceId":"all-people-groups-all-people-groups","assetsBase":"../../assets"};
|
||||
var BASE_CONFIG = {"profile":"all-people-groups","dataSource":"pray-tools","instanceId":"all-people-groups-all-people-groups","assetsBase":"../../assets","urlOwner":"app"};
|
||||
// Framed (the iframe embed on a partner's page): READ the address, never write it. A push inside a frame adds
|
||||
// entries to the partner page's own Back button (the joint session history).
|
||||
try { if (window.self !== window.top) delete BASE_CONFIG.urlOwner } catch (e) { delete BASE_CONFIG.urlOwner }
|
||||
// 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.
|
||||
// (A leaf whose engine is MapLibre at RUNTIME joins them — see PROVIDER below.)
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -67,7 +67,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <bible-posters> custom element. -->
|
||||
<script src="../bible-posters.js" integrity="sha384-ynxYcvYpparOlz9opwuHgBDP0XFUVXdThQWubDo1majENhbrlnf+AaGacWKIwIWx" crossorigin="anonymous"></script>
|
||||
<script src="../bible-posters.js" integrity="sha384-twS8rEG4vtm8MPjq+ytjSf6g4stmnzr/6KYbVRDhQ3TH7MiTgsNG7X19Y3Ncb0JJ" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
@ -83,7 +83,10 @@
|
|||
(function () {
|
||||
var slot = document.getElementById('slot');
|
||||
var status = document.getElementById('status');
|
||||
var BASE_CONFIG = {"profile":"bible-posters","dataSource":"pray-tools","instanceId":"bible-posters-bible-posters","assetsBase":"../../assets"};
|
||||
var BASE_CONFIG = {"profile":"bible-posters","dataSource":"pray-tools","instanceId":"bible-posters-bible-posters","assetsBase":"../../assets","urlOwner":"app"};
|
||||
// Framed (the iframe embed on a partner's page): READ the address, never write it. A push inside a frame adds
|
||||
// entries to the partner page's own Back button (the joint session history).
|
||||
try { if (window.self !== window.top) delete BASE_CONFIG.urlOwner } catch (e) { delete BASE_CONFIG.urlOwner }
|
||||
// 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.
|
||||
// (A leaf whose engine is MapLibre at RUNTIME joins them — see PROVIDER below.)
|
||||
|
|
|
|||
|
|
@ -89,7 +89,10 @@
|
|||
(function () {
|
||||
var slot = document.getElementById('slot');
|
||||
var status = document.getElementById('status');
|
||||
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"big-picture","provider":"maplibre","instanceId":"bp-big-picture"};
|
||||
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"big-picture","provider":"maplibre","instanceId":"bp-big-picture","urlOwner":"app"};
|
||||
// Framed (the iframe embed on a partner's page): READ the address, never write it. A push inside a frame adds
|
||||
// entries to the partner page's own Back button (the joint session history).
|
||||
try { if (window.self !== window.top) delete BASE_CONFIG.urlOwner } catch (e) { delete BASE_CONFIG.urlOwner }
|
||||
// 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.
|
||||
// (A leaf whose engine is MapLibre at RUNTIME joins them — see PROVIDER below.)
|
||||
|
|
|
|||
|
|
@ -89,7 +89,10 @@
|
|||
(function () {
|
||||
var slot = document.getElementById('slot');
|
||||
var status = document.getElementById('status');
|
||||
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"big-picture","provider":"maplibre","instanceId":"bp-big-picture-frontier","accent":"#22d3ee","title":"Big Picture — Frontier Focus"};
|
||||
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"big-picture","provider":"maplibre","instanceId":"bp-big-picture-frontier","accent":"#22d3ee","title":"Big Picture — Frontier Focus","urlOwner":"app"};
|
||||
// Framed (the iframe embed on a partner's page): READ the address, never write it. A push inside a frame adds
|
||||
// entries to the partner page's own Back button (the joint session history).
|
||||
try { if (window.self !== window.top) delete BASE_CONFIG.urlOwner } catch (e) { delete BASE_CONFIG.urlOwner }
|
||||
// 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.
|
||||
// (A leaf whose engine is MapLibre at RUNTIME joins them — see PROVIDER below.)
|
||||
|
|
|
|||
|
|
@ -89,7 +89,10 @@
|
|||
(function () {
|
||||
var slot = document.getElementById('slot');
|
||||
var status = document.getElementById('status');
|
||||
var BASE_CONFIG = {"profile":"big-picture","dataSource":"pray-tools","instanceId":"big-picture-big-picture","assetsBase":"../../assets"};
|
||||
var BASE_CONFIG = {"profile":"big-picture","dataSource":"pray-tools","instanceId":"big-picture-big-picture","assetsBase":"../../assets","urlOwner":"app"};
|
||||
// Framed (the iframe embed on a partner's page): READ the address, never write it. A push inside a frame adds
|
||||
// entries to the partner page's own Back button (the joint session history).
|
||||
try { if (window.self !== window.top) delete BASE_CONFIG.urlOwner } catch (e) { delete BASE_CONFIG.urlOwner }
|
||||
// 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.
|
||||
// (A leaf whose engine is MapLibre at RUNTIME joins them — see PROVIDER below.)
|
||||
|
|
|
|||
|
|
@ -83,7 +83,10 @@
|
|||
(function () {
|
||||
var slot = document.getElementById('slot');
|
||||
var status = document.getElementById('status');
|
||||
var BASE_CONFIG = {"profile":"bless-india-posters","dataSource":"pray-tools","instanceId":"bless-india-posters-bless-india-posters","assetsBase":"../../assets"};
|
||||
var BASE_CONFIG = {"profile":"bless-india-posters","dataSource":"pray-tools","instanceId":"bless-india-posters-bless-india-posters","assetsBase":"../../assets","urlOwner":"app"};
|
||||
// Framed (the iframe embed on a partner's page): READ the address, never write it. A push inside a frame adds
|
||||
// entries to the partner page's own Back button (the joint session history).
|
||||
try { if (window.self !== window.top) delete BASE_CONFIG.urlOwner } catch (e) { delete BASE_CONFIG.urlOwner }
|
||||
// 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.
|
||||
// (A leaf whose engine is MapLibre at RUNTIME joins them — see PROVIDER below.)
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue