fuzzyworld

when-am-i / files / src / components / DatePicker.tsx

DatePicker.tsx

17.4 KB · history · blame

1import React from 'react';
2import {
3 daysInYear,
4 dayOfYear,
5 fromDayOfYear,
6 fromHistoricalDate,
7 toHistoricalDate,
8 type PickerDate,
9} from '../utils/historicalDate';
10import { assignLanes } from '../utils/lanes';
11import { buildYearScale } from '../utils/yearScale';
12
13/** A stretch of the year axis using one calendar, for the colored track bands. */
14export interface YearBand {
15 /** Astronomical years; ±Infinity for open-ended eras. */
16 start: number;
17 end: number;
18 color: string;
19 calendar: string;
20}
21
22/** A stretch of the day-of-year axis using one calendar (1-based, inclusive). */
23export interface DayBand {
24 startDoy: number;
25 endDoy: number;
26 color: string;
27 calendar: string;
28}
29
30interface DatePickerProps {
31 value: PickerDate;
32 onChange: (next: PickerDate) => void;
33 /** Era bands for the picked region, shown along the year track. */
34 yearBands: YearBand[];
35 /** Era bands clipped to the selected year, shown along the day-of-year track. */
36 dayBands: DayBand[];
37 /** Earliest year the slider reaches; defaults to FALLBACK_MIN_YEAR. */
38 minYear?: number;
39}
40
41const MONTHS = [
42 'January', 'February', 'March', 'April', 'May', 'June',
43 'July', 'August', 'September', 'October', 'November', 'December',
44];
45const MONTH_INITIALS = ['J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D'];
46
47// Year axis: by default reaches back to 2000 BCE (astronomical -1999); App.tsx
48// passes a tighter, data-driven minYear so the slider doesn't waste most of its
49// width on millennia with no calendar data (see deriveMinYear in utils/dateRange.ts).
50const FALLBACK_MIN_YEAR = -1999;
51const MAX_YEAR = new Date().getFullYear();
52
53// The year slider runs in *position* units (0…POS_RESOLUTION), not years, so its
54// thumb tracks the piecewise-linear year scale (buildYearScale) rather than a
55// plain linear year mapping — the resolution is high enough that every year
56// stays reachable even where the axis is compressed (see utils/yearScale.ts).
57const POS_RESOLUTION = 10000;
58
59function yearLabel(astronomicalYear: number): string {
60 return astronomicalYear > 0 ? `${astronomicalYear} CE` : `${1 - astronomicalYear} BCE`;
61}
62
63// Compact axis-tick label: drop the "CE" suffix on positive years so interior
64// key dates (e.g. "1896 1897 1907 1910") stay narrow and don't collide; the
65// outer endpoints still carry the era (BCE/CE) for orientation.
66function tickLabel(astronomicalYear: number, withEra: boolean): string {
67 if (withEra || astronomicalYear <= 0) return yearLabel(astronomicalYear);
68 return String(astronomicalYear);
69}
70
71const barLabel: React.CSSProperties = { margin: 0, fontSize: '0.8em', color: '#666' };
72const trackStyle: React.CSSProperties = {
73 position: 'absolute',
74 inset: 0,
75 background: '#e9ecef',
76 borderRadius: 3,
77 overflow: 'hidden',
78};
79
80const DatePicker: React.FC<DatePickerProps> = ({ value, onChange, yearBands, dayBands, minYear = FALLBACK_MIN_YEAR }) => {
81 const astroYear = toHistoricalDate(value).year;
82 const doy = dayOfYear(astroYear, value.month, value.day);
83 const yearDays = daysInYear(astroYear);
84
85 // Day-of-year track: a day d (1-based) occupies the slice
86 // [(d-1)/yearDays, d/yearDays), matching the month-initial placement below,
87 // so a band covering days [startDoy, endDoy] spans those slices exactly.
88 const dayPct = (doy0: number) => (doy0 / yearDays) * 100;
89 // Pack any concurrent calendars (parallel reckonings in the same year) into
90 // lanes, as the year bar does. endDoy is inclusive ⇒ exclusive edge is +1.
91 const { lanes: dayLanes, count: dayLaneCount } = assignLanes(dayBands, (b) => b.startDoy, (b) => b.endDoy + 1);
92 // Days where the set of calendar colors changes — drawn as dashed verticals
93 // so a mid-year changeover reads as a transition, like the year axis.
94 const dayColorsAt = (d: number) =>
95 [...new Set(dayBands.filter((b) => b.startDoy <= d && d <= b.endDoy).map((b) => b.color))].sort().join('|');
96 const dayBoundaries = Array.from({ length: yearDays - 1 }, (_, i) => i + 2).filter(
97 (d) => dayColorsAt(d - 1) !== dayColorsAt(d),
98 );
99 // Each changeover day is a clickable tick (like the year axis's key dates):
100 // its label is the date the new calendar took effect, and clicking jumps the
101 // selection straight to that day.
102 const dayTickLabel = (d: number) => {
103 const { month, day } = fromDayOfYear(astroYear, d);
104 return `${MONTHS[month - 1].slice(0, 3)} ${day}`;
105 };
106 // Group ticks that are too close to label separately — render as a
107 // space-separated row at their midpoint so close changeovers (e.g. the
108 // 18-day 1871 Paris Commune) appear side-by-side on one line.
109 const DAY_TICK_CLUSTER_GAP_PCT = 8;
110 const dayTickClusters: { ticks: { d: number; pct: number }[]; midPct: number }[] = [];
111 for (const d of dayBoundaries) {
112 const pct = dayPct(d - 1);
113 const last = dayTickClusters[dayTickClusters.length - 1];
114 if (last && pct - last.ticks[0].pct < DAY_TICK_CLUSTER_GAP_PCT) {
115 last.ticks.push({ d, pct });
116 last.midPct = (last.ticks[0].pct + pct) / 2;
117 } else {
118 dayTickClusters.push({ ticks: [{ d, pct }], midPct: pct });
119 }
120 }
121 const emitYear = (nextAstroYear: number) => {
122 const { month, day } = fromDayOfYear(nextAstroYear, doy);
123 onChange(fromHistoricalDate({ year: nextAstroYear, month, day }));
124 };
125
126 const emitDoy = (nextDoy: number) => {
127 const { month, day } = fromDayOfYear(astroYear, nextDoy);
128 onChange(fromHistoricalDate({ year: astroYear, month, day }));
129 };
130
131 // Piecewise-linear year axis anchored on the picked region's era boundaries
132 // (the finite band edges), so each key date — like a calendar changeover —
133 // is its own labelled tick and the band transition lands exactly on it.
134 const keyYears = yearBands.flatMap((b) => [b.start, b.end]);
135 const scale = buildYearScale(minYear, MAX_YEAR, keyYears);
136 const yearPct = (year: number) => scale.toFraction(year) * 100;
137
138 // A dashed line is only drawn where the band *color* actually changes across
139 // a year — i.e. a real calendar changeover you can see — not at era-name
140 // changes within one calendar (e.g. Korea's Geonyang→Gwangmu, both
141 // Gregorian) or sub-year eras that collapse to nothing at year resolution.
142 const colorsAtYear = (year: number) =>
143 [...new Set(yearBands.filter((b) => b.start <= year && year < b.end).map((b) => b.color))].sort().join('|');
144 const isColorBoundary = (year: number) => colorsAtYear(year - 1) !== colorsAtYear(year);
145
146 // Drop an outer endpoint label when the nearest key date sits close enough
147 // to collide with it (e.g. "244 BCE" running into "1896"); the key dates
148 // matter more than the axis floor/ceiling, so the endpoint yields.
149 const ENDPOINT_COLLISION = 0.14;
150 const hasInterior = scale.anchors.length > 2;
151 const hideStartLabel = hasInterior && scale.toFraction(scale.anchors[1]) < ENDPOINT_COLLISION;
152 const hideEndLabel = hasInterior && scale.toFraction(scale.anchors[scale.anchors.length - 2]) > 1 - ENDPOINT_COLLISION;
153
154 // Unique calendars across the bands, for a small legend.
155 const bandLegend = [...new Map(yearBands.map((b) => [b.calendar, b.color])).entries()];
156
157 // Pack concurrent eras into parallel lanes so coexisting calendars stack as
158 // horizontal stripes instead of overpainting (cf. Wikipedia band timelines).
159 const { lanes, count: laneCount } = assignLanes(yearBands, (b) => b.start, (b) => b.end);
160
161 return (
162 <div style={{ margin: '12px 0' }}>
163 <div style={{ fontSize: '1.3em', fontWeight: 700, marginBottom: 12 }}>
164 {MONTHS[value.month - 1]} {value.day}, {yearLabel(astroYear)}
165 </div>
166
167 {/* Day-of-year bar: calendar-colored bands for the selected year
168 (or a grey progress fill when no region is pinned), with clickable
169 ticks at any mid-year changeover. */}
170 <p style={barLabel}>Day of year</p>
171 <div style={{ position: 'relative', height: 28 }}>
172 <div style={trackStyle}>
173 {dayBands.length > 0 ? (
174 <>
175 {/* Calendar-colored bands for the selected year: a
176 changeover mid-year splits the track, so you can
177 see *when in the year* the calendar changed. */}
178 {dayLanes.map(({ item: band, lane }, i) => {
179 const left = dayPct(band.startDoy - 1);
180 const width = dayPct(band.endDoy) - left;
181 return (
182 <div
183 key={i}
184 title={band.calendar}
185 style={{
186 position: 'absolute',
187 left: `${left}%`,
188 width: `${width}%`,
189 top: `${(lane / dayLaneCount) * 100}%`,
190 height: `${(1 / dayLaneCount) * 100}%`,
191 background: band.color,
192 opacity: 0.85,
193 }}
194 />
195 );
196 })}
197 {dayBoundaries.map((d) => (
198 <div
199 key={d}
200 aria-hidden
201 style={{
202 position: 'absolute',
203 left: `${dayPct(d - 1)}%`,
204 top: 0,
205 bottom: 0,
206 borderLeft: '1px dashed rgba(0, 0, 0, 0.4)',
207 pointerEvents: 'none',
208 }}
209 />
210 ))}
211 </>
212 ) : (
213 // No region pinned: fall back to a plain progress fill.
214 <div
215 style={{
216 position: 'absolute',
217 left: 0,
218 width: `${dayPct(doy)}%`,
219 top: 0,
220 bottom: 0,
221 background: '#adb5bd',
222 }}
223 />
224 )}
225 </div>
226 <input
227 className="timeline-slider"
228 type="range"
229 min={1}
230 max={yearDays}
231 step={1}
232 value={doy}
233 onChange={(e) => emitDoy(Number(e.target.value))}
234 aria-label="Day of year"
235 aria-valuetext={`${MONTHS[value.month - 1]} ${value.day}`}
236 />
237 </div>
238 <div style={{ position: 'relative', height: 16, marginTop: 4, marginBottom: 16, fontSize: '0.7em', color: '#999' }}>
239 {dayTickClusters.length === 0 && MONTH_INITIALS.map((initial, i) => {
240 const left = dayPct(dayOfYear(astroYear, i + 1, 1) - 1);
241 return (
242 <span key={i} style={{ position: 'absolute', top: 0, left: `${left}%` }}>
243 {initial}
244 </span>
245 );
246 })}
247 {/* Changeover ticks: close ticks are grouped into a cluster and
248 rendered side-by-side at their midpoint so they never stack. */}
249 {dayTickClusters.map(({ ticks, midPct }) => (
250 <span
251 key={ticks[0].d}
252 style={{ position: 'absolute', top: 0, left: `${midPct}%`, transform: 'translateX(-50%)', whiteSpace: 'nowrap', display: 'inline-flex', gap: '0.6em' }}
253 >
254 {ticks.map(({ d }) => (
255 <button
256 key={d}
257 type="button"
258 className="year-tick"
259 onClick={() => emitDoy(d)}
260 aria-label={`Jump to ${dayTickLabel(d)}`}
261 >
262 {dayTickLabel(d)}
263 </button>
264 ))}
265 </span>
266 ))}
267 </div>
268
269 {/* Year bar with era-color bands behind the track */}
270 <p style={barLabel}>Year</p>
271 <div style={{ position: 'relative', height: 28 }}>
272 <div style={trackStyle}>
273 {lanes.map(({ item: band, lane }, i) => {
274 const left = yearPct(band.start);
275 const width = yearPct(band.end) - left;
276 return (
277 <div
278 key={i}
279 title={band.calendar}
280 style={{
281 position: 'absolute',
282 left: `${left}%`,
283 width: `${width}%`,
284 top: `${(lane / laneCount) * 100}%`,
285 height: `${(1 / laneCount) * 100}%`,
286 background: band.color,
287 opacity: 0.85,
288 }}
289 />
290 );
291 })}
292 {/* Dashed verticals at the key dates where the calendar
293 actually changes (a visible color boundary), so they
294 read as transitions across the bands rather than marking
295 every era-name change. */}
296 {scale.anchors.slice(1, -1).filter(isColorBoundary).map((year) => (
297 <div
298 key={year}
299 aria-hidden
300 style={{
301 position: 'absolute',
302 left: `${yearPct(year)}%`,
303 top: 0,
304 bottom: 0,
305 borderLeft: '1px dashed rgba(0, 0, 0, 0.4)',
306 pointerEvents: 'none',
307 }}
308 />
309 ))}
310 </div>
311 <input
312 className="timeline-slider"
313 type="range"
314 min={0}
315 max={POS_RESOLUTION}
316 step={1}
317 value={Math.round(scale.toFraction(astroYear) * POS_RESOLUTION)}
318 onChange={(e) => emitYear(Math.round(scale.toYear(Number(e.target.value) / POS_RESOLUTION)))}
319 aria-label="Year"
320 aria-valuetext={yearLabel(astroYear)}
321 />
322 </div>
323 {/* Axis labels at the scale's anchor years: the region's key dates
324 (era boundaries), so the axis reads as "244 BCE … 1954 … 2026" —
325 with the changeover called out — rather than generic round years.
326 Each is a button that jumps the date to that year. */}
327 <div style={{ position: 'relative', height: 16, fontSize: '0.7em', color: '#999' }}>
328 {scale.anchors.map((year, i) => {
329 const isFirst = i === 0;
330 const isLast = i === scale.anchors.length - 1;
331 if ((isFirst && hideStartLabel) || (isLast && hideEndLabel)) return null;
332 const position: React.CSSProperties = isFirst
333 ? { left: 0 }
334 : isLast
335 ? { right: 0 }
336 : { left: `${yearPct(year)}%`, transform: 'translateX(-50%)' };
337 return (
338 <button
339 key={year}
340 type="button"
341 className="year-tick"
342 onClick={() => emitYear(year)}
343 aria-label={`Jump to ${yearLabel(year)}`}
344 style={{ position: 'absolute', whiteSpace: 'nowrap', ...position }}
345 >
346 {tickLabel(year, isFirst || isLast)}
347 </button>
348 );
349 })}
350 </div>
351
352 {bandLegend.length > 0 && (
353 <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginTop: 6, fontSize: '0.75em', color: '#555' }}>
354 {bandLegend.map(([cal, color]) => (
355 <span key={cal} style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
356 <span aria-hidden style={{ width: 11, height: 11, background: color, border: '1px solid #ccc', borderRadius: 2 }} />
357 {cal}
358 </span>
359 ))}
360 </div>
361 )}
362 </div>
363 );
364};
365
366export { DatePicker };