100 lines
3.3 KiB
TypeScript
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,
|
|
);
|
|
}
|