doxa-maps/library/charts/LanguageTreeChart.vue
Doxa Maps d3cdc2c3bc Two gates the branch backlog turned red
Seven Driver citations came back with the merged branches. Each keeps its
engineering reason and its date, and loses the speaker: the overlap report, the
poster's legend rule, the network-change defect, the chart's provenance.

check:plural-forms crashed rather than failed. It built its locale list by
listing the catalogue directory, so a README.md moved in beside the locale
folders became a 'locale', passed the exists check because a file does exist,
and crashed readdirSync on a non-directory. It now filters to directories. The
gate on development has the same line and only survives because that README is
not there yet -- so this is a fix for development, not a merge artefact.

Both found by running the full 103-gate chain; the shortened chain the merge
produced ran neither.
2026-09-29 14:55:35 +00:00

160 lines
8.6 KiB
Vue

<script setup>
/**
* LanguageTreeChart.vue — every language family a roster belongs to, as a radial tree on a whiteboard.
*
* Promoted from gospel-internet's GiTree (product 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>