when-am-i / commit
Rework the year timeline: piecewise key-date axis with changeover lines and clickable labels
src/components/DatePicker.tsx | 99 +++++++++++++++++++++++++++++++++++++------ src/index.css | 18 ++++++++ src/utils/yearScale.test.ts | 68 +++++++++++++++++++++++++++++ src/utils/yearScale.ts | 74 ++++++++++++++++++++++++++++++++ 4 files changed, 247 insertions(+), 12 deletions(-) diff --git a/src/components/DatePicker.tsx b/src/components/DatePicker.tsx index 574a182..3d649c2 100644 --- a/src/components/DatePicker.tsx +++ b/src/components/DatePicker.tsx @@ -8,6 +8,7 @@ import { type PickerDate, } from '../utils/historicalDate'; import { assignLanes } from '../utils/lanes'; +import { buildYearScale } from '../utils/yearScale'; /** A stretch of the year axis using one calendar, for the colored track bands. */ export interface YearBand { @@ -39,13 +40,22 @@ const MONTH_INITIALS = ['J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', ' const FALLBACK_MIN_YEAR = -1999; const MAX_YEAR = new Date().getFullYear(); +// The year slider runs in *position* units (0…POS_RESOLUTION), not years, so its +// thumb tracks the piecewise-linear year scale (buildYearScale) rather than a +// plain linear year mapping — the resolution is high enough that every year +// stays reachable even where the axis is compressed (see utils/yearScale.ts). +const POS_RESOLUTION = 10000; + function yearLabel(astronomicalYear: number): string { return astronomicalYear > 0 ? `${astronomicalYear} CE` : `${1 - astronomicalYear} BCE`; } -function pct(year: number, minYear: number): number { - const clamped = Math.max(minYear, Math.min(MAX_YEAR, year)); - return ((clamped - minYear) / (MAX_YEAR - minYear)) * 100; +// Compact axis-tick label: drop the "CE" suffix on positive years so interior +// key dates (e.g. "1896 1897 1907 1910") stay narrow and don't collide; the +// outer endpoints still carry the era (BCE/CE) for orientation. +function tickLabel(astronomicalYear: number, withEra: boolean): string { + if (withEra || astronomicalYear <= 0) return yearLabel(astronomicalYear); + return String(astronomicalYear); } const barLabel: React.CSSProperties = { margin: 0, fontSize: '0.8em', color: '#666' }; @@ -72,6 +82,29 @@ const DatePicker: React.FC<DatePickerProps> = ({ value, onChange, yearBands, min onChange(fromHistoricalDate({ year: astroYear, month, day })); }; + // Piecewise-linear year axis anchored on the picked region's era boundaries + // (the finite band edges), so each key date — like a calendar changeover — + // is its own labelled tick and the band transition lands exactly on it. + const keyYears = yearBands.flatMap((b) => [b.start, b.end]); + const scale = buildYearScale(minYear, MAX_YEAR, keyYears); + const yearPct = (year: number) => scale.toFraction(year) * 100; + + // A dashed line is only drawn where the band *color* actually changes across + // a year — i.e. a real calendar changeover you can see — not at era-name + // changes within one calendar (e.g. Korea's Geonyang→Gwangmu, both + // Gregorian) or sub-year eras that collapse to nothing at year resolution. + const colorsAtYear = (year: number) => + [...new Set(yearBands.filter((b) => b.start <= year && year < b.end).map((b) => b.color))].sort().join('|'); + const isColorBoundary = (year: number) => colorsAtYear(year - 1) !== colorsAtYear(year); + + // Drop an outer endpoint label when the nearest key date sits close enough + // to collide with it (e.g. "244 BCE" running into "1896"); the key dates + // matter more than the axis floor/ceiling, so the endpoint yields. + const ENDPOINT_COLLISION = 0.14; + const hasInterior = scale.anchors.length > 2; + const hideStartLabel = hasInterior && scale.toFraction(scale.anchors[1]) < ENDPOINT_COLLISION; + const hideEndLabel = hasInterior && scale.toFraction(scale.anchors[scale.anchors.length - 2]) > 1 - ENDPOINT_COLLISION; + // Unique calendars across the bands, for a small legend. const bandLegend = [...new Map(yearBands.map((b) => [b.calendar, b.color])).entries()]; @@ -131,8 +164,8 @@ const DatePicker: React.FC<DatePickerProps> = ({ value, onChange, yearBands, min <div style={{ position: 'relative', height: 28 }}> <div style={trackStyle}> {lanes.map(({ item: band, lane }, i) => { - const left = pct(band.start, minYear); - const width = pct(band.end, minYear) - left; + const left = yearPct(band.start); + const width = yearPct(band.end) - left; return ( <div key={i} @@ -149,22 +182,64 @@ const DatePicker: React.FC<DatePickerProps> = ({ value, onChange, yearBands, min /> ); })} + {/* Dashed verticals at the key dates where the calendar + actually changes (a visible color boundary), so they + read as transitions across the bands rather than marking + every era-name change. */} + {scale.anchors.slice(1, -1).filter(isColorBoundary).map((year) => ( + <div + key={year} + aria-hidden + style={{ + position: 'absolute', + left: `${yearPct(year)}%`, + top: 0, + bottom: 0, + borderLeft: '1px dashed rgba(0, 0, 0, 0.4)', + pointerEvents: 'none', + }} + /> + ))} </div> <input className="timeline-slider" type="range" - min={minYear} - max={MAX_YEAR} + min={0} + max={POS_RESOLUTION} step={1} - value={astroYear} - onChange={(e) => emitYear(Number(e.target.value))} + value={Math.round(scale.toFraction(astroYear) * POS_RESOLUTION)} + onChange={(e) => emitYear(Math.round(scale.toYear(Number(e.target.value) / POS_RESOLUTION)))} aria-label="Year" aria-valuetext={yearLabel(astroYear)} /> </div> - <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.7em', color: '#999' }}> - <span>{yearLabel(minYear)}</span> - <span>{yearLabel(MAX_YEAR)}</span> + {/* Axis labels at the scale's anchor years: the region's key dates + (era boundaries), so the axis reads as "244 BCE … 1954 … 2026" — + with the changeover called out — rather than generic round years. + Each is a button that jumps the date to that year. */} + <div style={{ position: 'relative', height: 16, fontSize: '0.7em', color: '#999' }}> + {scale.anchors.map((year, i) => { + const isFirst = i === 0; + const isLast = i === scale.anchors.length - 1; + if ((isFirst && hideStartLabel) || (isLast && hideEndLabel)) return null; + const position: React.CSSProperties = isFirst + ? { left: 0 } + : isLast + ? { right: 0 } + : { left: `${yearPct(year)}%`, transform: 'translateX(-50%)' }; + return ( + <button + key={year} + type="button" + className="year-tick" + onClick={() => emitYear(year)} + aria-label={`Jump to ${yearLabel(year)}`} + style={{ position: 'absolute', whiteSpace: 'nowrap', ...position }} + > + {tickLabel(year, isFirst || isLast)} + </button> + ); + })} </div> {bandLegend.length > 0 && ( diff --git a/src/index.css b/src/index.css index a54da02..a7b72bf 100644 --- a/src/index.css +++ b/src/index.css @@ -54,6 +54,24 @@ body { background: transparent; cursor: pointer; } + +/* Clickable year-axis labels: jump the date to that key year. Reset button + chrome so they read as plain ticks, with a hover/focus cue that they're + interactive. */ +.year-tick { + padding: 2px 1px; + border: 0; + background: none; + font: inherit; + color: inherit; + line-height: 1; + cursor: pointer; +} +.year-tick:hover, +.year-tick:focus-visible { + color: #1a1a1a; + text-decoration: underline; +} .timeline-slider::-webkit-slider-runnable-track { height: 28px; background: transparent; diff --git a/src/utils/yearScale.test.ts b/src/utils/yearScale.test.ts new file mode 100644 index 0000000..0e1fd5c --- /dev/null +++ b/src/utils/yearScale.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import { buildYearScale } from './yearScale'; + +describe('buildYearScale', () => { + it('places the endpoints at 0 and 1', () => { + const s = buildYearScale(-243, 2026, [1954]); + expect(s.toFraction(-243)).toBe(0); + expect(s.toFraction(2026)).toBe(1); + }); + + it('clamps years outside the range (including ±Infinity for open-ended eras)', () => { + const s = buildYearScale(-243, 2026, [1954]); + expect(s.toFraction(-Infinity)).toBe(0); + expect(s.toFraction(-5000)).toBe(0); + expect(s.toFraction(Infinity)).toBe(1); + expect(s.toFraction(9999)).toBe(1); + }); + + it('anchors on the region key years (a single boundary lands dead-centre)', () => { + const s = buildYearScale(-243, 2026, [1954]); + expect(s.anchors).toEqual([-243, 1954, 2026]); + expect(s.toFraction(1954)).toBeCloseTo(0.5, 6); + }); + + it('sorts and de-dupes key years and drops any outside the range', () => { + const s = buildYearScale(-243, 2026, [1910, 1896, 1896, 9999, -500, 1907, 1897]); + expect(s.anchors).toEqual([-243, 1896, 1897, 1907, 1910, 2026]); + }); + + it('spreads clustered key dates evenly so each is readable (Korea 1896–1910)', () => { + const s = buildYearScale(-243, 2026, [1896, 1897, 1907, 1910]); + const fracs = s.anchors.map((y) => s.toFraction(y)); + const step = 1 / (s.anchors.length - 1); + for (let i = 1; i < fracs.length; i++) { + // Equal-width segments → even spacing, even though 1896→1897 is one + // year and 1910→2026 is 116 (proportional spacing would collide them). + expect(fracs[i] - fracs[i - 1]).toBeCloseTo(step, 6); + } + }); + + it('falls back to round epoch anchors when the region has no key years', () => { + const s = buildYearScale(-243, 2026, []); + expect(s.anchors).toEqual([-243, 1500, 1800, 1900, 2026]); + }); + + it('is monotonic increasing in year', () => { + const s = buildYearScale(-243, 2026, [1896, 1897, 1907, 1910]); + let prev = -Infinity; + for (let y = -243; y <= 2026; y += 7) { + const f = s.toFraction(y); + expect(f).toBeGreaterThanOrEqual(prev); + prev = f; + } + }); + + it('round-trips year → fraction → year', () => { + const s = buildYearScale(-243, 2026, [1792, 1806, 1871]); + for (const y of [-243, 500, 1792, 1806, 1850, 1871, 1945, 2026]) { + expect(s.toYear(s.toFraction(y))).toBeCloseTo(y, 6); + } + }); + + it('degrades to a single linear segment when there are no usable anchors', () => { + const s = buildYearScale(1980, 2026, []); // no epoch anchors in range either + expect(s.anchors).toEqual([1980, 2026]); + expect(s.toFraction(2003)).toBeCloseTo((2003 - 1980) / (2026 - 1980), 6); + }); +}); diff --git a/src/utils/yearScale.ts b/src/utils/yearScale.ts new file mode 100644 index 0000000..cc7b614 --- /dev/null +++ b/src/utils/yearScale.ts @@ -0,0 +1,74 @@ +// A piecewise-linear scale for the year axis, so the timeline doesn't cram +// every era boundary into the right edge and the *key dates* of the region +// you're looking at get their own labels. +// +// The dataset's era boundaries are wildly skewed toward recent centuries (most +// fall after 1800), so a *linear* year axis leaves ~70% of its width nearly +// empty and bunches all the interesting transitions into a sliver on the right. +// This scale instead breaks the axis at the picked region's actual era +// boundaries (e.g. Vietnam's 1954 lunisolar→Gregorian changeover) and gives +// every segment equal screen width. Equal width is what makes *clustered* key +// dates readable — Korea's 1896/1897/1907/1910 transitions would collide under +// a proportional scale, but here each gets its own evenly-spaced label and a +// visible (clickable) band, at the cost of not drawing durations to scale. When +// a region has no boundaries of its own (the landing page, or a place like +// North Sentinel Island), it falls back to round epoch years for context. +// +// The mapping is a bijection (monotonic piecewise-linear), so it inverts +// cleanly: the slider drives `toYear` from a position, and bands/ticks are +// placed with `toFraction` from a year. + +// Round epoch years used as interior breakpoints when a region has no era +// boundaries to anchor on. Only those strictly inside (minYear, maxYear) are +// used; the floor and ceiling (the data-driven minYear and the current year) +// are always the outer anchors. +const EPOCH_ANCHORS = [1500, 1800, 1900]; + +export interface YearScale { + /** Breakpoint years, ascending: [minYear, …interior key dates…, maxYear]. */ + anchors: number[]; + /** Year → fraction along the axis in [0, 1] (±Infinity clamp to 0/1). */ + toFraction: (year: number) => number; + /** Fraction in [0, 1] → year (inverse of toFraction). */ + toYear: (fraction: number) => number; +} + +function clamp01(x: number): number { + return x < 0 ? 0 : x > 1 ? 1 : x; +} + +/** + * Build a piecewise-linear year scale over [minYear, maxYear], broken at + * `keyYears` (the picked region's era boundaries) so each is its own anchor — + * labelled, and with the band edge landing exactly on it. Each segment gets an + * equal share of the axis width. Years outside (minYear, maxYear), duplicates, + * and ±Infinity are ignored; if no key years remain, falls back to round epoch + * anchors so the axis still reads sensibly (landing page, boundary-less regions). + */ +export function buildYearScale(minYear: number, maxYear: number, keyYears: number[] = []): YearScale { + let interior = [...new Set(keyYears)] + .filter((y) => Number.isFinite(y) && y > minYear && y < maxYear) + .sort((a, b) => a - b); + if (interior.length === 0) interior = EPOCH_ANCHORS.filter((y) => y > minYear && y < maxYear); + + const anchors = [minYear, ...interior, maxYear]; + const segments = anchors.length - 1; + + const toFraction = (year: number): number => { + if (year <= anchors[0]) return 0; + if (year >= anchors[segments]) return 1; + let i = 0; + while (i < segments - 1 && year >= anchors[i + 1]) i++; + const local = (year - anchors[i]) / (anchors[i + 1] - anchors[i]); + return (i + local) / segments; + }; + + const toYear = (fraction: number): number => { + const scaled = clamp01(fraction) * segments; + const i = Math.min(Math.floor(scaled), segments - 1); + const local = scaled - i; + return anchors[i] + local * (anchors[i + 1] - anchors[i]); + }; + + return { anchors, toFraction, toYear }; +}