Merge remote-tracking branch 'origin/development' into drp/gospel-railname

This commit is contained in:
Doxa Maps 2026-10-02 18:50:32 +00:00
commit e5e5b55e77
6 changed files with 759 additions and 21 deletions

View file

@ -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.
![The delegation model: the pilot branch (development) at the centre behind the pilot lock; publishing to the landing branch (main) and its outputs below it; inside the Plugin Studio; eight plugins around it, each owned by one agent; a coordinator and a gate-keeper over the agents; below, how a change lands in four steps, and the three outputs](documentation/readme-images/delegation-model.png)
![The delegation model: the pilot branch (development) at the centre behind the pilot lock; publishing to the landing branch (main) and its outputs below it; inside the Plugin Studio; eight plugins around it, each owned by one agent; a coordinator and a gate-keeper over the agents; below, how a change lands in four steps, the three outputs, and how it scales: one PR branch per plugin, a self-hosted git service, and publishing to a public host only from the landing branch](documentation/readme-images/delegation-model.png)
<sub>[Open the diagram full size](documentation/readme-images/delegation-model.png): on a phone its small text needs it.</sub>
### 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=<name> 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 <development-commit> # 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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 767 KiB

After

Width:  |  Height:  |  Size: 926 KiB

View file

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1500" height="2086" viewBox="0 0 1500 2086" font-family="Inter, 'Helvetica Neue', Arial, sans-serif">
<svg xmlns="http://www.w3.org/2000/svg" width="1500" height="2256" viewBox="0 0 1500 2256" font-family="Inter, 'Helvetica Neue', Arial, sans-serif">
<defs>
<marker id="aA" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#2563eb"/></marker>
<marker id="aS" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#7c3aed"/></marker>
@ -9,7 +9,7 @@
<path id="agL" d="M 194,850 A 556,556 0 0 1 1306,850"/>
<path id="mqL" d="M 679,884 A 71,71 0 0 0 821,884"/>
</defs>
<rect width="1500" height="2086" fill="#ffffff"/>
<rect width="1500" height="2256" fill="#ffffff"/>
<text x="750" y="54" text-anchor="middle" font-size="34" font-weight="800" fill="#0f172a">How dx-maps is built: one owner per plugin, one merge at a time</text>
<circle cx="750" cy="850" r="540" fill="none" stroke="#2563eb" stroke-width="1.6" stroke-dasharray="3 7" opacity=".75"/>
<circle cx="750" cy="850" r="355" fill="none" stroke="#15803d" stroke-width="2.2" opacity=".85"/>
@ -173,12 +173,12 @@
<text x="1149.0" y="1641" text-anchor="middle" font-size="19" font-weight="800" fill="#fff">4</text>
<text x="1177.0" y="1641" font-size="19" font-weight="800" fill="#0f172a">Publish to landing</text>
<text x="1135.0" y="1680" font-size="15" fill="#475569">landing (main), production:</text>
<text x="1135.0" y="1702" font-size="15" fill="#475569">moves only when the owner approves,</text>
<text x="1135.0" y="1702" font-size="15" fill="#475569">moves only when a person approves,</text>
<text x="1135.0" y="1724" font-size="15" fill="#475569">after full checks on that version</text>
<line x1="750" y1="956" x2="750" y2="1786" stroke="#334155" stroke-width="2.5" stroke-dasharray="2 5" opacity=".0"/>
<rect x="60" y="1812" width="1380" height="254" rx="20" fill="#f8fafc" stroke="#cbd5e1" stroke-width="2"/>
<text x="750" y="1856" text-anchor="middle" font-size="22" font-weight="800" fill="#0f172a">landing (main) → ONE build → one web folder → three outputs</text>
<text x="750" y="1882" text-anchor="middle" font-size="16" fill="#475569">the pilot server follows development · the production server rebuilds from main</text>
<text x="750" y="1882" text-anchor="middle" font-size="16" fill="#475569">the pilot server follows development · the landing server rebuilds from main</text>
<rect x="80.0" y="1908" width="420" height="130" rx="14" fill="#fff" stroke="#15803d" stroke-width="2.5"/>
<text x="290.0" y="1930" text-anchor="middle" font-size="13" font-weight="800" fill="#64748b">OUTPUT 1</text>
<text x="290.0" y="1962" text-anchor="middle" font-size="22" font-weight="800" fill="#0f172a">The web folder</text>
@ -197,4 +197,9 @@
<text x="1210.0" y="1988" text-anchor="middle" font-size="15" fill="#475569">the same folder, wrapped by Tauri</text>
<rect x="1110.0" y="2002" width="200" height="26" rx="13" fill="#15803d" opacity=".13"/>
<text x="1210.0" y="2020" text-anchor="middle" font-size="12" font-weight="800" fill="#15803d">WORKING</text>
</svg>
<rect x="60" y="2090" width="1380" height="146" rx="20" fill="#eff6ff" stroke="#93c5fd" stroke-width="2"/>
<text x="750" y="2132" text-anchor="middle" font-size="22" font-weight="800" fill="#0f172a">At scale: many people × many agents × many plugins</text>
<text x="750" y="2164" text-anchor="middle" font-size="16" fill="#475569">one PR branch per plugin or feature → joins the pilot through the lock → previewed on the pilot server</text>
<text x="750" y="2192" text-anchor="middle" font-size="16" fill="#475569">in the cloud: a self-hosted git service, because an agent can publish by accident</text>
<text x="750" y="2218" text-anchor="middle" font-size="16" fill="#475569">publish to a public host only from landing (main), when it is ready</text>
</svg>

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View file

@ -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;

View file

@ -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<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 = tested against GEOMETRY, not the emission arrays (which
// numbering walks, so an emission-order check cannot fail — studio P2). Per classic4:
// L cy DESC (num↑ ⇒ screen y DOWN), R cy ASC (y UP), T cx ASC (x UP), B cx DESC (x DOWN).
// Pins are post-spread, so allow a drift tolerance = 60% of the side's mean step; a
// reversed sort (studio's poison) still fails, normal de-overlap drift does not.
const AXIS = { L: ['y', -1], R: ['y', +1], T: ['x', +1], B: ['x', -1] };
let monoOk = true, monoWhy = '';
for (const sd of SEQ) {
const ps = four.pins.filter((p) => 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<T<R<B');
if (!monoOk) fails.push(`fourSides: numbering not geometrically monotone within a side (${monoWhy})`);
// (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 });
note.push(`pairedRows: rows ${paired.stats.rows}, crossings ${paired.stats.crossings}, touches ${paired.stats.touches}, outsideState ${paired.stats.pinsOutsideState}, octilinear ${paired.stats.octilinear}`);
if (paired.stats.crossings !== 0) fails.push(`pairedRows: ${paired.stats.crossings} leader crossings (expected 0)`);
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`);
// (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`);
}

View file

@ -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<T<R<B + geometric monotonicity, pairedRows 0 crossings + octilinear + every leader polys.length===2, determinism, a poison, output recounts, and a node budget. Deterministic; ties by code unit. 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.",