fuzzyworld

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

calendarColor.test.ts

Blame: the commit that last changed each line · file · history

34c4dd59 2026-06-18 Pavel Safronov1import { describe, it, expect } from 'vitest';
862df8d3 2026-06-21 Pavel Safronov2import { calendarColor, calendarColorScale } from './calendarColor';
3cbfe9ad 2026-06-22 Pavel Safronov3import type { CalendarRegion, Era } from '../types';
4
5const WORLD_GEO = { type: 'Polygon' as const, coordinates: [[[0, 0], [1, 0], [1, 1], [0, 0]] as [number, number][]] };
6
7/** A region with the given era calendars (all sharing one made-up box geometry). */
8function regionWith(eras: Pick<Era, 'calendar' | 'start' | 'end'>[], geo = WORLD_GEO): CalendarRegion {
9 return {
10 region: { name: 'Test', geo },
11 eras: eras.map((e) => ({ name: e.calendar, ...e })),
12 };
13}
14
15function box(minLng: number, minLat: number, maxLng: number, maxLat: number) {
16 return {
17 type: 'Polygon' as const,
18 coordinates: [[[minLng, minLat], [maxLng, minLat], [maxLng, maxLat], [minLng, minLat]] as [number, number][]],
19 };
20}
21
22function hueOf(color: string): number {
23 const hslMatch = /^hsl\(([\d.]+)/.exec(color);
24 if (hslMatch) return Number(hslMatch[1]);
25 const hex = /^#([0-9a-f]{6})$/i.exec(color);
26 expect(hex).not.toBeNull();
27 const r = parseInt(hex![1].slice(0, 2), 16) / 255;
28 const g = parseInt(hex![1].slice(2, 4), 16) / 255;
29 const b = parseInt(hex![1].slice(4, 6), 16) / 255;
30 const max = Math.max(r, g, b);
31 const min = Math.min(r, g, b);
32 const d = max - min;
33 if (d === 0) return 0;
34 let hue: number;
35 if (max === r) hue = ((g - b) / d) % 6;
36 else if (max === g) hue = (b - r) / d + 2;
37 else hue = (r - g) / d + 4;
38 return (hue * 60 + 360) % 360;
39}
40
41function hueDistance(a: number, b: number): number {
42 const diff = Math.abs(a - b) % 360;
43 return Math.min(diff, 360 - diff);
44}
34c4dd59 2026-06-18 Pavel Safronov45
46describe('calendarColor', () => {
47 it('uses the curated palette for known calendars', () => {
48 expect(calendarColor('Gregorian')).toBe('#90ee90');
49 expect(calendarColor('Algonquian lunar calendar')).toBe('#add8e6');
50 });
51
52 it('is case/space-insensitive', () => {
53 expect(calendarColor(' gregorian ')).toBe('#90ee90');
54 });
55
56 it('is deterministic for unknown calendars', () => {
57 const a = calendarColor('Some Made-Up Calendar');
58 const b = calendarColor('Some Made-Up Calendar');
59 expect(a).toBe(b);
60 expect(a).toMatch(/^hsl\(/);
61 });
62});
862df8d3 2026-06-21 Pavel Safronov63
64describe('calendarColorScale', () => {
65 it('keeps curated calendars on their palette color', () => {
3cbfe9ad 2026-06-22 Pavel Safronov66 const regions = [regionWith([{ calendar: 'Chinese', start: null, end: null }])];
67 const color = calendarColorScale(regions, ['Gregorian']);
862df8d3 2026-06-21 Pavel Safronov68 expect(color('Gregorian')).toBe('#90ee90');
69 });
70
71 it('is case/space-insensitive in lookups', () => {
3cbfe9ad 2026-06-22 Pavel Safronov72 const regions = [regionWith([{ calendar: 'Chinese sexagenary cycle', start: null, end: null }])];
73 const color = calendarColorScale(regions);
862df8d3 2026-06-21 Pavel Safronov74 expect(color(' chinese sexagenary cycle ')).toBe(color('Chinese sexagenary cycle'));
75 });
76
3cbfe9ad 2026-06-22 Pavel Safronov77 it('is deterministic given the same regions, regardless of input order', () => {
78 const a = [
79 regionWith([{ calendar: 'Japanese', start: null, end: null }], box(0, 0, 10, 10)),
80 regionWith([{ calendar: 'Ethiopic', start: null, end: null }], box(20, 20, 30, 30)),
81 ];
82 const b = [a[1], a[0]];
83 const colorA = calendarColorScale(a);
84 const colorB = calendarColorScale(b);
85 for (const n of ['Japanese', 'Ethiopic']) expect(colorA(n)).toBe(colorB(n));
862df8d3 2026-06-21 Pavel Safronov86 });
87
88 it('falls back to a stable color for names outside the set', () => {
3cbfe9ad 2026-06-22 Pavel Safronov89 const regions = [regionWith([{ calendar: 'Gregorian', start: null, end: null }])];
90 const color = calendarColorScale(regions);
862df8d3 2026-06-21 Pavel Safronov91 expect(color('Totally Unseen Calendar')).toBe(calendarColor('Totally Unseen Calendar'));
92 });
3cbfe9ad 2026-06-22 Pavel Safronov93
94 it('gives two calendars in the SAME region (overlapping in time) distinct hues, even far from the curated palette', () => {
95 const regions = [
96 regionWith([
97 { calendar: 'Aztec Xiuhpōhualli', start: '1325-01-01', end: '1521-08-13' },
98 { calendar: 'Aztec/Mexica year-bearer', start: '1325-01-01', end: '1521-08-13' },
99 { calendar: 'Tōnalpōhualli', start: '1325-01-01', end: null },
100 ]),
101 ];
102 const color = calendarColorScale(regions);
103 const hues = ['Aztec Xiuhpōhualli', 'Aztec/Mexica year-bearer', 'Tōnalpōhualli'].map((n) => hueOf(color(n)));
104 expect(hueDistance(hues[0], hues[1])).toBeGreaterThanOrEqual(45);
105 expect(hueDistance(hues[0], hues[2])).toBeGreaterThanOrEqual(45);
106 expect(hueDistance(hues[1], hues[2])).toBeGreaterThanOrEqual(45);
107 });
108
109 it('gives two calendars in geographically-overlapping regions distinct hues', () => {
110 // Mirrors the Korea / North Korea situation: two region files whose
111 // boxes overlap, each with their own calendar active at the same time.
112 const regions = [
113 regionWith([{ calendar: 'Gregorian calendar (modern)', start: '1910-01-01', end: null }], box(124, 34, 130, 42)),
114 regionWith([{ calendar: 'Juche', start: '1997-01-01', end: '2024-01-01' }], box(124, 37, 130, 42)),
115 ];
116 const color = calendarColorScale(regions, ['Gregorian']);
117 const hues = ['Gregorian calendar (modern)', 'Juche'].map((n) => hueOf(color(n)));
118 expect(hueDistance(hues[0], hues[1])).toBeGreaterThanOrEqual(45);
119 });
120
121 it('lets two calendars that never co-occur share the same color', () => {
122 // Mirrors Japan vs. Iran: far apart geographically, never compete for a
123 // color, so there's no reason to force them apart — and with a
124 // deterministic, order-independent assignment they often do collide,
125 // which is fine since a viewer never sees them in the same view.
126 const regions = [
127 regionWith([{ calendar: 'Some Calendar A', start: null, end: null }], box(0, 0, 5, 5)),
128 regionWith([{ calendar: 'Some Calendar B', start: null, end: null }], box(100, 100, 105, 105)),
129 ];
130 const color = calendarColorScale(regions);
131 // Not asserting they're equal (that's an implementation detail of the
132 // greedy slot order) — just that the scale doesn't crash and returns
133 // valid colors for both, independent of each other.
134 expect(color('Some Calendar A')).toMatch(/^hsl\(/);
135 expect(color('Some Calendar B')).toMatch(/^hsl\(/);
136 });
137
138 it('does not flag two calendars as conflicting when their regions do not overlap geographically, even with overlapping eras', () => {
139 const regions = [
140 regionWith([{ calendar: 'Far Calendar A', start: '1900-01-01', end: null }], box(0, 0, 5, 5)),
141 regionWith([{ calendar: 'Far Calendar B', start: '1900-01-01', end: null }], box(100, 100, 105, 105)),
142 ];
143 // Force them into the same color slot by construction: since they never
144 // conflict, the algorithm is free to (and in this minimal case, will)
145 // assign them the same generated hue.
146 const color = calendarColorScale(regions);
147 expect(hueOf(color('Far Calendar A'))).toBe(hueOf(color('Far Calendar B')));
148 });
149
150 it('still distinguishes a calendar from the modern global Gregorian default when both are active after 1927', () => {
151 const regions = [regionWith([{ calendar: 'Dangi', start: '1948-01-01', end: null }], box(124, 34, 130, 42))];
152 const color = calendarColorScale(regions, ['Gregorian']);
153 const hues = ['Dangi', 'Gregorian'].map((n) => hueOf(color(n)));
154 expect(hueDistance(hues[0], hues[1])).toBeGreaterThanOrEqual(45);
155 });
862df8d3 2026-06-21 Pavel Safronov156});