refactor: timeline style tweaks

This commit is contained in:
Carlos Valente
2025-07-12 18:55:30 +02:00
committed by Carlos Valente
parent cdd021e76d
commit 0eaec1f88f
12 changed files with 427 additions and 162 deletions
@@ -0,0 +1,77 @@
import { RefObject, useCallback, useEffect } from 'react';
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
componentRef: RefObject<ComponentRef> | null,
scrollRef: RefObject<ScrollRef>,
leftOffset: number,
) {
if (!scrollRef.current) {
return;
}
if (!componentRef?.current) {
// If no target component, scroll to start
scrollRef.current.scrollTo({ left: 0, behavior: 'smooth' });
return;
}
const componentRect = componentRef.current.getBoundingClientRect();
const scrollRect = scrollRef.current.getBoundingClientRect();
const left = componentRect.left - scrollRect.left + scrollRef.current.scrollLeft - leftOffset;
scrollRef.current.scrollTo({ left, behavior: 'smooth' });
}
interface UseHorizontalFollowComponentOptions {
followRef: RefObject<HTMLElement | null>;
scrollRef: RefObject<HTMLElement | null>;
doFollow: boolean;
hasSelectedElement?: boolean;
leftOffset?: number;
setScrollFlag?: (newValue: boolean) => void;
}
/**
* This is a copy of useFollowComponent, but for horizontal scrolling
* Designed with the timeline component in mind
*/
export default function useHorizontalFollowComponent({
followRef,
scrollRef,
doFollow,
hasSelectedElement,
leftOffset = 0,
setScrollFlag,
}: UseHorizontalFollowComponentOptions) {
useEffect(() => {
if (!doFollow || !scrollRef.current) {
return;
}
setScrollFlag?.(true);
// Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => {
scrollToComponent(
hasSelectedElement ? (followRef as RefObject<HTMLElement>) : null,
scrollRef as RefObject<HTMLElement>,
leftOffset,
);
setScrollFlag?.(false);
});
}, [followRef, scrollRef, doFollow, hasSelectedElement, leftOffset, setScrollFlag]);
const scrollToRefComponent = useCallback(
(componentRef = followRef, containerRef = scrollRef, offset = leftOffset) => {
if (containerRef.current) {
scrollToComponent(
hasSelectedElement ? (componentRef as RefObject<HTMLElement>) : null,
containerRef as RefObject<HTMLElement>,
offset,
);
}
},
[followRef, scrollRef, hasSelectedElement, leftOffset],
);
return scrollToRefComponent;
}
+1 -1
View File
@@ -1,7 +1,7 @@
export const navigatorConstants = [
{ url: 'timer', label: 'Timer' },
{ url: 'backstage', label: 'Backstage' },
{ url: 'timeline', label: 'Timeline (beta)' },
{ url: 'timeline', label: 'Timeline' },
{ url: 'studio', label: 'Studio Clock' },
{ url: 'countdown', label: 'Countdown' },
{ url: 'info', label: 'Project Info' },
@@ -3,15 +3,21 @@
$timeline-height: 1rem;
$timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-override, $viewer-background-color));
.timeline {
.timelineContainer {
flex: 1;
font-weight: 600;
color: var(--color-override, $viewer-color);
background: var(--background-color-override, $viewer-background-color);
box-sizing: content-box;
// create progress background
box-shadow: inset 0 1rem 0 0 var(--card-background-color-override, $viewer-card-bg-color);
&.scroll {
overflow-x: scroll;
}
}
.timeline {
position: relative;
font-weight: 600;
height: 100%;
box-shadow: inset 0 1rem 0 0 var(--card-background-color-override, $viewer-card-bg-color);
}
.column {
@@ -19,8 +25,7 @@ $timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-over
flex-direction: column;
position: absolute;
border-left: 1px solid var(--background-color-override, $viewer-background-color);
// avoiding content being larger than the view
height: calc(100% - 3rem);
height: 100%;
}
// generate combined timeline
+77 -50
View File
@@ -1,10 +1,14 @@
import { memo } from 'react';
import { memo, useMemo, useRef } from 'react';
import { useViewportSize } from '@mantine/hooks';
import { isOntimeEvent, isPlayableEvent, OntimeEntry } from 'ontime-types';
import { isOntimeEvent, isPlayableEvent, OntimeEntry, PlayableEvent } from 'ontime-types';
import { dayInMs, getLastEvent, MILLIS_PER_HOUR } from 'ontime-utils';
import useHorizontalFollowComponent from '../../common/hooks/useHorizontalFollowComponent';
import { cx } from '../../common/utils/styleUtils';
import TimelineMarkers from './timeline-markers/TimelineMarkers';
import { getElementPosition, getEndHour, getStartHour } from './timeline.utils';
import { useTimelineOptions } from './timeline.options';
import { calculateTimelineLayout, getEndHour, getStartHour } from './timeline.utils';
import { ProgressStatus, TimelineEntry } from './TimelineEntry';
import style from './Timeline.module.scss';
@@ -17,63 +21,86 @@ interface TimelineProps {
}
export default memo(Timeline);
function Timeline(props: TimelineProps) {
const { firstStart, rundown, selectedEventId, totalDuration } = props;
function Timeline({ firstStart, rundown, selectedEventId, totalDuration }: TimelineProps) {
const { width: screenWidth } = useViewportSize();
const { hidePast, autosize } = useTimelineOptions();
const selectedRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const { lastEvent } = getLastEvent(rundown);
const startHour = getStartHour(firstStart);
const endHour = getEndHour(firstStart + totalDuration + (lastEvent?.delay ?? 0));
const scheduleStart = startHour * MILLIS_PER_HOUR;
const scheduleEnd = endHour * MILLIS_PER_HOUR;
// use horizontal follow when scroll is enabled
useHorizontalFollowComponent({
followRef: selectedRef,
scrollRef: scrollContainerRef,
doFollow: autosize,
hasSelectedElement: selectedEventId !== null,
// No offset when hiding past events to ensure content starts at 0
leftOffset: hidePast ? 0 : screenWidth / 6,
});
const { positions, totalWidth } = useMemo(() => {
const playableEvents = rundown
.filter((event): event is PlayableEvent => isOntimeEvent(event) && isPlayableEvent(event))
.map((event) => ({
start: event.timeStart + (event.dayOffset ?? 0) * dayInMs + (event.delay ?? 0),
duration: event.duration,
}));
return calculateTimelineLayout(playableEvents, scheduleStart, scheduleEnd, screenWidth, autosize);
}, [rundown, scheduleStart, scheduleEnd, screenWidth, autosize]);
if (totalDuration === 0) {
return null;
}
const { lastEvent } = getLastEvent(rundown);
const startHour = getStartHour(firstStart);
const endHour = getEndHour(firstStart + totalDuration + (lastEvent?.delay ?? 0));
// we use selectedEventId as a signifier on whether the timeline is live
let eventStatus: ProgressStatus = selectedEventId ? 'done' : 'future';
// Pre-calculate event statuses
let currentStatus: ProgressStatus = selectedEventId ? 'done' : 'future';
const statusMap: Record<string, ProgressStatus> = {};
rundown.forEach((event) => {
if (isOntimeEvent(event) && isPlayableEvent(event)) {
if (currentStatus === 'live') {
currentStatus = 'future';
}
if (event.id === selectedEventId) {
currentStatus = 'live';
}
statusMap[event.id] = currentStatus;
}
});
return (
<div className={style.timeline}>
<TimelineMarkers startHour={startHour} endHour={endHour} />
{rundown.map((event) => {
// for now we dont render delays and blocks
if (!isOntimeEvent(event) || !isPlayableEvent(event)) {
return null;
}
<div ref={scrollContainerRef} className={cx([style.timelineContainer, autosize && style.scroll])}>
<div className={style.timeline} style={{ width: totalWidth }}>
<TimelineMarkers startHour={startHour} endHour={endHour} />
{rundown.map((event, index) => {
if (!isOntimeEvent(event) || !isPlayableEvent(event)) {
return null;
}
// keep track of progress of rundown
if (eventStatus === 'live') {
eventStatus = 'future';
}
if (event.id === selectedEventId) {
eventStatus = 'live';
}
const position = positions[index];
if (!position) return null;
const normalisedStart = event.timeStart + event.dayOffset * dayInMs;
const { left: elementLeftPosition, width: elementWidth } = getElementPosition(
startHour * MILLIS_PER_HOUR,
endHour * MILLIS_PER_HOUR,
normalisedStart + (event.delay ?? 0),
event.duration,
screenWidth,
);
return (
<TimelineEntry
key={event.id}
colour={event.colour}
delay={event.delay ?? 0}
duration={event.duration}
left={elementLeftPosition}
status={eventStatus}
start={normalisedStart} // dataset solves issues related to crossing midnight
title={event.title}
width={elementWidth}
/>
);
})}
return (
<TimelineEntry
key={event.id}
ref={event.id === selectedEventId ? selectedRef : undefined}
colour={event.colour}
delay={event.delay ?? 0}
duration={event.duration}
left={position.left}
status={statusMap[event.id]}
start={event.timeStart + (event.dayOffset ?? 0) * dayInMs}
title={event.title}
width={position.width}
/>
);
})}
</div>
</div>
);
}
@@ -1,3 +1,5 @@
import { RefObject } from 'react';
import { useTimelineStatus, useTimer } from '../../common/hooks/useSocket';
import { getProgress } from '../../common/utils/getProgress';
import { alpha, cx } from '../../common/utils/styleUtils';
@@ -19,6 +21,7 @@ interface TimelineEntryProps {
start: number;
title: string;
width: number;
ref?: RefObject<HTMLDivElement | null>;
}
const formatOptions = {
@@ -26,9 +29,7 @@ const formatOptions = {
format24: 'HH:mm',
};
export function TimelineEntry(props: TimelineEntryProps) {
const { colour, delay, duration, left, status, start, title, width } = props;
export function TimelineEntry({ colour, delay, duration, left, status, start, title, width, ref }: TimelineEntryProps) {
const formattedStartTime = formatTime(start, formatOptions);
const formattedDuration = formatDuration(duration);
const delayedStart = start + delay;
@@ -41,6 +42,7 @@ export function TimelineEntry(props: TimelineEntryProps) {
return (
<div
ref={ref}
className={columnClasses}
style={{
'--color': colour,
@@ -80,8 +82,7 @@ interface TimelineEntryStatusProps {
}
// extract component to isolate re-renders provoked by the clock changes
function TimelineEntryStatus(props: TimelineEntryStatusProps) {
const { delay, start, status } = props;
function TimelineEntryStatus({ delay, start, status }: TimelineEntryStatusProps) {
const { clock, offset } = useTimelineStatus();
const { getLocalizedString } = useTranslation();
@@ -1,9 +1,11 @@
@use '@/theme/viewerDefs' as *;
.timeline {
width: 100vw;
margin: 0;
box-sizing: border-box; /* reset */
overflow: hidden;
width: 100%; /* restrict the page width to viewport */
height: 100vh;
padding-top: 0.5rem;
font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, $viewer-background-color);
@@ -11,11 +13,11 @@
display: flex;
flex-direction: column;
gap: 2rem;
gap: $view-element-gap;
.project-header {
padding-inline: 2rem;
font-size: clamp(32px, 4.5vw, 64px);
padding: $view-outer-padding;
font-size: $header-font-size;
font-weight: 600;
display: flex;
gap: 1rem;
@@ -25,19 +27,22 @@
max-width: min(200px, 20vw);
}
.title {
line-height: 1.1em;
}
.clock-container {
margin-left: auto;
font-weight: 600;
.label {
font-size: clamp(16px, 1.5vw, 24px);
font-weight: 600;
font-size: $timer-label-size;
color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase;
}
.time {
font-size: clamp(32px, 3.5vw, 50px);
font-weight: 600;
font-size: $timer-value-size;
color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em;
line-height: 0.95em;
@@ -30,8 +30,7 @@ interface TimelinePageProps {
* which refreshes at least once a second
* There is little point splitting or memoising top level elements
*/
export default function TimelinePage(props: TimelinePageProps) {
const { events, general, runtime, selectedId, settings, time } = props;
export default function TimelinePage({ events, general, runtime, selectedId, settings, time }: TimelinePageProps) {
// holds copy of the rundown with only relevant events
const { scopedRundown, firstStart, totalDuration } = useScopedRundown(events, selectedId);
const { getLocalizedString } = useTranslation();
@@ -76,7 +75,7 @@ export default function TimelinePage(props: TimelinePageProps) {
<ViewParamsEditor viewOptions={progressOptions} />
<div className='project-header'>
{general?.logo && <ViewLogo name={general.logo} className='logo' />}
{general.title}
<div className='title'>{general.title}</div>
<div className='clock-container'>
<div className='label'>{getLocalizedString('common.time_now')}</div>
<SuperscriptTime time={clock} className='time' />
@@ -1,92 +1,150 @@
import { dayInMs } from 'ontime-utils';
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { getElementPosition, getTimeToStart, makeTimelineSections } from '../timeline.utils';
import { calculateTimelineLayout, getElementPosition } from '../timeline.utils';
describe('getCSSPosition()', () => {
it('accounts for rundown with one event', () => {
const scheduleStart = 0;
const scheduleEnd = dayInMs;
const eventStart = 0;
const eventDuration = dayInMs;
const containerWidth = 100;
describe('getElementPosition()', () => {
const scheduleStart = 8 * MILLIS_PER_HOUR; // 8:00
const scheduleEnd = 12 * MILLIS_PER_HOUR; // 12:00
const containerWidth = 1000;
it('calculates proportional positions correctly', () => {
const eventStart = 9 * MILLIS_PER_HOUR; // 9:00
const eventDuration = MILLIS_PER_HOUR; // 1 hour duration
const result = getElementPosition(scheduleStart, scheduleEnd, eventStart, eventDuration, containerWidth);
expect(result.left).toBe(0);
expect(result.width).toBe(containerWidth);
// In a 4-hour window (1000px), 1 hour should take up 250px
// Event starts 1 hour after schedule start, so left should be 250px
expect(result.left).toBe(250);
expect(result.width).toBe(250);
});
it('accounts for an event that starts halfway and ends at end', () => {
const scheduleStart = 0;
const scheduleEnd = 100;
const eventStart = 50;
const eventDuration = 50;
const containerWidth = 100;
it('calculates small durations correctly', () => {
const eventStart = 9 * MILLIS_PER_HOUR;
const eventDuration = MILLIS_PER_HOUR / 60; // 1 minute duration
const result = getElementPosition(scheduleStart, scheduleEnd, eventStart, eventDuration, containerWidth);
expect(result.left).toBe(50);
expect(result.width).toBe(50);
// In a 4-hour window, 1 minute should be proportionally small
const expectedWidth = (eventDuration * containerWidth) / (scheduleEnd - scheduleStart);
expect(result.width).toBe(expectedWidth);
});
it('accounts for an event that starts first and ends halfway', () => {
const scheduleStart = 0;
const scheduleEnd = 100;
const eventStart = 0;
const eventDuration = 50;
const containerWidth = 100;
it('handles events at schedule boundaries correctly', () => {
// Event starts at schedule start
const result1 = getElementPosition(scheduleStart, scheduleEnd, scheduleStart, MILLIS_PER_HOUR, containerWidth);
expect(result1.left).toBe(0);
expect(result1.width).toBe(250);
const result = getElementPosition(scheduleStart, scheduleEnd, eventStart, eventDuration, containerWidth);
expect(result.left).toBe(0);
expect(result.width).toBe(50);
});
it('accounts for an event that is in the middle of the rundown', () => {
const scheduleStart = 7;
const scheduleEnd = 23;
const eventStart = 10;
const eventDuration = 1;
const containerWidth = 1000;
// 16 hour event, this gives 62.5px per hour
const result = getElementPosition(scheduleStart, scheduleEnd, eventStart, eventDuration, containerWidth);
expect(result.left).toBe(187.5); // 3 * 62.5
expect(result.width).toBe(62.5);
// Event ends at schedule end
const result2 = getElementPosition(
scheduleStart,
scheduleEnd,
scheduleEnd - MILLIS_PER_HOUR,
MILLIS_PER_HOUR,
containerWidth,
);
expect(result2.left).toBe(750);
expect(result2.width).toBe(250);
});
});
describe('makeTimelineSections', () => {
it('creates an array between the hours given, end excluded', () => {
const result = makeTimelineSections(11, 17);
expect(result).toEqual([11, 12, 13, 14, 15, 16]);
});
});
describe('getTimeToStart()', () => {
it("is the gap between now and the event's start time accounted for delays", () => {
const now = 150;
const start = 150;
const delay = 50;
const result = getTimeToStart(now, start, delay, 0);
expect(result).toBe(50);
});
it('accounts for offsets when running behind', () => {
const now = 150;
const start = 150;
const delay = 50;
const offset = -50; // running behind
const result = getTimeToStart(now, start, delay, offset);
expect(result).toBe(50 + 50);
});
it('accounts for offsets when running ahead', () => {
const now = 150;
const start = 150;
const delay = 50;
const offset = 10; // running behind
const result = getTimeToStart(now, start, delay, offset);
expect(result).toBe(50 - 10);
describe('calculateTimelineLayout()', () => {
const scheduleStart = 8 * MILLIS_PER_HOUR; // 8:00
const scheduleEnd = 12 * MILLIS_PER_HOUR; // 12:00
const containerWidth = 1000;
const MIN_WIDTH = 50;
it('returns original positions when no scaling is needed', () => {
const events = [
{ start: 9 * MILLIS_PER_HOUR, duration: MILLIS_PER_HOUR }, // 1-hour event
{ start: 10 * MILLIS_PER_HOUR, duration: MILLIS_PER_HOUR }, // Another 1-hour event
];
const result = calculateTimelineLayout(events, scheduleStart, scheduleEnd, containerWidth, true, MIN_WIDTH);
expect(result.scale).toBe(1);
expect(result.totalWidth).toBe(containerWidth);
expect(result.positions[0].width).toBe(250); // 1 hour = 250px in a 1000px/4hr window
expect(result.positions[1].width).toBe(250);
});
it('scales positions when events are smaller than minimum width', () => {
const events = [
{ start: 9 * MILLIS_PER_HOUR, duration: MILLIS_PER_HOUR / 60 }, // 1-minute event
{ start: 10 * MILLIS_PER_HOUR, duration: MILLIS_PER_HOUR }, // 1-hour event
];
const result = calculateTimelineLayout(events, scheduleStart, scheduleEnd, containerWidth, true, MIN_WIDTH);
// Scale should be calculated to make the 1-minute event MIN_WIDTH
const baseWidth = (events[0].duration * containerWidth) / (scheduleEnd - scheduleStart);
const expectedScale = MIN_WIDTH / baseWidth;
expect(result.scale).toBe(expectedScale);
expect(result.positions[0].width).toBe(MIN_WIDTH);
expect(result.totalWidth).toBe(containerWidth * expectedScale);
});
it('maintains relative proportions when scaling', () => {
const events = [
{ start: 9 * MILLIS_PER_HOUR, duration: MILLIS_PER_HOUR / 60 }, // 1-minute event
{ start: 10 * MILLIS_PER_HOUR, duration: MILLIS_PER_HOUR }, // 1-hour event
];
const result = calculateTimelineLayout(events, scheduleStart, scheduleEnd, containerWidth, true, MIN_WIDTH);
// Ratio between 1 hour and 1 minute should be maintained
expect(result.positions[1].width / result.positions[0].width).toBeCloseTo(60);
});
it('correctly positions events relative to each other after scaling', () => {
const events = [
{ start: 9 * MILLIS_PER_HOUR, duration: MILLIS_PER_HOUR / 60 }, // 1-minute at 9:00
{ start: 10 * MILLIS_PER_HOUR, duration: MILLIS_PER_HOUR / 60 }, // 1-minute at 10:00
];
const result = calculateTimelineLayout(events, scheduleStart, scheduleEnd, containerWidth, true, MIN_WIDTH);
// Events should maintain their relative spacing after scaling
const hourWidth = result.positions[1].left - result.positions[0].left;
const scaledHourInTimeline = (containerWidth * result.scale) / 4; // 4 hours total
expect(hourWidth).toBeCloseTo(scaledHourInTimeline);
});
it('handles overlapping events correctly', () => {
const events = [
{ start: 9 * MILLIS_PER_HOUR, duration: MILLIS_PER_HOUR * 2 }, // 2-hour event from 9:00 to 11:00
{ start: 10 * MILLIS_PER_HOUR, duration: MILLIS_PER_HOUR }, // 1-hour event from 10:00 to 11:00
];
const result = calculateTimelineLayout(events, scheduleStart, scheduleEnd, containerWidth, true, MIN_WIDTH);
expect(result.positions[0].left).toBe(250); // Starts at 9:00
expect(result.positions[0].width).toBe(500); // 2 hours wide
expect(result.positions[1].left).toBe(500); // Starts at 10:00
expect(result.positions[1].width).toBe(250); // 1 hour wide
});
it('handles empty events array', () => {
const result = calculateTimelineLayout([], scheduleStart, scheduleEnd, containerWidth, true, MIN_WIDTH);
expect(result.scale).toBe(1);
expect(result.totalWidth).toBe(containerWidth);
expect(result.positions).toEqual([]);
});
it('handles events at timeline boundaries', () => {
const events = [
{ start: scheduleStart, duration: MILLIS_PER_HOUR }, // Event at start
{ start: scheduleEnd - MILLIS_PER_HOUR, duration: MILLIS_PER_HOUR }, // Event at end
];
const result = calculateTimelineLayout(events, scheduleStart, scheduleEnd, containerWidth, true, MIN_WIDTH);
expect(result.positions[0].left).toBe(0);
expect(result.positions[1].left).toBe(750);
expect(result.positions[0].width).toBe(250);
expect(result.positions[1].width).toBe(250);
});
});
@@ -12,9 +12,7 @@ interface SectionProps {
export default memo(Section);
function Section(props: SectionProps) {
const { category, content, title, status } = props;
function Section({ category, content, title, status }: SectionProps) {
const sectionClasses = cx(['section', category === 'now' && 'section--now']);
const contentClasses = cx(['section-content', content ? `section-content--${category}` : 'section-content--subdue']);
return (
@@ -1,6 +1,10 @@
import { useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { getTimeOption } from '../../common/components/view-params-editor/common.options';
import { OptionTitle } from '../../common/components/view-params-editor/constants';
import { ViewOption } from '../../common/components/view-params-editor/viewParams.types';
import { isStringBoolean } from '../../features/viewers/common/viewUtils';
export const getTimelineOptions = (timeFormat: string): ViewOption[] => {
return [
@@ -16,7 +20,40 @@ export const getTimelineOptions = (timeFormat: string): ViewOption[] => {
type: 'boolean',
defaultValue: false,
},
{
id: 'autosize',
title: 'Autosize timeline',
description: 'Timeline will adjust sizes to help with readability and automatically scroll if necessary',
type: 'boolean',
defaultValue: false,
},
],
},
];
};
type TimelineOptions = {
hidePast: boolean;
autosize: boolean;
};
/**
* Utility extract the view options from URL Params
* the names and fallback are manually matched with timerOptions
*/
function getOptionsFromParams(searchParams: URLSearchParams): TimelineOptions {
// we manually make an object that matches the key above
return {
hidePast: isStringBoolean(searchParams.get('hidePast')),
autosize: isStringBoolean(searchParams.get('autosize')),
};
}
/**
* Hook exposes the timeline view options
*/
export function useTimelineOptions(): TimelineOptions {
const [searchParams] = useSearchParams();
const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]);
return options;
}
@@ -1,5 +1,4 @@
import { useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { isOntimeEvent, isPlayableEvent, MaybeString, OntimeEntry, OntimeEvent, PlayableEvent } from 'ontime-types';
import {
dayInMs,
@@ -12,8 +11,8 @@ import {
} from 'ontime-utils';
import { formatDuration } from '../../common/utils/time';
import { isStringBoolean } from '../../features/viewers/common/viewUtils';
import { useTimelineOptions } from './timeline.options';
import type { ProgressStatus } from './TimelineEntry';
type CSSPosition = {
@@ -22,7 +21,8 @@ type CSSPosition = {
};
/**
* Calculates an absolute position of an element based on a schedule
* Calculates the base position and width of an element based on schedule
* The scaling of these values (if needed) is handled by calculateTimelineLayout
*/
export function getElementPosition(
scheduleStart: number,
@@ -33,6 +33,8 @@ export function getElementPosition(
): CSSPosition {
const normalEnd = scheduleEnd < scheduleStart ? scheduleEnd + dayInMs : scheduleEnd;
const totalDuration = normalEnd - scheduleStart;
// Calculate proportional width and position
const width = (eventDuration * containerWidth) / totalDuration;
const left = ((eventStart - scheduleStart) * containerWidth) / totalDuration;
@@ -88,15 +90,13 @@ interface ScopedRundownData {
}
export function useScopedRundown(rundown: OntimeEntry[], selectedEventId: MaybeString): ScopedRundownData {
const [searchParams] = useSearchParams();
const { hidePast } = useTimelineOptions();
const data = useMemo(() => {
if (rundown.length === 0) {
return { scopedRundown: [], firstStart: 0, totalDuration: 0 };
}
const hidePast = isStringBoolean(searchParams.get('hidePast'));
const scopedRundown: PlayableEvent[] = [];
let selectedIndex = selectedEventId ? Infinity : -1;
let firstStart = null;
@@ -144,7 +144,7 @@ export function useScopedRundown(rundown: OntimeEntry[], selectedEventId: MaybeS
}
return { scopedRundown, firstStart: firstStart ?? 0, totalDuration };
}, [rundown, searchParams, selectedEventId]);
}, [hidePast, rundown, selectedEventId]);
return data;
}
@@ -185,3 +185,61 @@ export function getUpcomingEvents(events: PlayableEvent[], selectedId: MaybeStri
export function getTimeToStart(now: number, start: number, delay: number, offset: number): number {
return start + delay - now - offset;
}
interface TimelineLayout {
positions: CSSPosition[];
scale: number;
totalWidth: number;
}
/**
* Calculates positions for all events and applies scaling if needed
*/
export function calculateTimelineLayout(
events: Array<{ start: number; duration: number }>,
scheduleStart: number,
scheduleEnd: number,
containerWidth: number,
canScroll: boolean,
minWidth = 100,
): TimelineLayout {
// Calculate positions and track minimum width
let smallestWidth = Infinity;
const positions = events.map(({ start, duration }) => {
const position = getElementPosition(scheduleStart, scheduleEnd, start, duration, containerWidth);
smallestWidth = Math.min(smallestWidth, position.width);
return position;
});
if (!canScroll) {
return {
positions: positions,
scale: 1,
totalWidth: containerWidth,
};
}
// Determine if scaling is needed
const scale = smallestWidth < minWidth ? minWidth / smallestWidth : 1;
// If no scaling is needed, return base positions
if (scale === 1) {
return {
positions,
scale: 1,
totalWidth: containerWidth,
};
}
// Apply scale to all positions
const scaledPositions = positions.map((pos) => ({
left: pos.left * scale,
width: pos.width * scale,
}));
return {
positions: scaledPositions,
scale,
totalWidth: containerWidth * scale,
};
}
+1 -1
View File
@@ -154,7 +154,7 @@ function makeViewMenu(clientUrl) {
{ type: 'separator' },
makeItemOpenInBrowser('Timer', `${clientUrl}/timer`),
makeItemOpenInBrowser('Backstage', `${clientUrl}/backstage`),
makeItemOpenInBrowser('Timeline (beta)', `${clientUrl}/timeline`),
makeItemOpenInBrowser('Timeline', `${clientUrl}/timeline`),
makeItemOpenInBrowser('Studio Clock', `${clientUrl}/studio`),
makeItemOpenInBrowser('Countdown', `${clientUrl}/countdown`),
makeItemOpenInBrowser('Project info', `${clientUrl}/info`),