doxa-maps/library/packet/circularLayout.js
Doxa Maps 8bb4e7a8e6 circularLayout: static-import forkRoute + register gate after merge
Switch forkRoute/placePinsInState to static imports from their sibling
library modules (now on development) and delete the vendored forkRoute
fallback; opts may still override for a Worker. Re-register
check:circular-layout (script + gates:pre + description) after the merge
took development's package.json.
2026-10-02 15:59:38 +00:00

487 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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;