diff --git a/internal/check-packet-variants.mjs b/internal/check-packet-variants.mjs index 8f79b9c96..d7dcf0d41 100644 --- a/internal/check-packet-variants.mjs +++ b/internal/check-packet-variants.mjs @@ -58,7 +58,16 @@ const groups = Array.from({ length: 45 }, (_, i) => ({ slug: `g${i}`, name: `Gro const zone = { id: 'test', label: 'Test Zone' } const funnel = { steps: [{ label: 'a', groups: 12379 }, { label: 'b', groups: 2107 }] } const christian = { pctEvangelical: 3.2 } -const build = (v) => R.buildPacket(zone, groups, new Map(), '', funnel, christian, v) +// ★ THE SETS MUST BE SUPPLIED OR THIS GATE CANNOT SEE THE PAGE. setsPage returns '' without data — +// correctly, so a host with no set data still gets a valid packet — which means a build() that +// omits `sets` exercises the variant filter for every page EXCEPT the one most recently added. The +// gate stayed green through the whole sets rewrite while asserting a 2-sheet delta that had become +// 3. A fixture that is missing an input silently narrows what the whole file covers. +const sets = { + bounded: Array.from({ length: 30 }, (_, i) => ({ id: `b${i}`, reached: i % 3 === 0 ? true : (i % 3 === 1 ? false : undefined) })), + centered: Array.from({ length: 24 }, (_, i) => ({ id: `c${i}`, ring: (i % 12) - 1 })), +} +const build = (v) => R.buildPacket(zone, groups, new Map(), '', funnel, christian, v, sets) const count = (html, re) => (html.match(re) || []).length const full = build('full') @@ -74,8 +83,24 @@ ok(count(light, /sheet-life/g) === 0, 'lightweight still carries the life calend ok(count(light, /sheet-christian/g) === 0, 'lightweight still carries the %-evangelical page') // …and keeps the triangle, the profiles, the wheel and the points. ok(count(light, /funnel-title/g) === count(full, /funnel-title/g), 'lightweight lost the funnel (the Driver\'s "triangle")') -ok(count(full, /class="sheet/g) - count(light, /class="sheet/g) === 2, - `lightweight should be exactly 2 sheets shorter, got ${count(full, /class="sheet/g)} vs ${count(light, /class="sheet/g)}`) +// …and the SETS page, which is back matter a reader sits with rather than carries — the same +// reason the life calendar and the %-evangelical grid are dropped. +ok(count(full, /sheet-sets/g) === 1, 'the full packet has no bounded/centered sets page') +ok(count(light, /sheet-sets/g) === 0, 'lightweight still carries the sets page') +// ⚠ THREE, not two: the delta grew when the sets page landed, and this number is meant to be +// UPDATED in the commit that adds a page, never relaxed into a range. A tolerance here would make +// the assertion unable to see the next page that goes missing. +ok(count(full, /class="sheet/g) - count(light, /class="sheet/g) === 3, + `lightweight should be exactly 3 sheets shorter, got ${count(full, /class="sheet/g)} vs ${count(light, /class="sheet/g)}`) +// ★ UNKNOWN POINTS ARE STATED EVEN THOUGH THEY CANNOT BE PLOTTED. layoutBoundedSet gives them no +// x/y — deliberately, since `reached` undefined means we do not know which side they belong on and +// drawing them anywhere would assert exactly what is unknown. On a printed page there is no hover, +// so a category the reader cannot see reads as zero. It must appear in the tally text. +{ + const caps = full.match(/class="set-cap">([^<]*)${esc(t('points_attrib'))}`); } + // ── PAGE n+1: the BOUNDED and CENTERED sets ───────────────────────────────── + // Brief A1 BACK MATTER: after the profiles, before the wheel. FULL DETAIL ONLY — the lightweight + // packet is map · triangle · profiles · wheel · points, and these two figures are the part a + // reader sits with rather than carries. + // + // The LAYOUTS come from regions' library/components/charts modules, imported directly rather than + // injected: unlike describeGroup's country resolver they have ZERO imports and run under plain + // node (verified by importing and calling them, not by reading them), and packet-render already + // imports scatterCells from that same directory. They return DATA — points with x/y in unitless + // SVG user coordinates centred on (cx,cy) — so the markup below is ours. + // + // ⚠ GOSPEL'S NON-NEGOTIABLE, carried through regions: the bounded set keeps a deliberate gap + // either side of the boundary (inside within 0.92r, outside beyond 1.12r) because a dot sitting ON + // the line is the one thing a bounded set cannot express, and PRINT HAS NO HOVER to disambiguate + // it. If the figure does not fit, SHRINK THE WHOLE FIGURE — never compress the gap. Measured on + // the real module with 40 groups: inside max 85.3 of 92 allowed, outside min 119.8 of 112 + // required. The scale below is applied to the viewBox, so it cannot touch the gap. + function setsPage(zone, sets) { + // '' WITHOUT DATA, exactly like funnelPage and christianPage — a page that cannot state true + // figures must not appear at all. This is what keeps FULL valid before a host supplies sets. + if (!sets || (!Array.isArray(sets.bounded) && !Array.isArray(sets.centered))) return ''; + const bounded = Array.isArray(sets.bounded) ? sets.bounded : []; + const centered = Array.isArray(sets.centered) ? sets.centered : []; + if (!bounded.length && !centered.length) return ''; + + const usableW = W - 2 * M, colW = (usableW - 60) / 2; + const parts = []; + + if (bounded.length) { + const L = layoutBoundedSet(bounded, { r: 100, spread: 220 }); + const T = boundedSetTally(L); + // The viewBox is sized from the layout's own extent so the figure scales to the column + // without any coordinate being touched — the gap survives by construction. + // ⚠ UNKNOWN POINTS CARRY NO x/y, AND THAT IS THE MODULE BEING CORRECT. `reached` undefined + // means we do not know which side of the boundary a people belongs on, so there is no honest + // place to draw it — plotting it anywhere would assert the very thing that is unknown. My + // first version mapped over L.unknown with the same dot() helper and crashed on + // `p.x.toFixed`, which is a better outcome than silently drawing them at (0,0) inside the + // circle. They are STATED in the tally instead (regions: an un-inked category on a printed + // page, where there is no hover, reads as zero). + const plotted = [...L.inside, ...L.outside]; + const ext = Math.max(...plotted.map((p) => Math.hypot(p.x - L.cx, p.y - L.cy)), L.r * 1.2) * 1.08; + const dot = (p, cls) => ``; + parts.push(`
+

${esc(t('sets_bounded_title'))}

+ + + ${L.inside.map((p) => dot(p, 'set-in')).join('')} + ${L.outside.map((p) => dot(p, 'set-out')).join('')} + +

${esc(fill(t('sets_bounded_tally'), + { inside: num(T.inside), outside: num(T.outside), unknown: num(T.unknown) }))}

+
`); + } + + if (centered.length) { + const L = layoutCenteredSet(centered, {}); + const T = ringTally(L); + const ext = (L.innerR + RING_MAX * L.ringGap) * 1.12; + parts.push(`
+

${esc(t('sets_centered_title'))}

+ + ${(L.radii || []).map((r) => ``).join('')} + ${L.placed.map((p) => ``).join('')} + + +

${esc(fill(t('sets_centered_tally'), + { placed: num(T.placed), centre: num(T.centre), unplaced: num(T.unplaced) }))}

+
`); + } + + return sheet(` +
${esc(t('sets_kicker'))}
+

${esc(t('sets_title'))}

+

${esc(t('sets_lede'))}

+
${parts.join('')}
+ `, 'sheet-sets'); + } + // ── PAGE n+2: the prayer wheel ────────────────────────────────────────────── // REDRAWN as SVG rather than embedding the source PDF page: a raster embed cannot // be translated, and this packet must generate in any language. Twelve equal @@ -845,10 +950,15 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de // never drop a page whose count depends on the data. const VARIANTS = { full: null, // null = every page (today's packet, unchanged) - lightweight: new Set(['life', 'christian']), // page KEYS to omit + // page KEYS to omit. 'sets' joins them: the bounded/centered figures are BACK MATTER a reader + // sits with, which is the same reason the life calendar and the %-evangelical grid are dropped. + lightweight: new Set(['life', 'christian', 'sets']), }; - function buildPacket(zone, groups, photos, mapSvg, funnel = null, christian = null, variant = 'full') { + // `sets` is the EIGHTH parameter and the last positional one this signature should ever grow — it + // is already at the edge of readable. A ninth belongs in an options object; flagged here so the + // next person adding a page makes that change rather than extending the line again. + function buildPacket(zone, groups, photos, mapSvg, funnel = null, christian = null, variant = 'full', sets = null) { // ★ PAGE ORDER IS THE DRIVER'S, 2026-09-27, and it is an argument rather than a layout: // 1 map — where this zone is // 2 life calendar — 100 years x 52 weeks, a whole human life on one page @@ -872,6 +982,8 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de keep('funnel') && funnelPage(zone, groups, funnel), // NOT behind keep() — see the VARIANTS comment: dropping list pages drops people. ...listPages(zone, groups, photos), + // BACK MATTER: after the profiles, before the wheel (brief A1). Full-detail only. + keep('sets') && setsPage(zone, sets), keep('points') && pointsPage(), keep('wheel') && wheelPage(), ].filter(Boolean); diff --git a/library/scripts/posters/packet-strings.json b/library/scripts/posters/packet-strings.json index eecc163e9..cfaa4b56e 100644 --- a/library/scripts/posters/packet-strings.json +++ b/library/scripts/posters/packet-strings.json @@ -211,7 +211,14 @@ "desc_gsec_3": "Fewer than 2% are evangelical, and scattered church planting has begun among them.", "desc_gsec_4": "Between 2% and 5% are evangelical.", "desc_gsec_5": "Between 5% and 10% are evangelical.", - "desc_gsec_6": "More than 10% are evangelical." + "desc_gsec_6": "More than 10% are evangelical.", + "sets_kicker": "Who is near, and who is not", + "sets_title": "Two ways to see the same people", + "sets_lede": "The circle asks whether a people has been reached. The rings ask how far they sit from someone who could reach them.", + "sets_bounded_title": "Reached, and not", + "sets_centered_title": "Distance from a neighbour", + "sets_bounded_tally": "{inside} inside · {outside} outside · {unknown} unknown", + "sets_centered_tally": "{placed} placed · {centre} at the centre · {unplaced} unplaced" }, "es": { "map_page_kicker": "GRUPOS ÉTNICOS NO EVANGELIZADOS", @@ -417,7 +424,14 @@ "desc_gsec_3": "Menos del 2% evangélico, y entre ellos ha comenzado una plantación de iglesias dispersa.", "desc_gsec_4": "Entre el 2% y el 5% son evangélicos.", "desc_gsec_5": "Entre el 5% y el 10% son evangélicos.", - "desc_gsec_6": "Más del 10% son evangélicos." + "desc_gsec_6": "Más del 10% son evangélicos.", + "sets_kicker": "Quién está cerca y quién no", + "sets_title": "Dos maneras de ver al mismo pueblo", + "sets_lede": "El círculo pregunta si un pueblo ha sido alcanzado. Los anillos preguntan a qué distancia está de alguien que podría alcanzarlo.", + "sets_bounded_title": "Alcanzados y no alcanzados", + "sets_centered_title": "Distancia a un vecino", + "sets_bounded_tally": "{inside} dentro · {outside} fuera · {unknown} desconocido", + "sets_centered_tally": "{placed} ubicados · {centre} en el centro · {unplaced} sin ubicar" }, "fr": { "map_page_kicker": "GROUPES ETHNIQUES NON ATTEINTS", @@ -623,7 +637,14 @@ "desc_gsec_3": "Moins de 2% d’évangéliques, et une implantation d’Églises dispersée a commencé parmi eux.", "desc_gsec_4": "Entre 2% et 5% sont évangéliques.", "desc_gsec_5": "Entre 5% et 10% sont évangéliques.", - "desc_gsec_6": "Plus de 10% sont évangéliques." + "desc_gsec_6": "Plus de 10% sont évangéliques.", + "sets_kicker": "Qui est proche, et qui ne l’est pas", + "sets_title": "Deux façons de voir le même peuple", + "sets_lede": "Le cercle demande si un peuple a été atteint. Les anneaux demandent à quelle distance il se trouve de quelqu’un qui pourrait l’atteindre.", + "sets_bounded_title": "Atteints, ou non", + "sets_centered_title": "Distance d’un voisin", + "sets_bounded_tally": "{inside} à l’intérieur · {outside} à l’extérieur · {unknown} inconnu", + "sets_centered_tally": "{placed} placés · {centre} au centre · {unplaced} non placés" }, "pt": { "map_page_kicker": "GRUPOS ÉTNICOS NÃO ALCANÇADOS", @@ -827,7 +848,14 @@ "desc_gsec_3": "Menos de 2% evangélicos, e uma plantação de igrejas dispersa começou entre eles.", "desc_gsec_4": "Entre 2% e 5% são evangélicos.", "desc_gsec_5": "Entre 5% e 10% são evangélicos.", - "desc_gsec_6": "Mais de 10% são evangélicos." + "desc_gsec_6": "Mais de 10% são evangélicos.", + "sets_kicker": "Quem está perto e quem não está", + "sets_title": "Duas formas de ver o mesmo povo", + "sets_lede": "O círculo pergunta se um povo foi alcançado. Os anéis perguntam a que distância ele está de alguém que poderia alcançá-lo.", + "sets_bounded_title": "Alcançados e não alcançados", + "sets_centered_title": "Distância de um vizinho", + "sets_bounded_tally": "{inside} dentro · {outside} fora · {unknown} desconhecido", + "sets_centered_tally": "{placed} posicionados · {centre} no centro · {unplaced} sem posição" }, "de": { "map_page_kicker": "UNERREICHTE VOLKSGRUPPEN", @@ -1033,7 +1061,14 @@ "desc_gsec_3": "Unter 2% evangelikal, und vereinzelt hat unter ihnen die Gemeindegründung begonnen.", "desc_gsec_4": "Zwischen 2% und 5% sind evangelikal.", "desc_gsec_5": "Zwischen 5% und 10% sind evangelikal.", - "desc_gsec_6": "Mehr als 10% sind evangelikal." + "desc_gsec_6": "Mehr als 10% sind evangelikal.", + "sets_kicker": "Wer ist nah, und wer nicht", + "sets_title": "Zwei Blicke auf dasselbe Volk", + "sets_lede": "Der Kreis fragt, ob ein Volk erreicht wurde. Die Ringe fragen, wie weit es von jemandem entfernt ist, der es erreichen könnte.", + "sets_bounded_title": "Erreicht und nicht erreicht", + "sets_centered_title": "Abstand zu einem Nachbarn", + "sets_bounded_tally": "{inside} innen · {outside} außen · {unknown} unbekannt", + "sets_centered_tally": "{placed} platziert · {centre} in der Mitte · {unplaced} ohne Platz" }, "it": { "map_page_kicker": "GRUPPI ETNICI NON RAGGIUNTI", @@ -1237,7 +1272,14 @@ "desc_gsec_3": "Meno del 2% evangelici, e tra loro è iniziata una fondazione di chiese sparsa.", "desc_gsec_4": "Tra il 2% e il 5% sono evangelici.", "desc_gsec_5": "Tra il 5% e il 10% sono evangelici.", - "desc_gsec_6": "Più del 10% sono evangelici." + "desc_gsec_6": "Più del 10% sono evangelici.", + "sets_kicker": "Chi è vicino e chi no", + "sets_title": "Due modi di vedere lo stesso popolo", + "sets_lede": "Il cerchio chiede se un popolo è stato raggiunto. Gli anelli chiedono quanto dista da qualcuno che potrebbe raggiungerlo.", + "sets_bounded_title": "Raggiunti e non raggiunti", + "sets_centered_title": "Distanza da un vicino", + "sets_bounded_tally": "{inside} dentro · {outside} fuori · {unknown} sconosciuto", + "sets_centered_tally": "{placed} collocati · {centre} al centro · {unplaced} non collocati" }, "ro": { "map_page_kicker": "GRUPURI ETNICE NEATINSE", @@ -1441,7 +1483,14 @@ "desc_gsec_3": "Sub 2% evanghelici, iar printre ei a început o plantare de biserici răzleață.", "desc_gsec_4": "Între 2% și 5% sunt evanghelici.", "desc_gsec_5": "Între 5% și 10% sunt evanghelici.", - "desc_gsec_6": "Peste 10% sunt evanghelici." + "desc_gsec_6": "Peste 10% sunt evanghelici.", + "sets_kicker": "Cine este aproape și cine nu", + "sets_title": "Două feluri de a vedea același popor", + "sets_lede": "Cercul întreabă dacă un popor a fost atins. Inelele întreabă cât de departe este de cineva care l-ar putea atinge.", + "sets_bounded_title": "Atinse și neatinse", + "sets_centered_title": "Distanța față de un vecin", + "sets_bounded_tally": "{inside} înăuntru · {outside} în afară · {unknown} necunoscut", + "sets_centered_tally": "{placed} plasate · {centre} în centru · {unplaced} neplasate" }, "ru": { "map_page_kicker": "НЕОХВАЧЕННЫЕ НАРОДЫ", @@ -1647,7 +1696,14 @@ "desc_gsec_3": "Менее 2% евангельских верующих, и среди них местами началось основание церквей.", "desc_gsec_4": "От 2% до 5% — евангельские верующие.", "desc_gsec_5": "От 5% до 10% — евангельские верующие.", - "desc_gsec_6": "Более 10% — евангельские верующие." + "desc_gsec_6": "Более 10% — евангельские верующие.", + "sets_kicker": "Кто рядом, а кто нет", + "sets_title": "Два взгляда на один народ", + "sets_lede": "Круг спрашивает, достигнут ли народ. Кольца спрашивают, как далеко он от того, кто мог бы его достичь.", + "sets_bounded_title": "Достигнутые и нет", + "sets_centered_title": "Расстояние до соседа", + "sets_bounded_tally": "{inside} внутри · {outside} снаружи · {unknown} неизвестно", + "sets_centered_tally": "{placed} размещено · {centre} в центре · {unplaced} без места" }, "ar": { "map_page_kicker": "شعوب غير مبلَّغة", @@ -1853,7 +1909,14 @@ "desc_gsec_3": "أقل من 2% إنجيليين، وقد بدأ زرع متفرق للكنائس بينهم.", "desc_gsec_4": "ما بين 2% و5% إنجيليون.", "desc_gsec_5": "ما بين 5% و10% إنجيليون.", - "desc_gsec_6": "أكثر من 10% إنجيليون." + "desc_gsec_6": "أكثر من 10% إنجيليون.", + "sets_kicker": "من قريب ومن ليس كذلك", + "sets_title": "طريقتان لرؤية الشعب نفسه", + "sets_lede": "الدائرة تسأل إن كان الشعب قد وصلته البشارة. والحلقات تسأل كم يبعد عمّن يستطيع الوصول إليه.", + "sets_bounded_title": "وصلتهم البشارة ومن لم تصلهم", + "sets_centered_title": "المسافة إلى جار", + "sets_bounded_tally": "{inside} في الداخل · {outside} في الخارج · {unknown} غير معروف", + "sets_centered_tally": "{placed} موضوعون · {centre} في المركز · {unplaced} بلا موضع" }, "hi": { "map_page_kicker": "अनछुए जनसमूह", @@ -2057,7 +2120,14 @@ "desc_gsec_3": "2% से कम सुसमाचारी, और उनके बीच छिटपुट कलीसिया स्थापना शुरू हो चुकी है।", "desc_gsec_4": "2% से 5% के बीच सुसमाचारी हैं।", "desc_gsec_5": "5% से 10% के बीच सुसमाचारी हैं।", - "desc_gsec_6": "10% से अधिक सुसमाचारी हैं।" + "desc_gsec_6": "10% से अधिक सुसमाचारी हैं।", + "sets_kicker": "कौन निकट है और कौन नहीं", + "sets_title": "एक ही जन को देखने के दो तरीके", + "sets_lede": "वृत्त पूछता है कि क्या किसी जन तक सुसमाचार पहुँचा है। वलय पूछते हैं कि वे उस से कितनी दूर हैं जो उन तक पहुँच सकता है।", + "sets_bounded_title": "पहुँचे और नहीं पहुँचे", + "sets_centered_title": "पड़ोसी से दूरी", + "sets_bounded_tally": "{inside} भीतर · {outside} बाहर · {unknown} अज्ञात", + "sets_centered_tally": "{placed} स्थित · {centre} केंद्र में · {unplaced} अस्थित" }, "zh": { "map_page_kicker": "未得之民", @@ -2261,6 +2331,13 @@ "desc_gsec_3": "福音派不足 2%,他们当中已零星开始植堂。", "desc_gsec_4": "福音派占 2% 至 5%。", "desc_gsec_5": "福音派占 5% 至 10%。", - "desc_gsec_6": "福音派超过 10%。" + "desc_gsec_6": "福音派超过 10%。", + "sets_kicker": "谁在近处,谁不在", + "sets_title": "看同一群人的两种方式", + "sets_lede": "圆圈问的是这个族群是否已被福音触及。同心环问的是他们离能够触及他们的人有多远。", + "sets_bounded_title": "已触及与未触及", + "sets_centered_title": "与邻近族群的距离", + "sets_bounded_tally": "{inside} 圈内 · {outside} 圈外 · {unknown} 未知", + "sets_centered_tally": "{placed} 已定位 · {centre} 在中心 · {unplaced} 未定位" } }