diff --git a/src/components/App.tsx b/src/components/App.tsx index d58fc9d4..3bfaf99b 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -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; @@ -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); @@ -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 @@ -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(); + 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]); // playback time points // TODO: this is basic and may drop frames @@ -302,12 +291,7 @@ export default function App() { overflow: "hidden", }} > - + 0} /> diff --git a/src/components/Scene.tsx b/src/components/Scene.tsx index c9956813..29e80709 100644 --- a/src/components/Scene.tsx +++ b/src/components/Scene.tsx @@ -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) { diff --git a/src/hooks/usePointCanvas.ts b/src/hooks/usePointCanvas.ts index 093d02a9..d69a64f4 100644 --- a/src/hooks/usePointCanvas.ts +++ b/src/hooks/usePointCanvas.ts @@ -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", @@ -22,6 +17,8 @@ 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 { @@ -29,22 +26,11 @@ interface AutoRotate { 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; @@ -95,12 +81,21 @@ interface MinMaxTime { maxTime: number; } +interface AddSelectedPointIds { + type: ActionType.ADD_SELECTED_POINT_IDS; + selectedPointIndices: number[]; + selectedPointIds: Set; +} + +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 @@ -110,7 +105,9 @@ type PointCanvasAction = | ShowTracks | ShowTrackHighlights | Size - | MinMaxTime; + | MinMaxTime + | AddSelectedPointIds + | UpdateWithState; function reducer(canvas: PointCanvas, action: PointCanvasAction): PointCanvas { console.debug("usePointCanvas.reducer: ", action); @@ -118,9 +115,6 @@ function reducer(canvas: PointCanvas, action: PointCanvasAction): PointCanvas { 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") { @@ -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; @@ -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; @@ -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(); @@ -197,16 +205,23 @@ function createPointCanvas(initialViewerState: ViewerState): PointCanvas { function usePointCanvas( initialViewerState: ViewerState, ): [PointCanvas, Dispatch, RefObject] { - console.debug("usePointCanvas: ", initialViewerState); const divRef = useRef(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: diff --git a/src/lib/BoxPointSelector.ts b/src/lib/BoxPointSelector.ts index 26cb6192..27d26d1e 100644 --- a/src/lib/BoxPointSelector.ts +++ b/src/lib/BoxPointSelector.ts @@ -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. @@ -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. @@ -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); @@ -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; @@ -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) { diff --git a/src/lib/PointCanvas.ts b/src/lib/PointCanvas.ts index 6633eb67..11516acd 100644 --- a/src/lib/PointCanvas.ts +++ b/src/lib/PointCanvas.ts @@ -12,7 +12,6 @@ import { SRGBColorSpace, TextureLoader, Vector2, - Vector3, WebGLRenderer, } from "three"; import { OrbitControls } from "three/addons/controls/OrbitControls.js"; @@ -23,7 +22,7 @@ import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js" import { Track } from "@/lib/three/Track"; import { PointSelector, PointSelectionMode } from "@/lib/PointSelector"; -import { PointsCollection } from "@/lib/PointSelectionBox"; +import { ViewerState } from "./ViewerState"; type Tracks = Map; @@ -37,19 +36,32 @@ export class PointCanvas { readonly bloomPass: UnrealBloomPass; readonly selector: PointSelector; + // Maps from track ID to three.js Track objects. + // This contains all tracks or tracklets across the lineages of all + // selected cells. readonly tracks: Tracks = new Map(); + // Needed to skip fetches for lineages that have already been fetched. + // TODO: storing the fetched track and point IDs here works for now, + // but is likely a good candidate for a refactor. + readonly fetchedRootTrackIds = new Set(); + // Needed to skip fetches for point IDs that been selected. + readonly fetchedPointIds = new Set(); + // All the point IDs that have been selected. + // PointCanvas.selector.selection is the transient array of selected + // point indices associated with a specific time point and selection action, + // whereas these are a union of all those selection actions, are unique + // across the whole dataset and can be used for persistent storage. + selectedPointIds: Set = new Set(); showTracks = true; showTrackHighlights = true; curTime: number = 0; minTime: number = -6; maxTime: number = 5; pointBrightness = 1.0; - // this is used to initialize the points geometry, and kept to initialize the // tracks but could be pulled from the points geometry when adding tracks - // private here to consolidate external access via `TrackManager` instead - private maxPointsPerTimepoint = 0; + maxPointsPerTimepoint = 0; constructor(width: number, height: number) { this.scene = new Scene(); @@ -107,8 +119,32 @@ export class PointCanvas { return newCanvas as PointCanvas; } - get selectedPoints(): PointsCollection { - return this.selector.selection; + toState(): ViewerState { + const state = new ViewerState(); + state.curTime = this.curTime; + state.minTime = this.minTime; + state.maxTime = this.maxTime; + state.maxPointsPerTimepoint = this.maxPointsPerTimepoint; + state.pointBrightness = this.pointBrightness; + state.showTracks = this.showTracks; + state.showTrackHighlights = this.showTrackHighlights; + state.selectedPointIds = new Array(...this.selectedPointIds); + state.cameraPosition = this.camera.position.toArray(); + state.cameraTarget = this.controls.target.toArray(); + return state; + } + + updateWithState(state: ViewerState) { + this.curTime = state.curTime; + this.minTime = state.minTime; + this.maxTime = state.maxTime; + this.maxPointsPerTimepoint = state.maxPointsPerTimepoint; + this.pointBrightness = state.pointBrightness; + this.showTracks = state.showTracks; + this.showTrackHighlights = state.showTrackHighlights; + this.selectedPointIds = new Set(state.selectedPointIds); + this.camera.position.fromArray(state.cameraPosition); + this.controls.target.fromArray(state.cameraTarget); } setSelectionMode(mode: PointSelectionMode) { @@ -124,11 +160,6 @@ export class PointCanvas { this.controls.update(); }; - setCameraProperties(position?: Vector3, target?: Vector3) { - position && this.camera.position.set(position.x, position.y, position.z); - target && this.controls.target.set(target.x, target.y, target.z); - } - highlightPoints(points: number[]) { const colorAttribute = this.points.geometry.getAttribute("color"); const color = new Color(); @@ -222,6 +253,9 @@ export class PointCanvas { } removeAllTracks() { + this.selectedPointIds = new Set(); + this.fetchedRootTrackIds.clear(); + this.fetchedPointIds.clear(); for (const trackID of this.tracks.keys()) { this.removeTrack(trackID); } diff --git a/src/lib/PointSelector.ts b/src/lib/PointSelector.ts index 4feb1715..f82b2704 100644 --- a/src/lib/PointSelector.ts +++ b/src/lib/PointSelector.ts @@ -1,7 +1,6 @@ import { PerspectiveCamera, Points, Scene, WebGLRenderer } from "three"; import { OrbitControls } from "three/addons/controls/OrbitControls.js"; -import { PointsCollection } from "@/lib/PointSelectionBox"; import { BoxPointSelector } from "./BoxPointSelector"; import { SpherePointSelector } from "./SpherePointSelector"; @@ -22,7 +21,7 @@ interface PointSelectorInterface { dispose(): void; } -export type SelectionChanged = (selection: PointsCollection) => void; +export type SelectionChanged = (selection: number[]) => void; // this is a separate class to keep the point selection logic separate from the rendering logic in // the PointCanvas class this fixes some issues with callbacks and event listeners binding to @@ -34,9 +33,8 @@ export class PointSelector { readonly sphereSelector: SpherePointSelector; selectionMode: PointSelectionMode = PointSelectionMode.BOX; - selection: PointsCollection = new Map(); - // To optionally notify external observers about changes to the current selection. - selectionChanged: SelectionChanged = (_selection: PointsCollection) => {}; + // To notify external observers about changes to the current selection. + selectionChanged: SelectionChanged = (_selection: number[]) => {}; constructor( scene: Scene, @@ -45,7 +43,14 @@ export class PointSelector { controls: OrbitControls, points: Points, ) { - this.boxSelector = new BoxPointSelector(scene, renderer, camera, controls, this.setSelectedPoints.bind(this)); + this.boxSelector = new BoxPointSelector( + scene, + renderer, + camera, + controls, + points, + this.setSelectedPoints.bind(this), + ); this.sphereSelector = new SpherePointSelector( scene, renderer, @@ -84,9 +89,8 @@ export class PointSelector { return this.selectionMode === PointSelectionMode.BOX ? this.boxSelector : this.sphereSelector; } - setSelectedPoints(selection: PointsCollection) { + setSelectedPoints(selection: number[]) { console.debug("PointSelector.setSelectedPoints:", selection); - this.selection = selection; this.selectionChanged(selection); } diff --git a/src/lib/SpherePointSelector.ts b/src/lib/SpherePointSelector.ts index aad6e923..cdf1225c 100644 --- a/src/lib/SpherePointSelector.ts +++ b/src/lib/SpherePointSelector.ts @@ -14,8 +14,6 @@ import { import { OrbitControls } from "three/addons/controls/OrbitControls.js"; import { TransformControls } from "three/examples/jsm/Addons.js"; -import { PointsCollection } from "@/lib/PointSelectionBox"; - import { SelectionChanged } from "@/lib/PointSelector"; // Selecting with a sphere, with optional transform controls. @@ -175,10 +173,8 @@ export class SpherePointSelector { selected.push(i); } } - const points: PointsCollection = new Map(); - points.set(this.points.id, selected); console.log("selected points:", selected); - this.selectionChanged(points); + this.selectionChanged(selected); } pointerDown(_event: MouseEvent) {} diff --git a/src/lib/ViewerState.ts b/src/lib/ViewerState.ts index b309ecf9..8fd63b40 100644 --- a/src/lib/ViewerState.ts +++ b/src/lib/ViewerState.ts @@ -1,5 +1,3 @@ -import { Vector3 } from "three"; - export const DEFAULT_ZARR_URL = "https://sci-imaging-vis-public-demo-data.s3.us-west-2.amazonaws.com" + "/points-web-viewer/sparse-zarr-v2/ZSNS001_tracks_bundle.zarr"; @@ -16,41 +14,37 @@ export function clearUrlHash() { // Encapsulates all the persistent state in the viewer (e.g. that can be serialized and shared). export class ViewerState { - dataUrl: string; - curTime: number; - cameraPosition: Vector3; - cameraTarget: Vector3; - - constructor( - dataUrl: string = 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; - } + dataUrl = DEFAULT_ZARR_URL; + curTime = 0; + minTime: number = -6; + maxTime: number = 5; + maxPointsPerTimepoint = 0; + pointBrightness = 1.0; + selectedPointIds: Array = []; + showTracks = true; + showTrackHighlights = true; + // Default position and target from interacting with ZSNS001. + cameraPosition = [500, 500, -1250]; + cameraTarget = [500, 500, 250]; toUrlHash(): string { - // Use SearchParams to sanitize serialized string values for URL. + // Use URLSearchParams to sanitize serialized string values for URL. const searchParams = new URLSearchParams(); searchParams.append(HASH_KEY, JSON.stringify(this)); - return searchParams.toString(); + 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 state = new ViewerState(); + // Remove the # from the hash to get the fragment, which + // is encoded using URLSearchParams to handle special characters. const searchParams = new URLSearchParams(urlHash.slice(1)); if (searchParams.has(HASH_KEY)) { return JSON.parse(searchParams.get(HASH_KEY)!); - } - if (urlHash.length > 0) { + } else if (urlHash.length > 0) { console.error("failed to find state key in hash: %s", urlHash); } - return new ViewerState(); + return state; } }