diff --git a/library/components/poster/PosterWall.vue b/library/components/poster/PosterWall.vue index fe3097e40..3b595b094 100644 --- a/library/components/poster/PosterWall.vue +++ b/library/components/poster/PosterWall.vue @@ -471,6 +471,12 @@ const prints = usePosterPrints({ }, stock: () => pickedStock.value, label: (item) => item?.label || item?.id || '', + // The near-neighbours table for the FULL packet's bounded/centered back page — fetched from the + // SAME served asset pool the posters come from, so an embedded copy of this bundle prints the + // same page as the dev tree. null on any failure: the packet prints without the page rather + // than not printing. + sets: () => fetch(`${props.assetsBase}/tables/near-neighbors.json`) + .then((r) => (r.ok ? r.json() : null)).catch(() => null), }) const canPrintRows = computed(() => typeof props.records === 'function') const previewIsControl = computed(() => props.previewAction === 'print') diff --git a/library/composables/usePosterPrints.js b/library/composables/usePosterPrints.js index fcf3524ff..a435b7fc6 100644 --- a/library/composables/usePosterPrints.js +++ b/library/composables/usePosterPrints.js @@ -239,10 +239,16 @@ export function usePosterPrints(opts = {}) { // Node generator passes it (packet.mjs reads data/people-groups/christian-share.json); this // path did not, so buildPacket's christianPage returned '' and a browser print had one sheet // fewer with nothing to say so. Same seam as strings: a host may supply its own figure. - const [{ createPacketRenderer }, strings, christian] = await Promise.all([ + // `nearTable` feeds the FULL packet's bounded/centered sets page. Same seam as strings and + // christian: a host that can reach the served asset pool supplies a loader (opts.sets), and a + // host that cannot simply prints a packet without that page — setsPage's ''-without-data rule. + // .catch(() => null): the sets page is the one page whose absence must never block a print. + const [{ createPacketRenderer }, { buildPacketSets }, strings, christian, nearTable] = await Promise.all([ import('../packet/packet-render.js'), + import('../packet/packetSets.js'), opts.strings ? opts.strings() : import('../scripts/posters/packet-strings.json').then((m) => m.default || m), opts.christian ? opts.christian() : import('../../data/people-groups/christian-share.json').then((m) => m.default || m), + opts.sets ? Promise.resolve().then(() => opts.sets()).catch(() => null) : Promise.resolve(null), ]); // ★ THE DESCRIBER IS COMPOSED HERE AND INJECTED, because this is the BROWSER path and only it // can reach the Vite-only pieces. packet-render.js runs under Node too (the offline generator), @@ -266,7 +272,10 @@ export function usePosterPrints(opts = {}) { data.filter((g) => g.image_url).map((g) => [g.slug, g.image_url]), ); set(item, kind, 'render', `printing ${data.length} people groups`, 70); - openPrintFrame(R.buildPacket(item, data, photos, mapSvg, funnel, christian, variant), { autoprint: true }); + // The join runs over the SAME rows the packet prints, so the sets page cannot describe a + // different population than the profile table beside it. + const sets = nearTable ? buildPacketSets(data, nearTable) : null; + openPrintFrame(R.buildPacket(item, data, photos, mapSvg, funnel, christian, variant, sets), { autoprint: true }); done(item, kind); } catch (e) { fail(item, kind, e); } } diff --git a/library/packet/packetSets.js b/library/packet/packetSets.js new file mode 100644 index 000000000..8ab8a436a --- /dev/null +++ b/library/packet/packetSets.js @@ -0,0 +1,81 @@ +/** + * packetSets — join a packet's people groups to the near-neighbours table and shape the result + * for setsPage's two figures (bounded + centered). + * + * WHY THIS EXISTS: setsPage landed as a renderer capability with NO DATA SUPPLIER — it returns '' + * without data, correctly, which meant the FULL-DETAIL packet had never once printed its + * distinguishing back page in real use. This module is the supplier. It is PURE and has ZERO + * imports, deliberately: the same function runs under Node (the offline generator reads the table + * from disk) and under Vite (the browser path fetches it), so the JOIN cannot fork between the two. + * + * THE TABLE ARRIVES RAW ({ cols, dict, rows }) AND FETCHING STAYS WITH THE CALLER — same seam + * pattern as `strings` and `describe`: each host supplies what it can reach, and this module owns + * only the decode + join. The decode duplicates the shape gospel's nearRows.js reads (which itself + * documents being a copy of upg-neighbor's, pending promotion to library/) — a THIRD reading of one + * format is real debt, flagged rather than hidden: when the loader is promoted, this module's + * decode collapses into it and only the join below remains. + * + * THE RING STATES, as the data owner defines them (nearRows.js:12-17, kept in the same words so + * the two files cannot drift silently): + * 1-10 placed 0 REACHED (the centre) -1 unreached, no relation found + * absent row = the source does not carry the group; counted here as `unmatched` and REPORTED, + * because on a printed page a category the reader cannot see reads as zero. + */ + +/** Decode the raw { cols, dict, rows } table into a Map keyed by slug. */ +export function decodeNearTable(raw) { + if (!raw || !Array.isArray(raw.cols) || !Array.isArray(raw.rows)) return new Map() + const ix = Object.fromEntries(raw.cols.map((c, i) => [c, i])) + const dict = raw.dict || {} + const dec = (r, c) => (c in ix ? (dict[c] ? dict[c][r[ix[c]]] : r[ix[c]]) : undefined) + const bySlug = new Map() + for (const r of raw.rows) { + const slug = dec(r, 'slug') + if (!slug) continue + bySlug.set(String(slug), { + reached: dec(r, 'reached') === 1, + ring: dec(r, 'ring'), + ringNb: dec(r, 'ringNb'), + }) + } + return bySlug +} + +/** + * buildPacketSets(groups, rawTable) -> { bounded, centered, unmatched } | null + * + * bounded: [{ id, label, reached }] reached undefined = the table lacks the group — + * UNKNOWN, which layoutBoundedSet inks as its own + * category rather than guessing a side. + * centered: [{ id, label, ring, neighborId }] ring passed through untouched: 0 goes to the + * centre, -1 to unplaced, both by the layout's own + * rules — mapping them here would duplicate its + * policy and drift. + * Returns null when NOTHING joined: a sets page about none of the packet's groups would be a + * chart of the join failure, not of the people, and setsPage's ''-without-data rule is the + * honest rendering of that. + */ +export function buildPacketSets(groups, rawTable) { + const bySlug = decodeNearTable(rawTable) + if (!bySlug.size || !Array.isArray(groups) || !groups.length) return null + const bounded = [] + const centered = [] + let matched = 0 + for (const g of groups) { + const id = String(g.slug || g.id || '') + if (!id) continue + const label = g.name || id + const row = bySlug.get(id) + if (!row) { + // Unknown side of the boundary — inked, never guessed. No centered entry: a ring is a + // measurement, and an absent row has none to report. + bounded.push({ id, label, reached: undefined }) + continue + } + matched++ + bounded.push({ id, label, reached: row.reached }) + centered.push({ id, label, ring: row.ring, neighborId: row.ringNb == null ? null : String(row.ringNb) }) + } + if (!matched) return null + return { bounded, centered, unmatched: bounded.length - matched } +} diff --git a/library/scripts/posters/packet.mjs b/library/scripts/posters/packet.mjs index 427f0c0be..962b1adfa 100644 --- a/library/scripts/posters/packet.mjs +++ b/library/scripts/posters/packet.mjs @@ -136,6 +136,11 @@ const { createPacketRenderer } = await import( // `describe` seam below. const { describeGroup } = await import( join(__dirname, '..', '..', 'packets', 'describeGroup.js')); +// The sets supplier for the FULL packet's bounded/centered back page. Pure decode+join; the +// TABLE is read from the served asset pool below, same as the browser fetches it, so the two +// paths chart the same rows. +const { buildPacketSets } = await import( + join(__dirname, '..', '..', 'packet', 'packetSets.js')); // SCRIPT FONT, inlined. Some languages need a face the system stack does not // carry — Hindi renders as tofu boxes in Georgia/Palatino/serif. The packet is a // standalone file meant to print offline, so the font is base64'd INTO it rather @@ -404,9 +409,23 @@ async function fetchPhotos(groups, limit = 16) { // inlines base64 so the file is self-contained and printable offline; the browser // hands the renderer plain URLs the page already has). Everything downstream is // the shared renderer, so the two packets cannot drift. +// ★ THE NEAR-NEIGHBOURS TABLE, read once for every zone's sets page. From the SERVED asset pool +// (public/dx-maps/assets/tables/), not a plugin folder — the same copy the browser fetches, so the +// offline packet and a printed-from-the-app packet chart identical rows. A missing table yields +// null and buildPacketSets -> setsPage omit the page rather than failing the run: one way for the +// page to be absent, not two (the FUNNEL/CHRISTIAN pattern above, applied unchanged). +let NEAR_TABLE = null; +try { + NEAR_TABLE = JSON.parse(readFileSync( + join(__dirname, '..', '..', '..', 'public', 'dx-maps', 'assets', 'tables', 'near-neighbors.json'), 'utf8')); +} catch (e) { + console.warn(`⚠ near-neighbors.json unreadable (${e?.message || e}) — every packet will omit the bounded/centered sets page.`); +} + async function buildPacket(zone, groups, mapSvg) { const photos = await fetchPhotos(groups); - const html = R.buildPacket(zone, groups, photos, mapSvg, FUNNEL, CHRISTIAN); + const sets = NEAR_TABLE ? buildPacketSets(groups, NEAR_TABLE) : null; + const html = R.buildPacket(zone, groups, photos, mapSvg, FUNNEL, CHRISTIAN, 'full', sets); const withPhoto = groups.filter((g) => photos.has(g.slug)).length; // ★ COUNT THE SHEETS IN THE EMITTED HTML, do not re-derive them from a formula. The old line // computed `1 + ceil(n/PER_PAGE) + 2` while its own comment claimed the number was "asked of the diff --git a/plugin-designs/my-upg-100-list/upg-100-list.vue b/plugin-designs/my-upg-100-list/upg-100-list.vue index a1ffc81d5..2c7d58706 100644 --- a/plugin-designs/my-upg-100-list/upg-100-list.vue +++ b/plugin-designs/my-upg-100-list/upg-100-list.vue @@ -1367,6 +1367,12 @@ const packetPrints = usePosterPrints({ // Resolved here rather than inside the shared action, which knows nothing about a // bundle's asset layout — the same seam printPacket uses for its map svg. assetsBase: () => resolveAssetsBase(profileConfig.value), + // The near-neighbours table for the FULL packet's bounded/centered back page — fetched from the + // SAME served asset pool the posters come from, so an embedded copy of this bundle prints the + // same page as the dev tree. null on any failure: the packet prints without the page rather + // than not printing. + sets: () => fetch(`${resolveAssetsBase(profileConfig.value)}/tables/near-neighbors.json`) + .then((r) => (r.ok ? r.json() : null)).catch(() => null), downloadPrefix: 'doxa-upg-100', });