when-am-i / files / src / utils / yearScale.ts
yearScale.ts
| 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 | } |