Refactor zoom in/out, add test coverage
continuous-integration/drone/push Build is passing
Details
continuous-integration/drone/push Build is passing
Details
This commit is contained in:
parent
aa4d235c0c
commit
d136e00c59
|
@ -17,6 +17,7 @@ import './App.css';
|
||||||
import { firstValueFrom, from, Observable } from 'rxjs';
|
import { firstValueFrom, from, Observable } from 'rxjs';
|
||||||
import { first, map } from 'rxjs/operators';
|
import { first, map } from 'rxjs/operators';
|
||||||
import millisFromDuration from './helpers/millisFromDuration';
|
import millisFromDuration from './helpers/millisFromDuration';
|
||||||
|
import { zoomViewportIn, zoomViewportOut } from './helpers/zoom';
|
||||||
|
|
||||||
// ported from backend, where should they live?
|
// ported from backend, where should they live?
|
||||||
const thumbnailWidth = 177;
|
const thumbnailWidth = 177;
|
||||||
|
@ -24,6 +25,8 @@ const thumbnailHeight = 100;
|
||||||
|
|
||||||
const initialViewportCanvasPixels = 100;
|
const initialViewportCanvasPixels = 100;
|
||||||
|
|
||||||
|
const zoomFactor = 2;
|
||||||
|
|
||||||
const apiURL = process.env.REACT_APP_API_URL || 'http://localhost:8888';
|
const apiURL = process.env.REACT_APP_API_URL || 'http://localhost:8888';
|
||||||
|
|
||||||
// Frames represents a range of audio frames.
|
// Frames represents a range of audio frames.
|
||||||
|
@ -309,30 +312,32 @@ function App(): JSX.Element {
|
||||||
if (mediaSet == null) {
|
if (mediaSet == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (viewport.start == viewport.end) {
|
|
||||||
return;
|
const newViewport = zoomViewportIn(
|
||||||
}
|
viewport,
|
||||||
setViewport({
|
mediaSet.audioFrames,
|
||||||
...viewport,
|
selection,
|
||||||
end: viewport.end - Math.round((viewport.end - viewport.start) / 2),
|
currentTimeToFrame(positionRef.current.currentTime),
|
||||||
});
|
zoomFactor
|
||||||
|
);
|
||||||
|
|
||||||
|
setViewport(newViewport);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleZoomOut = () => {
|
const handleZoomOut = () => {
|
||||||
if (mediaSet == null) {
|
if (mediaSet == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (viewport.start == viewport.end) {
|
|
||||||
return;
|
const newViewport = zoomViewportOut(
|
||||||
}
|
viewport,
|
||||||
let end = viewport.end + Math.round(viewport.end - viewport.start);
|
mediaSet.audioFrames,
|
||||||
if (end > mediaSet.audioFrames) {
|
selection,
|
||||||
end = mediaSet.audioFrames;
|
currentTimeToFrame(positionRef.current.currentTime),
|
||||||
}
|
zoomFactor
|
||||||
setViewport({
|
);
|
||||||
...viewport,
|
|
||||||
end: end,
|
setViewport(newViewport);
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const setPositionFromFrame = useCallback(
|
const setPositionFromFrame = useCallback(
|
||||||
|
|
|
@ -0,0 +1,222 @@
|
||||||
|
import { zoomViewportIn, zoomViewportOut } from './zoom';
|
||||||
|
|
||||||
|
// zf is the zoom factor.
|
||||||
|
const zf = 2;
|
||||||
|
const emptySelection = { start: 0, end: 0 };
|
||||||
|
|
||||||
|
describe('zoomViewportIn', () => {
|
||||||
|
describe('when viewport start and end is equal', () => {
|
||||||
|
it('returns the same viewport', () => {
|
||||||
|
const newViewport = zoomViewportIn(
|
||||||
|
{ start: 100, end: 100 },
|
||||||
|
500,
|
||||||
|
emptySelection,
|
||||||
|
0,
|
||||||
|
zf
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(newViewport).toEqual({ start: 100, end: 100 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with nothing selected', () => {
|
||||||
|
it('centres the zoom on the playback position if possible', () => {
|
||||||
|
const newViewport = zoomViewportIn(
|
||||||
|
{ start: 100_000, end: 200_000 },
|
||||||
|
500_000,
|
||||||
|
emptySelection,
|
||||||
|
50_000,
|
||||||
|
zf
|
||||||
|
);
|
||||||
|
expect(newViewport).toEqual({ start: 25_000, end: 75_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offsets the new viewport if it overlaps the viewport minimum', () => {
|
||||||
|
const newViewport = zoomViewportIn(
|
||||||
|
{ start: 100_000, end: 200_000 },
|
||||||
|
500_000,
|
||||||
|
emptySelection,
|
||||||
|
0,
|
||||||
|
zf
|
||||||
|
);
|
||||||
|
expect(newViewport).toEqual({ start: 0, end: 50_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offsets the new viewport if it overlaps the viewport maximum', () => {
|
||||||
|
const newViewport = zoomViewportIn(
|
||||||
|
{ start: 100_000, end: 200_000 },
|
||||||
|
500_000,
|
||||||
|
emptySelection,
|
||||||
|
490_000,
|
||||||
|
zf
|
||||||
|
);
|
||||||
|
expect(newViewport).toEqual({ start: 450_000, end: 500_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with an active selection', () => {
|
||||||
|
it('centres the new viewport on the selection if possible', () => {
|
||||||
|
const newViewport = zoomViewportIn(
|
||||||
|
{ start: 100_000, end: 200_000 },
|
||||||
|
500_000,
|
||||||
|
{ start: 120_000, end: 140_000 },
|
||||||
|
0,
|
||||||
|
zf
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(newViewport).toEqual({ start: 105_000, end: 155_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offsets the new viewport if it overlaps the viewport minimum', () => {
|
||||||
|
const newViewport = zoomViewportIn(
|
||||||
|
{ start: 100_000, end: 200_000 },
|
||||||
|
500_000,
|
||||||
|
{ start: 10_000, end: 20_000 },
|
||||||
|
0,
|
||||||
|
zf
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(newViewport).toEqual({ start: 0, end: 50_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offsets the new viewport if it overlaps the viewport maximum', () => {
|
||||||
|
const newViewport = zoomViewportIn(
|
||||||
|
{ start: 100_000, end: 200_000 },
|
||||||
|
500_000,
|
||||||
|
{ start: 480_000, end: 490_000 },
|
||||||
|
0,
|
||||||
|
zf
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(newViewport).toEqual({ start: 450_000, end: 500_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when zooming beyond the selection', () => {
|
||||||
|
it('disallows the zoom', () => {
|
||||||
|
const newViewport = zoomViewportIn(
|
||||||
|
{ start: 100_000, end: 200_000 },
|
||||||
|
500_000,
|
||||||
|
{ start: 110_000, end: 190_000 },
|
||||||
|
0,
|
||||||
|
zf
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(newViewport).toEqual({ start: 100_000, end: 200_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('zoomViewportOut', () => {
|
||||||
|
describe('when viewport start and end is equal', () => {
|
||||||
|
it('returns the same viewport', () => {
|
||||||
|
const newViewport = zoomViewportOut(
|
||||||
|
{ start: 100, end: 100 },
|
||||||
|
500,
|
||||||
|
emptySelection,
|
||||||
|
0,
|
||||||
|
zf
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(newViewport).toEqual({ start: 100, end: 100 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with nothing selected', () => {
|
||||||
|
it('centres the zoom on the playback position if possible', () => {
|
||||||
|
const newViewport = zoomViewportOut(
|
||||||
|
{ start: 190_000, end: 210_000 },
|
||||||
|
500_000,
|
||||||
|
emptySelection,
|
||||||
|
170_000,
|
||||||
|
zf
|
||||||
|
);
|
||||||
|
expect(newViewport).toEqual({ start: 150_000, end: 190_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offsets the new viewport if it overlaps the viewport minimum', () => {
|
||||||
|
const newViewport = zoomViewportOut(
|
||||||
|
{ start: 190_000, end: 210_000 },
|
||||||
|
500_000,
|
||||||
|
emptySelection,
|
||||||
|
10_000,
|
||||||
|
zf
|
||||||
|
);
|
||||||
|
expect(newViewport).toEqual({ start: 0, end: 40_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offsets the new viewport if it overlaps the viewport maximum', () => {
|
||||||
|
const newViewport = zoomViewportOut(
|
||||||
|
{ start: 190_000, end: 210_000 },
|
||||||
|
500_000,
|
||||||
|
emptySelection,
|
||||||
|
485_000,
|
||||||
|
zf
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(newViewport).toEqual({ start: 460_000, end: 500_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to zoom out beyond the available limits', () => {
|
||||||
|
const newViewport = zoomViewportOut(
|
||||||
|
{ start: 10_000, end: 490_000 },
|
||||||
|
500_000,
|
||||||
|
emptySelection,
|
||||||
|
200_000,
|
||||||
|
zf
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(newViewport).toEqual({ start: 0, end: 500_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with an active selection', () => {
|
||||||
|
it('centres the new viewport on the selection if possible', () => {
|
||||||
|
const newViewport = zoomViewportOut(
|
||||||
|
{ start: 150_000, end: 170_000 },
|
||||||
|
500_000,
|
||||||
|
{ start: 120_000, end: 140_000 },
|
||||||
|
0,
|
||||||
|
zf
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(newViewport).toEqual({ start: 110_000, end: 150_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offsets the new viewport if it overlaps the viewport minimum', () => {
|
||||||
|
const newViewport = zoomViewportOut(
|
||||||
|
{ start: 190_000, end: 210_000 },
|
||||||
|
500_000,
|
||||||
|
{ start: 10_000, end: 20_000 },
|
||||||
|
10_000,
|
||||||
|
zf
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(newViewport).toEqual({ start: 0, end: 40_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offsets the new viewport if it overlaps the viewport minimum', () => {
|
||||||
|
const newViewport = zoomViewportOut(
|
||||||
|
{ start: 190_000, end: 210_000 },
|
||||||
|
500_000,
|
||||||
|
{ start: 495_000, end: 500_000 },
|
||||||
|
0,
|
||||||
|
zf
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(newViewport).toEqual({ start: 460_000, end: 500_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to zoom out beyond the available limits', () => {
|
||||||
|
const newViewport = zoomViewportOut(
|
||||||
|
{ start: 10_000, end: 490_000 },
|
||||||
|
500_000,
|
||||||
|
{ start: 20_000, end: 480_000 },
|
||||||
|
0,
|
||||||
|
zf
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(newViewport).toEqual({ start: 0, end: 500_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { Frames } from '../App';
|
||||||
|
|
||||||
|
export function zoomViewportIn(
|
||||||
|
viewport: Frames,
|
||||||
|
numFrames: number,
|
||||||
|
selection: Frames,
|
||||||
|
position: number,
|
||||||
|
factor: number
|
||||||
|
): Frames {
|
||||||
|
if (viewport.start == viewport.end) {
|
||||||
|
return viewport;
|
||||||
|
}
|
||||||
|
|
||||||
|
return zoom(
|
||||||
|
Math.round((viewport.end - viewport.start) / factor),
|
||||||
|
viewport,
|
||||||
|
numFrames,
|
||||||
|
selection,
|
||||||
|
position
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function zoomViewportOut(
|
||||||
|
viewport: Frames,
|
||||||
|
numFrames: number,
|
||||||
|
selection: Frames,
|
||||||
|
position: number,
|
||||||
|
factor: number
|
||||||
|
): Frames {
|
||||||
|
if (viewport.start == viewport.end) {
|
||||||
|
return viewport;
|
||||||
|
}
|
||||||
|
|
||||||
|
return zoom(
|
||||||
|
Math.round((viewport.end - viewport.start) * factor),
|
||||||
|
viewport,
|
||||||
|
numFrames,
|
||||||
|
selection,
|
||||||
|
position
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoom(
|
||||||
|
newWidth: number,
|
||||||
|
viewport: Frames,
|
||||||
|
numFrames: number,
|
||||||
|
selection: Frames,
|
||||||
|
position: number
|
||||||
|
): Frames {
|
||||||
|
let newStart;
|
||||||
|
|
||||||
|
if (selection.start != selection.end) {
|
||||||
|
const selectionWidth = selection.end - selection.start;
|
||||||
|
|
||||||
|
// disallow zooming beyond the selection:
|
||||||
|
if (newWidth < selectionWidth) {
|
||||||
|
return viewport;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectionMidpoint = selection.end - selectionWidth / 2;
|
||||||
|
newStart = selectionMidpoint - Math.round(newWidth / 2);
|
||||||
|
} else {
|
||||||
|
newStart = position - newWidth / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newStart < 0) {
|
||||||
|
newStart = 0;
|
||||||
|
}
|
||||||
|
let newEnd = newStart + newWidth;
|
||||||
|
if (newEnd > numFrames) {
|
||||||
|
newEnd = numFrames;
|
||||||
|
newStart = Math.max(0, newEnd - newWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { start: newStart, end: newEnd };
|
||||||
|
}
|
Loading…
Reference in New Issue