The language tree becomes anyone's, and the numbers nobody wrote down get written down
Driver brief §B. gospel-internet's GiTree had NO props and NO emits: it read a board composable and three module singletons, which is what made it gospel's rather than anybody's. Everything it reached for is now a prop, and the parts worth testing are no longer inside an SFC a gate cannot run. WHAT IS ACTUALLY IN THE COMPONENT: wiring, 160 lines. The drawing is the whiteboard tiered-points layer, the surface is whiteboard-map.js (moved out of gospel's plugin -- 67 lines whose every import already came from library, so it was never theirs), the hierarchy is language-tree-model.js and the camera is frame-target.js. Four modules, four headless gates. An SFC cannot be executed by a check, so it should hold as little as possible. THREE BEHAVIOURS PINNED BECAUSE THEY ARE EASY TO STATE WRONGLY: FRAME ON SELECT, NOT ON CHANGE. Clearing does not re-frame; the camera stays where the reader left it. "It frames when the selection changes" is the obvious phrasing and it is wrong in exactly the case that annoys people -- being yanked somewhere after deselecting. A SELECTION THAT ARRIVES BEFORE THE MAP IS REPLAYED. GiTree survived this by ordering: its host set the node through a composable that waited for data, so the value always landed after the map. As a PROP the ordering belongs to the host, and a prop whose effect depends on WHEN you set it is a trap -- a shared deep link would work or not depending on load timing, which nobody could report. ready replays whatever `selected` is. HOVER CANNOT MOVE THE CAMERA, structurally rather than by convention: hover sets a filter on its own layer and there is no camera call on that path to remove later. AND THE CONSTANTS. gospel's maintainer inherited GiTree and had to read intent out of structure -- there is no "this took measuring" note anywhere in it. Asked which line they would flag, they named frame(). So padding 60 (the outermost nodes carry labels that extend past the point), maxZoom 8 (a two-language family must not fill the screen with its own neighbourhood gone), duration 900, and the floor zoom that stops framing one node from zooming the reader OUT are now stated with their reasons and asserted. Two branch rules with them, both of which a tidy-up would erase: reduced motion is a DIFFERENT CALL (jumpTo, not easeTo with duration 0 -- the engines do not treat those as one), and a single point CENTRES rather than fitting a degenerate box, because a one-language family is ordinary and fitBounds on no extent zooms to nothing. THE ROLLUP IS OVER THE READER'S SCOPE. "A family shows the worst colour below it" is only true relative to what is currently on the board: a family that is red overall can be green inside a filter. Handing the rollup a whole subtree makes the colours quietly disagree with the board -- no error, no blank, just a wrong fact about a real people group. Asserted in both directions, and colorFor stays injectable because nothing in the drawing depends on the rollup being worst-of. THE CLICK TOGGLE MOVED TO THE HOST, and that is a change rather than a codification: GiTree cleared the selection when you re-clicked the selected node, and that toggle lived in the component's own handler. A host that wants it must now write it. In exchange the chart holds no selection state. gospel's UI is not wired and their boardEngine.js is untouched on main; they adopt on their own schedule.
This commit is contained in:
parent
d3ee0bb6ec
commit
fb769b3e86
7 changed files with 639 additions and 0 deletions
84
internal/check-frame-target.mjs
Normal file
84
internal/check-frame-target.mjs
Normal file
|
|
@ -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.')
|
||||
90
internal/check-language-tree-model.mjs
Normal file
90
internal/check-language-tree-model.mjs
Normal file
|
|
@ -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.')
|
||||
160
library/charts/LanguageTreeChart.vue
Normal file
160
library/charts/LanguageTreeChart.vue
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
<script setup>
|
||||
/**
|
||||
* LanguageTreeChart.vue — every language family a roster belongs to, as a radial tree on a whiteboard.
|
||||
*
|
||||
* Promoted from gospel-internet's GiTree (Driver brief §B). It is deliberately THIN: the drawing is
|
||||
* library/charts/whiteboard/tiered-points.js, the surface is library/maps/whiteboard-map.js, the
|
||||
* hierarchy is library/charts/whiteboard/language-tree-model.js and the camera is
|
||||
* library/charts/whiteboard/frame-target.js — all four tested headlessly. What is left here is wiring,
|
||||
* because an SFC cannot be run by a gate and so should hold as little as possible.
|
||||
*
|
||||
* ★ PROPS IN, EVENTS OUT. GiTree had NO props and NO emits: it read a board composable and a module
|
||||
* singleton, which is what made it gospel's rather than anybody's. Everything it reached for is now a
|
||||
* prop, and `selected` is a v-model so the HOST decides what a click means.
|
||||
*
|
||||
* ★ THE CLICK TOGGLE IS THE HOST'S, DELIBERATELY. GiTree cleared the selection when you clicked the
|
||||
* selected node again — and that toggle lived inside the component's own click handler, not at the call
|
||||
* site. Moving it out MOVES behaviour rather than codifying it, which is worth saying plainly: a host
|
||||
* that wants the toggle must write it. In exchange the chart holds no selection state at all.
|
||||
*
|
||||
* ★ FRAME ON SELECT, NOT ON CHANGE. Clearing does NOT re-frame: the camera stays where the reader left
|
||||
* it, because yanking it back is a motion nobody asked for. "Frames when the selection changes" is the
|
||||
* obvious phrasing and it is wrong in exactly one case, which is the case that annoys people.
|
||||
*
|
||||
* ★ AND A SELECTION SET BEFORE THE MAP IS READY IS REPLAYED. GiTree survived this by ordering: its host
|
||||
* set the node through a composable that waited for data, so the value always arrived after the map. As
|
||||
* a PROP the ordering belongs to the host, and a prop whose effect depends on WHEN you set it is a trap
|
||||
* for every future consumer — a shared deep link would work or not depending on load timing, which is
|
||||
* unreportable. So ready replays whatever `selected` currently is.
|
||||
*/
|
||||
import { ref, shallowRef, computed, watch, onMounted, onBeforeUnmount, markRaw } from 'vue'
|
||||
import { createWhiteboardMap, reducedMotion as prefersReducedMotion } from '@map/maps/whiteboard-map.js'
|
||||
import { addTieredPointLayers } from '@map/charts/whiteboard/tiered-points.js'
|
||||
import { indexTree, nodeFC, linkFC, subtreeCoords, worstOfDescendants } from '@map/charts/whiteboard/language-tree-model.js'
|
||||
import { frameTarget, applyFrame } from '@map/charts/whiteboard/frame-target.js'
|
||||
import { labelFont } from '@map/maps/label-font.js'
|
||||
|
||||
const props = defineProps({
|
||||
/** the built tree: { nodes: [...] } from library/scripts/build-language-tree.mjs */
|
||||
tree: { type: Object, default: null },
|
||||
/** ids or ISO codes currently on the board; null means everything is in scope */
|
||||
inScope: { type: Object, default: null },
|
||||
/** v-model: the selected node id, '' for none */
|
||||
selected: { type: String, default: '' },
|
||||
/** a node id to point at; never moves the camera */
|
||||
hovered: { type: String, default: '' },
|
||||
/** (key, fallback) => string */
|
||||
t: { type: Function, default: (k, en) => en || k },
|
||||
/** (node, { index, inScope }) => colour; defaults to the worst status among IN-SCOPE descendants */
|
||||
colorFor: { type: Function, default: null },
|
||||
/** engine config: { provider, tk, lang } — the same profile-config shape the maps take */
|
||||
profileConfig: { type: Object, default: null },
|
||||
token: { type: String, default: '' },
|
||||
/** override the media query, for hosts that expose their own setting */
|
||||
reducedMotion: { type: Boolean, default: null },
|
||||
})
|
||||
const emit = defineEmits(['update:selected', 'node-click', 'ready', 'error'])
|
||||
|
||||
const el = ref(null)
|
||||
const mapRef = shallowRef(null)
|
||||
const ready = ref(false)
|
||||
const err = ref('')
|
||||
let layers = null
|
||||
let unmounted = false
|
||||
|
||||
const index = computed(() => (props.tree?.nodes ? indexTree(props.tree.nodes) : null))
|
||||
const PALETTE = { g: '#1f8f5f', y: '#e3a82b', r: '#b42318' }
|
||||
const colour = (n, ctx) => {
|
||||
if (props.colorFor) return props.colorFor(n, ctx)
|
||||
const st = worstOfDescendants(ctx.index, n.id, { inScope: ctx.inScope })
|
||||
return PALETTE[st] || '#8d968e'
|
||||
}
|
||||
const points = computed(() => (index.value ? nodeFC(index.value, { colorFor: colour, inScope: props.inScope }) : null))
|
||||
|
||||
// One symbol layer per tier, each entering at its own zoom: families first, then languages, then
|
||||
// branches, then dialects. The engine resolves the collisions; this only says what may appear when.
|
||||
const TIERS = [
|
||||
{ tier: 'f', minzoom: 0, size: [0, 10, 1, 11, 6, 15], weight: 'bold', offset: 1.1, filter: ['==', ['get', 'root'], 1] },
|
||||
{ tier: 'l', minzoom: 3.6, size: [3.6, 10, 8, 13], offset: 0.8 },
|
||||
{ tier: 'b', minzoom: 4.4, size: [4.4, 9, 8, 11], offset: 0.7, muted: true },
|
||||
{ tier: 'd', minzoom: 5.6, size: [5.6, 9, 9, 11], offset: 0.7, muted: true },
|
||||
]
|
||||
|
||||
const motion = () => (props.reducedMotion === null ? prefersReducedMotion() : props.reducedMotion)
|
||||
|
||||
function frameSelection(id) {
|
||||
const map = mapRef.value
|
||||
if (!map || !ready.value || !id || !index.value) return
|
||||
applyFrame(map, frameTarget(subtreeCoords(index.value, id), {
|
||||
reducedMotion: motion(), currentZoom: map.getZoom ? map.getZoom() : 0,
|
||||
}))
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!el.value || !props.tree?.nodes?.length) { err.value = props.t('tree.noData', 'No tree data'); return }
|
||||
const made = await createWhiteboardMap(el.value, props.profileConfig, { token: props.token })
|
||||
if (unmounted) { try { made.map?.remove() } catch (_) {} return }
|
||||
if (made.error) { err.value = made.error; emit('error', made.error); return }
|
||||
const map = made.map
|
||||
mapRef.value = markRaw(map)
|
||||
|
||||
map.addSource('lt-links', { type: 'geojson', data: linkFC(index.value) })
|
||||
map.addLayer({
|
||||
id: 'lt-links', type: 'line', source: 'lt-links',
|
||||
paint: { 'line-color': '#8d968e', 'line-width': ['interpolate', ['linear'], ['zoom'], 1, 0.3, 7, 1], 'line-opacity': 0.4 },
|
||||
})
|
||||
layers = addTieredPointLayers(map, {
|
||||
sourceId: 'lt', data: points.value, tiers: TIERS,
|
||||
colorExpr: ['get', 'colour'],
|
||||
radiusExpr: ['interpolate', ['linear'], ['zoom'], 1, ['case', ['==', ['get', 'root'], 1], 3, 1.4], 8, ['case', ['==', ['get', 'root'], 1], 7, 4]],
|
||||
opacityExpr: ['case', ['==', ['get', 'm'], 0], 0.15, 0.95],
|
||||
dish: { filter: ['==', ['get', 'root'], 1], radiusExpr: ['interpolate', ['exponential', 2], ['zoom'], 1, ['*', ['get', 'R'], 1], 8, ['*', ['get', 'R'], 128]] },
|
||||
font: labelFont,
|
||||
// The chart reports the click; it does NOT decide what a click means, and it holds no selection.
|
||||
onClick: (id, f) => { emit('node-click', id, f); emit('update:selected', id) },
|
||||
onHover: (id) => { if (id !== props.hovered) emit('update:hovered', id) },
|
||||
})
|
||||
|
||||
ready.value = true
|
||||
// The opening view: the whole tapestry, instantly. An opening view is not a motion the reader asked
|
||||
// for, so it is never animated however the motion setting reads.
|
||||
const all = points.value.features.filter((f) => f.properties.root === 1).map((f) => f.geometry.coordinates)
|
||||
applyFrame(map, frameTarget(all, { reducedMotion: true }))
|
||||
// ...and THEN replay whatever the host already selected, so a deep link works regardless of ordering.
|
||||
if (props.selected) { layers.setSelected(props.selected); frameSelection(props.selected) }
|
||||
if (props.hovered) layers.setHovered(props.hovered)
|
||||
emit('ready', map)
|
||||
})
|
||||
|
||||
watch(() => props.selected, (id) => {
|
||||
if (!layers || !ready.value) return
|
||||
layers.setSelected(id)
|
||||
if (id) frameSelection(id) // frame on SELECT; clearing leaves the camera alone
|
||||
})
|
||||
// Hover only ever sets a filter. There is no camera call on this path, structurally.
|
||||
watch(() => props.hovered, (id) => { if (layers && ready.value) layers.setHovered(id) })
|
||||
watch(points, (fc) => { const m = mapRef.value; if (m && fc) { try { m.getSource('lt').setData(fc) } catch (_) {} } })
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unmounted = true
|
||||
try { layers?.destroy() } catch (_) {}
|
||||
try { mapRef.value?.remove() } catch (_) {}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ltc">
|
||||
<div ref="el" class="ltc-map" role="region"
|
||||
:aria-label="t('tree.aria', 'Language tree: every language family of these people groups')"></div>
|
||||
<div v-if="err" class="ltc-msg" role="alert">{{ err }}</div>
|
||||
<div v-else-if="!ready" class="ltc-msg" role="status">{{ t('tree.loading', 'Loading…') }}</div>
|
||||
<slot name="actions" :map="mapRef" />
|
||||
<slot name="card" :selected="selected" :node="index && selected ? index.byId.get(selected) : null" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ltc { position: absolute; inset: 0; }
|
||||
.ltc-map { position: absolute; inset: 0; }
|
||||
.ltc-msg { position: absolute; inset: 0; display: grid; place-items: center; font: 13px/1.4 system-ui, sans-serif; color: #6d766e; }
|
||||
</style>
|
||||
90
library/charts/whiteboard/frame-target.js
Normal file
90
library/charts/whiteboard/frame-target.js
Normal file
|
|
@ -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
|
||||
131
library/charts/whiteboard/language-tree-model.js
Normal file
131
library/charts/whiteboard/language-tree-model.js
Normal file
|
|
@ -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 }
|
||||
}
|
||||
82
library/maps/whiteboard-map.js
Normal file
82
library/maps/whiteboard-map.js
Normal file
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue