when-am-i / commit
Fix duplicate North Korea era and replace global color spacing with a conflict graph
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; +}