diff --git a/internal/check-frame-target.mjs b/internal/check-frame-target.mjs
new file mode 100644
index 000000000..530df2823
--- /dev/null
+++ b/internal/check-frame-target.mjs
@@ -0,0 +1,84 @@
+#!/usr/bin/env node
+/**
+ * check-frame-target — the language tree's framing constants, pinned with the reasons they exist.
+ *
+ * WHY THIS TEST EXISTS AT ALL. gospel-internet's maintainer inherited GiTree and had to read intent out
+ * of structure: there is no "this took measuring" comment anywhere in that file. Asked which line they
+ * would flag as load-bearing-but-arbitrary-looking, they named frame() and its four constants. Nobody
+ * had written down what a 60 or an 8 was for, so the next refactor would have tidied them away.
+ *
+ * THE TWO BRANCH ASSERTIONS ARE THE POINT, more than the numbers:
+ * · REDUCED MOTION IS A DIFFERENT CALL. jumpTo, not easeTo with duration 0. Those are not the same
+ * instruction and the engines do not treat them as one, so a "simplification" to a single easeTo
+ * is a regression that no screenshot would show.
+ * · A SINGLE POINT IS NOT A TINY BOX. fitBounds on a degenerate extent zooms to nothing; a
+ * one-language family is an ordinary case. Collapsing the branches makes that family vanish.
+ *
+ * Pure input, pure output, no map — plus a recording fake to prove applyFrame issues the right CALL.
+ * 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 { frameTarget, applyFrame } = await import(resolve(ROOT, 'library/charts/whiteboard/frame-target.js'))
+
+const fails = []
+const ok = (what, cond, detail = '') => { if (!cond) fails.push(`${what}${detail ? `\n ${detail}` : ''}`) }
+
+// ── nothing to frame must LEAVE THE CAMERA ALONE ────────────────────────────────────────────────
+// Clearing a selection does not re-frame in GiTree (`if (id) frame(...)`), and this is the other half
+// of that rule: a caller handed no coords gets no instruction, rather than a move to somewhere neutral.
+ok('no coords -> no instruction', frameTarget([]) === null)
+ok('null -> no instruction', frameTarget(null) === null)
+ok('garbage coords -> no instruction, not a NaN camera', frameTarget([['x', null]]) === null)
+
+// ── the two branches ────────────────────────────────────────────────────────────────────────────
+const one = frameTarget([[10, 20]], { currentZoom: 3 })
+ok('a single point CENTRES, it does not fit a degenerate box', one.kind === 'point')
+ok('...at the point', JSON.stringify(one.center) === JSON.stringify([10, 20]))
+ok('...at a floor zoom, never zooming OUT to frame one node', one.zoom === 6)
+ok('...and keeps the reader in if they are already closer', frameTarget([[10, 20]], { currentZoom: 9 }).zoom === 9)
+const many = frameTarget([[10, 20], [12, 24]])
+ok('a real extent FITS', many.kind === 'bounds')
+ok('...to the bounding box', JSON.stringify(many.bounds) === JSON.stringify([[10, 20], [12, 24]]))
+// Coincident points are a real subtree shape, not a pathological one.
+ok('coincident points take the point branch', frameTarget([[5, 5], [5, 5], [5, 5]]).kind === 'point')
+
+// ── the constants, each asserted where its reason applies ───────────────────────────────────────
+ok('padding 60 — the outermost nodes carry labels that extend past the point', many.padding === 60)
+ok('maxZoom 8 — a two-language family must not fill the screen with its own neighbourhood gone', many.maxZoom === 8)
+ok('duration 900 — a movement between two places, not a cut', many.duration === 900)
+
+// ── ★ REDUCED MOTION IS A DIFFERENT CALL ────────────────────────────────────────────────────────
+ok('reduced motion zeroes the duration (bounds)', frameTarget([[1, 2], [3, 4]], { reducedMotion: true }).duration === 0)
+ok('reduced motion zeroes the duration (point)', frameTarget([[1, 2]], { reducedMotion: true }).duration === 0)
+const calls = []
+const fake = {
+ easeTo: (a) => calls.push(['easeTo', a]), jumpTo: (a) => calls.push(['jumpTo', a]),
+ fitBounds: (b, o) => calls.push(['fitBounds', b, o]),
+}
+applyFrame(fake, frameTarget([[1, 2]], { reducedMotion: true }))
+ok('a reduced-motion POINT frame calls jumpTo — NOT easeTo with duration 0',
+ calls.length === 1 && calls[0][0] === 'jumpTo', `called ${calls.map((c) => c[0]).join(', ') || 'nothing'}`)
+calls.length = 0
+applyFrame(fake, frameTarget([[1, 2]]))
+ok('a normal POINT frame animates', calls.length === 1 && calls[0][0] === 'easeTo' && calls[0][1].duration === 900)
+calls.length = 0
+applyFrame(fake, frameTarget([[1, 2], [3, 4]], { reducedMotion: true }))
+ok('a reduced-motion BOUNDS frame still uses fitBounds, at duration 0',
+ calls.length === 1 && calls[0][0] === 'fitBounds' && calls[0][2].duration === 0)
+
+// ── and a degenerate frame never breaks the board ───────────────────────────────────────────────
+ok('a throwing map is survived, and says so', applyFrame({ fitBounds: () => { throw new Error('no') } },
+ frameTarget([[1, 2], [3, 4]])) === false)
+ok('no map, no crash', applyFrame(null, frameTarget([[1, 2]])) === false)
+ok('no target, no call', applyFrame(fake, null) === false)
+
+if (fails.length) {
+ console.error('FRAME-TARGET: FAIL')
+ for (const f of fails) console.error(` ✗ ${f}`)
+ process.exit(1)
+}
+console.log('FRAME-TARGET: PASS — a single point centres rather than fitting a degenerate box, reduced motion JUMPS '
+ + 'rather than animating for 0ms, and padding 60 / maxZoom 8 / duration 900 are pinned with their reasons.')
diff --git a/internal/check-language-tree-model.mjs b/internal/check-language-tree-model.mjs
new file mode 100644
index 000000000..23af148cd
--- /dev/null
+++ b/internal/check-language-tree-model.mjs
@@ -0,0 +1,90 @@
+#!/usr/bin/env node
+/**
+ * check-language-tree-model — the hierarchical half of the language tree, tested without a browser.
+ *
+ * The chart's drawing is a whiteboard of tiered points and is proven by check:tiered-points against a
+ * NON-tree fixture. This covers what is left: the parent/child index, the edges, the descendants a
+ * selection frames, and the colour rollup.
+ *
+ * ★ THE ROLLUP ASSERTION IS THE ONE THAT MATTERS. "A family shows the worst colour of the languages
+ * below it" is only true relative to what the reader is CURRENTLY looking at: it is computed against
+ * the filtered roster, not the whole subtree. A family that is red overall can be green inside the
+ * reader's selection, and handing the rollup an unfiltered subtree makes the colours quietly disagree
+ * with the board — no error, no blank, just a wrong fact about a real people group. So scope is
+ * asserted in both directions: with a scope, and without one.
+ *
+ * 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 M = await import(resolve(ROOT, 'library/charts/whiteboard/language-tree-model.js'))
+
+const fails = []
+const ok = (what, cond, detail = '') => { if (!cond) fails.push(`${what}${detail ? `\n ${detail}` : ''}`) }
+
+// fam (root)
+// ├── br1 ── lgA (g, iso aaa)
+// │ └── lgB (r, iso bbb)
+// └── lgC (y, iso ccc)
+const NODES = [
+ { id: 'fam', n: 'Family', lv: 'f', p: '', c: [0, 0], R: 5 },
+ { id: 'br1', n: 'Branch', lv: 'b', p: 'fam', c: [1, 0] },
+ { id: 'lgA', n: 'A', lv: 'l', p: 'br1', c: [2, 1], iso: 'aaa', st: 'g' },
+ { id: 'lgB', n: 'B', lv: 'l', p: 'br1', c: [2, -1], iso: 'bbb', st: 'r' },
+ { id: 'lgC', n: 'C', lv: 'l', p: 'fam', c: [-2, 0], iso: 'ccc', st: 'y' },
+]
+const ix = M.indexTree(NODES)
+
+ok('every node is indexed', ix.byId.size === 5)
+ok('the root is found by having no parent', JSON.stringify(ix.roots) === JSON.stringify(['fam']))
+ok('children are collected under their parent', JSON.stringify(ix.kids.get('br1')) === JSON.stringify(['lgA', 'lgB']))
+ok('a language is reachable from its ISO', ix.nodeOfIso.get('bbb') === 'lgB')
+
+// ── framing a selection fits the SUBTREE, not the node ───────────────────────────────────────────
+ok('a family frames all of its descendants', M.subtreeCoords(ix, 'fam').length === 5)
+ok('a branch frames only its own', M.subtreeCoords(ix, 'br1').length === 3)
+ok('a leaf frames itself — which is the single-point branch of frameTarget', M.subtreeCoords(ix, 'lgC').length === 1)
+ok('an unknown id frames nothing rather than throwing', M.subtreeCoords(ix, 'nope').length === 0)
+// A malformed index must not hang the board.
+const cyclic = M.indexTree([{ id: 'a', p: 'b', c: [0, 0] }, { id: 'b', p: 'a', c: [1, 1] }])
+ok('a cycle terminates instead of hanging', M.subtreeCoords(cyclic, 'a').length <= 2)
+
+// ── the ONE hierarchical layer ───────────────────────────────────────────────────────────────────
+const links = M.linkFC(ix)
+ok('one edge per non-root node', links.features.length === 4)
+ok('each edge runs parent -> child',
+ links.features.every((f) => f.geometry.type === 'LineString' && f.geometry.coordinates.length === 2))
+ok('the root has no edge', !links.features.some((f) => f.properties.id === 'fam'))
+
+// ── ★ THE ROLLUP, IN SCOPE AND OUT ───────────────────────────────────────────────────────────────
+ok('unscoped, a family is the WORST of everything below it', M.worstOfDescendants(ix, 'fam') === 'r')
+ok('unscoped, a branch is the worst of ITS own', M.worstOfDescendants(ix, 'br1') === 'r')
+// The reader filters to A and C: B (the red one) is no longer on the board, so the family is no longer red.
+const scope = new Set(['aaa', 'ccc'])
+ok('IN SCOPE, the family reflects only what the reader can see', M.worstOfDescendants(ix, 'fam', { inScope: scope }) === 'y',
+ `got ${M.worstOfDescendants(ix, 'fam', { inScope: scope })}, expected y — the red language is filtered out`)
+ok('...and a branch whose only in-scope child is green reads green',
+ M.worstOfDescendants(ix, 'br1', { inScope: scope }) === 'g')
+ok('a node with nothing in scope has no status rather than a wrong one',
+ M.worstOfDescendants(ix, 'br1', { inScope: new Set(['ccc']) }) === undefined)
+ok('a leaf is its own status', M.worstOfDescendants(ix, 'lgA') === 'g')
+
+// ── the point collection carries what the whiteboard layer needs ─────────────────────────────────
+const fc = M.nodeFC(ix, { colorFor: (n) => (n.st === 'r' ? '#b42318' : '#1f8f5f'), inScope: scope })
+ok('every node becomes a point', fc.features.length === 5)
+ok('tier comes from the node level', fc.features.find((f) => f.properties.id === 'lgA').properties.tier === 'l')
+ok('the root is marked, so a dish can filter on it', fc.features.find((f) => f.properties.id === 'fam').properties.root === 1)
+ok('out-of-scope nodes are MARKED, not dropped — they fade, never vanish',
+ fc.features.find((f) => f.properties.id === 'lgB').properties.m === 0 && fc.features.length === 5)
+ok('the caller\'s colour is applied per node',
+ fc.features.find((f) => f.properties.id === 'lgB').properties.colour === '#b42318')
+
+if (fails.length) {
+ console.error('LANGUAGE-TREE-MODEL: FAIL')
+ for (const f of fails) console.error(` ✗ ${f}`)
+ process.exit(1)
+}
+console.log('LANGUAGE-TREE-MODEL: PASS — the index, the one edge layer, subtree framing, and a colour rollup that '
+ + 'reflects the READER\'s scope rather than the whole subtree.')
diff --git a/library/charts/LanguageTreeChart.vue b/library/charts/LanguageTreeChart.vue
new file mode 100644
index 000000000..539117938
--- /dev/null
+++ b/library/charts/LanguageTreeChart.vue
@@ -0,0 +1,160 @@
+
+
+
+
+
+
{{ err }}
+
{{ t('tree.loading', 'Loading…') }}
+
+
+
+
+
+
diff --git a/library/charts/whiteboard/frame-target.js b/library/charts/whiteboard/frame-target.js
new file mode 100644
index 000000000..701d2cf40
--- /dev/null
+++ b/library/charts/whiteboard/frame-target.js
@@ -0,0 +1,90 @@
+/**
+ * frame-target.js — what camera move frames a set of points, decided purely so it can be tested.
+ *
+ * ★ EVERY NUMBER HERE WAS TUNED AND NONE OF THEM WAS WRITTEN DOWN. Extracted from gospel-internet's
+ * GiTree, whose maintainer inherited the file and had to reverse-engineer intent from structure: there
+ * is no "this took measuring" note anywhere in it. They named this function as the one to pin. So the
+ * values move here with their reasons attached, and a test asserts them, because the next person
+ * should not have to guess what a 60 was for.
+ *
+ * ★ THE REDUCED-MOTION BRANCH IS A DIFFERENT CALL, NOT A FASTER ONE. Under reduced motion the camera
+ * JUMPS; it does not animate with duration 0. A refactor that "simplifies" this to easeTo({duration:0})
+ * is NOT equivalent — it is an animation of zero length, and the engines do not treat the two the same.
+ * This is the specific thing gospel asked to have pinned hardest.
+ *
+ * ★ AND A SINGLE POINT TAKES A DIFFERENT BRANCH FROM A SET. fitBounds on a degenerate box — one point,
+ * or a subtree of coincident points — has no extent to fit, so it zooms to nothing. A one-language
+ * family is a real case, not a pathological one, so it is centred at a floor zoom instead. Collapsing
+ * these two branches is the other thing gospel flagged.
+ *
+ * Pure: no map, no refs, no DOM. The caller applies the returned instruction.
+ */
+
+/**
+ * @param {Array<[number, number]>} coords lng/lat pairs to frame
+ * @param {Object} [o]
+ * @param {boolean} [o.reducedMotion] jump instead of animating
+ * @param {number} [o.currentZoom] used as the floor for a single point, so framing never zooms OUT
+ * @param {number} [o.padding] px of breathing room around a fitted box
+ * @param {number} [o.maxZoom] how far a fit may zoom IN on a tight cluster
+ * @param {number} [o.duration] animation ms when motion is allowed
+ * @param {number} [o.pointZoom] floor zoom for the single-point branch
+ * @param {number} [o.epsilon] below this extent (degrees) a box counts as a single point
+ * @returns {null | { kind: 'point', center: [number, number], zoom: number, duration: number, essential: true }
+ * | { kind: 'bounds', bounds: [[number, number], [number, number]], padding: number,
+ * maxZoom: number, duration: number, essential: true }}
+ * null when there is nothing to frame — the caller must then leave the camera ALONE rather
+ * than moving it somewhere neutral, which is a motion the reader did not ask for.
+ */
+export function frameTarget(coords, {
+ reducedMotion = false,
+ currentZoom = 0,
+ // 60px: the fitted box is a SUBTREE, and its outermost nodes carry labels that extend beyond the
+ // point itself. Without padding the edge labels are clipped by the viewport they were just fitted to.
+ padding = 60,
+ // 8: a family of one or two languages fits at a zoom where the tapestry around it is gone and the
+ // reader loses where they are. The cap keeps the neighbourhood in frame.
+ maxZoom = 8,
+ // 900ms: long enough to read as a movement between two places rather than a cut.
+ duration = 900,
+ // 6: never zoom OUT to frame a single point — max() with the current zoom below.
+ pointZoom = 6,
+ epsilon = 1e-4,
+} = {}) {
+ if (!Array.isArray(coords) || !coords.length) return null
+ let w = 180, s = 90, e = -180, n = -90
+ for (const c of coords) {
+ if (!Array.isArray(c) || !Number.isFinite(c[0]) || !Number.isFinite(c[1])) continue
+ w = Math.min(w, c[0]); e = Math.max(e, c[0]); s = Math.min(s, c[1]); n = Math.max(n, c[1])
+ }
+ if (!(e >= w && n >= s)) return null
+ const ms = reducedMotion ? 0 : duration
+ if (e - w < epsilon && n - s < epsilon) {
+ return { kind: 'point', center: [w, s], zoom: Math.max(currentZoom, pointZoom), duration: ms, essential: true }
+ }
+ return { kind: 'bounds', bounds: [[w, s], [e, n]], padding, maxZoom, duration: ms, essential: true }
+}
+
+/**
+ * Apply an instruction to a map. Kept beside the decision so the JUMP-vs-ANIMATE rule has exactly one
+ * implementation: duration 0 means jumpTo, never easeTo with a zero duration.
+ */
+export function applyFrame(map, target) {
+ if (!map || !target) return false
+ try {
+ if (target.kind === 'point') {
+ if (target.duration) map.easeTo({ center: target.center, zoom: target.zoom, duration: target.duration, essential: true })
+ else map.jumpTo({ center: target.center, zoom: target.zoom })
+ return true
+ }
+ map.fitBounds(target.bounds, {
+ padding: target.padding, maxZoom: target.maxZoom, duration: target.duration, essential: true,
+ })
+ return true
+ } catch (_) {
+ // A degenerate frame never breaks the board.
+ return false
+ }
+}
+
+export default frameTarget
diff --git a/library/charts/whiteboard/language-tree-model.js b/library/charts/whiteboard/language-tree-model.js
new file mode 100644
index 000000000..6930a296f
--- /dev/null
+++ b/library/charts/whiteboard/language-tree-model.js
@@ -0,0 +1,131 @@
+/**
+ * language-tree-model.js — the parts of the language tree that are ABOUT the hierarchy, kept out of the
+ * component so they can be tested without a browser.
+ *
+ * The chart itself is a whiteboard of tiered points (library/charts/whiteboard/tiered-points.js) and
+ * knows nothing about trees. This is the rest: the parent/child index, the edge collection, the
+ * descendants of a node, and the default colour rollup.
+ *
+ * ★ THE ROLLUP IS OVER THE IN-SCOPE DESCENDANTS, NOT THE WHOLE SUBTREE, and getting that wrong is
+ * invisible. "A family shows the worst colour of the languages below it" is only meaningful relative to
+ * what the reader is currently looking at — the rollup is computed against the FILTERED roster, so a
+ * family that is red overall can be green within the reader's selection. Hand the rollup the whole
+ * subtree and the colours quietly stop agreeing with the board, which is the one failure mode the
+ * chart's author flagged when this was extracted.
+ *
+ * ★ AND THE ROLLUP IS A DEFAULT, NOT A RULE. Nothing in the drawing depends on a parent being the worst
+ * of its children; a consumer colouring by speaker count or by an average would render perfectly well.
+ * So statusFor is injectable and this is merely what the language tree does.
+ */
+
+/** Index a flat node list: by id, children by parent id, and the roots. */
+export function indexTree(nodes = []) {
+ const byId = new Map()
+ const kids = new Map()
+ const roots = []
+ const nodeOfIso = new Map()
+ for (const n of nodes) {
+ byId.set(n.id, n)
+ if (n.iso && !nodeOfIso.has(n.iso)) nodeOfIso.set(n.iso, n.id)
+ if (n.p) { if (!kids.has(n.p)) kids.set(n.p, []); kids.get(n.p).push(n.id) } else roots.push(n.id)
+ }
+ return { byId, kids, roots, nodeOfIso }
+}
+
+/** Every coordinate in a node's subtree, including its own — what framing a selection fits. */
+export function subtreeCoords(index, id) {
+ const out = []
+ if (!index || !index.byId.has(id)) return out
+ const seen = new Set()
+ const walk = (k) => {
+ if (seen.has(k)) return // a malformed index must not hang the board
+ seen.add(k)
+ const n = index.byId.get(k)
+ if (n && Array.isArray(n.c)) out.push(n.c)
+ for (const c of index.kids.get(k) || []) walk(c)
+ }
+ walk(id)
+ return out
+}
+
+/** The ids of a node's descendants that carry a status — the leaves a rollup reads. */
+export function descendantLeaves(index, id) {
+ const out = []
+ const walk = (k) => {
+ const n = index.byId.get(k)
+ if (!n) return
+ if (n.st !== undefined) out.push(n)
+ for (const c of index.kids.get(k) || []) walk(c)
+ }
+ walk(id)
+ return out
+}
+
+/**
+ * The language tree's own rollup: the WORST status among a node's in-scope descendants.
+ * @param {Object} index
+ * @param {string} id
+ * @param {Object} o
+ * @param {Set} [o.inScope] ids (or isos) currently on the board; omit for "everything"
+ * @param {string[]} [o.order] worst-first; the first one found wins
+ * @param {Function} [o.isInScope] (node) => boolean, when scope is not a plain id set
+ */
+export function worstOfDescendants(index, id, { inScope = null, order = ['r', 'y', 'g'], isInScope = null } = {}) {
+ const self = index.byId.get(id)
+ if (self && self.st !== undefined && !index.kids.get(id)?.length) return self.st
+ const keep = (n) => {
+ if (isInScope) return isInScope(n)
+ if (!inScope) return true
+ return inScope.has(n.id) || (n.iso !== undefined && inScope.has(n.iso))
+ }
+ const found = new Set()
+ for (const n of descendantLeaves(index, id)) if (keep(n)) found.add(n.st)
+ for (const s of order) if (found.has(s)) return s
+ return undefined
+}
+
+/**
+ * Parent -> child line segments as a GeoJSON collection. This is the ONE hierarchical layer; everything
+ * else the chart draws comes from the point collection.
+ * Edges are built here rather than stored by the builder because every node already carries its parent
+ * id and its own coordinate: storing them again would roughly double the file for no new information.
+ */
+export function linkFC(index) {
+ const features = []
+ for (const [id, n] of index.byId) {
+ if (!n.p) continue
+ const parent = index.byId.get(n.p)
+ if (!parent || !Array.isArray(parent.c) || !Array.isArray(n.c)) continue
+ features.push({
+ type: 'Feature',
+ geometry: { type: 'LineString', coordinates: [parent.c, n.c] },
+ properties: { id, p: n.p, lv: n.lv },
+ })
+ }
+ return { type: 'FeatureCollection', features }
+}
+
+/** The point collection the whiteboard layer draws, with the caller's colour applied per node. */
+export function nodeFC(index, { colorFor = () => undefined, inScope = null } = {}) {
+ const features = []
+ for (const [id, n] of index.byId) {
+ if (!Array.isArray(n.c)) continue
+ const inside = !inScope || inScope.has(id) || (n.iso !== undefined && inScope.has(n.iso))
+ features.push({
+ type: 'Feature',
+ geometry: { type: 'Point', coordinates: n.c },
+ properties: {
+ id,
+ label: n.n,
+ tier: n.lv,
+ k: n.R !== undefined ? 1e6 : (n.lv === 'l' ? 1e3 : 1),
+ root: n.p ? 0 : 1,
+ m: inside ? 1 : 0,
+ ...(n.iso !== undefined ? { iso: n.iso } : {}),
+ ...(n.R !== undefined ? { R: n.R } : {}),
+ colour: colorFor(n, { index, inScope }),
+ },
+ })
+ }
+ return { type: 'FeatureCollection', features }
+}
diff --git a/library/maps/whiteboard-map.js b/library/maps/whiteboard-map.js
new file mode 100644
index 000000000..be14f5d7e
--- /dev/null
+++ b/library/maps/whiteboard-map.js
@@ -0,0 +1,82 @@
+/**
+ * whiteboard-map.js — a map engine used as a DRAWING SURFACE rather than as a map.
+ *
+ * ★ WHY A CHART USES THE MAP ENGINE AT ALL (Driver, 2026-09-27: "snappy, lightweight, lower definition
+ * when zoomed out"). A GeoJSON source is tiled per zoom, so zoomed out it draws LESS; label placement
+ * never lets two labels overlap and reveals them in priority order as the reader zooms; hit-testing and
+ * pan/zoom come free. An SVG chart keeps every node in the page at every zoom, which is what makes a
+ * big one slow. So the engine is asked for a blank surface and the chart draws on it.
+ *
+ * ★ whiteboard:true IS A BEHAVIOUR, NOT A LOOK, and that is why this takes a MODE and never an engine
+ * factory. It hides every basemap layer except the background AND sets renderWorldCopies:false, because
+ * a whiteboard is not a world: with world copies on, the drawing repeats horizontally at low zoom and a
+ * reader sees three tapestries. A caller passing an ordinary map factory would get that, silently.
+ *
+ * ★ THE STYLE IS STILL LOADED even though every layer is then hidden. The style is what declares the
+ * GLYPH server, and a hand-typed glyph URL is how labels silently vanished on one engine before (see
+ * library/maps/label-font.js). Hiding layers after load costs one pass; guessing a glyph URL costs the
+ * labels.
+ *
+ * MOVED, NOT WRITTEN: this was gospel-internet/src/boardEngine.js — 67 lines whose every import already
+ * came from library. It was never gospel-specific; it simply lived in the first plugin that needed it.
+ */
+import { resolveMapProvider } from '../composables/useMapProvider.js'
+import { ensureEngineScript, engineGlobal, ensureEngineCSS, engineStylesheetUrl, engineUiLocale } from '../composables/useMapInstance.js'
+import { resolveNamedStyle } from '../styles/registry.js'
+import { installRtlTextPlugin } from './rtl-text.js'
+import { localizeBasemapLabels } from './localize-labels.js'
+import { addMapAttribution } from '../composables/useMapAttribution.js'
+
+/** The reader's language, from the host's config first and the page second. */
+export function readerLang(profileConfig) {
+ const pc = profileConfig || {}
+ if (pc.locale || pc.lang) return String(pc.locale || pc.lang)
+ try { return new URLSearchParams(location.search).get('lang') || document.documentElement.lang || 'en' } catch { return 'en' }
+}
+
+/**
+ * @returns {Promise<{ map, provider } | { error: string, provider }>} resolves once the style has loaded
+ */
+export async function createWhiteboardMap(el, profileConfig, {
+ center = [0, 0], zoom = 1, whiteboard = true, boundaries = false, background = '#f3f3f1', token = '',
+} = {}) {
+ const provider = resolveMapProvider(profileConfig)
+ try { await ensureEngineScript(provider) } catch (_) { /* reported below */ }
+ const gl = engineGlobal(provider)
+ if (!gl) return { provider, error: provider === 'maplibre' ? 'MapLibre' : 'Mapbox' }
+ installRtlTextPlugin(gl)
+ const css = engineStylesheetUrl(provider)
+ if (css) await ensureEngineCSS(el, css)
+ const lang = readerLang(profileConfig)
+ // The host's token by the same ladder the production maps use: profile-config tk, then the token
+ // ProfileLoader provides (a tokenUrl answer, or the page's shared token), then the page global.
+ const tk = profileConfig?.tk || token || (typeof window !== 'undefined' && window.MAP_TOKEN) || ''
+ const map = new gl.Map({
+ container: el,
+ style: resolveNamedStyle('paper', provider).style || 'mapbox://styles/mapbox/light-v11',
+ accessToken: tk,
+ locale: engineUiLocale(lang, provider),
+ center, zoom, projection: 'mercator', attributionControl: false,
+ renderWorldCopies: !whiteboard,
+ })
+ addMapAttribution(map, { boundaries, basemap: !whiteboard })
+ await new Promise((res) => map.once('load', res))
+ if (whiteboard) {
+ for (const l of map.getStyle().layers || []) {
+ try {
+ if (l.type === 'background') map.setPaintProperty(l.id, 'background-color', background)
+ else map.setLayoutProperty(l.id, 'visibility', 'none')
+ } catch (_) { /* a layer the engine will not touch is left as it is */ }
+ }
+ } else {
+ try { localizeBasemapLabels(map, lang) } catch (_) { /* a nicety, never a broken map */ }
+ }
+ return { map, provider }
+}
+
+/** Reduced motion, read LIVE (never cached): under it every camera change is a jump. */
+export function reducedMotion() {
+ try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches } catch (_) { return false }
+}
+
+export default createWhiteboardMap
diff --git a/package.json b/package.json
index 7e7722593..54a06be5b 100644
--- a/package.json
+++ b/package.json
@@ -130,6 +130,8 @@
"check:label-joins": "node internal/check-label-joins.mjs",
"check:language-code-link": "node plugin-designs/doxa-research-map/scripts/check-language-code-link.mjs",
"check:whiteboard-projection": "node internal/check-whiteboard-projection.mjs",
+ "check:frame-target": "node internal/check-frame-target.mjs",
+ "check:language-tree-model": "node internal/check-language-tree-model.mjs",
"check:tiered-points": "node internal/check-tiered-points.mjs",
"check:language-tree-layout": "node internal/check-language-tree-layout.mjs",
"check:layout-key-namespaced": "node plugin-designs/research-dashboard/scripts/check-layout-key-namespaced.mjs",