fuzzyworld

when-am-i / files / src / utils / calendarColor.ts

calendarColor.ts

6.9 KB · history · blame

1// Stable colors per calendar, for map overlays, the year-timeline, and answer cards.
2//
3// Two layers:
4// • A small curated PALETTE keeps the well-known calendars instantly
5// recognizable (and identical between every view).
6// • Everything else gets a color assigned by `calendarColorScale`, which only
7// needs to keep a calendar visually distinct from the calendars it can
8// actually *co-occur* with (see calendarConflicts.ts) — not from every
9// calendar in the whole dataset. Most calendars are nowhere near each
10// other geographically or in time, so this "conflict graph" is sparse:
11// graph-coloring it gives every calendar a wide hue gap from its real
12// neighbors, with far more room than spacing ~24 calendars around one
13// wheel ever could.
14
15import type { CalendarRegion } from '../types';
16import { buildConflictGraph } from './calendarConflicts';
17
18const PALETTE: Record<string, string> = {
19 'algonquian lunar calendar': '#add8e6', // light blue
20 gregorian: '#90ee90', // light green
21 julian: '#f4d35e', // gold
22 'islamic (umm al-qura)': '#c5b3e6', // lavender
23 islamic: '#c5b3e6',
24 hebrew: '#a8d5ba', // sage
25 'persian (solar hijri)': '#f4a6a6', // salmon
26 persian: '#f4a6a6',
27};
28
29// Saturation/lightness for generated pastels — kept in one place so curated and
30// generated colors read as one family on the map tiles.
31const SATURATION = 60;
32const LIGHTNESS = 74;
33
34// The golden angle (≈137.5°) spreads hues apart for the single-name fallback
35// (`calendarColor`), which has no co-occurrence information to work with.
36const GOLDEN_ANGLE = 137.50776405003785;
37
38function normalize(name: string): string {
39 return name.trim().toLowerCase();
40}
41
42function hashInt(value: string): number {
43 let hash = 0;
44 for (let i = 0; i < value.length; i++) {
45 hash = (hash * 31 + value.charCodeAt(i)) | 0;
46 }
47 return Math.abs(hash);
48}
49
50function hsl(hue: number): string {
51 return `hsl(${hue.toFixed(1)}, ${SATURATION}%, ${LIGHTNESS}%)`;
52}
53
54/** Parse the hue (0–360) out of an `hsl(...)` or `#rrggbb` palette color. */
55function paletteHue(color: string): number {
56 const hslMatch = /^hsl\(([\d.]+)/.exec(color);
57 if (hslMatch) return Number(hslMatch[1]);
58 const hex = /^#([0-9a-f]{6})$/i.exec(color);
59 if (!hex) return 0;
60 const r = parseInt(hex[1].slice(0, 2), 16) / 255;
61 const g = parseInt(hex[1].slice(2, 4), 16) / 255;
62 const b = parseInt(hex[1].slice(4, 6), 16) / 255;
63 const max = Math.max(r, g, b);
64 const min = Math.min(r, g, b);
65 const d = max - min;
66 if (d === 0) return 0;
67 let hue: number;
68 if (max === r) hue = ((g - b) / d) % 6;
69 else if (max === g) hue = (b - r) / d + 2;
70 else hue = (r - g) / d + 4;
71 return (hue * 60 + 360) % 360;
72}
73
74function hueDistance(a: number, b: number): number {
75 const diff = Math.abs(a - b) % 360;
76 return Math.min(diff, 360 - diff);
77}
78
79/**
80 * A stable CSS color for one calendar name, independent of any other. Curated
81 * calendars use the palette; the rest get a deterministic golden-angle hue from
82 * the name's hash. Use this for a lone calendar or as a fallback; prefer
83 * `calendarColorScale` when several calendars share a view.
84 */
85export function calendarColor(calendarName: string): string {
86 const key = normalize(calendarName);
87 return PALETTE[key] ?? hsl((hashInt(key) * GOLDEN_ANGLE) % 360);
88}
89
90// How far apart (degrees) two *conflicting* calendars' hues must land. With a
91// sparse conflict graph (max degree seen in the real dataset: ~14, mostly far
92// fewer), this is easy to satisfy without crowding — unlike trying to space
93// every calendar in the dataset apart regardless of whether they ever meet.
94const MIN_CONFLICT_HUE_DISTANCE = 45;
95
96/**
97 * Assign colors to every calendar named in `regions` (plus any extra names,
98 * e.g. the modern Gregorian default) so that any two calendars *which can
99 * actually appear together* — same region with overlapping eras, or
100 * geographically-overlapping regions with overlapping eras — get visually
101 * distinct hues. Calendars that never co-occur may freely share a color; the
102 * conflict graph (see calendarConflicts.ts) is what decides who needs to
103 * differ from whom, rather than spacing the whole dataset around one wheel.
104 *
105 * Curated calendars always keep their palette color. Generated colors are
106 * assigned by simple greedy graph coloring in degree order (color the most-
107 * constrained calendars first): each calendar takes the lowest-index hue slot
108 * not already used by one of its conflicts, falling back to a fresh hue if
109 * every existing slot conflicts. Deterministic given the same regions.
110 */
111export function calendarColorScale(regions: CalendarRegion[], extraNames: string[] = []): (calendarName: string) => string {
112 const graph = buildConflictGraph(regions);
113 // Make sure every named calendar has a node, even with no conflicts.
114 for (const name of extraNames) {
115 const key = normalize(name);
116 if (!graph.has(key)) graph.set(key, new Set());
117 }
118 for (const cr of regions) {
119 for (const era of cr.eras) {
120 const key = normalize(era.calendar);
121 if (!graph.has(key)) graph.set(key, new Set());
122 }
123 }
124
125 const colors = new Map<string, string>();
126 for (const key of graph.keys()) {
127 if (PALETTE[key]) colors.set(key, PALETTE[key]);
128 }
129
130 // Greedy coloring, most-constrained first: process calendars with the most
131 // conflicts before less-constrained ones, so the calendars with the
132 // tightest neighborhoods get first pick of well-spaced hues.
133 const generatedKeys = [...graph.keys()]
134 .filter((key) => !PALETTE[key])
135 .sort((a, b) => graph.get(b)!.size - graph.get(a)!.size || a.localeCompare(b));
136
137 // Hue "slots" discovered so far, each a representative hue already in use.
138 // A calendar reuses the first slot that doesn't conflict with any neighbor
139 // already colored; otherwise it opens a new slot via the golden angle.
140 const slotHues: number[] = [];
141 for (const key of generatedKeys) {
142 const neighborHues = new Set<number>();
143 for (const neighbor of graph.get(key)!) {
144 const c = colors.get(neighbor);
145 if (c) neighborHues.add(paletteHue(c));
146 }
147
148 let chosenHue: number | undefined;
149 for (const slotHue of slotHues) {
150 if ([...neighborHues].every((nh) => hueDistance(slotHue, nh) >= MIN_CONFLICT_HUE_DISTANCE)) {
151 chosenHue = slotHue;
152 break;
153 }
154 }
155 if (chosenHue === undefined) {
156 // Open a new slot, spaced from existing slots by the golden angle so
157 // slots themselves stay visually distinct as more are added.
158 chosenHue = ((slotHues.length + 0.5) * GOLDEN_ANGLE) % 360;
159 slotHues.push(chosenHue);
160 }
161 colors.set(key, hsl(chosenHue));
162 }
163
164 return (calendarName: string) => colors.get(normalize(calendarName)) ?? calendarColor(calendarName);
165}