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

Store selection state in URL hash #95

Merged
merged 47 commits into from
Jun 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
2e32a52
Split selection effect into two
andy-sweet May 31, 2024
0b645ea
Even simpler
andy-sweet May 31, 2024
55920fe
Simplify loading state
andy-sweet May 31, 2024
0d8c661
Fix formatting
andy-sweet May 31, 2024
61490f7
Mostly working with reducer
andy-sweet May 31, 2024
794265d
Remove setter
andy-sweet May 31, 2024
fc4cfed
Clean up
andy-sweet May 31, 2024
b588b11
Format
andy-sweet May 31, 2024
cb0de4c
Clean up effect
andy-sweet May 31, 2024
e304a10
More simplification
andy-sweet May 31, 2024
c9f9fc5
Simplify log
andy-sweet May 31, 2024
d0505a8
Restore track ID state
andy-sweet May 31, 2024
1c0d700
Add other state to URL
andy-sweet May 31, 2024
2ac5cd2
Format
andy-sweet May 31, 2024
bccd835
Add track IDs instead of clobbering
andy-sweet May 31, 2024
6ff4dbe
Fix lint
andy-sweet May 31, 2024
9b66310
Add maxPointsPerTimepoint to state
andy-sweet Jun 4, 2024
3c0ea91
Format
andy-sweet Jun 4, 2024
89253b8
Clean up
andy-sweet Jun 4, 2024
bf05cdc
Skip fetches for root tracks
andy-sweet Jun 4, 2024
266f712
More docs
andy-sweet Jun 4, 2024
7cad9b6
Remove optionals
andy-sweet Jun 4, 2024
08f7ceb
Rename fetched root track ids
andy-sweet Jun 4, 2024
d134a79
Refactor updates with viewer state
andy-sweet Jun 4, 2024
941091b
Move code to toState
andy-sweet Jun 4, 2024
cb45290
Format
andy-sweet Jun 4, 2024
761341b
Reset tracks IDs when clearing
andy-sweet Jun 4, 2024
787b01f
Rename
andy-sweet Jun 4, 2024
a77254c
Mostly working with point IDs
andy-sweet Jun 5, 2024
1278b62
Skip fetching for previously fetched points
andy-sweet Jun 5, 2024
0cff741
Format
andy-sweet Jun 5, 2024
537b84e
Simplify camera state
andy-sweet Jun 5, 2024
662b84d
Fix early return
andy-sweet Jun 5, 2024
52738dc
Fix spherical cursor
andy-sweet Jun 5, 2024
827ec93
Remove callback
andy-sweet Jun 5, 2024
051429c
Add TODO back
andy-sweet Jun 5, 2024
ba2202d
Remove unused import
andy-sweet Jun 5, 2024
d0fa89a
Change brightness when adding a selection
andy-sweet Jun 5, 2024
c902dcd
Remove unused props
andy-sweet Jun 5, 2024
7eaba4f
Format again
andy-sweet Jun 5, 2024
fef5b4e
Remove setSelectedPoints
andy-sweet Jun 6, 2024
8b81cd7
Remove highlight points action
andy-sweet Jun 6, 2024
52e4277
Format
andy-sweet Jun 6, 2024
68109c8
Add TODO
andy-sweet Jun 6, 2024
00d5f01
Remove adding
andy-sweet Jun 6, 2024
a7017f7
Use num tracks loading as state
andy-sweet Jun 6, 2024
caa6f4a
Format
andy-sweet Jun 6, 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
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 @@

// 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 @@
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 @@
// 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 @@
};
}, [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;
andy-sweet marked this conversation as resolved.
Show resolved Hide resolved
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 });
andy-sweet marked this conversation as resolved.
Show resolved Hide resolved

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 @@
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);
andy-sweet marked this conversation as resolved.
Show resolved Hide resolved
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
Loading