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": { diff --git a/__tests__/main.test.tsx b/__tests__/main.test.tsx index af4b845f..d128ef50 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,11 @@ test("tests work", () => { }); test("render Scene", () => { - const { container } = render(); + let canvasState: PointCanvas | null = null; + const setCanvas = (canvas: PointCanvas) => { + canvasState = canvas; + }; + const { container } = render(); expect(container).not.toBeNull(); + expect(canvasState).not.toBeNull(); }); diff --git a/src/components/app.tsx b/src/components/app.tsx index 9fb021dc..ac5e5a8d 100644 --- a/src/components/app.tsx +++ b/src/components/app.tsx @@ -1,41 +1,222 @@ import { useState, useEffect } from "react"; import "@/css/app.css"; + +import { Box } 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, setCanvas] = useState(null); + const [loading, setLoading] = useState(false); + + const { selectedPoints } = useSelectionBox(canvas); + 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 = () => { + if (canvas === null) return; + console.log("copy shareable URL to clipboard"); + const state = new ViewerState(dataUrl, curTime, canvas.camera.position, canvas.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?.setCameraProperties(state.cameraPosition, state.cameraTarget); + }; + // 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(() => { + 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) return; + canvas.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 + 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); + 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, 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?.updateAllTrackHighlights(minTime, maxTime); + }, [curTime, trackHighlightLength]); + + useEffect(() => { + 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 || !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.tracks.has(l)) continue; + adding.add(l); + const [pos, ids] = await trackManager.fetchPointsForTrack(l); + const newTrack = canvas.addTrack(l, pos, ids); + newTrack?.updateHighlightLine(minTime, maxTime); + } + } }; - }, [renderWidth]); + + const selected = selectedPoints.get(pointsID) || []; + canvas?.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(() => { + if (canvas) { + canvas.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?.removeAllTracks()} + /> + + + ); } diff --git a/src/components/dataControls.tsx b/src/components/dataControls.tsx new file mode 100644 index 00000000..9dcb8912 --- /dev/null +++ b/src/components/dataControls.tsx @@ -0,0 +1,62 @@ +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; + 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..88703be4 --- /dev/null +++ b/src/components/playbackControls.tsx @@ -0,0 +1,47 @@ +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..08e6ecef 100644 --- a/src/components/scene.tsx +++ b/src/components/scene.tsx @@ -1,296 +1,75 @@ -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"; +import { Box } from "@mui/material"; interface SceneProps { - renderWidth?: number; - renderHeight?: number; + setCanvas: (canvas: PointCanvas) => void; + loading: boolean; + initialCameraPosition?: THREE.Vector3; + initialCameraTarget?: THREE.Vector3; } -// 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 renderWidth = props.renderWidth || 800; - const renderHeight = props.renderHeight || 600; - // 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 - canvas.current = new PointCanvas(renderWidth, renderHeight); - canvas.current!.setCameraProperties(initialViewerState.cameraPosition, initialViewerState.cameraTarget); + const canvas = new PointCanvas(renderWidth, renderHeight); + canvas.setCameraProperties(props.initialCameraPosition, props.initialCameraTarget); - // handle any changes to the hash after the initial document has loaded - window.addEventListener("hashchange", setStateFromHash); + // 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; - divCurrent?.appendChild(renderer.domElement); + 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(); - return () => { - window.removeEventListener("hashchange", setStateFromHash); - renderer.domElement.remove(); - canvas.current?.dispose(); + const handleWindowResize = () => { + if (!divCurrent) return; + const renderWidth = divCurrent.clientWidth; + const renderHeight = divCurrent.clientHeight; + canvas.setSize(renderWidth, renderHeight); }; - }, []); // 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); - } + window.addEventListener("resize", handleWindowResize); + handleWindowResize(); return () => { - clearTimeout(loadingTimer); - ignore = true; + renderer.domElement.remove(); + canvas.dispose(); }; - }, [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); + }, []); // dependency array must be empty to run only on mount! - // 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..4afc46e5 100644 --- a/src/css/app.css +++ b/src/css/app.css @@ -1,13 +1,9 @@ -:root { - --app-padding: 32px; -} - #app { - max-width: 1280px; - margin: 0 auto; - padding: var(--app-padding); + 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; 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..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[]) {