doxa-maps/plugin-designs/gospel-internet/src/setChartFeatures.js
Doxa Maps a40fa017b4 The set charts fit their box, carry a cross, and draw the target — combined
One landing because it is one bundle: the ring band layout wiring (already on
this branch) plus the three set-fit commits, replayed as SOURCE only. The
generated tree is rebuilt below rather than merged — a previous cherry-pick of
mine swept a conflicted generated file into a commit, and hand-resolving build
output is how that happens.

From set-fit: extentFraction 0.17 -> 0.095 on both charts, so the drawing fits
the height the engine's zoom floor leaves it; crossFeature restored from history
and called by BOTH maps through one shared addCrossLayer; and the ResizeObserver
fit restored, which rega found missing because zoomAtOpen read 1.00 in all eight
chart/layout combinations — too clean to be a fit result.

Two things this merge had to get right, and neither was automatic:
- Taking GiCenteredSetMap.vue wholesale from set-fit silently REMOVED the ring
  wiring — bandFill, curvedRingLabels and openAndFrame all went to zero while
  every gate still passed. Both feature sets are now in the file together.
- Taking package.json wholesale would have deleted studio's check:ring-bands
  registration, including its gates:pre entry, landing a gate nothing runs. It is
  rebuilt from development's, with only check:gospel-set-fit added — in all three
  places this time: script, gates:pre, and //scripts.
2026-09-29 02:11:28 +00:00

185 lines
9.1 KiB
JavaScript

/**
* 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 } }
}
/**
* 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
}
/**
* 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)]
}
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
}
}