mirror of
https://github.com/cpvalente/ontime.git
synced 2026-07-26 10:38:55 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e73f2ecb8 | |||
| 8aeb4c7a01 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "3.3.2",
|
||||
"version": "3.4.0-alpha-timeline.2",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "3.3.2",
|
||||
"version": "3.4.0-alpha-timeline.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -17,6 +17,7 @@ const Countdown = lazy(() => import('./features/viewers/countdown/Countdown'));
|
||||
|
||||
const Backstage = lazy(() => import('./features/viewers/backstage/Backstage'));
|
||||
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 StudioClock = lazy(() => import('./features/viewers/studio/StudioClock'));
|
||||
|
||||
@@ -26,6 +27,7 @@ const SClock = withPreset(withData(ClockView));
|
||||
const SCountdown = withPreset(withData(Countdown));
|
||||
const SBackstage = withPreset(withData(Backstage));
|
||||
const SPublic = withPreset(withData(Public));
|
||||
const STimeline = withPreset(withData(Timeline));
|
||||
const SLowerThird = withPreset(withData(Lower));
|
||||
const SStudio = withPreset(withData(StudioClock));
|
||||
|
||||
@@ -60,6 +62,8 @@ export default function AppRouter() {
|
||||
|
||||
<Route path='/op' element={<Operator />} />
|
||||
|
||||
<Route path='/timeline' element={<STimeline />} />
|
||||
|
||||
{/*/!* Protected Routes *!/*/}
|
||||
<Route path='/editor' element={<Editor />} />
|
||||
<Route path='/cuesheet' element={<Cuesheet />} />
|
||||
|
||||
@@ -40,6 +40,7 @@ interface EditFormDrawerProps {
|
||||
paramFields: ParamField[];
|
||||
}
|
||||
|
||||
// TODO: this is a good candidate for memoisation, but needs the paramFields to be stable
|
||||
export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
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 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' };
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param classNames - css modules objects
|
||||
|
||||
@@ -95,3 +95,25 @@ export const formatTime = (
|
||||
const isNegative = milliseconds < 0;
|
||||
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 {
|
||||
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 {
|
||||
|
||||
@@ -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;
|
||||
$warning-orange: $orange-500;
|
||||
$opacity-disabled: 0.4;
|
||||
$active-red: $red-700;
|
||||
|
||||
// playback colours
|
||||
$playback-start: $green-600;
|
||||
|
||||
@@ -4,6 +4,7 @@ export const navigatorConstants = [
|
||||
{ url: '/minimal', label: 'Minimal Timer' },
|
||||
{ url: '/backstage', label: 'Backstage' },
|
||||
{ url: '/public', label: 'Public' },
|
||||
{ url: '/timeline', label: 'Timeline' },
|
||||
{ url: '/lower', label: 'Lower Thirds' },
|
||||
{ url: '/studio', label: 'Studio Clock' },
|
||||
{ url: '/countdown', label: 'Countdown' },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime",
|
||||
"version": "3.3.2",
|
||||
"version": "3.4.0-alpha-timeline.2",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "ontime-server",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"version": "3.3.2",
|
||||
"version": "3.4.0-alpha-timeline.2",
|
||||
"exports": "./src/index.js",
|
||||
"dependencies": {
|
||||
"@googleapis/sheets": "^5.0.5",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime",
|
||||
"version": "3.3.2",
|
||||
"version": "3.4.0-alpha-timeline.2",
|
||||
"description": "Time keeping for live events",
|
||||
"keywords": [
|
||||
"ontime",
|
||||
|
||||
@@ -8,6 +8,7 @@ export { sanitiseCue } from './src/cue-utils/cueUtils.js';
|
||||
export { getCueCandidate } from './src/cue-utils/cueUtils.js';
|
||||
export { generateId } from './src/generate-id/generateId.js';
|
||||
export {
|
||||
getByEventId,
|
||||
getFirst,
|
||||
getFirstEvent,
|
||||
getFirstEventNormal,
|
||||
|
||||
@@ -3,6 +3,15 @@ import { isOntimeEvent } from 'ontime-types';
|
||||
|
||||
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
|
||||
* @param {OntimeRundownEntry[]} rundown
|
||||
|
||||
Reference in New Issue
Block a user