doxa-maps/library/packet/posterSheets.js
Doxa Maps 7b494facf5 The browser-side generator keeps the reason and drops the speaker
library/packet/poster-render.js and posterSheets.js arrive in this landing
carrying the generator's comments as they stood before the sweep: two lines
named the Driver and one said "he changed the SHAPE". They are reworded as
the sweep reworded them in generate.mjs, and two pronouns the sweep left
behind (SearchFilterHeader, check-filter-width-stable) now say what was
asked instead of who asked. The baseline drops the two generate.mjs lines
that moved out.
2026-09-29 05:26:27 +00:00

119 lines
6.4 KiB
JavaScript

/**
* posterSheets.js — generate a map sheet IN THE BROWSER, at the moment it is asked for.
*
* The print standard's rule (2026-09-29): exports are generated, not stored; only true previews
* are stored. The map sheet — a poster, or the map-only sheet on a packet's page 1 — used to exist only
* if the Node generator had written it ahead of time. poster-render.js is that generator as a pure
* factory; this module is its browser feed: it loads the two tables every sheet shares (the series
* definitions and the Natural Earth places) once, keeps one generator per format, and draws.
*
* What only the HOST knows, it passes: the zone, its pins, its geometry (a FeatureCollection — the same
* resolveZoneGeometry output generate.mjs feeds), the ISO3 -> English name table the labels are drawn
* from, and the series facts ("map 3 of 28"). A packet in another language relabels the drawn names
* with packet-render's liftMap, exactly as it does for a stored sheet.
*
* const html = await generateSheet({ zone, pins, zoneFC, format: 'packet', seriesId: 'upg100',
* ISO3_NAME, series: { totalGroups, totalLists, index } })
*/
import { createPosterGenerator } from './poster-render.js'
let shared = null
function loadShared() {
shared ||= Promise.all([
import('../scripts/posters/series.json').then((m) => m.default || m),
import('../scripts/posters/data/ne-places.json').then((m) => (m.default || m).places || []).catch(() => []),
]).then(([seriesAll, nePlaces]) => ({ seriesAll, nePlaces }))
shared.catch(() => { shared = null })
return shared
}
/**
* @returns {Promise<string>} the sheet's full HTML document ('' when the zone cannot be drawn).
* format '3x4' | '3x3' | 'letter' | 'packet' (map-only, for a packet's page 1)
* seriesId a key of series.json ('upg100', 'country'); decides the poster's text and networks
* date the stamp printed on the sheet (default today) — pass one to reproduce a sheet exactly
*/
export async function generateSheet({
zone, pins = [], zoneFC = null, format = '3x4', seriesId = 'upg100', ISO3_NAME = {},
adm0 = null, adm1Ind = null, adm1Chn = null, series = { totalGroups: 0, totalLists: 0, index: 1 }, date = null,
} = {}) {
if (!zone || !zone.zoneKey) return ''
const { seriesAll, nePlaces } = await loadShared()
const SERIES_DEF = seriesAll[seriesId] || seriesAll.upg100
// ONE GENERATOR PER CALL. The factory's setup is cheap, and caching it by format (as this first did)
// pinned the FIRST caller's data — its boundaries, its name table — onto every later sheet: a caller
// without adm0 poisoned the cache for one with it. The inputs are the caller's, every time.
const gen = createPosterGenerator({
env: { FORMAT: format, ...(date ? { POSTER_DATE: date } : {}) },
SERIES_DEF, ISO3_NAME, adm0, adm1Ind, adm1Chn, nePlaces, series: { totalGroups: 0, totalLists: 0, index: 1, ...series },
})
const fc = zoneFC || { type: 'FeatureCollection', features: [] }
const out = await gen.renderPoster(zone, pins, fc)
return (out && out.html) || ''
}
// ── COUNTRIES ───────────────────────────────────────────────────────────────
// The world boundaries the Node generator reads (data/adm0/world-adm0.geojson) ship in the served tree
// as boundaries/world-adm0.geojson — the same file — so a sheet drawn here uses the same outlines.
// Fetched once per base, at the first print, never at page load.
const jsonCache = new Map()
function getJSON(url) {
if (!jsonCache.has(url)) {
const p = fetch(url, { cache: 'force-cache' }).then((r) => {
if (!r.ok) throw new Error(`${url.split('/').pop()} not found (${r.status})`)
return r.json()
})
p.catch(() => jsonCache.delete(url))
jsonCache.set(url, p)
}
return jsonCache.get(url)
}
export function loadAdm0(assetsBase) { return getJSON(`${assetsBase}/boundaries/world-adm0.geojson`) }
// The FULL-resolution set generate.mjs draws from (the map shows the lighter -web files; print does not):
// world ADM0, and the India and China ADM1 the Asia zones are made of. Same files, same bytes.
export async function loadBoundaries(assetsBase) {
const [adm0, adm1Ind, adm1Chn] = await Promise.all([
loadAdm0(assetsBase),
getJSON(`${assetsBase}/boundaries/gb-IND-ADM1.geojson`),
getJSON(`${assetsBase}/boundaries/gb-CHN-ADM1.geojson`),
])
return { adm0, adm1Ind, adm1Chn }
}
// The zone generate.mjs builds in COUNTRY mode, and its geometry exactly as resolveZoneGeometry resolves
// a country zone: the country's own ADM0 polygon(s), stamped with the zone — present only when the
// country has groups (memberCountryCodes is empty otherwise).
export function countryZone({ id, iso3, label, count }) {
return { id, zoneKey: id, label, count, color: '#2bb', adm0: [iso3], selector: { type: 'country_codes', values: [iso3] } }
}
export function countryGeometry(zone, adm0, hasGroups) {
const fc = { type: 'FeatureCollection', features: [] }
if (!hasGroups || !adm0) return fc
const want = new Set(zone.adm0 || [])
for (const f of adm0.features || []) {
if (!f.properties || !want.has(f.properties.iso3)) continue
fc.features.push({ type: 'Feature', properties: { ...f.properties, zoneKey: zone.zoneKey, zoneColor: zone.color, zoneLabel: zone.label }, geometry: f.geometry })
}
return fc
}
// Records -> the generator's pins: every group with coordinates (the fields generate.mjs keeps).
export function pinsFrom(records) {
const out = []
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 })
}
return out
}
/**
* A country's sheet, generated now: its people groups (records, with coordinates) on its own outline.
* @param country { id, iso3, label, count } — one entry of library/data/country-posters.json
*/
export async function generateCountrySheet({ country, records, assetsBase, ISO3_NAME, format = 'letter', series, date = null }) {
const zone = countryZone(country)
const adm0 = await loadAdm0(assetsBase)
const zoneFC = countryGeometry(zone, adm0, (records || []).length > 0)
return generateSheet({ zone, pins: pinsFrom(records), zoneFC, format, seriesId: 'country', ISO3_NAME, adm0, series, date })
}