when-am-i / files / src / utils / calendarColor.ts
calendarColor.ts
| 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 | |
| 15 | import type { CalendarRegion } from '../types'; |
| 16 | import { buildConflictGraph } from './calendarConflicts'; |
| 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 | |
| 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 | |
| 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. |
| 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 { |
| 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 | |
| 50 | function 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. */ |
| 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 | |
| 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 | */ |
| 85 | export 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. |
| 94 | const 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 | */ |
| 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 | |
| 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 | } |