when-am-i / commit
feat: regenerate-all screenshot tooling, robust pin, day-of-year color
README.md | 4 + package.json | 1 + plan.md | 31 ++++++ .../calendars/colonial-america/01-julian.png | Bin 421365 -> 428972 bytes .../calendars/colonial-america/02-gregorian.png | Bin 414401 -> 417193 bytes screenshots/calendars/iran/01-islamic.png | Bin 0 -> 557374 bytes screenshots/calendars/iran/02-persian.png | Bin 0 -> 552322 bytes .../massachusett/01-algonquian-lunar-calendar.png | Bin 1419418 -> 1447957 bytes .../calendars/oregon-country/01-gregorian.png | Bin 0 -> 524826 bytes .../calendars/russian-america/01-julian.png | Bin 0 -> 507205 bytes .../calendars/russian-america/02-gregorian.png | Bin 0 -> 497267 bytes scripts/lib/calendar-shots.mts | 124 +++++++++++++++++++++ scripts/screenshot-calendar.mts | 87 +-------------- scripts/screenshot-calendars.mts | 49 ++++++++ src/components/DatePicker.tsx | 2 +- 15 files changed, 216 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index 3b48cb6..db8c9b1 100644 --- a/README.md +++ b/README.md @@ -88,10 +88,14 @@ running dev server or start one automatically. ```bash npm run screenshot # app states → screenshots/app/ npm run screenshot:calendar -- src/data/calendars/massachusett.json # one calendar → screenshots/calendars/<file>/ +npm run screenshot:calendars # regenerate ALL calendars → screenshots/calendars/ ``` `screenshot:calendar` pins a point inside the region and a representative date in each era, capturing the answer panel and shaded map per era. +`screenshot:calendars` does the same for every calendar data file at once +(reusing one dev server + browser) — run it after adding or editing calendar +data so the checked-in screenshots stay current. ## Reporting issues diff --git a/package.json b/package.json index 0aa3749..65e611b 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "test:watch": "vitest", "screenshot": "tsx scripts/generate-screenshots.mts", "screenshot:calendar": "tsx scripts/screenshot-calendar.mts", + "screenshot:calendars": "tsx scripts/screenshot-calendars.mts", "gen:badges": "tsx scripts/gen-badges.mts", "gen:favicon": "sips -z 64 64 calendar-source.png --out src/assets/favicon.png", "copy:leaflet-icons": "mkdir -p public && cp node_modules/leaflet/dist/images/marker-icon.png public/ && cp node_modules/leaflet/dist/images/marker-icon-2x.png public/ && cp node_modules/leaflet/dist/images/marker-shadow.png public/", diff --git a/plan.md b/plan.md index c2cf035..e3e3961 100644 --- a/plan.md +++ b/plan.md @@ -237,6 +237,37 @@ calendar region file end-to-end, lowering the bar to contribute data. Sketch of - Possibly a second, smaller skill to **review/QA** an existing dataset against its sources. This is a tooling/authoring aid, not app runtime; keep it in `.claude/`, not shipped to users. +### In-browser map creation/editing (requested 2026-06-20) +Let contributors **draw and edit region polygons directly in the app**, instead of +hand-editing GeoJSON or round-tripping through geojson.io. Lowers the contribution bar +a lot (the current flow asks people to author raw `[lng, lat]` rings by hand). Initially +felt heavy-handed (a full path-editing UI), but it now pairs naturally with the other +authoring/build-time tooling being considered: +- A Leaflet drawing layer (e.g. `leaflet-draw` / `react-leaflet` editing) to add/move/delete + polygon vertices on the existing map, emitting schema-shaped GeoJSON. +- An **export** path: produce a ready-to-commit region JSON (or a prefilled GitLab + merge-request / issue) so a drawn map becomes a contribution without local tooling. +- Naturally combines with **land-clipping** (below) so a roughly-drawn coastal polygon is + auto-trimmed to land, and with **AI-assisted authoring** (above) so the AI can draft a + polygon the user then nudges by hand. +- Open question: purely a contributor/authoring tool vs. a playful end-user feature + ("draw your own region"). Keep authoring output reproducible either way. + +### Scalable calendar colors (requested 2026-06-20) +Today [src/utils/calendarColor.ts](src/utils/calendarColor.ts) already computes colors +**on the fly** — colors are *not* stored in the data files. A small curated `PALETTE` +covers the common calendars for recognizability; anything else falls back to a +deterministic `hsl(hashHue(name), 60%, 75%)`, so it already scales to many calendars. +The weakness at scale is **distinctness**, not hard-coding: `hashHue` can map two names +to near-identical hues, and the fixed saturation/lightness can clash or wash out. +Future work — generate visually separable colors: +- Space hues apart (e.g. golden-angle increments) instead of a raw name hash, and/or + assign colors **per render** so the calendars actually on screen are maximally distinct. +- Keep determinism where it matters (legend ↔ map agreement within a view) and keep the + curated palette for the handful of well-known calendars (or drop it if a good generator + makes it redundant — that would touch [calendarColor.test.ts](src/utils/calendarColor.test.ts)). +- Consider contrast/accessibility (avoid colors that vanish on the map tiles). + ### Rich descriptive-only answers For non-convertible calendars, replace the plain "no year count" note with the kind of answer a real local would give. Example target for **Boston, 1700-04-01**, asked of a diff --git a/screenshots/calendars/colonial-america/01-julian.png b/screenshots/calendars/colonial-america/01-julian.png index a4ac690..c7b794f 100644 Binary files a/screenshots/calendars/colonial-america/01-julian.png and b/screenshots/calendars/colonial-america/01-julian.png differ diff --git a/screenshots/calendars/colonial-america/02-gregorian.png b/screenshots/calendars/colonial-america/02-gregorian.png index 9b2f8b2..5cd9402 100644 Binary files a/screenshots/calendars/colonial-america/02-gregorian.png and b/screenshots/calendars/colonial-america/02-gregorian.png differ diff --git a/screenshots/calendars/iran/01-islamic.png b/screenshots/calendars/iran/01-islamic.png new file mode 100644 index 0000000..dc2ab52 Binary files /dev/null and b/screenshots/calendars/iran/01-islamic.png differ diff --git a/screenshots/calendars/iran/02-persian.png b/screenshots/calendars/iran/02-persian.png new file mode 100644 index 0000000..1c3bead Binary files /dev/null and b/screenshots/calendars/iran/02-persian.png differ diff --git a/screenshots/calendars/massachusett/01-algonquian-lunar-calendar.png b/screenshots/calendars/massachusett/01-algonquian-lunar-calendar.png index e89554e..fec53b0 100644 Binary files a/screenshots/calendars/massachusett/01-algonquian-lunar-calendar.png and b/screenshots/calendars/massachusett/01-algonquian-lunar-calendar.png differ diff --git a/screenshots/calendars/oregon-country/01-gregorian.png b/screenshots/calendars/oregon-country/01-gregorian.png new file mode 100644 index 0000000..aa69ffc Binary files /dev/null and b/screenshots/calendars/oregon-country/01-gregorian.png differ diff --git a/screenshots/calendars/russian-america/01-julian.png b/screenshots/calendars/russian-america/01-julian.png new file mode 100644 index 0000000..fcdf943 Binary files /dev/null and b/screenshots/calendars/russian-america/01-julian.png differ diff --git a/screenshots/calendars/russian-america/02-gregorian.png b/screenshots/calendars/russian-america/02-gregorian.png new file mode 100644 index 0000000..ba1222a Binary files /dev/null and b/screenshots/calendars/russian-america/02-gregorian.png differ diff --git a/scripts/lib/calendar-shots.mts b/scripts/lib/calendar-shots.mts new file mode 100644 index 0000000..a7c1615 --- /dev/null +++ b/scripts/lib/calendar-shots.mts @@ -0,0 +1,124 @@ +// Shared logic for screenshotting one calendar data file across its eras. +// +// Used by both the single-file entry (screenshot-calendar.mts) and the +// regenerate-all entry (screenshot-calendars.mts) so the two can't drift. For +// each era it pins a point inside the region and a representative date in that +// era, then captures the answer panel + shaded map. Output: +// screenshots/calendars/<file>/ + +import type { Page } from 'playwright'; +import { readFile, mkdir, rm } from 'node:fs/promises'; +import path from 'node:path'; +import { BASE_URL, REPO_ROOT } from './server.mts'; +import { pointInPolygon } from '../../src/utils/geo.ts'; +import type { Geometry } from '../../src/types/index.ts'; + +interface Era { + name: string; + start: string | null; + end: string | null; + calendar: string; +} +interface CalendarRegion { + region: { name: string; geo: { type: string; coordinates: unknown } }; + eras: Era[]; +} + +// Flatten any Polygon/MultiPolygon to its [lng, lat] positions. +function positions(geo: { type: string; coordinates: unknown }): [number, number][] { + const coords = geo.coordinates as number[][][] | number[][][][]; + return geo.type === 'Polygon' + ? (coords as number[][][]).flat() as [number, number][] + : (coords as number[][][][]).flat(2) as [number, number][]; +} + +// A pin location guaranteed to be inside the region, plus a fitting zoom. The +// bbox center works for blobby polygons but falls outside concave ones (e.g. a +// coast-hugging region whose bbox center sits in open water), which would make +// the app render "no calendar data for this spot". When that happens we grid- +// search the bbox for the interior point nearest the center, so the pin always +// lands on the region. +function pinAndZoom(geo: { type: string; coordinates: unknown }) { + const pts = positions(geo); + const lngs = pts.map((p) => p[0]); + const lats = pts.map((p) => p[1]); + const minLng = Math.min(...lngs), maxLng = Math.max(...lngs); + const minLat = Math.min(...lats), maxLat = Math.max(...lats); + const cLng = (minLng + maxLng) / 2, cLat = (minLat + maxLat) / 2; + const span = Math.max(maxLng - minLng, maxLat - minLat, 0.01); + const zoom = Math.max(2, Math.min(14, Math.floor(Math.log2(360 / span)) - 1)); + + const geometry = geo as Geometry; + let center = { lng: cLng, lat: cLat }; + if (!pointInPolygon(cLat, cLng, geometry)) { + const N = 24; + let bestDist = Infinity; + for (let i = 1; i < N; i++) { + for (let j = 1; j < N; j++) { + const lng = minLng + ((maxLng - minLng) * i) / N; + const lat = minLat + ((maxLat - minLat) * j) / N; + if (!pointInPolygon(lat, lng, geometry)) continue; + const dist = (lat - cLat) ** 2 + (lng - cLng) ** 2; + if (dist < bestDist) { + bestDist = dist; + center = { lng, lat }; + } + } + } + } + return { center, zoom }; +} + +function yearOf(value: string | null): number | null { + if (!value) return null; + const m = /^(-?\d+)/.exec(value); + return m ? parseInt(m[1], 10) : null; +} + +// A representative astronomical year inside an era's range. +function representativeYear(era: Era): number { + const start = yearOf(era.start); + const end = yearOf(era.end); + if (start !== null && end !== null) return Math.round((start + end) / 2); + if (start !== null) return start + 25; + if (end !== null) return end - 25; + return 1700; +} + +function slug(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); +} + +/** + * Screenshot every era of one calendar file. Reuses the given page (and thus the + * already-running dev server/browser). Returns the number of shots written. + */ +export async function screenshotCalendar(page: Page, fileArg: string): Promise<number> { + const absPath = path.resolve(fileArg); + const data = JSON.parse(await readFile(absPath, 'utf-8')) as CalendarRegion; + const { center, zoom } = pinAndZoom(data.region.geo); + + const outDir = path.join(REPO_ROOT, 'screenshots', 'calendars', path.basename(fileArg, '.json')); + console.log(`\n📸 ${data.region.name} (${data.eras.length} eras) — ${path.basename(fileArg)}`); + + await rm(outDir, { recursive: true, force: true }); + await mkdir(outDir, { recursive: true }); + + for (let i = 0; i < data.eras.length; i++) { + const era = data.eras[i]; + const astroYear = representativeYear(era); + const year = astroYear > 0 ? astroYear : 1 - astroYear; + const eraParam = astroYear > 0 ? 'CE' : 'BCE'; + const query = + `?lat=${center.lat}&lng=${center.lng}&zoom=${zoom}` + + `&year=${year}&month=7&day=1&era=${eraParam}`; + + await page.goto(`${BASE_URL}${query}`, { waitUntil: 'networkidle' }); + await page.waitForTimeout(800); + const name = `${String(i + 1).padStart(2, '0')}-${slug(era.calendar)}.png`; + await page.screenshot({ path: path.join(outDir, name), fullPage: true }); + console.log(` ✅ ${name} — ${era.name} (${year} ${eraParam})`); + } + + return data.eras.length; +} diff --git a/scripts/screenshot-calendar.mts b/scripts/screenshot-calendar.mts index 53827f6..f53e4bf 100644 --- a/scripts/screenshot-calendar.mts +++ b/scripts/screenshot-calendar.mts @@ -6,62 +6,13 @@ // For each era it pins a point inside the region and a representative date in // that era, then captures the answer panel + shaded map. Output: // screenshots/calendars/<file>/ +// +// To regenerate shots for every calendar at once, use `npm run screenshot:calendars`. import { chromium } from 'playwright'; -import { readFile, mkdir, rm } from 'node:fs/promises'; import path from 'node:path'; -import { startDevServer, BASE_URL, REPO_ROOT } from './lib/server.mts'; - -interface Era { - name: string; - start: string | null; - end: string | null; - calendar: string; -} -interface CalendarRegion { - region: { name: string; geo: { type: string; coordinates: unknown } }; - eras: Era[]; -} - -// Flatten any Polygon/MultiPolygon to its [lng, lat] positions. -function positions(geo: { type: string; coordinates: unknown }): [number, number][] { - const coords = geo.coordinates as number[][][] | number[][][][]; - return geo.type === 'Polygon' - ? (coords as number[][][]).flat() as [number, number][] - : (coords as number[][][][]).flat(2) as [number, number][]; -} - -function bboxCenterAndZoom(geo: { type: string; coordinates: unknown }) { - const pts = positions(geo); - const lngs = pts.map((p) => p[0]); - const lats = pts.map((p) => p[1]); - const minLng = Math.min(...lngs), maxLng = Math.max(...lngs); - const minLat = Math.min(...lats), maxLat = Math.max(...lats); - const center = { lng: (minLng + maxLng) / 2, lat: (minLat + maxLat) / 2 }; - const span = Math.max(maxLng - minLng, maxLat - minLat, 0.01); - const zoom = Math.max(2, Math.min(14, Math.floor(Math.log2(360 / span)) - 1)); - return { center, zoom }; -} - -function yearOf(value: string | null): number | null { - if (!value) return null; - const m = /^(-?\d+)/.exec(value); - return m ? parseInt(m[1], 10) : null; -} - -// A representative astronomical year inside an era's range. -function representativeYear(era: Era): number { - const start = yearOf(era.start); - const end = yearOf(era.end); - if (start !== null && end !== null) return Math.round((start + end) / 2); - if (start !== null) return start + 25; - if (end !== null) return end - 25; - return 1700; -} - -function slug(value: string): string { - return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); -} +import { startDevServer } from './lib/server.mts'; +import { screenshotCalendar } from './lib/calendar-shots.mts'; async function run() { const fileArg = process.argv[2]; @@ -70,39 +21,13 @@ async function run() { process.exit(1); } - const absPath = path.resolve(fileArg); - const data = JSON.parse(await readFile(absPath, 'utf-8')) as CalendarRegion; - const { center, zoom } = bboxCenterAndZoom(data.region.geo); - - const outDir = path.join(REPO_ROOT, 'screenshots', 'calendars', path.basename(fileArg, '.json')); - console.log(`\n📸 Screenshotting ${data.region.name} (${data.eras.length} eras)…\n`); - const server = await startDevServer(); try { - await rm(outDir, { recursive: true, force: true }); - await mkdir(outDir, { recursive: true }); - const browser = await chromium.launch(); const page = await browser.newPage({ viewport: { width: 1100, height: 920 }, deviceScaleFactor: 2 }); - - for (let i = 0; i < data.eras.length; i++) { - const era = data.eras[i]; - const astroYear = representativeYear(era); - const year = astroYear > 0 ? astroYear : 1 - astroYear; - const eraParam = astroYear > 0 ? 'CE' : 'BCE'; - const query = - `?lat=${center.lat}&lng=${center.lng}&zoom=${zoom}` + - `&year=${year}&month=7&day=1&era=${eraParam}`; - - await page.goto(`${BASE_URL}${query}`, { waitUntil: 'networkidle' }); - await page.waitForTimeout(800); - const name = `${String(i + 1).padStart(2, '0')}-${slug(era.calendar)}.png`; - await page.screenshot({ path: path.join(outDir, name), fullPage: true }); - console.log(` ✅ ${name} — ${era.name} (${year} ${eraParam})`); - } - + await screenshotCalendar(page, fileArg); await browser.close(); - console.log(`\n✨ Done — ${path.relative(REPO_ROOT, outDir)}/\n`); + console.log(`\n✨ Done — screenshots/calendars/${path.basename(fileArg, '.json')}/\n`); } finally { server.stop(); } diff --git a/scripts/screenshot-calendars.mts b/scripts/screenshot-calendars.mts new file mode 100644 index 0000000..f9156ae --- /dev/null +++ b/scripts/screenshot-calendars.mts @@ -0,0 +1,49 @@ +// Regenerate the per-era screenshots for EVERY calendar data file at once. +// +// npm run screenshot:calendars +// +// Discovers every region file in src/data/calendars/ (except the SCHEMA doc) and +// shoots each one, reusing a single dev server + browser. Output mirrors the +// single-file script: screenshots/calendars/<file>/. Run this after adding or +// editing calendar data so the checked-in screenshots stay current. + +import { chromium } from 'playwright'; +import { readdir } from 'node:fs/promises'; +import path from 'node:path'; +import { startDevServer, REPO_ROOT } from './lib/server.mts'; +import { screenshotCalendar } from './lib/calendar-shots.mts'; + +const CALENDARS_DIR = path.join(REPO_ROOT, 'src/data/calendars'); + +async function run() { + const files = (await readdir(CALENDARS_DIR)) + .filter((f) => f.endsWith('.json') && !f.startsWith('SCHEMA')) + .sort(); + + if (files.length === 0) { + console.error('No calendar data files found in src/data/calendars/'); + process.exit(1); + } + + console.log(`\n📸 Regenerating screenshots for ${files.length} calendar(s)…`); + const server = await startDevServer(); + try { + const browser = await chromium.launch(); + const page = await browser.newPage({ viewport: { width: 1100, height: 920 }, deviceScaleFactor: 2 }); + + let total = 0; + for (const file of files) { + total += await screenshotCalendar(page, path.join(CALENDARS_DIR, file)); + } + + await browser.close(); + console.log(`\n✨ Done — ${total} shot(s) across ${files.length} calendar(s) in screenshots/calendars/\n`); + } finally { + server.stop(); + } +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/components/DatePicker.tsx b/src/components/DatePicker.tsx index 912bff1..425dcd9 100644 --- a/src/components/DatePicker.tsx +++ b/src/components/DatePicker.tsx @@ -92,7 +92,7 @@ const DatePicker: React.FC<DatePickerProps> = ({ value, onChange, yearBands }) = width: `${(doy / yearDays) * 100}%`, top: 0, bottom: 0, - background: '#bcd9f0', + background: '#adb5bd', }} /> </div>