Skip to content

Commit

Permalink
Store selection state in URL hash (#95)
Browse files Browse the repository at this point in the history
Closes #49
Closes #62 (by making it no longer relevant)

This stores selection state in the URL hash. In order to make this work, we need some state that reflects the selected points/cells/tracks independent of `PointCanvas.curTime` because the ordering of the React effects when re-hydrating from the entire state can be different compared to when the user is interacting with the application.

Previously, the canvas stored the last selected point indices, which is transient state that depends on `PointCanvas.curTime`. Here we stored the all the point IDs that were selected by the user (inspired from #93). Alternatively, we considered storing the track IDs that were selected in #91 , but that doesn't work as well with the rest of the application logic.

This PR also updates the URL hash to store some simpler state that not been added like point brightness. It also refactors that code a little to make it easier to keep new state in sync. Though there is still a lot of duplication that could be improved.

It does not fix #30 , which is related, but can be solved independently.
  • Loading branch information
andy-sweet authored Jun 6, 2024
1 parent a3b1304 commit 8fca7c5
Show file tree
Hide file tree
Showing 8 changed files with 168 additions and 146 deletions.
88 changes: 36 additions & 52 deletions src/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { ColorMap } from "./overlays/ColorMap";

// 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));
console.log("initial viewer state: ", initialViewerState);
clearUrlHash();

const drawerWidth = 256;
Expand All @@ -32,7 +32,6 @@ export default function App() {
const numTimes = trackManager?.numTimes ?? 0;
// TODO: dataUrl can be stored in the TrackManager only
const [dataUrl, setDataUrl] = useState(initialViewerState.dataUrl);
const [isLoadingTracks, setIsLoadingTracks] = useState(false);

// PointCanvas is a Three.js canvas, updated via reducer
const [canvas, dispatchCanvas, sceneDivRef] = usePointCanvas(initialViewerState);
Expand All @@ -42,25 +41,23 @@ export default function App() {
// this state is pure React
const [playing, setPlaying] = useState(false);
const [isLoadingPoints, setIsLoadingPoints] = useState(false);
const [numLoadingTracks, setNumLoadingTracks] = useState(0);

// Manage shareable state that can persist across sessions.
const copyShareableUrlToClipboard = () => {
console.log("copy shareable URL to clipboard");
const state = new ViewerState(dataUrl, canvas.curTime, canvas.camera.position, canvas.controls.target);
const url = window.location.toString() + "#" + state.toUrlHash();
const state = canvas.toState();
if (trackManager) {
state.dataUrl = trackManager.store;
}
const url = window.location.toString() + state.toUrlHash();
navigator.clipboard.writeText(url);
};

const setStateFromHash = useCallback(() => {
const state = ViewerState.fromUrlHash(window.location.hash);
clearUrlHash();
setDataUrl(state.dataUrl);
dispatchCanvas({ type: ActionType.CUR_TIME, curTime: state.curTime });
dispatchCanvas({
type: ActionType.CAMERA_PROPERTIES,
cameraPosition: state.cameraPosition,
cameraTarget: state.cameraTarget,
});
dispatchCanvas({ type: ActionType.UPDATE_WITH_STATE, state: state });
}, [dispatchCanvas]);

// update the state when the hash changes, but only register the listener once
Expand Down Expand Up @@ -143,48 +140,40 @@ export default function App() {
};
}, [canvas.curTime, dispatchCanvas, trackManager]);

// This fetches track IDs based on the selected point IDs.
useEffect(() => {
console.debug("effect-selection");
const pointsID = canvas.points.id;
const selectedPoints = canvas.selectedPoints;
if (!selectedPoints || !selectedPoints.has(pointsID)) return;
// keep track of which tracks we are adding to avoid duplicate fetching
const adding = new Set<number>();
console.debug("effect-selectedPointIds: ", trackManager, canvas.selectedPointIds);
if (!trackManager) return;
if (canvas.selectedPointIds.size == 0) return;

// this fetches the entire lineage for each track
const fetchAndAddTrack = async (pointID: number) => {
if (!trackManager) return;
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);
// adding the track *in* the dispatcher creates issues with duplicate fetching
// but we refresh so the selected/loaded count is updated
canvas.addTrack(l, pos, ids);
dispatchCanvas({ type: ActionType.REFRESH });
const updateTracks = async () => {
console.debug("updateTracks: ", canvas.selectedPointIds);
for (const pointId of canvas.selectedPointIds) {
if (canvas.fetchedPointIds.has(pointId)) continue;
setNumLoadingTracks((n) => n + 1);
canvas.fetchedPointIds.add(pointId);
const trackIds = await trackManager.fetchTrackIDsForPoint(pointId);
// TODO: points actually only belong to one track, so can get rid of the outer loop
for (const trackId of trackIds) {
if (canvas.fetchedRootTrackIds.has(trackId)) continue;
canvas.fetchedRootTrackIds.add(trackId);
const lineage = await trackManager.fetchLineageForTrack(trackId);
for (const relatedTrackId of lineage) {
if (canvas.tracks.has(relatedTrackId)) continue;
const [pos, ids] = await trackManager.fetchPointsForTrack(relatedTrackId);
// adding the track *in* the dispatcher creates issues with duplicate fetching
// but we refresh so the selected/loaded count is updated
canvas.addTrack(relatedTrackId, pos, ids);
dispatchCanvas({ type: ActionType.REFRESH });
}
}
setNumLoadingTracks((n) => n - 1);
}
};

dispatchCanvas({ type: ActionType.POINT_BRIGHTNESS, brightness: 0.8 });

const selected = selectedPoints.get(pointsID) || [];
dispatchCanvas({ type: ActionType.HIGHLIGHT_POINTS, points: selected });

const maxPointsPerTimepoint = trackManager?.maxPointsPerTimepoint ?? 0;

setIsLoadingTracks(true);
Promise.all(selected.map((p: number) => canvas.curTime * maxPointsPerTimepoint + p).map(fetchAndAddTrack)).then(
() => {
setIsLoadingTracks(false);
},
);
updateTracks();
// TODO: add missing dependencies
}, [canvas.selectedPoints]);
}, [trackManager, dispatchCanvas, canvas.selectedPointIds]);

Check warning on line 176 in src/components/App.tsx

View workflow job for this annotation

GitHub Actions / lint-and-test

React Hook useEffect has a missing dependency: 'canvas'. Either include it or remove the dependency array

// playback time points
// TODO: this is basic and may drop frames
Expand Down Expand Up @@ -302,12 +291,7 @@ export default function App() {
overflow: "hidden",
}}
>
<Scene
ref={sceneDivRef}
isLoading={isLoadingPoints || isLoadingTracks}
initialCameraPosition={initialViewerState.cameraPosition}
initialCameraTarget={initialViewerState.cameraTarget}
/>
<Scene ref={sceneDivRef} isLoading={isLoadingPoints || numLoadingTracks > 0} />
<Box flexGrow={0} padding="1em">
<TimestampOverlay timestamp={canvas.curTime} />
<ColorMap />
Expand Down
2 changes: 0 additions & 2 deletions src/components/Scene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ import { Box } from "@mui/material";

interface SceneProps {
isLoading: boolean;
initialCameraPosition?: THREE.Vector3;
initialCameraTarget?: THREE.Vector3;
}

const Scene = forwardRef(function SceneRender(props: SceneProps, ref: React.Ref<HTMLDivElement>) {
Expand Down
81 changes: 48 additions & 33 deletions src/hooks/usePointCanvas.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,12 @@
import { useCallback, useEffect, useReducer, useRef, Dispatch, RefObject } from "react";

import { Vector3 } from "three";

import { PointCanvas } from "@/lib/PointCanvas";
import { PointsCollection } from "@/lib/PointSelectionBox";
import { PointSelectionMode } from "@/lib/PointSelector";
import { ViewerState } from "@/lib/ViewerState";

enum ActionType {
AUTO_ROTATE = "AUTO_ROTATE",
CAMERA_PROPERTIES = "CAMERA_PROPERTIES",
CUR_TIME = "CUR_TIME",
HIGHLIGHT_POINTS = "HIGHLIGHT_POINTS",
INIT_POINTS_GEOMETRY = "INIT_POINTS_GEOMETRY",
POINT_BRIGHTNESS = "POINT_BRIGHTNESS",
POINTS_POSITIONS = "POINTS_POSITIONS",
Expand All @@ -22,29 +17,20 @@ enum ActionType {
SHOW_TRACK_HIGHLIGHTS = "SHOW_TRACK_HIGHLIGHTS",
SIZE = "SIZE",
MIN_MAX_TIME = "MIN_MAX_TIME",
ADD_SELECTED_POINT_IDS = "ADD_SELECTED_POINT_IDS",
UPDATE_WITH_STATE = "UPDATE_WITH_STATE",
}

interface AutoRotate {
type: ActionType.AUTO_ROTATE;
autoRotate: boolean;
}

interface CameraProperties {
type: ActionType.CAMERA_PROPERTIES;
cameraPosition: Vector3;
cameraTarget: Vector3;
}

interface CurTime {
type: ActionType.CUR_TIME;
curTime: number | ((curTime: number) => number);
}

interface HighlightPoints {
type: ActionType.HIGHLIGHT_POINTS;
points: number[];
}

interface InitPointsGeometry {
type: ActionType.INIT_POINTS_GEOMETRY;
maxPointsPerTimepoint: number;
Expand Down Expand Up @@ -95,12 +81,21 @@ interface MinMaxTime {
maxTime: number;
}

interface AddSelectedPointIds {
type: ActionType.ADD_SELECTED_POINT_IDS;
selectedPointIndices: number[];
selectedPointIds: Set<number>;
}

interface UpdateWithState {
type: ActionType.UPDATE_WITH_STATE;
state: ViewerState;
}

// setting up a tagged union for the actions
type PointCanvasAction =
| AutoRotate
| CameraProperties
| CurTime
| HighlightPoints
| InitPointsGeometry
| PointBrightness
| PointsPositions
Expand All @@ -110,17 +105,16 @@ type PointCanvasAction =
| ShowTracks
| ShowTrackHighlights
| Size
| MinMaxTime;
| MinMaxTime
| AddSelectedPointIds
| UpdateWithState;

function reducer(canvas: PointCanvas, action: PointCanvasAction): PointCanvas {
console.debug("usePointCanvas.reducer: ", action);
const newCanvas = canvas.shallowCopy();
switch (action.type) {
case ActionType.REFRESH:
break;
case ActionType.CAMERA_PROPERTIES:
newCanvas.setCameraProperties(action.cameraPosition, action.cameraTarget);
break;
case ActionType.CUR_TIME: {
// if curTime is a function, call it with the current time
if (typeof action.curTime === "function") {
Expand All @@ -135,9 +129,6 @@ function reducer(canvas: PointCanvas, action: PointCanvasAction): PointCanvas {
case ActionType.AUTO_ROTATE:
newCanvas.controls.autoRotate = action.autoRotate;
break;
case ActionType.HIGHLIGHT_POINTS:
newCanvas.highlightPoints(action.points);
break;
case ActionType.INIT_POINTS_GEOMETRY:
newCanvas.initPointsGeometry(action.maxPointsPerTimepoint);
break;
Expand Down Expand Up @@ -173,6 +164,22 @@ function reducer(canvas: PointCanvas, action: PointCanvasAction): PointCanvas {
newCanvas.maxTime = action.maxTime;
newCanvas.updateAllTrackHighlights();
break;
case ActionType.ADD_SELECTED_POINT_IDS: {
newCanvas.pointBrightness = 0.8;
newCanvas.resetPointColors();
// TODO: only highlight the indices if the canvas is at the same time
// point as when it was selected.
newCanvas.highlightPoints(action.selectedPointIndices);
const newSelectedPointIds = new Set(canvas.selectedPointIds);
for (const trackId of action.selectedPointIds) {
newSelectedPointIds.add(trackId);
}
newCanvas.selectedPointIds = newSelectedPointIds;
break;
}
case ActionType.UPDATE_WITH_STATE:
newCanvas.updateWithState(action.state);
break;
default:
console.warn("usePointCanvas reducer - unknown action type: %s", action);
return canvas;
Expand All @@ -181,12 +188,13 @@ function reducer(canvas: PointCanvas, action: PointCanvasAction): PointCanvas {
}

function createPointCanvas(initialViewerState: ViewerState): PointCanvas {
console.debug("createPointCanvas: ", initialViewerState);
// create the canvas with some default dimensions
// these will be overridden when the canvas is inserted into a div
const canvas = new PointCanvas(800, 600);

// restore canvas from initial viewer state
canvas.setCameraProperties(initialViewerState.cameraPosition, initialViewerState.cameraTarget);
// Update the state from any initial values.
canvas.updateWithState(initialViewerState);

// start animating - this keeps the scene rendering when controls change, etc.
canvas.animate();
Expand All @@ -197,16 +205,23 @@ function createPointCanvas(initialViewerState: ViewerState): PointCanvas {
function usePointCanvas(
initialViewerState: ViewerState,
): [PointCanvas, Dispatch<PointCanvasAction>, RefObject<HTMLDivElement>] {
console.debug("usePointCanvas: ", initialViewerState);
const divRef = useRef<HTMLDivElement>(null);
const [canvas, dispatchCanvas] = useReducer(reducer, initialViewerState, createPointCanvas);

// When the selection changes internally due to the user interacting with the canvas,
// we need to trigger a react re-render.
canvas.selector.selectionChanged = useCallback((_selection: PointsCollection) => {
console.debug("selectionChanged: refresh");
dispatchCanvas({ type: ActionType.REFRESH });
}, []);
// we need to dispatch an addition to the canvas' state.
canvas.selector.selectionChanged = useCallback(
(pointIndices: number[]) => {
console.debug("selectionChanged:", pointIndices);
const pointIds = new Set(pointIndices.map((p) => canvas.curTime * canvas.maxPointsPerTimepoint + p));
dispatchCanvas({
type: ActionType.ADD_SELECTED_POINT_IDS,
selectedPointIndices: pointIndices,
selectedPointIds: pointIds,
});
},
[canvas.curTime, canvas.maxPointsPerTimepoint],
);

// set up the canvas when the div is available
// this is an effect because:
Expand Down
15 changes: 6 additions & 9 deletions src/lib/BoxPointSelector.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { PerspectiveCamera, Scene, WebGLRenderer } from "three";
import { PerspectiveCamera, Points, Scene, WebGLRenderer } from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { SelectionHelper } from "three/addons/interactive/SelectionHelper.js";

import { PointSelectionBox, PointsCollection } from "@/lib/PointSelectionBox";
import { PointSelectionBox } from "@/lib/PointSelectionBox";
import { SelectionChanged } from "@/lib/PointSelector";

// Selection with a 2D rectangle to make a 3D frustum.
Expand All @@ -11,6 +11,7 @@ export class BoxPointSelector {
readonly controls: OrbitControls;
readonly box: PointSelectionBox;
readonly helper: SelectionHelper;
readonly points: Points;
readonly selectionChanged: SelectionChanged;

// True if this should not perform selection, false otherwise.
Expand All @@ -23,10 +24,12 @@ export class BoxPointSelector {
renderer: WebGLRenderer,
camera: PerspectiveCamera,
controls: OrbitControls,
points: Points,
selectionChanged: SelectionChanged,
) {
this.renderer = renderer;
this.controls = controls;
this.points = points;
this.helper = new SelectionHelper(renderer, "selectBox");
this.helper.enabled = false;
this.box = new PointSelectionBox(camera, scene);
Expand All @@ -49,12 +52,6 @@ export class BoxPointSelector {
}
}

setSelectedPoints(selectedPoints: PointsCollection) {
console.debug("BoxPointSelector.setSelectedPoints: ", selectedPoints);
this.box.collection = selectedPoints;
this.selectionChanged(selectedPoints);
}

pointerUp(_event: MouseEvent) {
console.debug("BoxPointSelector.pointerUp");
this.blocked = false;
Expand All @@ -78,7 +75,7 @@ export class BoxPointSelector {
// TODO: consider restricting selection to a specific object
this.box.select();

this.setSelectedPoints(this.box.collection);
this.selectionChanged(this.box.collection.get(this.points.id) ?? []);
}

pointerCancel(_event: MouseEvent) {
Expand Down
Loading

0 comments on commit 8fca7c5

Please sign in to comment.