mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-16 21:03:29 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ec1179ede |
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { getRememberedAspectRatio, rememberAspectRatio } from '../imageDimensions';
|
||||
|
||||
/** stand-in for a loaded HTMLImageElement */
|
||||
function makeImage(naturalWidth: number, naturalHeight: number) {
|
||||
return { naturalWidth, naturalHeight } as HTMLImageElement;
|
||||
}
|
||||
|
||||
describe('image dimensions', () => {
|
||||
test('remembers the aspect ratio of a loaded image', () => {
|
||||
expect(getRememberedAspectRatio('http://ontime.local/image.png')).toBe(null);
|
||||
|
||||
rememberAspectRatio('http://ontime.local/image.png', makeImage(1920, 1080));
|
||||
|
||||
expect(getRememberedAspectRatio('http://ontime.local/image.png')).toBe(1920 / 1080);
|
||||
});
|
||||
|
||||
test('handles images which have not loaded', () => {
|
||||
rememberAspectRatio('http://ontime.local/broken.png', makeImage(0, 0));
|
||||
expect(getRememberedAspectRatio('http://ontime.local/broken.png')).toBe(null);
|
||||
});
|
||||
|
||||
test('handles missing values', () => {
|
||||
expect(getRememberedAspectRatio(undefined)).toBe(null);
|
||||
expect(getRememberedAspectRatio('')).toBe(null);
|
||||
expect(() => rememberAspectRatio('', makeImage(100, 100))).not.toThrow();
|
||||
});
|
||||
|
||||
test('forgets the least recently used entries', () => {
|
||||
for (let i = 0; i < 600; i++) {
|
||||
rememberAspectRatio(`http://ontime.local/${i}.png`, makeImage(100, 50));
|
||||
}
|
||||
|
||||
expect(getRememberedAspectRatio('http://ontime.local/0.png')).toBe(null);
|
||||
expect(getRememberedAspectRatio('http://ontime.local/599.png')).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Images in the cuesheet live inside a virtualised table:
|
||||
* rows are unmounted when they leave the viewport and mounted again when they come back.
|
||||
* A re-mounted image has no dimensions until it is available,
|
||||
* which makes the row change height and the table shift under the user.
|
||||
*
|
||||
* We remember the aspect ratio of the images we have already seen
|
||||
* so that we can reserve the space they will take.
|
||||
* This only holds two numbers per image: we leave the image data itself to the browser cache,
|
||||
* which knows better than us when memory should be released.
|
||||
*/
|
||||
|
||||
/** how many aspect ratios we remember, this is only a few bytes per entry */
|
||||
const maxSize = 500;
|
||||
|
||||
const aspectRatios = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* @returns the aspect ratio of a previously loaded image, if we have seen it before
|
||||
*/
|
||||
export function getRememberedAspectRatio(src: string | null | undefined): number | null {
|
||||
if (!src) {
|
||||
return null;
|
||||
}
|
||||
return aspectRatios.get(src) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the aspect ratio of a loaded image
|
||||
*/
|
||||
export function rememberAspectRatio(src: string | null | undefined, image: HTMLImageElement) {
|
||||
if (!src || image.naturalHeight === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// the map iteration order is our LRU queue, re-adding the entry marks it as recently used
|
||||
aspectRatios.delete(src);
|
||||
aspectRatios.set(src, image.naturalWidth / image.naturalHeight);
|
||||
|
||||
while (aspectRatios.size > maxSize) {
|
||||
const oldest = aspectRatios.keys().next();
|
||||
if (oldest.done) {
|
||||
return;
|
||||
}
|
||||
aspectRatios.delete(oldest.value);
|
||||
}
|
||||
}
|
||||
+21
-2
@@ -1,7 +1,8 @@
|
||||
import { memo } from 'react';
|
||||
import { memo, useState } from 'react';
|
||||
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Input from '../../../../common/components/input/input/Input';
|
||||
import { getRememberedAspectRatio, rememberAspectRatio } from '../../../../common/utils/imageDimensions';
|
||||
|
||||
import style from './EditableImage.module.scss';
|
||||
|
||||
@@ -14,6 +15,14 @@ interface EditableImageProps {
|
||||
export default memo(EditableImage);
|
||||
|
||||
function EditableImage({ initialValue, readOnly, updateValue }: EditableImageProps) {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
/**
|
||||
* The cuesheet is virtualised: rows are unmounted once they leave the viewport.
|
||||
* When the row comes back, we reserve the space the image took
|
||||
* so that the table does not shift while the browser makes it available.
|
||||
*/
|
||||
const knownAspectRatio = getRememberedAspectRatio(initialValue);
|
||||
|
||||
const handleUpdate = (newValue: string) => {
|
||||
if (newValue === initialValue) {
|
||||
return;
|
||||
@@ -62,7 +71,17 @@ function EditableImage({ initialValue, readOnly, updateValue }: EditableImagePro
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{Boolean(initialValue) && <img loading='lazy' src={initialValue} className={style.image} />}
|
||||
<img
|
||||
src={initialValue}
|
||||
alt=''
|
||||
className={style.image}
|
||||
onLoad={(event) => {
|
||||
rememberAspectRatio(initialValue, event.currentTarget);
|
||||
setIsLoaded(true);
|
||||
}}
|
||||
/** until the image is available, we reserve the space it took the last time we saw it */
|
||||
style={!isLoaded && knownAspectRatio !== null ? { aspectRatio: knownAspectRatio, width: '100%' } : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ describe('getExpectedFinish()', () => {
|
||||
expect(calculatedFinish).toBe(10);
|
||||
});
|
||||
describe('on timers of type time-to-end', () => {
|
||||
it('finish time is the fixed end, ignoring added time', () => {
|
||||
it('finish time is as schedule + added time', () => {
|
||||
const state = {
|
||||
eventNow: {
|
||||
timeEnd: 30,
|
||||
@@ -245,29 +245,6 @@ describe('getExpectedFinish()', () => {
|
||||
const calculatedFinish = getExpectedFinish(state);
|
||||
expect(calculatedFinish).toBe(40);
|
||||
});
|
||||
|
||||
it('finish time is the fixed end, including paused time', () => {
|
||||
const state = {
|
||||
eventNow: {
|
||||
timeEnd: 30,
|
||||
countToEnd: true,
|
||||
},
|
||||
clock: 25,
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
duration: dayInMs,
|
||||
startedAt: 10,
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: 20, // paused 5 ago
|
||||
hasFinished: false,
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
const calculatedFinish = getExpectedFinish(state);
|
||||
expect(calculatedFinish).toBe(35);
|
||||
});
|
||||
|
||||
it('handles events that finish the day after', () => {
|
||||
const state = {
|
||||
eventNow: {
|
||||
@@ -427,9 +404,6 @@ describe('getCurrent()', () => {
|
||||
it('current time is the time to end even if it hasnt started, this is weird, but by design', () => {
|
||||
const state = {
|
||||
eventNow: {
|
||||
timeStart: 0,
|
||||
duration: 100,
|
||||
dayOffset: 0,
|
||||
timeEnd: 100,
|
||||
countToEnd: true,
|
||||
},
|
||||
@@ -455,9 +429,6 @@ describe('getCurrent()', () => {
|
||||
it('current time is the time to end', () => {
|
||||
const state = {
|
||||
eventNow: {
|
||||
timeStart: 0,
|
||||
duration: 100,
|
||||
dayOffset: 0,
|
||||
timeEnd: 100,
|
||||
countToEnd: true,
|
||||
},
|
||||
@@ -480,13 +451,10 @@ describe('getCurrent()', () => {
|
||||
expect(current).toBe(70);
|
||||
});
|
||||
|
||||
it('current time is the time to end, ignoring added time', () => {
|
||||
it('current time is the time to end + added time', () => {
|
||||
const state = {
|
||||
eventNow: {
|
||||
timeStart: 0,
|
||||
duration: 100,
|
||||
timeEnd: 100,
|
||||
dayOffset: 0,
|
||||
countToEnd: true,
|
||||
},
|
||||
clock: 30,
|
||||
@@ -512,10 +480,8 @@ describe('getCurrent()', () => {
|
||||
const state = {
|
||||
eventNow: {
|
||||
timeStart: 79200000, // 22:00:00
|
||||
duration: 2 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE,
|
||||
timeEnd: 600000, // 00:10:00
|
||||
countToEnd: true,
|
||||
dayOffset: 0,
|
||||
},
|
||||
clock: 79500000, // 22:05:00
|
||||
timer: {
|
||||
@@ -526,7 +492,6 @@ describe('getCurrent()', () => {
|
||||
rundown: {
|
||||
actualStart: 79200000,
|
||||
plannedEnd: 600000,
|
||||
currentDay: 0,
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: null,
|
||||
@@ -538,36 +503,6 @@ describe('getCurrent()', () => {
|
||||
expect(current).toBe(dayInMs - 79500000 + 600000);
|
||||
});
|
||||
|
||||
it('handles overnight count-to-end after midnight', () => {
|
||||
const state = {
|
||||
eventNow: {
|
||||
timeStart: 23 * MILLIS_PER_HOUR, // 23:00:00
|
||||
duration: 2 * MILLIS_PER_HOUR, // 2h
|
||||
timeEnd: 1 * MILLIS_PER_HOUR, // 01:00:00
|
||||
countToEnd: true,
|
||||
dayOffset: 0,
|
||||
},
|
||||
clock: 30 * MILLIS_PER_MINUTE, // 00:30:00 on day 1
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
duration: Infinity, // not relevant,
|
||||
startedAt: 23 * MILLIS_PER_HOUR,
|
||||
},
|
||||
rundown: {
|
||||
actualStart: 23 * MILLIS_PER_HOUR,
|
||||
plannedEnd: 1 * MILLIS_PER_HOUR,
|
||||
currentDay: 1,
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: null,
|
||||
hasFinished: false,
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
const current = getCurrent(state);
|
||||
expect(current).toBe(30 * MILLIS_PER_MINUTE);
|
||||
});
|
||||
|
||||
it('handles events that were started late', () => {
|
||||
const state = {
|
||||
clock: 82000000, // 22:46:40 <--- starting 16 min after the scheduled end
|
||||
@@ -576,7 +511,6 @@ describe('getCurrent()', () => {
|
||||
timeEnd: 81000000, // 22:30:00
|
||||
duration: 3600000, // 01:00:00
|
||||
countToEnd: true,
|
||||
dayOffset: 0,
|
||||
},
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
@@ -1139,7 +1073,7 @@ describe('getRuntimeOffset()', () => {
|
||||
expect(absolute).toBe(0);
|
||||
});
|
||||
|
||||
it('with time-to-end, offset combines overtime and added time', () => {
|
||||
it('with time-to-end, offset is the overtime', () => {
|
||||
const state = {
|
||||
clock: 82000000, // 22:46:40
|
||||
eventNow: {
|
||||
@@ -1192,45 +1126,7 @@ describe('getRuntimeOffset()', () => {
|
||||
} as RuntimeState;
|
||||
|
||||
const { absolute } = getRuntimeOffset(state);
|
||||
// overtime (400000) plus the operator's added time (-200000)
|
||||
expect(absolute).toBe(200000);
|
||||
});
|
||||
|
||||
it('with time-to-end', () => {
|
||||
const state = {
|
||||
clock: 80000000, // 22:13:20 - before the scheduled end, not in overtime
|
||||
eventNow: {
|
||||
id: 'd6a2ce',
|
||||
timeStart: 77400000, // 21:30:00
|
||||
timeEnd: 81000000, // 22:30:00
|
||||
duration: 3600000, // 01:00:00
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
countToEnd: true,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
},
|
||||
rundown: {
|
||||
plannedStart: 77400000, // 21:30:00
|
||||
plannedEnd: 81000000, // 22:30:00
|
||||
actualStart: 78000000, // 21:40:00
|
||||
currentDay: 0,
|
||||
},
|
||||
offset: {
|
||||
absolute: 0,
|
||||
},
|
||||
timer: {
|
||||
addedTime: 300000, // operator added 5 minutes
|
||||
current: 1000000, // still counting down, no overtime
|
||||
duration: 3600000,
|
||||
startedAt: 78000000,
|
||||
},
|
||||
_startDayOffset: 0,
|
||||
_timer: { pausedAt: null },
|
||||
} as RuntimeState;
|
||||
|
||||
// the end is anchored, so the added 5 minutes shows up as offset
|
||||
const { absolute } = getRuntimeOffset(state);
|
||||
expect(absolute).toBe(300000);
|
||||
expect(absolute).toBe(400000 - 200000); // <--- offset is always the overtime + added time
|
||||
});
|
||||
|
||||
it('handles time-to-end started after the end time', () => {
|
||||
|
||||
@@ -62,34 +62,34 @@ export function getExpectedFinish(state: RuntimeState): MaybeNumber {
|
||||
*/
|
||||
|
||||
export function getCurrent(state: RuntimeState): number {
|
||||
const { timer, eventNow } = state;
|
||||
|
||||
// eslint-disable-next-line no-unused-labels -- dev code path
|
||||
DEV: {
|
||||
if (eventNow === null || timer.duration === null) {
|
||||
if (state.eventNow === null || state.timer.duration === null) {
|
||||
throw new Error('timerUtils.getCurrent: invalid state received');
|
||||
}
|
||||
}
|
||||
const { startedAt, duration, addedTime } = state.timer;
|
||||
const { countToEnd, timeStart, timeEnd } = state.eventNow;
|
||||
const { pausedAt } = state._timer;
|
||||
const { clock } = state;
|
||||
|
||||
if (eventNow.countToEnd) {
|
||||
const clockDayCorrected = clock + (state.rundown.currentDay ?? 0) * dayInMs;
|
||||
const eventDayOffset = eventNow.dayOffset * dayInMs;
|
||||
return eventDayOffset + eventNow.timeStart + eventNow.duration + timer.addedTime - clockDayCorrected;
|
||||
if (countToEnd) {
|
||||
const isEventOverMidnight = timeStart > timeEnd;
|
||||
const correctDay = isEventOverMidnight ? dayInMs : 0;
|
||||
return correctDay - clock + timeEnd + addedTime;
|
||||
}
|
||||
|
||||
if (timer.startedAt === null) {
|
||||
return timer.duration;
|
||||
if (startedAt === null) {
|
||||
return duration;
|
||||
}
|
||||
|
||||
if (pausedAt != null) {
|
||||
return timer.startedAt + timer.duration + timer.addedTime - pausedAt;
|
||||
return startedAt + duration + addedTime - pausedAt;
|
||||
}
|
||||
|
||||
const hasPassedMidnight = timer.startedAt > clock;
|
||||
const hasPassedMidnight = startedAt > clock;
|
||||
const correctDay = hasPassedMidnight ? dayInMs : 0;
|
||||
return timer.startedAt + timer.duration + timer.addedTime - clock - correctDay;
|
||||
return startedAt + duration + addedTime - clock - correctDay;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user