diff --git a/src/components/deck-gl-wrapper/deck-gl-wrapper.tsx b/src/components/deck-gl-wrapper/deck-gl-wrapper.tsx index 2f11fcfb..a6901a2b 100644 --- a/src/components/deck-gl-wrapper/deck-gl-wrapper.tsx +++ b/src/components/deck-gl-wrapper/deck-gl-wrapper.tsx @@ -1,7 +1,7 @@ import DeckGL from "@deck.gl/react"; import { LineLayer, ScatterplotLayer } from "@deck.gl/layers"; import { TerrainLayer, Tile3DLayer } from "@deck.gl/geo-layers"; -import { MapController } from "@deck.gl/core"; +import { MapController, InteractionState } from "@deck.gl/core"; import type { Tile3D, Tileset3D } from "@loaders.gl/tiles"; import { I3SLoader, SceneLayer3D } from "@loaders.gl/i3s"; import { CesiumIonLoader, Tiles3DLoader } from "@loaders.gl/3d-tiles"; @@ -161,6 +161,8 @@ type DeckGlI3sProps = { onWebGLInitialized?: (gl: any) => void; /** DeckGL after render callback */ onAfterRender?: () => void; + /** DeckGL onInteractionStateChange callback */ + onInteractionStateChange?: (interactionState: InteractionState) => void; /** DeckGL callback. On layer hover behavior */ getTooltip?: (info: { object: Tile3D; index: number; layer: any }) => void; /** DeckGL callback. On layer click behavior */ @@ -203,6 +205,7 @@ export const DeckGlWrapper = ({ onViewStateChange, onWebGLInitialized, onAfterRender, + onInteractionStateChange, getTooltip, onClick, onTilesetLoad, @@ -777,6 +780,7 @@ export const DeckGlWrapper = ({ }} onWebGLInitialized={onWebGLInitialized} onAfterRender={onAfterRender} + onInteractionStateChange={onInteractionStateChange} getTooltip={getTooltip} onClick={onClick} > diff --git a/src/pages/debug-app/debug-app.tsx b/src/pages/debug-app/debug-app.tsx index a743648e..9bcbe2ee 100644 --- a/src/pages/debug-app/debug-app.tsx +++ b/src/pages/debug-app/debug-app.tsx @@ -22,7 +22,7 @@ import { import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { render } from "react-dom"; import { lumaStats } from "@luma.gl/core"; -import { PickingInfo } from "@deck.gl/core"; +import { PickingInfo, InteractionStateChange, ViewState } from "@deck.gl/core"; import { v4 as uuidv4 } from "uuid"; import { Stats } from "@probe.gl/stats"; @@ -38,7 +38,11 @@ import ColorMap, { makeRGBObjectFromColor, } from "../../utils/debug/colors-map"; import { initStats, sumTilesetsStats } from "../../utils/stats"; -import { parseTilesetUrlParams } from "../../utils/url-utils"; +import { + parseTilesetUrlParams, + urlParamsToViewState, + viewStateToUrlParams, +} from "../../utils/url-utils"; import { validateTile } from "../../utils/debug/tile-debug"; import { BottomToolsPanelWrapper, @@ -149,7 +153,8 @@ export const DebugApp = () => { const [sublayers, setSublayers] = useState([]); const [buildingExplorerOpened, setBuildingExplorerOpened] = useState(false); - + const [stateUrlViewStateParams, setStateUrlViewStateParams] = + useState({}); const [, setSearchParams] = useSearchParams(); const dispatch = useAppDispatch(); @@ -187,6 +192,9 @@ export const DebugApp = () => { dispatch(setColorsByAttrubute(null)); dispatch(setDragMode(DragMode.pan)); dispatch(setDebugOptions({ minimap: true })); + + setStateUrlViewStateParams(urlParamsToViewState(viewState)); + return () => { dispatch(resetDebugOptions()); dispatch(setInitialBaseMaps()); @@ -278,6 +286,19 @@ export const DebugApp = () => { tilesetRef.current = tileset; setUpdateStatsNumber((prev) => prev + 1); + if (Object.keys(stateUrlViewStateParams).length > 0) { + const { longitude, latitude } = stateUrlViewStateParams; + setViewState({ + ...viewState, + main: { ...viewState.main, ...stateUrlViewStateParams }, + minimap: { + ...viewState.minimap, + longitude, + latitude, + }, + }); + setStateUrlViewStateParams({}); + } }; const handleValidateTile = (tile) => { @@ -690,6 +711,22 @@ export const DebugApp = () => { })); }, []); + const onInteractionStateChange = ( + interactionStateChange: InteractionStateChange + ) => { + const { isDragging, inTransition, isZooming, isPanning, isRotating } = + interactionStateChange; + if ( + !isDragging && + !inTransition && + !isZooming && + !isPanning && + !isRotating + ) { + setSearchParams(viewStateToUrlParams(viewState), { replace: true }); + } + }; + return ( {renderTilePanel()} @@ -722,6 +759,7 @@ export const DebugApp = () => { onTileLoad={onTileLoad} onWebGLInitialized={onWebGLInitialized} preventTransitions={preventTransitions} + onInteractionStateChange={onInteractionStateChange} /> {layout !== Layout.Mobile && ( diff --git a/src/pages/viewer-app/viewer-app.tsx b/src/pages/viewer-app/viewer-app.tsx index 80dfb736..9c5049dd 100644 --- a/src/pages/viewer-app/viewer-app.tsx +++ b/src/pages/viewer-app/viewer-app.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { render } from "react-dom"; +import { InteractionStateChange, ViewState } from "@deck.gl/core"; import { lumaStats } from "@luma.gl/core"; import { loadFeatureAttributes, StatisticsInfo } from "@loaders.gl/i3s"; import { v4 as uuidv4 } from "uuid"; @@ -13,7 +14,11 @@ import { Tileset3D } from "@loaders.gl/tiles"; import { DeckGlWrapper } from "../../components/deck-gl-wrapper/deck-gl-wrapper"; import { AttributesPanel } from "../../components/attributes-panel/attributes-panel"; import { initStats, sumTilesetsStats } from "../../utils/stats"; -import { parseTilesetUrlParams } from "../../utils/url-utils"; +import { + parseTilesetUrlParams, + urlParamsToViewState, + viewStateToUrlParams, +} from "../../utils/url-utils"; import { FeatureAttributes, Sublayer, @@ -122,6 +127,8 @@ export const ViewerApp = () => { const [sublayers, setSublayers] = useState([]); const [buildingExplorerOpened, setBuildingExplorerOpened] = useState(false); + const [stateUrlViewStateParams, setStateUrlViewStateParams] = + useState({}); const [, setSearchParams] = useSearchParams(); const dispatch = useAppDispatch(); const filtersByAttribute = useSelector((state: RootState) => @@ -154,6 +161,9 @@ export const ViewerApp = () => { setActiveLayers([newActiveLayer]); dispatch(setColorsByAttrubute(null)); dispatch(setDragMode(DragMode.pan)); + + setStateUrlViewStateParams(urlParamsToViewState(viewState)); + return () => { dispatch(setInitialBaseMaps()); }; @@ -228,6 +238,13 @@ export const ViewerApp = () => { ); tilesetRef.current = tileset; setUpdateStatsNumber((prev) => prev + 1); + if (Object.keys(stateUrlViewStateParams).length > 0) { + setViewState({ + ...viewState, + main: { ...viewState.main, ...stateUrlViewStateParams }, + }); + setStateUrlViewStateParams({}); + } }; const isLayerPickable = () => { @@ -547,6 +564,22 @@ export const ViewerApp = () => { })); }, []); + const onInteractionStateChange = ( + interactionStateChange: InteractionStateChange + ) => { + const { isDragging, inTransition, isZooming, isPanning, isRotating } = + interactionStateChange; + if ( + !isDragging && + !inTransition && + !isZooming && + !isPanning && + !isRotating + ) { + setSearchParams(viewStateToUrlParams(viewState), { replace: true }); + } + }; + return ( {selectedFeatureAttributes && renderAttributesPanel()} @@ -573,6 +606,7 @@ export const ViewerApp = () => { onTileLoad={onTileLoad} onWebGLInitialized={onWebGLInitialized} preventTransitions={preventTransitions} + onInteractionStateChange={onInteractionStateChange} /> {layout !== Layout.Mobile && ( diff --git a/src/utils/url-utils.spec.ts b/src/utils/url-utils.spec.ts index 65838b13..533b8685 100644 --- a/src/utils/url-utils.spec.ts +++ b/src/utils/url-utils.spec.ts @@ -3,6 +3,8 @@ import { getTilesetType, parseTilesetFromUrl, parseTilesetUrlParams, + urlParamsToViewState, + viewStateToUrlParams, } from "./url-utils"; const mockResponse = jest.fn(); @@ -134,3 +136,46 @@ describe("Url Utils - getTilesetType", () => { expect(resultEmptyUrl).toEqual(TilesetType.I3S); }); }); + +describe("Url Utils - viewStateToUrlParams", () => { + test("Should generate updated url search params", () => { + Object.defineProperty(window, "location", { + value: { + search: "tileset=test-tileset-name&token=test-token", + }, + writable: true, + }); + const viewState = { + main: { zoom: 1, longitude: 2, latitude: 3, bearing: 4, pitch: 5 }, + }; + const result = viewStateToUrlParams(viewState); + expect(result).toEqual({ + ...viewState.main, + tileset: "test-tileset-name", + token: "test-token", + }); + }); +}); + +describe("Url Utils - urlParamsToViewState", () => { + test("Should generate updated url search params", () => { + Object.defineProperty(window, "location", { + value: { + search: + "tileset=test-tileset-name&zoom=6&longitude=7&latitude=8&bearing=9&pitch=10", + }, + writable: true, + }); + const viewState = { + main: { zoom: 1, longitude: 2, latitude: 3, bearing: 4, pitch: 5 }, + }; + const result = urlParamsToViewState(viewState); + expect(result).toEqual({ + zoom: 6, + longitude: 7, + latitude: 8, + bearing: 9, + pitch: 10, + }); + }); +}); diff --git a/src/utils/url-utils.ts b/src/utils/url-utils.ts index 96c00a1a..3618e107 100644 --- a/src/utils/url-utils.ts +++ b/src/utils/url-utils.ts @@ -1,4 +1,4 @@ -import { TilesetType } from "../types"; +import { TilesetType, ViewStateSet } from "../types"; export const parseTilesetFromUrl = () => { const parsedUrl = new URL(window.location.href); @@ -7,7 +7,7 @@ export const parseTilesetFromUrl = () => { export const parseTilesetUrlParams = (url, options) => { if (!url) { - return { ...options, tilesetUrl: '', token: '', metadataUrl: '' } + return { ...options, tilesetUrl: "", token: "", metadataUrl: "" }; } const parsedUrl = new URL(url); @@ -57,3 +57,42 @@ const prepareTilesetUrl = (parsedUrl) => { .concat("layers/0"); return `${parsedUrl.origin}${replacedPathName}${parsedUrl.search}`; }; + +/** + * Generate updated url search params according to the viewState + * @param viewState view state + * @returns updated url search params object + */ +export const viewStateToUrlParams = (viewState: ViewStateSet) => { + const search = Object.fromEntries( + new URLSearchParams(window.location.search) + ); + const { longitude, latitude, pitch, bearing, zoom } = viewState.main; + return { + ...search, + longitude, + latitude, + pitch, + bearing, + zoom, + }; +}; + +/** + * Parse view state params from the url search params + * @param viewState view state + * @returns viewState params available in the url search params + */ +export const urlParamsToViewState = (viewState: ViewStateSet) => { + const search = new URLSearchParams(window.location.search); + const urlViewStateParams = {}; + for (const viewStateParam of search) { + if ( + Object.keys(viewState.main).includes(viewStateParam[0]) && + !isNaN(parseFloat(viewStateParam[1])) + ) { + urlViewStateParams[viewStateParam[0]] = parseFloat(viewStateParam[1]); + } + } + return urlViewStateParams; +};