From 8dd4b878f480f0171c28b73d0e859454238cea58 Mon Sep 17 00:00:00 2001 From: Ashley Anderson Date: Fri, 29 Mar 2024 22:28:37 -0400 Subject: [PATCH 01/13] Huge refactor into separate components. --- src/components/app.tsx | 231 ++++++++++++++++++++--- src/components/dataControls.tsx | 61 +++++++ src/components/playbackControls.tsx | 45 +++++ src/components/scene.tsx | 274 +++------------------------- src/css/app.css | 7 +- src/hooks/useSelectionBox.ts | 2 +- src/lib/PointCanvas.ts | 4 +- 7 files changed, 341 insertions(+), 283 deletions(-) create mode 100644 src/components/dataControls.tsx create mode 100644 src/components/playbackControls.tsx diff --git a/src/components/app.tsx b/src/components/app.tsx index 9fb021dc..b39ca786 100644 --- a/src/components/app.tsx +++ b/src/components/app.tsx @@ -1,41 +1,218 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import "@/css/app.css"; + +import { Stack } from "@mui/material"; + import Scene from "@/components/scene.tsx"; +import DataControls from "@/components/dataControls.tsx"; +import PlaybackControls from "@/components/playbackControls.tsx"; + +import useSelectionBox from "@/hooks/useSelectionBox"; -const aspectRatio = 4 / 3; +import { ViewerState, clearUrlHash } from "@/lib/ViewerState"; +import { TrackManager, loadTrackManager } from "@/lib/TrackManager"; +import { PointCanvas } from "@/lib/PointCanvas"; + +// Ideally we do this here so that we can use initial values as default values for React state. +const initialViewerState = ViewerState.fromUrlHash(window.location.hash); +console.log("initial viewer state: %s", JSON.stringify(initialViewerState)); +clearUrlHash(); export default function App() { - const [renderWidth, setRenderWidth] = useState(800); - - function handleWindowResize() { - const windowWidth = window.innerWidth; - const appPadding = parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--app-padding")); - let w: number; - if (windowWidth < 800) { - w = windowWidth; - } else if (windowWidth < 1200) { - w = 800; - } else if (windowWidth < 1600) { - w = 1024; + // Use references here for two things: + // * manage objects that should never change, even when the component re-renders + // * avoid triggering re-renders when these *do* change + + // data state + const [dataUrl, setDataUrl] = useState(initialViewerState.dataUrl); + const [trackManager, setTrackManager] = useState(null); + const canvas = useRef(null); + const [loading, setLoading] = useState(false); + + const { selectedPoints } = useSelectionBox(canvas.current); + const [trackHighlightLength, setTrackHighlightLength] = useState(11); + + // playback state + const [autoRotate, setAutoRotate] = useState(false); + const [playing, setPlaying] = useState(false); + const [curTime, setCurTime] = useState(initialViewerState.curTime); + const [numTimes, setNumTimes] = useState(0); + + // Manage shareable state than can persist across sessions. + const copyShareableUrlToClipboard = () => { + console.log("copy shareable URL to clipboard"); + const state = new ViewerState( + dataUrl, + curTime, + canvas.current!.camera.position, + canvas.current!.controls.target, + ); + const url = window.location.toString() + "#" + state.toUrlHash(); + navigator.clipboard.writeText(url); + }; + + // const setStateFromHash = () => { + // const state = ViewerState.fromUrlHash(window.location.hash); + // clearUrlHash(); + // setDataUrl(state.dataUrl); + // setCurTime(state.curTime); + // canvas.current?.setCameraProperties(state.cameraPosition, state.cameraTarget); + // }; + + // update the array when the dataUrl changes + useEffect(() => { + console.log("load data from %s", dataUrl); + const trackManager = loadTrackManager(dataUrl); + // TODO: add clean-up by returning another closure + trackManager.then((tm: TrackManager | null) => { + setTrackManager(tm); + setNumTimes(tm?.points.shape[0] || numTimes); + // Defend against the case when a curTime valid for previous data + // is no longer valid. + setCurTime(Math.min(curTime, tm?.points.shape[0] - 1 || numTimes - 1)); + }); + }, [dataUrl]); + + // update the geometry buffers when the array changes + // TODO: do this in the above useEffect + useEffect(() => { + if (!trackManager || !canvas.current) return; + canvas.current.initPointsGeometry(trackManager.maxPointsPerTimepoint); + }, [trackManager]); + + // update the points when the array or timepoint changes + useEffect(() => { + // show a loading indicator if the fetch takes longer than 10ms (avoid flicker) + const loadingTimer = setTimeout(() => setLoading(true), 100); + let ignore = false; + // TODO: this is a very basic attempt to prevent stale data + // in addition, we should debounce the input and verify the data is current + // before rendering it + console.log("trackManager: %s", trackManager); + console.log("canvas.current: %s", canvas.current); + if (canvas.current && trackManager && !ignore) { + const getPoints = async (canvas: PointCanvas, time: number) => { + console.debug("fetch points at time %d", time); + const data = await trackManager.fetchPointsAtTime(time); + console.debug("got %d points for time %d", data.length / 3, time); + + if (ignore) { + console.debug("IGNORE SET points at time %d", time); + return; + } + + // clearTimeout(loadingTimer); + setTimeout(() => setLoading(false), 250); + setLoading(false); + canvas.setPointsPositions(data); + canvas.resetPointColors(); + }; + getPoints(canvas.current, curTime); } else { - w = 1200; + // clearTimeout(loadingTimer); + setTimeout(() => setLoading(false), 250); + setLoading(false); + console.debug("IGNORE FETCH points at time %d", curTime); + } + + // stop playback if there is no data + if (!trackManager) { + setPlaying(false); } - let renderWidth = w - appPadding * 2; - renderWidth = renderWidth < 0 ? windowWidth : renderWidth; - setRenderWidth(renderWidth); - } - useEffect(() => { - handleWindowResize(); - window.addEventListener("resize", handleWindowResize); return () => { - window.removeEventListener("resize", handleWindowResize); + clearTimeout(loadingTimer); + ignore = true; + }; + }, [trackManager, curTime]); + + useEffect(() => { + // update the track highlights + const minTime = curTime - trackHighlightLength / 2; + const maxTime = curTime + trackHighlightLength / 2; + canvas.current?.updateAllTrackHighlights(minTime, maxTime); + }, [curTime, trackHighlightLength]); + + useEffect(() => { + const pointsID = canvas.current?.points.id || -1; + if (!selectedPoints || !selectedPoints.has(pointsID)) return; + // keep track of which tracks we are adding to avoid duplicate fetching + const adding = new Set(); + + // this fetches the entire lineage for each track + const fetchAndAddTrack = async (pointID: number) => { + if (!canvas.current || !trackManager) return; + const minTime = curTime - trackHighlightLength / 2; + const maxTime = curTime + trackHighlightLength / 2; + const tracks = await trackManager.fetchTrackIDsForPoint(pointID); + // TODO: points actually only belong to one track, so can get rid of the outer loop + for (const t of tracks) { + const lineage = await trackManager.fetchLineageForTrack(t); + for (const l of lineage) { + if (adding.has(l) || canvas.current.tracks.has(l)) continue; + adding.add(l); + const [pos, ids] = await trackManager.fetchPointsForTrack(l); + const newTrack = canvas.current.addTrack(l, pos, ids); + newTrack?.updateHighlightLine(minTime, maxTime); + } + } }; - }, [renderWidth]); + + const selected = selectedPoints.get(pointsID) || []; + canvas.current?.highlightPoints(selected); + + const maxPointsPerTimepoint = trackManager?.maxPointsPerTimepoint || 0; + Promise.all(selected.map((p: number) => curTime * maxPointsPerTimepoint + p).map(fetchAndAddTrack)); + // TODO: cancel the fetch if the selection changes? + }, [selectedPoints]); + + // TODO: maybe can be done without useEffect? + // could be a prop into the Scene component + useEffect(() => { + console.log("autoRotate: %s", autoRotate); + if (canvas.current) { + canvas.current.controls.autoRotate = autoRotate; + } + }, [autoRotate]); + + // playback time points + // TODO: this is basic and may drop frames + useEffect(() => { + if (playing) { + const frameDelay = 1000 / 8; // 1000 / fps + const interval = setInterval(() => { + setCurTime((curTime + 1) % numTimes); + }, frameDelay); + return () => { + clearInterval(interval); + }; + } + }, [numTimes, curTime, playing]); return ( - <> - - + + canvas.current?.removeAllTracks()} + /> + + + ); } diff --git a/src/components/dataControls.tsx b/src/components/dataControls.tsx new file mode 100644 index 00000000..afcc916b --- /dev/null +++ b/src/components/dataControls.tsx @@ -0,0 +1,61 @@ +import { TrackManager } from "@/lib/TrackManager"; +import { Button, ButtonIcon, InputSlider, InputText } from "@czi-sds/components"; +import { Stack } from "@mui/material"; + +interface DataControlsProps { + dataUrl: string; + initialDataUrl: string; + trackManager: TrackManager | null; + trackHighlightLength: number; + setDataUrl: (dataUrl: string) => void; + setTrackManager: (trackManager: TrackManager) => void; + setTrackHighlightLength: (trackHighlightLength: number) => void; + copyShareableUrlToClipboard: () => void; + clearTracks: () => void; +} + +export default function DataControls(props: DataControlsProps) { + const numTimes = props.trackManager?.points.shape[0] ?? 0; + const trackLengthPct = numTimes ? (props.trackHighlightLength / 2 / numTimes) * 100 : 0; + + console.log("trackLengthPct: %s", props.trackManager?.maxPointsPerTimepoint); + return ( + + props.setDataUrl(e.target.value)} + fullWidth={true} + intent={props.trackManager ? "default" : "error"} + /> + `${value}%`} + onChange={(_, value) => { + if (!props.trackManager) return; + const v = ((value as number) / 100) * 2 * numTimes; + props.setTrackHighlightLength(v); + }} + value={Math.round(trackLengthPct)} + /> + + + + + + ); +} diff --git a/src/components/playbackControls.tsx b/src/components/playbackControls.tsx new file mode 100644 index 00000000..0d9fc8c0 --- /dev/null +++ b/src/components/playbackControls.tsx @@ -0,0 +1,45 @@ +import { Stack } from "@mui/material"; + +import { InputSlider, InputToggle } from "@czi-sds/components"; + +interface PlaybackControlsProps { + enabled: boolean; + autoRotate: boolean; + playing: boolean; + curTime: number; + numTimes: number; + setAutoRotate: (isRotating: boolean) => void; + setPlaying: (isPlaying: boolean) => void; + setCurTime: (curTime: number) => void; +} + +export default function PlaybackControls(props: PlaybackControlsProps) { + return ( + + { + props.setAutoRotate((e.target as HTMLInputElement).checked); + }} + /> + { + props.setPlaying((e.target as HTMLInputElement).checked); + }} + /> + props.setCurTime(value as number)} + value={props.curTime} + /> + + ); +} diff --git a/src/components/scene.tsx b/src/components/scene.tsx index e646ffcf..9e28279c 100644 --- a/src/components/scene.tsx +++ b/src/components/scene.tsx @@ -1,15 +1,13 @@ -import { useEffect, useRef, useState } from "react"; -import { Button, InputSlider, InputText, InputToggle, LoadingIndicator } from "@czi-sds/components"; +import { useEffect, useRef } from "react"; import { PointCanvas } from "@/lib/PointCanvas"; -import { TrackManager, loadTrackManager } from "@/lib/TrackManager"; import { ViewerState, clearUrlHash } from "@/lib/ViewerState"; - -import useSelectionBox from "@/hooks/useSelectionBox"; +import { LoadingIndicator } from "@czi-sds/components"; interface SceneProps { - renderWidth?: number; - renderHeight?: number; + curTime: number; + canvas: React.MutableRefObject; + loading: boolean; } // Ideally we do this here so that we can use initial values as default values for React state. @@ -18,57 +16,23 @@ console.log("initial viewer state: %s", JSON.stringify(initialViewerState)); clearUrlHash(); export default function Scene(props: SceneProps) { - const renderWidth = props.renderWidth || 800; - const renderHeight = props.renderHeight || 600; + const canvas = props.canvas; // Use references here for two things: // * manage objects that should never change, even when the component re-renders // * avoid triggering re-renders when these *do* change const divRef: React.RefObject = useRef(null); - const canvas = useRef(); - - // Primary state that determines configuration of application. - const [dataUrl, setDataUrl] = useState(initialViewerState.dataUrl); - const [curTime, setCurTime] = useState(initialViewerState.curTime); - const [autoRotate, setAutoRotate] = useState(false); - const [playing, setPlaying] = useState(false); - - // Other state that is not or does not need to be persisted. - const [trackManager, setTrackManager] = useState(null); - const [numTimes, setNumTimes] = useState(0); - const [trackHighlightLength, setTrackHighlightLength] = useState(11); - const [loading, setLoading] = useState(false); - const { selectedPoints } = useSelectionBox(canvas.current); - - // Manage shareable state than can persist across sessions. - const copyShareableUrlToClipboard = () => { - const state = new ViewerState( - dataUrl, - curTime, - canvas.current!.camera.position, - canvas.current!.controls.target, - ); - const url = window.location.toString() + "#" + state.toUrlHash(); - navigator.clipboard.writeText(url); - }; - const setStateFromHash = () => { - const state = ViewerState.fromUrlHash(window.location.hash); - clearUrlHash(); - setDataUrl(state.dataUrl); - setCurTime(state.curTime); - canvas.current?.setCameraProperties(state.cameraPosition, state.cameraTarget); - }; + const renderWidth = divRef.current?.clientWidth || 800; + const renderHeight = divRef.current?.clientHeight || 600; // this useEffect is intended to make this part run only on mount // this requires keeping the dependency array empty useEffect(() => { // initialize the canvas + console.log("initialize canvas"); canvas.current = new PointCanvas(renderWidth, renderHeight); canvas.current!.setCameraProperties(initialViewerState.cameraPosition, initialViewerState.cameraTarget); - // handle any changes to the hash after the initial document has loaded - window.addEventListener("hashchange", setStateFromHash); - // append renderer canvas const divCurrent = divRef.current; const renderer = canvas.current!.renderer; @@ -77,220 +41,30 @@ export default function Scene(props: SceneProps) { // start animating - this keeps the scene rendering when controls change, etc. canvas.current.animate(); + const handleWindowResize = () => { + if (!divCurrent) return; + console.log("resize canvas", divCurrent.offsetWidth, divCurrent.offsetHeight); + const renderWidth = divCurrent.offsetWidth; + const renderHeight = 0.9 * divCurrent.offsetHeight; + canvas.current?.setSize(renderWidth, renderHeight); + }; + window.addEventListener("resize", handleWindowResize); + handleWindowResize(); + return () => { - window.removeEventListener("hashchange", setStateFromHash); + window.removeEventListener("resize", handleWindowResize); renderer.domElement.remove(); canvas.current?.dispose(); }; }, []); // dependency array must be empty to run only on mount! - useEffect(() => { - const pointsID = canvas.current?.points.id || -1; - if (!selectedPoints || !selectedPoints.has(pointsID)) return; - // keep track of which tracks we are adding to avoid duplicate fetching - const adding = new Set(); - - // this fetches the entire lineage for each track - const fetchAndAddTrack = async (pointID: number) => { - if (!canvas.current || !trackManager) return; - const minTime = curTime - trackHighlightLength / 2; - const maxTime = curTime + trackHighlightLength / 2; - const tracks = await trackManager.fetchTrackIDsForPoint(pointID); - // TODO: points actually only belong to one track, so can get rid of the outer loop - for (const t of tracks) { - const lineage = await trackManager.fetchLineageForTrack(t); - for (const l of lineage) { - if (adding.has(l) || canvas.current.tracks.has(l)) continue; - adding.add(l); - const [pos, ids] = await trackManager.fetchPointsForTrack(l); - const newTrack = canvas.current.addTrack(l, pos, ids); - newTrack?.updateHighlightLine(minTime, maxTime); - } - } - }; - - const selected = selectedPoints.get(pointsID) || []; - canvas.current?.highlightPoints(selected); - - const maxPointsPerTimepoint = trackManager?.maxPointsPerTimepoint || 0; - Promise.all(selected.map((p: number) => curTime * maxPointsPerTimepoint + p).map(fetchAndAddTrack)); - // TODO: cancel the fetch if the selection changes? - }, [selectedPoints]); - - // update the array when the dataUrl changes - useEffect(() => { - console.log("load data from %s", dataUrl); - const trackManager = loadTrackManager(dataUrl); - // TODO: add clean-up by returning another closure - trackManager.then((tm: TrackManager | null) => { - setTrackManager(tm); - setNumTimes(tm?.points.shape[0] || numTimes); - // Defend against the case when a curTime valid for previous data - // is no longer valid. - setCurTime(Math.min(curTime, tm?.points.shape[0] - 1 || numTimes - 1)); - }); - }, [dataUrl]); - - // set the controls to auto-rotate - useEffect(() => { - canvas.current && (canvas.current.controls.autoRotate = autoRotate); - }, [autoRotate]); - - // playback time points - // TODO: this is basic and may drop frames - useEffect(() => { - if (playing) { - const frameDelay = 1000 / 8; // 1000 / fps - const interval = setInterval(() => { - setCurTime((curTime + 1) % numTimes); - }, frameDelay); - return () => { - clearInterval(interval); - }; - } - }, [numTimes, curTime, playing]); - - // update the geometry buffers when the array changes - useEffect(() => { - if (!trackManager || !canvas.current) return; - canvas.current.initPointsGeometry(trackManager.maxPointsPerTimepoint); - }, [trackManager]); - - // update the points when the array or timepoint changes - useEffect(() => { - // show a loading indicator if the fetch takes longer than 10ms (avoid flicker) - const loadingTimer = setTimeout(() => setLoading(true), 10); - let ignore = false; - // TODO: this is a very basic attempt to prevent stale data - // in addition, we should debounce the input and verify the data is current - // before rendering it - if (canvas.current && trackManager && !ignore) { - const getPoints = async (canvas: PointCanvas, time: number) => { - console.debug("fetch points at time %d", time); - const data = await trackManager.fetchPointsAtTime(time); - console.debug("got %d points for time %d", data.length / 3, time); - - if (ignore) { - console.debug("IGNORE SET points at time %d", time); - return; - } - - clearTimeout(loadingTimer); - setLoading(false); - canvas.setPointsPositions(data); - canvas.resetPointColors(); - }; - getPoints(canvas.current, curTime); - } else { - clearTimeout(loadingTimer); - setLoading(false); - console.debug("IGNORE FETCH points at time %d", curTime); - } - - // stop playback if there is no data - if (!trackManager) { - setPlaying(false); - } - - return () => { - clearTimeout(loadingTimer); - ignore = true; - }; - }, [trackManager, curTime]); - - useEffect(() => { - // update the track highlights - const minTime = curTime - trackHighlightLength / 2; - const maxTime = curTime + trackHighlightLength / 2; - canvas.current?.updateAllTrackHighlights(minTime, maxTime); - }, [curTime, trackHighlightLength]); - - // update the renderer and composer when the render size changes - // TODO: check performance and avoid if unchanged - canvas.current?.setSize(renderWidth, renderHeight); - - // set up marks for the time slider - const spacing = 100; - const marks = [...Array(Math.round(numTimes / spacing)).keys()].map((i) => ({ - value: i * spacing, - label: i * spacing, - })); - marks.push({ value: numTimes - 1, label: numTimes - 1 }); + const loading = props.loading ? "visible" : "hidden"; return ( -
-
-
- setDataUrl(e.target.value)} - fullWidth={true} - intent={trackManager ? "default" : "error"} - /> - setCurTime(value as number)} - marks={marks} - value={curTime} - /> - setTrackHighlightLength(value as number)} - marks={marks} - value={trackHighlightLength} - /> -
- { - setAutoRotate((e.target as HTMLInputElement).checked); - }} - /> - { - setPlaying((e.target as HTMLInputElement).checked); - }} - /> - - -
-
+
+
+
- {loading && }
); } diff --git a/src/css/app.css b/src/css/app.css index 3a528568..3389e140 100644 --- a/src/css/app.css +++ b/src/css/app.css @@ -3,9 +3,10 @@ } #app { - max-width: 1280px; - margin: 0 auto; - padding: var(--app-padding); + /* margin: 0 auto; */ + /* padding: var(--app-padding); */ + margin: auto; + padding: 0px; display: flex; flex-direction: column; justify-content: center; diff --git a/src/hooks/useSelectionBox.ts b/src/hooks/useSelectionBox.ts index be5100ca..7b57ef10 100644 --- a/src/hooks/useSelectionBox.ts +++ b/src/hooks/useSelectionBox.ts @@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from "react"; import { PointSelectionBox, PointsCollection } from "@/lib/PointSelectionBox"; import { PointCanvas } from "@/lib/PointCanvas"; -export default function useSelectionBox(canvas: PointCanvas | undefined) { +export default function useSelectionBox(canvas: PointCanvas | null) { const [selecting, setSelecting] = useState(false); const [selectedPoints, setSelectedPoints] = useState(); diff --git a/src/lib/PointCanvas.ts b/src/lib/PointCanvas.ts index b53ac834..9a8fc86c 100644 --- a/src/lib/PointCanvas.ts +++ b/src/lib/PointCanvas.ts @@ -39,9 +39,9 @@ export class PointCanvas { // private here to consolidate external access via `TrackManager` instead private maxPointsPerTimepoint = 0; - constructor(width: number, height: number) { + constructor(width: number, height: number, canvas?: HTMLCanvasElement) { this.scene = new Scene(); - this.renderer = new WebGLRenderer(); + this.renderer = new WebGLRenderer({ canvas: canvas }); this.camera = new PerspectiveCamera( 35, // FOV From f3407e3e4d34a97133c42c4c79a9d0b4d37e8d02 Mon Sep 17 00:00:00 2001 From: Ashley Anderson Date: Fri, 29 Mar 2024 22:33:14 -0400 Subject: [PATCH 02/13] Remove unused CSS, add label for track length slider --- src/components/dataControls.tsx | 1 + src/css/app.css | 6 ------ 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/src/components/dataControls.tsx b/src/components/dataControls.tsx index afcc916b..27a4138e 100644 --- a/src/components/dataControls.tsx +++ b/src/components/dataControls.tsx @@ -36,6 +36,7 @@ export default function DataControls(props: DataControlsProps) { disabled={!props.trackManager} min={0} max={100} + valueLabelDisplay="auto" valueLabelFormat={(value) => `${value}%`} onChange={(_, value) => { if (!props.trackManager) return; diff --git a/src/css/app.css b/src/css/app.css index 3389e140..7202c795 100644 --- a/src/css/app.css +++ b/src/css/app.css @@ -1,10 +1,4 @@ -:root { - --app-padding: 32px; -} - #app { - /* margin: 0 auto; */ - /* padding: var(--app-padding); */ margin: auto; padding: 0px; display: flex; From a9bc3c7f502771c5c8b50806c267d0c447afd635 Mon Sep 17 00:00:00 2001 From: Ashley Anderson Date: Fri, 29 Mar 2024 22:41:10 -0400 Subject: [PATCH 03/13] Add labels --- src/components/dataControls.tsx | 1 + src/components/playbackControls.tsx | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/components/dataControls.tsx b/src/components/dataControls.tsx index 27a4138e..6346bec6 100644 --- a/src/components/dataControls.tsx +++ b/src/components/dataControls.tsx @@ -30,6 +30,7 @@ export default function DataControls(props: DataControlsProps) { fullWidth={true} intent={props.trackManager ? "default" : "error"} /> + + + Date: Fri, 29 Mar 2024 23:07:21 -0400 Subject: [PATCH 04/13] Update our one lonely test --- __tests__/main.test.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/__tests__/main.test.tsx b/__tests__/main.test.tsx index af4b845f..ed8bff14 100644 --- a/__tests__/main.test.tsx +++ b/__tests__/main.test.tsx @@ -1,6 +1,7 @@ import { expect, test } from "vitest"; -import Scene from "@/components/scene"; +import { PointCanvas } from "../src/lib/PointCanvas.ts"; +import Scene from "../src/components/scene"; import React from "react"; import { render } from "@testing-library/react"; @@ -9,6 +10,9 @@ test("tests work", () => { }); test("render Scene", () => { - const { container } = render(); + const canvasRef = { + current: new PointCanvas(800, 600), + }; + const { container } = render(); expect(container).not.toBeNull(); }); From 7626608fac39f332abe7ed4c21318268d814a7a0 Mon Sep 17 00:00:00 2001 From: Ashley Anderson Date: Sat, 30 Mar 2024 10:16:11 -0400 Subject: [PATCH 05/13] Remove some unused props and args --- __tests__/main.test.tsx | 2 +- src/components/app.tsx | 2 +- src/components/scene.tsx | 2 -- src/lib/PointCanvas.ts | 4 ++-- 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/__tests__/main.test.tsx b/__tests__/main.test.tsx index ed8bff14..0a485c38 100644 --- a/__tests__/main.test.tsx +++ b/__tests__/main.test.tsx @@ -13,6 +13,6 @@ test("render Scene", () => { const canvasRef = { current: new PointCanvas(800, 600), }; - const { container } = render(); + const { container } = render(); expect(container).not.toBeNull(); }); diff --git a/src/components/app.tsx b/src/components/app.tsx index b39ca786..0907abda 100644 --- a/src/components/app.tsx +++ b/src/components/app.tsx @@ -202,7 +202,7 @@ export default function App() { copyShareableUrlToClipboard={copyShareableUrlToClipboard} clearTracks={() => canvas.current?.removeAllTracks()} /> - + ; loading: boolean; } @@ -29,7 +28,6 @@ export default function Scene(props: SceneProps) { // this requires keeping the dependency array empty useEffect(() => { // initialize the canvas - console.log("initialize canvas"); canvas.current = new PointCanvas(renderWidth, renderHeight); canvas.current!.setCameraProperties(initialViewerState.cameraPosition, initialViewerState.cameraTarget); diff --git a/src/lib/PointCanvas.ts b/src/lib/PointCanvas.ts index 9a8fc86c..b53ac834 100644 --- a/src/lib/PointCanvas.ts +++ b/src/lib/PointCanvas.ts @@ -39,9 +39,9 @@ export class PointCanvas { // private here to consolidate external access via `TrackManager` instead private maxPointsPerTimepoint = 0; - constructor(width: number, height: number, canvas?: HTMLCanvasElement) { + constructor(width: number, height: number) { this.scene = new Scene(); - this.renderer = new WebGLRenderer({ canvas: canvas }); + this.renderer = new WebGLRenderer(); this.camera = new PerspectiveCamera( 35, // FOV From 3acdf477d356abbbe440782cf147a6942d8d622e Mon Sep 17 00:00:00 2001 From: Ashley Anderson Date: Tue, 2 Apr 2024 10:10:52 -0400 Subject: [PATCH 06/13] Re-add hash change listener --- src/components/app.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/components/app.tsx b/src/components/app.tsx index 0907abda..0ff7d3e6 100644 --- a/src/components/app.tsx +++ b/src/components/app.tsx @@ -51,13 +51,14 @@ export default function App() { navigator.clipboard.writeText(url); }; - // const setStateFromHash = () => { - // const state = ViewerState.fromUrlHash(window.location.hash); - // clearUrlHash(); - // setDataUrl(state.dataUrl); - // setCurTime(state.curTime); - // canvas.current?.setCameraProperties(state.cameraPosition, state.cameraTarget); - // }; + const setStateFromHash = () => { + const state = ViewerState.fromUrlHash(window.location.hash); + clearUrlHash(); + setDataUrl(state.dataUrl); + setCurTime(state.curTime); + canvas.current?.setCameraProperties(state.cameraPosition, state.cameraTarget); + }; + window.addEventListener("hashchange", setStateFromHash); // update the array when the dataUrl changes useEffect(() => { From 836b0ea0899b10d8ecda045d49050f45da7e11e1 Mon Sep 17 00:00:00 2001 From: Ashley Anderson Date: Tue, 2 Apr 2024 12:24:41 -0400 Subject: [PATCH 07/13] Fix hash change listener --- src/components/app.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/app.tsx b/src/components/app.tsx index 0ff7d3e6..cfafee15 100644 --- a/src/components/app.tsx +++ b/src/components/app.tsx @@ -58,7 +58,13 @@ export default function App() { setCurTime(state.curTime); canvas.current?.setCameraProperties(state.cameraPosition, state.cameraTarget); }; - window.addEventListener("hashchange", setStateFromHash); + // update the state when the hash changes, but only register the listener once + useEffect(() => { + window.addEventListener("hashchange", setStateFromHash); + return () => { + window.removeEventListener("hashchange", setStateFromHash); + }; + }, []); // update the array when the dataUrl changes useEffect(() => { From b8836ae00047108073b68201892764a29301b24a Mon Sep 17 00:00:00 2001 From: Ashley Anderson Date: Tue, 2 Apr 2024 15:34:33 -0400 Subject: [PATCH 08/13] Pass initial camera properties to Scene --- src/components/app.tsx | 7 ++++++- src/components/scene.tsx | 10 +++------- src/lib/PointCanvas.ts | 6 +++--- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/components/app.tsx b/src/components/app.tsx index cfafee15..686c8ddd 100644 --- a/src/components/app.tsx +++ b/src/components/app.tsx @@ -209,7 +209,12 @@ export default function App() { copyShareableUrlToClipboard={copyShareableUrlToClipboard} clearTracks={() => canvas.current?.removeAllTracks()} /> - + ; loading: boolean; } -// Ideally we do this here so that we can use initial values as default values for React state. -const initialViewerState = ViewerState.fromUrlHash(window.location.hash); -console.log("initial viewer state: %s", JSON.stringify(initialViewerState)); -clearUrlHash(); - export default function Scene(props: SceneProps) { const canvas = props.canvas; @@ -29,7 +25,7 @@ export default function Scene(props: SceneProps) { useEffect(() => { // initialize the canvas canvas.current = new PointCanvas(renderWidth, renderHeight); - canvas.current!.setCameraProperties(initialViewerState.cameraPosition, initialViewerState.cameraTarget); + canvas.current!.setCameraProperties(props.initialCameraPosition, props.initialCameraTarget); // append renderer canvas const divCurrent = divRef.current; diff --git a/src/lib/PointCanvas.ts b/src/lib/PointCanvas.ts index b53ac834..e75554fe 100644 --- a/src/lib/PointCanvas.ts +++ b/src/lib/PointCanvas.ts @@ -93,9 +93,9 @@ export class PointCanvas { this.controls.update(); }; - setCameraProperties(position: Vector3, target: Vector3) { - this.camera.position.set(position.x, position.y, position.z); - this.controls.target.set(target.x, target.y, target.z); + setCameraProperties(position?: Vector3, target?: Vector3) { + position && this.camera.position.set(position.x, position.y, position.z); + target && this.controls.target.set(target.x, target.y, target.z); } highlightPoints(points: number[]) { From 97e508bca7f427da92fbcc88348327d7e120e4a9 Mon Sep 17 00:00:00 2001 From: Ashley Anderson Date: Tue, 2 Apr 2024 21:00:41 -0400 Subject: [PATCH 09/13] Fix layout issues from code review --- src/components/app.tsx | 6 +++--- src/components/dataControls.tsx | 2 +- src/components/playbackControls.tsx | 2 +- src/components/scene.tsx | 27 ++++++++++++++++++--------- src/css/app.css | 5 +++-- src/css/index.css | 21 ++++----------------- 6 files changed, 30 insertions(+), 33 deletions(-) diff --git a/src/components/app.tsx b/src/components/app.tsx index 686c8ddd..e0057055 100644 --- a/src/components/app.tsx +++ b/src/components/app.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef } from "react"; import "@/css/app.css"; -import { Stack } from "@mui/material"; +import { Box } from "@mui/material"; import Scene from "@/components/scene.tsx"; import DataControls from "@/components/dataControls.tsx"; @@ -197,7 +197,7 @@ export default function App() { }, [numTimes, curTime, playing]); return ( - + - + ); } diff --git a/src/components/dataControls.tsx b/src/components/dataControls.tsx index 6346bec6..28aee7c8 100644 --- a/src/components/dataControls.tsx +++ b/src/components/dataControls.tsx @@ -20,7 +20,7 @@ export default function DataControls(props: DataControlsProps) { console.log("trackLengthPct: %s", props.trackManager?.maxPointsPerTimepoint); return ( - + + { if (!divCurrent) return; - console.log("resize canvas", divCurrent.offsetWidth, divCurrent.offsetHeight); - const renderWidth = divCurrent.offsetWidth; - const renderHeight = 0.9 * divCurrent.offsetHeight; + const renderWidth = divCurrent.clientWidth; + const renderHeight = divCurrent.clientHeight; canvas.current?.setSize(renderWidth, renderHeight); }; window.addEventListener("resize", handleWindowResize); handleWindowResize(); return () => { - window.removeEventListener("resize", handleWindowResize); renderer.domElement.remove(); canvas.current?.dispose(); }; @@ -55,10 +54,20 @@ export default function Scene(props: SceneProps) { const loading = props.loading ? "visible" : "hidden"; return ( -
-
+ + -
-
+ + ); } diff --git a/src/css/app.css b/src/css/app.css index 7202c795..4afc46e5 100644 --- a/src/css/app.css +++ b/src/css/app.css @@ -1,8 +1,9 @@ #app { - margin: auto; - padding: 0px; + height: 100vh; + width: 100vw; display: flex; flex-direction: column; justify-content: center; align-items: center; + overflow: hidden; } diff --git a/src/css/index.css b/src/css/index.css index 4686cee8..66273db2 100644 --- a/src/css/index.css +++ b/src/css/index.css @@ -1,3 +1,7 @@ +body { + margin: 0; +} + :root { font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; line-height: 1.5; @@ -20,23 +24,6 @@ } } -.inputcontainer { - width: 100%; /* Full-width */ - margin: 1em 0; - display: flex; - gap: 1em; - flex-direction: column; -} - -.buttoncontainer { - width: 100%; /* Full-width */ - margin: 1em 0; - display: flex; - flex-direction: row; - gap: 1em; - justify-content: flex-start; -} - /* From https://github.com/mrdoob/three.js/blob/master/examples/misc_boxselection.html */ .selectBox { border: 1px solid #ffffff; From 730bfc7fe185f5921c5c70e9ba5b010b7c0a5093 Mon Sep 17 00:00:00 2001 From: Ashley Anderson Date: Tue, 2 Apr 2024 21:00:57 -0400 Subject: [PATCH 10/13] Attempt to fix selection box initialization issue --- src/components/app.tsx | 2 +- src/hooks/useSelectionBox.ts | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/components/app.tsx b/src/components/app.tsx index e0057055..6c47e729 100644 --- a/src/components/app.tsx +++ b/src/components/app.tsx @@ -29,7 +29,7 @@ export default function App() { const canvas = useRef(null); const [loading, setLoading] = useState(false); - const { selectedPoints } = useSelectionBox(canvas.current); + const { selectedPoints } = useSelectionBox(canvas); const [trackHighlightLength, setTrackHighlightLength] = useState(11); // playback state diff --git a/src/hooks/useSelectionBox.ts b/src/hooks/useSelectionBox.ts index 7b57ef10..5dcc411d 100644 --- a/src/hooks/useSelectionBox.ts +++ b/src/hooks/useSelectionBox.ts @@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from "react"; import { PointSelectionBox, PointsCollection } from "@/lib/PointSelectionBox"; import { PointCanvas } from "@/lib/PointCanvas"; -export default function useSelectionBox(canvas: PointCanvas | null) { +export default function useSelectionBox(canvasRef: React.MutableRefObject) { const [selecting, setSelecting] = useState(false); const [selectedPoints, setSelectedPoints] = useState(); @@ -12,6 +12,7 @@ export default function useSelectionBox(canvas: PointCanvas | null) { const selectionHelper = useRef(); useEffect(() => { + const canvas = canvasRef.current; if (!canvas) { console.debug("canvas is undefined - deferring useSelectionBox setup"); return; @@ -89,14 +90,14 @@ export default function useSelectionBox(canvas: PointCanvas | null) { document.removeEventListener("keydown", keyDown); document.removeEventListener("keyup", keyUp); }; - }, [canvas]); + }, [canvasRef.current]); useEffect(() => { if (selectionHelper.current) { selectionHelper.current.enabled = selecting; } - if (canvas) { - canvas.controls.enabled = !selecting; + if (canvasRef.current) { + canvasRef.current.controls.enabled = !selecting; } }, [selecting]); From 4e5f8f6d3fe98797892e85f794f46874baf8c66b Mon Sep 17 00:00:00 2001 From: Ashley Anderson Date: Wed, 3 Apr 2024 09:43:15 -0400 Subject: [PATCH 11/13] Ignore errors for unused variables that start with _ --- .eslintrc.cjs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.eslintrc.cjs b/.eslintrc.cjs index d42dcf52..7745d22d 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -17,6 +17,19 @@ module.exports = { "semi": ["error", "always"], "quotes": ["error", "double"], "no-duplicate-imports": "error", + // ignore unused vars that start with _ + // https://stackoverflow.com/a/64067915/333308 + // note you must disable the base rule + // as it can report incorrect errors + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, + ], }, settings: { "import/resolver": { From b47e0f0cfa02395a592330d7445426614a485753 Mon Sep 17 00:00:00 2001 From: Ashley Anderson Date: Wed, 3 Apr 2024 09:44:33 -0400 Subject: [PATCH 12/13] Make canvas (PointCanvas) state instead of a ref to fix selection box initialization --- __tests__/main.test.tsx | 8 ++++--- src/components/app.tsx | 45 +++++++++++++++--------------------- src/components/scene.tsx | 22 ++++++++++-------- src/hooks/useSelectionBox.ts | 9 ++++---- 4 files changed, 40 insertions(+), 44 deletions(-) diff --git a/__tests__/main.test.tsx b/__tests__/main.test.tsx index 0a485c38..d128ef50 100644 --- a/__tests__/main.test.tsx +++ b/__tests__/main.test.tsx @@ -10,9 +10,11 @@ test("tests work", () => { }); test("render Scene", () => { - const canvasRef = { - current: new PointCanvas(800, 600), + let canvasState: PointCanvas | null = null; + const setCanvas = (canvas: PointCanvas) => { + canvasState = canvas; }; - const { container } = render(); + const { container } = render(); expect(container).not.toBeNull(); + expect(canvasState).not.toBeNull(); }); diff --git a/src/components/app.tsx b/src/components/app.tsx index 6c47e729..537dec0d 100644 --- a/src/components/app.tsx +++ b/src/components/app.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect } from "react"; import "@/css/app.css"; import { Box } from "@mui/material"; @@ -26,7 +26,7 @@ export default function App() { // data state const [dataUrl, setDataUrl] = useState(initialViewerState.dataUrl); const [trackManager, setTrackManager] = useState(null); - const canvas = useRef(null); + const [canvas, setCanvas] = useState(null); const [loading, setLoading] = useState(false); const { selectedPoints } = useSelectionBox(canvas); @@ -40,13 +40,9 @@ export default function App() { // Manage shareable state than can persist across sessions. const copyShareableUrlToClipboard = () => { + if (canvas === null) return; console.log("copy shareable URL to clipboard"); - const state = new ViewerState( - dataUrl, - curTime, - canvas.current!.camera.position, - canvas.current!.controls.target, - ); + const state = new ViewerState(dataUrl, curTime, canvas.camera.position, canvas.controls.target); const url = window.location.toString() + "#" + state.toUrlHash(); navigator.clipboard.writeText(url); }; @@ -56,7 +52,7 @@ export default function App() { clearUrlHash(); setDataUrl(state.dataUrl); setCurTime(state.curTime); - canvas.current?.setCameraProperties(state.cameraPosition, state.cameraTarget); + canvas?.setCameraProperties(state.cameraPosition, state.cameraTarget); }; // update the state when the hash changes, but only register the listener once useEffect(() => { @@ -83,8 +79,8 @@ export default function App() { // update the geometry buffers when the array changes // TODO: do this in the above useEffect useEffect(() => { - if (!trackManager || !canvas.current) return; - canvas.current.initPointsGeometry(trackManager.maxPointsPerTimepoint); + if (!trackManager || !canvas) return; + canvas.initPointsGeometry(trackManager.maxPointsPerTimepoint); }, [trackManager]); // update the points when the array or timepoint changes @@ -95,9 +91,7 @@ export default function App() { // TODO: this is a very basic attempt to prevent stale data // in addition, we should debounce the input and verify the data is current // before rendering it - console.log("trackManager: %s", trackManager); - console.log("canvas.current: %s", canvas.current); - if (canvas.current && trackManager && !ignore) { + if (canvas && trackManager && !ignore) { const getPoints = async (canvas: PointCanvas, time: number) => { console.debug("fetch points at time %d", time); const data = await trackManager.fetchPointsAtTime(time); @@ -114,7 +108,7 @@ export default function App() { canvas.setPointsPositions(data); canvas.resetPointColors(); }; - getPoints(canvas.current, curTime); + getPoints(canvas, curTime); } else { // clearTimeout(loadingTimer); setTimeout(() => setLoading(false), 250); @@ -137,18 +131,18 @@ export default function App() { // update the track highlights const minTime = curTime - trackHighlightLength / 2; const maxTime = curTime + trackHighlightLength / 2; - canvas.current?.updateAllTrackHighlights(minTime, maxTime); + canvas?.updateAllTrackHighlights(minTime, maxTime); }, [curTime, trackHighlightLength]); useEffect(() => { - const pointsID = canvas.current?.points.id || -1; + const pointsID = canvas?.points.id || -1; if (!selectedPoints || !selectedPoints.has(pointsID)) return; // keep track of which tracks we are adding to avoid duplicate fetching const adding = new Set(); // this fetches the entire lineage for each track const fetchAndAddTrack = async (pointID: number) => { - if (!canvas.current || !trackManager) return; + if (!canvas || !trackManager) return; const minTime = curTime - trackHighlightLength / 2; const maxTime = curTime + trackHighlightLength / 2; const tracks = await trackManager.fetchTrackIDsForPoint(pointID); @@ -156,17 +150,17 @@ export default function App() { for (const t of tracks) { const lineage = await trackManager.fetchLineageForTrack(t); for (const l of lineage) { - if (adding.has(l) || canvas.current.tracks.has(l)) continue; + if (adding.has(l) || canvas.tracks.has(l)) continue; adding.add(l); const [pos, ids] = await trackManager.fetchPointsForTrack(l); - const newTrack = canvas.current.addTrack(l, pos, ids); + const newTrack = canvas.addTrack(l, pos, ids); newTrack?.updateHighlightLine(minTime, maxTime); } } }; const selected = selectedPoints.get(pointsID) || []; - canvas.current?.highlightPoints(selected); + canvas?.highlightPoints(selected); const maxPointsPerTimepoint = trackManager?.maxPointsPerTimepoint || 0; Promise.all(selected.map((p: number) => curTime * maxPointsPerTimepoint + p).map(fetchAndAddTrack)); @@ -176,9 +170,8 @@ export default function App() { // TODO: maybe can be done without useEffect? // could be a prop into the Scene component useEffect(() => { - console.log("autoRotate: %s", autoRotate); - if (canvas.current) { - canvas.current.controls.autoRotate = autoRotate; + if (canvas) { + canvas.controls.autoRotate = autoRotate; } }, [autoRotate]); @@ -207,10 +200,10 @@ export default function App() { setTrackManager={setTrackManager} setTrackHighlightLength={setTrackHighlightLength} copyShareableUrlToClipboard={copyShareableUrlToClipboard} - clearTracks={() => canvas.current?.removeAllTracks()} + clearTracks={() => canvas?.removeAllTracks()} /> void; + loading: boolean; initialCameraPosition?: THREE.Vector3; initialCameraTarget?: THREE.Vector3; - canvas: React.MutableRefObject; - loading: boolean; } export default function Scene(props: SceneProps) { - const canvas = props.canvas; - // Use references here for two things: // * manage objects that should never change, even when the component re-renders // * avoid triggering re-renders when these *do* change @@ -25,29 +23,33 @@ export default function Scene(props: SceneProps) { // this requires keeping the dependency array empty useEffect(() => { // initialize the canvas - canvas.current = new PointCanvas(renderWidth, renderHeight); - canvas.current!.setCameraProperties(props.initialCameraPosition, props.initialCameraTarget); + const canvas = new PointCanvas(renderWidth, renderHeight); + canvas.setCameraProperties(props.initialCameraPosition, props.initialCameraTarget); + + // store the canvas in the parent component + // TODO: move this hook to the parent component? + props.setCanvas(canvas); // append renderer canvas const divCurrent = divRef.current; - const renderer = canvas.current!.renderer; + const renderer = canvas.renderer; divCurrent?.insertBefore(renderer.domElement, divCurrent.firstChild); // start animating - this keeps the scene rendering when controls change, etc. - canvas.current.animate(); + canvas.animate(); const handleWindowResize = () => { if (!divCurrent) return; const renderWidth = divCurrent.clientWidth; const renderHeight = divCurrent.clientHeight; - canvas.current?.setSize(renderWidth, renderHeight); + canvas.setSize(renderWidth, renderHeight); }; window.addEventListener("resize", handleWindowResize); handleWindowResize(); return () => { renderer.domElement.remove(); - canvas.current?.dispose(); + canvas.dispose(); }; }, []); // dependency array must be empty to run only on mount! diff --git a/src/hooks/useSelectionBox.ts b/src/hooks/useSelectionBox.ts index 5dcc411d..7b57ef10 100644 --- a/src/hooks/useSelectionBox.ts +++ b/src/hooks/useSelectionBox.ts @@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from "react"; import { PointSelectionBox, PointsCollection } from "@/lib/PointSelectionBox"; import { PointCanvas } from "@/lib/PointCanvas"; -export default function useSelectionBox(canvasRef: React.MutableRefObject) { +export default function useSelectionBox(canvas: PointCanvas | null) { const [selecting, setSelecting] = useState(false); const [selectedPoints, setSelectedPoints] = useState(); @@ -12,7 +12,6 @@ export default function useSelectionBox(canvasRef: React.MutableRefObject(); useEffect(() => { - const canvas = canvasRef.current; if (!canvas) { console.debug("canvas is undefined - deferring useSelectionBox setup"); return; @@ -90,14 +89,14 @@ export default function useSelectionBox(canvasRef: React.MutableRefObject { if (selectionHelper.current) { selectionHelper.current.enabled = selecting; } - if (canvasRef.current) { - canvasRef.current.controls.enabled = !selecting; + if (canvas) { + canvas.controls.enabled = !selecting; } }, [selecting]); From c87b62d6f2924c46d8d7bb82c32221e8b017dc9d Mon Sep 17 00:00:00 2001 From: Ashley Anderson Date: Wed, 3 Apr 2024 12:02:46 -0400 Subject: [PATCH 13/13] Remove unused setTrackManager prop --- src/components/app.tsx | 1 - src/components/dataControls.tsx | 1 - 2 files changed, 2 deletions(-) diff --git a/src/components/app.tsx b/src/components/app.tsx index 537dec0d..ac5e5a8d 100644 --- a/src/components/app.tsx +++ b/src/components/app.tsx @@ -197,7 +197,6 @@ export default function App() { trackManager={trackManager} trackHighlightLength={trackHighlightLength} setDataUrl={setDataUrl} - setTrackManager={setTrackManager} setTrackHighlightLength={setTrackHighlightLength} copyShareableUrlToClipboard={copyShareableUrlToClipboard} clearTracks={() => canvas?.removeAllTracks()} diff --git a/src/components/dataControls.tsx b/src/components/dataControls.tsx index 28aee7c8..9dcb8912 100644 --- a/src/components/dataControls.tsx +++ b/src/components/dataControls.tsx @@ -8,7 +8,6 @@ interface DataControlsProps { trackManager: TrackManager | null; trackHighlightLength: number; setDataUrl: (dataUrl: string) => void; - setTrackManager: (trackManager: TrackManager) => void; setTrackHighlightLength: (trackHighlightLength: number) => void; copyShareableUrlToClipboard: () => void; clearTracks: () => void;