fuzzyworld

when-am-i / files / scripts / lib / calendar-shots.mts

calendar-shots.mts

5.1 KB · history · blame

1// Shared logic for screenshotting one calendar data file across its eras.
2//
3// Used by both the single-file entry (screenshot-calendar.mts) and the
4// regenerate-all entry (screenshot-calendars.mts) so the two can't drift. For
5// each era it pins a point inside the region and a representative date in that
6// era, then captures the answer panel + shaded map. Output:
7// screenshots/calendars/<file>/
8
9import type { Page } from 'playwright';
10import { readFile, mkdir, rm } from 'node:fs/promises';
11import path from 'node:path';
12import { BASE_URL, REPO_ROOT } from './server.mts';
13import { pointInPolygon } from '../../src/utils/geo.ts';
14import type { Geometry } from '../../src/types/index.ts';
15
16interface Era {
17 name: string;
18 start: string | null;
19 end: string | null;
20 calendar: string;
21}
22interface CalendarRegion {
23 region: { name: string; geo: { type: string; coordinates: unknown } };
24 eras: Era[];
25}
26
27// Flatten any Polygon/MultiPolygon to its [lng, lat] positions.
28function positions(geo: { type: string; coordinates: unknown }): [number, number][] {
29 const coords = geo.coordinates as number[][][] | number[][][][];
30 return geo.type === 'Polygon'
31 ? (coords as number[][][]).flat() as [number, number][]
32 : (coords as number[][][][]).flat(2) as [number, number][];
33}
34
35// A pin location guaranteed to be inside the region, plus a fitting zoom. The
36// bbox center works for blobby polygons but falls outside concave ones (e.g. a
37// coast-hugging region whose bbox center sits in open water), which would make
38// the app render "no calendar data for this spot". When that happens we grid-
39// search the bbox for the interior point nearest the center, so the pin always
40// lands on the region.
41function pinAndZoom(geo: { type: string; coordinates: unknown }) {
42 const pts = positions(geo);
43 const lngs = pts.map((p) => p[0]);
44 const lats = pts.map((p) => p[1]);
45 const minLng = Math.min(...lngs), maxLng = Math.max(...lngs);
46 const minLat = Math.min(...lats), maxLat = Math.max(...lats);
47 const cLng = (minLng + maxLng) / 2, cLat = (minLat + maxLat) / 2;
48 const span = Math.max(maxLng - minLng, maxLat - minLat, 0.01);
49 const zoom = Math.max(2, Math.min(14, Math.floor(Math.log2(360 / span)) - 1));
50
51 const geometry = geo as Geometry;
52 let center = { lng: cLng, lat: cLat };
53 if (!pointInPolygon(cLat, cLng, geometry)) {
54 const N = 24;
55 let bestDist = Infinity;
56 for (let i = 1; i < N; i++) {
57 for (let j = 1; j < N; j++) {
58 const lng = minLng + ((maxLng - minLng) * i) / N;
59 const lat = minLat + ((maxLat - minLat) * j) / N;
60 if (!pointInPolygon(lat, lng, geometry)) continue;
61 const dist = (lat - cLat) ** 2 + (lng - cLng) ** 2;
62 if (dist < bestDist) {
63 bestDist = dist;
64 center = { lng, lat };
65 }
66 }
67 }
68 }
69 return { center, zoom };
70}
71
72function yearOf(value: string | null): number | null {
73 if (!value) return null;
74 const m = /^(-?\d+)/.exec(value);
75 return m ? parseInt(m[1], 10) : null;
76}
77
78// A representative astronomical year inside an era's range.
79function representativeYear(era: Era): number {
80 const start = yearOf(era.start);
81 const end = yearOf(era.end);
82 if (start !== null && end !== null) return Math.round((start + end) / 2);
83 if (start !== null) return start + 25;
84 if (end !== null) return end - 25;
85 return 1700;
86}
87
88function slug(value: string): string {
89 return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
90}
91
92/**
93 * Screenshot every era of one calendar file. Reuses the given page (and thus the
94 * already-running dev server/browser). Returns the number of shots written.
95 */
96export async function screenshotCalendar(page: Page, fileArg: string): Promise<number> {
97 const absPath = path.resolve(fileArg);
98 const data = JSON.parse(await readFile(absPath, 'utf-8')) as CalendarRegion;
99 const { center, zoom } = pinAndZoom(data.region.geo);
100
101 const outDir = path.join(REPO_ROOT, 'screenshots', 'calendars', path.basename(fileArg, '.json'));
102 console.log(`\n📸 ${data.region.name} (${data.eras.length} eras) — ${path.basename(fileArg)}`);
103
104 await rm(outDir, { recursive: true, force: true });
105 await mkdir(outDir, { recursive: true });
106
107 for (let i = 0; i < data.eras.length; i++) {
108 const era = data.eras[i];
109 const astroYear = representativeYear(era);
110 const year = astroYear > 0 ? astroYear : 1 - astroYear;
111 const eraParam = astroYear > 0 ? 'CE' : 'BCE';
112 const query =
113 `?lat=${center.lat}&lng=${center.lng}&zoom=${zoom}` +
114 `&year=${year}&month=7&day=1&era=${eraParam}`;
115
116 await page.goto(`${BASE_URL}${query}`, { waitUntil: 'networkidle' });
117 await page.waitForTimeout(800);
118 const name = `${String(i + 1).padStart(2, '0')}-${slug(era.calendar)}.png`;
119 await page.screenshot({ path: path.join(outDir, name), fullPage: true });
120 console.log(` ✅ ${name} — ${era.name} (${year} ${eraParam})`);
121 }
122
123 return data.eras.length;
124}