when-am-i / commit
feat: scalable golden-angle calendar colors
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); }