Skip to content

Commit

Permalink
Refactor controls into separate components (#59)
Browse files Browse the repository at this point in the history
* Huge refactor into separate components.

* Remove unused CSS, add label for track length slider

* Add labels

* Update our one lonely test

* Remove some unused props and args

* Re-add hash change listener

* Fix hash change listener

* Pass initial camera properties to Scene

* Fix layout issues from code review

* Attempt to fix selection box initialization issue

* Ignore errors for unused variables that start with _

* Make canvas (PointCanvas) state instead of a ref to fix selection box initialization

* Remove unused setTrackManager prop
  • Loading branch information
aganders3 authored Apr 3, 2024
1 parent 4ac3ff4 commit 603334c
Show file tree
Hide file tree
Showing 10 changed files with 391 additions and 320 deletions.
13 changes: 13 additions & 0 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
10 changes: 8 additions & 2 deletions __tests__/main.test.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -9,6 +10,11 @@ test("tests work", () => {
});

test("render Scene", () => {
const { container } = render(<Scene renderWidth={800} />);
let canvasState: PointCanvas | null = null;
const setCanvas = (canvas: PointCanvas) => {
canvasState = canvas;
};
const { container } = render(<Scene setCanvas={setCanvas} loading={false} />);
expect(container).not.toBeNull();
expect(canvasState).not.toBeNull();
});
233 changes: 207 additions & 26 deletions src/components/app.tsx
Original file line number Diff line number Diff line change
@@ -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<TrackManager | null>(null);
const [canvas, setCanvas] = useState<PointCanvas | null>(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<number>();

// 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 (
<>
<Scene renderWidth={renderWidth} renderHeight={renderWidth / aspectRatio} />
</>
<Box sx={{ display: "flex", flexDirection: "column", width: "80%", height: "100%" }}>
<DataControls
dataUrl={dataUrl}
initialDataUrl={initialViewerState.dataUrl}
trackManager={trackManager}
trackHighlightLength={trackHighlightLength}
setDataUrl={setDataUrl}
setTrackHighlightLength={setTrackHighlightLength}
copyShareableUrlToClipboard={copyShareableUrlToClipboard}
clearTracks={() => canvas?.removeAllTracks()}
/>
<Scene
setCanvas={setCanvas}
loading={loading}
initialCameraPosition={initialViewerState.cameraPosition}
initialCameraTarget={initialViewerState.cameraTarget}
/>
<PlaybackControls
enabled={true}
autoRotate={autoRotate}
playing={playing}
curTime={curTime}
numTimes={numTimes}
setAutoRotate={setAutoRotate}
setPlaying={setPlaying}
setCurTime={setCurTime}
/>
</Box>
);
}
62 changes: 62 additions & 0 deletions src/components/dataControls.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Stack spacing={4} sx={{ margin: "2em" }}>
<InputText
id="url-input"
label="Zarr URL"
placeholder={props.initialDataUrl}
defaultValue={props.initialDataUrl}
onChange={(e) => props.setDataUrl(e.target.value)}
fullWidth={true}
intent={props.trackManager ? "default" : "error"}
/>
<label htmlFor="track-highlight-length-slider">Track Highlight Length</label>
<InputSlider
id="track-highlight-length-slider"
aria-labelledby="input-slider-track-highlight-length"
disabled={!props.trackManager}
min={0}
max={100}
valueLabelDisplay="auto"
valueLabelFormat={(value) => `${value}%`}
onChange={(_, value) => {
if (!props.trackManager) return;
const v = ((value as number) / 100) * 2 * numTimes;
props.setTrackHighlightLength(v);
}}
value={Math.round(trackLengthPct)}
/>
<Stack direction="row" spacing={4}>
<Button disabled={!props.trackManager} onClick={props.clearTracks}>
Clear Tracks
</Button>
<ButtonIcon
sdsIcon="share"
sdsSize="large"
sdsType="primary"
disabled={!props.trackManager}
onClick={props.copyShareableUrlToClipboard}
/>
</Stack>
</Stack>
);
}
Loading

0 comments on commit 603334c

Please sign in to comment.