diff --git a/library/packet/circularLayout.js b/library/packet/circularLayout.js index 6c48124cf..16dcc5b10 100644 --- a/library/packet/circularLayout.js +++ b/library/packet/circularLayout.js @@ -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; diff --git a/library/packet/scripts/check-circular-layout.mjs b/library/packet/scripts/check-circular-layout.mjs index 778da3cd5..7a28761af 100644 --- a/library/packet/scripts/check-circular-layout.mjs +++ b/library/packet/scripts/check-circular-layout.mjs @@ -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 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 { 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 = []; diff --git a/package.json b/package.json index 1006cc090..82cb81026 100644 --- a/package.json +++ b/package.json @@ -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.",