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 7e7722593..54a06be5b 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",