From 03c310833b55b018dfae5f967e9fc8561feeb110 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Mon, 28 Sep 2026 16:24:40 +0000 Subject: [PATCH 1/5] The whiteboard is a layer of tiered points; only the edges were ever a tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Driver brief §B, the seam under the language tree, so gospel's set charts inherit zoom, label priority and tiling from the map engine instead of re-implementing them in SVG. READ FROM GiTree RATHER THAN ASSUMED: six layer groups, exactly ONE hierarchical. The parent->child links are sourced from linkFC; the points, the selection and hover rings, the dish behind a cluster and EVERY label layer come from one point collection and know nothing about trees. gospel re-read that independently and found the same split, which is the only reason I trust it -- "the seam is generic" is the claim that should never travel on one reader. So library/charts/whiteboard/tiered-points.js takes a point FeatureCollection whose features carry id, label, tier and priority, and gives each TIER its own symbol layer with its own minzoom. That is the whole of "band names zoomed out, ring names mid, group names in": the engine is told which labels may exist at which zoom and resolves the collisions itself. Paint expressions pass through unread -- the layer has no opinion about what a colour means, which is what keeps it reusable. Selection and hover are FILTERED layers, so hover costs one setFilter and structurally cannot move a camera: there is nothing in here that could. AND THE PROJECTION IS NOT A SCALE, which is the part that would have shipped wrong. gospel asked for degrees and planned to scale by one number; their charts are CONCENTRIC RINGS, and Mercator stretches vertically by 1/cos(lat) -- 1.0154x at 10 degrees, 1.1547x at 30 -- so a linear scale renders rings as OVALS, worst at the outer ring where the labels a reader compares are. library/maps/whiteboard-projection.js is the inverse conversion, EXTRACTED from build-language-tree.mjs rather than written, because the second copy was about to be made by hand. The degree-height it emits shrinks from 0.9996 of the width at the centre to 0.6794 at the edge; that shrink IS the correction the renderer then cancels. TWO TESTS, EACH GUARDING A DIFFERENT RISK. check:whiteboard-projection reproduces the old private implementation VERBATIM as a reference and compares over 1,734 grid points EXACTLY -- the language tree is a build-time artifact, so a rounding shift would move 4,000 coordinates and look like nothing. It then asserts a ring renders round at the inner, middle and outer radii, AND that a linear scale FAILS that same assertion, because an assertion the defect can pass is measuring nothing. check:tiered-points runs gospel's CENTERED SET through the layer: 2,079 points, 3 tiers, priority by population, and NO EDGES. The absent links layer is the evidence -- if the whiteboard draws this chart with no line layer anywhere, edges are separable in fact rather than in my reading. The map is a recording fake, so the assertions are about what the module DOES; a regex over the source would pass on a module that never called addLayer. Negative-tested: making the projection linear, changing its rounding, dropping the label priority, or giving every tier the same minzoom each turn one of them red. gospel's UI is NOT wired -- their component is theirs to write, later, against a module that now exists. Their boardEngine.js stays where it is until the chart lands. --- internal/check-tiered-points.mjs | 125 ++++++++++++++++++ internal/check-whiteboard-projection.mjs | 100 ++++++++++++++ library/charts/whiteboard/tiered-points.js | 145 +++++++++++++++++++++ library/maps/whiteboard-projection.js | 53 ++++++++ library/scripts/build-language-tree.mjs | 15 ++- package.json | 2 + 6 files changed, 433 insertions(+), 7 deletions(-) create mode 100644 internal/check-tiered-points.mjs create mode 100644 internal/check-whiteboard-projection.mjs create mode 100644 library/charts/whiteboard/tiered-points.js create mode 100644 library/maps/whiteboard-projection.js diff --git a/internal/check-tiered-points.mjs b/internal/check-tiered-points.mjs new file mode 100644 index 000000000..91a0a4297 --- /dev/null +++ b/internal/check-tiered-points.mjs @@ -0,0 +1,125 @@ +#!/usr/bin/env node +/** + * check-tiered-points — the whiteboard layer, proven by a chart that is NOT a tree. + * + * ★ THE FIXTURE IS THE POINT. A shared layer designed against ONE consumer is a tree with the word + * "generic" written on it, and this repo produced three of those today. So the subject here is + * gospel-internet's CENTERED SET, spec'd by its own author: ~2,100 group points on 10 concentric + * rings, 3 tiers (band / ring / group), priority = population so the biggest names win a collision, + * and NO EDGES AT ALL. The last part is what makes it evidence rather than decoration — if the layer + * draws this chart with no links layer anywhere, then edges really are separable from the whiteboard + * and not load-bearing in it. + * + * ★ AND IT IS EXERCISED, NOT GREPPED. The map is a recording fake: every addSource/addLayer/on call is + * captured, so these assertions are about what the module DOES, not about what its source says. A + * regex over the file would pass on a module that never called addLayer. + * + * Headless, no browser, no engine. 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 { addTieredPointLayers } = await import(resolve(ROOT, 'library/charts/whiteboard/tiered-points.js')) +const { whiteboardProjection } = await import(resolve(ROOT, 'library/maps/whiteboard-projection.js')) + +const fails = [] +const ok = (what, cond, detail = '') => { if (!cond) fails.push(`${what}${detail ? `\n ${detail}` : ''}`) } + +// ── gospel's centered set, laid out the way they describe it: innerR 30, ringGap 22, 10 rings ───── +const INNER = 30, GAP = 22, RINGS = 10 +const project = whiteboardProjection({ extent: INNER + (RINGS - 1) * GAP }) +const features = [] +let n = 0 +for (let ring = 0; ring < RINGS; ring++) { + const r = INNER + ring * GAP + const count = 40 + ring * 37 // ~2,100 across the ten rings + for (let i = 0; i < count; i++) { + // deterministic angle from the id, as gospel's layout is: same id, same place, every render. + const a = ((i * 2654435761) % 360) * Math.PI / 180 + features.push({ type: 'Feature', geometry: { type: 'Point', coordinates: project(r * Math.cos(a), r * Math.sin(a)) }, + properties: { id: `g${n++}`, label: `Group ${n}`, tier: 'group', k: 1000 + (n % 997), ring } }) + } + features.push({ type: 'Feature', geometry: { type: 'Point', coordinates: project(r, 0) }, + properties: { id: `ring${ring}`, label: `Ring ${ring + 1}`, tier: 'ring', k: 100000 + ring } }) +} +for (const [i, b] of ['dialect', 'language', 'family', 'nearby-only'].entries()) { + features.push({ type: 'Feature', geometry: { type: 'Point', coordinates: project(0, -(INNER + i * GAP)) }, + properties: { id: `band${i}`, label: b, tier: 'band', k: 900000 + i } }) +} +const data = { type: 'FeatureCollection', features } + +// ── a recording fake map ───────────────────────────────────────────────────────────────────────── +const makeMap = () => { + const m = { sources: [], layers: [], handlers: [], filters: {} } + m.addSource = (id, spec) => m.sources.push({ id, spec }) + m.addLayer = (spec) => m.layers.push(spec) + m.on = (ev, layer, fn) => m.handlers.push({ ev, layer, fn }) + m.off = (ev, layer, fn) => { m.handlers = m.handlers.filter((h) => !(h.ev === ev && h.layer === layer && h.fn === fn)) } + m.setFilter = (id, f) => { m.filters[id] = f } + m.removeLayer = (id) => { m.layers = m.layers.filter((l) => l.id !== id) } + m.removeSource = (id) => { m.sources = m.sources.filter((s) => s.id !== id) } + return m +} + +const TIERS = [ + { tier: 'band', minzoom: 0, weight: 'bold', offset: 1.1 }, + { tier: 'ring', minzoom: 3.6 }, + { tier: 'group', minzoom: 5.6, muted: true }, +] +const map = makeMap() +let clicked = null, hovered = 'unset' +const h = addTieredPointLayers(map, { + sourceId: 'cs', data, tiers: TIERS, + colorExpr: ['get', 'colour'], radiusExpr: 3, + onClick: (id) => { clicked = id }, onHover: (id) => { hovered = id }, +}) + +ok('the point collection is added once, promoted on its id', + map.sources.length === 1 && map.sources[0].spec.promoteId === 'id') +ok('~2,100 group points plus 10 ring labels plus 4 band labels', features.length > 2000 && features.length < 2300, + `built ${features.length}`) +// ★ NO EDGES: the whole argument for this fixture. +ok('NOTHING creates a line layer — edges are separable, not load-bearing', + map.layers.every((l) => l.type !== 'line')) +ok('one SYMBOL layer per tier, and no more', map.layers.filter((l) => l.type === 'symbol').length === TIERS.length) +for (const t of TIERS) { + const l = map.layers.find((x) => x.id === `cs-lab-${t.tier}`) + ok(`tier "${t.tier}" gets its own layer at its own minzoom`, l && l.minzoom === t.minzoom, + l ? `minzoom ${l.minzoom}, expected ${t.minzoom}` : 'layer missing') + ok(`tier "${t.tier}" resolves collisions by priority`, l && JSON.stringify(l.layout['symbol-sort-key']) === JSON.stringify(['get', 'k'])) +} +ok('a dish is NOT invented when the caller asks for none', !map.layers.some((l) => l.id === 'cs-dish')) +ok('paint expressions are passed through unread — the layer has no opinion about colour', + JSON.stringify(map.layers.find((l) => l.id === 'cs-dots').paint['circle-color']) === JSON.stringify(['get', 'colour'])) + +// ── selection and hover are FILTERS, and hover cannot move a camera because nothing here can ───── +h.setSelected('g7'); h.setHovered('g9') +ok('selection is a filter on its own layer', JSON.stringify(map.filters['cs-sel']) === JSON.stringify(['==', ['get', 'id'], 'g7'])) +ok('hover is a filter on its own layer', JSON.stringify(map.filters['cs-hover']) === JSON.stringify(['==', ['get', 'id'], 'g9'])) +ok('the module never calls a camera method', ['flyTo', 'easeTo', 'jumpTo', 'fitBounds'].every((k) => map[k] === undefined)) +const fire = (ev) => map.handlers.filter((x) => x.ev === ev).forEach((x) => x.fn({ features: [{ properties: { id: 'g42' } }] })) +fire('click'); ok('a click reports the id it was given', clicked === 'g42') +fire('mousemove'); ok('a hover reports the id it was given', hovered === 'g42') +map.handlers.filter((x) => x.ev === 'mouseleave').forEach((x) => x.fn()) +ok('leaving clears the hover rather than leaving it stuck', hovered === '') + +// ── the dish IS available for the bounded set, without a new layer type ────────────────────────── +const m2 = makeMap() +addTieredPointLayers(m2, { sourceId: 'bs', data, tiers: [TIERS[0]], dish: { filter: ['==', ['get', 'tier'], 'band'], radiusExpr: 40 } }) +ok("the bounded set's boundary circle is the same dish layer, not a new type", + m2.layers.some((l) => l.id === 'bs-dish' && l.type === 'circle')) + +// ── destroy leaves nothing behind ──────────────────────────────────────────────────────────────── +h.destroy() +ok('destroy removes every layer it added', map.layers.length === 0) +ok('destroy removes the source', map.sources.length === 0) +ok('destroy detaches its handlers', map.handlers.length === 0) + +if (fails.length) { + console.error('TIERED-POINTS: FAIL') + for (const f of fails) console.error(` ✗ ${f}`) + process.exit(1) +} +console.log(`TIERED-POINTS: PASS — ${features.length} points of a NON-TREE chart (gospel's centered set) render through the ` + + 'shared layer with no line layer anywhere, one label layer per tier at its own zoom, and selection and hover as filters.') diff --git a/internal/check-whiteboard-projection.mjs b/internal/check-whiteboard-projection.mjs new file mode 100644 index 000000000..9865b63f2 --- /dev/null +++ b/internal/check-whiteboard-projection.mjs @@ -0,0 +1,100 @@ +#!/usr/bin/env node +/** + * check-whiteboard-projection — the shared projection is byte-identical to the one it replaced, and it + * keeps a circle ROUND on screen. + * + * TWO ARMS, FOR TWO DIFFERENT RISKS. + * + * ARM A — THE EXTRACTION MUST NOT MOVE THE LANGUAGE TREE. whiteboardProjection was lifted out of + * library/scripts/build-language-tree.mjs, which is a BUILD-TIME artifact: if the maths shifted by a + * rounding step, 4,000 coordinates would change, every diff of that file would be unreadable, and + * nothing would look wrong. So the old private implementation is reproduced here verbatim as a + * REFERENCE and the two are compared over a grid, exactly — not approximately. + * + * ARM B — THE DEFECT THE NAIVE VERSION WOULD HAVE SHIPPED, and the reason this is a module at all. + * Mercator stretches vertically by 1/cos(latitude). A chart mapped LINEARLY into degrees is therefore + * rendered taller the further it sits from the equator, so a circle becomes an OVAL, worst at the + * edges. gospel's set charts are CONCENTRIC RINGS — the shape that shows it most and the one whose + * outer ring carries the labels a reader compares. This asserts the thing that is actually wanted: + * after the renderer's own stretch, a ring is still round, at the inner ring AND at the outer one. + * Written because gospel was about to scale by one number, and it would have looked plausible. + * + * Headless, no browser, no map. 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 { whiteboardProjection } = await import(resolve(ROOT, 'library/maps/whiteboard-projection.js')) + +const fails = [] + +// ── ARM A: the ORIGINAL, copied verbatim from build-language-tree.mjs before the extraction ────── +const referenceProjection = (ext, extentFraction) => { + const K = extentFraction / ext + return (u, v) => { + const X = 0.5 + u * K, Y = 0.5 - v * K + const lng = (X - 0.5) * 360 + const lat = Math.atan(Math.sinh(Math.PI * (1 - 2 * Y))) * 180 / Math.PI + return [Math.round(lng * 1e5) / 1e5, Math.round(lat * 1e5) / 1e5] + } +} +let compared = 0, drift = null +for (const [ext, frac] of [[1, 0.17], [228, 0.17], [260, 0.17], [17.5, 0.17], [228, 0.08], [228, 0.34]]) { + const ours = whiteboardProjection({ extent: ext, extentFraction: frac }) + const ref = referenceProjection(ext, frac) + if (ours.K !== frac / ext) fails.push(`K differs for extent ${ext}, fraction ${frac}: ${ours.K} vs ${frac / ext}`) + for (let u = -ext; u <= ext; u += ext / 8) { + for (let v = -ext; v <= ext; v += ext / 8) { + const a = ours(u, v), b = ref(u, v) + compared++ + if ((a[0] !== b[0] || a[1] !== b[1]) && !drift) drift = { u, v, ext, frac, a, b } + } + } +} +if (drift) { + fails.push(`the extracted projection DIFFERS from the one it replaced at (${drift.u}, ${drift.v}), ` + + `extent ${drift.ext}: got [${drift.a}], the original gives [${drift.b}]. Every built tree would move.`) +} + +// ── ARM B: a ring stays round AFTER the renderer's own Mercator stretch ────────────────────────── +// Screen y is proportional to the Mercator y of a latitude, not to the latitude itself. So "round on +// screen" means the projected ring's extent in MERCATOR space is square — which is the only space in +// which the comparison means anything. +const mercY = (latDeg) => Math.log(Math.tan(Math.PI / 4 + (latDeg * Math.PI / 180) / 2)) +const roundness = (project, radius) => { + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity + for (let i = 0; i < 360; i++) { + const a = (i * Math.PI) / 180 + const [lng, lat] = project(radius * Math.cos(a), radius * Math.sin(a)) + const y = mercY(lat) + minX = Math.min(minX, lng); maxX = Math.max(maxX, lng) + minY = Math.min(minY, y); maxY = Math.max(maxY, y) + } + // lng degrees and mercator y are both linear in screen px, related by the same constant (pi/180), + // so the ratio below is the rendered aspect ratio of the ring. + return ((maxY - minY) / (Math.PI / 180)) / (maxX - minX) +} +// gospel's centered set: inner ring 30, outer ring 228 (innerR 30 + 9 x ringGap 22). +const project = whiteboardProjection({ extent: 228 }) +for (const [name, r] of [['inner ring (30)', 30], ['mid ring (129)', 129], ['outer ring (228)', 228]]) { + const ratio = roundness(project, r) + if (Math.abs(ratio - 1) > 0.01) { + fails.push(`${name} does not render round: aspect ${ratio.toFixed(4)} (1.0 is round). ` + + 'A ring drawn as an oval is the defect a LINEAR scale into degrees produces.') + } +} +// ...and the control: a linear scale MUST fail the same assertion, or the assertion proves nothing. +const linear = (() => { const K = 0.17 / 228; const f = (u, v) => [u * K * 360, v * K * 360]; return f })() +if (Math.abs(roundness(linear, 228) - 1) <= 0.01) { + fails.push('a LINEAR scale passes the roundness assertion, so the assertion cannot detect the defect ' + + 'it exists for — it is measuring nothing.') +} + +if (fails.length) { + console.error('WHITEBOARD-PROJECTION: FAIL') + for (const f of fails) console.error(` ✗ ${f}`) + process.exit(1) +} +console.log(`WHITEBOARD-PROJECTION: PASS — identical to the implementation it replaced over ${compared} grid points, ` + + 'and a ring renders round at the inner, middle and outer radii while a linear scale does not.') diff --git a/library/charts/whiteboard/tiered-points.js b/library/charts/whiteboard/tiered-points.js new file mode 100644 index 000000000..5ade74769 --- /dev/null +++ b/library/charts/whiteboard/tiered-points.js @@ -0,0 +1,145 @@ +/** + * tiered-points.js — a whiteboard of TIERED POINTS: the zoom, label-priority and hit behaviour that a + * chart gets from the map engine instead of reimplementing. + * + * ★ WHY THIS IS NOT "THE TREE CHART". Read against gospel-internet's GiTree, six layer groups exist and + * exactly ONE is hierarchical — the parent->child links. The other five (the points, the selection and + * hover rings, the dish behind a cluster, and every label layer) are sourced from one point collection + * and know nothing about trees. gospel re-read that independently and found the same split. So the + * reusable thing is a whiteboard of points that carry a TIER, and the language tree is this PLUS edges + * PLUS hierarchical framing. + * + * ★ WHAT THE ENGINE IS ACTUALLY BEING USED FOR, since it is unusual: not a map. A GeoJSON source is + * tiled per zoom, so zoomed out it draws LESS; symbol placement never lets two labels overlap and + * reveals them in priority order as the reader zooms. That is a large amount of hard behaviour — + * collision, tiling, hit-testing — obtained by describing the data rather than writing it. An SVG + * chart keeps every node in the page at every zoom, which is what makes a big one slow. + * + * ★ TIERS ARE JUST STRINGS, and each gets its OWN layer with its OWN minzoom. That is the whole + * mechanism behind "band names zoomed out, ring names mid-zoom, group names zoomed in": the engine is + * told which labels may appear when, and it resolves the rest. The language tree's tiers are + * family/branch/language/dialect; a centered set's are band/ring/group. Same shape, different words. + * + * COORDINATES ARE DEGREES. A caller lays out in its own units and converts with + * library/maps/whiteboard-projection.js, which is a PROJECTION and not a scale — see that file for why + * a linear scale renders concentric rings as ovals. + * + * No framework import, so a gate can execute it against a recording fake map. + */ + +/** The default label ramp for a tier, when a caller states only a minzoom. */ +const DEFAULT_SIZE = (minzoom) => [minzoom, 10, minzoom + 4, 13] + +/** + * @param {Object} map a Mapbox/MapLibre map (or anything implementing addSource/addLayer/on) + * @param {Object} opts + * @param {Object} opts.data point FeatureCollection; each feature carries id, label, tier, priority + * @param {Array} opts.tiers [{ tier, minzoom, size?, weight?, offset?, muted?, filter? }] — ONE + * symbol layer per entry, in the order given + * @param {string} [opts.sourceId] + * @param {*} [opts.colorExpr] paint expressions, passed through UNREAD: this module has no opinion + * @param {*} [opts.radiusExpr] about what a colour means, which is what keeps it reusable + * @param {*} [opts.opacityExpr] + * @param {Object} [opts.dish] { filter, radiusExpr, color?, stroke? } — a circle drawn BEHIND the + * points; a family's enclosing circle, or a bounded set's boundary + * @param {Object} [opts.keys] { id, label, tier, priority } property names on the features + * @param {Function} [opts.font] (map, { weight }) => layout properties for the label font + * @param {Function} [opts.onClick] (id, feature) => void + * @param {Function} [opts.onHover] (id | '', feature | null) => void + * @returns {{ layerIds: string[], setSelected: Function, setHovered: Function, destroy: Function }} + */ +export function addTieredPointLayers(map, { + data, + tiers = [], + sourceId = 'wb-points', + colorExpr = '#3b463d', + radiusExpr = 3, + opacityExpr = 1, + dish = null, + keys = {}, + font = null, + onClick = null, + onHover = null, +} = {}) { + const K = { id: 'id', label: 'label', tier: 'tier', priority: 'k', ...keys } + const layerIds = [] + const add = (spec) => { map.addLayer(spec); layerIds.push(spec.id) } + + map.addSource(sourceId, { type: 'geojson', data, promoteId: K.id }) + + // A circle behind a cluster. Optional, because only some charts have one — but it is the SAME layer + // for a language family's dish and a bounded set's boundary, so it is not a new type for either. + if (dish) { + add({ + id: `${sourceId}-dish`, type: 'circle', source: sourceId, filter: dish.filter, + paint: { + 'circle-radius': dish.radiusExpr, 'circle-color': dish.color || '#ffffff', 'circle-opacity': 0.55, + 'circle-stroke-color': dish.stroke || '#d1d4cd', 'circle-stroke-width': 1, 'circle-pitch-alignment': 'map', + }, + }) + } + + add({ + id: `${sourceId}-dots`, type: 'circle', source: sourceId, + paint: { 'circle-radius': radiusExpr, 'circle-color': colorExpr, 'circle-opacity': opacityExpr }, + // Higher priority draws on top AND wins a label collision; one property, both effects. + layout: { 'circle-sort-key': ['-', 0, ['get', K.priority]] }, + }) + + // Selection and hover are FILTERED LAYERS, not re-paints of the points layer. That is what makes + // hover cost one setFilter and never a data update — and, structurally, why hover cannot move the + // camera: there is nothing here that could. + for (const kind of ['sel', 'hover']) { + add({ + id: `${sourceId}-${kind}`, type: 'circle', source: sourceId, + filter: ['==', ['get', K.id], ''], + paint: { + 'circle-radius': radiusExpr, 'circle-color': 'rgba(0,0,0,0)', + 'circle-stroke-color': kind === 'sel' ? '#3b463d' : '#e8a33d', 'circle-stroke-width': 2.5, + }, + }) + } + + // ★ ONE SYMBOL LAYER PER TIER, each with its own minzoom. This is the entire "labels appear as you + // zoom in" behaviour: the engine is told which labels may exist at which zoom and does the collision. + for (const t of tiers) { + add({ + id: `${sourceId}-lab-${t.tier}`, type: 'symbol', source: sourceId, minzoom: t.minzoom ?? 0, + filter: t.filter || ['==', ['get', K.tier], t.tier], + layout: { + 'text-field': ['get', K.label], + 'text-size': ['interpolate', ['linear'], ['zoom'], ...(t.size || DEFAULT_SIZE(t.minzoom ?? 0))], + 'text-anchor': 'top', 'text-offset': [0, t.offset ?? 0.8], 'text-max-width': t.maxWidth ?? 9, + 'symbol-sort-key': ['get', K.priority], + ...(font ? font(map, { weight: t.weight || 'regular' }) : {}), + }, + paint: { + 'text-color': t.muted ? '#6d766e' : '#3b463d', + 'text-halo-color': '#f3f3f1', 'text-halo-width': 1.4, + }, + }) + } + + const dots = `${sourceId}-dots` + const handlers = [] + const on = (ev, layer, fn) => { map.on(ev, layer, fn); handlers.push([ev, layer, fn]) } + if (onClick) on('click', dots, (e) => { const f = e.features && e.features[0]; if (f) onClick(f.properties[K.id], f) }) + if (onHover) { + on('mousemove', dots, (e) => { const f = e.features && e.features[0]; onHover(f ? f.properties[K.id] : '', f || null) }) + on('mouseleave', dots, () => onHover('', null)) + } + + const setFilterById = (layer, id) => { try { map.setFilter(layer, ['==', ['get', K.id], id || '']) } catch (_) {} } + return { + layerIds, + setSelected: (id) => setFilterById(`${sourceId}-sel`, id), + setHovered: (id) => setFilterById(`${sourceId}-hover`, id), + destroy: () => { + for (const [ev, layer, fn] of handlers) { try { map.off(ev, layer, fn) } catch (_) {} } + for (const id of [...layerIds].reverse()) { try { map.removeLayer(id) } catch (_) {} } + try { map.removeSource(sourceId) } catch (_) {} + }, + } +} + +export default addTieredPointLayers diff --git a/library/maps/whiteboard-projection.js b/library/maps/whiteboard-projection.js new file mode 100644 index 000000000..bfba33b7b --- /dev/null +++ b/library/maps/whiteboard-projection.js @@ -0,0 +1,53 @@ +/** + * whiteboard-projection.js — abstract chart coordinates onto a map, undistorted. + * + * A whiteboard chart is laid out in its OWN units (the language tree in layout units, gospel's + * centered set in ring radii) and then drawn by a map engine, which reads coordinates as lng/lat and + * renders them through Web Mercator. This converts the one into the other. + * + * ★ IT IS NOT A SCALE FACTOR, AND THAT IS THE WHOLE REASON IT IS A MODULE. Mercator stretches + * vertically by 1/cos(latitude), so a drawing mapped LINEARLY into degrees is rendered progressively + * taller the further it sits from the equator: 1.0154x at 10 degrees, 1.0642x at 20, 1.1547x at 30. + * A circle becomes an oval, and worse at the edges than the middle. This applies the INVERSE of + * Mercator, so the degree-height it emits shrinks by exactly the amount the renderer will add back. + * MEASURED on a unit circle at increasing distance from centre (height as a fraction of width): + * 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. + * + * ★ 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 + * labels are. One rule, one place, before there are two copies rather than after. + */ + +/** + * How much of the Mercator square the drawing spans, per unit of its own extent. + * ★ extentFraction DECIDES HOW FAR A READER CAN ZOOM OUT, which is why it is a parameter with a + * measured default rather than a constant. The engines stop at zoom 0 (the whole square), so the + * SMALLER the drawing, the more room there is below its opening view. At 0.68 there was about one + * level of room (Driver, 2026-09-27: "I need to be able to zoom out a bit more"); at 0.17 there are two. + */ +export function whiteboardScale(extent, extentFraction = 0.17) { + return extentFraction / (extent || 1) +} + +/** + * @param {Object} o + * @param {number} o.extent the drawing's half-extent in its OWN units (its outermost point) + * @param {number} [o.extentFraction] share of the Mercator square to span; see above + * @returns {function(number, number): [number, number]} (u, v) -> [lng, lat], with `.K` (the scale) + * exposed because a caller that also sizes RADII in degrees needs the same factor. + */ +export function whiteboardProjection({ extent, extentFraction = 0.17 } = {}) { + const K = whiteboardScale(extent, extentFraction) + const project = (u, v) => { + const X = 0.5 + u * K, Y = 0.5 - v * K + const lng = (X - 0.5) * 360 + const lat = Math.atan(Math.sinh(Math.PI * (1 - 2 * Y))) * 180 / Math.PI + return [Math.round(lng * 1e5) / 1e5, Math.round(lat * 1e5) / 1e5] + } + project.K = K + return project +} + +export default whiteboardProjection diff --git a/library/scripts/build-language-tree.mjs b/library/scripts/build-language-tree.mjs index 073f66762..c4d617a42 100644 --- a/library/scripts/build-language-tree.mjs +++ b/library/scripts/build-language-tree.mjs @@ -24,6 +24,8 @@ * without re-measuring is how this drawing gets quietly worse. */ +import { whiteboardProjection } from '../maps/whiteboard-projection.js' + /** A tree whose single-child branch chains are collapsed for DRAWING; the skipped names are kept in `via`. */ function collapse(n) { n.kids = n.kids.map((k) => { @@ -159,13 +161,12 @@ export function buildLanguageTree({ // whole Mercator square), so the SMALLER the drawing, the more room there is below its opening view. // At 68% there was about one level of room (Driver, 2026-09-27: "I need to be able to zoom out a bit // more"); at 0.17 there are two. - const K = extentFraction / ext - const toLngLat = (u, v) => { - const X = 0.5 + u * K, Y = 0.5 - v * K - const lng = (X - 0.5) * 360 - const lat = Math.atan(Math.sinh(Math.PI * (1 - 2 * Y))) * 180 / Math.PI - return [Math.round(lng * 1e5) / 1e5, Math.round(lat * 1e5) / 1e5] - } + // The conversion moved to library/maps/whiteboard-projection.js so a second whiteboard chart cannot + // re-derive it — gospel's set charts were about to scale linearly into degrees, which Mercator then + // renders as ovals. Behaviour here is unchanged and check:whiteboard-projection asserts that over a + // grid, so the language tree's existing layout cannot move by this refactor. + const toLngLat = whiteboardProjection({ extent: ext, extentFraction }) + const K = toLngLat.K // Edges are NOT emitted: every node carries its parent id and both coordinates, so the chart draws each // parent->child line itself. It halves the file. diff --git a/package.json b/package.json index 98ef4424f..0c301c005 100644 --- a/package.json +++ b/package.json @@ -129,6 +129,8 @@ "check:kpi-unit": "node internal/check-kpi-unit-once.mjs", "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: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", "check:leaf-engine-per-provider": "node internal/check-leaf-engine-per-provider.mjs", From 15fc38555c7922a05f91d4d9fe149c6980de6de4 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Mon, 28 Sep 2026 16:30:49 +0000 Subject: [PATCH 2/5] The language tree becomes anyone's, and the numbers nobody wrote down get written down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/check-frame-target.mjs | 84 +++++++++ internal/check-language-tree-model.mjs | 90 ++++++++++ library/charts/LanguageTreeChart.vue | 160 ++++++++++++++++++ library/charts/whiteboard/frame-target.js | 90 ++++++++++ .../charts/whiteboard/language-tree-model.js | 131 ++++++++++++++ library/maps/whiteboard-map.js | 82 +++++++++ package.json | 2 + 7 files changed, 639 insertions(+) create mode 100644 internal/check-frame-target.mjs create mode 100644 internal/check-language-tree-model.mjs create mode 100644 library/charts/LanguageTreeChart.vue create mode 100644 library/charts/whiteboard/frame-target.js create mode 100644 library/charts/whiteboard/language-tree-model.js create mode 100644 library/maps/whiteboard-map.js 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 @@ + + + + + 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 0c301c005..c8fd5f3b7 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", From 378cce63d24abf10fa88ab1b39b5c207985ef6b2 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Mon, 28 Sep 2026 16:44:11 +0000 Subject: [PATCH 3/5] extentFraction cannot deform a shape; what it can do is run out of latitude gospel measured the bounded set's 0.92r/1.12r band against this projection and reported the gap closing between extentFraction 0.25 and 0.35, and asked for the threshold in this header. I could not reproduce it, and the disagreement is worth the header more than the threshold would have been. MEASURED IN MERCATOR SPACE -- the space the map actually draws in -- a ring comes back at 0.000% warp at 0.17, 0.35, 0.45, 0.60, 0.80 and 0.95. That is not a tolerance being met; it is exact, because this is the exact inverse of the renderer's own transform. A consumer with a hard geometric invariant does not have to defend it against this parameter at all. MEASURED IN DEGREES, treating lng/lat as if it were the screen, the same ring reports 17.5% warp at 0.17, 35.3% at 0.25 and 62.9% at 0.35. Those numbers are the CORRECTION doing its job, read in a coordinate system nothing renders in. Screen y is proportional to ln(tan(pi/4 + lat/2)), never to lat -- which is the same confusion this module exists to prevent, met from the other side. THE REAL CEILING IS LATITUDE. The 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 near 85. So the parameter's usable range is bounded by how close to the pole the drawing lands, not by any deformation of shape. That is now in the header, because it is the limit a consumer actually has to respect. AND THE CONTROL'S PLACEMENT IS NOW ARGUED RATHER THAN ASSUMED, which is gospel's other finding and it was right: a linear scale is round to three parts in a thousand at the inner ring (0.9968) and only fails further out (0.795 at the outer). An assertion placed at the inner radius would PASS the exact mistake it exists to catch. Both halves are asserted now -- the outer control must fail, and the inner one is recorded as passing so nobody mistakes it for coverage, or "simplifies" three radii to one. --- internal/check-whiteboard-projection.mjs | 17 +++++++++++++++-- library/maps/whiteboard-projection.js | 16 ++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/internal/check-whiteboard-projection.mjs b/internal/check-whiteboard-projection.mjs index 9865b63f2..09f3fb076 100644 --- a/internal/check-whiteboard-projection.mjs +++ b/internal/check-whiteboard-projection.mjs @@ -84,11 +84,24 @@ for (const [name, r] of [['inner ring (30)', 30], ['mid ring (129)', 129], ['out + 'A ring drawn as an oval is the defect a LINEAR scale into degrees produces.') } } +// ★ WHERE THE CONTROL HAS TO SIT, AND WHY AN INNER-RING ASSERTION WOULD BE INERT (gospel, measured): +// a linear scale is round to THREE PARTS IN A THOUSAND at the inner ring -- 0.9968 -- which passes any +// tolerance anyone would write. The defect only becomes visible further out: 0.795 at the outer ring. +// So a roundness assertion placed at the inner radius would PASS the exact mistake it exists to catch, +// and a later "simplification" of these three radii to one would produce that silently. Both halves are +// asserted: the outer control must FAIL, and the inner one is recorded as PASSING so that nobody +// mistakes it for coverage. +const linearInner = (() => { const K = 0.17 / 228; return roundness((u, v) => [u * K * 360, v * K * 360], 30) })() +if (Math.abs(linearInner - 1) > 0.01) { + fails.push('a linear scale FAILS even at the inner ring, contradicting the measurement this test is built ' + + `on (${linearInner.toFixed(4)}). Re-derive where the control belongs before trusting it.`) +} // ...and the control: a linear scale MUST fail the same assertion, or the assertion proves nothing. const linear = (() => { const K = 0.17 / 228; const f = (u, v) => [u * K * 360, v * K * 360]; return f })() if (Math.abs(roundness(linear, 228) - 1) <= 0.01) { - fails.push('a LINEAR scale passes the roundness assertion, so the assertion cannot detect the defect ' - + 'it exists for — it is measuring nothing.') + fails.push('a LINEAR scale passes the roundness assertion AT THE OUTER RING, so the assertion cannot ' + + 'detect the defect it exists for. The outer radius is the load-bearing arm: do not reduce these ' + + 'three radii to one.') } if (fails.length) { diff --git a/library/maps/whiteboard-projection.js b/library/maps/whiteboard-projection.js index bfba33b7b..124b36c3c 100644 --- a/library/maps/whiteboard-projection.js +++ b/library/maps/whiteboard-projection.js @@ -14,6 +14,22 @@ * 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. + * + * ★ 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. + * * ★ 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 From edcc31d36e2b1c1c863ed0bcfb30581f5f3c38f5 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Mon, 28 Sep 2026 16:46:51 +0000 Subject: [PATCH 4/5] The other way to get the space wrong is to correct the axis that needs no correction gospel retracted their extentFraction threshold and named their bug: they computed screen x as lng * cos(lat). That is the plate-carree correction, and Web Mercator does not apply it -- screen x is proportional to LONGITUDE ALONE, which is precisely why the projection is conformal and why circles stay circles. They measured the damage done by a transform they had added themselves. Nothing needed retracting from this header: it already documented the opposite, that roundness is exact at every value. What it did NOT cover was this second failure, and this one is subtler than the first. A reader who has just been told "it is a projection, not a scale" naturally reaches for a cos(lat) term, because that is what correcting a projection feels like it should involve. MEASURED here rather than taken: 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, 56.5% at 0.45. Those are NOT gospel's numbers -- theirs ran 2.94/6.12/11.33/17.51, so their harness differed in a second way I did not reproduce. Both wrong spaces produce a clean monotonic table that grows with the fraction, and the magnitudes depend on which wrong transform was 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. So the header now says: if a measurement of this module ever reports a threshold, suspect the harness before the maths. Roundness cannot degrade, because this is the exact inverse of what the renderer does. The latitude ceiling stands and gospel confirmed it independently to a tenth of a degree: |lat| 52.1 at 0.17, 77.3 at 0.35, 83.2 at 0.45, 89.7 at 0.95, against the engines' ~85 clamp. That is the real usable range, and it is the only one. --- library/maps/whiteboard-projection.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/library/maps/whiteboard-projection.js b/library/maps/whiteboard-projection.js index 124b36c3c..98b59e85b 100644 --- a/library/maps/whiteboard-projection.js +++ b/library/maps/whiteboard-projection.js @@ -25,6 +25,22 @@ * 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 From b8bf3f4c4731acc327a11aad23975ac0f4ab1795 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Mon, 28 Sep 2026 16:53:33 +0000 Subject: [PATCH 5/5] The latitude ceiling belongs to extentFraction, not to a chart's extent gospel measured it across extents 100 / 228 / 260 / 1000 and I confirmed: |lat| is 52.070 at frac 0.17 for all four, identical to three decimals. K = extentFraction / extent and the outermost point sits at u = extent, so u*K = extentFraction whatever the extent is. It is worth the header because it kills the fix a reader would try first. Someone whose drawing hits the engines' ~85 degree clamp will reach for a smaller extent, and it buys nothing at all -- extent is a unit choice; the fraction is the whole ceiling. The corollary is the useful half: one assertion on extentFraction covers every consumer, so the shared module needs no per-chart latitude guard. --- library/maps/whiteboard-projection.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/library/maps/whiteboard-projection.js b/library/maps/whiteboard-projection.js index 98b59e85b..1e8fba659 100644 --- a/library/maps/whiteboard-projection.js +++ b/library/maps/whiteboard-projection.js @@ -46,6 +46,14 @@ * 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