The whiteboard is a layer of tiered points; only the edges were ever a tree
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.
This commit is contained in:
parent
5ab4af2e44
commit
d3ee0bb6ec
6 changed files with 433 additions and 7 deletions
125
internal/check-tiered-points.mjs
Normal file
125
internal/check-tiered-points.mjs
Normal file
|
|
@ -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.')
|
||||
100
internal/check-whiteboard-projection.mjs
Normal file
100
internal/check-whiteboard-projection.mjs
Normal file
|
|
@ -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.')
|
||||
145
library/charts/whiteboard/tiered-points.js
Normal file
145
library/charts/whiteboard/tiered-points.js
Normal file
|
|
@ -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
|
||||
53
library/maps/whiteboard-projection.js
Normal file
53
library/maps/whiteboard-projection.js
Normal file
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue