Comments in library/ and internal/ that quoted a person, attributed a decision to one, used first person, named fleet agents or carried private host names now state the same technical fact in neutral words: poster-render (including the comments it writes into generated posters), packet-render, the poster and research scripts, PosterWall, DataTable, the legends, the layouts and the gates. The network-policy note states the policy itself. Behaviour is unchanged: a few printed gate messages and reason labels drop an agent name, the poster-render integrity pin follows its comment-only edit, and series.json changes only in its _meta and _why notes. The historical poster design snapshots (library/scripts/posters/snapshots/), which nothing reads, are removed.
153 lines
7 KiB
JavaScript
153 lines
7 KiB
JavaScript
// osm-basemap.mjs — STREET-LEVEL basemaps with NO TOKEN and NO ACCOUNT.
|
|
//
|
|
// node osm-basemap.mjs <lat> <lng> <zoom> <outfile.png>
|
|
//
|
|
// ═══ WHY THIS EXISTS ════════════════════════════════════════════════════════
|
|
// City posters need streets and data, at a higher resolution than the polygon
|
|
// posters. An earlier static-image generator did this through Mapbox, but it is
|
|
// legacy and needs a Mapbox token, so a token-free alternative is better.
|
|
//
|
|
// Both halves of that were verified:
|
|
// - the earlier generator's file is literally named `legacy-static-image-service.js` and
|
|
// hits `api.mapbox.com/styles/v...` with `static mapboxToken`.
|
|
// - The 100-list/country/city posters draw ADM0/ADM1 POLYGONS. That is correct
|
|
// at country scale and useless at city scale: a 9km cluster needs STREETS,
|
|
// and a polygon fill has none.
|
|
//
|
|
// THE TOKEN-FREE PATH: OpenStreetMap raster tiles. Verified 2026-08-01 —
|
|
// https://tile.openstreetmap.org/{z}/{x}/{y}.png returns a real 256x256 PNG with
|
|
// NO key and NO account, which is the whole point:
|
|
// * a Mapbox token in a client-side poster generator is a credential to leak;
|
|
// * a token means an account, and an account is a name — against the
|
|
// no-account privacy stance for sensitive-context work;
|
|
// * privacy-first: no third party gets to log who rendered what.
|
|
//
|
|
// ═══ USAGE POLICY IS A HARD CONSTRAINT, NOT A SUGGESTION ════════════════════
|
|
// OSM's tile servers are donated infrastructure. Their policy requires an
|
|
// identifying User-Agent, forbids bulk downloading, and expects heavy users to
|
|
// self-host. So this module:
|
|
// * sends a real User-Agent
|
|
// * CACHES every tile on disk and never re-fetches one
|
|
// * rate-limits sequential fetches
|
|
// * caps the tile count per render (a runaway zoom would be bulk downloading)
|
|
// For production volume the right answer is a self-hosted tile server or
|
|
// Protomaps pmtiles; this is correct and polite for poster-scale use.
|
|
//
|
|
// HD comes from TILE COUNT, not from a bigger source image: at z14 a 4x4 grid is
|
|
// 1024px of real street detail. Raising zoom raises detail AND tile count, which
|
|
// is why the cap exists.
|
|
|
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, join } from 'node:path';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const CACHE = join(__dirname, 'out', '_tilecache');
|
|
|
|
export const TILE_SOURCE = {
|
|
name: 'OpenStreetMap raster tiles',
|
|
url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
|
licence: '© OpenStreetMap contributors, ODbL',
|
|
attribution_required: true,
|
|
no_key: true,
|
|
policy: 'identifying User-Agent required; no bulk download; heavy use must self-host',
|
|
};
|
|
|
|
const UA = 'doxa-maps-poster/1.0 (people-group prayer posters; contact via repo)';
|
|
const MAX_TILES = 64; // 8x8 — beyond this, self-host instead
|
|
|
|
// ── Web Mercator tile math (the standard slippy-map transform) ──────────────
|
|
export const lngToTileX = (lng, z) => ((lng + 180) / 360) * Math.pow(2, z);
|
|
export const latToTileY = (lat, z) => {
|
|
const r = (lat * Math.PI) / 180;
|
|
return ((1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2) * Math.pow(2, z);
|
|
};
|
|
|
|
/**
|
|
* Fetch one tile, cached forever on disk. A cached tile is NEVER re-requested —
|
|
* that is what keeps repeated poster builds off OSM's servers entirely.
|
|
*/
|
|
async function tile(z, x, y) {
|
|
const f = join(CACHE, `${z}_${x}_${y}.png`);
|
|
if (existsSync(f)) return readFileSync(f);
|
|
// RETRY a transient failure before giving up on the whole basemap. OSM returns
|
|
// 503 under load, and a single 503 was aborting an entire poster's tile grid —
|
|
// the sheet then rendered with no streets at all, which the poster guard caught.
|
|
// One flaky tile must not cost the map.
|
|
let res = null;
|
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
res = await fetch(`https://tile.openstreetmap.org/${z}/${x}/${y}.png`, {
|
|
headers: { 'User-Agent': UA },
|
|
});
|
|
if (res.ok) break;
|
|
if (res.status !== 503 && res.status !== 429) break; // a real error, not load
|
|
await new Promise((r) => setTimeout(r, 800 * (attempt + 1)));
|
|
}
|
|
if (!res || !res.ok) throw new Error(`tile ${z}/${x}/${y} -> HTTP ${res ? res.status : 'no response'}`);
|
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
if (!existsSync(CACHE)) mkdirSync(CACHE, { recursive: true });
|
|
writeFileSync(f, buf);
|
|
await new Promise((r) => setTimeout(r, 120)); // polite
|
|
return buf;
|
|
}
|
|
|
|
/**
|
|
* Fetch the tiles covering a bbox at a zoom, returning the grid + the exact
|
|
* pixel-space geo transform so pins can be placed on the stitched image.
|
|
*
|
|
* Returns { tiles, cols, rows, px, py, width, height, project }.
|
|
* `project(lng,lat)` gives pixel coordinates ON THE STITCHED IMAGE — without it
|
|
* a basemap is decoration, because nothing can be positioned on it.
|
|
*/
|
|
export async function fetchGrid(bbox, zoom) {
|
|
const [w, s, e, n] = bbox;
|
|
const x0 = Math.floor(lngToTileX(w, zoom));
|
|
const x1 = Math.floor(lngToTileX(e, zoom));
|
|
const y0 = Math.floor(latToTileY(n, zoom)); // north = smaller y
|
|
const y1 = Math.floor(latToTileY(s, zoom));
|
|
const cols = x1 - x0 + 1, rows = y1 - y0 + 1;
|
|
|
|
if (cols * rows > MAX_TILES) {
|
|
throw new Error(`[osm] ${cols}x${rows}=${cols * rows} tiles exceeds the ${MAX_TILES} cap — `
|
|
+ 'lower the zoom or self-host a tile server. Bulk downloading OSM is against their policy.');
|
|
}
|
|
|
|
const tiles = [];
|
|
for (let ty = y0; ty <= y1; ty++) {
|
|
for (let tx = x0; tx <= x1; tx++) {
|
|
tiles.push({ x: tx, y: ty, col: tx - x0, row: ty - y0, buf: await tile(zoom, tx, ty) });
|
|
}
|
|
}
|
|
|
|
const width = cols * 256, height = rows * 256;
|
|
const project = (lng, lat) => [
|
|
(lngToTileX(lng, zoom) - x0) * 256,
|
|
(latToTileY(lat, zoom) - y0) * 256,
|
|
];
|
|
return { tiles, cols, rows, x0, y0, zoom, width, height, project };
|
|
}
|
|
|
|
/**
|
|
* Pick the highest zoom whose tile grid still fits the cap for this bbox.
|
|
* Detail is a function of zoom, so taking the most we are allowed is the whole
|
|
* "make them more HD" ask — bounded by the policy rather than by guesswork.
|
|
*/
|
|
export function bestZoom(bbox, maxTiles = MAX_TILES) {
|
|
for (let z = 16; z >= 3; z--) {
|
|
const cols = Math.floor(lngToTileX(bbox[2], z)) - Math.floor(lngToTileX(bbox[0], z)) + 1;
|
|
const rows = Math.floor(latToTileY(bbox[1], z)) - Math.floor(latToTileY(bbox[3], z)) + 1;
|
|
if (cols * rows <= maxTiles) return z;
|
|
}
|
|
return 3;
|
|
}
|
|
|
|
// CLI smoke test
|
|
if (process.argv[1] && process.argv[1].endsWith('osm-basemap.mjs')) {
|
|
const [lat, lng, z] = [Number(process.argv[2]), Number(process.argv[3]), Number(process.argv[4] || 13)];
|
|
const d = 0.05;
|
|
const bbox = [lng - d, lat - d, lng + d, lat + d];
|
|
const g = await fetchGrid(bbox, z);
|
|
console.log(`[osm] ${g.cols}x${g.rows} tiles @ z${z} = ${g.width}x${g.height}px`);
|
|
console.log(`[osm] pin at centre -> ${g.project(lng, lat).map((v) => v.toFixed(0)).join(',')}`);
|
|
console.log(`[osm] cache: ${CACHE}`);
|
|
}
|