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

Add a button that copies shareable URL into the clipboard #44

Merged
merged 31 commits into from
Mar 12, 2024
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
13ba0e1
Encode curTime in URL hash with custom code
andy-sweet Feb 27, 2024
75ffc68
Also encode autoRotate in hash
andy-sweet Feb 27, 2024
394aa45
Clean up typing
andy-sweet Feb 27, 2024
e5ca9e2
Fix init value for autoRotate
andy-sweet Feb 27, 2024
f38734c
Fix formatting
andy-sweet Feb 27, 2024
0757a9d
Support most other state
andy-sweet Feb 28, 2024
b7403f6
Sync checked state with actual values
andy-sweet Feb 28, 2024
1ac3827
Initialization is working ish
andy-sweet Feb 28, 2024
1e6778f
Replace history instead of appending
andy-sweet Feb 28, 2024
dc2ad84
Add camera state
andy-sweet Feb 29, 2024
a0fe242
Format
andy-sweet Feb 29, 2024
190e009
Share button to generate URL
andy-sweet Mar 6, 2024
3b5ccf9
Fix default position and target
andy-sweet Mar 6, 2024
4ced367
Fix key check
andy-sweet Mar 6, 2024
a3278bb
Please linter
andy-sweet Mar 6, 2024
4a8f541
Make a static factory method
andy-sweet Mar 7, 2024
46eeed0
Remove selected points as persistent state
andy-sweet Mar 7, 2024
30752d6
Support copying in after initial load
andy-sweet Mar 7, 2024
eafdff2
Refactor setting camera props
andy-sweet Mar 8, 2024
00913ac
Simplify camera props
andy-sweet Mar 8, 2024
837a613
Merge branch 'main' into feat-url-share
andy-sweet Mar 8, 2024
e53d56c
Disable if current canvas is undefined
andy-sweet Mar 8, 2024
8c76448
Pass urlHash as argument for deserialization
andy-sweet Mar 8, 2024
ba60850
Use a single key/value in hash
andy-sweet Mar 8, 2024
91c3a1d
Remove playing and autoRotate from persisted state
andy-sweet Mar 8, 2024
b98de89
Fix linter error
andy-sweet Mar 8, 2024
d90e34c
Add defensive set for curTime
andy-sweet Mar 8, 2024
555188c
Satisfy linter
andy-sweet Mar 8, 2024
0ab0c4b
Fix Vercel build errors (typescript errors)
aganders3 Mar 12, 2024
ca28f2f
Move hash utility function
andy-sweet Mar 12, 2024
7800fe9
Remove unncessary import
andy-sweet Mar 12, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
76 changes: 76 additions & 0 deletions src/ViewerState.ts
aganders3 marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
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",
);

// Encapsulates all the persistent state in the viewer (e.g. that can be serialized and shared).
export class ViewerState {
dataUrl: URL;
curTime: number;
autoRotate: boolean;
playing: boolean;
cameraPosition: Vector3;
cameraTarget: Vector3;

constructor(
dataUrl: URL = DEFAULT_ZARR_URL,
curTime: number = 0,
autoRotate: boolean = false,
playing: boolean = false,
// 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.autoRotate = autoRotate;
this.playing = playing;
this.cameraPosition = cameraPosition;
this.cameraTarget = cameraTarget;
}

toUrlHash(): string {
// Use SearchParams to sanitize serialized string values for URL.
const searchParams = new URLSearchParams();
aganders3 marked this conversation as resolved.
Show resolved Hide resolved
searchParams.append("dataUrl", this.dataUrl.toString());
searchParams.append("curTime", this.curTime.toString());
searchParams.append("autoRotate", this.autoRotate.toString());
searchParams.append("playing", this.playing.toString());
searchParams.append("cameraPosition", JSON.stringify(this.cameraPosition));
searchParams.append("cameraTarget", JSON.stringify(this.cameraTarget));
return searchParams.toString();
}

// This consumes the window's location hash and has the side effect of clearing it.
static fromUrlHash() {
aganders3 marked this conversation as resolved.
Show resolved Hide resolved
const urlHash = window.location.hash;
console.log("getting state from hash: %s", urlHash);
const state = new ViewerState();
const searchParams = new URLSearchParams(urlHash.slice(1));
if (searchParams.has("dataUrl")) {
state.dataUrl = new URL(searchParams.get("dataUrl")!);
}
if (searchParams.has("curTime")) {
state.curTime = parseInt(searchParams.get("curTime")!);
}
if (searchParams.has("autoRotate")) {
state.autoRotate = searchParams.get("autoRotate") === "true";
}
if (searchParams.get("playing")) {
state.playing = searchParams.get("playing") === "true";
}
if (searchParams.get("cameraPosition")) {
state.cameraPosition = JSON.parse(searchParams.get("cameraPosition")!);
}
if (searchParams.get("cameraTarget")) {
state.cameraTarget = JSON.parse(searchParams.get("cameraTarget")!);
}
// Reset hash once initial state is consumed to keep URL clean.
// 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}`);
return state;
}
}
65 changes: 50 additions & 15 deletions src/scene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,39 +7,70 @@ import { TrackManager, loadTrackManager } from "./TrackManager";
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 { DEFAULT_ZARR_URL, ViewerState } from "./ViewerState";
aganders3 marked this conversation as resolved.
Show resolved Hide resolved

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();
console.log("initial viewer state: %s", JSON.stringify(initialViewerState));

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(initialViewerState.autoRotate);
const [playing, setPlaying] = useState(initialViewerState.playing);

// 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,
autoRotate,
playing,
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();
setDataUrl(state.dataUrl);
setCurTime(state.curTime);
setAutoRotate(state.autoRotate);
setPlaying(state.playing);
aganders3 marked this conversation as resolved.
Show resolved Hide resolved
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 +81,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 @@ -82,7 +114,7 @@ export default function Scene(props: SceneProps) {
trackManager.then((tm: ZarrArray) => {
setTrackManager(tm);
setNumTimes(tm.points.shape[0]);
setCurTime(0);
setCurTime(curTime);
aganders3 marked this conversation as resolved.
Show resolved Hide resolved
});
}, [dataUrl]);

Expand Down Expand Up @@ -121,9 +153,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?.getPointsAtTime(curTime).then((data) => {
console.debug("got %d points for time %d", data.length / 3, curTime);
if (ignore) {
Expand Down Expand Up @@ -185,6 +215,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 @@ -193,6 +224,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 @@ -206,6 +238,9 @@ export default function Scene(props: SceneProps) {
>
Clear Tracks
</Button>
<Button sdsType="primary" sdsStyle="rounded" onClick={copyShareableUrlToClipboard}>
aganders3 marked this conversation as resolved.
Show resolved Hide resolved
aganders3 marked this conversation as resolved.
Show resolved Hide resolved
Copy Link
</Button>
</div>
</div>
</div>
Expand Down
Loading