Refactor zoom in/out, add test coverage
continuous-integration/drone/push Build is passing Details

This commit is contained in:
Rob Watson 2022-01-16 08:58:07 +01:00
parent aa4d235c0c
commit d136e00c59
3 changed files with 321 additions and 18 deletions

View File

@ -17,6 +17,7 @@ import './App.css';
import { firstValueFrom, from, Observable } from 'rxjs';
import { first, map } from 'rxjs/operators';
import millisFromDuration from './helpers/millisFromDuration';
import { zoomViewportIn, zoomViewportOut } from './helpers/zoom';
// ported from backend, where should they live?
const thumbnailWidth = 177;
@ -24,6 +25,8 @@ const thumbnailHeight = 100;
const initialViewportCanvasPixels = 100;
const zoomFactor = 2;
const apiURL = process.env.REACT_APP_API_URL || 'http://localhost:8888';
// Frames represents a range of audio frames.
@ -309,30 +312,32 @@ function App(): JSX.Element {
if (mediaSet == null) {
return;
}
if (viewport.start == viewport.end) {
return;
}
setViewport({
...viewport,
end: viewport.end - Math.round((viewport.end - viewport.start) / 2),
});
const newViewport = zoomViewportIn(
viewport,
mediaSet.audioFrames,
selection,
currentTimeToFrame(positionRef.current.currentTime),
zoomFactor
);
setViewport(newViewport);
};
const handleZoomOut = () => {
if (mediaSet == null) {
return;
}
if (viewport.start == viewport.end) {
return;
}
let end = viewport.end + Math.round(viewport.end - viewport.start);
if (end > mediaSet.audioFrames) {
end = mediaSet.audioFrames;
}
setViewport({
...viewport,
end: end,
});
const newViewport = zoomViewportOut(
viewport,
mediaSet.audioFrames,
selection,
currentTimeToFrame(positionRef.current.currentTime),
zoomFactor
);
setViewport(newViewport);
};
const setPositionFromFrame = useCallback(

View File

@ -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 });
});
});
});

View File

@ -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 };
}