poster-render: large side-lane sheets (60x96, 66x66, 36x60) that name a whole country's groups with crossing-free leaders

A new layout mode, ADDITIVE: three new FORMATS with `sideLanes`; every existing format takes exactly its old path.
Every name horizontal, on the left and right of the map in two staggered columns per side (rows alternate, so each
column keeps a full line pitch and each row holds one name). Co-located groups form a cluster with one leader and
a bracketed block. Crossing-free by construction: one vertical split sends each cluster to the nearer side; each
leader is a short stub to its own lane, a horizontal run to the map edge, and a fan to its block, with lanes placed
farthest-from-the-edge first and clamped between their neighbours, and fans in the lanes' order. The renderer
measures and returns its crossing count. A real photo (never the placeholder) sits outside its name where the
format allows. The type is the largest that fits the rows and columns, capped per format (nameMaxPt).
On these sheets only: pins stay at their true positions (no settle pass), the map frame takes the country's own
shape centred in the band with a thin margin, and the side-lane styles are emitted.
India (855 groups, live data): 60x96 20.7 pt names + 7.9 mm photos; 66x66 14.6 pt + 5.5 mm; 36x60 12.6 pt, no
photos; 0 crossings on all three; 4 s per render where the numbered path took 65 s. Re-pinned.
This commit is contained in:
Doxa Maps 2026-10-01 05:52:47 +00:00
parent 315dc92a65
commit ef7ace486e
2 changed files with 148 additions and 10 deletions

View file

@ -2,13 +2,13 @@
// The sha384 of library/packet/poster-render.js. The browser fetches the served copy of that file
// (<assets>/lib/poster-render.js), hashes it, and runs it only when it matches this pin: code loaded at
// print time is integrity-pinned like every embed, because it executes on partner pages.
export const POSTER_RENDER_SHA384 = 'sha384-b5QGnl3AalMYjaEi6xRIYMCTI1VhXFHx1ZitoomfaKzool2CCnwSESFrmKFj28PW'
export const POSTER_RENDER_SHA384 = 'sha384-PKLZ6yUlNmP8N9+DtIXOKhr/vEiOu+3hn51pCqJrpBdVJ+D1VaTilRACL9zlB+T+'
// The sha384 of library/scripts/posters/data/ne-places.json, served at <assets>/boundaries/ne-places.json.
// Data, not code, but pinned the same way: a sheet uses the places only when they are the pinned ones.
export const NE_PLACES_SHA384 = 'sha384-Qtz2q8mcnA9Ei8Kwa2MRMyX95hSbSnUXjXdtsy8rHTX++mgSCih4LO4Rdrb2MIQy'
// The sha384 of the classic-script copy (<assets>/lib/poster-render.global.js, globalVariant of the file above),
// loaded with <script integrity> where a page's policy forbids importing a blob: of the verified text.
export const POSTER_RENDER_GLOBAL_SHA384 = 'sha384-DZa1KOOlRZMm17kxVEyMXNGafsBBtIza1sbVXNUAfQ2uYSiFn2RMnMr51/2hlOBU'
export const POSTER_RENDER_GLOBAL_SHA384 = 'sha384-td96tFF45usHjTt+Kivw3aHEBxpnXaFwmk6R/HRqECPZhkhJTl3mG6ttEud0gVWw'
// The sha384 of library/scripts/posters/packet-strings.json, served at <assets>/lib/packet-strings.json. The
// packet, list and card text in every language, fetched at print time (packet/packetStrings.js) and used only
// when it matches; otherwise the print falls back to the bundled English block.

View file

@ -262,8 +262,22 @@ const FORMATS = {
// about 1,600px of height against the letter sheet's ~1,200, which is what lets the names grow.
// Output: <zone>.packet.html beside the posters (see FILE_SUFFIX), never over them.
packet: { w: 1500, h: 1941, gutter: 330, page: 'letter portrait', pageWidthIn: 8.5, mapOnly: true, header: 250, footer: 90 },
// ★ LARGE SHEETS WITH SIDE LANES (`sideLanes`): a whole country's hundreds of groups, every one named horizontally
// in two staggered columns per side, ONE leader per cluster of co-located groups, crossing-free by construction
// (see sideLanesLayout). Same 41.67 px/in as the 3x3, so the header, pins and footer keep their physical size.
// `nameMaxPt` caps the names' print size; the layout solves the largest size that fits the rows and columns.
'60x96': { w: 2500, h: 4000, gutter: 572, page: '60in 96in', pageWidthIn: 60, sideLanes: true, nameMaxPt: 20.7, photos: true },
'66x66': { w: 2750, h: 2750, gutter: 470, page: '66in 66in', pageWidthIn: 66, sideLanes: true, nameMaxPt: 18, photos: true },
'36x60': { w: 1500, h: 2500, gutter: 400, page: '36in 60in', pageWidthIn: 36, sideLanes: true, nameMaxPt: 14, photos: false },
};
const FMT_KEY = FORMATS[process.env.FORMAT] ? process.env.FORMAT : '3x4';
// The side-lane marks' styles: emitted ONLY on a sideLanes sheet, so every other format's page is byte-identical.
const SIDE_LANES_CSS = `
.slead{stroke:#5a5246;stroke-width:0.9;fill:none;opacity:.5}
.sbr{stroke:#5a5246;stroke-width:1.1;fill:none}
.srow{stroke:#b9ac8c;stroke-width:.3;fill:none;opacity:.7}
.spin{fill:#1c1c17;stroke:#f7f4ec;stroke-width:.8}
.snm{font-family:Lato,'Segoe UI',system-ui,sans-serif;font-weight:700;fill:#1c1c17}`;
const FMT = FORMATS[FMT_KEY];
// Map-only sheets (packet page 1) skip the poster's header and footer furniture — see FORMATS.packet.
const MAP_ONLY = FMT.mapOnly === true;
@ -856,7 +870,9 @@ async function renderPoster(zone, pins, zoneFC) {
const floorDeg = CITIES ? _floor(Math.max(_w, _h)) : 1;
// PER-AXIS floor. A collinear cluster (zero span on one axis) must not be padded
// as if it were as wide as the other axis — that is what produced a 0.0% spread.
const pad = CITIES
// A side-lane sheet is the COUNTRY at its largest: a thin margin, since the names, not neighbours, fill the sides.
const pad = FMT.sideLanes ? [_w * 0.04, _h * 0.04]
: CITIES
? [_w * 0.22 + _floor(Math.max(_w, _h * 0.35)), _h * 0.22 + _floor(Math.max(_h, _w * 0.35))]
: [_w * 0.22 + floorDeg, _h * 0.22 + floorDeg];
const viewBbox = [bbox[0] - pad[0], bbox[1] - pad[1], bbox[2] + pad[0], bbox[3] + pad[1]];
@ -886,12 +902,14 @@ async function renderPoster(zone, pins, zoneFC) {
// frame is exactly what shows them.
// FULL SHEET HEIGHT — the map runs from just under the header to just above the
// footer rule, so there is no dead band anywhere on the page.
const FRAME_H = MAP_H;
// A side-lane sheet fits the frame to the country's own shape and centres it: its names run the sheet's full height
// beside it, so the frame need not, and a full-height frame would only shrink the country to show its neighbours.
const FRAME_H = FMT.sideLanes ? Math.min(MAP_H, FRAME_W / zoneAspect) : MAP_H;
// OPTICAL CENTRING, not geometric. A block centred by arithmetic looks like it
// is falling off the bottom of the sheet — the optical centre sits ABOVE the
// geometric one, which is why framers cut a bottom mat 1.25-1.4x the top. Here
// the remainder is split 42/58 so the bottom margin is the larger one.
const FRAME_Y = MAP_Y;
const FRAME_Y = FMT.sideLanes ? MAP_Y + (MAP_H - FRAME_H) / 2 : MAP_Y;
// PROJECT FROM viewBbox, NOT bbox.
// These two had drifted apart: `viewBbox` (bbox + 22% context pad) decides WHICH
// countries are drawn, while the projection scaled from the bare `bbox` — so the
@ -1249,7 +1267,8 @@ async function renderPoster(zone, pins, zoneFC) {
//
// This is what makes leaders viable again: leaders can only be routed without
// crossing if their endpoints are distinct, and the endpoints are these pins.
{
// A side-lane sheet keeps every pin at its TRUE position: co-located groups share one leader instead.
if (!FMT.sideLanes) {
// MIN_SEP MUST TRACK THE BADGE SIZE. It was a hardcoded 25 tuned against the
// old r=10.8 disc; leaving it fixed while the badge shrinks would keep pushing
// pins just as far apart and defeat the whole point of the smaller badge.
@ -1296,7 +1315,124 @@ async function renderPoster(zone, pins, zoneFC) {
// Feed the settled pixel positions back as lng/lat-free overrides.
const settled = prePins.map((p) => ({ ...p, _fixed: true }));
const laid = layoutLabels(settled, proj);
// ── SIDE LANES: a whole country's groups on one large sheet (FMT.sideLanes) ───────────────────────────
// Every name HORIZONTAL, on the left and right of the map, in two STAGGERED columns per side: the rows alternate
// inner/outer at half the line pitch, so each column keeps a full pitch and each row holds exactly ONE name.
// Co-located groups (within CL_R px) form a CLUSTER with ONE leader, a bracket and a block of names.
// CROSSING-FREE BY CONSTRUCTION, which the numbered design below could not reach at this density:
// · one vertical split (the weighted median x) sends each cluster to the nearer side, so runs never meet;
// · each leader is a short vertical stub to its own LANE, a horizontal run to the map edge, then a straight fan
// to its block; lanes are placed FARTHEST-FROM-THE-EDGE first, each clamped between its neighbouring lanes,
// so a stub never jumps a lane, and the fan keeps the lanes' top-to-bottom order (ordered points on two
// parallel lines, joined in order, cannot cross). The count is measured and returned, never assumed.
// A real photo (never the generic placeholder) sits on the OUTSIDE of its name when the format allows photos.
function sideLanesLayout(pinsIn) {
const X0 = MAP_X, X1 = MAP_X + MAP_W;
const BY0 = LABEL_TOP ?? FRAME_Y, BY1 = LABEL_BOT ?? (FRAME_Y + FRAME_H);
const PXI = PW / FMT.pageWidthIn;
const pins = pinsIn.map((p) => ({ ...p, px: p._px, py: p._py }));
const photoOf = (p) => (FMT.photos && p.image_url && !/no-photo-images/.test(p.image_url) ? p.image_url : null);
const CL_R = 14, clusters = [];
for (const p of pins) {
const c = clusters.find((q) => Math.hypot(q.x - p.px, q.y - p.py) <= CL_R);
if (c) { c.items.push(p); c.x += (p.px - c.x) / c.items.length; c.y += (p.py - c.y) / c.items.length; }
else clusters.push({ x: p.px, y: p.py, items: [p] });
}
for (const c of clusters) c.items.sort((a, b) => String(a.name).localeCompare(String(b.name), 'en'));
const byX = [...clusters].sort((a, b) => a.x - b.x);
let acc = 0, split = byX.length ? byX[0].x : 0;
for (const c of byX) { acc += c.items.length; if (acc >= pins.length / 2) { split = c.x; break; } }
for (const c of clusters) c.side = c.x <= split ? 'L' : 'R';
// THE TYPE: the largest size (capped by nameMaxPt) whose rows fit the band and whose two columns fit the gutter.
const FAN = 70, GAP = 10, PHOTO_GAP = 4;
const room = GUTTER - (SHEET_EDGE + 8) - FAN - GAP;
const photoPx = (f) => Math.floor(f * 1.18) - 1;
const longest = (f) => Math.max(...pins.map((p) => textW(String(p.name), f) * 1.06 + (photoOf(p) ? photoPx(f) + PHOTO_GAP : 0)));
const lineCount = (sd) => clusters.filter((c) => c.side === sd).reduce((a, c) => a + c.items.length, 0);
const maxLines = Math.max(1, lineCount('L'), lineCount('R'));
let font = Math.min((FMT.nameMaxPt || 14) / 72 * PXI, (BY1 - BY0) / (maxLines * 0.59 * 1.02));
while (font > 5 && 2 * (longest(font) + 8) > room) font -= 0.1;
const ROW = font * 1.18 / 2, PHOTO = photoPx(font), COLW = longest(font) + 8;
// LANES, farthest from the edge first (see above). PIN_CLEAR: within its safe gap a lane prefers a height
// that passes no other pin, at most 7.5 px from its own pin.
const LANE_GAP = 1.5, PIN_CLEAR = 4.5;
let laneJumps = 0;
for (const sd of ['L', 'R']) {
const L = sd === 'L';
const mine = clusters.filter((c) => c.side === sd).sort((a, b) => (L ? b.x - a.x : a.x - b.x));
const lanes = [];
for (const c of mine) {
let lo = -Infinity, hi = Infinity;
for (const ly of lanes) { if (ly < c.y && ly > lo) lo = ly; if (ly >= c.y && ly < hi) hi = ly; }
let y = Math.min(Math.max(c.y, lo + LANE_GAP), hi - LANE_GAP);
if (lo + LANE_GAP > hi - LANE_GAP) { y = (lo + hi) / 2; laneJumps++; }
else {
const a0 = L ? X0 : c.x, a1 = L ? c.x : X1;
const blockers = clusters.filter((o) => o !== c && o.x > a0 + 1 && o.x < a1 - 1);
let best = y, bestHits = Infinity;
for (let t = Math.max(lo + LANE_GAP, c.y - 7.5); t <= Math.min(hi - LANE_GAP, c.y + 7.5); t += 0.75) {
const hits = blockers.filter((o) => Math.abs(o.y - t) < PIN_CLEAR).length;
if (hits < bestHits || (hits === bestHits && Math.abs(t - c.y) < Math.abs(best - c.y))) { best = t; bestHits = hits; }
}
if (bestHits !== Infinity) y = best;
}
c.lane = y; lanes.push(y);
}
}
// BLOCKS, stacked top to bottom in lane order, each as near its lane as the rows allow.
const rowsAvail = Math.floor((BY1 - BY0) / ROW);
let leadersS = '', labelsS = '', dotsS = '', overflow = 0;
const placed = [];
for (const sd of ['L', 'R']) {
const L = sd === 'L';
const blocks = clusters.filter((c) => c.side === sd).sort((a, b) => a.lane - b.lane)
.map((c) => ({ c, n: c.items.length, want: (c.lane - BY0) / ROW }));
const pos = []; let cur = 0;
for (const b of blocks) { const q = Math.max(cur, Math.round(b.want - b.n / 2)); pos.push(q); cur = q + b.n; }
let over = cur - rowsAvail;
for (let k = pos.length - 1; k >= 0 && over > 0; k--) {
const lim = k ? pos[k - 1] + blocks[k - 1].n : 0, can = Math.min(over, pos[k] - lim);
for (let j = k; j < pos.length; j++) pos[j] -= can;
over -= can;
}
overflow += Math.max(0, over);
const edgeX = L ? X0 : X1, portX = L ? X0 - FAN : X1 + FAN;
const innerX = L ? portX - 6 : portX + 6, outerX = L ? innerX - COLW : innerX + COLW;
blocks.forEach((b, k) => {
const c = b.c, r0 = pos[k], midY = BY0 + (r0 + b.n / 2) * ROW;
leadersS += `<path class="slead" d="M${c.x.toFixed(1)} ${c.y.toFixed(1)}L${c.x.toFixed(1)} ${c.lane.toFixed(2)}L${edgeX.toFixed(1)} ${c.lane.toFixed(2)}L${portX.toFixed(1)} ${midY.toFixed(1)}"/>`;
if (b.n > 1) leadersS += `<path class="sbr" d="M${portX.toFixed(1)} ${(BY0 + r0 * ROW + 1).toFixed(1)}V${(BY0 + (r0 + b.n) * ROW - 1).toFixed(1)}"/>`;
dotsS += `<circle class="spin" cx="${c.x.toFixed(1)}" cy="${c.y.toFixed(1)}" r="${b.n > 1 ? 4.4 : 3.2}"/>`;
c.items.forEach((g, j) => {
const r = r0 + j, rowY = BY0 + (r + 0.5) * ROW, x = r % 2 === 0 ? innerX : outerX;
if (r % 2 === 1) leadersS += `<path class="srow" d="M${innerX.toFixed(1)} ${rowY.toFixed(1)}H${(L ? outerX + 3 : outerX - 3).toFixed(1)}"/>`;
labelsS += `<text class="snm" x="${x.toFixed(1)}" y="${(rowY + font * 0.34).toFixed(1)}" font-size="${font.toFixed(2)}"${L ? ' text-anchor="end"' : ''}>${esc(String(g.name))}</text>`;
const ph = photoOf(g);
if (ph) {
const tw = textW(String(g.name), font) * 1.06;
const px = L ? x - tw - PHOTO_GAP - PHOTO : x + tw + PHOTO_GAP;
labelsS += `<image href="${esc(ph)}" x="${px.toFixed(1)}" y="${(rowY - PHOTO / 2).toFixed(1)}" width="${PHOTO}" height="${PHOTO}" preserveAspectRatio="xMidYMid slice"/>`;
}
placed.push({ g, side: sd, r, photo: !!ph });
});
});
}
// MEASURED, not assumed: leader crossings (every segment pair of different leaders).
const polys = [...leadersS.matchAll(/class="slead" d="M([^"]+)"/g)].map((m) => m[1].split('L').map((q) => q.trim().split(' ').map(Number)));
const segsOf = polys.map((pl) => pl.slice(1).map((q, k) => [pl[k], q]));
const sx = (p, q, r) => (r[1] - p[1]) * (q[0] - p[0]) - (q[1] - p[1]) * (r[0] - p[0]);
const segX = (a, b) => (sx(a[0], a[1], b[0]) > 0) !== (sx(a[0], a[1], b[1]) > 0) && (sx(b[0], b[1], a[0]) > 0) !== (sx(b[0], b[1], a[1]) > 0);
let crossingsS = 0;
for (let i = 0; i < segsOf.length; i++) for (let j = i + 1; j < segsOf.length; j++) if (segsOf[i].some((a) => segsOf[j].some((b) => segX(a, b)))) crossingsS++;
const stats = { names: placed.length, pins: pins.length, clusters: clusters.length, crossings: crossingsS, overflowRows: overflow,
laneJumps, photos: placed.filter((p) => p.photo).length, namePt: +(font / PXI * 72).toFixed(1), photoMm: FMT.photos ? +(PHOTO / PXI * 25.4).toFixed(1) : 0 };
return { leaders: leadersS, labels: labelsS, pinDots: dotsS, crossings: crossingsS, pins, stats };
}
const laid = FMT.sideLanes ? [] : layoutLabels(settled, proj);
const sides = FMT.sideLanes ? sideLanesLayout(settled) : null;
// What the city layer must keep clear of: the drawn pins (laid out, or true positions on a side-lane sheet).
const avoidPins = sides ? sides.pins : laid;
// ── CAPITALS + MAJOR CITIES ────────────────────────────────────────────────
// Orientation marks, deliberately SUBORDINATE to the people-group pins. Imhof's
@ -1369,7 +1505,7 @@ async function renderPoster(zone, pins, zoneFC) {
// projected point is not where the badge is drawn. Measuring against the
// source coordinate let Saravan's label land 1.4px from a badge while the
// guard believed it was clear. This block therefore runs AFTER layoutLabels.
for (const pin of laid) {
for (const pin of avoidPins) {
const px = pin.px, py = pin.py;
const dx = px < x0 ? x0 - px : (px > x1 ? px - x1 : 0);
const dy = py < yTop ? yTop - py : (py > yBot ? py - yBot : 0);
@ -1703,8 +1839,9 @@ async function renderPoster(zone, pins, zoneFC) {
const tx = it.laneX + it.dirOut * 5;
labels += `<text x="${tx.toFixed(1)}" y="${(it.labelY + it.font * 0.34).toFixed(1)}" text-anchor="${it.tanchor}" font-size="${it.font.toFixed(1)}" class="lbl">${esc(it.name)}</text>`;
});
if (sides) { leaders += sides.leaders; pinDots += sides.pinDots; labels += sides.labels; }
if (!octiOK) console.warn(` ⚠ non-octilinear segment in ${zone.zoneKey}`);
const crossings = countPolyCrossings(routes);
const crossings = sides ? sides.crossings : countPolyCrossings(routes);
const diagFrac = bestD;
// ── TITLE vs SUBTITLE ──────────────────────────────────────────────────────
@ -2693,8 +2830,9 @@ async function renderPoster(zone, pins, zoneFC) {
page. Collapse the flow height to the scaled height. */
html,body{width:${(FMT.pageWidthIn * 96).toFixed(0)}px;height:${(FMT.pageWidthIn * 96 * FMT.h / FMT.w).toFixed(0)}px;overflow:hidden}
}
${FMT.sideLanes ? SIDE_LANES_CSS : ''}
</style></head><body><div class="stage">${svg}</div></body></html>`;
return { html, title, octiOK, crossings, diagFrac };
return sides ? { html, title, octiOK, crossings, diagFrac, sideLanes: sides.stats } : { html, title, octiOK, crossings, diagFrac };
}
return { renderPoster, CITIES, COUNTRIES, FILE_SUFFIX, FMT_KEY, FMT, MAP_ONLY, FORMATS };