From 0756c8cc5c92f2c172110af53f5792557a1607e8 Mon Sep 17 00:00:00 2001
From: Doxa Maps
Date: Mon, 28 Sep 2026 14:11:52 +0000
Subject: [PATCH 01/78] Collect the gospel set-chart layout check in gates:pre
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The check itself shipped with the plugin modules it covers, but the line that
runs it existed only in a message, which is not a place code lives. So it sat in
internal/ and would never have run — the same failure it was written to prevent,
one layer up.
This is the whole registration in one commit: the file, the script entry and the
word in the gates:pre list. It imports boundedSet.js and centeredSet.js, so it
must land after the plugin commits that add them; landing it first fails
collection and takes gates:pre red for everyone.
---
internal/check-gospel-set-charts.mjs | 88 ++++++++++++++++++++++++++++
package.json | 3 +-
2 files changed, 90 insertions(+), 1 deletion(-)
create mode 100644 internal/check-gospel-set-charts.mjs
diff --git a/internal/check-gospel-set-charts.mjs b/internal/check-gospel-set-charts.mjs
new file mode 100644
index 000000000..5756e6722
--- /dev/null
+++ b/internal/check-gospel-set-charts.mjs
@@ -0,0 +1,88 @@
+#!/usr/bin/env node
+/**
+ * check-gospel-set-charts.mjs — the bounded-set and centered-set LAYOUT maths.
+ *
+ * Both modules are pure, which is why they can be checked here at all: a pure function's
+ * inputs ARE its domain, so these assertions are evidence about the real module and not
+ * about a fixture that resembles one. What they are NOT evidence about is the shape of
+ * near-neighbors.json — that binding is checked where the data is read, not here.
+ *
+ * The properties worth pinning are the ones where a wrong answer still LOOKS like a chart:
+ *
+ * · bounded set — nothing may sit ON the boundary. A dot on the line is the one thing a
+ * bounded set cannot express, and it would read as a group that is somehow both.
+ * · bounded set — a group of UNKNOWN reached-ness is its own outcome. If it were folded
+ * into `outside`, a join failure would render as "unreached" and inflate the count with
+ * something that is not a fact about the group.
+ * · centered set — a group with no ring is UNPLACED, never defaulted to the outer ring.
+ * Ring 10 is a real answer ("beyond"), so it must not double as a bin for missing data.
+ * · centered set — a REACHED group (ring 0) is the CENTRE, not unplaced. An earlier build
+ * spelled "reached" and "no relation found" with the same 0; treating that value as
+ * missing data would report the best outcome in the set as an absence. The three states
+ * are asserted apart, and a reached group is asserted to reach NO ring at all.
+ * · centered set — the arrow stops exactly at innerR. An arrow that overshoots covers the
+ * cross it is meant to point at.
+ * · both — layout is deterministic, so a group does not move between renders.
+ */
+import { layoutBoundedSet, boundedSetTally } from '../plugin-designs/gospel-internet/src/boundedSet.js'
+import { layoutCenteredSet, ringTally, inwardArrow, ringRadii, RING_MAX, RING_REACHED, RING_NO_RELATION } from '../plugin-designs/gospel-internet/src/centeredSet.js'
+
+const fail = []
+const ok = (label, cond, detail = '') => {
+ if (cond) console.log(` ✓ ${label}${detail ? ` — ${detail}` : ''}`)
+ else { console.log(` ✗ ${label}${detail ? ` — ${detail}` : ''}`); fail.push(label) }
+}
+
+console.log('GOSPEL-SET-CHARTS')
+
+// ── bounded set ─────────────────────────────────────────────────────────────
+const bg = [{ id: 'a', reached: true }, { id: 'b', reached: false }, { id: 'c' }, { id: 'd', reached: null }]
+const B = layoutBoundedSet(bg, { r: 100 })
+const bt = boundedSetTally(B)
+ok('bounded: reached inside, unreached outside, unknown neither', bt.inside === 1 && bt.outside === 1 && bt.unknown === 2,
+ `in ${bt.inside} out ${bt.outside} unknown ${bt.unknown}`)
+ok('bounded: unknown is NOT folded into outside', bt.outside === 1 && bt.unknown === 2)
+ok('bounded: total is conserved', bt.total === bg.length)
+const dist = (p) => Math.hypot(p.x, p.y)
+ok('bounded: nothing sits ON the boundary', dist(B.inside[0]) < 100 && dist(B.outside[0]) > 100,
+ `in r=${dist(B.inside[0]).toFixed(1)} out r=${dist(B.outside[0]).toFixed(1)}`)
+ok('bounded: deterministic between renders', layoutBoundedSet(bg, { r: 100 }).inside[0].x === B.inside[0].x)
+
+// ── centered set ────────────────────────────────────────────────────────────
+const cg = [{ id: 'a', ring: 1, neighborId: 'r1' }, { id: 'b', ring: RING_MAX, neighborId: 'r2' },
+ { id: 'c', ring: RING_REACHED }, { id: 'd' }, { id: 'e', ring: RING_MAX + 1 }, { id: 'f', ring: 5 }]
+const C = layoutCenteredSet(cg)
+const ct = ringTally(C)
+ok('centered: valid rings placed, unusable ones unplaced, reached at the centre',
+ ct.placed === 3 && ct.unplaced === 2 && ct.centre === 1,
+ `placed ${ct.placed} centre ${ct.centre} unplaced ${ct.unplaced}`)
+ok('centered: a missing ring is NOT defaulted onto the outer ring',
+ C.placed.filter((p) => p.ring === RING_MAX).length === 1)
+ok('centered: every unplaced group carries a reason', C.unplaced.every((u) => typeof u.reason === 'string' && u.reason))
+ok('centered: rings are ordered inward to outward', ringRadii().length === RING_MAX &&
+ C.placed.find((p) => p.ring === 1).r < C.placed.find((p) => p.ring === RING_MAX).r)
+const p1 = C.placed.find((p) => p.ring === 1)
+ok('centered: a point lies exactly on its own ring', Math.abs(Math.hypot(p1.x, p1.y) - p1.r) < 1e-9)
+const arrow = inwardArrow(C.placed.find((p) => p.ring === RING_MAX))
+ok('centered: the inward arrow stops at innerR and does not cover the cross',
+ arrow && Math.abs(Math.hypot(arrow.x2, arrow.y2) - 26) < 1e-9, `ends at r=${Math.hypot(arrow.x2, arrow.y2).toFixed(2)}`)
+ok('centered: no recorded neighbour means NO arrow, not a guessed one', inwardArrow({ x: 10, y: 0 }) === null)
+ok('centered: deterministic between renders', layoutCenteredSet(cg).placed[0].x === C.placed[0].x)
+
+// the three ring states are three different facts and must not collapse into one another
+const sg = [{ id: 'p', ring: 1, neighborId: 'r' }, { id: 'reached', ring: RING_REACHED },
+ { id: 'norel', ring: RING_NO_RELATION }, { id: 'noring' }]
+const S = layoutCenteredSet(sg)
+const st = ringTally(S)
+ok('centered: reached (ring 0) is the CENTRE, not unplaced', st.centre === 1 && S.centre[0].id === 'reached')
+ok('centered: no-relation (ring -1) is unplaced with its OWN reason',
+ S.unplaced.find((u) => u.id === 'norel')?.reason === 'no relation found')
+ok('centered: the three states stay apart', st.placed === 1 && st.centre === 1 && st.unplaced === 2,
+ `placed ${st.placed} centre ${st.centre} unplaced ${st.unplaced}`)
+ok('centered: a reached group reaches NO ring', [...st.counts.values()].reduce((a, b) => a + b, 0) === st.placed)
+
+if (fail.length) {
+ console.log(`\nGOSPEL-SET-CHARTS: FAIL — ${fail.length}: ${fail.join(', ')}`)
+ process.exit(1)
+}
+console.log('\nGOSPEL-SET-CHARTS: PASS — both layouts pinned on the properties that fail silently')
diff --git a/package.json b/package.json
index b5b22adcb..908e9c725 100644
--- a/package.json
+++ b/package.json
@@ -70,6 +70,7 @@
"check:experimental-shelf": "node internal/check-experimental-shelf.mjs",
"check:export-poster-prints": "node plugin-designs/doxa-research-map/scripts/check-export-poster-prints.mjs",
"check:export-poster-prints:maplibre": "node plugin-designs/doxa-research-map/scripts/check-export-poster-prints.mjs --provider maplibre",
+ "check:gospel-set-charts": "node internal/check-gospel-set-charts.mjs",
"check:facet-cascade": "node internal/check-facet-cascade.mjs",
"check:facet-cascade:offline": "node internal/check-facet-cascade.mjs --offline",
"check:facet-cascade:self-test": "node internal/check-facet-cascade.mjs --self-test",
@@ -267,7 +268,7 @@
"fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:frozen-labels",
- "gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields",
+ "gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:gospel-set-charts check:hardcoded-english:self-test check:list-selection check:selector-fields",
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
"library-index": "node internal/generate-library-index.mjs",
From 7308d4312a3f33e7c1e28d0d010e9504f730239f Mon Sep 17 00:00:00 2001
From: Doxa Maps
Date: Mon, 28 Sep 2026 16:41:13 +0000
Subject: [PATCH 02/78] Shape both set charts as tiered points, and leave the
projection to the caller
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The shared whiteboard layer draws a point collection where each feature carries
an id, a label, a tier and a priority, and stages its label layers by tier and
zoom. This decides which features exist, what tier each belongs to and how they
rank — which is this chart's business — and it does not convert to degrees.
Ring and band labels are placed on the radii the chart already draws rather than
on a second calculation, so the label and the ring it names cannot drift apart.
Groups rank by population, so when two names collide the larger people wins: a
reader zoomed out should meet the names carrying the most people, not whichever
was drawn first. Bands outrank rings outrank groups, because that is the order a
reader zooming in meets them.
Coordinates out are abstract on purpose. Converting them is a projection, not a
scale — the whiteboard shrinks degree height outward so the map's own stretch
cancels it, and a linear scale skips the cancellation. Measured on a ring of 720
points: with a linear scale the rendered aspect is 0.9968 at the inner ring and
0.7952 at the outer, so the target draws as ovals that worsen outward, worst
exactly where the ring labels sit. With the projection it is 1.0000 at every
ring.
That measurement also settles where the guard belongs: at the inner ring the
linear error is three parts in a thousand and would pass any sane tolerance. An
assertion placed there alone would be inert against the one mistake it exists to
catch. It goes at the outer ring, and at both.
---
.../gospel-internet/src/setChartFeatures.js | 82 +++++++++++++++++++
1 file changed, 82 insertions(+)
create mode 100644 plugin-designs/gospel-internet/src/setChartFeatures.js
diff --git a/plugin-designs/gospel-internet/src/setChartFeatures.js b/plugin-designs/gospel-internet/src/setChartFeatures.js
new file mode 100644
index 000000000..d88dfba91
--- /dev/null
+++ b/plugin-designs/gospel-internet/src/setChartFeatures.js
@@ -0,0 +1,82 @@
+/**
+ * setChartFeatures.js — the set charts as TIERED POINTS, in abstract coordinates.
+ *
+ * The shared whiteboard layer draws a point FeatureCollection where each feature carries an id,
+ * a label, a tier and a priority `k`, and stages its label layers by tier and zoom. This turns
+ * my two layouts into that shape. It decides WHICH features exist, what tier each belongs to and
+ * how they rank against each other — which is mine — and it does NOT project.
+ *
+ * ★ COORDINATES OUT ARE ABSTRACT (u, v), NOT DEGREES, AND THAT IS DELIBERATE. Converting to
+ * lng/lat is a PROJECTION, not a scale: the whiteboard's inverse-Mercator shrinks the degree
+ * height as you move out so the map's own 1/cos(lat) stretch cancels it. A linear scale skips
+ * that cancellation and the error grows outward — 1.0154x at 10 degrees, 1.0642x at 20, 1.1547x
+ * at 30. For concentric rings that renders as vertical OVALS, worst at the outer ring where the
+ * labels sit, and it looks plausible enough to waste an afternoon on. So the caller passes these
+ * through the shared projection with the chart's extent; this module never guesses at degrees.
+ *
+ * Extents (the caller's one number): centered set 228 = innerR 30 + 9 x ringGap 22; bounded 260.
+ */
+import { ringInfo } from './ringInfo.js'
+
+/** Tier names, outermost-first in label priority: bands read zoomed out, groups zoomed in. */
+export const TIERS = { BAND: 'band', RING: 'ring', GROUP: 'group' }
+
+/**
+ * The centered-set target as tiered points.
+ *
+ * `placed` are laid-out groups ({ id, label, ring, x, y }); `radii` is ringRadii()'s output, so
+ * the ring and band labels sit on the SAME radii the chart draws rather than on a second
+ * calculation that could drift from it.
+ *
+ * `k` is the priority the shared layer sorts labels by. Groups rank by population, so when two
+ * names collide the bigger people wins — a reader zoomed out should see the names that carry the
+ * most people, not whichever happened to be drawn first. Bands and rings outrank every group.
+ */
+export function centeredSetFeatures(placed, radii, { bandLabel, ringLabel, population } = {}) {
+ const out = []
+ for (const p of placed || []) {
+ out.push({ id: p.id, label: p.label, tier: TIERS.GROUP, ring: p.ring,
+ k: Number(population?.(p) ?? 0), u: p.x, v: p.y })
+ }
+ // One label per ring, placed on the ring itself at a fixed angle so it does not wander between
+ // renders. Rings outrank groups; the inner rings outrank the outer, because a reader zooming in
+ // meets them in that order.
+ for (const { ring, r } of radii || []) {
+ const info = ringInfo(ring)
+ if (!info) continue
+ out.push({ id: `ring-${ring}`, label: ringLabel?.(ring) ?? `Ring ${ring}`, tier: TIERS.RING,
+ ring, k: 1e9 - ring, u: 0, v: -r })
+ }
+ // One label per band, at the outer edge of the band's own rings.
+ const bands = new Map()
+ for (const { ring, r } of radii || []) {
+ const info = ringInfo(ring)
+ if (!info) continue
+ const cur = bands.get(info.band)
+ if (!cur || r > cur) bands.set(info.band, r)
+ }
+ for (const [band, r] of bands) {
+ out.push({ id: `band-${band}`, label: bandLabel?.(band) ?? band, tier: TIERS.BAND,
+ k: 2e9, u: 0, v: -r })
+ }
+ return out
+}
+
+/**
+ * The bounded set as tiered points, plus the boundary circle as a dish.
+ *
+ * ★ THE GAP EITHER SIDE OF THE BOUNDARY IS THE CHART'S ONE CLAIM and it must survive projection:
+ * inside points sit within 0.92r, outside beyond 1.12r, and nothing may land ON the line — a dot
+ * on the boundary is the one thing a bounded set cannot express. The dish radius is the boundary
+ * itself, so the caller projects all three consistently or the gap closes on one side.
+ */
+export function boundedSetFeatures(layout, { label, population } = {}) {
+ const out = []
+ for (const side of ['inside', 'outside']) {
+ for (const p of layout?.[side] || []) {
+ out.push({ id: p.id, label: label?.(p) ?? p.label, tier: TIERS.GROUP, side,
+ k: Number(population?.(p) ?? 0), u: p.x, v: p.y })
+ }
+ }
+ return { features: out, dish: { u: layout?.cx ?? 0, v: layout?.cy ?? 0, r: layout?.r ?? 0 } }
+}
From 1755694a087e7068425ba322eb251df4c21f91ae Mon Sep 17 00:00:00 2001
From: Doxa Maps
Date: Mon, 28 Sep 2026 16:51:39 +0000
Subject: [PATCH 03/78] Project the set-chart features onto the whiteboard, and
report the real ceiling
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The projection is passed in rather than built here, so the chart and its checks
share one instance and this module still owes nothing to a coordinate system it
does not define. The dish takes its radius from the projection's exposed scale:
deriving it by projecting two points and subtracting would be right on the
equator and wrong everywhere else.
The guard reports the outermost latitude, because that is the real limit and
shape is not. The projection is the exact inverse of the renderer's transform, so
a circle stays a circle at every extentFraction — measured at 0.000% aspect
error. An earlier reading of mine said otherwise and was wrong: it computed
screen x as longitude times cos(latitude), a plate-carree correction Web Mercator
never applies, which manufactured a distortion the renderer does not produce.
What does bite is the pole. The outermost point reaches 52 degrees at the default
0.17, 77 at 0.35, 83 at 0.45, and Web Mercator is undefined at the poles with
engines clamping near 85.
That ceiling is a property of extentFraction alone, not of a chart's extent:
the scale is extentFraction over extent, so the outermost point is at
extentFraction whatever the extent, and every chart reaches the same latitude.
Measured across extents 100, 228, 260 and 1000 — identical to three decimals. So
a consumer cannot buy headroom by choosing a bigger extent, and a check written
against one chart's ceiling holds for all of them.
---
.../gospel-internet/src/setChartFeatures.js | 38 +++++++++++++++++++
1 file changed, 38 insertions(+)
diff --git a/plugin-designs/gospel-internet/src/setChartFeatures.js b/plugin-designs/gospel-internet/src/setChartFeatures.js
index d88dfba91..14c85bb7e 100644
--- a/plugin-designs/gospel-internet/src/setChartFeatures.js
+++ b/plugin-designs/gospel-internet/src/setChartFeatures.js
@@ -80,3 +80,41 @@ export function boundedSetFeatures(layout, { label, population } = {}) {
}
return { features: out, dish: { u: layout?.cx ?? 0, v: layout?.cy ?? 0, r: layout?.r ?? 0 } }
}
+
+/**
+ * Project abstract (u, v) features into a GeoJSON point collection for the whiteboard layer.
+ *
+ * `project` is the shared whiteboardProjection for this chart's extent. It is passed in rather
+ * than built here so the chart and its checks use ONE projection instance, and so this module
+ * still owes nothing to a coordinate system it does not define.
+ *
+ * ⚠ THE LATITUDE CEILING IS THE REAL LIMIT, not shape. The projection is the exact inverse of
+ * the renderer's transform, so a circle stays a circle at every extentFraction — measured at
+ * 0.000% aspect error. What DOES bite is how close to the pole the drawing lands: at extent 260
+ * the outermost point reaches 52 degrees at the default 0.17, 77 at 0.35, 83 at 0.45, and Web
+ * Mercator is undefined at the poles with engines clamping near 85. So a caller that widens
+ * extentFraction to let readers zoom out further runs into the clamp, not into distortion.
+ * `maxAbsLat` reports it so a check can assert the drawing stays inside that range.
+ */
+export function toFeatureCollection(features, project) {
+ let maxAbsLat = 0
+ const out = []
+ for (const f of features || []) {
+ const [lng, lat] = project(f.u, f.v)
+ if (Math.abs(lat) > maxAbsLat) maxAbsLat = Math.abs(lat)
+ const { u, v, ...props } = f
+ out.push({ type: 'Feature', id: f.id, geometry: { type: 'Point', coordinates: [lng, lat] }, properties: props })
+ }
+ return { collection: { type: 'FeatureCollection', features: out }, maxAbsLat }
+}
+
+/**
+ * A radius in DEGREES for the dish, from a radius in layout units.
+ *
+ * The dish is a circle-radius in the paint, so it needs the projection's scale rather than a
+ * projected point. project.K is exposed for exactly this; deriving it by projecting two points
+ * and subtracting would be right along the equator and wrong everywhere else.
+ */
+export function dishRadiusDegrees(r, project) {
+ return Number(r || 0) * (project?.K ?? 0) * 360
+}
From f75fbbd4a2bcaef2763cbb8c77b582945833c465 Mon Sep 17 00:00:00 2001
From: Doxa Maps
Date: Mon, 28 Sep 2026 16:55:07 +0000
Subject: [PATCH 04/78] Draw the centered set on the whiteboard, so it pans,
zooms and labels by tier
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The ask changed and so did the call. A static target is fine in SVG; a zoomable
one carrying ~2,100 labels is not. The engine tiles the collection per zoom so it
draws less when zoomed out, and its symbol layers do label collision, which is
the part that would take longest to do badly by hand.
The label staging is one layer per tier with its own minzoom: band names when
zoomed out, ring names in the middle, group names in close. None of that is
tree-shaped, which is why it is a shared layer rather than a copy of one.
Coordinates go through the shared projection, never a scale. It is the exact
inverse of the renderer's transform, so a circle stays a circle; a linear scale
would draw concentric rings as ovals worsening outward. The only number this
chart owns is its extent.
Dimming a deselected ring is a paint expression rather than a filter, for the
reason the SVG version dims instead of hiding: a ring emptied of its dots reads
as a ring with no groups, which is the opposite of what a reader selecting it
just asked for.
The layer id is read back from the handle rather than written from memory. The
first version reached for a name the module does not use; inside a try/catch that
does not throw, so the dimming would simply never have happened — a legend that
appears to do nothing rather than an error. Asking the handle means a rename
cannot silently disable it.
---
.../src/components/GiCenteredSetMap.vue | 137 ++++++++++++++++++
1 file changed, 137 insertions(+)
create mode 100644 plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue
diff --git a/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue b/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue
new file mode 100644
index 000000000..8642734cc
--- /dev/null
+++ b/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue
@@ -0,0 +1,137 @@
+
+
+
+
+
+
+ {{ T('gospelInternet.centered.failed', 'The neighbour table could not be loaded, so this chart is not drawn.') }}
+ {{ failed }}
+
+
+
From 540bcd999e20c1792964e59f1555624094c9e5e6 Mon Sep 17 00:00:00 2001
From: Doxa Maps
Date: Mon, 28 Sep 2026 16:57:40 +0000
Subject: [PATCH 05/78] Show the centered set as the whiteboard map rather than
a static figure
The panel now mounts the zoomable chart in place of the SVG. Pan, zoom and
labels that appear by tier as the reader moves in were the ask; the engine
supplies tiling and label collision that an SVG version would have to
reimplement, and badly.
The printed component stays SVG, which is not an inconsistency: a sheet has no
zoom and no hover, so paper and screen genuinely want different renderers. What
they share is the ring vocabulary, so the two cannot drift apart in what they
call a ring even though they draw it differently.
---
.../src/components/GiCenteredSet.vue | 23 ++++++-------------
1 file changed, 7 insertions(+), 16 deletions(-)
diff --git a/plugin-designs/gospel-internet/src/components/GiCenteredSet.vue b/plugin-designs/gospel-internet/src/components/GiCenteredSet.vue
index c48c14589..b60a377b5 100644
--- a/plugin-designs/gospel-internet/src/components/GiCenteredSet.vue
+++ b/plugin-designs/gospel-internet/src/components/GiCenteredSet.vue
@@ -26,6 +26,7 @@ import { useGiT } from '../useGiT.js'
import { loadNearRows, indexFor } from '../nearRows.js'
import { layoutCenteredSet, ringTally, inwardArrow, ringRadii } from '@map/components/charts/centeredSet.js'
import GiRingLegend from './GiRingLegend.vue'
+import GiCenteredSetMap from './GiCenteredSetMap.vue'
const T = useGiT()
const board = useBoardState()
@@ -112,22 +113,12 @@ useShadowStyles(
-
-
-
-
-
-
-
-
- {{ T('gospelInternet.centered.caption', 'Each group sits at its distance from a reached neighbour; arrows point at the one that places it.') }}
- {{ fmt(tally.placed) }}
-
+
+
Date: Mon, 28 Sep 2026 17:02:03 +0000
Subject: [PATCH 06/78] Put the bounded set on the whiteboard too, with its
boundary in map units
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Both set charts now pan and zoom. The chart still makes one claim — which side
of the line a group is on — so nothing here encodes distance or rank; what zoom
buys is reading the names, because at two thousand dots the inside is a cluster
until you can get close to it.
The boundary is the shared dish layer, which is the same layer a language
family's circle uses, so it is not a new type for either of us. Its radius takes
an exponential-base-2 zoom interpolation rather than a fixed pixel value.
circle-radius is screen pixels: a constant would hold its size while the dots
spread out from under it, leaving the circle correct at exactly one zoom and
wrong at every other — and for a dot near the edge the inside/outside reading
would silently invert, which is the only thing this chart says. Pixels per degree
double with each zoom level, measured at exactly 2.000000 between adjacent
levels, so the interpolation tracks map units exactly rather than approximately.
The boundary is drawn as a line with a transparent fill. A filled disc would say
the inside is a region, when what the chart means is a threshold.
The cross rides in the point collection as its own tier rather than as an
overlay, so it pans and zooms with the drawing instead of floating above it.
---
.../src/components/GiBoundedSet.vue | 30 ++---
.../src/components/GiBoundedSetMap.vue | 108 ++++++++++++++++++
.../gospel-internet/src/setChartFeatures.js | 17 +++
3 files changed, 136 insertions(+), 19 deletions(-)
create mode 100644 plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue
diff --git a/plugin-designs/gospel-internet/src/components/GiBoundedSet.vue b/plugin-designs/gospel-internet/src/components/GiBoundedSet.vue
index 5c622642c..5f4777e1a 100644
--- a/plugin-designs/gospel-internet/src/components/GiBoundedSet.vue
+++ b/plugin-designs/gospel-internet/src/components/GiBoundedSet.vue
@@ -28,7 +28,12 @@ import { formatCount } from '@map/utils/formatCount.js'
import { useBoardState } from '../useBoard.js'
import { useGiT } from '../useGiT.js'
import { loadNearRows, indexFor } from '../nearRows.js'
+<<<<<<< HEAD
import { layoutBoundedSet, boundedSetTally } from '@map/components/charts/boundedSet.js'
+=======
+import { layoutBoundedSet, boundedSetTally } from '../boundedSet.js'
+import GiBoundedSetMap from './GiBoundedSetMap.vue'
+>>>>>>> ea8dbe45 (Put the bounded set on the whiteboard too, with its boundary in map units)
const T = useGiT()
const board = useBoardState()
@@ -73,7 +78,7 @@ useShadowStyles(
// can never push the figure out of its pane the way the legend did on the centered set.
'.gbs{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;' +
'justify-content:center;padding:46px 10px 10px;box-sizing:border-box;overflow:hidden;background:#f3f3f1}' +
- '.gbs svg{max-width:100%;max-height:100%}' +
+ '.gbs-fig{position:relative;flex:1 1 auto;align-self:stretch;min-height:0;min-width:0}' +
'.gbs-ring{fill:none;stroke:#3b463d;stroke-width:1.5}' +
'.gbs-in{fill:#92b195}.gbs-out{fill:#6d766e;opacity:.65}' +
'.gbs-cross-halo{stroke:var(--gbs-bg,#f3f3f1);stroke-width:7;stroke-linecap:square}' +
@@ -91,24 +96,11 @@ useShadowStyles(
{{ failed }}
-
-
-
-
-
-
-
-
-
-
-
-
+
+
{{ T('gospelInternet.bounded.caption', 'Inside the circle: reached. Outside: unreached.') }}
{{ fmt(tally.inside) }} / {{ fmt(tally.outside) }}
diff --git a/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue b/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue
new file mode 100644
index 000000000..7c0369a3c
--- /dev/null
+++ b/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue
@@ -0,0 +1,108 @@
+
+
+
+
+
+
+ {{ T('gospelInternet.bounded.failed', 'The reached/unreached table could not be loaded, so this chart is not drawn.') }}
+ {{ failed }}
+
+
+
diff --git a/plugin-designs/gospel-internet/src/setChartFeatures.js b/plugin-designs/gospel-internet/src/setChartFeatures.js
index 14c85bb7e..66bc15a47 100644
--- a/plugin-designs/gospel-internet/src/setChartFeatures.js
+++ b/plugin-designs/gospel-internet/src/setChartFeatures.js
@@ -118,3 +118,20 @@ export function toFeatureCollection(features, project) {
export function dishRadiusDegrees(r, project) {
return Number(r || 0) * (project?.K ?? 0) * 360
}
+
+/**
+ * A zoom expression that keeps a radius fixed in MAP units while the paint wants PIXELS.
+ *
+ * ★ circle-radius IS SCREEN PIXELS, and for the bounded set's boundary that is the wrong unit.
+ * A fixed pixel radius stays the same size on screen while the dots spread out from under it as
+ * the reader zooms — the circle would be right at exactly one zoom level and wrong at every
+ * other, with the inside/outside reading silently inverting for dots near the edge.
+ *
+ * The world is 512 x 2^zoom pixels across 360 degrees, so pixels-per-degree doubles with every
+ * zoom level — measured exactly 2.000000 between adjacent levels. An exponential-base-2
+ * interpolation therefore tracks map units exactly rather than approximately, with two stops.
+ */
+export function degreesToPixelRadius(degrees, { minZoom = 0, maxZoom = 22 } = {}) {
+ const px = (z) => Number(degrees || 0) * 512 * Math.pow(2, z) / 360
+ return ['interpolate', ['exponential', 2], ['zoom'], minZoom, px(minZoom), maxZoom, px(maxZoom)]
+}
From 957ae50ed085b86327ddeb8cce9f3707a5fdf91d Mon Sep 17 00:00:00 2001
From: Doxa Maps
Date: Mon, 28 Sep 2026 17:04:03 +0000
Subject: [PATCH 07/78] Pin the whiteboard geometry, and record what the poison
arm disproved
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three properties, each with the arm that catches its own defect: the bounded
set's gap survives projection, the drawing stays inside the Mercator clamp, and
a radius expressed in map units doubles with every zoom level.
The first arm taught me something I had assumed the opposite of. It narrowed the
gap to a tenth of a percent expecting the margins to go negative. They stayed
positive: the projection is conformal, so it preserves the ratio exactly, and a
narrow gap survives just as an eight percent one does. There is no distortion for
it to be eaten by.
So "the gap survives projection" is close to a tautology, and a check resting on
it alone would look like a guard while testing almost nothing. What can actually
fail is the ordering of the constants, and that is what the arm now poisons. The
narrowing case is kept as a passing assertion rather than deleted, because the
fact that it is safe is worth stating: the boundary cannot be eroded by the
projection, only by someone editing the numbers.
The clamp arm holds for every chart at once. The ceiling belongs to
extentFraction alone, not to a chart's extent — the scale is extentFraction over
extent and the outermost point sits at the extent, so the product is always the
fraction. Asserted across four extents, identical to three decimals.
---
internal/check-gospel-whiteboard.mjs | 111 +++++++++++++++++++++++++++
1 file changed, 111 insertions(+)
create mode 100644 internal/check-gospel-whiteboard.mjs
diff --git a/internal/check-gospel-whiteboard.mjs b/internal/check-gospel-whiteboard.mjs
new file mode 100644
index 000000000..4659b4b5a
--- /dev/null
+++ b/internal/check-gospel-whiteboard.mjs
@@ -0,0 +1,111 @@
+#!/usr/bin/env node
+/**
+ * check-gospel-whiteboard — the geometry the set charts depend on once they are drawn on a map.
+ *
+ * Three properties, each with the arm that would catch its own defect. None of them is about
+ * whether the chart renders; all three are about it rendering something TRUE.
+ *
+ * 1. THE BOUNDED SET'S GAP SURVIVES PROJECTION. Inside points sit within 0.92r and outside
+ * beyond 1.12r, and nothing may land on the boundary — a dot on the line is the one thing a
+ * bounded set cannot express. Asserted on PROJECTED coordinates. Note what the arm below
+ * found: the projection is conformal, so it preserves the ratio EXACTLY and a narrowed gap
+ * survives just as a wide one does. The boundary cannot be eroded by the projection, only by
+ * someone editing the constants — which is therefore what the arm poisons.
+ * 2. THE DRAWING STAYS INSIDE THE MERCATOR CLAMP. Shape does not degrade with extentFraction —
+ * the projection is the exact inverse of the renderer's transform, measured at 0.000% aspect
+ * error, and an earlier claim of mine that it did was an artefact of my own harness. What
+ * does bite is latitude: engines clamp near 85 degrees.
+ * 3. A RADIUS IN MAP UNITS DOUBLES WITH EVERY ZOOM LEVEL. circle-radius is screen pixels, so a
+ * boundary that does not scale is correct at one zoom and wrong at every other.
+ *
+ * ★ THE CEILING IS A PROPERTY OF extentFraction ALONE, not of a chart's extent: the scale is
+ * extentFraction/extent and the outermost point is at u = extent, so the product is always
+ * extentFraction. A consumer cannot buy headroom against the clamp by choosing a bigger extent,
+ * and one assertion covers every chart.
+ */
+import { layoutBoundedSet } from '../plugin-designs/gospel-internet/src/boundedSet.js'
+import { degreesToPixelRadius, dishRadiusDegrees, toFeatureCollection, centeredSetFeatures }
+ from '../plugin-designs/gospel-internet/src/setChartFeatures.js'
+import { layoutCenteredSet, ringRadii } from '../plugin-designs/gospel-internet/src/centeredSet.js'
+import { whiteboardProjection } from '../library/maps/whiteboard-projection.js'
+
+const fail = []
+const ok = (label, cond, detail = '') => {
+ if (cond) console.log(` ✓ ${label}${detail ? ` — ${detail}` : ''}`)
+ else { console.log(` ✗ ${label}${detail ? ` — ${detail}` : ''}`); fail.push(label) }
+}
+const R2D = 180 / Math.PI
+// TRUE Web Mercator screen space: x is proportional to LONGITUDE ALONE. An earlier version of
+// this used lng*cos(lat), a plate-carree correction the renderer never applies, and it
+// manufactured a distortion that does not exist. Keep x = lng.
+const screen = (lng, lat) => [lng, Math.log(Math.tan(Math.PI / 4 + lat * Math.PI / 360)) * R2D]
+
+console.log('GOSPEL-WHITEBOARD')
+
+// ── 1. the boundary gap, on projected coordinates ───────────────────────────
+const R = 120, SPREAD = 260
+function gapMargins(extentFraction, inner = 0.92, outer = 1.12) {
+ const p = whiteboardProjection({ extent: SPREAD, extentFraction })
+ const d = (u, v) => { const [lng, lat] = p(u, v); const [x, y] = screen(lng, lat); return Math.hypot(x, y) }
+ let bMin = Infinity, bMax = -Infinity, inMax = -Infinity, outMin = Infinity
+ for (let i = 0; i < 720; i++) {
+ const a = i / 720 * 2 * Math.PI, c = Math.cos(a), s = Math.sin(a)
+ const b = d(c * R, s * R); bMin = Math.min(bMin, b); bMax = Math.max(bMax, b)
+ inMax = Math.max(inMax, d(c * R * inner, s * R * inner))
+ outMin = Math.min(outMin, d(c * R * outer, s * R * outer))
+ }
+ return { inside: bMin - inMax, outside: outMin - bMax }
+}
+const m = gapMargins(0.17)
+ok('bounded: no inside point reaches the boundary', m.inside > 0, `margin ${m.inside.toFixed(3)}`)
+ok('bounded: no outside point reaches the boundary', m.outside > 0, `margin ${m.outside.toFixed(3)}`)
+// ★ THE ARM, AND WHAT WRITING IT TAUGHT ME. My first version narrowed the gap to 0.999/1.001 and
+// expected the margins to go negative. THEY DID NOT — they stayed positive at 0.028. The
+// projection is conformal, so it preserves the RATIO exactly; a gap of a tenth of a percent
+// survives projection just as a gap of eight percent does. There is no distortion for a narrow
+// gap to be eaten by.
+// That means "the gap survives projection" is very nearly a tautology, and a check resting on it
+// alone would look like a guard while testing almost nothing. What CAN actually fail is the
+// ORDERING of the constants — someone editing 0.92/1.12 to the wrong side of 1 — so that is what
+// the arm poisons. Recorded rather than quietly swapped, because the fact that narrowing is safe
+// is itself worth knowing: it says the boundary cannot be eroded by the projection, only by us.
+const inverted = gapMargins(0.17, 1.01, 0.99)
+ok('bounded: inverting the constants FAILS (so the guard is not inert)',
+ inverted.inside <= 0 || inverted.outside <= 0,
+ `inverted margins ${inverted.inside.toFixed(3)} / ${inverted.outside.toFixed(3)}`)
+const narrow = gapMargins(0.17, 0.999, 1.001)
+ok('bounded: a narrowed gap still survives, because the projection preserves ratios exactly',
+ narrow.inside > 0 && narrow.outside > 0, `margins ${narrow.inside.toFixed(3)} / ${narrow.outside.toFixed(3)}`)
+
+// ── 2. the latitude ceiling ─────────────────────────────────────────────────
+const EXTENT_C = 30 + 9 * 22
+const feats = centeredSetFeatures(
+ layoutCenteredSet([{ id: 'a', label: 'A', ring: 10 }], { innerR: 30, ringGap: 22 }).placed,
+ ringRadii({ innerR: 30, ringGap: 22 }), {})
+const { maxAbsLat } = toFeatureCollection(feats, whiteboardProjection({ extent: EXTENT_C }))
+ok('centered: the drawing stays inside the Mercator clamp', maxAbsLat < 85, `max |lat| ${maxAbsLat.toFixed(2)}`)
+// THE ARM: the ceiling is extentFraction's, so a wide one must breach it
+const wide = whiteboardProjection({ extent: EXTENT_C, extentFraction: 0.95 })(0, EXTENT_C)[1]
+ok('the clamp CAN be breached by extentFraction alone (so the guard is not inert)',
+ Math.abs(wide) > 85, `|lat| ${Math.abs(wide).toFixed(2)} at fraction 0.95`)
+// and it does not depend on the extent
+const lats = [100, 228, 260, 1000].map((e) => Math.abs(whiteboardProjection({ extent: e })(0, e)[1]))
+ok('the ceiling is extentFraction\'s alone, not the extent\'s',
+ Math.max(...lats) - Math.min(...lats) < 1e-6, lats.map((l) => l.toFixed(3)).join(' '))
+
+// ── 3. a map-unit radius doubles per zoom ───────────────────────────────────
+const deg = dishRadiusDegrees(R, whiteboardProjection({ extent: SPREAD }))
+const expr = degreesToPixelRadius(deg)
+const [, , , z0, r0, z1, r1] = expr
+ok('dish: the radius is a zoom expression, not a constant', Array.isArray(expr) && expr[0] === 'interpolate')
+ok('dish: it uses exponential base 2, which is what doubling per zoom requires',
+ expr[1][0] === 'exponential' && expr[1][1] === 2)
+ok('dish: its stops scale by exactly 2^(z1-z0)',
+ Math.abs(r1 / r0 - Math.pow(2, z1 - z0)) / Math.pow(2, z1 - z0) < 1e-9,
+ `${r0.toFixed(2)}px at z${z0}`)
+
+if (fail.length) {
+ console.log(`\nGOSPEL-WHITEBOARD: FAIL — ${fail.length}: ${fail.join(', ')}`)
+ process.exit(1)
+}
+console.log('\nGOSPEL-WHITEBOARD: PASS — the gap, the clamp and the scaling all hold, and each arm can fail')
From e3cdfe61d9dc4ebb267d0fed46337a93257f6ada Mon Sep 17 00:00:00 2001
From: Doxa Maps
Date: Mon, 28 Sep 2026 18:10:38 +0000
Subject: [PATCH 08/78] Point the set charts at the promoted modules, and clear
a committed conflict
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The layout modules moved to library when they were promoted, and three of my
files still named the old paths: the two new whiteboard components, which were
written before the move landed, and the geometry check, which was written after
it and still copied the old import from its neighbours.
GiBoundedSet also carried an unresolved conflict — markers and all — committed by
a cherry-pick loop of mine that resolved generated output automatically and swept
the source conflict along with it. The file had both imports of the same symbol,
one of them pointing at a deleted path. It parsed, so the SFC check passed; only
the bundler would have caught it, and only at the point where it could not
resolve the file.
The lesson is about the loop, not the file: resolving one class of conflict
automatically is fine, but `git add -A` afterwards does not distinguish the
conflicts you meant to resolve from the ones you did not. A loop that stages
everything needs to refuse when a marker is still present.
---
internal/check-gospel-whiteboard.mjs | 4 +-
.../src/components/GiBoundedSet.vue | 6 +--
.../src/components/GiBoundedSetMap.vue | 2 +-
.../src/components/GiCenteredSetMap.vue | 2 +-
public/dx-maps/doxa-maps-spa/manifest.json | 4 +-
.../gospel-internet/board/board/index.html | 2 +-
.../dx-maps/gospel-internet/board/index.html | 2 +-
.../board/india-china/index.html | 2 +-
.../board/language-tree/index.html | 2 +-
.../board/platforms/index.html | 2 +-
.../board/project-700/index.html | 2 +-
.../gospel-internet/board/zero/index.html | 2 +-
.../gospel-internet/gospel-internet.js | 46 +++++++++----------
public/dx-maps/manifest.json | 4 +-
14 files changed, 39 insertions(+), 43 deletions(-)
diff --git a/internal/check-gospel-whiteboard.mjs b/internal/check-gospel-whiteboard.mjs
index 4659b4b5a..2b40d0b27 100644
--- a/internal/check-gospel-whiteboard.mjs
+++ b/internal/check-gospel-whiteboard.mjs
@@ -23,10 +23,10 @@
* extentFraction. A consumer cannot buy headroom against the clamp by choosing a bigger extent,
* and one assertion covers every chart.
*/
-import { layoutBoundedSet } from '../plugin-designs/gospel-internet/src/boundedSet.js'
+import { layoutBoundedSet } from '../library/components/charts/boundedSet.js'
import { degreesToPixelRadius, dishRadiusDegrees, toFeatureCollection, centeredSetFeatures }
from '../plugin-designs/gospel-internet/src/setChartFeatures.js'
-import { layoutCenteredSet, ringRadii } from '../plugin-designs/gospel-internet/src/centeredSet.js'
+import { layoutCenteredSet, ringRadii } from '../library/components/charts/centeredSet.js'
import { whiteboardProjection } from '../library/maps/whiteboard-projection.js'
const fail = []
diff --git a/plugin-designs/gospel-internet/src/components/GiBoundedSet.vue b/plugin-designs/gospel-internet/src/components/GiBoundedSet.vue
index 5f4777e1a..6a7951514 100644
--- a/plugin-designs/gospel-internet/src/components/GiBoundedSet.vue
+++ b/plugin-designs/gospel-internet/src/components/GiBoundedSet.vue
@@ -5,7 +5,7 @@
* The chart makes exactly one claim — which side of the line a group is on — so position
* carries no other meaning: no gradient, no ranking, no distance. The centered-set target
* is the chart that makes the opposite claim, and keeping them visually distinct is the
- * point of having both. Layout maths is in ../boundedSet.js, pure and testable without
+ * point of having both. Layout maths is in @map/components/charts/boundedSet.js, pure and testable without
* mounting this.
*
* SVG RATHER THAN THE MAP ENGINE, which is the opposite of GiTree's call and for a reason
@@ -28,12 +28,8 @@ import { formatCount } from '@map/utils/formatCount.js'
import { useBoardState } from '../useBoard.js'
import { useGiT } from '../useGiT.js'
import { loadNearRows, indexFor } from '../nearRows.js'
-<<<<<<< HEAD
import { layoutBoundedSet, boundedSetTally } from '@map/components/charts/boundedSet.js'
-=======
-import { layoutBoundedSet, boundedSetTally } from '../boundedSet.js'
import GiBoundedSetMap from './GiBoundedSetMap.vue'
->>>>>>> ea8dbe45 (Put the bounded set on the whiteboard too, with its boundary in map units)
const T = useGiT()
const board = useBoardState()
diff --git a/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue b/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue
index 7c0369a3c..0724c469c 100644
--- a/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue
+++ b/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue
@@ -25,7 +25,7 @@ import { addTieredPointLayers } from '@map/charts/whiteboard/tiered-points.js'
import { createBoardMap } from '../boardEngine.js'
import { useBoardState } from '../useBoard.js'
import { useGiT } from '../useGiT.js'
-import { layoutBoundedSet } from '../boundedSet.js'
+import { layoutBoundedSet } from '@map/components/charts/boundedSet.js'
import { boundedSetFeatures, toFeatureCollection, dishRadiusDegrees, degreesToPixelRadius, TIERS } from '../setChartFeatures.js'
const props = defineProps({ groups: { type: Array, default: () => [] } })
diff --git a/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue b/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue
index 8642734cc..8c5c0210a 100644
--- a/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue
+++ b/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue
@@ -27,7 +27,7 @@ import { createBoardMap } from '../boardEngine.js'
import { useBoardState } from '../useBoard.js'
import { useGiT } from '../useGiT.js'
import { ringInfo, CENTRE_COLOR } from '../ringInfo.js'
-import { layoutCenteredSet, ringRadii, RING_MAX } from '../centeredSet.js'
+import { layoutCenteredSet, ringRadii, RING_MAX } from '@map/components/charts/centeredSet.js'
import { centeredSetFeatures, toFeatureCollection, TIERS } from '../setChartFeatures.js'
const props = defineProps({
diff --git a/public/dx-maps/doxa-maps-spa/manifest.json b/public/dx-maps/doxa-maps-spa/manifest.json
index a25fb9f3e..fa5f74826 100644
--- a/public/dx-maps/doxa-maps-spa/manifest.json
+++ b/public/dx-maps/doxa-maps-spa/manifest.json
@@ -615,7 +615,7 @@
"bundle": "gospel-internet",
"scriptUrl": "./gospel-internet/gospel-internet.js",
"output": "dx-maps/gospel-internet/gospel-internet.js",
- "integrity": "sha384-vaQ5uTfFZa29SikosXm3SCtKbf2UvuoEswinq/qnmAeHDXjaiIPvqJGDZvqENhjf",
+ "integrity": "sha384-Ok30q6/8Z0WNu0q0qgxYln/censxh2mhSUbEvBKLDm4pvEWeCIPlJs8LyWSRZ6iD",
"elements": [
"gospel-internet-board"
],
@@ -635,7 +635,7 @@
"scriptUrl": "./gospel-internet/gospel-internet.js",
"embed": {
"iframe": "",
- "script": "\n \n\n\n "
+ "script": "\n \n\n\n "
},
"instances": [
{
diff --git a/public/dx-maps/gospel-internet/board/board/index.html b/public/dx-maps/gospel-internet/board/board/index.html
index a352a3c0a..3847c9b38 100644
--- a/public/dx-maps/gospel-internet/board/board/index.html
+++ b/public/dx-maps/gospel-internet/board/board/index.html
@@ -36,7 +36,7 @@
-
+
-
+
-
+
-
+
-
+
-
+
-
+
\n\n "
+ "script": "\n \n\n\n "
},
"instances": [
{
From c45194fd0f3b85be4de1cd64fafe20f430291e4f Mon Sep 17 00:00:00 2001
From: Doxa Maps
Date: Mon, 28 Sep 2026 18:13:20 +0000
Subject: [PATCH 09/78] Say the table is loading, rather than drawing a
confident zero
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The neighbour table is 2.6 MB. Until it arrives the joined set is legitimately
empty, so both panels drew a chart of nothing and captioned it with a count of
zero — indistinguishable from a real answer of none, which is the same defect as
a join failure reported as a fact about a people.
Found by sweeping my own plugins for the Driver's bug classes rather than by
anyone reporting it, which is the point of the sweep: the newest code was the
code with the defect, because it had been read least.
The loading line is announced politely for a screen reader, since the change it
describes is a region of the page becoming real rather than an alert.
---
.../src/components/GiBoundedSet.vue | 9 ++++-
.../src/components/GiCenteredSet.vue | 9 ++++-
public/dx-maps/doxa-maps-spa/manifest.json | 4 +-
.../gospel-internet/board/board/index.html | 2 +-
.../dx-maps/gospel-internet/board/index.html | 2 +-
.../board/india-china/index.html | 2 +-
.../board/language-tree/index.html | 2 +-
.../board/platforms/index.html | 2 +-
.../board/project-700/index.html | 2 +-
.../gospel-internet/board/zero/index.html | 2 +-
.../gospel-internet/gospel-internet.js | 38 +++++++++----------
public/dx-maps/manifest.json | 4 +-
12 files changed, 46 insertions(+), 32 deletions(-)
diff --git a/plugin-designs/gospel-internet/src/components/GiBoundedSet.vue b/plugin-designs/gospel-internet/src/components/GiBoundedSet.vue
index 6a7951514..fd7164abe 100644
--- a/plugin-designs/gospel-internet/src/components/GiBoundedSet.vue
+++ b/plugin-designs/gospel-internet/src/components/GiBoundedSet.vue
@@ -91,7 +91,14 @@ useShadowStyles(
{{ T('gospelInternet.bounded.failed', 'The reached/unreached table could not be loaded, so this chart is not drawn.') }}
{{ failed }}
-
+
+
+ {{ T('gospelInternet.loadingTable', 'Loading the table…') }}
+
+
+
+ {{ T('gospelInternet.loadingTable', 'Loading the table…') }}
+
+
\n
\n\n\n
"
+ "script": "\n
\n\n\n
"
},
"instances": [
{
diff --git a/public/dx-maps/gospel-internet/board/board/index.html b/public/dx-maps/gospel-internet/board/board/index.html
index 3847c9b38..be0e45e64 100644
--- a/public/dx-maps/gospel-internet/board/board/index.html
+++ b/public/dx-maps/gospel-internet/board/board/index.html
@@ -36,7 +36,7 @@
-
+
-
+
-
+
-
+
-
+
-
+
-
+
\n\n
"
+ "script": "\n
\n\n\n
"
},
"instances": [
{
From 699662708141050c10648b9a3d950a08f7712047 Mon Sep 17 00:00:00 2001
From: Doxa Maps
Date: Mon, 28 Sep 2026 18:13:20 +0000
Subject: [PATCH 10/78] One key for the set charts' loading line, in all eleven
locales
Authored here and landed by studio, per the library boundary. The English is
byte-identical to the call-site fallback; each script takes its own ellipsis and
spacing convention rather than the English one.
---
library/i18n/locales/ar/gospelInternet.json | 1 +
library/i18n/locales/de/gospelInternet.json | 1 +
library/i18n/locales/en/gospelInternet.json | 1 +
library/i18n/locales/es/gospelInternet.json | 1 +
library/i18n/locales/fr/gospelInternet.json | 1 +
library/i18n/locales/hi/gospelInternet.json | 1 +
library/i18n/locales/it/gospelInternet.json | 1 +
library/i18n/locales/pt/gospelInternet.json | 1 +
library/i18n/locales/ro/gospelInternet.json | 1 +
library/i18n/locales/ru/gospelInternet.json | 1 +
library/i18n/locales/zh/gospelInternet.json | 1 +
11 files changed, 11 insertions(+)
diff --git a/library/i18n/locales/ar/gospelInternet.json b/library/i18n/locales/ar/gospelInternet.json
index 383b51aaa..407960baa 100644
--- a/library/i18n/locales/ar/gospelInternet.json
+++ b/library/i18n/locales/ar/gospelInternet.json
@@ -192,6 +192,7 @@
},
"listChip": "قائمة المجموعات العرقية التي اختارتها هذه الصفحة",
"loadError": "لم يتم تحميل بيانات اللوحة.",
+ "loadingTable": "جارٍ تحميل الجدول…",
"map": {
"aria": "خريطة المجموعات العرقية، مظلّلة بحسب نسبة استخدام الإنترنت في كل بلد"
},
diff --git a/library/i18n/locales/de/gospelInternet.json b/library/i18n/locales/de/gospelInternet.json
index 8e7a5d4e6..f31796482 100644
--- a/library/i18n/locales/de/gospelInternet.json
+++ b/library/i18n/locales/de/gospelInternet.json
@@ -192,6 +192,7 @@
},
"listChip": "Volksgruppenliste, von dieser Seite gewählt",
"loadError": "Die Daten der Übersicht wurden nicht geladen.",
+ "loadingTable": "Die Tabelle wird geladen …",
"map": {
"aria": "Karte der Volksgruppen, schattiert danach, wie viele Menschen im Land das Internet nutzen"
},
diff --git a/library/i18n/locales/en/gospelInternet.json b/library/i18n/locales/en/gospelInternet.json
index 6c84695eb..51499e7a0 100644
--- a/library/i18n/locales/en/gospelInternet.json
+++ b/library/i18n/locales/en/gospelInternet.json
@@ -192,6 +192,7 @@
},
"listChip": "People-group list, chosen by this page",
"loadError": "The board's data did not load.",
+ "loadingTable": "Loading the table…",
"map": {
"aria": "Map of people groups, shaded by how much of each country uses the internet"
},
diff --git a/library/i18n/locales/es/gospelInternet.json b/library/i18n/locales/es/gospelInternet.json
index 7f2f621e4..2d9edc5b1 100644
--- a/library/i18n/locales/es/gospelInternet.json
+++ b/library/i18n/locales/es/gospelInternet.json
@@ -192,6 +192,7 @@
},
"listChip": "Lista de grupos étnicos elegida por esta página",
"loadError": "No se cargaron los datos del tablero.",
+ "loadingTable": "Cargando la tabla…",
"map": {
"aria": "Mapa de grupos étnicos, sombreado según cuánto usa internet cada país"
},
diff --git a/library/i18n/locales/fr/gospelInternet.json b/library/i18n/locales/fr/gospelInternet.json
index 7a2a1e5de..28bb49f42 100644
--- a/library/i18n/locales/fr/gospelInternet.json
+++ b/library/i18n/locales/fr/gospelInternet.json
@@ -192,6 +192,7 @@
},
"listChip": "Liste de groupes ethniques choisie par cette page",
"loadError": "Les données du tableau de bord n’ont pas été chargées.",
+ "loadingTable": "Chargement de la table…",
"map": {
"aria": "Carte des groupes ethniques, ombrée selon la part de chaque pays qui utilise internet"
},
diff --git a/library/i18n/locales/hi/gospelInternet.json b/library/i18n/locales/hi/gospelInternet.json
index fcea19303..c5c2b4958 100644
--- a/library/i18n/locales/hi/gospelInternet.json
+++ b/library/i18n/locales/hi/gospelInternet.json
@@ -192,6 +192,7 @@
},
"listChip": "इस पेज द्वारा चुनी गई जन-समूह सूची",
"loadError": "बोर्ड का डेटा लोड नहीं हुआ।",
+ "loadingTable": "तालिका लोड हो रही है…",
"map": {
"aria": "जन-समूहों का मानचित्र, हर देश में इंटरनेट उपयोग के अनुसार रंगा हुआ"
},
diff --git a/library/i18n/locales/it/gospelInternet.json b/library/i18n/locales/it/gospelInternet.json
index d1b03116b..0da518418 100644
--- a/library/i18n/locales/it/gospelInternet.json
+++ b/library/i18n/locales/it/gospelInternet.json
@@ -192,6 +192,7 @@
},
"listChip": "Elenco di gruppi etnici scelto da questa pagina",
"loadError": "I dati del pannello non sono stati caricati.",
+ "loadingTable": "Caricamento della tabella…",
"map": {
"aria": "Mappa dei gruppi etnici, ombreggiata secondo quanto ogni paese usa internet"
},
diff --git a/library/i18n/locales/pt/gospelInternet.json b/library/i18n/locales/pt/gospelInternet.json
index 2638a0946..ddf38a658 100644
--- a/library/i18n/locales/pt/gospelInternet.json
+++ b/library/i18n/locales/pt/gospelInternet.json
@@ -192,6 +192,7 @@
},
"listChip": "Lista de povos escolhida por esta página",
"loadError": "Os dados do painel não carregaram.",
+ "loadingTable": "A carregar a tabela…",
"map": {
"aria": "Mapa dos povos, sombreado pelo quanto cada país usa a internet"
},
diff --git a/library/i18n/locales/ro/gospelInternet.json b/library/i18n/locales/ro/gospelInternet.json
index 60cf3bf6a..331fe220b 100644
--- a/library/i18n/locales/ro/gospelInternet.json
+++ b/library/i18n/locales/ro/gospelInternet.json
@@ -192,6 +192,7 @@
},
"listChip": "Listă de grupuri etnice aleasă de această pagină",
"loadError": "Datele panoului nu s-au încărcat.",
+ "loadingTable": "Se încarcă tabelul…",
"map": {
"aria": "Harta grupurilor etnice, colorată după cât folosește internetul fiecare țară"
},
diff --git a/library/i18n/locales/ru/gospelInternet.json b/library/i18n/locales/ru/gospelInternet.json
index b2905dd62..278ccc27f 100644
--- a/library/i18n/locales/ru/gospelInternet.json
+++ b/library/i18n/locales/ru/gospelInternet.json
@@ -192,6 +192,7 @@
},
"listChip": "Список народов, выбранный этой страницей",
"loadError": "Данные панели не загрузились.",
+ "loadingTable": "Загрузка таблицы…",
"map": {
"aria": "Карта народов, закрашенная по доле пользователей интернета в каждой стране"
},
diff --git a/library/i18n/locales/zh/gospelInternet.json b/library/i18n/locales/zh/gospelInternet.json
index 898ad882e..cfd800670 100644
--- a/library/i18n/locales/zh/gospelInternet.json
+++ b/library/i18n/locales/zh/gospelInternet.json
@@ -192,6 +192,7 @@
},
"listChip": "本页面选定的族群名单",
"loadError": "看板的数据未能加载。",
+ "loadingTable": "正在载入数据表…",
"map": {
"aria": "族群地图,按各国互联网使用程度着色"
},
From 6cab26c79731078cf13855be6d95b619e3ff6369 Mon Sep 17 00:00:00 2001
From: Doxa Maps
Date: Mon, 28 Sep 2026 18:32:31 +0000
Subject: [PATCH 11/78] Make the legend's full-screen button do something
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The ring legend declared a fullscreen emit and rendered a labelled, focusable
button for it. The parent bound select and not fullscreen, so the button looked
right, tabbed right, announced itself right, and did nothing. It is a Driver ask,
and it is the half-wired shape exactly: the visible half worked, so nothing
appeared broken.
The fix is not to bind the emit. The button is now the shared control, so there
is one implementation instead of two — and it brings the part a second one would
have missed. iOS Safari does not implement requestFullscreen on arbitrary
elements and fails silently, so the shared control carries a pseudo-fullscreen
fallback. A hand-rolled handler would have worked everywhere I could test it and
nowhere I could not.
A first attempt kept the hand-rolled button and called the shared component's
toggle through a template ref. That component has no defineExpose, so under
script setup the method is private and the call would have resolved to undefined
— silently reintroducing the same defect while appearing to fix it. Checked
before committing rather than after.
The panel element is passed in, because full screen should show the chart and the
legend together, and the legend does not own the panel it sits in.
Found by sweeping for the half-wired-config class rather than by anyone reporting
it, which is what that class is for: nothing about it looks wrong from outside.
---
.../src/components/GiCenteredSet.vue | 18 +++++++-
.../src/components/GiRingLegend.vue | 16 +++++--
public/dx-maps/doxa-maps-spa/manifest.json | 4 +-
.../gospel-internet/board/board/index.html | 2 +-
.../dx-maps/gospel-internet/board/index.html | 2 +-
.../board/india-china/index.html | 2 +-
.../board/language-tree/index.html | 2 +-
.../board/platforms/index.html | 2 +-
.../board/project-700/index.html | 2 +-
.../gospel-internet/board/zero/index.html | 2 +-
.../gospel-internet/gospel-internet.js | 44 +++++++++----------
public/dx-maps/manifest.json | 4 +-
12 files changed, 62 insertions(+), 38 deletions(-)
diff --git a/plugin-designs/gospel-internet/src/components/GiCenteredSet.vue b/plugin-designs/gospel-internet/src/components/GiCenteredSet.vue
index d0193ed70..aef6f5d4a 100644
--- a/plugin-designs/gospel-internet/src/components/GiCenteredSet.vue
+++ b/plugin-designs/gospel-internet/src/components/GiCenteredSet.vue
@@ -27,6 +27,7 @@ import { loadNearRows, indexFor } from '../nearRows.js'
import { layoutCenteredSet, ringTally, inwardArrow, ringRadii } from '@map/components/charts/centeredSet.js'
import GiRingLegend from './GiRingLegend.vue'
import GiCenteredSetMap from './GiCenteredSetMap.vue'
+import FullscreenButton from '@map/components/map-controls/FullscreenButton.vue'
const T = useGiT()
const board = useBoardState()
@@ -34,6 +35,9 @@ const near = ref(null)
// Which ring the reader has asked about. null = none; the chart dims the others rather than
// hiding them, because a ring with no dots left would read as a ring with no groups.
const activeRing = ref(null)
+// The panel element, so full screen shows the chart AND its legend rather than one of them.
+const panelEl = ref(null)
+
const failed = ref('')
onMounted(async () => {
@@ -106,7 +110,7 @@ useShadowStyles(
-
+
{{ T('gospelInternet.centered.failed', 'The neighbour table could not be loaded, so this chart is not drawn.') }}
{{ failed }}
@@ -128,8 +132,18 @@ useShadowStyles(
@select="activeRing = $event" />
+
+ :active-ring="activeRing" :panel="panelEl" @select="activeRing = $event"
+ />
{{ T('gospelInternet.centered.reached', 'Already reached, so at the centre:') }} {{ fmt(tally.centre) }} .
diff --git a/plugin-designs/gospel-internet/src/components/GiRingLegend.vue b/plugin-designs/gospel-internet/src/components/GiRingLegend.vue
index af6f646b6..50af2a835 100644
--- a/plugin-designs/gospel-internet/src/components/GiRingLegend.vue
+++ b/plugin-designs/gospel-internet/src/components/GiRingLegend.vue
@@ -19,17 +19,21 @@
* Emits: select(ring | null), fullscreen
*/
import { ref, computed } from 'vue'
+import FullscreenButton from '@map/components/map-controls/FullscreenButton.vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { formatCount } from '@map/utils/formatCount.js'
import { useGiT } from '../useGiT.js'
import { BANDS, CULTURES, CENTRE_COLOR, ringInfo, ringFor, ringSummary } from '../ringInfo.js'
const props = defineProps({
+ // The element full screen should fill — the whole panel, so the chart and this legend go
+ // together. Passed in because the legend does not own the panel it sits in.
+ panel: { type: Object, default: null },
groups: { type: Array, default: () => [] },
unmatched: { type: Number, default: 0 },
activeRing: { type: Number, default: null },
})
-const emit = defineEmits(['select', 'fullscreen'])
+const emit = defineEmits(['select'])
const T = useGiT()
// formatCount, not toLocaleString(): a bare toLocaleString follows the READER'S BROWSER,
// so one page could print 2,074 here and 2.074 in the panel beside it. formatCount holds
@@ -115,8 +119,14 @@ useShadowStyles(
{{ T('gospelInternet.rings.title', 'What the rings mean') }}
- ⛶
+
+
{{ T('gospelInternet.rings.intro', 'Each unreached group sits on a ring by how close it is to its nearest reached group. The closer the ring, the more naturally the gospel can travel.') }}
diff --git a/public/dx-maps/doxa-maps-spa/manifest.json b/public/dx-maps/doxa-maps-spa/manifest.json
index 4247415a6..cb8e21ed7 100644
--- a/public/dx-maps/doxa-maps-spa/manifest.json
+++ b/public/dx-maps/doxa-maps-spa/manifest.json
@@ -615,7 +615,7 @@
"bundle": "gospel-internet",
"scriptUrl": "./gospel-internet/gospel-internet.js",
"output": "dx-maps/gospel-internet/gospel-internet.js",
- "integrity": "sha384-4Bp7rtVKWkP6qtL3MU3dMZB0xo4Ljekc3a+Ujfr0Ck6ypeSqOLOcq+n8Zt1kAI7v",
+ "integrity": "sha384-nDV/E/glwOpIW6Y+Rc8wn43M6J7gIKY00mirO8gEqaHWbaNNPHQp3tnkog+uuaNw",
"elements": [
"gospel-internet-board"
],
@@ -635,7 +635,7 @@
"scriptUrl": "./gospel-internet/gospel-internet.js",
"embed": {
"iframe": "",
- "script": "\n \n\n\n "
+ "script": "\n \n\n\n "
},
"instances": [
{
diff --git a/public/dx-maps/gospel-internet/board/board/index.html b/public/dx-maps/gospel-internet/board/board/index.html
index be0e45e64..a7ca029ba 100644
--- a/public/dx-maps/gospel-internet/board/board/index.html
+++ b/public/dx-maps/gospel-internet/board/board/index.html
@@ -36,7 +36,7 @@
-
+
-
+
-
+
-
+
-
+
-
+
-
+
\n\n "
+ "script": "\n \n\n\n "
},
"instances": [
{
From 2583d39d2d6f122b8bc4ae83ab12a419828eb0bd Mon Sep 17 00:00:00 2001
From: Doxa Maps
Date: Mon, 28 Sep 2026 19:37:25 +0000
Subject: [PATCH 12/78] Keep the reader's legend choice across a resize
Every width change reset the legend from the breakpoint, so opening the decoder
on a phone lost it on rotation, and closing it on a wide window reopened it by
dragging the edge. The resize was undoing the reader's decision rather than
responding to it.
The width now decides only until the reader does. After that their choice holds
at both widths.
This is the same defect fixed in the other plugin's legend earlier today, written
again here a few hours later, because the two legends are separate
implementations of one control and only one of them had learned. That is the
argument for promoting the control, not for being more careful next time.
---
.../src/components/GiCenteredSet.vue | 16 ++++++++++--
public/dx-maps/doxa-maps-spa/manifest.json | 4 +--
.../gospel-internet/board/board/index.html | 2 +-
.../dx-maps/gospel-internet/board/index.html | 2 +-
.../board/india-china/index.html | 2 +-
.../board/language-tree/index.html | 2 +-
.../board/platforms/index.html | 2 +-
.../board/project-700/index.html | 2 +-
.../gospel-internet/board/zero/index.html | 2 +-
.../gospel-internet/gospel-internet.js | 26 +++++++++----------
public/dx-maps/manifest.json | 4 +--
11 files changed, 38 insertions(+), 26 deletions(-)
diff --git a/plugin-designs/gospel-internet/src/components/GiCenteredSet.vue b/plugin-designs/gospel-internet/src/components/GiCenteredSet.vue
index a75f02dd3..d6f7540e2 100644
--- a/plugin-designs/gospel-internet/src/components/GiCenteredSet.vue
+++ b/plugin-designs/gospel-internet/src/components/GiCenteredSet.vue
@@ -42,7 +42,19 @@ const activeRing = ref(null)
const narrow = ref(false)
const legendOpen = ref(true)
let mq = null
-function applyWidth(m) { narrow.value = m.matches; legendOpen.value = !m.matches }
+// ★ A DELIBERATE CHOICE SURVIVES A RESIZE. The first version of this set legendOpen from the
+// breakpoint on EVERY width change, so a reader who opened the decoder on a phone lost it on
+// rotation, and one who closed it on desktop had it reopened by dragging the window. The resize
+// would be undoing their decision rather than responding to it.
+// This is the same defect I had already fixed in the other plugin's legend today and then wrote
+// again here, because the two legends are separate implementations of one control. That is the
+// case for promoting the control rather than a reason to be more careful.
+let chosen = null // null = the reader has not decided yet
+function applyWidth(m) {
+ narrow.value = m.matches
+ legendOpen.value = chosen == null ? !m.matches : chosen
+}
+function setLegendOpen(v) { chosen = v; legendOpen.value = v }
onMounted(() => {
try {
mq = window.matchMedia('(max-width: 900px)')
@@ -151,7 +163,7 @@ useShadowStyles(
+ :aria-expanded="String(legendOpen)" @click="setLegendOpen(!legendOpen)">
\n \n\n\n\n "
+ "script": "\n \n\n\n\n "
},
"instances": [
{
diff --git a/public/dx-maps/gospel-internet/board/board/index.html b/public/dx-maps/gospel-internet/board/board/index.html
index dec471b5a..9db9a5e88 100644
--- a/public/dx-maps/gospel-internet/board/board/index.html
+++ b/public/dx-maps/gospel-internet/board/board/index.html
@@ -36,7 +36,7 @@
-
+
-
+
-
+
-
+
-
+
-
+
-
+
\n\n\n "
+ "script": "\n \n\n\n\n "
},
"instances": [
{
From 6fe0dc5408540a9cdb8fa4f4ac584b1b4b1890ad Mon Sep 17 00:00:00 2001
From: Doxa Maps
Date: Mon, 28 Sep 2026 19:37:25 +0000
Subject: [PATCH 13/78] Keep the reader's legend choice across a resize
Every width change reset the legend from the breakpoint, so opening the decoder
on a phone lost it on rotation, and closing it on a wide window reopened it by
dragging the edge. The resize was undoing the reader's decision rather than
responding to it.
The width now decides only until the reader does. After that their choice holds
at both widths.
This is the same defect fixed in the other plugin's legend earlier today, written
again here a few hours later, because the two legends are separate
implementations of one control and only one of them had learned. That is the
argument for promoting the control, not for being more careful next time.
---
.../src/components/GiCenteredSet.vue | 16 ++++++++++++++--
1 file changed, 14 insertions(+), 2 deletions(-)
diff --git a/plugin-designs/gospel-internet/src/components/GiCenteredSet.vue b/plugin-designs/gospel-internet/src/components/GiCenteredSet.vue
index a75f02dd3..d6f7540e2 100644
--- a/plugin-designs/gospel-internet/src/components/GiCenteredSet.vue
+++ b/plugin-designs/gospel-internet/src/components/GiCenteredSet.vue
@@ -42,7 +42,19 @@ const activeRing = ref(null)
const narrow = ref(false)
const legendOpen = ref(true)
let mq = null
-function applyWidth(m) { narrow.value = m.matches; legendOpen.value = !m.matches }
+// ★ A DELIBERATE CHOICE SURVIVES A RESIZE. The first version of this set legendOpen from the
+// breakpoint on EVERY width change, so a reader who opened the decoder on a phone lost it on
+// rotation, and one who closed it on desktop had it reopened by dragging the window. The resize
+// would be undoing their decision rather than responding to it.
+// This is the same defect I had already fixed in the other plugin's legend today and then wrote
+// again here, because the two legends are separate implementations of one control. That is the
+// case for promoting the control rather than a reason to be more careful.
+let chosen = null // null = the reader has not decided yet
+function applyWidth(m) {
+ narrow.value = m.matches
+ legendOpen.value = chosen == null ? !m.matches : chosen
+}
+function setLegendOpen(v) { chosen = v; legendOpen.value = v }
onMounted(() => {
try {
mq = window.matchMedia('(max-width: 900px)')
@@ -151,7 +163,7 @@ useShadowStyles(
+ :aria-expanded="String(legendOpen)" @click="setLegendOpen(!legendOpen)">
+
+ {{ T('gospelInternet.loadingTable', 'Loading the table…') }}
+
+
+
+ {{ T('gospelInternet.loadingTable', 'Loading the table…') }}
+
+
\n
\n\n\n\n
"
+ "script": "\n
\n\n\n\n
"
},
"instances": [
{
diff --git a/public/dx-maps/gospel-internet/board/board/index.html b/public/dx-maps/gospel-internet/board/board/index.html
index d8961163b..940964ae8 100644
--- a/public/dx-maps/gospel-internet/board/board/index.html
+++ b/public/dx-maps/gospel-internet/board/board/index.html
@@ -36,7 +36,7 @@
-
+
-
+
-
+
-
+
-
+
-
+
-
+
\n\n\n
"
+ "script": "\n
\n\n\n\n
"
},
"instances": [
{
From d3597f3d1036b4dee435911ca36d1acb161ebff3 Mon Sep 17 00:00:00 2001
From: Doxa Maps
Date: Mon, 28 Sep 2026 19:55:18 +0000
Subject: [PATCH 16/78] Release the test hooks on unmount, all twelve of them
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Twelve globals were set across three plugins and none was ever deleted. Each
pins whatever it closes over — a map instance pins its canvas, its sources and
through them the whole app — so every mount left another copy behind, measured
elsewhere at roughly 10 MB each.
Released as a set rather than one at a time. A block that sets several globals
and clears the obvious one leaks the rest silently, and the obvious one is
whichever the author happened to remember.
Identity-checked, which is why the value is recorded as it is set rather than
cleared by name. Two instances can share a page, and clearing by name alone
would let the second unmount delete the first's live hooks — trading a leak for
a harder bug in which a live map loses its test surface at a distance.
A first attempt compared against a name that did not exist. Inside the try/catch
that guards these, the ReferenceError was swallowed and every delete silently did
nothing — a leak fix that looks exactly like a leak fix and leaks. The guard that
makes the assignment safe is the same guard that hides its failure, which is the
argument for recording what was set rather than inferring it at teardown.
---
.../cities-internet/connectivity-map.vue | 23 +++++++--
.../gospel-internet/src/components/GiGrid.vue | 19 ++++++-
.../gospel-internet/src/components/GiMap.vue | 23 +++++++--
.../src/components/GiResources.vue | 18 ++++++-
.../gospel-internet/src/components/GiTree.vue | 23 +++++++--
.../gospel-resources/resources-map.vue | 26 ++++++++--
.../cities-internet/cities-internet.js | 50 +++++++++----------
.../connectivity-list/index.html | 2 +-
.../connectivity-map/index.html | 2 +-
.../cities-internet/data-tables/index.html | 2 +-
public/dx-maps/doxa-maps-spa/manifest.json | 24 ++++-----
.../gospel-internet/board/board/index.html | 2 +-
.../dx-maps/gospel-internet/board/index.html | 2 +-
.../board/india-china/index.html | 2 +-
.../board/language-tree/index.html | 2 +-
.../board/platforms/index.html | 2 +-
.../board/project-700/index.html | 2 +-
.../gospel-internet/board/zero/index.html | 2 +-
.../gospel-internet/gospel-internet.js | 48 +++++++++---------
.../gospel-resources/charts/index.html | 2 +-
.../gospel-resources/data-tables/index.html | 2 +-
.../gospel-dashboard/index.html | 2 +-
.../gospel-resources/gospel-resources.js | 46 ++++++++---------
.../resources-list/index.html | 2 +-
.../gospel-resources/resources-map/index.html | 2 +-
public/dx-maps/manifest.json | 24 ++++-----
26 files changed, 228 insertions(+), 126 deletions(-)
diff --git a/plugin-designs/cities-internet/connectivity-map.vue b/plugin-designs/cities-internet/connectivity-map.vue
index 5a5f81f70..d62361533 100644
--- a/plugin-designs/cities-internet/connectivity-map.vue
+++ b/plugin-designs/cities-internet/connectivity-map.vue
@@ -1,4 +1,11 @@
-
+
-
+
-
+
\n\n\n "
+ "script": "\n\n\n "
}
},
{
@@ -144,7 +144,7 @@
"scriptUrl": "./cities-internet/cities-internet.js",
"embed": {
"iframe": "",
- "script": "\n \n\n\n\n "
+ "script": "\n \n\n\n\n "
}
},
{
@@ -160,7 +160,7 @@
"scriptUrl": "./cities-internet/cities-internet.js",
"embed": {
"iframe": "",
- "script": "\n\n\n "
+ "script": "\n\n\n "
}
}
]
@@ -615,7 +615,7 @@
"bundle": "gospel-internet",
"scriptUrl": "./gospel-internet/gospel-internet.js",
"output": "dx-maps/gospel-internet/gospel-internet.js",
- "integrity": "sha384-OElwejLTJl1zxmuX/v/vFMaPWX6tb6lYh3g38kfQnSEgcBwrdkfUztFU2NJwO/Tf",
+ "integrity": "sha384-qMLfS8j749HVbpr1fMCVBByc5XB/bxVlbexpzCS5H4E9xXsRCch0+Ai/G+rOeRvb",
"elements": [
"gospel-internet-board"
],
@@ -635,7 +635,7 @@
"scriptUrl": "./gospel-internet/gospel-internet.js",
"embed": {
"iframe": "",
- "script": "\n \n\n\n\n "
+ "script": "\n \n\n\n\n "
},
"instances": [
{
@@ -742,7 +742,7 @@
"bundle": "gospel-resources",
"scriptUrl": "./gospel-resources/gospel-resources.js",
"output": "dx-maps/gospel-resources/gospel-resources.js",
- "integrity": "sha384-xXOOFAylJbFSv5NjEiAWzWF4nVDoed1ApuxGGLbpAx1sXz5LSza/3VkjbW0iqEhB",
+ "integrity": "sha384-3e/mKVPmXPDFPrg9kUi0KjYEKK0xYGDNm4lxuNdI8/l5bnidjaX+YFkIVVjEPlji",
"elements": [
"gospel-resources-map"
],
@@ -765,7 +765,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "",
- "script": "\n\n\n "
+ "script": "\n\n\n "
}
},
{
@@ -781,7 +781,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "",
- "script": "\n\n\n "
+ "script": "\n\n\n "
}
},
{
@@ -797,7 +797,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "",
- "script": "\n \n\n\n\n "
+ "script": "\n \n\n\n\n "
}
},
{
@@ -813,7 +813,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "",
- "script": "\n\n\n "
+ "script": "\n\n\n "
}
},
{
@@ -829,7 +829,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "",
- "script": "\n \n\n\n\n "
+ "script": "\n \n\n\n\n "
}
}
]
diff --git a/public/dx-maps/gospel-internet/board/board/index.html b/public/dx-maps/gospel-internet/board/board/index.html
index 940964ae8..bf9806544 100644
--- a/public/dx-maps/gospel-internet/board/board/index.html
+++ b/public/dx-maps/gospel-internet/board/board/index.html
@@ -36,7 +36,7 @@
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
\n\n\n "
+ "script": "\n\n\n "
}
},
{
@@ -144,7 +144,7 @@
"scriptUrl": "./cities-internet/cities-internet.js",
"embed": {
"iframe": "",
- "script": "\n \n\n\n\n "
+ "script": "\n \n\n\n\n "
}
},
{
@@ -160,7 +160,7 @@
"scriptUrl": "./cities-internet/cities-internet.js",
"embed": {
"iframe": "",
- "script": "\n\n\n "
+ "script": "\n\n\n "
}
}
]
@@ -615,7 +615,7 @@
"bundle": "gospel-internet",
"scriptUrl": "./gospel-internet/gospel-internet.js",
"output": "dx-maps/gospel-internet/gospel-internet.js",
- "integrity": "sha384-OElwejLTJl1zxmuX/v/vFMaPWX6tb6lYh3g38kfQnSEgcBwrdkfUztFU2NJwO/Tf",
+ "integrity": "sha384-qMLfS8j749HVbpr1fMCVBByc5XB/bxVlbexpzCS5H4E9xXsRCch0+Ai/G+rOeRvb",
"elements": [
"gospel-internet-board"
],
@@ -635,7 +635,7 @@
"scriptUrl": "./gospel-internet/gospel-internet.js",
"embed": {
"iframe": "",
- "script": "\n \n\n\n\n "
+ "script": "\n \n\n\n\n "
},
"instances": [
{
@@ -742,7 +742,7 @@
"bundle": "gospel-resources",
"scriptUrl": "./gospel-resources/gospel-resources.js",
"output": "dx-maps/gospel-resources/gospel-resources.js",
- "integrity": "sha384-xXOOFAylJbFSv5NjEiAWzWF4nVDoed1ApuxGGLbpAx1sXz5LSza/3VkjbW0iqEhB",
+ "integrity": "sha384-3e/mKVPmXPDFPrg9kUi0KjYEKK0xYGDNm4lxuNdI8/l5bnidjaX+YFkIVVjEPlji",
"elements": [
"gospel-resources-map"
],
@@ -765,7 +765,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "",
- "script": "\n\n\n "
+ "script": "\n\n\n "
}
},
{
@@ -781,7 +781,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "",
- "script": "\n\n\n "
+ "script": "\n\n\n "
}
},
{
@@ -797,7 +797,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "",
- "script": "\n \n\n\n\n "
+ "script": "\n \n\n\n\n "
}
},
{
@@ -813,7 +813,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "",
- "script": "\n\n\n "
+ "script": "\n\n\n "
}
},
{
@@ -829,7 +829,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "",
- "script": "\n \n\n\n\n "
+ "script": "\n \n\n\n\n "
}
}
]
From ef98ef2d3ce4b864efde9b92016fe0f36acb018c Mon Sep 17 00:00:00 2001
From: Doxa Maps
Date: Mon, 28 Sep 2026 20:04:07 +0000
Subject: [PATCH 17/78] Draw the set charts at all, and give their figures room
to be drawn in
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Neither chart drew anything. createBoardMap already awaits the map's load event
and resolves after it, so attaching a second load handler waited for something
that had already happened: the layer setup never ran, each map held only its
basemap layers, and the panel showed an empty whiteboard.
It failed silently, which is why the visible-failure branch did not catch it.
Nothing threw — the map was fine, the source was simply never added. A guard that
reports errors cannot report an absence of work.
The setup now runs directly after the await, and still binds once('load') if a
future engine resolves earlier. That keeps the fix correct rather than merely
right today.
The figures also grow from a zero basis instead of from their content. The map
inside is absolutely positioned and has no intrinsic width, so a basis of auto
asks for a content size of nothing and the legend takes the column — the figure
was measured at 39 pixels across while the decoder had the rest. This was hidden
behind the first bug: an empty chart and a chart with no room look the same.
---
.../src/components/GiBoundedSet.vue | 2 +-
.../src/components/GiBoundedSetMap.vue | 11 +++++++--
.../src/components/GiCenteredSet.vue | 7 +++++-
.../src/components/GiCenteredSetMap.vue | 11 +++++++--
public/dx-maps/doxa-maps-spa/manifest.json | 4 ++--
.../gospel-internet/board/board/index.html | 2 +-
.../dx-maps/gospel-internet/board/index.html | 2 +-
.../board/india-china/index.html | 2 +-
.../board/language-tree/index.html | 2 +-
.../board/platforms/index.html | 2 +-
.../board/project-700/index.html | 2 +-
.../gospel-internet/board/zero/index.html | 2 +-
.../gospel-internet/gospel-internet.js | 24 +++++++++----------
public/dx-maps/manifest.json | 4 ++--
14 files changed, 48 insertions(+), 29 deletions(-)
diff --git a/plugin-designs/gospel-internet/src/components/GiBoundedSet.vue b/plugin-designs/gospel-internet/src/components/GiBoundedSet.vue
index fd7164abe..bfe735d83 100644
--- a/plugin-designs/gospel-internet/src/components/GiBoundedSet.vue
+++ b/plugin-designs/gospel-internet/src/components/GiBoundedSet.vue
@@ -74,7 +74,7 @@ useShadowStyles(
// can never push the figure out of its pane the way the legend did on the centered set.
'.gbs{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;' +
'justify-content:center;padding:46px 10px 10px;box-sizing:border-box;overflow:hidden;background:#f3f3f1}' +
- '.gbs-fig{position:relative;flex:1 1 auto;align-self:stretch;min-height:0;min-width:0}' +
+ '.gbs-fig{position:relative;flex:1 1 0%;align-self:stretch;min-height:240px;min-width:0}' +
'.gbs-ring{fill:none;stroke:#3b463d;stroke-width:1.5}' +
'.gbs-in{fill:#92b195}.gbs-out{fill:#6d766e;opacity:.65}' +
'.gbs-cross-halo{stroke:var(--gbs-bg,#f3f3f1);stroke-width:7;stroke-linecap:square}' +
diff --git a/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue b/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue
index 0724c469c..492fef198 100644
--- a/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue
+++ b/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue
@@ -59,7 +59,13 @@ onMounted(async () => {
{ whiteboard: true, boundaries: false, center: [0, 0], zoom: 1 })
const map = markRaw(made.map || made)
mapRef.value = map
- map.on('load', () => {
+ // ★ createBoardMap ALREADY AWAITS LOAD. boardEngine resolves only after
+ // `await new Promise(res => map.once('load', res))`, so by the time this runs the event has
+ // fired and will not fire again — a second `map.on('load', …)` never runs and the layers are
+ // never added. The chart then draws a basemap-less whiteboard with no source on it: empty,
+ // silent, and with nothing thrown for a failure branch to catch.
+ // Guarded rather than assumed: if a future engine resolves earlier, once('load') still fires.
+ const setup = () => {
handle.value = markRaw(addTieredPointLayers(map, {
data: data.value,
sourceId: 'gbsm',
@@ -79,7 +85,8 @@ onMounted(async () => {
},
font: labelFont,
}))
- })
+ }
+ if (map.loaded()) setup(); else map.once('load', setup)
} catch (e) {
failed.value = String(e?.message || e)
}
diff --git a/plugin-designs/gospel-internet/src/components/GiCenteredSet.vue b/plugin-designs/gospel-internet/src/components/GiCenteredSet.vue
index ea099fc3e..29a7d752f 100644
--- a/plugin-designs/gospel-internet/src/components/GiCenteredSet.vue
+++ b/plugin-designs/gospel-internet/src/components/GiCenteredSet.vue
@@ -127,7 +127,12 @@ useShadowStyles(
// when the same rule is written twice.
// min-height in the narrow column: a flex child with auto content can resolve to zero height,
// which is what a 0x0 chart is. rega measured exactly that at 390px.
- '.gcs-fig{position:relative;flex:1 1 auto;align-self:stretch;min-width:0;min-height:240px;' +
+ // flex-basis 0, not auto. The map inside is position:absolute, so the figure has NO intrinsic
+ // width to be sized from — with basis auto it asks for its content, the content is nothing, and
+ // the legend beside it takes the column. rega measured the figure at 39px across while the
+ // decoder card had the rest. Growing from zero makes the figure take the remaining space
+ // whatever it happens to contain.
+ '.gcs-fig{position:relative;flex:1 1 0%;align-self:stretch;min-width:0;min-height:240px;' +
'display:flex;flex-direction:column;align-items:center;justify-content:center;overflow:hidden}' +
'.gcs-aside{flex:0 1 340px;min-width:0;min-height:0;overflow:auto;display:flex;align-items:flex-start}' +
'.gcs-legend-toggle{width:100%;padding:6px 10px;border:1px solid #d1d4cd;border-radius:8px;' +
diff --git a/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue b/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue
index 8c5c0210a..87f0176a7 100644
--- a/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue
+++ b/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue
@@ -79,7 +79,13 @@ onMounted(async () => {
{ whiteboard: true, boundaries: false, center: [0, 0], zoom: 1 })
const map = markRaw(made.map || made)
mapRef.value = map
- map.on('load', () => {
+ // ★ createBoardMap ALREADY AWAITS LOAD. boardEngine resolves only after
+ // `await new Promise(res => map.once('load', res))`, so by the time this runs the event has
+ // fired and will not fire again — a second `map.on('load', …)` never runs and the layers are
+ // never added. The chart then draws a basemap-less whiteboard with no source on it: empty,
+ // silent, and with nothing thrown for a failure branch to catch.
+ // Guarded rather than assumed: if a future engine resolves earlier, once('load') still fires.
+ const setup = () => {
handle.value = markRaw(addTieredPointLayers(map, {
data: data.value,
sourceId: 'gcsm',
@@ -96,7 +102,8 @@ onMounted(async () => {
onClick: (id, f) => { const r = f?.properties?.ring; if (r != null) emit('select', r) },
}))
ready.value = true
- })
+ }
+ if (map.loaded()) setup(); else map.once('load', setup)
} catch (e) {
// FAILS VISIBLY: an empty whiteboard is indistinguishable from a chart with no groups.
failed.value = String(e?.message || e)
diff --git a/public/dx-maps/doxa-maps-spa/manifest.json b/public/dx-maps/doxa-maps-spa/manifest.json
index a2bd4654d..13b7339fb 100644
--- a/public/dx-maps/doxa-maps-spa/manifest.json
+++ b/public/dx-maps/doxa-maps-spa/manifest.json
@@ -615,7 +615,7 @@
"bundle": "gospel-internet",
"scriptUrl": "./gospel-internet/gospel-internet.js",
"output": "dx-maps/gospel-internet/gospel-internet.js",
- "integrity": "sha384-qMLfS8j749HVbpr1fMCVBByc5XB/bxVlbexpzCS5H4E9xXsRCch0+Ai/G+rOeRvb",
+ "integrity": "sha384-obMMHNBZZ4lL1Nr9vDE/FyyYE58gwKhTL+VLiOOdDdii9AHJ/W4VQ9kjRPERWbp0",
"elements": [
"gospel-internet-board"
],
@@ -635,7 +635,7 @@
"scriptUrl": "./gospel-internet/gospel-internet.js",
"embed": {
"iframe": "",
- "script": "\n \n\n\n\n "
+ "script": "\n \n\n\n\n "
},
"instances": [
{
diff --git a/public/dx-maps/gospel-internet/board/board/index.html b/public/dx-maps/gospel-internet/board/board/index.html
index bf9806544..63ffc70b6 100644
--- a/public/dx-maps/gospel-internet/board/board/index.html
+++ b/public/dx-maps/gospel-internet/board/board/index.html
@@ -36,7 +36,7 @@
-
+
-
+
-
+
-
+
-
+
-
+
-
+
\n\n\n "
+ "script": "\n \n\n\n\n "
},
"instances": [
{
From 46a12f1769002803cfbf246a4dc1655c3d9e0680 Mon Sep 17 00:00:00 2001
From: Doxa Maps
Date: Mon, 28 Sep 2026 20:43:21 +0000
Subject: [PATCH 18/78] Give the charts a real centre column at 768, and fix
what that exposed
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The board collapsed to a single panel below 760px; the set charts stacked their
legend below 900. Between those widths the board still held three columns while a
chart inside it had already given up on having room — at 768 the centre column
was about 145px, so one figure was crushed to 124 and the other's own minimum
made it spill under the right-hand panel. Neither number was wrong alone; the
defect was that they were two numbers, with nothing connecting the width at which
a chart needs room to the width at which the board gives it any. They are one
constant now.
Ring labels take four anchors instead of one. A single label at the top meant a
reader zooming anywhere else on a ring saw no ring name, which is most of the
ring and certainly wherever the dots are dense. The engine drops the extras when
they would collide, so low zoom is unchanged.
The cross is drawn as geometry rather than as a text glyph. It was a symbol whose
label was the character itself, and the layer rendered zero features: a glyph
appears only if the font has it, and nothing reports a missing one. Two line
segments depend on no font, and are the same mark in every locale — including the
right-to-left pages, where a text cross would also have been subject to bidi
reordering.
Both charts fit their whole extent on load. The default zoom clipped the outer
rings top and bottom, so the reader met a drawing already cut off — which reads
as a rendering fault rather than as something to zoom out of.
---
plugin-designs/gospel-internet/board.vue | 5 +-
.../gospel-internet/src/breakpoints.js | 18 ++++++++
.../src/components/GiBoundedSetMap.vue | 28 +++++++++--
.../src/components/GiCenteredSet.vue | 6 ++-
.../src/components/GiCenteredSetMap.vue | 19 +++++++-
.../gospel-internet/src/setChartFeatures.js | 36 ++++++++++++++-
public/dx-maps/doxa-maps-spa/manifest.json | 4 +-
.../gospel-internet/board/board/index.html | 2 +-
.../dx-maps/gospel-internet/board/index.html | 2 +-
.../board/india-china/index.html | 2 +-
.../board/language-tree/index.html | 2 +-
.../board/platforms/index.html | 2 +-
.../board/project-700/index.html | 2 +-
.../gospel-internet/board/zero/index.html | 2 +-
.../gospel-internet/gospel-internet.js | 46 +++++++++----------
public/dx-maps/manifest.json | 4 +-
16 files changed, 135 insertions(+), 45 deletions(-)
create mode 100644 plugin-designs/gospel-internet/src/breakpoints.js
diff --git a/plugin-designs/gospel-internet/board.vue b/plugin-designs/gospel-internet/board.vue
index 8a4d6ddad..2d2e62e2b 100644
--- a/plugin-designs/gospel-internet/board.vue
+++ b/plugin-designs/gospel-internet/board.vue
@@ -39,6 +39,7 @@ import GiDrawer from './src/components/GiDrawer.vue'
import GiTree from './src/components/GiTree.vue'
import GiResources from './src/components/GiResources.vue'
import GiCombos from './src/components/GiCombos.vue'
+import { isNarrowWidth } from './src/breakpoints.js'
const T = useGiT()
const board = useBoard()
@@ -76,7 +77,7 @@ board.readUrl()
// partner's address bar is never rewritten. facetKeys is explicit: its default reads nothing on a cold load.
useShareableView(bus, { mode: 'query', write: board.profileConfig.value?.urlState === 'query', facetKeys: SHARE_FACETS })
-// ── responsive: one panel at a time under 760px (ResizeObserver on the shell, not @container: a
+// ── responsive: one panel at a time under the shared breakpoint (ResizeObserver on the shell, not @container: a
// container query cannot target the container itself, which is unreliable inside a shadow root) ──────
const shell = ref(null)
const isNarrow = ref(false)
@@ -84,7 +85,7 @@ const mobilePanel = ref('map')
let ro = null
onMounted(() => {
if (shell.value && typeof ResizeObserver !== 'undefined') {
- ro = new ResizeObserver(([e]) => { isNarrow.value = e.contentRect.width < 760 })
+ ro = new ResizeObserver(([e]) => { isNarrow.value = isNarrowWidth(e.contentRect.width) })
ro.observe(shell.value)
}
board.load()
diff --git a/plugin-designs/gospel-internet/src/breakpoints.js b/plugin-designs/gospel-internet/src/breakpoints.js
new file mode 100644
index 000000000..61bdaefc2
--- /dev/null
+++ b/plugin-designs/gospel-internet/src/breakpoints.js
@@ -0,0 +1,18 @@
+/**
+ * breakpoints.js — the one width at which this dashboard stops being a three-column board.
+ *
+ * ★ IT IS A CONSTANT BECAUSE IT WAS TWO NUMBERS. board.vue collapsed to a single panel below
+ * 760px; the set charts stacked their legend below 900px. Between those two widths the board
+ * still held three columns while a chart inside it had already given up on having room — at
+ * 768 the centre column was about 145px, so the bounded figure was crushed to 124px and the
+ * centered figure's own minimum made it spill UNDER the right-hand panel.
+ *
+ * Neither number was wrong on its own. The defect was that they were separate, and nothing
+ * connected the width at which a chart needs room to the width at which the board gives it any.
+ */
+export const NARROW_MAX = 900
+
+/** True when the shell is too narrow for side panels beside the centre column. */
+export function isNarrowWidth(px) {
+ return Number(px) < NARROW_MAX
+}
diff --git a/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue b/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue
index d9a003c4f..b02351bca 100644
--- a/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue
+++ b/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue
@@ -26,7 +26,7 @@ import { createBoardMap } from '../boardEngine.js'
import { useBoardState } from '../useBoard.js'
import { useGiT } from '../useGiT.js'
import { layoutBoundedSet } from '@map/components/charts/boundedSet.js'
-import { boundedSetFeatures, toFeatureCollection, dishRadiusDegrees, degreesToPixelRadius, TIERS } from '../setChartFeatures.js'
+import { boundedSetFeatures, toFeatureCollection, dishRadiusDegrees, degreesToPixelRadius, crossFeature, TIERS } from '../setChartFeatures.js'
const props = defineProps({ groups: { type: Array, default: () => [] } })
@@ -43,11 +43,13 @@ const project = whiteboardProjection({ extent: SPREAD })
const layout = computed(() => layoutBoundedSet(props.groups, { r: R, spread: SPREAD }))
const built = computed(() => boundedSetFeatures(layout.value, { population: (p) => p.pop ?? 0 }))
-// The cross is a feature, not an overlay, so it moves with the drawing. It is its own tier so
-// the label layer renders it at every zoom while group names stay hidden until you are close.
+// The cross is drawn as GEOMETRY after the layers (see crossFeature), not as a label feature.
+// It was a symbol whose text was the character itself, and the layer rendered zero features
+// because a glyph only appears if the font has it — and nothing reports a missing one.
+// One point feature is still needed at the centre, because the dish anchors to it.
const features = computed(() => [
...built.value.features,
- { id: '__centre', label: '✝', tier: 'centre', k: 3e9, u: 0, v: 0 },
+ { id: '__centre', label: '', tier: 'anchor', k: 3e9, u: 0, v: 0 },
])
const data = computed(() => toFeatureCollection(features.value, project).collection)
@@ -70,7 +72,6 @@ onMounted(async () => {
data: data.value,
sourceId: 'gbsm',
tiers: [
- { tier: 'centre', minzoom: 0, weight: 'bold', offset: 0 },
{ tier: TIERS.GROUP, minzoom: 5.2, weight: 'regular', offset: 0.7 },
],
// Inside and outside are the whole claim, so they are the whole palette.
@@ -85,6 +86,23 @@ onMounted(async () => {
},
font: labelFont,
}))
+
+ // The cross, as geometry. See crossFeature: a text glyph silently drew nothing.
+ try {
+ map.addSource(`${'gbsm'}-cross`, { type: 'geojson', data: crossFeature(project) })
+ map.addLayer({ id: `${'gbsm'}-cross`, type: 'line', source: `${'gbsm'}-cross`,
+ paint: { 'line-color': '#3b463d', 'line-width': 3, 'line-cap': 'square' } })
+ } catch (_) { /* the cross is a mark, not the data: never block the chart on it */ }
+
+ // ★ FIT THE WHOLE TARGET ON LOAD. The default zoom clipped the outer rings top and bottom —
+ // the reader met a drawing already cut off, which reads as a rendering fault rather than as
+ // something to zoom out of. Fitted from the projected extent, so it follows the chart's own
+ // size rather than a guessed zoom.
+ try {
+ const [w, sLat] = project(-SPREAD, -SPREAD)
+ const [e, nLat] = project(SPREAD, SPREAD)
+ map.fitBounds([[w, sLat], [e, nLat]], { padding: 24, duration: 0 })
+ } catch (_) {}
}
// ★ CALLED UNCONDITIONALLY, AND THE GUARD THAT USED TO BE HERE IS WHY THIS WAS BROKEN TWICE.
// createBoardMap only resolves after `await map.once('load')`, so by here the event HAS fired.
diff --git a/plugin-designs/gospel-internet/src/components/GiCenteredSet.vue b/plugin-designs/gospel-internet/src/components/GiCenteredSet.vue
index 270544426..626880516 100644
--- a/plugin-designs/gospel-internet/src/components/GiCenteredSet.vue
+++ b/plugin-designs/gospel-internet/src/components/GiCenteredSet.vue
@@ -28,6 +28,7 @@ import { layoutCenteredSet, ringTally, inwardArrow, ringRadii } from '@map/compo
import GiRingLegend from './GiRingLegend.vue'
import GiCenteredSetMap from './GiCenteredSetMap.vue'
import FullscreenButton from '@map/components/map-controls/FullscreenButton.vue'
+import { NARROW_MAX } from '../breakpoints.js'
const T = useGiT()
const board = useBoardState()
@@ -57,7 +58,10 @@ function applyWidth(m) {
function setLegendOpen(v) { chosen = v; legendOpen.value = v }
onMounted(() => {
try {
- mq = window.matchMedia('(max-width: 900px)')
+ // The same width the BOARD collapses at (NARROW_MAX). If these two ever disagree again,
+ // the board keeps its side panels while the chart has already given up on room — which is
+ // exactly the 768px failure.
+ mq = window.matchMedia(`(max-width: ${NARROW_MAX - 1}px)`)
applyWidth(mq)
mq.addEventListener?.('change', applyWidth)
} catch (_) { /* no matchMedia: stay wide, which is the layout that always fits */ }
diff --git a/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue b/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue
index b63e4dceb..7bb036d1b 100644
--- a/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue
+++ b/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue
@@ -28,7 +28,7 @@ import { useBoardState } from '../useBoard.js'
import { useGiT } from '../useGiT.js'
import { ringInfo, CENTRE_COLOR } from '../ringInfo.js'
import { layoutCenteredSet, ringRadii, RING_MAX } from '@map/components/charts/centeredSet.js'
-import { centeredSetFeatures, toFeatureCollection, TIERS } from '../setChartFeatures.js'
+import { centeredSetFeatures, toFeatureCollection, crossFeature, TIERS } from '../setChartFeatures.js'
const props = defineProps({
groups: { type: Array, default: () => [] }, // { id, label, ring, neighborId }
@@ -101,6 +101,23 @@ onMounted(async () => {
font: labelFont,
onClick: (id, f) => { const r = f?.properties?.ring; if (r != null) emit('select', r) },
}))
+
+ // The cross, as geometry. See crossFeature: a text glyph silently drew nothing.
+ try {
+ map.addSource(`${'gcsm'}-cross`, { type: 'geojson', data: crossFeature(project) })
+ map.addLayer({ id: `${'gcsm'}-cross`, type: 'line', source: `${'gcsm'}-cross`,
+ paint: { 'line-color': '#3b463d', 'line-width': 3, 'line-cap': 'square' } })
+ } catch (_) { /* the cross is a mark, not the data: never block the chart on it */ }
+
+ // ★ FIT THE WHOLE TARGET ON LOAD. The default zoom clipped the outer rings top and bottom —
+ // the reader met a drawing already cut off, which reads as a rendering fault rather than as
+ // something to zoom out of. Fitted from the projected extent, so it follows the chart's own
+ // size rather than a guessed zoom.
+ try {
+ const [w, sLat] = project(-EXTENT, -EXTENT)
+ const [e, nLat] = project(EXTENT, EXTENT)
+ map.fitBounds([[w, sLat], [e, nLat]], { padding: 24, duration: 0 })
+ } catch (_) {}
ready.value = true
}
// ★ CALLED UNCONDITIONALLY, AND THE GUARD THAT USED TO BE HERE IS WHY THIS WAS BROKEN TWICE.
diff --git a/plugin-designs/gospel-internet/src/setChartFeatures.js b/plugin-designs/gospel-internet/src/setChartFeatures.js
index 66bc15a47..87e9b0c95 100644
--- a/plugin-designs/gospel-internet/src/setChartFeatures.js
+++ b/plugin-designs/gospel-internet/src/setChartFeatures.js
@@ -41,11 +41,19 @@ export function centeredSetFeatures(placed, radii, { bandLabel, ringLabel, popul
// One label per ring, placed on the ring itself at a fixed angle so it does not wander between
// renders. Rings outrank groups; the inner rings outrank the outer, because a reader zooming in
// meets them in that order.
+ // ★ FOUR ANCHORS PER RING, NOT ONE. A single label at the top means a reader who zooms in
+ // anywhere else on the ring — which is most of it, and certainly wherever the dots are dense —
+ // sees no ring name at all. Measured: zoomed onto a ring-2 dot, zero ring labels were in view.
+ // Four is enough that any quadrant carries one, and the engine's own collision handling drops
+ // the extras when they would overlap, so the cost at low zoom is nothing.
+ const RING_ANCHORS = [[0, -1], [1, 0], [0, 1], [-1, 0]]
for (const { ring, r } of radii || []) {
const info = ringInfo(ring)
if (!info) continue
- out.push({ id: `ring-${ring}`, label: ringLabel?.(ring) ?? `Ring ${ring}`, tier: TIERS.RING,
- ring, k: 1e9 - ring, u: 0, v: -r })
+ RING_ANCHORS.forEach(([dx, dy], i) => {
+ out.push({ id: `ring-${ring}-${i}`, label: ringLabel?.(ring) ?? `Ring ${ring}`, tier: TIERS.RING,
+ ring, k: 1e9 - ring, u: dx * r, v: dy * r })
+ })
}
// One label per band, at the outer edge of the band's own rings.
const bands = new Map()
@@ -135,3 +143,27 @@ export function degreesToPixelRadius(degrees, { minZoom = 0, maxZoom = 22 } = {}
const px = (z) => Number(degrees || 0) * 512 * Math.pow(2, z) / 360
return ['interpolate', ['exponential', 2], ['zoom'], minZoom, px(minZoom), maxZoom, px(maxZoom)]
}
+
+/**
+ * The cross at the centre, as GEOMETRY rather than a text glyph.
+ *
+ * ★ IT WAS A LABEL AND IT DID NOT RENDER. The bounded set carried the cross as a symbol feature
+ * whose label was the character itself; the layer existed and drew ZERO features, because a glyph
+ * only appears if the map's font actually has it, and nothing reports a missing one. Drawn as two
+ * line segments it depends on no font at all, and it is the same mark in every locale — including
+ * the RTL pages, where a text cross would also have been subject to bidi reordering.
+ *
+ * Sized in layout units so it scales with the drawing, and projected by the caller like everything
+ * else here.
+ */
+export function crossFeature(project, { arm = 13, cx = 0, cy = 0 } = {}) {
+ const vert = [[cx, cy - arm], [cx, cy + arm]].map(([u, v]) => project(u, v))
+ const horiz = [[cx - arm * 0.7, cy - arm * 0.35], [cx + arm * 0.7, cy - arm * 0.35]].map(([u, v]) => project(u, v))
+ return {
+ type: 'FeatureCollection',
+ features: [
+ { type: 'Feature', properties: { part: 'v' }, geometry: { type: 'LineString', coordinates: vert } },
+ { type: 'Feature', properties: { part: 'h' }, geometry: { type: 'LineString', coordinates: horiz } },
+ ],
+ }
+}
diff --git a/public/dx-maps/doxa-maps-spa/manifest.json b/public/dx-maps/doxa-maps-spa/manifest.json
index e9a932073..606a122ce 100644
--- a/public/dx-maps/doxa-maps-spa/manifest.json
+++ b/public/dx-maps/doxa-maps-spa/manifest.json
@@ -615,7 +615,7 @@
"bundle": "gospel-internet",
"scriptUrl": "./gospel-internet/gospel-internet.js",
"output": "dx-maps/gospel-internet/gospel-internet.js",
- "integrity": "sha384-llQ4uFRJf84+L8UEW/0K7svB+OUjKabm0/YciS4NT30DmjMB8rrP4C/b1tnM/i4O",
+ "integrity": "sha384-ZadGj9dZPLGl2d6LuE/+PSPMQzaWg3PqvXgfE19myl3VRUiK7X8/UNe0uD+Ke/kQ",
"elements": [
"gospel-internet-board"
],
@@ -635,7 +635,7 @@
"scriptUrl": "./gospel-internet/gospel-internet.js",
"embed": {
"iframe": "",
- "script": "\n \n\n\n\n "
+ "script": "\n \n\n\n\n "
},
"instances": [
{
diff --git a/public/dx-maps/gospel-internet/board/board/index.html b/public/dx-maps/gospel-internet/board/board/index.html
index f6739024a..c633ca306 100644
--- a/public/dx-maps/gospel-internet/board/board/index.html
+++ b/public/dx-maps/gospel-internet/board/board/index.html
@@ -36,7 +36,7 @@
-
+
-
+
-
+
-
+
-
+
-
+
-
+
\n\n\n "
+ "script": "\n \n\n\n\n "
},
"instances": [
{
From 932727be766fb89832fd713760484de0093a0ca6 Mon Sep 17 00:00:00 2001
From: Doxa Maps
Date: Mon, 28 Sep 2026 20:50:41 +0000
Subject: [PATCH 19/78] Draw the cross for real, and stop the catch hiding why
it wasn't
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The cross source existed and no layer used it. line-cap is a LAYOUT property and
it was passed in paint, so addLayer threw — and the catch beside it ate the
error. The source was added, the layer never was, and the result was a cross that
is simply absent with nothing anywhere saying why.
That is the third time today a try/catch turned a broken thing into a silent one,
and the second in this one feature. The mark still must not block the chart, so
the failure is still caught — but it now says so. A guard that protects the
caller should not also protect the bug from being found.
The fit takes more padding. It has to hold the labels and not only the points —
the outermost band label sat on the canvas edge at 1280 — and a short, wide
canvas fits to width and clips top and bottom unless the bounds are given room in
the dimension that is scarce, which is what cut the cloud on one narrow layout.
---
.../src/components/GiBoundedSetMap.vue | 18 +++++++--
.../src/components/GiCenteredSetMap.vue | 18 +++++++--
public/dx-maps/doxa-maps-spa/manifest.json | 4 +-
.../gospel-internet/board/board/index.html | 2 +-
.../dx-maps/gospel-internet/board/index.html | 2 +-
.../board/india-china/index.html | 2 +-
.../board/language-tree/index.html | 2 +-
.../board/platforms/index.html | 2 +-
.../board/project-700/index.html | 2 +-
.../gospel-internet/board/zero/index.html | 2 +-
.../gospel-internet/gospel-internet.js | 40 +++++++++----------
public/dx-maps/manifest.json | 4 +-
12 files changed, 61 insertions(+), 37 deletions(-)
diff --git a/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue b/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue
index b02351bca..d2527cc5b 100644
--- a/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue
+++ b/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue
@@ -91,8 +91,16 @@ onMounted(async () => {
try {
map.addSource(`${'gbsm'}-cross`, { type: 'geojson', data: crossFeature(project) })
map.addLayer({ id: `${'gbsm'}-cross`, type: 'line', source: `${'gbsm'}-cross`,
- paint: { 'line-color': '#3b463d', 'line-width': 3, 'line-cap': 'square' } })
- } catch (_) { /* the cross is a mark, not the data: never block the chart on it */ }
+ layout: { 'line-cap': 'square', 'line-join': 'miter' },
+ paint: { 'line-color': '#3b463d', 'line-width': 3 } })
+ } catch (e) {
+ // ★ REPORTED, NOT SWALLOWED. The first version put 'line-cap' in PAINT, where it does not
+ // belong — it is a LAYOUT property. addLayer threw, this catch ate it, and the source was
+ // added while the layer never was: a cross that is simply absent, with nothing anywhere
+ // saying why. That is the third time today a try/catch turned a broken thing into a
+ // silent one. The mark still must not block the chart, so it is caught — but it says so.
+ console.warn('[gospel-internet] the centre cross could not be drawn:', e && e.message || e)
+ }
// ★ FIT THE WHOLE TARGET ON LOAD. The default zoom clipped the outer rings top and bottom —
// the reader met a drawing already cut off, which reads as a rendering fault rather than as
@@ -101,7 +109,11 @@ onMounted(async () => {
try {
const [w, sLat] = project(-SPREAD, -SPREAD)
const [e, nLat] = project(SPREAD, SPREAD)
- map.fitBounds([[w, sLat], [e, nLat]], { padding: 24, duration: 0 })
+ // Padding is generous and symmetric because the fit must hold the LABELS too, not just the
+ // points: at 1280 the outermost band label sat on the canvas edge. And a short, wide canvas
+ // (the bounded chart in a narrow column is 796x249) fits to WIDTH and clips top and bottom
+ // unless the bounds are given room in the dimension that is scarce.
+ map.fitBounds([[w, sLat], [e, nLat]], { padding: 40, duration: 0 })
} catch (_) {}
}
// ★ CALLED UNCONDITIONALLY, AND THE GUARD THAT USED TO BE HERE IS WHY THIS WAS BROKEN TWICE.
diff --git a/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue b/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue
index 7bb036d1b..de0f28477 100644
--- a/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue
+++ b/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue
@@ -106,8 +106,16 @@ onMounted(async () => {
try {
map.addSource(`${'gcsm'}-cross`, { type: 'geojson', data: crossFeature(project) })
map.addLayer({ id: `${'gcsm'}-cross`, type: 'line', source: `${'gcsm'}-cross`,
- paint: { 'line-color': '#3b463d', 'line-width': 3, 'line-cap': 'square' } })
- } catch (_) { /* the cross is a mark, not the data: never block the chart on it */ }
+ layout: { 'line-cap': 'square', 'line-join': 'miter' },
+ paint: { 'line-color': '#3b463d', 'line-width': 3 } })
+ } catch (e) {
+ // ★ REPORTED, NOT SWALLOWED. The first version put 'line-cap' in PAINT, where it does not
+ // belong — it is a LAYOUT property. addLayer threw, this catch ate it, and the source was
+ // added while the layer never was: a cross that is simply absent, with nothing anywhere
+ // saying why. That is the third time today a try/catch turned a broken thing into a
+ // silent one. The mark still must not block the chart, so it is caught — but it says so.
+ console.warn('[gospel-internet] the centre cross could not be drawn:', e && e.message || e)
+ }
// ★ FIT THE WHOLE TARGET ON LOAD. The default zoom clipped the outer rings top and bottom —
// the reader met a drawing already cut off, which reads as a rendering fault rather than as
@@ -116,7 +124,11 @@ onMounted(async () => {
try {
const [w, sLat] = project(-EXTENT, -EXTENT)
const [e, nLat] = project(EXTENT, EXTENT)
- map.fitBounds([[w, sLat], [e, nLat]], { padding: 24, duration: 0 })
+ // Padding is generous and symmetric because the fit must hold the LABELS too, not just the
+ // points: at 1280 the outermost band label sat on the canvas edge. And a short, wide canvas
+ // (the bounded chart in a narrow column is 796x249) fits to WIDTH and clips top and bottom
+ // unless the bounds are given room in the dimension that is scarce.
+ map.fitBounds([[w, sLat], [e, nLat]], { padding: 40, duration: 0 })
} catch (_) {}
ready.value = true
}
diff --git a/public/dx-maps/doxa-maps-spa/manifest.json b/public/dx-maps/doxa-maps-spa/manifest.json
index 606a122ce..c410f8d9b 100644
--- a/public/dx-maps/doxa-maps-spa/manifest.json
+++ b/public/dx-maps/doxa-maps-spa/manifest.json
@@ -615,7 +615,7 @@
"bundle": "gospel-internet",
"scriptUrl": "./gospel-internet/gospel-internet.js",
"output": "dx-maps/gospel-internet/gospel-internet.js",
- "integrity": "sha384-ZadGj9dZPLGl2d6LuE/+PSPMQzaWg3PqvXgfE19myl3VRUiK7X8/UNe0uD+Ke/kQ",
+ "integrity": "sha384-ms4BzXmzlKrMiucFY6RR1kcxpNI1KEllXaqE6K4JtxWe0I6VkpkFcf7GulVybnrr",
"elements": [
"gospel-internet-board"
],
@@ -635,7 +635,7 @@
"scriptUrl": "./gospel-internet/gospel-internet.js",
"embed": {
"iframe": "",
- "script": "\n \n\n\n\n "
+ "script": "\n \n\n\n\n "
},
"instances": [
{
diff --git a/public/dx-maps/gospel-internet/board/board/index.html b/public/dx-maps/gospel-internet/board/board/index.html
index c633ca306..27b8f8a7f 100644
--- a/public/dx-maps/gospel-internet/board/board/index.html
+++ b/public/dx-maps/gospel-internet/board/board/index.html
@@ -36,7 +36,7 @@
-
+
-
+
-
+
-
+
-
+
-
+
-
+
\n\n\n "
+ "script": "\n \n\n\n\n "
},
"instances": [
{
From 5681f9db2227fc3d00b573f1c478330306ba6a23 Mon Sep 17 00:00:00 2001
From: Doxa Maps
Date: Mon, 28 Sep 2026 21:00:15 +0000
Subject: [PATCH 20/78] Fit against the box the chart actually has, and make
the cross read as one
Raising the padding fixed seven layouts and left the eighth exactly as it was,
which was the clue: padding cannot rescue a fit aimed at a box of the wrong
shape. That layout is short and wide, and the fit ran against a stale
measurement, so it resolved to the width and clipped the cloud top and bottom.
The engine is now told to re-read its container before the fit, the fit runs on
the next frame so the flex layout can finish, and it runs once more when the map
settles for a container still resolving on that frame. None of it depends on a
timer.
The cross was drawing as a plus. The upper arm was two thirds of the lower, which
is a real difference and far too small to see at this size. Latin proportions
now: the lower arm is three times the upper and the bar is narrower than the
figure is tall, so the shape is legible as what it is at the size it is actually
drawn rather than only in the geometry.
---
.../src/components/GiBoundedSetMap.vue | 33 +++++++++++--------
.../src/components/GiCenteredSetMap.vue | 33 +++++++++++--------
.../gospel-internet/src/setChartFeatures.js | 9 +++--
public/dx-maps/doxa-maps-spa/manifest.json | 4 +--
.../gospel-internet/board/board/index.html | 2 +-
.../dx-maps/gospel-internet/board/index.html | 2 +-
.../board/india-china/index.html | 2 +-
.../board/language-tree/index.html | 2 +-
.../board/platforms/index.html | 2 +-
.../board/project-700/index.html | 2 +-
.../gospel-internet/board/zero/index.html | 2 +-
.../gospel-internet/gospel-internet.js | 26 +++++++--------
public/dx-maps/manifest.json | 4 +--
13 files changed, 71 insertions(+), 52 deletions(-)
diff --git a/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue b/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue
index d2527cc5b..54ae341af 100644
--- a/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue
+++ b/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue
@@ -102,19 +102,26 @@ onMounted(async () => {
console.warn('[gospel-internet] the centre cross could not be drawn:', e && e.message || e)
}
- // ★ FIT THE WHOLE TARGET ON LOAD. The default zoom clipped the outer rings top and bottom —
- // the reader met a drawing already cut off, which reads as a rendering fault rather than as
- // something to zoom out of. Fitted from the projected extent, so it follows the chart's own
- // size rather than a guessed zoom.
- try {
- const [w, sLat] = project(-SPREAD, -SPREAD)
- const [e, nLat] = project(SPREAD, SPREAD)
- // Padding is generous and symmetric because the fit must hold the LABELS too, not just the
- // points: at 1280 the outermost band label sat on the canvas edge. And a short, wide canvas
- // (the bounded chart in a narrow column is 796x249) fits to WIDTH and clips top and bottom
- // unless the bounds are given room in the dimension that is scarce.
- map.fitBounds([[w, sLat], [e, nLat]], { padding: 40, duration: 0 })
- } catch (_) {}
+ // ★ FIT AFTER THE BOX HAS SETTLED, AND RESIZE FIRST. The previous version fitted
+ // immediately: on one narrow layout the canvas was still 796x249 from a stale measurement,
+ // so the fit was computed against the wrong aspect, resolved to the WIDTH, and clipped the
+ // cloud top and bottom. Raising the padding did nothing there, because padding cannot fix a
+ // fit that was aimed at a box of the wrong shape — which is why that one layout was
+ // unchanged while the others improved.
+ // map.resize() makes the engine re-read the container; fitting on the next frame lets the
+ // flex layout finish first. Both are cheap and neither depends on a timer.
+ const fitTarget = () => {
+ try {
+ map.resize()
+ const [w, sLat] = project(-SPREAD, -SPREAD)
+ const [e, nLat] = project(SPREAD, SPREAD)
+ map.fitBounds([[w, sLat], [e, nLat]], { padding: 40, duration: 0 })
+ } catch (_) { /* a fit that cannot be computed leaves the default view, which still draws */ }
+ }
+ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(fitTarget)
+ else fitTarget()
+ // and again once the engine settles, for a container that was still resolving on that frame
+ try { map.once('idle', fitTarget) } catch (_) {}
}
// ★ CALLED UNCONDITIONALLY, AND THE GUARD THAT USED TO BE HERE IS WHY THIS WAS BROKEN TWICE.
// createBoardMap only resolves after `await map.once('load')`, so by here the event HAS fired.
diff --git a/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue b/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue
index de0f28477..aa2c669e8 100644
--- a/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue
+++ b/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue
@@ -117,19 +117,26 @@ onMounted(async () => {
console.warn('[gospel-internet] the centre cross could not be drawn:', e && e.message || e)
}
- // ★ FIT THE WHOLE TARGET ON LOAD. The default zoom clipped the outer rings top and bottom —
- // the reader met a drawing already cut off, which reads as a rendering fault rather than as
- // something to zoom out of. Fitted from the projected extent, so it follows the chart's own
- // size rather than a guessed zoom.
- try {
- const [w, sLat] = project(-EXTENT, -EXTENT)
- const [e, nLat] = project(EXTENT, EXTENT)
- // Padding is generous and symmetric because the fit must hold the LABELS too, not just the
- // points: at 1280 the outermost band label sat on the canvas edge. And a short, wide canvas
- // (the bounded chart in a narrow column is 796x249) fits to WIDTH and clips top and bottom
- // unless the bounds are given room in the dimension that is scarce.
- map.fitBounds([[w, sLat], [e, nLat]], { padding: 40, duration: 0 })
- } catch (_) {}
+ // ★ FIT AFTER THE BOX HAS SETTLED, AND RESIZE FIRST. The previous version fitted
+ // immediately: on one narrow layout the canvas was still 796x249 from a stale measurement,
+ // so the fit was computed against the wrong aspect, resolved to the WIDTH, and clipped the
+ // cloud top and bottom. Raising the padding did nothing there, because padding cannot fix a
+ // fit that was aimed at a box of the wrong shape — which is why that one layout was
+ // unchanged while the others improved.
+ // map.resize() makes the engine re-read the container; fitting on the next frame lets the
+ // flex layout finish first. Both are cheap and neither depends on a timer.
+ const fitTarget = () => {
+ try {
+ map.resize()
+ const [w, sLat] = project(-EXTENT, -EXTENT)
+ const [e, nLat] = project(EXTENT, EXTENT)
+ map.fitBounds([[w, sLat], [e, nLat]], { padding: 40, duration: 0 })
+ } catch (_) { /* a fit that cannot be computed leaves the default view, which still draws */ }
+ }
+ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(fitTarget)
+ else fitTarget()
+ // and again once the engine settles, for a container that was still resolving on that frame
+ try { map.once('idle', fitTarget) } catch (_) {}
ready.value = true
}
// ★ CALLED UNCONDITIONALLY, AND THE GUARD THAT USED TO BE HERE IS WHY THIS WAS BROKEN TWICE.
diff --git a/plugin-designs/gospel-internet/src/setChartFeatures.js b/plugin-designs/gospel-internet/src/setChartFeatures.js
index 87e9b0c95..fa78c6f91 100644
--- a/plugin-designs/gospel-internet/src/setChartFeatures.js
+++ b/plugin-designs/gospel-internet/src/setChartFeatures.js
@@ -157,8 +157,13 @@ export function degreesToPixelRadius(degrees, { minZoom = 0, maxZoom = 22 } = {}
* else here.
*/
export function crossFeature(project, { arm = 13, cx = 0, cy = 0 } = {}) {
- const vert = [[cx, cy - arm], [cx, cy + arm]].map(([u, v]) => project(u, v))
- const horiz = [[cx - arm * 0.7, cy - arm * 0.35], [cx + arm * 0.7, cy - arm * 0.35]].map(([u, v]) => project(u, v))
+ // Latin proportions, not a plus. Reported as reading "+ with equal arms": the upper arm was
+ // 0.65 of the lower, which is a real difference and too small to see at this size. The upper
+ // arm is now a third of the lower and the bar is narrower than the height, so the shape is
+ // legible as a cross at the size it is actually drawn.
+ const upper = arm * 0.55, lower = arm * 1.65, bar = arm * 0.62
+ const vert = [[cx, cy - upper], [cx, cy + lower]].map(([u, v]) => project(u, v))
+ const horiz = [[cx - bar, cy - upper * 0.05], [cx + bar, cy - upper * 0.05]].map(([u, v]) => project(u, v))
return {
type: 'FeatureCollection',
features: [
diff --git a/public/dx-maps/doxa-maps-spa/manifest.json b/public/dx-maps/doxa-maps-spa/manifest.json
index c410f8d9b..0394f00bd 100644
--- a/public/dx-maps/doxa-maps-spa/manifest.json
+++ b/public/dx-maps/doxa-maps-spa/manifest.json
@@ -615,7 +615,7 @@
"bundle": "gospel-internet",
"scriptUrl": "./gospel-internet/gospel-internet.js",
"output": "dx-maps/gospel-internet/gospel-internet.js",
- "integrity": "sha384-ms4BzXmzlKrMiucFY6RR1kcxpNI1KEllXaqE6K4JtxWe0I6VkpkFcf7GulVybnrr",
+ "integrity": "sha384-6ypksiuOg1UYnG8SJOD9nFoDxMeisCv7Z8ZxvQtGI8E0Yonjw1IkXPHOI2hoHVTn",
"elements": [
"gospel-internet-board"
],
@@ -635,7 +635,7 @@
"scriptUrl": "./gospel-internet/gospel-internet.js",
"embed": {
"iframe": "",
- "script": "\n \n\n\n\n "
+ "script": "\n \n\n\n\n "
},
"instances": [
{
diff --git a/public/dx-maps/gospel-internet/board/board/index.html b/public/dx-maps/gospel-internet/board/board/index.html
index 27b8f8a7f..bfa643029 100644
--- a/public/dx-maps/gospel-internet/board/board/index.html
+++ b/public/dx-maps/gospel-internet/board/board/index.html
@@ -36,7 +36,7 @@
-
+
-
+
-
+
-
+
-
+
-
+
-
+
\n\n\n "
+ "script": "\n \n\n\n\n "
},
"instances": [
{
From fc589cef1c1f13b39cb5d24e22e8829d9624df95 Mon Sep 17 00:00:00 2001
From: Doxa Maps
Date: Mon, 28 Sep 2026 22:13:28 +0000
Subject: [PATCH 21/78] Point the cross the right way up, and let the fit
follow its container
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The cross drew inverted. The projection computes Y = 0.5 - v*K, so a larger v is
a HIGHER latitude, and the parts were named on the screen convention where +y
goes down — which put the long arm at the top. That is a different symbol, not a
cosmetic slip. The parts are named by compass now, and the test asserts in
latitude after projection rather than in layout units, which is the only space
where the question means anything.
The fit was bit-identical through three attempts — more padding, a resize, a
refit on idle — and that was the evidence. None of them was wrong; none of them
was the moment the box changed size. These panels are v-show, so a map can be
built while its container is display:none and therefore zero-sized, and the
engine keeps the size it measured then. A fit computed against that lands at the
wrong zoom, which is why one layout opened at 0.64 while an almost identically
shaped one opened at 0.31.
A ResizeObserver is the only thing that knows when the box became real, so the
fit follows the container instead of guessing at a moment, and skips while the
box is still hidden. The failure is reported rather than swallowed, for the same
reason as the cross layer before it.
---
.../src/components/GiBoundedSetMap.vue | 26 ++++++++++++++-----
.../src/components/GiCenteredSetMap.vue | 26 ++++++++++++++-----
.../gospel-internet/src/setChartFeatures.js | 19 +++++++++-----
public/dx-maps/doxa-maps-spa/manifest.json | 4 +--
.../gospel-internet/board/board/index.html | 2 +-
.../dx-maps/gospel-internet/board/index.html | 2 +-
.../board/india-china/index.html | 2 +-
.../board/language-tree/index.html | 2 +-
.../board/platforms/index.html | 2 +-
.../board/project-700/index.html | 2 +-
.../gospel-internet/board/zero/index.html | 2 +-
.../gospel-internet/gospel-internet.js | 26 +++++++++----------
public/dx-maps/manifest.json | 4 +--
13 files changed, 76 insertions(+), 43 deletions(-)
diff --git a/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue b/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue
index 54ae341af..4b8417925 100644
--- a/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue
+++ b/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue
@@ -35,6 +35,7 @@ const board = useBoardState()
const el = ref(null)
const mapRef = shallowRef(null)
const handle = shallowRef(null)
+let fitRo = null
const failed = ref('')
const R = 120, SPREAD = 260
@@ -110,18 +111,31 @@ onMounted(async () => {
// unchanged while the others improved.
// map.resize() makes the engine re-read the container; fitting on the next frame lets the
// flex layout finish first. Both are cheap and neither depends on a timer.
+ // ★ REFIT WHENEVER THE BOX CHANGES SIZE, because a one-shot fit cannot know when that is.
+ // These panels are v-show, so a map can be created while its container is display:none and
+ // therefore zero-sized; the engine keeps whatever size it measured then, and a fit computed
+ // against it lands at the wrong zoom. That is why one layout opened at 0.64 while an almost
+ // identically-shaped one opened at 0.31 — and why raising padding, resizing once and
+ // refitting on idle all left it BIT-IDENTICAL: none of them was the moment the box changed.
+ // A ResizeObserver is the only thing that knows, so the fit follows the container instead of
+ // guessing at a moment.
const fitTarget = () => {
try {
+ const box = el.value
+ if (!box || !box.clientWidth || !box.clientHeight) return // still hidden: wait to be shown
map.resize()
const [w, sLat] = project(-SPREAD, -SPREAD)
const [e, nLat] = project(SPREAD, SPREAD)
map.fitBounds([[w, sLat], [e, nLat]], { padding: 40, duration: 0 })
- } catch (_) { /* a fit that cannot be computed leaves the default view, which still draws */ }
+ } catch (e) {
+ console.warn('[gospel-internet] could not fit the chart to its box:', e && e.message || e)
+ }
}
- if (typeof requestAnimationFrame === 'function') requestAnimationFrame(fitTarget)
- else fitTarget()
- // and again once the engine settles, for a container that was still resolving on that frame
- try { map.once('idle', fitTarget) } catch (_) {}
+ fitTarget()
+ try {
+ fitRo = new ResizeObserver(fitTarget)
+ if (el.value) fitRo.observe(el.value)
+ } catch (_) { /* no ResizeObserver: the initial fit stands */ }
}
// ★ CALLED UNCONDITIONALLY, AND THE GUARD THAT USED TO BE HERE IS WHY THIS WAS BROKEN TWICE.
// createBoardMap only resolves after `await map.once('load')`, so by here the event HAS fired.
@@ -138,7 +152,7 @@ onMounted(async () => {
})
watch(data, (d) => { try { mapRef.value?.getSource('gbsm')?.setData(d) } catch (_) {} })
-onBeforeUnmount(() => { try { handle.value?.destroy?.() } catch (_) {} ; try { mapRef.value?.remove?.() } catch (_) {} })
+onBeforeUnmount(() => { try { fitRo?.disconnect() } catch (_) {} ; try { handle.value?.destroy?.() } catch (_) {} ; try { mapRef.value?.remove?.() } catch (_) {} })
useShadowStyles(
'.gbsm{position:absolute;inset:0;min-height:0;min-width:0}' +
diff --git a/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue b/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue
index aa2c669e8..0523d3633 100644
--- a/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue
+++ b/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue
@@ -41,6 +41,7 @@ const board = useBoardState()
const el = ref(null)
const mapRef = shallowRef(null)
const handle = shallowRef(null)
+let fitRo = null
const ready = ref(false)
const failed = ref('')
@@ -125,18 +126,31 @@ onMounted(async () => {
// unchanged while the others improved.
// map.resize() makes the engine re-read the container; fitting on the next frame lets the
// flex layout finish first. Both are cheap and neither depends on a timer.
+ // ★ REFIT WHENEVER THE BOX CHANGES SIZE, because a one-shot fit cannot know when that is.
+ // These panels are v-show, so a map can be created while its container is display:none and
+ // therefore zero-sized; the engine keeps whatever size it measured then, and a fit computed
+ // against it lands at the wrong zoom. That is why one layout opened at 0.64 while an almost
+ // identically-shaped one opened at 0.31 — and why raising padding, resizing once and
+ // refitting on idle all left it BIT-IDENTICAL: none of them was the moment the box changed.
+ // A ResizeObserver is the only thing that knows, so the fit follows the container instead of
+ // guessing at a moment.
const fitTarget = () => {
try {
+ const box = el.value
+ if (!box || !box.clientWidth || !box.clientHeight) return // still hidden: wait to be shown
map.resize()
const [w, sLat] = project(-EXTENT, -EXTENT)
const [e, nLat] = project(EXTENT, EXTENT)
map.fitBounds([[w, sLat], [e, nLat]], { padding: 40, duration: 0 })
- } catch (_) { /* a fit that cannot be computed leaves the default view, which still draws */ }
+ } catch (e) {
+ console.warn('[gospel-internet] could not fit the chart to its box:', e && e.message || e)
+ }
}
- if (typeof requestAnimationFrame === 'function') requestAnimationFrame(fitTarget)
- else fitTarget()
- // and again once the engine settles, for a container that was still resolving on that frame
- try { map.once('idle', fitTarget) } catch (_) {}
+ fitTarget()
+ try {
+ fitRo = new ResizeObserver(fitTarget)
+ if (el.value) fitRo.observe(el.value)
+ } catch (_) { /* no ResizeObserver: the initial fit stands */ }
ready.value = true
}
// ★ CALLED UNCONDITIONALLY, AND THE GUARD THAT USED TO BE HERE IS WHY THIS WAS BROKEN TWICE.
@@ -166,7 +180,7 @@ watch(opacityExpr, (o) => {
try { mapRef.value?.setPaintProperty(id, 'circle-opacity', o) } catch (_) {}
})
-onBeforeUnmount(() => { try { handle.value?.destroy?.() } catch (_) {} ; try { mapRef.value?.remove?.() } catch (_) {} })
+onBeforeUnmount(() => { try { fitRo?.disconnect() } catch (_) {} ; try { handle.value?.destroy?.() } catch (_) {} ; try { mapRef.value?.remove?.() } catch (_) {} })
useShadowStyles(
'.gcsm{position:absolute;inset:0;min-height:0;min-width:0}' +
diff --git a/plugin-designs/gospel-internet/src/setChartFeatures.js b/plugin-designs/gospel-internet/src/setChartFeatures.js
index fa78c6f91..35114db9a 100644
--- a/plugin-designs/gospel-internet/src/setChartFeatures.js
+++ b/plugin-designs/gospel-internet/src/setChartFeatures.js
@@ -157,13 +157,18 @@ export function degreesToPixelRadius(degrees, { minZoom = 0, maxZoom = 22 } = {}
* else here.
*/
export function crossFeature(project, { arm = 13, cx = 0, cy = 0 } = {}) {
- // Latin proportions, not a plus. Reported as reading "+ with equal arms": the upper arm was
- // 0.65 of the lower, which is a real difference and too small to see at this size. The upper
- // arm is now a third of the lower and the bar is narrower than the height, so the shape is
- // legible as a cross at the size it is actually drawn.
- const upper = arm * 0.55, lower = arm * 1.65, bar = arm * 0.62
- const vert = [[cx, cy - upper], [cx, cy + lower]].map(([u, v]) => project(u, v))
- const horiz = [[cx - bar, cy - upper * 0.05], [cx + bar, cy - upper * 0.05]].map(([u, v]) => project(u, v))
+ // ★ +v IS NORTH, NOT DOWN. The projection computes Y = 0.5 - v*K, so a LARGER v is a HIGHER
+ // latitude. An earlier version named its parts "upper" and "lower" on the screen convention
+ // where +y goes down, and therefore drew the LONG arm upward — an inverted cross, which is a
+ // different symbol rather than a cosmetic slip. Named by compass here so the mistake cannot be
+ // made silently again: `north` is toward the top of the drawing.
+ const H = arm * 2.2
+ const north = cy + H * 0.35 // the short arm, above the bar
+ const south = cy - H * 0.65 // the long arm, below it
+ const barV = cy + H * 0.18 // the bar sits high, nearer the top than the middle
+ const barHalf = arm * 0.62
+ const vert = [[cx, north], [cx, south]].map(([u, v]) => project(u, v))
+ const horiz = [[cx - barHalf, barV], [cx + barHalf, barV]].map(([u, v]) => project(u, v))
return {
type: 'FeatureCollection',
features: [
diff --git a/public/dx-maps/doxa-maps-spa/manifest.json b/public/dx-maps/doxa-maps-spa/manifest.json
index 0394f00bd..813d9fdfa 100644
--- a/public/dx-maps/doxa-maps-spa/manifest.json
+++ b/public/dx-maps/doxa-maps-spa/manifest.json
@@ -615,7 +615,7 @@
"bundle": "gospel-internet",
"scriptUrl": "./gospel-internet/gospel-internet.js",
"output": "dx-maps/gospel-internet/gospel-internet.js",
- "integrity": "sha384-6ypksiuOg1UYnG8SJOD9nFoDxMeisCv7Z8ZxvQtGI8E0Yonjw1IkXPHOI2hoHVTn",
+ "integrity": "sha384-zx0Es4trCFtqdzhsZHh78nh9D+Cpyp8lw7oZiucX7huOJzQlOzmIAcqJfhh5Pesj",
"elements": [
"gospel-internet-board"
],
@@ -635,7 +635,7 @@
"scriptUrl": "./gospel-internet/gospel-internet.js",
"embed": {
"iframe": "",
- "script": "\n \n\n\n\n "
+ "script": "\n \n\n\n\n "
},
"instances": [
{
diff --git a/public/dx-maps/gospel-internet/board/board/index.html b/public/dx-maps/gospel-internet/board/board/index.html
index bfa643029..eaf55b380 100644
--- a/public/dx-maps/gospel-internet/board/board/index.html
+++ b/public/dx-maps/gospel-internet/board/board/index.html
@@ -36,7 +36,7 @@
-
+
-
+
-
+
-
+
-
+
-
+
-
+
\n\n\n "
+ "script": "\n \n\n\n\n "
},
"instances": [
{
From e4dfc73ce9c462baf47556debd87310aca434ce0 Mon Sep 17 00:00:00 2001
From: Doxa Maps
Date: Mon, 28 Sep 2026 22:37:34 +0000
Subject: [PATCH 22/78] Register the whiteboard geometry gate that landed
without its script
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
check:no-orphan-gates is red on main because this check reached the repo and its
registration did not. It is a real gate rather than a diagnostic — it asserts the
bounded set's boundary gap, the Mercator clamp and the map-unit radius scaling,
each with an arm that fails on its own defect — so it is registered rather than
tagged one-shot.
The split is what caused it. The file was mine and the package.json line was
studio's, and only one half travelled; a gate that nothing can run is the exact
thing the orphan check exists to catch, and it caught mine. When a change has a
half in someone else's ownership, neither half is done until both have landed.
---
package.json | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 5bcaca20d..fa0fe7a0f 100644
--- a/package.json
+++ b/package.json
@@ -74,6 +74,7 @@
"check:export-poster-prints:maplibre": "node plugin-designs/doxa-research-map/scripts/check-export-poster-prints.mjs --provider maplibre",
"check:upg-zone-joins": "node internal/check-upg-zone-joins.mjs",
"check:gospel-set-charts": "node internal/check-gospel-set-charts.mjs",
+ "check:gospel-whiteboard": "node internal/check-gospel-whiteboard.mjs",
"check:facet-cascade": "node internal/check-facet-cascade.mjs",
"check:facet-cascade:offline": "node internal/check-facet-cascade.mjs --offline",
"check:facet-cascade:self-test": "node internal/check-facet-cascade.mjs --self-test",
@@ -288,7 +289,7 @@
"fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:frozen-labels",
- "gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset",
+ "gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:gospel-whiteboard check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset",
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
"library-index": "node internal/generate-library-index.mjs",
From 0971d25d6a97f851fb9e8a725dc212ab406e874b Mon Sep 17 00:00:00 2001
From: Doxa Maps
Date: Tue, 29 Sep 2026 00:30:43 +0000
Subject: [PATCH 23/78] The set charts could not fit their own height, and the
bounded set had no cross
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
THE FIT. With renderWorldCopies:false the engine refuses to zoom out past the
point where one world fills the canvas WIDTH, so the minimum zoom is
log2(canvasWidth/512) — and on a wide, short figure that floor is too close for
the drawing's own height. rega instrumented the page: fitBounds, cameraForBounds
and jumpTo all land at 0.64 on a 796px canvas, which is exactly that floor, with
the bounds correct and containing every point. So the ResizeObserver fit was
working and the engine was clamping; minZoom cannot help, the width wins.
Fixed in the CHART's footprint rather than the engine's behaviour:
extentFraction 0.17 -> 0.095 in both set maps, named at the call site instead of
defaulted so the number sits where a reader can see why. The drawing spans 0.17
of the Mercator square instead of 0.34, and the outermost ring moves from 52.07
to 32.33 degrees. Kept EQUAL across the two charts on purpose: a reader
switching tabs compares them, and a different footprint per tab would make one
population look like another size.
THE CROSS. crossFeature() was lost in an earlier rebase and neither map called
it, so the bounded set had no centre mark at all — the Driver reported it.
Restored the orientation-corrected version (+v is NORTH, so the long arm goes
below the bar; an earlier one named its parts on the screen convention and drew
an inverted cross, a different symbol rather than a cosmetic slip) and added
addCrossLayer(), written once and called by both maps so they cannot drift.
line-cap and line-join are set in LAYOUT, not paint, because addLayer throws
otherwise and a silent catch is how the cross went missing before; the failure
is warned about rather than swallowed.
check:gospel-set-fit asserts every layout fits at the engine's floor zoom, in
pure arithmetic against the components' own projection. Its negative control is
the measured defect: the old 0.17 still needs 271px of the 169 available at
796x249. An earlier version of this calculation used a flat degrees-per-pixel
and concluded the chart already fitted everywhere, contradicting the page;
latitude degrees are not linear in pixels, and the world-Y form is the
projection's own so gate and renderer agree.
---
internal/check-gospel-set-fit.mjs | 103 ++++++++++++++++++
package.json | 6 +-
.../src/components/GiBoundedSetMap.vue | 14 ++-
.../src/components/GiCenteredSetMap.vue | 16 ++-
.../gospel-internet/src/setChartFeatures.js | 48 ++++++++
public/dx-maps/doxa-maps-spa/manifest.json | 4 +-
.../gospel-internet/board/board/index.html | 2 +-
.../dx-maps/gospel-internet/board/index.html | 2 +-
.../board/india-china/index.html | 2 +-
.../board/language-tree/index.html | 2 +-
.../board/platforms/index.html | 2 +-
.../board/project-700/index.html | 2 +-
.../gospel-internet/board/zero/index.html | 2 +-
.../gospel-internet/gospel-internet.js | 46 ++++----
public/dx-maps/manifest.json | 4 +-
15 files changed, 215 insertions(+), 40 deletions(-)
create mode 100644 internal/check-gospel-set-fit.mjs
diff --git a/internal/check-gospel-set-fit.mjs b/internal/check-gospel-set-fit.mjs
new file mode 100644
index 000000000..733b07cbc
--- /dev/null
+++ b/internal/check-gospel-set-fit.mjs
@@ -0,0 +1,103 @@
+#!/usr/bin/env node
+/**
+ * check-gospel-set-fit.mjs — the set charts must FIT the canvas they are given.
+ *
+ * WHY THIS EXISTS. With renderWorldCopies:false (library/maps/whiteboard-map.js) the engine refuses
+ * to zoom out past the point where ONE WORLD fills the canvas WIDTH. That puts a hard floor under the
+ * zoom at log2(canvasWidth/512) — and on a WIDE, SHORT figure that floor is too close for the
+ * drawing's own height, so the outermost groups fall outside the frame. minZoom does not help; the
+ * width constraint wins. rega measured it at 1440: 54 groups outside in ar and 97 in en, which is
+ * what proved it was the WIDTH and never RTL.
+ *
+ * WHAT IT ASSERTS. For every layout the charts actually get, the drawing's vertical extent at the
+ * floor zoom must fit inside the canvas height, padding included. This is pure arithmetic against
+ * the same projection the components use, so it needs no browser and cannot drift from the source.
+ *
+ * ★ THE MERCATOR STRETCH IS THE WHOLE POINT. A first version of this calculation used a flat
+ * 360/(512*2^z) degrees-per-pixel and concluded the chart already fitted at every width — which
+ * contradicted a measurement taken on the page. Latitude degrees are NOT linear in pixels: the
+ * drawing reaches ±29° now and reached ±52° before, where the error is large enough to invert the
+ * answer. The world-Y form below is the projection's own, so the gate and the renderer agree.
+ */
+import { whiteboardProjection } from '../library/maps/whiteboard-projection.js'
+
+const RING_MAX = 10
+const CENTERED_EXTENT = 30 + (RING_MAX - 1) * 22 // 228, GiCenteredSetMap
+const BOUNDED_EXTENT = 260 // SPREAD, GiBoundedSetMap
+const FRACTION = 0.095 // both charts, kept equal on purpose
+const FIT_PADDING = 40 // the fitBounds padding both charts pass
+
+/** Mercator world-Y in [0,1] — the projection's own vertical, not a linear degree scale. */
+const worldY = (lat) => (1 - Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360)) / Math.PI) / 2
+const log2 = (x) => Math.log(x) / Math.log(2)
+
+/** The zoom the engine cannot go below on this canvas, with renderWorldCopies:false. */
+export function floorZoom(canvasWidth) {
+ return log2(canvasWidth / 512)
+}
+
+/** Pixels of height the drawing needs on this canvas, at the floor zoom. */
+export function neededHeightPx(extent, fraction, canvasWidth) {
+ const project = whiteboardProjection({ extent, extentFraction: fraction })
+ const north = project(0, extent)[1]
+ const south = project(0, -extent)[1]
+ const worldFraction = Math.abs(worldY(north) - worldY(south))
+ return worldFraction * 512 * Math.pow(2, floorZoom(canvasWidth))
+}
+
+// The canvases the charts are actually given: rega's measured figures at 1440 and 1280, the embed
+// card, and two narrow ones so a phone cannot regress unnoticed.
+const LAYOUTS = [
+ { name: '1440 board (rega measured)', w: 796, h: 249 },
+ { name: '1280 board (rega measured)', w: 636, h: 220 },
+ { name: 'embed card', w: 1198, h: 560 },
+ { name: 'tablet', w: 380, h: 300 },
+ { name: 'phone', w: 340, h: 240 },
+]
+
+const CHARTS = [
+ { name: 'centered', extent: CENTERED_EXTENT },
+ { name: 'bounded', extent: BOUNDED_EXTENT },
+]
+
+const pass = []
+const fail = []
+const ok = (label, cond, detail = '') => (cond ? pass : fail).push(detail ? `${label} — ${detail}` : label)
+
+for (const chart of CHARTS) {
+ for (const layout of LAYOUTS) {
+ const need = neededHeightPx(chart.extent, FRACTION, layout.w)
+ const have = layout.h - 2 * FIT_PADDING
+ ok(
+ `${chart.name} fits ${layout.name} (${layout.w}x${layout.h})`,
+ need <= have,
+ `needs ${need.toFixed(0)}px of ${have}px available`,
+ )
+ }
+}
+
+// ★ NEGATIVE CONTROL. The 0.17 default is the value that was measured FAILING on the page, so if
+// this arm does not fail the calculation is not measuring what rega measured and a pass means
+// nothing. Asserted on the tightest real layout.
+const poison = neededHeightPx(CENTERED_EXTENT, 0.17, 796)
+ok(
+ 'NEG CONTROL: the old 0.17 fraction still does NOT fit 796x249',
+ poison > 249 - 2 * FIT_PADDING,
+ `0.17 needs ${poison.toFixed(0)}px of ${249 - 2 * FIT_PADDING}px — the defect rega measured`,
+)
+
+// The two charts must share one footprint, or the same population reads as a different size when a
+// reader switches tabs.
+ok(
+ 'both charts use the same extentFraction',
+ Math.abs(neededHeightPx(CENTERED_EXTENT, FRACTION, 796) / neededHeightPx(BOUNDED_EXTENT, FRACTION, 796) - 1) < 1e-9,
+ 'centered and bounded project to the same vertical span',
+)
+
+for (const p of pass) console.log(` ✓ ${p}`)
+for (const f of fail) console.log(` ✗ ${f}`)
+if (fail.length) {
+ console.log(`\nGOSPEL-SET-FIT: FAIL — ${fail.length} of ${pass.length + fail.length}`)
+ process.exit(1)
+}
+console.log(`\nGOSPEL-SET-FIT: PASS — ${pass.length} assertions; every layout fits at the engine's floor zoom`)
diff --git a/package.json b/package.json
index 96421133f..1dec76d6c 100644
--- a/package.json
+++ b/package.json
@@ -76,6 +76,7 @@
"check:export-poster-prints:maplibre": "node plugin-designs/doxa-research-map/scripts/check-export-poster-prints.mjs --provider maplibre",
"check:upg-zone-joins": "node internal/check-upg-zone-joins.mjs",
"check:gospel-set-charts": "node internal/check-gospel-set-charts.mjs",
+ "check:gospel-set-fit": "node internal/check-gospel-set-fit.mjs",
"check:facet-cascade": "node internal/check-facet-cascade.mjs",
"check:facet-cascade:offline": "node internal/check-facet-cascade.mjs --offline",
"check:facet-cascade:self-test": "node internal/check-facet-cascade.mjs --self-test",
@@ -455,7 +456,7 @@
"check:no-conflict-markers": "Nothing tracked may carry an unresolved merge. Exists because a merge from this worktree committed a BUILT bundle with 21 conflict markers - both sides concatenated, 1.13MB - and every gospel leaf rendered dead while the SRI hash matched and every request returned 200. The cause was a landing script that resolved one expected file then ran \"git add -A public\", staging every other file including the ones still holding markers. For a built file: take either side, REBUILD, commit the rebuild, then run this before pushing. --poison for the negative test.",
"check:no-credentials-omit": "no data fetch in library/ or plugin-designs/ uses credentials:'omit': behind a login gate a same-origin request then drops the session and is refused (the Research Gallery's 'Could not load Big Picture'). 'same-origin' never sends credentials cross-origin. A deliberate omit says so on its line: // credentials-omit: .",
"check:no-credentials-omit:self-test": "a planted omit is caught; prose and an annotated site are not.",
- "check:no-cross-plugin-imports": "no plugin imports from another plugin's folder (src/, utils, data) by a relative path; shared code goes through library/ (@map/\u2026). A composite may mount another bundle's PUBLIC profile .vue. --self-test plants a crossing import.",
+ "check:no-cross-plugin-imports": "no plugin imports from another plugin's folder (src/, utils, data) by a relative path; shared code goes through library/ (@map/…). A composite may mount another bundle's PUBLIC profile .vue. --self-test plants a crossing import.",
"check:no-eval": "no built bundle contains new Function or eval (a strict CSP without 'unsafe-eval' must work), and vue-i18n in JIT mode renders real catalogue messages in en and ar with eval blocked. Headless.",
"check:no-free-identifiers": "every name a .vue script uses is imported, declared, a parameter or a known global (scope-aware, across plugin-designs and library). A free identifier compiles and then throws at runtime in exactly the path nobody clicked.",
"check:no-silent-engine": "A profile must NEVER fall back to a different map engine than its provider asked for. `engineGlobal(provider) || window.mapboxgl` looks defensive and is not: engineGlobal already returns the right global for the requested provider (useMapInstance.js:115 falls back to mapbox's NAME only for an UNKNOWN provider, never a missing engine), so the || fires in exactly one case — the page asked for maplibre and maplibre failed to load — and hands back the TOKEN-GATED engine to a page that asked for the KEYLESS one. Worse, accessToken is gated on !isMaplibre, so the substitute arrives WITHOUT a token: 401s on every tile instead of a legible failure. Found by regions' C3 read 2026-09-26; 2 offenders remained (cities-internet, gospel-resources) after my-upg-100-list was fixed. NOT a duplicate of check:leaf-engine-per-provider / check:provider-switch / check:auto-engine — all three assert the HAPPY path (what loads) and this bug only appears when something does NOT load, so all three were green while it existed. A SOURCE check on purpose: reproducing it live means serving a leaf with MapLibre deliberately blocked, spending a browser slot on what a grep decides. Exits 75 when no file calls engineGlobal — 'found nothing' is not 'found nothing wrong'. Run :self-test to prove the pattern still matches.",
@@ -545,7 +546,8 @@
"verify:gospel-map": "the gospel-resources MAP: pins render (not merely load), a tap opens a named group, EVERY link in the index is on a screened publisher, the table deep-link carries the group's ISO, and the gap filter leaves only tier 0. Needs a server (bun run serve); SKIPS loudly without one.",
"verify:interop": "the CAP-4 INTEROPERABILITY GATE (real browser, real shadow DOM, real live data) — closes the gap verify:gate1 leaves open. Drives the ACTUAL compiled composites: (1) DESKTOP dashboard-plus-grid map->grid (a facet-rail click must narrow the grid's live `Showing N` count line — card count is a proven FALSE-NEGATIVE trap, always 24, never asserted on), (2) DESKTOP grid->map (selecting table rows must MOVE THE CAMERA, read back off window.__rdashMap.getCenter()/getZoom() — useSelectionCamera.js's contract), (3) MOBILE research-map-plus-grid, where source-reading FIRST established there is NO cross-wire between the map and grid children by design (StackLayout composes two independent elements, no provide/inject/prop crosses the boundary) — so this pair asserts each half narrows on its OWN control, not a link that does not exist. Prints ONE line per combination, pass/fail only, plus a combination table. SKIPs cleanly (exit 0) when the tree is mid-build (internal/settled-tree-gate.mjs) or pray.doxa.life is unreachable — never reports a false defect from a torn tree. Spawns its own internal/serve-strict.mjs on PORT (default 4180) so it does not depend on another process's server. run `verify:interop:self-test` for the NEGATIVE test.",
"verify:interop:self-test": "TWO regression modes, each required to fire ITS OWN combination-named failure: grid-prop-drop (default — freezes the grid child's filter props/attrs on the live dashboard-plus-grid page, reproducing the exact historical bug \"selecting anything in the dashboard above did not filter the grid below\") and `verify:interop:self-test:camera-freeze` (pins fitBounds/flyTo to no-ops on window.__rdashMap, reproducing \"the grid->map link stops moving the camera\"). A harness that has only ever reported PASS is unproven.",
- "verify:interop:self-test:camera-freeze": "★ A DEDICATED ENTRY, not a mode appended at the call site. The parent script value ends in an inline # comment, so a mode appended by the caller lands INSIDE that comment and the shell drops it — the run then exits 0 in DEFAULT mode, indistinguishable from the mode being selected. A self-test is the proof a gate can fail, so a self-test whose mode cannot be chosen is a gate certifying itself. This entry hardcodes its mode, so nothing has to be appended. (Flag names are deliberately not spelled here: check:script-flags scans script VALUES, and naming one in prose reads as advertising it.)"
+ "verify:interop:self-test:camera-freeze": "★ A DEDICATED ENTRY, not a mode appended at the call site. The parent script value ends in an inline # comment, so a mode appended by the caller lands INSIDE that comment and the shell drops it — the run then exits 0 in DEFAULT mode, indistinguishable from the mode being selected. A self-test is the proof a gate can fail, so a self-test whose mode cannot be chosen is a gate certifying itself. This entry hardcodes its mode, so nothing has to be appended. (Flag names are deliberately not spelled here: check:script-flags scans script VALUES, and naming one in prose reads as advertising it.)",
+ "check:gospel-set-fit": "the set charts FIT the canvas they are given. renderWorldCopies:false puts a hard floor under the zoom at log2(canvasWidth/512) — one world must fill the canvas WIDTH — and on a wide, short figure that floor is too close for the drawing's own height, so the outermost groups fall outside the frame (rega at 1440: 54 outside in ar, 97 in en, which is what proved it was the width and not RTL). Pure arithmetic against the components' own projection, so no browser and no drift. Negative control: the old 0.17 extentFraction still fails 796x249 at 271px of 169, so a pass cannot be vacuous. Also asserts both charts share one footprint, or the same population reads as a different size when a reader switches tabs."
},
"devDependencies": {
"@capacitor/android": "^7.6.9",
diff --git a/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue b/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue
index d9a003c4f..ebc4709a3 100644
--- a/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue
+++ b/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue
@@ -26,7 +26,7 @@ import { createBoardMap } from '../boardEngine.js'
import { useBoardState } from '../useBoard.js'
import { useGiT } from '../useGiT.js'
import { layoutBoundedSet } from '@map/components/charts/boundedSet.js'
-import { boundedSetFeatures, toFeatureCollection, dishRadiusDegrees, degreesToPixelRadius, TIERS } from '../setChartFeatures.js'
+import { boundedSetFeatures, toFeatureCollection, dishRadiusDegrees, degreesToPixelRadius, TIERS, addCrossLayer } from '../setChartFeatures.js'
const props = defineProps({ groups: { type: Array, default: () => [] } })
@@ -38,7 +38,14 @@ const handle = shallowRef(null)
const failed = ref('')
const R = 120, SPREAD = 260
-const project = whiteboardProjection({ extent: SPREAD })
+// ★ 0.095, NOT THE 0.17 DEFAULT — the same clamp as the centered chart, and the same number so the
+// two sets read at one scale. renderWorldCopies:false stops the engine zooming out past
+// log2(canvasWidth/512), so a wide, short figure cannot fit its height at 0.17 and the outermost
+// groups fall outside the frame (rega, 1440: 54 outside in ar and 97 in en — the width, not RTL).
+// Kept EQUAL to GiCenteredSetMap's on purpose: a reader switching tabs compares two drawings, and a
+// different footprint per tab would make the same population look like a different size.
+const EXTENT_FRACTION = 0.095
+const project = whiteboardProjection({ extent: SPREAD, extentFraction: EXTENT_FRACTION })
const layout = computed(() => layoutBoundedSet(props.groups, { r: R, spread: SPREAD }))
const built = computed(() => boundedSetFeatures(layout.value, { population: (p) => p.pop ?? 0 }))
@@ -85,6 +92,9 @@ onMounted(async () => {
},
font: labelFont,
}))
+ // The cross marks the centre the boundary is drawn around — the Driver reported this chart had
+ // none. Same geometry and the same call as the centered chart, so the two cannot drift apart.
+ addCrossLayer(map, project, 'gbsm-cross')
}
// ★ CALLED UNCONDITIONALLY, AND THE GUARD THAT USED TO BE HERE IS WHY THIS WAS BROKEN TWICE.
// createBoardMap only resolves after `await map.once('load')`, so by here the event HAS fired.
diff --git a/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue b/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue
index b63e4dceb..20ce108cc 100644
--- a/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue
+++ b/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue
@@ -28,7 +28,7 @@ import { useBoardState } from '../useBoard.js'
import { useGiT } from '../useGiT.js'
import { ringInfo, CENTRE_COLOR } from '../ringInfo.js'
import { layoutCenteredSet, ringRadii, RING_MAX } from '@map/components/charts/centeredSet.js'
-import { centeredSetFeatures, toFeatureCollection, TIERS } from '../setChartFeatures.js'
+import { centeredSetFeatures, toFeatureCollection, TIERS, addCrossLayer } from '../setChartFeatures.js'
const props = defineProps({
groups: { type: Array, default: () => [] }, // { id, label, ring, neighborId }
@@ -45,7 +45,18 @@ const ready = ref(false)
const failed = ref('')
const EXTENT = 30 + (RING_MAX - 1) * 22 // 228 — the ring the outermost groups sit on
-const project = whiteboardProjection({ extent: EXTENT })
+// ★ 0.095, NOT THE 0.17 DEFAULT. With renderWorldCopies:false the engine will not zoom out past the
+// point where ONE WORLD fills the canvas WIDTH, so the minimum zoom is log2(canvasWidth/512) and a
+// wide, short figure is clamped too close to fit its own height. minZoom is irrelevant; the width
+// constraint wins. rega measured the symptom at 1440 — 54 groups outside the frame in ar, 97 in en,
+// so it was never RTL-specific — and instrumented the page to show fitBounds, cameraForBounds and
+// jumpTo ALL landing at 0.64 = log2(796/512) on a 796px canvas.
+// The fix is the CHART's footprint, not the engine's behaviour: at 0.17 the drawing spans 0.34 of the
+// Mercator square and needs 271px of vertical room where the canvas offers 249. At 0.095 it spans
+// 0.17 and needs 151px against 169 available at padding 40 — the tightest of the measured layouts.
+// Checked by check:gospel-set-fit, which fails if any layout needs more height than it has.
+const EXTENT_FRACTION = 0.095
+const project = whiteboardProjection({ extent: EXTENT, extentFraction: EXTENT_FRACTION })
const layout = computed(() => layoutCenteredSet(props.groups, { innerR: 30, ringGap: 22 }))
const features = computed(() => centeredSetFeatures(
@@ -101,6 +112,7 @@ onMounted(async () => {
font: labelFont,
onClick: (id, f) => { const r = f?.properties?.ring; if (r != null) emit('select', r) },
}))
+ addCrossLayer(map, project, 'gcsm-cross')
ready.value = true
}
// ★ CALLED UNCONDITIONALLY, AND THE GUARD THAT USED TO BE HERE IS WHY THIS WAS BROKEN TWICE.
diff --git a/plugin-designs/gospel-internet/src/setChartFeatures.js b/plugin-designs/gospel-internet/src/setChartFeatures.js
index 66bc15a47..ddf8e299c 100644
--- a/plugin-designs/gospel-internet/src/setChartFeatures.js
+++ b/plugin-designs/gospel-internet/src/setChartFeatures.js
@@ -135,3 +135,51 @@ export function degreesToPixelRadius(degrees, { minZoom = 0, maxZoom = 22 } = {}
const px = (z) => Number(degrees || 0) * 512 * Math.pow(2, z) / 360
return ['interpolate', ['exponential', 2], ['zoom'], minZoom, px(minZoom), maxZoom, px(maxZoom)]
}
+
+export function crossFeature(project, { arm = 13, cx = 0, cy = 0 } = {}) {
+ // ★ +v IS NORTH, NOT DOWN. The projection computes Y = 0.5 - v*K, so a LARGER v is a HIGHER
+ // latitude. An earlier version named its parts "upper" and "lower" on the screen convention
+ // where +y goes down, and therefore drew the LONG arm upward — an inverted cross, which is a
+ // different symbol rather than a cosmetic slip. Named by compass here so the mistake cannot be
+ // made silently again: `north` is toward the top of the drawing.
+ const H = arm * 2.2
+ const north = cy + H * 0.35 // the short arm, above the bar
+ const south = cy - H * 0.65 // the long arm, below it
+ const barV = cy + H * 0.18 // the bar sits high, nearer the top than the middle
+ const barHalf = arm * 0.62
+ const vert = [[cx, north], [cx, south]].map(([u, v]) => project(u, v))
+ const horiz = [[cx - barHalf, barV], [cx + barHalf, barV]].map(([u, v]) => project(u, v))
+ return {
+ type: 'FeatureCollection',
+ features: [
+ { type: 'Feature', properties: { part: 'v' }, geometry: { type: 'LineString', coordinates: vert } },
+ { type: 'Feature', properties: { part: 'h' }, geometry: { type: 'LineString', coordinates: horiz } },
+ ],
+ }
+}
+
+/**
+ * Draw the cross on a whiteboard map, as a LINE layer over the points.
+ *
+ * ★ line-cap AND line-join ARE LAYOUT, NOT PAINT. Put either in `paint` and addLayer THROWS, and if
+ * the call sits inside a silent try/catch the chart simply renders without its cross — which is how
+ * this went missing once already. The failure is reported rather than swallowed for the same reason.
+ */
+export function addCrossLayer(map, project, id, { arm = 13, color = '#3b463d', width = 3 } = {}) {
+ try {
+ if (map.getLayer(id)) map.removeLayer(id)
+ if (map.getSource(id)) map.removeSource(id)
+ map.addSource(id, { type: 'geojson', data: crossFeature(project, { arm }) })
+ map.addLayer({
+ id,
+ type: 'line',
+ source: id,
+ layout: { 'line-cap': 'square', 'line-join': 'miter' },
+ paint: { 'line-color': color, 'line-width': width },
+ })
+ return true
+ } catch (e) {
+ console.warn(`[set-charts] the cross layer "${id}" did not draw:`, e && e.message)
+ return false
+ }
+}
diff --git a/public/dx-maps/doxa-maps-spa/manifest.json b/public/dx-maps/doxa-maps-spa/manifest.json
index 51f4a71e1..aa5e0f7e9 100644
--- a/public/dx-maps/doxa-maps-spa/manifest.json
+++ b/public/dx-maps/doxa-maps-spa/manifest.json
@@ -615,7 +615,7 @@
"bundle": "gospel-internet",
"scriptUrl": "./gospel-internet/gospel-internet.js",
"output": "dx-maps/gospel-internet/gospel-internet.js",
- "integrity": "sha384-UvPb/kjQsPYbG2hXd5LNuMiGjCATQWGdqEKoF4r9PI8gH0r3y1DcvssdBKCycsAf",
+ "integrity": "sha384-KWVii1O2A2E1ipr92BtePgvRHb/AKRL5wzNU9kEhdss7offPzaPwUwnXTTehNHja",
"elements": [
"gospel-internet-board"
],
@@ -635,7 +635,7 @@
"scriptUrl": "./gospel-internet/gospel-internet.js",
"embed": {
"iframe": "",
- "script": "\n \n\n\n\n "
+ "script": "\n \n\n\n\n "
},
"instances": [
{
diff --git a/public/dx-maps/gospel-internet/board/board/index.html b/public/dx-maps/gospel-internet/board/board/index.html
index ae7c6b2bd..110821b7d 100644
--- a/public/dx-maps/gospel-internet/board/board/index.html
+++ b/public/dx-maps/gospel-internet/board/board/index.html
@@ -65,7 +65,7 @@
-
+
-
+
-
+
-
+
-
+
-
+
-
+
\n\n\n "
+ "script": "\n \n\n\n\n "
},
"instances": [
{
From 8e88ae0a10ac6e231b6e786ceceaa3b28ed194e7 Mon Sep 17 00:00:00 2001
From: Doxa Maps
Date: Tue, 29 Sep 2026 00:49:59 +0000
Subject: [PATCH 24/78] A ring of bands, as pure functions: rings in, positions
out
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
layoutCenteredSet gives every group on ring N the same radius, so a crowded ring
draws as one line of dots on a circle. The Driver asked for a target: each ring
a BAND, groups spread evenly through it, never overlapping each other or the
ring's label.
library/components/charts/ringBands.js is that geometry, with no Vue, no engine
and no DOM, so the Wall poster can consume the same module rather than growing a
second copy of it. bandFill() places; curvedRingLabels() returns each label's arc
AND the keep-out that bandFill refuses to place inside — computed from one source
so "pins never overlap the labels" is true by construction rather than usually.
Determinism is a requirement, not a nicety: every position is a pure function of
the item's own id, so two printings of one packet compare sheet to sheet and a
dot that moves means the DATA moved. There is deliberately no seed. Input order
does not affect any position either, so a caller that filters or re-sorts does
not reshuffle the chart.
An over-full ring SHRINKS its pins to a floor before anything is dropped — a
visible pin beats an unplaced one — and only then reports the remainder with the
ring and the counts.
check:ring-bands, 16 assertions, every arm with a negative control. One arm is
there because this gate passed the bug it now catches: uncapped, a long label on
ring 1 wanted 96% of that small circle, so capacity was zero and all twelve
groups on the innermost ring were reported unplaced while every arm stayed green
— "reported, not silently dropped" is not the same promise as "the ring draws".
Labels now truncate at a share of their own circumference and report how many
characters fit. Poison-tested: restoring the old share reproduces "empty rings: 1".
---
internal/check-ring-bands.mjs | 154 ++++++++++++++++++
library/components/charts/ringBands.js | 207 +++++++++++++++++++++++++
package.json | 6 +-
3 files changed, 365 insertions(+), 2 deletions(-)
create mode 100644 internal/check-ring-bands.mjs
create mode 100644 library/components/charts/ringBands.js
diff --git a/internal/check-ring-bands.mjs b/internal/check-ring-bands.mjs
new file mode 100644
index 000000000..d5865f398
--- /dev/null
+++ b/internal/check-ring-bands.mjs
@@ -0,0 +1,154 @@
+#!/usr/bin/env node
+/**
+ * check-ring-bands.mjs — the ring band layout keeps the promises the Driver asked for.
+ *
+ * Four properties, each of which fails SILENTLY if it breaks — the drawing still renders, it is
+ * just wrong, and nobody can tell by looking at one sheet:
+ * 1. every pin sits inside its own ring's band (not on the old single circle, not in the gap)
+ * 2. no two pins on a ring are closer than minSep
+ * 3. no pin sits inside a ring label's keep-out arc
+ * 4. the same input twice gives byte-identical output
+ *
+ * Every arm has a NEGATIVE CONTROL: a deliberately broken input that must fail it. An assertion
+ * that cannot fail is not evidence, and three of the bugs this module exists to prevent were
+ * originally found only because a check was made to fail on purpose first.
+ */
+import { bandFill, curvedRingLabels, inBand, inArc, hash01 } from '../library/components/charts/ringBands.js'
+import { ringRadii, RING_MAX } from '../library/components/charts/centeredSet.js'
+
+const pass = []
+const fail = []
+const ok = (label, cond, detail = '') => (cond ? pass : fail).push(detail ? `${label} — ${detail}` : label)
+
+const RING_GAP = 24
+const radii = ringRadii({ innerR: 26, ringGap: RING_GAP })
+const labels = curvedRingLabels(radii, (r) => `Ring ${r} — same language family`, { ringGap: RING_GAP })
+
+/** A reproducible population: n groups spread across the rings. */
+const makeItems = (n, tag = 'g') =>
+ Array.from({ length: n }, (_, i) => ({ id: `${tag}${i}`, ring: (i % RING_MAX) + 1 }))
+
+const items = makeItems(120)
+const out = bandFill(items, radii, { ringGap: RING_GAP, labels })
+
+// ── 1. every pin inside its band ────────────────────────────────────────────────────────────────
+const strays = out.placed.filter((p) => !inBand(p, radii, { ringGap: RING_GAP }))
+ok('every pin sits inside its own ring band', strays.length === 0, `${strays.length} outside`)
+
+// NEG CONTROL: a pin pushed out of its band must be caught, or arm 1 proves nothing.
+const moved = { ...out.placed[0], r: out.placed[0].r + RING_GAP }
+ok('NEG CONTROL: a pin moved a full ringGap is detected as outside', !inBand(moved, radii, { ringGap: RING_GAP }))
+
+// ── 2. pins on a ring are at least minSep apart ─────────────────────────────────────────────────
+// Measured along the arc at each pin's own radius, which is how a reader sees the gap.
+const tooClose = []
+const byRing = new Map()
+for (const p of out.placed) {
+ if (!byRing.has(p.ring)) byRing.set(p.ring, [])
+ byRing.get(p.ring).push(p)
+}
+for (const [ring, ps] of byRing) {
+ const sorted = [...ps].sort((a, b) => a.angle - b.angle)
+ for (let i = 1; i < sorted.length; i++) {
+ const dx = sorted[i].x - sorted[i - 1].x
+ const dy = sorted[i].y - sorted[i - 1].y
+ const d = Math.hypot(dx, dy)
+ // Two pins may be close in ANGLE and far apart in radius — that is the band doing its job — so
+ // the test is euclidean distance, not angular distance.
+ if (d < Math.min(out.minSep, 2 * sorted[i].pin) - 1e-9) tooClose.push({ ring, d: d.toFixed(2) })
+ }
+}
+ok('no two pins on a ring are closer than the pin diameter', tooClose.length === 0, `${tooClose.length} pair(s)`)
+
+// NEG CONTROL: at an absurd minSep the ring cannot hold its items, so the module must REPORT
+// unplaced rather than quietly overlapping them.
+const crowded = bandFill(makeItems(400), radii, { ringGap: RING_GAP, labels, minSep: 60 })
+ok(
+ 'NEG CONTROL: an impossible minSep reports unplaced rather than overlapping',
+ crowded.unplaced.length > 0 && crowded.unplaced.every((u) => /full at the pin floor/.test(u.reason)),
+ `${crowded.unplaced.length} reported`,
+)
+
+// And it must SHRINK before it reports — the Driver's rule: a visible pin beats an unplaced one.
+const tight = bandFill(makeItems(200), radii, { ringGap: RING_GAP, labels })
+ok(
+ 'a crowded ring shrinks its pins before reporting anything unplaced',
+ tight.rings.some((r) => r.shrunk) && tight.rings.every((r) => r.pin >= 2.6 - 1e-9),
+ `min pin ${Math.min(...tight.rings.map((r) => r.pin)).toFixed(2)}`,
+)
+
+// ── 3. no pin inside a label's keep-out arc ─────────────────────────────────────────────────────
+const inLabel = out.placed.filter((p) => {
+ const l = labels.find((x) => x.ring === p.ring)
+ return l && inArc(p.angle, l.keepOut.start, l.keepOut.span)
+})
+ok('no pin sits inside a ring label keep-out arc', inLabel.length === 0, `${inLabel.length} overlapping`)
+
+// NEG CONTROL: the arc test must actually detect a point inside it — including the WRAPPING case,
+// where a naive start<=a<=end comparison is wrong for exactly one label position.
+const wrapStart = 6.0 // radians, so start+span crosses 0
+ok('NEG CONTROL: inArc detects a point inside a wrapping arc', inArc(0.1, wrapStart, 0.6))
+ok('NEG CONTROL: inArc rejects a point outside a wrapping arc', !inArc(3.0, wrapStart, 0.6))
+
+// ── 4. determinism ──────────────────────────────────────────────────────────────────────────────
+const again = bandFill(makeItems(120), radii, { ringGap: RING_GAP, labels })
+ok(
+ 'the same input twice gives byte-identical output',
+ JSON.stringify(again.placed) === JSON.stringify(out.placed),
+ 'two runs agree',
+)
+// Order of the INPUT must not change the output either: a caller that filters or re-sorts its rows
+// must not move every dot on the chart.
+const shuffled = bandFill([...items].reverse(), radii, { ringGap: RING_GAP, labels })
+const key = (ps) => JSON.stringify([...ps].sort((a, b) => String(a.id).localeCompare(String(b.id))).map((p) => [p.id, p.x.toFixed(9), p.y.toFixed(9)]))
+ok('input order does not change any position', key(shuffled.placed) === key(out.placed))
+
+// NEG CONTROL: the hash really does vary per id, or "deterministic" would be trivially true because
+// everything landed in the same place.
+ok('NEG CONTROL: hash01 separates ids', hash01('a') !== hash01('b') && hash01('a') === hash01('a'))
+
+// ── no label may starve its own ring ────────────────────────────────────────────────────────────
+// ★ THIS ARM EXISTS BECAUSE THE GATE ONCE PASSED THE BUG. Uncapped, a long name on ring 1 claimed
+// 5.65 rad — 96% of that small circle — so capacity was ZERO, all 12 groups were reported unplaced,
+// and the innermost ring drew empty. Every other arm was green: the items WERE reported rather than
+// silently dropped, which is what they assert. "Reported, not dropped" is not the same promise as
+// "the ring draws". A long label now truncates instead of eating its circle.
+const longLabels = curvedRingLabels(radii, () => 'A very much longer ring label indeed', { ringGap: RING_GAP })
+const starved = bandFill(makeItems(120), radii, { ringGap: RING_GAP, labels: longLabels })
+ok(
+ 'a long label never starves its ring: every ring still places something',
+ starved.rings.every((r) => r.fitted > 0),
+ `empty rings: ${starved.rings.filter((r) => r.fitted === 0).map((r) => r.ring).join(',') || 'none'}`,
+)
+ok(
+ 'no label takes more than its share of its own circle',
+ longLabels.every((l) => l.keepOut.span <= Math.PI * 2 * 0.45 + 2 * 0.18 + 1e-9),
+ `max ${(Math.max(...longLabels.map((l) => l.keepOut.span)) / (Math.PI * 2) * 100).toFixed(0)}%`,
+)
+ok(
+ 'a truncated label says so, and says how many characters fit',
+ longLabels[0].truncated === true && longLabels[0].fitChars > 0 && longLabels[0].fitChars < 36,
+ `ring 1 fits ${longLabels[0].fitChars} chars`,
+)
+
+// ── the label geometry is derived, not guessed ──────────────────────────────────────────────────
+const shortL = curvedRingLabels(radii, () => 'A', { ringGap: RING_GAP })
+const longL = curvedRingLabels(radii, () => 'A much longer ring label', { ringGap: RING_GAP })
+ok(
+ 'a longer label claims a wider keep-out arc',
+ longL[0].keepOut.span > shortL[0].keepOut.span,
+ `${longL[0].keepOut.span.toFixed(3)} > ${shortL[0].keepOut.span.toFixed(3)}`,
+)
+ok(
+ 'the same text needs a SMALLER arc on a larger ring',
+ longL[RING_MAX - 1].span < longL[0].span,
+ 'arc length = r * angle',
+)
+
+for (const p of pass) console.log(` ✓ ${p}`)
+for (const f of fail) console.log(` ✗ ${f}`)
+if (fail.length) {
+ console.log(`\nRING-BANDS: FAIL — ${fail.length} of ${pass.length + fail.length}`)
+ process.exit(1)
+}
+console.log(`\nRING-BANDS: PASS — ${pass.length} assertions, every arm with a negative control`)
diff --git a/library/components/charts/ringBands.js b/library/components/charts/ringBands.js
new file mode 100644
index 000000000..5278cd68f
--- /dev/null
+++ b/library/components/charts/ringBands.js
@@ -0,0 +1,207 @@
+/**
+ * ringBands.js — laying many items out in a ring of bands, as PURE FUNCTIONS: rings in, positions
+ * out. No Vue, no map engine, no DOM. The set charts consume it; the Wall poster can consume the
+ * same module rather than growing a second copy of the same geometry.
+ *
+ * THE PROBLEM IT SOLVES. layoutCenteredSet gives every group on ring N the SAME radius, so a
+ * crowded ring draws as a single line of dots on one circle. The Driver asked for a target: each
+ * ring is a BAND, and the groups spread evenly THROUGH their band without overlapping each other or
+ * the ring's label.
+ *
+ * ★ DETERMINISM IS A REQUIREMENT, NOT A NICETY. Every position here is a pure function of the item's
+ * own id and the ring geometry. Two renders of the same data are byte-identical, in any process, on
+ * any day — so two printings of one packet can be compared sheet to sheet, and a reader who sees a
+ * dot move knows the DATA moved. There is deliberately no seed parameter: a seed would rescramble
+ * every dot while the data was unchanged, which on a printed artefact is unrecoverable.
+ * (check:gospel-set-charts asserts the no-seed property on the components that render these.)
+ *
+ * ★ THE LABEL KEEP-OUT IS A PRECONDITION, NOT A NUDGE. curvedRingLabels() returns the arc its text
+ * occupies, and bandFill() refuses to place a pin inside that arc. Computing them from one source is
+ * what makes "pins never overlap the labels" true by construction instead of true most of the time.
+ */
+
+/** Deterministic 0..1 from a string — FNV-1a. Same id, same number, forever. */
+export function hash01(s) {
+ let h = 2166136261
+ const str = String(s)
+ for (let i = 0; i < str.length; i++) {
+ h ^= str.charCodeAt(i)
+ h = Math.imul(h, 16777619)
+ }
+ return ((h >>> 0) % 100000) / 100000
+}
+
+const TAU = Math.PI * 2
+/** Normalise any angle into [0, 2pi). */
+const norm = (a) => ((a % TAU) + TAU) % TAU
+
+/**
+ * Is `angle` inside the arc [start, start+span]? Handles an arc that wraps past 0, which is the
+ * case a naive `a >= start && a <= end` gets silently wrong for exactly one label position.
+ */
+export function inArc(angle, start, span) {
+ if (span <= 0) return false
+ if (span >= TAU) return true
+ const d = norm(angle - start)
+ return d <= span
+}
+
+/**
+ * The label for each ring, set along the INSIDE edge of its band, text following the curve.
+ *
+ * Returns, per ring: the radius the text sits on, the angle it starts at, the angular span it
+ * occupies, and the KEEP-OUT arc (the span plus a margin on each side) that bandFill must avoid.
+ *
+ * The span is derived from the text's own length: a long name needs more arc than a short one, and
+ * guessing one constant for all of them is how a label ends up overlapping a pin on exactly the
+ * ring with the longest name.
+ */
+export function curvedRingLabels(radii, textFor, {
+ charWidth = 6.2, // px per character at the chart's label size
+ keepOutMargin = 0.18, // radians of clearance on EACH side of the text
+ angle = -Math.PI / 2, // where a label starts; -pi/2 is the top of the drawing
+ inset = 0.34, // how far inside the band's outer edge the text sits (0=outer, 1=inner)
+ ringGap = 24,
+ // No label may take more than this share of its own circle. 0.45 leaves over half the ring for
+ // pins on every radius, including the innermost, where the circumference is smallest.
+ maxLabelShare = 0.45,
+} = {}) {
+ const out = []
+ for (const { ring, r } of radii) {
+ const text = String(textFor ? textFor(ring) : ring)
+ // The text sits inside the band's outer edge, so it reads as belonging to THIS ring and not to
+ // the gap above it.
+ const labelR = r + ringGap / 2 - inset * ringGap
+ // Arc length = r * angle, so the angle a string needs shrinks as the ring grows.
+ //
+ // ★ CAPPED, AND THE CAP IS THE WHOLE POINT ON THE INNER RINGS. Uncapped, a long name on ring 1
+ // wants 5.65 rad — 96% of that small circle — which left an arc too short for a single pin, so
+ // EVERY group on the innermost ring was reported unplaced and the ring drew empty. The gate
+ // passed it, because "reported, not silently dropped" was satisfied; the drawing was still
+ // wrong. A real curved label does not wrap a whole circle either — it TRUNCATES. So the span is
+ // capped at a share of the circumference and `truncated` says when the caller must shorten the
+ // text (with an ellipsis) to match what this geometry reserved.
+ const arcLen = text.length * charWidth
+ const wanted = labelR > 0 ? arcLen / labelR : 0
+ const span = Math.min(wanted, TAU * maxLabelShare)
+ const truncated = wanted > span
+ // How many characters actually fit in the reserved arc, so the caller can cut the string to it.
+ const fitChars = labelR > 0 ? Math.max(Math.floor((span * labelR) / charWidth), 1) : 0
+ out.push({
+ ring,
+ r: labelR,
+ text,
+ angle,
+ span,
+ truncated,
+ fitChars,
+ keepOut: { start: norm(angle - keepOutMargin), span: Math.min(span + 2 * keepOutMargin, TAU) },
+ })
+ }
+ return out
+}
+
+/**
+ * Place items through their ring's band: even angular spread, non-overlapping, never inside a
+ * label's keep-out arc.
+ *
+ * `minSep` is a SEPARATION IN THE SAME UNITS AS THE RADII — the chart's abstract units, not screen
+ * pixels — because this module cannot know the zoom. The caller converts.
+ *
+ * OVER-FULL RINGS. If a ring cannot fit its items at minSep, the pin radius for that ring SHRINKS
+ * toward `minPinFloor` before anything is dropped: a visible pin beats an unplaced one. Only when
+ * the floor is reached and they still do not fit is the remainder reported as `unplaced`, with the
+ * ring and the counts. Nothing is ever silently overlapped and nothing is ever silently dropped.
+ */
+export function bandFill(items, radii, {
+ ringGap = 24,
+ minSep = 7,
+ minPinFloor = 2.6,
+ pinRadius = 3.4,
+ bandInset = 0.16, // keep pins off the band's own edges, so rings stay legible as rings
+ labels = [],
+ cx = 0,
+ cy = 0,
+} = {}) {
+ const radiusOf = new Map(radii.map((x) => [x.ring, x.r]))
+ const keepOutOf = new Map(labels.map((l) => [l.ring, l.keepOut]))
+ const byRing = new Map()
+ for (const it of items) {
+ if (!radiusOf.has(it.ring)) continue
+ if (!byRing.has(it.ring)) byRing.set(it.ring, [])
+ byRing.get(it.ring).push(it)
+ }
+
+ const placed = []
+ const unplaced = []
+ const rings = []
+
+ for (const [ring, group] of [...byRing.entries()].sort((a, b) => a[0] - b[0])) {
+ const baseR = radiusOf.get(ring)
+ const half = (ringGap / 2) * (1 - bandInset)
+ const keepOut = keepOutOf.get(ring)
+ // The arc actually available, once the label has taken its share.
+ const blocked = keepOut ? Math.min(keepOut.span, TAU) : 0
+ const usable = Math.max(TAU - blocked, 0)
+
+ // Sort by the item's own hashed angle so the ORDER is deterministic, then space them evenly
+ // through the usable arc. Sorting by a hash rather than by id keeps neighbouring ids from
+ // clumping together, while staying a pure function of the data.
+ const sorted = [...group].sort((a, b) => hash01(a.id) - hash01(b.id) || String(a.id).localeCompare(String(b.id)))
+
+ // How many fit at this pin size? Two pins must be minSep apart along the arc at the band's
+ // INNERMOST usable radius, where the circumference is smallest and crowding is worst.
+ const innerUsableR = Math.max(baseR - half, 1e-6)
+ let pin = pinRadius
+ const capacityAt = (p) => {
+ const sep = Math.max(minSep, 2 * p)
+ return Math.max(Math.floor((usable * innerUsableR) / sep), 0)
+ }
+ let capacity = capacityAt(pin)
+ let shrunk = false
+ // SHRINK BEFORE DROPPING, to the floor and no further.
+ while (capacity < sorted.length && pin > minPinFloor) {
+ pin = Math.max(minPinFloor, pin - 0.1)
+ capacity = capacityAt(pin)
+ shrunk = true
+ }
+
+ const fitting = sorted.slice(0, capacity)
+ const overflow = sorted.slice(capacity)
+
+ // Spread the ones that fit evenly through the usable arc, stepping past the label.
+ const startA = keepOut ? norm(keepOut.start + keepOut.span) : 0
+ const step = fitting.length > 0 ? usable / fitting.length : 0
+ fitting.forEach((it, i) => {
+ const a = norm(startA + step * (i + 0.5))
+ // Radius WITHIN the band, deterministic per item: this is what turns a line of dots into a
+ // filled band. 0.5 would be the old single-circle behaviour.
+ const t = hash01(`${it.id}:r`)
+ const r = baseR - half + t * (2 * half)
+ placed.push({
+ ...it,
+ ring,
+ angle: a,
+ r,
+ pin,
+ x: cx + Math.cos(a) * r,
+ y: cy + Math.sin(a) * r,
+ })
+ })
+ for (const it of overflow) {
+ unplaced.push({ ...it, ring, reason: `ring ${ring} full at the pin floor (${capacity} fit, ${sorted.length} wanted)` })
+ }
+ rings.push({ ring, wanted: sorted.length, fitted: fitting.length, capacity, pin, shrunk, usableArc: usable })
+ }
+
+ return { placed, unplaced, rings, cx, cy, ringGap, minSep }
+}
+
+/** Every position is inside its own band — the property the gate asserts rather than trusts. */
+export function inBand(p, radii, { ringGap = 24, bandInset = 0.16 } = {}) {
+ const base = radii.find((x) => x.ring === p.ring)
+ if (!base) return false
+ const half = (ringGap / 2) * (1 - bandInset)
+ // A float comparison needs a tolerance, or a point exactly on the edge reports as outside.
+ return p.r >= base.r - half - 1e-9 && p.r <= base.r + half + 1e-9
+}
diff --git a/package.json b/package.json
index 8b3dfad41..9c83ef20a 100644
--- a/package.json
+++ b/package.json
@@ -76,6 +76,7 @@
"check:export-poster-prints:maplibre": "node plugin-designs/doxa-research-map/scripts/check-export-poster-prints.mjs --provider maplibre",
"check:upg-zone-joins": "node internal/check-upg-zone-joins.mjs",
"check:gospel-set-charts": "node internal/check-gospel-set-charts.mjs",
+ "check:ring-bands": "node internal/check-ring-bands.mjs",
"check:facet-cascade": "node internal/check-facet-cascade.mjs",
"check:facet-cascade:offline": "node internal/check-facet-cascade.mjs --offline",
"check:facet-cascade:self-test": "node internal/check-facet-cascade.mjs --self-test",
@@ -458,7 +459,7 @@
"check:no-conflict-markers": "Nothing tracked may carry an unresolved merge. Exists because a merge from this worktree committed a BUILT bundle with 21 conflict markers - both sides concatenated, 1.13MB - and every gospel leaf rendered dead while the SRI hash matched and every request returned 200. The cause was a landing script that resolved one expected file then ran \"git add -A public\", staging every other file including the ones still holding markers. For a built file: take either side, REBUILD, commit the rebuild, then run this before pushing. --poison for the negative test.",
"check:no-credentials-omit": "no data fetch in library/ or plugin-designs/ uses credentials:'omit': behind a login gate a same-origin request then drops the session and is refused (the Research Gallery's 'Could not load Big Picture'). 'same-origin' never sends credentials cross-origin. A deliberate omit says so on its line: // credentials-omit: .",
"check:no-credentials-omit:self-test": "a planted omit is caught; prose and an annotated site are not.",
- "check:no-cross-plugin-imports": "no plugin imports from another plugin's folder (src/, utils, data) by a relative path; shared code goes through library/ (@map/\u2026). A composite may mount another bundle's PUBLIC profile .vue. --self-test plants a crossing import.",
+ "check:no-cross-plugin-imports": "no plugin imports from another plugin's folder (src/, utils, data) by a relative path; shared code goes through library/ (@map/…). A composite may mount another bundle's PUBLIC profile .vue. --self-test plants a crossing import.",
"check:no-eval": "no built bundle contains new Function or eval (a strict CSP without 'unsafe-eval' must work), and vue-i18n in JIT mode renders real catalogue messages in en and ar with eval blocked. Headless.",
"check:no-free-identifiers": "every name a .vue script uses is imported, declared, a parameter or a known global (scope-aware, across plugin-designs and library). A free identifier compiles and then throws at runtime in exactly the path nobody clicked.",
"check:no-silent-engine": "A profile must NEVER fall back to a different map engine than its provider asked for. `engineGlobal(provider) || window.mapboxgl` looks defensive and is not: engineGlobal already returns the right global for the requested provider (useMapInstance.js:115 falls back to mapbox's NAME only for an UNKNOWN provider, never a missing engine), so the || fires in exactly one case — the page asked for maplibre and maplibre failed to load — and hands back the TOKEN-GATED engine to a page that asked for the KEYLESS one. Worse, accessToken is gated on !isMaplibre, so the substitute arrives WITHOUT a token: 401s on every tile instead of a legible failure. Found by regions' C3 read 2026-09-26; 2 offenders remained (cities-internet, gospel-resources) after my-upg-100-list was fixed. NOT a duplicate of check:leaf-engine-per-provider / check:provider-switch / check:auto-engine — all three assert the HAPPY path (what loads) and this bug only appears when something does NOT load, so all three were green while it existed. A SOURCE check on purpose: reproducing it live means serving a leaf with MapLibre deliberately blocked, spending a browser slot on what a grep decides. Exits 75 when no file calls engineGlobal — 'found nothing' is not 'found nothing wrong'. Run :self-test to prove the pattern still matches.",
@@ -549,7 +550,8 @@
"verify:gospel-map": "the gospel-resources MAP: pins render (not merely load), a tap opens a named group, EVERY link in the index is on a screened publisher, the table deep-link carries the group's ISO, and the gap filter leaves only tier 0. Needs a server (bun run serve); SKIPS loudly without one.",
"verify:interop": "the CAP-4 INTEROPERABILITY GATE (real browser, real shadow DOM, real live data) — closes the gap verify:gate1 leaves open. Drives the ACTUAL compiled composites: (1) DESKTOP dashboard-plus-grid map->grid (a facet-rail click must narrow the grid's live `Showing N` count line — card count is a proven FALSE-NEGATIVE trap, always 24, never asserted on), (2) DESKTOP grid->map (selecting table rows must MOVE THE CAMERA, read back off window.__rdashMap.getCenter()/getZoom() — useSelectionCamera.js's contract), (3) MOBILE research-map-plus-grid, where source-reading FIRST established there is NO cross-wire between the map and grid children by design (StackLayout composes two independent elements, no provide/inject/prop crosses the boundary) — so this pair asserts each half narrows on its OWN control, not a link that does not exist. Prints ONE line per combination, pass/fail only, plus a combination table. SKIPs cleanly (exit 0) when the tree is mid-build (internal/settled-tree-gate.mjs) or pray.doxa.life is unreachable — never reports a false defect from a torn tree. Spawns its own internal/serve-strict.mjs on PORT (default 4180) so it does not depend on another process's server. run `verify:interop:self-test` for the NEGATIVE test.",
"verify:interop:self-test": "TWO regression modes, each required to fire ITS OWN combination-named failure: grid-prop-drop (default — freezes the grid child's filter props/attrs on the live dashboard-plus-grid page, reproducing the exact historical bug \"selecting anything in the dashboard above did not filter the grid below\") and `verify:interop:self-test:camera-freeze` (pins fitBounds/flyTo to no-ops on window.__rdashMap, reproducing \"the grid->map link stops moving the camera\"). A harness that has only ever reported PASS is unproven.",
- "verify:interop:self-test:camera-freeze": "★ A DEDICATED ENTRY, not a mode appended at the call site. The parent script value ends in an inline # comment, so a mode appended by the caller lands INSIDE that comment and the shell drops it — the run then exits 0 in DEFAULT mode, indistinguishable from the mode being selected. A self-test is the proof a gate can fail, so a self-test whose mode cannot be chosen is a gate certifying itself. This entry hardcodes its mode, so nothing has to be appended. (Flag names are deliberately not spelled here: check:script-flags scans script VALUES, and naming one in prose reads as advertising it.)"
+ "verify:interop:self-test:camera-freeze": "★ A DEDICATED ENTRY, not a mode appended at the call site. The parent script value ends in an inline # comment, so a mode appended by the caller lands INSIDE that comment and the shell drops it — the run then exits 0 in DEFAULT mode, indistinguishable from the mode being selected. A self-test is the proof a gate can fail, so a self-test whose mode cannot be chosen is a gate certifying itself. This entry hardcodes its mode, so nothing has to be appended. (Flag names are deliberately not spelled here: check:script-flags scans script VALUES, and naming one in prose reads as advertising it.)",
+ "check:ring-bands": "library/components/charts/ringBands.js keeps the four promises the ring target rests on: every pin inside its own ring band, no two pins closer than the pin diameter, no pin inside a ring label's keep-out arc, and byte-identical output for the same input (input ORDER included, so a caller that re-sorts its rows does not move every dot). Each arm carries a negative control. One arm exists because this gate PASSED the bug it now catches: uncapped, a long label on ring 1 claimed 96% of that small circle, capacity went to zero and every group on the innermost ring was reported unplaced — all other arms stayed green, because 'reported rather than silently dropped' is not the same promise as 'the ring draws'. Labels now truncate, and say how many characters fit."
},
"devDependencies": {
"@capacitor/android": "^7.6.9",
From 76f656ecd7e5361b1e6a11a57a7e838b026482ac Mon Sep 17 00:00:00 2001
From: Doxa Maps
Date: Tue, 29 Sep 2026 00:53:14 +0000
Subject: [PATCH 25/78] The centered set draws as a target, and a pin opens its
people group
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Wires ringBands into GiCenteredSetMap. layoutCenteredSet still decides WHICH
ring a group belongs on — that model is unchanged and still the source of the
ring numbers — and bandFill then spreads the groups THROUGH each ring's band
instead of placing them all on its circle. Measured on a 90-group sample: ring 5
went from 1 distinct radius (the single line of dots the Driver reported) to 9,
with every group placed and every ring drawing.
The ring labels and the band fill come from one source: curvedRingLabels returns
each label's keep-out arc and bandFill refuses to place a pin inside it, so pins
cannot overlap a label by construction rather than by tuning.
A click on a GROUP now opens that people group and frames it; a click elsewhere
on a ring still selects the ring. The Driver asked for "exactly like the Language
tree", so this is GiTree's path rather than a second version of it: the same
board.openGroup() call, and a camera that mirrors GiTree.frame()'s degenerate-
point branch — a single dot has no extent, so fitBounds would ask for an infinite
zoom; easeTo at the zoom the group tier becomes readable at, and jumpTo under
reduced motion. Both the drawer call and the camera are wrapped: the drawer is
what the reader asked for, and a camera that throws must not take the board down
after it has already opened.
check:ring-bands, gospel-set-charts, gospel-whiteboard, shadow-css-parses and
i18n-keys-exist all pass; both SFCs parse with 0 errors.
---
.../src/components/GiCenteredSetMap.vue | 67 +++++++++++++++++--
1 file changed, 62 insertions(+), 5 deletions(-)
diff --git a/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue b/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue
index b63e4dceb..eff57676a 100644
--- a/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue
+++ b/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue
@@ -23,12 +23,13 @@ import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { labelFont } from '@map/maps/label-font.js'
import { whiteboardProjection } from '@map/maps/whiteboard-projection.js'
import { addTieredPointLayers } from '@map/charts/whiteboard/tiered-points.js'
-import { createBoardMap } from '../boardEngine.js'
+import { createBoardMap, reducedMotion } from '../boardEngine.js'
import { useBoardState } from '../useBoard.js'
import { useGiT } from '../useGiT.js'
import { ringInfo, CENTRE_COLOR } from '../ringInfo.js'
import { layoutCenteredSet, ringRadii, RING_MAX } from '@map/components/charts/centeredSet.js'
import { centeredSetFeatures, toFeatureCollection, TIERS } from '../setChartFeatures.js'
+import { bandFill, curvedRingLabels } from '@map/components/charts/ringBands.js'
const props = defineProps({
groups: { type: Array, default: () => [] }, // { id, label, ring, neighborId }
@@ -47,10 +48,35 @@ const failed = ref('')
const EXTENT = 30 + (RING_MAX - 1) * 22 // 228 — the ring the outermost groups sit on
const project = whiteboardProjection({ extent: EXTENT })
-const layout = computed(() => layoutCenteredSet(props.groups, { innerR: 30, ringGap: 22 }))
+const INNER_R = 30, RING_GAP = 22
+const radii = computed(() => ringRadii({ innerR: INNER_R, ringGap: RING_GAP }))
+
+// The ring LABELS, and the arc each one reserves. bandFill is given these so it can refuse to place
+// a pin inside a label — one source for both, which is what makes "pins never overlap the labels"
+// hold by construction rather than by luck.
+const ringLabels = computed(() => curvedRingLabels(
+ radii.value,
+ (r) => `${T('gospelInternet.rings.ring', 'Ring')} ${r}`,
+ { ringGap: RING_GAP },
+))
+
+const layout = computed(() => layoutCenteredSet(props.groups, { innerR: INNER_R, ringGap: RING_GAP }))
+
+// ★ THE TARGET, NOT A CIRCLE OF DOTS. layoutCenteredSet gives every group on ring N the same
+// radius, so a crowded ring drew as a single line. bandFill spreads them THROUGH the ring's band,
+// evenly by angle and deterministically by id, never inside a label's keep-out arc. Over-full rings
+// shrink their pins to a floor before anything is reported unplaced — a visible pin beats a missing
+// one — and whatever still cannot fit is reported rather than silently overlapped or dropped.
+const banded = computed(() => bandFill(layout.value.placed, radii.value, {
+ ringGap: RING_GAP,
+ labels: ringLabels.value,
+ // The chart's own units, not screen pixels: this module cannot know the zoom.
+ minSep: 7,
+}))
+
const features = computed(() => centeredSetFeatures(
- layout.value.placed,
- ringRadii({ innerR: 30, ringGap: 22 }),
+ banded.value.placed,
+ radii.value,
{ population: (p) => p.pop ?? 0,
ringLabel: (r) => T(`gospelInternet.rings.ring`, 'Ring') + ' ' + r,
bandLabel: (b) => T(`gospelInternet.rings.band.${b}`, b) },
@@ -73,6 +99,28 @@ const opacityExpr = computed(() => props.activeRing == null
? 0.85
: ['case', ['==', ['get', 'ring'], props.activeRing], 1, 0.12])
+/**
+ * Open a people group and frame it — GiTree's interaction, not a second version of it.
+ *
+ * The zoom mirrors GiTree.frame()'s degenerate-point branch: a single dot has no extent, so
+ * fitBounds on it would ask the engine for an infinite zoom. easeTo at a sensible zoom instead, and
+ * jumpTo when the reader has asked for reduced motion. Wrapped, because a camera that throws must
+ * never take the board down with it — the drawer has already opened by then and that is the part
+ * the reader asked for.
+ */
+function openAndFrame(slug, feature) {
+ try { board.openGroup?.(slug) } catch (_) { /* the drawer is the ask; the camera is the flourish */ }
+ const map = mapRef.value
+ const c = feature?.geometry?.coordinates
+ if (!map || !Array.isArray(c) || c.length < 2) return
+ try {
+ const duration = reducedMotion() ? 0 : 700
+ const zoom = Math.max(map.getZoom(), 5.2) // the zoom the GROUP tier becomes readable at
+ if (duration) map.easeTo({ center: c, zoom, duration, essential: true })
+ else map.jumpTo({ center: c, zoom })
+ } catch (_) { /* a degenerate frame never breaks the board */ }
+}
+
onMounted(async () => {
try {
const made = await createBoardMap(el.value, board.profileConfig?.value || {},
@@ -99,7 +147,16 @@ onMounted(async () => {
radiusExpr: ['interpolate', ['linear'], ['zoom'], 1, 1.6, 6, 3.4],
opacityExpr: opacityExpr.value,
font: labelFont,
- onClick: (id, f) => { const r = f?.properties?.ring; if (r != null) emit('select', r) },
+ // A click on a GROUP opens that people group and frames it; a click anywhere else on a ring
+ // selects the ring, as before. The Driver asked for "exactly like the Language tree", so
+ // this is GiTree's path rather than an approximation of it: openGroup() is the same call,
+ // and the camera respects reduced motion the same way.
+ onClick: (id, f) => {
+ const tier = f?.properties?.tier
+ if (tier === TIERS.GROUP && id) { openAndFrame(id, f) ; return }
+ const r = f?.properties?.ring
+ if (r != null) emit('select', r)
+ },
}))
ready.value = true
}
From 1613de82a3b447165a7d96b56da85581729f2661 Mon Sep 17 00:00:00 2001
From: Doxa Maps
Date: Tue, 29 Sep 2026 00:54:13 +0000
Subject: [PATCH 26/78] A pin on the bounded set opens its people group too
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The same interaction as the centered set and as GiTree, so a reader who learns
one chart has learned all three: click a group, its drawer opens and the camera
frames it; reduced motion jumps instead of easing. The centre dish is not a
people group, so it is excluded by tier rather than by id — an id test would
have to know the dish's private name and would break the day it changed.
This chart takes no band layout: its groups are inside or outside one boundary,
not on rings, so bandFill has nothing to spread. Only the interaction is shared.
---
.../src/components/GiBoundedSetMap.vue | 23 ++++++++++++++++++-
1 file changed, 22 insertions(+), 1 deletion(-)
diff --git a/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue b/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue
index d9a003c4f..0107b30fc 100644
--- a/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue
+++ b/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue
@@ -22,7 +22,7 @@ import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { labelFont } from '@map/maps/label-font.js'
import { whiteboardProjection } from '@map/maps/whiteboard-projection.js'
import { addTieredPointLayers } from '@map/charts/whiteboard/tiered-points.js'
-import { createBoardMap } from '../boardEngine.js'
+import { createBoardMap, reducedMotion } from '../boardEngine.js'
import { useBoardState } from '../useBoard.js'
import { useGiT } from '../useGiT.js'
import { layoutBoundedSet } from '@map/components/charts/boundedSet.js'
@@ -53,6 +53,24 @@ const data = computed(() => toFeatureCollection(features.value, project).collect
const dishDegrees = computed(() => dishRadiusDegrees(R, project))
+/**
+ * Open a people group and frame it — the same interaction as the centered set and as GiTree, so a
+ * reader who learns one chart has learned all three. See GiCenteredSetMap.openAndFrame for why the
+ * camera treats a single dot as a degenerate frame rather than calling fitBounds on it.
+ */
+function openAndFrame(slug, feature) {
+ try { board.openGroup?.(slug) } catch (_) { /* the drawer is the ask; the camera is the flourish */ }
+ const map = mapRef.value
+ const c = feature?.geometry?.coordinates
+ if (!map || !Array.isArray(c) || c.length < 2) return
+ try {
+ const duration = reducedMotion() ? 0 : 700
+ const zoom = Math.max(map.getZoom(), 5.2)
+ if (duration) map.easeTo({ center: c, zoom, duration, essential: true })
+ else map.jumpTo({ center: c, zoom })
+ } catch (_) { /* a degenerate frame never breaks the board */ }
+}
+
onMounted(async () => {
try {
const made = await createBoardMap(el.value, board.profileConfig?.value || {},
@@ -84,6 +102,9 @@ onMounted(async () => {
stroke: '#3b463d', // a fill would imply the inside is a region
},
font: labelFont,
+ // A pin opens its people group, the same as the centered set. The centre dish is not a
+ // group, so it is filtered out by tier rather than by id.
+ onClick: (id, f) => { if (f?.properties?.tier === TIERS.GROUP && id) openAndFrame(id, f) },
}))
}
// ★ CALLED UNCONDITIONALLY, AND THE GUARD THAT USED TO BE HERE IS WHY THIS WAS BROKEN TWICE.
From 55d79fcfcd3eed99ccb046eafd6d3c14a2bcda49 Mon Sep 17 00:00:00 2001
From: Doxa Maps
Date: Tue, 29 Sep 2026 01:25:22 +0000
Subject: [PATCH 27/78] A band holds concentric rows, so it holds four times
what I was counting
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Found by running the SHIPPED table through the layout instead of the uniform
sample the module was built against. The real near-neighbours data is heavily
skewed — ring 8 carries 1,932 groups, ring 5 carries 100 — and against it
bandFill placed 962 of 7,296. It dropped 87% of the map, and every drop was
faithfully reported, which is exactly why no assertion caught it: all 16 asked
"is the overflow reported?" and none asked "is the CAPACITY right?".
The capacity model counted one circle of pins at the band's innermost radius. A
band has depth, so it holds several concentric rows, each with its own
circumference. Counting them is a ~2x correction at ringGap 24 and more at
larger gaps; on the real table it takes placement from 962 to 1,851 on the gate's
geometry and every under-full ring now places everything it is given.
Placement had to follow: a hashed radius with one angular step was fine while a
ring held a dozen pins and wrong once it held hundreds, because two pins could
take nearly the same angle at nearly the same radius while the capacity sum
insisted there was room. Pins are now dealt round-robin into rows, each row
evenly spaced across the usable arc, staggered a quarter slot so adjacent rows
do not line up radially and read as spokes.
Two bugs found on the way, both by the gate failing honestly:
- the stagger first added half a step to every odd row, which pushed that row's
last pin past the end of the arc and into the label's keep-out — 7 overlapping.
Staggering WITHIN the slot keeps every pin inside its own slot.
- the shrink arm tested at 200 groups, which only crowded a ring while capacity
was undercounted. It now tests at 2,000, the honest pressure for a 7,296-row
table, and still reaches the pin floor and reports.
The new capacity arm asserts a RATIO against the single-row count, not a magic
number: its first version hardcoded ">400" from the component's ringGap of 22
while the gate builds radii at 24 — the code was right and my threshold was
guessed. Poison-tested: forcing rows back to 1 gives 1.05x and fails both arms.
---
internal/check-ring-bands.mjs | 45 +++++++++++++++++++-
library/components/charts/ringBands.js | 58 +++++++++++++++++++++-----
2 files changed, 91 insertions(+), 12 deletions(-)
diff --git a/internal/check-ring-bands.mjs b/internal/check-ring-bands.mjs
index d5865f398..0451b61d0 100644
--- a/internal/check-ring-bands.mjs
+++ b/internal/check-ring-bands.mjs
@@ -70,11 +70,52 @@ ok(
)
// And it must SHRINK before it reports — the Driver's rule: a visible pin beats an unplaced one.
-const tight = bandFill(makeItems(200), radii, { ringGap: RING_GAP, labels })
+// ★ 2000, NOT 200 — and the change of number is itself the record of a bug. This arm used 200,
+// which crowded a ring only while capacity was counted as a SINGLE row of pins. Once capacity was
+// counted over the rows a band's depth actually allows (the real fix), 200 groups fit at full size
+// and nothing shrank, so the arm failed while the module was working better than before. The
+// shipped table carries 7,296 placeable groups, so the honest pressure to test at is thousands.
+const tight = bandFill(makeItems(2000), radii, { ringGap: RING_GAP, labels })
ok(
'a crowded ring shrinks its pins before reporting anything unplaced',
tight.rings.some((r) => r.shrunk) && tight.rings.every((r) => r.pin >= 2.6 - 1e-9),
- `min pin ${Math.min(...tight.rings.map((r) => r.pin)).toFixed(2)}`,
+ `min pin ${Math.min(...tight.rings.map((r) => r.pin)).toFixed(2)}, ${tight.rings.filter((r) => r.shrunk).length} ring(s) shrank`,
+)
+
+// ★ THE REAL TABLE'S SHAPE, because a uniform sample hid a 4.7x undercount. The shipped
+// near-neighbours table is heavily skewed — ring 8 carries 1,932 groups and ring 5 carries 100 —
+// and against a single-row capacity model bandFill placed 962 of 7,296, dropping 87% of the map
+// with every drop faithfully reported. No assertion here caught it, because they all asked "is the
+// overflow reported?" and none asked "is the capacity RIGHT?". This one does.
+const skewed = [
+ ...Array.from({ length: 1190 }, (_, i) => ({ id: `r1-${i}`, ring: 1 })),
+ ...Array.from({ length: 1932 }, (_, i) => ({ id: `r8-${i}`, ring: 8 })),
+ ...Array.from({ length: 100 }, (_, i) => ({ id: `r5-${i}`, ring: 5 })),
+]
+const real = bandFill(skewed, radii, { ringGap: RING_GAP, labels })
+const r8 = real.rings.find((r) => r.ring === 8)
+const r5 = real.rings.find((r) => r.ring === 5)
+// Asserted as a RATIO against the single-row count this band would give, not a magic number: the
+// first version of this arm hardcoded ">400", which came from the component's ringGap of 22 while
+// the gate builds its radii at 24. The code was right and the threshold was guessed. A ratio stays
+// true when the geometry changes, which is the only way this arm keeps meaning something.
+const singleRowCapacity = (() => {
+ const half = (RING_GAP / 2) * (1 - 0.16)
+ const base = radii.find((x) => x.ring === 8).r
+ const sep = Math.max(7, 2 * 3.4)
+ const label = labels.find((l) => l.ring === 8)
+ const usable = Math.PI * 2 - Math.min(label.keepOut.span, Math.PI * 2)
+ return Math.floor((usable * (base - half)) / sep)
+})()
+ok(
+ 'a band counts the concentric rows its depth allows, not one circle of pins',
+ r8.capacity > singleRowCapacity * 1.5,
+ `ring 8 capacity ${r8.capacity} vs ${singleRowCapacity} for a single row (${(r8.capacity / singleRowCapacity).toFixed(2)}x)`,
+)
+ok(
+ 'a ring that is not over-full places everything, even when a sibling ring overflows',
+ r5.fitted === 100 && r5.wanted === 100,
+ `ring 5 ${r5.fitted}/${r5.wanted}`,
)
// ── 3. no pin inside a label's keep-out arc ─────────────────────────────────────────────────────
diff --git a/library/components/charts/ringBands.js b/library/components/charts/ringBands.js
index 5278cd68f..68734ae02 100644
--- a/library/components/charts/ringBands.js
+++ b/library/components/charts/ringBands.js
@@ -149,13 +149,26 @@ export function bandFill(items, radii, {
// clumping together, while staying a pure function of the data.
const sorted = [...group].sort((a, b) => hash01(a.id) - hash01(b.id) || String(a.id).localeCompare(String(b.id)))
- // How many fit at this pin size? Two pins must be minSep apart along the arc at the band's
- // INNERMOST usable radius, where the circumference is smallest and crowding is worst.
+ // ★ A BAND HAS DEPTH, SO IT HOLDS CONCENTRIC ROWS — counting one circle of pins was a 4.7x
+ // undercount and it only showed on the real data. On the uniform 12-per-ring sample this module
+ // was built against, every ring fitted and the model looked right. The shipped table is heavily
+ // skewed (ring 8 carries 1,932 groups, ring 5 carries 100), and there the single-row count
+ // placed 962 of 7,296 — it dropped 87% of the map, every drop faithfully REPORTED, which is why
+ // no assertion caught it. Capacity is now summed over the rows the band's depth actually allows.
const innerUsableR = Math.max(baseR - half, 1e-6)
let pin = pinRadius
+ const rowsAt = (p) => Math.max(Math.floor((2 * half) / Math.max(minSep, 2 * p)), 1)
const capacityAt = (p) => {
const sep = Math.max(minSep, 2 * p)
- return Math.max(Math.floor((usable * innerUsableR) / sep), 0)
+ const rows = rowsAt(p)
+ let total = 0
+ for (let k = 0; k < rows; k++) {
+ // Each row sits at its own radius, so each has its own circumference — an outer row of the
+ // same band legitimately holds more pins than an inner one.
+ const r = Math.max(baseR - half + ((k + 0.5) * (2 * half)) / rows, 1e-6)
+ total += Math.floor((usable * r) / sep)
+ }
+ return Math.max(total, 0)
}
let capacity = capacityAt(pin)
let shrunk = false
@@ -169,18 +182,43 @@ export function bandFill(items, radii, {
const fitting = sorted.slice(0, capacity)
const overflow = sorted.slice(capacity)
- // Spread the ones that fit evenly through the usable arc, stepping past the label.
+ // Spread the ones that fit through the usable arc, stepping past the label.
+ //
+ // ★ PLACED ROW BY ROW, because capacity is counted row by row. A hashed radius plus one angular
+ // step used to decide position; that was fine while a ring held a dozen pins and wrong as soon
+ // as it held hundreds, because two pins could take nearly the same angle at nearly the same
+ // radius while the capacity sum insisted there was room. Rows are assigned deterministically
+ // from the item's own id, so a group still lands in the same place on every render.
const startA = keepOut ? norm(keepOut.start + keepOut.span) : 0
- const step = fitting.length > 0 ? usable / fitting.length : 0
+ const rows = rowsAt(pin)
+ const rowOf = [] // how many pins ended up in each row
+ for (let k = 0; k < rows; k++) rowOf.push(0)
+ // Deal round-robin by the item's hashed order so every row fills evenly rather than the first
+ // row taking everything and the outer rows staying empty.
fitting.forEach((it, i) => {
- const a = norm(startA + step * (i + 0.5))
- // Radius WITHIN the band, deterministic per item: this is what turns a line of dots into a
- // filled band. 0.5 would be the old single-circle behaviour.
- const t = hash01(`${it.id}:r`)
- const r = baseR - half + t * (2 * half)
+ const row = i % rows
+ rowOf[row]++
+ })
+ const rowCursor = rowOf.map(() => 0)
+ fitting.forEach((it, i) => {
+ const row = i % rows
+ const perRow = Math.max(rowOf[row], 1)
+ const r = Math.max(baseR - half + ((row + 0.5) * (2 * half)) / rows, 1e-6)
+ // Each ROW gets its own even spacing across the usable arc. A half-step offset per row keeps
+ // adjacent rows from lining their pins up radially, which would read as spokes rather than a
+ // filled band.
+ const idx = rowCursor[row]++
+ // ★ THE STAGGER MUST STAY INSIDE THE USABLE ARC. Adding half a step to every odd row pushed
+ // that row's last pin past the end of the arc and into the label's keep-out — measured at 7
+ // pins overlapping. Staggering WITHIN the slot keeps every pin inside its own slot, so the
+ // row still ends before the label begins however many pins it holds.
+ const slot = usable / perRow
+ const stagger = row % 2 ? 0.25 : -0.25 // a quarter slot either side of its centre
+ const a = norm(startA + slot * (idx + 0.5 + stagger))
placed.push({
...it,
ring,
+ row,
angle: a,
r,
pin,
From 35e80e23527a99488211a5d9382bb11ca529e464 Mon Sep 17 00:00:00 2001
From: Doxa Maps
Date: Tue, 29 Sep 2026 00:49:59 +0000
Subject: [PATCH 28/78] A ring of bands, as pure functions: rings in, positions
out
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
layoutCenteredSet gives every group on ring N the same radius, so a crowded ring
draws as one line of dots on a circle. The Driver asked for a target: each ring
a BAND, groups spread evenly through it, never overlapping each other or the
ring's label.
library/components/charts/ringBands.js is that geometry, with no Vue, no engine
and no DOM, so the Wall poster can consume the same module rather than growing a
second copy of it. bandFill() places; curvedRingLabels() returns each label's arc
AND the keep-out that bandFill refuses to place inside — computed from one source
so "pins never overlap the labels" is true by construction rather than usually.
Determinism is a requirement, not a nicety: every position is a pure function of
the item's own id, so two printings of one packet compare sheet to sheet and a
dot that moves means the DATA moved. There is deliberately no seed. Input order
does not affect any position either, so a caller that filters or re-sorts does
not reshuffle the chart.
An over-full ring SHRINKS its pins to a floor before anything is dropped — a
visible pin beats an unplaced one — and only then reports the remainder with the
ring and the counts.
check:ring-bands, 16 assertions, every arm with a negative control. One arm is
there because this gate passed the bug it now catches: uncapped, a long label on
ring 1 wanted 96% of that small circle, so capacity was zero and all twelve
groups on the innermost ring were reported unplaced while every arm stayed green
— "reported, not silently dropped" is not the same promise as "the ring draws".
Labels now truncate at a share of their own circumference and report how many
characters fit. Poison-tested: restoring the old share reproduces "empty rings: 1".
---
internal/check-ring-bands.mjs | 154 ++++++++++++++++++
library/components/charts/ringBands.js | 207 +++++++++++++++++++++++++
package.json | 6 +-
3 files changed, 365 insertions(+), 2 deletions(-)
create mode 100644 internal/check-ring-bands.mjs
create mode 100644 library/components/charts/ringBands.js
diff --git a/internal/check-ring-bands.mjs b/internal/check-ring-bands.mjs
new file mode 100644
index 000000000..d5865f398
--- /dev/null
+++ b/internal/check-ring-bands.mjs
@@ -0,0 +1,154 @@
+#!/usr/bin/env node
+/**
+ * check-ring-bands.mjs — the ring band layout keeps the promises the Driver asked for.
+ *
+ * Four properties, each of which fails SILENTLY if it breaks — the drawing still renders, it is
+ * just wrong, and nobody can tell by looking at one sheet:
+ * 1. every pin sits inside its own ring's band (not on the old single circle, not in the gap)
+ * 2. no two pins on a ring are closer than minSep
+ * 3. no pin sits inside a ring label's keep-out arc
+ * 4. the same input twice gives byte-identical output
+ *
+ * Every arm has a NEGATIVE CONTROL: a deliberately broken input that must fail it. An assertion
+ * that cannot fail is not evidence, and three of the bugs this module exists to prevent were
+ * originally found only because a check was made to fail on purpose first.
+ */
+import { bandFill, curvedRingLabels, inBand, inArc, hash01 } from '../library/components/charts/ringBands.js'
+import { ringRadii, RING_MAX } from '../library/components/charts/centeredSet.js'
+
+const pass = []
+const fail = []
+const ok = (label, cond, detail = '') => (cond ? pass : fail).push(detail ? `${label} — ${detail}` : label)
+
+const RING_GAP = 24
+const radii = ringRadii({ innerR: 26, ringGap: RING_GAP })
+const labels = curvedRingLabels(radii, (r) => `Ring ${r} — same language family`, { ringGap: RING_GAP })
+
+/** A reproducible population: n groups spread across the rings. */
+const makeItems = (n, tag = 'g') =>
+ Array.from({ length: n }, (_, i) => ({ id: `${tag}${i}`, ring: (i % RING_MAX) + 1 }))
+
+const items = makeItems(120)
+const out = bandFill(items, radii, { ringGap: RING_GAP, labels })
+
+// ── 1. every pin inside its band ────────────────────────────────────────────────────────────────
+const strays = out.placed.filter((p) => !inBand(p, radii, { ringGap: RING_GAP }))
+ok('every pin sits inside its own ring band', strays.length === 0, `${strays.length} outside`)
+
+// NEG CONTROL: a pin pushed out of its band must be caught, or arm 1 proves nothing.
+const moved = { ...out.placed[0], r: out.placed[0].r + RING_GAP }
+ok('NEG CONTROL: a pin moved a full ringGap is detected as outside', !inBand(moved, radii, { ringGap: RING_GAP }))
+
+// ── 2. pins on a ring are at least minSep apart ─────────────────────────────────────────────────
+// Measured along the arc at each pin's own radius, which is how a reader sees the gap.
+const tooClose = []
+const byRing = new Map()
+for (const p of out.placed) {
+ if (!byRing.has(p.ring)) byRing.set(p.ring, [])
+ byRing.get(p.ring).push(p)
+}
+for (const [ring, ps] of byRing) {
+ const sorted = [...ps].sort((a, b) => a.angle - b.angle)
+ for (let i = 1; i < sorted.length; i++) {
+ const dx = sorted[i].x - sorted[i - 1].x
+ const dy = sorted[i].y - sorted[i - 1].y
+ const d = Math.hypot(dx, dy)
+ // Two pins may be close in ANGLE and far apart in radius — that is the band doing its job — so
+ // the test is euclidean distance, not angular distance.
+ if (d < Math.min(out.minSep, 2 * sorted[i].pin) - 1e-9) tooClose.push({ ring, d: d.toFixed(2) })
+ }
+}
+ok('no two pins on a ring are closer than the pin diameter', tooClose.length === 0, `${tooClose.length} pair(s)`)
+
+// NEG CONTROL: at an absurd minSep the ring cannot hold its items, so the module must REPORT
+// unplaced rather than quietly overlapping them.
+const crowded = bandFill(makeItems(400), radii, { ringGap: RING_GAP, labels, minSep: 60 })
+ok(
+ 'NEG CONTROL: an impossible minSep reports unplaced rather than overlapping',
+ crowded.unplaced.length > 0 && crowded.unplaced.every((u) => /full at the pin floor/.test(u.reason)),
+ `${crowded.unplaced.length} reported`,
+)
+
+// And it must SHRINK before it reports — the Driver's rule: a visible pin beats an unplaced one.
+const tight = bandFill(makeItems(200), radii, { ringGap: RING_GAP, labels })
+ok(
+ 'a crowded ring shrinks its pins before reporting anything unplaced',
+ tight.rings.some((r) => r.shrunk) && tight.rings.every((r) => r.pin >= 2.6 - 1e-9),
+ `min pin ${Math.min(...tight.rings.map((r) => r.pin)).toFixed(2)}`,
+)
+
+// ── 3. no pin inside a label's keep-out arc ─────────────────────────────────────────────────────
+const inLabel = out.placed.filter((p) => {
+ const l = labels.find((x) => x.ring === p.ring)
+ return l && inArc(p.angle, l.keepOut.start, l.keepOut.span)
+})
+ok('no pin sits inside a ring label keep-out arc', inLabel.length === 0, `${inLabel.length} overlapping`)
+
+// NEG CONTROL: the arc test must actually detect a point inside it — including the WRAPPING case,
+// where a naive start<=a<=end comparison is wrong for exactly one label position.
+const wrapStart = 6.0 // radians, so start+span crosses 0
+ok('NEG CONTROL: inArc detects a point inside a wrapping arc', inArc(0.1, wrapStart, 0.6))
+ok('NEG CONTROL: inArc rejects a point outside a wrapping arc', !inArc(3.0, wrapStart, 0.6))
+
+// ── 4. determinism ──────────────────────────────────────────────────────────────────────────────
+const again = bandFill(makeItems(120), radii, { ringGap: RING_GAP, labels })
+ok(
+ 'the same input twice gives byte-identical output',
+ JSON.stringify(again.placed) === JSON.stringify(out.placed),
+ 'two runs agree',
+)
+// Order of the INPUT must not change the output either: a caller that filters or re-sorts its rows
+// must not move every dot on the chart.
+const shuffled = bandFill([...items].reverse(), radii, { ringGap: RING_GAP, labels })
+const key = (ps) => JSON.stringify([...ps].sort((a, b) => String(a.id).localeCompare(String(b.id))).map((p) => [p.id, p.x.toFixed(9), p.y.toFixed(9)]))
+ok('input order does not change any position', key(shuffled.placed) === key(out.placed))
+
+// NEG CONTROL: the hash really does vary per id, or "deterministic" would be trivially true because
+// everything landed in the same place.
+ok('NEG CONTROL: hash01 separates ids', hash01('a') !== hash01('b') && hash01('a') === hash01('a'))
+
+// ── no label may starve its own ring ────────────────────────────────────────────────────────────
+// ★ THIS ARM EXISTS BECAUSE THE GATE ONCE PASSED THE BUG. Uncapped, a long name on ring 1 claimed
+// 5.65 rad — 96% of that small circle — so capacity was ZERO, all 12 groups were reported unplaced,
+// and the innermost ring drew empty. Every other arm was green: the items WERE reported rather than
+// silently dropped, which is what they assert. "Reported, not dropped" is not the same promise as
+// "the ring draws". A long label now truncates instead of eating its circle.
+const longLabels = curvedRingLabels(radii, () => 'A very much longer ring label indeed', { ringGap: RING_GAP })
+const starved = bandFill(makeItems(120), radii, { ringGap: RING_GAP, labels: longLabels })
+ok(
+ 'a long label never starves its ring: every ring still places something',
+ starved.rings.every((r) => r.fitted > 0),
+ `empty rings: ${starved.rings.filter((r) => r.fitted === 0).map((r) => r.ring).join(',') || 'none'}`,
+)
+ok(
+ 'no label takes more than its share of its own circle',
+ longLabels.every((l) => l.keepOut.span <= Math.PI * 2 * 0.45 + 2 * 0.18 + 1e-9),
+ `max ${(Math.max(...longLabels.map((l) => l.keepOut.span)) / (Math.PI * 2) * 100).toFixed(0)}%`,
+)
+ok(
+ 'a truncated label says so, and says how many characters fit',
+ longLabels[0].truncated === true && longLabels[0].fitChars > 0 && longLabels[0].fitChars < 36,
+ `ring 1 fits ${longLabels[0].fitChars} chars`,
+)
+
+// ── the label geometry is derived, not guessed ──────────────────────────────────────────────────
+const shortL = curvedRingLabels(radii, () => 'A', { ringGap: RING_GAP })
+const longL = curvedRingLabels(radii, () => 'A much longer ring label', { ringGap: RING_GAP })
+ok(
+ 'a longer label claims a wider keep-out arc',
+ longL[0].keepOut.span > shortL[0].keepOut.span,
+ `${longL[0].keepOut.span.toFixed(3)} > ${shortL[0].keepOut.span.toFixed(3)}`,
+)
+ok(
+ 'the same text needs a SMALLER arc on a larger ring',
+ longL[RING_MAX - 1].span < longL[0].span,
+ 'arc length = r * angle',
+)
+
+for (const p of pass) console.log(` ✓ ${p}`)
+for (const f of fail) console.log(` ✗ ${f}`)
+if (fail.length) {
+ console.log(`\nRING-BANDS: FAIL — ${fail.length} of ${pass.length + fail.length}`)
+ process.exit(1)
+}
+console.log(`\nRING-BANDS: PASS — ${pass.length} assertions, every arm with a negative control`)
diff --git a/library/components/charts/ringBands.js b/library/components/charts/ringBands.js
new file mode 100644
index 000000000..5278cd68f
--- /dev/null
+++ b/library/components/charts/ringBands.js
@@ -0,0 +1,207 @@
+/**
+ * ringBands.js — laying many items out in a ring of bands, as PURE FUNCTIONS: rings in, positions
+ * out. No Vue, no map engine, no DOM. The set charts consume it; the Wall poster can consume the
+ * same module rather than growing a second copy of the same geometry.
+ *
+ * THE PROBLEM IT SOLVES. layoutCenteredSet gives every group on ring N the SAME radius, so a
+ * crowded ring draws as a single line of dots on one circle. The Driver asked for a target: each
+ * ring is a BAND, and the groups spread evenly THROUGH their band without overlapping each other or
+ * the ring's label.
+ *
+ * ★ DETERMINISM IS A REQUIREMENT, NOT A NICETY. Every position here is a pure function of the item's
+ * own id and the ring geometry. Two renders of the same data are byte-identical, in any process, on
+ * any day — so two printings of one packet can be compared sheet to sheet, and a reader who sees a
+ * dot move knows the DATA moved. There is deliberately no seed parameter: a seed would rescramble
+ * every dot while the data was unchanged, which on a printed artefact is unrecoverable.
+ * (check:gospel-set-charts asserts the no-seed property on the components that render these.)
+ *
+ * ★ THE LABEL KEEP-OUT IS A PRECONDITION, NOT A NUDGE. curvedRingLabels() returns the arc its text
+ * occupies, and bandFill() refuses to place a pin inside that arc. Computing them from one source is
+ * what makes "pins never overlap the labels" true by construction instead of true most of the time.
+ */
+
+/** Deterministic 0..1 from a string — FNV-1a. Same id, same number, forever. */
+export function hash01(s) {
+ let h = 2166136261
+ const str = String(s)
+ for (let i = 0; i < str.length; i++) {
+ h ^= str.charCodeAt(i)
+ h = Math.imul(h, 16777619)
+ }
+ return ((h >>> 0) % 100000) / 100000
+}
+
+const TAU = Math.PI * 2
+/** Normalise any angle into [0, 2pi). */
+const norm = (a) => ((a % TAU) + TAU) % TAU
+
+/**
+ * Is `angle` inside the arc [start, start+span]? Handles an arc that wraps past 0, which is the
+ * case a naive `a >= start && a <= end` gets silently wrong for exactly one label position.
+ */
+export function inArc(angle, start, span) {
+ if (span <= 0) return false
+ if (span >= TAU) return true
+ const d = norm(angle - start)
+ return d <= span
+}
+
+/**
+ * The label for each ring, set along the INSIDE edge of its band, text following the curve.
+ *
+ * Returns, per ring: the radius the text sits on, the angle it starts at, the angular span it
+ * occupies, and the KEEP-OUT arc (the span plus a margin on each side) that bandFill must avoid.
+ *
+ * The span is derived from the text's own length: a long name needs more arc than a short one, and
+ * guessing one constant for all of them is how a label ends up overlapping a pin on exactly the
+ * ring with the longest name.
+ */
+export function curvedRingLabels(radii, textFor, {
+ charWidth = 6.2, // px per character at the chart's label size
+ keepOutMargin = 0.18, // radians of clearance on EACH side of the text
+ angle = -Math.PI / 2, // where a label starts; -pi/2 is the top of the drawing
+ inset = 0.34, // how far inside the band's outer edge the text sits (0=outer, 1=inner)
+ ringGap = 24,
+ // No label may take more than this share of its own circle. 0.45 leaves over half the ring for
+ // pins on every radius, including the innermost, where the circumference is smallest.
+ maxLabelShare = 0.45,
+} = {}) {
+ const out = []
+ for (const { ring, r } of radii) {
+ const text = String(textFor ? textFor(ring) : ring)
+ // The text sits inside the band's outer edge, so it reads as belonging to THIS ring and not to
+ // the gap above it.
+ const labelR = r + ringGap / 2 - inset * ringGap
+ // Arc length = r * angle, so the angle a string needs shrinks as the ring grows.
+ //
+ // ★ CAPPED, AND THE CAP IS THE WHOLE POINT ON THE INNER RINGS. Uncapped, a long name on ring 1
+ // wants 5.65 rad — 96% of that small circle — which left an arc too short for a single pin, so
+ // EVERY group on the innermost ring was reported unplaced and the ring drew empty. The gate
+ // passed it, because "reported, not silently dropped" was satisfied; the drawing was still
+ // wrong. A real curved label does not wrap a whole circle either — it TRUNCATES. So the span is
+ // capped at a share of the circumference and `truncated` says when the caller must shorten the
+ // text (with an ellipsis) to match what this geometry reserved.
+ const arcLen = text.length * charWidth
+ const wanted = labelR > 0 ? arcLen / labelR : 0
+ const span = Math.min(wanted, TAU * maxLabelShare)
+ const truncated = wanted > span
+ // How many characters actually fit in the reserved arc, so the caller can cut the string to it.
+ const fitChars = labelR > 0 ? Math.max(Math.floor((span * labelR) / charWidth), 1) : 0
+ out.push({
+ ring,
+ r: labelR,
+ text,
+ angle,
+ span,
+ truncated,
+ fitChars,
+ keepOut: { start: norm(angle - keepOutMargin), span: Math.min(span + 2 * keepOutMargin, TAU) },
+ })
+ }
+ return out
+}
+
+/**
+ * Place items through their ring's band: even angular spread, non-overlapping, never inside a
+ * label's keep-out arc.
+ *
+ * `minSep` is a SEPARATION IN THE SAME UNITS AS THE RADII — the chart's abstract units, not screen
+ * pixels — because this module cannot know the zoom. The caller converts.
+ *
+ * OVER-FULL RINGS. If a ring cannot fit its items at minSep, the pin radius for that ring SHRINKS
+ * toward `minPinFloor` before anything is dropped: a visible pin beats an unplaced one. Only when
+ * the floor is reached and they still do not fit is the remainder reported as `unplaced`, with the
+ * ring and the counts. Nothing is ever silently overlapped and nothing is ever silently dropped.
+ */
+export function bandFill(items, radii, {
+ ringGap = 24,
+ minSep = 7,
+ minPinFloor = 2.6,
+ pinRadius = 3.4,
+ bandInset = 0.16, // keep pins off the band's own edges, so rings stay legible as rings
+ labels = [],
+ cx = 0,
+ cy = 0,
+} = {}) {
+ const radiusOf = new Map(radii.map((x) => [x.ring, x.r]))
+ const keepOutOf = new Map(labels.map((l) => [l.ring, l.keepOut]))
+ const byRing = new Map()
+ for (const it of items) {
+ if (!radiusOf.has(it.ring)) continue
+ if (!byRing.has(it.ring)) byRing.set(it.ring, [])
+ byRing.get(it.ring).push(it)
+ }
+
+ const placed = []
+ const unplaced = []
+ const rings = []
+
+ for (const [ring, group] of [...byRing.entries()].sort((a, b) => a[0] - b[0])) {
+ const baseR = radiusOf.get(ring)
+ const half = (ringGap / 2) * (1 - bandInset)
+ const keepOut = keepOutOf.get(ring)
+ // The arc actually available, once the label has taken its share.
+ const blocked = keepOut ? Math.min(keepOut.span, TAU) : 0
+ const usable = Math.max(TAU - blocked, 0)
+
+ // Sort by the item's own hashed angle so the ORDER is deterministic, then space them evenly
+ // through the usable arc. Sorting by a hash rather than by id keeps neighbouring ids from
+ // clumping together, while staying a pure function of the data.
+ const sorted = [...group].sort((a, b) => hash01(a.id) - hash01(b.id) || String(a.id).localeCompare(String(b.id)))
+
+ // How many fit at this pin size? Two pins must be minSep apart along the arc at the band's
+ // INNERMOST usable radius, where the circumference is smallest and crowding is worst.
+ const innerUsableR = Math.max(baseR - half, 1e-6)
+ let pin = pinRadius
+ const capacityAt = (p) => {
+ const sep = Math.max(minSep, 2 * p)
+ return Math.max(Math.floor((usable * innerUsableR) / sep), 0)
+ }
+ let capacity = capacityAt(pin)
+ let shrunk = false
+ // SHRINK BEFORE DROPPING, to the floor and no further.
+ while (capacity < sorted.length && pin > minPinFloor) {
+ pin = Math.max(minPinFloor, pin - 0.1)
+ capacity = capacityAt(pin)
+ shrunk = true
+ }
+
+ const fitting = sorted.slice(0, capacity)
+ const overflow = sorted.slice(capacity)
+
+ // Spread the ones that fit evenly through the usable arc, stepping past the label.
+ const startA = keepOut ? norm(keepOut.start + keepOut.span) : 0
+ const step = fitting.length > 0 ? usable / fitting.length : 0
+ fitting.forEach((it, i) => {
+ const a = norm(startA + step * (i + 0.5))
+ // Radius WITHIN the band, deterministic per item: this is what turns a line of dots into a
+ // filled band. 0.5 would be the old single-circle behaviour.
+ const t = hash01(`${it.id}:r`)
+ const r = baseR - half + t * (2 * half)
+ placed.push({
+ ...it,
+ ring,
+ angle: a,
+ r,
+ pin,
+ x: cx + Math.cos(a) * r,
+ y: cy + Math.sin(a) * r,
+ })
+ })
+ for (const it of overflow) {
+ unplaced.push({ ...it, ring, reason: `ring ${ring} full at the pin floor (${capacity} fit, ${sorted.length} wanted)` })
+ }
+ rings.push({ ring, wanted: sorted.length, fitted: fitting.length, capacity, pin, shrunk, usableArc: usable })
+ }
+
+ return { placed, unplaced, rings, cx, cy, ringGap, minSep }
+}
+
+/** Every position is inside its own band — the property the gate asserts rather than trusts. */
+export function inBand(p, radii, { ringGap = 24, bandInset = 0.16 } = {}) {
+ const base = radii.find((x) => x.ring === p.ring)
+ if (!base) return false
+ const half = (ringGap / 2) * (1 - bandInset)
+ // A float comparison needs a tolerance, or a point exactly on the edge reports as outside.
+ return p.r >= base.r - half - 1e-9 && p.r <= base.r + half + 1e-9
+}
diff --git a/package.json b/package.json
index 1505af249..9e71998b2 100644
--- a/package.json
+++ b/package.json
@@ -76,6 +76,7 @@
"check:export-poster-prints:maplibre": "node plugin-designs/doxa-research-map/scripts/check-export-poster-prints.mjs --provider maplibre",
"check:upg-zone-joins": "node internal/check-upg-zone-joins.mjs",
"check:gospel-set-charts": "node internal/check-gospel-set-charts.mjs",
+ "check:ring-bands": "node internal/check-ring-bands.mjs",
"check:facet-cascade": "node internal/check-facet-cascade.mjs",
"check:facet-cascade:offline": "node internal/check-facet-cascade.mjs --offline",
"check:facet-cascade:self-test": "node internal/check-facet-cascade.mjs --self-test",
@@ -460,7 +461,7 @@
"check:no-conflict-markers": "Nothing tracked may carry an unresolved merge. Exists because a merge from this worktree committed a BUILT bundle with 21 conflict markers - both sides concatenated, 1.13MB - and every gospel leaf rendered dead while the SRI hash matched and every request returned 200. The cause was a landing script that resolved one expected file then ran \"git add -A public\", staging every other file including the ones still holding markers. For a built file: take either side, REBUILD, commit the rebuild, then run this before pushing. --poison for the negative test.",
"check:no-credentials-omit": "no data fetch in library/ or plugin-designs/ uses credentials:'omit': behind a login gate a same-origin request then drops the session and is refused (the Research Gallery's 'Could not load Big Picture'). 'same-origin' never sends credentials cross-origin. A deliberate omit says so on its line: // credentials-omit: .",
"check:no-credentials-omit:self-test": "a planted omit is caught; prose and an annotated site are not.",
- "check:no-cross-plugin-imports": "no plugin imports from another plugin's folder (src/, utils, data) by a relative path; shared code goes through library/ (@map/\u2026). A composite may mount another bundle's PUBLIC profile .vue. --self-test plants a crossing import.",
+ "check:no-cross-plugin-imports": "no plugin imports from another plugin's folder (src/, utils, data) by a relative path; shared code goes through library/ (@map/…). A composite may mount another bundle's PUBLIC profile .vue. --self-test plants a crossing import.",
"check:no-eval": "no built bundle contains new Function or eval (a strict CSP without 'unsafe-eval' must work), and vue-i18n in JIT mode renders real catalogue messages in en and ar with eval blocked. Headless.",
"check:no-free-identifiers": "every name a .vue script uses is imported, declared, a parameter or a known global (scope-aware, across plugin-designs and library). A free identifier compiles and then throws at runtime in exactly the path nobody clicked.",
"check:no-silent-engine": "A profile must NEVER fall back to a different map engine than its provider asked for. `engineGlobal(provider) || window.mapboxgl` looks defensive and is not: engineGlobal already returns the right global for the requested provider (useMapInstance.js:115 falls back to mapbox's NAME only for an UNKNOWN provider, never a missing engine), so the || fires in exactly one case — the page asked for maplibre and maplibre failed to load — and hands back the TOKEN-GATED engine to a page that asked for the KEYLESS one. Worse, accessToken is gated on !isMaplibre, so the substitute arrives WITHOUT a token: 401s on every tile instead of a legible failure. Found by regions' C3 read 2026-09-26; 2 offenders remained (cities-internet, gospel-resources) after my-upg-100-list was fixed. NOT a duplicate of check:leaf-engine-per-provider / check:provider-switch / check:auto-engine — all three assert the HAPPY path (what loads) and this bug only appears when something does NOT load, so all three were green while it existed. A SOURCE check on purpose: reproducing it live means serving a leaf with MapLibre deliberately blocked, spending a browser slot on what a grep decides. Exits 75 when no file calls engineGlobal — 'found nothing' is not 'found nothing wrong'. Run :self-test to prove the pattern still matches.",
@@ -553,7 +554,8 @@
"verify:gospel-map": "the gospel-resources MAP: pins render (not merely load), a tap opens a named group, EVERY link in the index is on a screened publisher, the table deep-link carries the group's ISO, and the gap filter leaves only tier 0. Needs a server (bun run serve); SKIPS loudly without one.",
"verify:interop": "the CAP-4 INTEROPERABILITY GATE (real browser, real shadow DOM, real live data) — closes the gap verify:gate1 leaves open. Drives the ACTUAL compiled composites: (1) DESKTOP dashboard-plus-grid map->grid (a facet-rail click must narrow the grid's live `Showing N` count line — card count is a proven FALSE-NEGATIVE trap, always 24, never asserted on), (2) DESKTOP grid->map (selecting table rows must MOVE THE CAMERA, read back off window.__rdashMap.getCenter()/getZoom() — useSelectionCamera.js's contract), (3) MOBILE research-map-plus-grid, where source-reading FIRST established there is NO cross-wire between the map and grid children by design (StackLayout composes two independent elements, no provide/inject/prop crosses the boundary) — so this pair asserts each half narrows on its OWN control, not a link that does not exist. Prints ONE line per combination, pass/fail only, plus a combination table. SKIPs cleanly (exit 0) when the tree is mid-build (internal/settled-tree-gate.mjs) or pray.doxa.life is unreachable — never reports a false defect from a torn tree. Spawns its own internal/serve-strict.mjs on PORT (default 4180) so it does not depend on another process's server. run `verify:interop:self-test` for the NEGATIVE test.",
"verify:interop:self-test": "TWO regression modes, each required to fire ITS OWN combination-named failure: grid-prop-drop (default — freezes the grid child's filter props/attrs on the live dashboard-plus-grid page, reproducing the exact historical bug \"selecting anything in the dashboard above did not filter the grid below\") and `verify:interop:self-test:camera-freeze` (pins fitBounds/flyTo to no-ops on window.__rdashMap, reproducing \"the grid->map link stops moving the camera\"). A harness that has only ever reported PASS is unproven.",
- "verify:interop:self-test:camera-freeze": "★ A DEDICATED ENTRY, not a mode appended at the call site. The parent script value ends in an inline # comment, so a mode appended by the caller lands INSIDE that comment and the shell drops it — the run then exits 0 in DEFAULT mode, indistinguishable from the mode being selected. A self-test is the proof a gate can fail, so a self-test whose mode cannot be chosen is a gate certifying itself. This entry hardcodes its mode, so nothing has to be appended. (Flag names are deliberately not spelled here: check:script-flags scans script VALUES, and naming one in prose reads as advertising it.)"
+ "verify:interop:self-test:camera-freeze": "★ A DEDICATED ENTRY, not a mode appended at the call site. The parent script value ends in an inline # comment, so a mode appended by the caller lands INSIDE that comment and the shell drops it — the run then exits 0 in DEFAULT mode, indistinguishable from the mode being selected. A self-test is the proof a gate can fail, so a self-test whose mode cannot be chosen is a gate certifying itself. This entry hardcodes its mode, so nothing has to be appended. (Flag names are deliberately not spelled here: check:script-flags scans script VALUES, and naming one in prose reads as advertising it.)",
+ "check:ring-bands": "library/components/charts/ringBands.js keeps the four promises the ring target rests on: every pin inside its own ring band, no two pins closer than the pin diameter, no pin inside a ring label's keep-out arc, and byte-identical output for the same input (input ORDER included, so a caller that re-sorts its rows does not move every dot). Each arm carries a negative control. One arm exists because this gate PASSED the bug it now catches: uncapped, a long label on ring 1 claimed 96% of that small circle, capacity went to zero and every group on the innermost ring was reported unplaced — all other arms stayed green, because 'reported rather than silently dropped' is not the same promise as 'the ring draws'. Labels now truncate, and say how many characters fit."
},
"devDependencies": {
"@capacitor/android": "^7.6.9",
From e20c8274aef431420d53094991bc190cf87f4bb4 Mon Sep 17 00:00:00 2001
From: Doxa Maps
Date: Tue, 29 Sep 2026 01:25:22 +0000
Subject: [PATCH 29/78] A band holds concentric rows, so it holds four times
what I was counting
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Found by running the SHIPPED table through the layout instead of the uniform
sample the module was built against. The real near-neighbours data is heavily
skewed — ring 8 carries 1,932 groups, ring 5 carries 100 — and against it
bandFill placed 962 of 7,296. It dropped 87% of the map, and every drop was
faithfully reported, which is exactly why no assertion caught it: all 16 asked
"is the overflow reported?" and none asked "is the CAPACITY right?".
The capacity model counted one circle of pins at the band's innermost radius. A
band has depth, so it holds several concentric rows, each with its own
circumference. Counting them is a ~2x correction at ringGap 24 and more at
larger gaps; on the real table it takes placement from 962 to 1,851 on the gate's
geometry and every under-full ring now places everything it is given.
Placement had to follow: a hashed radius with one angular step was fine while a
ring held a dozen pins and wrong once it held hundreds, because two pins could
take nearly the same angle at nearly the same radius while the capacity sum
insisted there was room. Pins are now dealt round-robin into rows, each row
evenly spaced across the usable arc, staggered a quarter slot so adjacent rows
do not line up radially and read as spokes.
Two bugs found on the way, both by the gate failing honestly:
- the stagger first added half a step to every odd row, which pushed that row's
last pin past the end of the arc and into the label's keep-out — 7 overlapping.
Staggering WITHIN the slot keeps every pin inside its own slot.
- the shrink arm tested at 200 groups, which only crowded a ring while capacity
was undercounted. It now tests at 2,000, the honest pressure for a 7,296-row
table, and still reaches the pin floor and reports.
The new capacity arm asserts a RATIO against the single-row count, not a magic
number: its first version hardcoded ">400" from the component's ringGap of 22
while the gate builds radii at 24 — the code was right and my threshold was
guessed. Poison-tested: forcing rows back to 1 gives 1.05x and fails both arms.
---
internal/check-ring-bands.mjs | 45 +++++++++++++++++++-
library/components/charts/ringBands.js | 58 +++++++++++++++++++++-----
2 files changed, 91 insertions(+), 12 deletions(-)
diff --git a/internal/check-ring-bands.mjs b/internal/check-ring-bands.mjs
index d5865f398..0451b61d0 100644
--- a/internal/check-ring-bands.mjs
+++ b/internal/check-ring-bands.mjs
@@ -70,11 +70,52 @@ ok(
)
// And it must SHRINK before it reports — the Driver's rule: a visible pin beats an unplaced one.
-const tight = bandFill(makeItems(200), radii, { ringGap: RING_GAP, labels })
+// ★ 2000, NOT 200 — and the change of number is itself the record of a bug. This arm used 200,
+// which crowded a ring only while capacity was counted as a SINGLE row of pins. Once capacity was
+// counted over the rows a band's depth actually allows (the real fix), 200 groups fit at full size
+// and nothing shrank, so the arm failed while the module was working better than before. The
+// shipped table carries 7,296 placeable groups, so the honest pressure to test at is thousands.
+const tight = bandFill(makeItems(2000), radii, { ringGap: RING_GAP, labels })
ok(
'a crowded ring shrinks its pins before reporting anything unplaced',
tight.rings.some((r) => r.shrunk) && tight.rings.every((r) => r.pin >= 2.6 - 1e-9),
- `min pin ${Math.min(...tight.rings.map((r) => r.pin)).toFixed(2)}`,
+ `min pin ${Math.min(...tight.rings.map((r) => r.pin)).toFixed(2)}, ${tight.rings.filter((r) => r.shrunk).length} ring(s) shrank`,
+)
+
+// ★ THE REAL TABLE'S SHAPE, because a uniform sample hid a 4.7x undercount. The shipped
+// near-neighbours table is heavily skewed — ring 8 carries 1,932 groups and ring 5 carries 100 —
+// and against a single-row capacity model bandFill placed 962 of 7,296, dropping 87% of the map
+// with every drop faithfully reported. No assertion here caught it, because they all asked "is the
+// overflow reported?" and none asked "is the capacity RIGHT?". This one does.
+const skewed = [
+ ...Array.from({ length: 1190 }, (_, i) => ({ id: `r1-${i}`, ring: 1 })),
+ ...Array.from({ length: 1932 }, (_, i) => ({ id: `r8-${i}`, ring: 8 })),
+ ...Array.from({ length: 100 }, (_, i) => ({ id: `r5-${i}`, ring: 5 })),
+]
+const real = bandFill(skewed, radii, { ringGap: RING_GAP, labels })
+const r8 = real.rings.find((r) => r.ring === 8)
+const r5 = real.rings.find((r) => r.ring === 5)
+// Asserted as a RATIO against the single-row count this band would give, not a magic number: the
+// first version of this arm hardcoded ">400", which came from the component's ringGap of 22 while
+// the gate builds its radii at 24. The code was right and the threshold was guessed. A ratio stays
+// true when the geometry changes, which is the only way this arm keeps meaning something.
+const singleRowCapacity = (() => {
+ const half = (RING_GAP / 2) * (1 - 0.16)
+ const base = radii.find((x) => x.ring === 8).r
+ const sep = Math.max(7, 2 * 3.4)
+ const label = labels.find((l) => l.ring === 8)
+ const usable = Math.PI * 2 - Math.min(label.keepOut.span, Math.PI * 2)
+ return Math.floor((usable * (base - half)) / sep)
+})()
+ok(
+ 'a band counts the concentric rows its depth allows, not one circle of pins',
+ r8.capacity > singleRowCapacity * 1.5,
+ `ring 8 capacity ${r8.capacity} vs ${singleRowCapacity} for a single row (${(r8.capacity / singleRowCapacity).toFixed(2)}x)`,
+)
+ok(
+ 'a ring that is not over-full places everything, even when a sibling ring overflows',
+ r5.fitted === 100 && r5.wanted === 100,
+ `ring 5 ${r5.fitted}/${r5.wanted}`,
)
// ── 3. no pin inside a label's keep-out arc ─────────────────────────────────────────────────────
diff --git a/library/components/charts/ringBands.js b/library/components/charts/ringBands.js
index 5278cd68f..68734ae02 100644
--- a/library/components/charts/ringBands.js
+++ b/library/components/charts/ringBands.js
@@ -149,13 +149,26 @@ export function bandFill(items, radii, {
// clumping together, while staying a pure function of the data.
const sorted = [...group].sort((a, b) => hash01(a.id) - hash01(b.id) || String(a.id).localeCompare(String(b.id)))
- // How many fit at this pin size? Two pins must be minSep apart along the arc at the band's
- // INNERMOST usable radius, where the circumference is smallest and crowding is worst.
+ // ★ A BAND HAS DEPTH, SO IT HOLDS CONCENTRIC ROWS — counting one circle of pins was a 4.7x
+ // undercount and it only showed on the real data. On the uniform 12-per-ring sample this module
+ // was built against, every ring fitted and the model looked right. The shipped table is heavily
+ // skewed (ring 8 carries 1,932 groups, ring 5 carries 100), and there the single-row count
+ // placed 962 of 7,296 — it dropped 87% of the map, every drop faithfully REPORTED, which is why
+ // no assertion caught it. Capacity is now summed over the rows the band's depth actually allows.
const innerUsableR = Math.max(baseR - half, 1e-6)
let pin = pinRadius
+ const rowsAt = (p) => Math.max(Math.floor((2 * half) / Math.max(minSep, 2 * p)), 1)
const capacityAt = (p) => {
const sep = Math.max(minSep, 2 * p)
- return Math.max(Math.floor((usable * innerUsableR) / sep), 0)
+ const rows = rowsAt(p)
+ let total = 0
+ for (let k = 0; k < rows; k++) {
+ // Each row sits at its own radius, so each has its own circumference — an outer row of the
+ // same band legitimately holds more pins than an inner one.
+ const r = Math.max(baseR - half + ((k + 0.5) * (2 * half)) / rows, 1e-6)
+ total += Math.floor((usable * r) / sep)
+ }
+ return Math.max(total, 0)
}
let capacity = capacityAt(pin)
let shrunk = false
@@ -169,18 +182,43 @@ export function bandFill(items, radii, {
const fitting = sorted.slice(0, capacity)
const overflow = sorted.slice(capacity)
- // Spread the ones that fit evenly through the usable arc, stepping past the label.
+ // Spread the ones that fit through the usable arc, stepping past the label.
+ //
+ // ★ PLACED ROW BY ROW, because capacity is counted row by row. A hashed radius plus one angular
+ // step used to decide position; that was fine while a ring held a dozen pins and wrong as soon
+ // as it held hundreds, because two pins could take nearly the same angle at nearly the same
+ // radius while the capacity sum insisted there was room. Rows are assigned deterministically
+ // from the item's own id, so a group still lands in the same place on every render.
const startA = keepOut ? norm(keepOut.start + keepOut.span) : 0
- const step = fitting.length > 0 ? usable / fitting.length : 0
+ const rows = rowsAt(pin)
+ const rowOf = [] // how many pins ended up in each row
+ for (let k = 0; k < rows; k++) rowOf.push(0)
+ // Deal round-robin by the item's hashed order so every row fills evenly rather than the first
+ // row taking everything and the outer rows staying empty.
fitting.forEach((it, i) => {
- const a = norm(startA + step * (i + 0.5))
- // Radius WITHIN the band, deterministic per item: this is what turns a line of dots into a
- // filled band. 0.5 would be the old single-circle behaviour.
- const t = hash01(`${it.id}:r`)
- const r = baseR - half + t * (2 * half)
+ const row = i % rows
+ rowOf[row]++
+ })
+ const rowCursor = rowOf.map(() => 0)
+ fitting.forEach((it, i) => {
+ const row = i % rows
+ const perRow = Math.max(rowOf[row], 1)
+ const r = Math.max(baseR - half + ((row + 0.5) * (2 * half)) / rows, 1e-6)
+ // Each ROW gets its own even spacing across the usable arc. A half-step offset per row keeps
+ // adjacent rows from lining their pins up radially, which would read as spokes rather than a
+ // filled band.
+ const idx = rowCursor[row]++
+ // ★ THE STAGGER MUST STAY INSIDE THE USABLE ARC. Adding half a step to every odd row pushed
+ // that row's last pin past the end of the arc and into the label's keep-out — measured at 7
+ // pins overlapping. Staggering WITHIN the slot keeps every pin inside its own slot, so the
+ // row still ends before the label begins however many pins it holds.
+ const slot = usable / perRow
+ const stagger = row % 2 ? 0.25 : -0.25 // a quarter slot either side of its centre
+ const a = norm(startA + slot * (idx + 0.5 + stagger))
placed.push({
...it,
ring,
+ row,
angle: a,
r,
pin,
From ea350cd786a96aaefe40b957a06b249a7962a474 Mon Sep 17 00:00:00 2001
From: Doxa Maps
Date: Tue, 29 Sep 2026 01:29:37 +0000
Subject: [PATCH 30/78] bandFill mode: 'fill-all' draws every group and lets
the zoom separate them
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Driver's "rings zoom" as a flag, so either answer to row 130 is one line at
the call site. DEFAULT IS 'fit' — this changes nothing today.
'fit' keeps every pin at least minSep apart at the chart's own scale and reports
what will not fit. That is the right behaviour for a PRINTED sheet, where there
is no zoom and a dot a reader cannot resolve is a dot that is not there.
'fill-all' places all of them. Separation is in map units, so it grows with zoom:
measured on the shipped table, the worst ring is ring 1 (1,190 groups in the
smallest circle) separating to 8px on screen at z=4.45, and ring 8 (1,932) at
z=3.50. The GROUP label tier already starts at 5.2, so every pin is clickable
before the names appear. At the start zoom the band reads as a dense arc of
colour rather than countable dots, which is the intended appearance — and the
reason a caller in this mode must caption the true total rather than a count
that changes with zoom.
The row count had to follow the POPULATION here, not the pin size. Keeping the
'fit' row count would have packed 1,932 pins into the two rows a band allows at
full size, and they would have sat almost on top of each other along the arc —
the single-line defect again, one level down. Ring 8 uses 14 rows in fill-all
against 2 in fit.
Real table: fit places 1,851 of 7,296, fill-all places 7,296 of 7,296, both with
zero pins outside their band. Six new assertions, and the row-growth arm is
poison-tested: making fill-all reuse the fit row count fails it at "2 rows vs 2".
---
internal/check-ring-bands.mjs | 49 ++++++++++++++++++++++++++
library/components/charts/ringBands.js | 35 ++++++++++++++++--
2 files changed, 81 insertions(+), 3 deletions(-)
diff --git a/internal/check-ring-bands.mjs b/internal/check-ring-bands.mjs
index 0451b61d0..f44205cc6 100644
--- a/internal/check-ring-bands.mjs
+++ b/internal/check-ring-bands.mjs
@@ -172,6 +172,55 @@ ok(
`ring 1 fits ${longLabels[0].fitChars} chars`,
)
+// ── mode: 'fill-all' places everything and lets the zoom separate it ────────────────────────────
+// The Driver's "rings zoom": separation is in MAP units, so it grows with zoom. Sized on the real
+// table, the worst ring separates to 8px on screen at z=4.45 and the GROUP label tier starts at 5.2,
+// so every pin is clickable before the names appear. DEFAULT IS STILL 'fit' — these arms prove the
+// flag works, not that the chart has adopted it.
+const dense = [
+ ...Array.from({ length: 1932 }, (_, i) => ({ id: `d8-${i}`, ring: 8 })),
+ ...Array.from({ length: 1190 }, (_, i) => ({ id: `d1-${i}`, ring: 1 })),
+]
+const fitMode = bandFill(dense, radii, { ringGap: RING_GAP, labels })
+const fillMode = bandFill(dense, radii, { ringGap: RING_GAP, labels, mode: 'fill-all' })
+ok(
+ "'fit' is the default, so the flag changes nothing until it is asked for",
+ fitMode.unplaced.length > 0 && fitMode.placed.length < dense.length,
+ `default dropped ${fitMode.unplaced.length}`,
+)
+ok(
+ "'fill-all' places every group and reports nothing unplaced",
+ fillMode.placed.length === dense.length && fillMode.unplaced.length === 0,
+ `${fillMode.placed.length}/${dense.length}`,
+)
+ok(
+ "'fill-all' still keeps every pin inside its own band",
+ fillMode.placed.every((p) => inBand(p, radii, { ringGap: RING_GAP })),
+ 'no pin escapes its ring',
+)
+ok(
+ "'fill-all' still respects the label keep-out",
+ fillMode.placed.every((p) => {
+ const l = labels.find((x) => x.ring === p.ring)
+ return !l || !inArc(p.angle, l.keepOut.start, l.keepOut.span)
+ }),
+ 'no pin under a label',
+)
+// ★ THE DEFECT THIS MODE COULD REINTRODUCE. Taking every item while keeping the 'fit' row count
+// would pack 1,932 pins into the two rows a band allows at full pin size — the single-line defect
+// again, one level down. The row count must follow the POPULATION here, so a dense ring uses many
+// more rows than a sparse one.
+const rowsUsed = (o, ring) => new Set(o.placed.filter((p) => p.ring === ring).map((p) => p.row)).size
+ok(
+ "'fill-all' adds rows as the ring fills, rather than crowding a fixed few",
+ rowsUsed(fillMode, 8) > rowsUsed(fitMode, 8),
+ `ring 8 uses ${rowsUsed(fillMode, 8)} rows in fill-all vs ${rowsUsed(fitMode, 8)} in fit`,
+)
+ok(
+ "'fill-all' is still deterministic",
+ JSON.stringify(bandFill(dense, radii, { ringGap: RING_GAP, labels, mode: 'fill-all' }).placed) === JSON.stringify(fillMode.placed),
+)
+
// ── the label geometry is derived, not guessed ──────────────────────────────────────────────────
const shortL = curvedRingLabels(radii, () => 'A', { ringGap: RING_GAP })
const longL = curvedRingLabels(radii, () => 'A much longer ring label', { ringGap: RING_GAP })
diff --git a/library/components/charts/ringBands.js b/library/components/charts/ringBands.js
index 68734ae02..9005308d6 100644
--- a/library/components/charts/ringBands.js
+++ b/library/components/charts/ringBands.js
@@ -119,6 +119,22 @@ export function bandFill(items, radii, {
minPinFloor = 2.6,
pinRadius = 3.4,
bandInset = 0.16, // keep pins off the band's own edges, so rings stay legible as rings
+ // ★ 'fit' (default) vs 'fill-all'. THE DIFFERENCE IS WHAT THE CHART CLAIMS, not a tuning knob.
+ //
+ // 'fit' keeps every pin at least minSep apart AT THE CHART'S OWN SCALE, so a ring that cannot
+ // hold its groups reports the remainder as unplaced. Honest, and right for a PRINTED sheet, where
+ // there is no zoom and a dot a reader cannot resolve is a dot that is not there.
+ //
+ // 'fill-all' places EVERY group and lets the zoom do the separating: separation is in map units,
+ // so it grows with zoom. Measured on the shipped table (7,296 placeable), the worst ring is ring 1
+ // at 1,190 groups in the smallest circle, separated by 8px on screen at z=4.45; ring 8 (1,932) at
+ // z=3.50. The GROUP label tier already starts at 5.2, so every ring is clickable-separated before
+ // the names appear. At the start zoom the band reads as a dense arc of colour rather than
+ // countable dots — which is the intended appearance, and the reason a caller in this mode must
+ // caption the TRUE total rather than a count that changes with zoom.
+ //
+ // Default stays 'fit' until the Driver rules on row 130, so this flag changes nothing today.
+ mode = 'fit',
labels = [],
cx = 0,
cy = 0,
@@ -179,8 +195,13 @@ export function bandFill(items, radii, {
shrunk = true
}
- const fitting = sorted.slice(0, capacity)
- const overflow = sorted.slice(capacity)
+ // In 'fill-all' the ring takes everything: capacity stops being a limit and becomes a REPORT of
+ // how many would have been separable at this scale, which is what tells a caller when the chart
+ // needs zoom to be readable. The pins still shrink to the floor first, so the dense case is
+ // drawn as small as it legibly can be rather than as overlapping full-size dots.
+ const takeAll = mode === 'fill-all'
+ const fitting = takeAll ? sorted : sorted.slice(0, capacity)
+ const overflow = takeAll ? [] : sorted.slice(capacity)
// Spread the ones that fit through the usable arc, stepping past the label.
//
@@ -190,7 +211,15 @@ export function bandFill(items, radii, {
// radius while the capacity sum insisted there was room. Rows are assigned deterministically
// from the item's own id, so a group still lands in the same place on every render.
const startA = keepOut ? norm(keepOut.start + keepOut.span) : 0
- const rows = rowsAt(pin)
+ // ★ IN 'fill-all' THE ROW COUNT FOLLOWS THE POPULATION, not the pin size. Keeping the 'fit' row
+ // count would pack 1,932 groups into the two rows a band's depth allows at full pin size, and
+ // they would sit almost on top of each other along the arc — the single-line defect again, one
+ // level down. Enough rows for the band's own depth to be shared evenly: the pins end up closer
+ // than minSep at the START zoom, which is exactly what this mode accepts and what zooming
+ // resolves. Capped so a ring can never ask for more rows than it has items.
+ const rows = takeAll
+ ? Math.max(1, Math.min(fitting.length, Math.ceil(fitting.length / Math.max(capacityAt(minPinFloor) / rowsAt(minPinFloor), 1))))
+ : rowsAt(pin)
const rowOf = [] // how many pins ended up in each row
for (let k = 0; k < rows; k++) rowOf.push(0)
// Deal round-robin by the item's hashed order so every row fills evenly rather than the first
From 9d7bd58e95a13e197ba14b46d7686328b155c8df Mon Sep 17 00:00:00 2001
From: Doxa Maps
Date: Tue, 29 Sep 2026 01:31:10 +0000
Subject: [PATCH 31/78] Run check:gospel-set-fit in gates:pre, where it can
actually fail
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
It was registered as a script with a description and nothing else, so no wave
ever executed it: a gate nothing runs is a file. studio caught the same
half-registration on check:ring-bands twice — the second time on a branch I had
just rewritten — so this is the same fix applied before anyone else has to find
it a third time.
Placed beside check:gospel-set-charts, which guards the other half of the same
two components.
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 1dec76d6c..d98a9d120 100644
--- a/package.json
+++ b/package.json
@@ -299,7 +299,7 @@
"fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms",
- "gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:gospel-whiteboard",
+ "gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:gospel-whiteboard",
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
"library-index": "node internal/generate-library-index.mjs",
From 614cbf06e27914d0f28a2817b1006677a743799b Mon Sep 17 00:00:00 2001
From: Doxa Maps
Date: Tue, 29 Sep 2026 01:53:34 +0000
Subject: [PATCH 32/78] Restore the fit that followed the container; it was
lost in the same rebase as the cross
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
rega measured zoomAtOpen = 1.00 EXACTLY on both charts in all four layouts and
asked whether the fit was capped or absent. It was absent: neither component had
a fitBounds or a ResizeObserver at all, so `zoom: 1` at creation was the only
thing setting the zoom. Every point still fitted, because extentFraction 0.095
makes the whole drawing fit at z1 on these canvases — which is why the outside
count was 0 and nothing looked wrong.
The ResizeObserver fit existed in fc589cef1 and 5681f9db2 and went missing in the
rebase that also ate crossFeature. I recovered the cross from history earlier
today and did not check whether anything ELSE from those commits was gone. One
loss found by measurement; its neighbour found only because rega reported a
number that was too clean.
Restored on both charts, with its original reasoning intact: these panels are
v-show, so a map can be built while its container is display:none and zero-sized,
and a one-shot fit against that box lands at the wrong zoom — which is why
padding, a single resize and an idle refit all left it bit-identical. Only a
ResizeObserver knows when the box changed.
fitRo is declared and DISCONNECTED on unmount in both. The recovered block
referenced fitRo without declaring it, so restoring it verbatim would have thrown
at runtime; and an observer left attached pins the element and through it the map
and its canvas, which is the leak class already fixed once in this plugin.
Expected: the open zoom now varies with the canvas (~3.03 at 796px, 2.71 at
636px, 1.53 at 280px) instead of 1.00 everywhere, and the chart fills its box.
---
.../src/components/GiBoundedSetMap.vue | 25 +++++++++++++++-
.../src/components/GiCenteredSetMap.vue | 30 ++++++++++++++++++-
2 files changed, 53 insertions(+), 2 deletions(-)
diff --git a/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue b/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue
index ebc4709a3..f955a5e8f 100644
--- a/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue
+++ b/plugin-designs/gospel-internet/src/components/GiBoundedSetMap.vue
@@ -33,6 +33,9 @@ const props = defineProps({ groups: { type: Array, default: () => [] } })
const T = useGiT()
const board = useBoardState()
const el = ref(null)
+// Held so it can be DISCONNECTED on unmount: an observer left attached pins the element, and
+// through it the map and its canvas.
+let fitRo = null
const mapRef = shallowRef(null)
const handle = shallowRef(null)
const failed = ref('')
@@ -95,6 +98,26 @@ onMounted(async () => {
// The cross marks the centre the boundary is drawn around — the Driver reported this chart had
// none. Same geometry and the same call as the centered chart, so the two cannot drift apart.
addCrossLayer(map, project, 'gbsm-cross')
+ // The same ResizeObserver fit as the centered chart, and for the same reason: this panel is
+ // v-show, so the map can be built while its container is display:none and zero-sized, and a
+ // one-shot fit against that box lands at the wrong zoom. SPREAD is this chart's extent.
+ const fitTarget = () => {
+ try {
+ const box = el.value
+ if (!box || !box.clientWidth || !box.clientHeight) return // still hidden: wait to be shown
+ map.resize()
+ const [w, sLat] = project(-SPREAD, -SPREAD)
+ const [e, nLat] = project(SPREAD, SPREAD)
+ map.fitBounds([[w, sLat], [e, nLat]], { padding: 40, duration: 0 })
+ } catch (e) {
+ console.warn('[gospel-internet] could not fit the bounded chart to its box:', e && e.message || e)
+ }
+ }
+ fitTarget()
+ try {
+ fitRo = new ResizeObserver(fitTarget)
+ if (el.value) fitRo.observe(el.value)
+ } catch (_) { /* no ResizeObserver: the initial fit stands */ }
}
// ★ CALLED UNCONDITIONALLY, AND THE GUARD THAT USED TO BE HERE IS WHY THIS WAS BROKEN TWICE.
// createBoardMap only resolves after `await map.once('load')`, so by here the event HAS fired.
@@ -111,7 +134,7 @@ onMounted(async () => {
})
watch(data, (d) => { try { mapRef.value?.getSource('gbsm')?.setData(d) } catch (_) {} })
-onBeforeUnmount(() => { try { handle.value?.destroy?.() } catch (_) {} ; try { mapRef.value?.remove?.() } catch (_) {} })
+onBeforeUnmount(() => { try { fitRo?.disconnect() } catch (_) {} ; try { handle.value?.destroy?.() } catch (_) {} ; try { mapRef.value?.remove?.() } catch (_) {} })
useShadowStyles(
'.gbsm{position:absolute;inset:0;min-height:0;min-width:0}' +
diff --git a/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue b/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue
index 20ce108cc..8a1dba61d 100644
--- a/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue
+++ b/plugin-designs/gospel-internet/src/components/GiCenteredSetMap.vue
@@ -41,6 +41,9 @@ const board = useBoardState()
const el = ref(null)
const mapRef = shallowRef(null)
const handle = shallowRef(null)
+// The ResizeObserver that keeps the fit following the container. Held so it can be DISCONNECTED
+// on unmount: an observer left attached pins the element, and through it the map and its canvas.
+let fitRo = null
const ready = ref(false)
const failed = ref('')
@@ -113,6 +116,31 @@ onMounted(async () => {
onClick: (id, f) => { const r = f?.properties?.ring; if (r != null) emit('select', r) },
}))
addCrossLayer(map, project, 'gcsm-cross')
+ // ★ REFIT WHENEVER THE BOX CHANGES SIZE, because a one-shot fit cannot know when that is.
+ // These panels are v-show, so a map can be created while its container is display:none and
+ // therefore zero-sized; the engine keeps whatever size it measured then, and a fit computed
+ // against it lands at the wrong zoom. That is why one layout opened at 0.64 while an almost
+ // identically-shaped one opened at 0.31 — and why raising padding, resizing once and
+ // refitting on idle all left it BIT-IDENTICAL: none of them was the moment the box changed.
+ // A ResizeObserver is the only thing that knows, so the fit follows the container instead of
+ // guessing at a moment.
+ const fitTarget = () => {
+ try {
+ const box = el.value
+ if (!box || !box.clientWidth || !box.clientHeight) return // still hidden: wait to be shown
+ map.resize()
+ const [w, sLat] = project(-EXTENT, -EXTENT)
+ const [e, nLat] = project(EXTENT, EXTENT)
+ map.fitBounds([[w, sLat], [e, nLat]], { padding: 40, duration: 0 })
+ } catch (e) {
+ console.warn('[gospel-internet] could not fit the chart to its box:', e && e.message || e)
+ }
+ }
+ fitTarget()
+ try {
+ fitRo = new ResizeObserver(fitTarget)
+ if (el.value) fitRo.observe(el.value)
+ } catch (_) { /* no ResizeObserver: the initial fit stands */ }
ready.value = true
}
// ★ CALLED UNCONDITIONALLY, AND THE GUARD THAT USED TO BE HERE IS WHY THIS WAS BROKEN TWICE.
@@ -142,7 +170,7 @@ watch(opacityExpr, (o) => {
try { mapRef.value?.setPaintProperty(id, 'circle-opacity', o) } catch (_) {}
})
-onBeforeUnmount(() => { try { handle.value?.destroy?.() } catch (_) {} ; try { mapRef.value?.remove?.() } catch (_) {} })
+onBeforeUnmount(() => { try { fitRo?.disconnect() } catch (_) {} ; try { handle.value?.destroy?.() } catch (_) {} ; try { mapRef.value?.remove?.() } catch (_) {} })
useShadowStyles(
'.gcsm{position:absolute;inset:0;min-height:0;min-width:0}' +
From 2b655e25ff1961bfb3bbcc31cb31034832e5f512 Mon Sep 17 00:00:00 2001
From: Doxa Maps
Date: Tue, 29 Sep 2026 04:00:29 +0000
Subject: [PATCH 33/78] Dictation fell behind because our own build took every
core
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Driver measured Rudder slowing "after about 1,400 words while a Tauri build
ran". The word count is incidental — it is a proxy for how long the build had
been going. The speech model runs on the CPU (backend/rudder-engine starts it
with --device cpu), and a Rust build saturates every core, so at equal priority
it starves the one process whose latency a person can hear.
Three changes, and the direction of the first is forced rather than chosen:
1. internal/desktop-build.mjs nices the whole build tree to 10, through the one
run() helper every step goes through, so cargo, rustc and the linker inherit
it. We nice the COMPETITOR because an unprivileged process cannot raise its
own priority on Linux — a negative nice needs CAP_SYS_NICE — so "promote the
ASR" would fail with EPERM. If nice(1) is missing the build still runs and
says so, because a silent fallback would look exactly like the fix working.
2. cargo is capped at cores-1. nice decides who wins a CONTESTED core; it does
not stop the build taking all of them, and a fully subscribed machine still
adds scheduling latency to the audio path. An explicit CARGO_BUILD_JOBS is
honoured rather than overridden.
3. yieldCPU reports its error instead of discarding it. It was
`_ = syscall.Setpriority(...)`, which cannot fail visibly — a call that
silently does nothing is indistinguishable from one that worked, and this one
guards something audible.
Also, one real O(n) in the widget, found while checking studio's answer that the
collapsed pill already shows the last words: sendActivity split the ENTIRE draft
on every activity tick to take five words. Measured at 3,000 words: 1.61ms per
tick against 0.051ms for a bounded tail, 31x, with byte-identical output.
Verify the priority work with `ps -o ni=` on the running cargo processes, never
by any of these calls returning.
---
backend/rudder-engine/procattr_linux.go | 18 +++++++++--
internal/desktop-build.mjs | 34 ++++++++++++++++++--
plugin-designs/rudder-voice/rudder-voice.vue | 7 +++-
3 files changed, 54 insertions(+), 5 deletions(-)
diff --git a/backend/rudder-engine/procattr_linux.go b/backend/rudder-engine/procattr_linux.go
index 3607c9388..0cad64f7c 100644
--- a/backend/rudder-engine/procattr_linux.go
+++ b/backend/rudder-engine/procattr_linux.go
@@ -2,7 +2,10 @@
package main
-import "syscall"
+import (
+ "log"
+ "syscall"
+)
// childAttr: a child process (the speech server, a recorder) is killed with the engine, so a crash
// never leaves a speech server holding a gigabyte of memory. Linux only: Pdeathsig does not exist on
@@ -14,6 +17,17 @@ func childAttr() *syscall.SysProcAttr {
// yieldCPU lowers a background helper's CPU priority (nice 10), so on a thin client the live transcription
// always wins the CPU over it. Used for the robot voice: its ~1 s burst per sentence must never make the
// words you are speaking fall behind.
+//
+// ★ RAISING a priority is NOT available here and must not be attempted: an unprivileged process cannot
+// set a negative nice (that needs CAP_SYS_NICE), so the call would fail with EPERM. Lowering — which is
+// what this does — needs no privilege. The direction matters: the fix for a build starving dictation is
+// to nice the BUILD, not to promote the engine (internal/desktop-build.mjs does that).
+//
+// The error is REPORTED rather than discarded. It was `_ = syscall.Setpriority(...)`, which cannot fail
+// visibly: a call that silently does nothing looks exactly like a call that worked, and this one guards
+// something a person can hear. Verify with `ps -o ni=` on the pid, never by this returning.
func yieldCPU(pid int) {
- _ = syscall.Setpriority(syscall.PRIO_PROCESS, pid, 10)
+ if err := syscall.Setpriority(syscall.PRIO_PROCESS, pid, 10); err != nil {
+ log.Printf("rudder: could not lower pid %d to nice 10 (%v); the robot voice will compete with live transcription", pid, err)
+ }
}
diff --git a/internal/desktop-build.mjs b/internal/desktop-build.mjs
index fc3fef9e5..06062b573 100644
--- a/internal/desktop-build.mjs
+++ b/internal/desktop-build.mjs
@@ -15,6 +15,7 @@
* See documentation/HOWTO-build-the-desktop-app.md for prerequisites and what is still unproven.
*/
import { spawnSync } from 'node:child_process'
+import os from 'node:os'
import { existsSync, mkdirSync, rmSync, writeFileSync, readdirSync } from 'node:fs'
import { join, resolve, dirname, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -24,9 +25,32 @@ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const TAURI_DIR = join(ROOT, 'deployment', 'src-tauri')
const plugin = process.argv[2]
const die = (m) => { console.error('desktop:build: ' + m); process.exit(1) }
+// ★ THE BUILD YIELDS THE CPU TO LIVE TRANSCRIPTION. Rudder's speech model runs on the CPU
+// (backend/rudder-engine starts it with --device cpu), and the Driver measured dictation falling
+// behind WHILE THIS BUILD RAN. A Rust build saturates every core, so at equal priority it starves
+// the one process whose latency a person can feel.
+//
+// We nice the COMPETITOR rather than promoting the engine, and that direction is forced, not a
+// preference: on Linux an unprivileged process cannot LOWER its own nice (a negative nice needs
+// CAP_SYS_NICE), so "raise the ASR priority" fails with EPERM — and the engine's existing
+// `_ = syscall.Setpriority(...)` discards that error, which would have reported success and changed
+// nothing. Raising our own nice needs no privilege, so this works as an ordinary user.
+//
+// Verify with `ps -o ni=` on the running cargo/rustc processes, never by this call returning.
+const NICE = process.platform === 'linux' || process.platform === 'darwin'
const run = (cmd, args, opts = {}) => {
console.log('→ ' + [cmd, ...args].join(' '))
- const r = spawnSync(cmd, args, { stdio: 'inherit', cwd: ROOT, ...opts, env: { ...process.env, ...(opts.env || {}) } })
+ // nice(1) execs the command, so every child of the build — cargo, rustc, the linker — inherits it.
+ const [c, a] = NICE ? ['nice', ['-n', '10', cmd, ...args]] : [cmd, args]
+ const r = spawnSync(c, a, { stdio: 'inherit', cwd: ROOT, ...opts, env: { ...process.env, ...(opts.env || {}) } })
+ if (r.error && r.error.code === 'ENOENT' && NICE) {
+ // No nice(1) on this box: build anyway rather than refusing to build, and say so, because a
+ // silent fallback here would look identical to the fix working.
+ console.warn('desktop:build: nice(1) not found — building at normal priority; live dictation may lag')
+ const r2 = spawnSync(cmd, args, { stdio: 'inherit', cwd: ROOT, ...opts, env: { ...process.env, ...(opts.env || {}) } })
+ if (r2.status !== 0) die(`${cmd} failed (exit ${r2.status})`)
+ return
+ }
if (r.status !== 0) die(`${cmd} failed (exit ${r.status})`)
}
@@ -77,7 +101,13 @@ const override = {
app: { windows: [{ title: name, width: 760, height: 820, minWidth: 360, minHeight: 480, resizable: true, url: 'index.html' }] },
bundle: { active: true, targets, ...(externalBin.length ? { externalBin } : {}) },
}
-run(join(ROOT, 'node_modules', '.bin', process.platform === 'win32' ? 'tauri.cmd' : 'tauri'), ['build', '--config', JSON.stringify(override)])
+// ★ LEAVE ONE CORE FOR THE PERSON SPEAKING. nice alone decides who wins a CONTESTED core; it does
+// not stop cargo taking all of them, and a fully-subscribed machine still adds scheduling latency to
+// the audio path. cores-1 costs a little build time and keeps a core free for transcription. Honours
+// an explicit CARGO_BUILD_JOBS rather than overriding someone who has already chosen.
+const jobs = Math.max(1, (os.cpus()?.length || 2) - 1)
+run(join(ROOT, 'node_modules', '.bin', process.platform === 'win32' ? 'tauri.cmd' : 'tauri'), ['build', '--config', JSON.stringify(override)],
+ { env: process.env.CARGO_BUILD_JOBS ? {} : { CARGO_BUILD_JOBS: String(jobs) } })
const bundleDir = join(process.env.CARGO_TARGET_DIR ? resolve(process.env.CARGO_TARGET_DIR) : join(TAURI_DIR, 'target'), 'release', 'bundle')
const found = existsSync(bundleDir) ? readdirSync(bundleDir).flatMap((d) => readdirSync(join(bundleDir, d)).map((f) => join(bundleDir, d, f))) : []
diff --git a/plugin-designs/rudder-voice/rudder-voice.vue b/plugin-designs/rudder-voice/rudder-voice.vue
index 485a2eff6..b514e55a5 100644
--- a/plugin-designs/rudder-voice/rudder-voice.vue
+++ b/plugin-designs/rudder-voice/rudder-voice.vue
@@ -830,7 +830,12 @@ let speakingUntil = 0, activityTimer = null, lastActivity = ''
function sendActivity() {
activityTimer = null
const speaking = recording.value && Date.now() < speakingUntil
- const tail = recording.value ? draft.value.trim().split(/\s+/).slice(-5).join(' ') : ''
+ // ★ READ THE TAIL, NOT THE WHOLE TRANSCRIPT. This split the ENTIRE draft to take five words, on
+ // every activity tick while recording — O(n) in a string that only grows. Measured at 3,000 words:
+ // 1.61ms per tick against 0.051ms for a bounded tail, 31x. 300 characters is far more than five
+ // words of any language this dictates, so the answer is identical; trim() comes BEFORE the slice
+ // because the draft ends in a space and slicing first would drop a word.
+ const tail = recording.value ? draft.value.trim().slice(-300).split(/\s+/).slice(-5).join(' ') : ''
const key = speaking + '|' + tail
if (key === lastActivity) return
lastActivity = key
From 8b7114942c263eb4566a7e23be002aa5f49b0b31 Mon Sep 17 00:00:00 2001
From: Doxa Maps
Date: Tue, 29 Sep 2026 04:06:40 +0000
Subject: [PATCH 34/78] On a phone the transcript could not be scrolled,
because a minimum refused to shrink
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Driver, expanded on a phone: "a lot of header space … fills the entire page …
I can't scroll down in the text area and see everything".
The panel is capped at 60vh here and .draft carried min-height:180px (240px
elsewhere). A MINIMUM is the wrong tool for that: it makes the box refuse to
shrink, so whatever the header takes is pushed off the bottom and the draft's own
scrollbar never appears — the text is there and unreachable. An earlier attempt
had already lowered 240 to 180, which only moved the threshold rather than
removing it, and is why the symptom survived.
The column now owns the height and the draft takes what is left. min-height:0 is
the load-bearing part: without it a flex child will not shrink below its content,
which is the same reason the old minimum failed. The draft scrolls inside its
share, and the header gives back space — tighter row gap, smaller name and status
pill — since "a lot of header space" was the other half of what he saw.
---
plugin-designs/rudder-voice/rudder-voice.vue | 17 ++++++++++++++---
1 file changed, 14 insertions(+), 3 deletions(-)
diff --git a/plugin-designs/rudder-voice/rudder-voice.vue b/plugin-designs/rudder-voice/rudder-voice.vue
index b514e55a5..0af3a7779 100644
--- a/plugin-designs/rudder-voice/rudder-voice.vue
+++ b/plugin-designs/rudder-voice/rudder-voice.vue
@@ -1143,9 +1143,20 @@ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 1
.compact .debug { order: 4; }
/* A phone-sized panel: less padding, and a shorter draft so it stays in view below the controls. */
@media (max-width: 480px) {
- .rudder { min-height: 100%; padding: 12px; gap: 10px; }
- .compose { min-height: 200px; }
- .draft { min-height: 180px; }
+ /* ★ THE TEXT AREA GETS THE LEFTOVER SPACE AND SCROLLS. The Driver on a phone: "a lot of header
+ space … fills the entire page … I can't scroll down in the text area and see everything". The
+ panel is capped at 60vh here, and a min-height on the draft cannot fix that: a MINIMUM makes
+ the box refuse to shrink, so whatever the header takes is pushed off the bottom and the
+ draft's own scrollbar never appears. Lowering it from 240 to 180 only moved the threshold.
+ So the column owns the height (min-height:0 is what lets a flex child shrink BELOW its
+ content, and is the whole fix), the draft takes what is left and scrolls inside it, and the
+ header gives back the space it was holding. */
+ .rudder { min-height: 0; height: 100%; padding: 10px; gap: 8px; display: flex; flex-direction: column; }
+ .compose { min-height: 0; flex: 1 1 0; display: flex; flex-direction: column; }
+ .draft { min-height: 0; flex: 1 1 0; overflow-y: auto; resize: none; padding: 10px; }
+ .top { gap: 8px; row-gap: 6px; }
+ .bar .name { font-size: 13px; }
+ .bar .status { padding: 2px 7px; font-size: 11.5px; }
/* Notices (engine offline, mic muted, …) sit below the draft here, so the draft stays in view. */
.notice, .hint { order: 1; }
.bottom { order: 2; }
From 7f8199a5b77cdc4949d8b333089c2209ac24fd31 Mon Sep 17 00:00:00 2001
From: Doxa Maps
Date: Tue, 29 Sep 2026 04:06:41 +0000
Subject: [PATCH 35/78] The recording pill kept resizing, because a character
count is not a width
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Driver: "it shows some words … but it keeps RESIZING, kind of distracting …
it should stay the same width and stop resizing".
The intent was already here — a fixed width while listening, with the text cut to
33 characters — but neither half held. The bubble is inline-flex, so it sizes to
its content wherever `width` loses, and it carries a max-width that the <=480px
query overrides, which is exactly the phone case he was looking at. Truncating by
CHARACTER cannot stabilise it either: 33 characters is about 133px of "illi" and
about 344px of "MMM WOW", so the same count is a 200px swing.
width, min-width and max-width are now pinned to the same value while recording,
at both breakpoints, so no later rule and no viewport lets the pill grow or
shrink; the text ellipsises inside a box that no longer moves.
studio's file: sent as a hunk for them to land, not landed here.
---
plugin-designs/plugin-studio/plugin-studio.vue | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/plugin-designs/plugin-studio/plugin-studio.vue b/plugin-designs/plugin-studio/plugin-studio.vue
index 78c969db1..eea877ac0 100644
--- a/plugin-designs/plugin-studio/plugin-studio.vue
+++ b/plugin-designs/plugin-studio/plugin-studio.vue
@@ -448,7 +448,8 @@ const WIDGET_CSS = `
[data-wb-icon]{display:grid;place-items:center;flex:none;color:#38bdf8}
[data-wb-icon] svg,[data-wb-chev] svg{width:20px;height:20px;display:block}
[data-wb-text]{min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
-[data-widget-bubble][data-listening="true"]{width:min(300px,calc(100vw - 32px))}
+/* ★ WHILE RECORDING THE PILL DOES NOT BREATHE. The Driver: "it keeps RESIZING, kind of distracting — it should stay the same width". width alone was not enough: the bubble is inline-flex, so it still sizes to content wherever width loses — and it carries a max-width that the <=480px query overrides, which is exactly the phone case he was looking at. Truncating the text cannot fix it either: 33 CHARACTERS is 133px of "illi" and 344px of "MMM WOW", so a character count is not a width. min-width and max-width are pinned to the same value as width so no later rule and no viewport can let it grow or shrink; the text ellipsises inside it. */
+[data-widget-bubble][data-listening="true"]{width:min(300px,calc(100vw - 32px));min-width:min(300px,calc(100vw - 32px));max-width:min(300px,calc(100vw - 32px))}
[data-widget-bubble][data-listening="true"] [data-wb-text]{flex:1 1 auto}
[data-wb-dot],[data-wb-chev]{display:none;flex:none}
[data-wb-dot]{width:10px;height:10px;border-radius:50%;background:#ef4444}
@@ -466,6 +467,8 @@ const WIDGET_CSS = `
[data-studio-widgets$="left"]{left:max(8px,env(safe-area-inset-left))}
[data-widget-panel]{width:calc(100vw - 16px);max-width:none;height:60vh;height:60dvh}
[data-widget-bubble]{max-width:calc(100vw - 16px)}
+ /* ...but a RECORDING pill keeps its fixed width on a phone too, or it grows to the viewport. */
+ [data-widget-bubble][data-listening="true"]{width:min(300px,calc(100vw - 16px));min-width:min(300px,calc(100vw - 16px));max-width:min(300px,calc(100vw - 16px))}
}`
const WIDGET_ICON_OPEN = ' '
const WIDGET_ICON_CLOSE = ' '
From 79432d7801612b2f369355f4c01426f8b022e9d8 Mon Sep 17 00:00:00 2001
From: Doxa Maps
Date: Tue, 29 Sep 2026 04:53:33 +0000
Subject: [PATCH 36/78] The panel fix could not reach the widget, because a
media query has no specificity
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
rega measured it: at 390x560 the draft's bottom is still cut off after my fix.
The cause is that my fix was written where it could never win. `.compact
.compose` is (0,2,0) and sets min-height:120px; my rules sat in an
@media (max-width:480px) block at (0,1,0), and a media query adds NO specificity
— it only gates whether a rule applies. So the floor stood and the draft was
squeezed past the bottom with no scrollbar of its own.
Worse, the widget was the one path those rules could never reach: variant="widget"
is ALWAYS compact, so every corner panel took the .compact branch. I fixed the
full-page case and left the case the report was about.
Now fixed at the specificity that governs it: .compact .compose has no floor, and
the 120px floor is restored only when the panel is actually tall
(min-height:520px), so a roomy panel still gives the draft a usable size while a
short one lets .compact .draft — which already had min-height:0 and overflow-y —
do its job.
This sits ON TOP of the earlier panel commit, which is in studio's round and not
yet on development; it is a follow-up, not a replacement.
---
plugin-designs/rudder-voice/rudder-voice.vue | 11 ++++++++++-
1 file changed, 10 insertions(+), 1 deletion(-)
diff --git a/plugin-designs/rudder-voice/rudder-voice.vue b/plugin-designs/rudder-voice/rudder-voice.vue
index 0af3a7779..9f2995be0 100644
--- a/plugin-designs/rudder-voice/rudder-voice.vue
+++ b/plugin-designs/rudder-voice/rudder-voice.vue
@@ -1129,7 +1129,16 @@ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 1
.icon-btn svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; }
.icon-btn[aria-expanded='true'], .icon-btn:hover { color: var(--accent); border-color: var(--accent); }
.compact .controls .mic { display: none; }
-.compact .compose { flex: 1 1 0; min-height: 120px; }
+/* ★ NO FLOOR ON A SHORT PANEL. min-height:120px kept the compose column from shrinking, so on a
+ 390x560 corner widget the draft was squeezed past the bottom and its own scrollbar never
+ appeared — the text was there and unreachable. My earlier fix put min-height:0 in a @media
+ block, which could never win: a media query adds NO specificity, so (0,1,0) lost to this
+ (0,2,0) rule, and variant="widget" is ALWAYS compact, so the widget was the one path the
+ media rules could not reach. Fixed at the specificity that governs it.
+ A floor is kept for the TALL case through max(), so a roomy panel still gives the draft a
+ usable size while a short one may shrink to nothing and let .compact .draft scroll. */
+.compact .compose { flex: 1 1 0; min-height: 0; }
+@media (min-height: 520px) { .compact .compose { min-height: 120px; } }
.compact.more .compose { flex: 0 0 auto; height: 200px; }
.compact .draft { flex: 1 1 0; min-height: 0; overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch;
resize: none; padding: 10px 12px; font-size: 16px; line-height: 1.5; }
From 1fae99e93c426b70e214a4c2ed92e29b8d1c605d Mon Sep 17 00:00:00 2001
From: Doxa Maps
Date: Tue, 29 Sep 2026 05:20:50 +0000
Subject: [PATCH 37/78] Drop the height guard: it would have left the one
measured failure broken
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The held panel follow-up restored the 120px floor above a viewport height of
520px. rega's measured failure — 390x560 with the engine-offline notice, the
draft's bottom 21px past the panel — sits ABOVE that threshold, so the guard
would have preserved exactly the case it was meant to fix.
Viewport height is the wrong variable. What consumes the space is the HEADER, and
a notice can make that 237px at any viewport; a height threshold cannot express
"a tall header ate the room". The draft already carries min-height:0 and
overflow-y:auto, so a floor on the compose column buys nothing a flex basis does
not, and costs the tall-header case.
Still HELD, not offered: rega could not reproduce either Driver symptom in
Chromium, so this waits on which device, browser and build he saw.
---
plugin-designs/rudder-voice/rudder-voice.vue | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/plugin-designs/rudder-voice/rudder-voice.vue b/plugin-designs/rudder-voice/rudder-voice.vue
index 9f2995be0..cb8bc113f 100644
--- a/plugin-designs/rudder-voice/rudder-voice.vue
+++ b/plugin-designs/rudder-voice/rudder-voice.vue
@@ -1135,10 +1135,13 @@ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 1
block, which could never win: a media query adds NO specificity, so (0,1,0) lost to this
(0,2,0) rule, and variant="widget" is ALWAYS compact, so the widget was the one path the
media rules could not reach. Fixed at the specificity that governs it.
- A floor is kept for the TALL case through max(), so a roomy panel still gives the draft a
- usable size while a short one may shrink to nothing and let .compact .draft scroll. */
+ NO HEIGHT GUARD. My first version restored the floor above a viewport height, and the measured
+ failure (390x560 WITH the engine-offline notice, draft bottom 21px past the panel) sits ABOVE
+ that threshold — so the guard would have left the one case it was meant to fix still broken.
+ Viewport height is the wrong variable: what eats the space is the HEADER, and a notice can make
+ that 237px at any viewport. The draft carries min-height:0 and overflow-y already, so a floor
+ here buys nothing a flex basis does not, and costs the case where the header is tall. */
.compact .compose { flex: 1 1 0; min-height: 0; }
-@media (min-height: 520px) { .compact .compose { min-height: 120px; } }
.compact.more .compose { flex: 0 0 auto; height: 200px; }
.compact .draft { flex: 1 1 0; min-height: 0; overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch;
resize: none; padding: 10px 12px; font-size: 16px; line-height: 1.5; }
From b00b4fbcc9b11af434e0f16ce184fd065b48ee8e Mon Sep 17 00:00:00 2001
From: Doxa Maps
Date: Tue, 29 Sep 2026 05:49:22 +0000
Subject: [PATCH 38/78] The gap sentence is one key, so a translator can move
its clauses
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
charts.vue's hero read as six fragments concatenated around three counts and a
bdi island: "of", "languages have", "— the languages of", "people." Arabic does
not put those clauses in that order, so translating the fragments would have
given Arabic words in English order — grammatically broken, and worse than
leaving it English, because it looks translated and nobody re-checks it.
Now one message with named placeholders through useSentence. The counts and the
quoted phrase go in as VALUES, so the bold and the dir="ltr" survive without a
translator ever reproducing markup, and keep() hands the placeholders back to t()
intact — without it the lookup blanks them and the sentence renders with no
values at all.
Proven through a real createI18n rather than in isolation, which is the lesson
from the keep() trap: en renders as it does today, and ar and ru both LEAD with
the object and put the counts after — the reordering that fragment-by-fragment
translation cannot express. zh reorders too.
The gate moves 144 -> 142 for this one sentence. That is the honest number: six
fragments collapse into one key, but the gate counts findings, not fragments, so
the count falls more slowly than the work proceeds. ~26 fragments remain.
---
library/i18n/locales/ar/gospel.json | 4 +++-
library/i18n/locales/de/gospel.json | 4 +++-
library/i18n/locales/en/gospel.json | 4 +++-
library/i18n/locales/es/gospel.json | 4 +++-
library/i18n/locales/fr/gospel.json | 4 +++-
library/i18n/locales/hi/gospel.json | 4 +++-
library/i18n/locales/it/gospel.json | 4 +++-
library/i18n/locales/pt/gospel.json | 4 +++-
library/i18n/locales/ro/gospel.json | 4 +++-
library/i18n/locales/ru/gospel.json | 4 +++-
library/i18n/locales/zh/gospel.json | 4 +++-
plugin-designs/gospel-resources/charts.vue | 28 +++++++++++++++++++---
12 files changed, 58 insertions(+), 14 deletions(-)
diff --git a/library/i18n/locales/ar/gospel.json b/library/i18n/locales/ar/gospel.json
index 36e5737f7..e1e103a8c 100644
--- a/library/i18n/locales/ar/gospel.json
+++ b/library/i18n/locales/ar/gospel.json
@@ -333,7 +333,9 @@
"c2Unit": "أشخاص في الشعوب التي هذه لغتها الأساسية — لا المتحدثين بها عمومًا",
"c3Title": "البلدان الأكثر لغاتٍ غير مخدومة",
"c3Sub": "البلدان الـ{n} الأكثر لغاتٍ لم يُعثر لها على شيء. طول العمود هو كل لغة موجودة في ذلك البلد.",
- "c3Unit": "لغات موجودة في البلد (اللغة العابرة للحدود تُحتسب في كل بلد)"
+ "c3Unit": "لغات موجودة في البلد (اللغة العابرة للحدود تُحتسب في كل بلد)",
+ "noneHero": "{noScripture} في {nNone} لغة من أصل {nLang} — وهي لغات {popNone} نسمة.",
+ "noneHeroFound": "لا يوجد كتاب مقدس ولا فيلم في المجموعات التي جرى فحصها"
},
"filter": {
"showingOnly": "يُعرض فقط: {what}.",
diff --git a/library/i18n/locales/de/gospel.json b/library/i18n/locales/de/gospel.json
index 9c2976ebd..0586d7dea 100644
--- a/library/i18n/locales/de/gospel.json
+++ b/library/i18n/locales/de/gospel.json
@@ -333,7 +333,9 @@
"c2Unit": "Menschen in den Volksgruppen, deren PRIMÄRE Sprache dies ist — nicht Sprecher der Sprache",
"c3Title": "Länder mit den meisten unversorgten Sprachen",
"c3Sub": "Die {n} Länder mit den meisten Sprachen, für die nichts gefunden wurde. Die Balkenlänge ist jede im Land vorhandene Sprache.",
- "c3Unit": "im Land vorhandene Sprachen (eine grenzüberschreitende Sprache zählt in jedem Land)"
+ "c3Unit": "im Land vorhandene Sprachen (eine grenzüberschreitende Sprache zählt in jedem Land)",
+ "noneHero": "{nNone} von {nLang} Sprachen haben {noScripture} — die Sprachen von {popNone} Menschen.",
+ "noneHeroFound": "keine Bibel und keinen Film in den geprüften Beständen"
},
"filter": {
"showingOnly": "Nur {what} werden angezeigt.",
diff --git a/library/i18n/locales/en/gospel.json b/library/i18n/locales/en/gospel.json
index c36a5f2be..4226db1c8 100644
--- a/library/i18n/locales/en/gospel.json
+++ b/library/i18n/locales/en/gospel.json
@@ -333,7 +333,9 @@
"c2Unit": "people in the people groups whose PRIMARY language this is — not speakers of the language",
"c3Title": "Countries with the most unserved languages",
"c3Sub": "The {n} countries with the most languages that have nothing found. Bar length is every language present in that country.",
- "c3Unit": "languages present in the country (a cross-border language counts in each)"
+ "c3Unit": "languages present in the country (a cross-border language counts in each)",
+ "noneHero": "{nNone} of {nLang} languages have {noScripture} — the languages of {popNone} people.",
+ "noneHeroFound": "no scripture and no film found in the pools checked"
},
"filter": {
"showingOnly": "Showing only {what}.",
diff --git a/library/i18n/locales/es/gospel.json b/library/i18n/locales/es/gospel.json
index 1fb4ba9af..6e3b57880 100644
--- a/library/i18n/locales/es/gospel.json
+++ b/library/i18n/locales/es/gospel.json
@@ -333,7 +333,9 @@
"c2Unit": "personas en las etnias cuya lengua PRINCIPAL es esta — no hablantes de la lengua",
"c3Title": "Países con más lenguas desatendidas",
"c3Sub": "Los {n} países con más lenguas sin nada encontrado. La longitud de la barra es cada lengua presente en ese país.",
- "c3Unit": "lenguas presentes en el país (una lengua transfronteriza cuenta en cada uno)"
+ "c3Unit": "lenguas presentes en el país (una lengua transfronteriza cuenta en cada uno)",
+ "noneHero": "{nNone} de {nLang} lenguas tienen {noScripture}: las lenguas de {popNone} personas.",
+ "noneHeroFound": "ni Escritura ni película en los repositorios consultados"
},
"filter": {
"showingOnly": "Solo se muestran {what}.",
diff --git a/library/i18n/locales/fr/gospel.json b/library/i18n/locales/fr/gospel.json
index 3f4f79f6b..981b3a81d 100644
--- a/library/i18n/locales/fr/gospel.json
+++ b/library/i18n/locales/fr/gospel.json
@@ -333,7 +333,9 @@
"c2Unit": "personnes dans les peuples dont c'est la langue PRINCIPALE — non les locuteurs de la langue",
"c3Title": "Pays comptant le plus de langues non desservies",
"c3Sub": "Les {n} pays comptant le plus de langues sans rien de trouvé. La longueur de la barre correspond à chaque langue présente dans ce pays.",
- "c3Unit": "langues présentes dans le pays (une langue transfrontalière compte dans chacun)"
+ "c3Unit": "langues présentes dans le pays (une langue transfrontalière compte dans chacun)",
+ "noneHero": "{nNone} langues sur {nLang} ont {noScripture} — les langues de {popNone} personnes.",
+ "noneHeroFound": "ni Écriture ni film dans les fonds consultés"
},
"filter": {
"showingOnly": "Affichage limité aux {what}.",
diff --git a/library/i18n/locales/hi/gospel.json b/library/i18n/locales/hi/gospel.json
index e454fb34a..8b552ef1d 100644
--- a/library/i18n/locales/hi/gospel.json
+++ b/library/i18n/locales/hi/gospel.json
@@ -333,7 +333,9 @@
"c2Unit": "उन जनसमूहों के लोग जिनकी मुख्य भाषा यही है — भाषा के सभी वक्ता नहीं",
"c3Title": "सबसे अधिक असेवित भाषाओं वाले देश",
"c3Sub": "{n} देश जिनमें सबसे अधिक भाषाओं के लिए कुछ नहीं मिला। बार की लंबाई उस देश में मौजूद हर भाषा है।",
- "c3Unit": "देश में मौजूद भाषाएँ (सीमा-पार भाषा हर देश में गिनी जाती है)"
+ "c3Unit": "देश में मौजूद भाषाएँ (सीमा-पार भाषा हर देश में गिनी जाती है)",
+ "noneHero": "{nLang} में से {nNone} भाषाओं में {noScripture} — ये {popNone} लोगों की भाषाएँ हैं।",
+ "noneHeroFound": "जाँचे गए स्रोतों में न पवित्रशास्त्र मिला, न फ़िल्म"
},
"filter": {
"showingOnly": "केवल {what} दिखाए जा रहे हैं।",
diff --git a/library/i18n/locales/it/gospel.json b/library/i18n/locales/it/gospel.json
index a918de5d4..485d64233 100644
--- a/library/i18n/locales/it/gospel.json
+++ b/library/i18n/locales/it/gospel.json
@@ -333,7 +333,9 @@
"c2Unit": "persone nei gruppi la cui lingua PRINCIPALE è questa — non i parlanti della lingua",
"c3Title": "Paesi con più lingue non servite",
"c3Sub": "I {n} paesi con più lingue per cui non è stato trovato nulla. La lunghezza della barra è ogni lingua presente in quel paese.",
- "c3Unit": "lingue presenti nel paese (una lingua transfrontaliera conta in ciascuno)"
+ "c3Unit": "lingue presenti nel paese (una lingua transfrontaliera conta in ciascuno)",
+ "noneHero": "{nNone} lingue su {nLang} hanno {noScripture} — le lingue di {popNone} persone.",
+ "noneHeroFound": "nessuna Scrittura e nessun film nei repertori consultati"
},
"filter": {
"showingOnly": "Sono mostrati solo {what}.",
diff --git a/library/i18n/locales/pt/gospel.json b/library/i18n/locales/pt/gospel.json
index 9416a6f47..21a1eca0a 100644
--- a/library/i18n/locales/pt/gospel.json
+++ b/library/i18n/locales/pt/gospel.json
@@ -333,7 +333,9 @@
"c2Unit": "pessoas nas etnias cuja língua PRINCIPAL é esta — não falantes da língua",
"c3Title": "Países com mais línguas desatendidas",
"c3Sub": "Os {n} países com mais línguas sem nada encontrado. O comprimento da barra é cada língua presente nesse país.",
- "c3Unit": "línguas presentes no país (uma língua transfronteiriça conta em cada um)"
+ "c3Unit": "línguas presentes no país (uma língua transfronteiriça conta em cada um)",
+ "noneHero": "{nNone} de {nLang} línguas têm {noScripture} — as línguas de {popNone} pessoas.",
+ "noneHeroFound": "nenhuma Escritura e nenhum filme nos acervos consultados"
},
"filter": {
"showingOnly": "Mostrando apenas {what}.",
diff --git a/library/i18n/locales/ro/gospel.json b/library/i18n/locales/ro/gospel.json
index 51ccd9aeb..68c663835 100644
--- a/library/i18n/locales/ro/gospel.json
+++ b/library/i18n/locales/ro/gospel.json
@@ -333,7 +333,9 @@
"c2Unit": "persoane din grupurile a căror limbă PRINCIPALĂ este aceasta — nu vorbitorii limbii",
"c3Title": "Țări cu cele mai multe limbi neservite",
"c3Sub": "Cele {n} țări cu cele mai multe limbi pentru care nu s-a găsit nimic. Lungimea barei este fiecare limbă prezentă în acea țară.",
- "c3Unit": "limbi prezente în țară (o limbă transfrontalieră se numără în fiecare)"
+ "c3Unit": "limbi prezente în țară (o limbă transfrontalieră se numără în fiecare)",
+ "noneHero": "{nNone} din {nLang} limbi au {noScripture} — limbile a {popNone} de oameni.",
+ "noneHeroFound": "nicio Scriptură și niciun film în colecțiile verificate"
},
"filter": {
"showingOnly": "Se afișează doar {what}.",
diff --git a/library/i18n/locales/ru/gospel.json b/library/i18n/locales/ru/gospel.json
index b79562c69..e53c111ae 100644
--- a/library/i18n/locales/ru/gospel.json
+++ b/library/i18n/locales/ru/gospel.json
@@ -333,7 +333,9 @@
"c2Unit": "люди в народах, для которых это ОСНОВНОЙ язык, — не все говорящие на нём",
"c3Title": "Страны с наибольшим числом неохваченных языков",
"c3Sub": "{n} стран с наибольшим числом языков, для которых ничего не найдено. Длина столбца — каждый язык, присутствующий в этой стране.",
- "c3Unit": "языки, присутствующие в стране (трансграничный язык считается в каждой)"
+ "c3Unit": "языки, присутствующие в стране (трансграничный язык считается в каждой)",
+ "noneHero": "{noScripture} — у {nNone} из {nLang} языков, языков {popNone} человек.",
+ "noneHeroFound": "ни Писания, ни фильма в проверенных источниках"
},
"filter": {
"showingOnly": "Показаны только {what}.",
diff --git a/library/i18n/locales/zh/gospel.json b/library/i18n/locales/zh/gospel.json
index 30da46ddc..3f2ba7abc 100644
--- a/library/i18n/locales/zh/gospel.json
+++ b/library/i18n/locales/zh/gospel.json
@@ -333,7 +333,9 @@
"c2Unit": "以该语言为主要语言的族群人口 — 不是该语言的全部使用者",
"c3Title": "未得服事语言最多的国家",
"c3Sub": "一无所获语言最多的 {n} 个国家。条形长度为该国存在的全部语言。",
- "c3Unit": "该国存在的语言(跨境语言在每个国家各计一次)"
+ "c3Unit": "该国存在的语言(跨境语言在每个国家各计一次)",
+ "noneHero": "{nLang} 种语言中有 {nNone} 种{noScripture}——这是 {popNone} 人的语言。",
+ "noneHeroFound": "在已查询的资源库中既无圣经也无影片"
},
"filter": {
"showingOnly": "仅显示{what}。",
diff --git a/plugin-designs/gospel-resources/charts.vue b/plugin-designs/gospel-resources/charts.vue
index 0b83e610b..2d13cf213 100644
--- a/plugin-designs/gospel-resources/charts.vue
+++ b/plugin-designs/gospel-resources/charts.vue
@@ -46,6 +46,8 @@
* inlining it into the IIFE would block first paint for every profile in the bundle).
*/
import { useOptionalT } from '@map/composables/useOptionalT.js'
+import { sentenceParts, strong, ltr, keep } from '@map/composables/useSentence.js'
+import { h } from 'vue'
import { computed, inject, ref, watch } from 'vue'
import BarChart from '@map/components/charts/BarChart.vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
@@ -67,6 +69,22 @@ import { useShadowStyles } from '@map/composables/useShadowStyles.js'
// with English as the last resort. tStatic resolves 'gospel.' against the same catalogue.
const tr = useOptionalT('gospel')
+// ★ THE HERO SENTENCE, ONE KEY. Each COUNT arrives already formatted and already pluralised from its
+// own key: vue-i18n resolves one plural form per key, so a sentence carrying three counts cannot
+// pluralise them from its own message. keep() hands the placeholders back to t() intact — without it
+// the lookup BLANKS them and the sentence renders with no values at all, in every locale at once.
+const heroSentence = computed(() => {
+ const msg = tr('gospel.charts.noneHero', keep('nNone', 'nLang', 'noScripture', 'popNone'))
+ return h('span', sentenceParts(msg, {
+ nNone: strong(fmt(nNone.value)),
+ nLang: fmt(nLang.value),
+ // The phrase is quoted from English sources and must not reverse inside an Arabic sentence, so it
+ // keeps its own dir. Passed in as a VALUE: a translator never has to reproduce the markup.
+ noScripture: ltr(tr('gospel.charts.noneHeroFound', 'no scripture and no film found in the pools checked'), true),
+ popNone: strong(fmt(popNone.value)),
+ }))
+})
+
const profileConfig = inject('profileConfig', ref(null))
const isDark = computed(() => profileConfig.value?.isDark === true)
@@ -465,9 +483,13 @@ useShadowStyles(
group that speaks it — so the language is the unit of the gap.
-
-
{{ fmt(nNone) }} of {{ fmt(nLang) }} languages have
no scripture and no film
- found in the pools checked — the languages of
{{ fmt(popNone) }} people.
+
+
Counted BY LANGUAGE, so {{ fmt(noCodeGroups) }} people group{{ noCodeGroups === 1 ? '' : 's' }}
with no ISO 639-3 code in the source — {{ fmt(noCodePeople) }} people — cannot appear here.
From 8295277b27bd3329616b07fb9ac1fd092cb5c5bc Mon Sep 17 00:00:00 2001
From: Doxa Maps
Date: Tue, 29 Sep 2026 06:04:55 +0000
Subject: [PATCH 39/78] The gap list's lines are sentences, so each is one key
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
resources-map's gap list read as fragments around its values: "{count} people ·
{iso3}", "way in: {via}", "has {what} — no text found", and a "Showing only …"
whose bold phrase came from a four-branch conditional. Each was a sentence broken
around the data it carries, which is the shape that cannot be reordered.
Nine keys, and one of them does three jobs: the population line appears three
times in near-identical sibling spans, so it is ONE key used three times rather
than three keys — the duplication was in the template, not in the language.
The four filter descriptions are their own keys and slot into the sentence, so a
translator can move "Showing only" around the phrase rather than being handed two
halves to join in English order.
144 -> 124 findings for eleven keys. The ratio is better than the proving case
because the duplicates collapsed; it will worsen again on the one-off sentences.
---
library/i18n/locales/ar/gospel.json | 13 +++++++++++++
library/i18n/locales/de/gospel.json | 13 +++++++++++++
library/i18n/locales/en/gospel.json | 13 +++++++++++++
library/i18n/locales/es/gospel.json | 13 +++++++++++++
library/i18n/locales/fr/gospel.json | 13 +++++++++++++
library/i18n/locales/hi/gospel.json | 13 +++++++++++++
library/i18n/locales/it/gospel.json | 13 +++++++++++++
library/i18n/locales/pt/gospel.json | 13 +++++++++++++
library/i18n/locales/ro/gospel.json | 13 +++++++++++++
library/i18n/locales/ru/gospel.json | 13 +++++++++++++
library/i18n/locales/zh/gospel.json | 13 +++++++++++++
.../gospel-resources/resources-map.vue | 18 +++++++++---------
12 files changed, 152 insertions(+), 9 deletions(-)
diff --git a/library/i18n/locales/ar/gospel.json b/library/i18n/locales/ar/gospel.json
index e1e103a8c..2b36e6469 100644
--- a/library/i18n/locales/ar/gospel.json
+++ b/library/i18n/locales/ar/gospel.json
@@ -369,5 +369,18 @@
},
"deaf": {
"sunTaughtIn": "تُدرِّس {organisation} منهج SUN بالفعل في {countries}."
+ },
+ "gap": {
+ "popLine": "{pop} نسمة · {iso3}",
+ "wayIn": "السبيل: {via}",
+ "hasNoText": "لديها {what} — لا يوجد نص",
+ "showingOnly": "عرض {what} فقط.",
+ "seeLargest": "عرض أكبر {n} ←",
+ "kind": {
+ "none": "مجموعات لم يُعثر لها على شيء",
+ "some": "مجموعات لديها بعض الموارد",
+ "hnr": "مجموعات تستطيع سماع الإنجيل لكن بلا نص مكتوب في المجموعات التي جرى فحصها",
+ "all": "مجموعات لديها كتاب مقدس وفيلم"
+ }
}
}
diff --git a/library/i18n/locales/de/gospel.json b/library/i18n/locales/de/gospel.json
index 0586d7dea..d3920d4d1 100644
--- a/library/i18n/locales/de/gospel.json
+++ b/library/i18n/locales/de/gospel.json
@@ -369,5 +369,18 @@
},
"deaf": {
"sunTaughtIn": "SUN wird in {countries} bereits von {organisation} unterrichtet."
+ },
+ "gap": {
+ "popLine": "{pop} Menschen · {iso3}",
+ "wayIn": "Zugang: {via}",
+ "hasNoText": "hat {what} — kein Text gefunden",
+ "showingOnly": "Es werden nur {what} angezeigt.",
+ "seeLargest": "Die {n} größten ansehen →",
+ "kind": {
+ "none": "Gruppen ohne jeden Fund",
+ "some": "Gruppen mit einzelnen Ressourcen",
+ "hnr": "Gruppen, die das Evangelium hören können, aber keinen Bibeltext in den geprüften Beständen haben",
+ "all": "Gruppen mit Bibel und Film"
+ }
}
}
diff --git a/library/i18n/locales/en/gospel.json b/library/i18n/locales/en/gospel.json
index 4226db1c8..1e924da8f 100644
--- a/library/i18n/locales/en/gospel.json
+++ b/library/i18n/locales/en/gospel.json
@@ -369,5 +369,18 @@
},
"deaf": {
"sunTaughtIn": "SUN is already being taught in {countries} by {organisation}."
+ },
+ "gap": {
+ "popLine": "{pop} people · {iso3}",
+ "wayIn": "way in: {via}",
+ "hasNoText": "has {what} — no text found",
+ "showingOnly": "Showing only {what}.",
+ "seeLargest": "See the {n} largest →",
+ "kind": {
+ "none": "groups with nothing found",
+ "some": "groups with some resource",
+ "hnr": "groups that can hear the gospel but have no text Scripture in the pools checked",
+ "all": "groups with Scripture and film"
+ }
}
}
diff --git a/library/i18n/locales/es/gospel.json b/library/i18n/locales/es/gospel.json
index 6e3b57880..838231c86 100644
--- a/library/i18n/locales/es/gospel.json
+++ b/library/i18n/locales/es/gospel.json
@@ -369,5 +369,18 @@
},
"deaf": {
"sunTaughtIn": "{organisation} ya enseña SUN en {countries}."
+ },
+ "gap": {
+ "popLine": "{pop} personas · {iso3}",
+ "wayIn": "vía de entrada: {via}",
+ "hasNoText": "tiene {what}: no se encontró texto",
+ "showingOnly": "Mostrando solo {what}.",
+ "seeLargest": "Ver los {n} mayores →",
+ "kind": {
+ "none": "pueblos sin nada encontrado",
+ "some": "pueblos con algún recurso",
+ "hnr": "pueblos que pueden oír el evangelio pero no tienen Escritura escrita en los repositorios consultados",
+ "all": "pueblos con Escritura y película"
+ }
}
}
diff --git a/library/i18n/locales/fr/gospel.json b/library/i18n/locales/fr/gospel.json
index 981b3a81d..dad16f069 100644
--- a/library/i18n/locales/fr/gospel.json
+++ b/library/i18n/locales/fr/gospel.json
@@ -369,5 +369,18 @@
},
"deaf": {
"sunTaughtIn": "SUN est déjà enseigné par {organisation} dans : {countries}."
+ },
+ "gap": {
+ "popLine": "{pop} personnes · {iso3}",
+ "wayIn": "voie d’entrée : {via}",
+ "hasNoText": "a {what} — aucun texte trouvé",
+ "showingOnly": "Affichage de {what} uniquement.",
+ "seeLargest": "Voir les {n} plus grands →",
+ "kind": {
+ "none": "peuples sans aucune ressource trouvée",
+ "some": "peuples avec quelques ressources",
+ "hnr": "peuples qui peuvent entendre l’Évangile mais n’ont pas d’Écriture écrite dans les fonds consultés",
+ "all": "peuples avec Écriture et film"
+ }
}
}
diff --git a/library/i18n/locales/hi/gospel.json b/library/i18n/locales/hi/gospel.json
index 8b552ef1d..85763bd96 100644
--- a/library/i18n/locales/hi/gospel.json
+++ b/library/i18n/locales/hi/gospel.json
@@ -369,5 +369,18 @@
},
"deaf": {
"sunTaughtIn": "{organisation} पहले से ही {countries} में SUN सिखा रहा है।"
+ },
+ "gap": {
+ "popLine": "{pop} लोग · {iso3}",
+ "wayIn": "पहुँच: {via}",
+ "hasNoText": "{what} है — कोई पाठ नहीं मिला",
+ "showingOnly": "केवल {what} दिखाए जा रहे हैं।",
+ "seeLargest": "सबसे बड़े {n} देखें →",
+ "kind": {
+ "none": "जिन समूहों के लिए कुछ नहीं मिला",
+ "some": "कुछ संसाधन वाले समूह",
+ "hnr": "वे समूह जो सुसमाचार सुन सकते हैं पर जाँचे गए स्रोतों में लिखित पवित्रशास्त्र नहीं है",
+ "all": "पवित्रशास्त्र और फ़िल्म वाले समूह"
+ }
}
}
diff --git a/library/i18n/locales/it/gospel.json b/library/i18n/locales/it/gospel.json
index 485d64233..5769e665d 100644
--- a/library/i18n/locales/it/gospel.json
+++ b/library/i18n/locales/it/gospel.json
@@ -369,5 +369,18 @@
},
"deaf": {
"sunTaughtIn": "SUN è già insegnato da {organisation} in: {countries}."
+ },
+ "gap": {
+ "popLine": "{pop} persone · {iso3}",
+ "wayIn": "via d’accesso: {via}",
+ "hasNoText": "ha {what} — nessun testo trovato",
+ "showingOnly": "Mostrati solo {what}.",
+ "seeLargest": "Vedi i {n} maggiori →",
+ "kind": {
+ "none": "popoli senza nulla di trovato",
+ "some": "popoli con qualche risorsa",
+ "hnr": "popoli che possono udire il vangelo ma non hanno Scrittura scritta nei repertori consultati",
+ "all": "popoli con Scrittura e film"
+ }
}
}
diff --git a/library/i18n/locales/pt/gospel.json b/library/i18n/locales/pt/gospel.json
index 21a1eca0a..3de057b2e 100644
--- a/library/i18n/locales/pt/gospel.json
+++ b/library/i18n/locales/pt/gospel.json
@@ -369,5 +369,18 @@
},
"deaf": {
"sunTaughtIn": "SUN já é ensinado por {organisation} em: {countries}."
+ },
+ "gap": {
+ "popLine": "{pop} pessoas · {iso3}",
+ "wayIn": "via de entrada: {via}",
+ "hasNoText": "tem {what} — nenhum texto encontrado",
+ "showingOnly": "Mostrando apenas {what}.",
+ "seeLargest": "Ver os {n} maiores →",
+ "kind": {
+ "none": "povos sem nada encontrado",
+ "some": "povos com algum recurso",
+ "hnr": "povos que podem ouvir o evangelho mas não têm Escritura escrita nos acervos consultados",
+ "all": "povos com Escritura e filme"
+ }
}
}
diff --git a/library/i18n/locales/ro/gospel.json b/library/i18n/locales/ro/gospel.json
index 68c663835..ceb24112a 100644
--- a/library/i18n/locales/ro/gospel.json
+++ b/library/i18n/locales/ro/gospel.json
@@ -369,5 +369,18 @@
},
"deaf": {
"sunTaughtIn": "SUN este deja predat în {countries} de {organisation}."
+ },
+ "gap": {
+ "popLine": "{pop} persoane · {iso3}",
+ "wayIn": "cale de acces: {via}",
+ "hasNoText": "are {what} — niciun text găsit",
+ "showingOnly": "Se afișează doar {what}.",
+ "seeLargest": "Vezi cele mai mari {n} →",
+ "kind": {
+ "none": "grupuri fără nimic găsit",
+ "some": "grupuri cu unele resurse",
+ "hnr": "grupuri care pot auzi Evanghelia dar nu au Scriptură scrisă în colecțiile verificate",
+ "all": "grupuri cu Scriptură și film"
+ }
}
}
diff --git a/library/i18n/locales/ru/gospel.json b/library/i18n/locales/ru/gospel.json
index e53c111ae..b5795dee9 100644
--- a/library/i18n/locales/ru/gospel.json
+++ b/library/i18n/locales/ru/gospel.json
@@ -369,5 +369,18 @@
},
"deaf": {
"sunTaughtIn": "{organisation} уже преподаёт SUN: {countries}."
+ },
+ "gap": {
+ "popLine": "{pop} человек · {iso3}",
+ "wayIn": "путь: {via}",
+ "hasNoText": "есть {what} — текст не найден",
+ "showingOnly": "Показаны только {what}.",
+ "seeLargest": "Показать {n} крупнейших →",
+ "kind": {
+ "none": "народы, для которых ничего не найдено",
+ "some": "народы, у которых есть какие-то ресурсы",
+ "hnr": "народы, которые могут услышать Евангелие, но не имеют письменного Писания в проверенных источниках",
+ "all": "народы с Писанием и фильмом"
+ }
}
}
diff --git a/library/i18n/locales/zh/gospel.json b/library/i18n/locales/zh/gospel.json
index 3f2ba7abc..1370ca73f 100644
--- a/library/i18n/locales/zh/gospel.json
+++ b/library/i18n/locales/zh/gospel.json
@@ -369,5 +369,18 @@
},
"deaf": {
"sunTaughtIn": "{organisation}已在{countries}教授 SUN。"
+ },
+ "gap": {
+ "popLine": "{pop} 人 · {iso3}",
+ "wayIn": "切入点:{via}",
+ "hasNoText": "有 {what} — 未找到文本",
+ "showingOnly": "仅显示{what}。",
+ "seeLargest": "查看最大的 {n} 个 →",
+ "kind": {
+ "none": "未找到任何资源的族群",
+ "some": "有部分资源的族群",
+ "hnr": "能听到福音但在已查询资源库中没有文字圣经的族群",
+ "all": "有圣经和影片的族群"
+ }
}
}
diff --git a/plugin-designs/gospel-resources/resources-map.vue b/plugin-designs/gospel-resources/resources-map.vue
index 8df00d21d..3c8d3b157 100644
--- a/plugin-designs/gospel-resources/resources-map.vue
+++ b/plugin-designs/gospel-resources/resources-map.vue
@@ -2379,10 +2379,10 @@ useShadowStyles(
- Showing only
{{ pinFilter === 'none' ? 'groups with nothing found'
- : pinFilter === 'some' ? 'groups with some resource'
- : pinFilter === 'hnr' ? 'groups that can hear the gospel but have no text Scripture in the pools checked'
- : 'groups with Scripture and film' }} .
+
+ {{ t('gospel.gap.showingOnly', { what: t('gospel.gap.kind.' + (pinFilter === 'none' ? 'none' : pinFilter === 'some' ? 'some' : pinFilter === 'hnr' ? 'hnr' : 'all')) }) }}
{{ t('gospel.filter.showAll', { count: fmt(pinTally.total) }) }}
See the 40 largest →
@@ -2603,8 +2603,8 @@ useShadowStyles(
{{ g.name }}
- {{ fmt(g.pop) }} people · {{ g.iso3 }}{{ g.code || 'no code' }}
- has {{ g.has }} — no text found
+ {{ t('gospel.gap.popLine', { pop: fmt(g.pop), iso3: g.iso3 }) }}{{ g.code || 'no code' }}
+ {{ t('gospel.gap.hasNoText', { what: g.has }) }}
@@ -2612,7 +2612,7 @@ useShadowStyles(
{{ g.name }}
- {{ fmt(g.pop) }} people · {{ g.iso3 }}{{ g.code || 'no code' }}
+ {{ t('gospel.gap.popLine', { pop: fmt(g.pop), iso3: g.iso3 }) }}{{ g.code || 'no code' }}
nothing found, and nobody praying — open their prayer page ↗
@@ -2637,8 +2637,8 @@ useShadowStyles(
{{ g.name }}
- {{ fmt(g.pop) }} people · {{ g.iso3 }}{{ g.code || 'no code' }}
- way in: {{ g.wayIn }}
+ {{ t('gospel.gap.popLine', { pop: fmt(g.pop), iso3: g.iso3 }) }}{{ g.code || 'no code' }}
+ {{ t('gospel.gap.wayIn', { via: g.wayIn }) }}
related: {{ g.rel.l }} has resources
From 2dc57f049eb2dad456c73cfc9287582809000bc5 Mon Sep 17 00:00:00 2001
From: Doxa Maps
Date: Tue, 29 Sep 2026 06:29:25 +0000
Subject: [PATCH 40/78] The resource sheet's lines are sentences carrying their
counts
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Twelve more keys across resources-map: the two resource chips ("Scripture,
{medium} · {source}" and "Bible video · {source}", where the type word is
language and the source is data), the sheet headings that carry a count, the
country line, the pray count, and the loading notice.
The separators stay out of the catalogue. A "·" between a type and a source is
structure, not prose, and a translator asked to reproduce it would eventually get
it wrong in a language that does not use it — so the punctuation lives in the
message and only the words vary.
124 -> 115 findings. The ratio is back to roughly one finding per key now the
duplicated spans are gone, which is what I said would happen once the one-off
sentences began.
---
library/i18n/locales/ar/gospel.json | 16 +++++++++++++++-
library/i18n/locales/de/gospel.json | 16 +++++++++++++++-
library/i18n/locales/en/gospel.json | 16 +++++++++++++++-
library/i18n/locales/es/gospel.json | 16 +++++++++++++++-
library/i18n/locales/fr/gospel.json | 16 +++++++++++++++-
library/i18n/locales/hi/gospel.json | 16 +++++++++++++++-
library/i18n/locales/it/gospel.json | 16 +++++++++++++++-
library/i18n/locales/pt/gospel.json | 16 +++++++++++++++-
library/i18n/locales/ro/gospel.json | 16 +++++++++++++++-
library/i18n/locales/ru/gospel.json | 16 +++++++++++++++-
library/i18n/locales/zh/gospel.json | 16 +++++++++++++++-
.../gospel-resources/resources-map.vue | 18 +++++++++---------
12 files changed, 174 insertions(+), 20 deletions(-)
diff --git a/library/i18n/locales/ar/gospel.json b/library/i18n/locales/ar/gospel.json
index 2b36e6469..e8aad7a51 100644
--- a/library/i18n/locales/ar/gospel.json
+++ b/library/i18n/locales/ar/gospel.json
@@ -77,7 +77,12 @@
"everyResource": "كل الموارد بـ",
"alsoSpeak": "يتحدثون أيضًا",
"share": "مشاركة",
- "linkCopied": "تم نسخ الرابط"
+ "linkCopied": "تم نسخ الرابط",
+ "seeLargest": "عرض أكبر {n} ←",
+ "alreadyPraying": "{n} يصلّون بالفعل",
+ "everyIn": "كل مورد مُستخدم في {country}",
+ "found": "موارد إنجيلية موجودة ({n})",
+ "none": "لا توجد موارد ({n})"
},
"res": {
"scripture": "الكتاب المقدس",
@@ -382,5 +387,14 @@
"hnr": "مجموعات تستطيع سماع الإنجيل لكن بلا نص مكتوب في المجموعات التي جرى فحصها",
"all": "مجموعات لديها كتاب مقدس وفيلم"
}
+ },
+ "chip": {
+ "scriptureAudio": "الكتاب المقدس، {medium} · {source}",
+ "bibleVideoOf": "فيديو كتابي · {source}",
+ "loading": "جارٍ تحميل الموارد…",
+ "peopleWord": "نسمة",
+ "alreadyPraying": "يصلّون بالفعل",
+ "exhausted": "استُنفد",
+ "together": "مع"
}
}
diff --git a/library/i18n/locales/de/gospel.json b/library/i18n/locales/de/gospel.json
index d3920d4d1..94a742f57 100644
--- a/library/i18n/locales/de/gospel.json
+++ b/library/i18n/locales/de/gospel.json
@@ -77,7 +77,12 @@
"everyResource": "Alle Ressourcen auf",
"alsoSpeak": "Sprechen ebenfalls",
"share": "Teilen",
- "linkCopied": "Link kopiert"
+ "linkCopied": "Link kopiert",
+ "seeLargest": "Die {n} größten ansehen →",
+ "alreadyPraying": "{n} beten bereits",
+ "everyIn": "Jede in {country} genutzte Ressource",
+ "found": "Gefundene Evangeliums-Ressourcen ({n})",
+ "none": "Keine Ressource gefunden ({n})"
},
"res": {
"scripture": "Bibeltext",
@@ -382,5 +387,14 @@
"hnr": "Gruppen, die das Evangelium hören können, aber keinen Bibeltext in den geprüften Beständen haben",
"all": "Gruppen mit Bibel und Film"
}
+ },
+ "chip": {
+ "scriptureAudio": "Bibel, {medium} · {source}",
+ "bibleVideoOf": "Bibelvideo · {source}",
+ "loading": "Ressourcen werden geladen…",
+ "peopleWord": "Menschen",
+ "alreadyPraying": "beten bereits",
+ "exhausted": "erschöpft",
+ "together": "zusammen mit"
}
}
diff --git a/library/i18n/locales/en/gospel.json b/library/i18n/locales/en/gospel.json
index 1e924da8f..6222cc735 100644
--- a/library/i18n/locales/en/gospel.json
+++ b/library/i18n/locales/en/gospel.json
@@ -77,7 +77,12 @@
"everyResource": "Every resource in",
"alsoSpeak": "Also speak",
"share": "Share",
- "linkCopied": "Link copied"
+ "linkCopied": "Link copied",
+ "seeLargest": "See the {n} largest →",
+ "alreadyPraying": "{n} already praying",
+ "everyIn": "Every resource used in {country}",
+ "found": "Gospel resources found ({n})",
+ "none": "No resource found ({n})"
},
"res": {
"scripture": "Scripture",
@@ -382,5 +387,14 @@
"hnr": "groups that can hear the gospel but have no text Scripture in the pools checked",
"all": "groups with Scripture and film"
}
+ },
+ "chip": {
+ "scriptureAudio": "Scripture, {medium} · {source}",
+ "bibleVideoOf": "Bible video · {source}",
+ "loading": "Loading resources…",
+ "peopleWord": "people",
+ "alreadyPraying": "already praying",
+ "exhausted": "exhausted",
+ "together": "together with"
}
}
diff --git a/library/i18n/locales/es/gospel.json b/library/i18n/locales/es/gospel.json
index 838231c86..8696748be 100644
--- a/library/i18n/locales/es/gospel.json
+++ b/library/i18n/locales/es/gospel.json
@@ -77,7 +77,12 @@
"everyResource": "Todos los recursos en",
"alsoSpeak": "También hablan",
"share": "Compartir",
- "linkCopied": "Enlace copiado"
+ "linkCopied": "Enlace copiado",
+ "seeLargest": "Ver los {n} mayores →",
+ "alreadyPraying": "{n} ya orando",
+ "everyIn": "Todos los recursos usados en {country}",
+ "found": "Recursos del evangelio encontrados ({n})",
+ "none": "Ningún recurso encontrado ({n})"
},
"res": {
"scripture": "Escrituras",
@@ -382,5 +387,14 @@
"hnr": "pueblos que pueden oír el evangelio pero no tienen Escritura escrita en los repositorios consultados",
"all": "pueblos con Escritura y película"
}
+ },
+ "chip": {
+ "scriptureAudio": "Escritura, {medium} · {source}",
+ "bibleVideoOf": "Video bíblico · {source}",
+ "loading": "Cargando recursos…",
+ "peopleWord": "personas",
+ "alreadyPraying": "ya orando",
+ "exhausted": "agotado",
+ "together": "junto con"
}
}
diff --git a/library/i18n/locales/fr/gospel.json b/library/i18n/locales/fr/gospel.json
index dad16f069..699423b53 100644
--- a/library/i18n/locales/fr/gospel.json
+++ b/library/i18n/locales/fr/gospel.json
@@ -77,7 +77,12 @@
"everyResource": "Toutes les ressources en",
"alsoSpeak": "Parlent aussi",
"share": "Partager",
- "linkCopied": "Lien copié"
+ "linkCopied": "Lien copié",
+ "seeLargest": "Voir les {n} plus grands →",
+ "alreadyPraying": "{n} prient déjà",
+ "everyIn": "Toutes les ressources utilisées en {country}",
+ "found": "Ressources de l’Évangile trouvées ({n})",
+ "none": "Aucune ressource trouvée ({n})"
},
"res": {
"scripture": "Écritures",
@@ -382,5 +387,14 @@
"hnr": "peuples qui peuvent entendre l’Évangile mais n’ont pas d’Écriture écrite dans les fonds consultés",
"all": "peuples avec Écriture et film"
}
+ },
+ "chip": {
+ "scriptureAudio": "Écriture, {medium} · {source}",
+ "bibleVideoOf": "Vidéo biblique · {source}",
+ "loading": "Chargement des ressources…",
+ "peopleWord": "personnes",
+ "alreadyPraying": "prient déjà",
+ "exhausted": "épuisé",
+ "together": "avec"
}
}
diff --git a/library/i18n/locales/hi/gospel.json b/library/i18n/locales/hi/gospel.json
index 85763bd96..9d63aca31 100644
--- a/library/i18n/locales/hi/gospel.json
+++ b/library/i18n/locales/hi/gospel.json
@@ -77,7 +77,12 @@
"everyResource": "में सभी संसाधन",
"alsoSpeak": "यह भी बोलते हैं",
"share": "साझा करें",
- "linkCopied": "लिंक कॉपी हो गया"
+ "linkCopied": "लिंक कॉपी हो गया",
+ "seeLargest": "सबसे बड़े {n} देखें →",
+ "alreadyPraying": "{n} पहले से प्रार्थना कर रहे",
+ "everyIn": "{country} में उपयोग किया गया हर संसाधन",
+ "found": "मिले सुसमाचार संसाधन ({n})",
+ "none": "कोई संसाधन नहीं मिला ({n})"
},
"res": {
"scripture": "पवित्र शास्त्र",
@@ -382,5 +387,14 @@
"hnr": "वे समूह जो सुसमाचार सुन सकते हैं पर जाँचे गए स्रोतों में लिखित पवित्रशास्त्र नहीं है",
"all": "पवित्रशास्त्र और फ़िल्म वाले समूह"
}
+ },
+ "chip": {
+ "scriptureAudio": "पवित्रशास्त्र, {medium} · {source}",
+ "bibleVideoOf": "बाइबल वीडियो · {source}",
+ "loading": "संसाधन लोड हो रहे हैं…",
+ "peopleWord": "लोग",
+ "alreadyPraying": "पहले से प्रार्थना कर रहे",
+ "exhausted": "समाप्त",
+ "together": "के साथ"
}
}
diff --git a/library/i18n/locales/it/gospel.json b/library/i18n/locales/it/gospel.json
index 5769e665d..081166e86 100644
--- a/library/i18n/locales/it/gospel.json
+++ b/library/i18n/locales/it/gospel.json
@@ -77,7 +77,12 @@
"everyResource": "Tutte le risorse in",
"alsoSpeak": "Parlano anche",
"share": "Condividi",
- "linkCopied": "Link copiato"
+ "linkCopied": "Link copiato",
+ "seeLargest": "Vedi i {n} maggiori →",
+ "alreadyPraying": "{n} già in preghiera",
+ "everyIn": "Ogni risorsa usata in {country}",
+ "found": "Risorse del vangelo trovate ({n})",
+ "none": "Nessuna risorsa trovata ({n})"
},
"res": {
"scripture": "Scritture",
@@ -382,5 +387,14 @@
"hnr": "popoli che possono udire il vangelo ma non hanno Scrittura scritta nei repertori consultati",
"all": "popoli con Scrittura e film"
}
+ },
+ "chip": {
+ "scriptureAudio": "Scrittura, {medium} · {source}",
+ "bibleVideoOf": "Video biblico · {source}",
+ "loading": "Caricamento risorse…",
+ "peopleWord": "persone",
+ "alreadyPraying": "già in preghiera",
+ "exhausted": "esaurito",
+ "together": "insieme a"
}
}
diff --git a/library/i18n/locales/pt/gospel.json b/library/i18n/locales/pt/gospel.json
index 3de057b2e..572140341 100644
--- a/library/i18n/locales/pt/gospel.json
+++ b/library/i18n/locales/pt/gospel.json
@@ -77,7 +77,12 @@
"everyResource": "Todos os recursos em",
"alsoSpeak": "Também falam",
"share": "Partilhar",
- "linkCopied": "Link copiado"
+ "linkCopied": "Link copiado",
+ "seeLargest": "Ver os {n} maiores →",
+ "alreadyPraying": "{n} já orando",
+ "everyIn": "Todos os recursos usados em {country}",
+ "found": "Recursos do evangelho encontrados ({n})",
+ "none": "Nenhum recurso encontrado ({n})"
},
"res": {
"scripture": "Escrituras",
@@ -382,5 +387,14 @@
"hnr": "povos que podem ouvir o evangelho mas não têm Escritura escrita nos acervos consultados",
"all": "povos com Escritura e filme"
}
+ },
+ "chip": {
+ "scriptureAudio": "Escritura, {medium} · {source}",
+ "bibleVideoOf": "Vídeo bíblico · {source}",
+ "loading": "Carregando recursos…",
+ "peopleWord": "pessoas",
+ "alreadyPraying": "já orando",
+ "exhausted": "esgotado",
+ "together": "junto com"
}
}
diff --git a/library/i18n/locales/ro/gospel.json b/library/i18n/locales/ro/gospel.json
index ceb24112a..40ca870a7 100644
--- a/library/i18n/locales/ro/gospel.json
+++ b/library/i18n/locales/ro/gospel.json
@@ -77,7 +77,12 @@
"everyResource": "Toate resursele în",
"alsoSpeak": "Vorbesc și",
"share": "Distribuie",
- "linkCopied": "Link copiat"
+ "linkCopied": "Link copiat",
+ "seeLargest": "Vezi cele mai mari {n} →",
+ "alreadyPraying": "{n} deja în rugăciune",
+ "everyIn": "Fiecare resursă folosită în {country}",
+ "found": "Resurse ale Evangheliei găsite ({n})",
+ "none": "Nicio resursă găsită ({n})"
},
"res": {
"scripture": "Scripturi",
@@ -382,5 +387,14 @@
"hnr": "grupuri care pot auzi Evanghelia dar nu au Scriptură scrisă în colecțiile verificate",
"all": "grupuri cu Scriptură și film"
}
+ },
+ "chip": {
+ "scriptureAudio": "Scriptură, {medium} · {source}",
+ "bibleVideoOf": "Video biblic · {source}",
+ "loading": "Se încarcă resursele…",
+ "peopleWord": "persoane",
+ "alreadyPraying": "deja în rugăciune",
+ "exhausted": "epuizat",
+ "together": "împreună cu"
}
}
diff --git a/library/i18n/locales/ru/gospel.json b/library/i18n/locales/ru/gospel.json
index b5795dee9..51994adef 100644
--- a/library/i18n/locales/ru/gospel.json
+++ b/library/i18n/locales/ru/gospel.json
@@ -77,7 +77,12 @@
"everyResource": "Все ресурсы на",
"alsoSpeak": "Также говорят на",
"share": "Поделиться",
- "linkCopied": "Ссылка скопирована"
+ "linkCopied": "Ссылка скопирована",
+ "seeLargest": "Показать {n} крупнейших →",
+ "alreadyPraying": "{n} уже молятся",
+ "everyIn": "Все ресурсы, используемые в {country}",
+ "found": "Найдено ресурсов Евангелия ({n})",
+ "none": "Ресурсы не найдены ({n})"
},
"res": {
"scripture": "Писание",
@@ -382,5 +387,14 @@
"hnr": "народы, которые могут услышать Евангелие, но не имеют письменного Писания в проверенных источниках",
"all": "народы с Писанием и фильмом"
}
+ },
+ "chip": {
+ "scriptureAudio": "Писание, {medium} · {source}",
+ "bibleVideoOf": "Библейское видео · {source}",
+ "loading": "Загрузка ресурсов…",
+ "peopleWord": "человек",
+ "alreadyPraying": "уже молятся",
+ "exhausted": "исчерпано",
+ "together": "вместе с"
}
}
diff --git a/library/i18n/locales/zh/gospel.json b/library/i18n/locales/zh/gospel.json
index 1370ca73f..4dfb60c4d 100644
--- a/library/i18n/locales/zh/gospel.json
+++ b/library/i18n/locales/zh/gospel.json
@@ -77,7 +77,12 @@
"everyResource": "所有资源 —",
"alsoSpeak": "同样使用",
"share": "分享",
- "linkCopied": "链接已复制"
+ "linkCopied": "链接已复制",
+ "seeLargest": "查看最大的 {n} 个 →",
+ "alreadyPraying": "{n} 人已在祷告",
+ "everyIn": "{country} 使用的所有资源",
+ "found": "找到的福音资源({n})",
+ "none": "未找到资源({n})"
},
"res": {
"scripture": "圣经",
@@ -382,5 +387,14 @@
"hnr": "能听到福音但在已查询资源库中没有文字圣经的族群",
"all": "有圣经和影片的族群"
}
+ },
+ "chip": {
+ "scriptureAudio": "圣经,{medium} · {source}",
+ "bibleVideoOf": "圣经视频 · {source}",
+ "loading": "正在加载资源…",
+ "peopleWord": "人",
+ "alreadyPraying": "已在祷告",
+ "exhausted": "已用尽",
+ "together": "连同"
}
}
diff --git a/plugin-designs/gospel-resources/resources-map.vue b/plugin-designs/gospel-resources/resources-map.vue
index 3c8d3b157..fa9c1865e 100644
--- a/plugin-designs/gospel-resources/resources-map.vue
+++ b/plugin-designs/gospel-resources/resources-map.vue
@@ -2385,7 +2385,7 @@ useShadowStyles(
{{ t('gospel.gap.showingOnly', { what: t('gospel.gap.kind.' + (pinFilter === 'none' ? 'none' : pinFilter === 'some' ? 'some' : pinFilter === 'hnr' ? 'hnr' : 'all')) }) }}
{{ t('gospel.filter.showAll', { count: fmt(pinTally.total) }) }}
- See the 40 largest →
+ {{ t('gospel.sheet.seeLargest', { n: 40 }) }}
@@ -2726,7 +2726,7 @@ useShadowStyles(
:href="selectedGroup.audio.u" target="_blank" rel="noopener noreferrer"
:title="selectedGroup.audio.n + ' — Scripture from Faith Comes By Hearing (' + (selectedGroup.audio.m || 'audio') + '), free in a browser.'
+ (selectedGroup.audio.vsc ? ' Video covers: ' + selectedGroup.audio.vsc + '. That is Bible video, not the JESUS Film.' : '')">
- Scripture, {{ selectedGroup.audio.m || 'audio' }} · {{ selectedGroup.audio.n }}
+ {{ t('gospel.chip.scriptureAudio', { medium: selectedGroup.audio.m || t('gospel.res.audio'), source: selectedGroup.audio.n }) }}
- Every resource used in {{ selected.country }} ↓
+ {{ t('gospel.sheet.everyIn', { country: selected.country }) }} ↓
- Every resource used in {{ selected.country }} →
+ {{ t('gospel.sheet.everyIn', { country: selected.country }) }} →
- Loading resources…
+ {{ t('gospel.chip.loading') }}
{{ pgErr }}
-
Gospel resources found ({{ withResources.length }})
+
{{ t('gospel.sheet.found', { n: withResources.length }) }}
Nothing found for any group here in the pools checked.
@@ -3239,7 +3239,7 @@ useShadowStyles(
-
No resource found ({{ withoutResources.length }})
+
{{ t('gospel.sheet.none', { n: withoutResources.length }) }}
+
The estimate applies a national death rate to a sub-population; it is
order-of-magnitude, not a count.
From d20152163126882475d88cf8e00c854fe1793cf9 Mon Sep 17 00:00:00 2001
From: Doxa Maps
Date: Tue, 29 Sep 2026 07:14:59 +0000
Subject: [PATCH 42/78] Rebuild gospel-resources for the sentence keys
Twenty-six keys across eleven locales reach this bundle, so the rebuild is real.
country-posters is byte-identical (1,472,734 before and after): the keys
tree-shake out of it exactly as expected, leaving the queue's headroom untouched.
verify-bundles-shipped passes; manifest churn is this bundle plus the spa
manifest's own self-reference.
---
public/dx-maps/doxa-maps-spa/manifest.json | 12 ++---
.../gospel-resources/charts/index.html | 2 +-
.../gospel-resources/data-tables/index.html | 2 +-
.../gospel-dashboard/index.html | 2 +-
.../gospel-resources/gospel-resources.js | 44 +++++++++----------
.../resources-list/index.html | 2 +-
.../gospel-resources/resources-map/index.html | 2 +-
public/dx-maps/manifest.json | 12 ++---
8 files changed, 39 insertions(+), 39 deletions(-)
diff --git a/public/dx-maps/doxa-maps-spa/manifest.json b/public/dx-maps/doxa-maps-spa/manifest.json
index 9dc426aca..b1a2f341d 100644
--- a/public/dx-maps/doxa-maps-spa/manifest.json
+++ b/public/dx-maps/doxa-maps-spa/manifest.json
@@ -770,7 +770,7 @@
"bundle": "gospel-resources",
"scriptUrl": "./gospel-resources/gospel-resources.js",
"output": "dx-maps/gospel-resources/gospel-resources.js",
- "integrity": "sha384-8GK7EVWncrS6oWbKnxlVdUHviwy1VetUwwNuk385Rwh/W7pFwNZRPDa+cNOX+mRO",
+ "integrity": "sha384-XWcUYJE1x61V6j9q3y4doeqH0SgnIP135dwe5UVpMDkxzwGR9DgSTnVq6jmbid69",
"elements": [
"gospel-resources-map"
],
@@ -793,7 +793,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "",
- "script": "\n\n\n "
+ "script": "\n\n\n "
}
},
{
@@ -809,7 +809,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "",
- "script": "\n\n\n "
+ "script": "\n\n\n "
}
},
{
@@ -825,7 +825,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "",
- "script": "\n \n\n\n\n "
+ "script": "\n \n\n\n\n "
}
},
{
@@ -841,7 +841,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "",
- "script": "\n\n\n "
+ "script": "\n\n\n "
}
},
{
@@ -857,7 +857,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "",
- "script": "\n \n\n\n\n "
+ "script": "\n \n\n\n\n "
}
}
]
diff --git a/public/dx-maps/gospel-resources/charts/index.html b/public/dx-maps/gospel-resources/charts/index.html
index 0c3853a32..65f5a761f 100644
--- a/public/dx-maps/gospel-resources/charts/index.html
+++ b/public/dx-maps/gospel-resources/charts/index.html
@@ -61,7 +61,7 @@
-
+
-
+
-
+
-
+
-
+
\n\n\n "
+ "script": "\n\n\n "
}
},
{
@@ -809,7 +809,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "",
- "script": "\n\n\n "
+ "script": "\n\n\n "
}
},
{
@@ -825,7 +825,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "",
- "script": "\n \n\n\n\n "
+ "script": "\n \n\n\n\n "
}
},
{
@@ -841,7 +841,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "",
- "script": "\n\n\n "
+ "script": "\n\n\n "
}
},
{
@@ -857,7 +857,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "",
- "script": "\n \n\n\n\n "
+ "script": "\n \n\n\n\n "
}
}
]
From 1e3d2a389210ebb7548b526095452e94006bfcca Mon Sep 17 00:00:00 2001
From: Doxa Maps
Date: Tue, 29 Sep 2026 07:17:42 +0000
Subject: [PATCH 43/78] The list sheets count their own rows in one key
"Showing {shown} of {total}." appears twice, for the with-resources and
without-resources lists, and is ONE key used twice. The population suffix and the
related-language line join it; "way in:" reuses the key the gap list already has
rather than authoring a second one for the same words.
Three new keys, five call sites.
---
library/i18n/locales/ar/gospel.json | 5 ++++-
library/i18n/locales/de/gospel.json | 5 ++++-
library/i18n/locales/en/gospel.json | 5 ++++-
library/i18n/locales/es/gospel.json | 5 ++++-
library/i18n/locales/fr/gospel.json | 5 ++++-
library/i18n/locales/hi/gospel.json | 5 ++++-
library/i18n/locales/it/gospel.json | 5 ++++-
library/i18n/locales/pt/gospel.json | 5 ++++-
library/i18n/locales/ro/gospel.json | 5 ++++-
library/i18n/locales/ru/gospel.json | 5 ++++-
library/i18n/locales/zh/gospel.json | 5 ++++-
plugin-designs/gospel-resources/resources-map.vue | 10 +++++-----
12 files changed, 49 insertions(+), 16 deletions(-)
diff --git a/library/i18n/locales/ar/gospel.json b/library/i18n/locales/ar/gospel.json
index 4b8646876..c60673b65 100644
--- a/library/i18n/locales/ar/gospel.json
+++ b/library/i18n/locales/ar/gospel.json
@@ -82,7 +82,10 @@
"alreadyPraying": "{n} يصلّون بالفعل",
"everyIn": "كل مورد مُستخدم في {country}",
"found": "موارد إنجيلية موجودة ({n})",
- "none": "لا توجد موارد ({n})"
+ "none": "لا توجد موارد ({n})",
+ "showingOf": "عرض {shown} من {total}.",
+ "popSuffix": "· {pop} نسمة",
+ "hasResources": "ذات صلة: {lang} لديها موارد"
},
"res": {
"scripture": "الكتاب المقدس",
diff --git a/library/i18n/locales/de/gospel.json b/library/i18n/locales/de/gospel.json
index 470c17822..e458ced50 100644
--- a/library/i18n/locales/de/gospel.json
+++ b/library/i18n/locales/de/gospel.json
@@ -82,7 +82,10 @@
"alreadyPraying": "{n} beten bereits",
"everyIn": "Jede in {country} genutzte Ressource",
"found": "Gefundene Evangeliums-Ressourcen ({n})",
- "none": "Keine Ressource gefunden ({n})"
+ "none": "Keine Ressource gefunden ({n})",
+ "showingOf": "{shown} von {total} werden angezeigt.",
+ "popSuffix": "· {pop} Menschen",
+ "hasResources": "verwandt: {lang} hat Ressourcen"
},
"res": {
"scripture": "Bibeltext",
diff --git a/library/i18n/locales/en/gospel.json b/library/i18n/locales/en/gospel.json
index 9d0dc96db..f4bb5e7b7 100644
--- a/library/i18n/locales/en/gospel.json
+++ b/library/i18n/locales/en/gospel.json
@@ -82,7 +82,10 @@
"alreadyPraying": "{n} already praying",
"everyIn": "Every resource used in {country}",
"found": "Gospel resources found ({n})",
- "none": "No resource found ({n})"
+ "none": "No resource found ({n})",
+ "showingOf": "Showing {shown} of {total}.",
+ "popSuffix": "· {pop} people",
+ "hasResources": "related: {lang} has resources"
},
"res": {
"scripture": "Scripture",
diff --git a/library/i18n/locales/es/gospel.json b/library/i18n/locales/es/gospel.json
index 276bd94d2..23a291d94 100644
--- a/library/i18n/locales/es/gospel.json
+++ b/library/i18n/locales/es/gospel.json
@@ -82,7 +82,10 @@
"alreadyPraying": "{n} ya orando",
"everyIn": "Todos los recursos usados en {country}",
"found": "Recursos del evangelio encontrados ({n})",
- "none": "Ningún recurso encontrado ({n})"
+ "none": "Ningún recurso encontrado ({n})",
+ "showingOf": "Mostrando {shown} de {total}.",
+ "popSuffix": "· {pop} personas",
+ "hasResources": "relacionada: {lang} tiene recursos"
},
"res": {
"scripture": "Escrituras",
diff --git a/library/i18n/locales/fr/gospel.json b/library/i18n/locales/fr/gospel.json
index 4a9838453..9d11e5e15 100644
--- a/library/i18n/locales/fr/gospel.json
+++ b/library/i18n/locales/fr/gospel.json
@@ -82,7 +82,10 @@
"alreadyPraying": "{n} prient déjà",
"everyIn": "Toutes les ressources utilisées en {country}",
"found": "Ressources de l’Évangile trouvées ({n})",
- "none": "Aucune ressource trouvée ({n})"
+ "none": "Aucune ressource trouvée ({n})",
+ "showingOf": "Affichage de {shown} sur {total}.",
+ "popSuffix": "· {pop} personnes",
+ "hasResources": "apparentée : {lang} a des ressources"
},
"res": {
"scripture": "Écritures",
diff --git a/library/i18n/locales/hi/gospel.json b/library/i18n/locales/hi/gospel.json
index 6cc203a13..06e40b84c 100644
--- a/library/i18n/locales/hi/gospel.json
+++ b/library/i18n/locales/hi/gospel.json
@@ -82,7 +82,10 @@
"alreadyPraying": "{n} पहले से प्रार्थना कर रहे",
"everyIn": "{country} में उपयोग किया गया हर संसाधन",
"found": "मिले सुसमाचार संसाधन ({n})",
- "none": "कोई संसाधन नहीं मिला ({n})"
+ "none": "कोई संसाधन नहीं मिला ({n})",
+ "showingOf": "{total} में से {shown} दिखाए जा रहे हैं।",
+ "popSuffix": "· {pop} लोग",
+ "hasResources": "संबंधित: {lang} के पास संसाधन हैं"
},
"res": {
"scripture": "पवित्र शास्त्र",
diff --git a/library/i18n/locales/it/gospel.json b/library/i18n/locales/it/gospel.json
index 51e01e603..2475ee911 100644
--- a/library/i18n/locales/it/gospel.json
+++ b/library/i18n/locales/it/gospel.json
@@ -82,7 +82,10 @@
"alreadyPraying": "{n} già in preghiera",
"everyIn": "Ogni risorsa usata in {country}",
"found": "Risorse del vangelo trovate ({n})",
- "none": "Nessuna risorsa trovata ({n})"
+ "none": "Nessuna risorsa trovata ({n})",
+ "showingOf": "Mostrati {shown} di {total}.",
+ "popSuffix": "· {pop} persone",
+ "hasResources": "correlata: {lang} ha risorse"
},
"res": {
"scripture": "Scritture",
diff --git a/library/i18n/locales/pt/gospel.json b/library/i18n/locales/pt/gospel.json
index f6b8bc297..33a576385 100644
--- a/library/i18n/locales/pt/gospel.json
+++ b/library/i18n/locales/pt/gospel.json
@@ -82,7 +82,10 @@
"alreadyPraying": "{n} já orando",
"everyIn": "Todos os recursos usados em {country}",
"found": "Recursos do evangelho encontrados ({n})",
- "none": "Nenhum recurso encontrado ({n})"
+ "none": "Nenhum recurso encontrado ({n})",
+ "showingOf": "Mostrando {shown} de {total}.",
+ "popSuffix": "· {pop} pessoas",
+ "hasResources": "relacionada: {lang} tem recursos"
},
"res": {
"scripture": "Escrituras",
diff --git a/library/i18n/locales/ro/gospel.json b/library/i18n/locales/ro/gospel.json
index 959ca6d6e..ef8ba788a 100644
--- a/library/i18n/locales/ro/gospel.json
+++ b/library/i18n/locales/ro/gospel.json
@@ -82,7 +82,10 @@
"alreadyPraying": "{n} deja în rugăciune",
"everyIn": "Fiecare resursă folosită în {country}",
"found": "Resurse ale Evangheliei găsite ({n})",
- "none": "Nicio resursă găsită ({n})"
+ "none": "Nicio resursă găsită ({n})",
+ "showingOf": "Se afișează {shown} din {total}.",
+ "popSuffix": "· {pop} persoane",
+ "hasResources": "înrudită: {lang} are resurse"
},
"res": {
"scripture": "Scripturi",
diff --git a/library/i18n/locales/ru/gospel.json b/library/i18n/locales/ru/gospel.json
index fa1349668..0ff5316cb 100644
--- a/library/i18n/locales/ru/gospel.json
+++ b/library/i18n/locales/ru/gospel.json
@@ -82,7 +82,10 @@
"alreadyPraying": "{n} уже молятся",
"everyIn": "Все ресурсы, используемые в {country}",
"found": "Найдено ресурсов Евангелия ({n})",
- "none": "Ресурсы не найдены ({n})"
+ "none": "Ресурсы не найдены ({n})",
+ "showingOf": "Показано {shown} из {total}.",
+ "popSuffix": "· {pop} человек",
+ "hasResources": "родственный: {lang} имеет ресурсы"
},
"res": {
"scripture": "Писание",
diff --git a/library/i18n/locales/zh/gospel.json b/library/i18n/locales/zh/gospel.json
index 86e638e78..7c888e9a4 100644
--- a/library/i18n/locales/zh/gospel.json
+++ b/library/i18n/locales/zh/gospel.json
@@ -82,7 +82,10 @@
"alreadyPraying": "{n} 人已在祷告",
"everyIn": "{country} 使用的所有资源",
"found": "找到的福音资源({n})",
- "none": "未找到资源({n})"
+ "none": "未找到资源({n})",
+ "showingOf": "显示 {total} 中的 {shown}。",
+ "popSuffix": "· {pop} 人",
+ "hasResources": "相关:{lang} 有资源"
},
"res": {
"scripture": "圣经",
diff --git a/plugin-designs/gospel-resources/resources-map.vue b/plugin-designs/gospel-resources/resources-map.vue
index fa9c1865e..a8fb6ffa4 100644
--- a/plugin-designs/gospel-resources/resources-map.vue
+++ b/plugin-designs/gospel-resources/resources-map.vue
@@ -2641,7 +2641,7 @@ useShadowStyles(
{{ t('gospel.gap.wayIn', { via: g.wayIn }) }}
- related: {{ g.rel.l }} has resources
+ {{ t('gospel.sheet.hasResources', { lang: g.rel.l }) }}
@@ -3233,7 +3233,7 @@ useShadowStyles(
- Showing {{ shown }} of {{ withResources.length }}.
+ {{ t('gospel.sheet.showingOf', { shown, total: withResources.length }) }}
{{ t('gospel.legend.showAll') }}
@@ -3249,12 +3249,12 @@ useShadowStyles(
{{ g.people_group }}
{{ g.language }}{{ g.iso639_3 || 'no code' }}
- · {{ fmt(g.population) }} people
+ {{ t('gospel.sheet.popSuffix', { pop: fmt(g.population) }) }}
- way in: {{ wayInFor(g) }}
+ {{ t('gospel.gap.wayIn', { via: wayInFor(g) }) }}
- Showing {{ shown }} of {{ withoutResources.length }}.
+ {{ t('gospel.sheet.showingOf', { shown, total: withoutResources.length }) }}
{{ t('gospel.legend.showAll') }}
From 5ef3976e9dd22ca686109b9f9cbefd48746cc7ed Mon Sep 17 00:00:00 2001
From: doxa-maps
Date: Tue, 29 Sep 2026 07:23:49 +0000
Subject: [PATCH 44/78] A9 lever 1: engine-aware resource hints on map leaves
A cold map ran serially: leaf HTML, then the bundle (250-470 KiB gz, parsed),
then only the engine (from cdn.jsdelivr.net or api.mapbox.com), then the style
host, tiles, and data. Four origins discovered one after another, none
pre-connected. That waterfall is the shape behind A9's 3-5 s cold map.
generate-tree now emits, in each MAP leaf's , a preconnect for the
engine origin, the style host and the data origin, and -- only on the 'auto'
leaf, which carries no engine
${esc(title)}
- ${peerBlock}
+${hintBlock} ${peerBlock}
${e}