Commit graph

23 commits

Author SHA1 Message Date
Doxa Maps
a7930ca0ea Merge branch 'bundler-2/whiteboard' into bundler-2/handoff
# Conflicts:
#	internal/check-whiteboard-projection.mjs
#	library/maps/whiteboard-map.js
#	library/maps/whiteboard-projection.js
#	library/scripts/build-language-tree.mjs
2026-09-29 14:50:39 +00:00
Doxa Maps
bfc99ff827 library/ keeps the reason and drops the speaker: no comment quotes or cites the Driver
Every reference to the Driver in library/ is reworded: the engineering reason
stays, the speaker and the quote go, and a decision still open reads
"provisional, pending a product decision". Comments only: each changed file
is identical after stripping comments (JavaScript, template and CSS, including
CSS comments inside the style strings shadow-DOM components inject).

Those style-string comments do ship, so several bundles change by exactly
those comment bytes.
2026-09-29 05:26:25 +00:00
Doxa Maps
63276767cf The poster credited Mapbox for tiles OpenFreeMap had served
The footer's attribution is a licence statement, so it has to be true rather than
merely present. posterDefaults ships "(c) Mapbox (c) OpenStreetMap" and any caller
that did not override it printed exactly that -- including country-posters, whose
maps run on the fleet's MapLibre default, where Mapbox serves nothing at all and
OpenFreeMap, OpenMapTiles and OpenStreetMap are the three parties whose licences
actually apply. research-map had a hand-written pair for the two providers, which
was right for research-map and helped nobody else.

The obvious approach is to read the style's sources. Checked against the live
style rather than assumed, and it does not work: tiles.openfreemap.org/styles/
liberty declares two sources and NEITHER carries an attribution field. The credit
lives in the TileJSON the vector source points at, which the engine fetches and
merges into the SOURCE OBJECT once it loads. The style is the request and the
source is what came back, and only the second one knows. map-attribution.js reads
the live source first and falls back to any style-declared value.

The TileJSON value is HTML with anchors and a © entity, and a poster footer
is text, so it is flattened rather than printed raw. Repeated credits collapse.
When nothing can be read -- no map, a style that throws before it has loaded -- it
returns empty and the caller keeps its own string, because a blank licence line is
the one outcome that is never acceptable.

The check uses the real TileJSON string as its fixture. A tidy invented style
would have passed while the real map printed the wrong credit, and its first
negative test is exactly that: reading the style alone returns nothing for
OpenFreeMap.

Cut directly on main rather than on the branch this began life in, so it does not
wait behind a poster-layout change it has nothing to do with. A wrong tile credit
is a licence problem; the layout question it was sitting behind is not.
2026-09-29 00:06:52 +00:00
Doxa Maps
84e1e0cf42 The poster sliced its rows in degrees, and Mercator charges more for a degree up north
DRV1650-SR-POSTER-EXPORT, the "too much Antarctica" half. The big-poster renderer
stitches a grid of tiles, each drawn into an EQUAL-HEIGHT slot of the output image, and
it chose each row's latitude band by dividing the degree span evenly:

    latStep = (ne.lat - sw.lat) / rows

Screen height is proportional to ln(tan(pi/4 + lat/2)), not to latitude, so equal DEGREE
bands are not equal SCREEN bands. MEASURED on a -60..75 view in 4 rows: the top band is
37.0% of the true rendered height and was being painted into 25% of the poster; the next
19.7% into 25%; the bottom 25.2% into 25%. Every row is squeezed or stretched, which is
the wrong aspect, and the bands where a degree buys the most pixels are the most
over-represented -- the Antarctica the Driver saw.

Sliced in Mercator y instead, every row is 25.0%, exactly, because that IS the screen
coordinate.

★ THIRD PLACE TODAY FOR THE SAME MISTAKE. A whiteboard chart nearly shipped with a linear
scale into degrees; a measurement harness nearly reported a false threshold by correcting
the wrong axis; and this has been shipping. Treating latitude as a screen coordinate is
the error, the conversion is one line, and it now lives in library/maps/mercator-slice.js
rather than being rediscovered a fourth time.

check-mercator-slice asserts the PROPERTY across five view/row combinations -- every band
an equal share of rendered height, contiguous, covering the span, north-first -- and its
CONTROL is the old code: degree-linear slicing must FAIL the same assertion, which it does
by 12.0 points. Without that control the test would pass on a tree where nothing changed.
A -90..90 view clamps at the Mercator limit instead of going infinite.

NOT FIXED HERE, because it is a product decision and not a defect: the export also asks
for an 8.5x11 PORTRAIT sheet (research-map.vue:1410), so a landscape map is letterboxed
onto a tall page. That is a deliberate default, it is the other half of the wrong vertical aspect, and changing the poster's default shape is a product decision, not a fix.
2026-09-28 17:53:38 +00:00
Doxa Maps
3d16a1d62c Shipped comments describe the behaviour instead of quoting a person
Seven comments added in this batch quoted the reporter word for word. Each now states the
behaviour it guards.
2026-09-28 17:40:35 +00:00
Doxa Maps
fb769b3e86 The language tree becomes anyone's, and the numbers nobody wrote down get written down
Driver brief §B. gospel-internet's GiTree had NO props and NO emits: it read a board
composable and three module singletons, which is what made it gospel's rather than
anybody's. Everything it reached for is now a prop, and the parts worth testing are no
longer inside an SFC a gate cannot run.

WHAT IS ACTUALLY IN THE COMPONENT: wiring, 160 lines. The drawing is the whiteboard
tiered-points layer, the surface is whiteboard-map.js (moved out of gospel's plugin --
67 lines whose every import already came from library, so it was never theirs), the
hierarchy is language-tree-model.js and the camera is frame-target.js. Four modules,
four headless gates. An SFC cannot be executed by a check, so it should hold as little
as possible.

THREE BEHAVIOURS PINNED BECAUSE THEY ARE EASY TO STATE WRONGLY:

FRAME ON SELECT, NOT ON CHANGE. Clearing does not re-frame; the camera stays where the
reader left it. "It frames when the selection changes" is the obvious phrasing and it is
wrong in exactly the case that annoys people -- being yanked somewhere after deselecting.

A SELECTION THAT ARRIVES BEFORE THE MAP IS REPLAYED. GiTree survived this by ordering:
its host set the node through a composable that waited for data, so the value always
landed after the map. As a PROP the ordering belongs to the host, and a prop whose effect
depends on WHEN you set it is a trap -- a shared deep link would work or not depending on
load timing, which nobody could report. ready replays whatever `selected` is.

HOVER CANNOT MOVE THE CAMERA, structurally rather than by convention: hover sets a filter
on its own layer and there is no camera call on that path to remove later.

AND THE CONSTANTS. gospel's maintainer inherited GiTree and had to read intent out of
structure -- there is no "this took measuring" note anywhere in it. Asked which line they
would flag, they named frame(). So padding 60 (the outermost nodes carry labels that
extend past the point), maxZoom 8 (a two-language family must not fill the screen with
its own neighbourhood gone), duration 900, and the floor zoom that stops framing one node
from zooming the reader OUT are now stated with their reasons and asserted.
Two branch rules with them, both of which a tidy-up would erase: reduced motion is a
DIFFERENT CALL (jumpTo, not easeTo with duration 0 -- the engines do not treat those as
one), and a single point CENTRES rather than fitting a degenerate box, because a
one-language family is ordinary and fitBounds on no extent zooms to nothing.

THE ROLLUP IS OVER THE READER'S SCOPE. "A family shows the worst colour below it" is only
true relative to what is currently on the board: a family that is red overall can be
green inside a filter. Handing the rollup a whole subtree makes the colours quietly
disagree with the board -- no error, no blank, just a wrong fact about a real people
group. Asserted in both directions, and colorFor stays injectable because nothing in the
drawing depends on the rollup being worst-of.

THE CLICK TOGGLE MOVED TO THE HOST, and that is a change rather than a codification:
GiTree cleared the selection when you re-clicked the selected node, and that toggle lived
in the component's own handler. A host that wants it must now write it. In exchange the
chart holds no selection state.

gospel's UI is not wired and their boardEngine.js is untouched on main; they adopt on
their own schedule.
2026-09-28 17:40:34 +00:00
Doxa Maps
d3ee0bb6ec The whiteboard is a layer of tiered points; only the edges were ever a tree
Driver brief §B, the seam under the language tree, so gospel's set charts inherit zoom,
label priority and tiling from the map engine instead of re-implementing them in SVG.

READ FROM GiTree RATHER THAN ASSUMED: six layer groups, exactly ONE hierarchical. The
parent->child links are sourced from linkFC; the points, the selection and hover rings,
the dish behind a cluster and EVERY label layer come from one point collection and know
nothing about trees. gospel re-read that independently and found the same split, which
is the only reason I trust it -- "the seam is generic" is the claim that should never
travel on one reader.

So library/charts/whiteboard/tiered-points.js takes a point FeatureCollection whose
features carry id, label, tier and priority, and gives each TIER its own symbol layer
with its own minzoom. That is the whole of "band names zoomed out, ring names mid, group
names in": the engine is told which labels may exist at which zoom and resolves the
collisions itself. Paint expressions pass through unread -- the layer has no opinion
about what a colour means, which is what keeps it reusable. Selection and hover are
FILTERED layers, so hover costs one setFilter and structurally cannot move a camera:
there is nothing in here that could.

AND THE PROJECTION IS NOT A SCALE, which is the part that would have shipped wrong.
gospel asked for degrees and planned to scale by one number; their charts are CONCENTRIC
RINGS, and Mercator stretches vertically by 1/cos(lat) -- 1.0154x at 10 degrees, 1.1547x
at 30 -- so a linear scale renders rings as OVALS, worst at the outer ring where the
labels a reader compares are. library/maps/whiteboard-projection.js is the inverse
conversion, EXTRACTED from build-language-tree.mjs rather than written, because the
second copy was about to be made by hand. The degree-height it emits shrinks from 0.9996
of the width at the centre to 0.6794 at the edge; that shrink IS the correction the
renderer then cancels.

TWO TESTS, EACH GUARDING A DIFFERENT RISK.
check:whiteboard-projection reproduces the old private implementation VERBATIM as a
reference and compares over 1,734 grid points EXACTLY -- the language tree is a
build-time artifact, so a rounding shift would move 4,000 coordinates and look like
nothing. It then asserts a ring renders round at the inner, middle and outer radii, AND
that a linear scale FAILS that same assertion, because an assertion the defect can pass
is measuring nothing.
check:tiered-points runs gospel's CENTERED SET through the layer: 2,079 points, 3 tiers,
priority by population, and NO EDGES. The absent links layer is the evidence -- if the
whiteboard draws this chart with no line layer anywhere, edges are separable in fact
rather than in my reading. The map is a recording fake, so the assertions are about what
the module DOES; a regex over the source would pass on a module that never called
addLayer.

Negative-tested: making the projection linear, changing its rounding, dropping the label
priority, or giving every tier the same minzoom each turn one of them red.

gospel's UI is NOT wired -- their component is theirs to write, later, against a module
that now exists. Their boardEngine.js stays where it is until the chart lands.
2026-09-28 17:40:34 +00:00
Doxa Maps
b8bf3f4c47 The latitude ceiling belongs to extentFraction, not to a chart's extent
gospel measured it across extents 100 / 228 / 260 / 1000 and I confirmed: |lat| is 52.070
at frac 0.17 for all four, identical to three decimals. K = extentFraction / extent and
the outermost point sits at u = extent, so u*K = extentFraction whatever the extent is.

It is worth the header because it kills the fix a reader would try first. Someone whose
drawing hits the engines' ~85 degree clamp will reach for a smaller extent, and it buys
nothing at all -- extent is a unit choice; the fraction is the whole ceiling.

The corollary is the useful half: one assertion on extentFraction covers every consumer,
so the shared module needs no per-chart latitude guard.
2026-09-28 16:53:33 +00:00
Doxa Maps
edcc31d36e The other way to get the space wrong is to correct the axis that needs no correction
gospel retracted their extentFraction threshold and named their bug: they computed screen
x as lng * cos(lat). That is the plate-carree correction, and Web Mercator does not apply
it -- screen x is proportional to LONGITUDE ALONE, which is precisely why the projection
is conformal and why circles stay circles. They measured the damage done by a transform
they had added themselves.

Nothing needed retracting from this header: it already documented the opposite, that
roundness is exact at every value. What it did NOT cover was this second failure, and
this one is subtler than the first. A reader who has just been told "it is a projection,
not a scale" naturally reaches for a cos(lat) term, because that is what correcting a
projection feels like it should involve.

MEASURED here rather than taken: true Mercator gives 0.000% warp at every fraction, while
inserting cos(lat) into x invents 12.4% at 0.17, 23.8% at 0.25, 39.8% at 0.35, 56.5% at
0.45. Those are NOT gospel's numbers -- theirs ran 2.94/6.12/11.33/17.51, so their harness
differed in a second way I did not reproduce. Both wrong spaces produce a clean monotonic
table that grows with the fraction, and the magnitudes depend on which wrong transform was
picked, so the SHAPE of the numbers cannot tell you which mistake you made. Only comparing
against x = lng, y proportional to ln(tan(pi/4 + lat/2)) can.

So the header now says: if a measurement of this module ever reports a threshold, suspect
the harness before the maths. Roundness cannot degrade, because this is the exact inverse
of what the renderer does.

The latitude ceiling stands and gospel confirmed it independently to a tenth of a degree:
|lat| 52.1 at 0.17, 77.3 at 0.35, 83.2 at 0.45, 89.7 at 0.95, against the engines' ~85
clamp. That is the real usable range, and it is the only one.
2026-09-28 16:46:51 +00:00
Doxa Maps
378cce63d2 extentFraction cannot deform a shape; what it can do is run out of latitude
gospel measured the bounded set's 0.92r/1.12r band against this projection and reported
the gap closing between extentFraction 0.25 and 0.35, and asked for the threshold in
this header. I could not reproduce it, and the disagreement is worth the header more
than the threshold would have been.

MEASURED IN MERCATOR SPACE -- the space the map actually draws in -- a ring comes back at
0.000% warp at 0.17, 0.35, 0.45, 0.60, 0.80 and 0.95. That is not a tolerance being met;
it is exact, because this is the exact inverse of the renderer's own transform. A
consumer with a hard geometric invariant does not have to defend it against this
parameter at all.

MEASURED IN DEGREES, treating lng/lat as if it were the screen, the same ring reports
17.5% warp at 0.17, 35.3% at 0.25 and 62.9% at 0.35. Those numbers are the CORRECTION
doing its job, read in a coordinate system nothing renders in. Screen y is proportional
to ln(tan(pi/4 + lat/2)), never to lat -- which is the same confusion this module exists
to prevent, met from the other side.

THE REAL CEILING IS LATITUDE. The outermost point reaches |lat| 52 deg at 0.17, 77 at
0.35, 83 at 0.45 and 89.7 at 0.95, and Web Mercator is undefined at the poles with the
engines clamping near 85. So the parameter's usable range is bounded by how close to the
pole the drawing lands, not by any deformation of shape. That is now in the header,
because it is the limit a consumer actually has to respect.

AND THE CONTROL'S PLACEMENT IS NOW ARGUED RATHER THAN ASSUMED, which is gospel's other
finding and it was right: a linear scale is round to three parts in a thousand at the
inner ring (0.9968) and only fails further out (0.795 at the outer). An assertion placed
at the inner radius would PASS the exact mistake it exists to catch. Both halves are
asserted now -- the outer control must fail, and the inner one is recorded as passing so
nobody mistakes it for coverage, or "simplifies" three radii to one.
2026-09-28 16:44:11 +00:00
Doxa Maps
15fc38555c The language tree becomes anyone's, and the numbers nobody wrote down get written down
Driver brief §B. gospel-internet's GiTree had NO props and NO emits: it read a board
composable and three module singletons, which is what made it gospel's rather than
anybody's. Everything it reached for is now a prop, and the parts worth testing are no
longer inside an SFC a gate cannot run.

WHAT IS ACTUALLY IN THE COMPONENT: wiring, 160 lines. The drawing is the whiteboard
tiered-points layer, the surface is whiteboard-map.js (moved out of gospel's plugin --
67 lines whose every import already came from library, so it was never theirs), the
hierarchy is language-tree-model.js and the camera is frame-target.js. Four modules,
four headless gates. An SFC cannot be executed by a check, so it should hold as little
as possible.

THREE BEHAVIOURS PINNED BECAUSE THEY ARE EASY TO STATE WRONGLY:

FRAME ON SELECT, NOT ON CHANGE. Clearing does not re-frame; the camera stays where the
reader left it. "It frames when the selection changes" is the obvious phrasing and it is
wrong in exactly the case that annoys people -- being yanked somewhere after deselecting.

A SELECTION THAT ARRIVES BEFORE THE MAP IS REPLAYED. GiTree survived this by ordering:
its host set the node through a composable that waited for data, so the value always
landed after the map. As a PROP the ordering belongs to the host, and a prop whose effect
depends on WHEN you set it is a trap -- a shared deep link would work or not depending on
load timing, which nobody could report. ready replays whatever `selected` is.

HOVER CANNOT MOVE THE CAMERA, structurally rather than by convention: hover sets a filter
on its own layer and there is no camera call on that path to remove later.

AND THE CONSTANTS. gospel's maintainer inherited GiTree and had to read intent out of
structure -- there is no "this took measuring" note anywhere in it. Asked which line they
would flag, they named frame(). So padding 60 (the outermost nodes carry labels that
extend past the point), maxZoom 8 (a two-language family must not fill the screen with
its own neighbourhood gone), duration 900, and the floor zoom that stops framing one node
from zooming the reader OUT are now stated with their reasons and asserted.
Two branch rules with them, both of which a tidy-up would erase: reduced motion is a
DIFFERENT CALL (jumpTo, not easeTo with duration 0 -- the engines do not treat those as
one), and a single point CENTRES rather than fitting a degenerate box, because a
one-language family is ordinary and fitBounds on no extent zooms to nothing.

THE ROLLUP IS OVER THE READER'S SCOPE. "A family shows the worst colour below it" is only
true relative to what is currently on the board: a family that is red overall can be
green inside a filter. Handing the rollup a whole subtree makes the colours quietly
disagree with the board -- no error, no blank, just a wrong fact about a real people
group. Asserted in both directions, and colorFor stays injectable because nothing in the
drawing depends on the rollup being worst-of.

THE CLICK TOGGLE MOVED TO THE HOST, and that is a change rather than a codification:
GiTree cleared the selection when you re-clicked the selected node, and that toggle lived
in the component's own handler. A host that wants it must now write it. In exchange the
chart holds no selection state.

gospel's UI is not wired and their boardEngine.js is untouched on main; they adopt on
their own schedule.
2026-09-28 16:30:49 +00:00
Doxa Maps
03c310833b The whiteboard is a layer of tiered points; only the edges were ever a tree
Driver brief §B, the seam under the language tree, so gospel's set charts inherit zoom,
label priority and tiling from the map engine instead of re-implementing them in SVG.

READ FROM GiTree RATHER THAN ASSUMED: six layer groups, exactly ONE hierarchical. The
parent->child links are sourced from linkFC; the points, the selection and hover rings,
the dish behind a cluster and EVERY label layer come from one point collection and know
nothing about trees. gospel re-read that independently and found the same split, which
is the only reason I trust it -- "the seam is generic" is the claim that should never
travel on one reader.

So library/charts/whiteboard/tiered-points.js takes a point FeatureCollection whose
features carry id, label, tier and priority, and gives each TIER its own symbol layer
with its own minzoom. That is the whole of "band names zoomed out, ring names mid, group
names in": the engine is told which labels may exist at which zoom and resolves the
collisions itself. Paint expressions pass through unread -- the layer has no opinion
about what a colour means, which is what keeps it reusable. Selection and hover are
FILTERED layers, so hover costs one setFilter and structurally cannot move a camera:
there is nothing in here that could.

AND THE PROJECTION IS NOT A SCALE, which is the part that would have shipped wrong.
gospel asked for degrees and planned to scale by one number; their charts are CONCENTRIC
RINGS, and Mercator stretches vertically by 1/cos(lat) -- 1.0154x at 10 degrees, 1.1547x
at 30 -- so a linear scale renders rings as OVALS, worst at the outer ring where the
labels a reader compares are. library/maps/whiteboard-projection.js is the inverse
conversion, EXTRACTED from build-language-tree.mjs rather than written, because the
second copy was about to be made by hand. The degree-height it emits shrinks from 0.9996
of the width at the centre to 0.6794 at the edge; that shrink IS the correction the
renderer then cancels.

TWO TESTS, EACH GUARDING A DIFFERENT RISK.
check:whiteboard-projection reproduces the old private implementation VERBATIM as a
reference and compares over 1,734 grid points EXACTLY -- the language tree is a
build-time artifact, so a rounding shift would move 4,000 coordinates and look like
nothing. It then asserts a ring renders round at the inner, middle and outer radii, AND
that a linear scale FAILS that same assertion, because an assertion the defect can pass
is measuring nothing.
check:tiered-points runs gospel's CENTERED SET through the layer: 2,079 points, 3 tiers,
priority by population, and NO EDGES. The absent links layer is the evidence -- if the
whiteboard draws this chart with no line layer anywhere, edges are separable in fact
rather than in my reading. The map is a recording fake, so the assertions are about what
the module DOES; a regex over the source would pass on a module that never called
addLayer.

Negative-tested: making the projection linear, changing its rounding, dropping the label
priority, or giving every tier the same minzoom each turn one of them red.

gospel's UI is NOT wired -- their component is theirs to write, later, against a module
that now exists. Their boardEngine.js stays where it is until the chart lands.
2026-09-28 16:24:40 +00:00
Doxa Maps
42b4c8f16b Two readers of one private field, and nothing was holding them together
internal/lib/facet-walk.mjs's __srcFeatures (injected into the page by
check:facet-changes-map) and library/maps/pin-selection.js's readSourceFeatures (the
packet and zoomToLegendRow) answer the same question about the same private GeoJSON
field. They are two copies because one must be a string evaluated in a browser and the
other an ES module, so they cannot be one function -- but nothing tested that they
still agreed, and they had already stopped.

THE CROSS-CHECK FOUND A DIVERGENCE ON ITS FIRST RUN, which is the argument for it. A
source may hold a single Feature rather than a FeatureCollection; both engines accept
that. The product's reader handled it and the gate's did not, so the gate answered
"cannot measure" about a payload it could read perfectly well. Nobody reading either
file would have noticed: each is correct on its own terms. __srcFeatures now handles
it and the two agree.

ONE DIFFERENCE IS DELIBERATE AND IS NOW PINNED AS SUCH RATHER THAN WAIVED: on a
url-backed source the gate returns null while the product returns [] and warns. Both
are right for their caller -- a gate whose job is to tell "the map drew nothing" from
"I could not look" needs a third answer, while a selection returning null would break
the Set arithmetic every caller does on it. Asserted in both directions, so neither
side can quietly adopt the other's and call it a fix.

AND NEITHER MAY BE SILENT ABOUT A SHAPE IT DOES NOT KNOW. maplibre has a THIRD _data
shape: updateData() stores { updateable } (maplibre-gl-dev.js:49001). No product code
calls updateData today, so this is a landmine rather than a bug -- and it is armed the
moment someone adopts partial updates for performance, at which point the product
reader would have gone back to returning [] in silence: "the map drew nothing", stated
as fact about a payload nobody recognised, one line below the url warning that exists
to prevent exactly that. Both readers now name the unrecognised shape by its keys, and
a fixture holds it, so adopting updateData() trips a test instead of blanking a map.

Negative-tested: dropping the single-Feature case, silencing either reader, or making
the gate adopt the product's url answer each turn it red; restored, 14/14 green.
check:pin-selection still passes, so the product reader is unchanged where it matters.
2026-09-28 15:52:46 +00:00
Doxa Maps
88fe7509d0 The packet was praying over the numbers the page loaded with, not the live ones
rega RQ3(b): the packet's "Has Prayer" selection showed 50 of 997 where the legend
row said 699. The legend row is right and the packet was wrong.

A pin's prayer count arrives TWICE. The GeoJSON property is whatever was true when
the page loaded; from then on polling writes the current count with setFeatureState,
and prayer-progress.js:94 colours from ['feature-state','peoplePraying'] once polling
is on. So the lit pins a reader counts are feature-state, and pin-selection.js had no
'feature-state' case at all -- it resolved to undefined, to-number defaulted it to 0,
and the comparison selected only the pins whose FROZEN property happened to be above
zero. 50 of them. The other 649 people groups had been prayed for and the packet left
them out, which is the one thing a prayer packet must not get wrong.

resolveExpr and matchesExpression now take the state alongside the properties, and
selectedPinIds reads it per pin off the MAP rather than off the feature -- the source
still holds the GeoJSON it loaded, setFeatureState never touches it. The source is
added with promoteId:'uniqueId', so the id feature-state is keyed by is the same
props[idKey] the selection already uses.

Degrading is deliberate in both directions: before the first poll there is no state
and property expressions still select, so an early packet is thinner and never empty;
getFeatureState throws on an unknown source, so it is guarded and a bad source loses
the selection rather than aborting the print.

ALSO, rega RQ3(a), a candidate and not yet a confirmed cause. prewarmRegionPaint
wrapped any non-feature-reading paint value in a case to make it data-driven,
including a zoom interpolate -- I argued for that explicitly. useMapLayers.js:319
already held the rule and I had not read it: zoom interpolation is kept by putting the
case in the interpolate OUTPUTS, and "the reverse (zoom nested inside a 'case') is what
breaks the layer, so don't do that." That reverse is the shape Mapbox classifies as
composite before reaching for an interpolation factor the expression cannot supply,
which is the class of the interpolationFactor page error. The wrap now skips zoom
expressions too. A missed pre-warm costs a frame; an illegal paint costs the layer.
None of the three properties it touches is a zoom interpolate at boot, so this is not
proof -- it removes the only writer that can build that shape from a runtime value.

check-pin-selection asserts the live count wins over the stale property, that the
stale property alone selects nothing, that a map with no feature-state still selects
on properties, and that a throwing getFeatureState degrades. Negative-tested:
removing the case, not reading the state, and dropping the throw-guard each turn it
red; restored, green.
2026-09-28 13:56:22 +00:00
Doxa Maps
f44da45258 A test for the source reader, because a no-op cannot be seen any other way
dx-shepherd asked for this with a7b12b44 and the reason is exact: every caller of readSourceFeatures is
written to do NOTHING when it finds no features, deliberately, so that a legend row for an off-screen
region degrades quietly instead of throwing. That is why the MapLibre bug produced no error, no console
line and no visual difference except a camera that did not move — and why it survived until a packet
printed the wrong 50 people.

check:pin-selection drives the reader with fixtures for both engines and no map at all:
  · the MAPBOX shape (_data IS the FeatureCollection) reads its features;
  · the MAPLIBRE shape (_data is { geojson }) reads the SAME features — the regression that shipped;
  · a REMOTE source ({ url }) returns [] AND WARNS;
  · the serialize() fallback works, a missing source is [] rather than a throw, a bare Feature is one;
  · selectedPinIds ANDs its expressions, and with none returns every pin rather than none.

★ NEGATIVE-TESTED, which is the only thing that makes it worth having: run against the reader as it
shipped, it fails with exactly four findings — the maplibre shape reads 0 instead of 3, selectedPinIds
narrows to nothing, "no expressions" returns nothing, and the remote case returns [] silently. Against
the fixed reader it passes. A test that cannot fail on the code it was written for proves nothing.

★ AND THE REMOTE CASE IS NOW A DECISION, not an accident. dx-shepherd asked what { url } should do. It
returns [] and warns once per source: the features live on a server and cannot be enumerated locally, so
"I cannot read this" is a different fact from "there is nothing here". Collapsing those two is precisely
how the MapLibre bug hid for as long as it did, and no caller can tell them apart from an empty array.

Verified: check:pin-selection, check:mjs-parse, check:doc-scripts, check:package-json-unique-keys all
PASS. gates:pre was NOT run clean here — my main clone sits three landings behind origin/main, so a
newer package.json lists check:targets while that file is absent from the working tree; that failure is
my verification setup, not this change, and I checked it on an untouched tree before reporting.
2026-09-28 08:31:19 +00:00
Doxa Maps
8ce5b9fdd1 Reading a GeoJSON source found nothing on MapLibre, which broke more than the packet
rega's console answered the question in one line: "0 pin(s) selected but no rows matched", for both a
language-family selection and a prayer selection, with the pins plainly on screen. Not an id mismatch and
not a missing filter — readSourceFeatures returned an empty list.

THE TWO ENGINES STORE _data DIFFERENTLY. maplibre-gl's GeoJSONSource wraps it:

    this._data = typeof options.data === 'string' ? { url: options.data } : { geojson: options.data }

so `data.features` is undefined there. Mapbox stores the GeoJSON directly. This code was written against
Mapbox, where it works, and has been returning nothing on MapLibre ever since — which is the keyless
engine, the one the public builds use.

★ THE PACKET IS THE SMALL HALF. This reader is what zoomToLegendRow uses to answer "which pins does this
legend row select", and it backs research-map, country-posters and doxa-simple-map. On MapLibre every one
of them has been failing to fit the camera to a clicked legend row — silently, because the function is
written to no-op rather than throw when it finds no features. The original comment above it explains that
querySourceFeatures is viewport-clipped and would make off-screen rows "silently no-op"; the replacement
did exactly that on one engine, for a different reason.

The wrapper is unwrapped, and a URL source returns [] explicitly — having no local features to read is a
different fact from finding none, and collapsing the two is how this hid.

Verified: gates:pre 13/13. Needs eyes, and the second one matters more than the first:
  · the packet on MapLibre prints the SELECTED groups, cover count no longer the whole list;
  · clicking a legend row on MapLibre FITS THE CAMERA to that row — on research-map, country-posters and
    doxa-simple-map. If that has been dead on the keyless engine, this is the commit that revives it.
2026-09-28 08:31:18 +00:00
Doxa Maps
2ea3c53e0f The packet prints what the reader selected, and the question "which pins is that" becomes shared
PACKETSEL, found by exp-shepherd: the prayer packet printed the first 50 of EVERY people group whatever
was selected, so a reader who had narrowed the map to one language family got a packet about somebody
else. I carried that behaviour through verbatim when I moved the packet to the shared printer — it was
already wrong and I preserved it, which is worth saying plainly.

The rows now come from the pins currently SELECTED: the layer's own filter decides which pins render,
`_pinMatchExpr` decides which of those are lit (the affinity tab dims rather than filters), and both
must match — a pin at opacity 0 is not what the reader is looking at.

★ ASKED OF THE MAP'S FEATURES, NOT OF THE ROWS, and this is the part that had to be got right rather
than guessed. Pin properties are DERIVED from rows: useMapLayers fills languageFamily in when the API
returns null for all 2,069 groups, and canonicalizes doxaRegion onto the palette's key forms precisely
so setFilter matches. So evaluating a pin filter against a raw data row is wrong in a way that NEVER
THROWS — it just selects a different, plausible-looking subset. The features the map holds are the only
place the two vocabularies agree.

Answering that question already existed, privately, inside useMapFly: zoomToLegendRow has to solve it to
fit the camera to a legend row, including the reason it cannot use querySourceFeatures (viewport-clipped,
so an off-screen region silently selects nothing). Those three functions move VERBATIM to
library/maps/pin-selection.js and useMapFly imports them, so there is one evaluator rather than two —
and country-posters, which prints the same kind of packet from the same kind of selection, has the same
helper to reach for instead of a second copy.

Two failure modes are distinguished rather than collapsed: pins selected but NO row matching them is an
id mismatch, so it prints the full list and warns; a genuinely empty selection still reaches the shared
printer's own "that list resolved to no records", which is honest. And when the 50-row safety cap bites,
the cover now says "— 50 of 214" instead of quietly being a different packet than the one asked for.

Verified: gates:pre 13/13, check:composite-interop PASS. Needs eyes: rega — select one language family,
print, and check the cards are that family and the cover carries the count.
2026-09-28 06:29:49 +00:00
Doxa Maps
5f053d992c The galaxy basemap was localizable all along: its label keys use an underscore, not a colon
exp-shepherd found research-dashboard rendering ENDONYMS in every locale — 中国 and Türkiye on a Russian
page — and fetching a glyph range for every script on screen. The reading was that its basemap carries no
localized names and the choice was to swap the basemap or give up on its labels. Measured against the live
tiles, neither is necessary: the names are there, under keys this chain never tried.

The galaxy style is VersaTiles Colorful on the SHORTBREAD schema, whose label keys are name_ar / name_de /
name_en, not name:ar. This coalesce only tried the colon spelling, so all 43 of that style's label layers
fell through to `name` — the local name — while the localizer ran and reported success. A running fix that
cannot reach its data looks exactly like a missing feature.

MEASURED, six z3/z4 tiles across Europe, Asia and Africa, identical key sets on every one: name_ar,
name_de, name_el, name_en, name_es, name_fr, name_it, name_nl, name_pl, name_pt, name_uk. Of the eleven
locales we ship that is SEVEN in their own language — ar, de, en, es, fr, it, pt — with no change to the
basemap the Driver chose and no change to how the map looks.

hi, ro, ru and zh are genuinely absent from those tiles, so they now take name_en: one Latin script rather
than mixed CJK and Devanagari endonyms, which is also the single-script glyph saving. That is the same role
name:latin already plays for the OpenFreeMap styles, so it sits in the same position in the chain rather
than becoming a special case.

NOTHING CHANGES FOR THE OPENFREEMAP STYLES, by construction: the colon spelling stays first, so their
name:<lang> or name:latin still wins and the underscore entries are never reached. The order is also what
keeps the already-rewritten sentinel working — it tests for a chain beginning ["coalesce",["get","name:,
which this still is. Verified both, rather than assumed.

WHAT I DID NOT DO: swap the basemap. galaxy resolves to VersaTiles Colorful because the Driver asked for
blue oceans like the Mapbox map and that style's water measured closest (#bfd9f2; OpenFreeMap Liberty is
#9ebdff, noticeably stronger). Trading a look he chose for four locales' labels is his call, not mine, and
with this change those four get readable Latin labels anyway.

Verified: gates:pre 12/12. NOT measured here: glyph KB per locale, which needs a browser — rega has the
instrument and the before numbers.
2026-09-28 05:30:58 +00:00
Doxa Maps
3bb5fd7b86 Keyless maps label places in one language, English included
English readers got the style's own bilingual labels, native script under every name,
so an English page fetched a glyph range for every script at world view: Prayer
Progress loaded 18 font/sprite requests (777 KB) in English against 5 (185 KB) in
Arabic. English now gets the same single-language labels as every other locale.
2026-09-28 02:38:50 +00:00
doxa-maps-88655c70
e94cb56b79 Map labels ask for a font the engine's glyph server has: one shared helper
library/maps/label-font.js picks a symbol layer's text-font by engine: Noto Sans
(Regular, or Bold for medium/bold) under MapLibre, which is what OpenFreeMap
serves, and exactly the caller's fonts under Mapbox. Mapbox maps render as
before.

useMapClustering hard-coded 'DIN Offc Pro Medium, Arial Unicode MS Bold', which
OpenFreeMap does not have. Under MapLibre every native cluster count requested
it and got a 404; MapLibre then drew the counts in its fallback weight instead
of the intended bold. It now uses the helper, as do upg-neighbor (replacing its
own copy), prayer-walk-planner, and key-cities' city-name labels, which named no
font and so asked for MapLibre's default (also a 404 on OpenFreeMap).

Measured by importing the real composable in a browser, 3,000 points in native
mode:
- main under MapLibre: 1 glyph 404 (DIN Offc Pro Medium,Arial Unicode MS Bold),
  and the counts drawn in the fallback weight;
- this change under MapLibre: 0 glyph 404s, text-font Noto Sans Bold, 22
  clusters with counts in bold;
- this change under Mapbox: text-font still exactly DIN Offc Pro Medium /
  Arial Unicode MS Bold.
No production map reaches native cluster counts today (Simple Research and
Country Posters force the 'mst' line mode), so this was latent in shared code.

Correction to b1e0c7fa: it said those labels "drew nothing". The screenshots here
show MapLibre falls back and draws them. The defect was the failed glyph request
on every load and the wrong weight, not missing text.

Rebuilt: country-posters, doxa-research-map, upg-neighbor, prayer-walk-planner,
key-cities.
2026-09-25 13:48:10 +00:00
doxa-maps-88655c70
319064a11b Map place names in the reader's language, on both engines
Every map translated its own chrome but left the basemap's country, city
and street names in the style default (English on Mapbox, Latin + native
on OpenFreeMap) in all eleven locales. A reader in Arabic saw an Arabic
legend over an English map.

library/maps/localize-labels.js uses each engine's supported mechanism:
- Mapbox: map.setLanguage(lang). It also localizes the Standard style's
  IMPORTED basemap, which a layer rewrite cannot reach.
- MapLibre: basemap symbol layers point at
  ['coalesce', name:<lang>, name:latin, name]. English restores each
  layer's original expression, re-captured on every style load so a theme
  switch never restores the previous style's labels.
Only layers whose text-field reads an OSM name field are touched. A
plugin's own label layers (zone titles, group names) are never rewritten.

useMapInstance applies it on every style.load and when the reader's
language changes (ProfileLoader's 'lang', else profile-config locale, else
?lang / <html lang>). The Prayer Walk Planner drops its private copy for
the shared one.

Measured on the Prayer Progress map, ar/zh/ru/hi:
- Mapbox getLanguage() == locale in all four.
- MapLibre: 13/14 symbol layers localized, and the plugin's own label
  layer untouched.
Read in the Arabic screenshots: القاهرة, الإسكندرية, السويس, الأردن on
both engines. check:gallery-embed (+ :maplibre) and
check:prayer-walk-planner pass. The shared change reaches all 11 bundles
that compile initializeMap, and all are rebuilt here. check:shared-reached
marker basemap-labels-localized: 10/10 consumers.
2026-09-25 09:46:21 +00:00
doxa-maps-88655c70
4fb8176b00 Shape Arabic, Hebrew and Persian map labels on MapLibre maps too
library/maps/rtl-text.js installed the RTL text plugin for MAPBOX only
(window.mapboxgl, from api.mapbox.com). So every MapLibre map, i.e. every
keyless embed, drew RTL place names as disconnected, reversed glyphs.
Measured on the Prayer Walk Planner's printed Arabic sheet.

Under it was a second gap. Bundle entries call the installer at LOAD
time, before useMapInstance has lazily injected a MapLibre engine, so even
an engine-aware installer found nothing to install into.

- rtl-text.js is engine-aware. Mapbox is unchanged (same URL, same lazy
  call). MapLibre gets the same plugin package from jsDelivr, keyless and
  not api.mapbox.com, so keyless pages still make zero Mapbox requests.
  New ensureRtlTextPlugin(gl) waits until it is LOADED, for callers that
  capture a single frame (print).
- useMapInstance installs it the moment an engine global exists, the one
  point every map passes through.
- The planner drops its private copy for the shared helper.

Measured: a print map of downtown Cairo (MapLibre, keyless) shows shaped
Arabic street and place names (شارع التحرير, مسجد الطباخ …); plugin
status "loaded", fetched from jsDelivr. check:gallery-embed passes js,
iframe, neighbour and linked (14 tabs); :maplibre passes;
check:prayer-walk-planner passes.

The shared change reaches all 11 bundles that compile initializeMap, and
all are rebuilt here. check:shared-reached gains an rtl-text-maplibre
marker (10/10 consumers carry it; cities-internet, gospel-resources and
my-upg-100-list boot maps another way and are not affected).

Also: the new Experimental bundle pplr-data-maps loads the Mapbox geocoder
under maplibre (pplrData.js:38). It is an owned, printed MAPBOX_KNOWN entry
routed to its owner; delete it in their fix commit.
2026-09-25 08:20:12 +00:00
doxa-maps-88655c70
c959ed6323 Load the RTL text plugin from one place, and in the composite too
Some checks are pending
build-gate / build (push) Waiting to run
The cap-4 composite bakes research-dashboard's .vue directly, so it never runs
that bundle's entry file and has to repeat everything the entry does. It
repeated Pinia, i18n and the colour strategies. It did not repeat this one.

Measured in the shipped bundles:
  research-dashboard.js ..... 1 occurrence of mapbox-gl-rtl-text
  doxa-research-map.js ...... 1
  dashboard-plus-grid.js .... 0

So Arabic, Hebrew and Persian labels rendered reversed inside the cap-5 shell
and correctly standalone. Not cosmetic here: Arab World has 99 rows and
Persian-Median 49, and a reversed label is a wrong NAME for exactly the groups
this map is about. It failed silently -- no error, no warning, just mirrored
text that reads as a font problem to anyone who does not read the script.

Fixed as a shared library function rather than a fourth hand-copy, which is
what the composite-registration card asks for: one definition, two callers,
nothing to drift. Idempotent by construction, since Mapbox throws on a second
setRTLTextPlugin in a session and that throw is swallowed.

Four of the five entry side effects fail silently; only provide('profileModules')
throws, and that is the one safe to omit because a baked composite does not use
ProfileLoader. So "it renders" is no evidence the entry's work was done.

Found by the map-beauty agent, which I verified independently before acting
rather than taking the bundle counts on report.
2026-09-22 19:55:58 +00:00