Thirteen px/day opened the board on about a quarter of calendar, which is more time than these schedules are written in: a patch is six weeks, so most bars were short enough that their length stopped reading as a duration and their title no longer fit inside the bar it named. Thirty- two opens on roughly one patch cycle on a laptop. Only new readers move. `prefs` is written on every load, so anyone who has opened the app has a timelineDayWidth of their own and keeps it. A phone now shows about twelve days rather than thirty, which is the cost; the longer view is one press of − away and that answer is remembered. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
72 lines
3.1 KiB
TypeScript
72 lines
3.1 KiB
TypeScript
/**
|
||
* 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.
|
||
*
|
||
* Two steps up from where it used to sit. Thirteen px/day opened on about a
|
||
* quarter of calendar, and a quarter is more time than these schedules are
|
||
* written in: a patch is six weeks, so most bars were short enough that the
|
||
* length stopped reading as a duration and the title stopped fitting inside the
|
||
* bar it belonged to. Thirty-two opens on roughly a patch cycle on a laptop —
|
||
* bars long enough to compare by eye, and wide enough to carry their own name.
|
||
* The longer view is one press of − away, and that answer is remembered.
|
||
*
|
||
* This moves new readers only: `prefs` is written on every load, so anyone who
|
||
* has opened the app has a `timelineDayWidth` of their own and keeps it.
|
||
*/
|
||
export const DEFAULT_DAY_WIDTH = 32;
|
||
|
||
/**
|
||
* 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)));
|
||
}
|