fuzzyworld

when-am-i / commit

Rework the year timeline: piecewise key-date axis with changeover lines and clickable labels

bedc06094e6fbab9bc610818047100a121922426 · Pavel Safronov · 2026-06-23 06:32:09 -0700 · parents cedd4e9ce0ff

Rework the year timeline: piecewise key-date axis with changeover lines and clickable labels

The linear year axis crammed every era boundary into the right edge
(most of the dataset's boundaries fall after 1800). Replace it with a
piecewise-linear scale (utils/yearScale.ts) anchored on the picked
region's own era boundaries, each segment given equal width — so a
region's key dates (e.g. Vietnam's 1954 changeover, or Korea's clustered
1896/1897/1907/1910) become evenly-spaced, labelled ticks and even brief
eras stay visible and clickable. Falls back to round epoch years when a
region has no boundaries of its own (landing page, North Sentinel).

Dashed verticals mark only the years where the calendar actually changes
(a visible color boundary), not era-name changes within one calendar.
Outer floor/ceiling labels yield when a nearby key date would collide.
Each axis label is a button that jumps the date to that year. The slider
now runs in position units so its thumb tracks the warped axis.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
 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 };
+}