Compare commits
2 commits
main
...
country-po
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b060cec796 | ||
|
|
420141c918 |
9 changed files with 283 additions and 19 deletions
BIN
data/posters/country-ind.webp
Normal file
BIN
data/posters/country-ind.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
|
|
@ -87,7 +87,7 @@ export function makeCountrySheet({ records, assetsBase, countries = null }) {
|
|||
return generateCountrySheet({
|
||||
// region travels: countryZone colours the sheet from it, and a country arriving without one
|
||||
// silently falls back to the old flat teal for that poster alone.
|
||||
country: { id: item.id, iso3, label: item.label, count: item.count, region: item.region },
|
||||
country: { id: item.id, iso3, label: item.label, count: item.count, region: item.region, adminAreas: item.adminAreas },
|
||||
records: rows,
|
||||
assetsBase: assetsBase(),
|
||||
ISO3_NAME,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -240,8 +240,13 @@ export function countryRegionColor(region) {
|
|||
return `hsl(${Math.round((i * 360) / POSTER_REGIONS.length)}, 72%, 56%)`
|
||||
}
|
||||
|
||||
export function countryZone({ id, iso3, label, count, region }) {
|
||||
return { id, zoneKey: id, label, count, color: countryRegionColor(region), adm0: [iso3], selector: { type: 'country_codes', values: [iso3] } }
|
||||
export function countryZone({ id, iso3, label, count, region, adminAreas }) {
|
||||
return {
|
||||
id, zoneKey: id, label, count, color: countryRegionColor(region), adm0: [iso3], selector: { type: 'country_codes', values: [iso3] },
|
||||
// The facts row's "Administrative Areas" cell, when the caller knows it (India: "36 states and union territories");
|
||||
// without it the cell falls back to the country's name, as every country sheet has drawn it so far.
|
||||
...(adminAreas ? { provinces: [adminAreas] } : {}),
|
||||
}
|
||||
}
|
||||
export function countryGeometry(zone, adm0, hasGroups) {
|
||||
const fc = { type: 'FeatureCollection', features: [] }
|
||||
|
|
@ -259,7 +264,9 @@ export function pinsFrom(records) {
|
|||
for (const r of records || []) {
|
||||
const lat = Number(r.latitude), lng = Number(r.longitude)
|
||||
if (!isFinite(lat) || !isFinite(lng)) continue
|
||||
out.push({ name: r.name || r.slug || '', lat, lng, slug: r.slug, country_code: r.country_code, population: r.population })
|
||||
// image_url rides along for the large side-lane sheets, which put a real photo beside each name; every other
|
||||
// format ignores it, so their sheets are unchanged.
|
||||
out.push({ name: r.name || r.slug || '', lat, lng, slug: r.slug, country_code: r.country_code, population: r.population, image_url: r.image_url })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@
|
|||
"check:composites-mount": "node internal/check-composites-mount.mjs",
|
||||
"check:country-names": "node internal/check-country-names.mjs",
|
||||
"check:country-sheet-one-path": "node plugin-designs/country-posters/scripts/check-country-sheet-one-path.mjs",
|
||||
"check:side-lanes": "node plugin-designs/country-posters/scripts/check-side-lanes.mjs",
|
||||
"check:side-lanes:self-test": "node plugin-designs/country-posters/scripts/check-side-lanes.mjs --self-test",
|
||||
"check:country-sheet-one-path:self-test": "node plugin-designs/country-posters/scripts/check-country-sheet-one-path.mjs --self-test",
|
||||
"check:coverage-consistent": "node internal/check-gospel-coverage-consistent.mjs",
|
||||
"check:coverage-consistent:chained": "VERIFY_MAX_AGE_DAYS=36500 node internal/check-gospel-coverage-consistent.mjs",
|
||||
|
|
@ -348,7 +350,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:done-evidence check:script-flags check:backlog-status 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:backlog-refs 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",
|
||||
"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: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-new-driver-references 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: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:dxlife check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:wall-photo-weight 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-design-catalogue",
|
||||
"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: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-new-driver-references 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: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:dxlife check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:wall-photo-weight 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-design-catalogue",
|
||||
"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",
|
||||
|
|
@ -455,6 +457,8 @@
|
|||
"check:composites-mount": "every capability-4/5 composite's OWN setup() and mounted hooks run without throwing, in en and ar, headless (no browser): vite loads each composite as its bundle entry defines it (configureApp, and ProfileLoader's provides where the entry mounts ProfileLoader), and @vue/runtime-core mounts it on a no-op renderer. Exists because 08f89f55 shipped two composites calling getRuntimeLocale/detectLocale/RTL_LOCALES with no import: the build was green, setup threw a ReferenceError in every locale, and Big Picture and Simple Research rendered nothing. Proves setup runs; cannot prove the children render.",
|
||||
"check:country-names": "every ISO 3166-1 alpha-3 our data uses must resolve to a localized country name. Guards the JOIN, not Intl: library/data/iso3-to-alpha2.json drifting from library/data/countries.json makes a country render as a bare code, which still looks like a value. Checks en/ar/zh (ar is our only RTL locale; zh makes an untranslated Latin code unmistakable) and asserts BOTH directions of drift. Carries two negative arms — an unassigned code and a malformed one — and FAILS if either resolves, because a coverage count cannot otherwise tell a hit from a lookup that returns its input.",
|
||||
"check:country-sheet-one-path": "a country sheet, and an Indian state or union territory sheet, each draw through ONE path (@map/packet/countrySheet.js; country-posters/src/indiaStateSheet.js), so one place cannot print two posters. Parses (comments cannot count) plugin sources: a direct generateCountrySheet FAILS anywhere, a direct generateSheet FAILS inside country-posters outside the state helper, every makeCountrySheet call must pass `countries`, and the known callers must be found. Named data answers: seriesFor over country-posters.json (India 1 of 1, each 3x3 country N of 173, an absent code no series, exactly one no-sheet country); over the served india-states.json + gb-IND-ADM1.geojson: 36 divisions, the 34 with groups numbered 1..34 each with an outline, the rest named, the shared ADM1 never mutated.",
|
||||
"check:side-lanes": "the large side-lane sheets (60x96, 66x66, 36x60) through the real poster-render.js, on a SEEDED worst case (855 groups, stacked duplicates, tight clusters, real and placeholder photos): every name drawn exactly once, leader crossings 0 (re-measured from the SVG, and equal to the renderer's own count), no overflow rows, photos only where a real one exists and the format allows them.",
|
||||
"check:side-lanes:self-test": "a poster-render.js copy WITHOUT the lane clamp must make check:side-lanes' crossing arm FAIL on the same seeded data (it does: 36 crossings), so the arm is shown able to fail.",
|
||||
"check:country-sheet-one-path:self-test": "plants each defect check:country-sheet-one-path guards (a direct generateCountrySheet call, makeCountrySheet without countries, the old guard that skipped India, a position given to an absent code) and requires each to FAIL; a comment naming generateCountrySheet must NOT.",
|
||||
"check:coverage-consistent": "the gospel-resources surfaces must agree: no resource on an ISO RESERVED code (und/mul/mis/zxx), every resourced code NAMED, table reach recounted off the pins, no invented URL, tiers reconcile. Static, sub-second. Self-test: `bun run check:coverage-consistent:poison`, all 12 clauses (A-L) must fail. Clause L is the one that is not about consistency: it fails when the link-verification record goes stale, because 'link checked' is a claim about the present tense. Written after the same drift defect was found by hand three times.",
|
||||
"check:csv-shape": "the export must OPEN as a table: header on line 0, every row matching. Run the `check:csv-shape:self-test` script for the NEGATIVE test (forces the provenance comment back to line 0 IN MEMORY and requires this gate to REJECT it) — the header used to claim 'NEGATIVE-TESTED' with no way to re-run it.",
|
||||
|
|
|
|||
BIN
plugin-designs/country-posters/assets/previews/country-ind.webp
Normal file
BIN
plugin-designs/country-posters/assets/previews/country-ind.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
|
|
@ -37,7 +37,7 @@
|
|||
*/
|
||||
import { computed, inject, ref, watch } from 'vue'
|
||||
import PosterWall from '@map/components/poster/PosterWall.vue'
|
||||
import { useOptionalT, useOptionalI18n } from '@map/composables/useOptionalT.js'
|
||||
import { useOptionalT, useOptionalI18n, pickPluralForm, fillParams } from '@map/composables/useOptionalT.js'
|
||||
// A country's name in the reader's language (Intl, joined from our ISO-3 codes).
|
||||
import { countryName } from '@map/utils/countryName.js'
|
||||
import { createPeopleGroupRecords } from '@map/lists/peopleGroupRecords.js'
|
||||
|
|
@ -149,7 +149,10 @@ const recordsFor = createPeopleGroupRecords({
|
|||
lang: () => getRuntimeLocale() || detectLocale(),
|
||||
profileConfig,
|
||||
})
|
||||
const countryRecords = (c) => (c.indiaState
|
||||
// ★ THE WHOLE-INDIA POSTER IS THE POSTER ONLY: no prayer list, packet or cards (855 groups would make a book), so its
|
||||
// card's rows are empty. Its sheet still draws every group: makeCountrySheet fetches them itself.
|
||||
const isWholeIndia = (c) => !c.indiaState && String(c.iso3 || '').toUpperCase() === 'IND'
|
||||
const countryRecords = (c) => (isWholeIndia(c) ? Promise.resolve([]) : c.indiaState
|
||||
? (c.slugs.length ? recordsFor({ type: 'pg_slugs', values: c.slugs }) : Promise.resolve([]))
|
||||
: recordsFor({ type: 'country', values: [c.iso3] }))
|
||||
// ★ GENERATED AT THE CLICK, through the ONE shared path (@map/packet/countrySheet.js, which the Regions
|
||||
|
|
@ -165,7 +168,19 @@ const sheetForRecord = makeCountrySheet({
|
|||
assetsBase: () => assetsBase.value,
|
||||
countries: data.countries || [],
|
||||
})
|
||||
const countryOnlySheet = (card, format) => sheetForRecord(byId.get(card.id) || card, format)
|
||||
// ★ INDIA PRINTS AS ONE LARGE SIDE-LANE SHEET (60x96: every group named beside the map, one leader per cluster),
|
||||
// whatever paper the wall asks for, until the shared size picker also offers 66x66 and 36x60. Its facts row names
|
||||
// the true count of its divisions, in English like the rest of the sheet, from the same plural key as the wall head.
|
||||
const INDIA_FORMAT = '60x96'
|
||||
const indiaAdminAreas = () => {
|
||||
const n = (indiaStates.value || []).length
|
||||
return n ? fillParams(pickPluralForm(tStatic('countryPosters.indiaItemCount', 'en') || '', n, 'en'), { n }) : undefined
|
||||
}
|
||||
const countryOnlySheet = (card, format) => {
|
||||
const rec = byId.get(card.id) || card
|
||||
if (isWholeIndia(rec) && format !== 'packet') return sheetForRecord({ ...rec, adminAreas: indiaAdminAreas() }, INDIA_FORMAT)
|
||||
return sheetForRecord(rec, format)
|
||||
}
|
||||
// A state's sheet through its own ONE path (src/indiaStateSheet.js), in India's hue and the indiaState series.
|
||||
const stateSheet = makeIndiaStateSheet({
|
||||
records: recordsFor, assetsBase: () => assetsBase.value, states: () => indiaStates.value, region: INDIA_REGION,
|
||||
|
|
@ -223,7 +238,7 @@ const collapsedBy = (sec) => sec.total < 60
|
|||
// unsupportedReason is an English note (and a flag), not display text. The card already states the
|
||||
// count above it, so the sentence needs no number and no plural agreement.
|
||||
// A requested country with no poster at all says so in regions-list.vue's words (the regions catalogue).
|
||||
const unsupported = (c) => (c.noGroups ? t('countryPosters.indiaStateNoGroups') : c.noPoster ? tRegions('No poster has been generated for this country yet.') : c.unsupportedReason ? t('messages.countryPosterTooMany') : null)
|
||||
const unsupported = (c) => (c.noGroups ? t('countryPosters.indiaStateNoGroups') : c.noPoster ? tRegions('No poster has been generated for this country yet.') : c.unsupportedReason && !isWholeIndia(c) ? t('messages.countryPosterTooMany') : null)
|
||||
|
||||
// ★ THROUGH THE CATALOGUE, like the UPG-100 wall: the heading, the tag, the intro and the counts read
|
||||
// in the page's language. The intro keeps its one emphasis as three plain keys (A, the emphasis, B)
|
||||
|
|
|
|||
100
plugin-designs/country-posters/scripts/check-side-lanes.mjs
Normal file
100
plugin-designs/country-posters/scripts/check-side-lanes.mjs
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
#!/usr/bin/env node
|
||||
// check:side-lanes — the large side-lane sheets (60x96 / 66x66 / 36x60) name every group once, horizontally, with
|
||||
// leaders that never cross, on a DETERMINISTIC worst case: a seeded synthetic country of 855 groups with stacked
|
||||
// duplicates and tight clusters (district geocodes are like that), long and short names, real and placeholder photos.
|
||||
// Rendered through the real poster-render.js (renderPoster), so it measures what prints.
|
||||
//
|
||||
// Asserts per format: every name drawn exactly once; leader crossings 0 (the renderer's own count, re-measured here
|
||||
// from the SVG); no overflow rows; photos = the pins with a real photo (60x96/66x66) or none (36x60).
|
||||
// --self-test: a renderer copy WITHOUT the lane clamp must FAIL the crossing arm (else the arm proves nothing).
|
||||
// Exit 0 pass, 1 fail.
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
|
||||
const J = (p) => JSON.parse(readFileSync(join(ROOT, p), 'utf8'))
|
||||
const RENDER = join(ROOT, 'library/packet/poster-render.js')
|
||||
|
||||
function synthetic(n = 855) {
|
||||
let seed = 20261001
|
||||
const rnd = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648)
|
||||
const syll = ['ka', 'ra', 'mu', 'thi', 'ban', 'dho', 'li', 'sa', 'ga', 'pa', 'rwa', 'kot', 'ni', 'al', 'u']
|
||||
const centres = Array.from({ length: 60 }, () => [68 + rnd() * 28, 8 + rnd() * 27])
|
||||
const pins = []
|
||||
for (let i = 0; i < n; i++) {
|
||||
const c = centres[Math.floor(rnd() * centres.length)]
|
||||
const stacked = rnd() < 0.15 && pins.length // an exact duplicate of an earlier position
|
||||
const lng = stacked ? pins[pins.length - 1].lng : c[0] + (rnd() - 0.5) * 1.6
|
||||
const lat = stacked ? pins[pins.length - 1].lat : c[1] + (rnd() - 0.5) * 1.6
|
||||
const words = 1 + Math.floor(rnd() * (rnd() < 0.05 ? 5 : 2))
|
||||
const name = Array.from({ length: words }, () => Array.from({ length: 2 + Math.floor(rnd() * 3) }, () => syll[Math.floor(rnd() * syll.length)]).join('')).join(' ')
|
||||
.replace(/^./, (m) => m.toUpperCase()) + ` ${i}`
|
||||
const image_url = rnd() < 0.18 ? `https://example.invalid/p/${i}.jpg` : 'https://example.invalid/no-photo-images/asia.jpg'
|
||||
pins.push({ name, lat, lng, slug: `g-${i}`, country_code: 'IND', population: 1000 + i, image_url })
|
||||
}
|
||||
return pins
|
||||
}
|
||||
|
||||
async function measure(createPosterGenerator, format, pins) {
|
||||
const adm0 = J('public/dx-maps/assets/boundaries/world-adm0.geojson')
|
||||
const series = J('library/scripts/posters/series.json')
|
||||
const zone = { id: 'country-ind', zoneKey: 'country-ind', label: 'India', count: pins.length, color: '#d4a640', adm0: ['IND'], selector: { type: 'country_codes', values: ['IND'] } }
|
||||
const zoneFC = { type: 'FeatureCollection', features: adm0.features.filter((f) => f.properties?.iso3 === 'IND') }
|
||||
const gen = createPosterGenerator({ env: { FORMAT: format, SPARSE: '1', POSTER_DATE: '2026-10-01' }, SERIES_DEF: series.country, ISO3_NAME: { IND: 'India' }, adm0, adm1Ind: null, adm1Chn: null, nePlaces: [], series: { totalGroups: pins.length, totalLists: 1, index: 1 } })
|
||||
const out = await gen.renderPoster(zone, pins, zoneFC)
|
||||
const html = out.html || ''
|
||||
const names = [...html.matchAll(/<text class="snm"[^>]*>([^<]*)<\/text>/g)].map((m) => m[1])
|
||||
const photos = (html.match(/<image href="/g) || []).length
|
||||
// re-measure crossings from the SVG itself, independent of the renderer's own count
|
||||
const polys = [...html.matchAll(/class="slead" d="M([^"]+)"/g)].map((m) => m[1].split('L').map((q) => q.trim().split(' ').map(Number)))
|
||||
const segs = 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 X = (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 crossings = 0
|
||||
for (let i = 0; i < segs.length; i++) for (let j = i + 1; j < segs.length; j++) if (segs[i].some((a) => segs[j].some((b) => X(a, b)))) crossings++
|
||||
return { stats: out.sideLanes, names, photos, crossings, leaders: polys.length }
|
||||
}
|
||||
|
||||
function judge(format, m, pins, wantPhotos) {
|
||||
const fails = []
|
||||
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
const want = new Map(pins.map((p) => [esc(p.name), 0]))
|
||||
for (const n of m.names) if (want.has(n)) want.set(n, want.get(n) + 1)
|
||||
const missing = [...want].filter(([, v]) => v === 0).length, dup = [...want].filter(([, v]) => v > 1).length
|
||||
if (!m.stats) fails.push(`${format}: renderPoster returned no sideLanes stats (not a side-lane format?)`)
|
||||
if (m.names.length !== pins.length || missing || dup) fails.push(`${format}: ${m.names.length} names drawn for ${pins.length} groups (${missing} missing, ${dup} duplicated)`)
|
||||
if (m.crossings !== 0) fails.push(`${format}: ${m.crossings} leader crossings (re-measured from the SVG)`)
|
||||
if (m.stats && m.stats.crossings !== m.crossings) fails.push(`${format}: the renderer reports ${m.stats.crossings} crossings, the SVG has ${m.crossings}`)
|
||||
if (m.stats && m.stats.overflowRows) fails.push(`${format}: ${m.stats.overflowRows} rows overflow the band`)
|
||||
const real = pins.filter((p) => !/no-photo-images/.test(p.image_url)).length
|
||||
if (m.photos !== (wantPhotos ? real : 0)) fails.push(`${format}: ${m.photos} photos drawn, expected ${wantPhotos ? real : 0}`)
|
||||
return fails
|
||||
}
|
||||
|
||||
const selfTest = process.argv.includes('--self-test')
|
||||
const src = readFileSync(RENDER, 'utf8')
|
||||
const pins = synthetic()
|
||||
if (selfTest) {
|
||||
// POISON: drop the lane clamp (each lane at its own pin's height); stubs/runs/fans must then cross on this data
|
||||
const a = 'let y = Math.min(Math.max(c.y, lo + LANE_GAP), hi - LANE_GAP);'
|
||||
if (!src.includes(a)) { console.log('NOT MEASURED: the lane clamp line moved; update the poison'); process.exitCode = 75 }
|
||||
else {
|
||||
const poisoned = src.replace(a, 'let y = c.y; lo = -Infinity; hi = Infinity;')
|
||||
const { createPosterGenerator } = await import('data:text/javascript;base64,' + Buffer.from(poisoned).toString('base64'))
|
||||
const m = await measure(createPosterGenerator, '60x96', pins)
|
||||
const f = judge('60x96', m, pins, true)
|
||||
console.log(f.some((x) => /crossings/.test(x)) ? `✓ caught: no lane clamp -> ${m.crossings} crossings` : '✗ MISSED: the crossing arm stayed green without the lane clamp')
|
||||
process.exitCode = f.some((x) => /crossings/.test(x)) ? 0 : 1
|
||||
}
|
||||
} else {
|
||||
const { createPosterGenerator } = await import(RENDER)
|
||||
const fails = [], lines = []
|
||||
for (const [format, photos] of [['60x96', true], ['66x66', true], ['36x60', false]]) {
|
||||
const m = await measure(createPosterGenerator, format, pins)
|
||||
fails.push(...judge(format, m, pins, photos))
|
||||
lines.push(`${format}: ${m.names.length} names, ${m.leaders} leaders, ${m.crossings} crossings, ${m.photos} photos, ${m.stats?.namePt} pt`)
|
||||
}
|
||||
if (fails.length) { fails.forEach((f) => console.log('FAIL ' + f)); process.exitCode = 1 }
|
||||
else console.log(`PASS check:side-lanes — synthetic ${pins.length} groups (stacked + clustered); ${lines.join(' | ')}`)
|
||||
}
|
||||
Loading…
Reference in a new issue