diff --git a/src/PointCanvas.ts b/src/PointCanvas.ts index ffa8b9dc..70c533c0 100644 --- a/src/PointCanvas.ts +++ b/src/PointCanvas.ts @@ -45,11 +45,6 @@ export class PointCanvas { 0.1, // Near 10000, // Far ); - // Default position from interacting with ZSNS001 - // TODO: this should be set/reset when the data changes - const target = new Vector3(500, 500, 250); - this.camera.position.set(target.x, target.y, target.z - 1500); - this.camera.lookAt(target.x, target.y, target.z); const pointsGeometry = new BufferGeometry(); const pointsMaterial = new PointsMaterial({ @@ -82,7 +77,6 @@ export class PointCanvas { // Set up controls this.controls = new OrbitControls(this.camera, this.renderer.domElement); - this.controls.target.set(target.x, target.y, target.z); this.controls.autoRotateSpeed = 1; } @@ -95,6 +89,11 @@ 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); + } + highlightPoints(points: number[]) { const colorAttribute = this.points.geometry.getAttribute("color"); const color = new Color(); diff --git a/src/ViewerState.ts b/src/ViewerState.ts new file mode 100644 index 00000000..00dac67c --- /dev/null +++ b/src/ViewerState.ts @@ -0,0 +1,57 @@ +import { Vector3 } from "three"; + +export const DEFAULT_ZARR_URL = new URL( + "https://sci-imaging-vis-public-demo-data.s3.us-west-2.amazonaws.com" + + "/points-web-viewer/sparse-zarr-v2/ZSNS001_tracks_bundle.zarr", +); + +const HASH_KEY = "viewerState"; + +// Clears the hash from the window's URL without triggering a hashchange +// event or an update to history. +export function clearUrlHash() { + // Use this instead of setting window.location.hash to avoid triggering + // a hashchange event (which would reset the state again). + window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}`); +} + +// Encapsulates all the persistent state in the viewer (e.g. that can be serialized and shared). +export class ViewerState { + dataUrl: URL; + curTime: number; + cameraPosition: Vector3; + cameraTarget: Vector3; + + constructor( + dataUrl: URL = DEFAULT_ZARR_URL, + curTime: number = 0, + // Default position and target from interacting with ZSNS001. + cameraPosition: Vector3 = new Vector3(500, 500, -1250), + cameraTarget: Vector3 = new Vector3(500, 500, 250), + ) { + this.dataUrl = dataUrl; + this.curTime = curTime; + this.cameraPosition = cameraPosition; + this.cameraTarget = cameraTarget; + } + + toUrlHash(): string { + // Use SearchParams to sanitize serialized string values for URL. + const searchParams = new URLSearchParams(); + searchParams.append(HASH_KEY, JSON.stringify(this)); + return searchParams.toString(); + } + + static fromUrlHash(urlHash: string): ViewerState { + console.debug("getting state from hash: %s", urlHash); + // Remove the # from the hash to get the fragment. + const searchParams = new URLSearchParams(urlHash.slice(1)); + if (searchParams.has(HASH_KEY)) { + return JSON.parse(searchParams.get(HASH_KEY)!); + } + if (urlHash.length > 0) { + console.error("failed to find state key in hash: %s", urlHash); + } + return new ViewerState(); + } +} diff --git a/src/scene.tsx b/src/scene.tsx index 0236c1e1..a4618fcb 100644 --- a/src/scene.tsx +++ b/src/scene.tsx @@ -3,43 +3,70 @@ import { Button, InputSlider, InputText, InputToggle, LoadingIndicator } from "@ import { PointCanvas } from "./PointCanvas"; import { TrackManager, loadTrackManager } from "./TrackManager"; -// @ts-expect-error - types for zarr are not working right now, but a PR is open https://github.com/gzuidhof/zarr.js/pull/149 -import { ZarrArray } from "zarr"; import useSelectionBox from "./hooks/useSelectionBox"; -const DEFAULT_ZARR_URL = new URL( - "https://sci-imaging-vis-public-demo-data.s3.us-west-2.amazonaws.com" + - "/points-web-viewer/sparse-zarr-v2/ZSNS001_tracks_bundle.zarr", -); +import { ViewerState, clearUrlHash } from "./ViewerState"; + interface SceneProps { renderWidth?: number; renderHeight?: number; } +// 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; - const [trackManager, setTrackManager] = useState(); - const [dataUrl, setDataUrl] = useState(DEFAULT_ZARR_URL); - const [numTimes, setNumTimes] = useState(0); - const [curTime, setCurTime] = useState(0); - const [autoRotate, setAutoRotate] = useState(false); - const [playing, setPlaying] = useState(false); - const [loading, setLoading] = useState(false); - // 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(); + const [numTimes, setNumTimes] = useState(0); + const [loading, setLoading] = useState(false); const { selectedPoints, setSelectedPoints } = 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); + }; + // 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); + + // handle any changes to the hash after the initial document has loaded + window.addEventListener("hashchange", setStateFromHash); // append renderer canvas const divCurrent = divRef.current; @@ -50,6 +77,7 @@ export default function Scene(props: SceneProps) { canvas.current.animate(); return () => { + window.removeEventListener("hashchange", setStateFromHash); renderer.domElement.remove(); canvas.current?.dispose(); }; @@ -86,10 +114,13 @@ export default function Scene(props: SceneProps) { console.log("load data from %s", dataUrl); const trackManager = loadTrackManager(dataUrl.toString()); // TODO: add clean-up by returning another closure - trackManager.then((tm: ZarrArray) => { + trackManager.then((tm: TrackManager | null) => { + if (!tm) return; setTrackManager(tm); setNumTimes(tm.points.shape[0]); - setCurTime(0); + // Defend against the case when a curTime valid for previous data + // is no longer valid. + setCurTime(Math.min(curTime, tm.points.shape[0] - 1)); }); }, [dataUrl]); @@ -129,9 +160,7 @@ export default function Scene(props: SceneProps) { // in addition, we should debounce the input and verify the data is current // before rendering it if (trackManager && !ignore) { - // trackManager.highlightTracks(curTime); console.debug("fetch points at time %d", curTime); - // fetchPointsAtTime(array, curTime).then((data) => { trackManager?.fetchPointsAtTime(curTime).then((data) => { console.debug("got %d points for time %d", data.length / 3, curTime); if (ignore) { @@ -172,7 +201,7 @@ export default function Scene(props: SceneProps) { setDataUrl(new URL(e.target.value))} fullWidth={true} @@ -193,6 +222,7 @@ export default function Scene(props: SceneProps) { { setAutoRotate((e.target as HTMLInputElement).checked); @@ -201,6 +231,7 @@ export default function Scene(props: SceneProps) { { setPlaying((e.target as HTMLInputElement).checked); @@ -214,6 +245,14 @@ export default function Scene(props: SceneProps) { > Clear Tracks +