when-am-i / files / src / utils / calendarColor.ts
calendarColor.ts
3cbfe9ad 2026-06-22 Pavel Safronov | 1 | // Stable colors per calendar, for map overlays, the year-timeline, and answer cards. |
34c4dd59 2026-06-18 Pavel Safronov | 2 | // |
862df8d3 2026-06-21 Pavel Safronov | 3 | // Two layers: |
| 4 | // • A small curated PALETTE keeps the well-known calendars instantly | |
3cbfe9ad 2026-06-22 Pavel Safronov | 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 | ||
| 15 | import type { CalendarRegion } from '../types'; | |
| 16 | import { buildConflictGraph } from './calendarConflicts'; | |
34c4dd59 2026-06-18 Pavel Safronov | 17 | |
| 18 | const 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 | ||
862df8d3 2026-06-21 Pavel Safronov | 29 | // Saturation/lightness for generated pastels — kept in one place so curated and |
| 30 | // generated colors read as one family on the map tiles. | |
| 31 | const SATURATION = 60; | |
| 32 | const LIGHTNESS = 74; | |
| 33 | ||
3cbfe9ad 2026-06-22 Pavel Safronov | 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. | |
862df8d3 2026-06-21 Pavel Safronov | 36 | const GOLDEN_ANGLE = 137.50776405003785; |
| 37 | ||
| 38 | function normalize(name: string): string { | |
| 39 | return name.trim().toLowerCase(); | |
| 40 | } | |
| 41 | ||
| 42 | function hashInt(value: string): number { | |
34c4dd59 2026-06-18 Pavel Safronov | 43 | let hash = 0; |
| 44 | for (let i = 0; i < value.length; i++) { | |
862df8d3 2026-06-21 Pavel Safronov | 45 | hash = (hash * 31 + value.charCodeAt(i)) | 0; |
34c4dd59 2026-06-18 Pavel Safronov | 46 | } |
862df8d3 2026-06-21 Pavel Safronov | 47 | return Math.abs(hash); |
34c4dd59 2026-06-18 Pavel Safronov | 48 | } |
| 49 | ||
862df8d3 2026-06-21 Pavel Safronov | 50 | function hsl(hue: number): string { |
| 51 | return `hsl(${hue.toFixed(1)}, ${SATURATION}%, ${LIGHTNESS}%)`; | |
| 52 | } | |
| 53 | ||
3cbfe9ad 2026-06-22 Pavel Safronov | 54 | /** Parse the hue (0–360) out of an `hsl(...)` or `#rrggbb` palette color. */ |
| 55 | function 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 | ||
| 74 | function hueDistance(a: number, b: number): number { | |
| 75 | const diff = Math.abs(a - b) % 360; | |
| 76 | return Math.min(diff, 360 - diff); | |
| 77 | } | |
| 78 | ||
862df8d3 2026-06-21 Pavel Safronov | 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 | */ | |
34c4dd59 2026-06-18 Pavel Safronov | 85 | export function calendarColor(calendarName: string): string { |
862df8d3 2026-06-21 Pavel Safronov | 86 | const key = normalize(calendarName); |
| 87 | return PALETTE[key] ?? hsl((hashInt(key) * GOLDEN_ANGLE) % 360); | |
| 88 | } | |
| 89 | ||
3cbfe9ad 2026-06-22 Pavel Safronov | 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. | |
| 94 | const MIN_CONFLICT_HUE_DISTANCE = 45; | |
| 95 | ||
862df8d3 2026-06-21 Pavel Safronov | 96 | /** |
3cbfe9ad 2026-06-22 Pavel Safronov | 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. | |
862df8d3 2026-06-21 Pavel Safronov | 110 | */ |
3cbfe9ad 2026-06-22 Pavel Safronov | 111 | export 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 | ||
862df8d3 2026-06-21 Pavel Safronov | 125 | const colors = new Map<string, string>(); |
3cbfe9ad 2026-06-22 Pavel Safronov | 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)); | |
862df8d3 2026-06-21 Pavel Safronov | 146 | } |
3cbfe9ad 2026-06-22 Pavel Safronov | 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)); | |
862df8d3 2026-06-21 Pavel Safronov | 162 | } |
3cbfe9ad 2026-06-22 Pavel Safronov | 163 | |
862df8d3 2026-06-21 Pavel Safronov | 164 | return (calendarName: string) => colors.get(normalize(calendarName)) ?? calendarColor(calendarName); |
34c4dd59 2026-06-18 Pavel Safronov | 165 | } |