when-am-i / files / src / components / DatePicker.tsx
DatePicker.tsx
34c4dd59 2026-06-18 Pavel Safronov | 1 | import React from 'react'; |
4014e5e0 2026-06-18 Pavel Safronov | 2 | import { |
| 3 | daysInYear, | |
| 4 | dayOfYear, | |
| 5 | fromDayOfYear, | |
| 6 | fromHistoricalDate, | |
| 7 | toHistoricalDate, | |
| 8 | type PickerDate, | |
| 9 | } from '../utils/historicalDate'; | |
ba77fd91 2026-06-19 Pavel Safronov | 10 | import { assignLanes } from '../utils/lanes'; |
bedc0609 2026-06-23 Pavel Safronov | 11 | import { buildYearScale } from '../utils/yearScale'; |
4014e5e0 2026-06-18 Pavel Safronov | 12 | |
| 13 | /** A stretch of the year axis using one calendar, for the colored track bands. */ | |
| 14 | export interface YearBand { | |
| 15 | /** Astronomical years; ±Infinity for open-ended eras. */ | |
| 16 | start: number; | |
| 17 | end: number; | |
| 18 | color: string; | |
| 19 | calendar: string; | |
| 20 | } | |
34c4dd59 2026-06-18 Pavel Safronov | 21 | |
68dcf3ed 2026-06-25 Pavel Safronov | 22 | /** A stretch of the day-of-year axis using one calendar (1-based, inclusive). */ |
| 23 | export interface DayBand { | |
| 24 | startDoy: number; | |
| 25 | endDoy: number; | |
| 26 | color: string; | |
| 27 | calendar: string; | |
| 28 | } | |
| 29 | ||
34c4dd59 2026-06-18 Pavel Safronov | 30 | interface DatePickerProps { |
| 31 | value: PickerDate; | |
| 32 | onChange: (next: PickerDate) => void; | |
4014e5e0 2026-06-18 Pavel Safronov | 33 | /** Era bands for the picked region, shown along the year track. */ |
| 34 | yearBands: YearBand[]; | |
68dcf3ed 2026-06-25 Pavel Safronov | 35 | /** Era bands clipped to the selected year, shown along the day-of-year track. */ |
| 36 | dayBands: DayBand[]; | |
8fa8f3cb 2026-06-21 Pavel Safronov | 37 | /** Earliest year the slider reaches; defaults to FALLBACK_MIN_YEAR. */ |
| 38 | minYear?: number; | |
34c4dd59 2026-06-18 Pavel Safronov | 39 | } |
| 40 | ||
| 41 | const MONTHS = [ | |
| 42 | 'January', 'February', 'March', 'April', 'May', 'June', | |
| 43 | 'July', 'August', 'September', 'October', 'November', 'December', | |
| 44 | ]; | |
4014e5e0 2026-06-18 Pavel Safronov | 45 | const MONTH_INITIALS = ['J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D']; |
| 46 | ||
8fa8f3cb 2026-06-21 Pavel Safronov | 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). | |
| 50 | const FALLBACK_MIN_YEAR = -1999; | |
4014e5e0 2026-06-18 Pavel Safronov | 51 | const MAX_YEAR = new Date().getFullYear(); |
| 52 | ||
bedc0609 2026-06-23 Pavel Safronov | 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). | |
| 57 | const POS_RESOLUTION = 10000; | |
| 58 | ||
4014e5e0 2026-06-18 Pavel Safronov | 59 | function yearLabel(astronomicalYear: number): string { |
| 60 | return astronomicalYear > 0 ? `${astronomicalYear} CE` : `${1 - astronomicalYear} BCE`; | |
| 61 | } | |
| 62 | ||
bedc0609 2026-06-23 Pavel Safronov | 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. | |
| 66 | function tickLabel(astronomicalYear: number, withEra: boolean): string { | |
| 67 | if (withEra || astronomicalYear <= 0) return yearLabel(astronomicalYear); | |
| 68 | return String(astronomicalYear); | |
4014e5e0 2026-06-18 Pavel Safronov | 69 | } |
| 70 | ||
| 71 | const barLabel: React.CSSProperties = { margin: 0, fontSize: '0.8em', color: '#666' }; | |
d1eb8ad5 2026-06-19 Pavel Safronov | 72 | const trackStyle: React.CSSProperties = { |
| 73 | position: 'absolute', | |
| 74 | inset: 0, | |
| 75 | background: '#e9ecef', | |
| 76 | borderRadius: 3, | |
| 77 | overflow: 'hidden', | |
| 78 | }; | |
4014e5e0 2026-06-18 Pavel Safronov | 79 | |
68dcf3ed 2026-06-25 Pavel Safronov | 80 | const DatePicker: React.FC<DatePickerProps> = ({ value, onChange, yearBands, dayBands, minYear = FALLBACK_MIN_YEAR }) => { |
4014e5e0 2026-06-18 Pavel Safronov | 81 | const astroYear = toHistoricalDate(value).year; |
| 82 | const doy = dayOfYear(astroYear, value.month, value.day); | |
| 83 | const yearDays = daysInYear(astroYear); | |
34c4dd59 2026-06-18 Pavel Safronov | 84 | |
68dcf3ed 2026-06-25 Pavel Safronov | 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 | } | |
4014e5e0 2026-06-18 Pavel Safronov | 121 | const emitYear = (nextAstroYear: number) => { |
| 122 | const { month, day } = fromDayOfYear(nextAstroYear, doy); | |
| 123 | onChange(fromHistoricalDate({ year: nextAstroYear, month, day })); | |
| 124 | }; | |
34c4dd59 2026-06-18 Pavel Safronov | 125 | |
4014e5e0 2026-06-18 Pavel Safronov | 126 | const emitDoy = (nextDoy: number) => { |
| 127 | const { month, day } = fromDayOfYear(astroYear, nextDoy); | |
| 128 | onChange(fromHistoricalDate({ year: astroYear, month, day })); | |
| 129 | }; | |
34c4dd59 2026-06-18 Pavel Safronov | 130 | |
bedc0609 2026-06-23 Pavel Safronov | 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 | ||
4014e5e0 2026-06-18 Pavel Safronov | 154 | // Unique calendars across the bands, for a small legend. |
| 155 | const bandLegend = [...new Map(yearBands.map((b) => [b.calendar, b.color])).entries()]; | |
34c4dd59 2026-06-18 Pavel Safronov | 156 | |
ba77fd91 2026-06-19 Pavel Safronov | 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 | ||
34c4dd59 2026-06-18 Pavel Safronov | 161 | return ( |
d1eb8ad5 2026-06-19 Pavel Safronov | 162 | <div style={{ margin: '12px 0' }}> |
4014e5e0 2026-06-18 Pavel Safronov | 163 | <div style={{ fontSize: '1.3em', fontWeight: 700, marginBottom: 12 }}> |
| 164 | {MONTHS[value.month - 1]} {value.day}, {yearLabel(astroYear)} | |
| 165 | </div> | |
34c4dd59 2026-06-18 Pavel Safronov | 166 | |
68dcf3ed 2026-06-25 Pavel Safronov | 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. */} | |
4014e5e0 2026-06-18 Pavel Safronov | 170 | <p style={barLabel}>Day of year</p> |
d1eb8ad5 2026-06-19 Pavel Safronov | 171 | <div style={{ position: 'relative', height: 28 }}> |
| 172 | <div style={trackStyle}> | |
68dcf3ed 2026-06-25 Pavel Safronov | 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 | )} | |
d1eb8ad5 2026-06-19 Pavel Safronov | 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> | |
68dcf3ed 2026-06-25 Pavel Safronov | 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); | |
4014e5e0 2026-06-18 Pavel Safronov | 241 | return ( |
68dcf3ed 2026-06-25 Pavel Safronov | 242 | <span key={i} style={{ position: 'absolute', top: 0, left: `${left}%` }}> |
4014e5e0 2026-06-18 Pavel Safronov | 243 | {initial} |
| 244 | </span> | |
| 245 | ); | |
| 246 | })} | |
68dcf3ed 2026-06-25 Pavel Safronov | 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 | ))} | |
4014e5e0 2026-06-18 Pavel Safronov | 267 | </div> |
34c4dd59 2026-06-18 Pavel Safronov | 268 | |
4014e5e0 2026-06-18 Pavel Safronov | 269 | {/* Year bar with era-color bands behind the track */} |
| 270 | <p style={barLabel}>Year</p> | |
| 271 | <div style={{ position: 'relative', height: 28 }}> | |
d1eb8ad5 2026-06-19 Pavel Safronov | 272 | <div style={trackStyle}> |
ba77fd91 2026-06-19 Pavel Safronov | 273 | {lanes.map(({ item: band, lane }, i) => { |
bedc0609 2026-06-23 Pavel Safronov | 274 | const left = yearPct(band.start); |
| 275 | const width = yearPct(band.end) - left; | |
4014e5e0 2026-06-18 Pavel Safronov | 276 | return ( |
| 277 | <div | |
| 278 | key={i} | |
| 279 | title={band.calendar} | |
| 280 | style={{ | |
| 281 | position: 'absolute', | |
| 282 | left: `${left}%`, | |
| 283 | width: `${width}%`, | |
ba77fd91 2026-06-19 Pavel Safronov | 284 | top: `${(lane / laneCount) * 100}%`, |
| 285 | height: `${(1 / laneCount) * 100}%`, | |
4014e5e0 2026-06-18 Pavel Safronov | 286 | background: band.color, |
ba77fd91 2026-06-19 Pavel Safronov | 287 | opacity: 0.85, |
4014e5e0 2026-06-18 Pavel Safronov | 288 | }} |
| 289 | /> | |
| 290 | ); | |
| 291 | })} | |
bedc0609 2026-06-23 Pavel Safronov | 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 | ))} | |
4014e5e0 2026-06-18 Pavel Safronov | 310 | </div> |
34c4dd59 2026-06-18 Pavel Safronov | 311 | <input |
d1eb8ad5 2026-06-19 Pavel Safronov | 312 | className="timeline-slider" |
4014e5e0 2026-06-18 Pavel Safronov | 313 | type="range" |
bedc0609 2026-06-23 Pavel Safronov | 314 | min={0} |
| 315 | max={POS_RESOLUTION} | |
4014e5e0 2026-06-18 Pavel Safronov | 316 | step={1} |
bedc0609 2026-06-23 Pavel Safronov | 317 | value={Math.round(scale.toFraction(astroYear) * POS_RESOLUTION)} |
| 318 | onChange={(e) => emitYear(Math.round(scale.toYear(Number(e.target.value) / POS_RESOLUTION)))} | |
4014e5e0 2026-06-18 Pavel Safronov | 319 | aria-label="Year" |
| 320 | aria-valuetext={yearLabel(astroYear)} | |
34c4dd59 2026-06-18 Pavel Safronov | 321 | /> |
4014e5e0 2026-06-18 Pavel Safronov | 322 | </div> |
bedc0609 2026-06-23 Pavel Safronov | 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 | })} | |
4014e5e0 2026-06-18 Pavel Safronov | 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 | )} | |
34c4dd59 2026-06-18 Pavel Safronov | 362 | </div> |
| 363 | ); | |
| 364 | }; | |
| 365 | ||
| 366 | export { DatePicker }; |