From 0eaec1f88f6459100185d202e5ea71dab0cacb74 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Sat, 12 Jul 2025 18:55:30 +0200 Subject: [PATCH] refactor: timeline style tweaks --- .../hooks/useHorizontalFollowComponent.ts | 77 +++++++ apps/client/src/viewerConfig.ts | 2 +- .../src/views/timeline/Timeline.module.scss | 19 +- apps/client/src/views/timeline/Timeline.tsx | 127 ++++++----- .../src/views/timeline/TimelineEntry.tsx | 11 +- .../src/views/timeline/TimelinePage.scss | 23 +- .../src/views/timeline/TimelinePage.tsx | 5 +- .../timeline/__tests__/timeline.utils.test.ts | 210 +++++++++++------- .../timeline-section/TimelineSection.tsx | 4 +- .../src/views/timeline/timeline.options.ts | 37 +++ .../src/views/timeline/timeline.utils.ts | 72 +++++- apps/electron/src/menu/applicationMenu.js | 2 +- 12 files changed, 427 insertions(+), 162 deletions(-) create mode 100644 apps/client/src/common/hooks/useHorizontalFollowComponent.ts diff --git a/apps/client/src/common/hooks/useHorizontalFollowComponent.ts b/apps/client/src/common/hooks/useHorizontalFollowComponent.ts new file mode 100644 index 000000000..77888cbe6 --- /dev/null +++ b/apps/client/src/common/hooks/useHorizontalFollowComponent.ts @@ -0,0 +1,77 @@ +import { RefObject, useCallback, useEffect } from 'react'; + +function scrollToComponent( + componentRef: RefObject | null, + scrollRef: RefObject, + 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; + scrollRef: RefObject; + 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) : null, + scrollRef as RefObject, + 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) : null, + containerRef as RefObject, + offset, + ); + } + }, + [followRef, scrollRef, hasSelectedElement, leftOffset], + ); + + return scrollToRefComponent; +} diff --git a/apps/client/src/viewerConfig.ts b/apps/client/src/viewerConfig.ts index 45da1714d..88388853b 100644 --- a/apps/client/src/viewerConfig.ts +++ b/apps/client/src/viewerConfig.ts @@ -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' }, diff --git a/apps/client/src/views/timeline/Timeline.module.scss b/apps/client/src/views/timeline/Timeline.module.scss index f3f450eed..10916c54d 100644 --- a/apps/client/src/views/timeline/Timeline.module.scss +++ b/apps/client/src/views/timeline/Timeline.module.scss @@ -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 diff --git a/apps/client/src/views/timeline/Timeline.tsx b/apps/client/src/views/timeline/Timeline.tsx index 782ffbdcd..f5562d527 100644 --- a/apps/client/src/views/timeline/Timeline.tsx +++ b/apps/client/src/views/timeline/Timeline.tsx @@ -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(null); + const scrollContainerRef = useRef(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 = {}; + rundown.forEach((event) => { + if (isOntimeEvent(event) && isPlayableEvent(event)) { + if (currentStatus === 'live') { + currentStatus = 'future'; + } + if (event.id === selectedEventId) { + currentStatus = 'live'; + } + statusMap[event.id] = currentStatus; + } + }); return ( -
- - {rundown.map((event) => { - // for now we dont render delays and blocks - if (!isOntimeEvent(event) || !isPlayableEvent(event)) { - return null; - } +
+
+ + {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 ( - - ); - })} + return ( + + ); + })} +
); } diff --git a/apps/client/src/views/timeline/TimelineEntry.tsx b/apps/client/src/views/timeline/TimelineEntry.tsx index fbb2346e9..d0006e8f4 100644 --- a/apps/client/src/views/timeline/TimelineEntry.tsx +++ b/apps/client/src/views/timeline/TimelineEntry.tsx @@ -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; } 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 (
{general?.logo && } - {general.title} +
{general.title}
{getLocalizedString('common.time_now')}
diff --git a/apps/client/src/views/timeline/__tests__/timeline.utils.test.ts b/apps/client/src/views/timeline/__tests__/timeline.utils.test.ts index 1143b95d4..dc93d0657 100644 --- a/apps/client/src/views/timeline/__tests__/timeline.utils.test.ts +++ b/apps/client/src/views/timeline/__tests__/timeline.utils.test.ts @@ -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); }); }); diff --git a/apps/client/src/views/timeline/timeline-section/TimelineSection.tsx b/apps/client/src/views/timeline/timeline-section/TimelineSection.tsx index 2bdeac5ad..5caa87910 100644 --- a/apps/client/src/views/timeline/timeline-section/TimelineSection.tsx +++ b/apps/client/src/views/timeline/timeline-section/TimelineSection.tsx @@ -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 ( diff --git a/apps/client/src/views/timeline/timeline.options.ts b/apps/client/src/views/timeline/timeline.options.ts index cd454f4a3..d6957f8f1 100644 --- a/apps/client/src/views/timeline/timeline.options.ts +++ b/apps/client/src/views/timeline/timeline.options.ts @@ -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; +} diff --git a/apps/client/src/views/timeline/timeline.utils.ts b/apps/client/src/views/timeline/timeline.utils.ts index 6f50c794c..d63bfe950 100644 --- a/apps/client/src/views/timeline/timeline.utils.ts +++ b/apps/client/src/views/timeline/timeline.utils.ts @@ -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, + }; +} diff --git a/apps/electron/src/menu/applicationMenu.js b/apps/electron/src/menu/applicationMenu.js index 01186e795..e23878f97 100644 --- a/apps/electron/src/menu/applicationMenu.js +++ b/apps/electron/src/menu/applicationMenu.js @@ -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`),