From d136e00c59351d98d67ab56d8cdf3bc6de30818e Mon Sep 17 00:00:00 2001 From: Rob Watson Date: Sun, 16 Jan 2022 08:58:07 +0100 Subject: [PATCH] Refactor zoom in/out, add test coverage --- frontend/src/App.tsx | 41 +++--- frontend/src/helpers/zoom.test.ts | 222 ++++++++++++++++++++++++++++++ frontend/src/helpers/zoom.ts | 76 ++++++++++ 3 files changed, 321 insertions(+), 18 deletions(-) create mode 100644 frontend/src/helpers/zoom.test.ts create mode 100644 frontend/src/helpers/zoom.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a89456b..8a10f75 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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( diff --git a/frontend/src/helpers/zoom.test.ts b/frontend/src/helpers/zoom.test.ts new file mode 100644 index 0000000..5c8a522 --- /dev/null +++ b/frontend/src/helpers/zoom.test.ts @@ -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 }); + }); + }); +}); diff --git a/frontend/src/helpers/zoom.ts b/frontend/src/helpers/zoom.ts new file mode 100644 index 0000000..a4fa0a6 --- /dev/null +++ b/frontend/src/helpers/zoom.ts @@ -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 }; +}