mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-10 18:03:47 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e73f2ecb8 | |||
| 8aeb4c7a01 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getontime/cli",
|
"name": "@getontime/cli",
|
||||||
"version": "3.3.2",
|
"version": "3.4.0-alpha-timeline.2",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-ui",
|
"name": "ontime-ui",
|
||||||
"version": "3.3.2",
|
"version": "3.4.0-alpha-timeline.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const Countdown = lazy(() => import('./features/viewers/countdown/Countdown'));
|
|||||||
|
|
||||||
const Backstage = lazy(() => import('./features/viewers/backstage/Backstage'));
|
const Backstage = lazy(() => import('./features/viewers/backstage/Backstage'));
|
||||||
const Public = lazy(() => import('./features/viewers/public/Public'));
|
const Public = lazy(() => import('./features/viewers/public/Public'));
|
||||||
|
const Timeline = lazy(() => import('./features/timeline/TimelinePage'));
|
||||||
const Lower = lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
|
const Lower = lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
|
||||||
const StudioClock = lazy(() => import('./features/viewers/studio/StudioClock'));
|
const StudioClock = lazy(() => import('./features/viewers/studio/StudioClock'));
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ const SClock = withPreset(withData(ClockView));
|
|||||||
const SCountdown = withPreset(withData(Countdown));
|
const SCountdown = withPreset(withData(Countdown));
|
||||||
const SBackstage = withPreset(withData(Backstage));
|
const SBackstage = withPreset(withData(Backstage));
|
||||||
const SPublic = withPreset(withData(Public));
|
const SPublic = withPreset(withData(Public));
|
||||||
|
const STimeline = withPreset(withData(Timeline));
|
||||||
const SLowerThird = withPreset(withData(Lower));
|
const SLowerThird = withPreset(withData(Lower));
|
||||||
const SStudio = withPreset(withData(StudioClock));
|
const SStudio = withPreset(withData(StudioClock));
|
||||||
|
|
||||||
@@ -60,6 +62,8 @@ export default function AppRouter() {
|
|||||||
|
|
||||||
<Route path='/op' element={<Operator />} />
|
<Route path='/op' element={<Operator />} />
|
||||||
|
|
||||||
|
<Route path='/timeline' element={<STimeline />} />
|
||||||
|
|
||||||
{/*/!* Protected Routes *!/*/}
|
{/*/!* Protected Routes *!/*/}
|
||||||
<Route path='/editor' element={<Editor />} />
|
<Route path='/editor' element={<Editor />} />
|
||||||
<Route path='/cuesheet' element={<Cuesheet />} />
|
<Route path='/cuesheet' element={<Cuesheet />} />
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ interface EditFormDrawerProps {
|
|||||||
paramFields: ParamField[];
|
paramFields: ParamField[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: this is a good candidate for memoisation, but needs the paramFields to be stable
|
||||||
export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
|
export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { isOpen, onClose, onOpen } = useDisclosure();
|
const { isOpen, onClose, onOpen } = useDisclosure();
|
||||||
|
|||||||
@@ -509,3 +509,16 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getCountdownOptions = (timeFormat: string): ParamField[] => [getTimeOption(timeFormat), hideTimerSeconds];
|
export const getCountdownOptions = (timeFormat: string): ParamField[] => [getTimeOption(timeFormat), hideTimerSeconds];
|
||||||
|
|
||||||
|
export const getProgressOptions = (timeFormat: string): ParamField[] => {
|
||||||
|
return [
|
||||||
|
getTimeOption(timeFormat),
|
||||||
|
{
|
||||||
|
id: 'fullHeight',
|
||||||
|
title: 'Full height columns',
|
||||||
|
description: 'Whether the columns in the timeline should be the full height ',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|||||||
@@ -24,6 +24,19 @@ export const getAccessibleColour = (bgColour?: string): ColourCombination => {
|
|||||||
return { backgroundColor: '#1a1a1a', color: '#fffffa' };
|
return { backgroundColor: '#1a1a1a', color: '#fffffa' };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds opacity to a given colour, returns undefined if invalid
|
||||||
|
*/
|
||||||
|
export function alpha(colour: string, amount: number): string | undefined {
|
||||||
|
try {
|
||||||
|
const withAlpha = Color(colour).alpha(amount).hexa();
|
||||||
|
return withAlpha;
|
||||||
|
} catch (_error) {
|
||||||
|
/* we do not handle errors here */
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Creates a list of classnames from array of css module conditions
|
* @description Creates a list of classnames from array of css module conditions
|
||||||
* @param classNames - css modules objects
|
* @param classNames - css modules objects
|
||||||
|
|||||||
@@ -95,3 +95,25 @@ export const formatTime = (
|
|||||||
const isNegative = milliseconds < 0;
|
const isNegative = milliseconds < 0;
|
||||||
return `${isNegative ? '-' : ''}${display}`;
|
return `${isNegative ? '-' : ''}${display}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles case for formatting a duration time
|
||||||
|
* @param duration
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export function formatDuration(duration: number): string {
|
||||||
|
if (duration === 0) {
|
||||||
|
return '0h 0m';
|
||||||
|
}
|
||||||
|
|
||||||
|
const hours = Math.floor(duration / 3600000);
|
||||||
|
const minutes = Math.floor((duration % 3600000) / 60000);
|
||||||
|
let result = '';
|
||||||
|
if (hours > 0) {
|
||||||
|
result += `${hours}h `;
|
||||||
|
}
|
||||||
|
if (minutes > 0) {
|
||||||
|
result += `${minutes}m`;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
|
|
||||||
&.running {
|
&.running {
|
||||||
border-top: 1px solid $gray-1300;
|
border-top: 1px solid $gray-1300;
|
||||||
background-color: var(--operator-running-bg-override, $red-700);
|
background-color: var(--operator-running-bg-override, $active-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.past {
|
&.past {
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
$timeline-entry-height: 20px;
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timelineEvents {
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entryColumn {
|
||||||
|
position: absolute;
|
||||||
|
background-color: var(--lighter, $gray-500);
|
||||||
|
border-bottom: 0.25rem solid var(--color, $ui-white);
|
||||||
|
border-right: 1px solid $ui-black;
|
||||||
|
min-height: 80px;
|
||||||
|
|
||||||
|
&.fullHeight {
|
||||||
|
height: calc(100% - 4rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.entryContent {
|
||||||
|
padding-top: var(--top, 0);
|
||||||
|
|
||||||
|
&.lastElement {
|
||||||
|
text-align: right;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.entryText {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
color: $ui-white;
|
||||||
|
padding-inline: 0.5em;
|
||||||
|
width: fit-content;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&.textBg {
|
||||||
|
background-color: var(--bg, $black-10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.start,
|
||||||
|
.title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
// for elapsed events, we can hide some stuff
|
||||||
|
[data-status='finished'] {
|
||||||
|
color: $gray-500;
|
||||||
|
.status {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-status='live'] {
|
||||||
|
font-weight: 600;
|
||||||
|
color: $ui-white;
|
||||||
|
|
||||||
|
.status {
|
||||||
|
color: $active-red;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-status='future'] {
|
||||||
|
font-weight: 600;
|
||||||
|
color: $gray-300;
|
||||||
|
|
||||||
|
.status {
|
||||||
|
color: $green-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { useViewportSize } from '@mantine/hooks';
|
||||||
|
import { isOntimeEvent, MaybeNumber } from 'ontime-types';
|
||||||
|
import { dayInMs, getFirstEventNormal, getLastEventNormal, MILLIS_PER_HOUR } from 'ontime-utils';
|
||||||
|
|
||||||
|
import useRundown from '../../../common/hooks-query/useRundown';
|
||||||
|
import { isStringBoolean } from '../../viewers/common/viewUtils';
|
||||||
|
|
||||||
|
import { type ProgressStatus, TimelineEntry } from './TimelineEntry';
|
||||||
|
import { TimelineMarkers } from './TimelineMarkers';
|
||||||
|
import { ProgressBar } from './TimelineProgressBar';
|
||||||
|
import { getElementPosition, getEndHour, getEstimatedWidth, getLaneLevel, getStartHour } from './timelineUtils';
|
||||||
|
|
||||||
|
import style from './Timeline.module.scss';
|
||||||
|
|
||||||
|
export default memo(Timeline);
|
||||||
|
|
||||||
|
function useTimeline() {
|
||||||
|
const { data } = useRundown();
|
||||||
|
if (data.revision === -1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { firstEvent } = getFirstEventNormal(data.rundown, data.order);
|
||||||
|
const { lastEvent } = getLastEventNormal(data.rundown, data.order);
|
||||||
|
const firstStart = firstEvent?.timeStart ?? 0;
|
||||||
|
const lastEnd = lastEvent?.timeEnd ?? 0;
|
||||||
|
const normalisedLastEnd = lastEnd < firstStart ? lastEnd + dayInMs : lastEnd;
|
||||||
|
|
||||||
|
// timeline is padded to nearest hours (floor and ceil)
|
||||||
|
const startHour = getStartHour(firstStart) * MILLIS_PER_HOUR;
|
||||||
|
const endHour = getEndHour(normalisedLastEnd) * MILLIS_PER_HOUR;
|
||||||
|
|
||||||
|
return {
|
||||||
|
rundown: data.rundown,
|
||||||
|
order: data.order,
|
||||||
|
startHour,
|
||||||
|
endHour,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimelineProps {
|
||||||
|
selectedEventId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Timeline(props: TimelineProps) {
|
||||||
|
const { selectedEventId } = props;
|
||||||
|
const { width: screenWidth } = useViewportSize();
|
||||||
|
const timelineData = useTimeline();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const fullHeight = isStringBoolean(searchParams.get('fullHeight'));
|
||||||
|
|
||||||
|
if (timelineData === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rundown, order, startHour, endHour } = timelineData;
|
||||||
|
|
||||||
|
let hasTimelinePassedMidnight = false;
|
||||||
|
let previousEventStartTime: MaybeNumber = null;
|
||||||
|
let eventStatus: ProgressStatus = 'finished';
|
||||||
|
// a list of the right most element for each lane
|
||||||
|
const rightMostElements: Record<number, number> = {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.timeline}>
|
||||||
|
<TimelineMarkers />
|
||||||
|
<ProgressBar startHour={startHour} endHour={endHour} />
|
||||||
|
<div className={style.timelineEvents}>
|
||||||
|
{order.map((eventId) => {
|
||||||
|
// for now we dont render delays and blocks
|
||||||
|
const event = rundown[eventId];
|
||||||
|
if (!isOntimeEvent(event)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// keep track of progress of rundown
|
||||||
|
if (eventStatus === 'live') {
|
||||||
|
eventStatus = 'future';
|
||||||
|
}
|
||||||
|
if (eventId === selectedEventId) {
|
||||||
|
eventStatus = 'live';
|
||||||
|
}
|
||||||
|
|
||||||
|
// we need to offset the start to account for midnight
|
||||||
|
if (!hasTimelinePassedMidnight) {
|
||||||
|
hasTimelinePassedMidnight = previousEventStartTime !== null && event.timeStart < previousEventStartTime;
|
||||||
|
}
|
||||||
|
const normalisedStart = hasTimelinePassedMidnight ? event.timeStart + dayInMs : event.timeStart;
|
||||||
|
previousEventStartTime = normalisedStart;
|
||||||
|
|
||||||
|
const { left: elementLeftPosition, width: elementWidth } = getElementPosition(
|
||||||
|
startHour,
|
||||||
|
endHour,
|
||||||
|
normalisedStart,
|
||||||
|
event.duration,
|
||||||
|
screenWidth,
|
||||||
|
);
|
||||||
|
const estimatedWidth = getEstimatedWidth(event.title);
|
||||||
|
const estimatedRightPosition = elementLeftPosition + estimatedWidth;
|
||||||
|
const laneLevel = getLaneLevel(rightMostElements, elementLeftPosition);
|
||||||
|
|
||||||
|
if (rightMostElements[laneLevel] === undefined || rightMostElements[laneLevel] < estimatedRightPosition) {
|
||||||
|
rightMostElements[laneLevel] = estimatedRightPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TimelineEntry
|
||||||
|
key={eventId}
|
||||||
|
colour={event.colour}
|
||||||
|
duration={event.duration}
|
||||||
|
isLast={eventId === order[order.length - 1]}
|
||||||
|
lane={laneLevel}
|
||||||
|
left={elementLeftPosition}
|
||||||
|
status={eventStatus}
|
||||||
|
start={event.timeStart}
|
||||||
|
title={event.title}
|
||||||
|
width={elementWidth}
|
||||||
|
mayGrow={elementWidth < estimatedWidth}
|
||||||
|
fullHeight={fullHeight}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { useClock } from '../../../common/hooks/useSocket';
|
||||||
|
import { alpha, cx } from '../../../common/utils/styleUtils';
|
||||||
|
import { formatDuration, formatTime } from '../../../common/utils/time';
|
||||||
|
|
||||||
|
import { getStatusLabel } from './timelineUtils';
|
||||||
|
|
||||||
|
import style from './Timeline.module.scss';
|
||||||
|
|
||||||
|
export type ProgressStatus = 'finished' | 'live' | 'future';
|
||||||
|
|
||||||
|
interface TimelineEntry {
|
||||||
|
colour: string;
|
||||||
|
duration: number;
|
||||||
|
isLast: boolean;
|
||||||
|
lane: number;
|
||||||
|
left: number;
|
||||||
|
status: ProgressStatus;
|
||||||
|
start: number;
|
||||||
|
title: string;
|
||||||
|
width: number;
|
||||||
|
|
||||||
|
mayGrow: boolean;
|
||||||
|
fullHeight: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const laneHeight = 120;
|
||||||
|
const formatOptions = {
|
||||||
|
format12: 'hh:mm a',
|
||||||
|
format24: 'HH:mm',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TimelineEntry(props: TimelineEntry) {
|
||||||
|
const { colour, duration, isLast, lane, left, status, start, title, width, mayGrow, fullHeight } = props;
|
||||||
|
|
||||||
|
const formattedStartTime = formatTime(start, formatOptions);
|
||||||
|
const formattedDuration = formatDuration(duration);
|
||||||
|
|
||||||
|
const lighterColour = alpha(colour, 0.7);
|
||||||
|
const alphaColour = alpha(colour, 0.6);
|
||||||
|
const columnClasses = cx([style.entryColumn, fullHeight && style.fullHeight]);
|
||||||
|
const contentClasses = cx([style.entryContent, isLast && style.lastElement]);
|
||||||
|
const textBgClasses = cx([style.entryText, mayGrow && style.textBg]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={columnClasses}
|
||||||
|
style={{
|
||||||
|
'--color': colour,
|
||||||
|
'--lighter': lighterColour ?? '',
|
||||||
|
left: `${left}px`,
|
||||||
|
width: `${width}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={contentClasses}
|
||||||
|
data-status={status}
|
||||||
|
style={{
|
||||||
|
'--color': colour,
|
||||||
|
'--top': `${lane * laneHeight}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={textBgClasses}
|
||||||
|
style={{
|
||||||
|
'--bg': alphaColour ?? '',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={style.start}>{formattedStartTime}</div>
|
||||||
|
<div className={style.title}>{title}</div>
|
||||||
|
<div className={style.duration}>{formattedDuration}</div>
|
||||||
|
<TimelineEntryStatus status={status} start={start} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimelineEntryStatusProps {
|
||||||
|
status: ProgressStatus;
|
||||||
|
start: number;
|
||||||
|
}
|
||||||
|
// we isolate this component to avoid re-rendering too many elements
|
||||||
|
function TimelineEntryStatus(props: TimelineEntryStatusProps) {
|
||||||
|
const { status, start } = props;
|
||||||
|
// TODO: account for offset instead of just using the clock
|
||||||
|
const { clock } = useClock();
|
||||||
|
const statusText = getStatusLabel(start - clock, status);
|
||||||
|
|
||||||
|
return <div className={style.status}>{statusText}</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
.markers {
|
||||||
|
width: 100%;
|
||||||
|
color: $ui-white;
|
||||||
|
display: flex;
|
||||||
|
height: 1rem;
|
||||||
|
line-height: 1rem;
|
||||||
|
font-size: calc(1rem - 2px);
|
||||||
|
justify-content: space-evenly;
|
||||||
|
|
||||||
|
& > span {
|
||||||
|
flex-grow: 1;
|
||||||
|
border-left: 1px solid black;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import useRundown from '../../../common/hooks-query/useRundown';
|
||||||
|
|
||||||
|
import { getTimelineSections } from './timelineUtils';
|
||||||
|
|
||||||
|
import style from './TimelineMarkers.module.scss';
|
||||||
|
|
||||||
|
export function TimelineMarkers() {
|
||||||
|
const { data } = useRundown();
|
||||||
|
|
||||||
|
if (data.revision === -1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elements = getTimelineSections(data.rundown, data.order);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.markers}>
|
||||||
|
{elements.map((tag, index) => {
|
||||||
|
return <span key={index}>{tag}</span>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
.progressBar {
|
||||||
|
width: 100%;
|
||||||
|
height: 0.75rem;
|
||||||
|
|
||||||
|
transition-duration: 0.3s;
|
||||||
|
transition-property: left;
|
||||||
|
background-color: $gray-900;
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
height: 100%;
|
||||||
|
background-color: $active-red;;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { useClock } from '../../../common/hooks/useSocket';
|
||||||
|
|
||||||
|
import { getRelativePositionX } from './timelineUtils';
|
||||||
|
|
||||||
|
import style from './TimelineProgressBar.module.scss';
|
||||||
|
|
||||||
|
interface ProgressBarProps {
|
||||||
|
startHour: number;
|
||||||
|
endHour: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProgressBar(props: ProgressBarProps) {
|
||||||
|
const { startHour, endHour } = props;
|
||||||
|
const { clock } = useClock();
|
||||||
|
|
||||||
|
const width = getRelativePositionX(startHour, endHour, clock);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.progressBar}>
|
||||||
|
<div className={style.progress} style={{ width: `${width}%` }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { dayInMs } from 'ontime-utils';
|
||||||
|
|
||||||
|
import { getElementPosition, getLaneLevel } from '../timelineUtils';
|
||||||
|
|
||||||
|
describe('getCSSPosition()', () => {
|
||||||
|
it('accounts for rundown with one event', () => {
|
||||||
|
const scheduleStart = 0;
|
||||||
|
const scheduleEnd = dayInMs;
|
||||||
|
const eventStart = 0;
|
||||||
|
const eventDuration = dayInMs;
|
||||||
|
const containerWidth = 100;
|
||||||
|
|
||||||
|
const result = getElementPosition(scheduleStart, scheduleEnd, eventStart, eventDuration, containerWidth);
|
||||||
|
expect(result.left).toBe(0);
|
||||||
|
expect(result.width).toBe(containerWidth);
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
const result = getElementPosition(scheduleStart, scheduleEnd, eventStart, eventDuration, containerWidth);
|
||||||
|
expect(result.left).toBe(50);
|
||||||
|
expect(result.width).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getLaneLevel()', () => {
|
||||||
|
it('should place in lane 0 if there is no overlap', () => {
|
||||||
|
const rightMostElements = { 0: 50 };
|
||||||
|
const left = 60;
|
||||||
|
const result = getLaneLevel(rightMostElements, left);
|
||||||
|
expect(result).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should place in next available lane if there is overlap', () => {
|
||||||
|
const rightMostElements = { 0: 150, 1: 100 };
|
||||||
|
const left = 120;
|
||||||
|
const result = getLaneLevel(rightMostElements, left);
|
||||||
|
expect(result).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a new lane if all existing lanes are overlapped', () => {
|
||||||
|
const rightMostElements = { 0: 200, 1: 250 };
|
||||||
|
const left = 150;
|
||||||
|
const result = getLaneLevel(rightMostElements, left);
|
||||||
|
expect(result).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should place in lane 0 if it is the first event', () => {
|
||||||
|
const rightMostElements = {};
|
||||||
|
const left = 0;
|
||||||
|
const result = getLaneLevel(rightMostElements, left);
|
||||||
|
expect(result).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { isOntimeEvent, MaybeString, NormalisedRundown, OntimeEvent } from 'ontime-types';
|
||||||
|
import {
|
||||||
|
dayInMs,
|
||||||
|
getByEventId,
|
||||||
|
getFirstEvent,
|
||||||
|
getFirstEventNormal,
|
||||||
|
getLastEventNormal,
|
||||||
|
getNextEvent,
|
||||||
|
MILLIS_PER_HOUR,
|
||||||
|
millisToString,
|
||||||
|
removeSeconds,
|
||||||
|
} from 'ontime-utils';
|
||||||
|
|
||||||
|
import { clamp } from '../../../common/utils/math';
|
||||||
|
import { formatDuration } from '../../../common/utils/time';
|
||||||
|
|
||||||
|
import type { ProgressStatus } from './TimelineEntry';
|
||||||
|
|
||||||
|
type CSSPosition = {
|
||||||
|
left: number;
|
||||||
|
width: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the position (in %) of an element relative to a schedule
|
||||||
|
*/
|
||||||
|
export function getRelativePositionX(scheduleStart: number, scheduleEnd: number, now: number): number {
|
||||||
|
return clamp(((now - scheduleStart) / (scheduleEnd - scheduleStart)) * 100, 0, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimates the width of an element based on its content
|
||||||
|
*/
|
||||||
|
export function getEstimatedWidth(content: string): number {
|
||||||
|
// use 8 as minimum width to account for duration string
|
||||||
|
// 12 is a rough estimate of the width of a character at 15px font size
|
||||||
|
return Math.max(content.length, 8) * 12;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Calculates an absolute position of an element based on a schedule
|
||||||
|
*/
|
||||||
|
export function getElementPosition(
|
||||||
|
scheduleStart: number,
|
||||||
|
scheduleEnd: number,
|
||||||
|
eventStart: number,
|
||||||
|
eventDuration: number,
|
||||||
|
containerWidth: number,
|
||||||
|
): CSSPosition {
|
||||||
|
// TODO: events that finish at midnight have lastEnd 0
|
||||||
|
const totalDuration = scheduleEnd - scheduleStart;
|
||||||
|
const width = (eventDuration * containerWidth) / totalDuration;
|
||||||
|
const left = ((eventStart - scheduleStart) * containerWidth) / totalDuration;
|
||||||
|
|
||||||
|
return { left, width };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStartHour(startTime: number): number {
|
||||||
|
const hours = Math.floor(startTime / MILLIS_PER_HOUR);
|
||||||
|
return hours;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEndHour(endTime: number): number {
|
||||||
|
const hours = Math.ceil(endTime / MILLIS_PER_HOUR);
|
||||||
|
return hours;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeTimelineSections(firstHour: number, lastHour: number) {
|
||||||
|
const timelineSections = [];
|
||||||
|
for (let i = firstHour; i < lastHour; i++) {
|
||||||
|
timelineSections.push(removeSeconds(millisToString(i * MILLIS_PER_HOUR)));
|
||||||
|
}
|
||||||
|
return timelineSections;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: account for elapsed days
|
||||||
|
export function getTimelineSections(rundown: NormalisedRundown, order: string[]): string[] {
|
||||||
|
if (order.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const { firstEvent } = getFirstEventNormal(rundown, order);
|
||||||
|
const { lastEvent } = getLastEventNormal(rundown, order);
|
||||||
|
const firstStart = firstEvent?.timeStart ?? 0;
|
||||||
|
const lastEnd = lastEvent?.timeEnd ?? 0;
|
||||||
|
const normalisedLastEnd = lastEnd < firstStart ? lastEnd + dayInMs : lastEnd;
|
||||||
|
|
||||||
|
const startHour = getStartHour(firstStart);
|
||||||
|
const endHour = getEndHour(normalisedLastEnd);
|
||||||
|
|
||||||
|
const elements = makeTimelineSections(startHour, endHour);
|
||||||
|
return elements;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_DEPTH = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimates the top offset for an element
|
||||||
|
*/
|
||||||
|
export function getLaneLevel(rightMostElements: Record<number, number>, left: number): number {
|
||||||
|
for (let checkLane = 0; checkLane < MAX_DEPTH; checkLane++) {
|
||||||
|
if (rightMostElements[checkLane] === undefined) {
|
||||||
|
return checkLane;
|
||||||
|
}
|
||||||
|
if (rightMostElements[checkLane] < left) {
|
||||||
|
return checkLane;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a formatted label for a progress status
|
||||||
|
*/
|
||||||
|
export function getStatusLabel(timeToStart: number, status: ProgressStatus): string {
|
||||||
|
if (status === 'finished' || status === 'live') {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timeToStart < 0) {
|
||||||
|
return 'T - 0';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `T - ${formatDuration(timeToStart)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpcomingEvents = {
|
||||||
|
now: MaybeString;
|
||||||
|
next: MaybeString;
|
||||||
|
followedBy: MaybeString;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns upcoming events from current: now, next and followedBy
|
||||||
|
*/
|
||||||
|
export function getUpcomingEvents(events: OntimeEvent[], selectedId: MaybeString): UpcomingEvents {
|
||||||
|
if (events.length === 0) {
|
||||||
|
return { now: null, next: null, followedBy: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const nowEvent = selectedId ? getByEventId(events, selectedId) : getFirstEvent(events)?.firstEvent;
|
||||||
|
|
||||||
|
if (!isOntimeEvent(nowEvent)) {
|
||||||
|
return { now: null, next: null, followedBy: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextEvent = getNextEvent(events, nowEvent.id)?.nextEvent;
|
||||||
|
const followedByEvent = nextEvent ? getNextEvent(events, nextEvent.id)?.nextEvent : null;
|
||||||
|
|
||||||
|
// Return the titles, handling nulls appropriately
|
||||||
|
return {
|
||||||
|
now: nowEvent.title,
|
||||||
|
next: nextEvent ? nextEvent.title : null,
|
||||||
|
followedBy: followedByEvent ? followedByEvent.title : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
.progress {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
|
||||||
|
background-color: $ui-black;
|
||||||
|
color: $ui-white;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
padding-inline: 2rem;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 3.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sections {
|
||||||
|
padding-inline: 2rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
row-gap: 1rem;
|
||||||
|
column-gap: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
line-height: 1.2em;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionContent {
|
||||||
|
line-height: 1em;
|
||||||
|
font-size: 3rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&.now {
|
||||||
|
color: $red-500;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
&.next {
|
||||||
|
color: $green-500;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
&.subdue {
|
||||||
|
opacity: $opacity-disabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { MaybeString, OntimeEvent, ProjectData, Settings } from 'ontime-types';
|
||||||
|
|
||||||
|
import { getProgressOptions } from '../../common/components/view-params-editor/constants';
|
||||||
|
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
|
import { ViewExtendedTimer } from '../../common/models/TimeManager.type';
|
||||||
|
import { cx } from '../../common/utils/styleUtils';
|
||||||
|
import { formatTime, getDefaultFormat } from '../../common/utils/time';
|
||||||
|
|
||||||
|
import Timeline from './Timeline/Timeline';
|
||||||
|
import { getUpcomingEvents } from './Timeline/timelineUtils';
|
||||||
|
|
||||||
|
import style from './TimelinePage.module.scss';
|
||||||
|
|
||||||
|
interface TimelinePageProps {
|
||||||
|
backstageEvents: OntimeEvent[];
|
||||||
|
general: ProjectData;
|
||||||
|
selectedId: MaybeString;
|
||||||
|
settings: Settings | undefined;
|
||||||
|
time: ViewExtendedTimer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TimelinePage(props: TimelinePageProps) {
|
||||||
|
const { backstageEvents, general, selectedId, settings, time } = props;
|
||||||
|
|
||||||
|
const clock = formatTime(time.clock);
|
||||||
|
const { now, next, followedBy } = getUpcomingEvents(backstageEvents, selectedId);
|
||||||
|
|
||||||
|
// populate options
|
||||||
|
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
||||||
|
const progressOptions = getProgressOptions(defaultFormat);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.progress}>
|
||||||
|
<ViewParamsEditor paramFields={progressOptions} />
|
||||||
|
<div className={style.title}>{general.title}</div>
|
||||||
|
<div className={style.sections}>
|
||||||
|
<Section title='Time now' content={clock} category='now' />
|
||||||
|
<Section title='Next' content={next} category='next' />
|
||||||
|
<Section title='Current' content={now} category='now' />
|
||||||
|
<Section title='Followed by' content={followedBy} category='next' />
|
||||||
|
</div>
|
||||||
|
<Timeline selectedEventId={selectedId} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SectionProps {
|
||||||
|
category: 'now' | 'next';
|
||||||
|
content: MaybeString;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section(props: SectionProps) {
|
||||||
|
const { category, content, title } = props;
|
||||||
|
|
||||||
|
const contentClasses = cx([style.sectionContent, content != null ? style[category] : style.subdue]);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={style.sectionTitle}>{title}</div>
|
||||||
|
<div className={contentClasses}>{content ?? '-'}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ $ontime-color: #ff7597;
|
|||||||
$error-red: $red-500;
|
$error-red: $red-500;
|
||||||
$warning-orange: $orange-500;
|
$warning-orange: $orange-500;
|
||||||
$opacity-disabled: 0.4;
|
$opacity-disabled: 0.4;
|
||||||
|
$active-red: $red-700;
|
||||||
|
|
||||||
// playback colours
|
// playback colours
|
||||||
$playback-start: $green-600;
|
$playback-start: $green-600;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export const navigatorConstants = [
|
|||||||
{ url: '/minimal', label: 'Minimal Timer' },
|
{ url: '/minimal', label: 'Minimal Timer' },
|
||||||
{ url: '/backstage', label: 'Backstage' },
|
{ url: '/backstage', label: 'Backstage' },
|
||||||
{ url: '/public', label: 'Public' },
|
{ url: '/public', label: 'Public' },
|
||||||
|
{ url: '/timeline', label: 'Timeline' },
|
||||||
{ url: '/lower', label: 'Lower Thirds' },
|
{ url: '/lower', label: 'Lower Thirds' },
|
||||||
{ url: '/studio', label: 'Studio Clock' },
|
{ url: '/studio', label: 'Studio Clock' },
|
||||||
{ url: '/countdown', label: 'Countdown' },
|
{ url: '/countdown', label: 'Countdown' },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime",
|
"name": "ontime",
|
||||||
"version": "3.3.2",
|
"version": "3.4.0-alpha-timeline.2",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "ontime-server",
|
"name": "ontime-server",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"version": "3.3.2",
|
"version": "3.4.0-alpha-timeline.2",
|
||||||
"exports": "./src/index.js",
|
"exports": "./src/index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@googleapis/sheets": "^5.0.5",
|
"@googleapis/sheets": "^5.0.5",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime",
|
"name": "ontime",
|
||||||
"version": "3.3.2",
|
"version": "3.4.0-alpha-timeline.2",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ontime",
|
"ontime",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export { sanitiseCue } from './src/cue-utils/cueUtils.js';
|
|||||||
export { getCueCandidate } from './src/cue-utils/cueUtils.js';
|
export { getCueCandidate } from './src/cue-utils/cueUtils.js';
|
||||||
export { generateId } from './src/generate-id/generateId.js';
|
export { generateId } from './src/generate-id/generateId.js';
|
||||||
export {
|
export {
|
||||||
|
getByEventId,
|
||||||
getFirst,
|
getFirst,
|
||||||
getFirstEvent,
|
getFirstEvent,
|
||||||
getFirstEventNormal,
|
getFirstEventNormal,
|
||||||
|
|||||||
@@ -3,6 +3,15 @@ import { isOntimeEvent } from 'ontime-types';
|
|||||||
|
|
||||||
type IndexAndEntry = { entry: OntimeRundownEntry | null; index: number | null };
|
type IndexAndEntry = { entry: OntimeRundownEntry | null; index: number | null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets an event with given if if it exists
|
||||||
|
* @param {OntimeRundownEntry[]} rundown
|
||||||
|
* @return {OntimeRundownEntry | null}
|
||||||
|
*/
|
||||||
|
export function getByEventId(rundown: OntimeRundownEntry[], eventId: string): OntimeRundownEntry | null {
|
||||||
|
return rundown.find((event) => event.id === eventId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets first event in rundown, if it exists
|
* Gets first event in rundown, if it exists
|
||||||
* @param {OntimeRundownEntry[]} rundown
|
* @param {OntimeRundownEntry[]} rundown
|
||||||
|
|||||||
Reference in New Issue
Block a user