mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-07 00:13:53 +00:00
refactor: timeline style tweaks
This commit is contained in:
committed by
Carlos Valente
parent
cdd021e76d
commit
0eaec1f88f
@@ -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,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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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`),
|
||||
|
||||
Reference in New Issue
Block a user