From 6518fad1bbfbd97ec0b337b2022bf1aad8af40a8 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Mon, 28 Sep 2026 14:57:02 +0000 Subject: [PATCH] The language tree's layout becomes library's; its gospel classification does not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Driver brief §B, BUILD-LIBRARY-CHART-LANGUAGE-TREE: promote gospel-internet's language tree so a fork can build one from its OWN rows. gospel's scripts/build-language-tree.mjs does two jobs in one file. The generic half is the radial layout, the circle packing and the inverse Web Mercator projection. The other half reads gospel-internet.json, ebible-index.json, gospel-resource-links.json and FCBH verse scopes to classify each language g/y/r, and emits a second, purely gospel output. Moving the file whole would have put eBible and FCBH inside library/, and a fork building from its own roster has no eBible index -- so the literal move is the one shape that would NOT deliver the reuse the brief asks for. So the classification stays with its owner and arrives as `statusOf`, returning an OPAQUE string. Nothing here knows what 'g' means; a consumer colouring by speaker count substitutes one function instead of forking a data pipeline. The status is written on LEAVES ONLY, which is not an omission. Rolling a parent's colour up from its descendants is a RUNTIME decision the chart makes against the reader's current scope -- and the scope changes as the reader filters, so baking it would freeze one answer into the data. gospel confirmed the rollup is policy rather than geometry: nothing in the drawing depends on a parent being the worst of its children. The measured constants keep their reasons: the enclosing circle is the tree's real extent plus room for one label (sized by the deepest ring, packing wasted a third of the board); the poster layout drops dialects (2,653 dialect slots squeezed the 858 languages until ~40% of names could not be printed); and the share of the Mercator square is a parameter with a measured default, because it is what decides how far a reader can zoom out below the opening view. check-language-tree-layout asserts INVARIANTS rather than a golden file -- a snapshot of 4,000 coordinates fails on every legitimate change and says nothing about which property broke. Family circles do not overlap, every roster language is reachable, a missing one warns rather than vanishing, the drawing stays inside its extent budget, the poster layout excludes dialects, the status letter is carried through unexamined, and the same input builds the same tree twice. gospel's own script is untouched: they switch to this and drop their copy in the single owner-land already planned, so nothing of theirs breaks in between. --- internal/check-language-tree-layout.mjs | 105 +++++++++++++ library/scripts/build-language-tree.mjs | 199 ++++++++++++++++++++++++ 2 files changed, 304 insertions(+) create mode 100644 internal/check-language-tree-layout.mjs create mode 100644 library/scripts/build-language-tree.mjs diff --git a/internal/check-language-tree-layout.mjs b/internal/check-language-tree-layout.mjs new file mode 100644 index 000000000..e90115616 --- /dev/null +++ b/internal/check-language-tree-layout.mjs @@ -0,0 +1,105 @@ +#!/usr/bin/env node +/** + * check-language-tree-layout — the library language-tree layout, asserted headlessly. + * + * WHAT THIS PROTECTS. library/scripts/build-language-tree.mjs computes, at BUILD time, the drawing a + * fork's whole language tree is made of: every family a roster belongs to, radially laid out and packed + * into one non-overlapping tapestry, projected to lng/lat so a map engine draws it undistorted. Nobody + * proofreads 4,000 node positions. Every failure here is silent and looks like a design choice: + * overlapping families read as a messy drawing, a dropped roster language reads as a shorter list, and a + * tapestry that outgrows its share of the Mercator square just means the reader cannot zoom out as far. + * + * ★ THE GEOMETRY IS ASSERTED AS INVARIANTS, NOT AS A GOLDEN FILE. A snapshot of 4,000 coordinates fails + * on every legitimate change and tells you nothing about which property broke. These check the things + * that must be true of ANY correct layout: circles do not overlap, every roster language is reachable, + * the drawing stays inside its budget, the poster layout excludes dialects, and the same input gives the + * same output twice. + * + * ★ AND THE STATUS IS ASSERTED TO BE OPAQUE. The whole reason the layout is in library/ and gospel's + * eBible/FCBH classification is not: a fork substitutes one function. A test that only ever passes 'g' + * would not notice library growing an opinion about what the letters mean. + * + * Headless, no browser, no network. Exit 0 pass · 1 fail. + */ +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const { buildLanguageTree } = await import(resolve(ROOT, 'library/scripts/build-language-tree.mjs')) + +const fails = [] +const ok = (what, cond, detail = '') => { if (!cond) fails.push(`${what}${detail ? `\n ${detail}` : ''}`) } + +// Three families of different shapes: a deep one, a broad one, and a single-language isolate — the +// isolate matters because a one-node tree is the case a radial layout most easily divides by zero on. +const languoids = [ + { id: 'fam-a', name: 'Alpha', level: 'family', iso: '', parent: '' }, + { id: 'br-a1', name: 'A-One', level: 'family', iso: '', parent: 'fam-a' }, + { id: 'lg-a1', name: 'Aa', level: 'language', iso: 'aaa', parent: 'br-a1' }, + { id: 'lg-a2', name: 'Ab', level: 'language', iso: 'aab', parent: 'br-a1' }, + { id: 'dl-a1', name: 'Aa-north', level: 'dialect', iso: 'aac', parent: 'lg-a1' }, + { id: 'fam-b', name: 'Beta', level: 'family', iso: '', parent: '' }, + { id: 'lg-b1', name: 'Ba', level: 'language', iso: 'bba', parent: 'fam-b' }, + { id: 'lg-b2', name: 'Bb', level: 'language', iso: 'bbb', parent: 'fam-b' }, + { id: 'lg-b3', name: 'Bc', level: 'language', iso: 'bbc', parent: 'fam-b' }, + { id: 'fam-c', name: 'Gamma', level: 'family', iso: 'ccc', parent: '' }, +] +const roster = new Map([['aaa', ['g1']], ['aab', ['g2']], ['bba', ['g3']], ['ccc', ['g4']], ['aac', ['g5']]]) +const build = (over = {}) => buildLanguageTree({ languoids, roster, statusOf: () => 'g', ...over }) + +const out = build() +ok('every node carries an id, a level and a coordinate pair', + out.nodes.every((n) => n.id && n.lv && Array.isArray(n.c) && n.c.length === 2 && n.c.every(Number.isFinite))) +ok('every roster language reaches the tree', roster.size === out.nodes.filter((n) => n.iso && roster.has(n.iso)).length, + `roster ${roster.size}, on the tree ${out.nodes.filter((n) => n.iso && roster.has(n.iso)).length}`) +ok('a roster language absent from the snapshot WARNS rather than vanishing', + build({ roster: new Map([...roster, ['zzz', ['g9']]]) }).warnings.some((w) => /zzz/.test(w))) +ok('a languoid whose parent is outside the snapshot warns', + buildLanguageTree({ languoids: [...languoids, { id: 'orphan', name: 'Orphan', level: 'language', iso: 'ooo', parent: 'nope' }], roster }) + .warnings.some((w) => /orphan/.test(w))) + +// ── THE PACKING INVARIANT: family circles must not overlap. This is the one a reader sees instantly +// and nobody can check by eye across 300 families. +const fams = out.nodes.filter((n) => n.R !== undefined) +let worst = null +for (let i = 0; i < fams.length; i++) { + for (let j = i + 1; j < fams.length; j++) { + const [ax, ay] = fams[i].c, [bx, by] = fams[j].c + const gap = Math.hypot(ax - bx, ay - by) - (fams[i].R + fams[j].R) + if (worst === null || gap < worst.gap) worst = { gap, a: fams[i].id, b: fams[j].id } + } +} +ok('family circles do not overlap', !worst || worst.gap >= 0, worst ? `closest pair ${worst.a}/${worst.b} gap ${worst.gap.toFixed(4)}` : '') + +// ── THE ZOOM BUDGET. The drawing's share of the Mercator square is what decides how far a reader can +// zoom OUT below the opening view; it is a parameter precisely so a consumer can trade that off. +const lngSpan = (nodes) => { const xs = nodes.map((n) => n.c[0]); return Math.max(...xs) - Math.min(...xs) } +ok('the tapestry stays inside its extent budget', lngSpan(out.nodes) <= 0.17 * 360 + 1e-6, + `spans ${lngSpan(out.nodes).toFixed(2)}° of 360°`) +ok('a smaller extentFraction really does draw smaller', + lngSpan(build({ extentFraction: 0.08 }).nodes) < lngSpan(out.nodes)) + +// ── THE POSTER LAYOUT is a second, separate layout, and its whole reason is that a sheet cannot zoom. +const dialects = out.nodes.filter((n) => n.lv === 'd') +ok('dialects exist on screen', dialects.length > 0) +ok('...and get NO poster slot, so language names are not squeezed off the sheet', + dialects.every((n) => n.pc === undefined)) +ok('languages DO get a poster slot', out.nodes.filter((n) => n.lv === 'l').every((n) => Array.isArray(n.pc))) + +// ── STATUS IS OPAQUE. library must carry the letter without knowing what it means. +ok('an arbitrary status string is carried through, not normalised', + build({ statusOf: () => 'whatever-a-fork-calls-it' }).nodes.some((n) => n.st === 'whatever-a-fork-calls-it')) +ok('status is written on leaves only — a parent rollup is the CHART\'s runtime job, against the reader\'s scope', + build().nodes.filter((n) => n.lv === 'f' || n.lv === 'b').every((n) => n.st === undefined)) +ok('no statusOf at all is legal and simply omits st', + buildLanguageTree({ languoids, roster }).nodes.every((n) => n.st === undefined)) + +// ── DETERMINISM. A build-time artifact that differs run to run makes every diff unreadable. +ok('the same input builds the same tree twice', JSON.stringify(build().nodes) === JSON.stringify(build().nodes)) + +if (fails.length) { + console.error('LANGUAGE-TREE-LAYOUT: FAIL') + for (const f of fails) console.error(` ✗ ${f}`) + process.exit(1) +} +console.log(`LANGUAGE-TREE-LAYOUT: PASS — ${out.nodes.length} nodes, families packed without overlap, every roster language reachable, the poster layout drops dialects, and the status letter stays opaque to library.`) diff --git a/library/scripts/build-language-tree.mjs b/library/scripts/build-language-tree.mjs new file mode 100644 index 000000000..073f66762 --- /dev/null +++ b/library/scripts/build-language-tree.mjs @@ -0,0 +1,199 @@ +/** + * build-language-tree.mjs — the LANGUAGE TREE layout, as a library function a fork can call on ITS rows. + * + * Every language family a roster belongs to, drawn as a radial tree (family -> branches -> languages -> + * dialects), each family's enclosing circle packed against the others into one non-overlapping tapestry. + * LAYOUT IS COMPUTED HERE, ONCE, AT BUILD TIME, so a phone does none of it, and the output is projected + * to lng/lat so a map engine can draw it undistorted on the GPU. + * + * ★ WHY THIS IS IN library/ AND WHAT DELIBERATELY IS NOT (Driver brief §B, "make it reusable"). This was + * extracted from gospel-internet's scripts/build-language-tree.mjs, which does TWO jobs in one file: this + * layout, and a classification of each language from eBible, FCBH verse scopes and a screened link table. + * Moving the file whole would have put those sources inside library/ and a fork building the tree from + * its OWN rows has no eBible index — the literal move is the one shape that would not deliver the reuse. + * So the CLASSIFICATION STAYS WITH ITS OWNER and arrives here as `statusOf`, a function returning an + * OPAQUE string. Nothing in this file knows what 'g' means, and a consumer colouring by speaker count, + * or by anything else, substitutes one function instead of forking a data pipeline. + * + * ★ THE STATUS IS WRITTEN ON LEAVES ONLY, ON PURPOSE. A family or branch gets NO status here. Rolling a + * parent's colour up from its descendants ("worst of the languages below it") is a RUNTIME decision the + * chart makes against the reader's current scope — which is not knowable at build time, because it + * changes as the reader filters. Baking it would freeze one scope's answer into the data. + * + * The numbers below are measured, not chosen. Where a constant has a reason it is stated; changing one + * without re-measuring is how this drawing gets quietly worse. + */ + +/** A tree whose single-child branch chains are collapsed for DRAWING; the skipped names are kept in `via`. */ +function collapse(n) { + n.kids = n.kids.map((k) => { + let c = k + const via = [] + while (c.level === 'family' && c.kids.length === 1 && c.kids[0].level === 'family') { via.push(c.name); c = c.kids[0] } + if (via.length) c.via = via + return c + }) + n.kids.sort((a, b) => a.name.localeCompare(b.name)) + n.kids.forEach(collapse) +} + +const leaves = (n) => (n._leaves ??= (n.kids.length ? n.kids.reduce((s, k) => s + leaves(k), 0) : 1)) +const depth = (n) => (n._depth ??= (n.kids.length ? 1 + Math.max(...n.kids.map(depth)) : 0)) +const clearMemo = (n) => { delete n._leaves; delete n._depth; n.kids.forEach(clearMemo) } +const hideDialects = (n) => { n._allKids = n.kids; n.kids = n.kids.filter((k) => k.level !== 'dialect'); n.kids.forEach(hideDialects) } +const showDialects = (n) => { if (n._allKids) { n.kids = n._allKids; delete n._allKids } n.kids.forEach(showDialects) } + +/** One family: leaves evenly around the circle, parents at the mean of their children, radius by depth. */ +function layoutFamily(root, { ARC, STEP_MIN }) { + const nLeaves = leaves(root), d = depth(root) + const step = d ? Math.max(STEP_MIN, (nLeaves * ARC) / (2 * Math.PI * d)) : 0 + let slot = 0 + const place = (n, lvl) => { + if (!n.kids.length) { n._a = ((slot + 0.5) / nLeaves) * 2 * Math.PI; slot++ } + else { n.kids.forEach((k) => place(k, lvl + 1)); n._a = (n.kids[0]._a + n.kids[n.kids.length - 1]._a) / 2 } + n._r = lvl * step + } + place(root, 0) + // The enclosing circle is the tree's REAL extent (its outermost node) plus room for one label, NOT the + // deepest possible ring: sized by the ring, the packing wasted about a third of the board. + let far = 0 + const walk = (n) => { far = Math.max(far, n._r); n.kids.forEach(walk) } + walk(root) + root._R = far + 1.6 + Math.min(3, Math.sqrt(nLeaves) * 0.25) + return root +} + +/** Largest first, at the nearest free spot on a spiral: circles never overlap and the whole fills a disc. */ +function pack(roots) { + const placed = [] + for (const root of [...roots].sort((a, b) => b._R - a._R || a.name.localeCompare(b.name))) { + const R = root._R + let best = null + if (!placed.length) best = [0, 0] + for (let t = 0; !best; t += 0.05) { + const rad = 0.35 * t * Math.max(1, R * 0.2) + const x = rad * Math.cos(t), y = rad * Math.sin(t) + if (placed.every((p) => Math.hypot(p.x - x, p.y - y) >= p.R + R + 0.8)) best = [x, y] + } + placed.push({ x: best[0], y: best[1], R }) + root._cx = best[0]; root._cy = best[1] + } + return placed +} + +const LV = { family: 'f', language: 'l', dialect: 'd' } + +/** + * @param {Object} input + * @param {Array} input.languoids [{ id, name, level, iso, parent }] — a Glottolog-shaped subset + * @param {string[]} [input.notInGlottolog] ISO codes the snapshot lacks; each becomes a leaf under one + * labelled root, so no roster language is left off the tree + * @param {Map} input.roster iso -> whatever the consumer wants on the node as `g` + * @param {Function} [input.statusOf] (iso) => opaque string, written as `st` on LEAVES only + * @param {Function} [input.nameOf] (iso) => display name for a notInGlottolog leaf + * @param {Object} [input.screen] { ARC, STEP_MIN } — the on-screen layout + * @param {Object} [input.poster] { ARC, STEP_MIN } — the print layout, stored as `pc` / `pR` + * @param {number} [input.extentFraction] share of the Mercator square the tapestry spans + * @returns {{ nodes: Array, coverage: Object, warnings: string[] }} + */ +export function buildLanguageTree({ + languoids, + notInGlottolog = [], + roster = new Map(), + statusOf = () => undefined, + nameOf = (iso) => iso, + screen = { ARC: 1.0, STEP_MIN: 3.2 }, + poster = { ARC: 1.0, STEP_MIN: 3.2 }, + extentFraction = 0.17, + missingRootId = 'not-in-glottolog', + missingRootName = 'Not in Glottolog', +} = {}) { + const warnings = [] + const L = new Map((languoids || []).map((l) => [l.id, { ...l, kids: [] }])) + const roots = [] + for (const n of L.values()) { + if (n.parent && L.has(n.parent)) L.get(n.parent).kids.push(n) + else if (!n.parent) roots.push(n) + else warnings.push(`${n.id} (${n.name}) has a parent ${n.parent} outside the snapshot`) + } + if (notInGlottolog.length) { + const miss = { id: missingRootId, name: missingRootName, level: 'family', iso: '', parent: '', kids: [] } + for (const iso of notInGlottolog) { + const leaf = { id: 'iso-' + iso, name: nameOf(iso) || iso, level: 'language', iso, parent: missingRootId, kids: [] } + L.set(leaf.id, leaf); miss.kids.push(leaf) + } + L.set(missingRootId, miss); roots.push(miss) + } + const isoToNode = new Map() + for (const n of L.values()) if (n.iso) isoToNode.set(n.iso, n) + for (const iso of roster.keys()) if (!isoToNode.has(iso)) warnings.push(`language ${iso} of the roster is not on the tree`) + + roots.forEach(collapse) + + // ★ THE POSTER LAYOUT IS COMPUTED FIRST, AND WITHOUT DIALECTS. Resources are catalogued per LANGUAGE, + // and on a sheet that cannot zoom, 2,653 dialect slots squeezed the 858 languages until ~40% of the + // language names could not be printed without overlap. The screen layout keeps them, because the map + // engine hides labels that do not fit and reveals them as the reader zooms — a printed sheet has one + // scale and must fit every name at it. Stored per node in units of its own extent, so it is scale-free. + roots.forEach(hideDialects); roots.forEach(clearMemo) + roots.forEach((r) => layoutFamily(r, poster)) + { + const pl = pack(roots) + let pext = 1 + for (const p of pl) pext = Math.max(pext, Math.abs(p.x) + p.R, Math.abs(p.y) + p.R) + const keep = (n, root) => { + n._pu = [(root._cx + n._r * Math.cos(n._a)) / pext, (root._cy + n._r * Math.sin(n._a)) / pext] + n.kids.forEach((k) => keep(k, root)) + } + for (const root of roots) { keep(root, root); root._pR = root._R / pext } + } + roots.forEach(showDialects); roots.forEach(clearMemo) + roots.forEach((r) => layoutFamily(r, screen)) + const placed = pack(roots) + + // ── to lng/lat through the INVERSE of Web Mercator, uniformly scaled, so the drawing is undistorted ── + let ext = 1 + for (const p of placed) ext = Math.max(ext, Math.abs(p.x) + p.R, Math.abs(p.y) + p.R) + // ★ HOW MUCH OF THE SQUARE THE TAPESTRY SPANS DECIDES HOW FAR A READER CAN ZOOM OUT, and that is the + // whole reason this number is a parameter with a measured default. The engines stop at zoom 0 (the + // whole Mercator square), so the SMALLER the drawing, the more room there is below its opening view. + // At 68% there was about one level of room (Driver, 2026-09-27: "I need to be able to zoom out a bit + // more"); at 0.17 there are two. + const K = extentFraction / ext + const toLngLat = (u, v) => { + const X = 0.5 + u * K, Y = 0.5 - v * K + const lng = (X - 0.5) * 360 + const lat = Math.atan(Math.sinh(Math.PI * (1 - 2 * Y))) * 180 / Math.PI + return [Math.round(lng * 1e5) / 1e5, Math.round(lat * 1e5) / 1e5] + } + + // Edges are NOT emitted: every node carries its parent id and both coordinates, so the chart draws each + // parent->child line itself. It halves the file. + const nodes = [] + const emit = (n, root, parentId, isRoot) => { + const x = root._cx + n._r * Math.cos(n._a), y = root._cy + n._r * Math.sin(n._a) + const lv = isRoot && n.level === 'family' ? 'f' : n.level === 'family' ? 'b' : LV[n.level] || 'b' + const node = { id: n.id, n: n.name, lv, p: parentId || '', c: toLngLat(x, y) } + if (n.iso) node.iso = n.iso + if (n.via) node.via = n.via + if (lv === 'l' || (lv === 'd' && n.iso && roster.has(n.iso))) { + const st = statusOf(n.iso) + if (st !== undefined && st !== null && st !== '') node.st = st + } + if (n.iso && roster.has(n.iso)) node.g = roster.get(n.iso) + if (lv === 'f' || isRoot) { node.R = Math.round(root._R * K * 1e6) / 1e6; node.pR = Math.round(root._pR * 1e5) / 1e5 } + if (n._pu) node.pc = [Math.round(n._pu[0] * 1e5) / 1e5, Math.round(n._pu[1] * 1e5) / 1e5] + nodes.push(node) + for (const k of n.kids) emit(k, root, n.id, false) + } + for (const root of roots) emit(root, root, '', true) + + const levels = nodes.reduce((a, n) => ((a[n.lv] = (a[n.lv] || 0) + 1), a), {}) + return { + nodes, + coverage: { nodes: nodes.length, levels, families: roots.length, not_in_glottolog: notInGlottolog.length }, + warnings, + } +} + +export default buildLanguageTree