Merge origin/development into gospel/b14-gate-fp
This commit is contained in:
commit
84d5800106
165 changed files with 5092 additions and 1492 deletions
BIN
data/posters/country-ind.webp
Normal file
BIN
data/posters/country-ind.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
|
|
@ -106,8 +106,8 @@ Details, per host (Cloudflare Pages, jsDelivr, any web server) and per embed sty
|
|||
Copy the built `dx-maps/` folder into your site's `public/` (renaming it is fine) and your site becomes
|
||||
the host: no second server and no cross-site setup, because the maps and the page share one origin.
|
||||
|
||||
**Live example:** DX-life's drop-in branch copies the folder into its `public/` and fills `/research` with the
|
||||
Research Gallery.
|
||||
**Live example:** DX-life's drop-in branch copies the folder into its `public/` and fills `/research` with
|
||||
Deep Research.
|
||||
|
||||
**The two routes, on your laptop:** DX-life has one branch per route: `research-gallery-drop-in` (this
|
||||
section, no Docker) and `research-gallery-subdomain` (the dx-maps container on its own address, with
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ desktop path has never been run by anyone, and the steps say so where that is th
|
|||
| Piece | State |
|
||||
|---|---|
|
||||
| The web tree (`bun run build` → `public/dx-maps/`) | Proven. Every landing builds it. |
|
||||
| The generic Tauri shell (`deployment/src-tauri/`) | Exists. It opens the Research Gallery, builds only a Linux `.deb`, and has never been packaged on macOS or Windows. |
|
||||
| The generic Tauri shell (`deployment/src-tauri/`) | Exists. It opens Deep Research, builds only a Linux `.deb`, and has never been packaged on macOS or Windows. |
|
||||
| The engine on Linux (`bun run dev`, laptop mic) | Proven. |
|
||||
| The engine taking the **webview's** microphone (`RUDDER_AUDIO=browser`) | Proven on Linux: audio streamed over `/api/rudder/stream` came back as the full test sentence. |
|
||||
| The engine **compiling** for macOS (arm64, x86_64) and Windows (x86_64) | Proven by cross-compiling. It has **never been run** on either. |
|
||||
|
|
@ -124,6 +124,6 @@ macOS and Windows the app must do the same on first run:
|
|||
- `desktop:build` has run on Linux only; the app it produced has not been launched yet. Nothing has run on macOS or Windows.
|
||||
- The macOS/Windows runtime archive layout, and whether the engine's derived path matches it.
|
||||
- The first-run download, including resuming an interrupted 742 MB download.
|
||||
- Mapbox basemaps in the Research Gallery need the token host to allow the Tauri origin
|
||||
- Mapbox basemaps in Deep Research need the token host to allow the Tauri origin
|
||||
(`deployment/src-tauri/README.md`). Rudder itself needs no token.
|
||||
- If the engine crashes, its speech server may keep running (and holding ~1 GB) until the app quits.
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ The built tree has TWO kinds of page, and they need DIFFERENT framing policy:
|
|||
| **Direct embed** (the product) | `<bundle>/<profile>/index.html`, `<bundle>/<profile>/<instance>/index.html` — the bare map pages the manifest's `iframeUrl` points at | **`*`** — framable on any partner site (that's the whole point) |
|
||||
| **Staging** (internal preview) | tree root `index.html`, per-bundle `<bundle>/index.html` | **`'self'`** — preview only |
|
||||
|
||||
The Research Gallery (`doxa-maps-spa/doxa-maps-spa/index.html`) is a direct-embed page like any
|
||||
Deep Research (`doxa-maps-spa/doxa-maps-spa/index.html`) is a direct-embed page like any
|
||||
other leaf. Its full embed contract (script and iframe) is in
|
||||
`plugin-designs/doxa-maps-spa/EMBED.md`.
|
||||
|
||||
|
|
|
|||
|
|
@ -7,14 +7,15 @@ being duplicated.
|
|||
|
||||
## The one rule that keeps everything clean
|
||||
|
||||
> **Parameterized + genuinely reused by more than one map → the shared library**
|
||||
> **Could serve more than one map → build it in the shared library**, parameterized
|
||||
> (`library/`, alias `@map`).
|
||||
> **Not reused → it lives in your app-profile folder** (`plugin-designs/<your-map>/`).
|
||||
> **One-off or experimental → it lives in your app-profile folder** (`plugin-designs/<your-map>/`).
|
||||
|
||||
"**Local by default, promote deliberately.**" When in doubt, keep your change local to your
|
||||
own map; something graduates into the shared library only once a *second* map genuinely
|
||||
needs it. Full reasoning — the promotion / orphan-audit rule — is in
|
||||
[`../documentation/explanation.md`](../explanation.md).
|
||||
**Reusable by default.** If a component could serve more than one map, build it once in the shared
|
||||
library, driven by props, and land it through the build owner, so other maps adopt it instead of
|
||||
copying it. A one-off or experimental piece stays in your map's folder. A new pattern starts as a pilot
|
||||
on one plugin, is approved, and is then rolled out by each owner. The rule in full:
|
||||
[`skills/plugin-designer`](../../skills/plugin-designer/SKILL.md), section 3.
|
||||
|
||||
## Start here
|
||||
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ rsync -a --delete public/dx-maps/ <your-host-checkout>/dx-maps/
|
|||
it can see and whether it finds itself. To check that what you published is *current*, run
|
||||
`bun run check:catalog-currency`.
|
||||
|
||||
**Your plugin surfaces itself.** The Research Gallery (`doxa-maps-spa`) reads the built `manifest.json`
|
||||
**Your plugin surfaces itself.** Deep Research (`doxa-maps-spa`) reads the built `manifest.json`
|
||||
and puts every bundle it doesn't already curate into its folded **Experimental** shelf, with the label and
|
||||
description from the manifest — no code edit. Promoting it to a curated tab is an optional edit to
|
||||
`TAB_DEFS` and `NAV_MODEL` in `plugin-designs/doxa-maps-spa/`. One row per bundle: a bundle's extra
|
||||
|
|
|
|||
|
|
@ -415,6 +415,7 @@ every third-party script is pinned with an integrity hash (`library/constants/cd
|
|||
| `style-src` | `'unsafe-inline'`, the same CDN hosts | each plugin styles its own shadow root with a `<style>` element |
|
||||
| `worker-src` | `blob:` | the map engines run their tile workers from a blob URL |
|
||||
| `connect-src` | the tile/style hosts you use (`https://tiles.openfreemap.org` for MapLibre, `https://*.mapbox.com` for Mapbox), your data API | tiles, styles, glyphs, data |
|
||||
| `connect-src` (Bible Posters) | `https://bible.helloao.org` | the public-domain Bible text, fetched per book at render time (no text is bundled) |
|
||||
| `img-src` | `data:` `blob:` and the same tile hosts | sprites and raster tiles |
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
> bundle `parameterized` (cap-2) no matter how light it looks, and a `meta.json` `type`
|
||||
> declares tiers 3/4/5.
|
||||
|
||||
Generated from 27 bundle(s).
|
||||
Generated from 28 bundle(s).
|
||||
|
||||
### One canonical exemplar per tier (generated — quote these)
|
||||
|
||||
|
|
@ -37,6 +37,7 @@ Generated from 27 bundle(s).
|
|||
| Bundle | Profile | Machine key |
|
||||
|---|---|---|
|
||||
| `all-people-groups` | `all-people-groups` | `single` |
|
||||
| `bible-posters` | `bible-posters` | `single` |
|
||||
| `cities-internet` | `connectivity-list` | `single` |
|
||||
| `cities-internet` | `connectivity-map` | `single` |
|
||||
| `cities-internet` | `data-tables` | `single` |
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ and a change here can break other maps.
|
|||
`import … from '@map/components/ResearchMapFilterPanel.vue'`
|
||||
- **ResearchMapSideMenu** — ResearchMapSideMenu.vue — Side drawer specific to the research-map profile.
|
||||
`import … from '@map/components/ResearchMapSideMenu.vue'`
|
||||
- **ShellLanguageMenu** — ShellLanguageMenu — the language menu a header-off shell puts in VerticalTabsLayout's #phone-end
|
||||
`import … from '@map/components/ShellLanguageMenu.vue'`
|
||||
- **SideMenuDrawer** — SideMenuDrawer.vue — Slide-in side panel for the map application.
|
||||
`import … from '@map/components/SideMenuDrawer.vue'`
|
||||
- **ViewSelect** — ViewSelect.vue — the "SELECT VIEW" switcher: a fieldset + legend wrapping a row of
|
||||
|
|
@ -264,6 +266,8 @@ and a change here can break other maps.
|
|||
`import … from '@map/utils/html.js'`
|
||||
- **languageLabel** — languageLabel.js — turn the API's comma-inverted language label into the natural reading.
|
||||
`import … from '@map/utils/languageLabel.js'`
|
||||
- **languageRequest** — languageRequest.js — how a shell asks its HOST PAGE before it changes language.
|
||||
`import … from '@map/utils/languageRequest.js'`
|
||||
- **loadPinnedUmd** — loadPinnedUmd — load a PINNED, integrity-checked UMD library and capture it WITHOUT touching the host
|
||||
`import … from '@map/utils/loadPinnedUmd.js'`
|
||||
- **peopleGroupPoints** — peopleGroupPoints.js — SHARED (@map/utils/peopleGroupPoints.js).
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ A URL that returns a public token as JSON (`{token|tk|accessToken}`) or as a bar
|
|||
found; a token baked at build time wins.
|
||||
- **The studio:** the reader's URL (localStorage). The studio fetches it itself, and the fetched
|
||||
token then travels as `tk` / `window.MAP_TOKEN`.
|
||||
- **Plugins:** none reads `profile-config.tokenUrl` today. The studio and the Research Gallery
|
||||
- **Plugins:** none reads `profile-config.tokenUrl` today. The studio and Deep Research
|
||||
forward it, but no element consumes it (see the open points below).
|
||||
|
||||
## 5. People-group list: `'doxa'` | `'unreached'` | `'frontier'` | `'frontier-500k'` | `'unengaged'`
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ Smallest first. Which bundle is the current example of each: **[reference-capabi
|
|||
| 2 | **preset-element** (prop-table) | `parameterized` | one profile + a table of named presets; each row is a ready-made map with its own embed page | the bundle ships `<name>.instances.json` — `{ "instances": [{ "id", "title", "props" }] }`; one page per row at `<bundle>/<profile>/<id>/index.html` |
|
||||
| 3 | **multi-view-element** (monolithic) | `monolithic` | one profile holding several screens (tabs or steps), one shown at a time | declared: `<profile>.meta.json` → `{ "type": "monolithic" }` |
|
||||
| 4 | **composite-element** (nested elements) | `nested-elements-mfe` | a profile that places other plugins together and ships the set as one new element | declared: `<profile>.meta.json` → `{ "type": "nested-elements-mfe", "composes": [ … ] }` |
|
||||
| 5 | **app-shell** (nested SPAs) | `nested-mfe-spa` | the outer frame — rail, header, navigation — that mounts any of the others; e.g. the Research Gallery (`doxa-maps-spa`) and the Plugin Studio | declared the same way; a compiled Vue custom element like every other rung |
|
||||
| 5 | **app-shell** (nested SPAs) | `nested-mfe-spa` | the outer frame — rail, header, navigation — that mounts any of the others; e.g. Deep Research (`doxa-maps-spa`) and the Plugin Studio | declared the same way; a compiled Vue custom element like every other rung |
|
||||
|
||||
**Tiers 3–5 are always declared, never guessed** (either spelling; see `TYPE_ALIASES` in
|
||||
`internal/plugin-gallery/discover.mjs`). A folder's apparent simplicity does not decide its tier: an
|
||||
|
|
|
|||
|
|
@ -112,6 +112,45 @@ ok(!noCountry.length && resolveTerritory({ shapeName: 'Mahārāshtra' }).country
|
|||
ok(!disagree.length, `Copy's regroup warning disagrees with apply-grouping: ${disagree.join('; ')}`,
|
||||
`the regroup warning matches apply-grouping's own verdict in all ${Object.keys(REGROUP_CASES).length} cases (India, China, countries, and the three it refuses)`)
|
||||
|
||||
// ── A4. A STALLED people-group load can never hang Copy ─────────────────────────────────────────────────
|
||||
// Copy races the people-group detection against COPY_WAIT_MS through settleWithin(); past it, it copies the territory
|
||||
// lines alone (they need no network) and says why. The self-test: a promise that NEVER settles must still resolve to
|
||||
// the marker in time; a value passes through; a failure still rejects (its own message is kept).
|
||||
{
|
||||
const MARK = {}, t0 = Date.now()
|
||||
// The gate's own backstop: if settleWithin waited for ever, this reads 'GATE-TIMEOUT' and FAILS by name.
|
||||
const stalled = await Promise.race([X.settleWithin(new Promise(() => {}), 60, MARK), new Promise((r) => setTimeout(() => r('GATE-TIMEOUT'), 2000))])
|
||||
const quick = await X.settleWithin(Promise.resolve('v'), 1000, MARK)
|
||||
const failed = await X.settleWithin(Promise.reject(new Error('down')), 1000, MARK).then(() => 'resolved', (e) => e.message)
|
||||
ok(stalled === MARK && Date.now() - t0 < 1000 && quick === 'v' && failed === 'down',
|
||||
`settleWithin: a stalled source gave ${stalled === MARK ? 'the marker' : JSON.stringify(stalled)}, a value gave ${quick}, a failure gave ${failed}`,
|
||||
'a NEVER-settling people-group load still lets Copy settle in time (and a value / a failure pass through)')
|
||||
}
|
||||
|
||||
// ── A5. A FAILED load never shows the reader a raw browser message ─────────────────────────────────────
|
||||
// The second Copy after the 30 s abort flashed "signal is aborted without reason". The self-test aborts a REAL fetch
|
||||
// (the signal is aborted before the request starts, so nothing is sent) and makes a real one fail (a refused
|
||||
// localhost port, bounded at 2 s); both must read as the keyed stall text. A keyed error keeps its own text. The NEGATIVE CONTROL is
|
||||
// the old path, errorText without asReaderError, which must still show the raw message, so this test can see the defect.
|
||||
{
|
||||
const { keyedError, errorText } = await import(pathToFileURL(P('library/i18n/keyedError.js')).href)
|
||||
const en = JSON.parse(readFileSync(P('library/i18n/locales/en/upgMap.json'), 'utf8'))
|
||||
const tr = (k, p = {}) => { const v = k.startsWith('upgMap.') ? en[k.slice(7)] : undefined; return v == null ? k : v.replace(/\{(\w+)\}/g, (m, x) => (p[x] != null ? String(p[x]) : m)) }
|
||||
const stillLoading = () => keyedError('upgMap.peopleStillLoading', 'the people groups are still loading, try again in a moment')
|
||||
const aborted = await fetch('http://127.0.0.1:9/', { signal: AbortSignal.abort() }).then(() => null, (e) => e)
|
||||
// Bounded: a host running the discard service ACCEPTS port 9 and would hang; a timeout is a failure too.
|
||||
const refused = await fetch('http://127.0.0.1:9/', { signal: AbortSignal.timeout(2000) }).then(() => null, (e) => e)
|
||||
const keyed = keyedError('upgMap.selectionAdm1NoData', 'no people-group data for {names}', { names: 'Yunnan' })
|
||||
const shown = (e) => errorText(X.asReaderError(e, stillLoading), tr)
|
||||
const want = en.peopleStillLoading
|
||||
ok(aborted && aborted.name === 'AbortError' && /abort/i.test(errorText(aborted, tr)),
|
||||
`NEGATIVE CONTROL: an aborted fetch gave ${aborted && aborted.name} "${aborted && errorText(aborted, tr)}" on the old path, so the test cannot see the defect`,
|
||||
'negative control: on the old path an aborted fetch shows the raw "' + (aborted ? errorText(aborted, tr) : '') + '"')
|
||||
ok(shown(aborted) === want && refused && shown(refused) === want && shown(keyed) === tr('upgMap.selectionAdm1NoData', { names: 'Yunnan' }),
|
||||
`a failed load reads: aborted "${shown(aborted)}", refused "${refused && shown(refused)}", keyed "${shown(keyed)}"`,
|
||||
'an aborted or refused people-group fetch reads as the stall text; a keyed error keeps its own')
|
||||
}
|
||||
|
||||
// ── A6. A STATE-ONLY selection titles its Prayer list / Card deck ───────────────────────────────────────
|
||||
// Development threw "Reduce of empty array" for a state-only selection: the title named zones and countries only, so
|
||||
// a selection of Gujarāt alone had NO names. On the REAL served table: Gujarāt alone is titled "Gujarāt"; a state +
|
||||
|
|
@ -180,8 +219,13 @@ const wired = {
|
|||
'the Copy button': /@click="copySelection">\{\{ t\('upgMap\.copySelection'\) \}\}/.test(vue),
|
||||
'the Export CSV button': /@click="exportSelectionCsv">/.test(vue) && vue.includes("t('upgMap.exportCsv')"),
|
||||
'the prefetch on batch mode': /if \(next\) selectionRecordsLoad\(\)/.test(vue),
|
||||
'one detection for every action': (vue.match(/await selectionPeople\(\)/g) || []).length >= 2 && /selectionPeople\(\)\.then/.test(vue),
|
||||
'one detection for every action': (vue.match(/await selectionPeople\(\)/g) || []).length >= 2 && /settleWithin\(selectionPeople\(\)/.test(vue),
|
||||
// India's states resolve through the served table (library/data/indiaStates.js), fetched only once a state is selected
|
||||
'Copy waits at most COPY_WAIT_MS for the people groups': /settleWithin\(selectionPeople\(\), COPY_WAIT_MS, STALLED\)/.test(vue) && /if \(p === STALLED\) \{ note = t\('upgMap\.peopleStillLoading'\); return selectionClipboard\(sel, \{ color \}\); \}/.test(vue),
|
||||
'the records fetch times out and leaves the cache': /setTimeout\(\(\) => ctrl\.abort\(\), RECORDS_TIMEOUT_MS\)/.test(vue) && /signal: ctrl \? ctrl\.signal : undefined/.test(vue),
|
||||
'every failed load reads as the stall, never a raw message': /const readerError = \(e\) => asReaderError\(e, stillLoading\)/.test(vue)
|
||||
&& /\}\)\(\)\.catch\(\(e\) => \{ throw readerError\(e\); \}\);/.test(vue)
|
||||
&& /note = errorText\(readerError\(e\), t\)/.test(vue) && /csvFailed', \{ reason: errorText\(readerError\(e\), t\)/.test(vue),
|
||||
// In batch mode a tap on a pin opens NO detail sheet (it covered the whole HUD on a phone); the tapestry
|
||||
// listener under it toggles the territory. The real-tap proof is a browser check; this pins the guard in the source.
|
||||
'a pin tap in batch mode opens no sheet': /map\.on\('click', 'upg-pins-hit', \(e\) => \{[\s\S]{0,700}?if \(batchMode\.value\) return\s*\n\s*const f = e && e\.features/.test(vue),
|
||||
|
|
@ -205,7 +249,7 @@ ok(!missingWires.length, `the HUD is missing: ${missingWires.join(', ')}`, 'the
|
|||
// ── D. the strings, ×11 ──────────────────────────────────────────────────────────────────────────
|
||||
const LANGS = ['en', 'es', 'fr', 'pt', 'de', 'it', 'ro', 'ru', 'ar', 'hi', 'zh']
|
||||
const KEYS = {
|
||||
upgMap: ['copySelection', 'exportCsv', 'copyNotRegroupable', 'selectionColourGroup', 'selectionColour', 'customColour', 'copiedSelection', 'copiedTerritoriesOnly', 'csvSaved', 'csvFailed'] }
|
||||
upgMap: ['copySelection', 'exportCsv', 'copyNotRegroupable', 'peopleStillLoading', 'selectionColourGroup', 'selectionColour', 'customColour', 'copiedSelection', 'copiedTerritoriesOnly', 'csvSaved', 'csvFailed'] }
|
||||
const cat = (l, f) => JSON.parse(readFileSync(P('library/i18n/locales', l, `${f}.json`), 'utf8'))
|
||||
const bad = []
|
||||
for (const [f, keys] of Object.entries(KEYS)) {
|
||||
|
|
|
|||
|
|
@ -113,10 +113,11 @@ const PROBE = () => {
|
|||
if (!rail) return { noRail: true }
|
||||
|
||||
// Any control that can bring the rail on screen. Both are legitimate: the header toggle when a
|
||||
// header exists, the floating opener when it does not.
|
||||
// header exists, the phone bar's ☰ (.vtl-phone-menu, which replaced the floating corner circle) when
|
||||
// it does not.
|
||||
const openers = all
|
||||
.filter(n => n.tagName === 'BUTTON' && /vtl-menu|vtl-fab/.test(cls(n)))
|
||||
.map(n => ({ kind: /vtl-fab/.test(cls(n)) ? 'fab' : 'header-menu', on: onScreen(n), name: name(n) }))
|
||||
.filter(n => n.tagName === 'BUTTON' && /vtl-menu|vtl-phone-menu/.test(cls(n)))
|
||||
.map(n => ({ kind: /vtl-phone-menu/.test(cls(n)) ? 'phone-bar' : 'header-menu', on: onScreen(n), name: name(n) }))
|
||||
|
||||
return {
|
||||
railOnScreen: onScreen(rail),
|
||||
|
|
@ -173,7 +174,7 @@ for (const t of targets) {
|
|||
for (const c of (n.children || [])) w(c)
|
||||
})(document.body)
|
||||
const cls = (n) => (n.className || '').toString()
|
||||
const btn = all.find(n => n.tagName === 'BUTTON' && /vtl-menu|vtl-fab/.test(cls(n)) &&
|
||||
const btn = all.find(n => n.tagName === 'BUTTON' && /vtl-menu|vtl-phone-menu/.test(cls(n)) &&
|
||||
n.getBoundingClientRect().width > 0)
|
||||
if (!btn) return { clicked: false }
|
||||
btn.click()
|
||||
|
|
|
|||
78
internal/check-poster-sizes.mjs
Normal file
78
internal/check-poster-sizes.mjs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-poster-sizes.mjs — the poster-size LADDER (library/constants/posterSizes.js) says what it promises.
|
||||
*
|
||||
* Every print surface offers these sizes through one picker, and each renderer maps a key to its own
|
||||
* geometry. So the table itself must be right: a wrong sheet count or overlap prints a poster that does not
|
||||
* glue up to its size, and a landscape size breaks the portrait-only rule.
|
||||
*
|
||||
* ASSERTS: the picker order lists exactly the ladder; no size is both offered and legacy; only 66x66 and 60x96
|
||||
* are glued from several sheets; every key is dot-free (i18n paths, URLs); every size is
|
||||
* PORTRAIT (h ≥ w); the sheets glue up to the finished size (count × sheet − overlaps, in `direction`); a
|
||||
* photo slot is allowed on exactly the large sizes (66x66, 60x96); paper prints through the browser and the
|
||||
* rest through a print-shop PDF; every alias resolves; paperFor() picks Letter or A4 by region.
|
||||
* --self-test feeds broken copies of the table and must see each one refused.
|
||||
*/
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const S = await import(pathToFileURL(join(ROOT, 'library/constants/posterSizes.js')).href)
|
||||
|
||||
const PHOTO_SIZES = ['66x66', '60x96']
|
||||
// Every size up to 36x66 prints on ONE sheet; only these two are glued from several.
|
||||
const MULTI_SHEET = ['66x66', '60x96']
|
||||
function audit({ ladder, order, legacy, aliases }) {
|
||||
const bad = []
|
||||
const keys = Object.keys(ladder)
|
||||
if (order.join() !== keys.join()) bad.push(`the picker order [${order}] is not the ladder [${keys}]`)
|
||||
for (const [k, e] of [...Object.entries(ladder), ...Object.entries(legacy)]) {
|
||||
if (!/^[a-z0-9]+$/.test(k)) bad.push(`${k}: a key must be dot-free lowercase letters/digits`)
|
||||
if (e.id !== k) bad.push(`${k}: its id is "${e.id}"`)
|
||||
const { w, h } = e.inches
|
||||
if (!(h >= w)) bad.push(`${k}: ${w}×${h} in is landscape; every poster is portrait`)
|
||||
const s = e.sheets
|
||||
const W = s.direction === 'horizontal' ? s.count * s.sheetW - (s.count - 1) * s.joinIn : s.sheetW
|
||||
const H = s.direction === 'vertical' ? s.count * s.sheetH - (s.count - 1) * s.joinIn : s.sheetH
|
||||
if (W !== w || H !== h) bad.push(`${k}: ${s.count} sheet(s) of ${s.sheetW}×${s.sheetH} (${s.direction}, ${s.joinIn} in overlap) glue up to ${W}×${H}, not ${w}×${h}`)
|
||||
if (s.count === 1 && s.joinIn !== 0) bad.push(`${k}: one sheet cannot have a ${s.joinIn} in overlap`)
|
||||
}
|
||||
for (const k of Object.keys(legacy)) if (ladder[k]) bad.push(`${k} is both offered and legacy`)
|
||||
const multi = keys.filter((k) => ladder[k].sheets.count > 1)
|
||||
if (multi.join() !== MULTI_SHEET.join()) bad.push(`glued sizes are [${multi}], expected [${MULTI_SHEET}]; every other size is one sheet`)
|
||||
const photos = keys.filter((k) => ladder[k].photos)
|
||||
if (photos.join() !== PHOTO_SIZES.join()) bad.push(`photo slots are allowed on [${photos}], expected [${PHOTO_SIZES}] (the large sizes)`)
|
||||
for (const k of keys) {
|
||||
const want = ladder[k].paper ? 'print' : 'pdf'
|
||||
if (ladder[k].path !== want) bad.push(`${k}: path "${ladder[k].path}", expected "${want}"`)
|
||||
}
|
||||
for (const [a, t] of Object.entries(aliases)) if (!ladder[t] && !legacy[t]) bad.push(`alias ${a} → ${t}, which is not a size`)
|
||||
return bad
|
||||
}
|
||||
const table = { ladder: S.POSTER_LADDER, order: S.POSTER_LADDER_ORDER, legacy: S.POSTER_LADDER_LEGACY, aliases: S.POSTER_LADDER_ALIASES }
|
||||
const problems = audit(table)
|
||||
const paper = [['en-US', 'letter'], ['es-MX', 'letter'], ['fr-CA', 'letter'], ['en-GB', 'a4'], ['ar', 'a4'], ['zh-CN', 'a4'], ['en', 'letter']]
|
||||
for (const [tag, want] of paper) if (S.paperFor(tag) !== want) problems.push(`paperFor('${tag}') is ${S.paperFor(tag)}, expected ${want}`)
|
||||
if (S.resolvePosterSize('5x8ft')?.id !== '60x96' || S.resolvePosterSize('nope') !== null) problems.push('resolvePosterSize does not resolve an alias, or invents an unknown size')
|
||||
|
||||
if (process.argv.includes('--self-test')) {
|
||||
if (problems.length) { console.error('POSTER-SIZES SELF-TEST: FAIL — the real table already fails; fix that first.'); process.exit(1) }
|
||||
const clone = () => JSON.parse(JSON.stringify(table))
|
||||
const poisons = [
|
||||
['one sheet short', (t) => { t.ladder['60x96'].sheets.count = 2 }],
|
||||
['no overlap', (t) => { t.ladder['66x66'].sheets.joinIn = 0 }],
|
||||
['landscape', (t) => { t.ladder['36x60'].inches = { w: 60, h: 36 }; t.ladder['36x60'].sheets = { sheetW: 60, sheetH: 36, count: 1, direction: 'vertical', joinIn: 0 } }],
|
||||
['photos on a small size', (t) => { t.ladder['36x60'].photos = true }],
|
||||
['a dotted key', (t) => { t.ladder['5.5x5.5'] = { ...t.ladder['66x66'], id: '5.5x5.5' }; t.order.push('5.5x5.5') }],
|
||||
['order drift', (t) => { t.order.reverse() }],
|
||||
['a dangling alias', (t) => { t.aliases['4x6ft'] = '48x72' }],
|
||||
['paper through a PDF', (t) => { t.ladder.paper.path = 'pdf' }],
|
||||
['offered and legacy at once', (t) => { t.legacy['36x48'] = t.ladder['36x48'] }],
|
||||
['a single-sheet size split in two', (t) => { t.ladder['36x66'].sheets = { sheetW: 36, sheetH: 36, count: 2, direction: 'vertical', joinIn: 6 } }],
|
||||
]
|
||||
let missed = 0
|
||||
for (const [name, poison] of poisons) { const t = clone(); poison(t); const caught = audit(t).length > 0; console.log(` ${name}: ${caught ? 'caught' : 'NOT CAUGHT'}`); if (!caught) missed++ }
|
||||
if (missed) { console.error(`POSTER-SIZES SELF-TEST: FAIL — ${missed} poison(s) not caught.`); process.exit(1) }
|
||||
console.log(`POSTER-SIZES SELF-TEST: PASS — all ${poisons.length} broken tables refused.`); process.exit(0)
|
||||
}
|
||||
if (problems.length) { console.error('POSTER-SIZES: FAIL\n' + problems.map((p) => ' ✗ ' + p).join('\n')); process.exit(1) }
|
||||
console.log(`POSTER-SIZES: PASS — ${S.POSTER_LADDER_ORDER.length} sizes (${S.POSTER_LADDER_ORDER.join(' · ')}), portrait, sheets glue up, photos on ${PHOTO_SIZES.join(' + ')}, ${Object.keys(S.POSTER_LADDER_ALIASES).length} aliases resolve, paper by region.`)
|
||||
173
internal/check-shell-header.mjs
Normal file
173
internal/check-shell-header.mjs
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-shell-header — a header-off shell has ONE way to its tabs and ONE language menu, in the right
|
||||
* place for the width, and a language pick ASKS the host page first. Headless (Vite SSR, no browser).
|
||||
*
|
||||
* THE DESIGN (_qa-cycle/studio-organize/MOBILE-SHELL-HEADER-DESIGN.md): on a phone, a header-off shell
|
||||
* (the Research Gallery, Posterverse) gets a 44px bar with ☰ at the start and a #phone-end slot at the
|
||||
* end; on a desktop, a #rail-end slot at the bottom of the rail. The shell fills both with
|
||||
* library/components/ShellLanguageMenu.vue, whose pick dispatches ONE cancelable
|
||||
* `doxa-language-request` on the host element: cancelled → the host changes the language, the shell
|
||||
* does not; not cancelled → the shell switches, and writes ?lang= only on a page the tree generated.
|
||||
*
|
||||
* ASSERTS, on the REAL components:
|
||||
* 1. VerticalTabsLayout renders the phone bar ONLY when narrow AND header-off, #rail-end ONLY when
|
||||
* wide AND header-off (all four combinations), and never the old floating corner circle.
|
||||
* 2. one pick dispatches exactly ONE doxa-language-request, detail.language = the code, and
|
||||
* cancelable, composed and bubbles all true.
|
||||
* 3. a host that cancels → the menu emits no 'switch' and shows the current language again.
|
||||
* 4. nobody cancels → the menu emits 'switch' once, and ?lang= is written only when
|
||||
* window.MAP_OWNS_PAGE === true.
|
||||
* 5. parity: every SWITCHER_LANGUAGES code has a library/i18n/locales/<code>/ catalogue, and the
|
||||
* menu always lists the current language (fixture: de, which the switcher hides).
|
||||
* --self-test plants each defect in a temporary copy and must see every one refused.
|
||||
* EXIT 0 pass · 1 fail
|
||||
*/
|
||||
import { readFileSync, writeFileSync, rmSync, existsSync } from 'node:fs'
|
||||
import { join, resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const VTL = 'library/layouts/VerticalTabsLayout.vue'
|
||||
const MENU = 'library/components/ShellLanguageMenu.vue'
|
||||
const REQ = 'library/utils/languageRequest.js'
|
||||
const LANGS = 'library/layouts/app-wrapper-material/languages.js'
|
||||
|
||||
const { createServer } = await import('vite')
|
||||
const { default: vue } = await import('@vitejs/plugin-vue')
|
||||
const server = await createServer({ root: ROOT, configFile: false, logLevel: 'error', appType: 'custom',
|
||||
server: { middlewareMode: true, hmr: false, ws: false, watch: null }, plugins: [vue()],
|
||||
resolve: { alias: { '@map': join(ROOT, 'library') } } })
|
||||
const { createSSRApp, h, reactive } = await import('vue')
|
||||
const { renderToString } = await import('vue/server-renderer')
|
||||
|
||||
// A stand-in window for the ?lang= arm: records replaceState calls, owns the page or not.
|
||||
function fakeWindow(owns) {
|
||||
const calls = []
|
||||
return { calls, MAP_OWNS_PAGE: owns, location: { href: 'https://example.test/leaf/index.html?tab=x' },
|
||||
history: { state: null, replaceState: (_s, _t, url) => calls.push(url) } }
|
||||
}
|
||||
// A stand-in DOM for the pick: the select sits in a shadow root whose host is an EventTarget.
|
||||
function fakeSelect(value, host) { return { value, getRootNode: () => ({ host }) } }
|
||||
|
||||
async function audit({ vtl = VTL, menu = MENU, langs = LANGS } = {}) {
|
||||
const bad = []
|
||||
const fail = (what, detail) => bad.push(detail ? `${what} — ${detail}` : what)
|
||||
|
||||
// 1. where the bar and the rail-end render. The layout's chrome strings come through tStatic, whose
|
||||
// locale detection reads <html lang>; SSR has no document, so the render gets a one-field stand-in.
|
||||
if (typeof globalThis.document === 'undefined') globalThis.document = { documentElement: { lang: 'en' } }
|
||||
const Layout = (await server.ssrLoadModule('/' + vtl)).default
|
||||
for (const narrow of [true, false]) for (const header of [true, false]) {
|
||||
// A copy of the real layout whose data says `narrow` (in a browser mounted() reads it from matchMedia,
|
||||
// which SSR never runs). A copy, not `extends`: the render function is not inherited through extends.
|
||||
const Comp = { ...Layout, data() { return { ...Layout.data.call(this, this), narrow } } }
|
||||
const app = createSSRApp({ render: () => h(Comp, { header, items: [{ key: 'a', label: 'A' }], active: 'a' }, {
|
||||
'phone-end': () => h('i', { class: 'probe-phone-end' }),
|
||||
'rail-end': () => h('i', { class: 'probe-rail-end' }) }) })
|
||||
const html = (await renderToString(app)).replace(/<!--[\s\S]*?-->/g, '') // template comments are not markup
|
||||
const tag = `narrow=${narrow} header=${header}`
|
||||
const wantBar = narrow && !header, wantRail = !narrow && !header
|
||||
if (/class="vtl-pbar"/.test(html) !== wantBar) fail(`the phone bar ${wantBar ? 'is missing' : 'renders'} at ${tag}`)
|
||||
if (/probe-phone-end/.test(html) !== wantBar) fail(`#phone-end ${wantBar ? 'is not filled' : 'renders'} at ${tag}`)
|
||||
if (/class="vtl-rail-end"/.test(html) !== wantRail || /probe-rail-end/.test(html) !== wantRail) fail(`#rail-end ${wantRail ? 'is missing' : 'renders'} at ${tag}`)
|
||||
if (wantBar && !/class="vtl-phone-menu"[^>]*aria-expanded=/.test(html)) fail(`the bar's ☰ has no aria-expanded at ${tag}`)
|
||||
if (/vtl-fab|--map-fab-/.test(html)) fail(`the old corner circle (or its --map-fab-* footprint) renders at ${tag}`)
|
||||
if (wantBar && !/has-phone-bar/.test(html)) fail(`the phone bar is not a grid row (.has-phone-bar missing) at ${tag}`)
|
||||
}
|
||||
|
||||
// 2-4. the pick, through the menu's REAL handler (its <script setup> bindings; no DOM needed)
|
||||
const Menu = (await server.ssrLoadModule('/' + menu)).default
|
||||
// The menu's setup, run INSIDE an SSR render so its compiled wrapper finds the SSR context; in dev mode a
|
||||
// <script setup> returns its bindings, so the gate calls the component's real onPick and options.
|
||||
const mount = async (current) => {
|
||||
const emitted = []; let b = null
|
||||
const Probe = { setup() { b = Menu.setup(reactive({ current, collapsed: false }), { emit: (e, ...a) => emitted.push([e, ...a]), expose: () => {}, attrs: {}, slots: {} }); return () => null } }
|
||||
await renderToString(createSSRApp(Probe))
|
||||
if (!b || typeof b.onPick !== 'function') throw new Error('ShellLanguageMenu setup did not return its bindings (onPick)')
|
||||
return { b, emitted }
|
||||
}
|
||||
const run = async ({ cancel, owns }) => {
|
||||
const host = new EventTarget(); const seen = []
|
||||
host.addEventListener('doxa-language-request', (e) => { seen.push(e); if (cancel) e.preventDefault() })
|
||||
host.addEventListener('language-change', (e) => seen.push(e))
|
||||
const win = fakeWindow(owns); const had = globalThis.window; globalThis.window = win
|
||||
const { b, emitted } = await mount('en'); const sel = fakeSelect('ar', host)
|
||||
try { b.onPick({ target: sel }) } finally { if (had === undefined) delete globalThis.window; else globalThis.window = had }
|
||||
return { seen, emitted, sel, win }
|
||||
}
|
||||
const up = await run({ cancel: false, owns: true })
|
||||
const reqs = up.seen.filter((e) => e.type === 'doxa-language-request')
|
||||
if (up.seen.length !== 1 || reqs.length !== 1) fail('one pick must dispatch exactly ONE doxa-language-request', `saw ${up.seen.map((e) => e.type).join(', ') || 'none'}`)
|
||||
const e = reqs[0]
|
||||
if (e && !(e.detail && e.detail.language === 'ar')) fail('the event must carry detail.language', JSON.stringify(e.detail))
|
||||
if (e && !(e.cancelable && e.composed && e.bubbles)) fail('the event must be cancelable, composed and bubbling', `cancelable=${e.cancelable} composed=${e.composed} bubbles=${e.bubbles}`)
|
||||
if (up.emitted.filter(([n]) => n === 'switch').length !== 1 || (up.emitted[0] || [])[1] !== 'ar') fail('an uncancelled pick must emit switch("ar") once', JSON.stringify(up.emitted))
|
||||
if (!(up.win.calls.length === 1 && /[?&]lang=ar\b/.test(up.win.calls[0]) && /[?&]tab=x\b/.test(up.win.calls[0]))) fail('an uncancelled pick on a tree-owned page must write ?lang= (keeping the query)', JSON.stringify(up.win.calls))
|
||||
const notOwned = await run({ cancel: false, owns: undefined })
|
||||
if (notOwned.win.calls.length) fail('?lang= was written on a page the tree does not own (MAP_OWNS_PAGE unset)', JSON.stringify(notOwned.win.calls))
|
||||
const cancelled = await run({ cancel: true, owns: true })
|
||||
if (cancelled.emitted.some(([n]) => n === 'switch')) fail('a cancelled request must NOT switch the shell', JSON.stringify(cancelled.emitted))
|
||||
if (cancelled.win.calls.length) fail('a cancelled request must not write ?lang=', JSON.stringify(cancelled.win.calls))
|
||||
if (cancelled.sel.value !== 'en') fail('after a cancel the menu must show the current language again', cancelled.sel.value)
|
||||
|
||||
// 5. parity + the current language
|
||||
const L = await server.ssrLoadModule('/' + langs)
|
||||
const missing = L.SWITCHER_LANGUAGES.map((l) => l.code).filter((c) => !existsSync(join(ROOT, 'library/i18n/locales', c)))
|
||||
if (missing.length) fail('a switcher language has no catalogue', missing.join(', '))
|
||||
const de = (await mount('de')).b.options
|
||||
const codes = (de && de.value ? de.value : []).map((l) => l.code)
|
||||
if (!codes.includes('de')) fail('the menu must list the current language even when the switcher hides it (de)', codes.join(' '))
|
||||
const en = (await mount('en')).b.options
|
||||
if ((en && en.value ? en.value : []).map((l) => l.code).join() !== L.SWITCHER_LANGUAGES.map((l) => l.code).join()) fail('with a listed language current, the menu must be exactly the switcher list')
|
||||
return bad
|
||||
}
|
||||
|
||||
// ── --self-test: each defect planted in a temporary copy must be refused ─────────────────────────────
|
||||
function plant(rel, suffix, edit, redirect = []) {
|
||||
const src = readFileSync(join(ROOT, rel), 'utf8')
|
||||
let out = edit(src)
|
||||
if (out === src) throw new Error(`poison "${suffix}" did not change ${rel}`)
|
||||
for (const [from, to] of redirect) out = out.split(from).join(to)
|
||||
const dot = rel.lastIndexOf('.'); const tmp = rel.slice(0, rel.lastIndexOf('/') + 1) + `.tmp-shellhdr-${suffix}` + rel.slice(dot)
|
||||
writeFileSync(join(ROOT, tmp), out); PLANTED.push(tmp); return tmp
|
||||
}
|
||||
const PLANTED = [] // every temporary copy, removed in finally whatever happens
|
||||
const POISONS = [
|
||||
{ name: 'the bar also shows with a header', files: () => ({ vtl: plant(VTL, 'bar-header', (s) => s.replace('phoneBarShown() { return this.narrow && !this.header }', 'phoneBarShown() { return this.narrow }')) }) },
|
||||
{ name: 'the rail-end also shows on a phone', files: () => ({ vtl: plant(VTL, 'rail-phone', (s) => s.replace('railEndShown() { return !this.narrow && !this.header }', 'railEndShown() { return !this.header }')) }) },
|
||||
{ name: 'the corner circle is back', files: () => ({ vtl: plant(VTL, 'fab', (s) => s.replace('<div v-if="phoneBarShown" class="vtl-pbar">', '<button v-if="phoneBarShown" class="vtl-fab">☰</button><div v-if="phoneBarShown" class="vtl-pbar">')) }) },
|
||||
{ name: 'the event is not cancelable', files: () => { const r = plant(REQ, 'nocancel', (s) => s.replace('bubbles: true, composed: true, cancelable: true', 'bubbles: true, composed: true')); return { menu: plant(MENU, 'm-nocancel', (s) => s + ' ', [['../utils/languageRequest.js', '../utils/' + r.split('/').pop()]]) } } },
|
||||
{ name: 'the event does not leave the shadow root', files: () => { const r = plant(REQ, 'nocomposed', (s) => s.replace('bubbles: true, composed: true, cancelable: true', 'bubbles: true, cancelable: true')); return { menu: plant(MENU, 'm-nocomposed', (s) => s + ' ', [['../utils/languageRequest.js', '../utils/' + r.split('/').pop()]]) } } },
|
||||
{ name: 'the detail is Vue-shaped (an array)', files: () => { const r = plant(REQ, 'arr', (s) => s.replace('detail: { language: code }', 'detail: [{ language: code }]')); return { menu: plant(MENU, 'm-arr', (s) => s + ' ', [['../utils/languageRequest.js', '../utils/' + r.split('/').pop()]]) } } },
|
||||
{ name: 'a second event is fired', files: () => ({ menu: plant(MENU, 'twice', (s) => s.replace(" if (requestLanguage(sel, code)) {", " sel.getRootNode().host.dispatchEvent(new CustomEvent('language-change', { detail: { language: code } }))\n if (requestLanguage(sel, code)) {")) }) },
|
||||
{ name: 'a cancelled pick still switches', files: () => ({ menu: plant(MENU, 'ignorecancel', (s) => s.replace(' if (requestLanguage(sel, code)) {', ' if (requestLanguage(sel, code) || true) {')) }) },
|
||||
{ name: '?lang= is written on any page', files: () => { const r = plant(REQ, 'anypage', (s) => s.replace('win.MAP_OWNS_PAGE !== true || ', '')); return { menu: plant(MENU, 'm-anypage', (s) => s + ' ', [['../utils/languageRequest.js', '../utils/' + r.split('/').pop()]]) } } },
|
||||
{ name: 'the hidden current language is dropped', files: () => ({ menu: plant(MENU, 'nocurrent', (s) => s.replace(' if (cur) list.push(cur)', ' if (cur && !cur.hideFromSwitcher) list.push(cur)')) }) },
|
||||
{ name: 'a switcher language has no catalogue', files: () => ({ langs: plant(LANGS, 'xx', (s) => s.replace("{ code: 'ru', name: 'Russian',", "{ code: 'xx', name: 'Nowhere', nativeName: 'Nowhere', flag: '' },\n { code: 'ru', name: 'Russian',")) }) },
|
||||
]
|
||||
|
||||
let exit = 0
|
||||
try {
|
||||
if (process.argv.includes('--self-test')) {
|
||||
let caught = 0
|
||||
for (const p of POISONS) {
|
||||
try {
|
||||
const bad = await audit(p.files())
|
||||
if (bad.length) { caught++; console.log(` ${p.name}: caught (${bad[0].slice(0, 90)})`) }
|
||||
else { console.log(` ${p.name}: NOT CAUGHT`); exit = 1 }
|
||||
} finally {
|
||||
while (PLANTED.length) rmSync(join(ROOT, PLANTED.pop()), { force: true })
|
||||
}
|
||||
}
|
||||
console.log(exit ? `SHELL-HEADER SELF-TEST: FAIL — ${POISONS.length - caught} of ${POISONS.length} defects passed.` : `SHELL-HEADER SELF-TEST: PASS — all ${POISONS.length} planted defects refused.`)
|
||||
} else {
|
||||
const bad = await audit()
|
||||
if (bad.length) { exit = 1; console.log(`SHELL-HEADER: FAIL — ${bad.length} problem(s)`); for (const b of bad) console.log(' ✗ ' + b) }
|
||||
else console.log('SHELL-HEADER: PASS — the phone bar only on a narrow header-off layout, #rail-end only on a wide one, no corner circle; one cancelable composed doxa-language-request per pick; cancelled → no switch; ?lang= only on tree-owned pages; the switcher has catalogues and the current language is always listed.')
|
||||
}
|
||||
} finally {
|
||||
while (PLANTED.length) rmSync(join(ROOT, PLANTED.pop()), { force: true })
|
||||
await server.close()
|
||||
}
|
||||
process.exit(exit)
|
||||
93
internal/check-upg-count.mjs
Normal file
93
internal/check-upg-count.mjs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
#!/usr/bin/env node
|
||||
// check-upg-count — UPG-100 never writes its list's TOTAL into a sentence. The wall's intro printed "2,106" in all
|
||||
// eleven languages after the list had grown to 2,107, because the number lived inside the translated text. Now every
|
||||
// sentence takes it as {n}, from the funnel's last step (plugin-designs/my-upg-100-list/data/upg-total.js), formatted
|
||||
// for the reader's locale. Headless. Asserts:
|
||||
// A. the total is known (vision-funnel.json's last step) and upgTotal() reads it;
|
||||
// B. no upgMap value in any of the 11 locales contains the total (or the stale 2,106) in ANY digit system: Latin
|
||||
// with any separator, Arabic-Indic, Devanagari. A built-in NEGATIVE CONTROL plants "2,106" and must be caught;
|
||||
// C. upgMap.wallIntroA carries {n} in all 11 locales;
|
||||
// D. the consumers pass the live total: poster-wall.vue reads upgMap.wallIntroA with n (not the frozen
|
||||
// messages.upg100WallIntroA), and upg-100-list.vue passes no number literal to fetchingUupgs;
|
||||
// E. ONE SENTENCE, ONE DIGIT SYSTEM: the wall intro, rendered through each locale's real catalogue, writes its
|
||||
// numbers in one digit system (ar: Arabic-Indic, so ٢٬١٠٧ beside ٢٧ and ٩:١٤). A NEGATIVE CONTROL renders ar the
|
||||
// old way (plain Intl 'ar', which CLDR formats Western) and must be flagged as mixed.
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const P = (...a) => join(REPO, ...a)
|
||||
const fails = [], notes = []
|
||||
const ok = (cond, msg, note) => { if (!cond) fails.push(msg); else if (note) notes.push(` ok ${note}`) }
|
||||
const LANGS = ['en', 'es', 'fr', 'pt', 'de', 'it', 'ro', 'ru', 'ar', 'hi', 'zh']
|
||||
|
||||
let upgTotal, formatCount, countInSentence, funnel
|
||||
try {
|
||||
;({ upgTotal, formatCount, countInSentence } = await import(pathToFileURL(P('plugin-designs/my-upg-100-list/data/upg-total.js')).href))
|
||||
funnel = JSON.parse(readFileSync(P('data/people-groups/vision-funnel.json'), 'utf8'))
|
||||
} catch (e) {
|
||||
console.error(`UPG-COUNT: NOT MEASURED (exit 75) — an input did not load: ${e.message}`)
|
||||
process.exit(75)
|
||||
}
|
||||
|
||||
// ── A ──
|
||||
const total = upgTotal(funnel)
|
||||
ok(total > 1000, `the list's total reads as ${total} from vision-funnel.json`, `the list's total is ${total}, from the funnel`)
|
||||
|
||||
// ── B: a digit-system-blind scan ──
|
||||
const NATIVE = { '٠': 0, '١': 1, '٢': 2, '٣': 3, '٤': 4, '٥': 5, '٦': 6, '٧': 7, '٨': 8, '٩': 9, '०': 0, '१': 1, '२': 2, '३': 3, '४': 4, '५': 5, '६': 6, '७': 7, '८': 8, '९': 9 }
|
||||
const numbersIn = (s) => String(s).replace(/[٠-٩०-९]/g, (d) => NATIVE[d])
|
||||
.replace(/(\d)[,.'\u0020\u00A0\u202F\u2009\u066C](?=\d{3}\b)/g, '$1') // drop thousands separators (fr U+202F, ru U+00A0, ar U+066C)
|
||||
.match(/\d+/g) || []
|
||||
const FORBIDDEN = new Set([String(total), '2106'])
|
||||
const hasTotal = (s) => numbersIn(s).some((n) => FORBIDDEN.has(n))
|
||||
ok(hasTotal('We did the same with the 2,106 unreached') && hasTotal('فعلنا المثل مع ٢١٠٦ شعوب') && hasTotal(`mit den ${String(total).replace(/(\d)(\d{3})$/, '$1.$2')} Gruppen`)
|
||||
&& hasTotal(`les ${new Intl.NumberFormat('fr').format(total)} groupes`) && hasTotal(`из ${new Intl.NumberFormat('ru').format(total)} групп`),
|
||||
'NEGATIVE CONTROL: the scanner missed a planted total, so it cannot guard anything', 'negative control: a planted 2,106 / ٢١٠٦ / 2.107 / fr 2 107 / ru 2 107 is caught')
|
||||
const leaks = []
|
||||
for (const l of LANGS) {
|
||||
const cat = JSON.parse(readFileSync(P('library/i18n/locales', l, 'upgMap.json'), 'utf8'))
|
||||
for (const [k, v] of Object.entries(cat)) if (typeof v === 'string' && hasTotal(v)) leaks.push(`${l} upgMap.${k}`)
|
||||
}
|
||||
ok(!leaks.length, `the total is written into a sentence: ${leaks.slice(0, 6).join(', ')}`, `no upgMap sentence in the 11 locales carries the total (${total}) or the stale 2,106`)
|
||||
|
||||
// ── C ──
|
||||
const noN = LANGS.filter((l) => !String(JSON.parse(readFileSync(P('library/i18n/locales', l, 'upgMap.json'), 'utf8')).wallIntroA || '').includes('{n}'))
|
||||
ok(!noN.length, `upgMap.wallIntroA has no {n} in: ${noN.join(', ')}`, 'the wall intro takes the total as {n} in all 11 locales')
|
||||
|
||||
// ── D ──
|
||||
const wall = readFileSync(P('plugin-designs/my-upg-100-list/poster-wall.vue'), 'utf8')
|
||||
const map = readFileSync(P('plugin-designs/my-upg-100-list/upg-100-list.vue'), 'utf8')
|
||||
ok(/countInSentence\(t, 'upgMap\.wallIntroA', upgTotal\(/.test(wall) && !/t\('messages\.upg100WallIntroA'\)/.test(wall),
|
||||
'poster-wall.vue does not render the intro from upgMap.wallIntroA with the live total', 'the wall intro renders the live total')
|
||||
ok(!/fetchingUupgs', \{ n: \d/.test(map) && /countInSentence\(t, 'messages\.fetchingUupgs', upgTotal\(/.test(map),
|
||||
'upg-100-list.vue passes a number literal to fetchingUupgs', 'the fetching status shows the live total')
|
||||
|
||||
// ── E: one sentence, one digit system ──
|
||||
const SYSTEMS = [['latn', /[0-9]/], ['arab', /[\u0660-\u0669]/], ['arabext', /[\u06F0-\u06F9]/], ['deva', /[\u0966-\u096F]/], ['beng', /[\u09E6-\u09EF]/]]
|
||||
const systemsIn = (s) => SYSTEMS.filter(([, re]) => re.test(String(s))).map(([nu]) => nu)
|
||||
const trFor = (l) => {
|
||||
const c = JSON.parse(readFileSync(P('library/i18n/locales', l, 'upgMap.json'), 'utf8'))
|
||||
return (k, p = {}) => String(c[k.replace(/^upgMap\./, '')] ?? k).replace(/\{(\w+)\}/g, (m, x) => (p[x] != null ? String(p[x]) : m))
|
||||
}
|
||||
const arOld = trFor('ar')('upgMap.wallIntroA', { n: formatCount(total, 'ar') })
|
||||
ok(systemsIn(arOld).length > 1, `NEGATIVE CONTROL: the old ar render reads as ONE digit system (${systemsIn(arOld).join('+')}), so this check cannot see the defect`,
|
||||
`negative control: the old ar render (plain Intl 'ar' = "${formatCount(total, 'ar')}") is flagged as mixed (${systemsIn(arOld).join('+')})`)
|
||||
const mixed = []
|
||||
for (const l of LANGS) {
|
||||
const s = countInSentence(trFor(l), 'upgMap.wallIntroA', total, l)
|
||||
if (systemsIn(s).length > 1) mixed.push(`${l} ${systemsIn(s).join('+')}`)
|
||||
if (!numbersIn(s).includes(String(total))) mixed.push(`${l} does not show ${total}`)
|
||||
}
|
||||
const arNew = countInSentence(trFor('ar'), 'upgMap.wallIntroA', total, 'ar')
|
||||
ok(!mixed.length, `the wall intro: ${mixed.join('; ')}`,
|
||||
`the wall intro shows ${total} in ONE digit system in all 11 locales (ar: "${(arNew.match(/[\u0660-\u0669٬]{3,}/) || ['?'])[0]}")`)
|
||||
|
||||
for (const n of notes) console.log(n)
|
||||
if (fails.length) {
|
||||
console.error('UPG-COUNT: FAIL')
|
||||
for (const f of fails) console.error(` ✗ ${f}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`UPG-COUNT: PASS — the list's total (${total}) lives in one place and every sentence takes it as {n}.`)
|
||||
|
|
@ -10,6 +10,8 @@
|
|||
"check:coverage-consistent": "the full run includes clause L, the age of the oldest link verification (45 days), which depends on the CALENDAR: chained, it would turn gates red on a date (first 2026-11-08) with no code change. Run it with the link re-verification; every other clause runs in gates:pre as check:coverage-consistent:chained",
|
||||
"check:snippets-draw": "AWAITING ITS FIRST BROWSER RUN: the browser suite lists a gate only after it has run. Moves into verify-browser-all's GATES, and out of this list, once that run is recorded.",
|
||||
"check:grid-merge-defaults": "WAITS FOR ITS CODE: it asserts the defaults of three grid props (filtersToggle, backdrop, backdropFloor) that are not on development, because the grid merge (b1e839a33) has not landed. Chain it in gates:pre in the same landing as that merge.",
|
||||
"check:snippets-draw:mapbox": "AWAITING ITS FIRST BROWSER RUN: the browser suite lists a gate only after it has run. Moves into verify-browser-all's GATES, and out of this list, once that run is recorded. Needs MAP_TOKEN."
|
||||
"check:snippets-draw:mapbox": "AWAITING ITS FIRST BROWSER RUN: the browser suite lists a gate only after it has run. Moves into verify-browser-all's GATES, and out of this list, once that run is recorded. Needs MAP_TOKEN.",
|
||||
"check:side-lane-photo-pixels": "a BROWSER gate (browser seat only; elsewhere NOT MEASURED, 75). Its first slot run must come before it joins the browser suite (verify-browser-all lists only gates already run there); until then its source twin, check:side-lanes' photo arms, runs in gates:pre",
|
||||
"check:side-lane-photo-pixels:self-test": "the poison run of check:side-lane-photo-pixels (a raster without stripExternalImages must draw the glyph); same browser-seat reason"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
91
library/components/ShellLanguageMenu.vue
Normal file
91
library/components/ShellLanguageMenu.vue
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
<!--
|
||||
ShellLanguageMenu — the language menu a header-off shell puts in VerticalTabsLayout's #phone-end
|
||||
(the phone bar) and #rail-end (the bottom of the desktop rail) slots. ONE component for both shells
|
||||
and both places, so the list, the host contract and the look cannot drift apart.
|
||||
|
||||
THE LIST: SWITCHER_LANGUAGES (app-wrapper-material/languages.js) PLUS the current language when it is
|
||||
one of the hidden ones, so a reader on /de/… always sees Deutsch selected. hideFromSwitcher is the
|
||||
product's choice and is not changed here.
|
||||
|
||||
THE CONTRACT (library/utils/languageRequest.js): a pick first asks the host page with a cancelable
|
||||
doxa-language-request. Cancelled → nothing happens here; the host sets the shell's `language`, and
|
||||
`current` follows. Not cancelled → emits 'switch' (the shell runs its own setLocale + saveLocale)
|
||||
and writes ?lang= only on a page this tree generated.
|
||||
|
||||
A NATIVE <select>, laid transparent over its own face: the OS picker on a phone, the keyboard and
|
||||
screen-reader behaviour of a real select everywhere, and the face (🌐 + the language's own name, or
|
||||
its code in a collapsed rail) is only paint. The menu opens where the OS puts it (upward at the
|
||||
bottom of the rail), so it needs no placement prop.
|
||||
-->
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { SWITCHER_LANGUAGES, getLanguage } from '../layouts/app-wrapper-material/languages.js'
|
||||
import { icon } from '../layouts/app-wrapper-material/icons.js'
|
||||
import { requestLanguage, writeOwnedLangParam } from '../utils/languageRequest.js'
|
||||
import { tStatic } from '../i18n/index.js'
|
||||
|
||||
const props = defineProps({
|
||||
/** The shell's effective language code ('en', 'ar', …). */
|
||||
current: { type: String, default: 'en' },
|
||||
/** The desktop rail is collapsed to its icon strip: show the code, not the name. */
|
||||
collapsed: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['switch'])
|
||||
|
||||
const options = computed(() => {
|
||||
const list = SWITCHER_LANGUAGES.slice()
|
||||
if (props.current && !list.some((l) => l.code === props.current)) {
|
||||
const cur = getLanguage(props.current)
|
||||
if (cur) list.push(cur)
|
||||
}
|
||||
return list
|
||||
})
|
||||
const currentLang = computed(() => getLanguage(props.current) || { code: props.current, nativeName: props.current })
|
||||
// The menu's name, in the language the reader is reading (the catalogue's controls.language).
|
||||
const ariaLabel = computed(() => tStatic('controls.language', props.current) || 'Language')
|
||||
const globe = icon('globe')
|
||||
|
||||
function onPick(e) {
|
||||
const sel = e.target
|
||||
const code = sel.value
|
||||
if (!code || code === props.current) return
|
||||
if (requestLanguage(sel, code)) {
|
||||
emit('switch', code)
|
||||
writeOwnedLangParam(code)
|
||||
} else {
|
||||
// The host took the request; show the current language until the host changes it.
|
||||
sel.value = props.current
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="slm" :class="{ collapsed }">
|
||||
<span class="slm-face" aria-hidden="true">
|
||||
<span class="slm-ic" v-html="globe"></span>
|
||||
<span v-if="collapsed" class="slm-code">{{ String(current).toUpperCase() }}</span>
|
||||
<span v-else class="slm-name" :lang="currentLang.code" :dir="currentLang.dir || null">{{ currentLang.nativeName }}</span>
|
||||
<span v-if="!collapsed" class="slm-caret">▾</span>
|
||||
</span>
|
||||
<select class="slm-select" :value="current" :aria-label="ariaLabel" :title="ariaLabel" @change="onPick">
|
||||
<option v-for="l in options" :key="l.code" :value="l.code" :lang="l.code" :dir="l.dir || null">{{ l.nativeName }}</option>
|
||||
</select>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 44px tall: the phone bar's end slot and the rail's bottom row are both tap targets. */
|
||||
.slm { position: relative; display: inline-flex; align-items: center; min-height: 44px; max-width: 100%; box-sizing: border-box; border-radius: 8px; color: #3b463d; }
|
||||
.slm-face { display: inline-flex; align-items: center; gap: 6px; padding-inline: 10px; min-width: 0; font-size: 13px; font-weight: 500; line-height: 1.2; }
|
||||
.slm-ic { flex: 0 0 auto; display: inline-flex; color: var(--vtl-muted, #6d766e); }
|
||||
.slm-ic :deep(svg) { width: 18px; height: 18px; }
|
||||
.slm-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
|
||||
.slm-code { font-weight: 600; font-size: 12px; letter-spacing: .03em; }
|
||||
.slm-caret { flex: 0 0 auto; color: var(--vtl-muted, #6d766e); font-size: 11px; }
|
||||
.slm.collapsed .slm-face { flex-direction: column; gap: 2px; padding-inline: 4px; }
|
||||
/* The real control: transparent, covering the face, so every tap and key lands on the select. */
|
||||
.slm-select { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; margin: 0; border: 0; cursor: pointer; font-size: 16px; }
|
||||
.slm:hover { background: var(--vtl-accented, #d1d4cd); }
|
||||
.slm:focus-within { outline: 2px solid var(--vtl-accent, #92b195); outline-offset: -2px; }
|
||||
@media (prefers-reduced-motion: reduce) { .slm { transition: none; } }
|
||||
</style>
|
||||
|
|
@ -7,9 +7,9 @@ A change here does not affect one map. It ripples to **all** of them. So:
|
|||
- **Prototyping or experimenting? Don't edit these.** Put your custom, one-off, not-yet-proven component
|
||||
inside your own map's folder (`plugin-designs/<your-map>/`), where it can be as messy as you need and
|
||||
breaks nothing else.
|
||||
- **Only proven, parameterized, reusable components graduate into this library.** If a component has hard-coded
|
||||
values, assumptions about one specific map, or hasn't been used by more than one map yet — it is **not**
|
||||
ready to live here.
|
||||
- **Reusable by default.** If a component could serve more than one map, build it here once, driven by props,
|
||||
and land it through the build owner, so other maps adopt it instead of copying it. If it has hard-coded
|
||||
values or assumptions about one specific map, it is **not** ready to live here: parameterize it first.
|
||||
- **A reusable component takes props, not assumptions.** Before you move something in, make it parameterized:
|
||||
the map, the legend, the controls, the search bar all take configuration — yours should too.
|
||||
- **If you must change a shared component, check every map that uses it.** Run `bun run dev` and click through
|
||||
|
|
|
|||
|
|
@ -23,7 +23,14 @@ useShadowStyles(`
|
|||
mobile chrome (pull tab, collapse/expand caret, touch drag). ── */
|
||||
/* bottom = the sheet's height (it shows only while the sheet is open, whose height fits its content)
|
||||
+ a 16px gap. */
|
||||
.floating-mini-image{position:absolute;inset-inline-start:16px;bottom:calc(var(--legend-sheet-h,30%) + 16px);z-index:999;pointer-events:auto;cursor:pointer;}
|
||||
.floating-mini-image{position:absolute;inset-inline-start:16px;bottom:calc(var(--legend-img-seat,var(--legend-sheet-h,30%)) + 16px);z-index:999;pointer-events:auto;cursor:pointer;}
|
||||
/* The image SITS on --legend-img-seat, a copy of the sheet's height that moves only while the image shows (the
|
||||
sheet open, no finger on it; script: IMG_SEAT_VAR). Dragged up, and through the snap to full open, the sheet
|
||||
(z 1000) slides over the still image (z 999), so it goes INTO the legend instead of riding its top edge; dragged
|
||||
down, it waits and then follows the snap. It fades in and out instead of popping; the reduced-motion rule below
|
||||
turns the fade off for those who ask. */
|
||||
.lm-img-enter-active,.lm-img-leave-active{transition:opacity .2s ease;}
|
||||
.lm-img-enter-from,.lm-img-leave-to{opacity:0;}
|
||||
.mini-image{width:120px;height:150px;object-fit:cover;border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,0.25);display:block;}
|
||||
|
||||
/* Containment root: clamps absolute children to the map area. */
|
||||
|
|
@ -352,7 +359,8 @@ useShadowStyles(`
|
|||
that animates must carry its own. Additive only: it removes motion when the OS asks and
|
||||
changes nothing otherwise. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.legend-sheet, .legend-content, .legend-handle, .legend-toggle { transition: none !important; animation: none !important; }
|
||||
.legend-sheet, .legend-content, .legend-handle, .legend-toggle,
|
||||
.lm-img-enter-active, .lm-img-leave-active { transition: none !important; animation: none !important; }
|
||||
}
|
||||
`, 'legend-mobile-sheet')
|
||||
|
||||
|
|
@ -760,6 +768,8 @@ function onSheetTouchEnd() {
|
|||
// Free-drag tabs: leave the sheet where it was released (custom px stays,
|
||||
// state unchanged) — the user reads the legend and the map together.
|
||||
if (h < openH * 0.5) uiStore.collapseLegend()
|
||||
// The height stops where the finger did, so the observer may never fire again: seat the image here.
|
||||
else seatImage()
|
||||
return
|
||||
}
|
||||
if (Math.abs(travel) < SNAP_MIN_PX) { snapTo(drag.startState); return } // hysteresis
|
||||
|
|
@ -839,10 +849,19 @@ onBeforeUnmount(() => {
|
|||
// display:none desktop slot in detail mode (both profiles) observes 0 and must
|
||||
// never zero the var under the visible sheet. The owner clears it on unmount.
|
||||
const SHEET_H_VAR = '--legend-sheet-h'
|
||||
const IMG_SEAT_VAR = '--legend-img-seat'
|
||||
let _sheetResizeObserver = null
|
||||
let _sheetVarTarget = null
|
||||
let _observedSheet = null
|
||||
|
||||
// The floating image's seat follows the sheet only while the image shows: open, and no finger on it. The credit
|
||||
// and the camera keep reading the live height (SHEET_H_VAR); a drag holds the seat, and so does the snap to full
|
||||
// open, where the sheet slides over the fading image instead of carrying it up.
|
||||
function seatImage(h = sheetRef.value ? sheetRef.value.offsetHeight : 0) {
|
||||
if (!_sheetVarTarget || h <= 0 || isDragging.value || legendState.value !== 'open') return
|
||||
_sheetVarTarget.style.setProperty(IMG_SEAT_VAR, `${h}px`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const sheet = sheetRef.value
|
||||
if (!sheet || typeof ResizeObserver === 'undefined') return
|
||||
|
|
@ -855,6 +874,7 @@ onMounted(() => {
|
|||
const h = sheet.offsetHeight
|
||||
if (h <= 0) return
|
||||
_sheetVarTarget.style.setProperty(SHEET_H_VAR, `${h}px`)
|
||||
seatImage(h)
|
||||
_sheetVarTarget.__legendSheetVarOwner = sheet
|
||||
})
|
||||
_sheetResizeObserver.observe(sheet)
|
||||
|
|
@ -864,6 +884,7 @@ onBeforeUnmount(() => {
|
|||
if (_sheetResizeObserver) { _sheetResizeObserver.disconnect(); _sheetResizeObserver = null }
|
||||
if (_sheetVarTarget && _sheetVarTarget.__legendSheetVarOwner === _observedSheet) {
|
||||
_sheetVarTarget.style.removeProperty(SHEET_H_VAR)
|
||||
_sheetVarTarget.style.removeProperty(IMG_SEAT_VAR)
|
||||
_sheetVarTarget.__legendSheetVarOwner = null
|
||||
}
|
||||
_sheetVarTarget = null
|
||||
|
|
@ -907,7 +928,8 @@ onBeforeUnmount(() => { if (_creditTimer) { clearTimeout(_creditTimer); _creditT
|
|||
beside the legend, so it shows in no resting state (collapsed, or open with nothing selected). A
|
||||
tapped dot's detail view keeps its note inside the sheet, where it explains shared dots; the tree's
|
||||
own copy stays off (:pin-note="false" below). Desktop is unchanged. -->
|
||||
<!-- Floating mini-image: 16px above the open sheet's top edge (it shows only in that state). -->
|
||||
<!-- Floating mini-image: 16px above the open sheet's SEAT (it shows only in that state; see .floating-mini-image). -->
|
||||
<Transition name="lm-img">
|
||||
<div
|
||||
v-if="legendMode === 'detail' && selectedPeopleGroup && legendState === 'open' && miniImageUrl"
|
||||
class="floating-mini-image"
|
||||
|
|
@ -919,6 +941,7 @@ onBeforeUnmount(() => { if (_creditTimer) { clearTimeout(_creditTimer); _creditT
|
|||
class="mini-image"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- The grab lip above the open sheet (see .sheet-grab-lip): the same handlers as the sheet. -->
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -573,7 +573,11 @@ async function exportPoster(item) {
|
|||
const format = props.posterAction === 'print' ? 'letter' : (item.sheet === '3x3' ? '3x3' : '3x4')
|
||||
const html = await Promise.resolve().then(() => props.sheet(item, format)).catch(() => '')
|
||||
if (html) {
|
||||
const { rasterPoster, looksRendered } = await import('@map/packet/poster-raster.js')
|
||||
const { rasterPoster, looksRendered, hasExternalImages } = await import('@map/packet/poster-raster.js')
|
||||
// ★ A SHEET WITH PHOTOS PRINTS AS ITS OWN DOCUMENT (the large side-lane sheets): a raster cannot carry a
|
||||
// photo from its own host (poster-raster.js says why), and the sheet's own @page is its true size
|
||||
// (60in 96in), where an image of it would be shrunk onto letter. A download still rasterises, without them.
|
||||
if (props.posterAction === 'print' && hasExternalImages(html)) { await printSheetDocument(html); return }
|
||||
const blob = await rasterPoster(html, { scale: 2 })
|
||||
if (await looksRendered(blob)) { await deliverPoster(blob, item); return }
|
||||
}
|
||||
|
|
@ -802,6 +806,20 @@ function printPacketAs(item, variantId) {
|
|||
// ONE delivery point for the finished poster, so 'download' and 'print' cannot
|
||||
// drift apart or be applied to only one of the two paths (shipped .webp vs
|
||||
// browser-rasterised). Both callers in exportPoster() route through here.
|
||||
// Print a generated sheet as the document it is (photos load, the page is its own size). Same HUD steps as
|
||||
// deliverPoster's print, including the phone's Print button when the tap has expired.
|
||||
async function printSheetDocument(html) {
|
||||
exportState.value = { ...exportState.value, phase: 'save', message: hud('wall.hud.openingPrint', 'opening the print dialog'), pct: 80 }
|
||||
const { printDocument } = await import('@map/composables/printInPlace.js')
|
||||
const how = await printDocument(html, { onNeedsTap: (printNow, cancel) => {
|
||||
exportState.value = { ...exportState.value, phase: 'ready', message: hud('wall.hud.readyToPrint', 'ready to print'), pct: 100,
|
||||
action: () => { exportState.value = null; printNow() }, cancel }
|
||||
} })
|
||||
if (how === 'needs-tap') return
|
||||
exportState.value = { ...exportState.value, phase: 'done', message: hud('wall.hud.printOpened', 'print dialog opened'), pct: 100 }
|
||||
setTimeout(() => { exportState.value = null }, 900)
|
||||
}
|
||||
|
||||
async function deliverPoster(blob, item) {
|
||||
if (props.posterAction === 'print') {
|
||||
exportState.value = { ...exportState.value, phase: 'save', message: hud('wall.hud.openingPrint', 'opening the print dialog'), pct: 80 }
|
||||
|
|
|
|||
|
|
@ -96,3 +96,65 @@ export function listPosterSizes() {
|
|||
export function listByCategory(category) {
|
||||
return listPosterSizes().filter((s) => s.category === category);
|
||||
}
|
||||
|
||||
// ─── THE POSTER-SIZE LADDER (every print surface, 2026-09-30) ────────────────────────────────────
|
||||
// ONE table of the sizes a READER can pick for a poster, shared by every print surface (the poster
|
||||
// walls, the map's export, Bless, the map-print template) through ONE print picker. It is separate from
|
||||
// POSTER_SIZES above (the map exporter's US/ISO list), and written as plain literals, so a bundle that
|
||||
// never imports it carries none of it.
|
||||
//
|
||||
// KEYS are WxH in INCHES (this file's existing convention: '24x36', '27x40'); they are dot-free, so
|
||||
// they are safe in i18n key paths, URLs and stored choices. PORTRAIT ONLY: h ≥ w for every size (66x66
|
||||
// is square). `sheets` is what a printer actually feeds, named exactly as panelSheets.js's planPanels()
|
||||
// parameters, so a consumer passes it straight in: `count` sheets of sheetW × sheetH, stacked in
|
||||
// `direction`, each overlapping the next by `joinIn` (glued), which gives the finished w × h.
|
||||
// `photos` means a photo slot beside each name is ALLOWED at this size (there is room); which photos
|
||||
// print is the renderer's call. `path` is how the size reaches paper: 'print' = the browser's own print
|
||||
// (any home printer; a phone prints Letter/A4 only), 'pdf' = a print-shop PDF, one page per sheet at
|
||||
// exact inches, since no phone and few home printers take 3-8 ft paper.
|
||||
export const POSTER_LADDER = {
|
||||
paper: { id: 'paper', inches: { w: 8.5, h: 11 }, paper: true,
|
||||
sheets: { sheetW: 8.5, sheetH: 11, count: 1, direction: 'vertical', joinIn: 0 }, photos: false, path: 'print' },
|
||||
'18x24': { id: '18x24', inches: { w: 18, h: 24 },
|
||||
sheets: { sheetW: 18, sheetH: 24, count: 1, direction: 'vertical', joinIn: 0 }, photos: false, path: 'pdf' },
|
||||
'36x36': { id: '36x36', inches: { w: 36, h: 36 },
|
||||
sheets: { sheetW: 36, sheetH: 36, count: 1, direction: 'vertical', joinIn: 0 }, photos: false, path: 'pdf' },
|
||||
'36x48': { id: '36x48', inches: { w: 36, h: 48 },
|
||||
sheets: { sheetW: 36, sheetH: 48, count: 1, direction: 'vertical', joinIn: 0 }, photos: false, path: 'pdf' },
|
||||
'36x60': { id: '36x60', inches: { w: 36, h: 60 },
|
||||
sheets: { sheetW: 36, sheetH: 60, count: 1, direction: 'vertical', joinIn: 0 }, photos: false, path: 'pdf' },
|
||||
'36x66': { id: '36x66', inches: { w: 36, h: 66 },
|
||||
sheets: { sheetW: 36, sheetH: 66, count: 1, direction: 'vertical', joinIn: 0 }, photos: false, path: 'pdf' },
|
||||
'66x66': { id: '66x66', inches: { w: 66, h: 66 },
|
||||
sheets: { sheetW: 36, sheetH: 66, count: 2, direction: 'horizontal', joinIn: 6 }, photos: true, path: 'pdf' },
|
||||
'60x96': { id: '60x96', inches: { w: 60, h: 96 },
|
||||
sheets: { sheetW: 60, sheetH: 36, count: 3, direction: 'vertical', joinIn: 6 }, photos: true, path: 'pdf' },
|
||||
}
|
||||
/** The picker's order, smallest first. Every size up to 36x66 is one sheet; 66x66 and 60x96 are glued. */
|
||||
export const POSTER_LADDER_ORDER = ['paper', '18x24', '36x36', '36x48', '36x60', '36x66', '66x66', '60x96']
|
||||
/** A size that still resolves (stored choices, today's defaults) but is NOT offered in the picker. */
|
||||
export const POSTER_LADDER_LEGACY = {
|
||||
'24x36': { id: '24x36', inches: { w: 24, h: 36 },
|
||||
sheets: { sheetW: 24, sheetH: 36, count: 1, direction: 'vertical', joinIn: 0 }, photos: false, path: 'pdf' },
|
||||
}
|
||||
/** Older names for the same sizes (poster-render's FORMATS, Bless's PRESETS, the map exporter). */
|
||||
export const POSTER_LADDER_ALIASES = {
|
||||
'3x3': '36x36', '3x3ft': '36x36', '3x5': '36x60', '3x5ft': '36x60',
|
||||
'3x4': '36x48', '3x4ft': '36x48', '5x8': '60x96', '5x8ft': '60x96',
|
||||
'2x3': '24x36', '2x3ft': '24x36', letter: 'paper', a4: 'paper',
|
||||
}
|
||||
/** A key, an alias or a legacy key → its ladder entry; null when unknown. */
|
||||
export function resolvePosterSize(key) {
|
||||
const k = POSTER_LADDER_ALIASES[key] || key
|
||||
return POSTER_LADDER[k] || POSTER_LADDER_LEGACY[k] || null
|
||||
}
|
||||
// Countries that print on US Letter; everywhere else "Standard printer paper" means A4. Keyed by the
|
||||
// REGION of a locale tag (en-US, es-MX); a tag without a region falls back to its language's usual paper.
|
||||
const LETTER_REGIONS = new Set(['US', 'CA', 'MX', 'PH', 'CL', 'CO', 'VE', 'CR', 'GT', 'PA', 'DO', 'PR', 'SV', 'NI', 'HN', 'BO'])
|
||||
/** 'letter' or 'a4' for a locale tag (e.g. navigator.language). */
|
||||
export function paperFor(localeTag) {
|
||||
const m = String(localeTag || '').match(/^([a-z]{2,3})(?:[-_]([A-Za-z]{2}))?/i)
|
||||
if (!m) return 'letter'
|
||||
if (m[2]) return LETTER_REGIONS.has(m[2].toUpperCase()) ? 'letter' : 'a4'
|
||||
return m[1].toLowerCase() === 'en' ? 'letter' : 'a4'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,10 @@ without importing each other. This file is the contract they share. It is **docu
|
|||
exists today**, verified against `development`, not a proposal.
|
||||
|
||||
The components still live in `plugin-designs/research-dashboard/src/components/dashboards/`. They
|
||||
are deliberately **not** in `library/` yet: a library component with a single consumer is a
|
||||
relocation, not a promotion, and the repo has already had to move such modules back. They graduate
|
||||
when a second dashboard consumes them.
|
||||
are not in `library/` yet. Under the repo's "reusable by default" rule
|
||||
([`skills/plugin-designer`](../../skills/plugin-designer/SKILL.md), section 3), a component another
|
||||
dashboard could use belongs in `library/`, driven by props; moving these is a library change for the
|
||||
build owner.
|
||||
|
||||
## The shape
|
||||
|
||||
|
|
|
|||
27
library/i18n/locales/ar/biblePosters.json
Normal file
27
library/i18n/locales/ar/biblePosters.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"title": "ملصقات الكتاب المقدس",
|
||||
"book": "السفر",
|
||||
"language": "اللغة",
|
||||
"size": "الحجم",
|
||||
"print": "طباعة",
|
||||
"close": "إغلاق",
|
||||
"loading": "جارٍ تحميل السفر…",
|
||||
"fetchFailed": "تعذّر تحميل هذا النص. حاول مرة أخرى.",
|
||||
"fitsAt": "يتّسع بحجم {pt} نقطة في {cols} أعمدة.",
|
||||
"tooSmall": "أصغر من أن يُقرأ بهذا الحجم.",
|
||||
"trySize": "جرّب {size}",
|
||||
"books": {
|
||||
"ACT": "أعمال الرسل",
|
||||
"MAT": "متى",
|
||||
"JHN": "يوحنا",
|
||||
"MRK": "مرقس",
|
||||
"LUK": "لوقا",
|
||||
"HEB": "العبرانيين",
|
||||
"GEN": "التكوين",
|
||||
"1JN": "يوحنا الأولى",
|
||||
"COL": "كولوسي",
|
||||
"1CO": "كورنثوس الأولى",
|
||||
"1PE": "بطرس الأولى",
|
||||
"JOS": "يشوع"
|
||||
}
|
||||
}
|
||||
|
|
@ -40,5 +40,6 @@
|
|||
"error_failed": "تعذّر إنشاء الملصق. حاول مرة أخرى أو اختر حجمًا أصغر.",
|
||||
"optional": "(اختياري)",
|
||||
"phoneLimit": "على الهاتف، أكبر المقاسات غير متاحة: لا يستطيع الهاتف رسم ملصق بهذا الحجم. استخدم حاسوبًا لها.",
|
||||
"phoneLimitAll": "يتطلّب هذا حاسوبًا مكتبيًا أو محمولًا.",
|
||||
"glue_here": "الصق هنا"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,24 @@
|
|||
},
|
||||
"sizes": {
|
||||
"heading": "مقاسات الملصقات",
|
||||
"waiting": "سيظهر سُلّم المقاسات (الورق القياسي، و3 × 3 أقدام، و3 × 5 أقدام، و5.5 × 5.5 أقدام، و5 × 8 أقدام) هنا بعد نشر جدول المقاسات المشترك. سيُعرض كل مقاس كمعاينة طباعة، بالاتجاه الطولي فقط."
|
||||
"intro": "كل المقاسات التي يمكن طباعة الملصق بها، مرسومة بمقياس رسم. تُطبع المقاسات الكبيرة على عدة أوراق تتداخل على الجدار؛ والشريط الأحمر هو موضع لصق الورقة التالية.",
|
||||
"lineup": "كل المقاسات جنبًا إلى جنب، بمقياس رسم",
|
||||
"paper": "ورق طابعة قياسي",
|
||||
"paperLetter": "ورق Letter الأمريكي، 8.5 × 11 بوصة",
|
||||
"paperA4": "A4، 210 × 297 مم",
|
||||
"dimsFt": "{w} × {h} قدم",
|
||||
"dimsIn": "{w} × {h} بوصة",
|
||||
"dimsInOne": "{v} بوصة",
|
||||
"rowSheets": "الأوراق",
|
||||
"rowSheet": "كل ورقة",
|
||||
"rowOverlap": "التداخل الملصوق",
|
||||
"rowPhotos": "صور بجانب الأسماء",
|
||||
"rowPrint": "طريقة الطباعة",
|
||||
"yes": "نعم",
|
||||
"no": "لا",
|
||||
"printHome": "أي طابعة منزلية",
|
||||
"printShop": "مطبعة (ملف PDF، صفحة لكل ورقة)",
|
||||
"layoutNote": "تعرض الأوراق تخطيط ملصق الدولة بمقاس 3 × 3 أقدام في كل مقاس: الترويسة والخريطة والأسماء والجدول وتذييل الآية. سيظهر الملصق المرسوم بكل مقاس في الأنماط عند توفره."
|
||||
},
|
||||
"family": {
|
||||
"mapSheet": "صفحات الخرائط",
|
||||
|
|
@ -30,9 +47,7 @@
|
|||
"mapPrint": "طباعة الخريطة"
|
||||
},
|
||||
"missing": {
|
||||
"packet": "لا يرسم المعرض الحزمة بعد. تذكر التفاصيل مولّدها ونسختها.",
|
||||
"cards": "لا يرسم المعرض البطاقات بعد. تذكر التفاصيل المولّد ومقاس البطاقة.",
|
||||
"bless": "محرّك هذا القالب ليس في المكتبة المشتركة بعد، لذا لا يستطيع المعرض رسمه.",
|
||||
"bless": "الأوراق وشرائط اللصق ومواضع صفوف البطاقات مأخوذة من هندسة الألواح المشتركة؛ وتُعرض البطاقات كتخطيط، لأن جدار البركة يرسم بطاقاته في إضافته الخاصة.",
|
||||
"mapPrint": "يطبع هذا القالب الخريطة الحية التي ينظر إليها القارئ، لذا ليست له صورة نموذجية."
|
||||
},
|
||||
"details": {
|
||||
|
|
|
|||
|
|
@ -17,5 +17,7 @@
|
|||
"exportCsv": "تصدير CSV",
|
||||
"copyNotRegroupable": "لا يمكن إعادة تجميعها معًا: انسخ ولايات أو مقاطعات بلد واحد، أو البلدان، كلًّا على حدة",
|
||||
"displayName": "ملصقات قائمة UPG 100",
|
||||
"wallHeading": "ملصقات قائمة UPG 100"
|
||||
"wallHeading": "ملصقات قائمة UPG 100",
|
||||
"wallIntroA": "كيف تُطعم جمعًا من خمسة آلاف؟ أجلسهم يسوع جماعاتٍ من خمسين ومن مئة، فصار المستحيل قابلًا للمشاركة (لوقا ٩:١٤). فعلنا المثل مع {n} شعوب غير مبلَّغة: ٢٧ قائمة من نحو خمسين إلى مئة. تُطبع كل قائمة حزمةَ صلاة وملصقًا، ليحمل ٢٧ تلميذًا أو كنيسة أو قائدًا محليًا كلٌّ واحدة — مصلّين لأجلهم ",
|
||||
"peopleStillLoading": "لا تزال الشعوب قيد التحميل، حاول مرة أخرى بعد لحظة"
|
||||
}
|
||||
|
|
|
|||
27
library/i18n/locales/de/biblePosters.json
Normal file
27
library/i18n/locales/de/biblePosters.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"title": "Bibel-Poster",
|
||||
"book": "Buch",
|
||||
"language": "Sprache",
|
||||
"size": "Größe",
|
||||
"print": "Drucken",
|
||||
"close": "Schließen",
|
||||
"loading": "Buch wird geladen…",
|
||||
"fetchFailed": "Dieser Text konnte nicht geladen werden. Bitte erneut versuchen.",
|
||||
"fitsAt": "Passt bei {pt} pt in {cols} Spalten.",
|
||||
"tooSmall": "Zu klein, um in dieser Größe lesbar zu sein.",
|
||||
"trySize": "versuchen Sie {size}",
|
||||
"books": {
|
||||
"ACT": "Apostelgeschichte",
|
||||
"MAT": "Matthäus",
|
||||
"JHN": "Johannes",
|
||||
"MRK": "Markus",
|
||||
"LUK": "Lukas",
|
||||
"HEB": "Hebräer",
|
||||
"GEN": "1. Mose",
|
||||
"1JN": "1. Johannes",
|
||||
"COL": "Kolosser",
|
||||
"1CO": "1. Korinther",
|
||||
"1PE": "1. Petrus",
|
||||
"JOS": "Josua"
|
||||
}
|
||||
}
|
||||
|
|
@ -40,5 +40,6 @@
|
|||
"error_failed": "Das Poster konnte nicht erstellt werden. Versuchen Sie es erneut oder wählen Sie eine kleinere Größe.",
|
||||
"optional": "(optional)",
|
||||
"phoneLimit": "Auf dem Handy sind die größten Formate ausgeschaltet: Ein Handy kann kein so großes Poster zeichnen. Nutzen Sie dafür einen Computer.",
|
||||
"phoneLimitAll": "Dafür ist ein Desktop- oder Laptop-Computer nötig.",
|
||||
"glue_here": "hier kleben"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,24 @@
|
|||
},
|
||||
"sizes": {
|
||||
"heading": "Postergrößen",
|
||||
"waiting": "Die Größenstufen (Standardpapier, 3 × 3 ft, 3 × 5 ft, 5,5 × 5,5 ft und 5 × 8 ft) erscheinen hier, sobald die gemeinsame Größentabelle veröffentlicht ist. Jede Größe wird als Druckvorschau gezeigt, nur im Hochformat."
|
||||
"intro": "Jede Größe, in der ein Poster gedruckt werden kann, maßstabsgetreu gezeichnet. Die großen Größen werden auf mehrere Bögen gedruckt, die sich an der Wand überlappen; der rote Streifen zeigt, wo der nächste Bogen angeklebt wird.",
|
||||
"lineup": "Alle Größen nebeneinander, maßstabsgetreu",
|
||||
"paper": "Normales Druckerpapier",
|
||||
"paperLetter": "US-Letter, 8,5 × 11 in",
|
||||
"paperA4": "A4, 210 × 297 mm",
|
||||
"dimsFt": "{w} × {h} ft",
|
||||
"dimsIn": "{w} × {h} in",
|
||||
"dimsInOne": "{v} in",
|
||||
"rowSheets": "Bögen",
|
||||
"rowSheet": "Jeder Bogen",
|
||||
"rowOverlap": "Geklebte Überlappung",
|
||||
"rowPhotos": "Fotos neben den Namen",
|
||||
"rowPrint": "Druck",
|
||||
"yes": "Ja",
|
||||
"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."
|
||||
},
|
||||
"family": {
|
||||
"mapSheet": "Kartenblätter",
|
||||
|
|
@ -30,9 +47,7 @@
|
|||
"mapPrint": "Kartendruck"
|
||||
},
|
||||
"missing": {
|
||||
"packet": "Die Galerie zeichnet die Mappe noch nicht. Die Details nennen ihren Generator und ihre Variante.",
|
||||
"cards": "Die Galerie zeichnet die Karten noch nicht. Die Details nennen den Generator und das Kartenformat.",
|
||||
"bless": "Die Engine dieser Vorlage ist noch nicht in der gemeinsamen Bibliothek, deshalb kann die Galerie sie nicht zeichnen.",
|
||||
"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.",
|
||||
"mapPrint": "Diese Vorlage druckt die Live-Karte, die der Leser gerade ansieht, und hat deshalb kein Beispielbild."
|
||||
},
|
||||
"details": {
|
||||
|
|
|
|||
|
|
@ -17,5 +17,7 @@
|
|||
"exportCsv": "CSV exportieren",
|
||||
"copyNotRegroupable": "Nicht gemeinsam umgruppierbar: Bundesstaaten oder Provinzen eines Landes oder die Länder getrennt kopieren",
|
||||
"displayName": "Poster der UPG 100 Liste",
|
||||
"wallHeading": "Poster der UPG 100 Liste"
|
||||
"wallHeading": "Poster der UPG 100 Liste",
|
||||
"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"
|
||||
}
|
||||
|
|
|
|||
27
library/i18n/locales/en/biblePosters.json
Normal file
27
library/i18n/locales/en/biblePosters.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"title": "Bible Posters",
|
||||
"book": "Book",
|
||||
"language": "Language",
|
||||
"size": "Size",
|
||||
"print": "Print",
|
||||
"close": "Close",
|
||||
"loading": "Loading the book…",
|
||||
"fetchFailed": "Could not load this text. Please try again.",
|
||||
"fitsAt": "Fits at {pt} pt in {cols} columns.",
|
||||
"tooSmall": "Too small to read at this size.",
|
||||
"trySize": "try {size}",
|
||||
"books": {
|
||||
"ACT": "Acts",
|
||||
"MAT": "Matthew",
|
||||
"JHN": "John",
|
||||
"MRK": "Mark",
|
||||
"LUK": "Luke",
|
||||
"HEB": "Hebrews",
|
||||
"GEN": "Genesis",
|
||||
"1JN": "1 John",
|
||||
"COL": "Colossians",
|
||||
"1CO": "1 Corinthians",
|
||||
"1PE": "1 Peter",
|
||||
"JOS": "Joshua"
|
||||
}
|
||||
}
|
||||
|
|
@ -40,5 +40,6 @@
|
|||
"error_failed": "The poster could not be rendered. Try again, or choose a smaller size.",
|
||||
"optional": "(optional)",
|
||||
"phoneLimit": "On a phone, the largest sizes are off: a phone can't draw a poster that big. Use a computer for them.",
|
||||
"phoneLimitAll": "This needs a desktop or laptop computer.",
|
||||
"glue_here": "glue here"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,24 @@
|
|||
},
|
||||
"sizes": {
|
||||
"heading": "Poster sizes",
|
||||
"waiting": "The size ladder (standard paper, 3 × 3 ft, 3 × 5 ft, 5.5 × 5.5 ft and 5 × 8 ft) appears here once the shared size table is published. Each size will be shown as a print preview, portrait only."
|
||||
"intro": "Every size a poster can print at, drawn to scale. The large sizes print on several sheets that overlap on the wall; the red strip is where the next sheet is glued on.",
|
||||
"lineup": "All sizes side by side, to scale",
|
||||
"paper": "Standard printer paper",
|
||||
"paperLetter": "US Letter, 8.5 × 11 in",
|
||||
"paperA4": "A4, 210 × 297 mm",
|
||||
"dimsFt": "{w} × {h} ft",
|
||||
"dimsIn": "{w} × {h} in",
|
||||
"dimsInOne": "{v} in",
|
||||
"rowSheets": "Sheets",
|
||||
"rowSheet": "Each sheet",
|
||||
"rowOverlap": "Glued overlap",
|
||||
"rowPhotos": "Photos beside names",
|
||||
"rowPrint": "How it prints",
|
||||
"yes": "Yes",
|
||||
"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."
|
||||
},
|
||||
"family": {
|
||||
"mapSheet": "Map sheets",
|
||||
|
|
@ -30,9 +47,7 @@
|
|||
"mapPrint": "Map print"
|
||||
},
|
||||
"missing": {
|
||||
"packet": "The gallery does not draw the packet yet. The details name its renderer and variant.",
|
||||
"cards": "The gallery does not draw the cards yet. The details name the renderer and the card stock.",
|
||||
"bless": "This template's engine is not in the shared library yet, so the gallery cannot draw it.",
|
||||
"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.",
|
||||
"mapPrint": "This template prints the live map the reader is looking at, so it has no sample picture."
|
||||
},
|
||||
"details": {
|
||||
|
|
|
|||
|
|
@ -17,5 +17,7 @@
|
|||
"exportCsv": "Export CSV",
|
||||
"copyNotRegroupable": "Can't be regrouped together: copy one country's states or provinces, or the countries, separately",
|
||||
"displayName": "UPG 100 List Posters",
|
||||
"wallHeading": "UPG 100 List Posters"
|
||||
"wallHeading": "UPG 100 List 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"
|
||||
}
|
||||
|
|
|
|||
27
library/i18n/locales/es/biblePosters.json
Normal file
27
library/i18n/locales/es/biblePosters.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"title": "Carteles bíblicos",
|
||||
"book": "Libro",
|
||||
"language": "Idioma",
|
||||
"size": "Tamaño",
|
||||
"print": "Imprimir",
|
||||
"close": "Cerrar",
|
||||
"loading": "Cargando el libro…",
|
||||
"fetchFailed": "No se pudo cargar este texto. Inténtalo de nuevo.",
|
||||
"fitsAt": "Cabe a {pt} pt en {cols} columnas.",
|
||||
"tooSmall": "Demasiado pequeño para leerse en este tamaño.",
|
||||
"trySize": "prueba {size}",
|
||||
"books": {
|
||||
"ACT": "Hechos",
|
||||
"MAT": "Mateo",
|
||||
"JHN": "Juan",
|
||||
"MRK": "Marcos",
|
||||
"LUK": "Lucas",
|
||||
"HEB": "Hebreos",
|
||||
"GEN": "Génesis",
|
||||
"1JN": "1 Juan",
|
||||
"COL": "Colosenses",
|
||||
"1CO": "1 Corintios",
|
||||
"1PE": "1 Pedro",
|
||||
"JOS": "Josué"
|
||||
}
|
||||
}
|
||||
|
|
@ -40,5 +40,6 @@
|
|||
"error_failed": "No se pudo generar el póster. Inténtalo de nuevo o elige un tamaño menor.",
|
||||
"optional": "(opcional)",
|
||||
"phoneLimit": "En un teléfono, los tamaños más grandes están desactivados: un teléfono no puede dibujar un póster tan grande. Usa un ordenador para ellos.",
|
||||
"phoneLimitAll": "Esto requiere una computadora de escritorio o portátil.",
|
||||
"glue_here": "pegar aquí"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,24 @@
|
|||
},
|
||||
"sizes": {
|
||||
"heading": "Tamaños de póster",
|
||||
"waiting": "La escala de tamaños (papel estándar, 3 × 3 ft, 3 × 5 ft, 5,5 × 5,5 ft y 5 × 8 ft) aparecerá aquí cuando se publique la tabla de tamaños compartida. Cada tamaño se mostrará como vista previa de impresión, solo en vertical."
|
||||
"intro": "Todos los tamaños en que se puede imprimir un póster, dibujados a escala. Los tamaños grandes se imprimen en varias hojas que se superponen en la pared; la franja roja es donde se pega la hoja siguiente.",
|
||||
"lineup": "Todos los tamaños lado a lado, a escala",
|
||||
"paper": "Papel de impresora estándar",
|
||||
"paperLetter": "Carta (EE. UU.), 8,5 × 11 in",
|
||||
"paperA4": "A4, 210 × 297 mm",
|
||||
"dimsFt": "{w} × {h} ft",
|
||||
"dimsIn": "{w} × {h} in",
|
||||
"dimsInOne": "{v} in",
|
||||
"rowSheets": "Hojas",
|
||||
"rowSheet": "Cada hoja",
|
||||
"rowOverlap": "Superposición pegada",
|
||||
"rowPhotos": "Fotos junto a los nombres",
|
||||
"rowPrint": "Cómo se imprime",
|
||||
"yes": "Sí",
|
||||
"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."
|
||||
},
|
||||
"family": {
|
||||
"mapSheet": "Hojas de mapa",
|
||||
|
|
@ -30,9 +47,7 @@
|
|||
"mapPrint": "Impresión del mapa"
|
||||
},
|
||||
"missing": {
|
||||
"packet": "La galería todavía no dibuja el paquete. Los detalles indican su generador y su variante.",
|
||||
"cards": "La galería todavía no dibuja las tarjetas. Los detalles indican el generador y el tipo de tarjeta.",
|
||||
"bless": "El motor de esta plantilla aún no está en la biblioteca compartida, así que la galería no puede dibujarla.",
|
||||
"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.",
|
||||
"mapPrint": "Esta plantilla imprime el mapa en vivo que está mirando el lector, así que no tiene imagen de ejemplo."
|
||||
},
|
||||
"details": {
|
||||
|
|
|
|||
|
|
@ -17,5 +17,7 @@
|
|||
"exportCsv": "Exportar CSV",
|
||||
"copyNotRegroupable": "No se pueden reagrupar juntos: copia por separado los estados o provincias de un solo país, o los países",
|
||||
"displayName": "Carteles de la lista UPG 100",
|
||||
"wallHeading": "Carteles de la lista UPG 100"
|
||||
"wallHeading": "Carteles de la lista UPG 100",
|
||||
"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"
|
||||
}
|
||||
|
|
|
|||
27
library/i18n/locales/fr/biblePosters.json
Normal file
27
library/i18n/locales/fr/biblePosters.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"title": "Affiches bibliques",
|
||||
"book": "Livre",
|
||||
"language": "Langue",
|
||||
"size": "Taille",
|
||||
"print": "Imprimer",
|
||||
"close": "Fermer",
|
||||
"loading": "Chargement du livre…",
|
||||
"fetchFailed": "Impossible de charger ce texte. Veuillez réessayer.",
|
||||
"fitsAt": "Tient à {pt} pt sur {cols} colonnes.",
|
||||
"tooSmall": "Trop petit pour être lu à cette taille.",
|
||||
"trySize": "essayez {size}",
|
||||
"books": {
|
||||
"ACT": "Actes",
|
||||
"MAT": "Matthieu",
|
||||
"JHN": "Jean",
|
||||
"MRK": "Marc",
|
||||
"LUK": "Luc",
|
||||
"HEB": "Hébreux",
|
||||
"GEN": "Genèse",
|
||||
"1JN": "1 Jean",
|
||||
"COL": "Colossiens",
|
||||
"1CO": "1 Corinthiens",
|
||||
"1PE": "1 Pierre",
|
||||
"JOS": "Josué"
|
||||
}
|
||||
}
|
||||
|
|
@ -40,5 +40,6 @@
|
|||
"error_failed": "L’affiche n’a pas pu être générée. Réessayez ou choisissez un format plus petit.",
|
||||
"optional": "(facultatif)",
|
||||
"phoneLimit": "Sur un téléphone, les plus grands formats sont désactivés : un téléphone ne peut pas dessiner une affiche aussi grande. Utilisez un ordinateur pour ceux-là.",
|
||||
"phoneLimitAll": "Cela nécessite un ordinateur de bureau ou un portable.",
|
||||
"glue_here": "coller ici"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,24 @@
|
|||
},
|
||||
"sizes": {
|
||||
"heading": "Formats d'affiche",
|
||||
"waiting": "L'échelle des formats (papier standard, 3 × 3 ft, 3 × 5 ft, 5,5 × 5,5 ft et 5 × 8 ft) apparaîtra ici dès que la table des formats partagée sera publiée. Chaque format sera présenté en aperçu avant impression, en portrait uniquement."
|
||||
"intro": "Tous les formats d'impression d'une affiche, dessinés à l'échelle. Les grands formats s'impriment sur plusieurs feuilles qui se chevauchent au mur ; la bande rouge indique où coller la feuille suivante.",
|
||||
"lineup": "Tous les formats côte à côte, à l'échelle",
|
||||
"paper": "Papier d'imprimante standard",
|
||||
"paperLetter": "Lettre US, 8,5 × 11 in",
|
||||
"paperA4": "A4, 210 × 297 mm",
|
||||
"dimsFt": "{w} × {h} ft",
|
||||
"dimsIn": "{w} × {h} in",
|
||||
"dimsInOne": "{v} in",
|
||||
"rowSheets": "Feuilles",
|
||||
"rowSheet": "Chaque feuille",
|
||||
"rowOverlap": "Chevauchement collé",
|
||||
"rowPhotos": "Photos à côté des noms",
|
||||
"rowPrint": "Impression",
|
||||
"yes": "Oui",
|
||||
"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."
|
||||
},
|
||||
"family": {
|
||||
"mapSheet": "Feuilles de carte",
|
||||
|
|
@ -30,9 +47,7 @@
|
|||
"mapPrint": "Impression de la carte"
|
||||
},
|
||||
"missing": {
|
||||
"packet": "La galerie ne dessine pas encore le dossier. Les détails indiquent son générateur et sa variante.",
|
||||
"cards": "La galerie ne dessine pas encore les cartes. Les détails indiquent le générateur et le format de carte.",
|
||||
"bless": "Le moteur de ce modèle n'est pas encore dans la bibliothèque partagée, la galerie ne peut donc pas le dessiner.",
|
||||
"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.",
|
||||
"mapPrint": "Ce modèle imprime la carte en direct que le lecteur regarde, il n'a donc pas d'image d'exemple."
|
||||
},
|
||||
"details": {
|
||||
|
|
|
|||
|
|
@ -17,5 +17,7 @@
|
|||
"exportCsv": "Exporter en CSV",
|
||||
"copyNotRegroupable": "Impossible de les regrouper ensemble : copiez séparément les États ou provinces d’un seul pays, ou les pays",
|
||||
"displayName": "Affiches de la liste UPG 100",
|
||||
"wallHeading": "Affiches de la liste UPG 100"
|
||||
"wallHeading": "Affiches de la liste UPG 100",
|
||||
"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"
|
||||
}
|
||||
|
|
|
|||
27
library/i18n/locales/hi/biblePosters.json
Normal file
27
library/i18n/locales/hi/biblePosters.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"title": "बाइबल पोस्टर",
|
||||
"book": "पुस्तक",
|
||||
"language": "भाषा",
|
||||
"size": "आकार",
|
||||
"print": "प्रिंट करें",
|
||||
"close": "बंद करें",
|
||||
"loading": "पुस्तक लोड हो रही है…",
|
||||
"fetchFailed": "यह पाठ लोड नहीं हो सका। कृपया पुनः प्रयास करें।",
|
||||
"fitsAt": "{pt} pt पर {cols} स्तंभों में समाता है।",
|
||||
"tooSmall": "इस आकार में पढ़ने के लिए बहुत छोटा।",
|
||||
"trySize": "{size} आज़माएँ",
|
||||
"books": {
|
||||
"ACT": "प्रेरितों के काम",
|
||||
"MAT": "मत्ती",
|
||||
"JHN": "यूहन्ना",
|
||||
"MRK": "मरकुस",
|
||||
"LUK": "लूका",
|
||||
"HEB": "इब्रानियों",
|
||||
"GEN": "उत्पत्ति",
|
||||
"1JN": "1 यूहन्ना",
|
||||
"COL": "कुलुस्सियों",
|
||||
"1CO": "1 कुरिन्थियों",
|
||||
"1PE": "1 पतरस",
|
||||
"JOS": "यहोशू"
|
||||
}
|
||||
}
|
||||
|
|
@ -40,5 +40,6 @@
|
|||
"error_failed": "पोस्टर नहीं बन सका। फिर से कोशिश करें या छोटा आकार चुनें।",
|
||||
"optional": "(वैकल्पिक)",
|
||||
"phoneLimit": "फ़ोन पर सबसे बड़े आकार बंद हैं: फ़ोन इतना बड़ा पोस्टर नहीं बना सकता। उनके लिए कंप्यूटर का उपयोग करें।",
|
||||
"phoneLimitAll": "इसके लिए डेस्कटॉप या लैपटॉप कंप्यूटर चाहिए।",
|
||||
"glue_here": "यहाँ चिपकाएँ"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,24 @@
|
|||
},
|
||||
"sizes": {
|
||||
"heading": "पोस्टर आकार",
|
||||
"waiting": "साझा आकार तालिका प्रकाशित होते ही आकारों की सीढ़ी (मानक कागज़, 3 × 3 फ़ुट, 3 × 5 फ़ुट, 5.5 × 5.5 फ़ुट और 5 × 8 फ़ुट) यहाँ दिखेगी। हर आकार प्रिंट पूर्वावलोकन के रूप में दिखेगा, केवल खड़े (पोर्ट्रेट) रूप में।"
|
||||
"intro": "हर वह आकार जिसमें पोस्टर छापा जा सकता है, पैमाने के अनुसार बनाया गया। बड़े आकार कई शीटों पर छपते हैं जो दीवार पर एक-दूसरे पर चढ़ती हैं; लाल पट्टी वह जगह है जहाँ अगली शीट चिपकाई जाती है।",
|
||||
"lineup": "सभी आकार साथ-साथ, पैमाने के अनुसार",
|
||||
"paper": "सामान्य प्रिंटर कागज़",
|
||||
"paperLetter": "यूएस लेटर, 8.5 × 11 इंच",
|
||||
"paperA4": "A4, 210 × 297 मिमी",
|
||||
"dimsFt": "{w} × {h} फ़ुट",
|
||||
"dimsIn": "{w} × {h} इंच",
|
||||
"dimsInOne": "{v} इंच",
|
||||
"rowSheets": "शीटें",
|
||||
"rowSheet": "हर शीट",
|
||||
"rowOverlap": "चिपकाई जाने वाली ओवरलैप",
|
||||
"rowPhotos": "नामों के पास फ़ोटो",
|
||||
"rowPrint": "कैसे छपता है",
|
||||
"yes": "हाँ",
|
||||
"no": "नहीं",
|
||||
"printHome": "कोई भी घरेलू प्रिंटर",
|
||||
"printShop": "प्रिंटिंग प्रेस (PDF, हर शीट के लिए एक पेज)",
|
||||
"layoutNote": "शीटें हर आकार में 3 × 3 फ़ुट देश पोस्टर का लेआउट दिखाती हैं: शीर्षक, मानचित्र, नाम, तालिका और पवित्रशास्त्र वाला फ़ुटर। हर आकार में बना असली पोस्टर उपलब्ध होते ही शैलियाँ में दिखेगा।"
|
||||
},
|
||||
"family": {
|
||||
"mapSheet": "मानचित्र पृष्ठ",
|
||||
|
|
@ -30,9 +47,7 @@
|
|||
"mapPrint": "मानचित्र प्रिंट"
|
||||
},
|
||||
"missing": {
|
||||
"packet": "गैलरी अभी पैकेट नहीं बनाती। विवरण में उसका जनरेटर और संस्करण बताया गया है।",
|
||||
"cards": "गैलरी अभी कार्ड नहीं बनाती। विवरण में जनरेटर और कार्ड का आकार बताया गया है।",
|
||||
"bless": "इस टेम्पलेट का इंजन अभी साझा लाइब्रेरी में नहीं है, इसलिए गैलरी इसे नहीं बना सकती।",
|
||||
"bless": "शीटें, चिपकाने की पट्टियाँ और कार्ड-पंक्तियों की जगह साझा पैनल ज्यामिति से आती हैं; कार्ड लेआउट के रूप में दिखाए गए हैं, क्योंकि आशीष दीवार अपने कार्ड अपने प्लगइन में बनाती है।",
|
||||
"mapPrint": "यह टेम्पलेट वह लाइव मानचित्र प्रिंट करता है जिसे पाठक देख रहा है, इसलिए इसकी कोई नमूना तस्वीर नहीं है।"
|
||||
},
|
||||
"details": {
|
||||
|
|
|
|||
|
|
@ -17,5 +17,7 @@
|
|||
"exportCsv": "CSV निर्यात करें",
|
||||
"copyNotRegroupable": "इन्हें एक साथ पुनर्समूहित नहीं किया जा सकता: एक देश के राज्य या प्रांत, या देश, अलग-अलग कॉपी करें",
|
||||
"displayName": "UPG 100 सूची पोस्टर",
|
||||
"wallHeading": "UPG 100 सूची पोस्टर"
|
||||
"wallHeading": "UPG 100 सूची पोस्टर",
|
||||
"wallIntroA": "पाँच हज़ार की भीड़ को आप कैसे खिलाते? यीशु ने उन्हें पचास-पचास और सौ-सौ के समूहों में बिठाया, और असंभव बाँटने योग्य हो गया (लूका 9:14). हमने {n} अनपहुँचे जनसमूहों के साथ वही किया: लगभग पचास से सौ तक की 27 सूचियाँ. हर सूची प्रार्थना पैकेट और पोस्टर के रूप में छपती है, ताकि 27 शिष्य, कलीसियाएँ या स्थानीय अगुवे एक-एक ले सकें — उनके लिए प्रार्थना करते हुए ",
|
||||
"peopleStillLoading": "जनसमूह अभी लोड हो रहे हैं, थोड़ी देर में फिर कोशिश करें"
|
||||
}
|
||||
|
|
|
|||
27
library/i18n/locales/it/biblePosters.json
Normal file
27
library/i18n/locales/it/biblePosters.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"title": "Poster biblici",
|
||||
"book": "Libro",
|
||||
"language": "Lingua",
|
||||
"size": "Dimensione",
|
||||
"print": "Stampa",
|
||||
"close": "Chiudi",
|
||||
"loading": "Caricamento del libro…",
|
||||
"fetchFailed": "Impossibile caricare questo testo. Riprova.",
|
||||
"fitsAt": "Sta a {pt} pt su {cols} colonne.",
|
||||
"tooSmall": "Troppo piccolo da leggere a questa dimensione.",
|
||||
"trySize": "prova {size}",
|
||||
"books": {
|
||||
"ACT": "Atti",
|
||||
"MAT": "Matteo",
|
||||
"JHN": "Giovanni",
|
||||
"MRK": "Marco",
|
||||
"LUK": "Luca",
|
||||
"HEB": "Ebrei",
|
||||
"GEN": "Genesi",
|
||||
"1JN": "1 Giovanni",
|
||||
"COL": "Colossesi",
|
||||
"1CO": "1 Corinzi",
|
||||
"1PE": "1 Pietro",
|
||||
"JOS": "Giosuè"
|
||||
}
|
||||
}
|
||||
|
|
@ -40,5 +40,6 @@
|
|||
"error_failed": "Impossibile generare il poster. Riprova o scegli un formato più piccolo.",
|
||||
"optional": "(facoltativo)",
|
||||
"phoneLimit": "Sul telefono i formati più grandi sono disattivati: un telefono non può disegnare un poster così grande. Per quelli usa un computer.",
|
||||
"phoneLimitAll": "Serve un computer desktop o portatile.",
|
||||
"glue_here": "incollare qui"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,24 @@
|
|||
},
|
||||
"sizes": {
|
||||
"heading": "Formati dei poster",
|
||||
"waiting": "La scala dei formati (carta standard, 3 × 3 ft, 3 × 5 ft, 5,5 × 5,5 ft e 5 × 8 ft) comparirà qui quando sarà pubblicata la tabella dei formati condivisa. Ogni formato sarà mostrato come anteprima di stampa, solo in verticale."
|
||||
"intro": "Tutti i formati in cui si può stampare un poster, disegnati in scala. I formati grandi si stampano su più fogli che si sovrappongono sulla parete; la striscia rossa indica dove incollare il foglio successivo.",
|
||||
"lineup": "Tutti i formati affiancati, in scala",
|
||||
"paper": "Carta da stampante standard",
|
||||
"paperLetter": "Letter USA, 8,5 × 11 in",
|
||||
"paperA4": "A4, 210 × 297 mm",
|
||||
"dimsFt": "{w} × {h} ft",
|
||||
"dimsIn": "{w} × {h} in",
|
||||
"dimsInOne": "{v} in",
|
||||
"rowSheets": "Fogli",
|
||||
"rowSheet": "Ogni foglio",
|
||||
"rowOverlap": "Sovrapposizione incollata",
|
||||
"rowPhotos": "Foto accanto ai nomi",
|
||||
"rowPrint": "Come si stampa",
|
||||
"yes": "Sì",
|
||||
"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."
|
||||
},
|
||||
"family": {
|
||||
"mapSheet": "Fogli mappa",
|
||||
|
|
@ -30,9 +47,7 @@
|
|||
"mapPrint": "Stampa della mappa"
|
||||
},
|
||||
"missing": {
|
||||
"packet": "La galleria non disegna ancora il fascicolo. I dettagli indicano il suo generatore e la variante.",
|
||||
"cards": "La galleria non disegna ancora le schede. I dettagli indicano il generatore e il formato della scheda.",
|
||||
"bless": "Il motore di questo modello non è ancora nella libreria condivisa, quindi la galleria non può disegnarlo.",
|
||||
"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.",
|
||||
"mapPrint": "Questo modello stampa la mappa dal vivo che il lettore sta guardando, quindi non ha un'immagine di esempio."
|
||||
},
|
||||
"details": {
|
||||
|
|
|
|||
|
|
@ -17,5 +17,7 @@
|
|||
"exportCsv": "Esporta CSV",
|
||||
"copyNotRegroupable": "Non si possono raggruppare insieme: copia separatamente gli stati o le province di un solo paese, o i paesi",
|
||||
"displayName": "Poster della lista UPG 100",
|
||||
"wallHeading": "Poster della lista UPG 100"
|
||||
"wallHeading": "Poster della lista UPG 100",
|
||||
"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"
|
||||
}
|
||||
|
|
|
|||
27
library/i18n/locales/pt/biblePosters.json
Normal file
27
library/i18n/locales/pt/biblePosters.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"title": "Cartazes bíblicos",
|
||||
"book": "Livro",
|
||||
"language": "Idioma",
|
||||
"size": "Tamanho",
|
||||
"print": "Imprimir",
|
||||
"close": "Fechar",
|
||||
"loading": "Carregando o livro…",
|
||||
"fetchFailed": "Não foi possível carregar este texto. Tente novamente.",
|
||||
"fitsAt": "Cabe a {pt} pt em {cols} colunas.",
|
||||
"tooSmall": "Pequeno demais para ler neste tamanho.",
|
||||
"trySize": "tente {size}",
|
||||
"books": {
|
||||
"ACT": "Atos",
|
||||
"MAT": "Mateus",
|
||||
"JHN": "João",
|
||||
"MRK": "Marcos",
|
||||
"LUK": "Lucas",
|
||||
"HEB": "Hebreus",
|
||||
"GEN": "Gênesis",
|
||||
"1JN": "1 João",
|
||||
"COL": "Colossenses",
|
||||
"1CO": "1 Coríntios",
|
||||
"1PE": "1 Pedro",
|
||||
"JOS": "Josué"
|
||||
}
|
||||
}
|
||||
|
|
@ -40,5 +40,6 @@
|
|||
"error_failed": "Não foi possível gerar o pôster. Tente novamente ou escolha um tamanho menor.",
|
||||
"optional": "(opcional)",
|
||||
"phoneLimit": "No telefone, os tamanhos maiores ficam desativados: um telefone não consegue desenhar um cartaz tão grande. Use um computador para eles.",
|
||||
"phoneLimitAll": "Isto requer um computador de mesa ou portátil.",
|
||||
"glue_here": "colar aqui"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,24 @@
|
|||
},
|
||||
"sizes": {
|
||||
"heading": "Tamanhos de pôster",
|
||||
"waiting": "A escala de tamanhos (papel padrão, 3 × 3 ft, 3 × 5 ft, 5,5 × 5,5 ft e 5 × 8 ft) aparecerá aqui quando a tabela de tamanhos compartilhada for publicada. Cada tamanho será mostrado como pré-visualização de impressão, apenas na vertical."
|
||||
"intro": "Todos os tamanhos em que um pôster pode ser impresso, desenhados em escala. Os tamanhos grandes são impressos em várias folhas que se sobrepõem na parede; a faixa vermelha mostra onde colar a folha seguinte.",
|
||||
"lineup": "Todos os tamanhos lado a lado, em escala",
|
||||
"paper": "Papel de impressora padrão",
|
||||
"paperLetter": "Carta (EUA), 8,5 × 11 in",
|
||||
"paperA4": "A4, 210 × 297 mm",
|
||||
"dimsFt": "{w} × {h} ft",
|
||||
"dimsIn": "{w} × {h} in",
|
||||
"dimsInOne": "{v} in",
|
||||
"rowSheets": "Folhas",
|
||||
"rowSheet": "Cada folha",
|
||||
"rowOverlap": "Sobreposição colada",
|
||||
"rowPhotos": "Fotos ao lado dos nomes",
|
||||
"rowPrint": "Como se imprime",
|
||||
"yes": "Sim",
|
||||
"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."
|
||||
},
|
||||
"family": {
|
||||
"mapSheet": "Folhas de mapa",
|
||||
|
|
@ -30,9 +47,7 @@
|
|||
"mapPrint": "Impressão do mapa"
|
||||
},
|
||||
"missing": {
|
||||
"packet": "A galeria ainda não desenha o pacote. Os detalhes indicam o seu gerador e a variante.",
|
||||
"cards": "A galeria ainda não desenha os cartões. Os detalhes indicam o gerador e o formato do cartão.",
|
||||
"bless": "O motor deste modelo ainda não está na biblioteca compartilhada, por isso a galeria não consegue desenhá-lo.",
|
||||
"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.",
|
||||
"mapPrint": "Este modelo imprime o mapa ao vivo que o leitor está vendo, por isso não tem imagem de exemplo."
|
||||
},
|
||||
"details": {
|
||||
|
|
|
|||
|
|
@ -17,5 +17,7 @@
|
|||
"exportCsv": "Exportar CSV",
|
||||
"copyNotRegroupable": "Não podem ser reagrupados juntos: copie separadamente os estados ou províncias de um só país, ou os países",
|
||||
"displayName": "Cartazes da lista UPG 100",
|
||||
"wallHeading": "Cartazes da lista UPG 100"
|
||||
"wallHeading": "Cartazes da lista UPG 100",
|
||||
"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"
|
||||
}
|
||||
|
|
|
|||
27
library/i18n/locales/ro/biblePosters.json
Normal file
27
library/i18n/locales/ro/biblePosters.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"title": "Postere biblice",
|
||||
"book": "Cartea",
|
||||
"language": "Limba",
|
||||
"size": "Dimensiune",
|
||||
"print": "Printează",
|
||||
"close": "Închide",
|
||||
"loading": "Se încarcă cartea…",
|
||||
"fetchFailed": "Textul nu a putut fi încărcat. Încearcă din nou.",
|
||||
"fitsAt": "Încape la {pt} pt pe {cols} coloane.",
|
||||
"tooSmall": "Prea mic pentru a fi citit la această dimensiune.",
|
||||
"trySize": "încearcă {size}",
|
||||
"books": {
|
||||
"ACT": "Faptele Apostolilor",
|
||||
"MAT": "Matei",
|
||||
"JHN": "Ioan",
|
||||
"MRK": "Marcu",
|
||||
"LUK": "Luca",
|
||||
"HEB": "Evrei",
|
||||
"GEN": "Geneza",
|
||||
"1JN": "1 Ioan",
|
||||
"COL": "Coloseni",
|
||||
"1CO": "1 Corinteni",
|
||||
"1PE": "1 Petru",
|
||||
"JOS": "Iosua"
|
||||
}
|
||||
}
|
||||
|
|
@ -40,5 +40,6 @@
|
|||
"error_failed": "Posterul nu a putut fi generat. Încercați din nou sau alegeți o dimensiune mai mică.",
|
||||
"optional": "(opțional)",
|
||||
"phoneLimit": "Pe telefon, cele mai mari dimensiuni sunt dezactivate: un telefon nu poate desena un poster atât de mare. Folosește un calculator pentru ele.",
|
||||
"phoneLimitAll": "Pentru aceasta este nevoie de un computer desktop sau laptop.",
|
||||
"glue_here": "lipiți aici"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,24 @@
|
|||
},
|
||||
"sizes": {
|
||||
"heading": "Dimensiuni de afiș",
|
||||
"waiting": "Scara dimensiunilor (hârtie standard, 3 × 3 ft, 3 × 5 ft, 5,5 × 5,5 ft și 5 × 8 ft) va apărea aici după publicarea tabelului comun de dimensiuni. Fiecare dimensiune va fi afișată ca previzualizare de tipărire, doar pe verticală."
|
||||
"intro": "Toate dimensiunile la care se poate tipări un afiș, desenate la scară. Dimensiunile mari se tipăresc pe mai multe coli care se suprapun pe perete; banda roșie arată unde se lipește coala următoare.",
|
||||
"lineup": "Toate dimensiunile alăturate, la scară",
|
||||
"paper": "Hârtie standard de imprimantă",
|
||||
"paperLetter": "Letter SUA, 8,5 × 11 in",
|
||||
"paperA4": "A4, 210 × 297 mm",
|
||||
"dimsFt": "{w} × {h} ft",
|
||||
"dimsIn": "{w} × {h} in",
|
||||
"dimsInOne": "{v} in",
|
||||
"rowSheets": "Coli",
|
||||
"rowSheet": "Fiecare coală",
|
||||
"rowOverlap": "Suprapunere lipită",
|
||||
"rowPhotos": "Fotografii lângă nume",
|
||||
"rowPrint": "Cum se tipărește",
|
||||
"yes": "Da",
|
||||
"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."
|
||||
},
|
||||
"family": {
|
||||
"mapSheet": "Foi de hartă",
|
||||
|
|
@ -30,9 +47,7 @@
|
|||
"mapPrint": "Tipărirea hărții"
|
||||
},
|
||||
"missing": {
|
||||
"packet": "Galeria nu desenează încă pachetul. Detaliile numesc generatorul și varianta lui.",
|
||||
"cards": "Galeria nu desenează încă cartonașele. Detaliile numesc generatorul și formatul cartonașului.",
|
||||
"bless": "Motorul acestui șablon nu este încă în biblioteca comună, așa că galeria nu îl poate desena.",
|
||||
"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.",
|
||||
"mapPrint": "Acest șablon tipărește harta live pe care o privește cititorul, așa că nu are imagine de exemplu."
|
||||
},
|
||||
"details": {
|
||||
|
|
|
|||
|
|
@ -17,5 +17,7 @@
|
|||
"exportCsv": "Exportă CSV",
|
||||
"copyNotRegroupable": "Nu pot fi regrupate împreună: copiază separat statele sau provinciile unei singure țări, sau țările",
|
||||
"displayName": "Afișe din lista UPG 100",
|
||||
"wallHeading": "Afișe din lista UPG 100"
|
||||
"wallHeading": "Afișe din lista UPG 100",
|
||||
"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ă"
|
||||
}
|
||||
|
|
|
|||
27
library/i18n/locales/ru/biblePosters.json
Normal file
27
library/i18n/locales/ru/biblePosters.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"title": "Библейские плакаты",
|
||||
"book": "Книга",
|
||||
"language": "Язык",
|
||||
"size": "Размер",
|
||||
"print": "Печать",
|
||||
"close": "Закрыть",
|
||||
"loading": "Загрузка книги…",
|
||||
"fetchFailed": "Не удалось загрузить текст. Попробуйте ещё раз.",
|
||||
"fitsAt": "Помещается при {pt} pt в {cols} колонках.",
|
||||
"tooSmall": "Слишком мелко для чтения при этом размере.",
|
||||
"trySize": "попробуйте {size}",
|
||||
"books": {
|
||||
"ACT": "Деяния",
|
||||
"MAT": "Матфея",
|
||||
"JHN": "Иоанна",
|
||||
"MRK": "Марка",
|
||||
"LUK": "Луки",
|
||||
"HEB": "Евреям",
|
||||
"GEN": "Бытие",
|
||||
"1JN": "1 Иоанна",
|
||||
"COL": "Колоссянам",
|
||||
"1CO": "1 Коринфянам",
|
||||
"1PE": "1 Петра",
|
||||
"JOS": "Иисус Навин"
|
||||
}
|
||||
}
|
||||
|
|
@ -40,5 +40,6 @@
|
|||
"error_failed": "Не удалось создать плакат. Попробуйте ещё раз или выберите меньший размер.",
|
||||
"optional": "(необязательно)",
|
||||
"phoneLimit": "На телефоне самые большие размеры отключены: телефон не может нарисовать такой большой плакат. Используйте для них компьютер.",
|
||||
"phoneLimitAll": "Для этого нужен настольный компьютер или ноутбук.",
|
||||
"glue_here": "клеить здесь"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,24 @@
|
|||
},
|
||||
"sizes": {
|
||||
"heading": "Размеры плакатов",
|
||||
"waiting": "Шкала размеров (стандартная бумага, 3 × 3 фута, 3 × 5 футов, 5,5 × 5,5 фута и 5 × 8 футов) появится здесь, когда будет опубликована общая таблица размеров. Каждый размер будет показан как предпросмотр печати, только в книжной ориентации."
|
||||
"intro": "Все размеры, в которых можно напечатать плакат, в масштабе. Большие размеры печатаются на нескольких листах, которые на стене заходят друг на друга; красная полоса показывает, где приклеить следующий лист.",
|
||||
"lineup": "Все размеры рядом, в масштабе",
|
||||
"paper": "Обычная бумага для принтера",
|
||||
"paperLetter": "US Letter, 8,5 × 11 дюймов",
|
||||
"paperA4": "A4, 210 × 297 мм",
|
||||
"dimsFt": "{w} × {h} фута",
|
||||
"dimsIn": "{w} × {h} дюймов",
|
||||
"dimsInOne": "{v} дюймов",
|
||||
"rowSheets": "Листы",
|
||||
"rowSheet": "Каждый лист",
|
||||
"rowOverlap": "Склеиваемый нахлёст",
|
||||
"rowPhotos": "Фото рядом с названиями",
|
||||
"rowPrint": "Как печатать",
|
||||
"yes": "Да",
|
||||
"no": "Нет",
|
||||
"printHome": "Любой домашний принтер",
|
||||
"printShop": "Типография (PDF, одна страница на лист)",
|
||||
"layoutNote": "На листах показан макет плаката страны 3 × 3 фута в каждом размере: заголовок, карта, названия, таблица и нижняя полоса со стихом из Писания. Плакат, нарисованный в каждом размере, появится в разделе «Стили», когда будет готов."
|
||||
},
|
||||
"family": {
|
||||
"mapSheet": "Листы карт",
|
||||
|
|
@ -30,9 +47,7 @@
|
|||
"mapPrint": "Печать карты"
|
||||
},
|
||||
"missing": {
|
||||
"packet": "Галерея пока не рисует пакет. В сведениях указаны его генератор и вариант.",
|
||||
"cards": "Галерея пока не рисует карточки. В сведениях указаны генератор и формат карточки.",
|
||||
"bless": "Движка этого шаблона пока нет в общей библиотеке, поэтому галерея не может его нарисовать.",
|
||||
"bless": "Листы, полосы для склейки и места рядов карточек взяты из общей геометрии панелей; карточки показаны как макет, потому что стена «Благословить» рисует свои карточки в собственном плагине.",
|
||||
"mapPrint": "Этот шаблон печатает живую карту, на которую смотрит читатель, поэтому у него нет примерного изображения."
|
||||
},
|
||||
"details": {
|
||||
|
|
|
|||
|
|
@ -17,5 +17,7 @@
|
|||
"exportCsv": "Экспорт в CSV",
|
||||
"copyNotRegroupable": "Нельзя перегруппировать вместе: копируйте отдельно штаты или провинции одной страны либо страны",
|
||||
"displayName": "Плакаты списка UPG 100",
|
||||
"wallHeading": "Плакаты списка UPG 100"
|
||||
"wallHeading": "Плакаты списка UPG 100",
|
||||
"wallIntroA": "Как накормить толпу в пять тысяч человек? Иисус рассадил их группами по пятьдесят и по сто, и невозможное стало посильным (Лк. 9:14). Мы поступили так же с {n} неохваченными народами: 27 списков примерно по пятьдесят-сто. Каждый список печатается как молитвенный пакет и плакат, чтобы 27 учеников, церквей или местных служителей взяли по одному — молясь о них ",
|
||||
"peopleStillLoading": "народы ещё загружаются; попробуйте ещё раз через мгновение"
|
||||
}
|
||||
|
|
|
|||
27
library/i18n/locales/zh/biblePosters.json
Normal file
27
library/i18n/locales/zh/biblePosters.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"title": "圣经海报",
|
||||
"book": "书卷",
|
||||
"language": "语言",
|
||||
"size": "尺寸",
|
||||
"print": "打印",
|
||||
"close": "关闭",
|
||||
"loading": "正在加载书卷…",
|
||||
"fetchFailed": "无法加载此文本,请重试。",
|
||||
"fitsAt": "以 {pt} 磅、{cols} 栏排布。",
|
||||
"tooSmall": "此尺寸下字太小,无法阅读。",
|
||||
"trySize": "请尝试 {size}",
|
||||
"books": {
|
||||
"ACT": "使徒行传",
|
||||
"MAT": "马太福音",
|
||||
"JHN": "约翰福音",
|
||||
"MRK": "马可福音",
|
||||
"LUK": "路加福音",
|
||||
"HEB": "希伯来书",
|
||||
"GEN": "创世记",
|
||||
"1JN": "约翰一书",
|
||||
"COL": "歌罗西书",
|
||||
"1CO": "哥林多前书",
|
||||
"1PE": "彼得前书",
|
||||
"JOS": "约书亚记"
|
||||
}
|
||||
}
|
||||
|
|
@ -40,5 +40,6 @@
|
|||
"error_failed": "无法生成海报。请重试或选择较小的尺寸。",
|
||||
"optional": "(可选)",
|
||||
"phoneLimit": "在手机上,最大的尺寸已关闭:手机无法绘制这么大的海报。请使用电脑制作这些尺寸。",
|
||||
"phoneLimitAll": "这需要台式电脑或笔记本电脑。",
|
||||
"glue_here": "在此粘贴"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,24 @@
|
|||
},
|
||||
"sizes": {
|
||||
"heading": "海报尺寸",
|
||||
"waiting": "共享尺寸表发布后,尺寸阶梯(标准纸张、3 × 3 英尺、3 × 5 英尺、5.5 × 5.5 英尺和 5 × 8 英尺)将显示在这里。每种尺寸都会以打印预览呈现,仅限纵向。"
|
||||
"intro": "海报可打印的所有尺寸,按比例绘制。大尺寸海报分多张纸打印,贴在墙上时彼此重叠;红色条带标出下一张纸的粘贴位置。",
|
||||
"lineup": "所有尺寸并排,按比例",
|
||||
"paper": "标准打印纸",
|
||||
"paperLetter": "美国信纸,8.5 × 11 英寸",
|
||||
"paperA4": "A4,210 × 297 毫米",
|
||||
"dimsFt": "{w} × {h} 英尺",
|
||||
"dimsIn": "{w} × {h} 英寸",
|
||||
"dimsInOne": "{v} 英寸",
|
||||
"rowSheets": "纸张数",
|
||||
"rowSheet": "每张纸",
|
||||
"rowOverlap": "粘贴重叠",
|
||||
"rowPhotos": "名称旁配照片",
|
||||
"rowPrint": "打印方式",
|
||||
"yes": "是",
|
||||
"no": "否",
|
||||
"printHome": "任何家用打印机",
|
||||
"printShop": "印刷店(PDF,每张纸一页)",
|
||||
"layoutNote": "纸张显示的是 3 × 3 英尺国家海报在各尺寸下的版式:标题、地图、名称、表格和经文页脚。各尺寸实际绘制的海报完成后会出现在“样式”中。"
|
||||
},
|
||||
"family": {
|
||||
"mapSheet": "地图页",
|
||||
|
|
@ -30,9 +47,7 @@
|
|||
"mapPrint": "地图打印"
|
||||
},
|
||||
"missing": {
|
||||
"packet": "图库暂不绘制资料包。详情中列出了它的生成器和版本。",
|
||||
"cards": "图库暂不绘制祷告卡。详情中列出了生成器和卡片规格。",
|
||||
"bless": "此模板的引擎尚未进入共享组件库,因此图库无法绘制。",
|
||||
"bless": "纸张、粘贴条以及卡片行的位置都来自共享的拼板几何;卡片以版式示意,因为祝福墙在其自己的插件中绘制卡片。",
|
||||
"mapPrint": "此模板打印读者正在查看的实时地图,因此没有示例图片。"
|
||||
},
|
||||
"details": {
|
||||
|
|
|
|||
|
|
@ -17,5 +17,7 @@
|
|||
"exportCsv": "导出 CSV",
|
||||
"copyNotRegroupable": "无法一起重新分组:请分别复制同一国家的邦或省,或各个国家",
|
||||
"displayName": "UPG 100 名单海报",
|
||||
"wallHeading": "UPG 100 名单海报"
|
||||
"wallHeading": "UPG 100 名单海报",
|
||||
"wallIntroA": "你会怎样喂饱五千人的群众?耶稣让他们一排五十、一排一百地坐下,不可能的事就变得可以分担了(路加福音 9:14)。我们对 {n} 个未得之民做了同样的事:27 份名单,每份约五十到一百。每份名单都能印成祷告手册和海报,让 27 位门徒、教会或当地领袖各领一份 — 为他们祷告 ",
|
||||
"peopleStillLoading": "族群仍在加载中,请稍后再试"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,20 +91,11 @@
|
|||
after a resize back to desktop — the previous broken-restore bug.
|
||||
-->
|
||||
<template>
|
||||
<!-- ★ THE FAB'S FOOTPRINT IS PUBLISHED, NOT GUESSED. .vtl-fab is a 44px circle pinned to the
|
||||
bottom-left, and it lives in the LAYOUT — outside every plugin's shadow root — so it paints
|
||||
over plugin content whatever z-index the plugin uses internally (the mobile legend sheet is
|
||||
z-index:1000 and still loses, because they are different stacking contexts). A scrolled-to-
|
||||
the-end legend therefore ends UNDER the circle.
|
||||
A plugin cannot know whether the circle is there, so the layout says so: --map-fab-clearance
|
||||
is set ONLY while the button is actually rendered, and custom properties inherit through the
|
||||
flattened tree, so it reaches into each shadow root. No circle, no reserved space.
|
||||
--map-fab-beside is the same fact for the OTHER axis: where a sibling control must start to
|
||||
sit NEXT TO the circle rather than on top of it (12px inset + 44px button + 8px gap). A
|
||||
consumer that hardcoded 64px would silently overlap the day the circle resizes; reading it
|
||||
means the circle's geometry is stated once, here, by the component that owns it. -->
|
||||
<div class="vtl" :class="{ 'nav-open': navOpen, 'rail-collapsed': railCollapsed, 'no-header': !header }"
|
||||
:style="fabShown ? { '--map-fab-clearance': 'calc(56px + env(safe-area-inset-bottom))', '--map-fab-beside': 'calc(56px + env(safe-area-inset-left))' } : null">
|
||||
<!-- ★ NO CORNER CIRCLE ANY MORE (the phone bar below replaced it). The layout used to publish
|
||||
--map-fab-clearance / --map-fab-beside while a floating ☰ sat over the pane's bottom-start
|
||||
corner; with the ☰ in a bar ABOVE the pane nothing covers plugin content, so neither is set and
|
||||
every reader of them (BackToTopButton, PinHint, LegendMobile, the grid) takes its own fallback. -->
|
||||
<div class="vtl" :class="{ 'nav-open': navOpen, 'rail-collapsed': railCollapsed, 'no-header': !header, 'has-phone-bar': phoneBarShown }">
|
||||
<header class="vtl-top" v-if="header">
|
||||
<button class="vtl-menu" type="button" :aria-expanded="String(navOpen)" @click="navOpen = !navOpen"
|
||||
:aria-label="label('toggleList')">☰</button>
|
||||
|
|
@ -113,25 +104,25 @@
|
|||
<span class="vtl-meta"><slot name="meta" /></span>
|
||||
</header>
|
||||
|
||||
<!-- ★ HEADERLESS MOBILE ESCAPE HATCH — the ONLY way to reach the rail when there is no header.
|
||||
FOUND 2026-09-22 by resizing the cap-5 SPA to a phone width. That SPA is header-OFF by
|
||||
default (the no-stacked-headers rule), so the toggle above — which lives inside
|
||||
`v-if="header"` — does not exist. On mobile the rail is an off-canvas drawer at
|
||||
translateX(-100%) with `navOpen` starting false, and the only other toggle (.vtl-collapse)
|
||||
sits INSIDE the drawer, measured off-screen at x:-244. Net effect: every tab except the one
|
||||
that happened to open was UNREACHABLE. Measured, not inferred.
|
||||
<!-- ★ THE PHONE BAR — the way to the rail AND to the language when there is no header.
|
||||
Header-off shells (the Research Gallery, Posterverse) have no .vtl-top, so on a phone this slim
|
||||
bar is their only chrome: ☰ at the inline START (right in ar, the edge the drawer opens from)
|
||||
and a #phone-end slot at the inline END, which the shell fills with its own language menu
|
||||
(library/components/ShellLanguageMenu.vue). It is a real grid row (.has-phone-bar), so the
|
||||
drawer and the pane both start below it and it never covers plugin content.
|
||||
44px: the ☰ and the menu ARE the bar's tap targets; a 40px bar would push their 44px hit
|
||||
areas into the pane. Rendered ONLY when narrow AND header-off (phoneBarShown), so desktop and
|
||||
every header-on consumer (plugin-studio) render exactly as before. It replaced the floating
|
||||
corner circle that did this job before (found 2026-09-22: header-off on a phone, every tab
|
||||
but the first was unreachable), and check:nav-reachable still presses it. -->
|
||||
<div v-if="phoneBarShown" class="vtl-pbar">
|
||||
<button class="vtl-phone-menu" type="button" :aria-expanded="String(navOpen)"
|
||||
@click="navOpen = !navOpen" :aria-label="label('toggleList')">☰</button>
|
||||
<span class="vtl-pbar-end"><slot name="phone-end" /></span>
|
||||
</div>
|
||||
|
||||
Rendered ONLY when narrow AND headerless, so no existing consumer can see a change: every
|
||||
other case is desktop (rail always visible) or header-on (the toggle above already works).
|
||||
It hides itself while the drawer is open — tapping a tab closes the drawer anyway, and a
|
||||
floating button over an open drawer would just cover a row. -->
|
||||
<button v-if="fabShown"
|
||||
class="vtl-fab" type="button" :aria-expanded="String(navOpen)"
|
||||
@click="navOpen = true" :aria-label="label('openViews')">☰</button>
|
||||
|
||||
<!-- Beside the circle, driving the shell's own scroller. Reads --map-fab-beside, which this
|
||||
layout publishes only while the circle is up, so with no circle it falls back to the
|
||||
plain inset instead of leaving a gap where a button used to be. -->
|
||||
<!-- Driving the shell's own scroller. It reads --map-fab-beside, which nothing sets now that the
|
||||
corner circle is gone, so it sits at its plain inset. -->
|
||||
<BackToTopButton v-if="narrow" :target="activeHostEl"
|
||||
:labels="{ backToTop: T('actions.backToTop', 'Back to top') }" />
|
||||
|
||||
|
|
@ -142,7 +133,7 @@
|
|||
an off-canvas drawer parked at translateX(-100%) and slid in by the `nav-open` class. Because it
|
||||
is never display:none, a mobile-closed drawer can no longer strand the rail after a resize back
|
||||
to desktop (the old cross-breakpoint bug). `aria-hidden` mirrors real visibility for a11y. -->
|
||||
<nav class="vtl-nav" :aria-label="navLabel" :aria-hidden="String(!railShown)"
|
||||
<nav class="vtl-nav" :class="{ 'has-rail-end': railEndShown && !!$slots['rail-end'] }" :aria-label="navLabel" :aria-hidden="String(!railShown)"
|
||||
:role="$slots.rail ? null : 'tablist'" :aria-orientation="$slots.rail ? null : 'vertical'">
|
||||
<!-- THE HOST'S WAY HOME (optional). A host page that embeds this layout full-page (DX Life's
|
||||
/deep-research) has no header of its own there, so it hands in its homepage URL and label
|
||||
|
|
@ -236,6 +227,16 @@
|
|||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- #rail-end: pinned to the BOTTOM of the desktop rail, where apps put account details. A
|
||||
header-off shell puts its language menu here (the same ShellLanguageMenu as the phone bar),
|
||||
so a desktop reader can change language with no header above the pane. Rendered only when
|
||||
WIDE AND header-off: a header-on consumer keeps its #toolbar menu (never two), and on a
|
||||
phone the bar's #phone-end carries it. `collapsed` tells the filler the rail is the 72px
|
||||
icon strip, so it shows the language code instead of its name. -->
|
||||
<div v-if="railEndShown && $slots['rail-end']" class="vtl-rail-end">
|
||||
<slot name="rail-end" :collapsed="railCollapsed" />
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="vtl-main">
|
||||
|
|
@ -327,6 +328,8 @@ const VTL_SHADOW_CSS = `
|
|||
content to size it and collapses to 0 (the 1fr row sits empty) → the same 0-height black screen.
|
||||
With no header there is only one region, so declare a single 1fr row and the body fills. */
|
||||
.vtl.no-header { grid-template-rows:1fr; }
|
||||
/* ...except on a phone, where the header-off layout has its slim bar (.vtl-pbar) as a real first row. */
|
||||
.vtl.no-header.has-phone-bar { grid-template-rows:auto 1fr; }
|
||||
/* MATERIAL APP BAR = 64px desktop / 56px mobile (2026-09-21), was 51px from 11px padding.
|
||||
Stated as an explicit min-height rather than left to padding arithmetic, so the number a
|
||||
reader sees is the number that renders. min-height keeps a wrapping title from clipping.
|
||||
|
|
@ -337,17 +340,17 @@ const VTL_SHADOW_CSS = `
|
|||
THIS RULE EXISTS TWICE (VTL_SHADOW_CSS and the scoped style block); keep them identical. */
|
||||
.vtl-top { flex:0 0 auto; display:flex; align-items:center; gap:10px; box-sizing:border-box; min-height:64px; padding:8px 16px; border-bottom:1px solid var(--vtl-border); background:#ffffff; }
|
||||
.vtl-menu { display:none; font:16px/1 system-ui; color:#3b463d; background:#d1d4cd; border:1px solid #d1d4cd; border-radius:8px; padding:5px 10px; cursor:pointer; }
|
||||
/* Headerless-mobile drawer opener. Only ever rendered when narrow AND header-off AND the drawer is
|
||||
shut, so it cannot appear in any layout that already has a working toggle. z-index sits ABOVE the
|
||||
content but BELOW the drawer (.vtl-nav is z-index:20) so the drawer covers it when it slides in.
|
||||
44px is the minimum comfortable touch target; the safe-area insets keep it clear of an iOS home
|
||||
indicator and a notch in landscape. */
|
||||
/* THE CORNER CIRCLE NEVER MOVES: 40px, 8px from the bottom-start corner of the layout (plus the safe area). It does not
|
||||
ride above a legend sheet or follow a scroll; it sits over the sheet's corner, and the collapsed sheet pads its
|
||||
start edge by --map-fab-beside so its caret and title clear it. A fixed spot cannot fly or re-animate. */
|
||||
.vtl-fab { position:absolute; z-index:15; left:calc(8px + env(safe-area-inset-left)); bottom:calc(8px + env(safe-area-inset-bottom)); width:40px; height:40px; display:flex; align-items:center; justify-content:center; font:18px/1 system-ui; color:#3b463d; background:#ffffff; border:1px solid var(--vtl-border); border-radius:50%; box-shadow:0 2px 10px rgba(0,0,0,.18); cursor:pointer; }
|
||||
.vtl-fab:hover { background:#d1d4cd; }
|
||||
.vtl-fab:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:2px; }
|
||||
/* THE PHONE BAR (phoneBarShown = narrow AND header-off; it replaced the floating corner circle).
|
||||
A real grid row above .vtl-body (.has-phone-bar below), so the drawer and the pane both start under
|
||||
it and it never covers plugin content. 44px + the top safe area; the rule line is an INSET SHADOW,
|
||||
not a border, so the 44px is the whole bar and the ☰ is a full 44×44 target. Flex follows the
|
||||
writing direction, so in ar the ☰ sits at the right (where the drawer opens) and the #phone-end
|
||||
menu at the left, with no :dir override. THIS RULE EXISTS TWICE; keep the copies identical. */
|
||||
.vtl-pbar { display:flex; align-items:center; gap:8px; box-sizing:border-box; height:calc(44px + env(safe-area-inset-top)); padding:env(safe-area-inset-top) calc(4px + env(safe-area-inset-right)) 0 calc(4px + env(safe-area-inset-left)); background:#ffffff; box-shadow:inset 0 -1px 0 var(--vtl-border); }
|
||||
.vtl-phone-menu { flex:0 0 auto; width:44px; height:44px; display:inline-flex; align-items:center; justify-content:center; padding:0; margin:0; font:20px/1 system-ui; color:#3b463d; background:transparent; border:0; border-radius:8px; cursor:pointer; }
|
||||
.vtl-phone-menu:hover { background:var(--vtl-accented); }
|
||||
.vtl-phone-menu:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:-2px; }
|
||||
.vtl-pbar-end { margin-inline-start:auto; display:inline-flex; align-items:center; min-width:0; }
|
||||
.vtl-menu:hover { background:#d1d4cd; }
|
||||
.vtl-brand { display:inline-flex; align-items:center; }
|
||||
.vtl-brand:empty { display:none; }
|
||||
|
|
@ -381,6 +384,13 @@ const VTL_SHADOW_CSS = `
|
|||
.vtl-home-ic { flex:0 0 auto; display:inline-flex; }
|
||||
.vtl-home-ic svg { width:16px; height:16px; }
|
||||
.vtl-home-tx { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
/* THE RAIL'S BOTTOM SLOT (#rail-end: railEndShown = wide AND header-off, and a filler present). The nav
|
||||
becomes a column ONLY then, so every other rail lays out exactly as before. margin-top:auto parks the
|
||||
slot at the bottom while the tabs are short; sticky keeps it in view once they scroll. The nav's own
|
||||
bottom padding moves into the slot, so the sticky row sits flush with the rail's bottom edge. */
|
||||
.vtl-nav.has-rail-end { display:flex; flex-direction:column; padding-bottom:0; }
|
||||
.vtl-nav.has-rail-end > * { flex-shrink:0; }
|
||||
.vtl-rail-end { margin-top:auto; position:sticky; bottom:0; padding:8px 0 12px; background:#ffffff; border-top:1px solid var(--vtl-border); }
|
||||
.vtl-grp { margin-bottom:2px; }
|
||||
.vtl-grp + .vtl-grp { margin-top:8px; padding-top:8px; border-top:1px solid var(--vtl-border); }
|
||||
.vtl-tier { width:100%; padding:8px 8px 6px; display:flex; align-items:center; gap:8px; box-sizing:border-box; }
|
||||
|
|
@ -502,6 +512,7 @@ const VTL_SHADOW_CSS = `
|
|||
nav translate -184px the 256px rail slides left, leaving 256-184 = 72px visible
|
||||
main left 72px the content pane starts exactly where the rail ends
|
||||
collapse pad 184px pushes the toggle's icon into the 72px strip that stays on screen
|
||||
(the #rail-end slot takes the same pad, so its menu sits in the strip too)
|
||||
tab pad 188px the same for each row's icon (4px more, matching the old 196/200 pair)
|
||||
Change one without the others and the icons drift out of the visible strip or the content
|
||||
overlaps the rail. Old values were -196 / 60 / 196 / 200.
|
||||
|
|
@ -510,6 +521,7 @@ const VTL_SHADOW_CSS = `
|
|||
.vtl.rail-collapsed .vtl-main { left:72px; }
|
||||
.vtl.rail-collapsed .vtl-collapse, .vtl.rail-collapsed .vtl-home { padding-left:184px; justify-content:flex-start; }
|
||||
.vtl.rail-collapsed .vtl-collapse-ic { transform:rotate(0deg); }
|
||||
.vtl.rail-collapsed .vtl-rail-end { padding-left:184px; }
|
||||
.vtl.rail-collapsed .vtl-collapse-tx, .vtl.rail-collapsed .vtl-home-tx { display:none; }
|
||||
.vtl.rail-collapsed .vtl-tab { padding-left:188px; gap:0; }
|
||||
.vtl.rail-collapsed .vtl-tab-text { display:none; }
|
||||
|
|
@ -568,12 +580,12 @@ const VTL_SHADOW_CSS = `
|
|||
.vtl:dir(rtl) .vtl-meta, .vtl:dir(rtl) .vtl-td, .vtl:dir(rtl) .vtl-chev { margin-left:0; margin-right:auto; }
|
||||
.vtl:dir(rtl) .vtl-chev:not(.open) { transform:scaleX(-1); }
|
||||
.vtl:dir(rtl) .vtl-collapse-ic svg { transform:scaleX(-1); }
|
||||
.vtl:dir(rtl) .vtl-fab { left:auto; right:calc(8px + env(safe-area-inset-right)); }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-nav { transform:translateX(184px); }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-main { left:0; right:72px; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-collapse, .vtl.rail-collapsed:dir(rtl) .vtl-home { padding-left:10px; padding-right:184px; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-tab { padding-left:12px; padding-right:188px; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-tier { padding-left:8px; padding-right:196px; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-rail-end { padding-left:0; padding-right:184px; }
|
||||
@media (max-width: 760px) {
|
||||
.vtl:dir(rtl) .vtl-nav { transform:translateX(100%); }
|
||||
.vtl.nav-open:dir(rtl) .vtl-nav { transform:translateX(0); }
|
||||
|
|
@ -587,7 +599,7 @@ const VTL_SHADOW_CSS = `
|
|||
/* ★ REDUCED MOTION, OWNED BY THIS COMPONENT. A page-level rule cannot reach into a shadow root,
|
||||
and no generated leaf ships one anyway (measured: 0 across the whole tree). Additive only. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.vtl-nav, .vtl-collapse-ic, .vtl-tab, .vtl-tier, .vtl-fab, .vtl-scrim { transition: none !important; animation: none !important; }
|
||||
.vtl-nav, .vtl-collapse-ic, .vtl-tab, .vtl-tier, .vtl-pbar, .vtl-phone-menu, .vtl-rail-end, .vtl-scrim { transition: none !important; animation: none !important; }
|
||||
}
|
||||
`
|
||||
|
||||
|
|
@ -648,14 +660,13 @@ export default defineComponent({
|
|||
},
|
||||
computed: {
|
||||
/**
|
||||
* ★ ONE SOURCE OF TRUTH FOR THE FLOATING CIRCLE, because two copies of this condition is the
|
||||
* bug waiting to happen. The button's v-if and the --map-fab-clearance that reserves room for
|
||||
* it MUST agree: if they drift, you get either a legend whose last row sits under the circle
|
||||
* (the reported case) or a strip of dead whitespace with no circle to justify it. Neither
|
||||
* has a symptom a gate can see, so the fix is to make disagreement impossible rather than to
|
||||
* test for it.
|
||||
* ★ ONE SOURCE OF TRUTH FOR EACH SLOT'S PLACE. The phone bar and the rail's bottom slot are the
|
||||
* same control in two places, and exactly one of them may show: the bar on a phone, the rail-end
|
||||
* on a desktop, and neither when a header is on (its #toolbar carries the menu). The template,
|
||||
* the grid row (.has-phone-bar) and check:shell-header all read these two, never a copy.
|
||||
*/
|
||||
fabShown() { return this.narrow && !this.header && !this.navOpen },
|
||||
phoneBarShown() { return this.narrow && !this.header },
|
||||
railEndShown() { return !this.narrow && !this.header },
|
||||
/**
|
||||
* The element the back-to-top arrow scrolls. .vtl-host is the shell's scroller
|
||||
* (overflow-y:auto) and there is one PER TAB, so this follows `active` rather than binding a
|
||||
|
|
@ -918,6 +929,8 @@ export default defineComponent({
|
|||
content to size it and collapses to 0 (the 1fr row sits empty) → the same 0-height black screen.
|
||||
With no header there is only one region, so declare a single 1fr row and the body fills. */
|
||||
.vtl.no-header { grid-template-rows:1fr; }
|
||||
/* ...except on a phone, where the header-off layout has its slim bar (.vtl-pbar) as a real first row. */
|
||||
.vtl.no-header.has-phone-bar { grid-template-rows:auto 1fr; }
|
||||
/* MATERIAL APP BAR = 64px desktop / 56px mobile (2026-09-21), was 51px from 11px padding.
|
||||
Stated as an explicit min-height rather than left to padding arithmetic, so the number a
|
||||
reader sees is the number that renders. min-height keeps a wrapping title from clipping.
|
||||
|
|
@ -928,17 +941,17 @@ export default defineComponent({
|
|||
THIS RULE EXISTS TWICE (VTL_SHADOW_CSS and the scoped style block); keep them identical. */
|
||||
.vtl-top { flex:0 0 auto; display:flex; align-items:center; gap:10px; box-sizing:border-box; min-height:64px; padding:8px 16px; border-bottom:1px solid var(--vtl-border); background:#ffffff; }
|
||||
.vtl-menu { display:none; font:16px/1 system-ui; color:#3b463d; background:#d1d4cd; border:1px solid #d1d4cd; border-radius:8px; padding:5px 10px; cursor:pointer; }
|
||||
/* Headerless-mobile drawer opener. Only ever rendered when narrow AND header-off AND the drawer is
|
||||
shut, so it cannot appear in any layout that already has a working toggle. z-index sits ABOVE the
|
||||
content but BELOW the drawer (.vtl-nav is z-index:20) so the drawer covers it when it slides in.
|
||||
44px is the minimum comfortable touch target; the safe-area insets keep it clear of an iOS home
|
||||
indicator and a notch in landscape. */
|
||||
/* THE CORNER CIRCLE NEVER MOVES: 40px, 8px from the bottom-start corner of the layout (plus the safe area). It does not
|
||||
ride above a legend sheet or follow a scroll; it sits over the sheet's corner, and the collapsed sheet pads its
|
||||
start edge by --map-fab-beside so its caret and title clear it. A fixed spot cannot fly or re-animate. */
|
||||
.vtl-fab { position:absolute; z-index:15; left:calc(8px + env(safe-area-inset-left)); bottom:calc(8px + env(safe-area-inset-bottom)); width:40px; height:40px; display:flex; align-items:center; justify-content:center; font:18px/1 system-ui; color:#3b463d; background:#ffffff; border:1px solid var(--vtl-border); border-radius:50%; box-shadow:0 2px 10px rgba(0,0,0,.18); cursor:pointer; }
|
||||
.vtl-fab:hover { background:#d1d4cd; }
|
||||
.vtl-fab:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:2px; }
|
||||
/* THE PHONE BAR (phoneBarShown = narrow AND header-off; it replaced the floating corner circle).
|
||||
A real grid row above .vtl-body (.has-phone-bar below), so the drawer and the pane both start under
|
||||
it and it never covers plugin content. 44px + the top safe area; the rule line is an INSET SHADOW,
|
||||
not a border, so the 44px is the whole bar and the ☰ is a full 44×44 target. Flex follows the
|
||||
writing direction, so in ar the ☰ sits at the right (where the drawer opens) and the #phone-end
|
||||
menu at the left, with no :dir override. THIS RULE EXISTS TWICE; keep the copies identical. */
|
||||
.vtl-pbar { display:flex; align-items:center; gap:8px; box-sizing:border-box; height:calc(44px + env(safe-area-inset-top)); padding:env(safe-area-inset-top) calc(4px + env(safe-area-inset-right)) 0 calc(4px + env(safe-area-inset-left)); background:#ffffff; box-shadow:inset 0 -1px 0 var(--vtl-border); }
|
||||
.vtl-phone-menu { flex:0 0 auto; width:44px; height:44px; display:inline-flex; align-items:center; justify-content:center; padding:0; margin:0; font:20px/1 system-ui; color:#3b463d; background:transparent; border:0; border-radius:8px; cursor:pointer; }
|
||||
.vtl-phone-menu:hover { background:var(--vtl-accented); }
|
||||
.vtl-phone-menu:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:-2px; }
|
||||
.vtl-pbar-end { margin-inline-start:auto; display:inline-flex; align-items:center; min-width:0; }
|
||||
.vtl-menu:hover { background:#d1d4cd; }
|
||||
.vtl-brand { display:inline-flex; align-items:center; }
|
||||
.vtl-brand:empty { display:none; }
|
||||
|
|
@ -972,6 +985,13 @@ export default defineComponent({
|
|||
.vtl-home-ic { flex:0 0 auto; display:inline-flex; }
|
||||
.vtl-home-ic svg { width:16px; height:16px; }
|
||||
.vtl-home-tx { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
/* THE RAIL'S BOTTOM SLOT (#rail-end: railEndShown = wide AND header-off, and a filler present). The nav
|
||||
becomes a column ONLY then, so every other rail lays out exactly as before. margin-top:auto parks the
|
||||
slot at the bottom while the tabs are short; sticky keeps it in view once they scroll. The nav's own
|
||||
bottom padding moves into the slot, so the sticky row sits flush with the rail's bottom edge. */
|
||||
.vtl-nav.has-rail-end { display:flex; flex-direction:column; padding-bottom:0; }
|
||||
.vtl-nav.has-rail-end > * { flex-shrink:0; }
|
||||
.vtl-rail-end { margin-top:auto; position:sticky; bottom:0; padding:8px 0 12px; background:#ffffff; border-top:1px solid var(--vtl-border); }
|
||||
.vtl-grp { margin-bottom:2px; }
|
||||
.vtl-grp + .vtl-grp { margin-top:8px; padding-top:8px; border-top:1px solid var(--vtl-border); }
|
||||
.vtl-tier { width:100%; padding:8px 8px 6px; display:flex; align-items:center; gap:8px; box-sizing:border-box; }
|
||||
|
|
@ -1093,6 +1113,7 @@ export default defineComponent({
|
|||
nav translate -184px the 256px rail slides left, leaving 256-184 = 72px visible
|
||||
main left 72px the content pane starts exactly where the rail ends
|
||||
collapse pad 184px pushes the toggle's icon into the 72px strip that stays on screen
|
||||
(the #rail-end slot takes the same pad, so its menu sits in the strip too)
|
||||
tab pad 188px the same for each row's icon (4px more, matching the old 196/200 pair)
|
||||
Change one without the others and the icons drift out of the visible strip or the content
|
||||
overlaps the rail. Old values were -196 / 60 / 196 / 200.
|
||||
|
|
@ -1101,6 +1122,7 @@ export default defineComponent({
|
|||
.vtl.rail-collapsed .vtl-main { left:72px; }
|
||||
.vtl.rail-collapsed .vtl-collapse, .vtl.rail-collapsed .vtl-home { padding-left:184px; justify-content:flex-start; }
|
||||
.vtl.rail-collapsed .vtl-collapse-ic { transform:rotate(0deg); }
|
||||
.vtl.rail-collapsed .vtl-rail-end { padding-left:184px; }
|
||||
.vtl.rail-collapsed .vtl-collapse-tx, .vtl.rail-collapsed .vtl-home-tx { display:none; }
|
||||
.vtl.rail-collapsed .vtl-tab { padding-left:188px; gap:0; }
|
||||
.vtl.rail-collapsed .vtl-tab-text { display:none; }
|
||||
|
|
@ -1159,12 +1181,12 @@ export default defineComponent({
|
|||
.vtl:dir(rtl) .vtl-meta, .vtl:dir(rtl) .vtl-td, .vtl:dir(rtl) .vtl-chev { margin-left:0; margin-right:auto; }
|
||||
.vtl:dir(rtl) .vtl-chev:not(.open) { transform:scaleX(-1); }
|
||||
.vtl:dir(rtl) .vtl-collapse-ic svg { transform:scaleX(-1); }
|
||||
.vtl:dir(rtl) .vtl-fab { left:auto; right:calc(8px + env(safe-area-inset-right)); }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-nav { transform:translateX(184px); }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-main { left:0; right:72px; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-collapse, .vtl.rail-collapsed:dir(rtl) .vtl-home { padding-left:10px; padding-right:184px; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-tab { padding-left:12px; padding-right:188px; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-tier { padding-left:8px; padding-right:196px; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-rail-end { padding-left:0; padding-right:184px; }
|
||||
@media (max-width: 760px) {
|
||||
.vtl:dir(rtl) .vtl-nav { transform:translateX(100%); }
|
||||
.vtl.nav-open:dir(rtl) .vtl-nav { transform:translateX(0); }
|
||||
|
|
@ -1178,6 +1200,6 @@ export default defineComponent({
|
|||
/* ★ REDUCED MOTION, OWNED BY THIS COMPONENT. A page-level rule cannot reach into a shadow root,
|
||||
and no generated leaf ships one anyway (measured: 0 across the whole tree). Additive only. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.vtl-nav, .vtl-collapse-ic, .vtl-tab, .vtl-tier, .vtl-fab, .vtl-scrim { transition: none !important; animation: none !important; }
|
||||
.vtl-nav, .vtl-collapse-ic, .vtl-tab, .vtl-tier, .vtl-pbar, .vtl-phone-menu, .vtl-rail-end, .vtl-scrim { transition: none !important; animation: none !important; }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -72,6 +72,34 @@ export function seriesFor(countries, item) {
|
|||
return { totalGroups: tier.reduce((a, x) => a + (x.count || 0), 0), totalLists: tier.length, index }
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ COUNTRIES TOO LARGE FOR ANY PAPER SHEET print as ONE LARGE side-lane sheet instead (poster-render's 60x96 /
|
||||
* 66x66 / 36x60: every group named beside the map, crossing-free leaders). ONE source of truth for every caller (the
|
||||
* Country Posters wall, the Regions list), so one country never gets two answers. `desktopOnly`: its poster, list,
|
||||
* packet and cards are offered on a desktop or laptop only (a phone cannot draw a sheet that big).
|
||||
* The data (country-posters.json) still records why no PAPER sheet fits; this table is what prints instead.
|
||||
*/
|
||||
export const LARGE_SHEETS = { IND: { format: '60x96', formats: ['60x96', '66x66', '36x60'], desktopOnly: true } }
|
||||
export function largeSheet(item) { return (item && LARGE_SHEETS[String(item.iso3 || '').toUpperCase()]) || null }
|
||||
// The sheet format a request gets: a large-sheet country prints at the large size ASKED FOR when it has one (66x66,
|
||||
// 36x60), else at its default (60x96); a packet's page 1 stays a page; every other country gets exactly what it asked.
|
||||
export function sheetFormatFor(item, format) {
|
||||
const big = largeSheet(item)
|
||||
if (!big || format === 'packet') return format
|
||||
return big.formats.includes(format) ? format : big.format
|
||||
}
|
||||
|
||||
// The facts row's "Administrative Areas" cell for a large-sheet country whose divisions we know (India: its states and
|
||||
// union territories, counted from the shared state table). English, like the rest of the sheet.
|
||||
async function adminAreasFor(iso3, base) {
|
||||
if (iso3 !== 'IND') return undefined
|
||||
try {
|
||||
const { loadIndiaStates } = await import('@map/data/indiaStates.js')
|
||||
const n = ((await loadIndiaStates(base)).states || []).length
|
||||
return n ? `${n} states and union territories` : undefined
|
||||
} catch (_) { return undefined } // the cell falls back to the country's name
|
||||
}
|
||||
|
||||
export function makeCountrySheet({ records, assetsBase, countries = null }) {
|
||||
return async function countrySheet(item, format) {
|
||||
const iso3 = item && item.iso3
|
||||
|
|
@ -84,14 +112,16 @@ export function makeCountrySheet({ records, assetsBase, countries = null }) {
|
|||
records({ type: 'country', values: [iso3] }),
|
||||
])
|
||||
if (!rows || !rows.length) return '' // nothing to pin: fall through rather than draw an empty country
|
||||
const big = largeSheet(item)
|
||||
const adminAreas = item.adminAreas || (big ? await adminAreasFor(String(iso3).toUpperCase(), assetsBase()) : undefined)
|
||||
return generateCountrySheet({
|
||||
// region travels: countryZone colours the sheet from it, and a country arriving without one
|
||||
// silently falls back to the old flat teal for that poster alone.
|
||||
country: { id: item.id, iso3, label: item.label, count: item.count, region: item.region },
|
||||
country: { id: item.id, iso3, label: item.label, count: item.count, region: item.region, adminAreas },
|
||||
records: rows,
|
||||
assetsBase: assetsBase(),
|
||||
ISO3_NAME,
|
||||
format,
|
||||
format: sheetFormatFor(item, format),
|
||||
series: seriesFor(countries, item),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,13 +38,24 @@ export function inlineSvg(posterHtml) {
|
|||
return svg.replace(/<svg([^>]*)>/, `<svg$1><style>${safe}</style>`);
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠ AN EXTERNAL IMAGE CAN NEVER DRAW IN THAT ISOLATED DOCUMENT. An SVG loaded through <img> fetches no external
|
||||
* resource by spec, and no people-group photo host sends CORS headers, so a photo cannot be inlined as data: in
|
||||
* the browser either. Left in, each one rasterises as the browser's broken-image glyph (measured on the large
|
||||
* side-lane sheet's photos). So the raster drops every <image> whose href is not data: (the basemap tiles, which
|
||||
* ARE data:, stay); a sheet that carries photos PRINTS AS A DOCUMENT instead, where they load (hasExternalImages).
|
||||
*/
|
||||
const EXTERNAL_IMAGE = /<image\b[^>]*?\bhref="(?!data:)[^"]*"[^>]*?\/>/g;
|
||||
export function hasExternalImages(posterHtml) { return /<image\b[^>]*?\bhref="(?!data:)/.test(posterHtml || ''); }
|
||||
export function stripExternalImages(svg) { return svg.replace(EXTERNAL_IMAGE, ''); }
|
||||
|
||||
/**
|
||||
* Rasterise a poster to a Blob in the browser.
|
||||
* @param {string} posterHtml the poster document (posters/<id>.html)
|
||||
* @param {object} opts scale (default 2), type ('image/webp'|'image/png'), quality
|
||||
*/
|
||||
export async function rasterPoster(posterHtml, { scale = 2, type = 'image/webp', quality = 0.85 } = {}) {
|
||||
const svg = inlineSvg(posterHtml);
|
||||
const svg = stripExternalImages(inlineSvg(posterHtml));
|
||||
// encodeURIComponent, not btoa: the posters carry non-Latin-1 characters (people
|
||||
// group and place names), and btoa throws on those.
|
||||
const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
// The sha384 of library/packet/poster-render.js. The browser fetches the served copy of that file
|
||||
// (<assets>/lib/poster-render.js), hashes it, and runs it only when it matches this pin: code loaded at
|
||||
// print time is integrity-pinned like every embed, because it executes on partner pages.
|
||||
export const POSTER_RENDER_SHA384 = 'sha384-G47HkYq8eBHUsabdbIppQnCHHlYH6wMYwDH0JjOD++43LdHQugVItepj2arJ1ONR'
|
||||
export const POSTER_RENDER_SHA384 = 'sha384-eiiTZts7LtjX2hiisXAI4WuoxPQJk9uPEeN5yLHiYXmxsyw+Y+KmJkred53w1ioC'
|
||||
// The sha384 of library/scripts/posters/data/ne-places.json, served at <assets>/boundaries/ne-places.json.
|
||||
// Data, not code, but pinned the same way: a sheet uses the places only when they are the pinned ones.
|
||||
export const NE_PLACES_SHA384 = 'sha384-Qtz2q8mcnA9Ei8Kwa2MRMyX95hSbSnUXjXdtsy8rHTX++mgSCih4LO4Rdrb2MIQy'
|
||||
// The sha384 of the classic-script copy (<assets>/lib/poster-render.global.js, globalVariant of the file above),
|
||||
// loaded with <script integrity> where a page's policy forbids importing a blob: of the verified text.
|
||||
export const POSTER_RENDER_GLOBAL_SHA384 = 'sha384-Y9bdIl2H1EAjFWNlSLua0OPdDjAlRnnq6k14gv1X/Sa8rUmNOB77jpttQHbg/kWz'
|
||||
export const POSTER_RENDER_GLOBAL_SHA384 = 'sha384-bFtiRPqRP81tqK81UvcglvCfdEUQnoTqIAw4ezEbx14Hg3nR++nz3LNASQwtgXsk'
|
||||
// The sha384 of library/scripts/posters/packet-strings.json, served at <assets>/lib/packet-strings.json. The
|
||||
// packet, list and card text in every language, fetched at print time (packet/packetStrings.js) and used only
|
||||
// when it matches; otherwise the print falls back to the bundled English block.
|
||||
|
|
|
|||
|
|
@ -259,8 +259,22 @@ const FORMATS = {
|
|||
// about 1,600px of height against the letter sheet's ~1,200, which is what lets the names grow.
|
||||
// Output: <zone>.packet.html beside the posters (see FILE_SUFFIX), never over them.
|
||||
packet: { w: 1500, h: 1941, gutter: 330, page: 'letter portrait', pageWidthIn: 8.5, mapOnly: true, header: 250, footer: 90 },
|
||||
// ★ LARGE SHEETS WITH SIDE LANES (`sideLanes`): a whole country's hundreds of groups, every one named horizontally
|
||||
// in two staggered columns per side, ONE leader per cluster of co-located groups, crossing-free by construction
|
||||
// (see sideLanesLayout). Same 41.67 px/in as the 3x3, so the header, pins and footer keep their physical size.
|
||||
// `nameMaxPt` caps the names' print size; the layout solves the largest size that fits the rows and columns.
|
||||
'60x96': { w: 2500, h: 4000, gutter: 572, page: '60in 96in', pageWidthIn: 60, sideLanes: true, nameMaxPt: 20.7, photos: true },
|
||||
'66x66': { w: 2750, h: 2750, gutter: 470, page: '66in 66in', pageWidthIn: 66, sideLanes: true, nameMaxPt: 18, photos: true },
|
||||
'36x60': { w: 1500, h: 2500, gutter: 400, page: '36in 60in', pageWidthIn: 36, sideLanes: true, nameMaxPt: 14, photos: false },
|
||||
};
|
||||
const FMT_KEY = FORMATS[process.env.FORMAT] ? process.env.FORMAT : '3x4';
|
||||
// The side-lane marks' styles: emitted ONLY on a sideLanes sheet, so every other format's page is byte-identical.
|
||||
const SIDE_LANES_CSS = `
|
||||
.slead{stroke:#5a5246;stroke-width:0.9;fill:none;opacity:.5}
|
||||
.sbr{stroke:#5a5246;stroke-width:1.1;fill:none}
|
||||
.srow{stroke:#b9ac8c;stroke-width:.3;fill:none;opacity:.7}
|
||||
.spin{fill:#1c1c17;stroke:#f7f4ec;stroke-width:.8}
|
||||
.snm{font-family:Lato,'Segoe UI',system-ui,sans-serif;font-weight:700;fill:#1c1c17}`;
|
||||
const FMT = FORMATS[FMT_KEY];
|
||||
// Map-only sheets (packet page 1) skip the poster's header and footer furniture — see FORMATS.packet.
|
||||
const MAP_ONLY = FMT.mapOnly === true;
|
||||
|
|
@ -852,7 +866,9 @@ async function renderPoster(zone, pins, zoneFC) {
|
|||
const floorDeg = CITIES ? _floor(Math.max(_w, _h)) : 1;
|
||||
// PER-AXIS floor. A collinear cluster (zero span on one axis) must not be padded
|
||||
// as if it were as wide as the other axis — that is what produced a 0.0% spread.
|
||||
const pad = CITIES
|
||||
// A side-lane sheet is the COUNTRY at its largest: a thin margin, since the names, not neighbours, fill the sides.
|
||||
const pad = FMT.sideLanes ? [_w * 0.04, _h * 0.04]
|
||||
: CITIES
|
||||
? [_w * 0.22 + _floor(Math.max(_w, _h * 0.35)), _h * 0.22 + _floor(Math.max(_h, _w * 0.35))]
|
||||
: [_w * 0.22 + floorDeg, _h * 0.22 + floorDeg];
|
||||
const viewBbox = [bbox[0] - pad[0], bbox[1] - pad[1], bbox[2] + pad[0], bbox[3] + pad[1]];
|
||||
|
|
@ -882,12 +898,14 @@ async function renderPoster(zone, pins, zoneFC) {
|
|||
// frame is exactly what shows them.
|
||||
// FULL SHEET HEIGHT — the map runs from just under the header to just above the
|
||||
// footer rule, so there is no dead band anywhere on the page.
|
||||
const FRAME_H = MAP_H;
|
||||
// A side-lane sheet fits the frame to the country's own shape and centres it: its names run the sheet's full height
|
||||
// beside it, so the frame need not, and a full-height frame would only shrink the country to show its neighbours.
|
||||
const FRAME_H = FMT.sideLanes ? Math.min(MAP_H, FRAME_W / zoneAspect) : MAP_H;
|
||||
// OPTICAL CENTRING, not geometric. A block centred by arithmetic looks like it
|
||||
// is falling off the bottom of the sheet — the optical centre sits ABOVE the
|
||||
// geometric one, which is why framers cut a bottom mat 1.25-1.4x the top. Here
|
||||
// the remainder is split 42/58 so the bottom margin is the larger one.
|
||||
const FRAME_Y = MAP_Y;
|
||||
const FRAME_Y = FMT.sideLanes ? MAP_Y + (MAP_H - FRAME_H) / 2 : MAP_Y;
|
||||
// PROJECT FROM viewBbox, NOT bbox.
|
||||
// These two had drifted apart: `viewBbox` (bbox + 22% context pad) decides WHICH
|
||||
// countries are drawn, while the projection scaled from the bare `bbox` — so the
|
||||
|
|
@ -1245,7 +1263,8 @@ async function renderPoster(zone, pins, zoneFC) {
|
|||
//
|
||||
// This is what makes leaders viable again: leaders can only be routed without
|
||||
// crossing if their endpoints are distinct, and the endpoints are these pins.
|
||||
{
|
||||
// A side-lane sheet keeps every pin at its TRUE position: co-located groups share one leader instead.
|
||||
if (!FMT.sideLanes) {
|
||||
// MIN_SEP MUST TRACK THE BADGE SIZE. It was a hardcoded 25 tuned against the
|
||||
// old r=10.8 disc; leaving it fixed while the badge shrinks would keep pushing
|
||||
// pins just as far apart and defeat the whole point of the smaller badge.
|
||||
|
|
@ -1292,7 +1311,126 @@ async function renderPoster(zone, pins, zoneFC) {
|
|||
// Feed the settled pixel positions back as lng/lat-free overrides.
|
||||
const settled = prePins.map((p) => ({ ...p, _fixed: true }));
|
||||
|
||||
const laid = layoutLabels(settled, proj);
|
||||
// ── SIDE LANES: a whole country's groups on one large sheet (FMT.sideLanes) ───────────────────────────
|
||||
// Every name HORIZONTAL, on the left and right of the map, in two STAGGERED columns per side: the rows alternate
|
||||
// inner/outer at half the line pitch, so each column keeps a full pitch and each row holds exactly ONE name.
|
||||
// Co-located groups (within CL_R px) form a CLUSTER with ONE leader, a bracket and a block of names.
|
||||
// CROSSING-FREE BY CONSTRUCTION, which the numbered design below could not reach at this density:
|
||||
// · one vertical split (the weighted median x) sends each cluster to the nearer side, so runs never meet;
|
||||
// · each leader is a short vertical stub to its own LANE, a horizontal run to the map edge, then a straight fan
|
||||
// to its block; lanes are placed FARTHEST-FROM-THE-EDGE first, each clamped between its neighbouring lanes,
|
||||
// so a stub never jumps a lane, and the fan keeps the lanes' top-to-bottom order (ordered points on two
|
||||
// parallel lines, joined in order, cannot cross). The count is measured and returned, never assumed.
|
||||
// A real photo (never the generic placeholder) sits on the OUTSIDE of its name when the format allows photos.
|
||||
function sideLanesLayout(pinsIn) {
|
||||
const X0 = MAP_X, X1 = MAP_X + MAP_W;
|
||||
const BY0 = LABEL_TOP ?? FRAME_Y, BY1 = LABEL_BOT ?? (FRAME_Y + FRAME_H);
|
||||
const PXI = PW / FMT.pageWidthIn;
|
||||
const pins = pinsIn.map((p) => ({ ...p, px: p._px, py: p._py }));
|
||||
// A photo only from the known people-group photo hosts, over https, and never the generic placeholder.
|
||||
const PHOTO_HOSTS = /^https:\/\/(media\.joshuaproject\.net|joshuaproject\.net|images\.peoplegroups\.org|peoplegroupspublic\.blob\.core\.windows\.net)\//;
|
||||
const photoOf = (p) => (FMT.photos && typeof p.image_url === 'string' && PHOTO_HOSTS.test(p.image_url) && !/no-photo-images/.test(p.image_url) ? p.image_url : null);
|
||||
const CL_R = 14, clusters = [];
|
||||
for (const p of pins) {
|
||||
const c = clusters.find((q) => Math.hypot(q.x - p.px, q.y - p.py) <= CL_R);
|
||||
if (c) { c.items.push(p); c.x += (p.px - c.x) / c.items.length; c.y += (p.py - c.y) / c.items.length; }
|
||||
else clusters.push({ x: p.px, y: p.py, items: [p] });
|
||||
}
|
||||
for (const c of clusters) c.items.sort((a, b) => String(a.name).localeCompare(String(b.name), 'en'));
|
||||
const byX = [...clusters].sort((a, b) => a.x - b.x);
|
||||
let acc = 0, split = byX.length ? byX[0].x : 0;
|
||||
for (const c of byX) { acc += c.items.length; if (acc >= pins.length / 2) { split = c.x; break; } }
|
||||
for (const c of clusters) c.side = c.x <= split ? 'L' : 'R';
|
||||
// THE TYPE: the largest size (capped by nameMaxPt) whose rows fit the band and whose two columns fit the gutter.
|
||||
const FAN = 70, GAP = 10, PHOTO_GAP = 4;
|
||||
const room = GUTTER - (SHEET_EDGE + 8) - FAN - GAP;
|
||||
const photoPx = (f) => Math.floor(f * 1.18) - 1;
|
||||
const longest = (f) => Math.max(...pins.map((p) => textW(String(p.name), f) * 1.06 + (photoOf(p) ? photoPx(f) + PHOTO_GAP : 0)));
|
||||
const lineCount = (sd) => clusters.filter((c) => c.side === sd).reduce((a, c) => a + c.items.length, 0);
|
||||
const maxLines = Math.max(1, lineCount('L'), lineCount('R'));
|
||||
let font = Math.min((FMT.nameMaxPt || 14) / 72 * PXI, (BY1 - BY0) / (maxLines * 0.59 * 1.02));
|
||||
while (font > 5 && 2 * (longest(font) + 8) > room) font -= 0.1;
|
||||
const ROW = font * 1.18 / 2, PHOTO = photoPx(font), COLW = longest(font) + 8;
|
||||
// LANES, farthest from the edge first (see above). PIN_CLEAR: within its safe gap a lane prefers a height
|
||||
// that passes no other pin, at most 7.5 px from its own pin.
|
||||
const LANE_GAP = 1.5, PIN_CLEAR = 4.5;
|
||||
let laneJumps = 0;
|
||||
for (const sd of ['L', 'R']) {
|
||||
const L = sd === 'L';
|
||||
const mine = clusters.filter((c) => c.side === sd).sort((a, b) => (L ? b.x - a.x : a.x - b.x));
|
||||
const lanes = [];
|
||||
for (const c of mine) {
|
||||
let lo = -Infinity, hi = Infinity;
|
||||
for (const ly of lanes) { if (ly < c.y && ly > lo) lo = ly; if (ly >= c.y && ly < hi) hi = ly; }
|
||||
let y = Math.min(Math.max(c.y, lo + LANE_GAP), hi - LANE_GAP);
|
||||
if (lo + LANE_GAP > hi - LANE_GAP) { y = (lo + hi) / 2; laneJumps++; }
|
||||
else {
|
||||
const a0 = L ? X0 : c.x, a1 = L ? c.x : X1;
|
||||
const blockers = clusters.filter((o) => o !== c && o.x > a0 + 1 && o.x < a1 - 1);
|
||||
let best = y, bestHits = Infinity;
|
||||
for (let t = Math.max(lo + LANE_GAP, c.y - 7.5); t <= Math.min(hi - LANE_GAP, c.y + 7.5); t += 0.75) {
|
||||
const hits = blockers.filter((o) => Math.abs(o.y - t) < PIN_CLEAR).length;
|
||||
if (hits < bestHits || (hits === bestHits && Math.abs(t - c.y) < Math.abs(best - c.y))) { best = t; bestHits = hits; }
|
||||
}
|
||||
if (bestHits !== Infinity) y = best;
|
||||
}
|
||||
c.lane = y; lanes.push(y);
|
||||
}
|
||||
}
|
||||
// BLOCKS, stacked top to bottom in lane order, each as near its lane as the rows allow.
|
||||
const rowsAvail = Math.floor((BY1 - BY0) / ROW);
|
||||
let leadersS = '', labelsS = '', dotsS = '', overflow = 0;
|
||||
const placed = [];
|
||||
for (const sd of ['L', 'R']) {
|
||||
const L = sd === 'L';
|
||||
const blocks = clusters.filter((c) => c.side === sd).sort((a, b) => a.lane - b.lane)
|
||||
.map((c) => ({ c, n: c.items.length, want: (c.lane - BY0) / ROW }));
|
||||
const pos = []; let cur = 0;
|
||||
for (const b of blocks) { const q = Math.max(cur, Math.round(b.want - b.n / 2)); pos.push(q); cur = q + b.n; }
|
||||
let over = cur - rowsAvail;
|
||||
for (let k = pos.length - 1; k >= 0 && over > 0; k--) {
|
||||
const lim = k ? pos[k - 1] + blocks[k - 1].n : 0, can = Math.min(over, pos[k] - lim);
|
||||
for (let j = k; j < pos.length; j++) pos[j] -= can;
|
||||
over -= can;
|
||||
}
|
||||
overflow += Math.max(0, over);
|
||||
const edgeX = L ? X0 : X1, portX = L ? X0 - FAN : X1 + FAN;
|
||||
const innerX = L ? portX - 6 : portX + 6, outerX = L ? innerX - COLW : innerX + COLW;
|
||||
blocks.forEach((b, k) => {
|
||||
const c = b.c, r0 = pos[k], midY = BY0 + (r0 + b.n / 2) * ROW;
|
||||
leadersS += `<path class="slead" d="M${c.x.toFixed(1)} ${c.y.toFixed(1)}L${c.x.toFixed(1)} ${c.lane.toFixed(2)}L${edgeX.toFixed(1)} ${c.lane.toFixed(2)}L${portX.toFixed(1)} ${midY.toFixed(1)}"/>`;
|
||||
if (b.n > 1) leadersS += `<path class="sbr" d="M${portX.toFixed(1)} ${(BY0 + r0 * ROW + 1).toFixed(1)}V${(BY0 + (r0 + b.n) * ROW - 1).toFixed(1)}"/>`;
|
||||
dotsS += `<circle class="spin" cx="${c.x.toFixed(1)}" cy="${c.y.toFixed(1)}" r="${b.n > 1 ? 4.4 : 3.2}"/>`;
|
||||
c.items.forEach((g, j) => {
|
||||
const r = r0 + j, rowY = BY0 + (r + 0.5) * ROW, x = r % 2 === 0 ? innerX : outerX;
|
||||
if (r % 2 === 1) leadersS += `<path class="srow" d="M${innerX.toFixed(1)} ${rowY.toFixed(1)}H${(L ? outerX + 3 : outerX - 3).toFixed(1)}"/>`;
|
||||
labelsS += `<text class="snm" x="${x.toFixed(1)}" y="${(rowY + font * 0.34).toFixed(1)}" font-size="${font.toFixed(2)}"${L ? ' text-anchor="end"' : ''}>${esc(String(g.name))}</text>`;
|
||||
const ph = photoOf(g);
|
||||
if (ph) {
|
||||
const tw = textW(String(g.name), font) * 1.06;
|
||||
const px = L ? x - tw - PHOTO_GAP - PHOTO : x + tw + PHOTO_GAP;
|
||||
labelsS += `<image href="${esc(ph)}" x="${px.toFixed(1)}" y="${(rowY - PHOTO / 2).toFixed(1)}" width="${PHOTO}" height="${PHOTO}" preserveAspectRatio="xMidYMid slice"/>`;
|
||||
}
|
||||
placed.push({ g, side: sd, r, photo: !!ph });
|
||||
});
|
||||
});
|
||||
}
|
||||
// MEASURED, not assumed: leader crossings (every segment pair of different leaders).
|
||||
const polys = [...leadersS.matchAll(/class="slead" d="M([^"]+)"/g)].map((m) => m[1].split('L').map((q) => q.trim().split(' ').map(Number)));
|
||||
const segsOf = polys.map((pl) => pl.slice(1).map((q, k) => [pl[k], q]));
|
||||
const sx = (p, q, r) => (r[1] - p[1]) * (q[0] - p[0]) - (q[1] - p[1]) * (r[0] - p[0]);
|
||||
const segX = (a, b) => (sx(a[0], a[1], b[0]) > 0) !== (sx(a[0], a[1], b[1]) > 0) && (sx(b[0], b[1], a[0]) > 0) !== (sx(b[0], b[1], a[1]) > 0);
|
||||
let crossingsS = 0;
|
||||
for (let i = 0; i < segsOf.length; i++) for (let j = i + 1; j < segsOf.length; j++) if (segsOf[i].some((a) => segsOf[j].some((b) => segX(a, b)))) crossingsS++;
|
||||
const stats = { names: placed.length, pins: pins.length, clusters: clusters.length, crossings: crossingsS, overflowRows: overflow,
|
||||
laneJumps, photos: placed.filter((p) => p.photo).length, namePt: +(font / PXI * 72).toFixed(1), photoMm: FMT.photos ? +(PHOTO / PXI * 25.4).toFixed(1) : 0 };
|
||||
return { leaders: leadersS, labels: labelsS, pinDots: dotsS, crossings: crossingsS, pins, stats };
|
||||
}
|
||||
|
||||
const laid = FMT.sideLanes ? [] : layoutLabels(settled, proj);
|
||||
const sides = FMT.sideLanes ? sideLanesLayout(settled) : null;
|
||||
// What the city layer must keep clear of: the drawn pins (laid out, or true positions on a side-lane sheet).
|
||||
const avoidPins = sides ? sides.pins : laid;
|
||||
|
||||
// ── CAPITALS + MAJOR CITIES ────────────────────────────────────────────────
|
||||
// Orientation marks, deliberately SUBORDINATE to the people-group pins. Imhof's
|
||||
|
|
@ -1365,7 +1503,7 @@ async function renderPoster(zone, pins, zoneFC) {
|
|||
// projected point is not where the badge is drawn. Measuring against the
|
||||
// source coordinate let Saravan's label land 1.4px from a badge while the
|
||||
// guard believed it was clear. This block therefore runs AFTER layoutLabels.
|
||||
for (const pin of laid) {
|
||||
for (const pin of avoidPins) {
|
||||
const px = pin.px, py = pin.py;
|
||||
const dx = px < x0 ? x0 - px : (px > x1 ? px - x1 : 0);
|
||||
const dy = py < yTop ? yTop - py : (py > yBot ? py - yBot : 0);
|
||||
|
|
@ -1695,8 +1833,9 @@ async function renderPoster(zone, pins, zoneFC) {
|
|||
const tx = it.laneX + it.dirOut * 5;
|
||||
labels += `<text x="${tx.toFixed(1)}" y="${(it.labelY + it.font * 0.34).toFixed(1)}" text-anchor="${it.tanchor}" font-size="${it.font.toFixed(1)}" class="lbl">${esc(it.name)}</text>`;
|
||||
});
|
||||
if (sides) { leaders += sides.leaders; pinDots += sides.pinDots; labels += sides.labels; }
|
||||
if (!octiOK) console.warn(` ⚠ non-octilinear segment in ${zone.zoneKey}`);
|
||||
const crossings = countPolyCrossings(routes);
|
||||
const crossings = sides ? sides.crossings : countPolyCrossings(routes);
|
||||
const diagFrac = bestD;
|
||||
|
||||
// ── TITLE vs SUBTITLE ──────────────────────────────────────────────────────
|
||||
|
|
@ -2676,8 +2815,8 @@ async function renderPoster(zone, pins, zoneFC) {
|
|||
page. Collapse the flow height to the scaled height. */
|
||||
html,body{width:${(FMT.pageWidthIn * 96).toFixed(0)}px;height:${(FMT.pageWidthIn * 96 * FMT.h / FMT.w).toFixed(0)}px;overflow:hidden}
|
||||
}
|
||||
</style></head><body><div class="stage">${svg}</div></body></html>`;
|
||||
return { html, title, octiOK, crossings, diagFrac };
|
||||
${FMT.sideLanes ? SIDE_LANES_CSS + '\n' : ''}</style></head><body><div class="stage">${svg}</div></body></html>`;
|
||||
return sides ? { html, title, octiOK, crossings, diagFrac, sideLanes: sides.stats } : { html, title, octiOK, crossings, diagFrac };
|
||||
}
|
||||
|
||||
return { renderPoster, CITIES, COUNTRIES, FILE_SUFFIX, FMT_KEY, FMT, MAP_ONLY, FORMATS };
|
||||
|
|
|
|||
|
|
@ -240,8 +240,13 @@ export function countryRegionColor(region) {
|
|||
return `hsl(${Math.round((i * 360) / POSTER_REGIONS.length)}, 72%, 56%)`
|
||||
}
|
||||
|
||||
export function countryZone({ id, iso3, label, count, region }) {
|
||||
return { id, zoneKey: id, label, count, color: countryRegionColor(region), adm0: [iso3], selector: { type: 'country_codes', values: [iso3] } }
|
||||
export function countryZone({ id, iso3, label, count, region, adminAreas }) {
|
||||
return {
|
||||
id, zoneKey: id, label, count, color: countryRegionColor(region), adm0: [iso3], selector: { type: 'country_codes', values: [iso3] },
|
||||
// The facts row's "Administrative Areas" cell, when the caller knows it (India: "36 states and union territories");
|
||||
// without it the cell falls back to the country's name, as every country sheet has drawn it so far.
|
||||
...(adminAreas ? { provinces: [adminAreas] } : {}),
|
||||
}
|
||||
}
|
||||
export function countryGeometry(zone, adm0, hasGroups) {
|
||||
const fc = { type: 'FeatureCollection', features: [] }
|
||||
|
|
@ -259,7 +264,9 @@ export function pinsFrom(records) {
|
|||
for (const r of records || []) {
|
||||
const lat = Number(r.latitude), lng = Number(r.longitude)
|
||||
if (!isFinite(lat) || !isFinite(lng)) continue
|
||||
out.push({ name: r.name || r.slug || '', lat, lng, slug: r.slug, country_code: r.country_code, population: r.population })
|
||||
// image_url rides along for the large side-lane sheets, which put a real photo beside each name; every other
|
||||
// format ignores it, so their sheets are unchanged.
|
||||
out.push({ name: r.name || r.slug || '', lat, lng, slug: r.slug, country_code: r.country_code, population: r.population, image_url: r.image_url })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
|
|||
58
library/utils/languageRequest.js
Normal file
58
library/utils/languageRequest.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* languageRequest.js — how a shell asks its HOST PAGE before it changes language.
|
||||
*
|
||||
* A shell (the Research Gallery, Posterverse) shows its own language menu, but the page around it may
|
||||
* own the language: DX Life keeps it in the URL path (/ar/deep-research), and a shell that switched on
|
||||
* its own would fight the page. So a pick is a REQUEST, sent as one native event on the shell's host
|
||||
* element:
|
||||
*
|
||||
* doxa-language-request detail: { language: 'ar' } bubbles, composed, cancelable
|
||||
*
|
||||
* - A host that owns the language calls preventDefault() and changes it itself (it then sets the
|
||||
* shell's `language` attribute). dispatchEvent returns false, and the shell does NOT switch.
|
||||
* - Nobody cancels (a standalone leaf, or a partner page that set `language="es"` once and never
|
||||
* listens): dispatchEvent returns true, and the shell switches itself. That is why the rule keys on
|
||||
* the cancel and not on whether `language` was passed: a static attribute would otherwise leave a
|
||||
* dropdown that does nothing.
|
||||
*
|
||||
* ★ A NATIVE CustomEvent, NOT Vue's emit. Vue's custom-element emit wraps detail in an ARRAY
|
||||
* (detail[0]) and is never cancelable, so the host could neither read detail.language nor say no.
|
||||
*
|
||||
* The host is found the way library/utils/hostElement.js finds it (one hop out of the shadow root);
|
||||
* that helper takes a Vue instance, this takes the DOM node the pick happened on.
|
||||
*/
|
||||
|
||||
export const LANGUAGE_REQUEST_EVENT = 'doxa-language-request'
|
||||
|
||||
/** The custom element a node is rendered inside, or the node itself outside any shadow root. */
|
||||
export function hostOf(el) {
|
||||
try { const rn = el && el.getRootNode && el.getRootNode(); if (rn && rn.host) return rn.host } catch (_) {}
|
||||
return el || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the host page for `code`. Returns true when NOBODY cancelled (the shell should switch itself),
|
||||
* false when a host took the request (the shell waits for the host to set `language`).
|
||||
*/
|
||||
export function requestLanguage(fromEl, code) {
|
||||
const host = hostOf(fromEl)
|
||||
if (!host || typeof host.dispatchEvent !== 'function') return true
|
||||
return host.dispatchEvent(new CustomEvent(LANGUAGE_REQUEST_EVENT, {
|
||||
detail: { language: code }, bubbles: true, composed: true, cancelable: true,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an uncancelled pick in ?lang=, ONLY on a page this tree generated (window.MAP_OWNS_PAGE ===
|
||||
* true, stamped by internal/generate-tree.mjs and honoured by library/i18n). A host's URL is the host's:
|
||||
* a shell embedded anywhere else never writes it. Returns whether it wrote.
|
||||
*/
|
||||
export function writeOwnedLangParam(code, win = (typeof window !== 'undefined' ? window : null)) {
|
||||
try {
|
||||
if (!win || win.MAP_OWNS_PAGE !== true || !win.history || !win.location) return false
|
||||
const url = new URL(win.location.href)
|
||||
url.searchParams.set('lang', code)
|
||||
win.history.replaceState(win.history.state, '', url.toString())
|
||||
return true
|
||||
} catch (_) { return false }
|
||||
}
|
||||
24
package.json
24
package.json
|
|
@ -54,6 +54,8 @@
|
|||
"check:coverage-consistent:poison": "node internal/check-gospel-coverage-consistent.mjs --poison",
|
||||
"check:csv-shape": "node internal/check-csv-export-shape.mjs",
|
||||
"check:csv-shape:self-test": "node internal/check-csv-export-shape.mjs --self-test",
|
||||
"check:poster-sizes:self-test": "node internal/check-poster-sizes.mjs --self-test",
|
||||
"check:poster-sizes": "node internal/check-poster-sizes.mjs",
|
||||
"check:pin-hint-floor": "node internal/check-pin-hint-floor.mjs",
|
||||
"check:pin-hint-floor:self-test": "node internal/check-pin-hint-floor.mjs --self-test",
|
||||
"check:no-fleet-names": "node internal/check-no-fleet-names.mjs",
|
||||
|
|
@ -226,6 +228,7 @@
|
|||
"check:packet-a1-structure": "node internal/check-packet-a1-structure.mjs",
|
||||
"check:india-states": "node internal/check-india-states.mjs",
|
||||
"check:batch-selection-export": "node internal/check-batch-selection-export.mjs",
|
||||
"check:upg-count": "node internal/check-upg-count.mjs",
|
||||
"check:zone-title-tier-a": "node internal/check-zone-title-tier-a.mjs",
|
||||
"check:evangelical-share": "node internal/check-evangelical-share.mjs",
|
||||
"check:map-labels": "node internal/check-map-labels.mjs",
|
||||
|
|
@ -346,8 +349,8 @@
|
|||
"dev": "bun x vite",
|
||||
"fix:sri": "node internal/fix-sri.mjs",
|
||||
"gallery": "node internal/generate-gallery.mjs",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check: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",
|
||||
"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: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: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-design-catalogue check:wall-photo-weight check:no-fleet-names check:pin-hint-floor",
|
||||
"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",
|
||||
"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: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: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-design-catalogue check:wall-photo-weight check:no-fleet-names check:pin-hint-floor check:poster-sizes check:upg-count check:shell-header",
|
||||
"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",
|
||||
|
|
@ -409,7 +412,13 @@
|
|||
"pin:poster-render": "node library/scripts/posters/pin-poster-render.mjs",
|
||||
"check:board-map-options": "node internal/check-board-map-options.mjs",
|
||||
"check:panel-sheets": "node internal/check-panel-sheets.mjs",
|
||||
"check:poster-wall-host-seams": "node internal/check-poster-wall-host-seams.mjs"
|
||||
"check:poster-wall-host-seams": "node internal/check-poster-wall-host-seams.mjs",
|
||||
"check:shell-header:self-test": "node internal/check-shell-header.mjs --self-test",
|
||||
"check:shell-header": "node internal/check-shell-header.mjs",
|
||||
"check:side-lanes": "node plugin-designs/country-posters/scripts/check-side-lanes.mjs",
|
||||
"check:side-lanes:self-test": "node plugin-designs/country-posters/scripts/check-side-lanes.mjs --self-test",
|
||||
"check:side-lane-photo-pixels": "node plugin-designs/country-posters/scripts/check-side-lane-photo-pixels.mjs",
|
||||
"check:side-lane-photo-pixels:self-test": "node plugin-designs/country-posters/scripts/check-side-lane-photo-pixels.mjs --self-test"
|
||||
},
|
||||
"//scripts": {
|
||||
"_": "What each script does. These notes used to sit inside the command strings as trailing \"# …\" comments, which made the shell discard any arguments passed after \"--\" (so a gate given --url silently measured its hardcoded default port — possibly another agent's server). Keep notes here, never in a command.",
|
||||
|
|
@ -456,6 +465,7 @@
|
|||
"check:country-sheet-one-path:self-test": "plants each defect check:country-sheet-one-path guards (a direct generateCountrySheet call, makeCountrySheet without countries, the old guard that skipped India, a position given to an absent code) and requires each to FAIL; a comment naming generateCountrySheet must NOT.",
|
||||
"check:coverage-consistent": "the gospel-resources surfaces must agree: no resource on an ISO RESERVED code (und/mul/mis/zxx), every resourced code NAMED, table reach recounted off the pins, no invented URL, tiers reconcile. Static, sub-second. Self-test: `bun run check:coverage-consistent:poison`, all 12 clauses (A-L) must fail. Clause L is the one that is not about consistency: it fails when the link-verification record goes stale, because 'link checked' is a claim about the present tense. Written after the same drift defect was found by hand three times.",
|
||||
"check:csv-shape": "the export must OPEN as a table: header on line 0, every row matching. Run the `check:csv-shape:self-test` script for the NEGATIVE test (forces the provenance comment back to line 0 IN MEMORY and requires this gate to REJECT it) — the header used to claim 'NEGATIVE-TESTED' with no way to re-run it.",
|
||||
"check:poster-sizes": "the shared poster-size ladder (library/constants/posterSizes.js) is right: picker order = ladder, dot-free keys, portrait only, the sheets glue up to the finished size, photo slots on exactly 66x66 + 60x96, paper prints and the rest go to a print-shop PDF, every alias resolves, paperFor() picks Letter or A4 by region. Run the `check:poster-sizes:self-test` script for the NEGATIVE test",
|
||||
"check:pin-hint-floor": "the pin-hint pill sits at the map's bottom on a page that scrolls (the fold is not an obstacle there) and is still lifted above the fold on a full-screen page; the legend sheet and the licence credit still lift it; PinHint.vue takes its floor from pinHintFloor.js. Run the `check:pin-hint-floor:self-test` script for the NEGATIVE test",
|
||||
"check:no-fleet-names": "tracked source (not data/, public/ or the translation catalogues) names no build-team member, attributes no request to a user and carries no dated QA-round tag; an allowance is a named LINE, never a whole file. Run the `check:no-fleet-names:self-test` script for the NEGATIVE test",
|
||||
"check:dashboard-filter-a11y": "the dashboard's cross-filter is operable and audible: every filter chip is a <button type=\"button\"> with an aria-label naming the ACTION (a filter that can be set by keyboard must be clearable by keyboard), the shell carries exactly ONE always-rendered polite role=\"status\" region so a brush that changes a number far from the control is announced once rather than six times or not at all, and no resize handle's accessible name is a raw English literal. AST over the parsed templates plus the built bundle — no browser.",
|
||||
|
|
@ -563,6 +573,7 @@
|
|||
"check:packet-a1-structure": "the prayer packet is built to its specified structure, read page by page from each sheet's CONTENT: full detail = map, triangle, life calendar, % evangelical, profiles, bounded set, centered set, wheel, points; lightweight = map, triangle, profiles, wheel, points. Page 1 places a map-only vertical sheet on the packet's own margin and says it is one of the UPG 100 prayer lists only when a count is supplied; page 2 is one inverted triangle whose packets tier and \"- N\" steps appear only where true; the wheel carries the Metacamp Module 1 PDF's colours and opens at nine o'clock; all 28 legend zones resolve, Deaf included.",
|
||||
"check:india-states": "India's states-and-union-territories table (data/people-groups/india-states.json) stays true to the stamping it is derived from: all 36 ISO 3166-2:IN divisions (28 state + 8 union_territory, Nagaland + Mizoram listed even when empty), every India people group of admin-membership.json in exactly ONE division, every shapeName a feature of the served geometry, NO DRIFT (re-derived from today's inputs it is byte-identical and meta's input sha384s are the files on disk), and pin == table == served copy under a size ceiling. Mutation-tested. Headless; in gates:pre. By the UPG-100 owner.",
|
||||
"check:batch-selection-export": "UPG-100's batch selection EXPORTS its selection (restored 2026-09-30): Copy's first line parses in data/apply-grouping.mjs (names, UUPG total, colour) with the people-group CSV after it; the CSV header, quoting and formula-neutralising hold under a real CSV parse; the HUD wires the colour swatches, hex input, Copy, Export CSV and the records prefetch; the 9 new strings are in all 11 locales, translated. Mutation-tested (CSV first, no formula guard, Copy unwired: each FAILS). Headless; in gates:pre. By the UPG-100 owner.",
|
||||
"check:upg-count": "UPG-100 never writes its list's TOTAL into a sentence (the wall intro printed a stale 2,106 in eleven languages): the total comes from the funnel's last step (data/upg-total.js), no upgMap value in any locale carries it or 2,106 in any digit system (Latin, Arabic-Indic, Devanagari; a built-in negative control plants it), upgMap.wallIntroA takes {n} ×11, and the wall intro + the fetching status pass the live total. Mutation-tested. Headless; in gates:pre. By the UPG-100 owner.",
|
||||
"check:tree-relative": "the committed tree names no host of its own. With embedBaseUrl empty in the build config, the manifest must say embedBase \".\" and every scriptUrl, iframeUrl and embed snippet src must be relative. check:drop cannot see this: it looks for root-relative \"/...\" references and skips https:// ones, so a tree built with EMBED_BASE_URL (a deploy build) would pass it and pin every snippet to one host, the way the retired CDN route broke. Data addresses (runtime.apiBaseUrl) are not checked. SKIPs when the config sets embedBaseUrl. Static; takes an optional manifest path for poison tests.",
|
||||
"check:universal-props": "every plugin reads the props the Plugin Studio header sets for all of them through the library: map engine, locale, Mapbox token and token URL, and the people-group list. It reads each bundle's source AND its shipped bundle and fails on a bypass (a mapbox:// style literal, a private token global, a list fetched around the library); a by-design exception is ALLOWed by name with its reason. --report also prints the matrix. Headless.",
|
||||
"check:zone-title-tier-a": "generated zone titles ship for ONE-country zones only: the 3 such zones produce a localized, non-code title in en/es/ar that differs across languages, AND every other zone declines so its hand-written label survives unchanged (a multi-country zone has a name that is not a list of its members). Two-sided, and NOT MEASURED when the country-name supplier cannot resolve a known code.",
|
||||
|
|
@ -670,7 +681,12 @@
|
|||
"check:popup-pan-controls": "the popup auto-pan reserves the map's bottom control band, so a popup that clears the CLIP is also clear of the things that take the click. Measured at 390 on a 366x732 map: the pan fitted the popup inside the container, cut 0, and a real click on Full Profile still did nothing because the top element at the button's centre was div.maplibregl-ctrl-attrib-inner (and span.pin-hint-pill on the sibling pin) - a cut-height check cannot see this, because the cut really was zero. Attribution is a licence term of the basemap, so the popup may not be raised over it; the usable frame is shorter than the container and the fit test says so. Runs the SHIPPED panPlanFor, read out of the SFC with new Function (it is not exported), never a re-typed copy, and asserts where the popup ENDS UP rather than re-deriving the expected number from the same formula. Sweeps band heights 0-120px and every width the component renders at. Source-only, headless.",
|
||||
"check:control-inset": "bottomControlInset MEASURES the map's real bottom control band, so the pan reserves the band the controls actually occupy. check:popup-pan-controls proves the ARITHMETIC; this proves the band is real. If the inset silently returned 0 -- a selector typo, an engine renaming its control classes, a control moving outside the container -- panPlanFor would get bottomInset 0, behave exactly as it did before the fix, and check:popup-pan-controls would still pass every case, because its arithmetic is still correct: the bug back with both gates green. 8 cases against a real DOM, running the SHIPPED bottomControlInset read out of the SFC (it is not exported), never a copy: the band equals what the DOM independently says (not merely non-zero); no controls reserves 0; a zero-sized control reserves nothing; the taller of two corners wins; a wrapped attribution reserves more than one line; a control outside the container is ignored; a null container returns 0; and the MAPBOX corners count too, since the component takes a provider prop and every other case renders MapLibre names. Needs a browser: every assertion rests on getBoundingClientRect. 75 = no slot, never a pass.",
|
||||
"check:panel-sheets": "the shared panel geometry (library/components/poster/panelSheets.js) for posters printed on several sheets: 60x96 = 3 × 60×36 stacked and 66x66 = 2 × 36×66 side by side, 6 in joins, and a single sheet; the sheets tile the finished poster, glue slices tile and bleed slices repeat exactly the join, a bleed join prints a guide not a solid band, a screen preview always shows the red band, and the Bless 5x8ft takes its bands and marks from the module. Two-sided (4 planted defects). Headless.",
|
||||
"check:poster-wall-host-seams": "PosterWall's two host seams, and that with neither set every wall behaves as before. headerAction: default null is the Show-on-map TOGGLE (aria-pressed, one window event per click, is-last, no emit); { labelKey, ariaKey } is a plain button that emits header-action with the item, no window event, no aria-pressed, and reads the label alone for an item with no count. previewSrc: default null is the stored previews/<id>.webp and no IntersectionObserver; set, a card renders only once in view of the wall's own scroller, one render at a time, cached per page, and null / a throw / a timeout ends on the preview-unavailable placeholder. Image errors: a default wall retries the tree's own copy before the placeholder; a generated preview goes straight to it. Section heads: a section with people reads \"<n> people groups · <items>\" exactly as before; one with none reads its items alone. sectionItemCountKey lets a wall count one section in its own words ({ key, n }); unnamed sections keep the default. Headless: PosterWall compiled as a client component on a recording renderer; real onClick and a recording IntersectionObserver. 17 mutants each FAIL."
|
||||
"check:poster-wall-host-seams": "PosterWall's two host seams, and that with neither set every wall behaves as before. headerAction: default null is the Show-on-map TOGGLE (aria-pressed, one window event per click, is-last, no emit); { labelKey, ariaKey } is a plain button that emits header-action with the item, no window event, no aria-pressed, and reads the label alone for an item with no count. previewSrc: default null is the stored previews/<id>.webp and no IntersectionObserver; set, a card renders only once in view of the wall's own scroller, one render at a time, cached per page, and null / a throw / a timeout ends on the preview-unavailable placeholder. Image errors: a default wall retries the tree's own copy before the placeholder; a generated preview goes straight to it. Section heads: a section with people reads \"<n> people groups · <items>\" exactly as before; one with none reads its items alone. sectionItemCountKey lets a wall count one section in its own words ({ key, n }); unnamed sections keep the default. Headless: PosterWall compiled as a client component on a recording renderer; real onClick and a recording IntersectionObserver. 17 mutants each FAIL.",
|
||||
"check:shell-header": "a header-off shell has ONE way to its tabs and ONE language menu, in the right place for the width: VerticalTabsLayout renders the phone bar (☰ + #phone-end) only when narrow AND header-off, #rail-end only when wide AND header-off, and no corner circle; ShellLanguageMenu's pick dispatches exactly one cancelable, composed, bubbling doxa-language-request (detail.language), does not switch when a host cancels, and writes ?lang= only on a tree-owned page; every switcher language has a catalogue and the current language is always listed. Headless (Vite SSR). Run the `check:shell-header:self-test` script for the NEGATIVE test (11 planted defects)",
|
||||
"check:side-lanes": "the large side-lane sheets (60x96, 66x66, 36x60) through the real poster-render.js, on a SEEDED worst case (855 groups, stacked duplicates, tight clusters, real and placeholder photos): every name drawn exactly once, leader crossings 0 (re-measured from the SVG, and equal to the renderer's own count), no overflow rows, photos only where a real one exists and the format allows them.",
|
||||
"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:side-lane-photo-pixels": "BROWSER gate (browser seat; elsewhere 75 NOT MEASURED): the 60x96 side-lane sheet rasterised by poster-raster.js, measured in pixels at every photo slot. A data: photo draws; every photo on its own host stays flat, never the broken-image glyph an external <image> rasterises as.",
|
||||
"check:side-lane-photo-pixels:self-test": "the same raster WITHOUT stripExternalImages must leave the external photo slots NOT flat (the glyph), else the gate proves nothing."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/android": "^7.6.9",
|
||||
|
|
|
|||
7
plugin-designs/bible-posters/bible-posters.meta.json
Normal file
7
plugin-designs/bible-posters/bible-posters.meta.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"stage": "experimental",
|
||||
"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."
|
||||
}
|
||||
194
plugin-designs/bible-posters/bible-posters.vue
Normal file
194
plugin-designs/bible-posters/bible-posters.vue
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
<script setup>
|
||||
/**
|
||||
* bible-posters.vue — the Bible Posters generator (Posterverse → Experimental).
|
||||
*
|
||||
* 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 (bible.helloao.org); no Bibles are stored.
|
||||
* v1 offers STRICT PUBLIC-DOMAIN translations only (the licence gate); the layout engine + credit line
|
||||
* are in src/. Print goes through the shared in-place printer (works for a human click and an agent).
|
||||
*
|
||||
* @description A whole book of the Bible on one poster, in justified columns under a bold title, from a public-domain text.
|
||||
* @profile bible-posters
|
||||
* @bundle bible-posters
|
||||
* @element bible-posters
|
||||
*/
|
||||
import { inject, ref, reactive, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { printDocument } from '@map/composables/printInPlace.js'
|
||||
import { PACKET_BOOKS, booksForTranslation } from './src/books.js'
|
||||
import { TRANSLATIONS, TRANSLATION_BY_ID, DEFAULT_TRANSLATION_ID } from './src/translations.js'
|
||||
import { BIBLE_POSTER_SIZES, SIZE_BY_ID, DEFAULT_SIZE_ID } from './src/sizes.js'
|
||||
import { fetchBooks, fetchBook } from './src/bibleSource.js'
|
||||
import { planBiblePoster, buildBiblePosterHtml, screenBiblePoster, MIN_READABLE_PT } from './src/biblePoster.js'
|
||||
|
||||
const { t } = useI18n()
|
||||
const profileConfig = inject('profileConfig', null)
|
||||
const cfg = (typeof profileConfig === 'object' && profileConfig) || {}
|
||||
|
||||
const translationId = ref(cfg.translation || DEFAULT_TRANSLATION_ID)
|
||||
const bookId = ref(cfg.book || 'JHN')
|
||||
const sizeId = ref(cfg.size || DEFAULT_SIZE_ID)
|
||||
|
||||
const availableBookIds = ref(PACKET_BOOKS.map((b) => b.id)) // narrows per translation (OT books need OT)
|
||||
const book = ref(null) // the fetched, normalized book
|
||||
const loading = ref(false)
|
||||
const progress = reactive({ done: 0, total: 0 })
|
||||
const errorKey = ref('')
|
||||
let inflight = null
|
||||
|
||||
const translation = computed(() => TRANSLATION_BY_ID[translationId.value] || TRANSLATIONS[0])
|
||||
const size = computed(() => SIZE_BY_ID[sizeId.value] || BIBLE_POSTER_SIZES[0])
|
||||
const offeredBooks = computed(() => booksForTranslation(availableBookIds.value))
|
||||
|
||||
// The layout plan + the fit hint. `book` may be null while loading; guard.
|
||||
const plan = computed(() => (book.value ? planBiblePoster(size.value, book.value) : null))
|
||||
const screen = computed(() => {
|
||||
if (!book.value) return null
|
||||
return screenBiblePoster(book.value, size.value, { credit: translation.value.credit, dir: translation.value.dir, lang: translation.value.bcp47 })
|
||||
})
|
||||
|
||||
// Which of the ladder's sizes fit this book, for the "which size" guidance.
|
||||
const ladder = computed(() => {
|
||||
if (!book.value) return []
|
||||
return BIBLE_POSTER_SIZES.map((s) => {
|
||||
const p = planBiblePoster({ widthIn: s.widthIn, heightIn: s.heightIn }, book.value)
|
||||
return { id: s.id, label: s.label, fits: p.fits, fontPt: p.fontPt }
|
||||
})
|
||||
})
|
||||
const smallestFitting = computed(() => ladder.value.find((l) => l.fits) || null)
|
||||
|
||||
async function loadBooksList() {
|
||||
try {
|
||||
const list = await fetchBooks(translationId.value)
|
||||
availableBookIds.value = list.map((b) => b.id)
|
||||
if (!availableBookIds.value.includes(bookId.value)) {
|
||||
const firstNt = offeredBooks.value.find((b) => b.testament === 'NT') || offeredBooks.value[0]
|
||||
if (firstNt) bookId.value = firstNt.id
|
||||
}
|
||||
} catch { /* keep the full list; the book fetch reports a real failure */ }
|
||||
}
|
||||
|
||||
async function loadBook() {
|
||||
const controller = new AbortController()
|
||||
if (inflight) inflight.abort()
|
||||
inflight = controller
|
||||
loading.value = true; errorKey.value = ''; progress.done = 0; progress.total = 0
|
||||
try {
|
||||
const b = await fetchBook(translationId.value, bookId.value, {
|
||||
signal: controller.signal,
|
||||
dir: translation.value.dir,
|
||||
onProgress: (done, total) => { progress.done = done; progress.total = total },
|
||||
})
|
||||
if (controller.signal.aborted) return
|
||||
book.value = b
|
||||
} catch (e) {
|
||||
if (!controller.signal.aborted) { errorKey.value = 'biblePosters.fetchFailed'; book.value = null }
|
||||
} finally {
|
||||
if (inflight === controller) { inflight = null; loading.value = false }
|
||||
}
|
||||
}
|
||||
|
||||
function print() {
|
||||
if (!book.value) return
|
||||
const html = buildBiblePosterHtml(book.value, size.value, { credit: translation.value.credit, dir: translation.value.dir, lang: translation.value.bcp47 })
|
||||
printDocument(html, { labels: { print: t('biblePosters.print'), close: t('biblePosters.close') } })
|
||||
}
|
||||
|
||||
watch(translationId, async () => { await loadBooksList(); await loadBook() })
|
||||
watch(bookId, loadBook)
|
||||
onMounted(async () => { await loadBooksList(); await loadBook() })
|
||||
onBeforeUnmount(() => { if (inflight) inflight.abort() })
|
||||
|
||||
// The preview scales the poster's world (widthIn×96 px) to fit the preview box.
|
||||
const previewBox = ref(null)
|
||||
const previewScale = ref(1)
|
||||
function fitPreview() {
|
||||
const el = previewBox.value, s = screen.value
|
||||
if (!el || !s) return
|
||||
const pad = 24
|
||||
const sx = (el.clientWidth - pad) / s.worldW
|
||||
const sy = (el.clientHeight - pad) / s.worldH
|
||||
previewScale.value = Math.max(0.02, Math.min(sx, sy))
|
||||
}
|
||||
watch([screen, previewBox], fitPreview, { flush: 'post' })
|
||||
let ro = null
|
||||
onMounted(() => { ro = new ResizeObserver(fitPreview); if (previewBox.value) ro.observe(previewBox.value) })
|
||||
onBeforeUnmount(() => { if (ro) ro.disconnect() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bpx">
|
||||
<header class="bpx-bar">
|
||||
<div class="bpx-title">{{ t('biblePosters.title') }}</div>
|
||||
<div class="bpx-controls">
|
||||
<label class="bpx-field">
|
||||
<span>{{ t('biblePosters.book') }}</span>
|
||||
<select v-model="bookId">
|
||||
<option v-for="b in offeredBooks" :key="b.id" :value="b.id">{{ t('biblePosters.books.' + b.id) }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="bpx-field">
|
||||
<span>{{ t('biblePosters.language') }}</span>
|
||||
<select v-model="translationId">
|
||||
<option v-for="tr in TRANSLATIONS" :key="tr.id" :value="tr.id">{{ tr.endonym }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="bpx-field">
|
||||
<span>{{ t('biblePosters.size') }}</span>
|
||||
<select v-model="sizeId">
|
||||
<option v-for="s in BIBLE_POSTER_SIZES" :key="s.id" :value="s.id">{{ s.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" class="bpx-print" :disabled="!book || loading" @click="print">
|
||||
{{ t('biblePosters.print') }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p class="bpx-hint" v-if="plan">
|
||||
<span v-if="plan.fits">{{ t('biblePosters.fitsAt', { pt: plan.fontPt, cols: plan.cols }) }}</span>
|
||||
<span v-else class="bpx-warn">
|
||||
{{ t('biblePosters.tooSmall') }}
|
||||
<template v-if="smallestFitting"> — {{ t('biblePosters.trySize', { size: smallestFitting.label }) }}</template>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div class="bpx-stage" ref="previewBox">
|
||||
<div v-if="loading" class="bpx-status">
|
||||
{{ t('biblePosters.loading') }}<span v-if="progress.total"> {{ progress.done }} / {{ progress.total }}</span>
|
||||
</div>
|
||||
<div v-else-if="errorKey" class="bpx-status bpx-warn">{{ t(errorKey) }}</div>
|
||||
<template v-else-if="screen">
|
||||
<div class="bpx-scaler" :style="{ width: screen.worldW + 'px', height: screen.worldH + 'px', transform: 'scale(' + previewScale + ')' }">
|
||||
<component :is="'style'">{{ screen.css }}</component>
|
||||
<div class="bp" :lang="screen.lang || null" :dir="screen.dir" v-html="screen.html"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<p class="bpx-credit" v-if="book">{{ translation.credit }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.bpx { display: flex; flex-direction: column; height: 100%; min-height: 0; background: #f3f3f1; color: #3b463d;
|
||||
font-family: -apple-system, 'Segoe UI', system-ui, sans-serif; }
|
||||
.bpx-bar { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; justify-content: space-between;
|
||||
padding: 12px 16px; background: #fff; border-bottom: 1px solid #d1d4cd; }
|
||||
.bpx-title { font-size: 18px; font-weight: 700; }
|
||||
.bpx-controls { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; }
|
||||
.bpx-field { display: flex; flex-direction: column; gap: 3px; font-size: 12px; color: #6d766e; }
|
||||
.bpx-field select { min-height: 36px; padding: 0 10px; border: 1px solid #d1d4cd; border-radius: 8px;
|
||||
background: #fff; color: #3b463d; font: inherit; }
|
||||
.bpx-print { min-height: 38px; padding: 0 18px; border: 0; border-radius: 8px; background: #3b463d; color: #fff;
|
||||
font: inherit; font-weight: 600; cursor: pointer; }
|
||||
.bpx-print:disabled { background: #b7bdb4; cursor: default; }
|
||||
.bpx-hint { margin: 0; padding: 8px 16px; font-size: 13px; color: #3b463d; }
|
||||
.bpx-warn { color: #9a3b2f; }
|
||||
.bpx-stage { flex: 1 1 0; min-height: 0; position: relative; overflow: hidden; display: flex;
|
||||
align-items: center; justify-content: center; padding: 12px; }
|
||||
.bpx-scaler { transform-origin: center center; box-shadow: 0 6px 24px rgba(0,0,0,.18); background: #fff; }
|
||||
.bpx-status { font-size: 14px; color: #6d766e; }
|
||||
.bpx-credit { margin: 0; padding: 8px 16px; font-size: 11px; color: #6d766e; text-align: center;
|
||||
border-top: 1px solid #d1d4cd; background: #fff; }
|
||||
</style>
|
||||
30
plugin-designs/bible-posters/index.html
Normal file
30
plugin-designs/bible-posters/index.html
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>doxa-maps · bible-posters staging</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { background: #f3f3f1; color: #3b463d; min-height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; }
|
||||
.wrap { max-width: 1200px; margin: 0 auto; padding: 24px 20px; }
|
||||
h1 { font-size: 20px; font-weight: 700; }
|
||||
.sub { color: #6d766e; font-size: 13px; margin: 6px 0 20px; }
|
||||
.frame { height: 80vh; border: 1px solid #d1d4cd; border-radius: 12px; overflow: hidden; background: #fff; }
|
||||
bible-posters { display: block; width: 100%; height: 100%; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>bible-posters · staging</h1>
|
||||
<p class="sub">An entire book of the Bible on one poster, in the languages of the unreached.
|
||||
Text is fetched at render time from public-domain sources; no Bibles are stored.</p>
|
||||
<div class="frame">
|
||||
<bible-posters profile-config='{"profile":"bible-posters"}'></bible-posters>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="./index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
32
plugin-designs/bible-posters/index.js
Normal file
32
plugin-designs/bible-posters/index.js
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/**
|
||||
* plugin-designs/bible-posters/index.js
|
||||
*
|
||||
* Bundle entry for the Bible Posters generator (Posterverse → Experimental).
|
||||
* Folder name = bundle name = tag. One flat profile: bible-posters.vue.
|
||||
* Builds to public/dx-maps/bible-posters/bible-posters.js.
|
||||
*
|
||||
* This plugin has NO map — it is a text/poster generator — so it registers no colour strategies and
|
||||
* needs no Mapbox token; it honours only the reader's LOCALE (interface language) through the provided
|
||||
* i18n instance, exactly like every other plugin.
|
||||
*
|
||||
* <script src="/dx-maps/bible-posters/bible-posters.js"></script>
|
||||
* <bible-posters profile-config='{"profile":"bible-posters","locale":"ar"}'></bible-posters>
|
||||
*/
|
||||
import { defineMapElement } from '@map/elements/defineMapElement.js'
|
||||
|
||||
const profileModules = import.meta.glob('./*.vue')
|
||||
|
||||
const BiblePostersElement = defineMapElement({
|
||||
profileModules,
|
||||
configureApp: (app, { pinia, i18n }) => {
|
||||
// defineMapElement already installs Pinia + i18n and provides the i18n instance to ProfileLoader
|
||||
// (that is how a host sets the interface language). This hook is only for extra app-level provides;
|
||||
// Bible Posters needs none.
|
||||
},
|
||||
})
|
||||
|
||||
if (typeof customElements !== 'undefined' && !customElements.get('bible-posters')) {
|
||||
customElements.define('bible-posters', BiblePostersElement)
|
||||
}
|
||||
|
||||
export default BiblePostersElement
|
||||
156
plugin-designs/bible-posters/src/biblePoster.js
Normal file
156
plugin-designs/bible-posters/src/biblePoster.js
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
/**
|
||||
* biblePoster.js — the Bible-book poster layout engine (pure: plan maths + an HTML string, no Vue).
|
||||
*
|
||||
* Modeled on wall-of-people-groups/src/blessPoster.js (the wall's engine): a `fit()` search for the
|
||||
* largest readable type size, an HTML document for the shared in-place print frame, and a screen model
|
||||
* that mirrors the print exactly ("what the wall shows is what prints"). The CONTENT differs — a book
|
||||
* is flowing text, not a card grid — so the body is CSS multi-column justified text rather than a grid.
|
||||
*
|
||||
* The approved layout: a bold BOOK-NAME header, then MULTIPLE COLUMNS about the width of a phone
|
||||
* screen, ~7-10 words per line, JUSTIFIED, the whole book visible at once. RTL scripts flow right-to-left.
|
||||
*
|
||||
* ⚠ This is a MODEL of the layout (character-advance estimates), not a measurement of the rendered page.
|
||||
* A headless plan proves the arithmetic; only a browser check proves the page. Every number the model
|
||||
* uses is named here so a rendered measurement can correct the constants.
|
||||
*/
|
||||
|
||||
const PT = 1 / 72 // one point in inches
|
||||
export const MIN_READABLE_PT = 6 // a book printed below this is grey noise on a poster
|
||||
export const MAX_PT = 16
|
||||
export const PX_IN = 96 // screen world: CSS px per inch (matches blessPoster.screenPoster)
|
||||
|
||||
// Layout targets (the approved design, made numeric — correct against a rendered measurement).
|
||||
const WORDS_PER_LINE = 8 // "~7-10 words per line"
|
||||
const AVG_WORD_CHARS = 6 // 5 letters + a space, averaged
|
||||
const CHARS_PER_LINE = WORDS_PER_LINE * AVG_WORD_CHARS // 48
|
||||
const CHAR_EM = 0.5 // average glyph advance as a fraction of the em (justified serif)
|
||||
const LINE_LH = 1.32 // body line-height
|
||||
const COL_GAP_IN = 0.28 // gap between columns
|
||||
const MARGIN_IN = 0.5 // poster margin (all sides)
|
||||
const HEAD_FRAC = 0.10 // header band as a fraction of poster height (min 0.6in)
|
||||
const CREDIT_IN = 0.32 // credit-line band height
|
||||
const CONTENT_SLACK = 1.15 // justification + word-break slack over raw character count
|
||||
|
||||
/** Column width (inches) that yields ~WORDS_PER_LINE words per line at this type size. */
|
||||
function colWidthIn(pt) { return CHARS_PER_LINE * CHAR_EM * pt * PT }
|
||||
|
||||
/**
|
||||
* Plan a book onto a poster of widthIn × heightIn. Returns the chosen columns + type size and whether
|
||||
* the whole book fits at a readable size.
|
||||
* @param {{widthIn:number,heightIn:number}} size
|
||||
* @param {{charCount:number,verseCount:number,blocks:Array}} book
|
||||
*/
|
||||
export function planBiblePoster(size, book) {
|
||||
const W = size.widthIn - 2 * MARGIN_IN
|
||||
const headH = Math.max(0.6, size.heightIn * HEAD_FRAC)
|
||||
const H = size.heightIn - 2 * MARGIN_IN - headH - CREDIT_IN
|
||||
const headingCount = (book.blocks || []).filter((b) => b.kind === 'heading').length
|
||||
// effective characters the body must hold: text + verse-number glyphs + a line's worth per heading
|
||||
const effective = Math.ceil(book.charCount * CONTENT_SLACK + book.verseCount * 3 + headingCount * CHARS_PER_LINE)
|
||||
|
||||
const capacityAt = (pt) => {
|
||||
const colW = colWidthIn(pt)
|
||||
const cols = Math.max(1, Math.floor((W + COL_GAP_IN) / (colW + COL_GAP_IN)))
|
||||
const lineIn = pt * LINE_LH * PT
|
||||
const linesPerCol = Math.max(0, Math.floor(H / lineIn))
|
||||
return { pt, colW, cols, linesPerCol, capacity: cols * linesPerCol * CHARS_PER_LINE }
|
||||
}
|
||||
|
||||
// Largest type size (0.5pt steps) whose capacity holds the whole book.
|
||||
let chosen = null
|
||||
for (let pt = MAX_PT; pt >= MIN_READABLE_PT - 0.001; pt -= 0.5) {
|
||||
const c = capacityAt(pt)
|
||||
if (c.capacity >= effective) { chosen = c; break }
|
||||
}
|
||||
const fits = !!chosen
|
||||
const plan = chosen || capacityAt(MIN_READABLE_PT) // if nothing fits, report the floor's numbers
|
||||
return {
|
||||
fits, effective,
|
||||
widthIn: size.widthIn, heightIn: size.heightIn,
|
||||
marginIn: MARGIN_IN, headHIn: headH, creditHIn: CREDIT_IN,
|
||||
contentWIn: W, contentHIn: H,
|
||||
cols: plan.cols, colWIn: plan.colW, colGapIn: COL_GAP_IN,
|
||||
fontPt: plan.pt, lineHeight: LINE_LH, linesPerCol: plan.linesPerCol,
|
||||
capacity: plan.capacity,
|
||||
}
|
||||
}
|
||||
|
||||
/** Given a book and a set of candidate sizes, the smallest that fits (for the "which size" hint). */
|
||||
export function fitLadder(sizes, book) {
|
||||
return sizes.map((s) => {
|
||||
const p = planBiblePoster({ widthIn: s.widthIn, heightIn: s.heightIn }, book)
|
||||
return { id: s.id, label: s.label, fits: p.fits, fontPt: p.fontPt, cols: p.cols }
|
||||
})
|
||||
}
|
||||
|
||||
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]))
|
||||
|
||||
/** The body HTML: headings and verses in reading order; verse numbers are bidi-isolated superscripts. */
|
||||
function bodyHtml(book) {
|
||||
let html = ''
|
||||
let lastChapter = 0
|
||||
for (const b of book.blocks) {
|
||||
if (b.kind === 'heading') { html += '<h2 class="bh">' + esc(b.text) + '</h2>'; continue }
|
||||
if (b.chapter !== lastChapter) { html += '<span class="cn">' + esc(b.chapter) + '</span>'; lastChapter = b.chapter }
|
||||
html += '<span class="v"><sup class="vn" dir="ltr">' + esc(b.number) + '</sup>' + esc(b.text) + ' </span>'
|
||||
}
|
||||
return html
|
||||
}
|
||||
|
||||
/** CSS shared by print and screen. Built by concatenation (no backticks inside — AGENT-BRIEF house rule
|
||||
* for shadow CSS; kept here for consistency and safe embedding). `unit` scales the poster to px on screen. */
|
||||
function posterCss(plan, dir, unit) {
|
||||
const u = (inches) => (inches * unit).toFixed(2) + (unit === 1 ? 'in' : 'px')
|
||||
const isRtl = dir === 'rtl'
|
||||
return ''
|
||||
+ '*{box-sizing:border-box}'
|
||||
+ '.bp{position:relative;width:' + u(plan.widthIn) + ';height:' + u(plan.heightIn) + ';'
|
||||
+ 'padding:' + u(plan.marginIn) + ';background:#fff;color:#1c1c1c;'
|
||||
+ 'font-family:Georgia,"Times New Roman",serif;direction:' + (isRtl ? 'rtl' : 'ltr') + ';'
|
||||
+ '-webkit-print-color-adjust:exact;print-color-adjust:exact;overflow:hidden}'
|
||||
+ '.bp-head{height:' + u(plan.headHIn) + ';display:flex;flex-direction:column;align-items:center;'
|
||||
+ 'justify-content:center;text-align:center;border-bottom:' + u(0.02) + ' solid #1c1c1c}'
|
||||
+ '.bp-title{margin:0;font-weight:700;line-height:1.02;font-size:' + u(plan.headHIn * 0.5) + '}'
|
||||
+ '.bp-body{height:' + u(plan.contentHIn) + ';margin-top:' + u(0.12) + ';'
|
||||
+ 'column-count:' + plan.cols + ';column-gap:' + u(plan.colGapIn) + ';column-fill:auto;'
|
||||
+ 'font-size:' + (unit === 1 ? plan.fontPt + 'pt' : (plan.fontPt * unit / 72).toFixed(2) + 'px') + ';'
|
||||
+ 'line-height:' + plan.lineHeight + ';text-align:justify;hyphens:auto;-webkit-hyphens:auto;'
|
||||
+ 'text-justify:inter-word;overflow:hidden}'
|
||||
+ '.bp-body .bh{font-weight:700;font-size:1.02em;margin:0.5em 0 0.15em;break-after:avoid;'
|
||||
+ 'font-family:-apple-system,"Segoe UI",Arial,sans-serif}'
|
||||
+ '.bp-body .cn{font-weight:700;font-size:1.5em;line-height:1;float:' + (isRtl ? 'right' : 'left') + ';'
|
||||
+ 'margin:' + (isRtl ? '0 0 0 0.12em' : '0 0.12em 0 0') + ';color:#92552a}'
|
||||
+ '.bp-body .vn{font-size:0.62em;font-weight:700;color:#92552a;vertical-align:super;'
|
||||
+ 'unicode-bidi:isolate;margin:0 0.12em}'
|
||||
+ '.bp-body .v{}'
|
||||
+ '.bp-credit{position:absolute;left:' + u(plan.marginIn) + ';right:' + u(plan.marginIn) + ';'
|
||||
+ 'bottom:' + u(plan.marginIn * 0.4) + ';height:' + u(plan.creditHIn) + ';display:flex;align-items:center;'
|
||||
+ 'justify-content:center;text-align:center;font-family:-apple-system,"Segoe UI",Arial,sans-serif;'
|
||||
+ 'font-size:' + u(0.11) + ';color:#6d766e}'
|
||||
}
|
||||
|
||||
/** The print document: one page, the whole book. `texts` supplies the credit line and optional dir/lang. */
|
||||
export function buildBiblePosterHtml(book, size, texts = {}) {
|
||||
const dir = texts.dir || book.dir || 'ltr'
|
||||
const lang = texts.lang || '' // a BCP-47 tag so hyphens:auto works and the page declares its language
|
||||
const plan = planBiblePoster(size, book)
|
||||
const css = '@page{size:' + size.widthIn + 'in ' + size.heightIn + 'in;margin:0}html,body{margin:0;padding:0}'
|
||||
+ posterCss(plan, dir, 1)
|
||||
const inner = '<div class="bp-head"><h1 class="bp-title" dir="auto">' + esc(book.name) + '</h1></div>'
|
||||
+ '<div class="bp-body" dir="auto">' + bodyHtml(book) + '</div>'
|
||||
+ '<div class="bp-credit">' + esc(texts.credit || '') + '</div>'
|
||||
return '<!doctype html><html' + (lang ? ' lang="' + esc(lang) + '"' : '') + (dir === 'rtl' ? ' dir="rtl"' : '') + '><head><meta charset="utf-8">'
|
||||
+ '<title>' + esc(book.name) + '</title><style>' + css + '</style></head>'
|
||||
+ '<body><div class="bp">' + inner + '</div></body></html>'
|
||||
}
|
||||
|
||||
/** The screen model (WYSIWYG): the same poster, scaled to CSS px. Returns { css, html, worldW, worldH }. */
|
||||
export function screenBiblePoster(book, size, texts = {}) {
|
||||
const dir = texts.dir || book.dir || 'ltr'
|
||||
const plan = planBiblePoster(size, book)
|
||||
const css = posterCss(plan, dir, PX_IN)
|
||||
const html = '<div class="bp-head"><h1 class="bp-title" dir="auto">' + esc(book.name) + '</h1></div>'
|
||||
+ '<div class="bp-body" dir="auto">' + bodyHtml(book) + '</div>'
|
||||
+ '<div class="bp-credit">' + esc(texts.credit || '') + '</div>'
|
||||
return { css, html, dir, lang: texts.lang || '', plan, worldW: size.widthIn * PX_IN, worldH: size.heightIn * PX_IN }
|
||||
}
|
||||
114
plugin-designs/bible-posters/src/bibleSource.js
Normal file
114
plugin-designs/bible-posters/src/bibleSource.js
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
/**
|
||||
* bibleSource.js — the RENDER-TIME text client for the Free Use Bible API (bible.helloao.org).
|
||||
*
|
||||
* Fetches a whole book on demand and returns a normalized structure the renderer can lay out.
|
||||
* NOTHING is written to the repo or public/: whole Bibles are never stored (the licence gate and the
|
||||
* §4 spec). A small in-memory (per-bundle, per-session) cache avoids re-fetching a book the reader
|
||||
* just looked at; it is discarded on reload.
|
||||
*
|
||||
* The API (verified 2026-09-30):
|
||||
* {base}/{translationId}/books.json → { books: [{ id, commonName, numberOfChapters, ... }] }
|
||||
* {base}/{translationId}/{BOOK}/{chapter}.json → { chapter: { content: [ {type:'verse',number,content:[...]}
|
||||
* | {type:'heading',content:[...]} ] } }
|
||||
* CORS is open (Access-Control-Allow-Origin: *) and no key is required.
|
||||
*/
|
||||
|
||||
export const API_BASE = 'https://bible.helloao.org/api'
|
||||
|
||||
const booksCache = new Map() // translationId -> [{id, name, chapters, order}]
|
||||
const bookCache = new Map() // `${translationId}/${bookId}` -> normalized book
|
||||
|
||||
async function getJson(url, signal) {
|
||||
const res = await fetch(url, { signal, headers: { accept: 'application/json' } })
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status + ' for ' + url)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
/** The translation's book table: id → localized name + chapter count. Used to build the picker and to
|
||||
* know which of the 12 packet books this translation actually has (OT books need an OT translation). */
|
||||
export async function fetchBooks(translationId, { signal } = {}) {
|
||||
if (booksCache.has(translationId)) return booksCache.get(translationId)
|
||||
const d = await getJson(`${API_BASE}/${translationId}/books.json`, signal)
|
||||
const raw = Array.isArray(d) ? d : (d.books || [])
|
||||
const books = raw.map((b) => ({
|
||||
id: b.id,
|
||||
name: b.commonName || b.name || b.id,
|
||||
chapters: b.numberOfChapters || 0,
|
||||
order: b.order || 0,
|
||||
}))
|
||||
booksCache.set(translationId, books)
|
||||
return books
|
||||
}
|
||||
|
||||
/** One verse/heading item → plain text. Verse `content` is an array of strings and, occasionally,
|
||||
* objects (poetry lines, notes). We keep the readable text and drop note apparatus. */
|
||||
function itemText(item) {
|
||||
const parts = Array.isArray(item.content) ? item.content : [item.content]
|
||||
return parts
|
||||
.map((p) => {
|
||||
if (typeof p === 'string') return p
|
||||
if (p && typeof p === 'object') return p.text || (Array.isArray(p.content) ? p.content.filter((x) => typeof x === 'string').join(' ') : '')
|
||||
return ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch an ENTIRE book: every chapter, flattened to an ordered list of blocks.
|
||||
* Returns { bookId, name, dir, chapters: N, blocks: [{kind:'heading'|'verse', chapter, number?, text}],
|
||||
* verseCount, charCount }. `onProgress(done, total)` fires per chapter.
|
||||
*/
|
||||
export async function fetchBook(translationId, bookId, { signal, onProgress, dir = 'ltr', concurrency = 4 } = {}) {
|
||||
const key = `${translationId}/${bookId}`
|
||||
if (bookCache.has(key)) { onProgress?.(1, 1); return bookCache.get(key) }
|
||||
|
||||
const books = await fetchBooks(translationId, { signal })
|
||||
const meta = books.find((b) => b.id === bookId)
|
||||
if (!meta) throw new Error(`${translationId} has no book ${bookId}`)
|
||||
const total = meta.chapters
|
||||
const chapterBlocks = new Array(total) // keep chapter order regardless of fetch order
|
||||
let done = 0
|
||||
|
||||
// One book is one atomic fetch: a chapter failure ABORTS the siblings (never a half book), while still
|
||||
// honouring the caller's own signal. An internal controller chains to it so we can abort from inside.
|
||||
const internal = new AbortController()
|
||||
const onExt = () => internal.abort()
|
||||
if (signal) { if (signal.aborted) internal.abort(); else signal.addEventListener('abort', onExt, { once: true }) }
|
||||
|
||||
async function one(ch) {
|
||||
try {
|
||||
const d = await getJson(`${API_BASE}/${translationId}/${bookId}/${ch}.json`, internal.signal)
|
||||
const content = (d.chapter || d).content || []
|
||||
const blocks = []
|
||||
for (const item of content) {
|
||||
if (item.type === 'heading') { const t = itemText(item); if (t) blocks.push({ kind: 'heading', chapter: ch, text: t }) }
|
||||
else if (item.type === 'verse') { const t = itemText(item); if (t) blocks.push({ kind: 'verse', chapter: ch, number: item.number, text: t }) }
|
||||
}
|
||||
chapterBlocks[ch - 1] = blocks
|
||||
done += 1
|
||||
onProgress?.(done, total)
|
||||
} catch (e) { internal.abort(); throw e } // stop the other workers on the first failure
|
||||
}
|
||||
|
||||
// bounded-concurrency pool over chapters 1..total
|
||||
const queue = []
|
||||
for (let ch = 1; ch <= total; ch++) queue.push(ch)
|
||||
const workers = new Array(Math.min(concurrency, total)).fill(0).map(async () => {
|
||||
while (queue.length && !internal.signal.aborted) { const ch = queue.shift(); await one(ch) }
|
||||
})
|
||||
try { await Promise.all(workers) }
|
||||
finally { if (signal) signal.removeEventListener('abort', onExt) }
|
||||
|
||||
const blocks = chapterBlocks.flat().filter(Boolean)
|
||||
let verseCount = 0, charCount = 0
|
||||
for (const b of blocks) { if (b.kind === 'verse') verseCount++; charCount += b.text.length }
|
||||
const book = { bookId, name: meta.name, dir, chapters: total, blocks, verseCount, charCount }
|
||||
bookCache.set(key, book)
|
||||
return book
|
||||
}
|
||||
|
||||
/** Test seam: clear the session caches. */
|
||||
export function _clearCache() { booksCache.clear(); bookCache.clear() }
|
||||
39
plugin-designs/bible-posters/src/books.js
Normal file
39
plugin-designs/bible-posters/src/books.js
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* books.js — the 12 books the UPG-100 prayer packet cites, in packet-citation order.
|
||||
*
|
||||
* The Bible Posters generator shows an ENTIRE BOOK on one poster. These 12 are the starting set
|
||||
* (the approved starting set): every book referenced by the en packet in
|
||||
* library/scripts/posters/packet-strings.json. The verse list is the index; the poster offers the book.
|
||||
*
|
||||
* `id` — the USFM/OSIS book id the Free Use Bible API (bible.helloao.org) uses in its URLs.
|
||||
* `en` — an English fallback label for the UI picker BEFORE the fetched translation is known;
|
||||
* the POSTER HEADER itself uses the fetched translation's own `commonName` (in the text
|
||||
* language), so a reader sees "يوحنا" / "Johannes", never the English name.
|
||||
* `testament` — 'OT' books (Genesis, Joshua) exist only in a translation that HAS an Old Testament,
|
||||
* which narrows the offered language set; the other 10 are New Testament and fetch from
|
||||
* any NT-only translation too.
|
||||
* `chaptersHint` — the chapter count for the WEB text (eng_web), used only to size a progress bar and a
|
||||
* fit pre-estimate; the real count comes from the translation's own books.json at render.
|
||||
*/
|
||||
export const PACKET_BOOKS = [
|
||||
{ id: 'ACT', en: 'Acts', testament: 'NT', chaptersHint: 28 },
|
||||
{ id: 'MAT', en: 'Matthew', testament: 'NT', chaptersHint: 28 },
|
||||
{ id: 'JHN', en: 'John', testament: 'NT', chaptersHint: 21 },
|
||||
{ id: 'MRK', en: 'Mark', testament: 'NT', chaptersHint: 16 },
|
||||
{ id: 'LUK', en: 'Luke', testament: 'NT', chaptersHint: 24 },
|
||||
{ id: 'HEB', en: 'Hebrews', testament: 'NT', chaptersHint: 13 },
|
||||
{ id: 'GEN', en: 'Genesis', testament: 'OT', chaptersHint: 50 },
|
||||
{ id: '1JN', en: '1 John', testament: 'NT', chaptersHint: 5 },
|
||||
{ id: 'COL', en: 'Colossians', testament: 'NT', chaptersHint: 4 },
|
||||
{ id: '1CO', en: '1 Corinthians', testament: 'NT', chaptersHint: 16 },
|
||||
{ id: '1PE', en: '1 Peter', testament: 'NT', chaptersHint: 5 },
|
||||
{ id: 'JOS', en: 'Joshua', testament: 'OT', chaptersHint: 24 },
|
||||
]
|
||||
|
||||
export const BOOK_BY_ID = Object.fromEntries(PACKET_BOOKS.map((b) => [b.id, b]))
|
||||
|
||||
/** Books this translation can serve: OT books need a translation whose books.json includes them. */
|
||||
export function booksForTranslation(availableBookIds) {
|
||||
const have = new Set(availableBookIds || [])
|
||||
return PACKET_BOOKS.filter((b) => have.has(b.id))
|
||||
}
|
||||
35
plugin-designs/bible-posters/src/sizes.js
Normal file
35
plugin-designs/bible-posters/src/sizes.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* sizes.js — the poster size ladder for Bible Posters, taken from the SHARED table.
|
||||
*
|
||||
* Consumes the centrally-owned ladder (library/constants/posterSizes.js, imported as
|
||||
* '@map/constants/posterSizes.js'); this plugin only SELECTS its subset of keys and keeps its own
|
||||
* locale-neutral dimension labels until the shared picker (with localized names) lands. The size table
|
||||
* is never edited here.
|
||||
*
|
||||
* A book is laid out on the FINISHED poster (width × height, portrait). `sheets` is the shared entry's
|
||||
* panel count; the multi-panel PRINT path (reusing library panelSheets.js) is a later milestone — the
|
||||
* fit MODEL uses the finished dimensions.
|
||||
*/
|
||||
import { POSTER_LADDER, resolvePosterSize } from '@map/constants/posterSizes.js'
|
||||
|
||||
// The 5 ladder keys this plugin offers (portrait; a whole book needs the large ones). 'paper' carries
|
||||
// Letter inches in the shared table; sizing it to A4 for an A4 reader (via paperFor) is a later refinement.
|
||||
const BIBLE_KEYS = ['paper', '36x36', '36x60', '66x66', '60x96']
|
||||
|
||||
// Locale-neutral DIMENSION labels (in/ft are unit symbols, like cm) so no English word leaks into a
|
||||
// non-English UI. Swap for the shared picker's localized names when it lands.
|
||||
const LABELS = {
|
||||
paper: '8.5 × 11 in',
|
||||
'36x36': '3 × 3 ft',
|
||||
'36x60': '3 × 5 ft',
|
||||
'66x66': '5.5 × 5.5 ft',
|
||||
'60x96': '5 × 8 ft',
|
||||
}
|
||||
|
||||
export const BIBLE_POSTER_SIZES = BIBLE_KEYS.map((k) => {
|
||||
const e = resolvePosterSize(k) || POSTER_LADDER[k]
|
||||
return { id: k, key: k, label: LABELS[k], widthIn: e.inches.w, heightIn: e.inches.h, sheets: e.sheets.count }
|
||||
})
|
||||
|
||||
export const SIZE_BY_ID = Object.fromEntries(BIBLE_POSTER_SIZES.map((s) => [s.id, s]))
|
||||
export const DEFAULT_SIZE_ID = '36x60'
|
||||
63
plugin-designs/bible-posters/src/translations.js
Normal file
63
plugin-designs/bible-posters/src/translations.js
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* translations.js — the LICENCE-VETTED text sources for the Bible Posters generator.
|
||||
*
|
||||
* ⚠ LICENSING GATE (this plugin's first task). A full BOOK is displayed IN FULL on a
|
||||
* printed, white-label giveaway poster, so ONLY texts whose licence permits that may ship. This v1
|
||||
* list is STRICT PUBLIC-DOMAIN ONLY — the exact translations the UPG-100 packet already ships as
|
||||
* public domain, plus the WEB seed. It is safe under EITHER answer to the open licensing question
|
||||
* (PD-only vs. a licensed/broader set), so it is what builds now.
|
||||
*
|
||||
* The CC-BY / CC-BY-SA expansion (the IRV family — Hindi, Tamil, Telugu, Punjabi — and the wider
|
||||
* UPG languages) is DEFERRED pending the open licensing decision; do not add a non-public-domain text
|
||||
* here without explicit sign-off and a per-translation licence check (eBible details + Door43),
|
||||
* never the transport's blanket "no restrictions" claim nor eBible's Redistributable flag alone.
|
||||
*
|
||||
* Transport: the Free Use Bible API (bible.helloao.org) — CORS, no key, per-chapter JSON, fetched at
|
||||
* RENDER TIME. No whole Bibles are stored in the repo or public/. Each translation carries a
|
||||
* `licenseUrl` back to its eBible record; `credit` is the poster's printed credit line.
|
||||
*
|
||||
* `iso639_3` is the text language, so the picker can offer the languages of the top unreached people
|
||||
* groups (the Gospel Resources map data keys on iso639_3) once the CC-BY-SA set is approved.
|
||||
*/
|
||||
|
||||
/** @typedef {Object} Translation
|
||||
* @property {string} id Free Use Bible API translation id (its URL segment)
|
||||
* @property {string} iso639_3 text language code
|
||||
* @property {string} language English language name (UI grouping)
|
||||
* @property {string} name the translation's English name
|
||||
* @property {'ltr'|'rtl'} dir text direction
|
||||
* @property {string} licence the licence class (v1: all 'public-domain')
|
||||
* @property {string} credit the poster credit line
|
||||
* @property {string} licenseUrl the licence record of source
|
||||
*/
|
||||
|
||||
/** v1 STRICT PUBLIC-DOMAIN seed — the packet's 6 + WEB. Every entry has all 66 books (OT+NT). */
|
||||
export const TRANSLATIONS = [
|
||||
{ id: 'eng_web', iso639_3: 'eng', bcp47: 'en', language: 'English', endonym: 'English', name: 'World English Bible',
|
||||
dir: 'ltr', licence: 'public-domain',
|
||||
credit: 'World English Bible · public domain · ebible.org · bible.helloao.org',
|
||||
licenseUrl: 'https://ebible.org/Scriptures/details.php?id=eng-web' },
|
||||
{ id: 'spa_r09', iso639_3: 'spa', bcp47: 'es', language: 'Spanish', endonym: 'Español', name: 'Reina Valera 1909',
|
||||
dir: 'ltr', licence: 'public-domain',
|
||||
credit: 'Reina Valera 1909 · dominio público · ebible.org · bible.helloao.org',
|
||||
licenseUrl: 'https://ebible.org/Scriptures/details.php?id=spaRV1909' },
|
||||
{ id: 'fra_ost', iso639_3: 'fra', bcp47: 'fr', language: 'French', endonym: 'Français', name: 'French Ostervald Bible',
|
||||
dir: 'ltr', licence: 'public-domain',
|
||||
credit: 'Bible Ostervald · domaine public · ebible.org · bible.helloao.org',
|
||||
licenseUrl: 'https://ebible.org/Scriptures/details.php?id=fra_fob' },
|
||||
{ id: 'deu_l12', iso639_3: 'deu', bcp47: 'de', language: 'German', endonym: 'Deutsch', name: 'German Luther Bible 1912',
|
||||
dir: 'ltr', licence: 'public-domain',
|
||||
credit: 'Lutherbibel 1912 · gemeinfrei · ebible.org · bible.helloao.org',
|
||||
licenseUrl: 'https://ebible.org/Scriptures/details.php?id=deu1912' },
|
||||
{ id: 'rus_syn', iso639_3: 'rus', bcp47: 'ru', language: 'Russian', endonym: 'Русский', name: 'Russian Synodal Bible',
|
||||
dir: 'ltr', licence: 'public-domain',
|
||||
credit: 'Синодальный перевод · общественное достояние · ebible.org · bible.helloao.org',
|
||||
licenseUrl: 'https://ebible.org/Scriptures/details.php?id=russyn' },
|
||||
{ id: 'arb_vdv', iso639_3: 'arb', bcp47: 'ar', language: 'Arabic', endonym: 'العربية', name: 'Arabic Van Dyck Bible',
|
||||
dir: 'rtl', licence: 'public-domain',
|
||||
credit: 'فان دايك · ملك عام · ebible.org · bible.helloao.org',
|
||||
licenseUrl: 'https://ebible.org/Scriptures/details.php?id=arb-vd' },
|
||||
]
|
||||
|
||||
export const TRANSLATION_BY_ID = Object.fromEntries(TRANSLATIONS.map((t) => [t.id, t]))
|
||||
export const DEFAULT_TRANSLATION_ID = 'eng_web'
|
||||
BIN
plugin-designs/country-posters/assets/previews/country-ind.webp
Normal file
BIN
plugin-designs/country-posters/assets/previews/country-ind.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
|
|
@ -44,7 +44,9 @@ import { getRuntimeLocale, detectLocale } from '@map/i18n/index.js'
|
|||
// library/. Generated by library/scripts/posters/emit-countries.mjs, which now writes there.
|
||||
import data from '@map/data/country-posters.json'
|
||||
import { resolveAssetsBase } from '@map/utils/embedBase.js'
|
||||
import { makeCountrySheet } from '@map/packet/countrySheet.js'
|
||||
import { makeCountrySheet, largeSheet } from '@map/packet/countrySheet.js'
|
||||
import { isPhonePrint } from '@map/composables/printInPlace.js'
|
||||
import { keyedError } from '@map/i18n/keyedError.js'
|
||||
// India's states and union territories: the shared table (fetched when the wall shows them) and their sheet.
|
||||
import { loadIndiaStates } from '@map/data/indiaStates.js'
|
||||
import { makeIndiaStateSheet, statesWithPosters } from './src/indiaStateSheet.js'
|
||||
|
|
@ -145,7 +147,15 @@ const recordsFor = createPeopleGroupRecords({
|
|||
lang: () => getRuntimeLocale() || detectLocale(),
|
||||
profileConfig,
|
||||
})
|
||||
const countryRecords = (c) => (c.indiaState
|
||||
// ★ A LARGE-SHEET COUNTRY (India: one 5x8 sheet naming all its groups; countrySheet.js's LARGE_SHEETS) is DESKTOP-ONLY:
|
||||
// its poster, list, packet and cards (about a thousand pages) are offered on a desktop or laptop, and on a phone the
|
||||
// card says so instead of failing. Same rule as the Regions list, from the same table.
|
||||
const ON_PHONE = isPhonePrint()
|
||||
const phoneBlocked = (c) => !c.indiaState && ON_PHONE && Boolean(largeSheet(c)?.desktopOnly)
|
||||
// A blocked card's list, packet and cards REJECT with the notice (never resolve [], which the print path would
|
||||
// report as "no people groups" on a card that has hundreds), before any record is fetched.
|
||||
const desktopOnly = () => Promise.reject(keyedError('poster.phoneLimitAll', 'This needs a desktop or laptop computer.'))
|
||||
const countryRecords = (c) => (phoneBlocked(c) ? desktopOnly() : c.indiaState
|
||||
? (c.slugs.length ? recordsFor({ type: 'pg_slugs', values: c.slugs }) : Promise.resolve([]))
|
||||
: recordsFor({ type: 'country', values: [c.iso3] }))
|
||||
// ★ GENERATED AT THE CLICK, through the ONE shared path (@map/packet/countrySheet.js, which the Regions
|
||||
|
|
@ -219,7 +229,7 @@ const collapsedBy = (sec) => sec.total < 60
|
|||
// unsupportedReason is an English note (and a flag), not display text. The card already states the
|
||||
// count above it, so the sentence needs no number and no plural agreement.
|
||||
// A requested country with no poster at all says so in regions-list.vue's words (the regions catalogue).
|
||||
const unsupported = (c) => (c.noGroups ? t('countryPosters.indiaStateNoGroups') : c.noPoster ? tRegions('No poster has been generated for this country yet.') : c.unsupportedReason ? t('messages.countryPosterTooMany') : null)
|
||||
const unsupported = (c) => (c.noGroups ? t('countryPosters.indiaStateNoGroups') : c.noPoster ? tRegions('No poster has been generated for this country yet.') : phoneBlocked(c) ? t('poster.phoneLimitAll') : c.unsupportedReason && !largeSheet(c) ? t('messages.countryPosterTooMany') : null)
|
||||
|
||||
// ★ THROUGH THE CATALOGUE, like the UPG-100 wall: the heading, the tag, the intro and the counts read
|
||||
// in the page's language. The intro keeps its one emphasis as three plain keys (A, the emphasis, B)
|
||||
|
|
|
|||
|
|
@ -274,7 +274,7 @@ useShadowStyles(PANE_GRID_STYLES + `
|
|||
:label="t('tabs.views')"
|
||||
@select="go"
|
||||
/>
|
||||
<ShareButton :is-dark="isDark" :embed-path="SHARE_EMBED_PATH" />
|
||||
<ShareButton :is-dark="isDark" :embed-path="SHARE_EMBED_PATH" shape="rounded-square" />
|
||||
</header>
|
||||
<div class="stage" ref="stage"></div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -94,11 +94,18 @@ export function armA(files, callers = []) {
|
|||
}
|
||||
|
||||
// ARM B: named expectations for seriesFor over the real data.
|
||||
export function armB(seriesFor, countries) {
|
||||
export function armB(seriesFor, countries, sheetFormatFor = null) {
|
||||
const fails = []
|
||||
const by = (iso) => countries.find((c) => c.iso3 === iso)
|
||||
const ind = by('IND')
|
||||
const s = ind && seriesFor(countries, ind)
|
||||
// THE LARGE-SHEET FORMAT RULE: India prints the large size ASKED FOR, else its 60x96 default; a packet page stays a
|
||||
// page; any other country gets exactly what it asked for.
|
||||
if (sheetFormatFor) {
|
||||
const want = [[ind, '66x66', '66x66'], [ind, '36x60', '36x60'], [ind, '60x96', '60x96'], [ind, 'letter', '60x96'], [ind, '3x4', '60x96'], [ind, 'packet', 'packet'],
|
||||
[by('NPL'), '3x3', '3x3'], [by('NPL'), 'letter', 'letter'], [by('CHN'), '3x4', '3x4']]
|
||||
for (const [item, ask, exp] of want) { const got = sheetFormatFor(item, ask); if (got !== exp) fails.push(`sheetFormatFor(${item?.iso3}, ${ask}) = ${got}, expected ${exp}`) }
|
||||
}
|
||||
const oversized = countries.filter((c) => (c.sheet ?? null) === null)
|
||||
if (oversized.length > 1) fails.push(`${oversized.length} countries have no sheet size (${oversized.map((c) => c.iso3).join(', ')}): they now share ONE oversized series, so India prints "map 1 of ${oversized.length}". Decide that deliberately, then update this expectation.`)
|
||||
if (!ind) fails.push('India is missing from country-posters.json')
|
||||
|
|
@ -137,6 +144,20 @@ export function armC(H, states, adm1Ind) {
|
|||
// ARM E: India is its OWN section, FIRST, counted ONCE. The India country card, the state cards and the none-listed
|
||||
// cards group together; the section's total is India's own count (not the card sum, which counts 855 twice); on that
|
||||
// true total it outranks every regional section. PosterWall's default (no sectionTotal) must still be the card sum.
|
||||
// ARM F — A DESKTOP-ONLY CARD ON A PHONE (India): in EACH caller (the wall, the Regions list) its rows REJECT with
|
||||
// poster.phoneLimitAll before any record is fetched, never resolve [] (the print path reads [] as "that list has no
|
||||
// people groups to print", on a card that has 855), and the card shows the same full-block notice.
|
||||
export function armF(srcs) {
|
||||
const fails = []
|
||||
for (const [name, src] of Object.entries(srcs)) {
|
||||
if (!/phoneBlocked\((?:c|item)\) \? (?:desktopOnly\(\)|Promise\.reject\(keyedError\('poster\.phoneLimitAll')/.test(src)) fails.push(`${name}: a phone-blocked card's rows do not reject with poster.phoneLimitAll`)
|
||||
if (/phoneBlocked\((?:c|item)\) \? Promise\.resolve\(\[\]\)/.test(src)) fails.push(`${name}: a phone-blocked card's rows resolve [] (printed as "no people groups")`)
|
||||
if (/desktopOnly\(\)/.test(src) && !/const desktopOnly = \(\) => Promise\.reject\(keyedError\('poster\.phoneLimitAll'/.test(src)) fails.push(`${name}: desktopOnly() does not reject with poster.phoneLimitAll`)
|
||||
if (!/phoneBlocked\(c\) \? t(?:Key)?\('poster\.phoneLimitAll'\)/.test(src)) fails.push(`${name}: a phone-blocked card does not show poster.phoneLimitAll`)
|
||||
}
|
||||
return fails
|
||||
}
|
||||
|
||||
export function armE(countries, states, wallSrc, posterWallSrc, H) {
|
||||
const fails = []
|
||||
if (!/:section-total=/.test(wallSrc)) fails.push('country-poster-wall.vue does not pass :section-total, so India is counted twice')
|
||||
|
|
@ -158,7 +179,7 @@ export function armE(countries, states, wallSrc, posterWallSrc, H) {
|
|||
|
||||
async function main() {
|
||||
const selfTest = process.argv.includes('--self-test')
|
||||
const { seriesFor } = await import(join(ROOT, 'library/packet/countrySheet.js'))
|
||||
const { seriesFor, sheetFormatFor } = await import(join(ROOT, 'library/packet/countrySheet.js'))
|
||||
const data = JSON.parse(readFileSync(join(ROOT, 'library/data/country-posters.json'), 'utf8'))
|
||||
const countries = data.countries || []
|
||||
if (countries.length < 170) { console.log(`NOT MEASURED: country-posters.json parsed ${countries.length} countries (floor 170)`); process.exitCode = 75; return }
|
||||
|
|
@ -171,6 +192,7 @@ async function main() {
|
|||
const files = {}
|
||||
for (const p of listSources(PD)) files[relative(ROOT, p)] = readFileSync(p, 'utf8')
|
||||
|
||||
const REG = readFileSync(join(ROOT, 'plugin-designs/regions-dashboard/regions-list.vue'), 'utf8')
|
||||
if (selfTest) {
|
||||
const wall = KNOWN_CALLERS[0]
|
||||
const cases = [
|
||||
|
|
@ -184,6 +206,10 @@ async function main() {
|
|||
['a division silently dropped', () => armC(H, states.filter((st) => st.id !== 'india-mz'), adm1Ind)],
|
||||
['the wall stops passing sectionTotal (India counted twice)', () => armE(countries, states, files[wall].replace(':section-total=', ':x-section-total='), readFileSync(join(ROOT, 'library/components/poster/PosterWall.vue'), 'utf8'), H).fails],
|
||||
['India country card left in Asia', () => armE(countries, states, files[wall].replace("=== 'IND'", "=== 'XXX'"), readFileSync(join(ROOT, 'library/components/poster/PosterWall.vue'), 'utf8'), H).fails],
|
||||
['India ignores a requested 66x66', () => armB(seriesFor, countries, (it, f) => (it?.iso3 === 'IND' && f !== 'packet' ? '60x96' : f))],
|
||||
['a packet page becomes a large sheet', () => armB(seriesFor, countries, (it, f) => (it?.iso3 === 'IND' ? '60x96' : f))],
|
||||
['the wall resolves [] for a phone-blocked card', () => armF({ wall: files[wall].replace("const desktopOnly = () => Promise.reject(keyedError('poster.phoneLimitAll'", "const desktopOnly = () => Promise.resolve([]) || (keyedError('poster.phoneLimitAll'"), regions: REG })],
|
||||
['the Regions list resolves [] for a phone-blocked card', () => armF({ wall: files[wall], regions: REG.replace("phoneBlocked(item) ? Promise.reject(keyedError('poster.phoneLimitAll'", "phoneBlocked(item) ? Promise.resolve([]) || (keyedError('poster.phoneLimitAll'") })],
|
||||
['a helper that mutates the shared ADM1', () => armC({ ...H, stateGeometry: (z, a, n) => { (a.features || []).forEach((f) => { if (f.properties.shapeName === n) f.properties.listId = z.id }); return H.stateGeometry(z, a, n) } }, states, JSON.parse(JSON.stringify(adm1Ind)))],
|
||||
]
|
||||
let bad = 0
|
||||
|
|
@ -202,7 +228,7 @@ async function main() {
|
|||
|
||||
const callers = []
|
||||
const E = armE(countries, states, files['plugin-designs/country-posters/country-poster-wall.vue'], readFileSync(join(ROOT, 'library/components/poster/PosterWall.vue'), 'utf8'), H)
|
||||
const fails = [...armA(files, callers), ...armB(seriesFor, countries), ...armC(H, states, adm1Ind), ...E.fails]
|
||||
const fails = [...armA(files, callers), ...armB(seriesFor, countries, sheetFormatFor), ...armC(H, states, adm1Ind), ...E.fails, ...armF({ wall: files['plugin-designs/country-posters/country-poster-wall.vue'], regions: REG })]
|
||||
const missing = [...KNOWN_CALLERS, `${STATE_CALLER}#state`].filter((k) => !callers.includes(k))
|
||||
if (missing.length) fails.push(`known callers not found calling makeCountrySheet: ${missing.join(', ')}`)
|
||||
if (fails.length) { fails.forEach((f) => console.log(`FAIL ${f}`)); process.exitCode = 1; return }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
#!/usr/bin/env node
|
||||
// check:side-lane-photo-pixels — a BROWSER gate (browser seat only: internal/browser-slot.sh; elsewhere exit 75,
|
||||
// NOT MEASURED). The 60x96 side-lane sheet, rendered by the real poster-render.js, rasterised by the real
|
||||
// poster-raster.js, measured in PIXELS at every photo slot:
|
||||
// - a data: photo (a local red fixture, so it is offline) DRAWS: its slot is red;
|
||||
// - every photo on its own host stays FLAT (the paper shows through): never the broken-image glyph, which is
|
||||
// what an external <image> rasterises as inside an SVG loaded as an image.
|
||||
// --self-test: the same raster WITHOUT stripExternalImages must leave external slots NOT flat (the glyph).
|
||||
// Its source-level twin, always measured, is check:side-lanes' photo arms.
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import zlib from 'node:zlib'
|
||||
import { dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
|
||||
const { chromium } = await import(join(ROOT, 'internal/lib/playwright-guarded.mjs'))
|
||||
const J = (p) => JSON.parse(readFileSync(join(ROOT, p), 'utf8'))
|
||||
// a 4x4 red PNG, built here (no file)
|
||||
function png(w, h, rgb) {
|
||||
const crcT = Array.from({ length: 256 }, (_, n) => { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c >>> 0 })
|
||||
const crc = (b) => { let c = 0xffffffff; for (const x of b) c = crcT[(c ^ x) & 255] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0 }
|
||||
const chunk = (t, d) => { const l = Buffer.alloc(4); l.writeUInt32BE(d.length); const td = Buffer.concat([Buffer.from(t), d]); const c = Buffer.alloc(4); c.writeUInt32BE(crc(td)); return Buffer.concat([l, td, c]) }
|
||||
const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 2
|
||||
const raw = Buffer.concat(Array.from({ length: h }, () => Buffer.concat([Buffer.from([0]), Buffer.concat(Array.from({ length: w }, () => Buffer.from(rgb)))])))
|
||||
return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(raw)), chunk('IEND', Buffer.alloc(0))])
|
||||
}
|
||||
const RED = 'data:image/png;base64,' + png(4, 4, [230, 20, 20]).toString('base64')
|
||||
const { createPosterGenerator } = await import(join(ROOT, 'library/packet/poster-render.js'))
|
||||
let seed = 7; const rnd = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648)
|
||||
const pins = Array.from({ length: 300 }, (_, i) => ({ name: `Group ${i}`, lat: 8 + rnd() * 27, lng: 68 + rnd() * 28, slug: `g${i}`, country_code: 'IND', population: 1000 + i,
|
||||
image_url: i % 3 === 0 ? `https://media.joshuaproject.net/public/assets/media/profiles/photos/p${10120 + i}.jpg` : 'https://s3.doxa.life/no-photo-images/asia.jpg' }))
|
||||
const adm0 = J('public/dx-maps/assets/boundaries/world-adm0.geojson')
|
||||
const zone = { id: 'country-ind', zoneKey: 'country-ind', label: 'India', count: pins.length, color: '#d4a640', adm0: ['IND'], selector: { type: 'country_codes', values: ['IND'] } }
|
||||
const gen = createPosterGenerator({ env: { FORMAT: '60x96', SPARSE: '1', POSTER_DATE: '2026-10-01' }, SERIES_DEF: J('library/scripts/posters/series.json').country, ISO3_NAME: { IND: 'India' }, adm0, adm1Ind: null, adm1Chn: null, nePlaces: [], series: { totalGroups: pins.length, totalLists: 1, index: 1 } })
|
||||
let { html } = await gen.renderPoster(zone, pins, { type: 'FeatureCollection', features: adm0.features.filter((f) => f.properties?.iso3 === 'IND') })
|
||||
html = html.replace(/(<image href=")https:[^"]+(")/, `$1${RED}$2`) // the FIRST photo becomes the local fixture
|
||||
const slots = [...html.matchAll(/<image href="([^"]+)" x="([\d.]+)" y="([\d.]+)" width="([\d.]+)" height="([\d.]+)"/g)].map((m) => ({ data: m[1].startsWith('data:'), x: +m[2], y: +m[3], s: +m[4] }))
|
||||
const vb = html.match(/<svg[^>]*viewBox="0 0 ([\d.]+) ([\d.]+)"/); const W = vb ? +vb[1] : 2500
|
||||
const cur = readFileSync(join(ROOT, 'library/packet/poster-raster.js'), 'utf8')
|
||||
const poison = cur.replace('stripExternalImages(inlineSvg(posterHtml))', 'inlineSvg(posterHtml)')
|
||||
if (poison === cur) throw new Error('poison anchor moved')
|
||||
console.log(`sheet: ${slots.length} photo slots (${slots.filter((s) => s.data).length} data: fixture, ${slots.filter((s) => !s.data).length} external), viewBox width ${W}`)
|
||||
const selfTest = process.argv.includes('--self-test')
|
||||
const b = await chromium.launch()
|
||||
const page = await b.newPage({ viewport: { width: 800, height: 600 }, deviceScaleFactor: 1 })
|
||||
let verdict = 0
|
||||
try {
|
||||
await page.setContent('<!doctype html><html><body></body></html>')
|
||||
for (const [label, src] of selfTest ? [['POISON (no strip)', poison]] : [['CURRENT', cur]]) {
|
||||
const r = await page.evaluate(async ({ src, html, slots, W }) => {
|
||||
const mod = await import(URL.createObjectURL(new Blob([src], { type: 'text/javascript' })))
|
||||
const blob = await mod.rasterPoster(html, { scale: 0.5, type: 'image/png' })
|
||||
const bmp = await createImageBitmap(blob); const c = document.createElement('canvas'); c.width = bmp.width; c.height = bmp.height
|
||||
const ctx = c.getContext('2d'); ctx.drawImage(bmp, 0, 0); const k = bmp.width / W
|
||||
const stat = (s) => { const x = Math.round((s.x + s.s * 0.2) * k), y = Math.round((s.y + s.s * 0.2) * k), n = Math.max(2, Math.round(s.s * 0.6 * k))
|
||||
const d = ctx.getImageData(x, y, n, n).data; let r = 0, g = 0, b = 0, v = 0; const px = d.length / 4
|
||||
for (let i = 0; i < d.length; i += 4) { r += d[i]; g += d[i + 1]; b += d[i + 2] } r /= px; g /= px; b /= px
|
||||
for (let i = 0; i < d.length; i += 4) v += Math.abs(d[i] - r) + Math.abs(d[i + 1] - g) + Math.abs(d[i + 2] - b)
|
||||
return { r: Math.round(r), g: Math.round(g), b: Math.round(b), varr: +(v / px).toFixed(1) } }
|
||||
const bg = stat({ x: 2, y: 2, s: 30 })
|
||||
const fix = slots.filter((s) => s.data).map(stat)
|
||||
const ext = slots.filter((s) => !s.data).map(stat)
|
||||
const near = (a) => a.varr <= 6 // flat: nothing drawn in the slot (paper shows through)
|
||||
return { size: [bmp.width, bmp.height], bg, fixture: fix, extBackground: ext.filter(near).length, extTotal: ext.length, extSample: ext.filter((a) => !near(a)).slice(0, 3) }
|
||||
}, { src, html, slots, W })
|
||||
const redOk = r.fixture.length && r.fixture.every((a) => a.r > 180 && a.g < 80 && a.b < 80)
|
||||
const flatAll = r.extBackground === r.extTotal && r.extTotal > 0
|
||||
if (selfTest) verdict = r.extBackground === 0 ? 0 : 1
|
||||
else verdict = redOk && flatAll ? 0 : 1
|
||||
console.log(`${label}: raster ${r.size.join('x')}, background rgb(${r.bg.r},${r.bg.g},${r.bg.b}); fixture slot ${JSON.stringify(r.fixture)} -> ${redOk ? 'DRAWS (red)' : 'NOT drawn'}; external slots FLAT (nothing drawn): ${r.extBackground}/${r.extTotal}${r.extSample.length ? ' non-background e.g. ' + JSON.stringify(r.extSample) : ''}`)
|
||||
}
|
||||
} finally { await b.close() }
|
||||
if (selfTest) console.log(verdict ? '✗ MISSED: external slots stayed flat without the strip' : '✓ caught: without the strip every external slot draws (the glyph)')
|
||||
else console.log(verdict ? 'FAIL check:side-lane-photo-pixels' : 'PASS check:side-lane-photo-pixels — the data: photo draws; every external photo slot is flat (no broken-image glyph)')
|
||||
process.exitCode = verdict
|
||||
152
plugin-designs/country-posters/scripts/check-side-lanes.mjs
Normal file
152
plugin-designs/country-posters/scripts/check-side-lanes.mjs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
#!/usr/bin/env node
|
||||
// check:side-lanes — the large side-lane sheets (60x96 / 66x66 / 36x60) name every group once, horizontally, with
|
||||
// leaders that never cross, on a DETERMINISTIC worst case: a seeded synthetic country of 855 groups with stacked
|
||||
// duplicates and tight clusters (district geocodes are like that), long and short names, real photos on an allowed
|
||||
// host, the generic placeholder, and photos on a host that is NOT allowed (none of the last two may be drawn).
|
||||
// Rendered through the real poster-render.js (renderPoster), so it measures what prints.
|
||||
//
|
||||
// Asserts per format: every name drawn exactly once; leader crossings 0 (the renderer's own count, re-measured here
|
||||
// from the SVG); no overflow rows; photos = the pins with a real photo (60x96/66x66) or none (36x60).
|
||||
// PHOTOS ARE NEVER RASTERISED: a sheet whose photos sit on their own hosts must (a) reach the raster with NO external
|
||||
// <image> (poster-raster.js strips them: inside an SVG loaded as an image they can only draw the broken-image glyph,
|
||||
// and no photo host sends CORS), and (b) PRINT as its own document from PosterWall, where the photos load. The pixel
|
||||
// side of this (a data: photo draws, an external slot stays flat) is check:side-lane-photo-pixels, a browser gate.
|
||||
// --self-test: a renderer copy WITHOUT the lane clamp must FAIL the crossing arm, a raster that keeps external
|
||||
// images must FAIL arm (a), and a PosterWall print that rasterises them must FAIL arm (b) (else the arms prove nothing).
|
||||
// Exit 0 pass, 1 fail.
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
|
||||
const J = (p) => JSON.parse(readFileSync(join(ROOT, p), 'utf8'))
|
||||
const RENDER = join(ROOT, 'library/packet/poster-render.js')
|
||||
|
||||
function synthetic(n = 855) {
|
||||
let seed = 20261001
|
||||
const rnd = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648)
|
||||
const syll = ['ka', 'ra', 'mu', 'thi', 'ban', 'dho', 'li', 'sa', 'ga', 'pa', 'rwa', 'kot', 'ni', 'al', 'u']
|
||||
const centres = Array.from({ length: 60 }, () => [68 + rnd() * 28, 8 + rnd() * 27])
|
||||
const pins = []
|
||||
for (let i = 0; i < n; i++) {
|
||||
const c = centres[Math.floor(rnd() * centres.length)]
|
||||
const stacked = rnd() < 0.15 && pins.length // an exact duplicate of an earlier position
|
||||
const lng = stacked ? pins[pins.length - 1].lng : c[0] + (rnd() - 0.5) * 1.6
|
||||
const lat = stacked ? pins[pins.length - 1].lat : c[1] + (rnd() - 0.5) * 1.6
|
||||
const words = 1 + Math.floor(rnd() * (rnd() < 0.05 ? 5 : 2))
|
||||
const name = Array.from({ length: words }, () => Array.from({ length: 2 + Math.floor(rnd() * 3) }, () => syll[Math.floor(rnd() * syll.length)]).join('')).join(' ')
|
||||
.replace(/^./, (m) => m.toUpperCase()) + ` ${i}`
|
||||
// real (an allowed photo host), placeholder, or a non-placeholder on a host that is NOT allowed (must not draw)
|
||||
const roll = rnd()
|
||||
const image_url = roll < 0.18 ? `https://media.joshuaproject.net/public/assets/media/profiles/photos/p${i}.jpg`
|
||||
: roll < 0.22 ? `http://elsewhere.example/p/${i}.jpg`
|
||||
: 'https://s3.doxa.life/no-photo-images/asia.jpg'
|
||||
pins.push({ name, lat, lng, slug: `g-${i}`, country_code: 'IND', population: 1000 + i, image_url })
|
||||
}
|
||||
return pins
|
||||
}
|
||||
|
||||
async function measure(createPosterGenerator, format, pins) {
|
||||
const adm0 = J('public/dx-maps/assets/boundaries/world-adm0.geojson')
|
||||
const series = J('library/scripts/posters/series.json')
|
||||
const zone = { id: 'country-ind', zoneKey: 'country-ind', label: 'India', count: pins.length, color: '#d4a640', adm0: ['IND'], selector: { type: 'country_codes', values: ['IND'] } }
|
||||
const zoneFC = { type: 'FeatureCollection', features: adm0.features.filter((f) => f.properties?.iso3 === 'IND') }
|
||||
const gen = createPosterGenerator({ env: { FORMAT: format, SPARSE: '1', POSTER_DATE: '2026-10-01' }, SERIES_DEF: series.country, ISO3_NAME: { IND: 'India' }, adm0, adm1Ind: null, adm1Chn: null, nePlaces: [], series: { totalGroups: pins.length, totalLists: 1, index: 1 } })
|
||||
const out = await gen.renderPoster(zone, pins, zoneFC)
|
||||
const html = out.html || ''
|
||||
const names = [...html.matchAll(/<text class="snm"[^>]*>([^<]*)<\/text>/g)].map((m) => m[1])
|
||||
const photos = (html.match(/<image href="/g) || []).length
|
||||
// re-measure crossings from the SVG itself, independent of the renderer's own count
|
||||
const polys = [...html.matchAll(/class="slead" d="M([^"]+)"/g)].map((m) => m[1].split('L').map((q) => q.trim().split(' ').map(Number)))
|
||||
const segs = polys.map((pl) => pl.slice(1).map((q, k) => [pl[k], q]))
|
||||
const sx = (p, q, r) => (r[1] - p[1]) * (q[0] - p[0]) - (q[1] - p[1]) * (r[0] - p[0])
|
||||
const X = (a, b) => (sx(a[0], a[1], b[0]) > 0) !== (sx(a[0], a[1], b[1]) > 0) && (sx(b[0], b[1], a[0]) > 0) !== (sx(b[0], b[1], a[1]) > 0)
|
||||
let crossings = 0
|
||||
for (let i = 0; i < segs.length; i++) for (let j = i + 1; j < segs.length; j++) if (segs[i].some((a) => segs[j].some((b) => X(a, b)))) crossings++
|
||||
return { stats: out.sideLanes, names, photos, crossings, leaders: polys.length }
|
||||
}
|
||||
|
||||
function judge(format, m, pins, wantPhotos) {
|
||||
const fails = []
|
||||
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
const want = new Map(pins.map((p) => [esc(p.name), 0]))
|
||||
for (const n of m.names) if (want.has(n)) want.set(n, want.get(n) + 1)
|
||||
const missing = [...want].filter(([, v]) => v === 0).length, dup = [...want].filter(([, v]) => v > 1).length
|
||||
if (!m.stats) fails.push(`${format}: renderPoster returned no sideLanes stats (not a side-lane format?)`)
|
||||
if (m.names.length !== pins.length || missing || dup) fails.push(`${format}: ${m.names.length} names drawn for ${pins.length} groups (${missing} missing, ${dup} duplicated)`)
|
||||
if (m.crossings !== 0) fails.push(`${format}: ${m.crossings} leader crossings (re-measured from the SVG)`)
|
||||
if (m.stats && m.stats.crossings !== m.crossings) fails.push(`${format}: the renderer reports ${m.stats.crossings} crossings, the SVG has ${m.crossings}`)
|
||||
if (m.stats && m.stats.overflowRows) fails.push(`${format}: ${m.stats.overflowRows} rows overflow the band`)
|
||||
const real = pins.filter((p) => /^https:\/\/media\.joshuaproject\.net\//.test(p.image_url)).length // the allowed ones only
|
||||
if (m.photos !== (wantPhotos ? real : 0)) fails.push(`${format}: ${m.photos} photos drawn, expected ${wantPhotos ? real : 0}`)
|
||||
return fails
|
||||
}
|
||||
|
||||
// (a) + (b), on the 60x96 sheet's HTML. rasterSrc / wallSrc are the source texts, so a poison can swap them.
|
||||
async function photoArms(html, rasterSrc, wallSrc) {
|
||||
const fails = []
|
||||
const m = await import('data:text/javascript;base64,' + Buffer.from(rasterSrc).toString('base64'))
|
||||
const ext = (t) => (t.match(/<image\b[^>]*?\bhref="(?!data:)/g) || []).length
|
||||
if (!ext(html)) fails.push('photos: the 60x96 sheet carries no external photo, so these arms measure nothing')
|
||||
if (typeof m.hasExternalImages !== 'function' || !m.hasExternalImages(html)) fails.push('photos: poster-raster.js hasExternalImages() does not see the sheet\'s photos')
|
||||
// what rasterPoster hands to new Image(): its own first line, inlineSvg + whatever it applies
|
||||
const body = (rasterSrc.match(/export async function rasterPoster[\s\S]*?const url = /) || [''])[0]
|
||||
const prep = (body.match(/const svg = ([^;]+);/) || [])[1] || ''
|
||||
const svg = prep ? new Function('inlineSvg', 'stripExternalImages', 'posterHtml', `return ${prep}`)(m.inlineSvg, m.stripExternalImages, html) : ''
|
||||
if (!prep) fails.push('photos: could not find what rasterPoster rasterises (const svg = ...)')
|
||||
else if (ext(svg)) fails.push(`photos: rasterPoster would rasterise ${ext(svg)} external <image> (each one prints the broken-image glyph)`)
|
||||
// (b) the wall's generated-sheet branch prints a sheet with external images as a document, before any raster
|
||||
const branch = (wallSrc.match(/if \(props\.sheet\) \{[\s\S]*?rasterPoster\(html/) || [''])[0]
|
||||
if (!/posterAction === 'print' && hasExternalImages\(html\)\) \{ await printSheetDocument\(html\); return \}/.test(branch)) fails.push('photos: PosterWall prints a sheet with photos through the raster (they cannot draw there)')
|
||||
if (!/async function printSheetDocument\(html\)[\s\S]{0,400}printDocument\(html,/.test(wallSrc)) fails.push('photos: printSheetDocument does not hand the sheet itself to printDocument')
|
||||
return fails
|
||||
}
|
||||
const RASTER = join(ROOT, 'library/packet/poster-raster.js'), WALL = join(ROOT, 'library/components/poster/PosterWall.vue')
|
||||
async function sheetHtml(createPosterGenerator, pins) {
|
||||
const adm0 = J('public/dx-maps/assets/boundaries/world-adm0.geojson')
|
||||
const gen = createPosterGenerator({ env: { FORMAT: '60x96', SPARSE: '1', POSTER_DATE: '2026-10-01' }, SERIES_DEF: J('library/scripts/posters/series.json').country, ISO3_NAME: { IND: 'India' }, adm0, adm1Ind: null, adm1Chn: null, nePlaces: [], series: { totalGroups: pins.length, totalLists: 1, index: 1 } })
|
||||
const zone = { id: 'country-ind', zoneKey: 'country-ind', label: 'India', count: pins.length, color: '#d4a640', adm0: ['IND'], selector: { type: 'country_codes', values: ['IND'] } }
|
||||
return (await gen.renderPoster(zone, pins, { type: 'FeatureCollection', features: adm0.features.filter((f) => f.properties?.iso3 === 'IND') })).html || ''
|
||||
}
|
||||
|
||||
const selfTest = process.argv.includes('--self-test')
|
||||
const src = readFileSync(RENDER, 'utf8')
|
||||
const pins = synthetic()
|
||||
if (selfTest) {
|
||||
// POISON: drop the lane clamp (each lane at its own pin's height); stubs/runs/fans must then cross on this data
|
||||
const a = 'let y = Math.min(Math.max(c.y, lo + LANE_GAP), hi - LANE_GAP);'
|
||||
if (!src.includes(a)) { console.log('NOT MEASURED: the lane clamp line moved; update the poison'); process.exitCode = 75 }
|
||||
else {
|
||||
const poisoned = src.replace(a, 'let y = c.y; lo = -Infinity; hi = Infinity;')
|
||||
const { createPosterGenerator } = await import('data:text/javascript;base64,' + Buffer.from(poisoned).toString('base64'))
|
||||
const m = await measure(createPosterGenerator, '60x96', pins)
|
||||
const f = judge('60x96', m, pins, true)
|
||||
console.log(f.some((x) => /crossings/.test(x)) ? `✓ caught: no lane clamp -> ${m.crossings} crossings` : '✗ MISSED: the crossing arm stayed green without the lane clamp')
|
||||
process.exitCode = f.some((x) => /crossings/.test(x)) ? 0 : 1
|
||||
}
|
||||
const { createPosterGenerator } = await import(RENDER)
|
||||
const html = await sheetHtml(createPosterGenerator, pins)
|
||||
const rs = readFileSync(RASTER, 'utf8'), ws = readFileSync(WALL, 'utf8')
|
||||
for (const [name, r, w, rx] of [
|
||||
['a raster that keeps external images', rs.replace('stripExternalImages(inlineSvg(posterHtml))', 'inlineSvg(posterHtml)'), ws, /rasterise \d+ external/],
|
||||
['a wall print that rasterises a sheet with photos', rs, ws.replace("if (props.posterAction === 'print' && hasExternalImages(html)) { await printSheetDocument(html); return }", ''), /through the raster/],
|
||||
]) {
|
||||
if (r === rs && w === ws) { console.log(`NOT MEASURED: the poison anchor for "${name}" moved`); process.exitCode = 75; continue }
|
||||
const f = await photoArms(html, r, w)
|
||||
const ok = f.some((x) => rx.test(x)); console.log(ok ? `✓ caught: ${name} -> ${f.find((x) => rx.test(x))}` : `✗ MISSED: ${name}`)
|
||||
if (!ok) process.exitCode = 1
|
||||
}
|
||||
} else {
|
||||
const { createPosterGenerator } = await import(RENDER)
|
||||
const fails = [], lines = []
|
||||
for (const [format, photos] of [['60x96', true], ['66x66', true], ['36x60', false]]) {
|
||||
const m = await measure(createPosterGenerator, format, pins)
|
||||
fails.push(...judge(format, m, pins, photos))
|
||||
lines.push(`${format}: ${m.names.length} names, ${m.leaders} leaders, ${m.crossings} crossings, ${m.photos} photos, ${m.stats?.namePt} pt`)
|
||||
}
|
||||
const html = await sheetHtml(createPosterGenerator, pins)
|
||||
const pf = await photoArms(html, readFileSync(RASTER, 'utf8'), readFileSync(WALL, 'utf8'))
|
||||
fails.push(...pf); if (!pf.length) lines.push(`photos: ${(html.match(/<image\b[^>]*?\bhref="(?!data:)/g) || []).length} external, 0 reach the raster, the wall prints the sheet as a document`)
|
||||
if (fails.length) { fails.forEach((f) => console.log('FAIL ' + f)); process.exitCode = 1 }
|
||||
else console.log(`PASS check:side-lanes — synthetic ${pins.length} groups (stacked + clustered); ${lines.join(' | ')}`)
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
# Embedding the Research Gallery
|
||||
# Embedding Deep Research
|
||||
|
||||

|
||||

|
||||
|
||||
`<doxa-maps-spa>` (labeled **Research Gallery**) is the capability-5 app shell: one element whose
|
||||
`<doxa-maps-spa>` (labeled **Deep Research**) is the capability-5 app shell: one element whose
|
||||
tab rail nests every production map. It is built to drop onto somebody else's page. Below,
|
||||
`TREE` means the URL of the built `doxa-maps/` folder wherever it is served, for example
|
||||
`https://YOUR-HOST/doxa-maps`.
|
||||
|
|
@ -13,7 +13,7 @@ tab rail nests every production map. It is built to drop onto somebody else's pa
|
|||
|---|---|---|
|
||||
| **Paste** | two lines: `<script>` + `<doxa-maps-spa>` | one `<iframe>` |
|
||||
| **Sizing** | a block in your layout, `85vh` by default | whatever size you give the frame |
|
||||
| **Language** | follows your page (`<html lang>`), or `language="…"` | `?lang=…` on the `src` |
|
||||
| **Language** | follows your page (`<html lang>`), or `language="…"`; readers can switch it in the gallery (see Events) | `?lang=…` on the `src` |
|
||||
| **Open on a map** | `tab="upg-100"` | `?tab=upg-100` |
|
||||
| **Keyless maps** | `provider="maplibre"` | `?provider=maplibre` |
|
||||
| **Your server must send** | CORS on the whole tree | CORS + `frame-ancestors` on the leaf page |
|
||||
|
|
@ -78,6 +78,32 @@ document.querySelector('doxa-maps-spa')
|
|||
.addEventListener('tab-change', (e) => console.log(e.detail[0].id, e.detail[0].label))
|
||||
```
|
||||
|
||||
### The language menu: `doxa-language-request`
|
||||
|
||||
Readers can change the language from the gallery itself: a menu at the end of the phone bar, and at
|
||||
the bottom of the rail on a desktop. Before switching, the gallery asks your page first by dispatching
|
||||
a cancelable `doxa-language-request` event (it bubbles and crosses shadow roots). Its shape is
|
||||
`e.detail.language`, not an array.
|
||||
|
||||
- **Your page owns the language** (it passes `language`, or its URL carries the locale): call
|
||||
`preventDefault()` and change the language your own way. Then set the gallery's `language`, and the
|
||||
gallery follows.
|
||||
|
||||
```js
|
||||
const gallery = document.querySelector('doxa-maps-spa')
|
||||
gallery.addEventListener('doxa-language-request', (e) => {
|
||||
e.preventDefault()
|
||||
switchMySiteTo(e.detail.language) // your router or i18n; then gallery.setAttribute('language', …)
|
||||
})
|
||||
```
|
||||
|
||||
On Nuxt with @nuxtjs/i18n that call is `navigateTo(switchLocalePath(e.detail.language))`;
|
||||
switchLocalePath already keeps the query and hash. If a new locale route re-mounts the page, carry the
|
||||
open tab across: keep the id from `tab-change` (`e.detail[0].id`) in `?tab=`, which the gallery reads.
|
||||
|
||||
- **You do nothing:** the gallery switches itself. The reader's pick then holds even over a `language`
|
||||
you passed, until you change that attribute again. The gallery never writes your page's URL.
|
||||
|
||||
## A good guest on your page
|
||||
|
||||
The gallery is tested to leave its host page as it found it: it does not scroll your page, rewrite
|
||||
|
|
|
|||
|
|
@ -112,6 +112,14 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<!-- THE LANGUAGE MENU OF A HEADER-OFF SHELL: the end of the layout's phone bar, and the bottom of the
|
||||
desktop rail. ShellLanguageMenu first asks the host (a cancelable doxa-language-request). If the
|
||||
host takes it, the host sets `language` and this gallery follows. If not, it emits 'switch' and the
|
||||
gallery switches itself (pickLocale). `current` is uiLocale, so the select always has a matching
|
||||
option. -->
|
||||
<template #phone-end><ShellLanguageMenu :current="uiLocale" @switch="pickLocale" /></template>
|
||||
<template #rail-end="{ collapsed }"><ShellLanguageMenu :current="uiLocale" :collapsed="collapsed" @switch="pickLocale" /></template>
|
||||
|
||||
<!-- EVERY tab mounts its plugin's REAL custom element — there is no iframe mode. The tag is
|
||||
created imperatively into an EMPTY, Vue-unmanaged slot div (:ref, no children) after that
|
||||
bundle's script is injected once, so Vue never fights the custom element's own shadow DOM.
|
||||
|
|
@ -199,6 +207,7 @@
|
|||
<script>
|
||||
import { defineComponent, inject } from 'vue'
|
||||
import VerticalTabsLayout from '@map/layouts/VerticalTabsLayout.vue'
|
||||
import ShellLanguageMenu from '@map/components/ShellLanguageMenu.vue'
|
||||
import BackToTopButton from '@map/components/BackToTopButton.vue'
|
||||
import { SWITCHER_LANGUAGES, loadLocale, saveLocale, getLanguage } from '@map/layouts/app-wrapper-material/languages.js'
|
||||
import { translate, LOCALES, hasKey } from './src/strings.js'
|
||||
|
|
@ -365,7 +374,10 @@ const NOT_EXPERIMENTAL = new Set([
|
|||
// posterverse is the OTHER capability-5 shell (the poster plugins). Shown here it would be a
|
||||
// whole second rail inside this pane: a shell does not nest a shell, for the same reason this
|
||||
// shell excludes itself above.
|
||||
'posterverse'
|
||||
'posterverse',
|
||||
// bible-posters lives on POSTERVERSE's Experimental shelf, and only there: one poster plugin, shown
|
||||
// once, in the shell that gathers the poster plugins.
|
||||
'bible-posters'
|
||||
])
|
||||
|
||||
// ── EXPERIMENTAL ORDER = KINGDOM IMPACT, not the alphabet ────────────────────────────────
|
||||
|
|
@ -778,7 +790,7 @@ export default defineComponent({
|
|||
// (measured 2026-09-25). .vtl survives today only because it declares display:grid !important.
|
||||
// Everything the layout needs (dir, lang, labels, …) is passed explicitly below.
|
||||
inheritAttrs: false,
|
||||
components: { VerticalTabsLayout, BackToTopButton, MapInfo },
|
||||
components: { VerticalTabsLayout, BackToTopButton, MapInfo, ShellLanguageMenu },
|
||||
// profile-config props the standalone leaf page + gallery hand every element. treeBase lets a
|
||||
// host pin the tree root explicitly; the rest are accepted so the element is a well-behaved
|
||||
// member of the bundler (profile/instanceId/assetsBase/tk/shareUrl/qa are ignored here — the
|
||||
|
|
@ -879,6 +891,11 @@ export default defineComponent({
|
|||
// The host page's own language (detectHostLocale), kept live by a MutationObserver on <html lang>
|
||||
// — a host app that switches language at runtime (Nuxt i18n does) re-drives the whole gallery.
|
||||
hostLang: detectHostLocale(),
|
||||
// A language picked in the shell's menu in THIS session that no host took (see pickLocale). It
|
||||
// outranks the `language` prop it was picked over, and a change to that prop clears it. A saved
|
||||
// choice is never a source: loadLocale falls back to the browser's language, which would
|
||||
// override a partner page's own <html lang>.
|
||||
sessionPick: '',
|
||||
localeOpen: false,
|
||||
switcherLangs: SWITCHER_LANGUAGES,
|
||||
// Curated tabs start from TAB_DEFS and are COMPLETED from the manifest (tag + scriptUrl) as
|
||||
|
|
@ -1027,18 +1044,21 @@ export default defineComponent({
|
|||
showLocaleSwitcher() { return this.showHeader },
|
||||
// The active language's flag/native name for the toolbar toggle button.
|
||||
activeLang() { return getLanguage(this.locale) || { code: this.locale, flag: '🌐', nativeName: this.locale } },
|
||||
// The EFFECTIVE preview language pushed down to every tab. Precedence, most-authoritative first:
|
||||
// 1. `language` prop / profile-config.language|locale|lang — the parent-page (inherited) value.
|
||||
// 2. the manual switcher's data.locale (only meaningful when a header dropdown is present).
|
||||
// Every tab's element receives THIS via profile-config.locale, so the SPA honors one language prop.
|
||||
// 1. `language` prop / profile-config.language|locale|lang — what the host SAYS.
|
||||
// 2. the gallery's own switcher choice — only while that switcher is actually offered.
|
||||
// 3. the host page's language (?lang= / <html lang>, see detectHostLocale).
|
||||
// 4. English.
|
||||
// The language the HOST says: the `language` prop, or profile-config.language|locale|lang.
|
||||
propLocale() {
|
||||
return (this.language || this.cfg.language || this.cfg.locale || this.cfg.lang || '').trim()
|
||||
},
|
||||
// The EFFECTIVE preview language pushed down to every tab (each tab's element receives it via
|
||||
// profile-config.locale). Precedence, most-authoritative first:
|
||||
// 1. a pick from the shell's language menu in THIS session that no host took (sessionPick), which
|
||||
// the watcher below clears as soon as the host's `language` changes;
|
||||
// 2. `language` prop / profile-config.language|locale|lang: what the host SAYS;
|
||||
// 3. the header-on switcher's choice, only while that switcher is actually offered;
|
||||
// 4. the host page's language (?lang= / <html lang>, see detectHostLocale);
|
||||
// 5. English.
|
||||
effectiveLocale() {
|
||||
const fromProp = (this.language || this.cfg.language || this.cfg.locale || this.cfg.lang || '').trim()
|
||||
const fromSwitcher = this.showLocaleSwitcher ? this.locale : ''
|
||||
return (fromProp || fromSwitcher || this.hostLang || 'en').split('-')[0].toLowerCase()
|
||||
return (this.sessionPick || this.propLocale || fromSwitcher || this.hostLang || 'en').split('-')[0].toLowerCase()
|
||||
},
|
||||
// The locale the gallery's OWN words render in: the effective one when it ships, else English.
|
||||
uiLocale() { return LOCALES.includes(this.effectiveLocale) ? this.effectiveLocale : 'en' },
|
||||
|
|
@ -1096,6 +1116,8 @@ export default defineComponent({
|
|||
},
|
||||
watch: {
|
||||
activeIndex() { this.helpOpen = false },
|
||||
// The host changed its language: that is the newest word, so it replaces a menu pick made earlier.
|
||||
propLocale() { this.sessionPick = '' },
|
||||
// ★ A LANGUAGE SWITCH REMOUNTS, IT DOES NOT RE-SET THE ATTRIBUTE. Re-setting the visible tab's
|
||||
// profile-config was the old path, and it relied on every nested map re-reading its whole config
|
||||
// live. Two production maps do not: Big Picture's tStatic strings are resolved once (2/352 changed,
|
||||
|
|
@ -1390,6 +1412,12 @@ export default defineComponent({
|
|||
this.localeOpen = false
|
||||
saveLocale(code)
|
||||
},
|
||||
// The language menu's 'switch': it fires only when no host cancelled doxa-language-request, so the
|
||||
// gallery switches itself, and the pick holds over a `language` prop the host never updates.
|
||||
pickLocale(code) {
|
||||
this.setLocale(code)
|
||||
this.sessionPick = code
|
||||
},
|
||||
// The shared rail emits the selected tab's `key` (its id); map it back to the index the
|
||||
// pane-mount machinery below is keyed on.
|
||||
//
|
||||
|
|
|
|||
|
|
@ -850,6 +850,11 @@ function handleAggregateResult(evt) {
|
|||
}
|
||||
}
|
||||
|
||||
// The pins' resting opacity: the value the library creates the layer with (useMapLayers'
|
||||
// language-family-pins). Every path that clears a filter restores this, so a cleared map looks
|
||||
// exactly like a fresh one.
|
||||
const PIN_REST_OPACITY = 1
|
||||
|
||||
// Un-apply the aggregate filter so the map returns to normal colors.
|
||||
let _aggregateFilterActive = false
|
||||
function clearAggregateFilter() {
|
||||
|
|
@ -857,7 +862,7 @@ function clearAggregateFilter() {
|
|||
const m = map.value
|
||||
if (!m?.getLayer('language-family-pins')) return
|
||||
m.setPaintProperty('language-family-pins', 'circle-color', getBaseColorExpr())
|
||||
m.setPaintProperty('language-family-pins', 'circle-opacity', 0.9)
|
||||
m.setPaintProperty('language-family-pins', 'circle-opacity', PIN_REST_OPACITY)
|
||||
m.setPaintProperty('language-family-pins', 'circle-stroke-opacity', 1)
|
||||
_aggregateFilterActive = false
|
||||
}
|
||||
|
|
@ -1215,7 +1220,7 @@ function applyLegendFilter(filterType, filterKey) {
|
|||
if (!filterKey) {
|
||||
_currentFilter = { type: null, key: null }
|
||||
m.setPaintProperty('language-family-pins', 'circle-color', getBaseColorExpr())
|
||||
m.setPaintProperty('language-family-pins', 'circle-opacity', 0.9)
|
||||
m.setPaintProperty('language-family-pins', 'circle-opacity', PIN_REST_OPACITY)
|
||||
m.setPaintProperty('language-family-pins', 'circle-stroke-opacity', 1)
|
||||
_syncHitboxFilter(m, null)
|
||||
mapLayers.syncGlowFilter(null)
|
||||
|
|
@ -1227,7 +1232,7 @@ function applyLegendFilter(filterType, filterKey) {
|
|||
if (!matchExpr) {
|
||||
_currentFilter = { type: null, key: null }
|
||||
m.setPaintProperty('language-family-pins', 'circle-color', getBaseColorExpr())
|
||||
m.setPaintProperty('language-family-pins', 'circle-opacity', 0.9)
|
||||
m.setPaintProperty('language-family-pins', 'circle-opacity', PIN_REST_OPACITY)
|
||||
m.setPaintProperty('language-family-pins', 'circle-stroke-opacity', 1)
|
||||
_syncHitboxFilter(m, null)
|
||||
mapLayers.syncGlowFilter(null)
|
||||
|
|
|
|||
|
|
@ -74,6 +74,28 @@ export function selectionClipboard(selection, opts = {}, csv = '') {
|
|||
return csv ? head + '\n\n' + csv : head;
|
||||
}
|
||||
|
||||
/**
|
||||
* `promise` if it settles within `ms`, else `marker`. A rejection still rejects (so a load failure keeps its own
|
||||
* message). Copy uses it so a stalled people-group load can never hold the territory lines hostage: those need
|
||||
* no network, and apply-grouping needs nothing else.
|
||||
*/
|
||||
export function settleWithin(promise, ms, marker) {
|
||||
let timer = 0;
|
||||
const late = new Promise((resolve) => { timer = setTimeout(() => resolve(marker), ms); });
|
||||
return Promise.race([promise, late]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
/**
|
||||
* The error a READER sees for a failed load. One that already carries a catalogue key (keyedError: an HTTP status,
|
||||
* no service configured) passes through, because its text is already true and translated. Anything else (the
|
||||
* request aborted by our own timeout, the network gone, a body that is not JSON) arrives as the browser's raw English
|
||||
* ("signal is aborted without reason"), so it becomes `fallback(e)`, the keyed "still loading, try again" text. The
|
||||
* failed load leaves the cache, so trying again really does fetch again.
|
||||
*/
|
||||
export function asReaderError(e, fallback) {
|
||||
return e && e.i18nKey ? e : fallback(e);
|
||||
}
|
||||
|
||||
/**
|
||||
* The names a selection's Prayer list and Card deck are titled by: its zones (already localized by the caller), its
|
||||
* countries, and the India states and union territories that BROUGHT people (`from`: slug → the territory names
|
||||
|
|
@ -97,4 +119,4 @@ export function selectionCsvFileName(label) {
|
|||
return `upg-100-${base}.csv`;
|
||||
}
|
||||
|
||||
export default { SELECTION_CSV_COLUMNS, csvCell, payloadName, regroupKind, regroupable, selectionCsv, selectionClipboard, selectionTitleNames, selectionCsvFileName };
|
||||
export default { SELECTION_CSV_COLUMNS, csvCell, payloadName, regroupKind, regroupable, selectionCsv, selectionClipboard, settleWithin, asReaderError, selectionTitleNames, selectionCsvFileName };
|
||||
|
|
|
|||
36
plugin-designs/my-upg-100-list/data/upg-total.js
vendored
Normal file
36
plugin-designs/my-upg-100-list/data/upg-total.js
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// upg-total.js — how many people groups the UPG-100 list covers, from ONE place: the funnel's last step
|
||||
// (data/people-groups/vision-funnel.json, the same figure the packet's footnote uses). Never write the number into
|
||||
// a string: the list grows (2,106 was printed in eleven languages after it had become 2,107), so every sentence
|
||||
// takes it as {n}, formatted for the reader's locale. check:upg-count keeps it that way.
|
||||
// PURE (no JSON import, so node gates can import it): the .vue callers import the funnel and pass it in.
|
||||
|
||||
/** The list's total: the funnel's last step (the whole DOXA set). 0 when the funnel is missing. */
|
||||
export function upgTotal(funnel) {
|
||||
const steps = (funnel && Array.isArray(funnel.steps)) ? funnel.steps : []
|
||||
return Number(steps.length ? steps[steps.length - 1].groups : 0) || 0
|
||||
}
|
||||
|
||||
/** A count as the reader writes numbers (2,107 · 2.107 · 2 107), in `numberingSystem` when one is given (٢٬١٠٧). */
|
||||
export function formatCount(n, locale, numberingSystem) {
|
||||
try { return new Intl.NumberFormat(locale || undefined, numberingSystem ? { numberingSystem } : undefined).format(n) } catch (_) { return String(n) }
|
||||
}
|
||||
|
||||
// The non-Western digit system a text already writes its numbers in, or null (Western digits, or none).
|
||||
const DIGIT_SYSTEMS = [['arab', /[\u0660-\u0669]/], ['arabext', /[\u06F0-\u06F9]/], ['deva', /[\u0966-\u096F]/], ['beng', /[\u09E6-\u09EF]/]]
|
||||
export function digitSystemOf(text) {
|
||||
for (const [nu, re] of DIGIT_SYSTEMS) if (re.test(String(text || ''))) return nu
|
||||
return null
|
||||
}
|
||||
|
||||
// ★ ONE SENTENCE, ONE DIGIT SYSTEM. {n} is written in the digits the translated sentence already uses for its own
|
||||
// numbers. CLDR's default for a bare `ar` is Western (Intl.NumberFormat('ar') gives "2,107"), but the ar wall intro
|
||||
// writes "٢٧" and "٩:١٤" in Arabic-Indic, so a plain locale format would put "2,107" between them. The sentence is
|
||||
// rendered once with an empty slot to read its digits, then with the count. Locales whose sentence uses Western
|
||||
// digits (or none) format exactly as before.
|
||||
// `tr` is the caller's t(); passing it in keeps this module pure for the node gate.
|
||||
export function countInSentence(tr, key, n, locale, params = {}) {
|
||||
const bare = tr(key, { ...params, n: '' })
|
||||
return tr(key, { ...params, n: formatCount(n, locale, digitSystemOf(bare)) })
|
||||
}
|
||||
|
||||
export default { upgTotal, formatCount, digitSystemOf, countInSentence }
|
||||
|
|
@ -46,6 +46,9 @@ import PosterWall from '@map/components/poster/PosterWall.vue'
|
|||
import { resolveAssetsBase } from '@map/utils/embedBase.js'
|
||||
// The reader's language, for the DATA fetch below. Not for interface text — that goes through t().
|
||||
import { getRuntimeLocale, detectLocale } from '@map/i18n/index.js'
|
||||
// The list's total for the intro, from the funnel (never a number written into a string; see data/upg-total.js).
|
||||
import visionFunnel from '../../data/people-groups/vision-funnel.json'
|
||||
import { upgTotal, countInSentence } from './data/upg-total.js'
|
||||
// Named imports: only the list arrays ship in the bundle; the file's bookkeeping fields stay out.
|
||||
import { zonesForList } from './data/zones-for-list.js'
|
||||
// The SAME normalize+evaluate pipeline the sibling map profile uses to turn the live
|
||||
|
|
@ -244,7 +247,8 @@ const intro = computed(() => {
|
|||
// The three plain-text keys come from the shared catalogue (rule 5B). Until they land, the
|
||||
// single legacy key is still the source of truth, so this renders identically before AND after: no
|
||||
// flag day, and this consumer does not have to land in step with the catalogue.
|
||||
const a = t('messages.upg100WallIntroA')
|
||||
// The total is {n}, formatted for the reader (it printed a stale 2,106 in all eleven languages).
|
||||
const a = countInSentence(t, 'upgMap.wallIntroA', upgTotal(visionFunnel), getRuntimeLocale() || detectLocale())
|
||||
const b = t('messages.upg100WallIntroEmph')
|
||||
const c = t('messages.upg100WallIntroB')
|
||||
const missing = (v, key) => !v || v === key
|
||||
|
|
|
|||
|
|
@ -128,7 +128,9 @@ import { colorForZone } from './data/zone-colors.js'
|
|||
// clicked tapestry feature carries shapeName (ADM1) OR iso3 (ADM0) and this maps
|
||||
// either to { key, name, count } from admin-counts/membership (NEVER edits them).
|
||||
import { resolveTerritory, buildCountryNameMap, formatClipboard, ADM1_COUNTS, ISO3_NAME } from './data/territory-counts.js'
|
||||
import { selectionCsv, selectionClipboard, selectionCsvFileName, regroupable, selectionTitleNames } from './data/selection-export.js'
|
||||
import { selectionCsv, selectionClipboard, selectionCsvFileName, regroupable, settleWithin, asReaderError, selectionTitleNames } from './data/selection-export.js'
|
||||
import visionFunnel from '../../data/people-groups/vision-funnel.json'
|
||||
import { upgTotal, countInSentence } from './data/upg-total.js'
|
||||
// 09b-search-bar §1(a-c): unified search index (PG + zone + territory) + the
|
||||
// tokenized contains matcher. Pure data + pure fns; this profile builds the rows
|
||||
// from the live records (PG), the legend leafZones (zone), and admin-counts +
|
||||
|
|
@ -1824,16 +1826,26 @@ function selectedAdm1Names() {
|
|||
// Copy tap builds its text from cache and writes it inside the tap's user activation (a clipboard write
|
||||
// after a network wait is refused, on phones above all). A failed load is dropped so the next try refetches.
|
||||
let _selRecords = null; // { lang, promise }
|
||||
const RECORDS_TIMEOUT_MS = 30000; // the people-group list: abort a request that has not answered by then
|
||||
const COPY_WAIT_MS = 8000; // Copy waits this long for the people groups, then copies the territories alone
|
||||
// Any load failure without a catalogue key (the timeout's abort, a dropped network) reads as the stall does.
|
||||
const stillLoading = () => keyedError('upgMap.peopleStillLoading', 'the people groups are still loading, try again in a moment');
|
||||
const readerError = (e) => asReaderError(e, stillLoading);
|
||||
function selectionRecordsLoad() {
|
||||
const lang = packetLang();
|
||||
if (_selRecords && _selRecords.lang === lang) return _selRecords.promise;
|
||||
if (!API_BASE) return Promise.reject(keyedError('wall.hud.noApi', 'no people-groups service is configured'));
|
||||
const fields = PRINT_FIELDS; // the print standard's one field list (lists/peopleGroupRecords.js)
|
||||
// A STALLED request must not hold the cache forever: it aborts after RECORDS_TIMEOUT_MS, rejects, and the catch
|
||||
// below drops it, so the next action fetches again.
|
||||
const ctrl = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
||||
const timer = ctrl ? setTimeout(() => ctrl.abort(), RECORDS_TIMEOUT_MS) : 0;
|
||||
const promise = (async () => {
|
||||
const resp = await fetch(`${API_BASE}/api/people-groups/list?fields=${fields}&lang=${lang}`, { cache: 'no-store' });
|
||||
const resp = await fetch(`${API_BASE}/api/people-groups/list?fields=${fields}&lang=${lang}`, { cache: 'no-store', signal: ctrl ? ctrl.signal : undefined })
|
||||
.finally(() => clearTimeout(timer));
|
||||
if (!resp.ok) throw keyedError('wall.hud.fetchFailed', 'could not load the people groups ({status})', { status: resp.status });
|
||||
return normalizeRecords(await applyPeopleGroupList(extractRecords(await resp.json()), profileConfig));
|
||||
})();
|
||||
})().catch((e) => { throw readerError(e); });
|
||||
_selRecords = { lang, promise };
|
||||
promise.catch(() => { if (_selRecords && _selRecords.promise === promise) _selRecords = null; });
|
||||
return promise;
|
||||
|
|
@ -1919,10 +1931,12 @@ function copySelection() {
|
|||
if (!sel.length) return;
|
||||
const color = selectedColor.value;
|
||||
let count = null, note = '';
|
||||
const text = selectionPeople().then((p) => {
|
||||
const STALLED = {};
|
||||
const text = settleWithin(selectionPeople(), COPY_WAIT_MS, STALLED).then((p) => {
|
||||
if (p === STALLED) { note = t('upgMap.peopleStillLoading'); return selectionClipboard(sel, { color }); }
|
||||
count = p.records.length; note = selectionNote(p.missing);
|
||||
return selectionClipboard(sel, { color }, selectionCsv(p.records, p.from, { color }));
|
||||
}, (e) => { note = errorText(e, t); return selectionClipboard(sel, { color }); });
|
||||
}, (e) => { note = errorText(readerError(e), t); return selectionClipboard(sel, { color }); });
|
||||
text.then((v) => { try { window.__UPG_LAST_CLIPBOARD__ = v } catch (_) {} });
|
||||
// A selection apply-grouping cannot regroup in one go (a mix of kinds, or a legend zone) still copies; the
|
||||
// flash says so, so a partner does not hand an agent a line it will refuse.
|
||||
|
|
@ -1980,7 +1994,7 @@ async function exportSelectionCsv() {
|
|||
const note = selectionNote(p.missing);
|
||||
flashRef(copyFlash, t('upgMap.csvSaved', { count: p.records.length }) + (note ? ' · ' + note.slice(0, 90) : ''), note ? 6000 : 3000);
|
||||
} catch (e) {
|
||||
flashRef(copyFlash, t('upgMap.csvFailed', { reason: errorText(e, t).slice(0, 90) }), 5000);
|
||||
flashRef(copyFlash, t('upgMap.csvFailed', { reason: errorText(readerError(e), t).slice(0, 90) }), 5000);
|
||||
} finally { batchBusy.value = false; busyAction.value = ''; }
|
||||
}
|
||||
|
||||
|
|
@ -2746,7 +2760,7 @@ onMounted(async () => {
|
|||
// The pins need none of it: they are built from `records` (lat/lng) and slugToZone, and the
|
||||
// selector evaluate() at the pin build maps slugs to zone keys without touching any geometry.
|
||||
// 68 KB, arriving at ~2.9 s cold.
|
||||
status.value = t('messages.fetchingUupgs', { n: 2106 })
|
||||
status.value = countInSentence(t, 'messages.fetchingUupgs', upgTotal(visionFunnel), packetLang()) // the funnel's total, never a literal
|
||||
const records = normalizeRecords(await applyPeopleGroupList(extractRecords(await recordsPromise), profileConfig))
|
||||
// 09b shift-select §1(b): build iso3→display-name from the LIVE records so an
|
||||
// ADM0 country click resolves to the exact label the legend shows (the ADM0
|
||||
|
|
|
|||
|
|
@ -117,7 +117,8 @@ useShadowStyles(PANE_GRID_STYLES + `
|
|||
<div class="pdmfe">
|
||||
<header class="hdr">
|
||||
<ViewSelect :views="spec.panes" :current="view" :legend="t('tabs.selectView')" :label="t('tabs.views')" @select="go" />
|
||||
<ShareButton :is-dark="isDark" :embed-path="SHARE_EMBED_PATH" />
|
||||
<!-- A HEADER button, so the header form: a rounded square (Big Picture's DashboardTopBar uses the same). -->
|
||||
<ShareButton :is-dark="isDark" :embed-path="SHARE_EMBED_PATH" shape="rounded-square" />
|
||||
</header>
|
||||
<div class="stage" ref="stage"></div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,35 +1,164 @@
|
|||
<script setup>
|
||||
/**
|
||||
* poster-sizes.vue: every poster size, each card a print preview at that size.
|
||||
* poster-sizes.vue: every poster size, drawn to scale, with the sheets each one prints on.
|
||||
*
|
||||
* @element poster-design
|
||||
* @description Every poster size the print picker offers (standard paper up to 5 × 8 ft), each drawn as a
|
||||
* print preview, portrait only, from the one shared size table.
|
||||
* @description Every poster size the print picker offers, from standard paper to 5 × 8 ft, drawn to scale.
|
||||
* A size that prints on several sheets shows them pulled apart with the red strip where the next sheet is
|
||||
* glued on, as the Wall of People Groups previews its 5 × 8 ft poster.
|
||||
*
|
||||
* The sizes come from the shared size table in library/constants/posterSizes.js and nowhere else: a
|
||||
* second list here would drift from the print picker. Until that table carries the size ladder, this
|
||||
* tab says so rather than inventing one.
|
||||
* The sizes are the ONE shared ladder (library/constants/posterSizes.js POSTER_LADDER, in
|
||||
* POSTER_LADDER_ORDER), never a second list: a size the ladder gains appears here by itself. Each size's
|
||||
* `sheets` goes straight into the library's PanelSheets, the same component and the same geometry
|
||||
* (panelSheets.js) the print uses. What fills the sheets is the 3 × 3 country poster's LAYOUT drawn at that
|
||||
* size (src/layoutSvg.js), labelled as a layout, because the renderer does not draw the large sizes yet.
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import { useOptionalT } from '@map/composables/useOptionalT.js'
|
||||
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import PanelSheets from '@map/components/poster/PanelSheets.vue'
|
||||
import { planPanels, screenLayout } from '@map/components/poster/panelSheets.js'
|
||||
import { POSTER_LADDER, POSTER_LADDER_ORDER, paperFor } from '@map/constants/posterSizes.js'
|
||||
import { useOptionalT, useOptionalI18n } from '@map/composables/useOptionalT.js'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import { layoutSvgUrl } from './src/layoutSvg.js'
|
||||
|
||||
const t = useOptionalT('')
|
||||
const heading = computed(() => t('posterDesign.sizes.heading'))
|
||||
const waiting = computed(() => t('posterDesign.sizes.waiting'))
|
||||
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) })
|
||||
}
|
||||
// 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)
|
||||
const tag = (typeof navigator !== 'undefined' && navigator.language) || (typeof l === 'string' ? l : '') || 'en'
|
||||
return paperFor(tag) === 'a4'
|
||||
})
|
||||
|
||||
const sizes = computed(() => POSTER_LADDER_ORDER.map((key) => POSTER_LADDER[key]).filter(Boolean).map((s) => {
|
||||
const { sheetW, sheetH, count, direction, joinIn } = s.sheets
|
||||
return {
|
||||
key: s.id,
|
||||
w: s.inches.w,
|
||||
h: s.inches.h,
|
||||
sheets: { sheetW, sheetH, count, direction, joinIn },
|
||||
title: s.paper ? t('posterDesign.sizes.paper') : dims(s.inches.w, s.inches.h),
|
||||
subtitle: s.paper ? t(paperIsA4.value ? 'posterDesign.sizes.paperA4' : 'posterDesign.sizes.paperLetter') : '',
|
||||
count,
|
||||
sheetDims: dims(sheetW, sheetH),
|
||||
overlap: count > 1 ? t('posterDesign.sizes.dimsInOne', { v: n(joinIn) }) : '',
|
||||
photos: !!s.photos,
|
||||
shop: s.path === 'pdf',
|
||||
src: layoutSvgUrl(s.inches.w, s.inches.h),
|
||||
}
|
||||
}))
|
||||
|
||||
// The box PanelSheets draws, in inches: its own screenLayout of the same plan, so a fit computed here is the fit
|
||||
// it renders (each sheet whole, pulled apart by the gap, which is taller than the finished poster).
|
||||
const LINEUP_GAP_IN = 1.5
|
||||
const CARD_GAP_IN = 0.75
|
||||
function world(s, gapIn) {
|
||||
const L = screenLayout(planPanels(s.sheets), { gapIn, pxPerIn: 1 })
|
||||
return { w: L.worldW, h: L.worldH }
|
||||
}
|
||||
|
||||
// THE LINEUP: every size at ONE scale, standing on one baseline, so 5 × 8 ft looks as big beside paper as it
|
||||
// is. Each size gets an equal column; the scale is the largest at which the tallest drawing is LINEUP_H and
|
||||
// the widest still fits its column, so on a phone every size stays in view and stays to scale.
|
||||
const LINEUP_H = 240 // px for the tallest size, when there is room
|
||||
const COL_GAP = 12
|
||||
const rowEl = ref(null)
|
||||
const rowW = ref(1000)
|
||||
let ro = null
|
||||
onMounted(() => {
|
||||
if (!rowEl.value || typeof ResizeObserver === 'undefined') return
|
||||
ro = new ResizeObserver(([e]) => { if (e && e.contentRect.width) rowW.value = e.contentRect.width })
|
||||
ro.observe(rowEl.value)
|
||||
})
|
||||
onBeforeUnmount(() => { ro?.disconnect(); ro = null })
|
||||
const pxPerIn = computed(() => {
|
||||
const ws = sizes.value.map((s) => world(s, LINEUP_GAP_IN))
|
||||
if (!ws.length) return 1
|
||||
const col = (rowW.value - COL_GAP * (ws.length - 1)) / ws.length
|
||||
return Math.min(LINEUP_H / Math.max(...ws.map((w) => w.h)), col / Math.max(...ws.map((w) => w.w)))
|
||||
})
|
||||
const lineupW = (s) => `${Math.max(4, world(s, LINEUP_GAP_IN).w * pxPerIn.value)}px`
|
||||
|
||||
useShadowStyles(`
|
||||
:host{display:block;width:100%;height:100%;min-height:420px}
|
||||
.pd-sizes{position:absolute;inset:0;overflow-y:auto;padding:28px 20px;background:#f3f3f1;color:#3b463d;
|
||||
font:14px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,system-ui,sans-serif}
|
||||
.pd-sizes__h{margin:0 0 8px;font-size:22px;font-weight:700}
|
||||
.pd-sizes__note{margin:0;max-width:60ch;color:#6d766e}
|
||||
.pd-sizes{position:absolute;inset:0;overflow-y:auto;padding:24px 20px 40px;background:#f3f3f1;color:#3b463d;
|
||||
font:14px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,system-ui,sans-serif;box-sizing:border-box}
|
||||
.pd-sizes__h{margin:0 0 6px;font-size:22px;font-weight:700}
|
||||
.pd-sizes__intro{margin:0 0 18px;max-width:70ch;color:#6d766e}
|
||||
.pd-lineup{background:#fff;border:1px solid #d1d4cd;border-radius:12px;padding:16px 16px 10px;margin:0 0 24px}
|
||||
.pd-lineup__h{margin:0 0 12px;font-size:14px;font-weight:700;color:#6d766e}
|
||||
.pd-lineup__row{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(0,1fr);align-items:end;gap:12px;
|
||||
padding-bottom:6px;border-bottom:2px solid #d1d4cd}
|
||||
.pd-lineup__item{display:flex;flex-direction:column;align-items:center;gap:6px;min-width:0}
|
||||
.pd-lineup__label{font-size:12px;font-weight:600;text-align:center;line-height:1.25;overflow-wrap:anywhere}
|
||||
.pd-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:18px}
|
||||
.pd-card{background:#fff;border:1px solid #d1d4cd;border-radius:12px;padding:14px;display:flex;flex-direction:column;gap:10px;min-width:0}
|
||||
.pd-card__art{display:flex;justify-content:center;align-items:flex-end;height:220px;background:#f3f3f1;border-radius:8px;padding:10px;box-sizing:border-box}
|
||||
.pd-card__art > .pd-card__fit{height:100%;display:flex;align-items:flex-end;justify-content:center;width:100%}
|
||||
.pd-card__title{margin:0;font-size:17px;font-weight:700}
|
||||
.pd-card__sub{margin:-8px 0 0;color:#6d766e;font-size:13px}
|
||||
.pd-card__rows{display:grid;grid-template-columns:max-content 1fr;gap:4px 12px;margin:0;font-size:13px}
|
||||
.pd-card__rows dt{color:#6d766e}
|
||||
.pd-card__rows dd{margin:0}
|
||||
.pd-note{margin:20px 0 0;max-width:70ch;color:#6d766e;font-size:13px}
|
||||
`, 'poster-design-sizes')
|
||||
|
||||
// A card's art box leaves 200 × 222 px for the drawing; it fits by whichever side binds first.
|
||||
const ART_H = 200
|
||||
const ART_W = 222
|
||||
function artWidth(s) {
|
||||
const { w, h } = world(s, CARD_GAP_IN)
|
||||
return `${Math.floor(w * Math.min(ART_H / h, ART_W / w))}px`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pd-sizes">
|
||||
<h2 class="pd-sizes__h">{{ heading }}</h2>
|
||||
<p class="pd-sizes__note">{{ waiting }}</p>
|
||||
<h2 class="pd-sizes__h">{{ t('posterDesign.sizes.heading') }}</h2>
|
||||
<p class="pd-sizes__intro">{{ t('posterDesign.sizes.intro') }}</p>
|
||||
|
||||
<section class="pd-lineup" :aria-label="t('posterDesign.sizes.lineup')">
|
||||
<h3 class="pd-lineup__h">{{ t('posterDesign.sizes.lineup') }}</h3>
|
||||
<div ref="rowEl" class="pd-lineup__row">
|
||||
<div v-for="s in sizes" :key="s.key" class="pd-lineup__item">
|
||||
<div :style="{ width: lineupW(s) }">
|
||||
<PanelSheets v-bind="s.sheets" :gap-in="LINEUP_GAP_IN" :src="s.src" />
|
||||
</div>
|
||||
<span class="pd-lineup__label">{{ s.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="pd-grid">
|
||||
<article v-for="s in sizes" :key="s.key" class="pd-card" :data-size="s.key">
|
||||
<div class="pd-card__art">
|
||||
<div class="pd-card__fit">
|
||||
<div :style="{ width: artWidth(s) }"><PanelSheets v-bind="s.sheets" :gap-in="CARD_GAP_IN" :src="s.src" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="pd-card__title">{{ s.title }}</h3>
|
||||
<p v-if="s.subtitle" class="pd-card__sub">{{ s.subtitle }}</p>
|
||||
<dl class="pd-card__rows">
|
||||
<dt>{{ t('posterDesign.sizes.rowSheets') }}</dt><dd>{{ s.count }}</dd>
|
||||
<template v-if="s.count > 1">
|
||||
<dt>{{ t('posterDesign.sizes.rowSheet') }}</dt><dd>{{ s.sheetDims }}</dd>
|
||||
<dt>{{ t('posterDesign.sizes.rowOverlap') }}</dt><dd>{{ s.overlap }}</dd>
|
||||
</template>
|
||||
<dt>{{ t('posterDesign.sizes.rowPhotos') }}</dt><dd>{{ s.photos ? t('posterDesign.sizes.yes') : t('posterDesign.sizes.no') }}</dd>
|
||||
<dt>{{ t('posterDesign.sizes.rowPrint') }}</dt><dd>{{ s.shop ? t('posterDesign.sizes.printShop') : t('posterDesign.sizes.printHome') }}</dd>
|
||||
</dl>
|
||||
</article>
|
||||
</div>
|
||||
<p class="pd-note">{{ t('posterDesign.sizes.layoutNote') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ import { getApiBaseUrl } from '@map/api/apiBaseUrl.js'
|
|||
import { getRuntimeLocale, detectLocale } from '@map/i18n/index.js'
|
||||
import countryData from '@map/data/country-posters.json'
|
||||
import { makeCountrySheet } from '@map/packet/countrySheet.js'
|
||||
import { loadPacketStrings } from '@map/packet/packetStrings.js'
|
||||
import { firstPageImage } from './src/pageThumb.js'
|
||||
import { panelsSvgUrl } from './src/panelsSvg.js'
|
||||
import { resolvePosterSize } from '@map/constants/posterSizes.js'
|
||||
import { listConfigurations, FAMILY_KEYS, SAMPLE_COUNTRY_ID } from './src/templates.js'
|
||||
import FullDetailsDialog from './src/FullDetailsDialog.vue'
|
||||
|
||||
|
|
@ -49,7 +53,8 @@ const items = computed(() => configs.map((c) => ({ id: c.name, label: labelOf(c)
|
|||
const groupBy = (item) => t(FAMILY_KEYS[byName.get(item.id)?.family] || FAMILY_KEYS.mapSheet)
|
||||
|
||||
// ── drawing a configuration from the library ──────────────────────────────────────────────────
|
||||
const sample = (countryData.countries || []).find((c) => c.id === SAMPLE_COUNTRY_ID)
|
||||
const countryById = (id) => (countryData.countries || []).find((c) => c.id === id)
|
||||
const sample = countryById(SAMPLE_COUNTRY_ID)
|
||||
const recordsFor = createPeopleGroupRecords({
|
||||
apiBase: () => profileConfig.value?.apiBaseUrl || getApiBaseUrl(),
|
||||
lang: () => getRuntimeLocale() || detectLocale(),
|
||||
|
|
@ -58,34 +63,85 @@ const recordsFor = createPeopleGroupRecords({
|
|||
// The ONE path every surface draws a country sheet through (the Country Posters wall, the Regions tab),
|
||||
// so the sample carries its region hue and its "map N of M" exactly as the wall prints it.
|
||||
const sheetFor = makeCountrySheet({ records: recordsFor, assetsBase: () => assetsBase.value, countries: countryData.countries || [] })
|
||||
const countrySheet = (format) => (sample ? sheetFor(sample, format) : Promise.resolve(''))
|
||||
const ctx = { countrySheet }
|
||||
// A country's sheet at a format, through the shared path; the large side-lane sheets use their own sample (India).
|
||||
const countrySheet = (format, id = SAMPLE_COUNTRY_ID) => {
|
||||
const c = countryById(id)
|
||||
return c ? sheetFor(c, format) : Promise.resolve('')
|
||||
}
|
||||
|
||||
// The sample's people groups and the packet's words, each fetched once for the whole gallery.
|
||||
const lang = () => getRuntimeLocale() || detectLocale() || 'en'
|
||||
let rowsP = null
|
||||
const sampleRows = () => (rowsP ||= (sample ? recordsFor({ type: 'country', values: [sample.iso3] }) : Promise.resolve([]))
|
||||
.then((r) => r || []).catch(() => { rowsP = null; return [] }))
|
||||
let stringsP = null
|
||||
const packetStrings = () => (stringsP ||= loadPacketStrings(assetsBase.value))
|
||||
|
||||
// A card deck exactly as the wall prints one (usePosterPrints printCardDeck: createCardRenderer(...).buildDeck):
|
||||
// the whole deck is the document, its first card the thumbnail.
|
||||
async function cardDeck(stockId) {
|
||||
const [rows, strings, { createCardRenderer }] = await Promise.all([sampleRows(), packetStrings(), import('@map/packet/card-render.js')])
|
||||
if (!rows.length) return null
|
||||
const R = createCardRenderer({ strings, lang: lang(), stock: stockId })
|
||||
return { html: R.buildDeck(sample, rows), page: '.card', width: R.W, height: R.H }
|
||||
}
|
||||
// A packet's PAGE 1, its map page, from the same renderer and the same map sheet the wall's packet uses
|
||||
// (the sample's map-only 'packet' sheet, lifted by the renderer). Only page 1 is built: it is what the
|
||||
// thumbnail shows, and it needs nothing the later pages need (descriptions, the evangelical share).
|
||||
async function packetDocument(variant) {
|
||||
const [rows, strings, { createPacketRenderer }, sheetHtml] = await Promise.all([
|
||||
sampleRows(), packetStrings(), import('@map/packet/packet-render.js'), countrySheet('packet'),
|
||||
])
|
||||
if (!rows.length) return null
|
||||
const R = createPacketRenderer({ strings, lang: lang() })
|
||||
const mapSvg = sheetHtml ? R.liftMap(sheetHtml) : ''
|
||||
const doc = R.buildPacket(sample, rows, new Map(), mapSvg, null, null, variant, null)
|
||||
return { html: doc, page: '.sheet', width: R.W, height: R.H, firstPageOnly: true }
|
||||
}
|
||||
// A multi-sheet poster as its panels, from the shared ladder's sheets for that size.
|
||||
function panels(sizeKey) {
|
||||
const size = resolvePosterSize(sizeKey)
|
||||
return size ? { image: panelsSvgUrl(size.sheets) } : null
|
||||
}
|
||||
const ctx = { countrySheet, cardDeck, packetDocument, panels }
|
||||
|
||||
// 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).
|
||||
const fileBytes = ref({})
|
||||
async function renderDocument(name) {
|
||||
const c = byName.get(name)
|
||||
if (!c || !c.render) return ''
|
||||
const html = await c.render(ctx)
|
||||
if (html) fileBytes.value = { ...fileBytes.value, [name]: new Blob([html]).size }
|
||||
return html || ''
|
||||
if (!c || !c.render) return null
|
||||
const out = await c.render(ctx)
|
||||
if (out && out.image) return out
|
||||
const html = typeof out === 'string' ? out : out && out.html
|
||||
if (!html) return null
|
||||
if (!(out && out.firstPageOnly)) fileBytes.value = { ...fileBytes.value, [name]: new Blob([html]).size }
|
||||
return out
|
||||
}
|
||||
|
||||
// previewSrc: the thumbnail, rasterised from the drawn document at a quarter scale (a 1500px sheet
|
||||
// becomes ~375px, the card's size). A render that comes out blank is refused, so the card shows
|
||||
// "preview unavailable" rather than a black rectangle.
|
||||
// previewSrc: the thumbnail. A map sheet is rasterised at a quarter scale (a 1500px sheet becomes ~375px,
|
||||
// the card's size), and a render that comes out blank is refused, so the card shows "preview unavailable"
|
||||
// rather than a black rectangle. A document's first page is drawn as it prints (src/pageThumb.js).
|
||||
async function previewSrc(item) {
|
||||
const html = await renderDocument(item.id)
|
||||
if (!html) return null
|
||||
const out = await renderDocument(item.id)
|
||||
if (!out) return null
|
||||
if (out.image) return out.image
|
||||
if (typeof out === 'object') return firstPageImage(out.html, out)
|
||||
const { rasterPoster, looksRendered } = await import('@map/packet/poster-raster.js')
|
||||
const blob = await rasterPoster(html, { scale: 0.25 })
|
||||
const blob = await rasterPoster(out, { scale: 0.25 })
|
||||
if (!(await looksRendered(blob))) return null
|
||||
return URL.createObjectURL(blob)
|
||||
}
|
||||
|
||||
// The card's print button prints THIS configuration: the format the wall asks for is ignored on
|
||||
// purpose, because the card already names its size.
|
||||
const sheet = (item) => renderDocument(item.id)
|
||||
// The card's print button prints THIS configuration: the format the wall asks for is ignored on purpose,
|
||||
// because the card already names its size. A card deck prints whole, as the wall prints it. A packet is
|
||||
// built here only as far as its page 1, so it is NOT offered for printing from this card: printing a
|
||||
// packet missing its later pages would misrepresent the template.
|
||||
async function sheet(item) {
|
||||
const out = await renderDocument(item.id)
|
||||
if (!out || out.image || (typeof out === 'object' && out.firstPageOnly)) return ''
|
||||
return typeof out === 'string' ? out : out.html
|
||||
}
|
||||
|
||||
// ── "Show full details" ───────────────────────────────────────────────────────────────────────
|
||||
const open = ref(null)
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue