Skip to content

Commit

Permalink
Add a button that copies shareable URL into the clipboard (#44)
Browse files Browse the repository at this point in the history
This provides a simple way to share state between two instances of the tool. It captures most of the current state we care about persisting, with the exception of selection because that is timing/order sensitive and may need some related cleanup before we use it as a shareable state.

The approach defines a dataclass for serializing/deserializing the state from the URL's hash. I don't love it because it requires some boilerplate in the class itself and related book-keeping elsewhere (in `state.tsx`) but I think it's probably good enough for now. Open to better ideas though!

I encoded the state using `URLSearchParams` to sanitize the strings for usage in a URL. Alternatively, we could use a base64 encoding of the JSON string which could also simplify the (de)serialization code, though will likely be longer than the current encoding.

Closes #18

---------

Co-authored-by: Ashley Anderson <[email protected]>
  • Loading branch information
andy-sweet and aganders3 authored Mar 12, 2024
1 parent 1154fa4 commit 865a508
Show file tree
Hide file tree
Showing 3 changed files with 120 additions and 25 deletions.
11 changes: 5 additions & 6 deletions src/PointCanvas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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;
}

Expand All @@ -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();
Expand Down
57 changes: 57 additions & 0 deletions src/ViewerState.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
77 changes: 58 additions & 19 deletions src/scene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<TrackManager>();
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<HTMLDivElement> = useRef(null);
const canvas = useRef<PointCanvas>();

// 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<TrackManager>();
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;
Expand All @@ -50,6 +77,7 @@ export default function Scene(props: SceneProps) {
canvas.current.animate();

return () => {
window.removeEventListener("hashchange", setStateFromHash);
renderer.domElement.remove();
canvas.current?.dispose();
};
Expand Down Expand Up @@ -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]);

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -172,7 +201,7 @@ export default function Scene(props: SceneProps) {
<InputText
id="url-input"
label="Zarr URL"
placeholder={DEFAULT_ZARR_URL.toString()}
placeholder={initialViewerState.dataUrl.toString()}
value={dataUrl.toString()}
onChange={(e) => setDataUrl(new URL(e.target.value))}
fullWidth={true}
Expand All @@ -193,6 +222,7 @@ export default function Scene(props: SceneProps) {
<InputToggle
onLabel="Spin"
offLabel="Spin"
checked={autoRotate}
disabled={trackManager === undefined}
onChange={(e) => {
setAutoRotate((e.target as HTMLInputElement).checked);
Expand All @@ -201,6 +231,7 @@ export default function Scene(props: SceneProps) {
<InputToggle
onLabel="Play"
offLabel="Play"
checked={playing}
disabled={trackManager === undefined}
onChange={(e) => {
setPlaying((e.target as HTMLInputElement).checked);
Expand All @@ -214,6 +245,14 @@ export default function Scene(props: SceneProps) {
>
Clear Tracks
</Button>
<Button
disabled={canvas.current === undefined}
sdsType="primary"
sdsStyle="rounded"
onClick={copyShareableUrlToClipboard}
>
Copy Link
</Button>
</div>
</div>
</div>
Expand Down

0 comments on commit 865a508

Please sign in to comment.