when-am-i / files / src / utils / yearScale.ts
yearScale.ts
bedc0609 2026-06-23 Pavel Safronov | 1 | // A piecewise-linear scale for the year axis, so the timeline doesn't cram |
| 2 | // every era boundary into the right edge and the *key dates* of the region | |
| 3 | // you're looking at get their own labels. | |
| 4 | // | |
| 5 | // The dataset's era boundaries are wildly skewed toward recent centuries (most | |
| 6 | // fall after 1800), so a *linear* year axis leaves ~70% of its width nearly | |
| 7 | // empty and bunches all the interesting transitions into a sliver on the right. | |
| 8 | // This scale instead breaks the axis at the picked region's actual era | |
| 9 | // boundaries (e.g. Vietnam's 1954 lunisolar→Gregorian changeover) and gives | |
| 10 | // every segment equal screen width. Equal width is what makes *clustered* key | |
| 11 | // dates readable — Korea's 1896/1897/1907/1910 transitions would collide under | |
| 12 | // a proportional scale, but here each gets its own evenly-spaced label and a | |
| 13 | // visible (clickable) band, at the cost of not drawing durations to scale. When | |
| 14 | // a region has no boundaries of its own (the landing page, or a place like | |
| 15 | // North Sentinel Island), it falls back to round epoch years for context. | |
| 16 | // | |
| 17 | // The mapping is a bijection (monotonic piecewise-linear), so it inverts | |
| 18 | // cleanly: the slider drives `toYear` from a position, and bands/ticks are | |
| 19 | // placed with `toFraction` from a year. | |
| 20 | ||
| 21 | // Round epoch years used as interior breakpoints when a region has no era | |
| 22 | // boundaries to anchor on. Only those strictly inside (minYear, maxYear) are | |
| 23 | // used; the floor and ceiling (the data-driven minYear and the current year) | |
| 24 | // are always the outer anchors. | |
| 25 | const EPOCH_ANCHORS = [1500, 1800, 1900]; | |
| 26 | ||
| 27 | export interface YearScale { | |
| 28 | /** Breakpoint years, ascending: [minYear, …interior key dates…, maxYear]. */ | |
| 29 | anchors: number[]; | |
| 30 | /** Year → fraction along the axis in [0, 1] (±Infinity clamp to 0/1). */ | |
| 31 | toFraction: (year: number) => number; | |
| 32 | /** Fraction in [0, 1] → year (inverse of toFraction). */ | |
| 33 | toYear: (fraction: number) => number; | |
| 34 | } | |
| 35 | ||
| 36 | function clamp01(x: number): number { | |
| 37 | return x < 0 ? 0 : x > 1 ? 1 : x; | |
| 38 | } | |
| 39 | ||
| 40 | /** | |
| 41 | * Build a piecewise-linear year scale over [minYear, maxYear], broken at | |
| 42 | * `keyYears` (the picked region's era boundaries) so each is its own anchor — | |
| 43 | * labelled, and with the band edge landing exactly on it. Each segment gets an | |
| 44 | * equal share of the axis width. Years outside (minYear, maxYear), duplicates, | |
| 45 | * and ±Infinity are ignored; if no key years remain, falls back to round epoch | |
| 46 | * anchors so the axis still reads sensibly (landing page, boundary-less regions). | |
| 47 | */ | |
| 48 | export function buildYearScale(minYear: number, maxYear: number, keyYears: number[] = []): YearScale { | |
| 49 | let interior = [...new Set(keyYears)] | |
| 50 | .filter((y) => Number.isFinite(y) && y > minYear && y < maxYear) | |
| 51 | .sort((a, b) => a - b); | |
| 52 | if (interior.length === 0) interior = EPOCH_ANCHORS.filter((y) => y > minYear && y < maxYear); | |
| 53 | ||
| 54 | const anchors = [minYear, ...interior, maxYear]; | |
| 55 | const segments = anchors.length - 1; | |
| 56 | ||
| 57 | const toFraction = (year: number): number => { | |
| 58 | if (year <= anchors[0]) return 0; | |
| 59 | if (year >= anchors[segments]) return 1; | |
| 60 | let i = 0; | |
| 61 | while (i < segments - 1 && year >= anchors[i + 1]) i++; | |
| 62 | const local = (year - anchors[i]) / (anchors[i + 1] - anchors[i]); | |
| 63 | return (i + local) / segments; | |
| 64 | }; | |
| 65 | ||
| 66 | const toYear = (fraction: number): number => { | |
| 67 | const scaled = clamp01(fraction) * segments; | |
| 68 | const i = Math.min(Math.floor(scaled), segments - 1); | |
| 69 | const local = scaled - i; | |
| 70 | return anchors[i] + local * (anchors[i + 1] - anchors[i]); | |
| 71 | }; | |
| 72 | ||
| 73 | return { anchors, toFraction, toYear }; | |
| 74 | } |