doxa-maps/library/scripts/build-language-tree.mjs
Doxa Maps 6518fad1bb The language tree's layout becomes library's; its gospel classification does not
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.
2026-09-28 15:52:47 +00:00

199 lines
10 KiB
JavaScript

/**
* 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