diff --git a/react-components/src/higher-order-components/withCameraStateUrlParam.tsx b/react-components/src/higher-order-components/withCameraStateUrlParam.tsx new file mode 100644 index 00000000000..d5562ee2f13 --- /dev/null +++ b/react-components/src/higher-order-components/withCameraStateUrlParam.tsx @@ -0,0 +1,86 @@ +/*! + * Copyright 2023 Cognite AS + */ + +import { useEffect, type ReactElement, type FunctionComponent } from 'react'; +import { useReveal } from '..'; +import { Vector3 } from 'three'; +import { type CameraState } from '@cognite/reveal'; + +type CameraStateTransform = Omit, 'rotation'>; + +export function withCameraStateUrlParam( + Component: FunctionComponent +): FunctionComponent { + return function CameraStateUrlParam(props: T): ReactElement { + const reveal = useReveal(); + const getCameraStateFromUrlParam = useGetCameraStateFromUrlParam(); + + const setUrlParamOnCameraStop = (): void => { + const currentUrlCameraState = getCameraStateFromUrlParam(); + const currentCameraManagerState = reveal.cameraManager.getCameraState(); + if ( + currentUrlCameraState !== undefined && + !hasCameraStateChanged(currentUrlCameraState, currentCameraManagerState) + ) { + return; + } + const { position, target } = currentCameraManagerState; + const url = new URL(window.location.toString()); + url.searchParams.set('cameraPosition', `[${position.x},${position.y},${position.z}]`); + url.searchParams.set('cameraTarget', `[${target.x},${target.y},${target.z}]`); + window.history.pushState({}, '', url); + }; + + useEffect(() => { + reveal.cameraManager.on('cameraStop', setUrlParamOnCameraStop); + return () => { + reveal.cameraManager.off('cameraStop', setUrlParamOnCameraStop); + }; + }, []); + + return ; + }; + + function hasCameraStateChanged( + previous: CameraStateTransform, + current: CameraStateTransform + ): boolean { + const epsilon = 0.001; + const { position: previousPosition, target: previousTarget } = previous; + const { position: currentPosition, target: currentTarget } = current; + return ( + previousPosition.distanceToSquared(currentPosition) > epsilon || + previousTarget.distanceToSquared(currentTarget) > epsilon + ); + } +} + +export function useGetCameraStateFromUrlParam(): () => CameraStateTransform | undefined { + return () => { + const url = new URL(window.location.toString()); + const position = url.searchParams.get('cameraPosition'); + const target = url.searchParams.get('cameraTarget'); + + if (position === null || target === null) { + return; + } + + const parsedPosition = getParsedVector(position); + const parsedTarget = getParsedVector(target); + + if (parsedPosition === undefined || parsedTarget === undefined) { + return; + } + + return { position: parsedPosition, target: parsedTarget }; + }; + + function getParsedVector(s: string): Vector3 | undefined { + try { + return new Vector3().fromArray(JSON.parse(s)); + } catch (e) { + return undefined; + } + } +} diff --git a/react-components/src/higher-order-components/withSuppressRevealEvents.tsx b/react-components/src/higher-order-components/withSuppressRevealEvents.tsx index 7f440c405a4..f02281c7735 100644 --- a/react-components/src/higher-order-components/withSuppressRevealEvents.tsx +++ b/react-components/src/higher-order-components/withSuppressRevealEvents.tsx @@ -2,17 +2,11 @@ * Copyright 2023 Cognite AS */ -import { - useRef, - type ComponentType, - useEffect, - type ReactElement, - type FunctionComponent -} from 'react'; +import { useRef, useEffect, type ReactElement, type FunctionComponent } from 'react'; export function withSuppressRevealEvents( Component: FunctionComponent -): ComponentType { +): FunctionComponent { return function SuppressRevealEvents(props: T): ReactElement { const divRef = useRef(null); diff --git a/react-components/src/hooks/useCameraNavigation.tsx b/react-components/src/hooks/useCameraNavigation.tsx index 7af9b336306..2218f059fe3 100644 --- a/react-components/src/hooks/useCameraNavigation.tsx +++ b/react-components/src/hooks/useCameraNavigation.tsx @@ -2,7 +2,7 @@ * Copyright 2023 Cognite AS */ -import { type CogniteCadModel } from '@cognite/reveal'; +import { type CameraState, type CogniteCadModel } from '@cognite/reveal'; import { useReveal } from '../components/RevealContainer/RevealContext'; import { useFdmNodeCache } from '../components/NodeCacheProvider/NodeCacheProvider'; @@ -10,6 +10,7 @@ export type CameraNavigationActions = { fitCameraToAllModels: () => void; fitCameraToModelNode: (revisionId: number, nodeId: number) => Promise; fitCameraToInstance: (externalId: string, space: string) => Promise; + fitCameraToState: (cameraState: CameraState) => void; }; export const useCameraNavigation = (): CameraNavigationActions => { @@ -56,9 +57,14 @@ export const useCameraNavigation = (): CameraNavigationActions => { await fitCameraToModelNode(modelMappings.revisionId, nodeId.id); }; + const fitCameraToState = (cameraState: CameraState): void => { + viewer.cameraManager.setCameraState(cameraState); + }; + return { fitCameraToAllModels, fitCameraToInstance, - fitCameraToModelNode + fitCameraToModelNode, + fitCameraToState }; }; diff --git a/react-components/src/hooks/useIsRevealInitialized.tsx b/react-components/src/hooks/useIsRevealInitialized.tsx new file mode 100644 index 00000000000..3da6268428c --- /dev/null +++ b/react-components/src/hooks/useIsRevealInitialized.tsx @@ -0,0 +1,9 @@ +/*! + * Copyright 2023 Cognite AS + */ +import { useRevealKeepAlive } from '../components/RevealKeepAlive/RevealKeepAliveContext'; + +export const useIsRevealInitialized = (): boolean => { + const revealKeepAliveData = useRevealKeepAlive(); + return revealKeepAliveData?.viewerRef.current !== undefined; +}; diff --git a/react-components/src/index.ts b/react-components/src/index.ts index 9ba183226f8..7a487841275 100644 --- a/react-components/src/index.ts +++ b/react-components/src/index.ts @@ -26,11 +26,15 @@ export { } from './hooks/useClickedNode'; export { useCameraNavigation } from './hooks/useCameraNavigation'; export { useMappedEdgesForRevisions } from './components/NodeCacheProvider/NodeCacheProvider'; +export { useIsRevealInitialized } from './hooks/useIsRevealInitialized'; export { use3dNodeByExternalId } from './hooks/use3dNodeByExternalId'; // Higher order components export { withSuppressRevealEvents } from './higher-order-components/withSuppressRevealEvents'; - +export { + withCameraStateUrlParam, + useGetCameraStateFromUrlParam +} from './higher-order-components/withCameraStateUrlParam'; // Types export { type PointCloudModelStyling, diff --git a/react-components/stories/Toolbar.stories.tsx b/react-components/stories/Toolbar.stories.tsx index 50b52f422a5..d570293690a 100644 --- a/react-components/stories/Toolbar.stories.tsx +++ b/react-components/stories/Toolbar.stories.tsx @@ -8,13 +8,16 @@ import { type QualitySettings, RevealContainer, RevealToolbar, - withSuppressRevealEvents + withSuppressRevealEvents, + withCameraStateUrlParam, + useGetCameraStateFromUrlParam, + useCameraNavigation } from '../src'; import { CogniteClient } from '@cognite/sdk'; import { Color } from 'three'; import styled from 'styled-components'; import { Button, Menu, ToolBar, type ToolBarButton } from '@cognite/cogs.js'; -import { type ReactElement, useState } from 'react'; +import { type ReactElement, useState, useEffect } from 'react'; const meta = { title: 'Example/Toolbar', @@ -33,7 +36,7 @@ const sdk = new CogniteClient({ getToken: async () => await Promise.resolve(token) }); -const MyCustomToolbar = styled(withSuppressRevealEvents(ToolBar))` +const MyCustomToolbar = styled(withSuppressRevealEvents(withCameraStateUrlParam(ToolBar)))` position: absolute; right: 20px; top: 70px; @@ -103,6 +106,7 @@ export const Main: Story = { }, render: ({ addModelOptions }) => ( + ) }; + +function FitToUrlCameraState(): ReactElement { + const getCameraState = useGetCameraStateFromUrlParam(); + const cameraNavigation = useCameraNavigation(); + + useEffect(() => { + const currentCameraState = getCameraState(); + if (currentCameraState === undefined) return; + cameraNavigation.fitCameraToState(currentCameraState); + }, []); + + return <>; +}