fuzzyworld

when-am-i / commit

feat: regenerate-all screenshot tooling, robust pin, day-of-year color

73912a477217ffa0b32e567472ce447fe4557cc7 · Pavel Safronov · 2026-06-20 11:54:41 -0700 · parents c7d226e115e6

Claude Code usage: this commit ~294.8K out / ~29.4M cached (first commit of its session: everything since it began) · cost unknown (no per-model trailer) · session 2 tally 26.6K in / 294.8K out / 29.4M cached · usage by commit and session

feat: regenerate-all screenshot tooling, robust pin, day-of-year color

Tooling:
- `npm run screenshot:calendars` regenerates the per-era screenshots for every
  calendar at once, reusing one dev server + browser.
- Shared scripts/lib/calendar-shots.mts so the single-file and all-files entries
  can't drift.
- The auto-pin now uses the app's pointInPolygon and grid-searches for an
  interior point when the bbox center falls outside the region — so concave /
  coastal polygons (e.g. the tightened Russian America coast) pin on land
  instead of rendering "no calendar data for this spot".

UI:
- Day-of-year slider fill changed from blue to gray so it no longer clashes with
  the colored year-era bands.

Also regenerates all calendar screenshots (new Iran/Russian America/Oregon
Country, plus re-rendered Boston-area ones) and pencils in future tasks
(in-browser map editing, scalable colors) in plan.md.

Usage: 26.6K in / 294.8K out / 29.4M cached (session tally)
rtk:   81.1K saved, 90.9% over 162 cmds (global/lifetime)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
 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>