Name the rings on the packet's centered set

The figure printed rings without saying what they mean -- honest but thin.
It now names each one with the vocabulary gospel and exp settled, the same
words the screen legend renders, so a printed sheet cannot disagree with
the screen.

COMPOSED FROM band x culture, NOT A FLAT 1..10 TABLE, and that is the
whole design decision. "Same people" in column 1 is provisional on the
Driver's row 89 -- and it appears in rings 1, 4 AND 7. A ten-string table
would need three edits kept in step, which is exactly how two of them end
up saying different things. Composed, it is one string
(ring_culture_same), and if he picks "culture" the ring numbering, the
columns and the colours are all untouched.

Ring 10 stands alone ("Nearby only") because it is not a band-and-culture
reading at all -- it is what is left when no linguistic relation was
found. The gate asserts that separately, and a mutant that composes it
like the other nine fails.

ONLY THE RINGS THAT HOLD SOMEONE. Ten rows of vocabulary beside a figure
is a glossary, not a legend, and a named ring with no dot on it invites
the reader to hunt for something that is not there. ringTally's per-ring
counts drive the list, so it states what this zone actually contains.

Mutation-tested: a wrong band order, listing all ten rings regardless of
count, and composing ring 10 like the others each fail with their own
message. Eight new strings x eleven locales; packet-strings-parity green.
This commit is contained in:
Doxa Maps 2026-09-28 17:23:03 +00:00
parent 3c1d513cc3
commit fccf6ee2fe
3 changed files with 171 additions and 11 deletions

View file

@ -102,6 +102,32 @@ ok(count(full, /class="sheet/g) - count(light, /class="sheet/g) === 3,
ok(/10/.test(caps.join(' ')), 'the bounded tally does not state its unknown count — an un-inked category reads as zero in print')
}
// ── THE RING LEGEND: THE AGREED VOCABULARY, AND ONLY THE RINGS THAT HOLD SOMEONE ────────────────
// ★ The names are COMPOSED from band x culture rather than a flat 1..10 table, because "same
// people" (provisional on the Driver's row 89) appears in rings 1, 4 AND 7 — three cells that a
// flat table would have to keep in step. This asserts the composition produces the vocabulary
// gospel and exp settled, so a printed sheet cannot disagree with the screen legend.
{
const only = (rings) => {
const html = R.buildPacket(zone, groups, new Map(), '<svg/>', funnel, christian, 'full',
{ bounded: [{ id: 'x', reached: true }], centered: rings.map((r, i) => ({ id: `r${i}`, ring: r })) })
return (html.match(/<li><b>[^<]*<\/b>([^<]*)<\/li>/g) || []).map((s) => s.replace(/<[^>]+>/g, '').trim())
}
const all = only([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
ok(all.length === 10, `all ten rings populated should list ten names, got ${all.length}`)
const want = ['Same dialect, same people', 'Same dialect, near culture', 'Same dialect, different culture',
'Same language, same people', 'Same language, near culture', 'Same language, different culture',
'Same family, same people', 'Same family, near culture', 'Same family, different culture', 'Nearby only']
want.forEach((w, i) => ok((all[i] || '').endsWith(w),
`ring ${i + 1} should read ${JSON.stringify(w)}, got ${JSON.stringify(all[i])}`))
// ⚠ ONLY THE POPULATED RINGS. Ten rows of vocabulary beside a figure is a glossary, not a legend,
// and a named ring with no dot invites the reader to hunt for something that is not there.
const sparse = only([1, 1, 3, 7, 7, 7])
ok(sparse.length === 3, `only the populated rings should be listed, got ${sparse.length}: ${JSON.stringify(sparse)}`)
ok(/^2 /.test(sparse[0]) && /^1 /.test(sparse[1]) && /^3 /.test(sparse[2]),
`the legend should carry each ring's own count, got ${JSON.stringify(sparse)}`)
}
// An unknown variant must print the FULL packet. A typo that printed a SHORTER packet would look
// like a successful print.
ok(build('nonsense-typo') === full, 'an unknown variant did not fall back to the full packet')

View file

@ -207,6 +207,11 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
.set-unk{fill:#fff;stroke:#b9b2a0;stroke-width:1.5}
.set-centre{fill:#2f6d4f}
.set-cap{margin:10px 0 0;font:400 15px Georgia,serif;color:#413d35}
/* The ring legend: only the rings this zone actually fills, in ring order. Left-aligned inside
the centred column because a ragged-right list of names reads far better than centred prose. */
.set-rings{margin:10px 0 0;padding:0;list-style:none;text-align:start;
font:400 13px Georgia,serif;color:#6f6a5c;line-height:1.5}
.set-rings b{color:#1b1a17;font-weight:700}
/* ── THE THREE PRAYER COLUMNS, now a HORIZONTAL strip at the end of each row ──────────
They were a vertical stack in the old 683x160 card. In a ${CW}x${CH.toFixed(0)} ROW there is no
@ -820,6 +825,46 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
// 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.
// ★ THE AGREED RING VOCABULARY, COMPOSED — not a flat 1..10 table. Settled between gospel and exp
// and derived from exp's ringFor(), so the numbering cannot drift from the code that produces it;
// the screen legend renders the same words, so a printed sheet cannot disagree with the screen.
// 1 same dialect, same people 6 same language, different culture
// 2 same dialect, near culture 7 same family, same people
// 3 same dialect, different culture 8 same family, near culture
// 4 same language, same people 9 same family, different culture
// 5 same language, near culture 10 nearby only
// ⚠ COMPOSED FROM band x culture BECAUSE ONE CELL IS PROVISIONAL. "same people" vs the brief's
// "same culture" in column 1 is open on the Driver's row 89, and it appears in rings 1, 4 AND 7 —
// so a flat ten-string table would need THREE edits kept in step, which is exactly how two of them
// end up saying different things. Composed, it is ONE string (ring_culture_same) and the ring
// numbering, the columns and the colours are untouched either way.
const BANDS = ['dialect', 'dialect', 'dialect', 'language', 'language', 'language', 'family', 'family', 'family'];
const CULTURES = ['same', 'near', 'different', 'same', 'near', 'different', 'same', 'near', 'different'];
function ringName(n) {
if (n === RING_MAX) return t('ring_band_geography'); // 10 stands alone
const i = n - 1;
if (i < 0 || i >= BANDS.length) return '';
return fill(t('ring_join'), { band: t(`ring_band_${BANDS[i]}`), culture: t(`ring_culture_${CULTURES[i]}`) });
}
// ONLY THE RINGS THAT HOLD SOMEONE. Ten rows of vocabulary beside a figure is a glossary, not a
// legend, and nine of the ten are routinely empty for one zone. Printing a named ring with no
// group on it also invites the reader to look for a dot that is not there. `ringTally` gives the
// per-ring counts, so the legend states what this zone actually contains, in ring order.
function ringLegend(tally) {
const counts = tally && tally.counts;
if (!counts || typeof counts.get !== 'function') return '';
const rows = [];
for (let n = 1; n <= RING_MAX; n++) {
const c = Number(counts.get(n) || 0);
if (!c) continue;
const name = ringName(n);
if (!name) continue;
rows.push(`<li><b>${num(c)}</b> ${esc(name)}</li>`);
}
return rows.length ? `<ul class="set-rings">${rows.join('')}</ul>` : '';
}
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.
@ -871,6 +916,7 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
</svg>
<p class="set-cap">${esc(fill(t('sets_centered_tally'),
{ placed: num(T.placed), centre: num(T.centre), unplaced: num(T.unplaced) }))}</p>
${ringLegend(T)}
</div>`);
}

View file

@ -218,7 +218,15 @@
"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"
"sets_centered_tally": "{placed} placed · {centre} at the centre · {unplaced} unplaced",
"ring_band_dialect": "Same dialect",
"ring_band_language": "Same language",
"ring_band_family": "Same family",
"ring_band_geography": "Nearby only",
"ring_culture_same": "same people",
"ring_culture_near": "near culture",
"ring_culture_different": "different culture",
"ring_join": "{band}, {culture}"
},
"es": {
"map_page_kicker": "GRUPOS ÉTNICOS NO EVANGELIZADOS",
@ -431,7 +439,15 @@
"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"
"sets_centered_tally": "{placed} ubicados · {centre} en el centro · {unplaced} sin ubicar",
"ring_band_dialect": "Mismo dialecto",
"ring_band_language": "Misma lengua",
"ring_band_family": "Misma familia",
"ring_band_geography": "Solo cercanía",
"ring_culture_same": "mismo pueblo",
"ring_culture_near": "cultura cercana",
"ring_culture_different": "cultura distinta",
"ring_join": "{band}, {culture}"
},
"fr": {
"map_page_kicker": "GROUPES ETHNIQUES NON ATTEINTS",
@ -644,7 +660,15 @@
"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"
"sets_centered_tally": "{placed} placés · {centre} au centre · {unplaced} non placés",
"ring_band_dialect": "Même dialecte",
"ring_band_language": "Même langue",
"ring_band_family": "Même famille",
"ring_band_geography": "Proximité seule",
"ring_culture_same": "même peuple",
"ring_culture_near": "culture proche",
"ring_culture_different": "culture différente",
"ring_join": "{band}, {culture}"
},
"pt": {
"map_page_kicker": "GRUPOS ÉTNICOS NÃO ALCANÇADOS",
@ -855,7 +879,15 @@
"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"
"sets_centered_tally": "{placed} posicionados · {centre} no centro · {unplaced} sem posição",
"ring_band_dialect": "Mesmo dialeto",
"ring_band_language": "Mesma língua",
"ring_band_family": "Mesma família",
"ring_band_geography": "Apenas proximidade",
"ring_culture_same": "mesmo povo",
"ring_culture_near": "cultura próxima",
"ring_culture_different": "cultura diferente",
"ring_join": "{band}, {culture}"
},
"de": {
"map_page_kicker": "UNERREICHTE VOLKSGRUPPEN",
@ -1068,7 +1100,15 @@
"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"
"sets_centered_tally": "{placed} platziert · {centre} in der Mitte · {unplaced} ohne Platz",
"ring_band_dialect": "Gleicher Dialekt",
"ring_band_language": "Gleiche Sprache",
"ring_band_family": "Gleiche Sprachfamilie",
"ring_band_geography": "Nur benachbart",
"ring_culture_same": "gleiches Volk",
"ring_culture_near": "verwandte Kultur",
"ring_culture_different": "andere Kultur",
"ring_join": "{band}, {culture}"
},
"it": {
"map_page_kicker": "GRUPPI ETNICI NON RAGGIUNTI",
@ -1279,7 +1319,15 @@
"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"
"sets_centered_tally": "{placed} collocati · {centre} al centro · {unplaced} non collocati",
"ring_band_dialect": "Stesso dialetto",
"ring_band_language": "Stessa lingua",
"ring_band_family": "Stessa famiglia",
"ring_band_geography": "Solo vicinanza",
"ring_culture_same": "stesso popolo",
"ring_culture_near": "cultura vicina",
"ring_culture_different": "cultura diversa",
"ring_join": "{band}, {culture}"
},
"ro": {
"map_page_kicker": "GRUPURI ETNICE NEATINSE",
@ -1490,7 +1538,15 @@
"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"
"sets_centered_tally": "{placed} plasate · {centre} în centru · {unplaced} neplasate",
"ring_band_dialect": "Același dialect",
"ring_band_language": "Aceeași limbă",
"ring_band_family": "Aceeași familie",
"ring_band_geography": "Doar în apropiere",
"ring_culture_same": "același popor",
"ring_culture_near": "cultură apropiată",
"ring_culture_different": "cultură diferită",
"ring_join": "{band}, {culture}"
},
"ru": {
"map_page_kicker": "НЕОХВАЧЕННЫЕ НАРОДЫ",
@ -1703,7 +1759,15 @@
"sets_bounded_title": "Достигнутые и нет",
"sets_centered_title": "Расстояние до соседа",
"sets_bounded_tally": "{inside} внутри · {outside} снаружи · {unknown} неизвестно",
"sets_centered_tally": "{placed} размещено · {centre} в центре · {unplaced} без места"
"sets_centered_tally": "{placed} размещено · {centre} в центре · {unplaced} без места",
"ring_band_dialect": "Тот же диалект",
"ring_band_language": "Тот же язык",
"ring_band_family": "Та же семья",
"ring_band_geography": "Только соседство",
"ring_culture_same": "тот же народ",
"ring_culture_near": "близкая культура",
"ring_culture_different": "другая культура",
"ring_join": "{band}, {culture}"
},
"ar": {
"map_page_kicker": "شعوب غير مبلَّغة",
@ -1916,7 +1980,15 @@
"sets_bounded_title": "وصلتهم البشارة ومن لم تصلهم",
"sets_centered_title": "المسافة إلى جار",
"sets_bounded_tally": "{inside} في الداخل · {outside} في الخارج · {unknown} غير معروف",
"sets_centered_tally": "{placed} موضوعون · {centre} في المركز · {unplaced} بلا موضع"
"sets_centered_tally": "{placed} موضوعون · {centre} في المركز · {unplaced} بلا موضع",
"ring_band_dialect": "اللهجة نفسها",
"ring_band_language": "اللغة نفسها",
"ring_band_family": "العائلة اللغوية نفسها",
"ring_band_geography": "الجوار فقط",
"ring_culture_same": "الشعب نفسه",
"ring_culture_near": "ثقافة قريبة",
"ring_culture_different": "ثقافة مختلفة",
"ring_join": "{band}، {culture}"
},
"hi": {
"map_page_kicker": "अनछुए जनसमूह",
@ -2127,7 +2199,15 @@
"sets_bounded_title": "पहुँचे और नहीं पहुँचे",
"sets_centered_title": "पड़ोसी से दूरी",
"sets_bounded_tally": "{inside} भीतर · {outside} बाहर · {unknown} अज्ञात",
"sets_centered_tally": "{placed} स्थित · {centre} केंद्र में · {unplaced} अस्थित"
"sets_centered_tally": "{placed} स्थित · {centre} केंद्र में · {unplaced} अस्थित",
"ring_band_dialect": "वही बोली",
"ring_band_language": "वही भाषा",
"ring_band_family": "वही भाषा-परिवार",
"ring_band_geography": "केवल निकटता",
"ring_culture_same": "वही जन",
"ring_culture_near": "निकट संस्कृति",
"ring_culture_different": "भिन्न संस्कृति",
"ring_join": "{band}, {culture}"
},
"zh": {
"map_page_kicker": "未得之民",
@ -2338,6 +2418,14 @@
"sets_bounded_title": "已触及与未触及",
"sets_centered_title": "与邻近族群的距离",
"sets_bounded_tally": "{inside} 圈内 · {outside} 圈外 · {unknown} 未知",
"sets_centered_tally": "{placed} 已定位 · {centre} 在中心 · {unplaced} 未定位"
"sets_centered_tally": "{placed} 已定位 · {centre} 在中心 · {unplaced} 未定位",
"ring_band_dialect": "同一方言",
"ring_band_language": "同一语言",
"ring_band_family": "同一语系",
"ring_band_geography": "仅地理相邻",
"ring_culture_same": "同一族群",
"ring_culture_near": "相近文化",
"ring_culture_different": "不同文化",
"ring_join": "{band},{culture}"
}
}