Merge remote-tracking branch 'origin/country-posters-plugin/india-whole-2' into poster-design-plugin/b3

# Conflicts:
#	library/packet/poster-render.integrity.js
#	package.json
This commit is contained in:
Doxa Maps 2026-10-01 08:24:28 +00:00
commit b462d8d155
14 changed files with 350 additions and 31 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

View file

@ -72,6 +72,34 @@ export function seriesFor(countries, item) {
return { totalGroups: tier.reduce((a, x) => a + (x.count || 0), 0), totalLists: tier.length, index }
}
/**
* ★ COUNTRIES TOO LARGE FOR ANY PAPER SHEET print as ONE LARGE side-lane sheet instead (poster-render's 60x96 /
* 66x66 / 36x60: every group named beside the map, crossing-free leaders). ONE source of truth for every caller (the
* Country Posters wall, the Regions list), so one country never gets two answers. `desktopOnly`: its poster, list,
* packet and cards are offered on a desktop or laptop only (a phone cannot draw a sheet that big).
* The data (country-posters.json) still records why no PAPER sheet fits; this table is what prints instead.
*/
export const LARGE_SHEETS = { IND: { format: '60x96', formats: ['60x96', '66x66', '36x60'], desktopOnly: true } }
export function largeSheet(item) { return (item && LARGE_SHEETS[String(item.iso3 || '').toUpperCase()]) || null }
// The sheet format a request gets: a large-sheet country prints at the large size ASKED FOR when it has one (66x66,
// 36x60), else at its default (60x96); a packet's page 1 stays a page; every other country gets exactly what it asked.
export function sheetFormatFor(item, format) {
const big = largeSheet(item)
if (!big || format === 'packet') return format
return big.formats.includes(format) ? format : big.format
}
// The facts row's "Administrative Areas" cell for a large-sheet country whose divisions we know (India: its states and
// union territories, counted from the shared state table). English, like the rest of the sheet.
async function adminAreasFor(iso3, base) {
if (iso3 !== 'IND') return undefined
try {
const { loadIndiaStates } = await import('@map/data/indiaStates.js')
const n = ((await loadIndiaStates(base)).states || []).length
return n ? `${n} states and union territories` : undefined
} catch (_) { return undefined } // the cell falls back to the country's name
}
export function makeCountrySheet({ records, assetsBase, countries = null }) {
return async function countrySheet(item, format) {
const iso3 = item && item.iso3
@ -84,14 +112,16 @@ export function makeCountrySheet({ records, assetsBase, countries = null }) {
records({ type: 'country', values: [iso3] }),
])
if (!rows || !rows.length) return '' // nothing to pin: fall through rather than draw an empty country
const big = largeSheet(item)
const adminAreas = item.adminAreas || (big ? await adminAreasFor(String(iso3).toUpperCase(), assetsBase()) : undefined)
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 },
records: rows,
assetsBase: assetsBase(),
ISO3_NAME,
format,
format: sheetFormatFor(item, format),
series: seriesFor(countries, item),
})
}

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-G47HkYq8eBHUsabdbIppQnCHHlYH6wMYwDH0JjOD++43LdHQugVItepj2arJ1ONR'
export const POSTER_RENDER_SHA384 = 'sha384-eiiTZts7LtjX2hiisXAI4WuoxPQJk9uPEeN5yLHiYXmxsyw+Y+KmJkred53w1ioC'
// 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-Y9bdIl2H1EAjFWNlSLua0OPdDjAlRnnq6k14gv1X/Sa8rUmNOB77jpttQHbg/kWz'
export const POSTER_RENDER_GLOBAL_SHA384 = 'sha384-bFtiRPqRP81tqK81UvcglvCfdEUQnoTqIAw4ezEbx14Hg3nR++nz3LNASQwtgXsk'
// 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

@ -259,8 +259,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;
@ -852,7 +866,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]];
@ -882,12 +898,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
@ -1245,7 +1263,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.
@ -1292,7 +1311,126 @@ 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 }));
// A photo only from the known people-group photo hosts, over https, and never the generic placeholder.
const PHOTO_HOSTS = /^https:\/\/(media\.joshuaproject\.net|joshuaproject\.net|images\.peoplegroups\.org|peoplegroupspublic\.blob\.core\.windows\.net)\//;
const photoOf = (p) => (FMT.photos && typeof p.image_url === 'string' && PHOTO_HOSTS.test(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
@ -1365,7 +1503,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);
@ -1695,8 +1833,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 ──────────────────────────────────────────────────────
@ -2676,8 +2815,8 @@ 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}
}
</style></head><body><div class="stage">${svg}</div></body></html>`;
return { html, title, octiOK, crossings, diagFrac };
${FMT.sideLanes ? SIDE_LANES_CSS + '\n' : ''}</style></head><body><div class="stage">${svg}</div></body></html>`;
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

@ -48,6 +48,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",
@ -349,7 +351,7 @@
"fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:script-flags check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:auto-engine check:engine-contract check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:no-silent-engine:self-test check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:region-paint-single-source check:poster-print-fallback check:posters-exportable check:country-names check:reduced-motion check:no-dead-library check:universal-props check:readme-size check:tree-relative check:leaf-hints check:snippet-engine check:snippet-engine:self-test check:embed-base check:link-coverage check:no-public-binds check:shared-reached check:tree-weight check:no-fleet-names:self-test check:pin-hint-floor:self-test check:poster-sizes:self-test",
"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-internal-role-nouns check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:country-sheet-one-path check:country-sheet-one-path:self-test check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch check:panel-sheets check:poster-wall-host-seams check:poster-design-catalogue check:wall-photo-weight check:no-fleet-names check:pin-hint-floor check:poster-sizes",
"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-internal-role-nouns check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:country-sheet-one-path check:country-sheet-one-path:self-test check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch check:panel-sheets check:poster-wall-host-seams check:poster-design-catalogue check:wall-photo-weight check:no-fleet-names check:pin-hint-floor check:poster-sizes check:side-lanes check:side-lanes:self-test",
"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

@ -44,7 +44,8 @@ import { getRuntimeLocale, detectLocale } from '@map/i18n/index.js'
// library/. Generated by library/scripts/posters/emit-countries.mjs, which now writes there.
import data from '@map/data/country-posters.json'
import { resolveAssetsBase } from '@map/utils/embedBase.js'
import { makeCountrySheet } from '@map/packet/countrySheet.js'
import { makeCountrySheet, largeSheet } from '@map/packet/countrySheet.js'
import { isPhonePrint } from '@map/composables/printInPlace.js'
// India's states and union territories: the shared table (fetched when the wall shows them) and their sheet.
import { loadIndiaStates } from '@map/data/indiaStates.js'
import { makeIndiaStateSheet, statesWithPosters } from './src/indiaStateSheet.js'
@ -145,7 +146,12 @@ const recordsFor = createPeopleGroupRecords({
lang: () => getRuntimeLocale() || detectLocale(),
profileConfig,
})
const countryRecords = (c) => (c.indiaState
// ★ A LARGE-SHEET COUNTRY (India: one 5x8 sheet naming all its groups; countrySheet.js's LARGE_SHEETS) is DESKTOP-ONLY:
// its poster, list, packet and cards (about a thousand pages) are offered on a desktop or laptop, and on a phone the
// card says so instead of failing. Same rule as the Regions list, from the same table.
const ON_PHONE = isPhonePrint()
const phoneBlocked = (c) => !c.indiaState && ON_PHONE && Boolean(largeSheet(c)?.desktopOnly)
const countryRecords = (c) => (phoneBlocked(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
@ -219,7 +225,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.') : phoneBlocked(c) ? t('poster.phoneLimit') : c.unsupportedReason && !largeSheet(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

@ -274,7 +274,7 @@ useShadowStyles(PANE_GRID_STYLES + `
:label="t('tabs.views')"
@select="go"
/>
<ShareButton :is-dark="isDark" :embed-path="SHARE_EMBED_PATH" />
<ShareButton :is-dark="isDark" :embed-path="SHARE_EMBED_PATH" shape="rounded-square" />
</header>
<div class="stage" ref="stage"></div>
</div>

View file

@ -94,11 +94,18 @@ export function armA(files, callers = []) {
}
// ARM B: named expectations for seriesFor over the real data.
export function armB(seriesFor, countries) {
export function armB(seriesFor, countries, sheetFormatFor = null) {
const fails = []
const by = (iso) => countries.find((c) => c.iso3 === iso)
const ind = by('IND')
const s = ind && seriesFor(countries, ind)
// THE LARGE-SHEET FORMAT RULE: India prints the large size ASKED FOR, else its 60x96 default; a packet page stays a
// page; any other country gets exactly what it asked for.
if (sheetFormatFor) {
const want = [[ind, '66x66', '66x66'], [ind, '36x60', '36x60'], [ind, '60x96', '60x96'], [ind, 'letter', '60x96'], [ind, '3x4', '60x96'], [ind, 'packet', 'packet'],
[by('NPL'), '3x3', '3x3'], [by('NPL'), 'letter', 'letter'], [by('CHN'), '3x4', '3x4']]
for (const [item, ask, exp] of want) { const got = sheetFormatFor(item, ask); if (got !== exp) fails.push(`sheetFormatFor(${item?.iso3}, ${ask}) = ${got}, expected ${exp}`) }
}
const oversized = countries.filter((c) => (c.sheet ?? null) === null)
if (oversized.length > 1) fails.push(`${oversized.length} countries have no sheet size (${oversized.map((c) => c.iso3).join(', ')}): they now share ONE oversized series, so India prints "map 1 of ${oversized.length}". Decide that deliberately, then update this expectation.`)
if (!ind) fails.push('India is missing from country-posters.json')
@ -158,7 +165,7 @@ export function armE(countries, states, wallSrc, posterWallSrc, H) {
async function main() {
const selfTest = process.argv.includes('--self-test')
const { seriesFor } = await import(join(ROOT, 'library/packet/countrySheet.js'))
const { seriesFor, sheetFormatFor } = await import(join(ROOT, 'library/packet/countrySheet.js'))
const data = JSON.parse(readFileSync(join(ROOT, 'library/data/country-posters.json'), 'utf8'))
const countries = data.countries || []
if (countries.length < 170) { console.log(`NOT MEASURED: country-posters.json parsed ${countries.length} countries (floor 170)`); process.exitCode = 75; return }
@ -184,6 +191,8 @@ async function main() {
['a division silently dropped', () => armC(H, states.filter((st) => st.id !== 'india-mz'), adm1Ind)],
['the wall stops passing sectionTotal (India counted twice)', () => armE(countries, states, files[wall].replace(':section-total=', ':x-section-total='), readFileSync(join(ROOT, 'library/components/poster/PosterWall.vue'), 'utf8'), H).fails],
['India country card left in Asia', () => armE(countries, states, files[wall].replace("=== 'IND'", "=== 'XXX'"), readFileSync(join(ROOT, 'library/components/poster/PosterWall.vue'), 'utf8'), H).fails],
['India ignores a requested 66x66', () => armB(seriesFor, countries, (it, f) => (it?.iso3 === 'IND' && f !== 'packet' ? '60x96' : f))],
['a packet page becomes a large sheet', () => armB(seriesFor, countries, (it, f) => (it?.iso3 === 'IND' ? '60x96' : f))],
['a helper that mutates the shared ADM1', () => armC({ ...H, stateGeometry: (z, a, n) => { (a.features || []).forEach((f) => { if (f.properties.shapeName === n) f.properties.listId = z.id }); return H.stateGeometry(z, a, n) } }, states, JSON.parse(JSON.stringify(adm1Ind)))],
]
let bad = 0
@ -202,7 +211,7 @@ async function main() {
const callers = []
const E = armE(countries, states, files['plugin-designs/country-posters/country-poster-wall.vue'], readFileSync(join(ROOT, 'library/components/poster/PosterWall.vue'), 'utf8'), H)
const fails = [...armA(files, callers), ...armB(seriesFor, countries), ...armC(H, states, adm1Ind), ...E.fails]
const fails = [...armA(files, callers), ...armB(seriesFor, countries, sheetFormatFor), ...armC(H, states, adm1Ind), ...E.fails]
const missing = [...KNOWN_CALLERS, `${STATE_CALLER}#state`].filter((k) => !callers.includes(k))
if (missing.length) fails.push(`known callers not found calling makeCountrySheet: ${missing.join(', ')}`)
if (fails.length) { fails.forEach((f) => console.log(`FAIL ${f}`)); process.exitCode = 1; return }

View file

@ -0,0 +1,105 @@
#!/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 photos on an allowed
// host, the generic placeholder, and photos on a host that is NOT allowed (none of the last two may be drawn).
// 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}`
// real (an allowed photo host), placeholder, or a non-placeholder on a host that is NOT allowed (must not draw)
const roll = rnd()
const image_url = roll < 0.18 ? `https://media.joshuaproject.net/public/assets/media/profiles/photos/p${i}.jpg`
: roll < 0.22 ? `http://elsewhere.example/p/${i}.jpg`
: 'https://s3.doxa.life/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) => /^https:\/\/media\.joshuaproject\.net\//.test(p.image_url)).length // the allowed ones only
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(' | ')}`)
}

View file

@ -49,7 +49,8 @@ const items = computed(() => configs.map((c) => ({ id: c.name, label: labelOf(c)
const groupBy = (item) => t(FAMILY_KEYS[byName.get(item.id)?.family] || FAMILY_KEYS.mapSheet)
// ── drawing a configuration from the library ──────────────────────────────────────────────────
const sample = (countryData.countries || []).find((c) => c.id === SAMPLE_COUNTRY_ID)
const countryById = (id) => (countryData.countries || []).find((c) => c.id === id)
const sample = countryById(SAMPLE_COUNTRY_ID)
const recordsFor = createPeopleGroupRecords({
apiBase: () => profileConfig.value?.apiBaseUrl || getApiBaseUrl(),
lang: () => getRuntimeLocale() || detectLocale(),
@ -58,7 +59,11 @@ const recordsFor = createPeopleGroupRecords({
// The ONE path every surface draws a country sheet through (the Country Posters wall, the Regions tab),
// so the sample carries its region hue and its "map N of M" exactly as the wall prints it.
const sheetFor = makeCountrySheet({ records: recordsFor, assetsBase: () => assetsBase.value, countries: countryData.countries || [] })
const countrySheet = (format) => (sample ? sheetFor(sample, format) : Promise.resolve(''))
// A country's sheet at a format, through the shared path; the large side-lane sheets use their own sample (India).
const countrySheet = (format, id = SAMPLE_COUNTRY_ID) => {
const c = countryById(id)
return c ? sheetFor(c, format) : Promise.resolve('')
}
const ctx = { countrySheet }
// The printed document's size, measured when it is drawn: what "file size" means in the details.

View file

@ -23,8 +23,14 @@ export const SAMPLE_COUNTRY_ID = 'country-npl'
// The library's poster formats (library/packet/poster-render.js FORMATS). The generator is served as a
// pinned asset, not bundled, so its table cannot be imported here; check:poster-design-catalogue reads
// FORMATS from the source and fails when the two disagree.
// In the gallery's order: the 3 × 3 country sheet first, then the large sizes, then the rest. The large SIDE-LANE
// sheets name a whole country's groups beside the map; the one country that prints on them is India (countrySheet.js
// LARGE_SHEETS), so India is their sample, drawn at each requested side-lane size through the shared path.
export const SHEET_FORMATS = {
'3x3': { page: '3 × 3', px: [1500, 1500] },
'60x96': { page: '5 × 8 ft, side lanes', px: [2500, 4000], sample: 'country-ind' },
'66x66': { page: '5.5 × 5.5 ft, side lanes', px: [2750, 2750], sample: 'country-ind' },
'36x60': { page: '3 × 5 ft, side lanes', px: [1500, 2500], sample: 'country-ind' },
'3x4': { page: '3 × 4', px: [1500, 2000], legacy: true },
letter: { page: 'Letter, portrait', px: [1500, 1941] },
packet: { page: 'Letter, portrait (packet page 1)', px: [1500, 1941] },
@ -51,8 +57,8 @@ function sheetEntry(format) {
legacy: !!f.legacy,
renderer: 'library/packet/posterSheets.js generateCountrySheet → library/packet/poster-render.js',
settings: { format },
sample: SAMPLE_COUNTRY_ID,
render: (ctx) => ctx.countrySheet(format),
sample: f.sample || SAMPLE_COUNTRY_ID,
render: (ctx) => ctx.countrySheet(format, f.sample || SAMPLE_COUNTRY_ID),
}
}

View file

@ -70,7 +70,8 @@ import PosterWall from '@map/components/poster/PosterWall.vue'
import RegionSectionCard from '@map/components/regions/RegionSectionCard.vue'
import posterData from '@map/data/country-posters.json'
import { createPeopleGroupRecords } from '@map/lists/peopleGroupRecords.js'
import { makeCountrySheet } from '@map/packet/countrySheet.js'
import { makeCountrySheet, largeSheet } from '@map/packet/countrySheet.js'
import { isPhonePrint } from '@map/composables/printInPlace.js'
import { resolveAssetsBase } from '@map/utils/embedBase.js'
import { onBeforeUnmount } from 'vue'
import { cyrillicHeading } from '@map/styles/portedGlobals.js'
@ -366,7 +367,14 @@ function postersFor(region) {
// eleven, and needs no number in it: the card already shows the count directly above.
// India is the only country in the dataset carrying one (855 people groups, no sheet); China is
// fine at 3x4. My own not-yet reason, for a country with no record at all, is already through t().
const posterUnsupported = (c) => (c.unsupportedReason ? tKey('messages.countryPosterTooMany') : null)
// ★ A COUNTRY TOO LARGE FOR PAPER (countrySheet.js's LARGE_SHEETS; India) prints as one large sheet through the same
// makeCountrySheet, so its record's "too many" note no longer makes it unsupported here. It is DESKTOP-ONLY: on a phone
// its card says so and offers no rows, rather than failing (the Country Posters wall applies the same rule, same table).
const ON_PHONE = isPhonePrint()
const phoneBlocked = (c) => ON_PHONE && Boolean(largeSheet(c)?.desktopOnly)
const posterUnsupported = (c) => (phoneBlocked(c) ? tKey('poster.phoneLimit')
: c.unsupportedReason && !largeSheet(c) ? tKey('messages.countryPosterTooMany') : null)
const posterRows = (item) => (phoneBlocked(item) ? Promise.resolve([]) : countryRecords({ type: 'country', values: [item.iso3] }))
// One region's wall is already inside a region section, so it needs no second level of
// collapsing and no grouping: everything in it is that one region.
@ -585,7 +593,7 @@ useShadowStyles(
:min-card-px="150"
:item-noun-plural="t('Countries')"
item-count-key="regions.countryCount"
:records="countryRecords ? (item) => countryRecords({ type: 'country', values: [item.iso3] }) : null"
:records="countryRecords ? posterRows : null"
:sheet="countrySheet"
:card-stocks="true"
:collapsible="false"