Compare commits

...

2 commits

Author SHA1 Message Date
Doxa Maps
b060cec796 Country Posters: the whole-India poster prints, every group named
India's card is no longer "too many to fit": it prints as the 60x96 side-lane sheet (all 855 groups named beside
the map, crossing-free leaders, real photos), whatever paper the wall asks for, until the shared size picker
offers 66x66 and 36x60 too. Its facts row reads "36 states and union territories" (English, from the wall
head's plural key). As ruled, the whole-India card offers no prayer list, packet or cards; the state cards keep
theirs. Its card preview is the real sheet.
check:side-lanes (+ :self-test, gates:pre): the three side-lane formats on a seeded worst case (855 groups,
stacked and clustered) through the real renderer: every name once, 0 crossings re-measured from the SVG, no
overflow, photos only where real; without the lane clamp the same data crosses 36 times, and the arm fails.
2026-10-01 05:52:07 +00:00
Doxa Maps
420141c918 Country sheets: photos reach the renderer, and a caller can name the "Administrative Areas" cell
pinsFrom carries image_url (only side-lane sheets draw it; every other sheet ignores it). countryZone and
makeCountrySheet accept an optional adminAreas for the facts row (India: "36 states and union territories");
without it the cell shows the country's name as before.
2026-10-01 05:52:06 +00:00
9 changed files with 283 additions and 19 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

View file

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

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

View file

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

View file

@ -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.",

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View file

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

View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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(' | ')}`)
}