Driver request: "a toggle or carrot added to the bottom of the region section that says view country posters, which will let you view country posters for each of the countries in the regions" — with View-on-map and Print, "the same reusable code from upg 100 list". This is a third sanctioned addition alongside the tab strip and the no-selection chooser. Almost nothing was built. library/components/poster/PosterWall.vue was already the shared, fully parameterised grid, and it already owned the card layout, the "Show on map" header button, the PRINT OPTIONS footer and the client-side raster. regions-list.vue supplies only data and a toggle — the same division country-poster-wall.vue states in its own header. - A "View country posters" toggle at the bottom of every region section, carrying aria-expanded and aria-controls, using the same caret SVG the header and footer links already use, rotated when open. - Opening a region renders that region's countries. Measured: 41 cards for Africa and all 41 previews LOAD (naturalWidth>0 — a 404 still yields an <img>, so the load is the real check). No assets were copied: public/assets/previews already holds 174 country-*.webp and generate-tree already stages them. - "Show Chad on the map" reaches the Region Dashboards tab with title "Chad" and hash "#chad", by mapping PosterWall's CustomEvent listId back through the country-selected the tabs already exchange. - India prints its own reason with the exact number from the data; China renders a real poster. Only India is unsupported — 855 groups and no sheet, against China's 154 on a 3x4. Two defects the screenshot caught and no assertion did: - PosterWall's root is position:absolute;inset:0, because in its own bundles it IS a pane. Embedded in a region card it painted 1440x1100 over the region's country list. Fixed by parameterising the shared component rather than overriding its CSS locally: new `embedded` and `sectionHeads` props, both defaulting to today's behaviour, so country-posters and key-cities are unchanged by construction. Both re-checked. Re-measured: root static, 1200px wide, panel below the grid, no overlap. - With one unnamed group the wall printed a section head reading the literal "Other" plus a count duplicating the region card's own summary. Suppressed by sectionHeads=false. The dataset moved to library/data/country-posters.json now that it has a second consumer, one import line changed in country-posters, and emit-countries.mjs was retargeted so the next generator run cannot recreate the duplicate. The three new strings do not exist on DX-life, so there was nothing to copy and nothing was invented in another language: en only, with the other seven locales falling back to the key. Regression: 15 suites green, including region-vs-live 73/73, country-vs-live 28/28, mobile 44/44 and 26/26, and a new _probe-region-posters.mjs. check:library-index drifted for real this time (3 new library files) and is regenerated here.
135 lines
6.3 KiB
JavaScript
135 lines
6.3 KiB
JavaScript
// emit-countries.mjs — build the COUNTRY dataset for the country-posters app.
|
|
//
|
|
// One entry per country that has at least one people group, carrying everything
|
|
// both the map and the poster wall need: the count, the region it files under, its
|
|
// bbox, and which sheet size it takes.
|
|
//
|
|
// node emit-countries.mjs
|
|
// -> ../../data/country-posters.json (library/data — two consumers)
|
|
//
|
|
// SHEET LADDER (design note 2026-08-01) — the count selects the SHEET SIZE, it does not
|
|
// exclude a country:
|
|
// <= OVERSIZE_AT 3ft x 3ft (172 countries, incl. Sudan 105 + Brazil 103)
|
|
// <= UNSUPPORTED_AT 3ft x 4ft (China, 154 — "lengthen it by one foot")
|
|
// > UNSUPPORTED_AT none (India, 855 — a not-yet card printing the EXACT number)
|
|
//
|
|
// EVERY qualifying country gets a sheet, including the 92 with exactly one group.
|
|
// Brief: "if it has a one-people-group on it, we still want a one-people-group map
|
|
// generated… those get sorted to the bottom of the list." The wall's descending
|
|
// sort is what handles them — NOT a floor and NOT a sparse template variant.
|
|
|
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, join, resolve } from 'node:path';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
// Writes to library/data now, not into the country-posters bundle: the regions-dashboard
|
|
// poster dropdown is a SECOND consumer of this dataset, and a generator that still wrote to
|
|
// one plugin's folder would quietly recreate the duplicate this move removed.
|
|
const OUT_DIR = resolve(__dirname, '..', '..', 'data');
|
|
const RECORDS = join(__dirname, 'out', '_records.json');
|
|
const ADM0 = resolve(__dirname, '..', '..', '..', 'plugin-designs', 'my-upg-100-list', 'data', 'adm0', 'world-adm0.geojson');
|
|
|
|
const OVERSIZE_AT = 120; // above this, the 3x4 sheet
|
|
const UNSUPPORTED_AT = 200; // above this, no single sheet fits legibly
|
|
|
|
const raw = JSON.parse(readFileSync(RECORDS, 'utf8'));
|
|
const rows = Array.isArray(raw) ? raw : (raw.records || raw.data || []);
|
|
|
|
// Country polygons, so a country with records but no boundary is detectable rather
|
|
// than silently absent — that gap (175 record countries vs 161 polygons) is real.
|
|
//
|
|
// MEASURED 2026-08-01 — the countries without a polygon are NOT random:
|
|
// IND (855) — India USED to be absent from world-adm0.geojson. It is now fetched
|
|
// at ADM0 like every other country (2026-08-02, data/source-boundaries.mjs), so
|
|
// hasGeometry is true for it. India still renders no country SHEET: that is
|
|
// gated on record count (855 > UNSUPPORTED_AT), not on geometry — a not-yet
|
|
// card printing the EXACT number.
|
|
// The rest are microstates/territories with 1-3 groups each: KOS ESH CCK CXR
|
|
// GIB JEY MCO NFK HKG MAC SXM SJM TKL. The pin-extent fallback is the right
|
|
// answer for those — a 1-group territory needs a view, not a boundary.
|
|
const adm0 = JSON.parse(readFileSync(ADM0, 'utf8'));
|
|
const haveGeom = new Set(adm0.features.map((f) => f.properties?.iso3).filter(Boolean));
|
|
|
|
const bboxOf = (geom) => {
|
|
let x0 = 180, y0 = 90, x1 = -180, y1 = -90;
|
|
const walk = (c) => {
|
|
if (typeof c[0] === 'number') {
|
|
if (c[0] < x0) x0 = c[0]; if (c[0] > x1) x1 = c[0];
|
|
if (c[1] < y0) y0 = c[1]; if (c[1] > y1) y1 = c[1];
|
|
} else for (const p of c) walk(p);
|
|
};
|
|
walk(geom.coordinates);
|
|
return [x0, y0, x1, y1];
|
|
};
|
|
const geomBbox = new Map();
|
|
for (const f of adm0.features) {
|
|
const k = f.properties?.iso3;
|
|
if (k) geomBbox.set(k, bboxOf(f.geometry));
|
|
}
|
|
|
|
// Group the records by ISO3.
|
|
const byIso = new Map();
|
|
for (const r of rows) {
|
|
const iso = r.country_code;
|
|
if (!iso) continue;
|
|
if (!byIso.has(iso)) byIso.set(iso, []);
|
|
byIso.get(iso).push(r);
|
|
}
|
|
|
|
const countries = [];
|
|
for (const [iso, list] of byIso) {
|
|
const count = list.length;
|
|
// The region every record agrees on (they do, in practice — take the first).
|
|
const region = list[0].wagf_region_label || list[0].wagf_region || 'Other';
|
|
const label = list[0].country_code_label || iso;
|
|
|
|
// Fall back to the PIN extent when there is no polygon, so a country without a
|
|
// boundary still has a usable view rather than defaulting to the whole globe.
|
|
let bbox = geomBbox.get(iso);
|
|
if (!bbox) {
|
|
const lng = list.map((r) => +r.longitude).filter(Number.isFinite);
|
|
const lat = list.map((r) => +r.latitude).filter(Number.isFinite);
|
|
bbox = lng.length ? [Math.min(...lng), Math.min(...lat), Math.max(...lng), Math.max(...lat)] : null;
|
|
}
|
|
|
|
const sheet = count > UNSUPPORTED_AT ? null : count > OVERSIZE_AT ? '3x4' : '3x3';
|
|
|
|
countries.push({
|
|
id: `country-${iso.toLowerCase()}`,
|
|
iso3: iso,
|
|
label,
|
|
count,
|
|
region: region === 'N/A' || !region ? 'Other' : region,
|
|
bbox,
|
|
sheet,
|
|
hasGeometry: haveGeom.has(iso),
|
|
// The not-yet reason is DATA, not UI copy, so the exact number travels with the
|
|
// record and cannot drift from what the wall prints.
|
|
// Brief: "it should say the exact number… we should say the exact number in our data."
|
|
unsupportedReason: sheet ? null
|
|
: `${label} has ${count.toLocaleString()} people groups — too many to fit one poster legibly. A solution is in progress.`,
|
|
});
|
|
}
|
|
|
|
countries.sort((a, b) => b.count - a.count);
|
|
|
|
const stats = {
|
|
countries: countries.length,
|
|
people_groups: rows.length,
|
|
withGeometry: countries.filter((c) => c.hasGeometry).length,
|
|
noGeometry: countries.filter((c) => !c.hasGeometry).length,
|
|
sheet_3x3: countries.filter((c) => c.sheet === '3x3').length,
|
|
sheet_3x4: countries.filter((c) => c.sheet === '3x4').length,
|
|
unsupported: countries.filter((c) => !c.sheet).length,
|
|
single_group: countries.filter((c) => c.count === 1).length,
|
|
generated: new Date().toISOString().slice(0, 10),
|
|
};
|
|
|
|
if (!existsSync(OUT_DIR)) mkdirSync(OUT_DIR, { recursive: true });
|
|
writeFileSync(join(OUT_DIR, 'country-posters.json'), JSON.stringify({ stats, countries }, null, 1));
|
|
|
|
console.log(`[countries] ${stats.countries} countries, ${stats.people_groups} people groups`);
|
|
console.log(`[countries] 3x3=${stats.sheet_3x3} 3x4=${stats.sheet_3x4} unsupported=${stats.unsupported}`);
|
|
console.log(`[countries] no polygon: ${stats.noGeometry} single-group: ${stats.single_group}`);
|
|
for (const c of countries.slice(0, 5)) console.log(` ${String(c.count).padStart(4)} ${c.label} [${c.sheet || 'NOT-YET'}]`);
|