circularLayout: fix 5B findings

Clear check:no-fleet-names (neutral wording for the classic4 reference,
the vendored-fork note, the browser-budget comment, the gate's format
comment and the package.json description). Fix pairedRows leaders[].polys
(was null on every leader — store fork.polys; drop dead polysForCount).
Tie-break ids by code unit (cmpId), not localeCompare, for byte-identity
across runtimes/locales. Harden the gate: assert the fourSides side
sequence L<T<R<B and monotone numbering within each side (by label
emission order), and recount pins-outside-state and pairedRows crossings
from the OUTPUT, not from stats.
This commit is contained in:
Doxa Maps 2026-10-02 12:27:56 +00:00
parent 6e593f2ae9
commit 00db24ed0c
3 changed files with 104 additions and 17 deletions

View file

@ -9,7 +9,7 @@
* input yields byte-identical output, which the byte-proof in step 3 depends on.
*
* It is a PORT of the proven geometry in library/packet/poster-render.js and of
* reg-post's classic4 reference — the same math, lifted so a cheap phone (or a
* the classic4 four-sides reference — the same math, lifted so a cheap phone (or a
* Worker with no canvas) can lay a big poster out. The text measurer is PASSED IN
* (`opts.measure`) because a Worker has no canvas; the default is the renderer's
* own CHADV × WFUDGE advance table, lifted verbatim, so the numbers match.
@ -77,6 +77,10 @@ export function isOctilinear(pts, eps = 0.6) {
}
return true;
}
// Deterministic id tie-break by CODE UNIT (locale-independent, unlike localeCompare),
// so the layout is byte-identical on any runtime/locale (Node, a phone in ar or zh).
function cmpId(a, b) { const x = String(a), y = String(b); return x < y ? -1 : x > y ? 1 : 0; }
function segCrossXY(p, q) {
const o = (ax, ay, bx, by, cx, cy) => Math.sign((bx - ax) * (cy - ay) - (by - ay) * (cx - ax));
const [a, b] = p, [c, d] = q;
@ -184,7 +188,7 @@ async function resolveDeps(opts) {
return { placePinsInState, forkRoute };
}
// Vendored fallback copy of forkRoute (reg-post's reference), used only if
// Vendored fallback copy of forkRoute (the shared fork-leaders reference), used only if
// ./fork-leaders.js is not importable in this tree. The library version supersedes
// it the moment that file lands on the branch.
function vendoredForkRoute({ lane, a, b, dirOut, mode = 'row', forkFrac = 0.5, obstacles = [], r = 0 }) {
@ -312,7 +316,8 @@ function fourSides({ pins0 = null, pins, box, opts, fmt, measure, placePinsInSta
T.sort((a, b) => a.cx - b.cx || a.cy - b.cy); B.sort((a, b) => b.cx - a.cx || a.cy - b.cy);
// number 1..N in ring order [L, T, R, B]
const ringC = [...L, ...T, ...R, ...B]; let num = 0;
for (const c of ringC) { c.first = num + 1; for (const p of c.items) p.num = ++num; c.last = num; }
const sideOf = new Map(); L.forEach((c) => sideOf.set(c, 'L')); T.forEach((c) => sideOf.set(c, 'T')); R.forEach((c) => sideOf.set(c, 'R')); B.forEach((c) => sideOf.set(c, 'B'));
for (const c of ringC) { c.first = num + 1; const sd = sideOf.get(c); for (const p of c.items) { p.num = ++num; p.side = sd; } c.last = num; }
const Lp = L.flatMap((c) => c.items), Rp = R.flatMap((c) => c.items), Tp = T.flatMap((c) => c.items), Bp = B.flatMap((c) => c.items);
// bottom row hug: slant -45, anchor one longest-name diagonal below the map
const nmLen = (p) => `${p.num}. ${String(p.name)}`.length;
@ -333,9 +338,9 @@ function fourSides({ pins0 = null, pins, box, opts, fmt, measure, placePinsInSta
}
// emit geometry (NOT svg): label positions + badge/dot positions
const labels = [];
const placeCol = (arr, x, anchor) => { const n = arr.length || 1, pitch = colH / n; arr.forEach((p, i) => labels.push({ x, y: colTop + pitch * (i + 0.5), pt: font, anchor, rotate: 0, num: p.num, name: String(p.name) })); };
const placeRow = (arr, y) => { const n = arr.length || 1, pitch = rowW / n; arr.forEach((p, i) => { const x = rowX0 + pitch * (i + 0.5); labels.push({ x, y, pt: font, anchor: 'start', rotate: -45, num: p.num, name: String(p.name) }); }); };
placeCol(Lp, lcolX, 'start'); placeCol(Rp, rcolX, 'end'); placeRow(Tp, rowY_T); placeRow(Bp, rowY_B);
const placeCol = (arr, x, anchor, side) => { const n = arr.length || 1, pitch = colH / n; arr.forEach((p, i) => labels.push({ x, y: colTop + pitch * (i + 0.5), pt: font, anchor, rotate: 0, num: p.num, name: String(p.name), side })); };
const placeRow = (arr, y, side) => { const n = arr.length || 1, pitch = rowW / n; arr.forEach((p, i) => { const x = rowX0 + pitch * (i + 0.5); labels.push({ x, y, pt: font, anchor: 'start', rotate: -45, num: p.num, name: String(p.name), side }); }); };
placeCol(Lp, lcolX, 'start', 'L'); placeCol(Rp, rcolX, 'end', 'R'); placeRow(Tp, rowY_T, 'T'); placeRow(Bp, rowY_B, 'B');
const badges = [];
if (RANGE) for (const c of clusters) badges.push({ x: c.bx, y: c.by, first: c.first, last: c.last, k: c.items.length, pt: font });
const dots = RANGE ? clusters.map((c) => ({ x: c.bx, y: c.by })) : P.map((p) => ({ x: p.px, y: p.py, num: p.num }));
@ -354,7 +359,7 @@ function fourSides({ pins0 = null, pins, box, opts, fmt, measure, placePinsInSta
sides: { L: Lp.length, R: Rp.length, T: Tp.length, B: Bp.length },
rangeBadges: RANGE ? clusters.filter((c) => c.items.length > 1).length : 0,
};
return { numbers: P.map((p) => ({ id: p.id, num: p.num })), pins: P.map((p) => ({ id: p.id, x: p.px, y: p.py, num: p.num, state: p._state })), rows: { L: Lp.length, R: Rp.length, T: Tp.length, B: Bp.length }, leaders: [], badges, labels, dots, stats };
return { numbers: P.map((p) => ({ id: p.id, num: p.num })), pins: P.map((p) => ({ id: p.id, x: p.px, y: p.py, num: p.num, side: p.side, state: p._state })), rows: { L: Lp.length, R: Rp.length, T: Tp.length, B: Bp.length }, leaders: [], badges, labels, dots, stats };
}
// ── PAIRED-ROWS (design 2): two groups per row, two octilinear leaders. ────────
@ -382,7 +387,7 @@ function pairedRows({ pins, box, opts, fmt, measure, placePinsInState, forkRoute
var spread = { outsideState: 0, overlaps: 0, moved: 0, maxDisp: 0, infeasible: 0 };
}
// ring order: median x-split into L/R, then y within a side (monotone, non-crossing)
const byX = [...P].sort((a, b) => a.px - b.px || String(a.id).localeCompare(String(b.id)));
const byX = [...P].sort((a, b) => a.px - b.px || cmpId(a.id, b.id));
const half = Math.ceil(byX.length / 2);
byX.forEach((p, i) => { p.side = i < half ? 'L' : 'R'; });
const rowsOut = []; const leaderPolys = []; const allDots = P.map((p) => ({ id: p.id, x: p.px, y: p.py }));
@ -390,7 +395,7 @@ function pairedRows({ pins, box, opts, fmt, measure, placePinsInState, forkRoute
for (const side of ['L', 'R']) {
const dirOut = side === 'L' ? -1 : 1;
const laneX = side === 'L' ? MAP_X - 6 : MAP_X + MAP_W + 6;
const col = P.filter((p) => p.side === side).sort((a, b) => a.py - b.py || String(a.id).localeCompare(String(b.id)));
const col = P.filter((p) => p.side === side).sort((a, b) => a.py - b.py || cmpId(a.id, b.id));
// two groups per row, in ring (y) order. A row's PORT sits at the mean y of its
// own two pins (not an evenly-pitched slot): because the pairs are consecutive
// in y, these port ys are themselves monotone, so each fork stays local to its
@ -406,12 +411,10 @@ function pairedRows({ pins, box, opts, fmt, measure, placePinsInState, forkRoute
obstacles: allDots.filter((d) => d.id !== a.id && d.id !== b.id), r: rPin,
});
rowsOut.push({ side, rowY, aId: a.id, bId: b.id, octilinear: fork.octilinear, throughPins: fork.throughPins });
leaderPolys.push({ id: `${side}${i}`, pts: fork.segs.flat ? fork.polys[0] : fork.polys[0], segs: fork.segs });
leaderPolys.push({ id: `${side}${i}`, polys: fork.polys, segs: fork.segs });
}
}
// crossings: one polyline id per fork (its deduped segs), skip same-id
const polysForCount = [];
for (const lp of leaderPolys) for (const s of lp.segs) polysForCount.push([s[0], s[1]]);
// build as [ [segpts...] per fork ] so countPolyCrossings skips intra-fork
const perFork = leaderPolys.map((lp) => lp.segs.map((s) => [s[0], s[1]]));
let crossings = 0;
@ -448,7 +451,7 @@ function sideLanes({ pins, box, opts, fmt, measure }) {
return { ...p, px: x, py: y };
});
// median x-split → L/R (disjoint bands, non-crossing by construction)
const byX = [...items].sort((a, b) => a.px - b.px || String(a.id).localeCompare(String(b.id)));
const byX = [...items].sort((a, b) => a.px - b.px || cmpId(a.id, b.id));
const halfB = Math.ceil(byX.length / 2);
byX.forEach((it, i) => { it.side = i < halfB ? 'L' : 'R'; });
const LABEL_TOP = box.LABEL_TOP ?? MAP_Y, LABEL_BOT = box.LABEL_BOT ?? (MAP_Y + MAP_H);
@ -460,7 +463,7 @@ function sideLanes({ pins, box, opts, fmt, measure }) {
};
// intra-column ring order by greedy crossing repair (bubble on adjacent pairs)
for (const sd of ['L', 'R']) {
const col = items.filter((i) => i.side === sd).sort((a, b) => a.py - b.py || String(a.id).localeCompare(String(b.id)));
const col = items.filter((i) => i.side === sd).sort((a, b) => a.py - b.py || cmpId(a.id, b.id));
const n2 = col.length || 1;
const rowH0 = (LABEL_BOT - LABEL_TOP) / n2;
const sxCol = sd === 'L' ? MAP_X - 6 : MAP_X + MAP_W + 6;

View file

@ -23,7 +23,49 @@ catch (e) { console.error(`NOT MEASURED: cannot import circularLayout.js — ${e
const fails = [], note = [];
// ── 60x96 classic4 geometry (from poster-render-c4.reference.mjs FORMATS) ──────
// Independent recounts — defined HERE, not imported from the module, so a broken
// counter in circularLayout.js cannot make its own stats agree with itself.
function gateInRing(px, py, ring) {
let hit = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const [xi, yi] = ring[i], [xj, yj] = ring[j];
if ((yi > py) !== (yj > py) && px < ((xj - xi) * (py - yi)) / ((yj - yi) || 1e-12) + xi) hit = !hit;
}
return hit;
}
function gateInState(px, py, parts) {
for (const part of parts) {
const [outer, ...holes] = part;
if (!outer || outer.length < 3 || !gateInRing(px, py, outer)) continue;
if (!holes.some((h) => h && h.length >= 3 && gateInRing(px, py, h))) return true;
}
return false;
}
// Recount, from the OUTPUT pins, how many sit outside their own state. Projects the
// fixture feats to px the same way the module does (box.proj), keyed by shapeName.
function recountOutside(outPins, feats) {
const statesPx = {};
for (const f of feats) {
const sn = f.properties.shapeName;
const ps = f.geometry.type === 'Polygon' ? [f.geometry.coordinates] : f.geometry.coordinates;
const parts = ps.map((poly) => poly.map((ring) => ring.map(([lng, lat]) => box.proj(lng, lat))));
(statesPx[sn] = statesPx[sn] || []).push(...parts);
}
let out = 0;
for (const p of outPins) {
const parts = statesPx[p.state];
if (parts && !gateInState(p.x, p.y, parts)) out++;
}
return out;
}
function segCrossGate(p, q) {
const o = (ax, ay, bx, by, cx, cy) => Math.sign((bx - ax) * (cy - ay) - (by - ay) * (cx - ax));
const [a, b] = p, [c, d] = q;
return o(a[0], a[1], b[0], b[1], c[0], c[1]) !== o(a[0], a[1], b[0], b[1], d[0], d[1]) &&
o(c[0], c[1], d[0], d[1], a[0], a[1]) !== o(c[0], c[1], d[0], d[1], b[0], b[1]);
}
// ── 60x96 classic4 geometry (the 60x96c4b FORMATS entry: w2500 h4000 gutter165) ──
const PW = 2500, PH = 4000, PXI = PW / 60; // pageWidthIn 60
const SHEET_EDGE = 26, GUTTER = 165;
const HEADER = 355, FOOTER = 316;
@ -73,6 +115,33 @@ if (!four.stats.numbersRingOrdered) fails.push('fourSides: numbers not ring-orde
if (four.stats.numberDupes !== 0) fails.push(`fourSides: ${four.stats.numberDupes} duplicate numbers`);
if (four.stats.pinsOutsideState !== 0) fails.push(`fourSides: ${four.stats.pinsOutsideState} pins outside their state`);
if (four.stats.placed < 800) fails.push(`fourSides: only ${four.stats.placed} pins placed (floor 800) — vacuous`);
// (3) SIDE SEQUENCE: numbering must run L < T < R < B, and be monotone within each
// side — numbersRingOrdered alone (1..N, no gap) passes a scrambled side order too.
{
const SEQ = ['L', 'T', 'R', 'B'];
const bySide = { L: [], T: [], R: [], B: [] };
for (const p of four.pins) (bySide[p.side] || (bySide[p.side] = [])).push(p.num);
// side ranges from the pins (for the L<T<R<B sequence)
let lo = 0, seqOk = true;
for (const sd of SEQ) {
const nums = bySide[sd] || [];
for (const n of nums) if (n <= lo) seqOk = false;
if (nums.length) lo = Math.max(lo, ...nums);
}
// monotone WITHIN a side = numbers increase in LABEL EMISSION order (ring order),
// which is the real placement order (four.pins is input order, so it cannot test this).
let monoOk = true;
for (const sd of SEQ) {
const seq = four.labels.filter((l) => l.side === sd).map((l) => l.num);
for (let i = 1; i < seq.length; i++) if (seq[i] <= seq[i - 1]) { monoOk = false; break; }
}
if (!seqOk) fails.push('fourSides: numbers not in side sequence L<T<R<B');
if (!monoOk) fails.push('fourSides: numbers not monotone within a side');
// (4) RECOUNT outside-state from the OUTPUT (pins vs state rings), not from stats.
const outFx = recountOutside(four.pins, feats);
if (outFx !== 0) fails.push(`fourSides: recount found ${outFx} pins outside their state (stats said ${four.stats.pinsOutsideState})`);
note.push(`fourSides check: sideSeq ${seqOk}, monotone ${monoOk}, outside(recount) ${outFx}`);
}
// ── 2. pairedRows ─────────────────────────────────────────────────────────────
const paired = await layoutCircular({ pins, states: null, box, mode: 'pairedRows', opts: optsBase });
@ -81,6 +150,21 @@ if (paired.stats.crossings !== 0) fails.push(`pairedRows: ${paired.stats.crossin
if (paired.stats.pinsOutsideState !== 0) fails.push(`pairedRows: ${paired.stats.pinsOutsideState} pins outside their state`);
if (!paired.stats.octilinear) fails.push('pairedRows: a leader is not octilinear');
if (paired.stats.placed < 800) fails.push(`pairedRows: only ${paired.stats.placed} pins placed (floor 800)`);
// (4) RECOUNT pairedRows crossings from the OUTPUT leaders[].segs (one id per fork,
// skip same-id), and outside-state from the output pins — not from stats.
{
const segs = [];
paired.leaders.forEach((lf, li) => { for (const s of (lf.segs || [])) segs.push({ s, li }); });
let xr = 0;
for (let i = 0; i < segs.length; i++) for (let j = i + 1; j < segs.length; j++) {
if (segs[i].li === segs[j].li) continue;
if (segCrossGate(segs[i].s, segs[j].s)) xr++;
}
if (xr !== 0) fails.push(`pairedRows: recount found ${xr} crossings (stats said ${paired.stats.crossings})`);
const outPr = recountOutside(paired.pins, feats);
if (outPr !== 0) fails.push(`pairedRows: recount found ${outPr} pins outside their state`);
note.push(`pairedRows check: crossings(recount) ${xr}, outside(recount) ${outPr}`);
}
// ── 3. sideLanes ──────────────────────────────────────────────────────────────
const sideFmt = { nameMaxPt: 14, sideLanes: true, photos: false, pageWidthIn: 60, w: PW, h: PH };
@ -109,7 +193,7 @@ if (JSON.stringify(four.labels) !== JSON.stringify(four2.labels) || JSON.stringi
// ── 6. budget (step 2 node half): median of 3, heap delta ───────────────────────
// BASELINE: measured on this box at ~1.0-1.4s for 855 pins (fourSides, placePinsInState
// 400 iters). Budget fails at 1.5x of 3500ms = 5250ms to leave headroom on a slow CI
// box; the browser half (390px, 6x CPU throttle) is recorded separately by cities-plugin.
// box; the browser half (390px, 6x CPU throttle) is measured separately on the shared browser.
const BUDGET_MS = 5250;
const HEAP_CEIL_MB = 300;
const times = [];

View file

@ -581,7 +581,7 @@
"check:packet-strings-parity": "the PACKET catalog (packet-strings.json) must be whole in every language: all string keys, all 12 prayer points with every field, all 12 wheel rhythms. check:i18n-parity covers library/i18n/locales only — six gates READ this file and none checked it. Asserts PRESENCE, not translatedness, with point4_passage declared-absent on record.",
"check:packet-variants": "the poster wall's Prayer List prints the PACKET (not the columnar list), and every variant it offers exists in packet-render's VARIANTS. A variant may drop fixed pages but never people: lightweight keeps every group. By the UPG-100 owner; mutation-tested. Headless; in gates:pre.",
"check:pins-in-state": "library/packet/pins-in-state.js places whole-India poster pins so no two overlap AND every pin stays inside its own ADM1 state polygon, at the 60x96in sheet scale (2500x4000px, PIN_R 8). A deterministic repulsion relaxation (imitating poster-render.js, read not called) with the map-rect clamp replaced by a per-state multipolygon clamp: a pin pushed off its land is pulled to the nearest point just inside, holes and multi-part states (islands/coasts) honoured. Runs on the real India 855-pin data when present, else a synthetic 60x96 fixture (floor 200 pins, no vacuous pass); asserts 0 pins outside their state and 0 overlaps that are not NAMED in `infeasible` (a too-small state is reported, never silently stacked), plus a hole/island arm and a poison arm. Node only, headless, deterministic.",
"check:circular-layout": "library/packet/circularLayout.js is the poster circular-logic (ring order + leader routing + numbered four-sides + paired-row forks) as one pure Worker-safe module: numbers in, geometry out (positions/polylines/stats), no SVG. Ported from poster-render.js + reg-post classic4. Modes sideLanes (today L/R), fourSides (design 1: numbered, no leaders, 0 crossings), pairedRows (design 2: two leaders/row, 0 crossings). Gate asserts on a frozen 855-pin 60x96 fixture: fourSides ring-ordered + 0 dupes + 0 pins-outside-state (floor 800), pairedRows 0 crossings + octilinear, sideLanes runs octilinear, determinism, a poison (too-small state named infeasible), and a node time/heap budget. measure() passed in (CHADV x WFUDGE default). Deterministic; ties by id. Node only, headless.",
"check:circular-layout": "library/packet/circularLayout.js is the poster circular-logic (ring order + leader routing + numbered four-sides + paired-row forks) as one pure Worker-safe module: numbers in, geometry out (positions/polylines/stats), no SVG. Ported from poster-render.js + the classic4 four-sides reference. Modes sideLanes (today L/R), fourSides (design 1: numbered, no leaders, 0 crossings), pairedRows (design 2: two leaders/row, 0 crossings). Gate asserts on a frozen 855-pin 60x96 fixture: fourSides ring-ordered + 0 dupes + 0 pins-outside-state (floor 800), pairedRows 0 crossings + octilinear, sideLanes runs octilinear, determinism, a poison (too-small state named infeasible), and a node time/heap budget. measure() passed in (CHADV x WFUDGE default). Deterministic; ties by id. Node only, headless.",
"check:label-key-coverage": "node internal/check-label-key-coverage.mjs",
"check:host-locale-fetch": "node internal/check-host-locale-fetch.mjs",
"check:packet-a1-structure": "the prayer packet is built to its specified structure, read page by page from each sheet's CONTENT: full detail = map, triangle, life calendar, % evangelical, profiles, bounded set, centered set, wheel, points; lightweight = map, triangle, profiles, wheel, points. Page 1 places a map-only vertical sheet on the packet's own margin and says it is one of the UPG 100 prayer lists only when a count is supplied; page 2 is one inverted triangle whose packets tier and \"- N\" steps appear only where true; the wheel carries the Metacamp Module 1 PDF's colours and opens at nine o'clock; all 28 legend zones resolve, Deaf included.",