Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor controls into separate components #59

Merged
merged 13 commits into from
Apr 3, 2024
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}
andy-sweet marked this conversation as resolved.
Show resolved Hide resolved
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);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: remove console.log unless you want to keep it here 🤣

Copy link
Collaborator Author

@aganders3 aganders3 Apr 3, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. I should make a whole PR to clean up our logging or at least move it to debug 😵‍💫.

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
Loading