feat(timeline): let the reader set the scale

One density cannot answer both questions the board is asked. A patch cycle is
six weeks, a login campaign runs for months, and 13px a day is a compromise
between "what am I in the middle of this week?" and "how do the next three
months line up?" that serves neither well.

A pair of controls steps through a ladder of day widths and the choice is
remembered, on the same argument as the view tabs: a reader who has said how
they want to read this should not say it again on the next load.

Two things it had to get right. Zooming holds the middle of the view still —
rescaling around the left edge of a three-month board throws away whatever
they had scrolled to, and re-opening at today would undo the scrolling that
got them there. And the dated ticks thin out as the scale shrinks, because a
week is 42px at the widest setting and the dates would sit on top of each
other; the gridlines stay weekly, since they carry the rhythm rather than the
reading.

The scale is stored as the measurement, not a step number, and read through
`snapDayWidth`. An export written against a different ladder then opens on
something close to what its reader chose, and a corrupt value opens on the
default rather than a board one pixel wide.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-18 04:13:31 +02:00
co-authored by Claude Opus 5
parent d5dbe72e17
commit ba39ad2bce
9 changed files with 302 additions and 27 deletions
+11
View File
@@ -4,6 +4,7 @@ import type { Region } from "../../shared/schema.ts";
import { guessRegion } from "../../shared/time.ts";
import type { SortMode } from "./sort.ts";
import { KEYS, readJson, writeJson } from "./storage.ts";
import { DEFAULT_DAY_WIDTH } from "./zoom.ts";
/**
* Which of the two views the reader is looking at.
@@ -62,6 +63,15 @@ export interface Prefs {
* them and the choice is remembered from then on.
*/
view: View;
/**
* How wide one day is on the timeline, in px.
*
* Stored as the measurement rather than a step number, so the ladder in
* `state/zoom.ts` can change without silently rescaling boards that were set
* before it did. Read through `snapDayWidth`, which is what makes a value
* from an older export — or a corrupted one — land on something renderable.
*/
timelineDayWidth: number;
/**
* Whether to guess which events repeat daily from what the source printed.
* Off leaves only the ones the reader marked themselves; it never discards a
@@ -88,6 +98,7 @@ function defaults(): Prefs {
focusGame: null,
sort: "ending",
view: "soon",
timelineDayWidth: DEFAULT_DAY_WIDTH,
detectDaily: false,
showCompleted: true,
showIgnored: false,
+64
View File
@@ -0,0 +1,64 @@
/**
* How far the timeline is zoomed in, expressed as the width of one day.
*
* A patch cycle is six weeks and a login campaign can run for months, so no
* single scale answers both "what am I in the middle of this week?" and "how do
* the next three months line up?". The reader picks.
*
* Pure, and its own module rather than a constant inside `Timeline`, because
* `prefs` stores the chosen value and the two must agree on what is valid.
*/
/** The ladder, in px per day. Roughly a third wider at each step. */
export const DAY_WIDTHS = [6, 9, 13, 20, 32, 48] as const;
/**
* The scale the board opens at for a reader who has never touched the control.
*
* Thirteen px/day is a little over a quarter on a laptop and a patch cycle on a
* phone — dense enough that a bar's length reads as a duration rather than a
* dash, wide enough that most event names fit inside their own bar.
*/
export const DEFAULT_DAY_WIDTH = 13;
/**
* The nearest valid scale to a stored number.
*
* `prefs` is a file on someone's device that an export/import round trip can
* carry between versions, so the ladder is allowed to change and a value off
* it must not render a board one pixel wide. Anything unusable falls back to
* the default rather than to the nearest edge — a corrupt value is not a
* preference.
*/
export function snapDayWidth(px: number): number {
if (!Number.isFinite(px) || px <= 0) return DEFAULT_DAY_WIDTH;
// `<=` over an ascending ladder means a value sitting exactly between two
// steps takes the wider one. Ties go to the more legible board.
return DAY_WIDTHS.reduce((best, step) =>
Math.abs(step - px) <= Math.abs(best - px) ? step : best,
);
}
/** One step in or out, stopping at the ends of the ladder. */
export function stepDayWidth(px: number, by: 1 | -1): number {
const at = DAY_WIDTHS.indexOf(snapDayWidth(px) as (typeof DAY_WIDTHS)[number]);
return DAY_WIDTHS[Math.min(Math.max(at + by, 0), DAY_WIDTHS.length - 1)] ?? DEFAULT_DAY_WIDTH;
}
/** Whether there is anywhere further to go in that direction. */
export function canStep(px: number, by: 1 | -1): boolean {
return stepDayWidth(px, by) !== snapDayWidth(px);
}
/**
* How many weeks apart the dated ticks on the axis are.
*
* Every Monday is right at the default scale and unreadable at the widest zoom
* out, where a week is 42px and the labels would sit on top of each other. The
* gridlines stay weekly either way — they are hairlines and they carry the
* rhythm; it is only the dates that have to thin out.
*/
export function weekLabelStep(dayWidth: number): number {
const MIN_LABEL_GAP = 64;
return Math.max(1, Math.ceil(MIN_LABEL_GAP / (7 * dayWidth)));
}