From 6807f571c28c397cba5a92bde44adb6e0929c1a6 Mon Sep 17 00:00:00 2001 From: Martin Schuhfuss Date: Thu, 17 Aug 2023 14:50:46 +0200 Subject: [PATCH] feat(globe): wait for layer to load before advancing timestep (#1268) * feat(globe): store layer loading state in app-state * feat(globe): only advance time when layers have rendered * fix: add defaults for layerLoadingState --- .../actions/update-layer-loading-state.ts | 16 +++++++ .../layers/time-playback/time-playback.ts | 44 ++++++++++++++++-- .../layers/time-slider/time-slider.tsx | 2 + .../main/data-viewer/data-viewer.tsx | 10 +++- src/scripts/components/main/globe/globe.tsx | 46 +++++++++++++++++-- src/scripts/config/main.ts | 3 +- src/scripts/libs/globe-url-parameter.ts | 1 + src/scripts/reducers/globe/index.ts | 4 +- .../reducers/globe/layer-loading-state.ts | 21 +++++++++ .../selectors/globe/layer-loading-state.ts | 6 +++ 10 files changed, 141 insertions(+), 12 deletions(-) create mode 100644 src/scripts/actions/update-layer-loading-state.ts create mode 100644 src/scripts/reducers/globe/layer-loading-state.ts create mode 100644 src/scripts/selectors/globe/layer-loading-state.ts diff --git a/src/scripts/actions/update-layer-loading-state.ts b/src/scripts/actions/update-layer-loading-state.ts new file mode 100644 index 000000000..145fe8b5c --- /dev/null +++ b/src/scripts/actions/update-layer-loading-state.ts @@ -0,0 +1,16 @@ +import {LayerLoadingState} from '@ubilabs/esa-webgl-globe'; + +export const UPDATE_LAYER_LOADING_STATE = 'UPDATE_LAYER_LOADING_STATE'; + +export interface UpdateLayerLoadingStateAction { + type: typeof UPDATE_LAYER_LOADING_STATE; + layerId: string; + loadingState: LayerLoadingState; +} + +export default function updateLayerLoadingStateAction( + layerId: string, + loadingState: LayerLoadingState +): UpdateLayerLoadingStateAction { + return {type: UPDATE_LAYER_LOADING_STATE, layerId, loadingState}; +} diff --git a/src/scripts/components/layers/time-playback/time-playback.ts b/src/scripts/components/layers/time-playback/time-playback.ts index 8f8c731b5..22d84b2c9 100644 --- a/src/scripts/components/layers/time-playback/time-playback.ts +++ b/src/scripts/components/layers/time-playback/time-playback.ts @@ -1,9 +1,12 @@ -import {FunctionComponent} from 'react'; -import {useSelector, useDispatch} from 'react-redux'; +import {FunctionComponent, useEffect, useState} from 'react'; +import {useDispatch, useSelector} from 'react-redux'; import {timeSelector} from '../../../selectors/globe/time'; +import {layerLoadingStateSelector} from '../../../selectors/globe/layer-loading-state'; + import setGlobeTime from '../../../actions/set-globe-time'; import {useInterval} from '../../../hooks/use-interval'; +import {LayerLoadingState} from '@ubilabs/esa-webgl-globe'; const PLAYBACK_STEP = 1000 * 60 * 60 * 24 * 30; // one month const PLAYBACK_SPEED = 1000; // increase one step per x milliseconds @@ -13,17 +16,23 @@ interface Props { maxTime: number; speed?: number; step?: number; + mainLayerId?: string | null; + compareLayerId?: string | null; } const TimePlayback: FunctionComponent = ({ minTime, maxTime, speed = PLAYBACK_SPEED, - step = PLAYBACK_STEP + step = PLAYBACK_STEP, + mainLayerId, + compareLayerId }) => { const dispatch = useDispatch(); const time = useSelector(timeSelector); + const [nextTime, setNextTime] = useState(time); + useInterval(() => { let newTime = time + step; @@ -31,9 +40,36 @@ const TimePlayback: FunctionComponent = ({ newTime = minTime; } - dispatch(setGlobeTime(newTime)); + // don't immediately set the new time, since we might have to wait + // for the layer to complete loading the previous timestamp. + setNextTime(newTime); }, speed); + // before the globe-time is updated, make sure the layers have actually + // rendered the current time. + + // get the state for both possible layers. When no layer is specified, we + // use 'idle' as to not interfere with the loading logic + const layerLoadingState = useSelector(layerLoadingStateSelector); + const mainLayerState = mainLayerId + ? layerLoadingState[mainLayerId] + : ('idle' as LayerLoadingState); + const compareLayerState = compareLayerId + ? layerLoadingState[compareLayerId] + : ('idle' as LayerLoadingState); + + useEffect(() => { + if ( + nextTime === time || + mainLayerState === 'loading' || + compareLayerState === 'loading' + ) { + return; + } + + dispatch(setGlobeTime(nextTime)); + }, [dispatch, time, nextTime, mainLayerState, compareLayerState]); + return null; }; diff --git a/src/scripts/components/layers/time-slider/time-slider.tsx b/src/scripts/components/layers/time-slider/time-slider.tsx index 70a0ce75f..8ff8eb324 100644 --- a/src/scripts/components/layers/time-slider/time-slider.tsx +++ b/src/scripts/components/layers/time-slider/time-slider.tsx @@ -132,6 +132,8 @@ const TimeSlider: FunctionComponent = ({ minTime={combined.min} maxTime={combined.max} step={playbackStep} + mainLayerId={mainId} + compareLayerId={compareId} /> )}
diff --git a/src/scripts/components/main/data-viewer/data-viewer.tsx b/src/scripts/components/main/data-viewer/data-viewer.tsx index ae11e0519..6d4596980 100644 --- a/src/scripts/components/main/data-viewer/data-viewer.tsx +++ b/src/scripts/components/main/data-viewer/data-viewer.tsx @@ -6,7 +6,7 @@ import React, { useLayoutEffect } from 'react'; import {useSelector, useDispatch} from 'react-redux'; -import {CameraView} from '@ubilabs/esa-webgl-globe'; +import {CameraView, LayerLoadingState} from '@ubilabs/esa-webgl-globe'; import {layerListItemSelector} from '../../../selectors/layers/list-item'; import {globeViewSelector} from '../../../selectors/globe/view'; @@ -18,6 +18,7 @@ import {selectedLayerIdsSelector} from '../../../selectors/layers/selected-ids'; import {globeSpinningSelector} from '../../../selectors/globe/spinning'; import setGlobeViewAction from '../../../actions/set-globe-view'; import setGlobeSpinningAction from '../../../actions/set-globe-spinning'; +import updateLayerLoadingStateAction from '../../../actions/update-layer-loading-state'; import {State} from '../../../reducers'; import Globe from '../globe/globe'; import Gallery from '../gallery/gallery'; @@ -90,6 +91,12 @@ const DataViewer: FunctionComponent = ({ [dispatch] ); + const onLayerLoadingStateChangeHandler = useCallback( + (layerId: string, loadingState: LayerLoadingState) => + dispatch(updateLayerLoadingStateAction(layerId, loadingState)), + [dispatch] + ); + const mainImageLayer = useImageLayerData(mainLayerDetails, time); const compareImageLayer = useImageLayerData(compareLayerDetails, time); @@ -147,6 +154,7 @@ const DataViewer: FunctionComponent = ({ onChange={onChangeHandler} onMoveStart={onMoveStartHandler} onMoveEnd={onMoveEndHandler} + onLayerLoadingStateChange={onLayerLoadingStateChangeHandler} /> ); }; diff --git a/src/scripts/components/main/globe/globe.tsx b/src/scripts/components/main/globe/globe.tsx index 05fd4e3f9..882726dbd 100644 --- a/src/scripts/components/main/globe/globe.tsx +++ b/src/scripts/components/main/globe/globe.tsx @@ -10,6 +10,7 @@ import cx from 'classnames'; import { CameraView, + LayerLoadingState, LayerProps, MarkerProps, RenderMode, @@ -53,6 +54,10 @@ interface Props { onChange: (view: CameraView) => void; onMoveStart: () => void; onMoveEnd: (view: CameraView) => void; + onLayerLoadingStateChange: ( + layerId: string, + state: LayerLoadingState + ) => void; } const EMPTY_FUNCTION = () => {}; @@ -76,7 +81,9 @@ const Globe: FunctionComponent = props => { useProjectionSwitch(globe, projectionState.projection); useMultiGlobeSynchronization(globe, props); - // eslint-disable-next-line no-warning-comments + + useLayerLoadingStateUpdater(globe, props.onLayerLoadingStateChange); + // fixme: add auto-rotate functionality return ( @@ -186,8 +193,11 @@ function useInitialBasemapTilesLoaded(globe: WebGlGlobe | null) { const handleLoadingStateChange = useCallback( (ev: LayerLoadingStateChangedEvent) => { const {layer, state} = ev.detail; - if (layer.id === 'basemap' && state === 'ready') { - setInitialTilesLoaded(true); + + if (layer.id === 'basemap') { + if (state === 'ready' || state === 'idle') { + setInitialTilesLoaded(true); + } } }, [] @@ -214,6 +224,9 @@ function useInitialBasemapTilesLoaded(globe: WebGlGlobe | null) { return initalTilesLoaded; } +/** + * Switch the projection mode used by the globe. + */ function useProjectionSwitch( globe: WebGlGlobe | null, projection: GlobeProjection @@ -258,8 +271,6 @@ function useMultiGlobeSynchronization(globe: WebGlGlobe | null, props: Props) { /** * Call the onChange callback from the props from an active globe. - * @param globe - * @param props */ function useCameraChangeEvents(globe: WebGlGlobe | null, props: Props) { const {active, onMoveStart, onChange, onMoveEnd} = props; @@ -307,6 +318,31 @@ function useCameraChangeEvents(globe: WebGlGlobe | null, props: Props) { }, [globe, active, handleViewChanged]); } +/** + * Dispatch layerLoadingStates from the globe to the parent component. + */ +function useLayerLoadingStateUpdater( + globe: WebGlGlobe | null, + callback: (layerId: string, state: LayerLoadingState) => void +) { + const handler = useCallback( + (ev: LayerLoadingStateChangedEvent) => { + callback(ev.detail.layer.id, ev.detail.state); + }, + [callback] + ); + + useEffect(() => { + if (!globe) { + return EMPTY_FUNCTION; + } + + globe.addEventListener('layerLoadingStateChanged', handler); + + return () => globe.removeEventListener('layerLoadingStateChanged', handler); + }, [globe, handler]); +} + // ---- // utility functions // ---- diff --git a/src/scripts/config/main.ts b/src/scripts/config/main.ts index e8532262e..5281ec53b 100644 --- a/src/scripts/config/main.ts +++ b/src/scripts/config/main.ts @@ -18,7 +18,8 @@ const globeState: GlobeState = { altitude: 23840000, zoom: 0 }, - spinning: true + spinning: true, + layerLoadingState: {} }; export const uiEmbedElements: UiEmbedElement[] = [ diff --git a/src/scripts/libs/globe-url-parameter.ts b/src/scripts/libs/globe-url-parameter.ts index 4213925c0..72e4ca52c 100644 --- a/src/scripts/libs/globe-url-parameter.ts +++ b/src/scripts/libs/globe-url-parameter.ts @@ -58,6 +58,7 @@ export function parseUrl(): UrlHashState | null { projection, morphTime: 2 }, + layerLoadingState: {}, time, spinning }, diff --git a/src/scripts/reducers/globe/index.ts b/src/scripts/reducers/globe/index.ts index 2203ea577..721a83ca1 100644 --- a/src/scripts/reducers/globe/index.ts +++ b/src/scripts/reducers/globe/index.ts @@ -4,12 +4,14 @@ import projectionReducer from './projection'; import viewReducer from './view'; import timeReducer from './time'; import spinningReducer from './spinning'; +import layerLoadingStateReducer from './layer-loading-state'; const globeReducer = combineReducers({ view: viewReducer, projectionState: projectionReducer, time: timeReducer, - spinning: spinningReducer + spinning: spinningReducer, + layerLoadingState: layerLoadingStateReducer }); export default globeReducer; diff --git a/src/scripts/reducers/globe/layer-loading-state.ts b/src/scripts/reducers/globe/layer-loading-state.ts new file mode 100644 index 000000000..cdf2c10ae --- /dev/null +++ b/src/scripts/reducers/globe/layer-loading-state.ts @@ -0,0 +1,21 @@ +import {LayerLoadingState} from '@ubilabs/esa-webgl-globe'; +import { + UPDATE_LAYER_LOADING_STATE, + UpdateLayerLoadingStateAction +} from '../../actions/update-layer-loading-state'; + +export type LoadingStateByLayer = {[layerId: string]: LayerLoadingState}; + +function layerLoadingStateReducer( + state: LoadingStateByLayer = {}, + action: UpdateLayerLoadingStateAction +): LoadingStateByLayer { + switch (action.type) { + case UPDATE_LAYER_LOADING_STATE: + return {...state, [action.layerId]: action.loadingState}; + default: + return state; + } +} + +export default layerLoadingStateReducer; diff --git a/src/scripts/selectors/globe/layer-loading-state.ts b/src/scripts/selectors/globe/layer-loading-state.ts new file mode 100644 index 000000000..80336d161 --- /dev/null +++ b/src/scripts/selectors/globe/layer-loading-state.ts @@ -0,0 +1,6 @@ +import {State} from '../../reducers/index'; +import {LoadingStateByLayer} from '../../reducers/globe/layer-loading-state'; + +export function layerLoadingStateSelector(state: State): LoadingStateByLayer { + return state.globe.layerLoadingState; +}