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
8 changes: 6 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,9 @@ test("tests work", () => {
});

test("render Scene", () => {
const { container } = render(<Scene renderWidth={800} />);
const canvasRef = {
current: new PointCanvas(800, 600),
};
const { container } = render(<Scene canvas={canvasRef} loading={false} />);
expect(container).not.toBeNull();
});
243 changes: 216 additions & 27 deletions src/components/app.tsx
Original file line number Diff line number Diff line change
@@ -1,41 +1,230 @@
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<TrackManager | null>(null);
const canvas = useRef<PointCanvas | null>(null);
const [loading, setLoading] = useState(false);

const { selectedPoints } = useSelectionBox(canvas.current);
const [trackHighlightLength, setTrackHighlightLength] = useState(11);
aganders3 marked this conversation as resolved.
Show resolved Hide resolved

// 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 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.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<number>();

// 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 (
<>
<Scene renderWidth={renderWidth} renderHeight={renderWidth / aspectRatio} />
</>
<Stack spacing={4} sx={{ width: "90vw", height: "90vh" }}>
<DataControls
dataUrl={dataUrl}
initialDataUrl={initialViewerState.dataUrl}
trackManager={trackManager}
trackHighlightLength={trackHighlightLength}
setDataUrl={setDataUrl}
setTrackManager={setTrackManager}
setTrackHighlightLength={setTrackHighlightLength}
copyShareableUrlToClipboard={copyShareableUrlToClipboard}
clearTracks={() => canvas.current?.removeAllTracks()}
andy-sweet marked this conversation as resolved.
Show resolved Hide resolved
/>
<Scene
canvas={canvas}
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}
/>
</Stack>
);
}
63 changes: 63 additions & 0 deletions src/components/dataControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
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;
aganders3 marked this conversation as resolved.
Show resolved Hide resolved
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}>
<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>
);
}
47 changes: 47 additions & 0 deletions src/components/playbackControls.tsx
Original file line number Diff line number Diff line change
@@ -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 (
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is this an example of a dumb component? I.e. state is passed through, it uses it for rendering/presentation, and it calls the necessary setters. But it doesn't contain any logic/effects of its own?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Ah I think so! Now that you mention that though I feel perhaps this should take (optional?) props for onClick, etc. instead of requiring specific value setters. What do you think? I can try to look if there's some convention for this.

Copy link
Collaborator

Choose a reason for hiding this comment

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

As a general rule, I tend to prefer avoiding optionals if possible. Do you think this might make it easier to reuse?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yes, and it would be more flexible. The parent (smart component) would be able to take additional actions in onClick aside from just setting a value.

My thinking on being optional is that it would be closer to how a basic <Button/> might work, but I'm okay either way on that. I don't think they need to be optional without a specific use case in mind.

Choose a reason for hiding this comment

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

this looks really good for a "dumb" component, nice job 🫡

as for whether a prop should be optional, I think it entirely depends on if the component is meant to work in a context where it doesn't need that prop

for example, buttons can be used for submitting forms using the type="submit" prop, so it wouldn't need an onclick event handler in that context. additionally, Material UI buttons can also function as links, so it's not required if passing in a href prop:

<Button href="#text-buttons">Link</Button>

<Stack direction="row" spacing={8}>
<label htmlFor="auto-rotate-toggle">Auto Rotate</label>
<InputToggle
checked={props.autoRotate}
disabled={!props.enabled}
onChange={(e) => {
props.setAutoRotate((e.target as HTMLInputElement).checked);
}}
/>
<label htmlFor="playback-toggle">Playback</label>
<InputToggle
checked={props.playing}
disabled={!props.enabled}
onChange={(e) => {
props.setPlaying((e.target as HTMLInputElement).checked);
}}
/>
<InputSlider
id="time-frame-slider"
aria-labelledby="input-slider-time-frame"
disabled={!props.enabled}
min={0}
max={props.numTimes - 1}
valueLabelDisplay="on"
onChange={(_, value) => props.setCurTime(value as number)}
value={props.curTime}
/>
</Stack>
);
}
Loading
Loading