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",