trainlog/web/src/routes/sleepTimelineGeometry.ts

100 lines
3.3 KiB
TypeScript

export const SLEEP_TIMELINE_DURATION_MS = 24 * 60 * 60 * 1000;
export interface TimelineTick {
timestamp: number;
position: number;
}
export function timelinePosition(
timestamp: string | number,
start: string | number,
end: string | number,
): number {
// INVARIANT: agenda rows, HR samples, factual overlays, estimates, and hour
// ticks all use this normalized projection. Presentation code may scale the
// result but must not introduce event-specific pixel offsets.
const eventTime =
typeof timestamp === "number" ? timestamp : new Date(timestamp).getTime();
const startTime = typeof start === "number" ? start : new Date(start).getTime();
const endTime = typeof end === "number" ? end : new Date(end).getTime();
const duration = endTime - startTime;
if (
!Number.isFinite(eventTime) ||
!Number.isFinite(startTime) ||
!Number.isFinite(endTime) ||
duration <= 0
) {
return 0;
}
return Math.max(0, Math.min(1, (eventTime - startTime) / duration));
}
export function timelineInterval(
intervalStart: string | number,
intervalEnd: string | number,
timelineStart: string | number,
timelineEnd: string | number,
): { left: number; width: number } {
const left = timelinePosition(intervalStart, timelineStart, timelineEnd);
const right = timelinePosition(intervalEnd, timelineStart, timelineEnd);
return { left, width: Math.max(0, right - left) };
}
export function hourlyTimelineTicks(start: number, end: number): TimelineTick[] {
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return [];
const firstHour = new Date(start);
firstHour.setMinutes(0, 0, 0);
firstHour.setHours(firstHour.getHours() + 1);
const ticks: TimelineTick[] = [];
// WHY: advance in elapsed hours so ticks remain monotonic through offset
// changes; start/end still retain their exact factual positions.
for (
let timestamp = firstHour.getTime();
timestamp < end;
timestamp += 60 * 60 * 1000
) {
ticks.push({ timestamp, position: timelinePosition(timestamp, start, end) });
}
return ticks;
}
export function sleepTimelinePosition(
timestamp: string,
nightStartDate: string,
timelineOffsetSource = timestamp,
): number {
const timelineOffset =
timelineOffsetSource.match(/(Z|[+-]\d{2}:\d{2})$/)?.[1] ?? "";
// CONTRACT: the visual day is exactly 24 elapsed hours beginning at local
// 18:00 in the event timestamp's own offset. Ticks and every event consumer
// call this projection, so text columns can never affect temporal geometry.
const timelineStart = new Date(
`${nightStartDate}T18:00:00${timelineOffset}`,
).getTime();
const eventTime = new Date(timestamp).getTime();
if (!Number.isFinite(timelineStart) || !Number.isFinite(eventTime)) return 0;
return timelinePosition(
eventTime,
timelineStart,
timelineStart + SLEEP_TIMELINE_DURATION_MS,
);
}
export function sleepTimelineInterval(
startAt: string,
endAt: string,
nightStartDate: string,
timelineOffsetSource = startAt,
): { left: number; width: number } {
const timelineOffset =
timelineOffsetSource.match(/(Z|[+-]\d{2}:\d{2})$/)?.[1] ?? "";
const timelineStart = new Date(
`${nightStartDate}T18:00:00${timelineOffset}`,
).getTime();
return timelineInterval(
startAt,
endAt,
timelineStart,
timelineStart + SLEEP_TIMELINE_DURATION_MS,
);
}