Merge branch 'bundler-2/whiteboard' into bundler-2/handoff

# Conflicts:
#	internal/check-whiteboard-projection.mjs
#	library/maps/whiteboard-map.js
#	library/maps/whiteboard-projection.js
#	library/scripts/build-language-tree.mjs
This commit is contained in:
Doxa Maps 2026-09-29 14:50:39 +00:00
commit a7930ca0ea
2 changed files with 200 additions and 0 deletions

View 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>

View file

@ -14,6 +14,46 @@
* 0.9996 at the centre, 0.9729, 0.8993, 0.7953, and 0.6794 at the outer edge — those numbers ARE the
* correction, not an error in it, and they are what make the circle round on screen.
*
* ★ WHAT extentFraction CANNOT BREAK, AND WHAT IT CAN. Roundness is exact at EVERY value of it: this
* is the exact inverse of the renderer's own transform, so a circle measured in MERCATOR space — the
* space the map actually draws in — comes back at 0.000% warp whether the fraction is 0.17 or 0.95.
* A consumer with a hard geometric invariant (gospel's bounded set needs an 0.92r/1.12r band that
* nothing may sit on) does NOT have to defend it against this parameter.
*
* ★ MEASURE IT IN MERCATOR SPACE OR THE ANSWER IS NOISE. Treating lng/lat AS IF it were the screen
* reports a warp that grows with the fraction — 17.5% at 0.17, 35.3% at 0.25, 62.9% at 0.35 — and none
* of it is rendered. Those numbers are the CORRECTION doing its job, read in the wrong coordinate
* system. Screen y is proportional to ln(tan(pi/4 + lat/2)), never to lat.
*
* ★ AND THERE IS A SECOND, SUBTLER WAY TO GET THE SPACE WRONG: correcting the X axis. Web Mercator's
* screen x is proportional to LONGITUDE ALONE -- no cos(lat) term. That absence is not an oversight in
* the formula, it is the reason the projection is conformal and the reason circles stay circles. A
* reader who has just been told "it is a projection, not a scale" naturally reaches for the
* plate-carree correction x = lng * cos(lat), which the renderer never performs, and then measures the
* damage their own extra transform did. MEASURED here on a ring at extent 260: true Mercator gives
* 0.000% warp at every fraction, while inserting cos(lat) into x invents 12.4% at 0.17, 23.8% at 0.25,
* 39.8% at 0.35 and 56.5% at 0.45 -- a clean, plausible, monotonic table describing nothing.
* (Both wrong spaces produce a table that GROWS with the fraction, and their magnitudes differ by which
* wrong transform you picked, so the shape of the numbers cannot tell you which mistake you made. Only
* comparing against x = lng, y proportional to ln(tan(pi/4 + lat/2)) can.)
* This happened to a careful reader within an hour of the first correction, in the opposite direction:
* first a projection mistaken for a scale, then an over-correction applying a transform that is not
* there. If a measurement of this module ever reports a threshold, suspect the harness before the maths
* -- roundness here cannot degrade, because it is the exact inverse of what the renderer does.
*
* ★ THE REAL CEILING IS LATITUDE, not roundness. The drawing's outermost point reaches |lat| 52 deg at
* 0.17, 77 at 0.35, 83 at 0.45 and 89.7 at 0.95 — and Web Mercator is undefined at the poles, with the
* engines clamping around 85. So the parameter's usable range is bounded by how close to the pole the
* outermost point lands, not by any deformation of shape.
*
* ★ AND THAT CEILING BELONGS TO extentFraction ALONE — NOT to a chart's extent (gospel, measured, and
* it kills the fix someone would reach for first). K = extentFraction / extent and the outermost point
* sits at u = extent, so u*K = extentFraction whatever the extent is: |lat| is 52.070 at frac 0.17 for
* extents 100, 228, 260 and 1000 alike, identical to three decimals. A consumer whose drawing hits the
* clamp therefore gains NOTHING by choosing a smaller extent — extent is a unit choice, the fraction is
* the whole ceiling. The useful corollary is that one assertion on extentFraction covers every chart;
* no per-consumer latitude guard is needed.
*
* ★ EXTRACTED, NOT WRITTEN. This was private inside library/scripts/build-language-tree.mjs and would
* have been copied the moment a second chart needed it — gospel's set charts were about to scale
* linearly by one number, which renders concentric rings as ovals, worst at the outer ring where the