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