Merge remote-tracking branch 'upstream/main' into bundler-1/work
Some checks are pending
build-gate / build (push) Waiting to run
133
README.md
|
|
@ -1,5 +1,24 @@
|
|||
# doxa-maps — one build, maps that embed everywhere
|
||||
|
||||
> **The map cruncher.** Many hand-built maps go in; one shared library, one repo and one build
|
||||
> come out — and every map fans out to five deployment targets from the same artifacts.
|
||||
>
|
||||
> ```text
|
||||
> map · map · map · map · map · map · map (7 production maps, once 7 codebases)
|
||||
> \ \ | | | / /
|
||||
> +----- plugin-designs/ ------+ one folder per plugin, free zone
|
||||
> |
|
||||
> library/ (@map) shared, parameterized, high-bar zone
|
||||
> |
|
||||
> ONE bun run build
|
||||
> |
|
||||
> doxa-maps/ (drop-anywhere tree: bundles + leaf pages + manifest.json)
|
||||
> / | \ \ \
|
||||
> iframe JS-embed mobile WordPress desktop
|
||||
> (proven) (proven) (built) (built) (built)
|
||||
> ```
|
||||
|
||||
|
||||
**A Vite bundler that turns each map into a self-contained web component.** Write a map in
|
||||
`plugin-designs/<your-map>/`, run one build, and get a folder you can drop on any host — iframe it,
|
||||
script-embed it, wrap it as a mobile app, or open every map at once as one SPA.
|
||||
|
|
@ -12,18 +31,20 @@ This README has three parts, in the order you need them:
|
|||
|
||||
---
|
||||
|
||||
## 1. The 5 plugin capabilities
|
||||
## 1. The 5 plugin capabilities — most advanced first
|
||||
|
||||
A "capability" is **how much a plugin nests** — not how hard it is. Every screenshot below is a real
|
||||
bundle from this repo's build output.
|
||||
A "capability" is **how much a plugin nests** — not how hard it is to use. The tour below starts from
|
||||
the most advanced thing this repo produces (a whole app of apps) and walks down to the simplest
|
||||
(one element), because the top of the ladder is the point: every rung below it stays independently
|
||||
embeddable. Every screenshot is a real bundle from this repo's build output.
|
||||
|
||||
| # | Capability | What it is | Example bundle |
|
||||
|---|------------|------------|----------------|
|
||||
| 1 | **Single element** | One folder → one bundle → one custom element. | `all-people-groups` |
|
||||
| 2 | **Prop-table element** | One `root.vue` + a preset table → many maps from one plugin. | `doxa-simple-map` |
|
||||
| 3 | **Monolithic element** | One `root.vue` nests many views internally → one element. | `doxa-research-map` |
|
||||
| 4 | **Nested-element SPA** | A composite that nests *other plugins* and wires them with props/events. | `big-picture` |
|
||||
| 5 | **Nested-SPA / app shell** | Many SPAs nested into one deep-linkable app. | `doxa-maps-spa` |
|
||||
| 4 | **Nested-element SPA** | A composite that nests *other plugins* and wires them with props/events. | `big-picture` |
|
||||
| 3 | **Monolithic element** | One `root.vue` nests many views internally → one element. | `doxa-research-map` |
|
||||
| 2 | **Prop-table element** | One `root.vue` + a preset table → many maps from one plugin. | `doxa-simple-map` |
|
||||
| 1 | **Single element** | One folder → one bundle → one custom element. | `all-people-groups` |
|
||||
|
||||
> The tier is decided by the files, not by how simple a folder looks: an `*.instances.json` makes a
|
||||
> bundle **cap-2** however light it is, and a `meta.json` `type` declares tiers 3/4/5. The authoritative,
|
||||
|
|
@ -31,38 +52,7 @@ bundle from this repo's build output.
|
|||
> **[documentation/reference-capability-exemplars.md](./documentation/reference-capability-exemplars.md)**
|
||||
> — cite that file rather than remembering.
|
||||
|
||||
### Capability 1 — single element
|
||||
|
||||
One folder, one `.vue`, one embeddable element. `plugin-designs/all-people-groups/` shows every people
|
||||
group on one map.
|
||||
|
||||

|
||||
|
||||
### Capability 2 — prop-table element
|
||||
|
||||
One `root.vue`, many maps. `plugin-designs/doxa-simple-map/` ships a preset prop-table
|
||||
(`simple-map.instances.json`) that renders it three ways — each a standalone embeddable page from the
|
||||
**same** code.
|
||||
|
||||
| Prayer | Engagement | Adoption |
|
||||
|--------|------------|----------|
|
||||
|  |  |  |
|
||||
|
||||
### Capability 3 — monolithic element
|
||||
|
||||
A whole multi-view map from one `root.vue`. The trade-off: you cannot embed just one of its tabs.
|
||||
|
||||

|
||||
|
||||
### Capability 4 — nested-element SPA
|
||||
|
||||
A composite that nests **lower-tier plugins** and wires them with props and events. Each inner plugin
|
||||
stays independently embeddable, and the composite is embeddable too. `plugin-designs/my-upg-100-list/`
|
||||
ships three leaves:
|
||||
|
||||
| Composite (`upg-100-mfe`) | List (`upg-100-list`) | Poster wall (`poster-wall`) |
|
||||
|---------------------------|-----------------------|-----------------------------|
|
||||
|  |  |  |
|
||||
|
||||
### Capability 5 — nested-SPA / app shell
|
||||
|
||||
|
|
@ -79,6 +69,39 @@ studio without any wiring. This is [section 3](#3-the-plugin-studio).
|
|||
|
||||

|
||||
|
||||
### Capability 4 — nested-element SPA
|
||||
|
||||
A composite that nests **lower-tier plugins** and wires them with props and events. Each inner plugin
|
||||
stays independently embeddable, and the composite is embeddable too. `plugin-designs/my-upg-100-list/`
|
||||
ships three leaves:
|
||||
|
||||
| Composite (`upg-100-mfe`) | List (`upg-100-list`) | Poster wall (`poster-wall`) |
|
||||
|---------------------------|-----------------------|-----------------------------|
|
||||
|  |  |  |
|
||||
|
||||
### Capability 3 — monolithic element
|
||||
|
||||
A whole multi-view map from one `root.vue`. The trade-off: you cannot embed just one of its tabs.
|
||||
|
||||

|
||||
|
||||
### Capability 2 — prop-table element
|
||||
|
||||
One `root.vue`, many maps. `plugin-designs/doxa-simple-map/` ships a preset prop-table
|
||||
(`simple-map.instances.json`) that renders it three ways — each a standalone embeddable page from the
|
||||
**same** code.
|
||||
|
||||
| Prayer | Engagement | Adoption |
|
||||
|--------|------------|----------|
|
||||
|  |  |  |
|
||||
|
||||
### Capability 1 — single element
|
||||
|
||||
One folder, one `.vue`, one embeddable element. `plugin-designs/all-people-groups/` shows every people
|
||||
group on one map.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 2. The 5 deployment methods
|
||||
|
|
@ -100,6 +123,17 @@ Android SDK for method 2, a live WordPress for method 4, and a GUI (this box is
|
|||
|
||||
Methods **1 and 3 are the two proven-live paths.** If you need a map on a site today, use one of those.
|
||||
|
||||
Both were **certified cross-origin on 2026-09-24** — the entire capability-5 app, embedded on a
|
||||
foreign host page, every inner plugin riding along (prove-the-whole = prove-the-parts):
|
||||
|
||||
| Method 1 — iframe on a host site | Method 3 — JS embed on a host site |
|
||||
|----------------------------------|-------------------------------------|
|
||||
|  |  |
|
||||
|
||||
> For a JS embed on another site the host page sets `window.MAP_TOKEN` (nested elements read it)
|
||||
> and the serving layer must send `Access-Control-Allow-Origin: *` — the tree is fetched
|
||||
> cross-origin by `manifest.json`-composed surfaces.
|
||||
|
||||
The full per-method command matrix, with every prerequisite, is in
|
||||
**[documentation/README-SECTION-3-cli-commands.md](./documentation/README-SECTION-3-cli-commands.md)**.
|
||||
|
||||
|
|
@ -138,6 +172,29 @@ runs on it yet).
|
|||
|
||||
---
|
||||
|
||||
## The repo at a glance
|
||||
|
||||
```text
|
||||
doxa-maps/
|
||||
├── plugin-designs/ YOUR maps live here — one folder per plugin (the FREE zone).
|
||||
│ ├── doxa-maps-spa/ the capability-5 app (nests everything below)
|
||||
│ ├── dashboard-plus-grid/ capability-4 composite: dashboard + people-groups grid
|
||||
│ ├── my-upg-100-list/ capability-4: UPG-100 lists + poster wall + map
|
||||
│ ├── doxa-simple-map/ capability-2: one root.vue, three preset maps
|
||||
│ └── template-bundle/ copy this to start a new plugin
|
||||
├── library/ the SHARED zone (@map) — reusable, parameterized only.
|
||||
│ │ A change here reaches EVERY bundle: high bar, see
|
||||
│ │ documentation/contributing/.
|
||||
│ ├── components/ map controls, legends, search, poster, detail panels
|
||||
│ ├── composables/ useMapInstance, shadow styles, print-in-place, retry
|
||||
│ └── styles/ colors/ data/ style registry, palettes, lookup tables
|
||||
├── data/ the public datasets the maps ship with
|
||||
├── documentation/ tutorial · how-to · reference · explanation + architecture
|
||||
├── internal/ the build generator + the verification gates
|
||||
└── public/doxa-maps/ the BUILD OUTPUT — the drop-anywhere tree (bundles,
|
||||
leaf pages, manifest.json). What gets served + embedded.
|
||||
```
|
||||
|
||||
## 3. The Plugin Studio
|
||||
|
||||
★ **This is where you work — and the keyword is INTEROPERABILITY.**
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 621 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 633 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 495 KiB |
|
Before Width: | Height: | Size: 904 KiB After Width: | Height: | Size: 686 KiB |
BIN
documentation/readme-images/deploy1-iframe-hostsite.png
Normal file
|
After Width: | Height: | Size: 565 KiB |
BIN
documentation/readme-images/deploy3-js-embed-hostsite.png
Normal file
|
After Width: | Height: | Size: 603 KiB |
126
internal/check-packet-render.mjs
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
// check-packet-render — the UPG-100 prayer packet must RENDER, not merely generate.
|
||||
//
|
||||
// Every bug this gate exists for produced a file of the right size, with no error, no
|
||||
// broken image, and a generator that reported success. They were only visible at the
|
||||
// RENDERED layer, which is why this drives a real browser and reads computed style.
|
||||
//
|
||||
// ★ THE LAW THIS GATE ENCODES: never print a number without asserting on it. An earlier
|
||||
// version of this probe PRINTED "PDF pages: 16" for several runs while reporting
|
||||
// "✓ packet verified", because the failure counter never looked at that value. A number
|
||||
// that is displayed but not asserted on is decoration, not verification.
|
||||
//
|
||||
// What it catches, each measured on a real regression:
|
||||
// 1. PAGE COUNT. transform:scale() is PAINT-time, so a scaled sheet still RESERVES its
|
||||
// full layout box and the paginator splits it — 8 sheets came out as 16 PDF pages,
|
||||
// inking only the top of each. (zoom is LAYOUT-time and paginates correctly.)
|
||||
// The packet is printed front-and-back, so an exact page count is load-bearing.
|
||||
// 2. MAP PALETTE. The map SVG carries almost no presentation attributes; it is styled
|
||||
// from the poster document's stylesheet. Lift the <svg> without those rules and SVG
|
||||
// `fill` falls back to its INITIAL value, which is BLACK — 670 elements painted solid
|
||||
// black with no error of any kind. Asserted as a WHOLE-MAP property, because the first
|
||||
// attempt at this check tested one hand-picked <rect> (the one just repaired) and went
|
||||
// green while 647 others were still black.
|
||||
// 3. Content the Driver specified explicitly: Mark 5:1-20 in full, 12 wheel segments,
|
||||
// the prayer rhythms, and exactly two minimal metacamp.org attributions.
|
||||
//
|
||||
// Usage: node internal/check-packet-render.mjs [path/to/packet.html]
|
||||
// Default path is overridable with PACKET_HTML so this is not machine-specific.
|
||||
|
||||
import { chromium } from 'playwright'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
const target = process.argv[2] || process.env.PACKET_HTML
|
||||
if (!target) {
|
||||
console.log('PACKET-RENDER: SKIP — no packet given (pass a path or set PACKET_HTML).')
|
||||
console.log(' The packet is generated on demand, so this gate is opt-in rather than')
|
||||
console.log(' failing a build on a file that is not expected to exist.')
|
||||
process.exit(0)
|
||||
}
|
||||
const file = resolve(target)
|
||||
if (!existsSync(file)) {
|
||||
console.error(`PACKET-RENDER: FAIL — no such packet: ${file}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage({ viewport: { width: 1500, height: 1941 } })
|
||||
const errs = []
|
||||
page.on('pageerror', (e) => errs.push(String(e).slice(0, 120)))
|
||||
await page.goto(pathToFileURL(file).href, { waitUntil: 'networkidle' })
|
||||
|
||||
const r = await page.evaluate(() => {
|
||||
const body = document.body.innerText
|
||||
const imgs = [...document.querySelectorAll('.card img')]
|
||||
const RHYTHMS = ['PRAISE', 'WAIT', 'CONFESS', 'READ THE WORD', 'ASK', 'INTERCESSION',
|
||||
'PRAY THE WORD', 'THANK', 'SING', 'MEDITATE', 'LISTEN']
|
||||
|
||||
// Whole-map property, NOT one sampled element — see header note 2.
|
||||
const mapFaults = (() => {
|
||||
const svg = document.querySelector('.sheet svg')
|
||||
if (!svg) return ['no map svg found']
|
||||
const faults = []
|
||||
const names = [...new Set((svg.outerHTML.match(/var\((--[a-z0-9-]+)/g) || []).map((v) => v.slice(4)))]
|
||||
const cs = getComputedStyle(svg)
|
||||
for (const n of names) if (!cs.getPropertyValue(n).trim()) faults.push(`unresolved ${n}`)
|
||||
const all = [...svg.querySelectorAll('path,circle,text,rect,line')]
|
||||
const black = all.filter((e) => getComputedStyle(e).fill === 'rgb(0, 0, 0)')
|
||||
if (all.length && black.length > all.length * 0.25) {
|
||||
faults.push(`${black.length}/${all.length} map elements fill BLACK`)
|
||||
}
|
||||
return faults
|
||||
})()
|
||||
|
||||
return {
|
||||
sheets: document.querySelectorAll('.sheet').length,
|
||||
cards: document.querySelectorAll('.card').length,
|
||||
imgs: imgs.length,
|
||||
broken: imgs.filter((i) => !i.complete || i.naturalWidth === 0).length,
|
||||
nophoto: document.querySelectorAll('.nophoto').length,
|
||||
rhythms: RHYTHMS.filter((w) => body.includes(w)).length,
|
||||
rhythmsTotal: RHYTHMS.length,
|
||||
wheelSegs: document.querySelectorAll('svg path[fill^="hsl"]').length,
|
||||
hasMark5: body.includes('Gadarenes'), // a word unique to Mark 5:1-20
|
||||
metacamp: (body.match(/metacamp\.org/g) || []).length,
|
||||
mapFaults,
|
||||
}
|
||||
})
|
||||
|
||||
const pdf = await page.pdf({ preferCSSPageSize: true })
|
||||
await browser.close()
|
||||
|
||||
const raw = pdf.toString('latin1')
|
||||
const boxes = [...raw.matchAll(/MediaBox\s*\[\s*0\s+0\s+([\d.]+)\s+([\d.]+)\s*\]/g)].map((m) => [+m[1], +m[2]])
|
||||
const pages = (raw.match(/\/Type\s*\/Page[^s]/g) || []).length
|
||||
const landscape = boxes.filter(([w, h]) => h <= w).length
|
||||
|
||||
// Counts are RELATIVE (sheets vs pages, cards vs photos) so this gate is not pinned to
|
||||
// one zone's group count — a per-zone packet has a different number of both.
|
||||
const fails = []
|
||||
if (!r.sheets) fails.push('no .sheet elements — the packet did not render')
|
||||
if (!r.cards) fails.push('no people-group cards')
|
||||
if (r.broken) fails.push(`${r.broken} broken photo(s)`)
|
||||
if (r.wheelSegs !== 12) fails.push(`prayer wheel has ${r.wheelSegs} segments, expected 12`)
|
||||
if (r.rhythms !== r.rhythmsTotal) fails.push(`only ${r.rhythms}/${r.rhythmsTotal} prayer rhythms present`)
|
||||
if (!r.hasMark5) fails.push('Mark 5:1-20 is NOT printed in full (Driver requirement for point 4)')
|
||||
if (r.metacamp !== 2) fails.push(`${r.metacamp} metacamp.org attributions, expected exactly 2`)
|
||||
if (r.mapFaults.length) fails.push(`map: ${r.mapFaults.join('; ')}`)
|
||||
if (errs.length) fails.push(`${errs.length} page error(s): ${errs[0]}`)
|
||||
if (landscape) fails.push(`${landscape} landscape page(s) — the packet must be portrait`)
|
||||
if (pages !== r.sheets) fails.push(`${pages} PDF pages for ${r.sheets} sheets — one sheet must be exactly one page`)
|
||||
|
||||
console.log(` sheets/PDF pages : ${r.sheets} / ${pages}`)
|
||||
console.log(` people cards : ${r.cards} (${r.imgs} photos, ${r.nophoto} typographic)`)
|
||||
console.log(` wheel segments : ${r.wheelSegs}`)
|
||||
console.log(` Mark 5 in full : ${r.hasMark5}`)
|
||||
console.log(` metacamp.org : ${r.metacamp}`)
|
||||
console.log(` map : ${r.mapFaults.length ? r.mapFaults.join('; ') : 'palette resolves, not black'}`)
|
||||
console.log(` page size : ${boxes[0] ? boxes[0].join(' x ') : '?'} pt`)
|
||||
|
||||
if (fails.length) {
|
||||
console.error(`\nPACKET-RENDER: FAIL — ${fails.length} problem(s)`)
|
||||
for (const f of fails) console.error(` - ${f}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`\nPACKET-RENDER: PASS — ${r.sheets} sheets, ${r.sheets} pages, portrait, map painted, content intact.`)
|
||||
|
|
@ -116,6 +116,15 @@ createServer((req, res) => {
|
|||
'content-type': TYPES[extname(file).toLowerCase()] || 'application/octet-stream',
|
||||
'content-length': st.size,
|
||||
'cache-control': 'no-cache',
|
||||
// ★ CORS: the tree is PUBLIC STATIC ASSETS built to be embedded on OTHER sites (the
|
||||
// drop-anywhere contract). A cross-origin <script> tag loads fine without this, but the
|
||||
// composed surfaces (cap-4/cap-5) then FETCH manifest.json + assets/ -- and fetch()
|
||||
// enforces CORS, so on any third-party host the shell mounted and every manifest-composed
|
||||
// tab died with 'Could not load ...' (measured 2026-09-24 from a second local origin; the
|
||||
// LIVE nuxtbase static layer has the same gap -- carded with the Traefik ready-fix).
|
||||
// ACAO:* is the standard for credential-less public assets; production parity is the
|
||||
// whole point of this server, so it must teach the same requirement.
|
||||
'access-control-allow-origin': '*',
|
||||
})
|
||||
createReadStream(file).pipe(res)
|
||||
}).listen(PORT, HOST, () => {
|
||||
|
|
|
|||
161
library/scripts/posters/packet-strings.json
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
{
|
||||
"_meta": {
|
||||
"purpose": "EVERY human-readable string in the UPG-100 prayer packet, in one file, so a new language is a new block here and NOT a code change. packet.mjs reads LANG (default 'en') and falls back to 'en' key-by-key, so a partial translation renders the translated keys and leaves the rest in English rather than blanking.",
|
||||
"how_to_translate": "Copy the \"en\" block, change the key to the ISO code, translate the values. Keep {placeholders} exactly as they are — they are substituted at render time. Do not translate scripture REFERENCES (book names are resolved by the reader's own Bible); DO translate the prayer text.",
|
||||
"note_on_scripture": "point4_passage is the ONLY full scripture text in the packet (Mark 5:1-20, printed in full at the Driver's instruction). A translation must supply its own rendering of that passage in a public-domain or properly-licensed version for that language."
|
||||
},
|
||||
"en": {
|
||||
"map_page_kicker": "UNENGAGED PEOPLE GROUPS",
|
||||
"map_page_sub": "A prayer list of {count} unreached people groups",
|
||||
"list_header": "{from}–{to} of {count} · pray for them by name",
|
||||
"people_suffix": "people",
|
||||
"no_photo": "no photo available",
|
||||
"points_title": "12 Prayer Points",
|
||||
"points_intro": "One hour with God, five minutes each. Pray these twelve for the people groups in this packet.",
|
||||
"points_attrib": "Prayer rhythms inspired by Metacamp · metacamp.org",
|
||||
"wheel_title": "Prayer Wheel",
|
||||
"wheel_intro": "Spend an hour with God? It is easier than you think. Break it into 5-minute blocks and focus on one of these during each segment.",
|
||||
"wheel_minutes": "minutes",
|
||||
"wheel_attrib": "Prayer Wheel · Metacamp Module 1 · metacamp.org",
|
||||
"privacy_note": "Marker positions are approximate · people-group locations are generalised at the source for privacy",
|
||||
"rhythms": {
|
||||
"praise": "PRAISE",
|
||||
"wait": "WAIT",
|
||||
"confess": "CONFESS",
|
||||
"read": "READ THE WORD",
|
||||
"ask": "ASK",
|
||||
"intercede": "INTERCESSION",
|
||||
"pray_word": "PRAY THE WORD",
|
||||
"thank": "THANK",
|
||||
"sing": "SING",
|
||||
"meditate": "MEDITATE",
|
||||
"listen": "LISTEN",
|
||||
"praise_end": "PRAISE"
|
||||
},
|
||||
"points": [
|
||||
{
|
||||
"n": 1,
|
||||
"rhythm": "praise",
|
||||
"sub": "Two-Cross Gospel",
|
||||
"text": "That they accept Christ as King, forgiven by His cross — and take up their own cross, willing to suffer in a persecution zone. May the Lamb receive the reward of His suffering, starting with me.",
|
||||
"refs": [
|
||||
"Matthew 27:27",
|
||||
"Matthew 16:24–25"
|
||||
]
|
||||
},
|
||||
{
|
||||
"n": 2,
|
||||
"rhythm": "wait",
|
||||
"sub": "Holy Spirit & deliverance",
|
||||
"text": "That they are filled with the Spirit and made bold witnesses; not isolated; baptized in water and Spirit; every evil grip broken. That believers in neighbouring peoples are mobilised as laborers.",
|
||||
"refs": [
|
||||
"Acts 1:8",
|
||||
"John 14:26"
|
||||
]
|
||||
},
|
||||
{
|
||||
"n": 3,
|
||||
"rhythm": "confess",
|
||||
"sub": "",
|
||||
"text": "We confess unbelief that this is too big for God. That He revives our love and faith for the global unreached.",
|
||||
"refs": [
|
||||
"John 3:16",
|
||||
"1 John 1:9"
|
||||
]
|
||||
},
|
||||
{
|
||||
"n": 4,
|
||||
"rhythm": "read",
|
||||
"sub": "the Father's love",
|
||||
"text": "That they would be SAVED and KNOW their heavenly Father loves them — and that through them He would bring salvation to their family, friends, neighbours, coworkers, classmates, everyone they know personally.",
|
||||
"refs": [
|
||||
"Mark 5:1–20",
|
||||
"Acts 16:31"
|
||||
],
|
||||
"print_passage": true
|
||||
},
|
||||
{
|
||||
"n": 5,
|
||||
"rhythm": "ask",
|
||||
"sub": "baptisms",
|
||||
"text": "New baptisms among the locals, and every baptized one trained to baptize others, multiplying.",
|
||||
"refs": [
|
||||
"Acts 16",
|
||||
"Acts 10"
|
||||
]
|
||||
},
|
||||
{
|
||||
"n": 6,
|
||||
"rhythm": "intercede",
|
||||
"sub": "laborers & shepherds",
|
||||
"text": "Laborers sent to deeply love a people they have never met; good shepherds raised up from within.",
|
||||
"refs": [
|
||||
"Luke 10:2",
|
||||
"John 10:11–15"
|
||||
]
|
||||
},
|
||||
{
|
||||
"n": 7,
|
||||
"rhythm": "pray_word",
|
||||
"sub": "Persons & Houses of Peace",
|
||||
"text": "That laborers and local leaders find the receptive person and the house of peace, and disciple them well.",
|
||||
"refs": [
|
||||
"Luke 10:5–7",
|
||||
"Matthew 10:5–14"
|
||||
]
|
||||
},
|
||||
{
|
||||
"n": 8,
|
||||
"rhythm": "thank",
|
||||
"sub": "rest",
|
||||
"text": "Thank God for the rest He has given; that they enter that rest in Christ, and that their spiritual family is a safe place.",
|
||||
"refs": [
|
||||
"Mark 4:26–29",
|
||||
"Hebrews 4:9–11"
|
||||
]
|
||||
},
|
||||
{
|
||||
"n": 9,
|
||||
"rhythm": "sing",
|
||||
"sub": "",
|
||||
"text": "That the people group is flooded with worshippers — worship everywhere, in freedom and in chains.",
|
||||
"refs": [
|
||||
"Acts 16:25–26",
|
||||
"Colossians 3:16"
|
||||
]
|
||||
},
|
||||
{
|
||||
"n": 10,
|
||||
"rhythm": "meditate",
|
||||
"sub": "communion",
|
||||
"text": "That they take communion together and grasp the weight of His death, burial and resurrection.",
|
||||
"refs": [
|
||||
"Matthew 26:26–30",
|
||||
"1 Corinthians 11:23–26"
|
||||
]
|
||||
},
|
||||
{
|
||||
"n": 11,
|
||||
"rhythm": "listen",
|
||||
"sub": "accountability",
|
||||
"text": "Not only that they would listen, but that they would have people in their life who help them read the Word, pray, commit to community, and respond to suffering well — resisting the adversary who comes after believers across the whole world.",
|
||||
"refs": [
|
||||
"1 Peter 5:6–11",
|
||||
"Hebrews 10:24–25"
|
||||
]
|
||||
},
|
||||
{
|
||||
"n": 12,
|
||||
"rhythm": "praise_end",
|
||||
"sub": "great · greater · greatest",
|
||||
"text": "Praise in advance — for salvations; GREATER, for spiritual families multiplying; GREATEST, for lives yielded and local church planters raised up who multiply healthy churches across the whole people group, no matter the cost.",
|
||||
"refs": [
|
||||
"Acts 2:42–47",
|
||||
"Matthew 28:19–20"
|
||||
]
|
||||
}
|
||||
],
|
||||
"point4_passage": "And they came to the other side of the sea, into the country of the Gadarenes. When he had come out of the boat, immediately a man with an unclean spirit met him out of the tombs. He lived in the tombs. Nobody could bind him any more, not even with chains, because he had been often bound with fetters and chains, and the chains had been torn apart by him, and the fetters broken in pieces. Nobody had the strength to tame him. Always, night and day, in the tombs and in the mountains, he was crying out, and cutting himself with stones. When he saw Jesus from afar, he ran and bowed down to him, and crying out with a loud voice, he said, “What have I to do with you, Jesus, you Son of the Most High God? I adjure you by God, don’t torment me.” For he said to him, “Come out of the man, you unclean spirit!” He asked him, “What is your name?” He said to him, “My name is Legion, for we are many.” He begged him much that he would not send them away out of the country. Now on the mountainside there was a great herd of pigs feeding. All the demons begged him, saying, “Send us into the pigs, that we may enter into them.” At once Jesus gave them permission. The unclean spirits came out and entered into the pigs. The herd of about two thousand rushed down the steep bank into the sea, and they were drowned in the sea. Those who fed them fled, and told it in the city and in the country. The people came to see what it was that had happened. They came to Jesus, and saw him who had been possessed by demons sitting, clothed, and in his right mind, even him who had the legion; and they were afraid. Those who saw it declared to them what happened to him who was possessed by demons, and about the pigs. They began to beg him to depart from their region. As he was entering into the boat, he who had been possessed by demons begged him that he might be with him. He didn’t allow him, but said to him, “Go to your house, to your friends, and tell them what great things the Lord has done for you and how he had mercy on you.” He went his way, and began to proclaim in Decapolis how Jesus had done great things for him, and everyone marveled.",
|
||||
"point4_passage_credit": "Mark 5:1–20 · World English Bible (public domain)"
|
||||
}
|
||||
}
|
||||
411
library/scripts/posters/packet.mjs
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
// packet.mjs — the UPG-100 PRAYER PACKET generator.
|
||||
//
|
||||
// One printable packet per zone. Structure is the Driver's, from the ideation
|
||||
// (_qa-cycle/01-ideation-research/prayer-points.md):
|
||||
//
|
||||
// page 1 the MAP — a lightweight vertical one-pager, NOT the poster format
|
||||
// pages 2..n the PEOPLE GROUPS — 20 per page, photo + name + brief detail
|
||||
// page n+1 the 12 PRAYER POINTS
|
||||
// page n+2 the PRAYER WHEEL — the rhythm these twelve points follow
|
||||
//
|
||||
// Printed front-and-back: Laos (95 groups) is 8 pages = 4 sheets.
|
||||
//
|
||||
// ── WHY A SEPARATE SCRIPT, not a FORMAT inside generate.mjs ──────────────────
|
||||
// generate.mjs is a 2,600-line POSTER renderer. The packet is a different artefact
|
||||
// with different pages; forking it would duplicate the design. Instead this reads
|
||||
// the SAME zone resolution (clusters.json + the selector evaluator) so a packet can
|
||||
// never disagree with its poster about which groups belong to a zone.
|
||||
//
|
||||
// ── EVERY STRING IS IN packet-strings.json ──────────────────────────────────
|
||||
// Driver: "it all needs to be generative so that I can generate it in multiple
|
||||
// languages." So no human-readable text is written in this file. LANG=xx picks a
|
||||
// block; missing keys fall back to English individually, so a partial translation
|
||||
// renders what it has rather than blanking the page.
|
||||
//
|
||||
// ── PHOTOS ──────────────────────────────────────────────────────────────────
|
||||
// MEASURED: 1,072 of 2,107 groups have one (51%); they average ~244KB on Joshua
|
||||
// Project's CDN, ~0.8s each. Fetched in parallel at packet time and inlined as
|
||||
// data URIs so the finished HTML is ONE self-contained file that prints anywhere.
|
||||
// Groups without a photo get a typographic card, never a broken image box.
|
||||
//
|
||||
// Usage:
|
||||
// node packet.mjs # every zone, English
|
||||
// ONLY=laos node packet.mjs # one zone
|
||||
// LANG=es ONLY=laos node packet.mjs # one zone, Spanish
|
||||
// NOPHOTOS=1 ONLY=laos node packet.mjs # skip the fetch (fast layout iteration)
|
||||
//
|
||||
// Output: out/packets/<zoneKey>[.<lang>].html
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const DATA = join(__dirname, '../../../plugin-designs/my-upg-100-list/data');
|
||||
const OUT = join(__dirname, 'out', 'packets');
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
|
||||
// ⚠ NOT process.env.LANG — that is the SHELL's POSIX locale ("en_US.UTF-8"), which
|
||||
// is not a valid BCP-47 tag and throws in localeCompare/toLocaleString. Measured:
|
||||
// it crashed the first run. PACKET_LANG is the only source.
|
||||
const LANG = process.env.PACKET_LANG || 'en';
|
||||
const ONLY = process.env.ONLY || '';
|
||||
const NOPHOTOS = process.env.NOPHOTOS === '1';
|
||||
const API = process.env.VITE_API_BASE_URL || process.env.MAP_APP_API_URL;
|
||||
if (!API) {
|
||||
console.error('[packet] No API base — set VITE_API_BASE_URL. Failing closed.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── STRINGS: pick the language, fall back per-key to English ────────────────
|
||||
const STRINGS = JSON.parse(readFileSync(join(__dirname, 'packet-strings.json'), 'utf8'));
|
||||
const EN = STRINGS.en;
|
||||
const L = STRINGS[LANG] || {};
|
||||
// Per-KEY fallback (not whole-block): a half-finished translation should render
|
||||
// its finished keys, not fall back wholesale to English.
|
||||
const t = (k) => (L[k] !== undefined ? L[k] : EN[k]);
|
||||
const tr = (k) => ((L.rhythms || {})[k] !== undefined ? L.rhythms[k] : EN.rhythms[k]);
|
||||
const POINTS = L.points || EN.points;
|
||||
const fill = (s, vars) => String(s).replace(/\{(\w+)\}/g, (_, k) => (vars[k] ?? ''));
|
||||
|
||||
// ── SHEET GEOMETRY — the letter-portrait sheet, same as the poster format ───
|
||||
const W = 1500, H = 1941; // CSS px; 8.5x11 at width 1500
|
||||
const PAGE_W_IN = 8.5;
|
||||
const SCALE = (PAGE_W_IN * 96 / W).toFixed(6); // print scale: one sheet = one page
|
||||
const M = 60, HEAD = 90; // margins + running header
|
||||
const COLS = 2, ROWS = 10, PER_PAGE = COLS * ROWS;
|
||||
const GUT = 14;
|
||||
const CW = (W - 2 * M - GUT * (COLS - 1)) / COLS;
|
||||
const CH = (H - 2 * M - HEAD - GUT * (ROWS - 1)) / ROWS;
|
||||
const PHOTO = Math.min(CH - 8, CW * 0.34);
|
||||
|
||||
const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
// The API returns some fields as { value, label } dicts; the card wants the label.
|
||||
const flat = (v) => (v && typeof v === 'object' ? (v.label ?? v.value ?? '') : (v ?? ''));
|
||||
const num = (n) => (Number.isFinite(Number(n)) ? Number(n).toLocaleString(LANG) : '—');
|
||||
|
||||
// ── DATA ────────────────────────────────────────────────────────────────────
|
||||
const clusters = JSON.parse(readFileSync(join(DATA, 'clusters.json'), 'utf8'));
|
||||
const { evaluate } = await import(join(DATA, 'selector-evaluator.js'));
|
||||
const { normalizeRecords, extractRecords } = await import(join(DATA, 'normalize.js'));
|
||||
|
||||
async function loadRecords() {
|
||||
const cache = join(__dirname, 'out', '_records.json');
|
||||
if (process.env.OFFLINE && existsSync(cache)) return JSON.parse(readFileSync(cache, 'utf8'));
|
||||
const fields = 'slug,name,country_code,population,religion,image_url,has_photo,picture_credit,latitude,longitude';
|
||||
const r = await fetch(`${API}/api/people-groups/list?fields=${fields}&lang=${LANG}`,
|
||||
{ headers: { accept: 'application/json' } });
|
||||
if (!r.ok) throw new Error(`PG API ${r.status}`);
|
||||
const recs = normalizeRecords(extractRecords(await r.json()));
|
||||
try { writeFileSync(cache, JSON.stringify(recs)); } catch { /* best effort */ }
|
||||
return recs;
|
||||
}
|
||||
|
||||
function allZones() {
|
||||
const out = [];
|
||||
for (const k of ['lists', 'asia_lists', 'uncovered_lists']) {
|
||||
for (const z of clusters[k] || []) out.push({ ...z, zoneKey: z.id });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── PHOTOS: parallel fetch, inlined as data URIs ────────────────────────────
|
||||
// Bounded concurrency: 16 was measured at 20 photos in 5.9s. Unbounded would open
|
||||
// ~118 sockets against someone else's CDN, which is rude and slower in practice.
|
||||
async function fetchPhotos(groups, limit = 16) {
|
||||
if (NOPHOTOS) return new Map();
|
||||
const out = new Map();
|
||||
let i = 0;
|
||||
const worker = async () => {
|
||||
while (i < groups.length) {
|
||||
const g = groups[i++];
|
||||
if (!g.image_url) continue;
|
||||
try {
|
||||
const r = await fetch(g.image_url, { signal: AbortSignal.timeout(20000) });
|
||||
if (!r.ok) continue;
|
||||
const buf = Buffer.from(await r.arrayBuffer());
|
||||
const mime = r.headers.get('content-type') || 'image/jpeg';
|
||||
out.set(g.slug, `data:${mime};base64,${buf.toString('base64')}`);
|
||||
} catch { /* a missing photo degrades to the typographic card */ }
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(limit, groups.length) }, worker));
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── PAGE SHELL ──────────────────────────────────────────────────────────────
|
||||
const sheet = (inner, cls = '') =>
|
||||
`<div class="sheet ${cls}">${inner}</div>`;
|
||||
|
||||
function css() {
|
||||
return `
|
||||
@page{size:letter portrait;margin:0}
|
||||
html,body{margin:0;background:#fff;-webkit-print-color-adjust:exact;print-color-adjust:exact}
|
||||
.sheet{position:relative;width:${W}px;height:${H}px;background:#f7f4ec;overflow:hidden;
|
||||
page-break-after:always;break-after:page}
|
||||
.sheet:last-child{page-break-after:auto;break-after:auto}
|
||||
.hd{position:absolute;left:${M}px;top:46px;font:700 30px Georgia,'Palatino Linotype',serif;color:#1b1a17}
|
||||
.hd-r{position:absolute;right:${M}px;top:54px;font:400 19px Georgia,serif;color:#8c8170}
|
||||
.card{position:absolute;display:flex;gap:12px;align-items:center;
|
||||
border-bottom:1px solid #e2ddd0;padding-bottom:6px}
|
||||
.card img,.card .nophoto{width:${PHOTO.toFixed(0)}px;height:${PHOTO.toFixed(0)}px;flex:0 0 auto;border-radius:3px}
|
||||
.card img{object-fit:cover}
|
||||
.card .nophoto{border:1px solid #d6d0c0;background:#f2efe6;display:flex;align-items:center;
|
||||
justify-content:center;font:italic 13px Georgia,serif;color:#a89f8c;text-align:center;padding:6px}
|
||||
.nm{font:700 25px Georgia,serif;color:#1b1a17;line-height:1.1}
|
||||
.mt{font:400 17px Georgia,serif;color:#6d766e}
|
||||
/* FINE PRINT — Driver: "attribution minimal, fine print small". */
|
||||
.fine{position:absolute;left:0;right:0;bottom:22px;text-align:center;
|
||||
font:400 13px Georgia,serif;color:#a89f8c}
|
||||
/* PRINT. The sheet is authored at ${W}px wide while @page is physical, so without
|
||||
a rescale the browser maps ${W}px -> 15.6in and TILES one sheet over many pages.
|
||||
|
||||
⚠ Use zoom, NOT transform:scale(). They look identical on screen and behave
|
||||
completely differently under pagination:
|
||||
transform is paint-time — the element still RESERVES its full ${H}px layout
|
||||
box, so the paginator walks ${H}px of content and breaks it across
|
||||
two sheets, inking only the top of the first. Measured: 8 sheets
|
||||
came out as 16 PDF pages. Negative margins do NOT fix this — they
|
||||
collapse the outer space, not the fragmentation box.
|
||||
zoom is layout-time — the box itself becomes ${(PAGE_W_IN*96).toFixed(0)}px
|
||||
wide, so pagination sees the scaled size. Measured: 1 page/sheet.
|
||||
Verified by _probe-pagecount.mjs (transform 2, transform+margins 2, zoom 1). */
|
||||
@media print{
|
||||
html,body{width:${(PAGE_W_IN * 96).toFixed(0)}px;margin:0;padding:0}
|
||||
.sheet{width:${W}px;height:${H}px;zoom:${SCALE};
|
||||
page-break-after:always;break-after:page}
|
||||
.sheet:last-child{page-break-after:auto;break-after:auto}
|
||||
}`;
|
||||
}
|
||||
|
||||
// Lift the poster's stylesheet so the inlined map keeps its palette and map rules,
|
||||
// dropping only the rules that would fight the packet's own page shell.
|
||||
//
|
||||
// Comments are stripped FIRST and deliberately: generate.mjs's CSS comments are long
|
||||
// prose that contains braces and selector-like text, so any brace-counting or
|
||||
// selector regex run over the raw source mis-parses them as rules. Strip, then split.
|
||||
function mapCss(posterHtml) {
|
||||
const block = posterHtml.match(/<style>([\s\S]*?)<\/style>/);
|
||||
if (!block) return '';
|
||||
const css = block[1].replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
|
||||
// Drop @media (the poster's own print-scaling rules, which do not apply here).
|
||||
// Depth-aware because @media bodies nest a further level of braces.
|
||||
let out = '', depth = 0, i = 0;
|
||||
while (i < css.length) {
|
||||
if (css.startsWith('@media', i)) {
|
||||
let d = 0, started = false;
|
||||
while (i < css.length) {
|
||||
if (css[i] === '{') { d++; started = true; }
|
||||
else if (css[i] === '}') { d--; if (started && d === 0) { i++; break; } }
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
out += css[i++];
|
||||
}
|
||||
|
||||
// Drop document-level rules: these style the POSTER page, not the map, and would
|
||||
// override the packet's background, box-sizing and stage geometry.
|
||||
const DOC = /(^|,)\s*(html|body|\*|\.stage)\s*$/;
|
||||
return out.split('}')
|
||||
.map((r) => {
|
||||
const at = r.indexOf('{');
|
||||
if (at < 0) return '';
|
||||
const sel = r.slice(0, at).trim();
|
||||
if (!sel) return '';
|
||||
// :root must be KEPT — it carries the palette the map's var()s resolve against.
|
||||
if (sel !== ':root' && sel.split(',').some((s) => DOC.test(s.trim()) || /^(html|body|\*|\.stage)$/.test(s.trim()))) return '';
|
||||
return `${sel}{${r.slice(at + 1).trim()}}`;
|
||||
})
|
||||
.filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
// ── PAGE 1: the map one-pager ───────────────────────────────────────────────
|
||||
// Lightweight by design — the Driver asked for "a lightweight one pager that shows
|
||||
// the geography with all the pins", explicitly NOT the poster. The poster remains
|
||||
// its own artefact (generate.mjs); this is the packet's cover.
|
||||
function mapPage(zone, groups, mapSvg) {
|
||||
const count = groups.length;
|
||||
return sheet(`
|
||||
<div style="position:absolute;left:${M}px;top:${M}px;right:${M}px;text-align:center">
|
||||
<div style="font:600 21px Georgia,serif;letter-spacing:3px;color:#8c8170">${esc(t('map_page_kicker'))}</div>
|
||||
<div style="font:700 56px Georgia,serif;color:#1b1a17;margin-top:8px">${esc(zone.label || zone.id)}</div>
|
||||
<div style="font:italic 24px Georgia,serif;color:#6d766e;margin-top:10px">
|
||||
${esc(fill(t('map_page_sub'), { count: num(count) }))}</div>
|
||||
</div>
|
||||
${mapSvg}
|
||||
<div class="fine">${esc(t('privacy_note'))}</div>`);
|
||||
}
|
||||
|
||||
// ── PAGES 2..n: the people groups, 20 per sheet ─────────────────────────────
|
||||
function listPages(zone, groups, photos) {
|
||||
const pages = [];
|
||||
for (let start = 0; start < groups.length; start += PER_PAGE) {
|
||||
const slice = groups.slice(start, start + PER_PAGE);
|
||||
const cards = slice.map((g, k) => {
|
||||
const i = start + k;
|
||||
const c = k % COLS, r = Math.floor(k / COLS);
|
||||
const left = M + c * (CW + GUT), top = M + HEAD + r * (CH + GUT);
|
||||
const src = photos.get(g.slug);
|
||||
const img = src
|
||||
? `<img src="${src}" alt="">`
|
||||
: `<div class="nophoto">${esc(t('no_photo'))}</div>`;
|
||||
return `<div class="card" style="left:${left.toFixed(0)}px;top:${top.toFixed(0)}px;
|
||||
width:${CW.toFixed(0)}px;height:${CH.toFixed(0)}px">${img}
|
||||
<div style="min-width:0">
|
||||
<div class="nm">${i + 1}. ${esc(g.name)}</div>
|
||||
<div class="mt" style="margin-top:4px">${esc(flat(g.religion))}</div>
|
||||
<div class="mt">${num(g.population)} ${esc(t('people_suffix'))} · ${esc(flat(g.country_code))}</div>
|
||||
</div></div>`;
|
||||
}).join('');
|
||||
pages.push(sheet(`
|
||||
<div class="hd">${esc(zone.label || zone.id)}</div>
|
||||
<div class="hd-r">${esc(fill(t('list_header'),
|
||||
{ from: num(start + 1), to: num(Math.min(start + PER_PAGE, groups.length)), count: num(groups.length) }))}</div>
|
||||
${cards}`));
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
// ── PAGE n+1: the 12 prayer points ──────────────────────────────────────────
|
||||
function pointsPage() {
|
||||
const rows = POINTS.map((p) => {
|
||||
const refs = (p.refs || []).map((r) => esc(r)).join(' · ');
|
||||
const sub = p.sub ? ` <span style="font:italic 17px Georgia,serif;color:#8c8170">— ${esc(p.sub)}</span>` : '';
|
||||
const passage = p.print_passage
|
||||
// Point 4 prints Mark 5:1-20 IN FULL. Driver: "it actually lists out the full
|
||||
// scripture, because prayer point number four is actually read the word."
|
||||
? `<div style="font:400 14px Georgia,serif;color:#4a4a45;line-height:1.5;margin-top:5px;
|
||||
padding-left:10px;border-left:2px solid #d9d3c4">${esc(t('point4_passage'))}
|
||||
<div style="font:italic 12px Georgia,serif;color:#a89f8c;margin-top:3px">
|
||||
${esc(t('point4_passage_credit'))}</div></div>`
|
||||
: '';
|
||||
return `<div style="margin-bottom:${p.print_passage ? 10 : 13}px">
|
||||
<div style="font:700 19px Georgia,serif;color:#1b1a17">
|
||||
${p.n}. ${esc(tr(p.rhythm))}${sub}</div>
|
||||
<div style="font:400 17px Georgia,serif;color:#3b3226;line-height:1.35;margin-top:2px">${esc(p.text)}</div>
|
||||
<div style="font:600 14px Georgia,serif;color:#7a3b2e;margin-top:2px">${refs}</div>
|
||||
${passage}
|
||||
</div>`;
|
||||
}).join('');
|
||||
return sheet(`
|
||||
<div style="position:absolute;left:${M}px;top:44px;right:${M}px">
|
||||
<div style="font:700 40px Georgia,serif;color:#1b1a17">${esc(t('points_title'))}</div>
|
||||
<div style="font:italic 18px Georgia,serif;color:#6d766e;margin-top:6px">${esc(t('points_intro'))}</div>
|
||||
</div>
|
||||
<div style="position:absolute;left:${M}px;top:${M + 96}px;right:${M}px;bottom:52px">${rows}</div>
|
||||
<div class="fine">${esc(t('points_attrib'))}</div>`);
|
||||
}
|
||||
|
||||
// ── PAGE n+2: the prayer wheel ──────────────────────────────────────────────
|
||||
// REDRAWN as SVG rather than embedding the source PDF page: a raster embed cannot
|
||||
// be translated, and this packet must generate in any language. Twelve equal
|
||||
// segments, the rhythm name set along each, 5 minutes each = one hour.
|
||||
function wheelPage() {
|
||||
const cx = W / 2, cy = 1090, R = 470;
|
||||
const order = ['praise', 'wait', 'confess', 'read', 'ask', 'intercede',
|
||||
'pray_word', 'thank', 'sing', 'meditate', 'listen', 'praise_end'];
|
||||
// Hues sweep the wheel so neighbouring rhythms stay distinguishable in print.
|
||||
const seg = order.map((key, i) => {
|
||||
const a0 = (i * 30 - 90) * Math.PI / 180, a1 = ((i + 1) * 30 - 90) * Math.PI / 180;
|
||||
const x0 = cx + R * Math.cos(a0), y0 = cy + R * Math.sin(a0);
|
||||
const x1 = cx + R * Math.cos(a1), y1 = cy + R * Math.sin(a1);
|
||||
const hue = Math.round(i * 30);
|
||||
const mid = (a0 + a1) / 2;
|
||||
const tx = cx + R * 0.62 * Math.cos(mid), ty = cy + R * 0.62 * Math.sin(mid);
|
||||
let deg = mid * 180 / Math.PI;
|
||||
if (deg > 90 || deg < -90) deg += 180; // keep every label upright
|
||||
return `<path d="M ${cx} ${cy} L ${x0.toFixed(1)} ${y0.toFixed(1)} A ${R} ${R} 0 0 1 ${x1.toFixed(1)} ${y1.toFixed(1)} Z"
|
||||
fill="hsl(${hue} 70% 72%)" stroke="#fff" stroke-width="3"/>
|
||||
<text x="${tx.toFixed(1)}" y="${ty.toFixed(1)}" transform="rotate(${deg.toFixed(1)} ${tx.toFixed(1)} ${ty.toFixed(1)})"
|
||||
text-anchor="middle" dominant-baseline="middle"
|
||||
font-family="Georgia,serif" font-weight="700" font-size="21" fill="#2b2a26">${esc(tr(key))}</text>`;
|
||||
}).join('');
|
||||
const pips = order.map((_, i) => {
|
||||
const a = ((i + 0.5) * 30 - 90) * Math.PI / 180;
|
||||
const x = cx + (R + 46) * Math.cos(a), y = cy + (R + 46) * Math.sin(a);
|
||||
return `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="21" fill="#26251f"/>
|
||||
<text x="${x.toFixed(1)}" y="${(y + 1).toFixed(1)}" text-anchor="middle" dominant-baseline="middle"
|
||||
font-family="Georgia,serif" font-weight="700" font-size="20" fill="#fff">5</text>`;
|
||||
}).join('');
|
||||
return sheet(`
|
||||
<div style="position:absolute;left:${M}px;top:48px;right:${M}px;text-align:center">
|
||||
<div style="font:700 42px Georgia,serif;color:#1b1a17">${esc(t('wheel_title'))}</div>
|
||||
<div style="font:italic 19px Georgia,serif;color:#6d766e;margin-top:10px;
|
||||
max-width:1000px;margin-left:auto;margin-right:auto">${esc(t('wheel_intro'))}</div>
|
||||
</div>
|
||||
<svg width="${W}" height="${H}" style="position:absolute;left:0;top:0" xmlns="http://www.w3.org/2000/svg">
|
||||
${seg}${pips}
|
||||
<text x="${cx}" y="${cy + R + 130}" text-anchor="middle"
|
||||
font-family="Georgia,serif" font-size="20" fill="#8c8170">5 ${esc(t('wheel_minutes'))} × 12</text>
|
||||
</svg>
|
||||
<div class="fine">${esc(t('wheel_attrib'))}</div>`);
|
||||
}
|
||||
|
||||
// ── BUILD ONE PACKET ────────────────────────────────────────────────────────
|
||||
async function buildPacket(zone, groups, mapSvg) {
|
||||
const photos = await fetchPhotos(groups);
|
||||
const pages = [
|
||||
mapPage(zone, groups, mapSvg),
|
||||
...listPages(zone, groups, photos),
|
||||
pointsPage(),
|
||||
wheelPage(),
|
||||
];
|
||||
const withPhoto = groups.filter((g) => photos.has(g.slug)).length;
|
||||
const html = `<!DOCTYPE html><html lang="${esc(LANG)}"><head><meta charset="utf-8">
|
||||
<title>${esc(zone.label || zone.id)}</title><style>${css()}</style></head>
|
||||
<body>${pages.join('\n')}</body></html>`;
|
||||
return { html, pageCount: pages.length, withPhoto };
|
||||
}
|
||||
|
||||
// ── MAIN ────────────────────────────────────────────────────────────────────
|
||||
const records = await loadRecords();
|
||||
const bySlug = new Map(records.map((r) => [r.slug, r]));
|
||||
const zones = allZones().filter((z) => !ONLY || z.id === ONLY);
|
||||
if (!zones.length) { console.error(`[packet] no zone matches ONLY=${ONLY}`); process.exit(1); }
|
||||
|
||||
let made = 0;
|
||||
for (const z of zones) {
|
||||
let slugs = [];
|
||||
try {
|
||||
// evaluate() returns a SET, not an array — spread it rather than assuming.
|
||||
slugs = z.selector?.type === 'pg_slugs'
|
||||
? (z.selector.values || [])
|
||||
: (z.selector ? [...evaluate(z.selector, records)] : []);
|
||||
} catch (e) { console.warn(`[packet] selector failed for ${z.id}: ${e.message}`); continue; }
|
||||
const groups = slugs.map((s) => bySlug.get(s)).filter(Boolean)
|
||||
.sort((a, b) => String(a.name).localeCompare(String(b.name), LANG));
|
||||
if (!groups.length) { console.warn(`[packet] ${z.id}: no groups resolved — skipped`); continue; }
|
||||
|
||||
// The map one-pager reuses the poster's SVG when it has been generated; without
|
||||
// it the packet still builds (cover page without the map) rather than failing.
|
||||
const posterPath = join(__dirname, 'out', 'posters', `${z.id}.html`);
|
||||
let mapSvg = '';
|
||||
if (existsSync(posterPath)) {
|
||||
const poster = readFileSync(posterPath, 'utf8');
|
||||
const m = poster.match(/<svg[\s\S]*?<\/svg>/);
|
||||
// ⚠ The map SVG carries almost NO presentation attributes — generate.mjs styles
|
||||
// it entirely from the poster document's <style> block (.ctry, .lbl, .lead, the
|
||||
// :root palette...). Lifting the <svg> alone leaves those rules behind, and SVG
|
||||
// `fill` then falls back to its initial value, which is BLACK. Measured: 349
|
||||
// <text>, 203 <path class="ctry"> and 95 <circle> painted solid black, with no
|
||||
// error and no broken image — the map was a black rectangle.
|
||||
// So carry the poster's stylesheet across too, MINUS its document-level rules
|
||||
// (html/body/.stage/@media print) which would fight the packet's own shell.
|
||||
// Taking the stylesheet rather than duplicating hex codes means a palette or
|
||||
// map-style change in generate.mjs follows the packet automatically.
|
||||
if (m) mapSvg = `<style>${mapCss(poster)}</style>
|
||||
<div style="position:absolute;left:0;top:250px;width:${W}px;
|
||||
transform:scale(0.62);transform-origin:top center">${m[0]}</div>`;
|
||||
}
|
||||
|
||||
const { html, pageCount, withPhoto } = await buildPacket(z, groups, mapSvg);
|
||||
const name = LANG === 'en' ? `${z.id}.html` : `${z.id}.${LANG}.html`;
|
||||
writeFileSync(join(OUT, name), html);
|
||||
made++;
|
||||
console.log(` ✓ ${z.id.padEnd(34)} ${String(groups.length).padStart(4)} groups `
|
||||
+ `${pageCount} pages ${withPhoto} photos ${(html.length / 1024 / 1024).toFixed(1)} MB`);
|
||||
}
|
||||
console.log(`\n${made} packet(s) → ${OUT}${ONLY ? ` (ONLY=${ONLY})` : ''}${LANG !== 'en' ? ` [${LANG}]` : ''}`);
|
||||
|
|
@ -55,6 +55,7 @@
|
|||
"check:heartbeat": "node internal/check-heartbeat-threshold.mjs # the HEARTBEAT ZONE threshold (the choropleth's top bin) must come from config AND the shipped config must still render today's ramp. Two sides: (B) a configured threshold must MOVE the edge — catches re-hard-coding; (A2) dashboard.config.js's own map.* must still resolve to 5/20/60/150/Infinity — catches the shipped default silently changing. A2 was MISSING at first and heartbeatThreshold:999 shipped past this gate printing 'default ramp unchanged'. A grep for '150' cannot do either job: it false-positives on the legitimate default and false-negatives on a re-hard-coding at 200. run check:heartbeat:self-test for the NEGATIVE tests (3 cases).",
|
||||
"check:heartbeat:self-test": "node internal/check-heartbeat-threshold.mjs --self-test # THREE regression cases, each required to fire ITS OWN assertion: [literal] resolver replaced by the old hard-coded array -> B; [shipped-default] dashboard.config.js heartbeatThreshold changed -> A2; [shipped-mode] heartbeatMode flipped to quantile -> A2. Poisons IN MEMORY, never the file. Testing that the FEATURE works is not testing that the REGRESSION is caught — this gate shipped with only the first and missed the second.",
|
||||
"build:unlocked": "bun run gates:pre && bun run library-index && bun run check:library-index && bun run clean && bun run token && bun run build:bundles && bun run tree && bun run gates:post && bun run gallery",
|
||||
"check:packet-render": "node internal/check-packet-render.mjs",
|
||||
"check:template-literal": "node internal/template-literal-check.mjs",
|
||||
"verify:map-paint": "node internal/verify-map-paint.mjs",
|
||||
"check:sri": "node internal/sri-consistency-check.mjs",
|
||||
|
|
|
|||