fuzzyworld

when-am-i / commit

feat: scalable golden-angle calendar colors

862df8d39c0b0357f1640dfe54d83a9626f254ec · Pavel Safronov · 2026-06-20 18:29:34 -0700 · parents 3adbd147b2d2

Claude Code usage: this commit ~242.9K out / ~21.3M cached · cost unknown (no per-model trailer) · session 3 tally 29.0K in / 269.0K out / 22.9M cached · usage by commit and session

feat: scalable golden-angle calendar colors

Add `calendarColorScale(names)`, which assigns colors to the whole set of
calendars at once: curated calendars keep their PALETTE color and the rest get
golden-angle-spaced hues so they stay visually distinct instead of colliding on
a raw name hash. App builds one scale from every calendar in the dataset, so a
calendar keeps the same color across the map, the timeline, and date-scrubbing.
`calendarColor(name)` is kept for a lone name / fallback (now golden-angle too).

Usage: 29.0K in / 269.0K out / 22.9M cached (session tally)
rtk:   110.8K saved, 80.9% over 243 cmds (global/lifetime)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
 src/App.tsx                     | 16 +++++++---
 src/utils/calendarColor.test.ts | 41 +++++++++++++++++++++++-
 src/utils/calendarColor.ts      | 71 +++++++++++++++++++++++++++++++++++------
 3 files changed, 113 insertions(+), 15 deletions(-)

diff --git a/src/App.tsx b/src/App.tsx
index 0a6d260..70eb508 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -9,7 +9,7 @@ import { resolve, MODERN_DEFAULT_START_YEAR } from './utils/resolve';
 import { findEras } from './utils/eras';
 import { findRegions } from './utils/geo';
 import { parseGregorianDate } from './utils/julianDay';
-import { calendarColor } from './utils/calendarColor';
+import { calendarColorScale } from './utils/calendarColor';
 import {
     fromHistoricalDate,
     toHistoricalDate,
@@ -26,6 +26,14 @@ import {
 
 const initial = parseInitialState(typeof window !== 'undefined' ? window.location.search : '');
 
+// One color assignment for every calendar in the dataset (plus the modern global
+// Gregorian default), so colors are distinct *and* stable app-wide — a calendar
+// keeps the same color across the map, the timeline, and as you scrub dates.
+const colorFor = calendarColorScale([
+    'Gregorian',
+    ...regions.flatMap((cr) => cr.eras.map((era) => era.calendar)),
+]);
+
 const now = new Date();
 const defaultDate: PickerDate = initial.date
     ? fromHistoricalDate(initial.date)
@@ -52,7 +60,7 @@ function App() {
                 items.push({
                     id: `${cr.sourceFile ?? cr.region.name}::${era.name}`,
                     geometry: cr.region.geo,
-                    color: calendarColor(era.calendar),
+                    color: colorFor(era.calendar),
                     calendar: era.calendar,
                 });
             }
@@ -69,7 +77,7 @@ function App() {
                 bands.push({
                     start: era.start ? parseGregorianDate(era.start).year : -Infinity,
                     end: era.end ? parseGregorianDate(era.end).year : Infinity,
-                    color: calendarColor(era.calendar),
+                    color: colorFor(era.calendar),
                     calendar: era.calendar,
                 });
             }
@@ -83,7 +91,7 @@ function App() {
             bands.push({
                 start: MODERN_DEFAULT_START_YEAR,
                 end: Infinity,
-                color: calendarColor('Gregorian'),
+                color: colorFor('Gregorian'),
                 calendar: 'Gregorian',
             });
         }
diff --git a/src/utils/calendarColor.test.ts b/src/utils/calendarColor.test.ts
index 4c1aa55..3fa7dc5 100644
--- a/src/utils/calendarColor.test.ts
+++ b/src/utils/calendarColor.test.ts
@@ -1,5 +1,5 @@
 import { describe, it, expect } from 'vitest';
-import { calendarColor } from './calendarColor';
+import { calendarColor, calendarColorScale } from './calendarColor';
 
 describe('calendarColor', () => {
     it('uses the curated palette for known calendars', () => {
@@ -18,3 +18,42 @@ describe('calendarColor', () => {
         expect(a).toMatch(/^hsl\(/);
     });
 });
+
+describe('calendarColorScale', () => {
+    it('keeps curated calendars on their palette color', () => {
+        const color = calendarColorScale(['Gregorian', 'Chinese', 'Indian (Saka)']);
+        expect(color('Gregorian')).toBe('#90ee90');
+    });
+
+    it('is case/space-insensitive in lookups', () => {
+        const color = calendarColorScale(['Chinese sexagenary cycle']);
+        expect(color('  chinese sexagenary cycle ')).toBe(color('Chinese sexagenary cycle'));
+    });
+
+    it('gives non-curated calendars distinct hues', () => {
+        const names = ['Chinese', 'Japanese', 'Ethiopic', 'Coptic', 'Mayan'];
+        const color = calendarColorScale(names);
+        const hues = names.map((n) => {
+            const m = /^hsl\(([\d.]+)/.exec(color(n));
+            expect(m).not.toBeNull();
+            return Number(m![1]);
+        });
+        expect(new Set(hues).size).toBe(names.length); // all different
+        // Golden-angle spacing keeps successive picks well apart, never identical.
+        const sorted = [...hues].sort((a, b) => a - b);
+        for (let i = 1; i < sorted.length; i++) {
+            expect(sorted[i] - sorted[i - 1]).toBeGreaterThan(5);
+        }
+    });
+
+    it('is deterministic given the same set, regardless of input order', () => {
+        const a = calendarColorScale(['Japanese', 'Ethiopic', 'Coptic']);
+        const b = calendarColorScale(['Coptic', 'Japanese', 'Ethiopic']);
+        for (const n of ['Japanese', 'Ethiopic', 'Coptic']) expect(a(n)).toBe(b(n));
+    });
+
+    it('falls back to a stable color for names outside the set', () => {
+        const color = calendarColorScale(['Gregorian']);
+        expect(color('Totally Unseen Calendar')).toBe(calendarColor('Totally Unseen Calendar'));
+    });
+});
diff --git a/src/utils/calendarColor.ts b/src/utils/calendarColor.ts
index 105f037..098185c 100644
--- a/src/utils/calendarColor.ts
+++ b/src/utils/calendarColor.ts
@@ -1,8 +1,12 @@
-// Stable colors per calendar, for map overlays and the legend.
+// Stable colors per calendar, for map overlays and the year-timeline bands.
 //
-// A small curated palette keeps the common calendars recognizable; anything
-// else gets a deterministic pastel derived from the name, so colors are stable
-// across renders and the same calendar always looks the same.
+// Two layers:
+//   • A small curated PALETTE keeps the well-known calendars instantly
+//     recognizable (and identical between the map and the timeline).
+//   • Everything else gets a deterministic hue. `calendarColor` derives one
+//     straight from the name (stable in isolation); `calendarColorScale` spaces
+//     a *set* of calendars apart by the golden angle so the ones sharing a view
+//     stay visually distinct instead of colliding on a raw name hash.
 
 const PALETTE: Record<string, string> = {
     'algonquian lunar calendar': '#add8e6', // light blue
@@ -15,16 +19,63 @@ const PALETTE: Record<string, string> = {
     persian: '#f4a6a6',
 };
 
-function hashHue(value: string): number {
+// Saturation/lightness for generated pastels — kept in one place so curated and
+// generated colors read as one family on the map tiles.
+const SATURATION = 60;
+const LIGHTNESS = 74;
+
+// The golden angle (≈137.5°) spreads successive hues as far apart as possible,
+// so N calendars placed around the wheel stay maximally distinct for any N.
+const GOLDEN_ANGLE = 137.50776405003785;
+
+function normalize(name: string): string {
+    return name.trim().toLowerCase();
+}
+
+function hashInt(value: string): number {
     let hash = 0;
     for (let i = 0; i < value.length; i++) {
-        hash = (hash * 31 + value.charCodeAt(i)) % 360;
+        hash = (hash * 31 + value.charCodeAt(i)) | 0;
     }
-    return hash;
+    return Math.abs(hash);
 }
 
-/** A stable CSS color for a calendar name. */
+function hsl(hue: number): string {
+    return `hsl(${hue.toFixed(1)}, ${SATURATION}%, ${LIGHTNESS}%)`;
+}
+
+/**
+ * A stable CSS color for one calendar name, independent of any other. Curated
+ * calendars use the palette; the rest get a deterministic golden-angle hue from
+ * the name's hash. Use this for a lone calendar or as a fallback; prefer
+ * `calendarColorScale` when several calendars share a view.
+ */
 export function calendarColor(calendarName: string): string {
-    const key = calendarName.trim().toLowerCase();
-    return PALETTE[key] ?? `hsl(${hashHue(key)}, 60%, 75%)`;
+    const key = normalize(calendarName);
+    return PALETTE[key] ?? hsl((hashInt(key) * GOLDEN_ANGLE) % 360);
+}
+
+/**
+ * Assign visually-distinct colors to a *set* of calendars that will be shown
+ * together (e.g. every calendar in the dataset). Curated calendars keep their
+ * palette color; the rest are walked in a stable order and handed golden-angle-
+ * spaced hues, so adjacent map polygons and timeline bands don't collide. The
+ * result is deterministic given the same set, so the map, timeline and legend
+ * agree. Returns a lookup that falls back to `calendarColor` for unknown names.
+ */
+export function calendarColorScale(names: Iterable<string>): (calendarName: string) => string {
+    const keys = [...new Set([...names].map(normalize))].sort();
+    const colors = new Map<string, string>();
+    let generatedIndex = 0;
+    for (const key of keys) {
+        if (PALETTE[key]) {
+            colors.set(key, PALETTE[key]);
+            continue;
+        }
+        // Offset by a half-step so the first generated hue (0° = red) doesn't sit
+        // right on the curated salmon/gold; successive ones fan out by the angle.
+        colors.set(key, hsl(((generatedIndex + 0.5) * GOLDEN_ANGLE) % 360));
+        generatedIndex++;
+    }
+    return (calendarName: string) => colors.get(normalize(calendarName)) ?? calendarColor(calendarName);
 }