Every language family of the board's people groups, drawn as a radial tree (family, branches, languages, dialects) on a whiteboard, the families packed like cells in a dish with no overlaps. Green: a New Testament or full Bible found in the language; yellow: something found but no New Testament; red: nothing found. A family shows the worst colour inside it; a dialect carries its language's colour, lighter, because every source catalogues resources by language. Click a circle to narrow the board to it. The table under the centre gains a Gospel resources tab listing every screened link for the languages in scope. The tree is Glottolog 5.2.1 (CC BY 4.0), snapshotted to the languoids the roster needs: 833 of 864 codes, the 31 IMB private codes kept under their own node. The layout is computed at build time and drawn by the same map engine the board already loads, so zooming out draws less and labels never overlap. check:gospel-internet now also measures the tree (complete, coloured by its rule, no overlapping families) and server-renders every panel with the real data in en and ar: profiles-mount never renders a profile's children, and a throw planted in a panel passed it.
143 lines
9.1 KiB
JavaScript
143 lines
9.1 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* check-language-tree.mjs — the language tree is complete, coloured by its stated rule, and laid out as a
|
|
* tapestry that does not overlap. Headless: the geometry is measured in Web Mercator pixels at zoom 0
|
|
* (the space the map engine draws it in), no browser.
|
|
*
|
|
* 1. current: the committed files are what the builder makes from main's inputs;
|
|
* 2. complete: every roster language (ISO 639-3) is a node, and none is lost;
|
|
* 3. colours: a language is g/y/r by the stated rule (recomputed here from the same inputs);
|
|
* no dialect claims its own evidence (every dialect status is inherited, inh:1, unless its
|
|
* ISO code is itself a roster language);
|
|
* 4. tapestry: no two FAMILY circles overlap; every node lies inside its own family's circle;
|
|
* 5. spacing: no two nodes of one family closer than a floor (they would print one on the other).
|
|
* `--png <file>` also writes a preview (dots + links, coloured) to read with an image viewer.
|
|
* `--poison` shifts one family onto another and requires check 4 to FAIL.
|
|
*/
|
|
import { readFileSync, writeFileSync } from 'node:fs'
|
|
import { resolve, dirname } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { execFileSync } from 'node:child_process'
|
|
import { deflateSync } from 'node:zlib'
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
const REPO = resolve(HERE, '../../..')
|
|
const argv = process.argv.slice(2)
|
|
const POISON = argv.includes('--poison')
|
|
const PNG = argv.includes('--png') ? argv[argv.indexOf('--png') + 1] : ''
|
|
let fails = 0
|
|
const ok = (c, m) => { if (c) console.log('ok ' + m); else { fails++; console.log('FAIL ' + m) } }
|
|
|
|
let current = true
|
|
try { execFileSync('node', [resolve(HERE, 'build-language-tree.mjs'), '--check'], { stdio: 'pipe' }) } catch { current = false }
|
|
ok(current, 'the tree files are what the builder makes from main\'s inputs')
|
|
|
|
const T = (f) => JSON.parse(readFileSync(resolve(REPO, 'data/tables', f), 'utf8'))
|
|
const tree = T('gospel-internet-tree.json')
|
|
const gi = T('gospel-internet.json')
|
|
const nodes = tree.nodes
|
|
const byId = new Map(nodes.map((n) => [n.id, n]))
|
|
|
|
// 2. complete
|
|
const langs = new Set(gi.rows.map((r) => r.iso639_3))
|
|
const onTree = new Set(nodes.filter((n) => n.iso).map((n) => n.iso))
|
|
const lost = [...langs].filter((l) => !onTree.has(l))
|
|
ok(lost.length === 0, `every roster language is on the tree (${langs.size}; lost: ${lost.slice(0, 5).join(', ') || 'none'})`)
|
|
ok(nodes.every((n) => !n.p || byId.has(n.p)), 'every parent link resolves')
|
|
|
|
// 3. colours, recomputed
|
|
const eb = T('ebible-index.json').codes || {}
|
|
const links = T('gospel-resource-links.json').rows
|
|
const nt = new Set(Object.entries(eb).filter(([, v]) => v && (v.nt || v.full)).map(([k]) => k))
|
|
for (const r of links) if (/^(New Testament|Full Bible)$|^(27|66) parts/.test(r.scope || '')) nt.add(r.iso639_3)
|
|
const some = new Set(gi.rows.filter((r) => r.access_detail && r.access_detail !== 'none').map((r) => r.iso639_3))
|
|
const want = (iso) => nt.has(iso) ? 'g' : some.has(iso) ? 'y' : 'r'
|
|
const wrong = nodes.filter((n) => n.iso && langs.has(n.iso) && n.st !== want(n.iso))
|
|
ok(wrong.length === 0, `every roster language coloured by the rule (${wrong.length} wrong${wrong[0] ? ': ' + wrong[0].iso : ''})`)
|
|
const ownDialect = nodes.filter((n) => n.lv === 'd' && !n.inh && !(n.iso && langs.has(n.iso)))
|
|
ok(ownDialect.length === 0, `no dialect claims its own evidence (${ownDialect.length})`)
|
|
|
|
// 4 + 5. geometry in Mercator pixels at z0 (512-px world, the engines' tile size)
|
|
const W = 512
|
|
const px = ([lng, lat]) => {
|
|
const x = (lng + 180) / 360 * W
|
|
const s = Math.sin(lat * Math.PI / 180)
|
|
const y = (0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI)) * W
|
|
return [x, y]
|
|
}
|
|
const rootOf = (n) => { let c = n; while (c.p) c = byId.get(c.p); return c }
|
|
const roots = nodes.filter((n) => !n.p)
|
|
const circ = new Map(roots.map((r) => [r.id, { c: px(r.c), R: (r.R || 0) * W }]))
|
|
if (POISON && roots.length > 1) { const [a, b] = roots; circ.get(a.id).c = circ.get(b.id).c }
|
|
let overlaps = 0
|
|
for (let i = 0; i < roots.length; i++) for (let j = i + 1; j < roots.length; j++) {
|
|
const A = circ.get(roots[i].id), B = circ.get(roots[j].id)
|
|
if (Math.hypot(A.c[0] - B.c[0], A.c[1] - B.c[1]) < A.R + B.R - 1e-6) overlaps++
|
|
}
|
|
ok(overlaps === 0, `no two family circles overlap (${overlaps} overlapping pairs of ${roots.length} families)`)
|
|
let outside = 0
|
|
for (const n of nodes) {
|
|
const r = rootOf(n), C = circ.get(r.id), p = px(n.c)
|
|
if (Math.hypot(p[0] - C.c[0], p[1] - C.c[1]) > C.R + 1e-6) outside++
|
|
}
|
|
ok(outside === 0, `every node lies inside its family's circle (${outside} outside)`)
|
|
// spacing: the true nearest pair, via a 1-px grid (any pair closer than 1 px shares a 3x3 block)
|
|
const cell = 1
|
|
const grid = new Map()
|
|
let minD = Infinity, pair = null
|
|
nodes.forEach((n, i) => {
|
|
const p = px(n.c), cx = Math.floor(p[0] / cell), cy = Math.floor(p[1] / cell)
|
|
for (let dx = -1; dx <= 1; dx++) for (let dy = -1; dy <= 1; dy++) {
|
|
for (const [q, j] of grid.get(`${cx + dx},${cy + dy}`) || []) { const d = Math.hypot(p[0] - q[0], p[1] - q[1]); if (d < minD) { minD = d; pair = [i, j] } }
|
|
}
|
|
const k = `${cx},${cy}`
|
|
if (!grid.has(k)) grid.set(k, [])
|
|
grid.get(k).push([p, i])
|
|
})
|
|
const ext = roots.reduce((m, r) => Math.max(m, circ.get(r.id).R), 0)
|
|
if (minD === Infinity) console.log('NOT MEASURED: no two nodes within 1 px at z0; the spacing measure found nothing to compare')
|
|
console.log(`info closest two nodes: ${minD.toFixed(4)} px at z0 = ${(minD * 64).toFixed(1)} px at z6 (${pair ? nodes[pair[0]].n + ' / ' + nodes[pair[1]].n : ''}); largest family radius ${ext.toFixed(1)} px at z0`)
|
|
// Dialect dots first show at z6; two dots closer than 4 px there print as one.
|
|
ok(minD * 2 ** 6 >= 4, 'no two nodes print on one another at zoom 6 (>= 4 px apart)')
|
|
|
|
if (PNG) {
|
|
// a tiny RGBA rasteriser: links, then dots, then family rings. No dependency.
|
|
const S = 1400, scale = S / W
|
|
const buf = Buffer.alloc(S * S * 4, 255)
|
|
for (let i = 0; i < S * S; i++) { buf[i * 4] = 243; buf[i * 4 + 1] = 243; buf[i * 4 + 2] = 241 }
|
|
const set = (x, y, [r, g, b], a = 1) => {
|
|
x = Math.round(x); y = Math.round(y)
|
|
if (x < 0 || y < 0 || x >= S || y >= S) return
|
|
const i = (y * S + x) * 4
|
|
buf[i] = buf[i] * (1 - a) + r * a; buf[i + 1] = buf[i + 1] * (1 - a) + g * a; buf[i + 2] = buf[i + 2] * (1 - a) + b * a
|
|
}
|
|
const line = (a, b, col, alpha) => { const n = Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1])) + 1; for (let i = 0; i <= n; i++) set(a[0] + (b[0] - a[0]) * i / n, a[1] + (b[1] - a[1]) * i / n, col, alpha) }
|
|
const disc = (c, r, col) => { for (let dx = -r; dx <= r; dx++) for (let dy = -r; dy <= r; dy++) if (dx * dx + dy * dy <= r * r) set(c[0] + dx, c[1] + dy, col) }
|
|
const COL = { g: [16, 150, 90], y: [232, 180, 40], r: [180, 35, 24] }
|
|
// centre the tapestry in the frame
|
|
const all = nodes.map((n) => px(n.c))
|
|
const mx = (Math.min(...all.map((p) => p[0])) + Math.max(...all.map((p) => p[0]))) / 2
|
|
const my = (Math.min(...all.map((p) => p[1])) + Math.max(...all.map((p) => p[1]))) / 2
|
|
const span = Math.max(...all.map((p) => Math.max(Math.abs(p[0] - mx), Math.abs(p[1] - my)))) * 2.1
|
|
const tp = (p) => [(p[0] - mx) / span * S + S / 2, (p[1] - my) / span * S + S / 2]
|
|
for (const n of nodes) if (n.p) line(tp(px(byId.get(n.p).c)), tp(px(n.c)), [110, 118, 110], 0.35)
|
|
for (const r of roots) { const C = circ.get(r.id); const c = tp(C.c), R = C.R / span * S; for (let a = 0; a < 6.283; a += 0.5 / Math.max(R, 1)) set(c[0] + R * Math.cos(a), c[1] + R * Math.sin(a), [150, 160, 150], 0.6) }
|
|
const worst = new Map()
|
|
for (const n of nodes) if (n.st && !n.inh) { let p = n; while (p) { const w = worst.get(p.id); if (!w || 'gyr'.indexOf(n.st) > 'gyr'.indexOf(w)) worst.set(p.id, n.st); p = p.p ? byId.get(p.p) : null } }
|
|
for (const n of nodes) {
|
|
const st = n.st || worst.get(n.id) || 'g'
|
|
const r = n.lv === 'f' ? 5 : n.lv === 'b' ? 1 : n.lv === 'l' ? 2 : 1
|
|
disc(tp(px(n.c)), r, n.inh ? COL[st].map((v) => Math.round(v + (255 - v) * 0.5)) : COL[st])
|
|
}
|
|
const crcTable = new Int32Array(256).map((_, n) => { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c })
|
|
const crc = (b) => { let c = -1; for (const x of b) c = crcTable[(c ^ x) & 255] ^ (c >>> 8); return (c ^ -1) >>> 0 }
|
|
const chunk = (t, d) => { const l = Buffer.alloc(4); l.writeUInt32BE(d.length); const td = Buffer.concat([Buffer.from(t), d]); const c = Buffer.alloc(4); c.writeUInt32BE(crc(td)); return Buffer.concat([l, td, c]) }
|
|
const raw = Buffer.alloc(S * (S * 4 + 1))
|
|
for (let y = 0; y < S; y++) { raw[y * (S * 4 + 1)] = 0; buf.copy(raw, y * (S * 4 + 1) + 1, y * S * 4, (y + 1) * S * 4) }
|
|
const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(S, 0); ihdr.writeUInt32BE(S, 4); ihdr[8] = 8; ihdr[9] = 6
|
|
writeFileSync(PNG, Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]))
|
|
console.log('info preview written: ' + PNG)
|
|
}
|
|
|
|
if (POISON) { console.log(fails ? `POISON OK: ${fails} caught` : 'POISON FAILED'); process.exitCode = fails ? 0 : 1 }
|
|
else { console.log(fails ? `FAIL check-language-tree: ${fails}` : 'PASS check-language-tree'); process.exitCode = fails ? 1 : 0 }
|