fuzzyworld

when-am-i / files / src / utils / yearScale.ts

yearScale.ts

Blame: the commit that last changed each line · file · history

bedc0609 2026-06-23 Pavel Safronov1// 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.
25const EPOCH_ANCHORS = [1500, 1800, 1900];
26
27export 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
36function 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 */
48export 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}