Merge PanelSheets (wall-of-people-groups-plugin/work, 3ee5ed9fd..f5ec4a180) with the PosterWall host seams, for one capture
This commit is contained in:
commit
356e1063dd
74 changed files with 3173 additions and 445 deletions
154
internal/check-batch-selection-export.mjs
Normal file
154
internal/check-batch-selection-export.mjs
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
#!/usr/bin/env node
|
||||
// check-batch-selection-export — UPG-100's batch selection EXPORTS its selection (restored 2026-09-30).
|
||||
//
|
||||
// WHY IT MATTERS: when the people-group data changes, partners redraw the zones on the map, Copy or Export the
|
||||
// selection, and hand it to an agent that regroups the zones (data/apply-grouping.mjs) and regenerates the
|
||||
// posters. The copy/export path was removed once and the maps stopped being reproducible, so this pins it.
|
||||
//
|
||||
// Headless, no browser, no network. Asserts:
|
||||
// A. Copy's FIRST line is exactly what data/apply-grouping.mjs parses: fed through its own parsePayload, the
|
||||
// text returns the selected territory names, the UUPG total and the colour. The CSV after it must not
|
||||
// change that.
|
||||
// B. The CSV: the header is the declared columns; a cell with a comma, a quote or a line break survives a
|
||||
// real CSV parse; a leading = + - @ is neutralised (a spreadsheet never runs a name as a formula); one row
|
||||
// per people group, carrying the territories that contain it and the picked colour.
|
||||
// C. The HUD wires it: the colour swatches + hex input, Copy and Export CSV buttons with their handlers, and
|
||||
// batch mode prefetching the records so a tap copies inside its user activation.
|
||||
// D. The nine new strings exist in all 11 locales, non-empty, and translated (not English) outside en.
|
||||
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 = []
|
||||
const notes = []
|
||||
const ok = (cond, msg, note) => { if (!cond) fails.push(msg); else if (note) notes.push(` ok ${note}`) }
|
||||
|
||||
let X, parsePayload, runApply, formatClipboard
|
||||
try {
|
||||
X = await import(pathToFileURL(P('plugin-designs/my-upg-100-list/data/selection-export.js')).href)
|
||||
;({ parsePayload, runApply } = await import(pathToFileURL(P('plugin-designs/my-upg-100-list/data/apply-grouping.mjs')).href))
|
||||
;({ formatClipboard } = await import(pathToFileURL(P('plugin-designs/my-upg-100-list/data/territory-counts.js')).href))
|
||||
} catch (e) {
|
||||
console.error(`BATCH-SELECTION-EXPORT: NOT MEASURED (exit 75) — an input did not load: ${e.message}`)
|
||||
process.exit(75)
|
||||
}
|
||||
|
||||
// ── A. the clipboard's first line round-trips through apply-grouping ─────────────────────────────
|
||||
const selection = [
|
||||
{ key: 'adm1:Mahārāshtra', name: 'Mahārāshtra', count: 54, kind: 'adm1' },
|
||||
{ key: 'adm1:Goa', name: 'Goa', count: 5, kind: 'adm1' },
|
||||
{ key: 'adm0:NPL', name: 'Nepal', count: 34, kind: 'adm0' },
|
||||
]
|
||||
const records = [
|
||||
{ slug: 'agaria', name: 'Agaria', country_code: 'IND', population: 12000 },
|
||||
{ slug: 'q-comma', name: 'Bhil, Tadvi "Hill"', country_code: 'IND', population: 3100 },
|
||||
{ slug: 'q-formula', name: '=HYPERLINK("x")', country_code: 'NPL', population: 450 },
|
||||
{ slug: 'q-newline', name: 'Line one\nLine two', country_code: 'NPL', population: 7 },
|
||||
{ slug: 'q-tab', name: '\t=cmd', country_code: 'NPL', population: 1 },
|
||||
]
|
||||
const from = new Map([['agaria', ['Mahārāshtra', 'Goa']], ['q-comma', ['Goa']], ['q-formula', ['Nepal']], ['q-newline', ['Nepal']], ['q-tab', ['Nepal']]])
|
||||
const color = '#e69f00'
|
||||
const csv = X.selectionCsv(records, from, { color })
|
||||
const text = X.selectionClipboard(selection, { color }, csv)
|
||||
let parsed = null
|
||||
try { parsed = parsePayload(text) } catch (e) { fails.push(`apply-grouping's parsePayload REFUSED the Copy text: ${e.message}`) }
|
||||
if (parsed) {
|
||||
const machine = ['Mahārāshtra', 'Goa', 'NPL']
|
||||
ok(JSON.stringify(parsed.names) === JSON.stringify(machine),
|
||||
`apply-grouping reads the names as ${JSON.stringify(parsed.names)}, expected ${JSON.stringify(machine)}`,
|
||||
'Copy\'s first line parses in apply-grouping: the 3 territories in machine names (a country as its ISO3), in order')
|
||||
ok(parsed.total === 93, `apply-grouping reads the total as ${parsed.total}, expected 93`, 'the UUPG total (93) and the colour survive the CSV after them')
|
||||
ok(parsed.hex === color, `apply-grouping reads the colour as ${parsed.hex}, expected ${color}`)
|
||||
}
|
||||
ok(text.split('\n')[0] === 'Mahārāshtra, Goa, NPL — 93 UUPG · #e69f00', `Copy's first line is "${text.split('\n')[0]}"`)
|
||||
ok(text.split('\n')[1] === 'Mahārāshtra 54, Goa 5, Nepal 34', `Copy's second line (the reader's names) is "${text.split('\n')[1]}"`)
|
||||
ok(text.includes('\n\n' + csv), 'Copy does not carry the CSV after a blank line')
|
||||
ok(X.selectionClipboard(selection, {}, '').split('\n').length === 2, 'Copy without people groups is not exactly the two territory lines')
|
||||
|
||||
// ── A2. apply-grouping RESOLVES line 1 in ANY UI language ───────────────────────────────────────────────
|
||||
// parsePayload only splits the names; resolution happens later (upsertPayloadZone), so this runs the tool's own
|
||||
// dry run. In Arabic the HUD shows "نيبال", and a line 1 carrying it was rejected ("unresolved territory name(s)").
|
||||
// The NEGATIVE CONTROL proves this check can fail: the same selection formatted with the reader's names must be
|
||||
// refused by the same dry run.
|
||||
const arabic = [
|
||||
{ key: 'adm0:NPL', name: 'نيبال', count: 34, kind: 'adm0' },
|
||||
{ key: 'adm0:BTN', name: 'بوتان', count: 13, kind: 'adm0' },
|
||||
]
|
||||
const dry = (payloadText) => { try { const r = runApply({ payloadText, dry: true, quiet: true }); return r && r.ok !== false ? 'ok' : 'not ok' } catch (e) { return 'threw: ' + String(e.message || e).slice(0, 120) } }
|
||||
const arText = X.selectionClipboard(arabic, { color: '#0072b2' })
|
||||
ok(dry(arText) === 'ok', `apply-grouping's dry run REFUSES the Arabic Copy: ${dry(arText)} (line 1 "${arText.split('\n')[0]}")`,
|
||||
'an ARABIC selection\'s Copy resolves in apply-grouping\'s own dry run (line 1 "NPL, BTN — 47 UUPG · #0072b2")')
|
||||
ok(arText.split('\n')[1] === 'نيبال 34, بوتان 13', `the Arabic Copy's second line is "${arText.split('\n')[1]}", expected the reader's names`)
|
||||
ok(dry(formatClipboard(arabic, { color: '#0072b2' })) !== 'ok', 'NEGATIVE CONTROL: a line 1 in the reader\'s Arabic names should be refused by the dry run, and was not: this check cannot fail',
|
||||
'negative control: the same selection in the reader\'s names IS refused (the check can fail)')
|
||||
const indiaText = X.selectionClipboard([{ key: 'adm1:Mahārāshtra', name: 'Mahārāshtra', count: 54, kind: 'adm1' }, { key: 'adm1:Goa', name: 'Goa', count: 5, kind: 'adm1' }], {})
|
||||
ok(dry(indiaText) === 'ok', `apply-grouping's dry run refuses an India state selection: ${dry(indiaText)}`, 'an India state selection resolves too (shape names are canonical)')
|
||||
|
||||
// ── B. the CSV ───────────────────────────────────────────────────────────────────────────────────
|
||||
function parseCsv(s) { // RFC 4180: quoted fields, doubled quotes, CRLF records
|
||||
const rows = []; let row = [], cell = '', q = false
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const c = s[i]
|
||||
if (q) { if (c === '"') { if (s[i + 1] === '"') { cell += '"'; i++ } else q = false } else cell += c }
|
||||
else if (c === '"') q = true
|
||||
else if (c === ',') { row.push(cell); cell = '' }
|
||||
else if (c === '\r' && s[i + 1] === '\n') { row.push(cell); rows.push(row); row = []; cell = ''; i++ }
|
||||
else cell += c
|
||||
}
|
||||
row.push(cell); rows.push(row)
|
||||
return rows
|
||||
}
|
||||
const rows = parseCsv(csv)
|
||||
ok(JSON.stringify(rows[0]) === JSON.stringify(X.SELECTION_CSV_COLUMNS), `the CSV header is ${JSON.stringify(rows[0])}`, `the CSV header: ${X.SELECTION_CSV_COLUMNS.join(',')}`)
|
||||
ok(rows.length === 1 + records.length, `the CSV has ${rows.length - 1} data rows for ${records.length} people groups`)
|
||||
const col = (r, k) => r[X.SELECTION_CSV_COLUMNS.indexOf(k)]
|
||||
ok(col(rows[2], 'name') === 'Bhil, Tadvi "Hill"', `a name with a comma and quotes reads back as ${JSON.stringify(col(rows[2], 'name'))}`)
|
||||
ok(col(rows[4], 'name') === 'Line one\nLine two', `a name with a line break reads back as ${JSON.stringify(col(rows[4], 'name'))}`,
|
||||
'commas, quotes and line breaks survive a real CSV parse')
|
||||
ok(col(rows[3], 'name') === '\'=HYPERLINK("x")', `a leading = reads back as ${JSON.stringify(col(rows[3], 'name'))}: a spreadsheet would run it`,
|
||||
'a leading = + - @ is neutralised')
|
||||
ok(col(rows[5], 'name') === "'\t=cmd", `a leading TAB reads back as ${JSON.stringify(col(rows[5], 'name'))}: OWASP lists it as a formula trigger`,
|
||||
'a leading TAB or CR is neutralised too (OWASP)')
|
||||
ok(col(rows[1], 'territories') === 'Mahārāshtra; Goa' && col(rows[1], 'color') === color && col(rows[1], 'slug') === 'agaria',
|
||||
`row 1 is ${JSON.stringify(rows[1])}`, 'each row carries its slug, the territories that contain it, and the colour')
|
||||
|
||||
// ── C. the HUD wires it ──────────────────────────────────────────────────────────────────────────
|
||||
const vue = readFileSync(P('plugin-designs/my-upg-100-list/upg-100-list.vue'), 'utf8')
|
||||
const wired = {
|
||||
'the colour swatches': /class="upg-swatch"[\s\S]{0,400}@click="setSelectionColor\(c\)"/.test(vue),
|
||||
'the custom hex input': /class="upg-hex"[\s\S]{0,200}@change="onHexInput"/.test(vue),
|
||||
'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),
|
||||
}
|
||||
const missingWires = Object.entries(wired).filter(([, v]) => !v).map(([k]) => k)
|
||||
ok(!missingWires.length, `the HUD is missing: ${missingWires.join(', ')}`, 'the HUD wires the swatches, hex input, Copy, Export CSV, and the prefetch')
|
||||
|
||||
// ── D. the strings, ×11 ──────────────────────────────────────────────────────────────────────────
|
||||
const LANGS = ['en', 'es', 'fr', 'pt', 'de', 'it', 'ro', 'ru', 'ar', 'hi', 'zh']
|
||||
const KEYS = {
|
||||
upgMap: ['copySelection', 'exportCsv', '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)) {
|
||||
const en = cat('en', f)
|
||||
for (const l of LANGS) {
|
||||
const c = cat(l, f)
|
||||
for (const k of keys) {
|
||||
if (typeof c[k] !== 'string' || !c[k].trim()) bad.push(`${l} ${f}.${k} missing`)
|
||||
else if (l !== 'en' && c[k] === en[k]) bad.push(`${l} ${f}.${k} is English`)
|
||||
}
|
||||
}
|
||||
}
|
||||
ok(!bad.length, `strings: ${bad.slice(0, 8).join('; ')}${bad.length > 8 ? ` (+${bad.length - 8})` : ''}`, `the ${KEYS.upgMap.length} new strings in all ${LANGS.length} locales, translated`)
|
||||
|
||||
for (const n of notes) console.log(n)
|
||||
if (fails.length) {
|
||||
console.error('BATCH-SELECTION-EXPORT: FAIL')
|
||||
for (const f of fails) console.error(` ✗ ${f}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('BATCH-SELECTION-EXPORT: PASS — Copy parses in apply-grouping, the CSV is well-formed and safe, the HUD wires both, and the strings are in all 11 locales.')
|
||||
|
|
@ -17,7 +17,7 @@
|
|||
* were a terminal's rendering, not the bytes.
|
||||
* - "quoting is broken" — FALSE. esc() is genuinely RFC-4180: it quotes when , " CR or LF appear and
|
||||
* doubles internal quotes. Embedded commas, quotes AND newlines survive a strict parser round trip.
|
||||
* - "CSV injection" — already handled; a leading = + - @ is prefixed with a single quote.
|
||||
* - "CSV injection" — handled; a leading = + - @ TAB or CR is prefixed with a single quote (asserted in 5).
|
||||
* Only the POSITION was wrong. Chasing the other three would have produced churn and no fix.
|
||||
*
|
||||
* ★ WHY NO EXISTING GATE SAW IT. 46 gate-ish scripts exist and not one parses the export. The bug is
|
||||
|
|
@ -89,8 +89,10 @@ const rows = [
|
|||
{ slug: 'kluet', name: 'Kluet', religion: 'Islam - Sunni',
|
||||
imb_people_description: 'Line one\nLine two, with a comma' },
|
||||
{ slug: 'inj', name: '=SUM(A1)', religion: 'Islam - Sunni', imb_people_description: 'injection probe' },
|
||||
{ slug: 'inj-tab', name: '\t=cmd', religion: 'Islam - Sunni', imb_people_description: 'injection probe, a leading TAB' },
|
||||
{ slug: 'inj-cr', name: '\r=cmd', religion: 'Islam - Sunni', imb_people_description: 'injection probe, a leading CR' },
|
||||
]
|
||||
const provenance = '# Research Dashboard export — scope: filter result; filters: religion=Islam - Sunni; rows: 3'
|
||||
const provenance = '# Research Dashboard export — scope: filter result; filters: religion=Islam - Sunni; rows: 5'
|
||||
const body = [cols.map(esc).join(','), ...rows.map((r) => cols.map((c) => esc(r[c])).join(','))]
|
||||
const csv = (provenanceFirst ? [provenance, ...body] : [...body, provenance]).join('\r\n')
|
||||
|
||||
|
|
@ -164,6 +166,18 @@ if (!(last.length === 1 && String(last[0]).startsWith('#'))) {
|
|||
' between a dataset and an anecdote. Keep it — just keep it last.')
|
||||
}
|
||||
|
||||
// 5 — every formula trigger comes back NEUTRALISED (a leading single quote), so a spreadsheet shows it as text.
|
||||
// The probes cover = and OWASP's other two leading triggers, TAB and CR, which a spreadsheet can strip before
|
||||
// reading the = behind them. Checked by SLUG, so the rows above may be reordered.
|
||||
const bySlug = new Map(dataRows.map((r) => [r[0], r]))
|
||||
for (const [slug, raw] of [['inj', '=SUM(A1)'], ['inj-tab', '\t=cmd'], ['inj-cr', '\r=cmd']]) {
|
||||
const got = bySlug.get(slug) && bySlug.get(slug)[1]
|
||||
if (got !== "'" + raw) {
|
||||
fail(`the formula probe ${JSON.stringify(raw)} came back as ${JSON.stringify(got)}, not neutralised with a leading quote.`,
|
||||
' esc() must prefix a single quote to a value starting with = + - @ TAB or CR (CSV injection).')
|
||||
}
|
||||
}
|
||||
|
||||
// Reaching here under --self-test means the gate WAVED THROUGH a known-broken export.
|
||||
if (SELF_TEST) {
|
||||
console.error('CSV-SHAPE SELF-TEST: FAIL — the gate PASSED a deliberately broken export.')
|
||||
|
|
|
|||
|
|
@ -57,6 +57,12 @@ const UA = 'Mozilla/5.0 (compatible; doxa-maps-gate/1.0)'
|
|||
// figure was also confirmed INDEPENDENTLY against the live endpoint (2107 rows) rather than
|
||||
// taken from this gate's own reading.
|
||||
//
|
||||
// Re-measured 2026-09-30 (FOURTH reading). One more group crossed from Unengaged to Engaged: 2079/28 ->
|
||||
// 2078/29, the total still 2107. The same order as before, the baseline LAST: the live endpoint was read
|
||||
// independently (this gate: 2107 = 2078 + 29), a grep found no shipping code hardcoding the totals (the only
|
||||
// 2078 hits are SVG path coordinates in static poster snapshots), and a reader confirmed the maps DISPLAY
|
||||
// the new split (Prayer Progress's engagement legend read Unengaged 2,078 / Engaged 29 on development).
|
||||
//
|
||||
// ⚠ THIS DRIFTS, AND THAT IS THE POINT. Three readings in two days, moving in both directions.
|
||||
// A failure here is normally UPSTREAM DATA MOVING, not a regression — but it must still be seen,
|
||||
// because the one thing it would also catch is a legend that stopped computing and started
|
||||
|
|
@ -64,7 +70,7 @@ const UA = 'Mozilla/5.0 (compatible; doxa-maps-gate/1.0)'
|
|||
// numbers on the page are live.
|
||||
const EXPECT = {
|
||||
total: 2107,
|
||||
engagement: { Unengaged: 2079, Engaged: 28 },
|
||||
engagement: { Unengaged: 2078, Engaged: 29 },
|
||||
adoptedPct: 1,
|
||||
}
|
||||
|
||||
|
|
|
|||
118
internal/check-panel-sheets.mjs
Normal file
118
internal/check-panel-sheets.mjs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-panel-sheets — the shared panel geometry (library/components/poster/panelSheets.js). Headless.
|
||||
*
|
||||
* A poster too big for one sheet prints as several sheets, each trimmed edge laid over the next sheet's
|
||||
* join band. This pins:
|
||||
* A. the two multi-sheet sizes: 60x96 = 3 × 60×36 stacked, 6 in joins (the Bless 5 × 8 ft poster);
|
||||
* 66x66 = 2 × 36×66 side by side, 6 in join (India); and a single sheet has no join;
|
||||
* B. the sheets TILE the finished poster: what each sheet shows (itself minus the band the sheet before
|
||||
* covers) covers every inch once, no gap, no double;
|
||||
* C. sliceFor: with 'glue' the slices tile the poster; with 'bleed' neighbours repeat exactly joinIn;
|
||||
* D. the print marks: a 'glue' join prints the red band; a 'bleed' join prints only a guide (a solid band
|
||||
* would show through a misaligned trim); a screen preview always draws the red band; the trim line is on
|
||||
* every sheet but the last; every sheet has its "s / N" tag;
|
||||
* E. the Bless poster takes its sheets, bands and marks from this module (no private copy to drift): its
|
||||
* 5x8ft content bands equal the panels' content boxes, and blessPoster.js carries no glue band of its own.
|
||||
* Two-sided: each arm runs against a planted defect and must fail.
|
||||
* EXIT 0 pass · 1 fail
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const real = await import(pathToFileURL(join(ROOT, 'library/components/poster/panelSheets.js')).href)
|
||||
const bless = await import(pathToFileURL(join(ROOT, 'plugin-designs/wall-of-people-groups/src/blessPoster.js')).href)
|
||||
const blessSrc = readFileSync(join(ROOT, 'plugin-designs/wall-of-people-groups/src/blessPoster.js'), 'utf8')
|
||||
const eq = (a, b) => Math.abs(a - b) < 1e-9
|
||||
|
||||
const SIZES = {
|
||||
'60x96': { sheetW: 60, sheetH: 36, count: 3, direction: 'vertical', joinIn: 6, want: { w: 60, h: 96, at: [0, 30, 60] } },
|
||||
'66x66': { sheetW: 36, sheetH: 66, count: 2, direction: 'horizontal', joinIn: 6, want: { w: 66, h: 66, at: [0, 30] } },
|
||||
'36x36': { sheetW: 36, sheetH: 36, count: 1, direction: 'vertical', joinIn: 0, want: { w: 36, h: 36, at: [0] } },
|
||||
}
|
||||
|
||||
function run(M) {
|
||||
const errs = []
|
||||
let checked = 0
|
||||
for (const [key, z] of Object.entries(SIZES)) {
|
||||
for (const joinFill of ['glue', 'bleed']) {
|
||||
const P = M.planPanels({ ...z, borderIn: 2, joinFill })
|
||||
const V = P.direction === 'vertical', along = V ? P.sheetH : P.sheetW, total = V ? P.finished.h : P.finished.w
|
||||
// A. geometry
|
||||
if (!eq(P.finished.w, z.want.w) || !eq(P.finished.h, z.want.h)) errs.push(`A ${key}: finished ${P.finished.w} × ${P.finished.h}, want ${z.want.w} × ${z.want.h}`)
|
||||
if (P.sheets.length !== z.count) errs.push(`A ${key}: ${P.sheets.length} sheets, want ${z.count}`)
|
||||
P.sheets.forEach((sh, s) => {
|
||||
const off = V ? sh.at.y : sh.at.x
|
||||
if (!eq(off, z.want.at[s])) errs.push(`A ${key}: sheet ${s + 1} starts at ${off} in, want ${z.want.at[s]}`)
|
||||
if ((s > 0) !== !!sh.band) errs.push(`A ${key}: sheet ${s + 1} ${sh.band ? 'has' : 'lacks'} a join band`)
|
||||
if (sh.band && !eq(V ? sh.band.h : sh.band.w, z.joinIn)) errs.push(`A ${key}: sheet ${s + 1}'s band is not ${z.joinIn} in deep`)
|
||||
if (sh.trimmed !== (s < z.count - 1)) errs.push(`A ${key}: sheet ${s + 1} trimmed=${sh.trimmed}`)
|
||||
const c = sh.content, lead = V ? c.y0 : c.x0, trail = along - (V ? c.y1 : c.x1)
|
||||
if (!eq(lead, s === 0 ? 2 : z.joinIn) || !eq(trail, s === z.count - 1 ? 2 : 0)) errs.push(`A ${key}: sheet ${s + 1}'s content box leaves ${lead} / ${trail} in`)
|
||||
})
|
||||
// B. the visible parts tile the finished poster (1/8 in samples)
|
||||
const hits = new Array(Math.round(total * 8)).fill(0)
|
||||
P.sheets.forEach((sh, s) => {
|
||||
const off = V ? sh.at.y : sh.at.x, from = off + (sh.band ? P.joinIn : 0), to = off + along
|
||||
for (let k = 0; k < hits.length; k++) { const t = (k + 0.5) / 8; if (t >= from && t < to) hits[k]++ }
|
||||
})
|
||||
const bad = hits.findIndex((h) => h !== 1)
|
||||
if (bad >= 0) errs.push(`B ${key}: at ${(bad + 0.5) / 8} in the poster is shown by ${hits[bad]} sheets, not 1`)
|
||||
// C. slices
|
||||
const sl = P.sheets.map((_, s) => M.sliceFor(P, s))
|
||||
for (let s = 1; s < sl.length; s++) {
|
||||
const endPrev = V ? sl[s - 1].y + sl[s - 1].h : sl[s - 1].x + sl[s - 1].w, start = V ? sl[s].y : sl[s].x
|
||||
const want = joinFill === 'bleed' ? endPrev - z.joinIn : endPrev
|
||||
if (!eq(start, want)) errs.push(`C ${key} ${joinFill}: slice ${s + 1} starts at ${start} in, want ${want}`)
|
||||
}
|
||||
const last = sl[sl.length - 1]
|
||||
if (!eq(V ? last.y + last.h : last.x + last.w, total) || !eq(V ? sl[0].y : sl[0].x, 0)) errs.push(`C ${key} ${joinFill}: the slices do not span the poster`)
|
||||
// D. marks
|
||||
P.sheets.forEach((sh, s) => {
|
||||
const pr = M.panelChromeHtml(P, s, {}), sc = M.panelChromeHtml(P, s, { screen: true })
|
||||
const solid = /class="glue[ "]/.test(pr.before), guide = /class="glue-guide/.test(pr.before)
|
||||
if (sh.band && joinFill === 'glue' && !solid) errs.push(`D ${key}: a glue join does not print its red band`)
|
||||
if (sh.band && joinFill === 'bleed' && (solid || !guide)) errs.push(`D ${key}: a bleed join prints ${solid ? 'a solid band' : 'no guide'}`)
|
||||
if (sh.band && !/class="glue[ "]/.test(sc.before)) errs.push(`D ${key}: the screen preview does not draw the red band`)
|
||||
if (!sh.band && (pr.before || sc.before)) errs.push(`D ${key}: sheet 1 draws a join band`)
|
||||
if (/class="trim/.test(pr.after) !== sh.trimmed) errs.push(`D ${key}: sheet ${s + 1}'s trim line is wrong`)
|
||||
if (!pr.after.includes(`>${s + 1} / ${z.count}<`)) errs.push(`D ${key}: sheet ${s + 1} has no "${s + 1} / ${z.count}" tag`)
|
||||
})
|
||||
checked++
|
||||
}
|
||||
}
|
||||
return { errs, checked }
|
||||
}
|
||||
|
||||
const fails = []
|
||||
const r = run(real)
|
||||
fails.push(...r.errs)
|
||||
if (r.checked < 6) fails.push(`only ${r.checked} of 6 size × fill cases ran`)
|
||||
// E. the Bless consumer
|
||||
const plan = bless.planPoster('5x8ft', 2107)
|
||||
const P = real.planPanels({ sheetW: 60, sheetH: 36, count: 3, direction: 'vertical', joinIn: 6, borderIn: 6, joinFill: 'glue' })
|
||||
plan.bands.forEach((bd, s) => {
|
||||
const c = P.sheets[s].content
|
||||
if (!eq(bd.y0 - (s === 0 ? plan.bannerH : 0), c.y0) || !eq(bd.y1 + (s === 2 ? plan.footerH + (plan.gapH || 0) : 0), c.y1)) fails.push(`E: Bless 5x8ft sheet ${s + 1}'s band is not the panels' content box`)
|
||||
})
|
||||
if (!eq(plan.posterW, P.finished.w) || !eq(plan.posterH, P.finished.h)) fails.push('E: the Bless poster\'s finished size is not the panels\'')
|
||||
if (!/from '[./]+library\/components\/poster\/panelSheets\.js'/.test(blessSrc)) fails.push('E: blessPoster.js does not import panelSheets.js')
|
||||
if (/class="glue"/.test(blessSrc) || /\.glue\{/.test(blessSrc)) fails.push('E: blessPoster.js carries its own glue band (a private copy to drift)')
|
||||
if (!/class="glue"/.test(bless.buildBlessPosterHtml(Array.from({ length: 50 }, () => ({ name: 'x' })), '5x8ft', {}))) fails.push('E: the Bless 5x8ft print has no glue band')
|
||||
|
||||
// Two-sided: each arm against a planted defect.
|
||||
const planted = {
|
||||
'A/B (the joins not subtracted)': { ...real, planPanels: (o) => { const p = real.planPanels(o); const V = p.direction === 'vertical'; return { ...p, finished: V ? { w: p.sheetW, h: p.count * p.sheetH } : { w: p.count * p.sheetW, h: p.sheetH } } } },
|
||||
'C (a glue slice keeps the band)': { ...real, sliceFor: (p, s) => real.sliceFor({ ...p, joinFill: 'bleed' }, s) },
|
||||
'D (a bleed join prints a solid band)': { ...real, panelChromeHtml: (p, s, o = {}) => real.panelChromeHtml(p, s, { ...o, screen: true }) },
|
||||
'D (no trim line)': { ...real, panelChromeHtml: (p, s, o) => { const h = real.panelChromeHtml(p, s, o); return { ...h, after: h.after.replace(/<div class="trim[^>]*><\/div>/, '') } } },
|
||||
}
|
||||
for (const [name, M] of Object.entries(planted)) {
|
||||
const want = name[0]
|
||||
if (!run(M).errs.some((e) => e.startsWith(want) || (want === 'A' && e.startsWith('B')))) fails.push(`SELF-TEST: the planted defect "${name}" was not caught`)
|
||||
}
|
||||
|
||||
if (fails.length) { console.log('PANEL-SHEETS: FAIL'); for (const f of fails) console.log(' - ' + f); process.exitCode = 1 }
|
||||
else console.log(`PANEL-SHEETS: PASS — ${r.checked} size × fill cases (60x96 stacked, 66x66 side by side, one sheet; glue and bleed): the sheets tile the finished poster, the slices tile or repeat exactly the join, the marks follow the fill, and the Bless 5x8ft takes its bands and marks from panelSheets.js; ${Object.keys(planted).length} planted defects caught.`)
|
||||
|
|
@ -53,13 +53,21 @@ const fail = (m) => { console.error('TAB-RADIUS: FAIL — ' + m); process.exit(1
|
|||
// catches 999px, 9999px and the occasional 50vh.
|
||||
const PILL_MIN = 100
|
||||
|
||||
// Does this line open a rule whose selector list names `cls` as a WHOLE selector? `.vtl-collapse, .vtl-home {`
|
||||
// counts (the home link joined Collapse's rule so the two share one box); `.vtl-collapse:hover`,
|
||||
// `.vtl-collapse-tx` and `.vtl.rail-collapsed .vtl-collapse` do not.
|
||||
function opensRuleFor(ln, cls) {
|
||||
const m = ln.match(/^\s*([^{}]+)\{/)
|
||||
return !!m && m[1].split(',').map((x) => x.trim()).includes(cls)
|
||||
}
|
||||
|
||||
function auditLines(lines) {
|
||||
const problems = []
|
||||
|
||||
// ── the reference: .vtl-collapse's literal radius, read from the file, never assumed ──────────
|
||||
const collapse = []
|
||||
lines.forEach((ln, i) => {
|
||||
if (!/^\s*\.vtl-collapse\s*\{/.test(ln)) return
|
||||
if (!opensRuleFor(ln, '.vtl-collapse')) return
|
||||
const m = ln.match(/border-radius:\s*([^;]+);/)
|
||||
if (m) collapse.push({ line: i + 1, value: m[1].trim() })
|
||||
})
|
||||
|
|
@ -151,6 +159,9 @@ if (process.argv.includes('--self-test')) {
|
|||
'cursor:pointer; border-radius:999px; font:inherit;')],
|
||||
['literal-instead-of-token', (s) => s.replace('cursor:pointer; border-radius:var(--vtl-radius,8px); font:inherit;',
|
||||
'cursor:pointer; border-radius:8px; font:inherit;')],
|
||||
// the reference must be read from Collapse's rule even inside a selector list: change ONLY that rule's
|
||||
// radius and the (unchanged) tabs no longer agree with it.
|
||||
['collapse-radius-moved', (s) => s.split('\n').map((l) => (opensRuleFor(l, '.vtl-collapse') ? l.replace(/border-radius:\s*8px;/, 'border-radius:12px;') : l)).join('\n')],
|
||||
]
|
||||
let bad = 0
|
||||
for (const [name, poison] of cases) {
|
||||
|
|
@ -161,7 +172,7 @@ if (process.argv.includes('--self-test')) {
|
|||
if (!caught) bad++
|
||||
}
|
||||
if (bad) { console.error(`TAB-RADIUS SELF-TEST: FAIL — ${bad} case(s) not caught.`); process.exit(1) }
|
||||
console.log('TAB-RADIUS SELF-TEST: PASS — both regressions caught; tree untouched (poisoned in memory).')
|
||||
console.log('TAB-RADIUS SELF-TEST: PASS — every regression caught; tree untouched (poisoned in memory).')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
|
|
|
|||
121
library/components/poster/PanelSheets.vue
Normal file
121
library/components/poster/PanelSheets.vue
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
<!--
|
||||
PanelSheets.vue — SHARED. A preview of a large poster as the sheets it prints on: the sheets pulled apart, each
|
||||
join's under-strip drawn as a red "glue here" band, the trimmed edges and the "s / N" tags, the way a reader
|
||||
will assemble it on the wall. The geometry is panelSheets.js (the same plan the print uses).
|
||||
|
||||
- Give it `src` (the finished poster as one image or SVG URL) and each sheet shows its own slice of it, cut with
|
||||
CSS (an overflow:hidden box, the image sized to the whole poster and offset by the sheet's place). Or use
|
||||
the scoped slot #sheet="{ s, slice, band }" to draw a sheet yourself.
|
||||
- It fills its container's width; its height follows the layout's aspect. The container decides the size.
|
||||
- It handles no pointer events (pointer-events:none throughout), so a card can make the preview its control.
|
||||
- It emits ONE `load` when `src` has loaded, or ONE `error` when it cannot, per src.
|
||||
- The red band is always drawn here, even for a poster whose print BLEEDS under the join: a preview's job is
|
||||
to show how the pieces go together.
|
||||
Styles go in through useShadowStyles, because it renders inside a plugin's shadow root.
|
||||
-->
|
||||
<template>
|
||||
<div class="pnl" :style="{ aspectRatio: aspect }" aria-hidden="true">
|
||||
<div v-for="sh in view" :key="sh.s" class="pnl-sheet" :style="sh.box">
|
||||
<img v-if="src && !failed" class="pnl-img" :src="src" alt="" draggable="false" :style="sh.img">
|
||||
<slot name="sheet" :s="sh.s" :slice="sh.slice" :band="sh.band"></slot>
|
||||
<svg class="pnl-marks" :viewBox="`0 0 ${plan.sheetW} ${plan.sheetH}`" preserveAspectRatio="none">
|
||||
<g v-if="sh.band">
|
||||
<rect :x="sh.band.x" :y="sh.band.y" :width="sh.band.w" :height="sh.band.h" fill="rgba(176,0,0,.14)" />
|
||||
<line v-bind="sh.bandEdge" stroke="#b00" :stroke-width="stroke" :stroke-dasharray="`${stroke * 4} ${stroke * 3}`" />
|
||||
</g>
|
||||
<line v-if="sh.trimEdge" v-bind="sh.trimEdge" stroke="#999" :stroke-width="stroke" />
|
||||
</svg>
|
||||
<!-- Text in HTML, not in the stretched SVG, so it keeps its proportions; sized in the sheet's own units. -->
|
||||
<div v-if="sh.band" class="pnl-glue" :class="{ h: horizontal }" :style="sh.bandBox">
|
||||
<span :style="{ fontSize: sh.labelFs }">{{ glueHere }} <svg v-if="horizontal" class="pnl-arr" viewBox="0 0 10 10"><path d="M1 5 9 1v8z" fill="currentColor"/></svg><template v-else>▲</template> <bdi dir="ltr">{{ sh.s }} / {{ plan.count }}</bdi></span>
|
||||
</div>
|
||||
<div class="pnl-tag" dir="ltr" :style="{ fontSize: sh.tagFs }">{{ sh.s + 1 }} / {{ plan.count }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch, onBeforeUnmount } from 'vue'
|
||||
import { planPanels, sliceFor, screenLayout } from './panelSheets.js'
|
||||
import { useShadowStyles } from '../../composables/useShadowStyles.js'
|
||||
import { useOptionalT } from '../../composables/useOptionalT.js'
|
||||
|
||||
const props = defineProps({
|
||||
/** One printed sheet, in inches. */
|
||||
sheetW: { type: Number, required: true },
|
||||
sheetH: { type: Number, required: true },
|
||||
count: { type: Number, default: 1 },
|
||||
direction: { type: String, default: 'vertical' }, // 'vertical' (stacked) | 'horizontal' (side by side)
|
||||
/** How far each sheet slides under the one before it (inches). */
|
||||
joinIn: { type: Number, default: 0 },
|
||||
/** The gap drawn between sheets, in inches (the Bless wall uses 0.75). */
|
||||
gapIn: { type: Number, default: 0.75 },
|
||||
/** The finished poster as one image (webp/png/svg URL). Optional: the #sheet slot can draw instead. */
|
||||
src: { type: String, default: '' },
|
||||
/** Translated strings: { glueHere }. Without one, the catalogue's poster.glue_here in the reader's language. */
|
||||
labels: { type: Object, default: () => ({}) },
|
||||
})
|
||||
const emit = defineEmits(['load', 'error'])
|
||||
|
||||
// The preview shows the finished poster continuing under each band ('bleed'), so a sliced image lines up.
|
||||
const plan = computed(() => planPanels({ sheetW: props.sheetW, sheetH: props.sheetH, count: props.count,
|
||||
direction: props.direction, joinIn: props.joinIn, joinFill: 'bleed' }))
|
||||
const horizontal = computed(() => plan.value.direction === 'horizontal')
|
||||
const layout = computed(() => screenLayout(plan.value, { gapIn: props.gapIn, pxPerIn: 1 }))
|
||||
const aspect = computed(() => `${layout.value.worldW} / ${layout.value.worldH}`)
|
||||
const t = useOptionalT('poster')
|
||||
const glueHere = computed(() => props.labels.glueHere || t('poster.glue_here'))
|
||||
// Marks scale with the sheet: a line ~1/300 of the sheet's short side, text from the band's depth.
|
||||
const stroke = computed(() => Math.min(plan.value.sheetW, plan.value.sheetH) / 300)
|
||||
const pct = (v, of) => `${(v / of) * 100}%`
|
||||
|
||||
const view = computed(() => {
|
||||
const P = plan.value, L = layout.value, F = P.finished
|
||||
return L.sheets.map((r) => {
|
||||
const sh = P.sheets[r.s], slice = sliceFor(P, r.s), b = sh.band
|
||||
const H = P.direction === 'horizontal'
|
||||
// font sizes as a share of the SHEET's width, via container query units (the sheet is the container)
|
||||
const cq = (inches) => `${(inches / P.sheetW) * 100}cqw`
|
||||
return {
|
||||
s: r.s, slice, band: b,
|
||||
box: { left: pct(r.x, L.worldW), top: pct(r.y, L.worldH), width: pct(r.w, L.worldW), height: pct(r.h, L.worldH) },
|
||||
// the whole finished poster behind this sheet, shifted so the sheet's slice shows
|
||||
img: { width: pct(F.w, P.sheetW), height: pct(F.h, P.sheetH), left: pct(-sh.at.x, P.sheetW), top: pct(-sh.at.y, P.sheetH) },
|
||||
bandBox: b ? { left: pct(b.x, P.sheetW), top: pct(b.y, P.sheetH), width: pct(b.w, P.sheetW), height: pct(b.h, P.sheetH) } : null,
|
||||
bandEdge: b ? (H ? { x1: b.w, y1: 0, x2: b.w, y2: P.sheetH } : { x1: 0, y1: b.h, x2: P.sheetW, y2: b.h }) : null,
|
||||
trimEdge: sh.trimmed ? (H ? { x1: P.sheetW, y1: 0, x2: P.sheetW, y2: P.sheetH } : { x1: 0, y1: P.sheetH, x2: P.sheetW, y2: P.sheetH }) : null,
|
||||
labelFs: cq(Math.min(P.joinIn * 0.16, P.sheetW * 0.03)),
|
||||
tagFs: cq(Math.max(0.35, Math.min(P.sheetW, P.sheetH) * 0.02)),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ONE load or error per src, from one probe image (the per-sheet <img>s share its cached response). A src that
|
||||
// fails shows blank sheets, never the browser's broken-image icon; the card says why.
|
||||
const failed = ref(false)
|
||||
let probe = null
|
||||
watch(() => props.src, (src) => {
|
||||
if (probe) { probe.onload = probe.onerror = null; probe = null }
|
||||
failed.value = false
|
||||
if (!src || typeof Image === 'undefined') return
|
||||
const img = new Image()
|
||||
probe = img
|
||||
img.onload = () => { if (probe === img) { probe = null; emit('load') } }
|
||||
img.onerror = () => { if (probe === img) { probe = null; failed.value = true; emit('error') } }
|
||||
img.src = src
|
||||
}, { immediate: true })
|
||||
onBeforeUnmount(() => { if (probe) { probe.onload = probe.onerror = null; probe = null } })
|
||||
|
||||
useShadowStyles(
|
||||
'.pnl{position:relative;width:100%;pointer-events:none;user-select:none}' +
|
||||
'.pnl-sheet{position:absolute;overflow:hidden;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.28);container-type:inline-size}' +
|
||||
'.pnl-img{position:absolute;max-width:none;display:block;object-fit:fill}' +
|
||||
'.pnl-marks{position:absolute;inset:0;width:100%;height:100%;overflow:visible}' +
|
||||
'.pnl-glue{position:absolute;display:flex;align-items:center;justify-content:center;overflow:hidden}' +
|
||||
'.pnl-glue span{color:#b00;font:700 1em/1.1 system-ui,-apple-system,"Segoe UI",sans-serif;letter-spacing:.05em;text-transform:uppercase;white-space:nowrap;background:rgba(255,255,255,.82);padding:.1em .35em;border-radius:.2em}' +
|
||||
'.pnl-glue.h span{writing-mode:vertical-rl}' +
|
||||
'.pnl-arr{width:.8em;height:.8em;vertical-align:middle;display:inline-block}' +
|
||||
'.pnl-tag{position:absolute;top:1.5%;right:2%;color:#999;font:600 1em/1 system-ui,-apple-system,"Segoe UI",sans-serif}',
|
||||
'PanelSheets'
|
||||
)
|
||||
</script>
|
||||
149
library/components/poster/panelSheets.js
Normal file
149
library/components/poster/panelSheets.js
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
/**
|
||||
* panelSheets.js — SHARED. The geometry of a large poster printed as several sheets and joined on the wall,
|
||||
* and the marks that tell the print shop how to join them. Pure: numbers and HTML/CSS strings, no Vue.
|
||||
*
|
||||
* A poster too big for one sheet is printed as `count` sheets of sheetW × sheetH inches, joined in a line
|
||||
* (`direction` 'vertical': stacked top to bottom; 'horizontal': side by side). At each join the EARLIER sheet's
|
||||
* edge is trimmed flat and laid over the first `joinIn` inches of the next one, so
|
||||
* finished length = count × sheet − (count − 1) × joinIn (3 × 36 − 2 × 6 = 96; 2 × 36 − 6 = 66).
|
||||
* The strip that ends up underneath (the JOIN BAND) is filled one of two ways, chosen by the renderer:
|
||||
* · 'glue' — left blank and marked "glue here" in red; content is fitted around it, so nothing crosses a
|
||||
* join (the Bless wall's cards);
|
||||
* · 'bleed' — the poster simply continues under it, so a trim a few millimetres off shows poster, never a
|
||||
* white seam (a map, whose title and land cross the join). Only a thin guide and a small tag
|
||||
* are printed there: a solid band would show through a misaligned join.
|
||||
* On SCREEN (PanelSheets.vue, and any preview) the band is always drawn red with the sheets pulled apart, so a
|
||||
* reader sees how the pieces go together, whichever way the print fills it.
|
||||
*
|
||||
* The parameter names are the size table's `sheets` object (library/constants/posterSizes.js), so a consumer
|
||||
* can call planPanels(SIZES[key].sheets); borderIn and joinFill belong to the renderer, not the size.
|
||||
* Lengths are inches unless a name says px.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {{ sheetW:number, sheetH:number, count?:number, direction?:'vertical'|'horizontal', joinIn?:number,
|
||||
* borderIn?:number, joinFill?:'glue'|'bleed' }} o
|
||||
* @returns the plan: finished size, and per sheet where it sits in the finished poster, its join band (sheet
|
||||
* coordinates, null on the first sheet), whether its trailing edge is trimmed, and its CONTENT box (sheet
|
||||
* coordinates): the outer border on the poster's outer edges, the join band on a joined leading edge, and
|
||||
* nothing on a trimmed trailing edge (that edge lies over the next sheet's join band).
|
||||
*/
|
||||
export function planPanels(o) {
|
||||
const sheetW = +o.sheetW, sheetH = +o.sheetH
|
||||
const count = Math.max(1, Math.round(+o.count || 1))
|
||||
const direction = o.direction === 'horizontal' ? 'horizontal' : 'vertical'
|
||||
const joinIn = count > 1 ? Math.max(0, +o.joinIn || 0) : 0
|
||||
const borderIn = Math.max(0, +o.borderIn || 0)
|
||||
const joinFill = o.joinFill === 'bleed' ? 'bleed' : 'glue'
|
||||
if (!(sheetW > 0 && sheetH > 0)) throw new Error('planPanels: sheetW and sheetH must be positive inches')
|
||||
const along = direction === 'vertical' ? sheetH : sheetW
|
||||
if (joinIn >= along) throw new Error('planPanels: joinIn must be smaller than the sheet along the join')
|
||||
const H = direction === 'vertical'
|
||||
const sheets = []
|
||||
for (let s = 0; s < count; s++) {
|
||||
const first = s === 0, last = s === count - 1
|
||||
const off = s * (along - joinIn) // this sheet's leading edge in the finished poster
|
||||
const at = H ? { x: 0, y: off } : { x: off, y: 0 }
|
||||
const band = first ? null : H ? { x: 0, y: 0, w: sheetW, h: joinIn } : { x: 0, y: 0, w: joinIn, h: sheetH }
|
||||
const lead = first ? borderIn : joinIn, trail = last ? borderIn : 0
|
||||
const content = H
|
||||
? { x0: borderIn, y0: lead, x1: sheetW - borderIn, y1: sheetH - trail }
|
||||
: { x0: lead, y0: borderIn, x1: sheetW - trail, y1: sheetH - borderIn }
|
||||
sheets.push({ s, first, last, at, band, trimmed: !last && count > 1, content })
|
||||
}
|
||||
const finished = H
|
||||
? { w: sheetW, h: count * sheetH - (count - 1) * joinIn }
|
||||
: { w: count * sheetW - (count - 1) * joinIn, h: sheetH }
|
||||
return { sheetW, sheetH, count, direction, joinIn, borderIn, joinFill, finished, sheets }
|
||||
}
|
||||
|
||||
/**
|
||||
* The part of the FINISHED poster that sheet s (0-based: 0 … count − 1) prints, and where on the sheet it goes (inches). With 'bleed' the
|
||||
* whole sheet is poster (its band repeats what the sheet before shows); with 'glue' the band is left out.
|
||||
* A renderer draws the finished poster once and places this rect of it on page s (the per-sheet PDF page).
|
||||
*/
|
||||
export function sliceFor(plan, s) {
|
||||
const sh = plan.sheets[s]
|
||||
if (!sh) throw new Error(`sliceFor: no sheet ${s}; sheets are 0-based, 0..${plan.count - 1}`)
|
||||
const skip = plan.joinFill === 'glue' && sh.band ? plan.joinIn : 0
|
||||
const V = plan.direction === 'vertical'
|
||||
return V
|
||||
? { x: sh.at.x, y: sh.at.y + skip, w: plan.sheetW, h: plan.sheetH - skip, sheetX: 0, sheetY: skip }
|
||||
: { x: sh.at.x + skip, y: sh.at.y, w: plan.sheetW - skip, h: plan.sheetH, sheetX: skip, sheetY: 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* The sheets laid out for a SCREEN preview, pulled apart by gapIn so the joins show: stacked (vertical) or side
|
||||
* by side (horizontal), in px at pxPerIn. `step` is the distance between sheet origins in inches.
|
||||
*/
|
||||
export function screenLayout(plan, { gapIn = 0.75, pxPerIn = 96 } = {}) {
|
||||
const V = plan.direction === 'vertical'
|
||||
const step = (V ? plan.sheetH : plan.sheetW) + gapIn
|
||||
const sheets = plan.sheets.map((sh) => V
|
||||
? { s: sh.s, x: 0, y: sh.s * step * pxPerIn, w: plan.sheetW * pxPerIn, h: plan.sheetH * pxPerIn }
|
||||
: { s: sh.s, x: sh.s * step * pxPerIn, y: 0, w: plan.sheetW * pxPerIn, h: plan.sheetH * pxPerIn })
|
||||
const n = plan.count
|
||||
return {
|
||||
step, gapIn, pxPerIn, sheets,
|
||||
worldW: (V ? plan.sheetW : n * plan.sheetW + (n - 1) * gapIn) * pxPerIn,
|
||||
worldH: (V ? n * plan.sheetH + (n - 1) * gapIn : plan.sheetH) * pxPerIn,
|
||||
}
|
||||
}
|
||||
|
||||
// A left-pointing arrow as a shape: vertical text rotates arrow CHARACTERS, never an inline SVG.
|
||||
export const LEFT_ARROW = '<svg viewBox="0 0 10 10" width="0.8em" height="0.8em" style="vertical-align:middle"><path d="M1 5 9 1v8z" fill="currentColor"/></svg>'
|
||||
|
||||
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]))
|
||||
const inch = (v) => v.toFixed(3) + 'in'
|
||||
|
||||
/**
|
||||
* The join marks for sheet s (0-based), as HTML to place inside the sheet's own positioned box (class "sheet"):
|
||||
* before — the join band ("glue here ▲ s / N"), drawn first so content paints over nothing it needs;
|
||||
* after — the trim line on a trimmed edge, and the "s / N" sheet tag in the corner.
|
||||
* `screen: true` always draws the red band (a preview shows how the sheets join, even for a bleed print).
|
||||
* glueHere: the translated "glue here" (default English; the Bless wall passes wall.glueHere).
|
||||
*/
|
||||
export function panelChromeHtml(plan, s, { glueHere = 'glue here', screen = false } = {}) {
|
||||
const sh = plan.sheets[s], N = plan.count, V = plan.direction === 'vertical'
|
||||
let before = ''
|
||||
if (sh.band) {
|
||||
// The arrow points at the sheet laid over this band: the one above, or (side by side) the one to its left.
|
||||
// (In the side-by-side band the label runs top to bottom, which would turn a ◀ character on its side; the
|
||||
// arrow is drawn instead, so it points left in every browser.)
|
||||
const label = V ? `${esc(glueHere)} ▲ ${s} / ${N}`
|
||||
: `${esc(glueHere)} ${LEFT_ARROW} <bdi dir="ltr">${s} / ${N}</bdi>`
|
||||
if (plan.joinFill === 'glue' || screen) {
|
||||
before = V
|
||||
? `<div class="glue" style="top:0;height:${inch(plan.joinIn)}">${label}</div>`
|
||||
: `<div class="glue glue-h" style="left:0;width:${inch(plan.joinIn)}">${label}</div>`
|
||||
} else {
|
||||
before = `<div class="glue-guide${V ? '' : ' glue-h'}"><span>${label}</span></div>`
|
||||
}
|
||||
}
|
||||
const trim = sh.trimmed ? (V ? '<div class="trim"></div>' : '<div class="trim trim-h"></div>') : ''
|
||||
return { before, after: `${trim}<div class="tag">${s + 1} / ${N}</div>` }
|
||||
}
|
||||
|
||||
/**
|
||||
* The CSS for panelChromeHtml's marks. The vertical rules are the Bless poster's own, byte for byte (its print
|
||||
* is pinned by check:wall-wysiwyg and check:panel-sheets). The label and tag scale with the join band (the
|
||||
* border on a single sheet).
|
||||
*/
|
||||
export function panelCss(plan) {
|
||||
const j = plan.joinIn || plan.borderIn, b = plan.borderIn
|
||||
let css = `.glue{position:absolute;left:0;width:100%;border-bottom:.02in dashed #b00;display:flex;align-items:center;justify-content:center;font:700 ${inch(Math.min(0.5, j * 0.08))} sans-serif;color:#b00;background:rgba(176,0,0,.035);letter-spacing:.05em;text-transform:uppercase}
|
||||
.trim{position:absolute;left:0;bottom:0;width:100%;border-bottom:.02in solid #999}
|
||||
.tag{position:absolute;right:${inch(Math.max(0.15, b * 0.1))};top:${inch(Math.max(0.1, b * 0.08))};font:600 ${inch(Math.max(0.12, b * 0.035))} sans-serif;color:#aaa}`
|
||||
if (plan.direction === 'horizontal') {
|
||||
css += `
|
||||
.glue.glue-h{top:0;height:100%;width:auto;border-bottom:0;border-right:.02in dashed #b00;writing-mode:vertical-rl}
|
||||
.trim.trim-h{left:auto;right:0;top:0;bottom:auto;width:0;height:100%;border-bottom:0;border-right:.02in solid #999}`
|
||||
}
|
||||
if (plan.joinFill === 'bleed') {
|
||||
css += `
|
||||
.glue-guide{position:absolute;left:0;top:0;width:100%;height:${inch(plan.joinIn)};border-bottom:.01in dashed #b00;pointer-events:none}
|
||||
.glue-guide.glue-h{width:${inch(plan.joinIn)};height:100%;border-bottom:0;border-right:.01in dashed #b00}
|
||||
.glue-guide span{position:absolute;left:${inch(0.15)};top:${inch(0.1)};font:600 ${inch(Math.max(0.12, j * 0.02))} sans-serif;color:#b00;background:rgba(255,255,255,.85);padding:0 .06in;letter-spacing:.04em;text-transform:uppercase}`
|
||||
}
|
||||
return css
|
||||
}
|
||||
|
|
@ -199,10 +199,12 @@ export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_AC
|
|||
for (const k of Object.keys(r)) o[k] = cell(r[k])
|
||||
return o
|
||||
}
|
||||
// Quote if the field has , " CR LF; double internal quotes; neutralize leading = + - @ (CSV injection).
|
||||
// Quote if the field has , " CR LF; double internal quotes; neutralize a leading = + - @ TAB or CR (CSV
|
||||
// injection: OWASP's full list of formula triggers, since a spreadsheet can strip a leading TAB/CR and then
|
||||
// read the = that follows as a formula).
|
||||
function esc(v) {
|
||||
let s = v === null || v === undefined ? '' : String(v)
|
||||
if (/^[=+\-@]/.test(s)) s = "'" + s
|
||||
if (/^[=+\-@\t\r]/.test(s)) s = "'" + s
|
||||
if (/[",\n\r]/.test(s)) s = '"' + s.replace(/"/g, '""') + '"'
|
||||
return s
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,5 +39,6 @@
|
|||
"error_too_large": "هذا الملصق أكبر من ذاكرة هذا الجهاز. اختر حجمًا أصغر أو دقة أقل.",
|
||||
"error_failed": "تعذّر إنشاء الملصق. حاول مرة أخرى أو اختر حجمًا أصغر.",
|
||||
"optional": "(اختياري)",
|
||||
"phoneLimit": "على الهاتف، أكبر المقاسات غير متاحة: لا يستطيع الهاتف رسم ملصق بهذا الحجم. استخدم حاسوبًا لها."
|
||||
"phoneLimit": "على الهاتف، أكبر المقاسات غير متاحة: لا يستطيع الهاتف رسم ملصق بهذا الحجم. استخدم حاسوبًا لها.",
|
||||
"glue_here": "الصق هنا"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,5 +5,14 @@
|
|||
"selectionPosterNeedsZone": "الملصقات متاحة لكل منطقة في مفتاح الخريطة — استخدم «قائمة الصلاة» لتحديد على الخريطة",
|
||||
"mapDataFailed": "تعذّر تحميل بيانات الخريطة",
|
||||
"infoLead": "قائمة UPG-100 على خريطة واحدة: الشعوب غير المُبشَّرة، مجمّعة في مناطق يمكنك أن تتبنّاها بالصلاة.",
|
||||
"infoTakeaway": "لكل منطقة: ملصق للحائط، وحزمة صلاة للتوزيع، ومجموعة من بطاقات الصلاة. المثال أدناه هو المنطقة التي تضم أكبر عدد من الناس في شعوب لا يعمل أحد بعد على غرس كنائس بينها."
|
||||
"infoTakeaway": "لكل منطقة: ملصق للحائط، وحزمة صلاة للتوزيع، ومجموعة من بطاقات الصلاة. المثال أدناه هو المنطقة التي تضم أكبر عدد من الناس في شعوب لا يعمل أحد بعد على غرس كنائس بينها.",
|
||||
"selectionColourGroup": "لون التحديد",
|
||||
"selectionColour": "تلوين التحديد {hex}",
|
||||
"customColour": "لون مخصص (#rrggbb)",
|
||||
"copiedSelection": "تم النسخ — الشعوب: {count}",
|
||||
"copiedTerritoriesOnly": "تم نسخ الأقاليم فقط — {reason}",
|
||||
"csvSaved": "تم حفظ CSV — الشعوب: {count}",
|
||||
"csvFailed": "فشل تصدير CSV: {reason}",
|
||||
"copySelection": "نسخ",
|
||||
"exportCsv": "تصدير CSV"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,5 +39,6 @@
|
|||
"error_too_large": "Dieses Poster ist zu groß für den Speicher dieses Geräts. Wählen Sie eine kleinere Größe oder eine niedrigere Auflösung.",
|
||||
"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."
|
||||
"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.",
|
||||
"glue_here": "hier kleben"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,5 +5,14 @@
|
|||
"selectionPosterNeedsZone": "Plakate gibt es pro Gebiet in der Legende – nutzen Sie Gebetsliste für eine Auswahl auf der Karte",
|
||||
"mapDataFailed": "Die Kartendaten konnten nicht geladen werden",
|
||||
"infoLead": "Die UPG-100-Liste auf einer Karte: unerreichte Volksgruppen, zusammengefasst in Zonen, die Sie im Gebet übernehmen können.",
|
||||
"infoTakeaway": "für jede Zone ein Wandposter, ein Gebetsheft zum Weitergeben und ein Satz Gebetskarten. Das Beispiel unten ist die Zone mit den meisten Menschen in Volksgruppen, unter denen noch niemand Gemeinden gründet."
|
||||
"infoTakeaway": "für jede Zone ein Wandposter, ein Gebetsheft zum Weitergeben und ein Satz Gebetskarten. Das Beispiel unten ist die Zone mit den meisten Menschen in Volksgruppen, unter denen noch niemand Gemeinden gründet.",
|
||||
"selectionColourGroup": "Farbe der Auswahl",
|
||||
"selectionColour": "Auswahl einfärben: {hex}",
|
||||
"customColour": "Eigene Farbe (#rrggbb)",
|
||||
"copiedSelection": "Kopiert — Volksgruppen: {count}",
|
||||
"copiedTerritoriesOnly": "Nur die Gebiete kopiert — {reason}",
|
||||
"csvSaved": "CSV gespeichert — Volksgruppen: {count}",
|
||||
"csvFailed": "CSV-Export fehlgeschlagen: {reason}",
|
||||
"copySelection": "Kopieren",
|
||||
"exportCsv": "CSV exportieren"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,5 +39,6 @@
|
|||
"error_too_large": "This poster is too large for this device's memory. Choose a smaller size or a lower resolution.",
|
||||
"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."
|
||||
"phoneLimit": "On a phone, the largest sizes are off: a phone can't draw a poster that big. Use a computer for them.",
|
||||
"glue_here": "glue here"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,5 +5,14 @@
|
|||
"selectionPosterNeedsZone": "Posters exist per territory on the legend — use Prayer List for a map selection",
|
||||
"mapDataFailed": "The map data could not be loaded",
|
||||
"infoLead": "The UPG-100 list on one map: unreached people groups, gathered into zones you can adopt in prayer.",
|
||||
"infoTakeaway": "for any zone, a wall poster, a prayer packet to hand out and a deck of prayer cards. The example below is the zone with the most people in groups where no one is planting churches yet."
|
||||
"infoTakeaway": "for any zone, a wall poster, a prayer packet to hand out and a deck of prayer cards. The example below is the zone with the most people in groups where no one is planting churches yet.",
|
||||
"selectionColourGroup": "Selection colour",
|
||||
"selectionColour": "Colour the selection {hex}",
|
||||
"customColour": "Custom colour (#rrggbb)",
|
||||
"copiedSelection": "Copied — people groups: {count}",
|
||||
"copiedTerritoriesOnly": "Copied the territories only — {reason}",
|
||||
"csvSaved": "CSV saved — people groups: {count}",
|
||||
"csvFailed": "CSV failed: {reason}",
|
||||
"copySelection": "Copy",
|
||||
"exportCsv": "Export CSV"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,5 +39,6 @@
|
|||
"error_too_large": "Este póster es demasiado grande para la memoria de este dispositivo. Elige un tamaño menor o una resolución más baja.",
|
||||
"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."
|
||||
"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.",
|
||||
"glue_here": "pegar aquí"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,5 +5,14 @@
|
|||
"selectionPosterNeedsZone": "Hay carteles por territorio en la leyenda; usa Lista de oración para una selección en el mapa",
|
||||
"mapDataFailed": "No se pudieron cargar los datos del mapa",
|
||||
"infoLead": "La lista UPG-100 en un solo mapa: pueblos no alcanzados, agrupados en zonas que usted puede adoptar en oración.",
|
||||
"infoTakeaway": "para cada zona, un póster para la pared, un paquete de oración para repartir y un mazo de tarjetas de oración. El ejemplo de abajo es la zona con más personas en pueblos donde todavía nadie está plantando iglesias."
|
||||
"infoTakeaway": "para cada zona, un póster para la pared, un paquete de oración para repartir y un mazo de tarjetas de oración. El ejemplo de abajo es la zona con más personas en pueblos donde todavía nadie está plantando iglesias.",
|
||||
"selectionColourGroup": "Color de la selección",
|
||||
"selectionColour": "Colorear la selección {hex}",
|
||||
"customColour": "Color personalizado (#rrggbb)",
|
||||
"copiedSelection": "Copiado — pueblos: {count}",
|
||||
"copiedTerritoriesOnly": "Solo se copiaron los territorios — {reason}",
|
||||
"csvSaved": "CSV guardado — pueblos: {count}",
|
||||
"csvFailed": "Error al exportar el CSV: {reason}",
|
||||
"copySelection": "Copiar",
|
||||
"exportCsv": "Exportar CSV"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,5 +39,6 @@
|
|||
"error_too_large": "Cette affiche est trop grande pour la mémoire de cet appareil. Choisissez un format plus petit ou une résolution plus basse.",
|
||||
"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à."
|
||||
"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à.",
|
||||
"glue_here": "coller ici"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,5 +5,14 @@
|
|||
"selectionPosterNeedsZone": "Les affiches existent par territoire dans la légende — utilisez Liste de prière pour une sélection sur la carte",
|
||||
"mapDataFailed": "Les données de la carte n’ont pas pu être chargées",
|
||||
"infoLead": "La liste UPG-100 sur une seule carte : les peuples non atteints, regroupés en zones que vous pouvez adopter dans la prière.",
|
||||
"infoTakeaway": "pour chaque zone, une affiche murale, un livret de prière à distribuer et un jeu de cartes de prière. L'exemple ci-dessous est la zone qui compte le plus de personnes dans des peuples où personne n'implante encore d'Églises."
|
||||
"infoTakeaway": "pour chaque zone, une affiche murale, un livret de prière à distribuer et un jeu de cartes de prière. L'exemple ci-dessous est la zone qui compte le plus de personnes dans des peuples où personne n'implante encore d'Églises.",
|
||||
"selectionColourGroup": "Couleur de la sélection",
|
||||
"selectionColour": "Colorer la sélection {hex}",
|
||||
"customColour": "Couleur personnalisée (#rrggbb)",
|
||||
"copiedSelection": "Copié — peuples : {count}",
|
||||
"copiedTerritoriesOnly": "Seuls les territoires ont été copiés — {reason}",
|
||||
"csvSaved": "CSV enregistré — peuples : {count}",
|
||||
"csvFailed": "Échec de l’export CSV : {reason}",
|
||||
"copySelection": "Copier",
|
||||
"exportCsv": "Exporter en CSV"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,5 +39,6 @@
|
|||
"error_too_large": "यह पोस्टर इस डिवाइस की मेमोरी के लिए बहुत बड़ा है। छोटा आकार या कम रिज़ॉल्यूशन चुनें।",
|
||||
"error_failed": "पोस्टर नहीं बन सका। फिर से कोशिश करें या छोटा आकार चुनें।",
|
||||
"optional": "(वैकल्पिक)",
|
||||
"phoneLimit": "फ़ोन पर सबसे बड़े आकार बंद हैं: फ़ोन इतना बड़ा पोस्टर नहीं बना सकता। उनके लिए कंप्यूटर का उपयोग करें।"
|
||||
"phoneLimit": "फ़ोन पर सबसे बड़े आकार बंद हैं: फ़ोन इतना बड़ा पोस्टर नहीं बना सकता। उनके लिए कंप्यूटर का उपयोग करें।",
|
||||
"glue_here": "यहाँ चिपकाएँ"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,5 +5,14 @@
|
|||
"selectionPosterNeedsZone": "पोस्टर लेजेंड में हर क्षेत्र के लिए हैं — मानचित्र पर चयन के लिए प्रार्थना सूची का उपयोग करें",
|
||||
"mapDataFailed": "मानचित्र का डेटा लोड नहीं हो सका",
|
||||
"infoLead": "UPG-100 सूची एक ही नक्शे पर: अपहुँचे जन-समूह, क्षेत्रों में बाँटे गए, जिन्हें आप प्रार्थना में अपना सकते हैं।",
|
||||
"infoTakeaway": "हर क्षेत्र के लिए दीवार का एक पोस्टर, बाँटने के लिए एक प्रार्थना पुस्तिका और प्रार्थना कार्डों का एक सेट। नीचे का उदाहरण वह क्षेत्र है जहाँ ऐसे जन-समूहों में सबसे अधिक लोग हैं जिनके बीच अभी कोई कलीसिया स्थापित नहीं कर रहा है।"
|
||||
"infoTakeaway": "हर क्षेत्र के लिए दीवार का एक पोस्टर, बाँटने के लिए एक प्रार्थना पुस्तिका और प्रार्थना कार्डों का एक सेट। नीचे का उदाहरण वह क्षेत्र है जहाँ ऐसे जन-समूहों में सबसे अधिक लोग हैं जिनके बीच अभी कोई कलीसिया स्थापित नहीं कर रहा है।",
|
||||
"selectionColourGroup": "चयन का रंग",
|
||||
"selectionColour": "चयन को रंगें: {hex}",
|
||||
"customColour": "अपना रंग (#rrggbb)",
|
||||
"copiedSelection": "कॉपी हुआ — जनसमूह: {count}",
|
||||
"copiedTerritoriesOnly": "केवल क्षेत्र कॉपी हुए — {reason}",
|
||||
"csvSaved": "CSV सहेजा गया — जनसमूह: {count}",
|
||||
"csvFailed": "CSV निर्यात विफल: {reason}",
|
||||
"copySelection": "कॉपी करें",
|
||||
"exportCsv": "CSV निर्यात करें"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,5 +39,6 @@
|
|||
"error_too_large": "Questo poster è troppo grande per la memoria di questo dispositivo. Scegli un formato più piccolo o una risoluzione più bassa.",
|
||||
"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."
|
||||
"phoneLimit": "Sul telefono i formati più grandi sono disattivati: un telefono non può disegnare un poster così grande. Per quelli usa un computer.",
|
||||
"glue_here": "incollare qui"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,5 +5,14 @@
|
|||
"selectionPosterNeedsZone": "I manifesti esistono per territorio nella legenda: usa Lista di preghiera per una selezione sulla mappa",
|
||||
"mapDataFailed": "Impossibile caricare i dati della mappa",
|
||||
"infoLead": "La lista UPG-100 su un'unica mappa: popoli non raggiunti, raggruppati in zone che puoi adottare nella preghiera.",
|
||||
"infoTakeaway": "per ogni zona, un poster da parete, un libretto di preghiera da distribuire e un mazzo di carte di preghiera. L'esempio qui sotto è la zona con più persone in popoli dove nessuno sta ancora fondando chiese."
|
||||
"infoTakeaway": "per ogni zona, un poster da parete, un libretto di preghiera da distribuire e un mazzo di carte di preghiera. L'esempio qui sotto è la zona con più persone in popoli dove nessuno sta ancora fondando chiese.",
|
||||
"selectionColourGroup": "Colore della selezione",
|
||||
"selectionColour": "Colora la selezione {hex}",
|
||||
"customColour": "Colore personalizzato (#rrggbb)",
|
||||
"copiedSelection": "Copiato — popoli: {count}",
|
||||
"copiedTerritoriesOnly": "Copiati solo i territori — {reason}",
|
||||
"csvSaved": "CSV salvato — popoli: {count}",
|
||||
"csvFailed": "Esportazione CSV non riuscita: {reason}",
|
||||
"copySelection": "Copia",
|
||||
"exportCsv": "Esporta CSV"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,5 +39,6 @@
|
|||
"error_too_large": "Este pôster é grande demais para a memória deste dispositivo. Escolha um tamanho menor ou uma resolução mais baixa.",
|
||||
"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."
|
||||
"phoneLimit": "No telefone, os tamanhos maiores ficam desativados: um telefone não consegue desenhar um cartaz tão grande. Use um computador para eles.",
|
||||
"glue_here": "colar aqui"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,5 +5,14 @@
|
|||
"selectionPosterNeedsZone": "Há cartazes por território na legenda — use Lista de oração para uma seleção no mapa",
|
||||
"mapDataFailed": "Não foi possível carregar os dados do mapa",
|
||||
"infoLead": "A lista UPG-100 em um só mapa: povos não alcançados, agrupados em zonas que você pode adotar em oração.",
|
||||
"infoTakeaway": "para cada zona, um pôster de parede, um pacote de oração para distribuir e um baralho de cartões de oração. O exemplo abaixo é a zona com mais pessoas em povos onde ninguém ainda está plantando igrejas."
|
||||
"infoTakeaway": "para cada zona, um pôster de parede, um pacote de oração para distribuir e um baralho de cartões de oração. O exemplo abaixo é a zona com mais pessoas em povos onde ninguém ainda está plantando igrejas.",
|
||||
"selectionColourGroup": "Cor da seleção",
|
||||
"selectionColour": "Colorir a seleção {hex}",
|
||||
"customColour": "Cor personalizada (#rrggbb)",
|
||||
"copiedSelection": "Copiado — povos: {count}",
|
||||
"copiedTerritoriesOnly": "Apenas os territórios foram copiados — {reason}",
|
||||
"csvSaved": "CSV salvo — povos: {count}",
|
||||
"csvFailed": "Falha ao exportar o CSV: {reason}",
|
||||
"copySelection": "Copiar",
|
||||
"exportCsv": "Exportar CSV"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,5 +39,6 @@
|
|||
"error_too_large": "Acest poster este prea mare pentru memoria acestui dispozitiv. Alegeți o dimensiune mai mică sau o rezoluție mai mică.",
|
||||
"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."
|
||||
"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.",
|
||||
"glue_here": "lipiți aici"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,5 +5,14 @@
|
|||
"selectionPosterNeedsZone": "Afișele există pe teritoriu în legendă — folosiți Listă de rugăciune pentru o selecție pe hartă",
|
||||
"mapDataFailed": "Datele hărții nu au putut fi încărcate",
|
||||
"infoLead": "Lista UPG-100 pe o singură hartă: popoare neatinse, grupate în zone pe care le poți adopta în rugăciune.",
|
||||
"infoTakeaway": "pentru fiecare zonă, un poster de perete, un pachet de rugăciune de împărțit și un set de cartonașe de rugăciune. Exemplul de mai jos este zona cu cei mai mulți oameni din popoare în care nimeni nu plantează încă biserici."
|
||||
"infoTakeaway": "pentru fiecare zonă, un poster de perete, un pachet de rugăciune de împărțit și un set de cartonașe de rugăciune. Exemplul de mai jos este zona cu cei mai mulți oameni din popoare în care nimeni nu plantează încă biserici.",
|
||||
"selectionColourGroup": "Culoarea selecției",
|
||||
"selectionColour": "Colorează selecția {hex}",
|
||||
"customColour": "Culoare personalizată (#rrggbb)",
|
||||
"copiedSelection": "Copiat — popoare: {count}",
|
||||
"copiedTerritoriesOnly": "Au fost copiate doar teritoriile — {reason}",
|
||||
"csvSaved": "CSV salvat — popoare: {count}",
|
||||
"csvFailed": "Exportul CSV a eșuat: {reason}",
|
||||
"copySelection": "Copiază",
|
||||
"exportCsv": "Exportă CSV"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,5 +39,6 @@
|
|||
"error_too_large": "Этот плакат слишком велик для памяти устройства. Выберите меньший размер или более низкое разрешение.",
|
||||
"error_failed": "Не удалось создать плакат. Попробуйте ещё раз или выберите меньший размер.",
|
||||
"optional": "(необязательно)",
|
||||
"phoneLimit": "На телефоне самые большие размеры отключены: телефон не может нарисовать такой большой плакат. Используйте для них компьютер."
|
||||
"phoneLimit": "На телефоне самые большие размеры отключены: телефон не может нарисовать такой большой плакат. Используйте для них компьютер.",
|
||||
"glue_here": "клеить здесь"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,5 +5,14 @@
|
|||
"selectionPosterNeedsZone": "Плакаты есть для каждой территории в легенде — для выбора на карте используйте «Молитвенный список»",
|
||||
"mapDataFailed": "Не удалось загрузить данные карты",
|
||||
"infoLead": "Список UPG-100 на одной карте: недостигнутые народы, объединённые в зоны, которые вы можете взять на молитвенное попечение.",
|
||||
"infoTakeaway": "для каждой зоны — настенный плакат, молитвенный буклет для раздачи и набор молитвенных карточек. Ниже показана зона, где больше всего людей в народах, среди которых пока никто не основывает церкви."
|
||||
"infoTakeaway": "для каждой зоны — настенный плакат, молитвенный буклет для раздачи и набор молитвенных карточек. Ниже показана зона, где больше всего людей в народах, среди которых пока никто не основывает церкви.",
|
||||
"selectionColourGroup": "Цвет выделения",
|
||||
"selectionColour": "Окрасить выделение: {hex}",
|
||||
"customColour": "Свой цвет (#rrggbb)",
|
||||
"copiedSelection": "Скопировано — народы: {count}",
|
||||
"copiedTerritoriesOnly": "Скопированы только территории — {reason}",
|
||||
"csvSaved": "CSV сохранён — народы: {count}",
|
||||
"csvFailed": "Не удалось экспортировать CSV: {reason}",
|
||||
"copySelection": "Копировать",
|
||||
"exportCsv": "Экспорт в CSV"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,5 +39,6 @@
|
|||
"error_too_large": "此海报超出了本设备的内存。请选择较小的尺寸或较低的分辨率。",
|
||||
"error_failed": "无法生成海报。请重试或选择较小的尺寸。",
|
||||
"optional": "(可选)",
|
||||
"phoneLimit": "在手机上,最大的尺寸已关闭:手机无法绘制这么大的海报。请使用电脑制作这些尺寸。"
|
||||
"phoneLimit": "在手机上,最大的尺寸已关闭:手机无法绘制这么大的海报。请使用电脑制作这些尺寸。",
|
||||
"glue_here": "在此粘贴"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,5 +5,14 @@
|
|||
"selectionPosterNeedsZone": "海报按图例中的区域提供——地图上的选择请使用“祷告名单”",
|
||||
"mapDataFailed": "无法加载地图数据",
|
||||
"infoLead": "UPG-100 名单尽在一张地图上:未得之民按区域分组,你可以在祷告中认领一个区域。",
|
||||
"infoTakeaway": "每个区域都有一张墙面海报、一本可分发的祷告手册和一套祷告卡。下面的示例是这样一个区域:在尚无人植堂的族群中,这里的人数最多。"
|
||||
"infoTakeaway": "每个区域都有一张墙面海报、一本可分发的祷告手册和一套祷告卡。下面的示例是这样一个区域:在尚无人植堂的族群中,这里的人数最多。",
|
||||
"selectionColourGroup": "所选区域的颜色",
|
||||
"selectionColour": "将所选区域着色为 {hex}",
|
||||
"customColour": "自定义颜色(#rrggbb)",
|
||||
"copiedSelection": "已复制 — 族群:{count}",
|
||||
"copiedTerritoriesOnly": "仅复制了区域 — {reason}",
|
||||
"csvSaved": "CSV 已保存 — 族群:{count}",
|
||||
"csvFailed": "CSV 导出失败:{reason}",
|
||||
"copySelection": "复制",
|
||||
"exportCsv": "导出 CSV"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -144,6 +144,16 @@
|
|||
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)"
|
||||
: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
|
||||
(homeHref / homeLabel) and the link sits here: directly above Collapse, the same box, in the
|
||||
rail on a desktop and at the top of the drawer on a phone. The URL is the HOST's, never a
|
||||
default, so an embed that passes none shows nothing and the layout stays embeddable anywhere. -->
|
||||
<a v-if="homeLink" class="vtl-home" :href="homeLink" target="_top" :aria-label="homeText"
|
||||
:title="railCollapsed && !narrow ? homeText : null">
|
||||
<span class="vtl-home-ic" aria-hidden="true" v-html="iconSvg('home')"></span>
|
||||
<span class="vtl-home-tx">{{ homeText }}</span>
|
||||
</a>
|
||||
<!-- Rail toggle: always visible, collapses the rail (slim icon rail on desktop; off-canvas
|
||||
drawer on mobile). The chevron points the way the click will move the rail. -->
|
||||
<button
|
||||
|
|
@ -359,12 +369,18 @@ const VTL_SHADOW_CSS = `
|
|||
.vtl-nav::-webkit-scrollbar-track { background:transparent; }
|
||||
.vtl-nav::-webkit-scrollbar-thumb { background:#c4c8c0; border-radius:8px; }
|
||||
.vtl-nav::-webkit-scrollbar-thumb:hover { background:#9aa197; }
|
||||
.vtl-collapse { display:flex; align-items:center; gap:8px; width:100%; box-sizing:border-box; margin:0 0 8px; padding:7px 10px; border:1px solid var(--vtl-border); border-radius:8px; background:#d1d4cd; color:var(--vtl-muted); font:inherit; font-size:12px; cursor:pointer; }
|
||||
.vtl-collapse:hover { background:var(--vtl-accented); color:#3b463d; }
|
||||
.vtl-collapse:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:-2px; }
|
||||
.vtl-collapse, .vtl-home { display:flex; align-items:center; gap:8px; width:100%; box-sizing:border-box; margin:0 0 8px; padding:7px 10px; border:1px solid var(--vtl-border); border-radius:8px; background:#d1d4cd; color:var(--vtl-muted); font:inherit; font-size:12px; cursor:pointer; }
|
||||
.vtl-collapse:hover, .vtl-home:hover { background:var(--vtl-accented); color:#3b463d; }
|
||||
.vtl-collapse:focus-visible, .vtl-home:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:-2px; }
|
||||
.vtl-collapse-ic { flex:0 0 auto; display:inline-flex; transition:transform .16s ease; transform:rotate(180deg); }
|
||||
.vtl-collapse-ic svg { width:16px; height:16px; }
|
||||
.vtl-collapse-tx { white-space:nowrap; }
|
||||
/* The host’s home link (homeHref) shares Collapse’s box through the selector lists above and below, so the
|
||||
two are the same size by construction and cannot drift apart. The house does not rotate or mirror. */
|
||||
.vtl-home { text-decoration:none; }
|
||||
.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; }
|
||||
.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; }
|
||||
|
|
@ -496,9 +512,9 @@ const VTL_SHADOW_CSS = `
|
|||
THIS BLOCK EXISTS TWICE (VTL_SHADOW_CSS and the scoped style block); keep them byte-identical. */
|
||||
.vtl.rail-collapsed .vtl-nav { transform:translateX(-184px); }
|
||||
.vtl.rail-collapsed .vtl-main { left:72px; }
|
||||
.vtl.rail-collapsed .vtl-collapse { padding-left:184px; justify-content:flex-start; }
|
||||
.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-collapse-tx { display:none; }
|
||||
.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; }
|
||||
.vtl.rail-collapsed .vtl-tab-emoji { font-size:19px; }
|
||||
|
|
@ -533,9 +549,9 @@ const VTL_SHADOW_CSS = `
|
|||
.vtl.rail-collapsed .vtl-nav { transform:translateX(-100%); }
|
||||
.vtl.rail-collapsed.nav-open .vtl-nav { transform:translateX(0); }
|
||||
.vtl.rail-collapsed .vtl-main { left:0; }
|
||||
.vtl.rail-collapsed .vtl-collapse { padding-left:10px; justify-content:flex-start; }
|
||||
.vtl.rail-collapsed .vtl-collapse, .vtl.rail-collapsed .vtl-home { padding-left:10px; justify-content:flex-start; }
|
||||
.vtl.rail-collapsed .vtl-collapse-ic { transform:rotate(180deg); }
|
||||
.vtl.rail-collapsed .vtl-collapse-tx { display:inline; }
|
||||
.vtl.rail-collapsed .vtl-collapse-tx, .vtl.rail-collapsed .vtl-home-tx { display:inline; }
|
||||
.vtl.rail-collapsed .vtl-tab { padding-left:10px; gap:11px; }
|
||||
.vtl.rail-collapsed .vtl-tab-text { display:flex; }
|
||||
.vtl.rail-collapsed .vtl-tab-fallback { display:none; }
|
||||
|
|
@ -559,7 +575,7 @@ const VTL_SHADOW_CSS = `
|
|||
.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 { padding-left:10px; padding-right:184px; }
|
||||
.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; }
|
||||
@media (max-width: 760px) {
|
||||
|
|
@ -568,7 +584,7 @@ const VTL_SHADOW_CSS = `
|
|||
.vtl:dir(rtl) .vtl-main, .vtl.rail-collapsed:dir(rtl) .vtl-main { left:0; right:0; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-nav { transform:translateX(100%); }
|
||||
.vtl.rail-collapsed.nav-open:dir(rtl) .vtl-nav { transform:translateX(0); }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-collapse { padding-left:10px; padding-right:10px; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-collapse, .vtl.rail-collapsed:dir(rtl) .vtl-home { padding-left:10px; padding-right:10px; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-tab { padding-left:12px; padding-right:10px; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-tier { padding-left:8px; padding-right:8px; }
|
||||
}
|
||||
|
|
@ -593,7 +609,7 @@ export default defineComponent({
|
|||
groups: { type: Array, default: () => [] },
|
||||
navLabel: { type: String, default: 'Sections' },
|
||||
// The shell's own words, for a localised host: { toggleList, openViews, expandList, collapseList,
|
||||
// collapse }. Any key left out keeps its English default, so existing hosts are unchanged.
|
||||
// collapse, home }. Any key left out keeps its English default, so existing hosts are unchanged.
|
||||
labels: { type: Object, default: () => ({}) },
|
||||
// Start with the rail open (desktop). Auto-collapses on narrow screens after mount.
|
||||
initialNavOpen: { type: Boolean, default: true },
|
||||
|
|
@ -601,7 +617,11 @@ export default defineComponent({
|
|||
// (embedded where a host header already exists — stacked headers look bad). #toolbar is unaffected.
|
||||
header: { type: Boolean, default: true },
|
||||
// Start the desktop rail collapsed (slim icon rail). Off by default so the rail opens expanded.
|
||||
initialRailCollapsed: { type: Boolean, default: false }
|
||||
initialRailCollapsed: { type: Boolean, default: false },
|
||||
// The HOST's homepage, shown as a link above Collapse (see the template). Empty = no link, which is
|
||||
// every existing consumer. homeLabel is the host's own words; unset, labels.home, then English.
|
||||
homeHref: { type: String, default: '' },
|
||||
homeLabel: { type: String, default: '' }
|
||||
},
|
||||
emits: ['update:active'],
|
||||
setup() {
|
||||
|
|
@ -656,6 +676,13 @@ export default defineComponent({
|
|||
// only when the off-canvas drawer is open (navOpen). Binding the rail's v-show to THIS (not raw
|
||||
// navOpen) prevents a mobile-closed drawer from hiding the rail after a resize back to desktop.
|
||||
railShown() { return this.narrow ? this.navOpen : true },
|
||||
// The home link's URL, or '' for none. A script URL is refused: the value can arrive through an
|
||||
// attribute or a JSON config, and a link that runs code is never a way home.
|
||||
homeLink() {
|
||||
const h = String(this.homeHref || '').trim()
|
||||
return (!h || /^(javascript|data|vbscript):/i.test(h.replace(/[\u0000- ]/g, ''))) ? '' : h
|
||||
},
|
||||
homeText() { return this.homeLabel || this.label('home') },
|
||||
// Every row/item flattened across whichever shape is in use (groups take precedence over items).
|
||||
allRows() {
|
||||
if (this.groups && this.groups.length) {
|
||||
|
|
@ -730,7 +757,7 @@ export default defineComponent({
|
|||
}
|
||||
},
|
||||
label(key) {
|
||||
const EN = { toggleList: 'Toggle list', openViews: 'Open the list of views', expandList: 'Expand list', collapseList: 'Collapse list', collapse: 'Collapse' }
|
||||
const EN = { toggleList: 'Toggle list', openViews: 'Open the list of views', expandList: 'Expand list', collapseList: 'Collapse list', collapse: 'Collapse', home: 'Back to homepage' }
|
||||
return (this.labels && this.labels[key]) || EN[key]
|
||||
},
|
||||
// Open the desktop rail if it is in its slim state (a #rail host calls this when its slim-strip
|
||||
|
|
@ -937,12 +964,18 @@ export default defineComponent({
|
|||
.vtl-nav::-webkit-scrollbar-track { background:transparent; }
|
||||
.vtl-nav::-webkit-scrollbar-thumb { background:#c4c8c0; border-radius:8px; }
|
||||
.vtl-nav::-webkit-scrollbar-thumb:hover { background:#9aa197; }
|
||||
.vtl-collapse { display:flex; align-items:center; gap:8px; width:100%; box-sizing:border-box; margin:0 0 8px; padding:7px 10px; border:1px solid var(--vtl-border); border-radius:8px; background:#d1d4cd; color:var(--vtl-muted); font:inherit; font-size:12px; cursor:pointer; }
|
||||
.vtl-collapse:hover { background:var(--vtl-accented); color:#3b463d; }
|
||||
.vtl-collapse:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:-2px; }
|
||||
.vtl-collapse, .vtl-home { display:flex; align-items:center; gap:8px; width:100%; box-sizing:border-box; margin:0 0 8px; padding:7px 10px; border:1px solid var(--vtl-border); border-radius:8px; background:#d1d4cd; color:var(--vtl-muted); font:inherit; font-size:12px; cursor:pointer; }
|
||||
.vtl-collapse:hover, .vtl-home:hover { background:var(--vtl-accented); color:#3b463d; }
|
||||
.vtl-collapse:focus-visible, .vtl-home:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:-2px; }
|
||||
.vtl-collapse-ic { flex:0 0 auto; display:inline-flex; transition:transform .16s ease; transform:rotate(180deg); }
|
||||
.vtl-collapse-ic svg { width:16px; height:16px; }
|
||||
.vtl-collapse-tx { white-space:nowrap; }
|
||||
/* The host’s home link (homeHref) shares Collapse’s box through the selector lists above and below, so the
|
||||
two are the same size by construction and cannot drift apart. The house does not rotate or mirror. */
|
||||
.vtl-home { text-decoration:none; }
|
||||
.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; }
|
||||
.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; }
|
||||
|
|
@ -1074,9 +1107,9 @@ export default defineComponent({
|
|||
THIS BLOCK EXISTS TWICE (VTL_SHADOW_CSS and the scoped style block); keep them byte-identical. */
|
||||
.vtl.rail-collapsed .vtl-nav { transform:translateX(-184px); }
|
||||
.vtl.rail-collapsed .vtl-main { left:72px; }
|
||||
.vtl.rail-collapsed .vtl-collapse { padding-left:184px; justify-content:flex-start; }
|
||||
.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-collapse-tx { display:none; }
|
||||
.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; }
|
||||
.vtl.rail-collapsed .vtl-tab-emoji { font-size:19px; }
|
||||
|
|
@ -1111,9 +1144,9 @@ export default defineComponent({
|
|||
.vtl.rail-collapsed .vtl-nav { transform:translateX(-100%); }
|
||||
.vtl.rail-collapsed.nav-open .vtl-nav { transform:translateX(0); }
|
||||
.vtl.rail-collapsed .vtl-main { left:0; }
|
||||
.vtl.rail-collapsed .vtl-collapse { padding-left:10px; justify-content:flex-start; }
|
||||
.vtl.rail-collapsed .vtl-collapse, .vtl.rail-collapsed .vtl-home { padding-left:10px; justify-content:flex-start; }
|
||||
.vtl.rail-collapsed .vtl-collapse-ic { transform:rotate(180deg); }
|
||||
.vtl.rail-collapsed .vtl-collapse-tx { display:inline; }
|
||||
.vtl.rail-collapsed .vtl-collapse-tx, .vtl.rail-collapsed .vtl-home-tx { display:inline; }
|
||||
.vtl.rail-collapsed .vtl-tab { padding-left:10px; gap:11px; }
|
||||
.vtl.rail-collapsed .vtl-tab-text { display:flex; }
|
||||
.vtl.rail-collapsed .vtl-tab-fallback { display:none; }
|
||||
|
|
@ -1137,7 +1170,7 @@ export default defineComponent({
|
|||
.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 { padding-left:10px; padding-right:184px; }
|
||||
.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; }
|
||||
@media (max-width: 760px) {
|
||||
|
|
@ -1146,7 +1179,7 @@ export default defineComponent({
|
|||
.vtl:dir(rtl) .vtl-main, .vtl.rail-collapsed:dir(rtl) .vtl-main { left:0; right:0; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-nav { transform:translateX(100%); }
|
||||
.vtl.rail-collapsed.nav-open:dir(rtl) .vtl-nav { transform:translateX(0); }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-collapse { padding-left:10px; padding-right:10px; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-collapse, .vtl.rail-collapsed:dir(rtl) .vtl-home { padding-left:10px; padding-right:10px; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-tab { padding-left:12px; padding-right:10px; }
|
||||
.vtl.rail-collapsed:dir(rtl) .vtl-tier { padding-left:8px; padding-right:8px; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ const P = {
|
|||
search: '<circle cx="10.5" cy="10.5" r="7.5"/><line x1="15.8" y1="15.8" x2="20.5" y2="20.5"/>',
|
||||
// `book` = Gospel Access (an open book / the Word reaching a place).
|
||||
book: '<path d="M12 6.5C12 6.5 9.5 4.5 4 4.5v13C9.5 17.5 12 19.5 12 19.5"/><path d="M12 6.5C12 6.5 14.5 4.5 20 4.5v13C14.5 17.5 12 19.5 12 19.5"/>',
|
||||
// `home` = the host site's homepage (VerticalTabsLayout's optional "Back to homepage" link): a house.
|
||||
home: '<path d="M3 11 12 3l9 8"/><path d="M5 9.5V20a1 1 0 0 0 1 1h4v-6h4v6h4a1 1 0 0 0 1-1V9.5"/>',
|
||||
// `wifi` = Cities with Internet (connectivity arcs + node). Local, no CDN.
|
||||
wifi: '<path d="M3.5 9a13 13 0 0 1 17 0"/><path d="M6.8 12.4a8.5 8.5 0 0 1 10.4 0"/><path d="M10 15.7a4 4 0 0 1 4 0"/><circle cx="12" cy="19" r="1"/>',
|
||||
}
|
||||
|
|
|
|||
18
package.json
18
package.json
|
|
@ -222,6 +222,7 @@
|
|||
"check:label-key-coverage": "node internal/check-label-key-coverage.mjs",
|
||||
"check:host-locale-fetch": "node internal/check-host-locale-fetch.mjs",
|
||||
"check:packet-a1-structure": "node internal/check-packet-a1-structure.mjs",
|
||||
"check:batch-selection-export": "node internal/check-batch-selection-export.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",
|
||||
|
|
@ -239,6 +240,7 @@
|
|||
"check:popup-pan-controls": "node internal/check-popup-pan-clears-controls.mjs",
|
||||
"check:country-map-refetch": "node internal/check-country-map-refetch.mjs",
|
||||
"check:pane-config-reforward": "node internal/check-pane-config-reforward.mjs",
|
||||
"check:panel-sheets": "node internal/check-panel-sheets.mjs",
|
||||
"check:control-inset": "node internal/check-control-inset-measured.mjs",
|
||||
"check:viewport-meta": "node internal/check-viewport-meta.mjs",
|
||||
"check:viewport-meta:self-test": "VIEWPORT_META_SELFTEST=1 node internal/check-viewport-meta.mjs",
|
||||
|
|
@ -250,9 +252,9 @@
|
|||
"check:poster-fetch-guard": "node internal/check-poster-fetch-guard.mjs",
|
||||
"check:poster-print-fallback": "node internal/check-poster-print-fallback.mjs",
|
||||
"check:posters-exportable": "node internal/check-posters-exportable.mjs",
|
||||
"check:posterverse-shelf": "node plugin-designs/posterverse/scripts/check-posterverse-shelf.mjs",
|
||||
"check:poster-prints": "node internal/check-poster-prints.mjs",
|
||||
"check:poster-raster": "node internal/check-poster-raster.mjs",
|
||||
"check:poster-wall-host-seams": "node internal/check-poster-wall-host-seams.mjs",
|
||||
"check:poster-wall-localised": "node internal/check-poster-wall-localised.mjs",
|
||||
"check:prayer-walk-planner": "node internal/check-prayer-walk-planner.mjs",
|
||||
"check:preset-config-merge": "node internal/check-preset-config-merge.mjs",
|
||||
|
|
@ -343,7 +345,7 @@
|
|||
"fix:sri": "node internal/fix-sri.mjs",
|
||||
"gallery": "node internal/generate-gallery.mjs",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs 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",
|
||||
"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:poster-wall-host-seams 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: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-new-driver-references 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: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:dxlife check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch",
|
||||
"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: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-new-driver-references 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: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:dxlife check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:panel-sheets check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch check:poster-wall-host-seams",
|
||||
"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",
|
||||
|
|
@ -397,11 +399,13 @@
|
|||
"check:wall-wysiwyg": "node plugin-designs/wall-of-people-groups/scripts/check-wall-wysiwyg.mjs",
|
||||
"check:wall-print-watch": "node plugin-designs/wall-of-people-groups/scripts/check-wall-print-watch.mjs",
|
||||
"check:wall-no-bg-photos": "node plugin-designs/wall-of-people-groups/scripts/check-wall-no-bg-photos.mjs",
|
||||
"check:wall-pan": "node plugin-designs/wall-of-people-groups/scripts/check-wall-pan.mjs",
|
||||
"check:template-i18n": "node plugin-designs/template-bundle/scripts/check-template-i18n.mjs",
|
||||
"check:snippets-draw": "node internal/check-snippets-draw.mjs",
|
||||
"check:snippets-draw:mapbox": "node internal/check-snippets-draw.mjs --mapbox",
|
||||
"pin:poster-render": "node library/scripts/posters/pin-poster-render.mjs",
|
||||
"check:board-map-options": "node internal/check-board-map-options.mjs"
|
||||
"check:board-map-options": "node internal/check-board-map-options.mjs",
|
||||
"check:poster-wall-host-seams": "node internal/check-poster-wall-host-seams.mjs"
|
||||
},
|
||||
"//scripts": {
|
||||
"_": "What each script does. These notes used to sit inside the command strings as trailing \"# …\" comments, which made the shell discard any arguments passed after \"--\" (so a gate given --url silently measured its hardcoded default port — possibly another agent's server). Keep notes here, never in a command.",
|
||||
|
|
@ -419,6 +423,8 @@
|
|||
"check:wall-wysiwyg": "the Wall's screen IS its poster: the Bless view's on-screen poster and its print come from one layout, so for every preset (3x4ft, 2x3ft, 5x8ft) the sheets, the 2,107 card rects, the card text, the verse reference and the chrome agree. Two-sided on planted layout defects. Headless.",
|
||||
"check:wall-print-watch": "the Wall's HD poster print never prints silently incomplete: with fake timers and a fake frame, the watch counts photos as they load, counts the ones that could not, and reports both. Two-sided on planted printWatch copies. Headless.",
|
||||
"check:wall-no-bg-photos": "the Wall's store downloads no photos on its own (it used to test all 932, 132 MB, on every visit): the REAL store runs through Vite against the committed fixtures/pray-list-sample.json with fetch stubbed and every Image request counted. Two-sided with the old background test planted. Headless, no network.",
|
||||
"check:wall-pan": "the Wall pans and zooms with the pointer 1:1: the REAL WallCanvas methods (through Vite) with d3-zoom's transform arithmetic, over 12 fits and zooms: a pan moves every card exactly as far as the pointer, a zoom keeps the point under the pointer, toWorld inverts toScreen, and a fling never loses the wall. Two-sided: the old transform-inside-the-fit formulas are planted and caught. Headless.",
|
||||
"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:template-i18n": "the starter template every new plugin copies renders its text from the catalogue: profile B, server-rendered with the REAL template.json in en, ar and zh, takes its heading, note and sentence from the page's language. Two-sided with the English catalogue served as Arabic. Headless (SSR).",
|
||||
"check:coverage-consistent:chained": "every clause of check:coverage-consistent EXCEPT the link-verification age (VERIFY_MAX_AGE_DAYS set out of reach): the chains run what depends on the code and data, and leave what depends on the calendar to the on-demand run, so the gates cannot turn red on a date with no change.",
|
||||
"check:viewport-meta": "every generated page declares <meta name=\"viewport\"> with width=device-width. Without it a browser lays the page out at the 980px desktop fallback even when the viewport is set to 390, so a mobile probe silently measures a viewport no phone has and still passes (measured: innerWidth 980 without the tag, 390 with it). width=device-width is required specifically, because a meta carrying only initial-scale still lays out at 980. Checked on the BUILT tree, not the generator template. Fixed-size print sheets are exempt by their stated shape (a .stage with a three-digit px width), and a page that must render at a fixed desktop width carries a viewport-meta-ok: reason. 8 self-tests. Source-only, headless.",
|
||||
|
|
@ -550,6 +556,7 @@
|
|||
"check:label-key-coverage": "node internal/check-label-key-coverage.mjs",
|
||||
"check:host-locale-fetch": "node internal/check-host-locale-fetch.mjs",
|
||||
"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: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: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.",
|
||||
|
|
@ -570,8 +577,8 @@
|
|||
"check:poster-fetch-guard": "a host's 200 fallback page is never taken for a poster. On a drop-in dev host a missing posters/<id>.webp answered 200 text/html, and export handed that page to the print dialog as the image. Asserts library/packet/poster-fetch.js accepts a raster only if it is an image (by type or by its first bytes) and a source only if it is a poster document (class=\"stage\"), and that neither usePosterPrints nor PosterWall fetches posters/ directly. Node only, no server.",
|
||||
"check:poster-layout-optin": "the composed poster sheet (header, map, legend beside it, footer) is OPT-IN: spec.layout === 'poster' asks for it, and every caller that does not gets the full-page map exactly as before, asserted by object identity, not just by value. Drives the real layout decisions with real spec objects. Headless, no browser.",
|
||||
"check:poster-print-fallback": "clicks the REAL poster print control with the served poster generator and every posters/*.webp aborted, and asserts the preview RASTERISES from posters/<id>.html instead of opening on \"Poster not available yet\". Needs a served tree built with posterSources:true; NOT MEASURED otherwise.",
|
||||
"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. Headless: PosterWall compiled as a client component on a recording renderer; real onClick and a recording IntersectionObserver. 15 mutants each FAIL.",
|
||||
"check:posters-exportable": "every poster the tree offers can be printed. With posterSources on, the tree once shipped only the 28 zone sources, so 350 of 378 posters said \"poster not found (404)\". Each id, from data/posters and the wall's previews, must ship a real WebP (RIFF..WEBP) or a real poster source (its class=\"stage\" element), so a placeholder or a host's fallback page cannot pass. SKIPs when posters are off by config, which check:poster-degrades covers. Reads the built tree; no server.",
|
||||
"check:posterverse-shelf": "Posterverse (the capability-5 poster shell) shows exactly its allow-list and never a gated plugin. Asserts the rail rows per section (Posters: upg-100, country-posters, poster-design; Experimental: wall-of-people-groups, all-people-groups, upg-neighbor, bible-posters), that no row names key-cities or a shell (a key-cities bundle is planted into the built manifest, because the real one withholds gated profiles), that every present row opens the profile it names (no silent fallback to a leaf), that posterverse.meta.json composes equals the rows whose bundle exists, and 11-locale parity of the shell strings. --self-test plants one defect per arm. Headless. Exit 0 pass, 1 fail, 75 no built manifest.",
|
||||
"check:prayer-walk-planner": "the Prayer Walk Planner is a full-page tool that prints a real walking map: the map canvas fills the page below the toolbar in its own leaf AND inside the Research Gallery at 1280x800 and 390x844 (the gallery once collapsed it to 76px / 0px); a real zone (Balat, Istanbul) renders a >=2000px print map that is not blank, saved to OUT=<dir> so a human can read it; ?lang=es gives a Spanish toolbar; 0 page errors. Run inside the shared browser locks. Exit 2 = SKIP.",
|
||||
"check:preset-config-merge": "a preset page (<bundle>/<profile>/<instance>/index.html) keeps every default in its profile's .meta.json config, with the instance's props merged over them (go-int's upg-neighbor presets lost pinHint:false when props replaced the config). Reads the built tree; SKIP without one.",
|
||||
"check:profile-table": "the prayer packet's profile TABLE (20 full-width rows: picture, generated description, three boxes; PER_PAGE stays 20): a row stays tall enough for a name plus a two-line description at the smallest fit size, and truncation happens only at a whole word. Character-count, not DOM, so Node and the browser agree. Cannot see the RENDERED row height: that is check:packet-render, a browser gate. By the UPG-100 owner; mutation-tested. Headless; in gates:pre.",
|
||||
|
|
@ -654,7 +661,8 @@
|
|||
"pin:poster-render": "rewrite library/packet/poster-render.integrity.js from library/packet/poster-render.js — run after any change to the generator; check:poster-render-pinned fails until it is.",
|
||||
"check:board-map-options": "the GEOGRAPHIC map caller must state whiteboard/boundaries EXPLICITLY, never inherit them. library/maps/whiteboard-map.js defaults to whiteboard:true/boundaries:false (it was extracted for the whiteboard case), the inverse of what a real map needs, so a caller relying on defaults renders a blank basemap. check:gospel-whiteboard cannot see this — it asserts chart geometry and imports no component.",
|
||||
"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: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: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. Headless: PosterWall compiled as a client component on a recording renderer; real onClick and a recording IntersectionObserver. 15 mutants each FAIL."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/android": "^7.6.9",
|
||||
|
|
|
|||
|
|
@ -61,9 +61,11 @@ Add `?tab=<id>` to the `src` to open a specific map.
|
|||
| `token-url` | none | Token endpoint forwarded to each map. |
|
||||
| `tree-base` | derived from the script URL | Pin the tree root explicitly (needed only when the script is re-hosted apart from its tree). |
|
||||
| `heading` | `DX-Maps` | Shell title (shown only when the shell header is enabled). |
|
||||
| `home-href` | none | Your homepage. Shows a "Back to homepage" link directly above the rail’s Collapse button (at the top of the drawer on a phone). Without it there is no link. `javascript:` and `data:` URLs are ignored. |
|
||||
| `home-label` | "Back to homepage" in the gallery’s language | The link’s text, in your own words. |
|
||||
|
||||
Tab ids: `dashboard-plus-grid` (Big Picture), `research-map-plus-grid` (Simple Research),
|
||||
`regions-dashboard`, `upg-100`, `prayer-map` (Prayer Progress), `cities-internet`,
|
||||
`regions-dashboard`, `upg-100` (UPG 100 List Posters), `prayer-map` (Prayer Progress), `country-posters`, `cities-internet`,
|
||||
`gospel-resources`, and every Experimental bundle by its bundle name.
|
||||
|
||||
## Events
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
{
|
||||
"_comment": "Declares the composability tier. 'nested-mfe-spa' (display alias 'app-shell') = Capability-5: this plugin is the OUTER app shell of the dx-maps app, the Research Gallery. Its single element <doxa-maps-spa> renders a vertical-tab rail whose tabs each mount a REAL, independently embeddable plugin: the shell loads that bundle's script (URL from manifest.json) and creates its registered custom element in the pane. There is no iframe mode. Tab order and grouping live in NAV_MODEL/TAB_DEFS in doxa-maps-spa.vue; `composes` below lists the seven curated tabs as bundle/profile and must be kept in step with TAB_DEFS (check:composite reads it; the studio's \"Inside doxa-maps-spa\" tree shows it). Bundles not placed in a section appear under Experimental automatically. The tier is now DERIVED from the `composes` edges below at tree generation (a profile that composes anything is cap-4; one that composes a cap-4 is cap-5), and this explicit `type` is CHECKED against that derivation rather than being the only source: if the two disagree the build fails, because one of them is stale. discover.mjs still never infers tiers 3-5 on its own.",
|
||||
"_comment": "Declares the composability tier. 'nested-mfe-spa' (display alias 'app-shell') = Capability-5: this plugin is the OUTER app shell of the dx-maps app, the Research Gallery. Its single element <doxa-maps-spa> renders a vertical-tab rail whose tabs each mount a REAL, independently embeddable plugin: the shell loads that bundle's script (URL from manifest.json) and creates its registered custom element in the pane. There is no iframe mode. Tab order and grouping live in NAV_MODEL/TAB_DEFS in doxa-maps-spa.vue; `composes` below lists the eight curated tabs as bundle/profile and must be kept in step with TAB_DEFS (check:composite reads it; the studio's \"Inside doxa-maps-spa\" tree shows it). Bundles not placed in a section appear under Experimental automatically. The tier is now DERIVED from the `composes` edges below at tree generation (a profile that composes anything is cap-4; one that composes a cap-4 is cap-5), and this explicit `type` is CHECKED against that derivation rather than being the only source: if the two disagree the build fails, because one of them is stale. discover.mjs still never infers tiers 3-5 on its own.",
|
||||
"type": "nested-mfe-spa",
|
||||
"composes": [
|
||||
"dashboard-plus-grid/dashboard-plus-grid",
|
||||
"research-map-plus-grid/research-map-plus-grid",
|
||||
"regions-dashboard/regions-mfe",
|
||||
"my-upg-100-list/upg-100-mfe",
|
||||
"country-posters/country-posters-mfe",
|
||||
"doxa-simple-map/doxa-simple-map",
|
||||
"cities-internet/connectivity-map",
|
||||
"gospel-resources/resources-map"
|
||||
],
|
||||
"label": "Deep Research (vertical-tab app shell: the seven production maps)"
|
||||
"label": "Deep Research (vertical-tab app shell: the eight production maps)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,8 @@
|
|||
:dir="uiDir"
|
||||
:lang="uiLocale"
|
||||
:labels="layoutLabels"
|
||||
:home-href="homeHref || cfg.homeHref || ''"
|
||||
:home-label="homeLabel || cfg.homeLabel || ''"
|
||||
:title="headingText"
|
||||
:nav-label="t('navLabel')"
|
||||
:header="showHeader"
|
||||
|
|
@ -276,11 +278,17 @@ const NAV_MODEL = [
|
|||
// per the spec's table. UPG-100 BEFORE Prayer Progress is a deliberate product order: to someone
|
||||
// being mobilized to pray, the UPG-100 list is the NEW thing, so they meet it first — hand the
|
||||
// newcomer the concrete new artifact, THEN ask them to pray it.
|
||||
//
|
||||
// THE POSTER ROWS (2026-09-30): the section now runs Prayer Progress, UPG 100 List Posters, Country Posters,
|
||||
// then Poster Design. The same poster plugins also live in the Posterverse shell; each is ONE bundle, mounted
|
||||
// by whichever shell opens it. Poster Design joins this list when its bundle exists: a row whose bundle is
|
||||
// not in the manifest is dropped without a word (resolveTabsFromManifest), so listing it early shows nothing.
|
||||
{ key: 'prayer-mobilization', label: 'Prayer Mobilization', i18n: 'sec.prayer', icon: 'heart',
|
||||
tabs: [
|
||||
// Prayer Progress, then the UPG-100 List: the published order of this section, fixed.
|
||||
{ id: 'prayer-map', icon: 'embed' },
|
||||
{ id: 'upg-100', icon: 'list' },
|
||||
// Prayer Progress, then UPG 100 List Posters, then Country Posters: the published order, fixed.
|
||||
{ id: 'prayer-map', icon: 'embed' },
|
||||
{ id: 'upg-100', icon: 'list' },
|
||||
{ id: 'country-posters', icon: 'poster' },
|
||||
] },
|
||||
// Gospel Research — RENAMED section (was "Research"). Cities with Internet then Gospel Resources,
|
||||
// per the spec's table (rows 5-6). The plain, uncomposited "Research Map" tab (doxa-research-map)
|
||||
|
|
@ -352,7 +360,11 @@ const NOT_EXPERIMENTAL = new Set([
|
|||
'doxa-research-map',
|
||||
// stack-demo is broken and DEFERRED; a broken tab in the giveaway shelf reads as
|
||||
// product damage. It returns when someone fixes it (a tracked issue).
|
||||
'stack-demo'
|
||||
'stack-demo',
|
||||
// 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'
|
||||
])
|
||||
|
||||
// ── EXPERIMENTAL ORDER = KINGDOM IMPACT, not the alphabet ────────────────────────────────
|
||||
|
|
@ -371,8 +383,7 @@ const EXPERIMENTAL_RANK = {
|
|||
'upg-neighbor': 3, // near reached neighbors, both directions ("who goes, to whom")
|
||||
'key-cities': 4, // where the unreached physically live
|
||||
'all-people-groups': 5, // the census view that feeds everything else
|
||||
'country-posters': 6, // mobilization print artifacts
|
||||
'doxa-vision-card': 7, // vision framing
|
||||
'doxa-vision-card': 7, // vision framing (6 was country-posters, now a Prayer Mobilization row)
|
||||
}
|
||||
|
||||
// The tab manifest. Each entry CURATES one tab: which bundle+profile it shows, in what order, under
|
||||
|
|
@ -476,9 +487,16 @@ const TAB_DEFS = [
|
|||
bundle: 'regions-dashboard', profile: 'regions-mfe'
|
||||
},
|
||||
{
|
||||
id: 'upg-100', label: 'UPG-100 List', sub: 'my unreached list + posters', emoji: '📋',
|
||||
id: 'upg-100', label: 'UPG 100 List Posters', sub: 'my unreached list + posters', emoji: '📋',
|
||||
bundle: 'my-upg-100-list', profile: 'upg-100-mfe'
|
||||
},
|
||||
{
|
||||
// The capability-4 composite, as with Regions and UPG-100 above: country-posters also ships the
|
||||
// single views countries-map and country-poster-wall, and naming one of those would mount a
|
||||
// part without the view strip that joins them.
|
||||
id: 'country-posters', label: 'Country Posters', sub: 'a poster for every country', emoji: '🗺️',
|
||||
bundle: 'country-posters', profile: 'country-posters-mfe'
|
||||
},
|
||||
// ── NOTE: there are deliberately NO hardcoded Experimental entries here. Every built bundle not
|
||||
// named above is discovered from manifest.json at runtime and appended as an Experimental tab
|
||||
// (see discoverExperimental + NOT_EXPERIMENTAL). That is what keeps a NEW plugin visible with
|
||||
|
|
@ -794,7 +812,12 @@ export default defineComponent({
|
|||
provider: { type: String, default: '' },
|
||||
// Which tab opens first, by tab id (e.g. "upg-100", "gospel-resources"). Lets a host page deep-
|
||||
// link into one map of the gallery. Falls back to ?tab= on the page URL, then the device default.
|
||||
tab: { type: String, default: '' }
|
||||
tab: { type: String, default: '' },
|
||||
// The HOST's homepage: a "Back to homepage" link directly above the rail's Collapse button (a phone
|
||||
// shows it at the top of the drawer). Also accepted as profile-config.homeHref / homeLabel. No URL,
|
||||
// no link: the gallery never invents one, because it cannot know whose page it is on.
|
||||
homeHref: { type: String, default: '' },
|
||||
homeLabel: { type: String, default: '' }
|
||||
},
|
||||
emits: ['tab-change'],
|
||||
setup() {
|
||||
|
|
@ -1021,7 +1044,7 @@ export default defineComponent({
|
|||
// defaults inside VerticalTabsLayout, translated here through its `labels` contract.
|
||||
layoutLabels() {
|
||||
const out = {}
|
||||
for (const k of ['toggleList', 'openViews', 'expandList', 'collapseList', 'collapse']) out[k] = this.tr(k)
|
||||
for (const k of ['toggleList', 'openViews', 'expandList', 'collapseList', 'collapse', 'home']) out[k] = this.tr(k)
|
||||
return out
|
||||
},
|
||||
// The engine a HOST asked for ('' = none; each map keeps its own ladder).
|
||||
|
|
|
|||
|
|
@ -25,16 +25,16 @@ export const STRINGS = {
|
|||
'tab.cities-internet': 'Cities with Internet', 'sub.cities-internet': 'connectivity of key cities',
|
||||
'tab.gospel-resources': 'Gospel Resources', 'sub.gospel-resources': 'resources by place',
|
||||
'tab.regions-dashboard': 'Regions', 'sub.regions-dashboard': 'regions and their countries',
|
||||
'tab.upg-100': 'UPG-100 List', 'sub.upg-100': 'my unreached list + posters',
|
||||
'tab.upg-100': "UPG 100 List Posters", 'sub.upg-100': 'my unreached list + posters',
|
||||
navLabel: 'Research maps', meta: '{n} maps', loading: 'Loading {label}…', loadFailed: 'Could not load {label}.',
|
||||
manifestHint: 'This tab is composed from manifest.json — build the tree, or serve the built tree, to load it.',
|
||||
close: 'Close', help: 'About this map', desktopOnly: 'desktop only', lab: 'lab', experimentalSub: 'experimental bundle',
|
||||
mobileNotBuilt: 'not built for mobile', dismiss: 'Dismiss', desktopOnly: 'desktop only', lab: 'lab', experimentalSub: 'experimental bundle',
|
||||
// The shared VerticalTabsLayout's own controls, VERBATIM from library/i18n/locales/en/studio.json
|
||||
// (plugin studio's catalog) so both shells name the same buttons the same way.
|
||||
toggleList: "Toggle list", openViews: "Open the list of views", expandList: "Expand list", collapseList: "Collapse list", collapse: "Collapse",
|
||||
toggleList: "Toggle list", openViews: "Open the list of views", expandList: "Expand list", collapseList: "Collapse list", collapse: "Collapse", home: "Back to homepage",
|
||||
// Experimental bundles known today (rail-length names; a bundle not listed keeps its manifest title).
|
||||
'tab.prayer-walk-planner': "Prayer Walk Planner", 'tab.upg-neighbor': "UPG Neighbor", 'tab.key-cities': "Key Cities", 'tab.all-people-groups': "All People Groups", 'tab.pplr-data-maps': "PPLR Data Maps", 'tab.wall-of-people-groups': "Wall of People Groups", 'tab.country-posters': "Country Posters", 'tab.doxa-vision-card': "Doxa Vision Card",
|
||||
'tab.prayer-walk-planner': "Prayer Walk Planner", 'tab.upg-neighbor': "UPG Neighbor", 'tab.key-cities': "Key Cities", 'tab.all-people-groups': "All People Groups", 'tab.pplr-data-maps': "PPLR Data Maps", 'tab.wall-of-people-groups': "Wall of People Groups", 'tab.country-posters': "Country Posters", 'sub.country-posters': "a poster for every country", 'tab.doxa-vision-card': "Doxa Vision Card",
|
||||
},
|
||||
es: {
|
||||
backToTop: 'Volver arriba',
|
||||
|
|
@ -46,16 +46,16 @@ export const STRINGS = {
|
|||
'tab.cities-internet': 'Ciudades con Internet', 'sub.cities-internet': 'conectividad de ciudades clave',
|
||||
'tab.gospel-resources': 'Recursos del Evangelio', 'sub.gospel-resources': 'recursos por lugar',
|
||||
'tab.regions-dashboard': 'Regiones', 'sub.regions-dashboard': 'regiones y sus países',
|
||||
'tab.upg-100': 'Lista UPG-100', 'sub.upg-100': 'mi lista de no alcanzados + carteles',
|
||||
'tab.upg-100': "Carteles de la lista UPG 100", 'sub.upg-100': 'mi lista de no alcanzados + carteles',
|
||||
navLabel: 'Mapas de investigación', meta: '{n} mapas', loading: 'Cargando {label}…', loadFailed: 'No se pudo cargar {label}.',
|
||||
manifestHint: 'Esta pestaña se compone a partir de manifest.json: compila el árbol o sirve el árbol compilado para cargarla.',
|
||||
close: 'Cerrar', help: 'Acerca de este mapa', desktopOnly: 'solo escritorio', lab: 'beta', experimentalSub: 'mapa experimental',
|
||||
mobileNotBuilt: 'no diseñado para móvil', dismiss: 'Cerrar', desktopOnly: 'solo escritorio', lab: 'beta', experimentalSub: 'mapa experimental',
|
||||
// The shared VerticalTabsLayout's own controls, VERBATIM from library/i18n/locales/es/studio.json
|
||||
// (plugin studio's catalog) so both shells name the same buttons the same way.
|
||||
toggleList: "Mostrar u ocultar la lista", openViews: "Abrir la lista de vistas", expandList: "Expandir la lista", collapseList: "Contraer la lista", collapse: "Contraer",
|
||||
toggleList: "Mostrar u ocultar la lista", openViews: "Abrir la lista de vistas", expandList: "Expandir la lista", collapseList: "Contraer la lista", collapse: "Contraer", home: "Volver a la página de inicio",
|
||||
// Experimental bundles known today (rail-length names; a bundle not listed keeps its manifest title).
|
||||
'tab.prayer-walk-planner': "Caminatas de oración", 'tab.upg-neighbor': "Vecino UPG", 'tab.key-cities': "Ciudades clave", 'tab.all-people-groups': "Todos los pueblos", 'tab.pplr-data-maps': "Mapas de datos PPLR", 'tab.wall-of-people-groups': "Muro de los pueblos", 'tab.country-posters': "Carteles por país", 'tab.doxa-vision-card': "Tarjeta de visión Doxa",
|
||||
'tab.prayer-walk-planner': "Caminatas de oración", 'tab.upg-neighbor': "Vecino UPG", 'tab.key-cities': "Ciudades clave", 'tab.all-people-groups': "Todos los pueblos", 'tab.pplr-data-maps': "Mapas de datos PPLR", 'tab.wall-of-people-groups': "Muro de los pueblos", 'tab.country-posters': "Carteles por país", 'sub.country-posters': "un cartel para cada país", 'tab.doxa-vision-card': "Tarjeta de visión Doxa",
|
||||
},
|
||||
fr: {
|
||||
backToTop: 'Retour en haut',
|
||||
|
|
@ -67,16 +67,16 @@ export const STRINGS = {
|
|||
'tab.cities-internet': 'Villes avec Internet', 'sub.cities-internet': 'connectivité des villes clés',
|
||||
'tab.gospel-resources': "Ressources de l'Évangile", 'sub.gospel-resources': 'ressources par lieu',
|
||||
'tab.regions-dashboard': 'Régions', 'sub.regions-dashboard': 'régions et leurs pays',
|
||||
'tab.upg-100': 'Liste UPG-100', 'sub.upg-100': 'ma liste des non atteints + affiches',
|
||||
'tab.upg-100': "Affiches de la liste UPG 100", 'sub.upg-100': 'ma liste des non atteints + affiches',
|
||||
navLabel: 'Cartes de recherche', meta: '{n} cartes', loading: 'Chargement de {label}…', loadFailed: 'Impossible de charger {label}.',
|
||||
manifestHint: "Cet onglet est composé à partir de manifest.json : construisez l'arborescence ou servez l'arborescence construite pour le charger.",
|
||||
close: 'Fermer', help: 'À propos de cette carte', desktopOnly: 'ordinateur uniquement', lab: 'bêta', experimentalSub: 'carte expérimentale',
|
||||
mobileNotBuilt: 'pas conçu pour mobile', dismiss: 'Fermer', desktopOnly: 'ordinateur uniquement', lab: 'bêta', experimentalSub: 'carte expérimentale',
|
||||
// The shared VerticalTabsLayout's own controls, VERBATIM from library/i18n/locales/fr/studio.json
|
||||
// (plugin studio's catalog) so both shells name the same buttons the same way.
|
||||
toggleList: "Afficher ou masquer la liste", openViews: "Ouvrir la liste des vues", expandList: "Déplier la liste", collapseList: "Replier la liste", collapse: "Replier",
|
||||
toggleList: "Afficher ou masquer la liste", openViews: "Ouvrir la liste des vues", expandList: "Déplier la liste", collapseList: "Replier la liste", collapse: "Replier", home: "Retour à l’accueil",
|
||||
// Experimental bundles known today (rail-length names; a bundle not listed keeps its manifest title).
|
||||
'tab.prayer-walk-planner': "Marches de prière", 'tab.upg-neighbor': "Voisin UPG", 'tab.key-cities': "Villes clés", 'tab.all-people-groups': "Tous les peuples", 'tab.pplr-data-maps': "Cartes de données PPLR", 'tab.wall-of-people-groups': "Mur des peuples", 'tab.country-posters': "Affiches par pays", 'tab.doxa-vision-card': "Carte de vision Doxa",
|
||||
'tab.prayer-walk-planner': "Marches de prière", 'tab.upg-neighbor': "Voisin UPG", 'tab.key-cities': "Villes clés", 'tab.all-people-groups': "Tous les peuples", 'tab.pplr-data-maps': "Cartes de données PPLR", 'tab.wall-of-people-groups': "Mur des peuples", 'tab.country-posters': "Affiches par pays", 'sub.country-posters': "une affiche pour chaque pays", 'tab.doxa-vision-card': "Carte de vision Doxa",
|
||||
},
|
||||
de: {
|
||||
backToTop: 'Nach oben',
|
||||
|
|
@ -88,16 +88,16 @@ export const STRINGS = {
|
|||
'tab.cities-internet': 'Städte mit Internet', 'sub.cities-internet': 'Konnektivität wichtiger Städte',
|
||||
'tab.gospel-resources': 'Evangeliumsressourcen', 'sub.gospel-resources': 'Ressourcen nach Ort',
|
||||
'tab.regions-dashboard': 'Regionen', 'sub.regions-dashboard': 'Regionen und ihre Länder',
|
||||
'tab.upg-100': 'UPG-100-Liste', 'sub.upg-100': 'meine Liste der Unerreichten + Poster',
|
||||
'tab.upg-100': "Poster der UPG 100 Liste", 'sub.upg-100': 'meine Liste der Unerreichten + Poster',
|
||||
navLabel: 'Forschungskarten', meta: '{n} Karten', loading: '{label} wird geladen…', loadFailed: '{label} konnte nicht geladen werden.',
|
||||
manifestHint: 'Dieser Tab wird aus manifest.json zusammengesetzt – bauen Sie den Baum oder stellen Sie den gebauten Baum bereit, um ihn zu laden.',
|
||||
close: 'Schließen', help: 'Über diese Karte', desktopOnly: 'nur Desktop', lab: 'Beta', experimentalSub: 'experimentelle Karte',
|
||||
mobileNotBuilt: 'nicht für Mobilgeräte', dismiss: 'Schließen', desktopOnly: 'nur Desktop', lab: 'Beta', experimentalSub: 'experimentelle Karte',
|
||||
// The shared VerticalTabsLayout's own controls, VERBATIM from library/i18n/locales/de/studio.json
|
||||
// (plugin studio's catalog) so both shells name the same buttons the same way.
|
||||
toggleList: "Liste ein-/ausblenden", openViews: "Ansichtsliste öffnen", expandList: "Liste ausklappen", collapseList: "Liste einklappen", collapse: "Einklappen",
|
||||
toggleList: "Liste ein-/ausblenden", openViews: "Ansichtsliste öffnen", expandList: "Liste ausklappen", collapseList: "Liste einklappen", collapse: "Einklappen", home: "Zurück zur Startseite",
|
||||
// Experimental bundles known today (rail-length names; a bundle not listed keeps its manifest title).
|
||||
'tab.prayer-walk-planner': "Gebetsspaziergänge", 'tab.upg-neighbor': "UPG-Nachbar", 'tab.key-cities': "Schlüsselstädte", 'tab.all-people-groups': "Alle Volksgruppen", 'tab.pplr-data-maps': "PPLR-Datenkarten", 'tab.wall-of-people-groups': "Wand der Volksgruppen", 'tab.country-posters': "Länderposter", 'tab.doxa-vision-card': "Doxa-Visionskarte",
|
||||
'tab.prayer-walk-planner': "Gebetsspaziergänge", 'tab.upg-neighbor': "UPG-Nachbar", 'tab.key-cities': "Schlüsselstädte", 'tab.all-people-groups': "Alle Volksgruppen", 'tab.pplr-data-maps': "PPLR-Datenkarten", 'tab.wall-of-people-groups': "Wand der Volksgruppen", 'tab.country-posters': "Länderposter", 'sub.country-posters': "ein Poster für jedes Land", 'tab.doxa-vision-card': "Doxa-Visionskarte",
|
||||
},
|
||||
pt: {
|
||||
backToTop: 'Voltar ao topo',
|
||||
|
|
@ -109,16 +109,16 @@ export const STRINGS = {
|
|||
'tab.cities-internet': 'Cidades com Internet', 'sub.cities-internet': 'conectividade das principais cidades',
|
||||
'tab.gospel-resources': 'Recursos do Evangelho', 'sub.gospel-resources': 'recursos por lugar',
|
||||
'tab.regions-dashboard': 'Regiões', 'sub.regions-dashboard': 'regiões e seus países',
|
||||
'tab.upg-100': 'Lista UPG-100', 'sub.upg-100': 'minha lista de não alcançados + cartazes',
|
||||
'tab.upg-100': "Cartazes da lista UPG 100", 'sub.upg-100': 'minha lista de não alcançados + cartazes',
|
||||
navLabel: 'Mapas de pesquisa', meta: '{n} mapas', loading: 'Carregando {label}…', loadFailed: 'Não foi possível carregar {label}.',
|
||||
manifestHint: 'Esta aba é composta a partir do manifest.json — compile a árvore ou sirva a árvore compilada para carregá-la.',
|
||||
close: 'Fechar', help: 'Sobre este mapa', desktopOnly: 'só no computador', lab: 'beta', experimentalSub: 'mapa experimental',
|
||||
mobileNotBuilt: 'não feito para celular', dismiss: 'Fechar', desktopOnly: 'só no computador', lab: 'beta', experimentalSub: 'mapa experimental',
|
||||
// The shared VerticalTabsLayout's own controls, VERBATIM from library/i18n/locales/pt/studio.json
|
||||
// (plugin studio's catalog) so both shells name the same buttons the same way.
|
||||
toggleList: "Mostrar ou ocultar a lista", openViews: "Abrir a lista de visualizações", expandList: "Expandir a lista", collapseList: "Recolher a lista", collapse: "Recolher",
|
||||
toggleList: "Mostrar ou ocultar a lista", openViews: "Abrir a lista de visualizações", expandList: "Expandir a lista", collapseList: "Recolher a lista", collapse: "Recolher", home: "Voltar à página inicial",
|
||||
// Experimental bundles known today (rail-length names; a bundle not listed keeps its manifest title).
|
||||
'tab.prayer-walk-planner': "Caminhadas de oração", 'tab.upg-neighbor': "Vizinho UPG", 'tab.key-cities': "Cidades-chave", 'tab.all-people-groups': "Todos os povos", 'tab.pplr-data-maps': "Mapas de dados PPLR", 'tab.wall-of-people-groups': "Mural dos povos", 'tab.country-posters': "Cartazes por país", 'tab.doxa-vision-card': "Cartão de visão Doxa",
|
||||
'tab.prayer-walk-planner': "Caminhadas de oração", 'tab.upg-neighbor': "Vizinho UPG", 'tab.key-cities': "Cidades-chave", 'tab.all-people-groups': "Todos os povos", 'tab.pplr-data-maps': "Mapas de dados PPLR", 'tab.wall-of-people-groups': "Mural dos povos", 'tab.country-posters': "Cartazes por país", 'sub.country-posters': "um cartaz para cada país", 'tab.doxa-vision-card': "Cartão de visão Doxa",
|
||||
},
|
||||
it: {
|
||||
backToTop: 'Torna su',
|
||||
|
|
@ -132,16 +132,16 @@ export const STRINGS = {
|
|||
'tab.cities-internet': 'Città con Internet', 'sub.cities-internet': 'connettività delle città chiave',
|
||||
'tab.gospel-resources': 'Risorse del Vangelo', 'sub.gospel-resources': 'risorse per luogo',
|
||||
'tab.regions-dashboard': 'Regioni', 'sub.regions-dashboard': 'regioni e i loro paesi',
|
||||
'tab.upg-100': 'Lista UPG-100', 'sub.upg-100': 'la mia lista dei non raggiunti + poster',
|
||||
'tab.upg-100': "Poster della lista UPG 100", 'sub.upg-100': 'la mia lista dei non raggiunti + poster',
|
||||
navLabel: 'Mappe di ricerca', meta: '{n} mappe', loading: 'Caricamento di {label}…', loadFailed: 'Impossibile caricare {label}.',
|
||||
manifestHint: "Questa scheda è composta da manifest.json: compila l'albero o pubblica l'albero compilato per caricarla.",
|
||||
close: 'Chiudi', help: 'Informazioni su questa mappa', desktopOnly: 'solo desktop', lab: 'beta', experimentalSub: 'mappa sperimentale',
|
||||
mobileNotBuilt: 'non pensato per mobile', dismiss: 'Chiudi', desktopOnly: 'solo desktop', lab: 'beta', experimentalSub: 'mappa sperimentale',
|
||||
// The shared VerticalTabsLayout's own controls, VERBATIM from library/i18n/locales/it/studio.json
|
||||
// (plugin studio's catalog) so both shells name the same buttons the same way.
|
||||
toggleList: "Mostra o nascondi l’elenco", openViews: "Apri l’elenco delle viste", expandList: "Espandi l’elenco", collapseList: "Comprimi l’elenco", collapse: "Comprimi",
|
||||
toggleList: "Mostra o nascondi l’elenco", openViews: "Apri l’elenco delle viste", expandList: "Espandi l’elenco", collapseList: "Comprimi l’elenco", collapse: "Comprimi", home: "Torna alla home page",
|
||||
// Experimental bundles known today (rail-length names; a bundle not listed keeps its manifest title).
|
||||
'tab.prayer-walk-planner': "Passeggiate di preghiera", 'tab.upg-neighbor': "Vicino UPG", 'tab.key-cities': "Città chiave", 'tab.all-people-groups': "Tutti i popoli", 'tab.pplr-data-maps': "Mappe dati PPLR", 'tab.wall-of-people-groups': "Muro dei popoli", 'tab.country-posters': "Poster per paese", 'tab.doxa-vision-card': "Scheda visione Doxa",
|
||||
'tab.prayer-walk-planner': "Passeggiate di preghiera", 'tab.upg-neighbor': "Vicino UPG", 'tab.key-cities': "Città chiave", 'tab.all-people-groups': "Tutti i popoli", 'tab.pplr-data-maps': "Mappe dati PPLR", 'tab.wall-of-people-groups': "Muro dei popoli", 'tab.country-posters': "Poster per paese", 'sub.country-posters': "un poster per ogni paese", 'tab.doxa-vision-card': "Scheda visione Doxa",
|
||||
},
|
||||
ro: {
|
||||
backToTop: 'Sus',
|
||||
|
|
@ -153,16 +153,16 @@ export const STRINGS = {
|
|||
'tab.cities-internet': 'Orașe cu internet', 'sub.cities-internet': 'conectivitatea orașelor-cheie',
|
||||
'tab.gospel-resources': 'Resurse ale Evangheliei', 'sub.gospel-resources': 'resurse după loc',
|
||||
'tab.regions-dashboard': 'Regiuni', 'sub.regions-dashboard': 'regiunile și țările lor',
|
||||
'tab.upg-100': 'Lista UPG-100', 'sub.upg-100': 'lista mea de neatinși + afișe',
|
||||
'tab.upg-100': "Afișe din lista UPG 100", 'sub.upg-100': 'lista mea de neatinși + afișe',
|
||||
navLabel: 'Hărți de cercetare', meta: '{n} hărți', loading: 'Se încarcă {label}…', loadFailed: '{label} nu a putut fi încărcat.',
|
||||
manifestHint: 'Această filă este compusă din manifest.json — construiți arborele sau serviți arborele construit pentru a o încărca.',
|
||||
close: 'Închide', help: 'Despre această hartă', desktopOnly: 'doar pe desktop', lab: 'beta', experimentalSub: 'hartă experimentală',
|
||||
mobileNotBuilt: 'nu e făcut pentru mobil', dismiss: 'Închide', desktopOnly: 'doar pe desktop', lab: 'beta', experimentalSub: 'hartă experimentală',
|
||||
// The shared VerticalTabsLayout's own controls, VERBATIM from library/i18n/locales/ro/studio.json
|
||||
// (plugin studio's catalog) so both shells name the same buttons the same way.
|
||||
toggleList: "Afișează sau ascunde lista", openViews: "Deschide lista de vizualizări", expandList: "Extinde lista", collapseList: "Restrânge lista", collapse: "Restrânge",
|
||||
toggleList: "Afișează sau ascunde lista", openViews: "Deschide lista de vizualizări", expandList: "Extinde lista", collapseList: "Restrânge lista", collapse: "Restrânge", home: "Înapoi la pagina principală",
|
||||
// Experimental bundles known today (rail-length names; a bundle not listed keeps its manifest title).
|
||||
'tab.prayer-walk-planner': "Plimbări de rugăciune", 'tab.upg-neighbor': "Vecin UPG", 'tab.key-cities': "Orașe-cheie", 'tab.all-people-groups': "Toate popoarele", 'tab.pplr-data-maps': "Hărți de date PPLR", 'tab.wall-of-people-groups': "Zidul popoarelor", 'tab.country-posters': "Afișe pe țări", 'tab.doxa-vision-card': "Card de viziune Doxa",
|
||||
'tab.prayer-walk-planner': "Plimbări de rugăciune", 'tab.upg-neighbor': "Vecin UPG", 'tab.key-cities': "Orașe-cheie", 'tab.all-people-groups': "Toate popoarele", 'tab.pplr-data-maps': "Hărți de date PPLR", 'tab.wall-of-people-groups': "Zidul popoarelor", 'tab.country-posters': "Afișe pe țări", 'sub.country-posters': "un afiș pentru fiecare țară", 'tab.doxa-vision-card': "Card de viziune Doxa",
|
||||
},
|
||||
ru: {
|
||||
backToTop: 'Наверх',
|
||||
|
|
@ -174,16 +174,16 @@ export const STRINGS = {
|
|||
'tab.cities-internet': 'Города с интернетом', 'sub.cities-internet': 'подключённость ключевых городов',
|
||||
'tab.gospel-resources': 'Евангельские ресурсы', 'sub.gospel-resources': 'ресурсы по местам',
|
||||
'tab.regions-dashboard': 'Регионы', 'sub.regions-dashboard': 'регионы и их страны',
|
||||
'tab.upg-100': 'Список UPG-100', 'sub.upg-100': 'мой список недостигнутых + плакаты',
|
||||
'tab.upg-100': "Плакаты списка UPG 100", 'sub.upg-100': 'мой список недостигнутых + плакаты',
|
||||
navLabel: 'Исследовательские карты', meta: 'Карт: {n}', loading: 'Загрузка: {label}…', loadFailed: 'Не удалось загрузить: {label}.',
|
||||
manifestHint: 'Эта вкладка собирается из manifest.json — соберите дерево или разместите собранное дерево, чтобы загрузить её.',
|
||||
close: 'Закрыть', help: 'Об этой карте', desktopOnly: 'только на компьютере', lab: 'бета', experimentalSub: 'экспериментальная карта',
|
||||
mobileNotBuilt: 'не для телефона', dismiss: 'Закрыть', desktopOnly: 'только на компьютере', lab: 'бета', experimentalSub: 'экспериментальная карта',
|
||||
// The shared VerticalTabsLayout's own controls, VERBATIM from library/i18n/locales/ru/studio.json
|
||||
// (plugin studio's catalog) so both shells name the same buttons the same way.
|
||||
toggleList: "Показать или скрыть список", openViews: "Открыть список представлений", expandList: "Развернуть список", collapseList: "Свернуть список", collapse: "Свернуть",
|
||||
toggleList: "Показать или скрыть список", openViews: "Открыть список представлений", expandList: "Развернуть список", collapseList: "Свернуть список", collapse: "Свернуть", home: "На главную страницу",
|
||||
// Experimental bundles known today (rail-length names; a bundle not listed keeps its manifest title).
|
||||
'tab.prayer-walk-planner': "Молитвенные прогулки", 'tab.upg-neighbor': "Сосед UPG", 'tab.key-cities': "Ключевые города", 'tab.all-people-groups': "Все народы", 'tab.pplr-data-maps': "Карты данных PPLR", 'tab.wall-of-people-groups': "Стена народов", 'tab.country-posters': "Плакаты по странам", 'tab.doxa-vision-card': "Карточка видения Doxa",
|
||||
'tab.prayer-walk-planner': "Молитвенные прогулки", 'tab.upg-neighbor': "Сосед UPG", 'tab.key-cities': "Ключевые города", 'tab.all-people-groups': "Все народы", 'tab.pplr-data-maps': "Карты данных PPLR", 'tab.wall-of-people-groups': "Стена народов", 'tab.country-posters': "Плакаты по странам", 'sub.country-posters': "плакат для каждой страны", 'tab.doxa-vision-card': "Карточка видения Doxa",
|
||||
},
|
||||
ar: {
|
||||
backToTop: 'إلى الأعلى',
|
||||
|
|
@ -195,16 +195,16 @@ export const STRINGS = {
|
|||
'tab.cities-internet': 'مدن متصلة بالإنترنت', 'sub.cities-internet': 'اتصال المدن الرئيسية',
|
||||
'tab.gospel-resources': 'موارد الإنجيل', 'sub.gospel-resources': 'الموارد حسب المكان',
|
||||
'tab.regions-dashboard': 'المناطق', 'sub.regions-dashboard': 'المناطق ودولها',
|
||||
'tab.upg-100': 'قائمة UPG-100', 'sub.upg-100': 'قائمتي لغير المُبشَّرين + ملصقات',
|
||||
'tab.upg-100': "ملصقات قائمة UPG 100", 'sub.upg-100': 'قائمتي لغير المُبشَّرين + ملصقات',
|
||||
navLabel: 'خرائط البحث', meta: 'عدد الخرائط: {n}', loading: 'جارٍ تحميل {label}…', loadFailed: 'تعذّر تحميل {label}.',
|
||||
manifestHint: 'تُبنى علامة التبويب هذه من manifest.json — ابنِ الشجرة أو قدّم الشجرة المبنية لتحميلها.',
|
||||
close: 'إغلاق', help: 'عن هذه الخريطة', desktopOnly: 'على الحاسوب فقط', lab: 'تجريبي', experimentalSub: 'خريطة تجريبية',
|
||||
mobileNotBuilt: 'غير مُعدّ للهاتف', dismiss: 'إغلاق', desktopOnly: 'على الحاسوب فقط', lab: 'تجريبي', experimentalSub: 'خريطة تجريبية',
|
||||
// The shared VerticalTabsLayout's own controls, VERBATIM from library/i18n/locales/ar/studio.json
|
||||
// (plugin studio's catalog) so both shells name the same buttons the same way.
|
||||
toggleList: "إظهار القائمة أو إخفاؤها", openViews: "فتح قائمة العروض", expandList: "توسيع القائمة", collapseList: "طي القائمة", collapse: "طي",
|
||||
toggleList: "إظهار القائمة أو إخفاؤها", openViews: "فتح قائمة العروض", expandList: "توسيع القائمة", collapseList: "طي القائمة", collapse: "طي", home: "العودة إلى الصفحة الرئيسية",
|
||||
// Experimental bundles known today (rail-length names; a bundle not listed keeps its manifest title).
|
||||
'tab.prayer-walk-planner': "جولات الصلاة", 'tab.upg-neighbor': "جار UPG", 'tab.key-cities': "المدن الرئيسية", 'tab.all-people-groups': "جميع الشعوب", 'tab.pplr-data-maps': "خرائط بيانات PPLR", 'tab.wall-of-people-groups': "جدار الشعوب", 'tab.country-posters': "ملصقات الدول", 'tab.doxa-vision-card': "بطاقة رؤية Doxa",
|
||||
'tab.prayer-walk-planner': "جولات الصلاة", 'tab.upg-neighbor': "جار UPG", 'tab.key-cities': "المدن الرئيسية", 'tab.all-people-groups': "جميع الشعوب", 'tab.pplr-data-maps': "خرائط بيانات PPLR", 'tab.wall-of-people-groups': "جدار الشعوب", 'tab.country-posters': "ملصقات الدول", 'sub.country-posters': "ملصق لكل دولة", 'tab.doxa-vision-card': "بطاقة رؤية Doxa",
|
||||
},
|
||||
hi: {
|
||||
backToTop: 'ऊपर जाएँ',
|
||||
|
|
@ -216,16 +216,16 @@ export const STRINGS = {
|
|||
'tab.cities-internet': 'इंटरनेट वाले शहर', 'sub.cities-internet': 'प्रमुख शहरों की कनेक्टिविटी',
|
||||
'tab.gospel-resources': 'सुसमाचार संसाधन', 'sub.gospel-resources': 'स्थान के अनुसार संसाधन',
|
||||
'tab.regions-dashboard': 'क्षेत्र', 'sub.regions-dashboard': 'क्षेत्र और उनके देश',
|
||||
'tab.upg-100': 'UPG-100 सूची', 'sub.upg-100': 'मेरी अप्राप्त सूची + पोस्टर',
|
||||
'tab.upg-100': "UPG 100 सूची पोस्टर", 'sub.upg-100': 'मेरी अप्राप्त सूची + पोस्टर',
|
||||
navLabel: 'शोध मानचित्र', meta: '{n} मानचित्र', loading: '{label} लोड हो रहा है…', loadFailed: '{label} लोड नहीं हो सका।',
|
||||
manifestHint: 'यह टैब manifest.json से बनता है — इसे लोड करने के लिए ट्री बनाएँ या बना हुआ ट्री सर्व करें।',
|
||||
close: 'बंद करें', help: 'इस नक्शे के बारे में', desktopOnly: 'केवल डेस्कटॉप', lab: 'बीटा', experimentalSub: 'प्रायोगिक मानचित्र',
|
||||
mobileNotBuilt: 'मोबाइल के लिए नहीं बना', dismiss: 'बंद करें', desktopOnly: 'केवल डेस्कटॉप', lab: 'बीटा', experimentalSub: 'प्रायोगिक मानचित्र',
|
||||
// The shared VerticalTabsLayout's own controls, VERBATIM from library/i18n/locales/hi/studio.json
|
||||
// (plugin studio's catalog) so both shells name the same buttons the same way.
|
||||
toggleList: "सूची दिखाएँ या छिपाएँ", openViews: "दृश्यों की सूची खोलें", expandList: "सूची फैलाएँ", collapseList: "सूची संक्षिप्त करें", collapse: "संक्षिप्त करें",
|
||||
toggleList: "सूची दिखाएँ या छिपाएँ", openViews: "दृश्यों की सूची खोलें", expandList: "सूची फैलाएँ", collapseList: "सूची संक्षिप्त करें", collapse: "संक्षिप्त करें", home: "होमपेज पर वापस जाएँ",
|
||||
// Experimental bundles known today (rail-length names; a bundle not listed keeps its manifest title).
|
||||
'tab.prayer-walk-planner': "प्रार्थना पदयात्रा", 'tab.upg-neighbor': "UPG पड़ोसी", 'tab.key-cities': "प्रमुख शहर", 'tab.all-people-groups': "सभी जनसमूह", 'tab.pplr-data-maps': "PPLR डेटा मानचित्र", 'tab.wall-of-people-groups': "जनसमूहों की दीवार", 'tab.country-posters': "देश पोस्टर", 'tab.doxa-vision-card': "Doxa दर्शन कार्ड",
|
||||
'tab.prayer-walk-planner': "प्रार्थना पदयात्रा", 'tab.upg-neighbor': "UPG पड़ोसी", 'tab.key-cities': "प्रमुख शहर", 'tab.all-people-groups': "सभी जनसमूह", 'tab.pplr-data-maps': "PPLR डेटा मानचित्र", 'tab.wall-of-people-groups': "जनसमूहों की दीवार", 'tab.country-posters': "देश पोस्टर", 'sub.country-posters': "हर देश के लिए एक पोस्टर", 'tab.doxa-vision-card': "Doxa दर्शन कार्ड",
|
||||
},
|
||||
zh: {
|
||||
backToTop: '回到顶部',
|
||||
|
|
@ -237,16 +237,16 @@ export const STRINGS = {
|
|||
'tab.cities-internet': '有互联网的城市', 'sub.cities-internet': '主要城市的网络连接',
|
||||
'tab.gospel-resources': '福音资源', 'sub.gospel-resources': '按地点划分的资源',
|
||||
'tab.regions-dashboard': '区域', 'sub.regions-dashboard': '区域及其国家',
|
||||
'tab.upg-100': 'UPG-100 名单', 'sub.upg-100': '我的未得之民名单 + 海报',
|
||||
'tab.upg-100': "UPG 100 名单海报", 'sub.upg-100': '我的未得之民名单 + 海报',
|
||||
navLabel: '研究地图', meta: '{n} 张地图', loading: '正在加载{label}…', loadFailed: '无法加载{label}。',
|
||||
manifestHint: '此标签页由 manifest.json 组成——请构建目录树或提供已构建的目录树以加载它。',
|
||||
close: '关闭', help: '关于此地图', desktopOnly: '仅限桌面', lab: '测试版', experimentalSub: '实验性地图',
|
||||
mobileNotBuilt: '未针对手机设计', dismiss: '关闭', desktopOnly: '仅限桌面', lab: '测试版', experimentalSub: '实验性地图',
|
||||
// The shared VerticalTabsLayout's own controls, VERBATIM from library/i18n/locales/zh/studio.json
|
||||
// (plugin studio's catalog) so both shells name the same buttons the same way.
|
||||
toggleList: "显示或隐藏列表", openViews: "打开视图列表", expandList: "展开列表", collapseList: "折叠列表", collapse: "折叠",
|
||||
toggleList: "显示或隐藏列表", openViews: "打开视图列表", expandList: "展开列表", collapseList: "折叠列表", collapse: "折叠", home: "返回首页",
|
||||
// Experimental bundles known today (rail-length names; a bundle not listed keeps its manifest title).
|
||||
'tab.prayer-walk-planner': "祷告行走", 'tab.upg-neighbor': "UPG 邻居", 'tab.key-cities': "重点城市", 'tab.all-people-groups': "所有民族", 'tab.pplr-data-maps': "PPLR 数据地图", 'tab.wall-of-people-groups': "民族之墙", 'tab.country-posters': "国家海报", 'tab.doxa-vision-card': "Doxa 异象卡",
|
||||
'tab.prayer-walk-planner': "祷告行走", 'tab.upg-neighbor': "UPG 邻居", 'tab.key-cities': "重点城市", 'tab.all-people-groups': "所有民族", 'tab.pplr-data-maps': "PPLR 数据地图", 'tab.wall-of-people-groups': "民族之墙", 'tab.country-posters': "国家海报", 'sub.country-posters': "每个国家一张海报", 'tab.doxa-vision-card': "Doxa 异象卡",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
70
plugin-designs/my-upg-100-list/data/selection-export.js
vendored
Normal file
70
plugin-designs/my-upg-100-list/data/selection-export.js
vendored
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
// selection-export.js — what the batch selection's Copy and Export CSV buttons produce. PURE: no DOM, no
|
||||
// fetch, so internal/check-batch-selection-export.mjs gates it headless.
|
||||
//
|
||||
// ★ WHY THE SELECTION IS EXPORTED AT ALL: when the people-group data changes, a partner redraws the zone
|
||||
// boundaries on the map, exports, and hands the export to an agent, which regroups the zones and regenerates
|
||||
// the posters. Without the export the maps cannot be reproduced.
|
||||
//
|
||||
// THE CLIPBOARD TEXT, in this order:
|
||||
// line 1 "NPL, BTN, … — {total} UUPG · #hex" formatClipboard's line in MACHINE names (payloadName):
|
||||
// data/apply-grouping.mjs parses AND resolves it, so it MUST stay first
|
||||
// line 2 "Nepal 34, Bhutan 13, …" the per-territory breakdown, in the reader's names
|
||||
// blank line, then the SAME CSV that Export CSV downloads (when the people groups could be detected).
|
||||
//
|
||||
// THE CSV: one row per people group, header first, CRLF line ends. The cells follow the library's rules
|
||||
// (useCsvExport's esc): quote a cell holding , " CR or LF and double its quotes; prefix a leading = + - @
|
||||
// with a single quote, so a spreadsheet never runs a name as a formula. Also a leading TAB or CR (OWASP's
|
||||
// full list of formula triggers; the library's esc stops at = + - @).
|
||||
import { formatClipboard } from './territory-counts.js';
|
||||
|
||||
export const SELECTION_CSV_COLUMNS = ['slug', 'name', 'country_code', 'population', 'territories', 'color'];
|
||||
|
||||
export function csvCell(v) {
|
||||
let s = v === null || v === undefined ? '' : String(v);
|
||||
if (/^[=+\-@\t\r]/.test(s)) s = "'" + s;
|
||||
if (/[",\n\r]/.test(s)) s = '"' + s.replace(/"/g, '""') + '"';
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<{slug,name,country_code,population}>} records the detected people groups
|
||||
* @param {Map<string,string[]>} territoriesBySlug slug → the selected territories that contain it
|
||||
* @param {{color?: string}} opts the picked selection colour ('#rrggbb' or '')
|
||||
*/
|
||||
export function selectionCsv(records, territoriesBySlug = new Map(), opts = {}) {
|
||||
const color = opts && typeof opts.color === 'string' ? opts.color : '';
|
||||
const rows = (records || []).map((r) => [
|
||||
r.slug, r.name, r.country_code, r.population,
|
||||
(territoriesBySlug.get(r.slug) || []).join('; '), color,
|
||||
].map(csvCell).join(','));
|
||||
return [SELECTION_CSV_COLUMNS.join(','), ...rows].join('\r\n');
|
||||
}
|
||||
|
||||
// ★ LINE 1 IS A MACHINE CONTRACT: data/apply-grouping.mjs resolves every name in it, so it carries the names that
|
||||
// tool resolves in EVERY UI language, never the reader's translation (an Arabic "نيبال, بوتان" was rejected:
|
||||
// "unresolved territory name(s)"). A country is its ISO3 code, the only country form apply-grouping resolves for
|
||||
// every country (its friendly-name table has ~15); a state or province is its shape name, already canonical; a
|
||||
// zone keeps its name. The reader's own names stay on line 2, in the HUD and in the CSV.
|
||||
export function payloadName(t) {
|
||||
if (t && t.kind === 'adm0') return String(t.key || '').replace(/^adm0:/, '') || t.name;
|
||||
return t ? t.name : '';
|
||||
}
|
||||
|
||||
/** The clipboard text: apply-grouping's line (machine names), the reader's breakdown, then the CSV after a blank line. */
|
||||
export function selectionClipboard(selection, opts = {}, csv = '') {
|
||||
const color = opts && opts.color ? opts.color : '';
|
||||
const sel = Array.isArray(selection) ? selection : [];
|
||||
const line1 = formatClipboard(sel.map((t) => ({ ...t, name: payloadName(t) })), { color }).split('\n')[0];
|
||||
const line2 = formatClipboard(sel, { color }).split('\n')[1];
|
||||
const head = line1 + '\n' + line2;
|
||||
return csv ? head + '\n\n' + csv : head;
|
||||
}
|
||||
|
||||
/** A file name a partner can recognise: the selection's label, reduced to safe characters. */
|
||||
export function selectionCsvFileName(label) {
|
||||
const base = String(label || 'selection').normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase().slice(0, 60) || 'selection';
|
||||
return `upg-100-${base}.csv`;
|
||||
}
|
||||
|
||||
export default { SELECTION_CSV_COLUMNS, csvCell, payloadName, selectionCsv, selectionClipboard, selectionCsvFileName };
|
||||
|
|
@ -128,6 +128,7 @@ 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 } from './data/selection-export.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 +
|
||||
|
|
@ -258,6 +259,8 @@ function setBatchMode(on) {
|
|||
if (next === batchMode.value) return next // idempotent: repeat sets don't churn
|
||||
batchMode.value = next
|
||||
if (map) map.getCanvas().style.cursor = '' // reset hover cursor on mode flip
|
||||
// Warm the people-group records so Copy / Export CSV run from cache inside the tap (see selectionRecordsLoad).
|
||||
if (next) selectionRecordsLoad().catch(() => {})
|
||||
// R1.4: show/hide ALL batch chrome (map highlight + legend markers) in the SAME
|
||||
// pass as the mode flip — no frame where the mode and its chrome disagree.
|
||||
refreshSelectionPaint()
|
||||
|
|
@ -1814,65 +1817,65 @@ function selectedAdm1Names() {
|
|||
.map((t) => t.name || String(t.key).replace(/^adm1:/, ''));
|
||||
}
|
||||
|
||||
async function selectionAsList() {
|
||||
// ── THE SELECTION'S PEOPLE, detected ONCE for every action (packet, cards, Copy, Export CSV) ─────────
|
||||
// The records load once per language and are CACHED: batch mode prefetches them when it turns on, so a
|
||||
// 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 }
|
||||
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)
|
||||
const promise = (async () => {
|
||||
const resp = await fetch(`${API_BASE}/api/people-groups/list?fields=${fields}&lang=${lang}`, { cache: 'no-store' });
|
||||
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));
|
||||
})();
|
||||
_selRecords = { lang, promise };
|
||||
promise.catch(() => { if (_selRecords && _selRecords.promise === promise) _selRecords = null; });
|
||||
return promise;
|
||||
}
|
||||
|
||||
// { records, from: Map(slug → the selected territories that contain it), missing: adm1 names } in the
|
||||
// SELECTION'S order: a zone's groups, then a country's, each record once. Territory names are the HUD's own
|
||||
// (selectedTerritories[].name), the same names the clipboard's first line carries.
|
||||
async function selectionPeople() {
|
||||
const lists = selectedLists();
|
||||
const codes = selectedCountryCodes();
|
||||
const adm1 = selectedAdm1Names();
|
||||
if (!lists.length && !codes.length) {
|
||||
// Name WHY when the only thing selected is a province — "nothing selected" would be a lie the
|
||||
// reader can see is false, with their selection visibly highlighted on the map.
|
||||
// reader can see is false.
|
||||
if (adm1.length) throw keyedError('upgMap.selectionAdm1NoData', 'no people-group data for {names} — select the country instead', { names: formatList(adm1, { locale: packetLang() }) });
|
||||
throw keyedError('upgMap.nothingSelected', 'nothing is selected');
|
||||
}
|
||||
if (!API_BASE) throw keyedError('wall.hud.noApi', 'no people-groups service is configured');
|
||||
// ★ primary_language + imb_gsec FEED THE GENERATED PROFILE DESCRIPTION (brief A1.5 col 2,
|
||||
// 2026-09-28). describeGroup builds a localized sentence — who, where, what language, how reached —
|
||||
// and GSEC is the only reachedness field that is a NUMBER: EvangelicalLevel is English prose from the
|
||||
// API ('2% or Greater but Less than 5%'), so interpolating it would leave every non-English packet
|
||||
// half-English in its widest column (sr measured this across 12,379 rows). primary_language is the
|
||||
// same field poster-wall already fetches (:171), so this adds no new API surface.
|
||||
// ★ wagf_region + wagf_block ARE SELECTOR INPUTS, not display fields. SIX of the 27 printable
|
||||
// zones select on them — west-africa / east-africa / central-africa (wagf_block), europe
|
||||
// (wagf_region), southern-africa-islands (union of two wagf_block) and oceania-indonesia (union of
|
||||
// wagf_region + country_code). Without them here, evaluate() matched NOTHING on packet rows and
|
||||
// those packets printed EMPTY, while the same selectors resolved correctly on the map — whose
|
||||
// fetch (:1988) has always carried both. Measured: a wagf_block selector returns 0 members on
|
||||
// packet rows and 1 on map rows. oceania-indonesia was the worst of the six because its union
|
||||
// still matched IDN, so it printed a PLAUSIBLE packet that was silently missing all of Oceania.
|
||||
const fields = PRINT_FIELDS; // the print standard's one field list (lists/peopleGroupRecords.js)
|
||||
const resp = await fetch(`${API_BASE}/api/people-groups/list?fields=${fields}&lang=${packetLang()}`,
|
||||
{ cache: 'no-store' });
|
||||
if (!resp.ok) throw keyedError('wall.hud.fetchFailed', 'could not load the people groups ({status})', { status: resp.status });
|
||||
const records = normalizeRecords(await applyPeopleGroupList(extractRecords(await resp.json()), profileConfig));
|
||||
const records = await selectionRecordsLoad();
|
||||
const { evaluate } = await import('./data/selector-evaluator.js');
|
||||
const slugs = new Set();
|
||||
for (const l of lists) {
|
||||
if (l.selector && l.selector.type === 'pg_slugs') {
|
||||
for (const s of (l.selector.values || [])) slugs.add(s);
|
||||
} else {
|
||||
for (const s of evaluate(l.selector, records)) slugs.add(s);
|
||||
const bySlug = new Map(records.map((r) => [r.slug, r]));
|
||||
const byId = new Map(printableZones().map((l) => [l.id, l]));
|
||||
const from = new Map();
|
||||
const add = (slug, name) => {
|
||||
const at = from.get(slug);
|
||||
if (!at) from.set(slug, [name]);
|
||||
else if (!at.includes(name)) at.push(name);
|
||||
};
|
||||
for (const t of selectedTerritories.value) {
|
||||
if (t.kind === 'zone') {
|
||||
const l = byId.get(String(t.key).replace(/^zone:/, ''));
|
||||
if (!l) continue;
|
||||
const slugs = l.selector && l.selector.type === 'pg_slugs' ? (l.selector.values || []) : evaluate(l.selector, records);
|
||||
for (const s of slugs) add(s, t.name);
|
||||
} else if (t.kind === 'adm0') {
|
||||
for (const s of evaluate({ type: 'country_codes', values: [String(t.key).replace(/^adm0:/, '')] }, records)) add(s, t.name);
|
||||
}
|
||||
}
|
||||
// The tapped countries, through the SAME evaluator and into the SAME slug set, so a mixed
|
||||
// selection (a legend zone AND a country) unions correctly and de-duplicates for free.
|
||||
if (codes.length) {
|
||||
for (const s of evaluate({ type: 'country_codes', values: codes }, records)) slugs.add(s);
|
||||
}
|
||||
// The label is what the reader sees on the cover and in the print dialog's title. Name the
|
||||
// territories they actually chose rather than inventing a title for their selection.
|
||||
// ⚠ THE JOINERS WERE HARDCODED ENGLISH on a cover an Arabic reader sees: ' + ' and ' +N more', printed
|
||||
// straight onto the packet's first page and into the print dialog's title. Found while checking whether
|
||||
// batch selection was already done (dx-shepherd END-DXS-U100B) — the SELECTION path was, but its label
|
||||
// was not localized, so a reader choosing three territories in Arabic got their names in Arabic joined
|
||||
// by an English tail.
|
||||
// zone_join / zone_join_more already exist in all eleven packet-strings blocks (landed with the zone
|
||||
// title generator), translated once for exactly this shape, so this is a reuse rather than new strings.
|
||||
// ⚠ NAME THE COUNTRIES TOO. Built from `lists` alone this was EMPTY for a country-only selection,
|
||||
// and names.reduce(join2) on an empty array THROWS ("Reduce of empty array with no initial value")
|
||||
// — so even after the selector fix the packet would have died on its cover. The names come from
|
||||
// selectedTerritories in the reader's own selection order, which is the order the cover promises.
|
||||
// Each chosen zone named as its own packet would title it — generated in the reader's language where
|
||||
// it can be (one-country zones), the hand-written label otherwise.
|
||||
return { records: [...from.keys()].map((s) => bySlug.get(s)).filter(Boolean), from, missing: adm1 };
|
||||
}
|
||||
|
||||
async function selectionAsList() {
|
||||
const lists = selectedLists();
|
||||
const { from } = await selectionPeople();
|
||||
const zoneNames = (await Promise.all(lists.map(localizedZone))).map((l) => l.label || l.id);
|
||||
const countryNames = selectedTerritories.value.filter((t) => t.kind === 'adm0').map((t) => t.name);
|
||||
const names = [...zoneNames, ...countryNames];
|
||||
|
|
@ -1882,8 +1885,84 @@ async function selectionAsList() {
|
|||
? names.reduce(join2)
|
||||
: fillJoin(S.zone_join_more, { a: names.slice(0, 2).reduce(join2), n: names.length - 2 })
|
||||
?? `${names.slice(0, 2).join(' + ')} +${names.length - 2} more`;
|
||||
return { id: 'selection', label, count: slugs.size,
|
||||
selector: { type: 'pg_slugs', values: [...slugs] } };
|
||||
return { id: 'selection', label, count: from.size,
|
||||
selector: { type: 'pg_slugs', values: [...from.keys()] } };
|
||||
}
|
||||
|
||||
// ── COPY + EXPORT CSV (restored 2026-09-30; see data/selection-export.js for WHY and the formats) ────────
|
||||
// COPY starts SYNCHRONOUSLY: the text is a promise handed to ClipboardItem, which Safari and Chrome accept
|
||||
// inside the tap even when the people groups are still loading. Where that is refused it falls back to
|
||||
// writeText, then to the textarea path. A selection whose people groups cannot be detected (a province)
|
||||
// still copies the territory lines that data/apply-grouping.mjs needs, and the flash says what was left out.
|
||||
function selectionNote(missing) {
|
||||
return missing && missing.length
|
||||
? t('upgMap.selectionAdm1NoData', { names: formatList(missing, { locale: packetLang() }) }) : '';
|
||||
}
|
||||
function copySelection() {
|
||||
const sel = selectedTerritories.value;
|
||||
if (!sel.length) return;
|
||||
const color = selectedColor.value;
|
||||
let count = null, note = '';
|
||||
const text = selectionPeople().then((p) => {
|
||||
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 }); });
|
||||
text.then((v) => { try { window.__UPG_LAST_CLIPBOARD__ = v } catch (_) {} });
|
||||
writeClipboard(text).then((ok) => {
|
||||
const done = count == null ? t('upgMap.copiedTerritoriesOnly', { reason: note.slice(0, 90) })
|
||||
: t('upgMap.copiedSelection', { count }) + (note ? ' · ' + note.slice(0, 90) : '');
|
||||
flashRef(copyFlash, ok ? done : t('messages.copyFailed'), ok && note ? 6000 : 3000);
|
||||
});
|
||||
}
|
||||
async function writeClipboard(textPromise) {
|
||||
const nav = typeof navigator !== 'undefined' ? navigator : null;
|
||||
if (nav && nav.clipboard && nav.clipboard.write && typeof ClipboardItem !== 'undefined') {
|
||||
try {
|
||||
await nav.clipboard.write([new ClipboardItem({ 'text/plain': textPromise.then((v) => new Blob([v], { type: 'text/plain' })) })]);
|
||||
return true;
|
||||
} catch (_) { /* refused (e.g. no promise support): the next paths */ }
|
||||
}
|
||||
const text = await textPromise;
|
||||
try {
|
||||
if (nav && nav.clipboard && nav.clipboard.writeText) { await nav.clipboard.writeText(text); return true; }
|
||||
} catch (_) { /* no permission or no activation left: the textarea path */ }
|
||||
return legacyCopy(text);
|
||||
}
|
||||
// textarea + execCommand('copy'): a non-secure context has no navigator.clipboard at all.
|
||||
function legacyCopy(text) {
|
||||
try {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed'; ta.style.opacity = '0'; ta.style.top = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.focus(); ta.select();
|
||||
const ok = document.execCommand && document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
return !!ok;
|
||||
} catch (_) { return false; }
|
||||
}
|
||||
// EXPORT CSV downloads the same CSV Copy carries. A UTF-8 byte-order mark leads the file so a spreadsheet
|
||||
// reads the diacritics and non-Latin names as UTF-8 rather than guessing a legacy code page.
|
||||
async function exportSelectionCsv() {
|
||||
if (batchBusy.value) return;
|
||||
batchBusy.value = true;
|
||||
busyAction.value = 'csv';
|
||||
try {
|
||||
const p = await selectionPeople();
|
||||
const color = selectedColor.value;
|
||||
const csv = selectionCsv(p.records, p.from, { color });
|
||||
try { window.__UPG_LAST_CSV__ = csv } catch (_) {}
|
||||
const url = URL.createObjectURL(new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' }));
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = selectionCsvFileName(selectedTerritories.value.map((x) => x.name).join(' '));
|
||||
a.style.display = 'none';
|
||||
document.body.appendChild(a); a.click();
|
||||
setTimeout(() => { try { URL.revokeObjectURL(url); a.remove(); } catch (_) {} }, 400);
|
||||
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);
|
||||
} finally { batchBusy.value = false; busyAction.value = ''; }
|
||||
}
|
||||
|
||||
async function makeSelectionPacket() {
|
||||
|
|
@ -3009,6 +3088,9 @@ onBeforeUnmount(() => {
|
|||
if (window.__UPG_SEARCH__ === _mySearchHandle) delete window.__UPG_SEARCH__
|
||||
if (window.__UPG_MAP__ === _myMapHandle) delete window.__UPG_MAP__
|
||||
if (window.__UPG_SELECT__ === _mySelectHandle) delete window.__UPG_SELECT__
|
||||
// Copy / Export CSV's last text (the gates read them back); released with the rest of this map's hooks.
|
||||
delete window.__UPG_LAST_CLIPBOARD__
|
||||
delete window.__UPG_LAST_CSV__
|
||||
} catch (_) { /* a frozen or cross-origin window must not break teardown */ }
|
||||
}
|
||||
})
|
||||
|
|
@ -3232,12 +3314,22 @@ onBeforeUnmount(() => {
|
|||
</span>
|
||||
</div>
|
||||
<div v-if="selCount" class="upg-hud-names">{{ formatList(selectedTerritories.map(t => t.name), { locale: packetLang() }) }}</div>
|
||||
<!-- ★ THE SELECTION MAKES SOMETHING. The batch tool was reported very buggy, and letting
|
||||
a reader change the colour of regions is NOT what is wanted — the batch selection tool
|
||||
should let them create their own poster or prayer packet from their own selection.
|
||||
So the colour swatches and the hex input are GONE, and the actions that remain BUILD
|
||||
the two artefacts this app already knows how to make, from exactly what was picked.
|
||||
These reuse printPrayerPacket / printPoster rather than growing a third pipeline. -->
|
||||
<!-- ★ RECOLOUR IS BACK (2026-09-30), beside the actions that build from the selection. The batch tool
|
||||
exists so that when the people-group data changes, partners can redraw the zones and re-derive
|
||||
their people groups: the colour previews on the map (the upg-sel-fill layer repaints; the base map
|
||||
stays clean) and travels in Copy's first line as " · #hex", which data/apply-grouping.mjs applies
|
||||
to the regrouped zone. 8 Okabe–Ito swatches (colour-blind aware) + a custom hex. -->
|
||||
<div v-if="selCount" class="upg-hud-swatches" role="group" :aria-label="t('upgMap.selectionColourGroup')">
|
||||
<button v-for="c in SWATCHES" :key="c" type="button"
|
||||
class="upg-swatch" :class="{ active: selectedColor === c }" :aria-pressed="selectedColor === c ? 'true' : 'false'"
|
||||
:data-color="c" :style="{ background: c }"
|
||||
:title="t('upgMap.selectionColour', { hex: c })" :aria-label="t('upgMap.selectionColour', { hex: c })"
|
||||
@click="setSelectionColor(c)"></button>
|
||||
<input v-model="hexDraft" class="upg-hex" placeholder="#hex" spellcheck="false"
|
||||
:aria-label="t('upgMap.customColour')" @change="onHexInput" @keyup.enter="onHexInput" />
|
||||
</div>
|
||||
<!-- The make-row BUILDS what this app already makes from exactly what was picked (printPrayerPacket /
|
||||
printPoster / the card deck); the data-row below EXPORTS the selection itself. -->
|
||||
<!-- ★ THE BUTTON SAYS WHAT IT IS DOING. Reported from mobile 2026-09-28: pressing these gave
|
||||
no sign of progress. batchBusy only DISABLED them, and a disabled button is visually
|
||||
indistinguishable from an unresponsive one — so a reader on a phone taps, sees the label
|
||||
|
|
@ -3252,6 +3344,13 @@ onBeforeUnmount(() => {
|
|||
@click="makeSelectionPoster">{{ busyAction === 'poster' ? t('buttons.working') : t('buttons.posterMap') }}</button>
|
||||
<button class="upg-hud-btn make" :class="{ 'is-busy': busyAction === 'cards' }" :disabled="batchBusy"
|
||||
@click="makeSelectionCards">{{ busyAction === 'cards' ? t('buttons.working') : t('buttons.cardDeck') }}</button>
|
||||
</div>
|
||||
<!-- COPY + EXPORT CSV: the selection's territories (apply-grouping's line) and its people groups
|
||||
(data/selection-export.js). A second row, so six buttons never squeeze into one on a phone. -->
|
||||
<div v-if="selCount" class="upg-hud-actions">
|
||||
<button class="upg-hud-btn copy" @click="copySelection">{{ t('upgMap.copySelection') }}</button>
|
||||
<button class="upg-hud-btn csv" :class="{ 'is-busy': busyAction === 'csv' }" :disabled="batchBusy"
|
||||
@click="exportSelectionCsv">{{ busyAction === 'csv' ? t('buttons.working') : t('upgMap.exportCsv') }}</button>
|
||||
<button class="upg-hud-btn clear" @click="clearSelection">{{ t('buttons.clearSelection') }}</button>
|
||||
</div>
|
||||
<div v-if="selCount" class="upg-hud-flash">{{ copyFlash }}</div>
|
||||
|
|
|
|||
21
plugin-designs/posterverse/index.html
Normal file
21
plugin-designs/posterverse/index.html
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>doxa-maps · posterverse staging</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height: 100%; background: #f3f3f1; color: #3b463d;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; }
|
||||
posterverse-app { display: block; position: fixed; inset: 0; width: 100%; height: 100%; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Capability 5, the app shell: one element, <posterverse-app>, is a vertical-tab rail of every
|
||||
poster plugin. Each tab loads its plugin's own script lazily on first open. -->
|
||||
<posterverse-app></posterverse-app>
|
||||
<script type="module" src="./index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
37
plugin-designs/posterverse/index.js
Normal file
37
plugin-designs/posterverse/index.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* posterverse / index.js — bundle entry (capability 5, nested-mfe-spa / app shell).
|
||||
*
|
||||
* ONE folder -> ONE bundle -> ONE custom element: <posterverse-app>. (A custom-element name needs a
|
||||
* hyphen, so the tag cannot be the bare folder name.)
|
||||
*
|
||||
* It registers only its own tag. It never imports another plugin's components and never re-registers a
|
||||
* tag another bundle owns: each nested poster plugin registers its own tag when its script loads.
|
||||
*
|
||||
* TREE BASE: to find each nested plugin's script, the shell needs the built tree's root URL. It is
|
||||
* derived HERE, at IIFE load, from this script's own URL (.../<tree>/posterverse/posterverse.js, two
|
||||
* levels up), so the element self-locates wherever the tree is served. A host can override it with
|
||||
* the tree-base="" attribute or profile-config.treeBase.
|
||||
*
|
||||
* Usage from a host page:
|
||||
* <script src="/dx-maps/posterverse/posterverse.js"></script>
|
||||
* <posterverse-app></posterverse-app>
|
||||
*/
|
||||
|
||||
import { defineCustomElement } from 'vue'
|
||||
import Posterverse from './posterverse.vue'
|
||||
import { deriveTreeBase } from '@map/utils/embedScript.js'
|
||||
|
||||
// Captured synchronously at IIFE load, while document.currentScript is still this bundle's tag.
|
||||
const TREE_BASE = deriveTreeBase()
|
||||
|
||||
const PosterverseElement = defineCustomElement(Posterverse, {
|
||||
configureApp(app) {
|
||||
app.provide('pvTreeBase', TREE_BASE)
|
||||
}
|
||||
})
|
||||
|
||||
if (typeof customElements !== 'undefined' && !customElements.get('posterverse-app')) {
|
||||
customElements.define('posterverse-app', PosterverseElement)
|
||||
}
|
||||
|
||||
export default PosterverseElement
|
||||
16
plugin-designs/posterverse/posterverse.instances.json
Normal file
16
plugin-designs/posterverse/posterverse.instances.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"_comment": "One preset of the capability-5 shell. provider:'maplibre' marks the generated standalone page TOKEN-FREE (generate-tree TOKEN_FREE), so the shell mounts at once: the shell paints no map itself, its tabs do, each with its own token ladder. The meta.json type still wins over the preset signal, so this stays capability 5.",
|
||||
"profile": "posterverse",
|
||||
"instances": [
|
||||
{
|
||||
"id": "app",
|
||||
"title": "Posterverse — every poster plugin (app shell)",
|
||||
"props": {
|
||||
"profile": "posterverse",
|
||||
"provider": "maplibre",
|
||||
"instanceId": "posterverse-app",
|
||||
"heading": "Posterverse"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
12
plugin-designs/posterverse/posterverse.meta.json
Normal file
12
plugin-designs/posterverse/posterverse.meta.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"_comment": "Declares the composability tier. 'nested-mfe-spa' = capability 5: an app shell whose single element <posterverse-app> renders a vertical-tab rail of whole poster plugins, each mounted as its own real element from the manifest (no iframes). The tier is DERIVED from `composes` at tree generation (composing a cap-4 makes this cap-5) and this `type` is checked against it. `composes` lists every row in src/tabs.js whose bundle exists today; a provisional row (poster-design, bible-posters) joins the list when its bundle lands, since an unresolved ref fails check:capability-derived.",
|
||||
"type": "nested-mfe-spa",
|
||||
"composes": [
|
||||
"my-upg-100-list/upg-100-mfe",
|
||||
"country-posters/country-posters-mfe",
|
||||
"wall-of-people-groups/wall-of-people-groups",
|
||||
"all-people-groups/all-people-groups",
|
||||
"upg-neighbor/upg-neighbor"
|
||||
],
|
||||
"label": "Posterverse (vertical-tab app shell: every poster plugin)"
|
||||
}
|
||||
397
plugin-designs/posterverse/posterverse.vue
Normal file
397
plugin-designs/posterverse/posterverse.vue
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
<!--
|
||||
posterverse.vue — the second capability-5 app shell (nested-mfe-spa): every poster plugin in one place.
|
||||
|
||||
* @description Every poster plugin in one app: a vertical-tab rail of real, embeddable poster plugins.
|
||||
Tabs: UPG 100 List Posters, Country Posters, Poster Design, and an Experimental folder of the
|
||||
poster-related experimental plugins. The same pattern as the Research Gallery (doxa-maps-spa),
|
||||
reused rather than copied: the shared VerticalTabsLayout, bundle+profile identities resolved from
|
||||
the tree's manifest, the library's loadScriptOnce/whenTagDefined, and the same profile-config
|
||||
contract for every child.
|
||||
|
||||
WHAT IS DIFFERENT FROM THE GALLERY, on purpose:
|
||||
- The rail is an ALLOW-LIST (src/tabs.js), Experimental included. The gallery's Experimental is
|
||||
"every bundle nobody placed", which here would expose the gated key-cities.
|
||||
- No MapInfo row, no hosted "?" and no shell-level phone notice. Panes do NOT carry
|
||||
data-doxa-gallery or data-doxa-help, so each child keeps its OWN info popup and its own phone
|
||||
notice (ProfileLoader / useScreenGate) instead of deferring to a host that has none.
|
||||
|
||||
ONE tag, <posterverse-app>, is defined by this bundle (index.js). It never imports a child's
|
||||
components and never registers a child's tag: each child's own script does that.
|
||||
-->
|
||||
<template>
|
||||
<VerticalTabsLayout
|
||||
:dir="uiDir"
|
||||
:lang="uiLocale"
|
||||
:labels="layoutLabels"
|
||||
:title="headingText"
|
||||
:nav-label="tr('sec.posters')"
|
||||
:header="false"
|
||||
:groups="navGroups"
|
||||
:active="activeKey"
|
||||
@update:active="onSelect">
|
||||
|
||||
<!-- Every tab mounts its plugin's real custom element into an EMPTY, Vue-unmanaged slot, after that
|
||||
bundle's script is injected once. The hint is a separate sibling, so clearing the slot can never
|
||||
remove a Vue-managed node.
|
||||
LIGHT rows (v-show) stay mounted once loaded, so switching back is instant. HEAVY rows (anything
|
||||
that owns or nests a WebGL map) are rendered with v-if and DESTROYED on deactivate, which runs the
|
||||
element's own teardown (map.remove()) and keeps the document to about one live map context. -->
|
||||
<section
|
||||
v-for="(t, i) in tabs" :key="t.id" class="pv-pane"
|
||||
:ref="el => { if (el) paneEls[i] = el; else delete paneEls[i] }"
|
||||
v-show="i === activeIndex" :hidden="i !== activeIndex">
|
||||
<template v-if="!t.heavy || i === activeIndex">
|
||||
<div class="pv-slot" :ref="el => setSlot(i, el)"></div>
|
||||
<div v-if="t.state === 'loading'" class="pv-hint">{{ tr('loading', { label: tabLabel(t) }) }}</div>
|
||||
<div v-else-if="t.state === 'error'" class="pv-hint err">
|
||||
{{ tr('loadFailed', { label: tabLabel(t) }) }}<br><small>{{ t.error }}</small>
|
||||
</div>
|
||||
<!-- No manifest means no scriptUrl, so the row can never mount: say so rather than show an empty pane. -->
|
||||
<div v-else-if="t.state === 'idle' && !t.scriptUrl && manifestError" class="pv-hint err">
|
||||
{{ tr('loadFailed', { label: tabLabel(t) }) }}<br>
|
||||
<small>{{ manifestError }}</small><br>
|
||||
<small>{{ tr('manifestHint') }}</small>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<!-- The pane is the scroller (overflow-y:auto), so back-to-top follows the active pane. -->
|
||||
<BackToTopButton :target="activePaneEl" :labels="{ backToTop: tr('backToTop') }" />
|
||||
</VerticalTabsLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent, inject } from 'vue'
|
||||
import VerticalTabsLayout from '@map/layouts/VerticalTabsLayout.vue'
|
||||
import BackToTopButton from '@map/components/BackToTopButton.vue'
|
||||
import { loadScriptOnce, whenTagDefined } from '@map/utils/embedScript.js'
|
||||
import { translate, LOCALES } from './src/strings.js'
|
||||
import { SECTIONS, TABS, resolveTabs } from './src/tabs.js'
|
||||
|
||||
// The host page's language by the ladder every nested map uses: ?lang= / ?locale=, then <html lang>.
|
||||
// '' when the page says nothing. Never navigator.language: a browser's language must not override the
|
||||
// language of the page the shell is embedded in.
|
||||
function detectHostLocale() {
|
||||
try {
|
||||
const q = new URLSearchParams(location.search)
|
||||
const fromUrl = (q.get('lang') || q.get('locale') || '').split('-')[0].toLowerCase()
|
||||
if (LOCALES.includes(fromUrl)) return fromUrl
|
||||
} catch { /* no window */ }
|
||||
try {
|
||||
const fromHtml = (document.documentElement.lang || '').split('-')[0].toLowerCase()
|
||||
if (LOCALES.includes(fromHtml)) return fromHtml
|
||||
} catch { /* no document */ }
|
||||
return ''
|
||||
}
|
||||
|
||||
const freshTab = (d) => ({ ...d, tag: d.tag || '', scriptUrl: d.scriptUrl || '', heavy: !!d.heavy, mounted: false, state: 'idle', error: '' })
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Posterverse',
|
||||
// The host's attributes stay on the host: an inline style copied onto the layout root beats the
|
||||
// layout's own rules (a styled host once collapsed a nested map to 76px).
|
||||
inheritAttrs: false,
|
||||
components: { VerticalTabsLayout, BackToTopButton },
|
||||
props: {
|
||||
treeBase: { type: String, default: '' },
|
||||
profileConfig: { type: [String, Object], default: '' },
|
||||
// Not `title`: that is a native HTMLElement property, and defineCustomElement maps props to attributes.
|
||||
heading: { type: String, default: 'Posterverse' },
|
||||
tokenUrl: { type: String, default: '' },
|
||||
tk: { type: String, default: '' },
|
||||
// The language, inherited from the host (canonical prop). profile-config.locale / lang also work.
|
||||
language: { type: String, default: '' },
|
||||
apiBaseUrl: { type: String, default: '' },
|
||||
// The engine for every nested map: 'maplibre' or 'mapbox'. Unset = each map's own ladder.
|
||||
provider: { type: String, default: '' },
|
||||
// Which tab opens first, by row id (src/tabs.js). Also ?tab= on the page URL.
|
||||
tab: { type: String, default: '' },
|
||||
},
|
||||
emits: ['tab-change'],
|
||||
setup() {
|
||||
return { injectedBase: inject('pvTreeBase', '') }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
paneEls: {},
|
||||
activeIndex: 0,
|
||||
manifestError: '',
|
||||
// The directory the manifest answered from (trailing slash). Tree-relative scriptUrls resolve
|
||||
// against THIS, which differs from the document under the dev server.
|
||||
manifestBase: '',
|
||||
// The manifest's `runtime` block (the build's apiBaseUrl + public token), for foreign-page embeds.
|
||||
runtime: {},
|
||||
hostLang: detectHostLocale(),
|
||||
// Until the manifest lands, the rail shows the rows that are expected to exist; provisional rows
|
||||
// appear only once the manifest proves their bundle is in this tree.
|
||||
tabs: TABS.filter((d) => !d.provisional).map(freshTab),
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
cfg() {
|
||||
let pc = this.profileConfig
|
||||
if (typeof pc === 'string') { try { pc = pc ? JSON.parse(pc) : {} } catch { pc = {} } }
|
||||
return pc && typeof pc === 'object' ? pc : {}
|
||||
},
|
||||
// The tree root with a trailing slash: host override, then the base index.js derived from its own
|
||||
// <script> URL, then '' (relative: sibling bundle folders are one level up).
|
||||
base() {
|
||||
const raw = String(this.treeBase || this.cfg.treeBase || this.injectedBase || '').trim()
|
||||
return raw ? raw.replace(/\/+$/, '') + '/' : ''
|
||||
},
|
||||
// The tree's asset pool, ABSOLUTE, for every child. Without it a nested element climbs to a sibling
|
||||
// pool the publisher does not ship.
|
||||
assetsBase() {
|
||||
const v = String(this.cfg.assetsBase || '').trim()
|
||||
if (/^https?:\/\//i.test(v) || v.startsWith('/')) return v.replace(/\/+$/, '')
|
||||
return this.manifestBase ? this.manifestBase.replace(/\/+$/, '') + '/assets' : ''
|
||||
},
|
||||
headingText() { return String(this.cfg.heading || this.heading || '').trim() || 'Posterverse' },
|
||||
// Language precedence: what the host says, then the host page's own language, then English.
|
||||
effectiveLocale() {
|
||||
const fromProp = String(this.language || this.cfg.language || this.cfg.locale || this.cfg.lang || '').trim()
|
||||
return (fromProp || this.hostLang || 'en').split('-')[0].toLowerCase()
|
||||
},
|
||||
uiLocale() { return LOCALES.includes(this.effectiveLocale) ? this.effectiveLocale : 'en' },
|
||||
uiDir() { return this.uiLocale === 'ar' ? 'rtl' : 'ltr' },
|
||||
layoutLabels() {
|
||||
const out = {}
|
||||
for (const k of ['toggleList', 'openViews', 'expandList', 'collapseList', 'collapse']) out[k] = this.tr(k)
|
||||
return out
|
||||
},
|
||||
effectiveProvider() {
|
||||
const p = String(this.provider || this.cfg.provider || '').trim().toLowerCase()
|
||||
return (p === 'maplibre' || p === 'mapbox') ? p : ''
|
||||
},
|
||||
activeKey() { const t = this.tabs[this.activeIndex]; return t ? t.id : '' },
|
||||
activePaneEl() { return this.paneEls[this.activeIndex] || null },
|
||||
// The grouped rail: SECTIONS projected over the rows that exist. An empty section is omitted.
|
||||
navGroups() {
|
||||
return SECTIONS.map((g) => ({
|
||||
key: g.key,
|
||||
label: this.tr(g.i18n),
|
||||
icon: g.icon,
|
||||
folder: !!g.folder,
|
||||
open: g.open,
|
||||
rows: this.tabs.filter((t) => t.section === g.key).map((t) => ({
|
||||
key: t.id, label: this.tabLabel(t), sub: this.tabSub(t), icon: t.icon,
|
||||
badge: g.folder ? this.tr('lab') : undefined,
|
||||
})),
|
||||
})).filter((g) => g.rows.length)
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
// A language change REMOUNTS: not every child re-reads its config live, but a fresh element reads
|
||||
// the new locale at mount. Hidden mounted panes are reset so their next open is fresh too.
|
||||
effectiveLocale() {
|
||||
const reset = (i, t) => {
|
||||
try { const slot = this._slots && this._slots[i]; if (slot) slot.innerHTML = '' } catch { /* reset anyway */ }
|
||||
t.mounted = false; t.state = 'idle'; t.error = ''
|
||||
}
|
||||
this.tabs.forEach((t, i) => { if (t.mounted && i !== this.activeIndex) reset(i, t) })
|
||||
const t = this.tabs[this.activeIndex]
|
||||
if (!t || !t.mounted) return
|
||||
reset(this.activeIndex, t)
|
||||
this.mountElement(this.activeIndex)
|
||||
},
|
||||
tab(id) { if (id) this.onSelect(id) },
|
||||
},
|
||||
mounted() {
|
||||
this.watchHostLang()
|
||||
// Paint the rail at once; the manifest completes the rows in the background and re-drives the mount.
|
||||
this.select(this.initialIndex())
|
||||
this.loadManifest()
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this._langObs) { this._langObs.disconnect(); this._langObs = null }
|
||||
},
|
||||
methods: {
|
||||
tr(key, params) { return translate(this.uiLocale, key, params) },
|
||||
tabLabel(t) { return this.tr('tab.' + t.id) },
|
||||
tabSub(t) { return t.section === 'experimental' ? this.tr('experimentalSub') : this.tr('sub.' + t.id) },
|
||||
setSlot(i, el) { if (el) this._slots = Object.assign(this._slots || {}, { [i]: el }) },
|
||||
watchHostLang() {
|
||||
if (typeof MutationObserver === 'undefined' || typeof document === 'undefined') return
|
||||
this._langObs = new MutationObserver(() => { this.hostLang = detectHostLocale() })
|
||||
this._langObs.observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] })
|
||||
},
|
||||
requestedTab() {
|
||||
let q = ''
|
||||
try { q = new URLSearchParams(location.search).get('tab') || '' } catch { /* non-browser */ }
|
||||
return String(this.tab || this.cfg.tab || q || '').trim()
|
||||
},
|
||||
initialIndex() {
|
||||
const i = this.tabs.findIndex((t) => t.id === this.requestedTab())
|
||||
return i >= 0 ? i : 0
|
||||
},
|
||||
// Read the manifest once. Candidates, in order:
|
||||
// 1. <tree>/manifest.json (same origin);
|
||||
// 2. <tree>/doxa-maps-spa/manifest.json, the byte-identical copy generate-tree writes for the
|
||||
// Research Gallery. The live static layer sends CORS headers only inside a tree folder, not
|
||||
// on the root manifest, so a partner page's script embed reads this copy FIRST;
|
||||
// 3. /public/<tree>/manifest.json, where the dev server keeps the built manifest.
|
||||
// Script URLs always resolve against the tree ROOT, never the copy's folder.
|
||||
loadManifest() {
|
||||
const dirOf = (u) => { try { return new URL(u, location.href).href.replace(/[^/]*$/, '') } catch { return '' } }
|
||||
const rootUrl = this.resolve('manifest.json')
|
||||
const root = { url: rootUrl, root: dirOf(rootUrl) }
|
||||
const copy = { url: this.resolve('doxa-maps-spa/manifest.json'), root: root.root }
|
||||
let crossOrigin = false
|
||||
try { crossOrigin = new URL(rootUrl, location.href).origin !== location.origin } catch { /* relative = same */ }
|
||||
const candidates = crossOrigin ? [copy, root] : [root, copy]
|
||||
const devFallback = '/public/' + __DOXA_TREE_NAME__ + '/manifest.json'
|
||||
if (!candidates.some((c) => c.url === devFallback)) candidates.push({ url: devFallback, root: dirOf(devFallback) })
|
||||
const tried = []
|
||||
const attempt = (i) => {
|
||||
if (i >= candidates.length) {
|
||||
this.manifestError = 'manifest.json not found (tried ' + tried.join(', ') + ')'
|
||||
console.error('[posterverse] ' + this.manifestError + ' — every tab is composed from it.')
|
||||
return
|
||||
}
|
||||
const { url, root: treeRoot } = candidates[i]
|
||||
tried.push(url)
|
||||
return fetch(url, { credentials: 'same-origin' })
|
||||
.then((r) => { if (!r || !r.ok) throw new Error(url + ' → HTTP ' + (r ? r.status : '?')); return r.json() })
|
||||
.then((m) => {
|
||||
if (!m) throw new Error(url + ' → empty')
|
||||
this.manifestError = ''
|
||||
this.manifestBase = treeRoot || ''
|
||||
this.applyManifest(m)
|
||||
})
|
||||
.catch(() => attempt(i + 1))
|
||||
}
|
||||
try { attempt(0) } catch { /* no fetch (non-browser): fail soft */ }
|
||||
},
|
||||
// Replace the provisional rail with the rows this tree actually ships, keep the active row by id,
|
||||
// and re-drive the mount that could not run before its scriptUrl existed.
|
||||
applyManifest(m) {
|
||||
this.runtime = (m && m.runtime && typeof m.runtime === 'object') ? m.runtime : {}
|
||||
this.applyPageGlobals()
|
||||
const activeId = this.activeKey
|
||||
this.tabs = resolveTabs(m, (msg) => console.warn(msg)).map(freshTab)
|
||||
const want = this.requestedTab()
|
||||
const w = !this._honouredTab && want ? this.tabs.findIndex((t) => t.id === want) : -1
|
||||
const i = this.tabs.findIndex((t) => t.id === activeId)
|
||||
this._honouredTab = true
|
||||
this.select(w >= 0 ? w : (i >= 0 ? i : 0))
|
||||
},
|
||||
onSelect(key) {
|
||||
const i = this.tabs.findIndex((t) => t.id === key)
|
||||
if (i !== -1) this.select(i)
|
||||
},
|
||||
// A tree-relative ref resolves against the manifest's own directory (then the tree base, then '../').
|
||||
// An absolute or rooted ref is returned untouched: prefixing a rooted dev ref makes a path that exists nowhere.
|
||||
resolve(ref) {
|
||||
const raw = String(ref)
|
||||
if (/^(https?:)?\/\//.test(raw) || raw.startsWith('/')) return raw
|
||||
const r = raw.replace(/^\.\//, '')
|
||||
if (this.manifestBase) return this.manifestBase.replace(/\/+$/, '') + '/' + r
|
||||
if (this.base) return this.base.replace(/\/+$/, '') + '/' + r
|
||||
return '../' + r
|
||||
},
|
||||
select(i) {
|
||||
const prevIndex = this.activeIndex
|
||||
this.activeIndex = i
|
||||
// Release the outgoing heavy pane first, so at most one live map context exists at a time.
|
||||
if (prevIndex !== i && prevIndex >= 0) {
|
||||
const prev = this.tabs[prevIndex]
|
||||
if (prev && prev.heavy && prev.mounted) this.releaseTab(prevIndex, prev)
|
||||
}
|
||||
const t = this.tabs[i]
|
||||
if (!t) return
|
||||
// Tell the host which plugin is showing (CustomEvent 'tab-change', detail[0] = { id, label }).
|
||||
if (prevIndex !== i || !this._announced) { this._announced = true; this.$emit('tab-change', { id: t.id, label: this.tabLabel(t) }) }
|
||||
if (!t.scriptUrl) return // applyManifest selects again once the manifest lands
|
||||
// A heavy tab's slot exists only after its v-if subtree renders, so mount on the next tick.
|
||||
if (!t.mounted) this.$nextTick(() => { if (this.activeIndex === i) this.mountElement(i) })
|
||||
},
|
||||
releaseTab(i, t) {
|
||||
try { const slot = this._slots && this._slots[i]; if (slot) slot.innerHTML = '' } catch { /* reset either way */ }
|
||||
if (this._slots) delete this._slots[i]
|
||||
t.mounted = false; t.state = 'idle'; t.error = ''
|
||||
},
|
||||
// THE ONE profile-config every nested element receives (the Research Gallery's contract).
|
||||
childConfig(t) {
|
||||
const pc = { profile: t.profile || t.id, instanceId: 'pv-' + t.id }
|
||||
if (this.effectiveLocale) pc.locale = this.effectiveLocale
|
||||
const tk = this.tk || this.cfg.tk || this.runtime.token
|
||||
if (tk) pc.tk = tk
|
||||
if (this.tokenUrl || this.cfg.tokenUrl) pc.tokenUrl = this.tokenUrl || this.cfg.tokenUrl
|
||||
if (this.effectiveProvider) pc.provider = this.effectiveProvider
|
||||
if (this.base) pc.treeBase = this.base
|
||||
if (this.assetsBase) pc.assetsBase = this.assetsBase
|
||||
// The host owns its URL, hash and title: a nested plugin must not write them.
|
||||
pc.urlSync = false
|
||||
// The plugin's own name for its print header: this tab's localized label.
|
||||
pc.mapTitle = this.tabLabel(t)
|
||||
return pc
|
||||
},
|
||||
// Page globals a generated leaf page bakes and a foreign host does not. Set ONLY when the page has
|
||||
// not set them: the host's own value always wins. MAP_APP_API_URL has no profile-config path, and
|
||||
// a composite's inner maps read MAP_TOKEN / MAP_PROVIDER rather than their parent's config.
|
||||
applyPageGlobals() {
|
||||
if (typeof window === 'undefined') return
|
||||
const api = String(this.apiBaseUrl || this.cfg.apiBaseUrl || this.runtime.apiBaseUrl || '').trim()
|
||||
if (api && !window.MAP_APP_API_URL) window.MAP_APP_API_URL = api.replace(/\/+$/, '')
|
||||
const tk = this.tk || this.cfg.tk || this.runtime.token
|
||||
if (tk && !window.MAP_TOKEN) window.MAP_TOKEN = tk
|
||||
if (this.effectiveProvider && !window.MAP_PROVIDER) window.MAP_PROVIDER = this.effectiveProvider
|
||||
},
|
||||
// Inject the bundle's script once, wait for its TAG to register (onload fires even when a dev module
|
||||
// fails to execute), then create the element in the pane's empty slot.
|
||||
mountElement(i) {
|
||||
const t = this.tabs[i]
|
||||
t.mounted = true
|
||||
t.state = 'loading'
|
||||
loadScriptOnce(this.resolve(t.scriptUrl)).then(() => whenTagDefined(t.tag)).then(() => {
|
||||
try {
|
||||
// Deactivated (and released) while its script loaded: mounting now would leak a map context.
|
||||
if (t.heavy && this.activeIndex !== i) { t.mounted = false; t.state = 'idle'; return }
|
||||
const slot = this._slots && this._slots[i]
|
||||
if (!slot) { t.state = 'error'; t.error = 'no slot'; return }
|
||||
slot.innerHTML = ''
|
||||
const el = document.createElement(t.tag)
|
||||
el.setAttribute('profile-config', JSON.stringify(this.childConfig(t)))
|
||||
// Also as a plain attribute: an element without a ProfileLoader reads tree-base as a prop.
|
||||
if (this.base) el.setAttribute('tree-base', this.base.replace(/\/+$/, ''))
|
||||
// Pin the element to the pane. min-height:0 is load-bearing: a child's own floor (420-600px on
|
||||
// these plugins) otherwise wins over height:100% and the pane scrolls the whole plugin.
|
||||
el.style.cssText = 'display:block;position:absolute;inset:0;width:100%;height:100%;min-height:0;'
|
||||
slot.appendChild(el)
|
||||
t.state = 'ready'
|
||||
} catch (e) {
|
||||
t.state = 'error'
|
||||
t.error = 'mount failed: ' + ((e && e.message) || String(e))
|
||||
}
|
||||
}).catch((e) => {
|
||||
t.state = 'error'
|
||||
t.error = (e && e.message) || String(e)
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* The element is its own containing block with a real height, so the layout (.vtl, position:absolute)
|
||||
stays inside the box the host placed it in. A host's own height or --posterverse-height overrides it. */
|
||||
:host { display: block; position: relative; height: var(--posterverse-height, 85vh); min-height: 480px; }
|
||||
/* Shadow DOM isolates selectors, not inheritance: reset the host page's typography at the box. */
|
||||
:host {
|
||||
font-size: 16px; line-height: normal; font-weight: 400; font-style: normal; font-variant: normal;
|
||||
font-stretch: normal; letter-spacing: normal; word-spacing: normal; text-transform: none;
|
||||
text-align: start; text-indent: 0; text-shadow: none; white-space: normal; hyphens: manual;
|
||||
visibility: visible; cursor: auto;
|
||||
}
|
||||
:host([hidden]) { display: none; }
|
||||
/* The pane fills the layout's content area and owns the scroll. --profile-min-h:0 opts every child out of
|
||||
ProfileLoader's 100dvh floor, which measures the viewport while this pane measures its host. */
|
||||
.pv-pane { position: absolute; inset: 0; overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; --profile-min-h: 0; }
|
||||
.pv-slot { position: relative; min-height: 100%; }
|
||||
.pv-hint {
|
||||
position: absolute; inset: 0; display: grid; place-items: center; text-align: center;
|
||||
color: #6d766e; font-size: 13px; padding: 20px;
|
||||
}
|
||||
.pv-hint.err { color: #b3261e; }
|
||||
.pv-hint small { color: #6d766e; }
|
||||
</style>
|
||||
155
plugin-designs/posterverse/scripts/check-posterverse-shelf.mjs
Normal file
155
plugin-designs/posterverse/scripts/check-posterverse-shelf.mjs
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-posterverse-shelf.mjs — Posterverse shows exactly its allow-list, never a gated plugin, and its
|
||||
* declared `composes` stays in step with its rail.
|
||||
*
|
||||
* WHY. The Research Gallery's Experimental folder is a deny-list catch-all ("every bundle nobody
|
||||
* placed"). Posterverse deliberately is not: copied here, that rule would expose key-cities, which is
|
||||
* GATED. The allow-list lives in src/tabs.js; this gate is what keeps it one.
|
||||
*
|
||||
* WHAT IT ASSERTS (each arm is poisoned by --self-test before a pass is trusted):
|
||||
* A. the rail's rows are exactly the named sets: Posters = upg-100, country-posters, poster-design;
|
||||
* Experimental = wall-of-people-groups, all-people-groups, upg-neighbor, bible-posters. Ids unique,
|
||||
* every row in a declared section.
|
||||
* B. no row names a NEVER bundle (key-cities, the shells themselves), and resolving the built
|
||||
* manifest WITH a key-cities bundle planted in it yields no key-cities row (the real manifest
|
||||
* withholds gated profiles, so without the plant the exclusion would never be exercised).
|
||||
* C. every non-provisional row resolves on the built manifest to the profile it names (a silent
|
||||
* fallback to a leaf is the "composite shown as a flat list" defect), and a row whose bundle is
|
||||
* absent is pruned.
|
||||
* D. posterverse.meta.json `composes` == the bundle/profile of every row whose bundle exists in
|
||||
* plugin-designs/ (two-sided: nothing declared that the rail cannot show, nothing shown undeclared).
|
||||
* E. strings: every locale carries every English key, and every key the shell reads exists in English.
|
||||
*
|
||||
* Headless, no browser. Exit 0 pass · 1 fail · 75 not measured (no built manifest).
|
||||
*/
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
const TAG = 'POSTERVERSE-SHELF'
|
||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||
const PLUGIN = resolve(HERE, '..')
|
||||
const ROOT = resolve(PLUGIN, '../..')
|
||||
|
||||
const EXPECT = {
|
||||
posters: ['upg-100', 'country-posters', 'poster-design'],
|
||||
experimental: ['wall-of-people-groups', 'all-people-groups', 'upg-neighbor', 'bible-posters'],
|
||||
}
|
||||
const GATED = 'key-cities'
|
||||
|
||||
const sameList = (a, b) => a.length === b.length && a.every((x, i) => x === b[i])
|
||||
|
||||
export function checkShelf({ SECTIONS, TABS, NEVER, resolveTabs, manifest, meta, bundleExists, STRINGS }) {
|
||||
const errs = []
|
||||
const notes = []
|
||||
// A. exact membership, per section, in order
|
||||
const ids = TABS.map((t) => t.id)
|
||||
if (new Set(ids).size !== ids.length) errs.push(`A: duplicate row id in TABS: ${ids.join(', ')}`)
|
||||
const sectionKeys = new Set(SECTIONS.map((s) => s.key))
|
||||
for (const t of TABS) if (!sectionKeys.has(t.section)) errs.push(`A: row ${t.id} is in undeclared section "${t.section}"`)
|
||||
for (const [sec, want] of Object.entries(EXPECT)) {
|
||||
const got = TABS.filter((t) => t.section === sec).map((t) => t.id)
|
||||
if (!sameList(got, want)) errs.push(`A: section ${sec} rows are [${got.join(', ')}], expected [${want.join(', ')}]`)
|
||||
}
|
||||
const extra = TABS.filter((t) => !EXPECT[t.section]).map((t) => t.id)
|
||||
if (extra.length) errs.push(`A: rows outside the allow-listed sections: ${extra.join(', ')}`)
|
||||
// B. never a gated bundle — by declaration and by resolution over the real manifest
|
||||
if (!NEVER.has(GATED)) errs.push(`B: NEVER no longer lists ${GATED}`)
|
||||
for (const t of TABS) if (NEVER.has(t.bundle) || t.bundle === GATED) errs.push(`B: row ${t.id} names never-shown bundle ${t.bundle}`)
|
||||
// The built manifest WITHHOLDS gated profiles (generate-tree), so a real tree never offers key-cities
|
||||
// to the resolver. Plant it (shaped like a real row) so the exclusion is exercised on every run: the
|
||||
// question is whether Posterverse would show it if the tree ever did carry it.
|
||||
const planted = { ...manifest, bundles: [...(manifest.bundles || []).filter((b) => b.bundle !== GATED),
|
||||
{ bundle: GATED, scriptUrl: `./${GATED}/${GATED}.js`, elements: [`${GATED}-map`], profiles: [{ profile: GATED, element: `${GATED}-map`, isMap: true }] }] }
|
||||
for (const r of resolveTabs(planted, () => {})) if (r.bundle === GATED || NEVER.has(r.bundle)) errs.push(`B: resolution produced a ${r.bundle} row`)
|
||||
const resolved = resolveTabs(manifest, () => {})
|
||||
const inManifest = new Set((manifest.bundles || []).map((b) => b.bundle))
|
||||
// C. rows resolve to the profile they name; absent bundles are pruned
|
||||
const resolvedIds = new Set(resolved.map((r) => r.id))
|
||||
for (const t of TABS) {
|
||||
const present = inManifest.has(t.bundle)
|
||||
if (!present && resolvedIds.has(t.id)) errs.push(`C: row ${t.id} resolved although ${t.bundle} is not in the manifest`)
|
||||
if (present && !t.provisional) {
|
||||
const r = resolved.find((x) => x.id === t.id)
|
||||
if (!r) errs.push(`C: row ${t.id} did not resolve although ${t.bundle} is in the manifest`)
|
||||
else if (r.profile !== t.profile) errs.push(`C: row ${t.id} opens "${r.profile}", not its named profile "${t.profile}"`)
|
||||
else if (!r.tag || !r.scriptUrl) errs.push(`C: row ${t.id} resolved without a tag or scriptUrl`)
|
||||
}
|
||||
}
|
||||
// D. composes == the rows whose bundle exists in source
|
||||
const composes = Array.isArray(meta.composes) ? meta.composes : []
|
||||
const want = TABS.filter((t) => bundleExists(t.bundle, t.profile)).map((t) => `${t.bundle}/${t.profile}`)
|
||||
for (const c of composes) if (!want.includes(c)) errs.push(`D: meta composes "${c}", which no existing rail row shows`)
|
||||
for (const w of want) if (!composes.includes(w)) errs.push(`D: the rail shows "${w}" but meta.composes does not declare it`)
|
||||
if (meta.type !== 'nested-mfe-spa') errs.push(`D: meta type is ${JSON.stringify(meta.type)}, expected "nested-mfe-spa"`)
|
||||
// E. strings
|
||||
const en = STRINGS.en || {}
|
||||
const used = ['lab', 'experimentalSub', 'loading', 'loadFailed', 'manifestHint', 'backToTop',
|
||||
'toggleList', 'openViews', 'expandList', 'collapseList', 'collapse',
|
||||
...SECTIONS.map((s) => s.i18n), ...TABS.map((t) => 'tab.' + t.id),
|
||||
...TABS.filter((t) => t.section !== 'experimental').map((t) => 'sub.' + t.id)]
|
||||
for (const k of used) if (!(k in en)) errs.push(`E: the shell reads "${k}", which English does not define`)
|
||||
const locales = Object.keys(STRINGS)
|
||||
if (locales.length !== 11) errs.push(`E: ${locales.length} locales, expected 11`)
|
||||
for (const l of locales) for (const k of Object.keys(en)) {
|
||||
const v = STRINGS[l][k]
|
||||
if (typeof v !== 'string' || !v.trim()) errs.push(`E: ${l} is missing "${k}"`)
|
||||
}
|
||||
return { errs, notes, counts: { rows: TABS.length, resolved: resolved.length, composes: composes.length, locales: locales.length, keys: Object.keys(en).length } }
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const tabs = await import(pathToFileURL(join(PLUGIN, 'src/tabs.js')).href)
|
||||
const { STRINGS } = await import(pathToFileURL(join(PLUGIN, 'src/strings.js')).href)
|
||||
const meta = JSON.parse(readFileSync(join(PLUGIN, 'posterverse.meta.json'), 'utf8'))
|
||||
const { TREE_NAME } = await import(pathToFileURL(join(ROOT, 'internal/_tree-name.mjs')).href)
|
||||
const mf = join(ROOT, 'public', TREE_NAME, 'manifest.json')
|
||||
const manifest = existsSync(mf) ? JSON.parse(readFileSync(mf, 'utf8')) : null
|
||||
const bundleExists = (b, p) => existsSync(join(ROOT, 'plugin-designs', b, `${p}.vue`))
|
||||
return { ...tabs, STRINGS, meta, manifest, bundleExists }
|
||||
}
|
||||
|
||||
function selfTest(base) {
|
||||
const clone = (o) => JSON.parse(JSON.stringify(o))
|
||||
const cases = [
|
||||
['A: key-cities added to Experimental', (c) => { c.TABS = [...c.TABS, { id: 'key-cities', section: 'experimental', bundle: 'key-cities', profile: 'key-cities' }] }, /^A: section experimental|^B: row key-cities/],
|
||||
['A: a row dropped', (c) => { c.TABS = c.TABS.filter((t) => t.id !== 'upg-neighbor') }, /^A: section experimental/],
|
||||
['B: resolver that shows whatever the manifest carries', (c) => { c.resolveTabs = (m) => (m.bundles || []).map((b) => ({ id: b.bundle, bundle: b.bundle, profile: b.bundle, tag: b.bundle, scriptUrl: b.scriptUrl })) }, /^B: resolution produced a key-cities row/],
|
||||
['C: a composite row falls back to a leaf', (c) => { c.manifest = clone(c.manifest); const b = c.manifest.bundles.find((x) => x.bundle === 'my-upg-100-list'); b.profiles = b.profiles.filter((p) => p.profile !== 'upg-100-mfe') }, /^C: row upg-100 opens/],
|
||||
['D: composes drift', (c) => { c.meta = { ...c.meta, composes: c.meta.composes.slice(1) } }, /^D: the rail shows/],
|
||||
['D: composes a ref the rail cannot show', (c) => { c.meta = { ...c.meta, composes: [...c.meta.composes, 'key-cities/key-cities'] } }, /^D: meta composes/],
|
||||
['E: a locale loses a key', (c) => { c.STRINGS = clone(c.STRINGS); delete c.STRINGS.ar['tab.upg-100'] }, /^E: ar is missing "tab.upg-100"/],
|
||||
['E: a row without an English label', (c) => { c.STRINGS = clone(c.STRINGS); delete c.STRINGS.en['tab.bible-posters'] }, /^E: the shell reads "tab.bible-posters"/],
|
||||
]
|
||||
let bad = 0
|
||||
for (const [name, mutate, expect] of cases) {
|
||||
const c = { ...base, TABS: [...base.TABS] }
|
||||
mutate(c)
|
||||
const { errs } = checkShelf(c)
|
||||
const hit = errs.some((e) => expect.test(e))
|
||||
console.log(`${hit ? 'ok ' : 'MISS'} self-test: ${name}${hit ? '' : ' — errors were: ' + (errs.join(' | ') || 'none')}`)
|
||||
if (!hit) bad++
|
||||
}
|
||||
const clean = checkShelf(base).errs
|
||||
if (clean.length) { console.log(`MISS self-test: the unmutated inputs are not clean: ${clean.join(' | ')}`); bad++ }
|
||||
return bad
|
||||
}
|
||||
|
||||
const base = await load()
|
||||
if (!base.manifest) {
|
||||
console.log(`${TAG}: NOT MEASURED — no built manifest (run bun run tree first)`)
|
||||
process.exitCode = 75
|
||||
} else if (process.argv.includes('--self-test')) {
|
||||
const bad = selfTest(base)
|
||||
console.log(bad ? `${TAG} self-test: FAIL (${bad} arm(s) could not fail)` : `${TAG} self-test: PASS (every arm fails on its planted defect)`)
|
||||
process.exitCode = bad ? 1 : 0
|
||||
} else {
|
||||
const { errs, notes, counts } = checkShelf(base)
|
||||
for (const n of notes) console.log(n)
|
||||
for (const e of errs) console.log('FAIL ' + e)
|
||||
const summary = `${counts.rows} rows declared, ${counts.resolved} resolved on the built manifest, ${counts.composes} composes refs, ${counts.keys} keys x ${counts.locales} locales`
|
||||
if (errs.length) { console.log(`${TAG}: FAIL (${errs.length}) — ${summary}`); process.exitCode = 1 }
|
||||
else if (notes.length) { console.log(`${TAG}: NOT MEASURED — ${summary}`); process.exitCode = 75 }
|
||||
else console.log(`${TAG}: PASS — ${summary}`)
|
||||
}
|
||||
169
plugin-designs/posterverse/src/strings.js
Normal file
169
plugin-designs/posterverse/src/strings.js
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
/**
|
||||
* strings.js — Posterverse's own words, in the 11 shipped locales.
|
||||
*
|
||||
* PRIVATE TO THIS PLUGIN, like the Research Gallery's table: these strings belong to the shell alone
|
||||
* (its rail, sections and status hints). Each nested poster plugin translates ITSELF.
|
||||
*
|
||||
* VOCABULARY FOLLOWS THE RESEARCH GALLERY. A row that also appears in the gallery
|
||||
* (plugin-designs/doxa-maps-spa/src/strings.js) uses the gallery's words verbatim, so the same plugin
|
||||
* has one name in both shells: tab.upg-100 follows the "UPG 100 List Posters" rename, and the
|
||||
* Experimental names, status hints and the layout's own controls are copied as the gallery ships them.
|
||||
*
|
||||
* `en` is the source and the fallback: a key missing in a locale renders in English rather than as a
|
||||
* key name. {label} is the only placeholder.
|
||||
*/
|
||||
export const STRINGS = {
|
||||
en: {
|
||||
'sec.posters': 'Posters', 'sec.experimental': 'Experimental',
|
||||
'tab.upg-100': 'UPG 100 List Posters', 'sub.upg-100': 'my unreached list + posters',
|
||||
'tab.country-posters': 'Country Posters', 'sub.country-posters': 'a poster for every country',
|
||||
'tab.poster-design': 'Poster Design', 'sub.poster-design': 'every poster style and size',
|
||||
'tab.wall-of-people-groups': 'Wall of People Groups', 'tab.all-people-groups': 'All People Groups',
|
||||
'tab.upg-neighbor': 'UPG Neighbor', 'tab.bible-posters': 'Bible Posters',
|
||||
experimentalSub: 'experimental bundle', lab: 'lab',
|
||||
loading: 'Loading {label}…', loadFailed: 'Could not load {label}.',
|
||||
manifestHint: 'This tab is composed from manifest.json — build the tree, or serve the built tree, to load it.',
|
||||
backToTop: 'Back to top',
|
||||
toggleList: 'Toggle list', openViews: 'Open the list of views', expandList: 'Expand list', collapseList: 'Collapse list', collapse: 'Collapse',
|
||||
},
|
||||
es: {
|
||||
'sec.posters': 'Carteles', 'sec.experimental': 'Experimental',
|
||||
'tab.upg-100': 'Carteles de la lista UPG 100', 'sub.upg-100': 'mi lista de no alcanzados + carteles',
|
||||
'tab.country-posters': 'Carteles por país', 'sub.country-posters': 'un cartel para cada país',
|
||||
'tab.poster-design': 'Diseño de carteles', 'sub.poster-design': 'todos los estilos y tamaños de cartel',
|
||||
'tab.wall-of-people-groups': 'Muro de los pueblos', 'tab.all-people-groups': 'Todos los pueblos',
|
||||
'tab.upg-neighbor': 'Vecino UPG', 'tab.bible-posters': 'Carteles bíblicos',
|
||||
experimentalSub: 'mapa experimental', lab: 'beta',
|
||||
loading: 'Cargando {label}…', loadFailed: 'No se pudo cargar {label}.',
|
||||
manifestHint: 'Esta pestaña se compone a partir de manifest.json: compila el árbol o sirve el árbol compilado para cargarla.',
|
||||
backToTop: 'Volver arriba',
|
||||
toggleList: 'Mostrar u ocultar la lista', openViews: 'Abrir la lista de vistas', expandList: 'Expandir la lista', collapseList: 'Contraer la lista', collapse: 'Contraer',
|
||||
},
|
||||
fr: {
|
||||
'sec.posters': 'Affiches', 'sec.experimental': 'Expérimental',
|
||||
'tab.upg-100': 'Affiches de la liste UPG 100', 'sub.upg-100': 'ma liste des non atteints + affiches',
|
||||
'tab.country-posters': 'Affiches par pays', 'sub.country-posters': 'une affiche pour chaque pays',
|
||||
'tab.poster-design': 'Conception d’affiches', 'sub.poster-design': 'tous les styles et formats d’affiche',
|
||||
'tab.wall-of-people-groups': 'Mur des peuples', 'tab.all-people-groups': 'Tous les peuples',
|
||||
'tab.upg-neighbor': 'Voisin UPG', 'tab.bible-posters': 'Affiches bibliques',
|
||||
experimentalSub: 'carte expérimentale', lab: 'bêta',
|
||||
loading: 'Chargement de {label}…', loadFailed: 'Impossible de charger {label}.',
|
||||
manifestHint: "Cet onglet est composé à partir de manifest.json : construisez l'arborescence ou servez l'arborescence construite pour le charger.",
|
||||
backToTop: 'Retour en haut',
|
||||
toggleList: 'Afficher ou masquer la liste', openViews: 'Ouvrir la liste des vues', expandList: 'Déplier la liste', collapseList: 'Replier la liste', collapse: 'Replier',
|
||||
},
|
||||
de: {
|
||||
'sec.posters': 'Poster', 'sec.experimental': 'Experimentell',
|
||||
'tab.upg-100': 'Poster der UPG 100 Liste', 'sub.upg-100': 'meine Liste der Unerreichten + Poster',
|
||||
'tab.country-posters': 'Länderposter', 'sub.country-posters': 'ein Poster für jedes Land',
|
||||
'tab.poster-design': 'Postergestaltung', 'sub.poster-design': 'alle Posterstile und -größen',
|
||||
'tab.wall-of-people-groups': 'Wand der Volksgruppen', 'tab.all-people-groups': 'Alle Volksgruppen',
|
||||
'tab.upg-neighbor': 'UPG-Nachbar', 'tab.bible-posters': 'Bibelposter',
|
||||
experimentalSub: 'experimentelle Karte', lab: 'Beta',
|
||||
loading: '{label} wird geladen…', loadFailed: '{label} konnte nicht geladen werden.',
|
||||
manifestHint: 'Dieser Tab wird aus manifest.json zusammengesetzt – bauen Sie den Baum oder stellen Sie den gebauten Baum bereit, um ihn zu laden.',
|
||||
backToTop: 'Nach oben',
|
||||
toggleList: 'Liste ein-/ausblenden', openViews: 'Ansichtsliste öffnen', expandList: 'Liste ausklappen', collapseList: 'Liste einklappen', collapse: 'Einklappen',
|
||||
},
|
||||
pt: {
|
||||
'sec.posters': 'Cartazes', 'sec.experimental': 'Experimental',
|
||||
'tab.upg-100': 'Cartazes da lista UPG 100', 'sub.upg-100': 'minha lista de não alcançados + cartazes',
|
||||
'tab.country-posters': 'Cartazes por país', 'sub.country-posters': 'um cartaz para cada país',
|
||||
'tab.poster-design': 'Design de cartazes', 'sub.poster-design': 'todos os estilos e tamanhos de cartaz',
|
||||
'tab.wall-of-people-groups': 'Mural dos povos', 'tab.all-people-groups': 'Todos os povos',
|
||||
'tab.upg-neighbor': 'Vizinho UPG', 'tab.bible-posters': 'Cartazes bíblicos',
|
||||
experimentalSub: 'mapa experimental', lab: 'beta',
|
||||
loading: 'Carregando {label}…', loadFailed: 'Não foi possível carregar {label}.',
|
||||
manifestHint: 'Esta aba é composta a partir do manifest.json — compile a árvore ou sirva a árvore compilada para carregá-la.',
|
||||
backToTop: 'Voltar ao topo',
|
||||
toggleList: 'Mostrar ou ocultar a lista', openViews: 'Abrir a lista de visualizações', expandList: 'Expandir a lista', collapseList: 'Recolher a lista', collapse: 'Recolher',
|
||||
},
|
||||
it: {
|
||||
'sec.posters': 'Poster', 'sec.experimental': 'Sperimentale',
|
||||
'tab.upg-100': 'Poster della lista UPG 100', 'sub.upg-100': 'la mia lista dei non raggiunti + poster',
|
||||
'tab.country-posters': 'Poster per paese', 'sub.country-posters': 'un poster per ogni paese',
|
||||
'tab.poster-design': 'Design dei poster', 'sub.poster-design': 'tutti gli stili e i formati dei poster',
|
||||
'tab.wall-of-people-groups': 'Muro dei popoli', 'tab.all-people-groups': 'Tutti i popoli',
|
||||
'tab.upg-neighbor': 'Vicino UPG', 'tab.bible-posters': 'Poster biblici',
|
||||
experimentalSub: 'mappa sperimentale', lab: 'beta',
|
||||
loading: 'Caricamento di {label}…', loadFailed: 'Impossibile caricare {label}.',
|
||||
manifestHint: "Questa scheda è composta da manifest.json: compila l'albero o pubblica l'albero compilato per caricarla.",
|
||||
backToTop: 'Torna su',
|
||||
toggleList: 'Mostra o nascondi l’elenco', openViews: 'Apri l’elenco delle viste', expandList: 'Espandi l’elenco', collapseList: 'Comprimi l’elenco', collapse: 'Comprimi',
|
||||
},
|
||||
ro: {
|
||||
'sec.posters': 'Afișe', 'sec.experimental': 'Experimental',
|
||||
'tab.upg-100': 'Afișe din lista UPG 100', 'sub.upg-100': 'lista mea de neatinși + afișe',
|
||||
'tab.country-posters': 'Afișe pe țări', 'sub.country-posters': 'un afiș pentru fiecare țară',
|
||||
'tab.poster-design': 'Design de afișe', 'sub.poster-design': 'toate stilurile și dimensiunile de afiș',
|
||||
'tab.wall-of-people-groups': 'Zidul popoarelor', 'tab.all-people-groups': 'Toate popoarele',
|
||||
'tab.upg-neighbor': 'Vecin UPG', 'tab.bible-posters': 'Afișe biblice',
|
||||
experimentalSub: 'hartă experimentală', lab: 'beta',
|
||||
loading: 'Se încarcă {label}…', loadFailed: '{label} nu a putut fi încărcat.',
|
||||
manifestHint: 'Această filă este compusă din manifest.json — construiți arborele sau serviți arborele construit pentru a o încărca.',
|
||||
backToTop: 'Sus',
|
||||
toggleList: 'Afișează sau ascunde lista', openViews: 'Deschide lista de vizualizări', expandList: 'Extinde lista', collapseList: 'Restrânge lista', collapse: 'Restrânge',
|
||||
},
|
||||
ru: {
|
||||
'sec.posters': 'Плакаты', 'sec.experimental': 'Экспериментальное',
|
||||
'tab.upg-100': 'Плакаты списка UPG 100', 'sub.upg-100': 'мой список недостигнутых + плакаты',
|
||||
'tab.country-posters': 'Плакаты по странам', 'sub.country-posters': 'плакат для каждой страны',
|
||||
'tab.poster-design': 'Дизайн плакатов', 'sub.poster-design': 'все стили и размеры плакатов',
|
||||
'tab.wall-of-people-groups': 'Стена народов', 'tab.all-people-groups': 'Все народы',
|
||||
'tab.upg-neighbor': 'Сосед UPG', 'tab.bible-posters': 'Библейские плакаты',
|
||||
experimentalSub: 'экспериментальная карта', lab: 'бета',
|
||||
loading: 'Загрузка: {label}…', loadFailed: 'Не удалось загрузить: {label}.',
|
||||
manifestHint: 'Эта вкладка собирается из manifest.json — соберите дерево или разместите собранное дерево, чтобы загрузить её.',
|
||||
backToTop: 'Наверх',
|
||||
toggleList: 'Показать или скрыть список', openViews: 'Открыть список представлений', expandList: 'Развернуть список', collapseList: 'Свернуть список', collapse: 'Свернуть',
|
||||
},
|
||||
ar: {
|
||||
'sec.posters': 'ملصقات', 'sec.experimental': 'تجريبي',
|
||||
'tab.upg-100': 'ملصقات قائمة UPG 100', 'sub.upg-100': 'قائمتي لغير المُبشَّرين + ملصقات',
|
||||
'tab.country-posters': 'ملصقات الدول', 'sub.country-posters': 'ملصق لكل دولة',
|
||||
'tab.poster-design': 'تصميم الملصقات', 'sub.poster-design': 'كل أنماط الملصقات وأحجامها',
|
||||
'tab.wall-of-people-groups': 'جدار الشعوب', 'tab.all-people-groups': 'جميع الشعوب',
|
||||
'tab.upg-neighbor': 'جار UPG', 'tab.bible-posters': 'ملصقات الكتاب المقدس',
|
||||
experimentalSub: 'خريطة تجريبية', lab: 'تجريبي',
|
||||
loading: 'جارٍ تحميل {label}…', loadFailed: 'تعذّر تحميل {label}.',
|
||||
manifestHint: 'تُبنى علامة التبويب هذه من manifest.json — ابنِ الشجرة أو قدّم الشجرة المبنية لتحميلها.',
|
||||
backToTop: 'إلى الأعلى',
|
||||
toggleList: 'إظهار القائمة أو إخفاؤها', openViews: 'فتح قائمة العروض', expandList: 'توسيع القائمة', collapseList: 'طي القائمة', collapse: 'طي',
|
||||
},
|
||||
hi: {
|
||||
'sec.posters': 'पोस्टर', 'sec.experimental': 'प्रायोगिक',
|
||||
'tab.upg-100': 'UPG 100 सूची पोस्टर', 'sub.upg-100': 'मेरी अप्राप्त सूची + पोस्टर',
|
||||
'tab.country-posters': 'देश पोस्टर', 'sub.country-posters': 'हर देश के लिए एक पोस्टर',
|
||||
'tab.poster-design': 'पोस्टर डिज़ाइन', 'sub.poster-design': 'हर पोस्टर शैली और आकार',
|
||||
'tab.wall-of-people-groups': 'जनसमूहों की दीवार', 'tab.all-people-groups': 'सभी जनसमूह',
|
||||
'tab.upg-neighbor': 'UPG पड़ोसी', 'tab.bible-posters': 'बाइबल पोस्टर',
|
||||
experimentalSub: 'प्रायोगिक मानचित्र', lab: 'बीटा',
|
||||
loading: '{label} लोड हो रहा है…', loadFailed: '{label} लोड नहीं हो सका।',
|
||||
manifestHint: 'यह टैब manifest.json से बनता है — इसे लोड करने के लिए ट्री बनाएँ या बना हुआ ट्री सर्व करें।',
|
||||
backToTop: 'ऊपर जाएँ',
|
||||
toggleList: 'सूची दिखाएँ या छिपाएँ', openViews: 'दृश्यों की सूची खोलें', expandList: 'सूची फैलाएँ', collapseList: 'सूची संक्षिप्त करें', collapse: 'संक्षिप्त करें',
|
||||
},
|
||||
zh: {
|
||||
'sec.posters': '海报', 'sec.experimental': '实验性',
|
||||
'tab.upg-100': 'UPG 100 名单海报', 'sub.upg-100': '我的未得之民名单 + 海报',
|
||||
'tab.country-posters': '国家海报', 'sub.country-posters': '每个国家一张海报',
|
||||
'tab.poster-design': '海报设计', 'sub.poster-design': '所有海报样式与尺寸',
|
||||
'tab.wall-of-people-groups': '民族之墙', 'tab.all-people-groups': '所有民族',
|
||||
'tab.upg-neighbor': 'UPG 邻居', 'tab.bible-posters': '圣经海报',
|
||||
experimentalSub: '实验性地图', lab: '测试版',
|
||||
loading: '正在加载{label}…', loadFailed: '无法加载{label}。',
|
||||
manifestHint: '此标签页由 manifest.json 组成——请构建目录树或提供已构建的目录树以加载它。',
|
||||
backToTop: '回到顶部',
|
||||
toggleList: '显示或隐藏列表', openViews: '打开视图列表', expandList: '展开列表', collapseList: '折叠列表', collapse: '折叠',
|
||||
},
|
||||
}
|
||||
|
||||
export const LOCALES = Object.keys(STRINGS)
|
||||
|
||||
/** Translate `key` into `locale`, falling back to English, then to the key itself. */
|
||||
export function translate(locale, key, params) {
|
||||
const table = STRINGS[locale] || STRINGS.en
|
||||
let s = table[key] ?? STRINGS.en[key] ?? key
|
||||
if (params) for (const k in params) s = s.split('{' + k + '}').join(String(params[k]))
|
||||
return s
|
||||
}
|
||||
87
plugin-designs/posterverse/src/tabs.js
Normal file
87
plugin-designs/posterverse/src/tabs.js
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
/**
|
||||
* tabs.js — WHICH plugins Posterverse shows, and how each row is resolved from the tree's manifest.
|
||||
*
|
||||
* Pure data + pure functions (no Vue), so the shell and its headless check read the same list.
|
||||
*
|
||||
* ★ THE WHOLE RAIL IS AN ALLOW-LIST, the Experimental folder included. The Research Gallery fills its
|
||||
* Experimental folder with every bundle nobody placed (a deny-list catch-all). Copied here, that rule
|
||||
* would show every research map, including key-cities, which is GATED and must never be exposed.
|
||||
* So every row Posterverse can ever paint is named below, and a bundle that is not named never
|
||||
* appears, whatever the manifest holds.
|
||||
*
|
||||
* ★ A ROW WHOSE BUNDLE IS NOT IN THE TREE IS PRUNED, not painted dead. That is how a row can be listed
|
||||
* before its plugin lands: Poster Design and Bible Posters are declared with PROVISIONAL ids and stay
|
||||
* invisible until a bundle of that id exists. Their owners confirm the ids when they start; change the
|
||||
* `bundle`/`profile` here to match, never the row `id` (the id is the stable key for ?tab= links).
|
||||
*
|
||||
* ★ MOUNT THE COMPOSITE, NOT A LEAF. The profile named here is the one that mounts. my-upg-100-list and
|
||||
* country-posters each ship a leaf map, a leaf wall and the capability-4 composite of the two; the
|
||||
* composite is the plugin as its owner designed it (and it is what makes Posterverse capability 5).
|
||||
*/
|
||||
|
||||
// The rail's sections, in order. `folder` renders collapsible and starts closed, like the gallery's.
|
||||
export const SECTIONS = [
|
||||
{ key: 'posters', i18n: 'sec.posters', icon: 'poster' },
|
||||
{ key: 'experimental', i18n: 'sec.experimental', icon: 'flask', folder: true, open: false },
|
||||
]
|
||||
|
||||
// Every row Posterverse can show, in rail order.
|
||||
export const TABS = [
|
||||
{ id: 'upg-100', section: 'posters', icon: 'list', bundle: 'my-upg-100-list', profile: 'upg-100-mfe' },
|
||||
{ id: 'country-posters', section: 'posters', icon: 'map', bundle: 'country-posters', profile: 'country-posters-mfe' },
|
||||
// Provisional ids: no poster-design bundle exists yet, so this row is pruned until one does.
|
||||
{ id: 'poster-design', section: 'posters', icon: 'poster', bundle: 'poster-design', profile: 'poster-design-mfe', provisional: true },
|
||||
{ id: 'wall-of-people-groups', section: 'experimental', icon: 'flask', bundle: 'wall-of-people-groups', profile: 'wall-of-people-groups' },
|
||||
{ id: 'all-people-groups', section: 'experimental', icon: 'flask', bundle: 'all-people-groups', profile: 'all-people-groups' },
|
||||
{ id: 'upg-neighbor', section: 'experimental', icon: 'flask', bundle: 'upg-neighbor', profile: 'upg-neighbor' },
|
||||
// Provisional ids: no bible-posters bundle exists yet, so this row is pruned until one does.
|
||||
{ id: 'bible-posters', section: 'experimental', icon: 'flask', bundle: 'bible-posters', profile: 'bible-posters', provisional: true },
|
||||
]
|
||||
|
||||
// Bundles that must never be a Posterverse row. The allow-list above already excludes them; this set
|
||||
// makes the exclusion an ASSERTION (check-posterverse-shelf.mjs fails if a row ever names one), so a
|
||||
// later edit cannot add one by accident.
|
||||
export const NEVER = new Set(['key-cities', 'posterverse', 'doxa-maps-spa', 'plugin-studio'])
|
||||
|
||||
// Elements that own or NEST a WebGL map, so their pane is DESTROYED on deactivate rather than hidden
|
||||
// (browsers keep ~8-16 live contexts and silently kill the oldest). Taken from the Research Gallery's
|
||||
// measured HEAVY_ELEMENTS for the tags this rail shows; any profile the manifest marks `isMap` is
|
||||
// treated as heavy too, so a new map row is bounded without an edit here.
|
||||
const HEAVY_TAGS = new Set(['my-upg-100-list-map', 'country-posters-map', 'all-people-groups'])
|
||||
|
||||
/**
|
||||
* Complete each row from the manifest: the bundle's scriptUrl and the profile's element tag. A row
|
||||
* whose bundle is absent from this tree, or that ships no script, is left out (pruned).
|
||||
* Returns the resolved rows in rail order.
|
||||
*/
|
||||
export function resolveTabs(manifest, warn = () => {}) {
|
||||
const bundles = (manifest && Array.isArray(manifest.bundles)) ? manifest.bundles : []
|
||||
const byBundle = Object.fromEntries(bundles.map((b) => [b && b.bundle, b]))
|
||||
const out = []
|
||||
for (const d of TABS) {
|
||||
if (NEVER.has(d.bundle)) continue
|
||||
const b = byBundle[d.bundle]
|
||||
if (!b) continue
|
||||
const profiles = Array.isArray(b.profiles) ? b.profiles : []
|
||||
let p = profiles.find((x) => x && x.profile === d.profile)
|
||||
if (!p) {
|
||||
// The named profile is gone (renamed, or a provisional id guessed wrong). Degrade to the profile
|
||||
// named like the bundle, then the first, and say so: a quiet fallback is how a composite row
|
||||
// once showed a bare leaf.
|
||||
p = profiles.find((x) => x && x.profile === d.bundle) || profiles[0]
|
||||
if (!p) continue
|
||||
warn(`[posterverse] ${d.bundle} has no profile "${d.profile}"; row "${d.id}" opens "${p.profile}" instead`)
|
||||
}
|
||||
const tag = p.element || (Array.isArray(b.elements) && b.elements[0]) || d.bundle
|
||||
const scriptUrl = b.scriptUrl || p.scriptUrl || ''
|
||||
if (!scriptUrl) continue
|
||||
out.push({
|
||||
...d,
|
||||
tag,
|
||||
scriptUrl,
|
||||
profile: p.profile,
|
||||
heavy: HEAVY_TAGS.has(tag) || !!p.isMap,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
103
plugin-designs/wall-of-people-groups/scripts/check-wall-pan.mjs
Normal file
103
plugin-designs/wall-of-people-groups/scripts/check-wall-pan.mjs
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-wall-pan — the Wall pans and zooms with the pointer, 1 : 1. Headless.
|
||||
*
|
||||
* d3-zoom on a <canvas> reports its transform in the canvas's own CSS px, and wallCanvas.js applies it AFTER
|
||||
* the "meet" fit of the world into the canvas. It used to apply it inside the fit, so every pan moved the wall
|
||||
* by only the fit scale k0 of the pointer (measured on development 6e1a33534: a 200 px mouse drag panned 34 px
|
||||
* at 1440; a 120 × 200 px finger drag 14 × 23 px on a phone) and a wheel zoom drifted off the pointer.
|
||||
* This runs the REAL WallCanvas methods (through Vite, so @map resolves) with d3's own transform arithmetic
|
||||
* and asserts, over several fits and zooms:
|
||||
* - a pan of d px in d3's space moves every card d px on screen;
|
||||
* - a zoom about a pointer keeps the world point under that pointer;
|
||||
* - toWorld inverts toScreen;
|
||||
* - constrain() keeps the wall over the centre of the view after a fling, and leaves a resting view alone.
|
||||
* Two-sided: the old formulas (transform inside the fit) are planted and must fail the pan and zoom arms.
|
||||
* EXIT 0 pass · 1 fail
|
||||
*/
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { createServer } from 'vite'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
|
||||
const server = await createServer({ root: ROOT, configFile: join(ROOT, 'vite.config.js'), server: { middlewareMode: true, hmr: false, ws: false }, appType: 'custom', logLevel: 'error', optimizeDeps: { noDiscovery: true, include: [] } })
|
||||
|
||||
// d3-zoom's ZoomTransform arithmetic (d3-zoom/src/transform.js): translate() works in pre-scale units.
|
||||
class T {
|
||||
constructor(k, x, y) { this.k = k; this.x = x; this.y = y }
|
||||
translate(x, y) { return new T(this.k, this.x + this.k * x, this.y + this.k * y) }
|
||||
invert(p) { return [(p[0] - this.x) / this.k, (p[1] - this.y) / this.k] }
|
||||
}
|
||||
// d3's zoom about a pointer p: the point under p (in d3's space) stays under p.
|
||||
const zoomAbout = (t, k, p) => { const q = t.invert(p); return new T(k, p[0] - q[0] * k, p[1] - q[1] * k) }
|
||||
|
||||
/** A WallCanvas with no DOM: the prototype's own methods, over a fitted world. */
|
||||
function wall(W, cssW, cssH, worldW, worldH) {
|
||||
const w = Object.create(W.prototype)
|
||||
w.mode = 'social'; w.sp = null; w.o = { worldW, worldH }; w.cssW = cssW; w.cssH = cssH
|
||||
w.k0 = Math.min(cssW / worldW, cssH / worldH); w.ox = (cssW - worldW * w.k0) / 2; w.oy = (cssH - worldH * w.k0) / 2
|
||||
w.t = new T(1, 0, 0)
|
||||
return w
|
||||
}
|
||||
|
||||
function run(W, plant = false) {
|
||||
const errs = []
|
||||
if (plant) { // the formulas before the fix: d3's transform applied inside the fit
|
||||
Object.defineProperty(W.prototype, 'tx', { configurable: true, get() { return this.ox + this.t.x * this.k0 } })
|
||||
Object.defineProperty(W.prototype, 'ty', { configurable: true, get() { return this.oy + this.t.y * this.k0 } })
|
||||
}
|
||||
const cases = [[1440, 793, 3456, 4608], [390, 696, 5760, 9216], [1184, 791, 4000, 3000], [390, 696, 3000, 4000]]
|
||||
let checked = 0
|
||||
for (const [cssW, cssH, worldW, worldH] of cases) {
|
||||
for (const k of [1, 2.5, 9]) {
|
||||
const w = wall(W, cssW, cssH, worldW, worldH)
|
||||
w.t = zoomAbout(w.t, k, [cssW * 0.3, cssH * 0.6])
|
||||
const pt = [worldW * 0.37, worldH * 0.61], s0 = w.toScreen(...pt)
|
||||
// pan: a drag of (120, -200) px is t.translate(120 / k, -200 / k) in d3
|
||||
w.t = w.t.translate(120 / k, -200 / k)
|
||||
const s1 = w.toScreen(...pt)
|
||||
const ratio = [(s1[0] - s0[0]) / 120, (s1[1] - s0[1]) / -200]
|
||||
if (Math.abs(ratio[0] - 1) > 1e-9 || Math.abs(ratio[1] - 1) > 1e-9) errs.push(`pan ${cssW}×${cssH} world ${worldW}×${worldH} zoom ${k}: the wall moved ${ratio.map((r) => r.toFixed(3)).join(' × ')} of the pointer, not 1`)
|
||||
// zoom about a pointer: the world point under it stays
|
||||
const p = [cssW * 0.71, cssH * 0.23], before = w.toWorld(...p)
|
||||
w.t = zoomAbout(w.t, k * 1.4, p)
|
||||
const after = w.toScreen(...before)
|
||||
if (Math.hypot(after[0] - p[0], after[1] - p[1]) > 1e-6) errs.push(`zoom ${cssW}×${cssH} zoom ${k}: the point under the pointer drifted ${Math.hypot(after[0] - p[0], after[1] - p[1]).toFixed(1)} px`)
|
||||
const back = w.toScreen(...w.toWorld(...p))
|
||||
if (Math.hypot(back[0] - p[0], back[1] - p[1]) > 1e-6) errs.push('toWorld does not invert toScreen')
|
||||
// constrain: a fling five views away still leaves the wall over the centre; a resting view is untouched
|
||||
const rest = w.t
|
||||
if (w.constrain(rest) !== rest) errs.push(`constrain moved a view that already covers the centre (${cssW}×${cssH}, zoom ${k})`)
|
||||
for (const [fx, fy] of [[5, 0], [-5, 0], [0, 5], [0, -5]]) {
|
||||
w.t = w.constrain(rest.translate(fx * cssW / rest.k, fy * cssH / rest.k))
|
||||
const [x0, y0] = w.toScreen(0, 0), [x1, y1] = w.toScreen(worldW, worldH)
|
||||
if (!(x0 <= cssW / 2 + 1e-6 && x1 >= cssW / 2 - 1e-6 && y0 <= cssH / 2 + 1e-6 && y1 >= cssH / 2 - 1e-6)) errs.push(`a fling (${fx}, ${fy}) views away lost the wall (${cssW}×${cssH}, zoom ${k})`)
|
||||
}
|
||||
checked++
|
||||
}
|
||||
}
|
||||
if (plant) { delete W.prototype.tx; delete W.prototype.ty }
|
||||
return { errs, checked }
|
||||
}
|
||||
|
||||
const fails = []
|
||||
let real = null
|
||||
try {
|
||||
const { WallCanvas } = await server.ssrLoadModule('/plugin-designs/wall-of-people-groups/src/wallCanvas.js')
|
||||
const own = { tx: Object.getOwnPropertyDescriptor(WallCanvas.prototype, 'tx'), ty: Object.getOwnPropertyDescriptor(WallCanvas.prototype, 'ty') }
|
||||
if (!own.tx || !own.ty) fails.push('WallCanvas has no tx/ty getters: this check reads the wrong file or the renderer changed shape')
|
||||
else {
|
||||
real = run(WallCanvas)
|
||||
fails.push(...real.errs)
|
||||
if (real.checked < 12) fails.push(`only ${real.checked} of 12 cases ran`)
|
||||
const planted = run(WallCanvas, true)
|
||||
Object.defineProperty(WallCanvas.prototype, 'tx', own.tx); Object.defineProperty(WallCanvas.prototype, 'ty', own.ty)
|
||||
if (!planted.errs.some((e) => /^pan /.test(e))) fails.push('SELF-TEST: the old in-fit transform was not caught by the pan arm')
|
||||
if (!planted.errs.some((e) => /^zoom /.test(e))) fails.push('SELF-TEST: the old in-fit transform was not caught by the zoom arm')
|
||||
}
|
||||
} catch (e) { fails.push('could not run WallCanvas: ' + e.message) }
|
||||
await server.close()
|
||||
|
||||
if (fails.length) { console.log('WALL-PAN: FAIL'); for (const f of fails) console.log(' - ' + f); process.exit(1) }
|
||||
console.log(`WALL-PAN: PASS — ${real.checked} fits × zooms: a pan moves the wall 1:1 with the pointer, a zoom keeps the point under the pointer, toWorld inverts toScreen, and a fling never loses the wall; the old in-fit transform is caught.`)
|
||||
process.exit(0)
|
||||
|
|
@ -21,6 +21,8 @@
|
|||
* SAME layout for the screen (screenPoster: what the wall shows is what prints). No Vue.
|
||||
*/
|
||||
import { blessingFor, POSTER, THEMES } from './blessings.js'
|
||||
// The sheets, their glue bands and the print marks: the shared panel geometry (also India's large posters).
|
||||
import { planPanels, screenLayout, panelChromeHtml, panelCss } from '../../../library/components/poster/panelSheets.js'
|
||||
|
||||
// Sheet sizes in inches (w × h of ONE printed page), how many stack vertically, and the border. The brief's
|
||||
// three: 3 × 4 ft (the default, a living-room wall), 2 × 3 ft (small), 5 × 8 ft (three glued panels).
|
||||
|
|
@ -33,6 +35,9 @@ export const DEFAULT_PRESET = '3x4ft'
|
|||
export const MIN_READABLE_PT = 6 // verse size below which a verse under every card is not readable
|
||||
export const STRIP_PT = 9 // the one-verse strip under each row, on the sizes that use it
|
||||
|
||||
// A preset as panels: its sheets stacked top to bottom, each join a blank GLUE band as deep as the border.
|
||||
const panelsOf = (P) => planPanels({ sheetW: P.sheetW, sheetH: P.sheetH, count: P.sheets, direction: 'vertical', joinIn: P.border, borderIn: P.border, joinFill: 'glue' })
|
||||
|
||||
const CARD_ASPECT = 0.8 // card width / height: a portrait card (name, photo, verse)
|
||||
const PT = 1 / 72 // one point in inches
|
||||
|
||||
|
|
@ -48,15 +53,14 @@ export function planPoster(presetId, n) {
|
|||
// priestly blessing, the journey from Abraham to the church and the closing line. All scale with the width.
|
||||
const headH = W * 0.075, tableH = W * 0.17
|
||||
const bannerH = headH + tableH, footerH = footerHeight(W)
|
||||
// Each sheet's content band, in the sheet's own coordinates: y0..y1.
|
||||
const bands = []
|
||||
for (let s = 0; s < P.sheets; s++) {
|
||||
const last = s === P.sheets - 1
|
||||
let y0 = b // sheet 1: outer border; the others: the glue band
|
||||
let y1 = P.sheetH - (last ? b : 0) // flat bottom edge, except the last sheet's border
|
||||
// Each sheet's content band, in the sheet's own coordinates: y0..y1. The panels give each sheet's content box:
|
||||
// sheet 1 starts below its outer border, the others below their glue band; only the last has a bottom border
|
||||
// (the others' bottom edge is trimmed flat and glued over the next sheet's band).
|
||||
const panels = panelsOf(P)
|
||||
const bands = panels.sheets.map(({ s, last, content: { y0, y1 } }) => {
|
||||
const top = s === 0 ? bannerH : 0, bottom = last ? footerH : 0
|
||||
bands.push({ s, y0: y0 + top, y1: y1 - bottom, bannerY: s === 0 ? y0 : null, footerY: last ? y1 - footerH : null })
|
||||
}
|
||||
return { s, y0: y0 + top, y1: y1 - bottom, bannerY: s === 0 ? y0 : null, footerY: last ? y1 - footerH : null }
|
||||
})
|
||||
// The largest card (fewest columns) whose grid holds n when rows are fitted to each band; `strip` is the
|
||||
// height of the verse strip under each row (0 = the card layout, a verse on every card).
|
||||
const fit = (strip) => {
|
||||
|
|
@ -87,7 +91,7 @@ export function planPoster(presetId, n) {
|
|||
preset: P, presetId: PRESETS[presetId] ? presetId : DEFAULT_PRESET, n, cols, rowsPer, cw, ch, bands, cards,
|
||||
layout: stripLayout ? 'strip' : 'cards', strips,
|
||||
bannerH, footerH, headH, tableH, contentW: W,
|
||||
posterW: P.sheetW, posterH: P.sheetH * P.sheets - (P.sheets - 1) * b, // after gluing: 60 × 96 for 5x8ft
|
||||
posterW: panels.finished.w, posterH: panels.finished.h, // after gluing: 60 × 96 for 5x8ft
|
||||
text: { versePt: +versePt.toFixed(1), namePt: +(versePt * 1.25).toFixed(1), readable: !stripLayout, stripPt: STRIP_PT },
|
||||
}
|
||||
}
|
||||
|
|
@ -173,12 +177,11 @@ const PX_IN = 96 // CSS px per inch: the screen world is the print, in CSS px
|
|||
*/
|
||||
export function screenPoster(groups, presetId = DEFAULT_PRESET, texts = {}) {
|
||||
const { plan, css, pages } = posterParts(groups, presetId, texts, false)
|
||||
const P = plan.preset, step = P.sheetH + SCREEN_GAP_IN
|
||||
const sheets = pages.map((pg, s) => ({ x: 0, y: s * step * PX_IN, w: P.sheetW * PX_IN, h: P.sheetH * PX_IN }))
|
||||
const L = screenLayout(panelsOf(plan.preset), { gapIn: SCREEN_GAP_IN, pxPerIn: PX_IN }), step = L.step
|
||||
return {
|
||||
plan, css, pxPerIn: PX_IN,
|
||||
worldW: P.sheetW * PX_IN, worldH: (P.sheets * P.sheetH + (P.sheets - 1) * SCREEN_GAP_IN) * PX_IN,
|
||||
sheets,
|
||||
worldW: L.worldW, worldH: L.worldH,
|
||||
sheets: L.sheets.map(({ x, y, w, h }) => ({ x, y, w, h })),
|
||||
cards: plan.cards.map((c) => ({ i: c.i, x: c.x * PX_IN, y: (c.s * step + c.y) * PX_IN, w: c.w * PX_IN, h: c.h * PX_IN })),
|
||||
chromeHtml: pages.map((pg, s) => `<section class="sheet" style="position:absolute;left:0;top:${s * step}in">${pg.chrome}</section>`).join(''),
|
||||
}
|
||||
|
|
@ -217,9 +220,10 @@ function posterParts(groups, presetId, texts, withCards = true) {
|
|||
return `<div class="card" style="left:${f(c.x)};top:${f(c.y)};width:${f(c.w)};height:${f(c.h)}">`
|
||||
+ `<div class="nm${nf.wrap ? ' w2' : ''}" dir="auto" style="font-size:${nf.pt}pt${nf.wrap ? ';-webkit-line-clamp:' + nf.lines : ''}">${esc(g.name || '')}</div>${img}${verse}</div>`
|
||||
}
|
||||
const panels = panelsOf(P)
|
||||
const pages = plan.bands.map((bd) => {
|
||||
const s = bd.s, last = s === plan.bands.length - 1
|
||||
const glue = s > 0 ? `<div class="glue" style="top:0;height:${f(b)}">${esc(glueHere)} ▲ ${s} / ${P.sheets}</div>` : ''
|
||||
const s = bd.s
|
||||
const marks = panelChromeHtml(panels, s, { glueHere }) // the glue band (before), the trim line + s / N tag (after)
|
||||
// THE STORY, top to bottom: in Jesus' name → three prayer themes (OT + NT) → every people group →
|
||||
// the priestly blessing → the calling to bless, Abraham to the church with Christ at its centre →
|
||||
// the closing line, at the foundation. Each station's text takes dir="auto": the grid runs right to left
|
||||
|
|
@ -247,11 +251,10 @@ function posterParts(groups, presetId, texts, withCards = true) {
|
|||
+ `<div class="jny">${POSTER.journey.map((v) => `<div class="st${v.centre ? ' ctr' : ''}"><p class="sn" dir="auto">${esc(lab('journey_' + v.id, JOURNEY_EN[v.id]))}</p><p class="sv" dir="auto">${esc(v.text)}</p><p class="sr" dir="auto">${esc(v.ref)}</p></div>`).join('')}</div>`
|
||||
+ `<p class="close" dir="auto">${esc(lab('closing', CLOSING_EN))}</p>`
|
||||
+ `</footer>` : ''
|
||||
const trim = !last && P.sheets > 1 ? '<div class="trim"></div>' : ''
|
||||
// STRIP layout: one blessing verse per row, on a strip under the row (the same rotation as the cards').
|
||||
const strips = plan.strips.filter((t) => t.s === s).map((t) => { const v = blessingFor(t.row)
|
||||
return `<div class="vstrip" dir="auto" style="left:${f(t.x)};top:${f(t.y)};width:${f(t.w)};height:${f(t.h)}">${esc(v.text)}<span class="vref">${esc(v.ref)}</span></div>` }).join('')
|
||||
return { cards: withCards ? plan.cards.filter((c) => c.s === s).map(card).join('') : '', chrome: `${glue}${head}${strips}${foot}${trim}<div class="tag">${s + 1} / ${P.sheets}</div>` }
|
||||
return { cards: withCards ? plan.cards.filter((c) => c.s === s).map(card).join('') : '', chrome: `${marks.before}${head}${strips}${foot}${marks.after}` }
|
||||
})
|
||||
const css = `@page{size:${P.sheetW}in ${P.sheetH}in;margin:0}
|
||||
*{box-sizing:border-box}html,body{margin:0;padding:0}
|
||||
|
|
@ -293,8 +296,6 @@ body,.poster{font-family:Georgia,'Times New Roman',serif;color:#1c1c1c;-webkit-p
|
|||
.card .vs{font-size:${vPt}pt;line-height:1.15;font-style:italic}
|
||||
.card .vs,.card .rf{flex:none}
|
||||
.card .rf{font-size:${rPt.toFixed(1)}pt;color:#777;margin-top:1pt}
|
||||
.glue{position:absolute;left:0;width:100%;border-bottom:.02in dashed #b00;display:flex;align-items:center;justify-content:center;font:700 ${f(Math.min(0.5, b * 0.08))} sans-serif;color:#b00;background:rgba(176,0,0,.035);letter-spacing:.05em;text-transform:uppercase}
|
||||
.trim{position:absolute;left:0;bottom:0;width:100%;border-bottom:.02in solid #999}
|
||||
.tag{position:absolute;right:${f(Math.max(0.15, b * 0.1))};top:${f(Math.max(0.1, b * 0.08))};font:600 ${f(Math.max(0.12, b * 0.035))} sans-serif;color:#aaa}`
|
||||
${panelCss(panels)}`
|
||||
return { plan, css, pages, banner }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,7 +75,20 @@ export class WallCanvas {
|
|||
this.ctx = this.canvas.getContext('2d')
|
||||
this.icons = Object.fromEntries(Object.entries(ICON).map(([k, d]) => [k, new Path2D(d)]))
|
||||
const d3 = wallD3()
|
||||
this.zoom = d3.zoom().scaleExtent([0.1, 20]).filter((e) => !e.ctrlKey && !e.button)
|
||||
// d3's transform lives in the canvas's own CSS px (d3.pointer on a <canvas> is element px, unlike the old
|
||||
// SVG, whose viewBox made it world units), so it is applied AFTER the "meet" fit: screen = t(fit(world)).
|
||||
// Multiplying it by the fit scale instead moved the wall by only k0 of the pointer (measured on
|
||||
// development 6e1a33534: a 200 px drag panned 34 px at 1440, a 120 × 200 px finger drag 14 × 23 px on a
|
||||
// phone), so the wall could barely be panned.
|
||||
// A ctrl-wheel is a trackpad pinch (or ctrl + mouse wheel): it zooms the wall. Filtering it out let the
|
||||
// browser zoom the whole page instead. Its step is capped, so one mouse notch is at most ~1.4×.
|
||||
this.zoom = d3.zoom().scaleExtent([0.1, 20])
|
||||
.filter((e) => (!e.ctrlKey || e.type === 'wheel') && !e.button)
|
||||
.wheelDelta((e) => {
|
||||
const d = -e.deltaY * (e.deltaMode === 1 ? 0.05 : e.deltaMode ? 1 : 0.002)
|
||||
return e.ctrlKey ? Math.max(-0.5, Math.min(0.5, d * 5)) : d
|
||||
})
|
||||
.constrain((t) => this.constrain(t))
|
||||
.on('zoom', (e) => { this.t = e.transform; this.request() })
|
||||
this.sel = d3.select(this.canvas).call(this.zoom).on('dblclick.zoom', null)
|
||||
this._click = (e) => this.onClick(e)
|
||||
|
|
@ -140,17 +153,31 @@ export class WallCanvas {
|
|||
this.request()
|
||||
}
|
||||
|
||||
/** Screen px of one world unit, and world → screen. */
|
||||
/** Screen px of one world unit, and world → screen: the "meet" fit first, then d3's transform (CSS px). */
|
||||
get scale() { return this.k0 * this.t.k }
|
||||
toScreen(x, y) { return [this.ox + (x * this.t.k + this.t.x) * this.k0, this.oy + (y * this.t.k + this.t.y) * this.k0] }
|
||||
toWorld(px, py) { return [((px - this.ox) / this.k0 - this.t.x) / this.t.k, ((py - this.oy) / this.k0 - this.t.y) / this.t.k] }
|
||||
get tx() { return this.t.x + this.t.k * this.ox }
|
||||
get ty() { return this.t.y + this.t.k * this.oy }
|
||||
toScreen(x, y) { return [this.tx + x * this.scale, this.ty + y * this.scale] }
|
||||
toWorld(px, py) { return [(px - this.tx) / this.scale, (py - this.ty) / this.scale] }
|
||||
|
||||
/** Open at a zoom where one card is `cardPx` wide (the old wall opened at ~96 px). */
|
||||
/**
|
||||
* Keep the wall findable: however far it is panned, it still covers the middle of the view (a zoomed-out
|
||||
* wall smaller than the view may move, but never off it). Without this a fast fling left an empty grey table.
|
||||
*/
|
||||
constrain(t) {
|
||||
const W = this.worldW * this.k0, H = this.worldH * this.k0, cx = this.cssW / 2, cy = this.cssH / 2
|
||||
const x0 = t.x + t.k * this.ox, y0 = t.y + t.k * this.oy
|
||||
const dx = x0 > cx ? cx - x0 : x0 + t.k * W < cx ? cx - x0 - t.k * W : 0
|
||||
const dy = y0 > cy ? cy - y0 : y0 + t.k * H < cy ? cy - y0 - t.k * H : 0
|
||||
return dx || dy ? t.translate(dx / t.k, dy / t.k) : t // d3's translate() is in pre-scale units
|
||||
}
|
||||
|
||||
/** Open at a zoom where one card is `cardPx` wide (the old wall opened at ~96 px), world top-left in place. */
|
||||
startView(cardPx = 96) {
|
||||
if (this.posterOn) return this.fitAll() // the poster opens whole: what you see is what prints
|
||||
if (!this.grid) return
|
||||
const k = Math.max(1, Math.min(20, cardPx / (this.grid.cw * this.k0)))
|
||||
this.sel.call(this.zoom.transform, wallD3().zoomIdentity.scale(k))
|
||||
this.sel.call(this.zoom.transform, wallD3().zoomIdentity.translate(this.ox * (1 - k), this.oy * (1 - k)).scale(k))
|
||||
}
|
||||
resetZoom() { this.sel.transition().duration(750).call(this.zoom.transform, wallD3().zoomIdentity) }
|
||||
|
||||
|
|
@ -177,7 +204,7 @@ export class WallCanvas {
|
|||
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
||||
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
|
||||
if (!this.grid) return
|
||||
const s = this.scale, tx = this.ox + this.t.x * this.k0, ty = this.oy + this.t.y * this.k0
|
||||
const s = this.scale, tx = this.tx, ty = this.ty
|
||||
ctx.setTransform(this.dpr * s, 0, 0, this.dpr * s, this.dpr * tx, this.dpr * ty)
|
||||
if (this.posterOn) return this.drawPoster(ctx, s, tx, ty)
|
||||
const cardPx = this.grid.cw * s
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
This folder is GENERATED by `bun run build`; do not edit it by hand. It holds exactly one thing:
|
||||
|
||||
- **`dx-maps/`** — the output folder: 25 plugin bundles and 37 pages, self-contained.
|
||||
- **`dx-maps/`** — the output folder: 26 plugin bundles and 38 pages, self-contained.
|
||||
Copy this one folder to any web host (or open it from disk) and it works.
|
||||
- `dx-maps/index.html` — the front page: the Plugin Studio, every plugin in one place.
|
||||
- `dx-maps/<plugin>/<plugin>.js` — a plugin's bundle (a custom element; load it with a `<script>` tag).
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -81,7 +81,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <dashboard-plus-grid> custom element. -->
|
||||
<script src="../../dashboard-plus-grid.js" integrity="sha384-6mq3KUZ+iKSaydM3j7LCgJgLgGkl6ikcocNBF+QJYDxqxm1cFWmh79rDrcHcL1em" crossorigin="anonymous"></script>
|
||||
<script src="../../dashboard-plus-grid.js" integrity="sha384-Nq/tVXO4V9haojw/zIcHSAPNqpTqaQuf+Z3FZc8cwbK4asNXpu2X/R1gE1w3M4EI" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <dashboard-plus-grid> custom element. -->
|
||||
<script src="../dashboard-plus-grid.js" integrity="sha384-6mq3KUZ+iKSaydM3j7LCgJgLgGkl6ikcocNBF+QJYDxqxm1cFWmh79rDrcHcL1em" crossorigin="anonymous"></script>
|
||||
<script src="../dashboard-plus-grid.js" integrity="sha384-Nq/tVXO4V9haojw/zIcHSAPNqpTqaQuf+Z3FZc8cwbK4asNXpu2X/R1gE1w3M4EI" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -67,7 +67,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-maps-spa> custom element. -->
|
||||
<script src="../../doxa-maps-spa.js" integrity="sha384-7l58VigRUKdP7+n6FJxxOM0uUHwUWFaltIzYdghcUIOGrw9Uo1fjhBz6dQaR2fb0" crossorigin="anonymous"></script>
|
||||
<script src="../../doxa-maps-spa.js" integrity="sha384-FVAW1+l/o0xARsf8Tv40pKDxqGmoOHjgoUTI5E4a9WfTaZypsvNfzD/8/HH2sVu+" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <doxa-maps-spa> custom element. -->
|
||||
<script src="../doxa-maps-spa.js" integrity="sha384-7l58VigRUKdP7+n6FJxxOM0uUHwUWFaltIzYdghcUIOGrw9Uo1fjhBz6dQaR2fb0" crossorigin="anonymous"></script>
|
||||
<script src="../doxa-maps-spa.js" integrity="sha384-FVAW1+l/o0xARsf8Tv40pKDxqGmoOHjgoUTI5E4a9WfTaZypsvNfzD/8/HH2sVu+" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
"schema": "doxa-maps-manifest@1",
|
||||
"generatedFrom": "plugin-designs/*/*.vue",
|
||||
"embedBase": ".",
|
||||
"bundleCount": 25,
|
||||
"profileCount": 37,
|
||||
"bundleCount": 26,
|
||||
"profileCount": 38,
|
||||
"runtime": {
|
||||
"apiBaseUrl": "https://pray.doxa.life"
|
||||
},
|
||||
|
|
@ -266,7 +266,7 @@
|
|||
"bundle": "dashboard-plus-grid",
|
||||
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
|
||||
"output": "dx-maps/dashboard-plus-grid/dashboard-plus-grid.js",
|
||||
"integrity": "sha384-6mq3KUZ+iKSaydM3j7LCgJgLgGkl6ikcocNBF+QJYDxqxm1cFWmh79rDrcHcL1em",
|
||||
"integrity": "sha384-Nq/tVXO4V9haojw/zIcHSAPNqpTqaQuf+Z3FZc8cwbK4asNXpu2X/R1gE1w3M4EI",
|
||||
"elements": [
|
||||
"dashboard-plus-grid"
|
||||
],
|
||||
|
|
@ -290,8 +290,8 @@
|
|||
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./dashboard-plus-grid/dashboard-plus-grid/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-6mq3KUZ+iKSaydM3j7LCgJgLgGkl6ikcocNBF+QJYDxqxm1cFWmh79rDrcHcL1em\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>",
|
||||
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-6mq3KUZ+iKSaydM3j7LCgJgLgGkl6ikcocNBF+QJYDxqxm1cFWmh79rDrcHcL1em\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>"
|
||||
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-Nq/tVXO4V9haojw/zIcHSAPNqpTqaQuf+Z3FZc8cwbK4asNXpu2X/R1gE1w3M4EI\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>",
|
||||
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-Nq/tVXO4V9haojw/zIcHSAPNqpTqaQuf+Z3FZc8cwbK4asNXpu2X/R1gE1w3M4EI\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -361,7 +361,7 @@
|
|||
"bundle": "doxa-maps-spa",
|
||||
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
|
||||
"output": "dx-maps/doxa-maps-spa/doxa-maps-spa.js",
|
||||
"integrity": "sha384-7l58VigRUKdP7+n6FJxxOM0uUHwUWFaltIzYdghcUIOGrw9Uo1fjhBz6dQaR2fb0",
|
||||
"integrity": "sha384-FVAW1+l/o0xARsf8Tv40pKDxqGmoOHjgoUTI5E4a9WfTaZypsvNfzD/8/HH2sVu+",
|
||||
"elements": [
|
||||
"doxa-maps-spa"
|
||||
],
|
||||
|
|
@ -370,6 +370,7 @@
|
|||
"research-map-plus-grid/research-map-plus-grid",
|
||||
"regions-dashboard/regions-mfe",
|
||||
"my-upg-100-list/upg-100-mfe",
|
||||
"country-posters/country-posters-mfe",
|
||||
"doxa-simple-map/doxa-simple-map",
|
||||
"cities-internet/connectivity-map",
|
||||
"gospel-resources/resources-map"
|
||||
|
|
@ -379,7 +380,7 @@
|
|||
{
|
||||
"profile": "doxa-maps-spa",
|
||||
"stage": "experimental",
|
||||
"label": "Deep Research (vertical-tab app shell: the seven production maps)",
|
||||
"label": "Deep Research (vertical-tab app shell: the eight production maps)",
|
||||
"file": "doxa-maps-spa.vue",
|
||||
"description": "The DX-maps app itself: one element whose vertical-tab rail nests every other",
|
||||
"element": "doxa-maps-spa",
|
||||
|
|
@ -389,7 +390,7 @@
|
|||
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./doxa-maps-spa/doxa-maps-spa/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-7l58VigRUKdP7+n6FJxxOM0uUHwUWFaltIzYdghcUIOGrw9Uo1fjhBz6dQaR2fb0\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-FVAW1+l/o0xARsf8Tv40pKDxqGmoOHjgoUTI5E4a9WfTaZypsvNfzD/8/HH2sVu+\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -880,7 +881,7 @@
|
|||
"bundle": "my-upg-100-list",
|
||||
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
|
||||
"output": "dx-maps/my-upg-100-list/my-upg-100-list.js",
|
||||
"integrity": "sha384-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1",
|
||||
"integrity": "sha384-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB",
|
||||
"elements": [
|
||||
"my-upg-100-list-map"
|
||||
],
|
||||
|
|
@ -903,7 +904,7 @@
|
|||
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./my-upg-100-list/poster-wall/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -919,8 +920,8 @@
|
|||
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>",
|
||||
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>",
|
||||
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -936,8 +937,8 @@
|
|||
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>",
|
||||
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>",
|
||||
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -1010,7 +1011,7 @@
|
|||
"bundle": "plugin-studio",
|
||||
"scriptUrl": "./plugin-studio/plugin-studio.js",
|
||||
"output": "dx-maps/plugin-studio/plugin-studio.js",
|
||||
"integrity": "sha384-KL22TtxwdbrZIDZ+w5caP/NZB6p2yLAZJO+g1NSXUlGi6z3jsLfffUbF3nFbJ9sm",
|
||||
"integrity": "sha384-1Xj2sf1jO2XAjNCS5kSFvmfLVNpZdE+KzXb1x0BL7bg5jNEfpX0xh5bp+0P2ypjI",
|
||||
"elements": [
|
||||
"plugin-studio"
|
||||
],
|
||||
|
|
@ -1041,7 +1042,7 @@
|
|||
"scriptUrl": "./plugin-studio/plugin-studio.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./plugin-studio/plugin-studio/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./plugin-studio/plugin-studio.js\" integrity=\"sha384-KL22TtxwdbrZIDZ+w5caP/NZB6p2yLAZJO+g1NSXUlGi6z3jsLfffUbF3nFbJ9sm\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<plugin-studio profile-config='{\"profile\":\"plugin-studio\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></plugin-studio>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./plugin-studio/plugin-studio.js\" integrity=\"sha384-1Xj2sf1jO2XAjNCS5kSFvmfLVNpZdE+KzXb1x0BL7bg5jNEfpX0xh5bp+0P2ypjI\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<plugin-studio profile-config='{\"profile\":\"plugin-studio\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></plugin-studio>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -1064,6 +1065,59 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"bundle": "posterverse",
|
||||
"scriptUrl": "./posterverse/posterverse.js",
|
||||
"output": "dx-maps/posterverse/posterverse.js",
|
||||
"integrity": "sha384-xwStH6d1rIR+LOJ9iBnJXyNFIMTzSQ51YA2+UhqJgloN4EeH6vYubtHY3JM9j7p0",
|
||||
"elements": [
|
||||
"posterverse-app"
|
||||
],
|
||||
"composes": [
|
||||
"my-upg-100-list/upg-100-mfe",
|
||||
"country-posters/country-posters-mfe",
|
||||
"wall-of-people-groups/wall-of-people-groups",
|
||||
"all-people-groups/all-people-groups",
|
||||
"upg-neighbor/upg-neighbor"
|
||||
],
|
||||
"stage": "experimental",
|
||||
"profiles": [
|
||||
{
|
||||
"profile": "posterverse",
|
||||
"stage": "experimental",
|
||||
"label": "Posterverse (vertical-tab app shell: every poster plugin)",
|
||||
"file": "posterverse.vue",
|
||||
"description": "Every poster plugin in one app: a vertical-tab rail of real, embeddable poster plugins.",
|
||||
"element": "posterverse-app",
|
||||
"type": "nested-mfe-spa",
|
||||
"isMap": false,
|
||||
"iframeUrl": "./posterverse/posterverse/index.html",
|
||||
"scriptUrl": "./posterverse/posterverse.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./posterverse/posterverse/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./posterverse/posterverse.js\" integrity=\"sha384-xwStH6d1rIR+LOJ9iBnJXyNFIMTzSQ51YA2+UhqJgloN4EeH6vYubtHY3JM9j7p0\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<posterverse-app profile-config='{\"profile\":\"posterverse\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></posterverse-app>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
"id": "app",
|
||||
"title": "Posterverse — every poster plugin (app shell)",
|
||||
"apiBaseUrl": "",
|
||||
"props": {
|
||||
"profile": "posterverse",
|
||||
"provider": "maplibre",
|
||||
"instanceId": "posterverse-app",
|
||||
"heading": "Posterverse"
|
||||
},
|
||||
"element": "posterverse-app",
|
||||
"iframeUrl": "./posterverse/posterverse/app/index.html",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./posterverse/posterverse/app/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"bundle": "pplr-data-maps",
|
||||
"scriptUrl": "./pplr-data-maps/pplr-data-maps.js",
|
||||
|
|
@ -1192,7 +1246,7 @@
|
|||
"bundle": "research-dashboard",
|
||||
"scriptUrl": "./research-dashboard/research-dashboard.js",
|
||||
"output": "dx-maps/research-dashboard/research-dashboard.js",
|
||||
"integrity": "sha384-coB77fbeu9HkeixR1EKTfAFsBFDGrPcXevrU1wSM8oy1rEuB7tOUgAPH7XfQj+x0",
|
||||
"integrity": "sha384-NXlQlksDe95yrPiFqTkiIAc7pE+MW30uohIV/2Qlops26l+YKZCeo9q+PQJY8I6/",
|
||||
"elements": [
|
||||
"research-dashboard-map"
|
||||
],
|
||||
|
|
@ -1211,8 +1265,8 @@
|
|||
"scriptUrl": "./research-dashboard/research-dashboard.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./research-dashboard/research-dashboard/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-coB77fbeu9HkeixR1EKTfAFsBFDGrPcXevrU1wSM8oy1rEuB7tOUgAPH7XfQj+x0\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>",
|
||||
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-coB77fbeu9HkeixR1EKTfAFsBFDGrPcXevrU1wSM8oy1rEuB7tOUgAPH7XfQj+x0\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>"
|
||||
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-NXlQlksDe95yrPiFqTkiIAc7pE+MW30uohIV/2Qlops26l+YKZCeo9q+PQJY8I6/\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>",
|
||||
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-NXlQlksDe95yrPiFqTkiIAc7pE+MW30uohIV/2Qlops26l+YKZCeo9q+PQJY8I6/\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>"
|
||||
},
|
||||
"bundle": "research-dashboard"
|
||||
}
|
||||
|
|
@ -1419,7 +1473,7 @@
|
|||
"bundle": "wall-of-people-groups",
|
||||
"scriptUrl": "./wall-of-people-groups/wall-of-people-groups.js",
|
||||
"output": "dx-maps/wall-of-people-groups/wall-of-people-groups.js",
|
||||
"integrity": "sha384-jXcu2Fpvms5zKTuyxRQ1oZG7jciYNwC1+KOpUYXMlm5u59l3SGJOLxN3vbXvt5c1",
|
||||
"integrity": "sha384-+0FF2Yam1UsHfo2turc9M9IORYEfg/eKrOjl3VLUymNUK7P8/AnL8/p9zlrf/NEh",
|
||||
"elements": [
|
||||
"wall-of-people-groups"
|
||||
],
|
||||
|
|
@ -1438,7 +1492,7 @@
|
|||
"scriptUrl": "./wall-of-people-groups/wall-of-people-groups.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./wall-of-people-groups/wall-of-people-groups/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./wall-of-people-groups/wall-of-people-groups.js\" integrity=\"sha384-jXcu2Fpvms5zKTuyxRQ1oZG7jciYNwC1+KOpUYXMlm5u59l3SGJOLxN3vbXvt5c1\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<wall-of-people-groups profile-config='{\"profile\":\"wall-of-people-groups\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></wall-of-people-groups>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./wall-of-people-groups/wall-of-people-groups.js\" integrity=\"sha384-+0FF2Yam1UsHfo2turc9M9IORYEfg/eKrOjl3VLUymNUK7P8/AnL8/p9zlrf/NEh\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<wall-of-people-groups profile-config='{\"profile\":\"wall-of-people-groups\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></wall-of-people-groups>"
|
||||
},
|
||||
"bundle": "wall-of-people-groups"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,6 @@
|
|||
</head>
|
||||
<body>
|
||||
<plugin-studio tree-base="."></plugin-studio>
|
||||
<script src="./plugin-studio/plugin-studio.js" integrity="sha384-KL22TtxwdbrZIDZ+w5caP/NZB6p2yLAZJO+g1NSXUlGi6z3jsLfffUbF3nFbJ9sm" crossorigin="anonymous"></script>
|
||||
<script src="./plugin-studio/plugin-studio.js" integrity="sha384-1Xj2sf1jO2XAjNCS5kSFvmfLVNpZdE+KzXb1x0BL7bg5jNEfpX0xh5bp+0P2ypjI" crossorigin="anonymous"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
"schema": "doxa-maps-manifest@1",
|
||||
"generatedFrom": "plugin-designs/*/*.vue",
|
||||
"embedBase": ".",
|
||||
"bundleCount": 25,
|
||||
"profileCount": 37,
|
||||
"bundleCount": 26,
|
||||
"profileCount": 38,
|
||||
"runtime": {
|
||||
"apiBaseUrl": "https://pray.doxa.life"
|
||||
},
|
||||
|
|
@ -266,7 +266,7 @@
|
|||
"bundle": "dashboard-plus-grid",
|
||||
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
|
||||
"output": "dx-maps/dashboard-plus-grid/dashboard-plus-grid.js",
|
||||
"integrity": "sha384-6mq3KUZ+iKSaydM3j7LCgJgLgGkl6ikcocNBF+QJYDxqxm1cFWmh79rDrcHcL1em",
|
||||
"integrity": "sha384-Nq/tVXO4V9haojw/zIcHSAPNqpTqaQuf+Z3FZc8cwbK4asNXpu2X/R1gE1w3M4EI",
|
||||
"elements": [
|
||||
"dashboard-plus-grid"
|
||||
],
|
||||
|
|
@ -290,8 +290,8 @@
|
|||
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./dashboard-plus-grid/dashboard-plus-grid/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-6mq3KUZ+iKSaydM3j7LCgJgLgGkl6ikcocNBF+QJYDxqxm1cFWmh79rDrcHcL1em\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>",
|
||||
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-6mq3KUZ+iKSaydM3j7LCgJgLgGkl6ikcocNBF+QJYDxqxm1cFWmh79rDrcHcL1em\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>"
|
||||
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-Nq/tVXO4V9haojw/zIcHSAPNqpTqaQuf+Z3FZc8cwbK4asNXpu2X/R1gE1w3M4EI\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>",
|
||||
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-Nq/tVXO4V9haojw/zIcHSAPNqpTqaQuf+Z3FZc8cwbK4asNXpu2X/R1gE1w3M4EI\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -361,7 +361,7 @@
|
|||
"bundle": "doxa-maps-spa",
|
||||
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
|
||||
"output": "dx-maps/doxa-maps-spa/doxa-maps-spa.js",
|
||||
"integrity": "sha384-7l58VigRUKdP7+n6FJxxOM0uUHwUWFaltIzYdghcUIOGrw9Uo1fjhBz6dQaR2fb0",
|
||||
"integrity": "sha384-FVAW1+l/o0xARsf8Tv40pKDxqGmoOHjgoUTI5E4a9WfTaZypsvNfzD/8/HH2sVu+",
|
||||
"elements": [
|
||||
"doxa-maps-spa"
|
||||
],
|
||||
|
|
@ -370,6 +370,7 @@
|
|||
"research-map-plus-grid/research-map-plus-grid",
|
||||
"regions-dashboard/regions-mfe",
|
||||
"my-upg-100-list/upg-100-mfe",
|
||||
"country-posters/country-posters-mfe",
|
||||
"doxa-simple-map/doxa-simple-map",
|
||||
"cities-internet/connectivity-map",
|
||||
"gospel-resources/resources-map"
|
||||
|
|
@ -379,7 +380,7 @@
|
|||
{
|
||||
"profile": "doxa-maps-spa",
|
||||
"stage": "experimental",
|
||||
"label": "Deep Research (vertical-tab app shell: the seven production maps)",
|
||||
"label": "Deep Research (vertical-tab app shell: the eight production maps)",
|
||||
"file": "doxa-maps-spa.vue",
|
||||
"description": "The DX-maps app itself: one element whose vertical-tab rail nests every other",
|
||||
"element": "doxa-maps-spa",
|
||||
|
|
@ -389,7 +390,7 @@
|
|||
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./doxa-maps-spa/doxa-maps-spa/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-7l58VigRUKdP7+n6FJxxOM0uUHwUWFaltIzYdghcUIOGrw9Uo1fjhBz6dQaR2fb0\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-FVAW1+l/o0xARsf8Tv40pKDxqGmoOHjgoUTI5E4a9WfTaZypsvNfzD/8/HH2sVu+\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -880,7 +881,7 @@
|
|||
"bundle": "my-upg-100-list",
|
||||
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
|
||||
"output": "dx-maps/my-upg-100-list/my-upg-100-list.js",
|
||||
"integrity": "sha384-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1",
|
||||
"integrity": "sha384-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB",
|
||||
"elements": [
|
||||
"my-upg-100-list-map"
|
||||
],
|
||||
|
|
@ -903,7 +904,7 @@
|
|||
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./my-upg-100-list/poster-wall/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -919,8 +920,8 @@
|
|||
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>",
|
||||
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>",
|
||||
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -936,8 +937,8 @@
|
|||
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>",
|
||||
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>",
|
||||
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -1010,7 +1011,7 @@
|
|||
"bundle": "plugin-studio",
|
||||
"scriptUrl": "./plugin-studio/plugin-studio.js",
|
||||
"output": "dx-maps/plugin-studio/plugin-studio.js",
|
||||
"integrity": "sha384-KL22TtxwdbrZIDZ+w5caP/NZB6p2yLAZJO+g1NSXUlGi6z3jsLfffUbF3nFbJ9sm",
|
||||
"integrity": "sha384-1Xj2sf1jO2XAjNCS5kSFvmfLVNpZdE+KzXb1x0BL7bg5jNEfpX0xh5bp+0P2ypjI",
|
||||
"elements": [
|
||||
"plugin-studio"
|
||||
],
|
||||
|
|
@ -1041,7 +1042,7 @@
|
|||
"scriptUrl": "./plugin-studio/plugin-studio.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./plugin-studio/plugin-studio/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./plugin-studio/plugin-studio.js\" integrity=\"sha384-KL22TtxwdbrZIDZ+w5caP/NZB6p2yLAZJO+g1NSXUlGi6z3jsLfffUbF3nFbJ9sm\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<plugin-studio profile-config='{\"profile\":\"plugin-studio\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></plugin-studio>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./plugin-studio/plugin-studio.js\" integrity=\"sha384-1Xj2sf1jO2XAjNCS5kSFvmfLVNpZdE+KzXb1x0BL7bg5jNEfpX0xh5bp+0P2ypjI\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<plugin-studio profile-config='{\"profile\":\"plugin-studio\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></plugin-studio>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
|
|
@ -1064,6 +1065,59 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"bundle": "posterverse",
|
||||
"scriptUrl": "./posterverse/posterverse.js",
|
||||
"output": "dx-maps/posterverse/posterverse.js",
|
||||
"integrity": "sha384-xwStH6d1rIR+LOJ9iBnJXyNFIMTzSQ51YA2+UhqJgloN4EeH6vYubtHY3JM9j7p0",
|
||||
"elements": [
|
||||
"posterverse-app"
|
||||
],
|
||||
"composes": [
|
||||
"my-upg-100-list/upg-100-mfe",
|
||||
"country-posters/country-posters-mfe",
|
||||
"wall-of-people-groups/wall-of-people-groups",
|
||||
"all-people-groups/all-people-groups",
|
||||
"upg-neighbor/upg-neighbor"
|
||||
],
|
||||
"stage": "experimental",
|
||||
"profiles": [
|
||||
{
|
||||
"profile": "posterverse",
|
||||
"stage": "experimental",
|
||||
"label": "Posterverse (vertical-tab app shell: every poster plugin)",
|
||||
"file": "posterverse.vue",
|
||||
"description": "Every poster plugin in one app: a vertical-tab rail of real, embeddable poster plugins.",
|
||||
"element": "posterverse-app",
|
||||
"type": "nested-mfe-spa",
|
||||
"isMap": false,
|
||||
"iframeUrl": "./posterverse/posterverse/index.html",
|
||||
"scriptUrl": "./posterverse/posterverse.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./posterverse/posterverse/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./posterverse/posterverse.js\" integrity=\"sha384-xwStH6d1rIR+LOJ9iBnJXyNFIMTzSQ51YA2+UhqJgloN4EeH6vYubtHY3JM9j7p0\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<posterverse-app profile-config='{\"profile\":\"posterverse\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></posterverse-app>"
|
||||
},
|
||||
"instances": [
|
||||
{
|
||||
"id": "app",
|
||||
"title": "Posterverse — every poster plugin (app shell)",
|
||||
"apiBaseUrl": "",
|
||||
"props": {
|
||||
"profile": "posterverse",
|
||||
"provider": "maplibre",
|
||||
"instanceId": "posterverse-app",
|
||||
"heading": "Posterverse"
|
||||
},
|
||||
"element": "posterverse-app",
|
||||
"iframeUrl": "./posterverse/posterverse/app/index.html",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./posterverse/posterverse/app/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"bundle": "pplr-data-maps",
|
||||
"scriptUrl": "./pplr-data-maps/pplr-data-maps.js",
|
||||
|
|
@ -1192,7 +1246,7 @@
|
|||
"bundle": "research-dashboard",
|
||||
"scriptUrl": "./research-dashboard/research-dashboard.js",
|
||||
"output": "dx-maps/research-dashboard/research-dashboard.js",
|
||||
"integrity": "sha384-coB77fbeu9HkeixR1EKTfAFsBFDGrPcXevrU1wSM8oy1rEuB7tOUgAPH7XfQj+x0",
|
||||
"integrity": "sha384-NXlQlksDe95yrPiFqTkiIAc7pE+MW30uohIV/2Qlops26l+YKZCeo9q+PQJY8I6/",
|
||||
"elements": [
|
||||
"research-dashboard-map"
|
||||
],
|
||||
|
|
@ -1211,8 +1265,8 @@
|
|||
"scriptUrl": "./research-dashboard/research-dashboard.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./research-dashboard/research-dashboard/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-coB77fbeu9HkeixR1EKTfAFsBFDGrPcXevrU1wSM8oy1rEuB7tOUgAPH7XfQj+x0\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>",
|
||||
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-coB77fbeu9HkeixR1EKTfAFsBFDGrPcXevrU1wSM8oy1rEuB7tOUgAPH7XfQj+x0\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>"
|
||||
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-NXlQlksDe95yrPiFqTkiIAc7pE+MW30uohIV/2Qlops26l+YKZCeo9q+PQJY8I6/\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>",
|
||||
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-NXlQlksDe95yrPiFqTkiIAc7pE+MW30uohIV/2Qlops26l+YKZCeo9q+PQJY8I6/\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>"
|
||||
},
|
||||
"bundle": "research-dashboard"
|
||||
}
|
||||
|
|
@ -1419,7 +1473,7 @@
|
|||
"bundle": "wall-of-people-groups",
|
||||
"scriptUrl": "./wall-of-people-groups/wall-of-people-groups.js",
|
||||
"output": "dx-maps/wall-of-people-groups/wall-of-people-groups.js",
|
||||
"integrity": "sha384-jXcu2Fpvms5zKTuyxRQ1oZG7jciYNwC1+KOpUYXMlm5u59l3SGJOLxN3vbXvt5c1",
|
||||
"integrity": "sha384-+0FF2Yam1UsHfo2turc9M9IORYEfg/eKrOjl3VLUymNUK7P8/AnL8/p9zlrf/NEh",
|
||||
"elements": [
|
||||
"wall-of-people-groups"
|
||||
],
|
||||
|
|
@ -1438,7 +1492,7 @@
|
|||
"scriptUrl": "./wall-of-people-groups/wall-of-people-groups.js",
|
||||
"embed": {
|
||||
"iframe": "<iframe src=\"./wall-of-people-groups/wall-of-people-groups/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./wall-of-people-groups/wall-of-people-groups.js\" integrity=\"sha384-jXcu2Fpvms5zKTuyxRQ1oZG7jciYNwC1+KOpUYXMlm5u59l3SGJOLxN3vbXvt5c1\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<wall-of-people-groups profile-config='{\"profile\":\"wall-of-people-groups\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></wall-of-people-groups>"
|
||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./wall-of-people-groups/wall-of-people-groups.js\" integrity=\"sha384-+0FF2Yam1UsHfo2turc9M9IORYEfg/eKrOjl3VLUymNUK7P8/AnL8/p9zlrf/NEh\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<wall-of-people-groups profile-config='{\"profile\":\"wall-of-people-groups\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></wall-of-people-groups>"
|
||||
},
|
||||
"bundle": "wall-of-people-groups"
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -67,7 +67,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1" crossorigin="anonymous"></script>
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1" crossorigin="anonymous"></script>
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1" crossorigin="anonymous"></script>
|
||||
<script src="../my-upg-100-list.js" integrity="sha384-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -67,7 +67,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <plugin-studio> custom element. -->
|
||||
<script src="../plugin-studio.js" integrity="sha384-KL22TtxwdbrZIDZ+w5caP/NZB6p2yLAZJO+g1NSXUlGi6z3jsLfffUbF3nFbJ9sm" crossorigin="anonymous"></script>
|
||||
<script src="../plugin-studio.js" integrity="sha384-1Xj2sf1jO2XAjNCS5kSFvmfLVNpZdE+KzXb1x0BL7bg5jNEfpX0xh5bp+0P2ypjI" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <plugin-studio> custom element. -->
|
||||
<script src="../../plugin-studio.js" integrity="sha384-KL22TtxwdbrZIDZ+w5caP/NZB6p2yLAZJO+g1NSXUlGi6z3jsLfffUbF3nFbJ9sm" crossorigin="anonymous"></script>
|
||||
<script src="../../plugin-studio.js" integrity="sha384-1Xj2sf1jO2XAjNCS5kSFvmfLVNpZdE+KzXb1x0BL7bg5jNEfpX0xh5bp+0P2ypjI" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
155
public/dx-maps/posterverse/posterverse.js
Normal file
155
public/dx-maps/posterverse/posterverse.js
Normal file
File diff suppressed because one or more lines are too long
250
public/dx-maps/posterverse/posterverse/app/index.html
Normal file
250
public/dx-maps/posterverse/posterverse/app/index.html
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- THIS PAGE IS OURS. The only thing that knows a document belongs to the plugin is the page
|
||||
the generator writes, so it says so here. library/i18n reads it before reflecting an RTL
|
||||
locale on <html dir>; nothing else sets it, so a PARTNER page defaults to "not ours" and
|
||||
is never touched. Absence must mean restraint — an embedded plugin flipping a partner's
|
||||
whole <html> because a reader followed a ?lang=ar link is the bug this prevents. -->
|
||||
<script>window.MAP_OWNS_PAGE = true;</script>
|
||||
<title>Posterverse — every poster plugin (app shell) · posterverse</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
|
||||
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
|
||||
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
|
||||
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->
|
||||
<style>
|
||||
/* BARE full-page map — no chrome. The custom element IS the page, so the
|
||||
browser URL shares the map itself.
|
||||
HEIGHT IN svh, NOT inset:0. A fixed box with inset:0 follows the phone's DYNAMIC viewport, so the map
|
||||
(and a legend sheet sized from it) changes height every time the address bar hides or shows. 100svh is
|
||||
the viewport WITH the bar shown and never changes; when the bar hides, the page background shows below
|
||||
instead of the map resizing. On a desktop svh = the viewport, so nothing moves there. The 100% before it
|
||||
is the fallback for a browser without svh, which would otherwise size the box to its content.
|
||||
--profile-min-h:0 opts out of ProfileLoader's 100dvh floor, which would grow the element again. */
|
||||
html, body { height: 100%; margin: 0; background: #0a0e15; }
|
||||
#slot, posterverse-app { position: fixed; inset: 0 auto auto 0; width: 100%; height: 100%; height: 100svh; --profile-min-h: 0; display: block; }
|
||||
#status { position: fixed; inset: 0; z-index: 2; display: grid; place-items: center;
|
||||
padding: 24px; text-align: center; color: #9aa7b8;
|
||||
font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
#status.err { color: #f87171; }
|
||||
|
||||
/* ── PRINT, BOTH LEAF SHAPES ────────────────────────────────────────────────────────────
|
||||
Two things every generated leaf got wrong on paper, because no generated page has ever
|
||||
carried an @media print rule (measured: 0 across the tree).
|
||||
|
||||
The background is #0a0e15 — a dark page, printed. Browsers drop background colours by
|
||||
default, but a reader who ticks "Background graphics" to get the MAP also gets the page
|
||||
flooded black, and that is the reader who most wants the printout.
|
||||
|
||||
#status is the LOADING OVERLAY: position:fixed, inset:0, over everything. On paper it is
|
||||
a full sheet reading "loading…" above the content that did in fact load. It is hidden
|
||||
only while it is NOT an error — an error still prints, because a blank sheet with the
|
||||
explanation suppressed is worse than a sheet that says what went wrong. */
|
||||
@media print {
|
||||
html, body { height: auto; background: #fff; }
|
||||
#status:not(.err) { display: none; }
|
||||
/* ★ RELEASE THE FIXED HOST, MEASURED (rega, RQ-RGPRINT on a035408e2). The leaf pins both the
|
||||
slot and the element with position:fixed; inset:0. A fixed element is painted once, on the
|
||||
first page, so the printed document was exactly ONE viewport: 900px tall, while the content
|
||||
continued to y=1753. Every plugin whose leaf is taller than a screen has been printing its
|
||||
top screenful and silently dropping the rest — the dashboard printed its stats and its map,
|
||||
then blank to the bottom of the sheet.
|
||||
min-height, not a released height alone: a full-bleed map leaf takes its SIZE from this rule,
|
||||
so height:auto with nothing else can leave the element a zero-height box and print an empty
|
||||
page — a worse failure than the one being fixed, and the discriminator is not isMap
|
||||
(region-dashboards is isMap:true and is a scrolling dashboard). The floor keeps a definite
|
||||
box for the map case while letting a taller document grow onto page two. */
|
||||
#slot, posterverse-app { position: static; inset: auto; width: 100%; height: auto; min-height: 14cm; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="slot"></div>
|
||||
<div id="status">loading…</div>
|
||||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <posterverse-app> custom element. -->
|
||||
<script src="../../posterverse.js" integrity="sha384-xwStH6d1rIR+LOJ9iBnJXyNFIMTzSQ51YA2+UhqJgloN4EeH6vYubtHY3JM9j7p0" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
// 1. prop — a token hardcoded into this page's profile-config (config.tk)
|
||||
// 2. ?tk= URL param — token carried in the shared link
|
||||
// 3. baked token — opt-in literal token inlined at build time (build-config.token)
|
||||
// A token from rungs 1-3 is used DIRECTLY (no network). Otherwise a token
|
||||
// *endpoint* is resolved and fetched:
|
||||
// 4. window.MAP_TOKEN_URL override 5. configured TOKEN_SRC URL 6. same-origin /api/maps/token
|
||||
// TOKEN_SRC is treated as DATA, never eval'd: the literal "window.MAP_TOKEN_URL"
|
||||
// means "read that global"; anything else is used as a URL — so both a global
|
||||
// name and a plain URL are safe (no SyntaxError footgun).
|
||||
(function () {
|
||||
var slot = document.getElementById('slot');
|
||||
var status = document.getElementById('status');
|
||||
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"posterverse","provider":"maplibre","instanceId":"posterverse-app","heading":"Posterverse"};
|
||||
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
|
||||
// the ladder below is skipped entirely and the element mounts immediately.
|
||||
// (A leaf whose engine is MapLibre at RUNTIME joins them — see PROVIDER below.)
|
||||
var TOKEN_FREE = true;
|
||||
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
|
||||
// rather than this page's profile-config. With no baked token (the public copy), it must get
|
||||
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.
|
||||
var NESTS_MAPS = false;
|
||||
var TOKEN_SRC = "window.MAP_TOKEN_URL";
|
||||
var BAKED_TOKEN = "";
|
||||
// Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts
|
||||
// a map component directly, with no ProfileLoader/profile-config wrapper) still finds a token —
|
||||
// useMapInstance falls back to window.MAP_TOKEN. No effect when BAKED_TOKEN is empty.
|
||||
// A token the HOST set before this page ran outranks the baked one (measured: it used to lose).
|
||||
var HOST_TOKEN = (typeof window.MAP_TOKEN === 'string' && window.MAP_TOKEN) || '';
|
||||
if (BAKED_TOKEN && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = BAKED_TOKEN; }
|
||||
var DEFAULT_TOKEN_URL = '/api/maps/token';
|
||||
|
||||
function fail(msg) { if (status) { status.className = 'err'; status.textContent = '⚠ ' + msg; } }
|
||||
// No token anywhere on the ladder → ask ONCE, store locally, remount. No dead ends.
|
||||
function askToken() {
|
||||
if (!status) return;
|
||||
status.className = 'err';
|
||||
status.innerHTML = 'map needs a Mapbox token — ' +
|
||||
'<input id="tkIn" type="password" placeholder="paste pk.… (stays in this browser)" ' +
|
||||
'style="font:12px ui-monospace,monospace;padding:6px 8px;border-radius:6px;border:1px solid #223047;background:#0e1623;color:#eef2f7;width:min(320px,70vw)"> ' +
|
||||
'<button id="tkGo" style="font:700 11px ui-monospace,monospace;padding:7px 12px;border-radius:999px;border:0;cursor:pointer;background:linear-gradient(90deg,#46d4ff,#b9a6f5);color:#0a0e15">use it</button>';
|
||||
var go = document.getElementById('tkGo');
|
||||
if (go) go.addEventListener('click', function () {
|
||||
var v = (document.getElementById('tkIn').value || '').trim();
|
||||
if (!v) return;
|
||||
try { localStorage.setItem('MAPS_STAGING_TK', v); } catch (e) {}
|
||||
mount(v);
|
||||
});
|
||||
}
|
||||
/* ★ RELEASE THE LOAD BACKDROP WHEN THE ELEMENT MOUNTS (BUG-GOSPEL-MAP-BLACK-RECTANGLE).
|
||||
html/body carry background:#0a0e15 so the light #status text (#9aa7b8) is readable while the
|
||||
bundle loads. That backdrop is LOAD-STATE CHROME, but it outlived its moment: any gap the
|
||||
mounted element does not cover shows it through, because every layer above body is
|
||||
transparent. On gospel-resources/resources-map that produced a 352x126 DARK RECTANGLE between
|
||||
the legend card and the detail panel — MEASURED rgb(8,12,18), i.e. the page background, NOT an
|
||||
unpainted canvas as first supposed. Clearing it here (the one place we already know the
|
||||
element is up) is a one-shot style write — no resize, no repaint loop. Keep it a one-shot
|
||||
write: do NOT turn this into a per-frame or per-event handler (a resize loop strobes the screen). */
|
||||
function done() {
|
||||
if (status && status.parentNode) { status.parentNode.removeChild(status); }
|
||||
document.documentElement.style.background = '#f3f3f1';
|
||||
document.body.style.background = '#f3f3f1';
|
||||
}
|
||||
|
||||
function mount(token) {
|
||||
if (!token && !TOKEN_FREE) { return askToken(); }
|
||||
// Nested maps (a composite's children) read window.MAP_TOKEN, not this element's config.
|
||||
if (token && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = token; }
|
||||
try {
|
||||
var cfg = token ? Object.assign({}, BASE_CONFIG, { tk: token }) : BASE_CONFIG;
|
||||
var el = document.createElement("posterverse-app");
|
||||
el.setAttribute('profile-config', JSON.stringify(cfg));
|
||||
slot.innerHTML = '';
|
||||
slot.appendChild(el);
|
||||
done();
|
||||
} catch (e) { fail(e && e.message ? e.message : String(e)); }
|
||||
}
|
||||
|
||||
function qp(name) {
|
||||
try { return new URLSearchParams(location.search).get(name) || ''; }
|
||||
catch (e) { return ''; }
|
||||
}
|
||||
|
||||
function extractToken(data) {
|
||||
if (typeof data === 'string') return data.trim();
|
||||
if (data && typeof data === 'object') return data.token || data.tk || data.accessToken || '';
|
||||
return '';
|
||||
}
|
||||
|
||||
// Rungs 1-3b: an actual token → mount directly, no fetch.
|
||||
// 2b = localStorage: the paste-once store shared with the staging pages
|
||||
// (MAPS_STAGING_TK) — paste a token ONCE anywhere in the tree, every page uses it.
|
||||
var stored = '';
|
||||
try { stored = localStorage.getItem('MAPS_STAGING_TK') || ''; } catch (e) {}
|
||||
var urlTk = qp('tk');
|
||||
if (urlTk) { try { localStorage.setItem('MAPS_STAGING_TK', urlTk); } catch (e) {} }
|
||||
var directToken = (BASE_CONFIG && BASE_CONFIG.tk) || urlTk || stored || HOST_TOKEN || BAKED_TOKEN;
|
||||
// PROVIDER — the same ladder as resolveMapProvider (library/composables/useMapProvider.js):
|
||||
// profile-config.provider → ?provider= → window.MAP_PROVIDER → 'auto'. An explicit engine wins,
|
||||
// and 'auto' draws with MapLibre whether or not a token is available (the element decides that).
|
||||
// This page only decides whether to FIND a token first: when one is on offer (a rung above, a
|
||||
// host-set window.MAP_TOKEN, or a token endpoint) it resolves it, since place search uses it on
|
||||
// either engine; with none, it mounts at once, keyless: no token prompt, no request to Mapbox.
|
||||
var ASKED = String((BASE_CONFIG && BASE_CONFIG.provider) || qp('provider') || window.MAP_PROVIDER || '').toLowerCase().trim();
|
||||
var EXPLICIT = (ASKED === 'mapbox' || ASKED === 'maplibre') ? ASKED : '';
|
||||
var HAS_TOKEN = !!(directToken || (typeof window.MAP_TOKEN === 'string' && window.MAP_TOKEN));
|
||||
var HAS_ENDPOINT = !!(window.MAP_TOKEN_URL || (TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL'));
|
||||
if (EXPLICIT === 'maplibre' || (!EXPLICIT && !HAS_TOKEN && !HAS_ENDPOINT)) { TOKEN_FREE = true; }
|
||||
if (TOKEN_FREE) {
|
||||
if (directToken && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = directToken; }
|
||||
mount('');
|
||||
// A token-free composite with no token anywhere: its content renders, but its nested maps
|
||||
// cannot. Ask once; the token is stored for the whole tree and the page reloads with it.
|
||||
// Only when Mapbox was ASKED for: under 'auto' the nested maps draw with MapLibre instead.
|
||||
if (NESTS_MAPS && !directToken && EXPLICIT === 'mapbox') {
|
||||
var bar = document.createElement('div');
|
||||
bar.id = 'status';
|
||||
bar.setAttribute('style', 'position:fixed;left:12px;right:12px;bottom:12px;z-index:2147483000;padding:10px 12px;border-radius:10px;background:#0e1623;color:#eef2f7;font:13px system-ui,sans-serif;box-shadow:0 6px 24px rgba(0,0,0,.25)');
|
||||
document.body.appendChild(bar);
|
||||
status = bar;
|
||||
askToken();
|
||||
var goBtn = document.getElementById('tkGo');
|
||||
if (goBtn) goBtn.addEventListener('click', function () {
|
||||
var v = (document.getElementById('tkIn').value || '').trim();
|
||||
if (v) { location.reload(); }
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (directToken) { return mount(directToken); }
|
||||
|
||||
// Rungs 4-6: resolve the token ENDPOINT candidates and try them IN ORDER — the first
|
||||
// one that answers with a token wins. Order: window.MAP_TOKEN_URL override → the
|
||||
// configured TOKEN_SRC (an ABSOLUTE url, so a tree hosted on a CDN away from the API
|
||||
// still finds it) → same-origin /api/maps/token (dev, or the site hosting both).
|
||||
// A single hard-coded pick used to kill the page when its one url 404'd or was
|
||||
// blocked by CORS; a shared link must work wherever the tree is hosted.
|
||||
var candidates = [];
|
||||
function addCandidate(u) { if (u && candidates.indexOf(u) === -1) candidates.push(u); }
|
||||
addCandidate(window.MAP_TOKEN_URL);
|
||||
addCandidate(TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL' ? TOKEN_SRC : '');
|
||||
addCandidate(DEFAULT_TOKEN_URL);
|
||||
|
||||
function fetchToken(i, errors) {
|
||||
if (i >= candidates.length) {
|
||||
// Every endpoint failed. Under 'auto' that means no token: draw with MapLibre, keyless.
|
||||
if (!EXPLICIT) { TOKEN_FREE = true; return mount(''); }
|
||||
// Mapbox was asked for: say which endpoints failed, and still offer the paste-once box.
|
||||
askToken();
|
||||
if (status) {
|
||||
var d = document.createElement('div');
|
||||
d.textContent = '⚠ Token fetch failed from ' + candidates.join(', ') + ' — set window.MAP_TOKEN_URL or pass ?tk=. (' + errors.join(' | ') + ')';
|
||||
status.insertBefore(d, status.firstChild);
|
||||
}
|
||||
return;
|
||||
}
|
||||
var url = candidates[i];
|
||||
return fetch(url)
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error(url + ' → HTTP ' + r.status);
|
||||
var ct = r.headers.get('content-type') || '';
|
||||
return ct.indexOf('json') !== -1 ? r.json() : r.text();
|
||||
})
|
||||
.then(function (data) {
|
||||
var tk = extractToken(data);
|
||||
if (!tk) throw new Error(url + ' → no token in response');
|
||||
mount(tk);
|
||||
})
|
||||
.catch(function (e) {
|
||||
errors.push(e && e.message ? e.message : String(e));
|
||||
return fetchToken(i + 1, errors);
|
||||
});
|
||||
}
|
||||
fetchToken(0, []);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
250
public/dx-maps/posterverse/posterverse/index.html
Normal file
250
public/dx-maps/posterverse/posterverse/index.html
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- THIS PAGE IS OURS. The only thing that knows a document belongs to the plugin is the page
|
||||
the generator writes, so it says so here. library/i18n reads it before reflecting an RTL
|
||||
locale on <html dir>; nothing else sets it, so a PARTNER page defaults to "not ours" and
|
||||
is never touched. Absence must mean restraint — an embedded plugin flipping a partner's
|
||||
whole <html> because a reader followed a ?lang=ar link is the bug this prevents. -->
|
||||
<script>window.MAP_OWNS_PAGE = true;</script>
|
||||
<title>posterverse · posterverse</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
|
||||
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
|
||||
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
|
||||
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->
|
||||
<style>
|
||||
/* BARE full-page map — no chrome. The custom element IS the page, so the
|
||||
browser URL shares the map itself.
|
||||
HEIGHT IN svh, NOT inset:0. A fixed box with inset:0 follows the phone's DYNAMIC viewport, so the map
|
||||
(and a legend sheet sized from it) changes height every time the address bar hides or shows. 100svh is
|
||||
the viewport WITH the bar shown and never changes; when the bar hides, the page background shows below
|
||||
instead of the map resizing. On a desktop svh = the viewport, so nothing moves there. The 100% before it
|
||||
is the fallback for a browser without svh, which would otherwise size the box to its content.
|
||||
--profile-min-h:0 opts out of ProfileLoader's 100dvh floor, which would grow the element again. */
|
||||
html, body { height: 100%; margin: 0; background: #0a0e15; }
|
||||
#slot, posterverse-app { position: fixed; inset: 0 auto auto 0; width: 100%; height: 100%; height: 100svh; --profile-min-h: 0; display: block; }
|
||||
#status { position: fixed; inset: 0; z-index: 2; display: grid; place-items: center;
|
||||
padding: 24px; text-align: center; color: #9aa7b8;
|
||||
font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
#status.err { color: #f87171; }
|
||||
|
||||
/* ── PRINT, BOTH LEAF SHAPES ────────────────────────────────────────────────────────────
|
||||
Two things every generated leaf got wrong on paper, because no generated page has ever
|
||||
carried an @media print rule (measured: 0 across the tree).
|
||||
|
||||
The background is #0a0e15 — a dark page, printed. Browsers drop background colours by
|
||||
default, but a reader who ticks "Background graphics" to get the MAP also gets the page
|
||||
flooded black, and that is the reader who most wants the printout.
|
||||
|
||||
#status is the LOADING OVERLAY: position:fixed, inset:0, over everything. On paper it is
|
||||
a full sheet reading "loading…" above the content that did in fact load. It is hidden
|
||||
only while it is NOT an error — an error still prints, because a blank sheet with the
|
||||
explanation suppressed is worse than a sheet that says what went wrong. */
|
||||
@media print {
|
||||
html, body { height: auto; background: #fff; }
|
||||
#status:not(.err) { display: none; }
|
||||
/* ★ RELEASE THE FIXED HOST, MEASURED (rega, RQ-RGPRINT on a035408e2). The leaf pins both the
|
||||
slot and the element with position:fixed; inset:0. A fixed element is painted once, on the
|
||||
first page, so the printed document was exactly ONE viewport: 900px tall, while the content
|
||||
continued to y=1753. Every plugin whose leaf is taller than a screen has been printing its
|
||||
top screenful and silently dropping the rest — the dashboard printed its stats and its map,
|
||||
then blank to the bottom of the sheet.
|
||||
min-height, not a released height alone: a full-bleed map leaf takes its SIZE from this rule,
|
||||
so height:auto with nothing else can leave the element a zero-height box and print an empty
|
||||
page — a worse failure than the one being fixed, and the discriminator is not isMap
|
||||
(region-dashboards is isMap:true and is a scrolling dashboard). The floor keeps a definite
|
||||
box for the map case while letting a taller document grow onto page two. */
|
||||
#slot, posterverse-app { position: static; inset: auto; width: 100%; height: auto; min-height: 14cm; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="slot"></div>
|
||||
<div id="status">loading…</div>
|
||||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <posterverse-app> custom element. -->
|
||||
<script src="../posterverse.js" integrity="sha384-xwStH6d1rIR+LOJ9iBnJXyNFIMTzSQ51YA2+UhqJgloN4EeH6vYubtHY3JM9j7p0" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
// 1. prop — a token hardcoded into this page's profile-config (config.tk)
|
||||
// 2. ?tk= URL param — token carried in the shared link
|
||||
// 3. baked token — opt-in literal token inlined at build time (build-config.token)
|
||||
// A token from rungs 1-3 is used DIRECTLY (no network). Otherwise a token
|
||||
// *endpoint* is resolved and fetched:
|
||||
// 4. window.MAP_TOKEN_URL override 5. configured TOKEN_SRC URL 6. same-origin /api/maps/token
|
||||
// TOKEN_SRC is treated as DATA, never eval'd: the literal "window.MAP_TOKEN_URL"
|
||||
// means "read that global"; anything else is used as a URL — so both a global
|
||||
// name and a plain URL are safe (no SyntaxError footgun).
|
||||
(function () {
|
||||
var slot = document.getElementById('slot');
|
||||
var status = document.getElementById('status');
|
||||
var BASE_CONFIG = {"profile":"posterverse","dataSource":"pray-tools","instanceId":"posterverse-posterverse","assetsBase":"../../assets"};
|
||||
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
|
||||
// the ladder below is skipped entirely and the element mounts immediately.
|
||||
// (A leaf whose engine is MapLibre at RUNTIME joins them — see PROVIDER below.)
|
||||
var TOKEN_FREE = true;
|
||||
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
|
||||
// rather than this page's profile-config. With no baked token (the public copy), it must get
|
||||
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.
|
||||
var NESTS_MAPS = false;
|
||||
var TOKEN_SRC = "window.MAP_TOKEN_URL";
|
||||
var BAKED_TOKEN = "";
|
||||
// Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts
|
||||
// a map component directly, with no ProfileLoader/profile-config wrapper) still finds a token —
|
||||
// useMapInstance falls back to window.MAP_TOKEN. No effect when BAKED_TOKEN is empty.
|
||||
// A token the HOST set before this page ran outranks the baked one (measured: it used to lose).
|
||||
var HOST_TOKEN = (typeof window.MAP_TOKEN === 'string' && window.MAP_TOKEN) || '';
|
||||
if (BAKED_TOKEN && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = BAKED_TOKEN; }
|
||||
var DEFAULT_TOKEN_URL = '/api/maps/token';
|
||||
|
||||
function fail(msg) { if (status) { status.className = 'err'; status.textContent = '⚠ ' + msg; } }
|
||||
// No token anywhere on the ladder → ask ONCE, store locally, remount. No dead ends.
|
||||
function askToken() {
|
||||
if (!status) return;
|
||||
status.className = 'err';
|
||||
status.innerHTML = 'map needs a Mapbox token — ' +
|
||||
'<input id="tkIn" type="password" placeholder="paste pk.… (stays in this browser)" ' +
|
||||
'style="font:12px ui-monospace,monospace;padding:6px 8px;border-radius:6px;border:1px solid #223047;background:#0e1623;color:#eef2f7;width:min(320px,70vw)"> ' +
|
||||
'<button id="tkGo" style="font:700 11px ui-monospace,monospace;padding:7px 12px;border-radius:999px;border:0;cursor:pointer;background:linear-gradient(90deg,#46d4ff,#b9a6f5);color:#0a0e15">use it</button>';
|
||||
var go = document.getElementById('tkGo');
|
||||
if (go) go.addEventListener('click', function () {
|
||||
var v = (document.getElementById('tkIn').value || '').trim();
|
||||
if (!v) return;
|
||||
try { localStorage.setItem('MAPS_STAGING_TK', v); } catch (e) {}
|
||||
mount(v);
|
||||
});
|
||||
}
|
||||
/* ★ RELEASE THE LOAD BACKDROP WHEN THE ELEMENT MOUNTS (BUG-GOSPEL-MAP-BLACK-RECTANGLE).
|
||||
html/body carry background:#0a0e15 so the light #status text (#9aa7b8) is readable while the
|
||||
bundle loads. That backdrop is LOAD-STATE CHROME, but it outlived its moment: any gap the
|
||||
mounted element does not cover shows it through, because every layer above body is
|
||||
transparent. On gospel-resources/resources-map that produced a 352x126 DARK RECTANGLE between
|
||||
the legend card and the detail panel — MEASURED rgb(8,12,18), i.e. the page background, NOT an
|
||||
unpainted canvas as first supposed. Clearing it here (the one place we already know the
|
||||
element is up) is a one-shot style write — no resize, no repaint loop. Keep it a one-shot
|
||||
write: do NOT turn this into a per-frame or per-event handler (a resize loop strobes the screen). */
|
||||
function done() {
|
||||
if (status && status.parentNode) { status.parentNode.removeChild(status); }
|
||||
document.documentElement.style.background = '#f3f3f1';
|
||||
document.body.style.background = '#f3f3f1';
|
||||
}
|
||||
|
||||
function mount(token) {
|
||||
if (!token && !TOKEN_FREE) { return askToken(); }
|
||||
// Nested maps (a composite's children) read window.MAP_TOKEN, not this element's config.
|
||||
if (token && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = token; }
|
||||
try {
|
||||
var cfg = token ? Object.assign({}, BASE_CONFIG, { tk: token }) : BASE_CONFIG;
|
||||
var el = document.createElement("posterverse-app");
|
||||
el.setAttribute('profile-config', JSON.stringify(cfg));
|
||||
slot.innerHTML = '';
|
||||
slot.appendChild(el);
|
||||
done();
|
||||
} catch (e) { fail(e && e.message ? e.message : String(e)); }
|
||||
}
|
||||
|
||||
function qp(name) {
|
||||
try { return new URLSearchParams(location.search).get(name) || ''; }
|
||||
catch (e) { return ''; }
|
||||
}
|
||||
|
||||
function extractToken(data) {
|
||||
if (typeof data === 'string') return data.trim();
|
||||
if (data && typeof data === 'object') return data.token || data.tk || data.accessToken || '';
|
||||
return '';
|
||||
}
|
||||
|
||||
// Rungs 1-3b: an actual token → mount directly, no fetch.
|
||||
// 2b = localStorage: the paste-once store shared with the staging pages
|
||||
// (MAPS_STAGING_TK) — paste a token ONCE anywhere in the tree, every page uses it.
|
||||
var stored = '';
|
||||
try { stored = localStorage.getItem('MAPS_STAGING_TK') || ''; } catch (e) {}
|
||||
var urlTk = qp('tk');
|
||||
if (urlTk) { try { localStorage.setItem('MAPS_STAGING_TK', urlTk); } catch (e) {} }
|
||||
var directToken = (BASE_CONFIG && BASE_CONFIG.tk) || urlTk || stored || HOST_TOKEN || BAKED_TOKEN;
|
||||
// PROVIDER — the same ladder as resolveMapProvider (library/composables/useMapProvider.js):
|
||||
// profile-config.provider → ?provider= → window.MAP_PROVIDER → 'auto'. An explicit engine wins,
|
||||
// and 'auto' draws with MapLibre whether or not a token is available (the element decides that).
|
||||
// This page only decides whether to FIND a token first: when one is on offer (a rung above, a
|
||||
// host-set window.MAP_TOKEN, or a token endpoint) it resolves it, since place search uses it on
|
||||
// either engine; with none, it mounts at once, keyless: no token prompt, no request to Mapbox.
|
||||
var ASKED = String((BASE_CONFIG && BASE_CONFIG.provider) || qp('provider') || window.MAP_PROVIDER || '').toLowerCase().trim();
|
||||
var EXPLICIT = (ASKED === 'mapbox' || ASKED === 'maplibre') ? ASKED : '';
|
||||
var HAS_TOKEN = !!(directToken || (typeof window.MAP_TOKEN === 'string' && window.MAP_TOKEN));
|
||||
var HAS_ENDPOINT = !!(window.MAP_TOKEN_URL || (TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL'));
|
||||
if (EXPLICIT === 'maplibre' || (!EXPLICIT && !HAS_TOKEN && !HAS_ENDPOINT)) { TOKEN_FREE = true; }
|
||||
if (TOKEN_FREE) {
|
||||
if (directToken && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = directToken; }
|
||||
mount('');
|
||||
// A token-free composite with no token anywhere: its content renders, but its nested maps
|
||||
// cannot. Ask once; the token is stored for the whole tree and the page reloads with it.
|
||||
// Only when Mapbox was ASKED for: under 'auto' the nested maps draw with MapLibre instead.
|
||||
if (NESTS_MAPS && !directToken && EXPLICIT === 'mapbox') {
|
||||
var bar = document.createElement('div');
|
||||
bar.id = 'status';
|
||||
bar.setAttribute('style', 'position:fixed;left:12px;right:12px;bottom:12px;z-index:2147483000;padding:10px 12px;border-radius:10px;background:#0e1623;color:#eef2f7;font:13px system-ui,sans-serif;box-shadow:0 6px 24px rgba(0,0,0,.25)');
|
||||
document.body.appendChild(bar);
|
||||
status = bar;
|
||||
askToken();
|
||||
var goBtn = document.getElementById('tkGo');
|
||||
if (goBtn) goBtn.addEventListener('click', function () {
|
||||
var v = (document.getElementById('tkIn').value || '').trim();
|
||||
if (v) { location.reload(); }
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (directToken) { return mount(directToken); }
|
||||
|
||||
// Rungs 4-6: resolve the token ENDPOINT candidates and try them IN ORDER — the first
|
||||
// one that answers with a token wins. Order: window.MAP_TOKEN_URL override → the
|
||||
// configured TOKEN_SRC (an ABSOLUTE url, so a tree hosted on a CDN away from the API
|
||||
// still finds it) → same-origin /api/maps/token (dev, or the site hosting both).
|
||||
// A single hard-coded pick used to kill the page when its one url 404'd or was
|
||||
// blocked by CORS; a shared link must work wherever the tree is hosted.
|
||||
var candidates = [];
|
||||
function addCandidate(u) { if (u && candidates.indexOf(u) === -1) candidates.push(u); }
|
||||
addCandidate(window.MAP_TOKEN_URL);
|
||||
addCandidate(TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL' ? TOKEN_SRC : '');
|
||||
addCandidate(DEFAULT_TOKEN_URL);
|
||||
|
||||
function fetchToken(i, errors) {
|
||||
if (i >= candidates.length) {
|
||||
// Every endpoint failed. Under 'auto' that means no token: draw with MapLibre, keyless.
|
||||
if (!EXPLICIT) { TOKEN_FREE = true; return mount(''); }
|
||||
// Mapbox was asked for: say which endpoints failed, and still offer the paste-once box.
|
||||
askToken();
|
||||
if (status) {
|
||||
var d = document.createElement('div');
|
||||
d.textContent = '⚠ Token fetch failed from ' + candidates.join(', ') + ' — set window.MAP_TOKEN_URL or pass ?tk=. (' + errors.join(' | ') + ')';
|
||||
status.insertBefore(d, status.firstChild);
|
||||
}
|
||||
return;
|
||||
}
|
||||
var url = candidates[i];
|
||||
return fetch(url)
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error(url + ' → HTTP ' + r.status);
|
||||
var ct = r.headers.get('content-type') || '';
|
||||
return ct.indexOf('json') !== -1 ? r.json() : r.text();
|
||||
})
|
||||
.then(function (data) {
|
||||
var tk = extractToken(data);
|
||||
if (!tk) throw new Error(url + ' → no token in response');
|
||||
mount(tk);
|
||||
})
|
||||
.catch(function (e) {
|
||||
errors.push(e && e.message ? e.message : String(e));
|
||||
return fetchToken(i + 1, errors);
|
||||
});
|
||||
}
|
||||
fetchToken(0, []);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -76,7 +76,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <research-dashboard-map> custom element. -->
|
||||
<script src="../research-dashboard.js" integrity="sha384-coB77fbeu9HkeixR1EKTfAFsBFDGrPcXevrU1wSM8oy1rEuB7tOUgAPH7XfQj+x0" crossorigin="anonymous"></script>
|
||||
<script src="../research-dashboard.js" integrity="sha384-NXlQlksDe95yrPiFqTkiIAc7pE+MW30uohIV/2Qlops26l+YKZCeo9q+PQJY8I6/" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -67,7 +67,7 @@
|
|||
|
||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||
<!-- Bundle IIFE — registers the <wall-of-people-groups> custom element. -->
|
||||
<script src="../wall-of-people-groups.js" integrity="sha384-jXcu2Fpvms5zKTuyxRQ1oZG7jciYNwC1+KOpUYXMlm5u59l3SGJOLxN3vbXvt5c1" crossorigin="anonymous"></script>
|
||||
<script src="../wall-of-people-groups.js" integrity="sha384-+0FF2Yam1UsHfo2turc9M9IORYEfg/eKrOjl3VLUymNUK7P8/AnL8/p9zlrf/NEh" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||
|
|
|
|||
Loading…
Reference in a new issue