fuzzyworld

when-am-i / commit

Fix duplicate North Korea era and replace global color spacing with a conflict graph

3cbfe9add2d184808f283ff682ef42b42465ca60 · Pavel Safronov · 2026-06-22 13:01:48 -0700 · parents 5d4ee3c26870

Fix duplicate North Korea era and replace global color spacing with a conflict graph

korea.json's South-Korea-only Dangi era was firing in North Korea too
(its polygon covers the whole peninsula), duplicating north-korea.json's
own correctly-scoped Dangi era — removed it and noted the resulting gap
in the README wishlist.

Calendar colors previously had to be spread across one hue wheel for
the entire ~24-calendar dataset, which left near-collisions once enough
calendars shared the wheel (e.g. two Aztec-region calendars reading as
the same color). Replaced this with a conflict graph: two calendars only
need distinct colors if they can actually appear together (same region
with overlapping eras, or geographically-overlapping regions with
overlapping eras), which most pairs never do. Greedy graph coloring over
this much sparser graph gives every real conflict a wide hue gap instead
of a forced, crowded one.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
 README.md                                          |   6 +
 eslint.config.js                                   |   2 +-
 .../calendars/aztec/01-aztec-xiuhp-hualli.png      | Bin 877605 -> 878464 bytes
 .../aztec/02-aztec-mexica-year-bearer.png          | Bin 877605 -> 878464 bytes
 screenshots/calendars/aztec/03-t-nalp-hualli.png   | Bin 878700 -> 879434 bytes
 .../china/01-chinese-sexagenary-cycle.png          | Bin 569152 -> 568834 bytes
 .../calendars/colonial-america/01-julian.png       | Bin 414894 -> 414928 bytes
 .../calendars/colonial-america/02-gregorian.png    | Bin 400229 -> 400255 bytes
 screenshots/calendars/ethiopia/01-ethiopian.png    | Bin 484879 -> 484364 bytes
 screenshots/calendars/india/01-indian.png          | Bin 450103 -> 450006 bytes
 screenshots/calendars/iran/01-islamic.png          | Bin 497797 -> 497658 bytes
 screenshots/calendars/iran/02-persian.png          | Bin 601997 -> 601831 bytes
 .../japan/01-japanese-lunisolar-calendar.png       | Bin 432155 -> 431361 bytes
 screenshots/calendars/japan/02-japanese.png        | Bin 410713 -> 410529 bytes
 screenshots/calendars/judea/01-hebrew.png          | Bin 875751 -> 875822 bytes
 .../korea/01-korean-lunisolar-calendar.png         | Bin 745514 -> 744930 bytes
 screenshots/calendars/korea/02-gregorian.png       | Bin 669890 -> 668884 bytes
 screenshots/calendars/korea/03-gregorian.png       | Bin 668499 -> 667359 bytes
 screenshots/calendars/korea/04-gregorian.png       | Bin 644427 -> 643294 bytes
 screenshots/calendars/korea/05-gregorian.png       | Bin 651484 -> 650418 bytes
 screenshots/calendars/korea/06-dangi.png           | Bin 767086 -> 0 bytes
 .../maori/01-maramataka-m-ori-lunar-calendar.png   | Bin 444141 -> 443338 bytes
 .../massachusett/01-algonquian-lunar-calendar.png  | Bin 1234930 -> 1234936 bytes
 screenshots/calendars/maya/01-maya-long-count.png  | Bin 901105 -> 899567 bytes
 .../maya/02-tzolk-in-and-haab-calendar-round.png   | Bin 702255 -> 701156 bytes
 screenshots/calendars/north-korea/01-dangi.png     | Bin 733548 -> 733310 bytes
 screenshots/calendars/north-korea/02-juche.png     | Bin 774455 -> 774301 bytes
 screenshots/calendars/north-korea/03-gregorian.png | Bin 726975 -> 726844 bytes
 .../calendars/oregon-country/01-gregorian.png      | Bin 463583 -> 463595 bytes
 screenshots/calendars/ottoman/01-islamic.png       | Bin 479053 -> 478899 bytes
 screenshots/calendars/ottoman/02-rumi.png          | Bin 689519 -> 688846 bytes
 screenshots/calendars/ottoman/03-gregorian.png     | Bin 415066 -> 414897 bytes
 .../revolutionary-france/01-gregorian.png          | Bin 442867 -> 442862 bytes
 .../02-french-republican-calendar.png              | Bin 514945 -> 514939 bytes
 .../03-french-republican-calendar.png              | Bin 434674 -> 434679 bytes
 .../revolutionary-france/04-gregorian.png          | Bin 437778 -> 437763 bytes
 .../calendars/russian-america/01-julian.png        | Bin 474990 -> 474969 bytes
 .../calendars/russian-america/02-gregorian.png     | Bin 458865 -> 458840 bytes
 screenshots/calendars/thailand/01-buddhist.png     | Bin 482738 -> 482008 bytes
 .../vietnam/01-vietnamese-lunisolar-calendar.png   | Bin 709775 -> 709369 bytes
 screenshots/calendars/vietnam/02-gregorian.png     | Bin 691512 -> 690579 bytes
 .../vietnam/03-vietnamese-lunisolar-calendar.png   | Bin 691512 -> 690579 bytes
 src/App.tsx                                        |  15 ++-
 src/data/calendars/korea.json                      |  12 --
 src/utils/calendarColor.test.ts                    | 143 +++++++++++++++++----
 src/utils/calendarColor.ts                         | 134 +++++++++++++++----
 src/utils/calendarConflicts.test.ts                |  90 +++++++++++++
 src/utils/calendarConflicts.ts                     | 124 ++++++++++++++++++
 48 files changed, 460 insertions(+), 66 deletions(-)

diff --git a/README.md b/README.md
index 96800be..bcc113c 100644
--- a/README.md
+++ b/README.md
@@ -286,6 +286,12 @@ ship as descriptive-only.
 10. **Sikh Nanakshahi calendar** — a solar calendar adopted by the SGPC in 2003
     for Sikh religious dates, with a clear epoch (Guru Nanak's birth year) —
     likely a simple convertible offset like Dangi/Juche.
+11. **A dedicated South Korea region** — `korea.json`'s polygon covers the whole
+    peninsula, so it can't host South-only eras without colliding with
+    `north-korea.json` (both would fire on the same point). South Korea's own
+    1948–1962 official use of the Dangi (Dangun) era was removed from
+    `korea.json` for this reason and isn't documented anywhere yet; a
+    South-Korea-only polygon could restore it.
 
 ## License
 
diff --git a/eslint.config.js b/eslint.config.js
index 87ef063..c245d15 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -4,7 +4,7 @@ import reactHooks from 'eslint-plugin-react-hooks';
 import reactRefresh from 'eslint-plugin-react-refresh';
 
 export default tseslint.config(
-    { ignores: ['dist', 'node_modules'] },
+    { ignores: ['dist', 'node_modules', 'temp'] },
     js.configs.recommended,
     ...tseslint.configs.recommended,
     {
diff --git a/screenshots/calendars/aztec/01-aztec-xiuhp-hualli.png b/screenshots/calendars/aztec/01-aztec-xiuhp-hualli.png
index 5455d4d..6990688 100644
Binary files a/screenshots/calendars/aztec/01-aztec-xiuhp-hualli.png and b/screenshots/calendars/aztec/01-aztec-xiuhp-hualli.png differ
diff --git a/screenshots/calendars/aztec/02-aztec-mexica-year-bearer.png b/screenshots/calendars/aztec/02-aztec-mexica-year-bearer.png
index 5455d4d..6990688 100644
Binary files a/screenshots/calendars/aztec/02-aztec-mexica-year-bearer.png and b/screenshots/calendars/aztec/02-aztec-mexica-year-bearer.png differ
diff --git a/screenshots/calendars/aztec/03-t-nalp-hualli.png b/screenshots/calendars/aztec/03-t-nalp-hualli.png
index 20331c0..aa937ab 100644
Binary files a/screenshots/calendars/aztec/03-t-nalp-hualli.png and b/screenshots/calendars/aztec/03-t-nalp-hualli.png differ
diff --git a/screenshots/calendars/china/01-chinese-sexagenary-cycle.png b/screenshots/calendars/china/01-chinese-sexagenary-cycle.png
index 4ee22f4..9591755 100644
Binary files a/screenshots/calendars/china/01-chinese-sexagenary-cycle.png and b/screenshots/calendars/china/01-chinese-sexagenary-cycle.png differ
diff --git a/screenshots/calendars/colonial-america/01-julian.png b/screenshots/calendars/colonial-america/01-julian.png
index ba00961..a813d0f 100644
Binary files a/screenshots/calendars/colonial-america/01-julian.png and b/screenshots/calendars/colonial-america/01-julian.png differ
diff --git a/screenshots/calendars/colonial-america/02-gregorian.png b/screenshots/calendars/colonial-america/02-gregorian.png
index ac921e5..5bc9d72 100644
Binary files a/screenshots/calendars/colonial-america/02-gregorian.png and b/screenshots/calendars/colonial-america/02-gregorian.png differ
diff --git a/screenshots/calendars/ethiopia/01-ethiopian.png b/screenshots/calendars/ethiopia/01-ethiopian.png
index d0e448e..fafcf5f 100644
Binary files a/screenshots/calendars/ethiopia/01-ethiopian.png and b/screenshots/calendars/ethiopia/01-ethiopian.png differ
diff --git a/screenshots/calendars/india/01-indian.png b/screenshots/calendars/india/01-indian.png
index aa63bb7..36c7054 100644
Binary files a/screenshots/calendars/india/01-indian.png and b/screenshots/calendars/india/01-indian.png differ
diff --git a/screenshots/calendars/iran/01-islamic.png b/screenshots/calendars/iran/01-islamic.png
index 23e7c07..d796fde 100644
Binary files a/screenshots/calendars/iran/01-islamic.png and b/screenshots/calendars/iran/01-islamic.png differ
diff --git a/screenshots/calendars/iran/02-persian.png b/screenshots/calendars/iran/02-persian.png
index e22604d..659c7fe 100644
Binary files a/screenshots/calendars/iran/02-persian.png and b/screenshots/calendars/iran/02-persian.png differ
diff --git a/screenshots/calendars/japan/01-japanese-lunisolar-calendar.png b/screenshots/calendars/japan/01-japanese-lunisolar-calendar.png
index 4b335b5..0d6a4de 100644
Binary files a/screenshots/calendars/japan/01-japanese-lunisolar-calendar.png and b/screenshots/calendars/japan/01-japanese-lunisolar-calendar.png differ
diff --git a/screenshots/calendars/japan/02-japanese.png b/screenshots/calendars/japan/02-japanese.png
index 315159e..800ba6c 100644
Binary files a/screenshots/calendars/japan/02-japanese.png and b/screenshots/calendars/japan/02-japanese.png differ
diff --git a/screenshots/calendars/judea/01-hebrew.png b/screenshots/calendars/judea/01-hebrew.png
index 63396eb..c18ff04 100644
Binary files a/screenshots/calendars/judea/01-hebrew.png and b/screenshots/calendars/judea/01-hebrew.png differ
diff --git a/screenshots/calendars/korea/01-korean-lunisolar-calendar.png b/screenshots/calendars/korea/01-korean-lunisolar-calendar.png
index f3a1aa5..0170483 100644
Binary files a/screenshots/calendars/korea/01-korean-lunisolar-calendar.png and b/screenshots/calendars/korea/01-korean-lunisolar-calendar.png differ
diff --git a/screenshots/calendars/korea/02-gregorian.png b/screenshots/calendars/korea/02-gregorian.png
index 90814d6..82c89bf 100644
Binary files a/screenshots/calendars/korea/02-gregorian.png and b/screenshots/calendars/korea/02-gregorian.png differ
diff --git a/screenshots/calendars/korea/03-gregorian.png b/screenshots/calendars/korea/03-gregorian.png
index 7646be5..7909f0a 100644
Binary files a/screenshots/calendars/korea/03-gregorian.png and b/screenshots/calendars/korea/03-gregorian.png differ
diff --git a/screenshots/calendars/korea/04-gregorian.png b/screenshots/calendars/korea/04-gregorian.png
index e133eb6..cd3e8b2 100644
Binary files a/screenshots/calendars/korea/04-gregorian.png and b/screenshots/calendars/korea/04-gregorian.png differ
diff --git a/screenshots/calendars/korea/05-gregorian.png b/screenshots/calendars/korea/05-gregorian.png
index 21134a8..421de06 100644
Binary files a/screenshots/calendars/korea/05-gregorian.png and b/screenshots/calendars/korea/05-gregorian.png differ
diff --git a/screenshots/calendars/korea/06-dangi.png b/screenshots/calendars/korea/06-dangi.png
deleted file mode 100644
index 85015bd..0000000
Binary files a/screenshots/calendars/korea/06-dangi.png and /dev/null differ
diff --git a/screenshots/calendars/maori/01-maramataka-m-ori-lunar-calendar.png b/screenshots/calendars/maori/01-maramataka-m-ori-lunar-calendar.png
index 8247641..8a46352 100644
Binary files a/screenshots/calendars/maori/01-maramataka-m-ori-lunar-calendar.png and b/screenshots/calendars/maori/01-maramataka-m-ori-lunar-calendar.png differ
diff --git a/screenshots/calendars/massachusett/01-algonquian-lunar-calendar.png b/screenshots/calendars/massachusett/01-algonquian-lunar-calendar.png
index 267716f..75ec889 100644
Binary files a/screenshots/calendars/massachusett/01-algonquian-lunar-calendar.png and b/screenshots/calendars/massachusett/01-algonquian-lunar-calendar.png differ
diff --git a/screenshots/calendars/maya/01-maya-long-count.png b/screenshots/calendars/maya/01-maya-long-count.png
index 17fd8e3..64eac19 100644
Binary files a/screenshots/calendars/maya/01-maya-long-count.png and b/screenshots/calendars/maya/01-maya-long-count.png differ
diff --git a/screenshots/calendars/maya/02-tzolk-in-and-haab-calendar-round.png b/screenshots/calendars/maya/02-tzolk-in-and-haab-calendar-round.png
index c965461..c210520 100644
Binary files a/screenshots/calendars/maya/02-tzolk-in-and-haab-calendar-round.png and b/screenshots/calendars/maya/02-tzolk-in-and-haab-calendar-round.png differ
diff --git a/screenshots/calendars/north-korea/01-dangi.png b/screenshots/calendars/north-korea/01-dangi.png
index c7db5ed..e50d05c 100644
Binary files a/screenshots/calendars/north-korea/01-dangi.png and b/screenshots/calendars/north-korea/01-dangi.png differ
diff --git a/screenshots/calendars/north-korea/02-juche.png b/screenshots/calendars/north-korea/02-juche.png
index bca8a6c..ea98280 100644
Binary files a/screenshots/calendars/north-korea/02-juche.png and b/screenshots/calendars/north-korea/02-juche.png differ
diff --git a/screenshots/calendars/north-korea/03-gregorian.png b/screenshots/calendars/north-korea/03-gregorian.png
index 9594157..146f472 100644
Binary files a/screenshots/calendars/north-korea/03-gregorian.png and b/screenshots/calendars/north-korea/03-gregorian.png differ
diff --git a/screenshots/calendars/oregon-country/01-gregorian.png b/screenshots/calendars/oregon-country/01-gregorian.png
index 6eb056b..772375d 100644
Binary files a/screenshots/calendars/oregon-country/01-gregorian.png and b/screenshots/calendars/oregon-country/01-gregorian.png differ
diff --git a/screenshots/calendars/ottoman/01-islamic.png b/screenshots/calendars/ottoman/01-islamic.png
index c547e71..62207e7 100644
Binary files a/screenshots/calendars/ottoman/01-islamic.png and b/screenshots/calendars/ottoman/01-islamic.png differ
diff --git a/screenshots/calendars/ottoman/02-rumi.png b/screenshots/calendars/ottoman/02-rumi.png
index 4ed335e..24324d8 100644
Binary files a/screenshots/calendars/ottoman/02-rumi.png and b/screenshots/calendars/ottoman/02-rumi.png differ
diff --git a/screenshots/calendars/ottoman/03-gregorian.png b/screenshots/calendars/ottoman/03-gregorian.png
index 0a292f0..64c15f2 100644
Binary files a/screenshots/calendars/ottoman/03-gregorian.png and b/screenshots/calendars/ottoman/03-gregorian.png differ
diff --git a/screenshots/calendars/revolutionary-france/01-gregorian.png b/screenshots/calendars/revolutionary-france/01-gregorian.png
index c7add60..2a69b11 100644
Binary files a/screenshots/calendars/revolutionary-france/01-gregorian.png and b/screenshots/calendars/revolutionary-france/01-gregorian.png differ
diff --git a/screenshots/calendars/revolutionary-france/02-french-republican-calendar.png b/screenshots/calendars/revolutionary-france/02-french-republican-calendar.png
index 8a4d250..36ee547 100644
Binary files a/screenshots/calendars/revolutionary-france/02-french-republican-calendar.png and b/screenshots/calendars/revolutionary-france/02-french-republican-calendar.png differ
diff --git a/screenshots/calendars/revolutionary-france/03-french-republican-calendar.png b/screenshots/calendars/revolutionary-france/03-french-republican-calendar.png
index 90b8937..b31d27c 100644
Binary files a/screenshots/calendars/revolutionary-france/03-french-republican-calendar.png and b/screenshots/calendars/revolutionary-france/03-french-republican-calendar.png differ
diff --git a/screenshots/calendars/revolutionary-france/04-gregorian.png b/screenshots/calendars/revolutionary-france/04-gregorian.png
index e3fc759..5a91b34 100644
Binary files a/screenshots/calendars/revolutionary-france/04-gregorian.png and b/screenshots/calendars/revolutionary-france/04-gregorian.png differ
diff --git a/screenshots/calendars/russian-america/01-julian.png b/screenshots/calendars/russian-america/01-julian.png
index d0c5147..947fd83 100644
Binary files a/screenshots/calendars/russian-america/01-julian.png and b/screenshots/calendars/russian-america/01-julian.png differ
diff --git a/screenshots/calendars/russian-america/02-gregorian.png b/screenshots/calendars/russian-america/02-gregorian.png
index e62be08..03a1bb4 100644
Binary files a/screenshots/calendars/russian-america/02-gregorian.png and b/screenshots/calendars/russian-america/02-gregorian.png differ
diff --git a/screenshots/calendars/thailand/01-buddhist.png b/screenshots/calendars/thailand/01-buddhist.png
index 71ac9b8..c4a6e68 100644
Binary files a/screenshots/calendars/thailand/01-buddhist.png and b/screenshots/calendars/thailand/01-buddhist.png differ
diff --git a/screenshots/calendars/vietnam/01-vietnamese-lunisolar-calendar.png b/screenshots/calendars/vietnam/01-vietnamese-lunisolar-calendar.png
index 2719f80..9254cde 100644
Binary files a/screenshots/calendars/vietnam/01-vietnamese-lunisolar-calendar.png and b/screenshots/calendars/vietnam/01-vietnamese-lunisolar-calendar.png differ
diff --git a/screenshots/calendars/vietnam/02-gregorian.png b/screenshots/calendars/vietnam/02-gregorian.png
index a35da1c..f2d5120 100644
Binary files a/screenshots/calendars/vietnam/02-gregorian.png and b/screenshots/calendars/vietnam/02-gregorian.png differ
diff --git a/screenshots/calendars/vietnam/03-vietnamese-lunisolar-calendar.png b/screenshots/calendars/vietnam/03-vietnamese-lunisolar-calendar.png
index a35da1c..f2d5120 100644
Binary files a/screenshots/calendars/vietnam/03-vietnamese-lunisolar-calendar.png and b/screenshots/calendars/vietnam/03-vietnamese-lunisolar-calendar.png differ
diff --git a/src/App.tsx b/src/App.tsx
index 1ceed45..61691ec 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -31,10 +31,10 @@ const initial = parseInitialState(typeof window !== 'undefined' ? window.locatio
 // 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)),
-]);
+// Colors only need to differ between calendars that can actually appear
+// together (see calendarConflicts.ts) — calendars that never co-occur (e.g.
+// Japan and Iran) may freely share a color.
+const colorFor = calendarColorScale(regions, ['Gregorian']);
 
 // Year slider floor: the oldest dated era in the dataset, plus a margin, so the
 // timeline doesn't waste most of its width on millennia with no calendar data.
@@ -259,7 +259,12 @@ function App() {
                 </div>
 
                 <div className="app-answers">
-                    <ResultPanel picked={picked} date={date} resolution={resolution} colorFor={colorFor} />
+                    <ResultPanel
+                        picked={picked}
+                        date={date}
+                        resolution={resolution}
+                        colorFor={colorFor}
+                    />
                 </div>
             </div>
 
diff --git a/src/data/calendars/korea.json b/src/data/calendars/korea.json
index 270c61e..3bb7985 100644
--- a/src/data/calendars/korea.json
+++ b/src/data/calendars/korea.json
@@ -89,18 +89,6 @@
                 "https://en.wikipedia.org/wiki/Gregorian_calendar",
                 "https://en.wikipedia.org/wiki/Korean_calendar"
             ]
-        },
-        {
-            "name": "Dangi (Dangun era)",
-            "start": "1948-09-25",
-            "end": "1962-01-01",
-            "calendar": "Dangi",
-            "usedBy": "the Republic of Korea (South Korea)",
-            "description": "The Dangi (Dangun) era counts years from the legendary founding of Gojoseon by Dangun in 2333 BC. South Korea made it the official year count by the Reign-Title Act of 25 September 1948, used alongside the Gregorian calendar in government documents, before the Gregorian calendar became the sole official system on 1 January 1962. It remains in occasional cultural use.",
-            "sources": [
-                "https://en.wikipedia.org/wiki/Dangun_calendar",
-                "https://en.wikipedia.org/wiki/Dangun_years"
-            ]
         }
     ]
 }
diff --git a/src/utils/calendarColor.test.ts b/src/utils/calendarColor.test.ts
index 3fa7dc5..a4f2e73 100644
--- a/src/utils/calendarColor.test.ts
+++ b/src/utils/calendarColor.test.ts
@@ -1,5 +1,47 @@
 import { describe, it, expect } from 'vitest';
 import { calendarColor, calendarColorScale } from './calendarColor';
+import type { CalendarRegion, Era } from '../types';
+
+const WORLD_GEO = { type: 'Polygon' as const, coordinates: [[[0, 0], [1, 0], [1, 1], [0, 0]] as [number, number][]] };
+
+/** A region with the given era calendars (all sharing one made-up box geometry). */
+function regionWith(eras: Pick<Era, 'calendar' | 'start' | 'end'>[], geo = WORLD_GEO): CalendarRegion {
+    return {
+        region: { name: 'Test', geo },
+        eras: eras.map((e) => ({ name: e.calendar, ...e })),
+    };
+}
+
+function box(minLng: number, minLat: number, maxLng: number, maxLat: number) {
+    return {
+        type: 'Polygon' as const,
+        coordinates: [[[minLng, minLat], [maxLng, minLat], [maxLng, maxLat], [minLng, minLat]] as [number, number][]],
+    };
+}
+
+function hueOf(color: string): number {
+    const hslMatch = /^hsl\(([\d.]+)/.exec(color);
+    if (hslMatch) return Number(hslMatch[1]);
+    const hex = /^#([0-9a-f]{6})$/i.exec(color);
+    expect(hex).not.toBeNull();
+    const r = parseInt(hex![1].slice(0, 2), 16) / 255;
+    const g = parseInt(hex![1].slice(2, 4), 16) / 255;
+    const b = parseInt(hex![1].slice(4, 6), 16) / 255;
+    const max = Math.max(r, g, b);
+    const min = Math.min(r, g, b);
+    const d = max - min;
+    if (d === 0) return 0;
+    let hue: number;
+    if (max === r) hue = ((g - b) / d) % 6;
+    else if (max === g) hue = (b - r) / d + 2;
+    else hue = (r - g) / d + 4;
+    return (hue * 60 + 360) % 360;
+}
+
+function hueDistance(a: number, b: number): number {
+    const diff = Math.abs(a - b) % 360;
+    return Math.min(diff, 360 - diff);
+}
 
 describe('calendarColor', () => {
     it('uses the curated palette for known calendars', () => {
@@ -21,39 +63,94 @@ describe('calendarColor', () => {
 
 describe('calendarColorScale', () => {
     it('keeps curated calendars on their palette color', () => {
-        const color = calendarColorScale(['Gregorian', 'Chinese', 'Indian (Saka)']);
+        const regions = [regionWith([{ calendar: 'Chinese', start: null, end: null }])];
+        const color = calendarColorScale(regions, ['Gregorian']);
         expect(color('Gregorian')).toBe('#90ee90');
     });
 
     it('is case/space-insensitive in lookups', () => {
-        const color = calendarColorScale(['Chinese sexagenary cycle']);
+        const regions = [regionWith([{ calendar: 'Chinese sexagenary cycle', start: null, end: null }])];
+        const color = calendarColorScale(regions);
         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('is deterministic given the same regions, regardless of input order', () => {
+        const a = [
+            regionWith([{ calendar: 'Japanese', start: null, end: null }], box(0, 0, 10, 10)),
+            regionWith([{ calendar: 'Ethiopic', start: null, end: null }], box(20, 20, 30, 30)),
+        ];
+        const b = [a[1], a[0]];
+        const colorA = calendarColorScale(a);
+        const colorB = calendarColorScale(b);
+        for (const n of ['Japanese', 'Ethiopic']) expect(colorA(n)).toBe(colorB(n));
     });
 
     it('falls back to a stable color for names outside the set', () => {
-        const color = calendarColorScale(['Gregorian']);
+        const regions = [regionWith([{ calendar: 'Gregorian', start: null, end: null }])];
+        const color = calendarColorScale(regions);
         expect(color('Totally Unseen Calendar')).toBe(calendarColor('Totally Unseen Calendar'));
     });
+
+    it('gives two calendars in the SAME region (overlapping in time) distinct hues, even far from the curated palette', () => {
+        const regions = [
+            regionWith([
+                { calendar: 'Aztec Xiuhpōhualli', start: '1325-01-01', end: '1521-08-13' },
+                { calendar: 'Aztec/Mexica year-bearer', start: '1325-01-01', end: '1521-08-13' },
+                { calendar: 'Tōnalpōhualli', start: '1325-01-01', end: null },
+            ]),
+        ];
+        const color = calendarColorScale(regions);
+        const hues = ['Aztec Xiuhpōhualli', 'Aztec/Mexica year-bearer', 'Tōnalpōhualli'].map((n) => hueOf(color(n)));
+        expect(hueDistance(hues[0], hues[1])).toBeGreaterThanOrEqual(45);
+        expect(hueDistance(hues[0], hues[2])).toBeGreaterThanOrEqual(45);
+        expect(hueDistance(hues[1], hues[2])).toBeGreaterThanOrEqual(45);
+    });
+
+    it('gives two calendars in geographically-overlapping regions distinct hues', () => {
+        // Mirrors the Korea / North Korea situation: two region files whose
+        // boxes overlap, each with their own calendar active at the same time.
+        const regions = [
+            regionWith([{ calendar: 'Gregorian calendar (modern)', start: '1910-01-01', end: null }], box(124, 34, 130, 42)),
+            regionWith([{ calendar: 'Juche', start: '1997-01-01', end: '2024-01-01' }], box(124, 37, 130, 42)),
+        ];
+        const color = calendarColorScale(regions, ['Gregorian']);
+        const hues = ['Gregorian calendar (modern)', 'Juche'].map((n) => hueOf(color(n)));
+        expect(hueDistance(hues[0], hues[1])).toBeGreaterThanOrEqual(45);
+    });
+
+    it('lets two calendars that never co-occur share the same color', () => {
+        // Mirrors Japan vs. Iran: far apart geographically, never compete for a
+        // color, so there's no reason to force them apart — and with a
+        // deterministic, order-independent assignment they often do collide,
+        // which is fine since a viewer never sees them in the same view.
+        const regions = [
+            regionWith([{ calendar: 'Some Calendar A', start: null, end: null }], box(0, 0, 5, 5)),
+            regionWith([{ calendar: 'Some Calendar B', start: null, end: null }], box(100, 100, 105, 105)),
+        ];
+        const color = calendarColorScale(regions);
+        // Not asserting they're equal (that's an implementation detail of the
+        // greedy slot order) — just that the scale doesn't crash and returns
+        // valid colors for both, independent of each other.
+        expect(color('Some Calendar A')).toMatch(/^hsl\(/);
+        expect(color('Some Calendar B')).toMatch(/^hsl\(/);
+    });
+
+    it('does not flag two calendars as conflicting when their regions do not overlap geographically, even with overlapping eras', () => {
+        const regions = [
+            regionWith([{ calendar: 'Far Calendar A', start: '1900-01-01', end: null }], box(0, 0, 5, 5)),
+            regionWith([{ calendar: 'Far Calendar B', start: '1900-01-01', end: null }], box(100, 100, 105, 105)),
+        ];
+        // Force them into the same color slot by construction: since they never
+        // conflict, the algorithm is free to (and in this minimal case, will)
+        // assign them the same generated hue.
+        const color = calendarColorScale(regions);
+        expect(hueOf(color('Far Calendar A'))).toBe(hueOf(color('Far Calendar B')));
+    });
+
+    it('still distinguishes a calendar from the modern global Gregorian default when both are active after 1927', () => {
+        const regions = [regionWith([{ calendar: 'Dangi', start: '1948-01-01', end: null }], box(124, 34, 130, 42))];
+        const color = calendarColorScale(regions, ['Gregorian']);
+        const hues = ['Dangi', 'Gregorian'].map((n) => hueOf(color(n)));
+        expect(hueDistance(hues[0], hues[1])).toBeGreaterThanOrEqual(45);
+    });
 });
diff --git a/src/utils/calendarColor.ts b/src/utils/calendarColor.ts
index 098185c..d089b73 100644
--- a/src/utils/calendarColor.ts
+++ b/src/utils/calendarColor.ts
@@ -1,12 +1,19 @@
-// Stable colors per calendar, for map overlays and the year-timeline bands.
+// Stable colors per calendar, for map overlays, the year-timeline, and answer cards.
 //
 // 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.
+//     recognizable (and identical between every view).
+//   • Everything else gets a color assigned by `calendarColorScale`, which only
+//     needs to keep a calendar visually distinct from the calendars it can
+//     actually *co-occur* with (see calendarConflicts.ts) — not from every
+//     calendar in the whole dataset. Most calendars are nowhere near each
+//     other geographically or in time, so this "conflict graph" is sparse:
+//     graph-coloring it gives every calendar a wide hue gap from its real
+//     neighbors, with far more room than spacing ~24 calendars around one
+//     wheel ever could.
+
+import type { CalendarRegion } from '../types';
+import { buildConflictGraph } from './calendarConflicts';
 
 const PALETTE: Record<string, string> = {
     'algonquian lunar calendar': '#add8e6', // light blue
@@ -24,8 +31,8 @@ const PALETTE: Record<string, string> = {
 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.
+// The golden angle (≈137.5°) spreads hues apart for the single-name fallback
+// (`calendarColor`), which has no co-occurrence information to work with.
 const GOLDEN_ANGLE = 137.50776405003785;
 
 function normalize(name: string): string {
@@ -44,6 +51,31 @@ function hsl(hue: number): string {
     return `hsl(${hue.toFixed(1)}, ${SATURATION}%, ${LIGHTNESS}%)`;
 }
 
+/** Parse the hue (0–360) out of an `hsl(...)` or `#rrggbb` palette color. */
+function paletteHue(color: string): number {
+    const hslMatch = /^hsl\(([\d.]+)/.exec(color);
+    if (hslMatch) return Number(hslMatch[1]);
+    const hex = /^#([0-9a-f]{6})$/i.exec(color);
+    if (!hex) return 0;
+    const r = parseInt(hex[1].slice(0, 2), 16) / 255;
+    const g = parseInt(hex[1].slice(2, 4), 16) / 255;
+    const b = parseInt(hex[1].slice(4, 6), 16) / 255;
+    const max = Math.max(r, g, b);
+    const min = Math.min(r, g, b);
+    const d = max - min;
+    if (d === 0) return 0;
+    let hue: number;
+    if (max === r) hue = ((g - b) / d) % 6;
+    else if (max === g) hue = (b - r) / d + 2;
+    else hue = (r - g) / d + 4;
+    return (hue * 60 + 360) % 360;
+}
+
+function hueDistance(a: number, b: number): number {
+    const diff = Math.abs(a - b) % 360;
+    return Math.min(diff, 360 - diff);
+}
+
 /**
  * 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
@@ -55,27 +87,79 @@ export function calendarColor(calendarName: string): string {
     return PALETTE[key] ?? hsl((hashInt(key) * GOLDEN_ANGLE) % 360);
 }
 
+// How far apart (degrees) two *conflicting* calendars' hues must land. With a
+// sparse conflict graph (max degree seen in the real dataset: ~14, mostly far
+// fewer), this is easy to satisfy without crowding — unlike trying to space
+// every calendar in the dataset apart regardless of whether they ever meet.
+const MIN_CONFLICT_HUE_DISTANCE = 45;
+
 /**
- * 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.
+ * Assign colors to every calendar named in `regions` (plus any extra names,
+ * e.g. the modern Gregorian default) so that any two calendars *which can
+ * actually appear together* — same region with overlapping eras, or
+ * geographically-overlapping regions with overlapping eras — get visually
+ * distinct hues. Calendars that never co-occur may freely share a color; the
+ * conflict graph (see calendarConflicts.ts) is what decides who needs to
+ * differ from whom, rather than spacing the whole dataset around one wheel.
+ *
+ * Curated calendars always keep their palette color. Generated colors are
+ * assigned by simple greedy graph coloring in degree order (color the most-
+ * constrained calendars first): each calendar takes the lowest-index hue slot
+ * not already used by one of its conflicts, falling back to a fresh hue if
+ * every existing slot conflicts. Deterministic given the same regions.
  */
-export function calendarColorScale(names: Iterable<string>): (calendarName: string) => string {
-    const keys = [...new Set([...names].map(normalize))].sort();
+export function calendarColorScale(regions: CalendarRegion[], extraNames: string[] = []): (calendarName: string) => string {
+    const graph = buildConflictGraph(regions);
+    // Make sure every named calendar has a node, even with no conflicts.
+    for (const name of extraNames) {
+        const key = normalize(name);
+        if (!graph.has(key)) graph.set(key, new Set());
+    }
+    for (const cr of regions) {
+        for (const era of cr.eras) {
+            const key = normalize(era.calendar);
+            if (!graph.has(key)) graph.set(key, new Set());
+        }
+    }
+
     const colors = new Map<string, string>();
-    let generatedIndex = 0;
-    for (const key of keys) {
-        if (PALETTE[key]) {
-            colors.set(key, PALETTE[key]);
-            continue;
+    for (const key of graph.keys()) {
+        if (PALETTE[key]) colors.set(key, PALETTE[key]);
+    }
+
+    // Greedy coloring, most-constrained first: process calendars with the most
+    // conflicts before less-constrained ones, so the calendars with the
+    // tightest neighborhoods get first pick of well-spaced hues.
+    const generatedKeys = [...graph.keys()]
+        .filter((key) => !PALETTE[key])
+        .sort((a, b) => graph.get(b)!.size - graph.get(a)!.size || a.localeCompare(b));
+
+    // Hue "slots" discovered so far, each a representative hue already in use.
+    // A calendar reuses the first slot that doesn't conflict with any neighbor
+    // already colored; otherwise it opens a new slot via the golden angle.
+    const slotHues: number[] = [];
+    for (const key of generatedKeys) {
+        const neighborHues = new Set<number>();
+        for (const neighbor of graph.get(key)!) {
+            const c = colors.get(neighbor);
+            if (c) neighborHues.add(paletteHue(c));
         }
-        // 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++;
+
+        let chosenHue: number | undefined;
+        for (const slotHue of slotHues) {
+            if ([...neighborHues].every((nh) => hueDistance(slotHue, nh) >= MIN_CONFLICT_HUE_DISTANCE)) {
+                chosenHue = slotHue;
+                break;
+            }
+        }
+        if (chosenHue === undefined) {
+            // Open a new slot, spaced from existing slots by the golden angle so
+            // slots themselves stay visually distinct as more are added.
+            chosenHue = ((slotHues.length + 0.5) * GOLDEN_ANGLE) % 360;
+            slotHues.push(chosenHue);
+        }
+        colors.set(key, hsl(chosenHue));
     }
+
     return (calendarName: string) => colors.get(normalize(calendarName)) ?? calendarColor(calendarName);
 }
diff --git a/src/utils/calendarConflicts.test.ts b/src/utils/calendarConflicts.test.ts
new file mode 100644
index 0000000..b3d086b
--- /dev/null
+++ b/src/utils/calendarConflicts.test.ts
@@ -0,0 +1,90 @@
+import { describe, it, expect } from 'vitest';
+import { buildConflictGraph } from './calendarConflicts';
+import type { CalendarRegion, Era } from '../types';
+
+function box(minLng: number, minLat: number, maxLng: number, maxLat: number) {
+    return {
+        type: 'Polygon' as const,
+        coordinates: [[[minLng, minLat], [maxLng, minLat], [maxLng, maxLat], [minLng, minLat]] as [number, number][]],
+    };
+}
+
+function regionWith(eras: Pick<Era, 'calendar' | 'start' | 'end'>[], geo = box(0, 0, 1, 1)): CalendarRegion {
+    return {
+        region: { name: 'Test', geo },
+        eras: eras.map((e) => ({ name: e.calendar, ...e })),
+    };
+}
+
+describe('buildConflictGraph', () => {
+    it('connects two eras in the same region that overlap in time', () => {
+        const regions = [
+            regionWith([
+                { calendar: 'A', start: '1700-01-01', end: '1800-01-01' },
+                { calendar: 'B', start: '1750-01-01', end: '1850-01-01' },
+            ]),
+        ];
+        const graph = buildConflictGraph(regions);
+        expect(graph.get('a')?.has('b')).toBe(true);
+        expect(graph.get('b')?.has('a')).toBe(true);
+    });
+
+    it('does not connect two eras in the same region that do not overlap in time', () => {
+        const regions = [
+            regionWith([
+                { calendar: 'A', start: '1700-01-01', end: '1750-01-01' },
+                { calendar: 'B', start: '1750-01-01', end: '1800-01-01' },
+            ]),
+        ];
+        const graph = buildConflictGraph(regions);
+        expect(graph.get('a')?.has('b')).toBeFalsy();
+    });
+
+    it('connects eras in different regions only when their bounding boxes overlap', () => {
+        const overlapping = [
+            regionWith([{ calendar: 'A', start: null, end: null }], box(0, 0, 10, 10)),
+            regionWith([{ calendar: 'B', start: null, end: null }], box(5, 5, 15, 15)),
+        ];
+        const graph1 = buildConflictGraph(overlapping);
+        expect(graph1.get('a')?.has('b')).toBe(true);
+
+        const farApart = [
+            regionWith([{ calendar: 'A', start: null, end: null }], box(0, 0, 10, 10)),
+            regionWith([{ calendar: 'C', start: null, end: null }], box(100, 100, 110, 110)),
+        ];
+        const graph2 = buildConflictGraph(farApart);
+        expect(graph2.get('a')?.has('c')).toBeFalsy();
+    });
+
+    it('does not connect eras in overlapping regions if their time ranges do not overlap', () => {
+        const regions = [
+            regionWith([{ calendar: 'A', start: '1700-01-01', end: '1800-01-01' }], box(0, 0, 10, 10)),
+            regionWith([{ calendar: 'B', start: '1900-01-01', end: '2000-01-01' }], box(5, 5, 15, 15)),
+        ];
+        const graph = buildConflictGraph(regions);
+        expect(graph.get('a')?.has('b')).toBeFalsy();
+    });
+
+    it('connects any calendar still active after 1927 to the modern global Gregorian default', () => {
+        const regions = [regionWith([{ calendar: 'A', start: '1900-01-01', end: null }])];
+        const graph = buildConflictGraph(regions);
+        expect(graph.get('a')?.has('gregorian')).toBe(true);
+    });
+
+    it('does not connect a calendar that ended before 1927 to the modern Gregorian default', () => {
+        const regions = [regionWith([{ calendar: 'A', start: '1700-01-01', end: '1800-01-01' }])];
+        const graph = buildConflictGraph(regions);
+        expect(graph.get('a')?.has('gregorian')).toBeFalsy();
+    });
+
+    it('treats null start/end as open-ended for overlap purposes', () => {
+        const regions = [
+            regionWith([
+                { calendar: 'A', start: null, end: null },
+                { calendar: 'B', start: '3000-01-01', end: '3100-01-01' },
+            ]),
+        ];
+        const graph = buildConflictGraph(regions);
+        expect(graph.get('a')?.has('b')).toBe(true);
+    });
+});
diff --git a/src/utils/calendarConflicts.ts b/src/utils/calendarConflicts.ts
new file mode 100644
index 0000000..2dd6b60
--- /dev/null
+++ b/src/utils/calendarConflicts.ts
@@ -0,0 +1,124 @@
+// Which calendars can actually appear together, for color assignment.
+//
+// Two calendars only need visually distinct colors if they can ever show up as
+// simultaneous answers — either two eras in the same region overlapping in
+// time, or two eras in *different* regions whose areas overlap geographically
+// and whose time ranges overlap. Most calendars in the dataset are nowhere
+// near each other (Japan and Iran never compete for a color), so the actual
+// "must be distinct" graph is sparse — far sparser than "every calendar must
+// differ from every other calendar," which is what forced near-collisions
+// once the dataset grew past ~20 calendars sharing one hue wheel.
+
+import type { CalendarRegion, Era, Geometry } from '../types';
+import { parseGregorianDate } from './julianDay';
+import { MODERN_DEFAULT_START_YEAR } from './resolve';
+
+interface BBox {
+    minLng: number;
+    minLat: number;
+    maxLng: number;
+    maxLat: number;
+}
+
+function expandBBox(box: BBox, lng: number, lat: number): void {
+    if (lng < box.minLng) box.minLng = lng;
+    if (lng > box.maxLng) box.maxLng = lng;
+    if (lat < box.minLat) box.minLat = lat;
+    if (lat > box.maxLat) box.maxLat = lat;
+}
+
+/** A conservative bounding box for a Polygon/MultiPolygon (over-approximates the shape). */
+function geometryBBox(geo: Geometry): BBox {
+    const box: BBox = { minLng: Infinity, minLat: Infinity, maxLng: -Infinity, maxLat: -Infinity };
+    const polygons = geo.type === 'Polygon' ? [geo.coordinates] : geo.coordinates;
+    for (const rings of polygons) {
+        for (const ring of rings) {
+            for (const [lng, lat] of ring) expandBBox(box, lng, lat);
+        }
+    }
+    return box;
+}
+
+function bboxesOverlap(a: BBox, b: BBox): boolean {
+    return a.minLng <= b.maxLng && b.minLng <= a.maxLng && a.minLat <= b.maxLat && b.minLat <= a.maxLat;
+}
+
+/** [start, end) in astronomical years; null start/end means open-ended. */
+interface TimeRange {
+    start: number;
+    end: number;
+}
+
+function eraTimeRange(era: Era): TimeRange {
+    return {
+        start: era.start ? parseGregorianDate(era.start).year : -Infinity,
+        end: era.end ? parseGregorianDate(era.end).year : Infinity,
+    };
+}
+
+function rangesOverlap(a: TimeRange, b: TimeRange): boolean {
+    return a.start < b.end && b.start < a.end;
+}
+
+/**
+ * Build the conflict graph: a map from each (normalized) calendar name to the
+ * set of other calendar names it can co-occur with, given the loaded region
+ * data plus the modern global Gregorian default (which can pair with anything
+ * still active from MODERN_DEFAULT_START_YEAR on, anywhere on Earth).
+ */
+export function buildConflictGraph(regions: CalendarRegion[]): Map<string, Set<string>> {
+    const graph = new Map<string, Set<string>>();
+    const addEdge = (a: string, b: string) => {
+        const ka = a.trim().toLowerCase();
+        const kb = b.trim().toLowerCase();
+        if (ka === kb) return;
+        if (!graph.has(ka)) graph.set(ka, new Set());
+        if (!graph.has(kb)) graph.set(kb, new Set());
+        graph.get(ka)!.add(kb);
+        graph.get(kb)!.add(ka);
+    };
+
+    const entries = regions.map((cr) => ({
+        bbox: geometryBBox(cr.region.geo),
+        eras: cr.eras.map((era) => ({ calendar: era.calendar, range: eraTimeRange(era) })),
+    }));
+
+    for (let i = 0; i < entries.length; i++) {
+        // Eras within the same region: any two overlapping in time conflict,
+        // regardless of geometry (they're the same place by definition).
+        const { eras } = entries[i];
+        for (let a = 0; a < eras.length; a++) {
+            for (let b = a + 1; b < eras.length; b++) {
+                if (rangesOverlap(eras[a].range, eras[b].range)) {
+                    addEdge(eras[a].calendar, eras[b].calendar);
+                }
+            }
+        }
+        // Eras across different regions: only conflict if the regions'
+        // bounding boxes overlap (conservative — may flag a few pairs whose
+        // exact shapes don't actually touch, but never misses a real one).
+        for (let j = i + 1; j < entries.length; j++) {
+            if (!bboxesOverlap(entries[i].bbox, entries[j].bbox)) continue;
+            for (const eraA of entries[i].eras) {
+                for (const eraB of entries[j].eras) {
+                    if (rangesOverlap(eraA.range, eraB.range)) {
+                        addEdge(eraA.calendar, eraB.calendar);
+                    }
+                }
+            }
+        }
+    }
+
+    // The modern global Gregorian default applies everywhere from
+    // MODERN_DEFAULT_START_YEAR on, so it conflicts with anything else still
+    // active in that window (mirrors resolve.ts's DEFAULT_MODERN_ERA).
+    const modernRange: TimeRange = { start: MODERN_DEFAULT_START_YEAR, end: Infinity };
+    for (const { eras } of entries) {
+        for (const era of eras) {
+            if (era.calendar.trim().toLowerCase() === 'gregorian') continue;
+            if (rangesOverlap(era.range, modernRange)) addEdge(era.calendar, 'Gregorian');
+        }
+    }
+
+    return graph;
+}