diff --git a/README.md b/README.md
index af2c5bafb..6bb9c19db 100644
--- a/README.md
+++ b/README.md
@@ -697,25 +697,29 @@ your own page, set `"locale"` in its `profile-config`. Arabic reads right to lef
**Many AI agents can build many plugins at once without breaking each other's work.** Every plugin
has one agent that owns it, and the work moves through three kinds of branch:
-- **The pilot branch (`development`)** is the shared working version. A development server with hot
- reload follows it, so each change that lands can be seen there within about 15 seconds.
-- **The landing branch (`main`)** is production. It moves only when you publish, and the production
- server rebuilds from it.
-- **PR branches** hold one feature each. They start from the landing branch and land on the pilot
- branch, one at a time, after the checks pass.
+- **The pilot branch (`development`)** is where reviewed PR branches come together. A pilot server
+ with hot reload follows it, so each change that joins can be seen there within about 15 seconds.
+- **The landing branch (`main`)** is what ships. It moves only when a person approves a publish, and
+ the landing server rebuilds from it.
+- **PR branches** hold one plugin or feature each. They start from the landing branch and join the
+ pilot branch, one at a time, after the checks pass and a second reader has read the change.
+
+**It scales by adding PR branches, not by sharing one.** Many people, each with several agents, can
+work on many plugins at the same time: every plugin or feature gets its own PR branch, and each one
+can be seen on the pilot server as soon as it joins the pilot.
**Bring your own harness.** dx-maps works with whatever AI coding agent or agent harness you already
use. The repo gives you the branch strategy below, the checks (`bun run gates:pre`) and the Plugin
Studio; it does not ship a harness. Roles such as a coordinator that puts the merges onto the pilot in order, or a
gate-keeper that reads each change before it lands, are jobs your harness gives to its agents.
-
+
[Open the diagram full size](documentation/readme-images/delegation-model.png): on a phone its small text needs it.
### Set up the branch strategy
-1. **The landing branch (`main`) is production.** It moves only when you publish (step 7).
+1. **The landing branch (`main`) is what ships.** It moves only when a person approves a publish (step 7).
2. **The pilot branch (`development`) is the shared working branch.** Create it once, from `main`:
```bash
git fetch origin && git switch -c development origin/main && git push -u origin development
@@ -732,7 +736,7 @@ gate-keeper that reads each change before it lands, are jobs your harness gives
```bash
bun x vite --port 5301 --strictPort # the next agent uses 5302, and so on
```
-5. **Land on the pilot one at a time, through a lock.** Inside the lock, merge your PR branch into a
+5. **Join the pilot one at a time, through one lock (a merge queue).** Inside the lock, merge your PR branch into a
throwaway copy of the pilot branch, run the checks, and push that merge only if nothing fails
(`gates:pre` exits non-zero when a check fails, so the push never runs then). Your PR branch itself
stays one feature on the landing branch; only the pilot gets the merge:
@@ -751,8 +755,8 @@ gate-keeper that reads each change before it lands, are jobs your harness gives
BUILD_ALLOW=1 bun run build # the build owner: every plugin
BUNDLE= bun x vite build && bun run tree && bun run fix:sri # everyone else: one plugin
```
-7. **Publish:** fast-forward the landing branch to a pilot commit that passed the checks, tag it, and
- deploy from the tag:
+7. **Publish, when a person approves it:** fast-forward the landing branch to a pilot commit that
+ passed the checks, tag it, and deploy from the tag:
```bash
git fetch origin && git switch main && git merge --ff-only origin/main
git merge --ff-only # a commit that passed the checks
@@ -760,12 +764,14 @@ gate-keeper that reads each change before it lands, are jobs your harness gives
git tag -a v1.0.0 -m "Release v1.0.0" && git push origin main v1.0.0
```
The tag's `public/dx-maps/` folder is the built site: deploy that
- ([how to deploy](./documentation/DEPLOY.md)).
+ ([how to deploy](./documentation/DEPLOY.md)). Publish to a public host, such as GitHub, only from
+ the landing branch and only when it is ready.
### Developing in the cloud with a swarm
-Keep the repository on a forge you host yourself: a swarm pushes often, and on a public host one wrong
-push publishes work you did not mean to share. Give each app two servers: a **pilot server** that
+Keep the repository on a git service you host yourself: a swarm pushes often, and an agent can
+publish by accident. On a public host, one wrong push publishes work you did not mean to share, so
+publish there only from the landing branch, when it is ready. Give each app two servers: a **pilot server** that
follows the pilot branch with hot reload, and a **landing server** that rebuilds from the landing
branch. The cloud container in `deployment/cloud/` runs both today (`BRANCH=development` for the pilot server,
`PRODUCTION_BRANCH=main` for the landing server; `BRANCH` defaults to `main`). One pilot server per app, rather
@@ -798,7 +804,7 @@ So the project works like a busy kitchen:
pilot branch only if nothing fails. The pilot branch always works.
3. **Someone keeps order.** A coordinator can put the merges onto the pilot in order, and a gate-keeper can read
each change by its content before it lands. Your harness gives these jobs to its agents.
-4. **The landing branch moves only when you publish**, after the checks pass on that exact version.
+4. **The landing branch moves only when a person approves a publish**, after the checks pass on that exact version.
The pilot server follows the pilot branch; the production server rebuilds from the landing branch.
That's how many plugins get built at the same time, in one project, without the maps breaking.
diff --git a/documentation/readme-images/delegation-model.png b/documentation/readme-images/delegation-model.png
index ad16d0da6..a4d7711da 100644
Binary files a/documentation/readme-images/delegation-model.png and b/documentation/readme-images/delegation-model.png differ
diff --git a/documentation/readme-images/src/delegation-model.svg b/documentation/readme-images/src/delegation-model.svg
index a773495cc..985018654 100644
--- a/documentation/readme-images/src/delegation-model.svg
+++ b/documentation/readme-images/src/delegation-model.svg
@@ -1,4 +1,4 @@
-
diff --git a/library/packet/circularLayout.js b/library/packet/circularLayout.js
new file mode 100644
index 000000000..6740824eb
--- /dev/null
+++ b/library/packet/circularLayout.js
@@ -0,0 +1,487 @@
+/**
+ * circularLayout.js — the poster "circular logic" (ring order + leader routing +
+ * numbered four-sides + paired-row forks) as ONE pure, Worker-safe module.
+ *
+ * Numbers in, geometry out. The module returns POSITIONS, POLYLINES and STATS —
+ * never SVG/HTML strings (those stay in poster-render.js). It is pure: no DOM, no
+ * Vue, no fetch, no canvas, no module-level mutable format state, no Math.random,
+ * and no reliance on Set/Map iteration order (every tie is broken by id). The same
+ * 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
+ * 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.
+ *
+ * layoutCircular({ pins, states, box, mode, opts }) -> { numbers, pins, rows, leaders, badges, stats }
+ *
+ * MODES
+ * 'sideLanes' — today's large-sheet L/R layout: octilinear leaders in margin
+ * lanes, photo slots. Pins are NOT moved (byte identity in step 1).
+ * 'fourSides' — design (1): NUMBERED, NO leaders. Numbers in the pin and before
+ * the name; names on all four sides; 0 crossings by construction.
+ * Pins de-overlapped in-state via placePinsInState.
+ * 'pairedRows' — design (2): two groups per row, TWO leaders per row (octilinear
+ * forks), in ring order, 0 crossings; pins spread in-state.
+ *
+ * `box` carries the per-poster geometry the renderer already computes (all px):
+ * { PW, PH, MAP_X, MAP_W, MAP_Y, MAP_H, HEADER, FOOTER, SHEET_EDGE, GUTTER,
+ * C4_BAND, PXI, FRAME_X, FRAME_Y, FRAME_W, FRAME_H, LABEL_TOP, LABEL_BOT,
+ * proj(lng,lat)->[x,y] }
+ * `opts`:
+ * { measure(str,pt)->px, slotOf(pin,pt)->px, photoOf(pin)->url|null,
+ * fmt:{ nameMaxPt, photos, rangeBadge, classic4, fork, sideLanes, pageWidthIn,
+ * w, h }, seamYs:[y...], placePinsInState, forkRoute, feats:[...] }
+ *
+ * placePinsInState and forkRoute come from their sibling library modules (static
+ * imports below); a caller may still override either via opts for a Worker that
+ * injects its own. Both are pure ESM, so the static import stays Worker-portable.
+ */
+import { placePinsInState as libPlacePinsInState } from './pins-in-state.js';
+import { forkRoute as libForkRoute } from './fork-leaders.js';
+
+// ── text advance (lifted verbatim from poster-render.js: CHADV × WFUDGE) ───────
+// Worker-safe: a static char-advance table, no canvas. Callers may override via
+// opts.measure, but the default MUST equal the renderer's or the bytes move.
+const CHADV = (ch) => {
+ if ('iIjl|.,:;\'! '.includes(ch)) return 0.28;
+ if ('ftr()[]-'.includes(ch)) return 0.34;
+ if ('mwMW'.includes(ch)) return 0.86;
+ if ('ABCDEFGHKLNOPQRSTUVXYZ0123456789'.includes(ch)) return 0.63;
+ return 0.53;
+};
+const WFUDGE = 1.11;
+export const textW = (str, fs) => [...String(str)].reduce((a, ch) => a + CHADV(ch), 0) * fs * WFUDGE;
+
+// ── octilinear leader primitives (lifted verbatim from poster-render.js) ───────
+export function octiRoute(it, laneX, dirOut, dfrac) {
+ const STUB = 7;
+ const P = [it.px, it.py];
+ const xStub = it.px + dirOut * STUB;
+ const dy = it.labelY - it.py;
+ const vdir = dy === 0 ? 0 : Math.sign(dy);
+ const gap = Math.abs(xStub - laneX);
+ const diag = Math.max(0, Math.min(dfrac * Math.abs(dy), gap));
+ const feeler = gap - diag;
+ const pts = [P, [xStub, it.py]];
+ pts.push([xStub + dirOut * feeler, it.py]);
+ pts.push([laneX, it.py + vdir * diag]);
+ pts.push([laneX, it.labelY]);
+ return pts;
+}
+export function isOctilinear(pts, eps = 0.6) {
+ for (let i = 0; i < pts.length - 1; i++) {
+ const dx = Math.abs(pts[i + 1][0] - pts[i][0]), dy = Math.abs(pts[i + 1][1] - pts[i][1]);
+ if (dx < eps || dy < eps) continue;
+ if (Math.abs(dx - dy) < eps) continue;
+ return false;
+ }
+ 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;
+ const same = (u, v) => Math.abs(u[0] - v[0]) < 0.5 && Math.abs(u[1] - v[1]) < 0.5;
+ if (same(a, c) || same(a, d) || same(b, c) || same(b, d)) return false;
+ const o1 = o(...a, ...b, ...c), o2 = o(...a, ...b, ...d), o3 = o(...c, ...d, ...a), o4 = o(...c, ...d, ...b);
+ return o1 !== o2 && o3 !== o4 && o1 !== 0 && o2 !== 0 && o3 !== 0 && o4 !== 0;
+}
+// Count crossings between polylines, skipping same-polyline pairs (same li). A
+// FORK must be passed as ONE polyline id (its unique segs), never its two polys.
+export function countPolyCrossings(polys) {
+ const segs = [];
+ polys.forEach((pts, li) => { for (let i = 0; i < pts.length - 1; i++) segs.push({ s: [pts[i], pts[i + 1]], li }); });
+ let n = 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 (segCrossXY(segs[i].s, segs[j].s)) n++;
+ }
+ return n;
+}
+// A leader that passes within r of ANOTHER group's dot ("touch"). segs: array of
+// { li, pts }, dots: [{ id, x, y }]. Skips the leader's own endpoint dot by id.
+function countTouches(leaderPolys, dots, r) {
+ let touches = 0;
+ for (const lp of leaderPolys) {
+ for (const dot of dots) {
+ if (dot.id === lp.id) continue;
+ if (polyNearPoint(lp.pts, dot.x, dot.y, r)) touches++;
+ }
+ }
+ return touches;
+}
+function polyNearPoint(pts, px, py, r) {
+ for (let i = 0; i < pts.length - 1; i++) {
+ if (distPointToSeg(px, py, pts[i][0], pts[i][1], pts[i + 1][0], pts[i + 1][1]) <= r) return true;
+ }
+ return false;
+}
+function distPointToSeg(px, py, ax, ay, bx, by) {
+ const abx = bx - ax, aby = by - ay;
+ const len2 = abx * abx + aby * aby;
+ let t = len2 ? ((px - ax) * abx + (py - ay) * aby) / len2 : 0;
+ t = t < 0 ? 0 : t > 1 ? 1 : t;
+ const x = ax + t * abx, y = ay + t * aby;
+ return Math.hypot(px - x, py - y);
+}
+
+// ── point-in-ring / state polygon helpers (for in-state de-overlap inputs) ─────
+function inRing(lng, lat, r) {
+ let o = false;
+ for (let i = 0, j = r.length - 1; i < r.length; j = i++) {
+ const xi = r[i][0], yi = r[i][1], xj = r[j][0], yj = r[j][1];
+ if (((yi > lat) !== (yj > lat)) && (lng < (xj - xi) * (lat - yi) / (yj - yi) + xi)) o = !o;
+ }
+ return o;
+}
+function inPoly(lng, lat, p) {
+ if (!inRing(lng, lat, p[0])) return false;
+ for (let k = 1; k < p.length; k++) if (inRing(lng, lat, p[k])) return false;
+ return true;
+}
+function inFeat(lng, lat, g) {
+ return g.type === 'Polygon' ? inPoly(lng, lat, g.coordinates)
+ : g.type === 'MultiPolygon' ? g.coordinates.some((p) => inPoly(lng, lat, p)) : false;
+}
+// Nearest ADM1 shapeName for an off-polygon point (island fallback), lifted from
+// classic4Layout.nearest. Deterministic: first feature wins a tie by scan order.
+function nearestState(lng, lat, feats) {
+ let best = null, bd = Infinity;
+ const cl = Math.cos(lat * Math.PI / 180);
+ for (const f of feats) {
+ const ps = f.geometry.type === 'Polygon' ? [f.geometry.coordinates] : f.geometry.coordinates;
+ for (const po of ps) for (const r of po) for (let i = 0, j = r.length - 1; i < r.length; j = i++) {
+ const ax = r[j][0], ay = r[j][1], bx = r[i][0], by = r[i][1];
+ const dx = (bx - ax) * cl, dy = by - ay, pax = (lng - ax) * cl, pay = lat - ay, l2 = dx * dx + dy * dy;
+ let t = l2 ? (pax * dx + pay * dy) / l2 : 0; t = Math.max(0, Math.min(1, t));
+ const qx = pax - t * dx, qy = pay - t * dy, d = qx * qx + qy * qy;
+ if (d < bd) { bd = d; best = f.properties.shapeName; }
+ }
+ }
+ return best;
+}
+// Build { shapeName: polygonsPx } for placePinsInState, projecting every ring.
+function statesToPx(feats, proj) {
+ 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]) => proj(lng, lat))));
+ (statesPx[sn] = statesPx[sn] || []).push(...parts);
+ }
+ return statesPx;
+}
+
+// ── dependency resolution (injected; lazy Node fallback) ───────────────────────
+function resolveDeps(opts) {
+ // The library implementations by default; a Worker may inject its own via opts.
+ return {
+ placePinsInState: (opts && opts.placePinsInState) || libPlacePinsInState,
+ forkRoute: (opts && opts.forkRoute) || libForkRoute,
+ };
+}
+
+// ── clustering (id-stable; CL_R on the given coordinate pair) ──────────────────
+// Scans pins in input order; nearest existing cluster within CL_R wins, ties by
+// cluster creation order (first found). Deterministic — no Map/Set ordering.
+function clusterBy(pins, CL_R, getX, getY) {
+ const clusters = [];
+ for (const p of pins) {
+ let best = null, bd = CL_R;
+ for (const c of clusters) {
+ const d = Math.hypot(getX(c) - getX(p), getY(c) - getY(p));
+ if (d <= bd) { bd = d; best = c; }
+ }
+ if (best) {
+ best.items.push(p);
+ best._sx += (getX(p) - best._sx) / best.items.length;
+ best._sy += (getY(p) - best._sy) / best.items.length;
+ } else {
+ clusters.push({ items: [p], _sx: getX(p), _sy: getY(p) });
+ }
+ }
+ return clusters;
+}
+
+// ───────────────────────────────── MAIN ───────────────────────────────────────
+export async function layoutCircular({ pins, states, box, mode, opts = {} }) {
+ if (!Array.isArray(pins)) throw new TypeError('pins must be an array');
+ if (!box || typeof box !== 'object') throw new TypeError('box (geometry) is required');
+ if (!['sideLanes', 'fourSides', 'pairedRows'].includes(mode)) throw new TypeError(`unknown mode: ${mode}`);
+ const measure = opts.measure || textW;
+ const fmt = opts.fmt || {};
+ const { placePinsInState, forkRoute } = resolveDeps(opts);
+ if (mode === 'fourSides') return fourSides({ pins, box, opts, fmt, measure, placePinsInState });
+ if (mode === 'pairedRows') return pairedRows({ pins, box, opts, fmt, measure, placePinsInState, forkRoute });
+ return sideLanes({ pins, box, opts, fmt, measure });
+}
+
+// ── FOUR-SIDES (design 1): numbered, no leaders. Ported from classic4Layout. ───
+function fourSides({ pins0 = null, pins, box, opts, fmt, measure, placePinsInState }) {
+ const { PW, PH, MAP_X, MAP_W, MAP_Y, MAP_H, HEADER, FOOTER, SHEET_EDGE, C4_BAND, PXI, FRAME_X, FRAME_Y, FRAME_W, FRAME_H, proj } = box;
+ const feats = opts.feats || [];
+ const RANGE = !!fmt.rangeBadge;
+ // project pins; px/py are the engine-settled coords passed in on each pin as _px/_py.
+ const P = pins.map((p) => {
+ const [rx, ry] = proj ? proj(+p.lng, +p.lat) : [p._px, p._py];
+ return { ...p, px: p._px, py: p._py, rx, ry };
+ });
+ // state assignment + island fallback (deterministic scan order)
+ let islandFallback = 0;
+ for (const p of P) {
+ let st = null;
+ for (const f of feats) if (inFeat(+p.lng, +p.lat, f.geometry)) { st = f.properties.shapeName; break; }
+ if (!st) { st = nearestState(+p.lng, +p.lat, feats); if (st) islandFallback++; }
+ p._state = st;
+ }
+ // cluster co-located pins on RAW projected coords (CL_R=12), consecutive numbers
+ const CL_R = 12;
+ const clusters = [];
+ for (const p of P) {
+ let c = null, bd = CL_R;
+ for (const q of clusters) { const d = Math.hypot(q.rx - p.rx, q.ry - p.ry); if (d <= bd) { bd = d; c = q; } }
+ if (c) {
+ c.items.push(p);
+ c.rx += (p.rx - c.rx) / c.items.length; c.ry += (p.ry - c.ry) / c.items.length;
+ c.cx += (p.px - c.cx) / c.items.length; c.cy += (p.py - c.cy) / c.items.length;
+ } else clusters.push({ items: [p], rx: p.rx, ry: p.ry, cx: p.px, cy: p.py, state: p._state });
+ }
+ // geometry: L/R columns at the sheet edges; T/B rows on the reserved bands
+ const colTop = HEADER, colBot = PH - FOOTER, colH = colBot - colTop;
+ const lcolX = SHEET_EDGE + 6, rcolX = PW - SHEET_EDGE - 6;
+ const rowY_T = HEADER + C4_BAND - 8, rowX0 = MAP_X + 8, rowX1 = MAP_X + MAP_W - 8, rowW = rowX1 - rowX0;
+ const pitchCol = (f) => f * 1.14, pitchRow = (f) => f * 1.5;
+ const capCol = (f) => Math.floor(colH / pitchCol(f)), capRow = (f) => Math.floor(rowW / pitchRow(f));
+ const fCap = (fmt.nameMaxPt || 11) / 72 * PXI;
+ let font = 5;
+ for (let f = fCap; f >= 5; f -= 0.1) { if (2 * capRow(f) + 2 * capCol(f) >= P.length) { font = f; break; } }
+ // assign whole clusters to sides (balanced by member count)
+ const fRow = Math.min(capRow(font), Math.max(1, Math.floor(P.length * 0.22)));
+ const byCy = [...clusters].sort((a, b) => a.cy - b.cy || a.cx - b.cx);
+ const used = new Set();
+ const T = [], B = []; let cT = 0, cB = 0;
+ for (const c of byCy) { if (cT + c.items.length <= fRow) { T.push(c); cT += c.items.length; used.add(c); } else break; }
+ for (let i = byCy.length - 1; i >= 0; i--) { const c = byCy[i]; if (used.has(c)) break; if (cB + c.items.length <= fRow) { B.push(c); cB += c.items.length; used.add(c); } else break; }
+ const midc = clusters.filter((c) => !used.has(c)).sort((a, b) => a.cx - b.cx || a.cy - b.cy);
+ const halfN = Math.ceil(midc.reduce((a, c) => a + c.items.length, 0) / 2);
+ const L = [], R = []; let cL = 0;
+ for (const c of midc) { if (cL < halfN) { L.push(c); cL += c.items.length; } else R.push(c); }
+ L.sort((a, b) => b.cy - a.cy || a.cx - b.cx); R.sort((a, b) => a.cy - b.cy || a.cx - b.cx);
+ 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;
+ 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;
+ const maxBExt = Bp.length ? Math.max(...Bp.map((p) => nmLen(p) * font * 0.55 * Math.SQRT1_2)) : 0;
+ const rowY_B = Math.min(PH - FOOTER - 8, MAP_Y + MAP_H + maxBExt + 10);
+ // in-state de-overlap (clusters in range mode, else individual pins)
+ const rBadge = Math.max(5, font * 0.62);
+ const statesPx = statesToPx(feats, proj);
+ let dres;
+ if (RANGE) {
+ dres = placePinsInState({ pins: clusters.map((c, i) => ({ id: i, x: c.cx, y: c.cy, stateId: c.state })), states: statesPx, r: rBadge, gap: 1, iters: 400, maxDrift: 80 });
+ const byId = new Map(dres.pins.map((q) => [q.id, q]));
+ clusters.forEach((c, i) => { const q = byId.get(i); c.bx = q ? q.x : c.cx; c.by = q ? q.y : c.cy; });
+ } else {
+ dres = placePinsInState({ pins: P.map((p, i) => ({ id: i, x: p.px, y: p.py, stateId: p._state })), states: statesPx, r: rBadge, gap: 1, iters: 400, maxDrift: 80 });
+ const byId = new Map(dres.pins.map((q) => [q.id, q]));
+ P.forEach((p, i) => { const q = byId.get(i); if (q) { p.px = q.x; p.py = q.y; } });
+ }
+ // emit geometry (NOT svg): label positions + badge/dot positions
+ const labels = [];
+ 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 }));
+ // stats
+ const marks = RANGE ? clusters.map((c) => ({ px: c.bx, py: c.by })) : P;
+ const offFrame = marks.filter((q) => q.px < FRAME_X || q.px > FRAME_X + FRAME_W || q.py < FRAME_Y || q.py > FRAME_Y + FRAME_H).length;
+ const allNums = P.map((p) => p.num).sort((a, b) => a - b);
+ const numberDupes = allNums.filter((n, i) => i && n === allNums[i - 1]).length;
+ const numbersRingOrdered = allNums.every((n, i) => n === i + 1);
+ const stats = {
+ placed: P.length, total: pins.length, namePt: +(font / PXI * 72).toFixed(1),
+ crossings: 0, touches: 0, overflow: offFrame,
+ numberDupes, numbersRingOrdered,
+ pinsOutsideState: dres.outsideState, pinOverlaps: dres.overlapsRemaining,
+ pinsMoved: dres.moved, maxDisp: +dres.maxDisp.toFixed(1), islandFallback, infeasible: dres.infeasible.length,
+ 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, 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. ────────
+function pairedRows({ pins, box, opts, fmt, measure, placePinsInState, forkRoute }) {
+ const { MAP_X, MAP_W, MAP_Y, MAP_H, PXI, FRAME_X, FRAME_Y, FRAME_W, FRAME_H, proj } = box;
+ const feats = opts.feats || [];
+ const P = pins.map((p) => ({ ...p, px: p._px, py: p._py }));
+ // state assignment for spreading
+ for (const p of P) {
+ let st = null;
+ for (const f of feats) if (inFeat(+p.lng, +p.lat, f.geometry)) { st = f.properties.shapeName; break; }
+ if (!st) st = nearestState(+p.lng, +p.lat, feats);
+ p._state = st;
+ }
+ // spread pins in-state (design 2 moves pins)
+ const font = Math.max(5, (fmt.nameMaxPt || 11) / 72 * PXI);
+ const rPin = Math.max(5, font * 0.62);
+ const statesPx = statesToPx(feats, proj);
+ if (feats.length) {
+ const dres = placePinsInState({ pins: P.map((p, i) => ({ id: i, x: p.px, y: p.py, stateId: p._state })), states: statesPx, r: rPin, gap: 1, iters: 400, maxDrift: 80 });
+ const byId = new Map(dres.pins.map((q) => [q.id, q]));
+ P.forEach((p, i) => { const q = byId.get(i); if (q) { p.px = q.x; p.py = q.y; } });
+ var spread = { outsideState: dres.outsideState, overlaps: dres.overlapsRemaining, moved: dres.moved, maxDisp: dres.maxDisp, infeasible: dres.infeasible.length };
+ } else {
+ 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 || 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 }));
+ const bandTop = box.LABEL_TOP ?? MAP_Y, bandBot = box.LABEL_BOT ?? (MAP_Y + MAP_H);
+ 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 || 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
+ // pins and neighbouring forks cannot sweep across one another. (An evenly-pitched
+ // port put a row's port far from its pins and produced same-side crossings.)
+ for (let i = 0; i < col.length; i += 2) {
+ const a = col[i], b = col[i + 1] || col[i];
+ const rowY = (a.py + (b.py)) / 2;
+ const fork = forkRoute({
+ lane: { x: laneX, y: rowY },
+ a: { id: a.id, x: a.px, y: a.py }, b: { id: b.id, x: b.px, y: b.py },
+ dirOut, mode: 'row',
+ 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}`, polys: fork.polys, segs: fork.segs });
+ }
+ }
+ // crossings: one polyline id per fork (its deduped segs), skip same-id
+ // 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;
+ {
+ const segs = [];
+ perFork.forEach((forkSegs, li) => { for (const s of forkSegs) segs.push({ s, li }); });
+ 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 (segCrossXY(segs[i].s, segs[j].s)) crossings++;
+ }
+ }
+ const touches = rowsOut.reduce((a, r) => a + (r.throughPins ? r.throughPins.length : 0), 0);
+ const octilinear = rowsOut.every((r) => r.octilinear);
+ const offFrame = P.filter((q) => q.px < FRAME_X || q.px > FRAME_X + FRAME_W || q.py < FRAME_Y || q.py > FRAME_Y + FRAME_H).length;
+ const stats = {
+ placed: P.length, total: pins.length, rows: rowsOut.length, namePt: +(font / PXI * 72).toFixed(1),
+ crossings, touches, overflow: offFrame, octilinear,
+ pinsOutsideState: spread.outsideState, pinOverlaps: spread.overlaps,
+ pinsMoved: spread.moved, maxDisp: +(+spread.maxDisp).toFixed(1), infeasible: spread.infeasible,
+ };
+ return { numbers: P.map((p, i) => ({ id: p.id, num: i + 1 })), pins: P.map((p) => ({ id: p.id, x: p.px, y: p.py, state: p._state })), rows: rowsOut, leaders: leaderPolys.map((lp) => ({ id: lp.id, polys: lp.polys || null, segs: lp.segs })), badges: [], labels: [], dots: allDots, stats };
+}
+
+// ── SIDE-LANES (today's L/R large sheet). Ported from layoutLabels + lanes. ─────
+// Pins are NOT moved. Returns leader polylines (octilinear) + label ports + stats.
+function sideLanes({ pins, box, opts, fmt, measure }) {
+ const { MAP_X, MAP_W, MAP_Y, MAP_H, SHEET_EDGE, GUTTER, PXI, FRAME_X, FRAME_Y, FRAME_W, FRAME_H, proj } = box;
+ const photoOf = opts.photoOf || (() => null);
+ // project + clamp into the map rect (uses engine-settled _px/_py when present)
+ const items = pins.map((p) => {
+ const [lx, ly] = (p._fixed || p._px != null) ? [p._px, p._py] : proj(+p.lng, +p.lat);
+ const x = Math.max(MAP_X + 2, Math.min(MAP_X + MAP_W - 2, lx));
+ const y = Math.max(MAP_Y + 2, Math.min(MAP_Y + MAP_H - 2, ly));
+ 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 || 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);
+ const segCross = (a, b) => {
+ const d = (p1, p2, p3) => (p3.y - p1.y) * (p2.x - p1.x) - (p2.y - p1.y) * (p3.x - p1.x);
+ const A1 = { x: a.sx, y: a.sy }, A2 = { x: a.px, y: a.py };
+ const B1 = { x: b.sx, y: b.sy }, B2 = { x: b.px, y: b.py };
+ return (d(A1, A2, B1) > 0) !== (d(A1, A2, B2) > 0) && (d(B1, B2, A1) > 0) !== (d(B1, B2, A2) > 0);
+ };
+ // 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 || 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;
+ col.forEach((it, i) => { it.sx = sxCol; it.sy = LABEL_TOP + rowH0 * (i + 0.5); });
+ for (let pass = 0; pass < 60; pass++) {
+ let improved = false;
+ for (let i = 0; i < col.length - 1; i++) {
+ const a = col[i], b = col[i + 1];
+ const before = segCross(a, b) ? 1 : 0;
+ const ay = a.sy, by = b.sy; a.sy = by; b.sy = ay;
+ const after = segCross(a, b) ? 1 : 0;
+ if (after < before) { col[i] = b; col[i + 1] = a; improved = true; } else { a.sy = ay; b.sy = by; }
+ }
+ if (!improved) break;
+ }
+ col.forEach((it, i) => { it.ringIdx = i; });
+ }
+ // place rows in ring order, evenly pitched over the band; assign lanes
+ const SHEET_MARGIN = SHEET_EDGE + 8;
+ const LANE_EDGE_GAP = 6, LABEL_GAP = 6, LANE_PITCH_MAX = 3;
+ const LABEL_RES = GUTTER - SHEET_MARGIN - LANE_EDGE_GAP - 24;
+ const laid = [];
+ for (const side of ['L', 'R']) {
+ const col = items.filter((i) => i.side === side).sort((a, b) => a.ringIdx - b.ringIdx);
+ const n = col.length || 1;
+ const bandH = LABEL_BOT - LABEL_TOP, rowH = bandH / n;
+ const dirOut = side === 'L' ? -1 : 1;
+ const edge = side === 'L' ? MAP_X : MAP_X + MAP_W;
+ const band = MAP_X - LABEL_RES - LANE_EDGE_GAP - LABEL_GAP;
+ const pitch = Math.min(LANE_PITCH_MAX, band / Math.max(1, n - 1));
+ col.forEach((it, k) => {
+ it.labelY = LABEL_TOP + rowH * (k + 0.5);
+ it.laneX = edge + dirOut * (LANE_EDGE_GAP + k * pitch);
+ it.dirOut = dirOut;
+ it.tanchor = side === 'L' ? 'end' : 'start';
+ laid.push(it);
+ });
+ }
+ // route leaders at dfrac 0 (current dev sweep is [0]); octilinear by construction
+ const routesFor = (d) => laid.map((it) => octiRoute(it, it.laneX, it.dirOut, d));
+ let bestD = 0, bestX = Infinity;
+ for (const d of [0]) { const x = countPolyCrossings(routesFor(d)); if (x < bestX) { bestX = x; bestD = d; } }
+ const routes = routesFor(bestD);
+ const octiOK = routes.every((r) => isOctilinear(r));
+ const dots = laid.map((it) => ({ id: it.id, x: it.px, y: it.py }));
+ const touches = countTouches(routes.map((pts, i) => ({ id: laid[i].id, pts })), dots, 2);
+ const stats = {
+ placed: laid.length, total: pins.length, crossings: bestX, touches, overflow: 0,
+ octilinear: octiOK, dfrac: bestD, pinsOutsideState: 0,
+ photos: laid.filter((it) => photoOf(it)).length,
+ sides: { L: items.filter((i) => i.side === 'L').length, R: items.filter((i) => i.side === 'R').length },
+ };
+ return {
+ numbers: [], pins: laid.map((it) => ({ id: it.id, x: it.px, y: it.py })),
+ rows: [], badges: [],
+ labels: laid.map((it) => ({ x: it.laneX, y: it.labelY, anchor: it.tanchor, name: String(it.name) })),
+ leaders: routes.map((pts, i) => ({ id: laid[i].id, pts })),
+ dots, stats,
+ };
+}
+
+export default layoutCircular;
diff --git a/library/packet/scripts/check-circular-layout.mjs b/library/packet/scripts/check-circular-layout.mjs
new file mode 100644
index 000000000..f7f88f63f
--- /dev/null
+++ b/library/packet/scripts/check-circular-layout.mjs
@@ -0,0 +1,238 @@
+#!/usr/bin/env node
+/**
+ * check-circular-layout.mjs — gate for library/packet/circularLayout.js.
+ *
+ * Builds a FROZEN synthetic 855-pin India-scale fixture (sheet 2500x4000, the
+ * classic4 60x96 constants) with a grid of fake ADM1 "states", then asserts:
+ *
+ * fourSides — numbersRingOrdered true, numberDupes 0, pinsOutsideState 0,
+ * >= 800 pins placed (no vacuous pass).
+ * pairedRows — 0 crossings on the clean set, pinsOutsideState 0, octilinear true.
+ * sideLanes — runs, returns stats with a pin-count floor and octilinear leaders.
+ * determinism — two fourSides runs byte-identical.
+ * poison — a state far too small for its pins is NAMED in infeasible
+ * (never silently stacked), surfaced via stats.infeasible > 0.
+ * budget — fourSides on 855 pins runs under BUDGET_MS (1.5x a measured
+ * baseline) and under a heap ceiling. Median of 3, so it is not flaky.
+ *
+ * Node only, headless, deterministic. Exit 0 PASS / 1 FAIL / 75 import failure.
+ */
+let layoutCircular;
+try { ({ layoutCircular } = await import('../circularLayout.js')); }
+catch (e) { console.error(`NOT MEASURED: cannot import circularLayout.js — ${e.message}`); process.exit(75); }
+
+const fails = [], note = [];
+
+// 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;
+const C4_BAND = Math.round(PH * 0.096);
+const MAP_X = GUTTER, MAP_W = PW - 2 * GUTTER;
+const MAP_Y = HEADER + C4_BAND, MAP_H = PH - HEADER - FOOTER - 2 * C4_BAND;
+const FRAME_X = MAP_X, FRAME_Y = MAP_Y, FRAME_W = MAP_W, FRAME_H = MAP_H;
+
+// A linear "projection" over an India-ish bbox (68..98 lng, 6..37 lat) into the map rect.
+const LNG0 = 68, LNG1 = 98, LAT0 = 6, LAT1 = 37;
+const proj = (lng, lat) => [
+ MAP_X + ((lng - LNG0) / (LNG1 - LNG0)) * MAP_W,
+ MAP_Y + ((LAT1 - lat) / (LAT1 - LAT0)) * MAP_H,
+];
+const box = { PW, PH, MAP_X, MAP_W, MAP_Y, MAP_H, HEADER, FOOTER, SHEET_EDGE, GUTTER, C4_BAND, PXI, FRAME_X, FRAME_Y, FRAME_W, FRAME_H, proj };
+
+// ── fixture: 855 pins across a 6x5 grid of rectangular "states" (lng/lat space) ─
+// Deterministic PRNG (mulberry32) so pins are varied but fixed run-to-run.
+function rng(seed) { return () => { seed |= 0; seed = (seed + 0x6D2B79F5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; }
+function makeFixture(N) {
+ const cols = 6, rows = 5, feats = [];
+ const dLng = (LNG1 - LNG0) / cols, dLat = (LAT1 - LAT0) / rows;
+ for (let c = 0; c < cols; c++) for (let r = 0; r < rows; r++) {
+ const x0 = LNG0 + c * dLng, x1 = x0 + dLng, y0 = LAT0 + r * dLat, y1 = y0 + dLat;
+ feats.push({ properties: { shapeName: `S${c}_${r}` }, geometry: { type: 'Polygon', coordinates: [[[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]]] } });
+ }
+ const rand = rng(12345);
+ const pins = [];
+ for (let i = 0; i < N; i++) {
+ // keep a margin inside a state so the point-in-poly is unambiguous
+ const lng = LNG0 + 0.5 + rand() * (LNG1 - LNG0 - 1);
+ const lat = LAT0 + 0.5 + rand() * (LAT1 - LAT0 - 1);
+ const [px, py] = proj(lng, lat);
+ pins.push({ id: `p${i}`, name: `Group ${i} ${'abcdefgh'[i % 8]}`, lng, lat, _px: px, _py: py, image_url: null });
+ }
+ return { pins, feats };
+}
+
+const { pins, feats } = makeFixture(855);
+const fmt4 = { nameMaxPt: 11, classic4: true, rangeBadge: true, photos: false, pageWidthIn: 60, w: PW, h: PH };
+const optsBase = { fmt: fmt4, feats };
+
+// ── 1. fourSides ──────────────────────────────────────────────────────────────
+const four = await layoutCircular({ pins, states: null, box, mode: 'fourSides', opts: optsBase });
+note.push(`fourSides: placed ${four.stats.placed}, ringOrdered ${four.stats.numbersRingOrdered}, dupes ${four.stats.numberDupes}, outsideState ${four.stats.pinsOutsideState}, islandFallback ${four.stats.islandFallback}, namePt ${four.stats.namePt}, sides ${JSON.stringify(four.stats.sides)}`);
+if (!four.stats.numbersRingOrdered) fails.push('fourSides: numbers not ring-ordered 1..N');
+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 p.side === sd).sort((a, b) => a.num - b.num);
+ if (ps.length < 3) continue;
+ const [axis, dir] = AXIS[sd];
+ const coords = ps.map((p) => p[axis] * dir); // expected non-decreasing
+ const span = coords[coords.length - 1] - coords[0];
+ const tol = 0.6 * Math.abs(span) / (coords.length - 1); // 60% of mean step
+ let inversions = 0;
+ for (let i = 1; i < coords.length; i++) if (coords[i] < coords[i - 1] - tol) inversions++;
+ // a correct side has only small local drift inversions; a reversed sort inverts ~everything
+ if (inversions > coords.length * 0.2) { monoOk = false; monoWhy = `${sd}:${inversions}/${coords.length}`; }
+ }
+ 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`);
+ // (P3) every leader must actually carry its two polylines — a dropped polys passed before.
+ const badPolys = paired.leaders.filter((l) => !Array.isArray(l.polys) || l.polys.length !== 2).length;
+ if (badPolys !== 0) fails.push(`pairedRows: ${badPolys} leaders missing polys.length===2`);
+ note.push(`pairedRows check: crossings(recount) ${xr}, outside(recount) ${outPr}, leaders-with-2-polys ${paired.leaders.length - badPolys}/${paired.leaders.length}`);
+}
+
+// ── 3. sideLanes ──────────────────────────────────────────────────────────────
+const sideFmt = { nameMaxPt: 14, sideLanes: true, photos: false, pageWidthIn: 60, w: PW, h: PH };
+const side = await layoutCircular({ pins, states: null, box: { ...box, LABEL_TOP: HEADER - 40, LABEL_BOT: PH - FOOTER + 40 }, mode: 'sideLanes', opts: { fmt: sideFmt, feats } });
+note.push(`sideLanes: placed ${side.stats.placed}, crossings ${side.stats.crossings}, touches ${side.stats.touches}, octilinear ${side.stats.octilinear}, sides ${JSON.stringify(side.stats.sides)}`);
+if (side.stats.placed < 800) fails.push(`sideLanes: only ${side.stats.placed} placed (floor 800)`);
+if (!side.stats.octilinear) fails.push('sideLanes: leaders not all octilinear');
+
+// ── 4. determinism ────────────────────────────────────────────────────────────
+const four2 = await layoutCircular({ pins, states: null, box, mode: 'fourSides', opts: optsBase });
+if (JSON.stringify(four.labels) !== JSON.stringify(four2.labels) || JSON.stringify(four.dots) !== JSON.stringify(four2.dots)) fails.push('NON-DETERMINISTIC: two fourSides runs differ');
+
+// ── 5. poison — a tiny state cannot hold its pins → infeasible NAMED ───────────
+{
+ // one tiny state in lng/lat; pack many pins into it
+ const tinyFeats = [{ properties: { shapeName: 'TINY' }, geometry: { type: 'Polygon', coordinates: [[[80, 20], [80.02, 20], [80.02, 20.02], [80, 20.02], [80, 20]]] } }];
+ const tinyPins = [];
+ for (let i = 0; i < 40; i++) { const [px, py] = proj(80.01, 20.01); tinyPins.push({ id: `t${i}`, name: `T${i}`, lng: 80.01, lat: 20.01, _px: px, _py: py }); }
+ const pf = { nameMaxPt: 11, classic4: true, rangeBadge: false, photos: false, pageWidthIn: 60, w: PW, h: PH };
+ const pres = await layoutCircular({ pins: tinyPins, states: null, box, mode: 'fourSides', opts: { fmt: pf, feats: tinyFeats } });
+ note.push(`poison: infeasible ${pres.stats.infeasible}, outsideState ${pres.stats.pinsOutsideState}`);
+ if (pres.stats.infeasible < 1) fails.push('POISON: a too-small state was NOT named in infeasible');
+ if (pres.stats.pinsOutsideState !== 0) fails.push(`POISON: ${pres.stats.pinsOutsideState} pins escaped the tiny state`);
+}
+
+// ── 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 measured separately on the shared browser.
+const BUDGET_MS = 5250;
+const HEAP_CEIL_MB = 300;
+const times = [];
+let heapDelta = 0;
+for (let k = 0; k < 3; k++) {
+ if (global.gc) global.gc();
+ const h0 = process.memoryUsage().heapUsed;
+ const t0 = process.hrtime.bigint();
+ await layoutCircular({ pins, states: null, box, mode: 'fourSides', opts: optsBase });
+ const t1 = process.hrtime.bigint();
+ times.push(Number(t1 - t0) / 1e6);
+ heapDelta = Math.max(heapDelta, (process.memoryUsage().heapUsed - h0) / 1048576);
+}
+times.sort((a, b) => a - b);
+const medMs = times[1];
+note.push(`budget: fourSides median ${medMs.toFixed(0)}ms (runs ${times.map((t) => t.toFixed(0)).join('/')}), heapΔ ~${heapDelta.toFixed(0)}MB; budget ${BUDGET_MS}ms / ${HEAP_CEIL_MB}MB`);
+if (medMs > BUDGET_MS) fails.push(`budget: ${medMs.toFixed(0)}ms over ${BUDGET_MS}ms`);
+if (heapDelta > HEAP_CEIL_MB) fails.push(`budget: heapΔ ${heapDelta.toFixed(0)}MB over ${HEAP_CEIL_MB}MB`);
+
+for (const n of note) console.log(` · ${n}`);
+if (fails.length) {
+ console.error('FAIL check:circular-layout');
+ for (const f of fails) console.error(` ✗ ${f}`);
+ process.exit(1);
+} else {
+ console.log(`PASS check:circular-layout — fourSides ring-ordered 0-dupes 0-outside, pairedRows 0-crossings, sideLanes octilinear, deterministic, poison named, ${medMs.toFixed(0)}ms < ${BUDGET_MS}ms budget`);
+}
diff --git a/package.json b/package.json
index 7a3474453..a9750bab0 100644
--- a/package.json
+++ b/package.json
@@ -225,6 +225,7 @@
"check:selector-fields:self-test": "node internal/check-selector-fields.mjs --self-test",
"check:packet-strings-parity": "node internal/check-packet-strings-parity.mjs",
"check:packet-variants": "node internal/check-packet-variants.mjs",
+ "check:circular-layout": "node library/packet/scripts/check-circular-layout.mjs",
"check:fork-leaders": "node library/packet/scripts/check-fork-leaders.mjs",
"check:pins-in-state": "node library/packet/scripts/check-pins-in-state.mjs",
"check:label-key-coverage": "node internal/check-label-key-coverage.mjs",
@@ -356,7 +357,7 @@
"fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:script-flags check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:auto-engine check:engine-contract check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:no-silent-engine:self-test check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:region-paint-single-source check:poster-print-fallback check:posters-exportable check:country-names check:reduced-motion check:no-dead-library check:universal-props check:readme-size check:tree-relative check:leaf-hints check:snippet-engine check:snippet-engine:self-test check:embed-base check:link-coverage check:no-public-binds check:shared-reached check:tree-weight check:no-fleet-names:self-test check:pin-hint-floor:self-test check:poster-sizes:self-test check:shell-header:self-test check:preview-flag-dev-only:self-test check:poster-type-registry:self-test check:host-floor:self-test check:phone-print-keep:self-test",
- "gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:pins-in-state check:fork-leaders check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:india-states check:batch-selection-export check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-internal-role-nouns check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:country-sheet-one-path check:country-sheet-one-path:self-test check:side-lanes check:side-lanes:self-test check:poster-section check:poster-section:self-test check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch check:panel-sheets check:poster-wall-host-seams check:poster-sections check:poster-sections:self-test check:poster-design-catalogue check:poster-design-catalogue:self-test check:wall-photo-weight check:no-fleet-names check:pin-hint-floor check:poster-sizes check:upg-count check:shell-header check:pg-name-gate check:preview-flag-dev-only check:poster-type-registry check:host-floor check:phone-print-keep",
+ "gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:pins-in-state check:fork-leaders check:circular-layout check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:india-states check:batch-selection-export check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-internal-role-nouns check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:country-sheet-one-path check:country-sheet-one-path:self-test check:side-lanes check:side-lanes:self-test check:poster-section check:poster-section:self-test check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch check:panel-sheets check:poster-wall-host-seams check:poster-sections check:poster-sections:self-test check:poster-design-catalogue check:poster-design-catalogue:self-test check:wall-photo-weight check:no-fleet-names check:pin-hint-floor check:poster-sizes check:upg-count check:shell-header check:pg-name-gate check:preview-flag-dev-only check:poster-type-registry check:host-floor check:phone-print-keep",
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
"library-index": "node internal/generate-library-index.mjs",
@@ -589,6 +590,7 @@
"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:fork-leaders": "library/packet/fork-leaders.js routes ONE paired-row leader as an octilinear fork: a stem forks at a 45° chamfer into two short H/V legs, one per pin. Modes row and trunk. Returns polylines + a deduped segs list (one polyline id per fork for crossings). Reports octilinear + throughPins. Pure, px-only, deterministic. The gate asserts both modes octilinear, legs land on pins, 0 crossings on a 220-row set (floor 200), through-pin hit+miss, determinism, and a poison. 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, no SVG. Modes sideLanes, fourSides (numbered, no leaders), pairedRows (two leaders/row). forkRoute + placePinsInState are static imports (opts may override). Gate asserts on a frozen 855-pin 60x96 fixture: fourSides ring-ordered + 0 dupes + 0 pins-outside-state + side sequence L