diff --git a/apps/geoportal/src/app/App.tsx b/apps/geoportal/src/app/App.tsx index 6aa87013f..9f90decce 100644 --- a/apps/geoportal/src/app/App.tsx +++ b/apps/geoportal/src/app/App.tsx @@ -1,13 +1,9 @@ // Built-in Modules -import { useEffect, useState } from "react"; +import { useState } from "react"; // 3rd party Modules - import { Button, Modal } from "antd"; -import LZString from "lz-string"; import { ErrorBoundary } from "react-error-boundary"; -import { useDispatch, useSelector } from "react-redux"; -import { useLocation, useSearchParams } from "react-router-dom"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faSpinner } from "@fortawesome/free-solid-svg-icons"; // 1st party Modules @@ -24,10 +20,7 @@ import { TweakpaneProvider } from "@carma-commons/debug"; import { CarmaMapContextProvider, FeatureFlagProvider, - type BackgroundLayer, - type Settings, } from "@carma-apps/portals"; -import type { Layer } from "@carma-mapping/layers"; // Local Modules import AppErrorFallback from "./components/AppErrorFallback"; @@ -37,32 +30,18 @@ import MapMeasurement from "./components/map-measure/MapMeasurement"; import TopNavbar from "./components/TopNavbar"; import { ObliqueDataProvider } from "./oblique/components/ObliqueDataContext"; -import type { AppDispatch } from "./store"; -import { - getBackgroundLayer, - getSelectedMapLayer, - setBackgroundLayer, - setLayers, - setSelectedLuftbildLayer, - setSelectedMapLayer, - setShowFullscreenButton, - setShowHamburgerMenu, - setShowLocatorButton, - setShowMeasurementButton, -} from "./store/slices/mapping"; -import { getIfPopupOpend } from "./store/slices/print"; - -import { - getUIAllowChanges, - getUIMode, - getZenMode, - setUIAllowChanges, - setUIShowLayerButtons, - setUIShowLayerHideButtons, -} from "./store/slices/ui"; +import { useAppConfig } from "./hooks/useAppConfig"; +import { useManageLayers } from "./hooks/useManageLayers"; +import { useSyncToken } from "./hooks/useSyncToken"; +import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts"; +import { useCesiumSearchParams } from "./hooks/useCesiumSearchParams"; import { layerMap } from "./config"; -import { CESIUM_CONFIG } from "./config/app.config"; +import { + CESIUM_CONFIG, + CONFIG_BASE_URL, + MIN_MOBILE_WIDTH, +} from "./config/app.config"; import { featureFlagConfig } from "./config/featureFlags"; import { OBLIQUE_CONFIG, CAMERA_ID_TO_DIRECTION } from "./oblique/config"; @@ -75,189 +54,15 @@ import "./index.css"; if (typeof global === "undefined") { window.global = window; } - -type View = { - center: string[]; - zoom: string; -}; - -type Config = { - layers: Layer[]; - backgroundLayer: BackgroundLayer & { selectedLayerId: string }; - settings?: Settings; - view?: View; -}; - function App({ published }: { published?: boolean }) { - const dispatch: AppDispatch = useDispatch(); - const [searchParams, setSearchParams] = useSearchParams(); - const allowUiChanges = useSelector(getUIAllowChanges); - const uiMode = useSelector(getUIMode); - const zenMode = useSelector(getZenMode); - const location = useLocation(); - const backgroundLayer = useSelector(getBackgroundLayer); - const selectedMapLayer = useSelector(getSelectedMapLayer); - - const [syncToken, setSyncToken] = useState(null); - const [loadingConfig, setLoadingConfig] = useState(false); const [isModalOpen, setIsModalOpen] = useState(true); - const isMobile = window.innerWidth < 600; - const ifPopupPrintOpened = useSelector(getIfPopupOpend); - - const configBaseUrl = - "https://ceepr.cismet.de/config/wuppertal/_dev_geoportal/"; - - useEffect(() => { - console.debug( - " [GEOPORTAL|ROUTER] App Route changed to:", - location.pathname - ); - }, [location]); - - useEffect(() => { - if (searchParams.get("sync")) { - setSyncToken(searchParams.get("sync")); - } - - if (searchParams.get("config")) { - setLoadingConfig(true); - const config = searchParams.get("config"); - - fetch(configBaseUrl + config) - .then((response) => { - return response.json(); - }) - .then((newConfig: Config) => { - dispatch(setLayers(newConfig.layers)); - const selectedMapLayerId = newConfig.backgroundLayer.selectedLayerId; - const selectedBackgroundLayer: BackgroundLayer = { - title: layerMap[selectedMapLayerId].title, - id: selectedMapLayerId, - opacity: newConfig.backgroundLayer.opacity, - description: layerMap[selectedMapLayerId].description, - inhalt: layerMap[selectedMapLayerId].inhalt, - eignung: layerMap[selectedMapLayerId].eignung, - visible: newConfig.backgroundLayer.visible, - layerType: "wmts", - props: { - name: "", - url: layerMap[selectedMapLayerId].url, - }, - layers: layerMap[selectedMapLayerId].layers, - }; - dispatch( - setBackgroundLayer({ - ...selectedBackgroundLayer, - id: newConfig.backgroundLayer.id, - }) - ); - if (newConfig.backgroundLayer.id === "luftbild") { - dispatch(setSelectedLuftbildLayer(selectedBackgroundLayer)); - } else { - dispatch(setSelectedMapLayer(selectedBackgroundLayer)); - } - searchParams.delete("config"); - setSearchParams(searchParams); - }) - .finally(() => { - setLoadingConfig(false); - }); - } - - if (searchParams.get("data")) { - const data = searchParams.get("data"); - const newConfig: Config = JSON.parse( - LZString.decompressFromEncodedURIComponent(data) - ); - dispatch(setLayers(newConfig.layers)); - dispatch(setBackgroundLayer(newConfig.backgroundLayer)); - if (newConfig.settings) { - dispatch(setUIShowLayerButtons(newConfig.settings.showLayerButtons)); - dispatch(setShowFullscreenButton(newConfig.settings.showFullscreen)); - dispatch(setShowLocatorButton(newConfig.settings.showLocator)); - dispatch(setShowMeasurementButton(newConfig.settings.showMeasurement)); - } - searchParams.delete("data"); - setSearchParams(searchParams); - } - }, [searchParams]); - - useEffect(() => { - const onKeyDown = (e: KeyboardEvent) => { - if (e.shiftKey) { - dispatch(setUIShowLayerHideButtons(true)); - } - - // if (e.key === "Escape") { - // if (uiMode === "print" && !ifPopupPrintOpened) { - // dispatch(setUIMode("default")); - // } - // dispatch(changeIfPopupOpend(false)); - // } - }; - - const onKeyUp = (e: KeyboardEvent) => { - if (allowUiChanges) { - dispatch(setUIShowLayerHideButtons(false)); - } - }; - - document.addEventListener("keydown", onKeyDown); - document.addEventListener("keyup", onKeyUp); - window.addEventListener("blur", onKeyUp); - - return () => { - document.removeEventListener("keydown", onKeyDown); - document.removeEventListener("keyup", onKeyUp); - window.removeEventListener("blur", onKeyUp); - }; - }, [allowUiChanges]); - - useEffect(() => { - const backgroundLayerId = backgroundLayer.id; - const selectedMapLayerId = selectedMapLayer.id; - - const getId = () => { - return backgroundLayerId === "luftbild" - ? backgroundLayerId - : selectedMapLayerId; - }; - dispatch( - setBackgroundLayer({ - title: layerMap[getId()].title, - id: backgroundLayerId, - opacity: backgroundLayer.opacity, - description: layerMap[getId()].description, - inhalt: layerMap[getId()].inhalt, - eignung: layerMap[getId()].eignung, - visible: backgroundLayer.visible, - layerType: "wmts", - props: { - name: "", - url: layerMap[getId()].url, - }, - layers: layerMap[getId()].layers, - }) - ); + const isMobile = window.innerWidth < MIN_MOBILE_WIDTH; - dispatch( - setSelectedMapLayer({ - title: layerMap[selectedMapLayerId].title, - id: selectedMapLayerId, - opacity: 1.0, - description: ``, - inhalt: layerMap[selectedMapLayerId].inhalt, - eignung: layerMap[selectedMapLayerId].eignung, - visible: selectedMapLayer.visible, - layerType: "wmts", - props: { - name: "", - url: layerMap[selectedMapLayerId].url, - }, - layers: layerMap[selectedMapLayerId].layers, - }) - ); - }, []); + const isLoadingConfig = useAppConfig(CONFIG_BASE_URL, layerMap); + useManageLayers(layerMap); + useCesiumSearchParams(); + const syncToken = useSyncToken(); + useKeyboardShortcuts(); const content = ( @@ -277,7 +82,7 @@ function App({ published }: { published?: boolean }) { className="flex flex-col w-full " style={{ height: "100dvh" }} > - {loadingConfig && ( + {isLoadingConfig && (
{ const dispatch = useDispatch(); - const location = useLocation(); + const { pathname } = useLocation(); const rerenderCountRef = useRef(0); const lastRenderTimeStampRef = useRef(Date.now()); const lastRenderIntervalRef = useRef(0); - const [urlParams, setUrlParams] = useSearchParams(); const container3dMapRef = useRef(null); // State and Selectors @@ -117,6 +121,7 @@ export const GeoportalMap = ({ height, width, allow3d }: MapProps) => { useCesiumContext(); const { getLeafletZoom } = useLeafletZoomControls(); const showPrimaryTileset = useSelector(selectShowPrimaryTileset); + const currentSceneStyle = useSelector(selectCurrentSceneStyle); const infoBoxOverlay = addCssToOverlayHelperItem( getCollabedHelpElementsConfig("INFOBOX", geoElements), @@ -154,6 +159,7 @@ export const GeoportalMap = ({ height, width, allow3d }: MapProps) => { const [markerAccent, setMarkerAccent] = useState(undefined); const [pos, setPos] = useState<[number, number] | null>(null); + const cesiumInitialCameraView = useCesiumInitialCameraFromSearchParams(); const version = getApplicationVersion(versionData); // custom hooks @@ -291,6 +297,68 @@ export const GeoportalMap = ({ height, width, allow3d }: MapProps) => { }, 150); }; + /** TODO: + move url request to own hook something like + shouldUpdateLocationHash on only set the TopicMapLocation to some ref or state + same for onSceneChange with cesium so we can centrally handle and debounce url updates + **/ + const topicMapLocationChangedHandler = ({ + lat, + lng, + zoom, + }: { + lat: number; + lng: number; + zoom: number; + }) => { + if (!isMode2d) { + console.debug( + "[TopicMap|DEBUG] Location changed handler triggered while in 3D mode" + ); + return; + } + + const currentParams = getHashParams(); + + const latTruncated = lat.toFixed(8); + const lngTruncated = lng.toFixed(8); + const zoomString = parseFloat(zoom.toFixed(2)).toString(); + const sceneStyle = currentSceneStyle === "primary" ? "1" : "0"; + + const newParams = { + ...currentParams, + ...clear3dParams, + m: sceneStyle, + lat: latTruncated, + lng: lngTruncated, + zoom: zoomString, + }; + + replaceHashRoutedHistory( + { hashParams: newParams }, + pathname, + "GPM:TopicMap:locationChangedHandler" + ); + + if ( + // TODO this should be it's own hook, not a side effect + zoom.toString() !== currentParams.zoom.toString() && + isModeFeatureInfo + ) { + updateFeatureInfo(); + } + }; + + const onSceneChange = (e: { hashParams: Record }) => { + if (isMode2d) { + console.debug( + "[CESIUM|DEBUG|CESIUM_WARN] Cesium scene change triggered while in 2D mode" + ); + return; + } + replaceHashRoutedHistory(e, pathname, "GPM:3D"); + }; + // TODO Move out Controls to own component console.debug("RENDER: [GEOPORTAL] MAP", isMode2d); @@ -330,16 +398,7 @@ export const GeoportalMap = ({ height, width, allow3d }: MapProps) => { mappingBoundsChanged={(boundingbox) => { // console.debug('xxx bbox', createWMSBbox(boundingbox)); }} - locationChangedHandler={(location) => { - const newParams = { ...paramsToObject(urlParams), ...location }; - setUrlParams(newParams); - if ( - location.zoom.toString() !== urlParams.get("zoom").toString() && - isModeFeatureInfo - ) { - updateFeatureInfo(); - } - }} + locationChangedHandler={topicMapLocationChangedHandler} onclick={(e) => { const map = routedMap.leafletMap.leafletElement; const baseUrl = window.location.origin + window.location.pathname; @@ -478,7 +537,7 @@ export const GeoportalMap = ({ height, width, allow3d }: MapProps) => {
- {allow3d && ( + {allow3d && cesiumInitialCameraView !== null && (
{ > { - console.debug( - "[GEOPORTALMAP|HASH|SCENE|CESIUM]cesium scene changed", - e - ); - replaceHashRoutedHistory(e, location.pathname); - }} + cameraLimiterOptions={CESIUM_CONFIG.camera} + initialCameraView={cesiumInitialCameraView} + onSceneChange={onSceneChange} />
)} diff --git a/apps/geoportal/src/app/components/GeoportalMap/controls/MapWrapper.tsx b/apps/geoportal/src/app/components/GeoportalMap/controls/MapWrapper.tsx index 8a576079d..7b9fc1bf7 100644 --- a/apps/geoportal/src/app/components/GeoportalMap/controls/MapWrapper.tsx +++ b/apps/geoportal/src/app/components/GeoportalMap/controls/MapWrapper.tsx @@ -1,4 +1,5 @@ import { useContext, useEffect, useMemo, useRef, useState } from "react"; +import { useLocation } from "react-router-dom"; import { isMobile } from "react-device-detect"; import { useDispatch, useSelector } from "react-redux"; @@ -27,6 +28,7 @@ import { TopicMapContext } from "react-cismap/contexts/TopicMapContextProvider"; import { ResponsiveTopicMapContext } from "react-cismap/contexts/ResponsiveTopicMapContextProvider"; import { + replaceHashRoutedHistory, SelectionMetaData, useFeatureFlags, useGazData, @@ -38,6 +40,7 @@ import { ENDPOINT, isAreaType } from "@carma-commons/resources"; import { detectWebGLContext } from "@carma-commons/utils"; import { + getClearCesiumCameraParams, MapTypeSwitcher, PitchingCompass, selectViewerIsMode2d, @@ -108,8 +111,13 @@ const setHasGPU = (flag: boolean) => (hasGPU = flag); const testGPU = () => detectWebGLContext(setHasGPU); window.addEventListener("load", testGPU, false); +const clear3dHashParamsObject = getClearCesiumCameraParams(); + +// TODO: centralize the hash params update behavior + const MapWrapper = () => { const dispatch = useDispatch(); + const { pathname } = useLocation(); const flags = useFeatureFlags(); @@ -132,7 +140,8 @@ const MapWrapper = () => { const showLocatorButton = useSelector(getShowLocatorButton); const showMeasurementButton = useSelector(getShowMeasurementButton); const zenMode = useSelector(getZenMode); - const { viewerRef, viewerAnimationMapRef } = useCesiumContext(); + const { viewerRef, viewerAnimationMapRef, isViewerReady } = + useCesiumContext(); const homeControl = useHomeControl(); const isObliqueMode = useSelector(getObliqueMode); @@ -249,6 +258,15 @@ const MapWrapper = () => { const { gazData } = useGazData(); const { width, height } = useWindowSize(wrapperRef); + const clear3dHashParams = () => { + console.debug("[CESIUM|DEBUG] MapTypeSwitcher: 2D mode, clear hash params"); + replaceHashRoutedHistory( + { hashParams: clear3dHashParamsObject }, + pathname, + "MapTypeSwitcher Clear" + ); + }; + const handleToggleMeasurement = () => { cancelOngoingRequests(); dispatch(toggleUIMode(UIMode.MEASUREMENT)); @@ -406,6 +424,7 @@ const MapWrapper = () => { )} @@ -415,6 +434,7 @@ const MapWrapper = () => { duration={CESIUM_CONFIG.transitions.mapMode.duration} className="!rounded-t-none !border-t-[1px]" onComplete={(isTo2d: boolean) => { + isTo2d && clear3dHashParams(); //dispatch(setBackgroundLayer({ ...backgroundLayer, visible: isTo2d })); }} ref={tourRefLabels.toggle2d3d} diff --git a/apps/geoportal/src/app/config/app.config.ts b/apps/geoportal/src/app/config/app.config.ts index f5a6e204c..8e93ee183 100644 --- a/apps/geoportal/src/app/config/app.config.ts +++ b/apps/geoportal/src/app/config/app.config.ts @@ -12,6 +12,11 @@ export const APP_BASE_PATH = import.meta.env.BASE_URL; export const ICON_PREFIX = "https://www.wuppertal.de/geoportal/geoportal_icon_legends/"; +export const CONFIG_BASE_URL = + "https://ceepr.cismet.de/config/wuppertal/_dev_geoportal/"; + +export const MIN_MOBILE_WIDTH = 600; + const CESIUM_PATHNAME = "__cesium__"; export const CESIUM_CONFIG: CesiumConfig = { diff --git a/apps/geoportal/src/app/hooks/useAppConfig.ts b/apps/geoportal/src/app/hooks/useAppConfig.ts new file mode 100644 index 000000000..cd8a2a10d --- /dev/null +++ b/apps/geoportal/src/app/hooks/useAppConfig.ts @@ -0,0 +1,96 @@ +import { useEffect, useState, useRef } from "react"; +import { useDispatch } from "react-redux"; +import { useSearchParams } from "react-router-dom"; + +import { BackgroundLayer, LayerMap, Settings } from "@carma-apps/portals"; +import { Layer } from "@carma-mapping/layers"; + +import { + setBackgroundLayer, + setLayers, + setSelectedLuftbildLayer, + setSelectedMapLayer, +} from "../store/slices/mapping"; + +import { AppDispatch } from "../store"; + +type View = { + center: string[]; + zoom: string; +}; + +type Config = { + layers: Layer[]; + backgroundLayer: BackgroundLayer & { selectedLayerId: string }; + settings?: Settings; + view?: View; +}; + +const onLoadedConfig = ( + config: Config, + layerMap: LayerMap, + dispatch: AppDispatch +) => { + dispatch(setLayers(config.layers)); + const selectedMapLayerId = config.backgroundLayer.selectedLayerId; + const selectedBackgroundLayer: BackgroundLayer = { + title: layerMap[selectedMapLayerId].title, + id: selectedMapLayerId, + opacity: config.backgroundLayer.opacity, + description: layerMap[selectedMapLayerId].description, + inhalt: layerMap[selectedMapLayerId].inhalt, + eignung: layerMap[selectedMapLayerId].eignung, + visible: config.backgroundLayer.visible, + layerType: "wmts", + props: { + name: "", + url: layerMap[selectedMapLayerId].url, + }, + layers: layerMap[selectedMapLayerId].layers, + }; + dispatch( + setBackgroundLayer({ + ...selectedBackgroundLayer, + id: config.backgroundLayer.id, + }) + ); + if (config.backgroundLayer.id === "luftbild") { + dispatch(setSelectedLuftbildLayer(selectedBackgroundLayer)); + } else { + dispatch(setSelectedMapLayer(selectedBackgroundLayer)); + } +}; + +export const useAppConfig = (configBaseUrl: string, layerMap: LayerMap) => { + const [searchParams, setSearchParams] = useSearchParams(); + const dispatch = useDispatch(); + const config = searchParams.get("config"); + const [isLoadingConfig, setIsLoadingConfig] = useState(false); + + useEffect(() => { + if (!config) return; + setIsLoadingConfig(true); + const controller = new AbortController(); + + fetch(configBaseUrl + config, { signal: controller.signal }) + .then((response) => response.json()) + .then((newConfig: Config) => { + onLoadedConfig(newConfig, layerMap, dispatch); + searchParams.delete("config"); + setSearchParams(searchParams); + setIsLoadingConfig(false); + }) + .catch((error) => { + if (error.name === "AbortError") return; + setIsLoadingConfig(false); + console.error("Error loading config:", error); + }); + + return () => { + controller.abort(); + }; + // run only once on load + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return isLoadingConfig; +}; diff --git a/apps/geoportal/src/app/hooks/useCesiumSearchParams.ts b/apps/geoportal/src/app/hooks/useCesiumSearchParams.ts new file mode 100644 index 000000000..2337af7e0 --- /dev/null +++ b/apps/geoportal/src/app/hooks/useCesiumSearchParams.ts @@ -0,0 +1,31 @@ +import { useEffect } from "react"; +import { useSearchParams } from "react-router-dom"; +import { useDispatch } from "react-redux"; + +import { + setIsMode2d, + setCurrentSceneStyle, +} from "@carma-mapping/cesium-engine"; + +export const useCesiumSearchParams = () => { + const [searchParams] = useSearchParams(); + const dispatch = useDispatch(); + useEffect(() => { + if (searchParams.has("is3d")) { + const is3d = searchParams.get("is3d"); + if (is3d === "1") { + dispatch(setIsMode2d(false)); + } else { + dispatch(setIsMode2d(true)); + } + } + + // TODO: handle this in common hook with TopicMap basemap setting on start from URL + if (searchParams.has("m")) { + const isPrimaryStyle = searchParams.get("m") === "1"; + dispatch(setCurrentSceneStyle(isPrimaryStyle ? "primary" : "secondary")); + } + // run only once on load + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); +}; diff --git a/apps/geoportal/src/app/hooks/useKeyboardShortcuts.ts b/apps/geoportal/src/app/hooks/useKeyboardShortcuts.ts new file mode 100644 index 000000000..82449b2e0 --- /dev/null +++ b/apps/geoportal/src/app/hooks/useKeyboardShortcuts.ts @@ -0,0 +1,42 @@ +import { useDispatch, useSelector } from "react-redux"; +import { + getUIAllowChanges, + setUIShowLayerHideButtons, +} from "../store/slices/ui"; +import { useEffect } from "react"; + +export const useKeyboardShortcuts = () => { + const dispatch = useDispatch(); + const allowUiChanges = useSelector(getUIAllowChanges); + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.shiftKey) { + dispatch(setUIShowLayerHideButtons(true)); + } + + // if (e.key === "Escape") { + // if (uiMode === "print" && !ifPopupPrintOpened) { + // dispatch(setUIMode("default")); + // } + // dispatch(changeIfPopupOpend(false)); + // } + }; + + const onKeyUp = (e: KeyboardEvent) => { + if (allowUiChanges) { + dispatch(setUIShowLayerHideButtons(false)); + } + }; + + document.addEventListener("keydown", onKeyDown); + document.addEventListener("keyup", onKeyUp); + window.addEventListener("blur", onKeyUp); + + return () => { + document.removeEventListener("keydown", onKeyDown); + document.removeEventListener("keyup", onKeyUp); + window.removeEventListener("blur", onKeyUp); + }; + }, [allowUiChanges, dispatch]); +}; diff --git a/apps/geoportal/src/app/hooks/useManageLayers.ts b/apps/geoportal/src/app/hooks/useManageLayers.ts new file mode 100644 index 000000000..0ccf01eda --- /dev/null +++ b/apps/geoportal/src/app/hooks/useManageLayers.ts @@ -0,0 +1,61 @@ +import { LayerMap } from "@carma-apps/portals"; +import { useEffect } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { + getBackgroundLayer, + getSelectedMapLayer, + setBackgroundLayer, + setSelectedMapLayer, +} from "../store/slices/mapping"; + +export const useManageLayers = (layerMap: LayerMap) => { + const dispatch = useDispatch(); + const backgroundLayer = useSelector(getBackgroundLayer); + const selectedMapLayer = useSelector(getSelectedMapLayer); + + useEffect(() => { + const backgroundLayerId = backgroundLayer.id; + const selectedMapLayerId = selectedMapLayer.id; + + const getId = () => { + return backgroundLayerId === "luftbild" + ? backgroundLayerId + : selectedMapLayerId; + }; + dispatch( + setBackgroundLayer({ + title: layerMap[getId()].title, + id: backgroundLayerId, + opacity: backgroundLayer.opacity, + description: layerMap[getId()].description, + inhalt: layerMap[getId()].inhalt, + eignung: layerMap[getId()].eignung, + visible: backgroundLayer.visible, + layerType: "wmts", + props: { + name: "", + url: layerMap[getId()].url, + }, + layers: layerMap[getId()].layers, + }) + ); + + dispatch( + setSelectedMapLayer({ + title: layerMap[selectedMapLayerId].title, + id: selectedMapLayerId, + opacity: 1.0, + description: ``, + inhalt: layerMap[selectedMapLayerId].inhalt, + eignung: layerMap[selectedMapLayerId].eignung, + visible: selectedMapLayer.visible, + layerType: "wmts", + props: { + name: "", + url: layerMap[selectedMapLayerId].url, + }, + layers: layerMap[selectedMapLayerId].layers, + }) + ); + }, [dispatch, layerMap]); +}; diff --git a/apps/geoportal/src/app/hooks/useSyncToken.ts b/apps/geoportal/src/app/hooks/useSyncToken.ts new file mode 100644 index 000000000..ae41d96f1 --- /dev/null +++ b/apps/geoportal/src/app/hooks/useSyncToken.ts @@ -0,0 +1,16 @@ +import { useEffect, useState } from "react"; +import { useSearchParams } from "react-router-dom"; + +export const useSyncToken = () => { + const [searchParams] = useSearchParams(); + const [syncToken, setSyncToken] = useState(null); + useEffect(() => { + if (searchParams.get("sync")) { + setSyncToken(searchParams.get("sync")); + } + // run only once on load + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return syncToken; +}; diff --git a/envirometrics/wuppertal/floodingmap/src/App.tsx b/envirometrics/wuppertal/floodingmap/src/App.tsx index 69022cd7f..15613dd09 100644 --- a/envirometrics/wuppertal/floodingmap/src/App.tsx +++ b/envirometrics/wuppertal/floodingmap/src/App.tsx @@ -1,21 +1,18 @@ import { useContext, useEffect, useMemo, useRef, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; -import { Tooltip } from "antd"; -import { Math as CesiumMath } from "cesium"; - +import { useSearchParams } from "react-router-dom"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { - faCompress, - faExpand, faHouseChimney, faMinus, faPlus, } from "@fortawesome/free-solid-svg-icons"; import EnviroMetricMap from "@cismet-dev/react-cismap-envirometrics-maps/EnviroMetricMap"; +import { version as cismapEnvirometricsVersion } from "@cismet-dev/react-cismap-envirometrics-maps/meta"; +import { ResponsiveTopicMapContext } from "react-cismap/contexts/ResponsiveTopicMapContextProvider"; import GenericModalApplicationMenu from "react-cismap/topicmaps/menu/ModalApplicationMenu"; -import { version as cismapEnvirometricsVersion } from "@cismet-dev/react-cismap-envirometrics-maps/meta"; import CrossTabCommunicationControl from "react-cismap/CrossTabCommunicationControl"; import { TopicMapContext } from "react-cismap/contexts/TopicMapContextProvider"; @@ -34,6 +31,7 @@ import { isAreaTypeWithGEP, } from "@carma-commons/resources"; import { getApplicationVersion } from "@carma-commons/utils"; + import { getCollabedHelpComponentConfig } from "@carma-collab/wuppertal/hochwassergefahrenkarte"; import { @@ -46,6 +44,7 @@ import { selectViewerModels, setIsMode2d, useCesiumContext, + useCesiumInitialCameraFromSearchParams, useHomeControl, useZoomControls, } from "@carma-mapping/cesium-engine"; @@ -55,6 +54,10 @@ import { } from "@carma-mapping/fuzzy-search"; import { type SearchResultItem } from "@carma-commons/types"; +import { + FullscreenControl, + RoutedMapLocateControl, +} from "@carma-mapping/components"; import { Control, ControlButtonStyler, @@ -67,8 +70,6 @@ import versionData from "./version.json"; import useLeafletZoomControls from "./hooks/useLeafletZoomControls"; -import { prepareSceneForHGK } from "./utils/scene"; - import config from "./config"; import { EMAIL, HOME_ZOOM } from "./config/app.config"; import { @@ -77,11 +78,6 @@ import { } from "./config/cesium/cesium.config"; import "cesium/Build/Cesium/Widgets/widgets.css"; -import { ResponsiveTopicMapContext } from "react-cismap/contexts/ResponsiveTopicMapContextProvider"; -import { - FullscreenControl, - RoutedMapLocateControl, -} from "@carma-mapping/components"; function App({ sync = false }: { sync?: boolean }) { const version = getApplicationVersion(versionData); @@ -98,10 +94,15 @@ function App({ sync = false }: { sync?: boolean }) { const reactCismapEnvirometricsVersion = cismapEnvirometricsVersion; const [hochwasserschutz, setHochwasserschutz] = useState(true); + const [searchParams] = useSearchParams(); + + const initialCameraView = useCesiumInitialCameraFromSearchParams(); + // CONTROLS const { viewerRef, viewerAnimationMapRef, + isViewerReady, terrainProviderRef, surfaceProviderRef, } = useCesiumContext(); @@ -168,7 +169,7 @@ function App({ sync = false }: { sync?: boolean }) { }; const onCesiumSceneChange = (e) => { - replaceHashRoutedHistory(e, "/"); + isMode2d ? undefined : replaceHashRoutedHistory(e, "/", "app/hgk"); }; useSelectionTopicMap(); @@ -187,19 +188,26 @@ function App({ sync = false }: { sync?: boolean }) { ); useEffect(() => { - if (viewerRef.current) { - const viewer = viewerRef.current; - // remove default cesium credit because no ion resorce is used; - (viewer as any)._cesiumWidget._creditContainer.style.display = "none"; - setTimeout(() => { - prepareSceneForHGK(viewer); - }, 300); + if (searchParams.has("is3d")) { + const is3d = searchParams.get("is3d") === "1"; + dispatch(setIsMode2d(!is3d)); } - }, [viewerRef]); + // run only once on load + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); useEffect(() => { - dispatch(setIsMode2d(true)); - }, []); + if ( + isViewerReady && + viewerRef.current && + !viewerRef.current.isDestroyed() + ) { + const viewer = viewerRef.current; + // remove default cesium credit because no ion resource is used; + (viewer as any)._cesiumWidget._creditContainer.style.display = "none"; + viewer.scene.requestRender(); + } + }, [viewerRef, isViewerReady]); const onFullscreenClick = () => { if (document.fullscreenElement) { @@ -228,6 +236,11 @@ function App({ sync = false }: { sync?: boolean }) { /> ); + if (initialCameraView === null) { + // viewer from URL not yet evaluated, don't render anything yet + return null; + } + return ( <>
{/* */} @@ -391,7 +405,8 @@ function App({ sync = false }: { sync?: boolean }) { > { const conf = config.config; // CESIUM - const { viewerRef, terrainProviderRef } = useCesiumContext(); + const { viewerRef, terrainProviderRef, isViewerReady } = useCesiumContext(); const [cesiumPickedPosition, setCesiumPickedPosition] = useState< [number, number] | null >(null); @@ -69,7 +69,13 @@ export const StateAwareChildren = () => { controlState.currentFeatureInfoPosition ) { const asyncUpdate = async () => { - if (!viewerRef.current || !terrainProviderRef.current) return; + if ( + !isViewerReady || + !viewerRef.current || + viewerRef.current.isDestroyed() || + !terrainProviderRef.current + ) + return; const { lat, lon } = getWebMercatorInWGS84( controlState.currentFeatureInfoPosition ); @@ -91,6 +97,7 @@ export const StateAwareChildren = () => { asyncUpdate(); } }, [ + isViewerReady, viewerRef, terrainProviderRef, controlState.featureInfoModeActivated, @@ -131,6 +138,9 @@ export const StateAwareChildren = () => { return () => { handler.destroy(); setCesiumPickedPosition(null); + + if (viewer.isDestroyed()) return; + if (markerEntityRef.current) { viewer.entities.remove(markerEntityRef.current); markerEntityRef.current = null; @@ -147,6 +157,8 @@ export const StateAwareChildren = () => { // Add effect to cleanup marker when feature info mode is disabled useEffect(() => { if (!controlState.featureInfoModeActivated && viewerRef.current) { + if (viewerRef.current.isDestroyed()) return; + if (markerEntityRef.current) { viewerRef.current.entities.remove(markerEntityRef.current); markerEntityRef.current = null; diff --git a/envirometrics/wuppertal/floodingmap/src/hooks/useHGKCesiumTerrain.ts b/envirometrics/wuppertal/floodingmap/src/hooks/useHGKCesiumTerrain.ts index 40ccd9579..d17c17bdb 100644 --- a/envirometrics/wuppertal/floodingmap/src/hooks/useHGKCesiumTerrain.ts +++ b/envirometrics/wuppertal/floodingmap/src/hooks/useHGKCesiumTerrain.ts @@ -1,10 +1,54 @@ -import { useEffect } from "react"; +import { useEffect, useState, useRef } from "react"; +import type { Viewer } from "cesium"; import { CesiumTerrainProvider } from "cesium"; import { useCesiumContext } from "@carma-mapping/cesium-engine"; +import { prepareSceneForHGK } from "../utils/scene"; -// reuse terrain provider instances -const hgkTerrainProviders = {}; +const MAX_RETRIES = 5; +const RETRY_DELAY = 100; + +const terrainProvidersMap = new WeakMap< + Viewer, + Record +>(); + +const getProvider = async ( + viewer: Viewer, + hqKey: string, + HGK_TERRAIN_PROVIDER_URLS: Record +) => { + if (viewer.isDestroyed()) return null; + if (!terrainProvidersMap.has(viewer)) { + terrainProvidersMap.set(viewer, {}); + } + + const viewerTerrainProviders = terrainProvidersMap.get(viewer) ?? {}; + + if (viewerTerrainProviders[hqKey]) { + console.debug("Existing HQ Terrain Layer Provider found", hqKey); + return viewerTerrainProviders[hqKey]; + } + + try { + const url = HGK_TERRAIN_PROVIDER_URLS[hqKey]; + if (!url) { + console.warn(`No terrain provider URL found for key: ${hqKey}`); + return null; + } + + const provider = await CesiumTerrainProvider.fromUrl(url); + console.debug("New HQ Terrain Layer Provider Initialized", hqKey); + + viewerTerrainProviders[hqKey] = provider; + terrainProvidersMap.set(viewer, viewerTerrainProviders); + + return provider; + } catch (e) { + console.warn(e); + return null; + } +}; export const useHGKCesiumTerrain = ( selectedSimulation: number, @@ -12,60 +56,109 @@ export const useHGKCesiumTerrain = ( HGK_KEYS, HGK_TERRAIN_PROVIDER_URLS ) => { - const { terrainProviderRef, viewerRef } = useCesiumContext(); + const { terrainProviderRef, viewerRef, isViewerReady } = useCesiumContext(); + const retryTimeoutRef = useRef(null); + const currentAttemptRef = useRef(null); + const [retryCount, setRetryCount] = useState(0); useEffect(() => { const useHws = isHWS && selectedSimulation !== 2; const hqKey = HGK_KEYS[selectedSimulation][useHws ? "hws" : "noHws"]; - /* - console.info( - "hqKey changed", - hqKey, - selectedSimulation, - useHws, - HGK_TERRAIN_PROVIDER_URLS[hqKey] - ); - */ - if (hqKey) { - (async () => { - if (!hgkTerrainProviders[hqKey]) { - try { - const url = HGK_TERRAIN_PROVIDER_URLS[hqKey]; - hgkTerrainProviders[hqKey] = await CesiumTerrainProvider.fromUrl( - url - ); - } catch (e) { - console.error(e); - } + const attemptId = `${hqKey}-${Date.now()}`; + currentAttemptRef.current = attemptId; + + if (retryTimeoutRef.current !== null) { + clearTimeout(retryTimeoutRef.current); + retryTimeoutRef.current = null; + } + + setRetryCount(0); + + if (!hqKey) return; + + const loadTerrain = (retry = 0) => { + if (currentAttemptRef.current !== attemptId) return; + + if ( + !isViewerReady || + !viewerRef.current || + viewerRef.current.isDestroyed() + ) { + console.debug( + "hq Key changed, viewer not ready yet", + hqKey, + selectedSimulation, + useHws, + retry + ); + + if (retry < MAX_RETRIES) { + const nextRetryDelay = RETRY_DELAY * Math.pow(2, retry); + + retryTimeoutRef.current = window.setTimeout(() => { + if (currentAttemptRef.current === attemptId) { + setRetryCount(retry + 1); + loadTerrain(retry + 1); + } + }, nextRetryDelay); + } else { + console.warn("HQ Max retries reached, not setting terrain provider"); } + return; + } + + console.debug( + "hq Key changed, viewer ready", + hqKey, + selectedSimulation, + useHws, + retry + ); + + const viewer = viewerRef.current; + if (!viewer) return; - const provider = hgkTerrainProviders[hqKey]; + setTimeout(() => { + !viewer.isDestroyed() && prepareSceneForHGK(viewer); + }, 500); + viewer.scene.requestRender(); + + getProvider(viewer, hqKey, HGK_TERRAIN_PROVIDER_URLS).then((provider) => { + if ( + currentAttemptRef.current !== attemptId || + !viewer || + viewer.isDestroyed() + ) + return; terrainProviderRef.current = provider; - if (viewerRef.current && provider) { - const viewer = viewerRef.current; - setTimeout(() => { - // overwrite default terrain provider - /* - console.debug( - "set HGK terrain provider for", - hqKey, - isHWS, - provider - ); - */ + if (provider && viewer.scene) { + try { viewer.scene.terrainProvider = provider; viewer.scene.requestRender(); - }, 500); + } catch (e) { + console.warn("Error applying terrain provider:", e); + } } - })(); - } + }); + }; + + loadTerrain(retryCount); + + return () => { + if (retryTimeoutRef.current !== null) { + clearTimeout(retryTimeoutRef.current); + retryTimeoutRef.current = null; + } + }; }, [ isHWS, selectedSimulation, terrainProviderRef, viewerRef, + isViewerReady, HGK_KEYS, HGK_TERRAIN_PROVIDER_URLS, + retryCount, ]); }; diff --git a/envirometrics/wuppertal/floodingmap/src/utils/cesiumHandlers.ts b/envirometrics/wuppertal/floodingmap/src/utils/cesiumHandlers.ts index 14d5d4ebc..da7573c1f 100644 --- a/envirometrics/wuppertal/floodingmap/src/utils/cesiumHandlers.ts +++ b/envirometrics/wuppertal/floodingmap/src/utils/cesiumHandlers.ts @@ -11,6 +11,8 @@ export const onCesiumClick = async ( highlightEntityRef, callback ) => { + if (viewer.isDestroyed()) return; + const cartesian = viewer.scene.pickPosition(click.position); if (cartesian && terrainProviderRef.current) { const cartographic = Cartographic.fromCartesian(cartesian); diff --git a/envirometrics/wuppertal/floodingmap/src/utils/scene.ts b/envirometrics/wuppertal/floodingmap/src/utils/scene.ts index cbba59f1f..83506c725 100644 --- a/envirometrics/wuppertal/floodingmap/src/utils/scene.ts +++ b/envirometrics/wuppertal/floodingmap/src/utils/scene.ts @@ -3,6 +3,8 @@ import { WATER_CESIUM_COLOR } from "../config/cesium/cesium.config"; export const prepareSceneForHGK = (viewer: Viewer) => { //console.debug("3d setup for HGK terrain style"); + if (viewer.isDestroyed()) return; + viewer.scene.backgroundColor = Color.DIMGREY; viewer.scene.globe.baseColor = WATER_CESIUM_COLOR; viewer.scene.globe.show = true; diff --git a/libraries/appframeworks/portals/src/index.ts b/libraries/appframeworks/portals/src/index.ts index 4d2702530..0e7bf69ad 100644 --- a/libraries/appframeworks/portals/src/index.ts +++ b/libraries/appframeworks/portals/src/index.ts @@ -7,7 +7,7 @@ export enum SELECTED_LAYER_INDEX { } export { utils }; -export { replaceHashRoutedHistory } from "./lib/utils/routing"; +export { replaceHashRoutedHistory, getHashParams } from "./lib/utils/routing"; export { FeatureFlagProvider, diff --git a/libraries/appframeworks/portals/src/lib/utils/routing.ts b/libraries/appframeworks/portals/src/lib/utils/routing.ts index 2326ffd00..38210a599 100644 --- a/libraries/appframeworks/portals/src/lib/utils/routing.ts +++ b/libraries/appframeworks/portals/src/lib/utils/routing.ts @@ -1,26 +1,40 @@ type EncodedSceneParams = { hashParams: Record; - state: unknown; + state?: unknown; +}; + +export const getHashParams = ( + hash = window.location.hash.split("?")[1] || "" +) => { + const params = Object.fromEntries(new URLSearchParams(hash)); + return params; }; export const replaceHashRoutedHistory = ( - encodedScene: EncodedSceneParams, - routedPath: string + { hashParams }: EncodedSceneParams, + routedPath: string, + label: string = "N/A" // for tracing debugging only ) => { // this is method is used to avoid triggering rerenders from the HashRouter when updating the hash - if (encodedScene.hashParams) { - const currentHash = window.location.hash.split("?")[1] || ""; - const currentParams = Object.fromEntries(new URLSearchParams(currentHash)); - - const combinedParams = { + if (hashParams) { + const currentParams = getHashParams(); + const combinedParams: Record = { ...currentParams, - ...encodedScene.hashParams, // overwrite from state but keep others + ...hashParams, // overwrite from state but keep others }; + // remove empty values + // be aware this disables "boolean" keys without a value + Object.entries(combinedParams).forEach(([key, value]) => { + if (value.trim() === "") { + delete combinedParams[key]; + } + }); + const combinedSearchParams = new URLSearchParams(combinedParams); const combinedHash = combinedSearchParams.toString(); - const formattedHash = combinedHash.replace(/=&/g, "&").replace(/=$/, ""); // remove empty values - const fullHashState = `#${routedPath}?${formattedHash}`; + //const formattedHash = combinedHash.replace(/=&/g, "&").replace(/=$/, ""); // remove empty values + const fullHashState = `#${routedPath}?${combinedHash}`; // this is a workaround to avoid triggering rerenders from the HashRouter // navigate would cause rerenders // navigate(`${routedPath}?${formattedHash}`, { replace: true }); diff --git a/libraries/collaboration/carma-pecher-collab/pecher-collab-submodule b/libraries/collaboration/carma-pecher-collab/pecher-collab-submodule index 7cf49dc79..d2f192c95 160000 --- a/libraries/collaboration/carma-pecher-collab/pecher-collab-submodule +++ b/libraries/collaboration/carma-pecher-collab/pecher-collab-submodule @@ -1 +1 @@ -Subproject commit 7cf49dc79a6829c23a6f79d123f2146e7dca8522 +Subproject commit d2f192c95f899539d18bcea280e76b5ef0a17481 diff --git a/libraries/collaboration/carma-wuppertal-collab/wuppertal-collab-submodule b/libraries/collaboration/carma-wuppertal-collab/wuppertal-collab-submodule index a2a955ce5..c77f9a573 160000 --- a/libraries/collaboration/carma-wuppertal-collab/wuppertal-collab-submodule +++ b/libraries/collaboration/carma-wuppertal-collab/wuppertal-collab-submodule @@ -1 +1 @@ -Subproject commit a2a955ce5db7dc72c4245f67f90c55f685fcd2c6 +Subproject commit c77f9a573c17a7de6285e8c4f2bfbec8e4887c71 diff --git a/libraries/commons/src/index.ts b/libraries/commons/src/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/mapping/engines/cesium/src/index.d.ts b/libraries/mapping/engines/cesium/src/index.d.ts index cd3ebcb0d..3a60142d5 100644 --- a/libraries/mapping/engines/cesium/src/index.d.ts +++ b/libraries/mapping/engines/cesium/src/index.d.ts @@ -1,7 +1,6 @@ import { TerrainProvider } from "cesium"; import { PlainCartesian3 } from "types/common-geo"; -import { hashcodecs } from "./lib/utils/hashHelpers"; import { ProviderConfig } from "./lib/utils/cesiumProviders"; import { TilesetConfigs } from "./lib/utils/cesiumTilesetProviders"; @@ -173,20 +172,6 @@ export type RootState = { cesium: CesiumState; }; -// from Hash - -type HashKey = keyof typeof hashcodecs; - -type CodecKeys = { - [K in HashKey]: (typeof hashcodecs)[K]["key"]; -}; - -export type FlatDecodedSceneHash = { - [K in CodecKeys[keyof CodecKeys]]?: ReturnType< - (typeof hashcodecs)[HashKey]["decode"] - >; -}; - export type SceneStateDescription = { camera: { longitude?: number | null; @@ -206,8 +191,3 @@ export type AppState = { isSecondaryStyle?: boolean; zoom?: number; }; - -export type EncodedSceneParams = { - hashParams: Record; - state: SceneStateDescription; -}; diff --git a/libraries/mapping/engines/cesium/src/index.ts b/libraries/mapping/engines/cesium/src/index.ts index 458e9623d..47940001e 100644 --- a/libraries/mapping/engines/cesium/src/index.ts +++ b/libraries/mapping/engines/cesium/src/index.ts @@ -7,6 +7,8 @@ export { CustomCesiumWidget } from "./lib/CustomCesiumWidget"; export { CustomViewer, DEFAULT_VIEWER_CONSTRUCTOR_OPTIONS, + type InitialCameraView, + type CameraLimiterOptions, } from "./lib/CustomViewer"; export { CustomViewerPlayground } from "./lib/CustomViewerPlayground"; @@ -23,6 +25,7 @@ export { SceneStyleToggle } from "./lib/components/controls/SceneStyleToggle"; export { useCesiumContext } from "./lib/hooks/useCesiumContext"; export { useCesiumCameraForceOblique } from "./lib/hooks/useCameraForceOblique"; export { useHomeControl } from "./lib/hooks/useHomeControl"; +export { useCesiumInitialCameraFromSearchParams } from "./lib/hooks/useCesiumInitialCameraFromSearchParams"; export { useFovWheelZoom } from "./lib/hooks/useFovWheelZoom"; export { useSceneStyles } from "./lib/hooks/useSceneStyles"; export { useZoomControls } from "./lib/hooks/useZoomControls"; @@ -45,6 +48,13 @@ export { export { applyRollToHeadingForCameraNearNadir } from "./lib/utils/cesiumCamera"; +export { + encodeCesiumCamera, + decodeCesiumCamera, + cesiumCameraParamsKeyList, + getClearCesiumCameraParams, +} from "./lib/utils/cesiumHashParamsCodec"; + export { pickViewerCanvasCenter, getDegreesFromCartesian, diff --git a/libraries/mapping/engines/cesium/src/lib/CesiumContextProvider.tsx b/libraries/mapping/engines/cesium/src/lib/CesiumContextProvider.tsx index c87aa621a..93bca501d 100644 --- a/libraries/mapping/engines/cesium/src/lib/CesiumContextProvider.tsx +++ b/libraries/mapping/engines/cesium/src/lib/CesiumContextProvider.tsx @@ -69,6 +69,10 @@ export const CesiumContextProvider = ({ }, [providerConfig.imageryProvider]); useEffect(() => { + if (!isViewerReady) { + return; + } // avoids runtime issues with WebGL context not available + const abortController = new AbortController(); const { signal } = abortController; @@ -81,9 +85,13 @@ export const CesiumContextProvider = ({ return () => { abortController.abort(); }; - }, [providerConfig.terrainProvider.url]); + }, [providerConfig.terrainProvider.url, isViewerReady]); useEffect(() => { + if (!isViewerReady) { + return; + } // avoids runtime issues with WebGL context not available + if (providerConfig.surfaceProvider) { const abortController = new AbortController(); const { signal } = abortController; @@ -98,11 +106,16 @@ export const CesiumContextProvider = ({ abortController.abort(); }; } - }, [providerConfig.surfaceProvider]); + }, [providerConfig.surfaceProvider, isViewerReady]); // Load Primary Tileset useEffect(() => { - if (tilesetConfigs.primary) { + if ( + tilesetConfigs.primary && + isViewerReady && + viewerRef.current && + !viewerRef.current.isDestroyed() + ) { const fetchPrimary = async () => { console.debug( "[CESIUM|DEBUG] Loading primary tileset", @@ -125,11 +138,16 @@ export const CesiumContextProvider = ({ primaryTilesetRef.current = null; } }; - }, [tilesetConfigs.primary]); + }, [tilesetConfigs.primary, viewerRef, isViewerReady]); // Load Secondary Tileset useEffect(() => { - if (tilesetConfigs.secondary) { + if ( + tilesetConfigs.secondary && + isViewerReady && + viewerRef.current && + !viewerRef.current.isDestroyed() + ) { const fetchSecondary = async () => { console.debug( "[CESIUM|DEBUG] Loading secondary tileset", @@ -154,7 +172,7 @@ export const CesiumContextProvider = ({ secondaryTilesetRef.current = null; } }; - }, [tilesetConfigs.secondary]); + }, [tilesetConfigs.secondary, viewerRef, isViewerReady]); const contextValue = useMemo( () => ({ @@ -175,7 +193,11 @@ export const CesiumContextProvider = ({ [isViewerReady] ); - console.debug("CesiumContextProvider Initialized", contextValue); + console.debug( + "CesiumContextProvider Changed/Rendered", + isViewerReady, + contextValue + ); return ( diff --git a/libraries/mapping/engines/cesium/src/lib/CustomViewer.tsx b/libraries/mapping/engines/cesium/src/lib/CustomViewer.tsx index 951df51f0..b0ff2f386 100644 --- a/libraries/mapping/engines/cesium/src/lib/CustomViewer.tsx +++ b/libraries/mapping/engines/cesium/src/lib/CustomViewer.tsx @@ -1,5 +1,5 @@ import { type RefObject, useMemo } from "react"; -import { Color, Viewer, Rectangle, SceneMode } from "cesium"; +import { Color, Viewer, Rectangle, SceneMode, Cartographic } from "cesium"; import UAParser from "ua-parser-js"; import { merge } from "lodash"; @@ -19,7 +19,7 @@ import useTransitionTimeout from "./hooks/useTransitionTimeout"; import useTweakpane from "./hooks/useTweakpane"; import { useTilesets } from "./hooks/useTilesets"; import { useSceneStyles } from "./hooks/useSceneStyles"; -import { EncodedSceneParams } from ".."; +import { StringifiedCameraState } from "./utils/cesiumHashParamsCodec"; export type GlobeOptions = { // https://cesium.com/learn/cesiumjs/ref-doc/Globe.html @@ -29,17 +29,33 @@ export type GlobeOptions = { showSkirts?: boolean; }; +export type CameraLimiterOptions = { + pitchLimiter?: boolean; + minPitch?: number; + minPitchRange?: number; +}; + +export type InitialCameraView = { + position?: Cartographic; + heading?: number; + pitch?: number; + fov?: number; +}; + export type CustomViewerProps = { containerRef: RefObject; - cameraOptions?: { - pitchLimiter?: boolean; - minPitch?: number; - minPitchRange?: number; - }; + cameraLimiterOptions?: CameraLimiterOptions; + initialCameraView?: InitialCameraView; constructorOptions?: Viewer.ConstructorOptions; globeOptions?: GlobeOptions; // callbacks - onSceneChange?: (encodedScene: EncodedSceneParams) => void; + onSceneChange?: ( + e: { hashParams: Record }, + viewer?: Viewer, + cesiumCameraState?: StringifiedCameraState | null, + isSecondaryStyle?: boolean, + isMode2d?: boolean + ) => void; postInit?: () => void; enableSceneStyles?: boolean; }; @@ -89,7 +105,8 @@ export function CustomViewer(props: CustomViewerProps) { showGroundAtmosphere: false, showSkirts: false, }, - cameraOptions, + cameraLimiterOptions, + initialCameraView, constructorOptions, containerRef, onSceneChange, @@ -101,16 +118,16 @@ export function CustomViewer(props: CustomViewerProps) { [constructorOptions] ); - useInitializeViewer(containerRef, options); + useInitializeViewer(containerRef, options, initialCameraView); useCesiumGlobe(globeOptions); useTransitionTimeout(); // camera enhancements useDisableSSCC(); - useCameraRollSoftLimiter(cameraOptions); - useCameraPitchSoftLimiter(cameraOptions); - useCameraPitchEasingLimiter(cameraOptions); + useCameraRollSoftLimiter(cameraLimiterOptions); + useCameraPitchSoftLimiter(cameraLimiterOptions); + useCameraPitchEasingLimiter(cameraLimiterOptions); useCesiumWhenHidden(TRANSITION_DELAY); diff --git a/libraries/mapping/engines/cesium/src/lib/CustomViewerPlayground.tsx b/libraries/mapping/engines/cesium/src/lib/CustomViewerPlayground.tsx index 60c2dd623..6a9e14d7c 100644 --- a/libraries/mapping/engines/cesium/src/lib/CustomViewerPlayground.tsx +++ b/libraries/mapping/engines/cesium/src/lib/CustomViewerPlayground.tsx @@ -37,9 +37,7 @@ import { useTilesets } from "./hooks/useTilesets"; import { resolutionFractions } from "./utils/cesiumHelpers"; import { formatFractions } from "./utils/formatters"; -import { encodeScene } from "./utils/hashHelpers"; import { setLeafletView } from "./utils/leafletHelpers"; -import { EncodedSceneParams } from ".."; type CustomViewerProps = { children?: ReactNode; @@ -71,7 +69,7 @@ type CustomViewerProps = { }; minimapLayerUrl?: string; - onSceneChange?: (encodedScene: EncodedSceneParams) => void; + onSceneChange?: (e: unknown) => void; }; export function CustomViewerPlayground(props: CustomViewerProps) { @@ -96,7 +94,6 @@ export function CustomViewerPlayground(props: CustomViewerProps) { showSkirts: false, }, minimapLayerUrl, - onSceneChange, } = props; const [showFader, setShowFader] = useState(props.showFader ?? false); @@ -296,8 +293,6 @@ export function CustomViewerPlayground(props: CustomViewerProps) { "HOOK: update Hash, route or style changed", isSecondaryStyle ); - onSceneChange && - onSceneChange(encodeScene(viewer.scene, { isSecondaryStyle })); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [viewerRef, location.pathname, isSecondaryStyle]); @@ -331,10 +326,6 @@ export function CustomViewerPlayground(props: CustomViewerProps) { const moveEndListener = async () => { if (viewer?.camera.position) { console.debug("LISTENER: moveEndListener", isSecondaryStyle); - const encodedScene = encodeScene(viewer.scene, { isSecondaryStyle }); - - // let TopicMap/leaflet handle the view change in 2d Mode - !isMode2d && onSceneChange && onSceneChange(encodedScene); if (isUserAction && (!isMode2d || showFader)) { // remove roll from camera orientation @@ -375,7 +366,6 @@ export function CustomViewerPlayground(props: CustomViewerProps) { topicMapContext?.routedMapRef, isMode2d, isUserAction, - onSceneChange, ]); console.debug("RENDER: CustomViewer"); diff --git a/libraries/mapping/engines/cesium/src/lib/components/controls/PitchingCompass/PitchingCompass.tsx b/libraries/mapping/engines/cesium/src/lib/components/controls/PitchingCompass/PitchingCompass.tsx index 33b8e7cd8..30979f3ef 100644 --- a/libraries/mapping/engines/cesium/src/lib/components/controls/PitchingCompass/PitchingCompass.tsx +++ b/libraries/mapping/engines/cesium/src/lib/components/controls/PitchingCompass/PitchingCompass.tsx @@ -28,6 +28,7 @@ import { useCesiumContext } from "../../../hooks/useCesiumContext"; interface RotateButtonProps { viewerRef: React.RefObject; viewerAnimationMapRef: React.RefObject; + isViewerReady: boolean; minPitch?: number; maxPitch?: number; durationReset?: number; @@ -39,6 +40,7 @@ interface RotateButtonProps { /** * @viewerRef reference to cesium viewer * @viewerAnimationMapRef reference to a WeakMap of viewer animations + * @isViewerReady boolean state indicating if the viewer is ready * @minPitch pitch angle in radians starting from Nadir -90 to -0, should be left at -90 * @maxPitch pitch angle in radians starting from Nadir -90 to -0 is flat with terrain and should be avoided. * @durationReset duration in milliseconds when returning to top down or default oblique view @@ -51,6 +53,7 @@ interface RotateButtonProps { export const PitchingCompass: React.FC = ({ viewerRef, viewerAnimationMapRef, + isViewerReady, minPitch = CesiumMath.toRadians(-90), maxPitch = CesiumMath.toRadians(-30), durationReset = 1500, @@ -71,14 +74,18 @@ export const PitchingCompass: React.FC = ({ const handleMouseDown = (event: React.MouseEvent) => { shouldSuspendPitchLimiterRef.current = true; - if (viewerRef.current && viewerAnimationMapRef.current) { + if ( + viewerRef.current && + viewerAnimationMapRef.current && + !viewerRef.current.isDestroyed() + ) { cancelViewerAnimation(viewerRef.current, viewerAnimationMapRef.current); setIsControlMouseDown(true); setInitialMouseX(event.clientX); setInitialMouseY(event.clientY); setInitialHeading(viewerRef.current.camera.heading); - setInitialPitch(viewerRef.current.scene.camera.pitch); - setCurrentPitch(viewerRef.current.scene.camera.pitch); + setInitialPitch(viewerRef.current.camera.pitch); + setCurrentPitch(viewerRef.current.camera.pitch); setCurrentHeading(viewerRef.current.camera.heading); const target = getOrbitPoint(viewerRef.current); @@ -95,20 +102,30 @@ export const PitchingCompass: React.FC = ({ const handleControlMouseUp = () => { shouldSuspendPitchLimiterRef.current = false; setIsControlMouseDown(false); - if (viewerRef.current && initialHeading !== null) { - const scene = viewerRef.current.scene; - scene.camera.lookAtTransform(Matrix4.IDENTITY); + if ( + viewerRef.current && + initialHeading !== null && + !viewerRef.current.isDestroyed() + ) { + viewerRef.current.camera.lookAtTransform(Matrix4.IDENTITY); } }; useEffect(() => { - if (!viewerRef.current || !viewerAnimationMapRef.current) return; + if ( + !viewerRef.current || + viewerRef.current.isDestroyed() || + !viewerAnimationMapRef.current + ) { + return; + } const viewer = viewerRef.current; + const camera = viewer.camera; const animationMap = viewerAnimationMapRef.current; const getCameraOrientation = () => { - if (!viewer || !viewer.camera) return; - const { pitch, heading } = viewer.camera; + if (!camera) return; + const { pitch, heading } = camera; setCurrentPitch(pitch); setCurrentHeading(heading); }; @@ -119,11 +136,11 @@ export const PitchingCompass: React.FC = ({ cancelViewerAnimation(viewer, animationMap); }, ScreenSpaceEventType.LEFT_DOWN); - viewer.camera.changed.addEventListener(getCameraOrientation); + camera.changed.addEventListener(getCameraOrientation); return () => { handler.destroy(); - viewer.camera.changed.removeEventListener(getCameraOrientation); + camera.changed.removeEventListener(getCameraOrientation); }; }, [viewerRef, viewerAnimationMapRef]); @@ -148,7 +165,7 @@ export const PitchingCompass: React.FC = ({ const target = getOrbitPoint(viewerRef.current); if (target && initialRange !== null) { - viewerRef.current.scene.camera.lookAt( + viewerRef.current.camera.lookAt( target, new HeadingPitchRange(heading, pitch, initialRange) ); @@ -211,9 +228,12 @@ export const PitchingCompass: React.FC = ({ }; useEffect(() => { - const viewer = viewerRef.current; - if (viewer) { - const camera = viewer.scene.camera; + if ( + viewerRef.current && + isViewerReady && + !viewerRef.current.isDestroyed() + ) { + const camera = viewerRef.current.camera; const updateOrientation = () => { setCurrentPitch(camera.pitch); // correct heading for compass needle @@ -226,7 +246,7 @@ export const PitchingCompass: React.FC = ({ camera.changed.removeEventListener(updateOrientation); }; } - }, [viewerRef]); + }, [viewerRef, isViewerReady]); return ( // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions diff --git a/libraries/mapping/engines/cesium/src/lib/hooks/useCameraPitchSoftLimiter.ts b/libraries/mapping/engines/cesium/src/lib/hooks/useCameraPitchSoftLimiter.ts index 2399a53ee..adeb5d164 100644 --- a/libraries/mapping/engines/cesium/src/lib/hooks/useCameraPitchSoftLimiter.ts +++ b/libraries/mapping/engines/cesium/src/lib/hooks/useCameraPitchSoftLimiter.ts @@ -90,7 +90,8 @@ const useCameraPitchSoftLimiter = ( }; viewer.camera.moveEnd.addEventListener(moveEndListener); return () => { - viewer.camera.moveEnd.removeEventListener(moveEndListener); + !viewer.isDestroyed() && + viewer.camera.moveEnd.removeEventListener(moveEndListener); }; } }, [ diff --git a/libraries/mapping/engines/cesium/src/lib/hooks/useCameraRollSoftLimiter.ts b/libraries/mapping/engines/cesium/src/lib/hooks/useCameraRollSoftLimiter.ts index 0fcaf9895..b348053fa 100644 --- a/libraries/mapping/engines/cesium/src/lib/hooks/useCameraRollSoftLimiter.ts +++ b/libraries/mapping/engines/cesium/src/lib/hooks/useCameraRollSoftLimiter.ts @@ -75,7 +75,8 @@ const useCameraRollSoftLimiter = ({ }; viewer.camera.moveEnd.addEventListener(moveEndListener); return () => { - viewer.camera.moveEnd.removeEventListener(moveEndListener); + !viewer.isDestroyed() && + viewer.camera.moveEnd.removeEventListener(moveEndListener); }; } }, [ diff --git a/libraries/mapping/engines/cesium/src/lib/hooks/useCesiumInitialCameraFromSearchParams.ts b/libraries/mapping/engines/cesium/src/lib/hooks/useCesiumInitialCameraFromSearchParams.ts new file mode 100644 index 000000000..6e8aa064b --- /dev/null +++ b/libraries/mapping/engines/cesium/src/lib/hooks/useCesiumInitialCameraFromSearchParams.ts @@ -0,0 +1,25 @@ +import { useEffect, useState } from "react"; +import { decodeCesiumCamera } from "../utils/cesiumHashParamsCodec"; +import { useSearchParams } from "react-router-dom"; +import { InitialCameraView } from "../CustomViewer"; + +// null means not set, undefined means no camera view found +export const useCesiumInitialCameraFromSearchParams = () => { + const [searchParams] = useSearchParams(); + const [initialCameraView, setInitialCameraView] = useState< + InitialCameraView | undefined | null + >(null); + + useEffect(() => { + const view = decodeCesiumCamera(searchParams); + if (view && initialCameraView === null) { + setInitialCameraView(view); + } else { + setInitialCameraView(undefined); + } + // only evaluate url once on load for intial view + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return initialCameraView; +}; diff --git a/libraries/mapping/engines/cesium/src/lib/hooks/useCesiumWhenHidden.ts b/libraries/mapping/engines/cesium/src/lib/hooks/useCesiumWhenHidden.ts index 342d70e6f..71cd57f46 100644 --- a/libraries/mapping/engines/cesium/src/lib/hooks/useCesiumWhenHidden.ts +++ b/libraries/mapping/engines/cesium/src/lib/hooks/useCesiumWhenHidden.ts @@ -5,6 +5,9 @@ import { useSelector } from "react-redux"; import { Viewer } from "cesium"; const hideLayers = (viewer: Viewer) => { + if (viewer.isDestroyed()) { + return; + } for (let i = 0; i < viewer.imageryLayers.length; i++) { const layer = viewer.imageryLayers.get(i); if (layer) { @@ -15,6 +18,9 @@ const hideLayers = (viewer: Viewer) => { }; const showLayers = (viewer: Viewer) => { + if (viewer.isDestroyed()) { + return; + } for (let i = 0; i < viewer.imageryLayers.length; i++) { const layer = viewer.imageryLayers.get(i); if (layer) { @@ -24,6 +30,7 @@ const showLayers = (viewer: Viewer) => { } }; +// reduce resoures use when cesium is not visible export const useCesiumWhenHidden = (delay = 0) => { const viewer = useCesiumViewer(); const isMode2d = useSelector(selectViewerIsMode2d); diff --git a/libraries/mapping/engines/cesium/src/lib/hooks/useInitializeViewer.ts b/libraries/mapping/engines/cesium/src/lib/hooks/useInitializeViewer.ts index d84b7c4a2..624b10915 100644 --- a/libraries/mapping/engines/cesium/src/lib/hooks/useInitializeViewer.ts +++ b/libraries/mapping/engines/cesium/src/lib/hooks/useInitializeViewer.ts @@ -3,11 +3,14 @@ import { useSelector } from "react-redux"; import { BoundingSphere, + Camera, Cartesian3, Cartographic, Math as CesiumMath, + HeadingPitchRange, Matrix4, PerspectiveFrustum, + Rectangle, Scene, ScreenSpaceCameraController, Viewer, @@ -25,8 +28,8 @@ import { selectViewerHomeOffset, } from "../slices/cesium"; -import { decodeSceneFromLocation } from "../utils/hashHelpers"; import { validateWorldCoordinate } from "../utils/positions"; +import type { InitialCameraView } from "../CustomViewer"; // Type for storing position and orientation interface CameraState { @@ -38,17 +41,29 @@ interface CameraState { const postRenderHandlerMap: WeakMap void> = new WeakMap(); const preUpdateHandlerMap: WeakMap void> = new WeakMap(); +const initialViewSetMap: WeakMap = new WeakMap(); export const useInitializeViewer = ( containerRef?: React.RefObject, - options?: Viewer.ConstructorOptions + options?: Viewer.ConstructorOptions, + initialCameraView?: InitialCameraView | null ) => { - const { viewerRef, setIsViewerReady } = useCesiumContext(); + const { viewerRef, isViewerReady, setIsViewerReady } = useCesiumContext(); const home = useSelector(selectViewerHome); const homeOffset = useSelector(selectViewerHomeOffset); - // todo move initialization from hash to consuming component - const hashRef = useRef(null); // effectively hook should run only once + // aling Cesium Default fallback with local home + if (home) { + const { longitude, latitude } = Cartographic.fromCartesian(home); + const rect = new Rectangle(longitude, latitude, longitude, latitude); + + Camera.DEFAULT_VIEW_RECTANGLE = rect; + } + Camera.DEFAULT_OFFSET = new HeadingPitchRange( + CesiumMath.toRadians(0), + CesiumMath.toRadians(-45), + 700 + ); const previousIsMode2d = useRef(null); const previousIsSecondaryStyle = useRef(null); @@ -74,6 +89,13 @@ export const useInitializeViewer = ( console.debug("HOOK: [CESIUM] init CustomViewer"); if (containerRef?.current) { try { + console.debug( + "HOOK: [CESIUM] new init CustomViewer", + containerRef, + Date.now(), + options, + initialCameraView + ); const viewer = new Viewer(containerRef.current, options); viewerRef.current = viewer; @@ -161,10 +183,18 @@ export const useInitializeViewer = ( viewerRef.current = null; } }; - }, [options, containerRef, viewerRef, home, maxZoom, setIsViewerReady]); + }, [ + options, + containerRef, + initialCameraView, + viewerRef, + home, + maxZoom, + setIsViewerReady, + ]); useEffect(() => { - console.debug("HOOK: useInitializeViewer useEffect terrain"); + console.debug("HOOK: useInitializeViewer useEffect scene settings"); if (viewerRef.current) { const scene: Scene = viewerRef.current.scene; const sscc: ScreenSpaceCameraController = @@ -183,21 +213,9 @@ export const useInitializeViewer = ( }, [viewerRef, isSecondaryStyle, maxZoom, minZoom, enableCollisionDetection]); useEffect(() => { - console.debug("HOOK: useInitializeViewer useEffect hash"); - if (viewerRef.current && hashRef.current === null) { + console.debug("HOOK: useInitializeViewer position", initialCameraView); + if (viewerRef.current && isViewerReady && initialCameraView !== null) { const viewer = viewerRef.current; - const locationHash = window.location.hash ?? ""; - hashRef.current = locationHash; - console.debug("HOOK: set initialHash", locationHash); - - const hashParams = locationHash.split("?")[1]; - const sceneFromHashParams = decodeSceneFromLocation(hashParams); - const { camera } = sceneFromHashParams; - const { latitude, longitude, height, heading, pitch } = camera; - - if (viewer.camera.frustum instanceof PerspectiveFrustum) { - viewer.camera.frustum.fov = Math.PI / 4; - } if (!home || !homeOffset) { console.warn( @@ -206,6 +224,13 @@ export const useInitializeViewer = ( return; } + if (initialViewSetMap.has(viewer)) { + console.debug( + "HOOK: [CESIUM|CAMERA] Initial view already set, skipping." + ); + return; + } + const resetToHome = () => { viewer.camera.lookAt(home, homeOffset); viewer.camera.flyToBoundingSphere(new BoundingSphere(home, 500), { @@ -213,19 +238,25 @@ export const useInitializeViewer = ( }); }; - // TODO enable 2D Mode if zoom value is present in hash on startup - if (isMode2d) { console.debug( "HOOK: skipping cesium location setup with 2d mode active zoom" ); } else { - if (sceneFromHashParams && longitude && latitude) { - const destination = Cartesian3.fromRadians( - longitude, - latitude, - height ?? 1000 // restore height if missing + const position = initialCameraView?.position; + const heading = initialCameraView?.heading; + const pitch = initialCameraView?.pitch; + const fov = initialCameraView?.fov; + + if (position) { + const restoredHeight = CesiumMath.clamp( + position?.height || 1000, + 0, + 50000 ); + position.height = restoredHeight; + + const destination = Cartographic.toCartesian(position); const isValidDestination = validateWorldCoordinate( destination, @@ -234,13 +265,15 @@ export const useInitializeViewer = ( 0 ); + if (viewer.camera.frustum instanceof PerspectiveFrustum) { + viewer.camera.frustum.fov = fov ?? Math.PI / 4; + } + if (isValidDestination) { console.debug( - "HOOK [2D3D|CESIUM|CAMERA] init Viewer set camera from hash", + "HOOK [2D3D|CESIUM|CAMERA] init Viewer set camera from provided position", destination, - longitude, - latitude, - height + position ); viewer.camera.setView({ destination, @@ -264,9 +297,17 @@ export const useInitializeViewer = ( ); resetToHome(); } + initialViewSetMap.set(viewer, true); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [viewerRef, home, homeOffset, location.pathname, isMode2d]); + }, [ + viewerRef, + isViewerReady, + home, + homeOffset, + initialCameraView, + isMode2d, + maxZoom, + ]); useEffect(() => { console.debug("HOOK: useInitializeViewer useEffect resize"); @@ -274,7 +315,7 @@ export const useInitializeViewer = ( const viewer = viewerRef.current; const resizeObserver = new ResizeObserver(() => { console.debug("HOOK: resize cesium container"); - if (viewer && containerRef?.current) { + if (viewer && !viewer.isDestroyed() && containerRef?.current) { viewer.canvas.width = containerRef.current.clientWidth; viewer.canvas.height = containerRef.current.clientHeight; viewer.canvas.style.width = "100%"; diff --git a/libraries/mapping/engines/cesium/src/lib/hooks/useOnSceneChange.ts b/libraries/mapping/engines/cesium/src/lib/hooks/useOnSceneChange.ts index 9d6232bd0..1beab73b6 100644 --- a/libraries/mapping/engines/cesium/src/lib/hooks/useOnSceneChange.ts +++ b/libraries/mapping/engines/cesium/src/lib/hooks/useOnSceneChange.ts @@ -2,43 +2,73 @@ import { useEffect } from "react"; import { useSelector } from "react-redux"; import { cameraToCartographicDegrees } from "../utils/cesiumHelpers"; -import { encodeScene } from "../utils/hashHelpers"; import { selectShowSecondaryTileset, selectViewerIsMode2d, + selectViewerIsTransitioning, } from "../slices/cesium"; -import { EncodedSceneParams } from "../.."; +import { encodeCesiumCamera } from "../.."; import { useCesiumContext } from "./useCesiumContext"; +import { Viewer } from "cesium"; +import { StringifiedCameraState } from "../utils/cesiumHashParamsCodec"; + +export const VIEWERSTATE_KEYS = { + mapStyle: "m", + is3d: "is3d", +}; + +const toHashParams = ( + cesiumCameraState: StringifiedCameraState, + args: { isSecondaryStyle: boolean; isMode2d: boolean } +) => { + const viewerState = { + [VIEWERSTATE_KEYS.mapStyle]: args.isSecondaryStyle ? "0" : "1", + [VIEWERSTATE_KEYS.is3d]: args.isMode2d ? "0" : "1", + }; + + const hashParams = cesiumCameraState.reduce((acc, { key, value }) => { + acc[key] = value; + return acc; + }, viewerState); + + return hashParams; +}; export const useOnSceneChange = ( - onSceneChange?: (p: EncodedSceneParams) => void + onSceneChange?: ( + e: { hashParams: Record }, + viewer?: Viewer, + cesiumCameraState?: StringifiedCameraState | null, + isSecondaryStyle?: boolean, + isMode2d?: boolean + ) => void ) => { const { viewerRef } = useCesiumContext(); const isSecondaryStyle = useSelector(selectShowSecondaryTileset); const isMode2d = useSelector(selectViewerIsMode2d); + const isTransitioning = useSelector(selectViewerIsTransitioning); // todo handle style change explicitly not via tileset, is secondarystyle // todo consider declaring changed part of state in the callback, not full state only - console.debug("HOOKINIT [CESIUM|HASH] useCesiumHashUpdater"); - useEffect(() => { + // on changes to mode or style const viewer = viewerRef.current; - if (viewer && viewer.scene && !isMode2d) { + if (viewer && viewer.camera && !isMode2d) { console.debug( "HOOK: update Hash, route or style changed", isSecondaryStyle ); - - const encodedScene = encodeScene(viewer.scene, { - isSecondaryStyle, - isMode2d, - }); - if (onSceneChange) { - onSceneChange(encodedScene); + const cameraState = encodeCesiumCamera(viewer.camera); + const hashParams = toHashParams(cameraState, { + isSecondaryStyle, + isMode2d, + }); + hashParams.zoom = ""; + onSceneChange({ hashParams }); } else { console.info("HOOK: [NOOP] no onSceneChange callback"); } @@ -49,40 +79,45 @@ export const useOnSceneChange = ( useEffect(() => { // update hash hook const viewer = viewerRef.current; - if (viewer && viewer.scene) { + if (isTransitioning) { + return; + } + + if (viewer && viewer.camera) { console.debug( "HOOK: [2D3D|CESIUM] viewer changed add new Cesium MoveEnd Listener to update hash" ); const moveEndListener = async () => { // let TopicMap/leaflet handle the view change in 2d Mode - if (viewer.scene && viewer.camera.position && !isMode2d) { + if (viewer.camera && viewer.camera.position && !isMode2d) { const camDeg = cameraToCartographicDegrees(viewer.camera); console.debug( "LISTENER: Cesium moveEndListener encode viewer to hash", isSecondaryStyle, camDeg ); - const encodedScene = - viewer.scene && - encodeScene(viewer.scene, { + + if (onSceneChange) { + const cameraState = encodeCesiumCamera(viewer.camera); + const hashParams = toHashParams(cameraState, { isSecondaryStyle, isMode2d, }); - if (onSceneChange) { - onSceneChange(encodedScene); + onSceneChange({ hashParams }); } else { console.info("HOOK: [NOOP] no onSceneChange callback"); } } }; - viewer.scene.camera.moveEnd.addEventListener(moveEndListener); + viewer.camera.moveEnd.addEventListener(moveEndListener); return () => { - viewer && - viewer.scene && - viewer.scene.camera.moveEnd.removeEventListener(moveEndListener); + // clear hash on unmount + // onSceneChange && onSceneChange({ hashParams: clear3dOnlyHashParams }); + !viewer.isDestroyed() && + viewer.camera.moveEnd.removeEventListener(moveEndListener); }; } - }, [viewerRef, isSecondaryStyle, isMode2d, onSceneChange]); + }, [viewerRef, isSecondaryStyle, isMode2d, onSceneChange, isTransitioning]); }; export default useOnSceneChange; diff --git a/libraries/mapping/engines/cesium/src/lib/hooks/useSceneStyles.ts b/libraries/mapping/engines/cesium/src/lib/hooks/useSceneStyles.ts index 2facd98bf..066fd1303 100644 --- a/libraries/mapping/engines/cesium/src/lib/hooks/useSceneStyles.ts +++ b/libraries/mapping/engines/cesium/src/lib/hooks/useSceneStyles.ts @@ -10,7 +10,6 @@ import { } from "../slices/cesium"; import { setupPrimaryStyle, setupSecondaryStyle } from "../utils/sceneStyles"; -import { useCesiumViewer } from "./useCesiumViewer"; import { useCesiumContext } from "./useCesiumContext"; export const useSceneStyles = (enabled = true) => { @@ -18,12 +17,19 @@ export const useSceneStyles = (enabled = true) => { const currentSceneStyle = useSelector(selectCurrentSceneStyle); const ctx = useCesiumContext(); - const viewer = useCesiumViewer(); + const { viewerRef, isViewerReady } = ctx; const primaryStyle = useSelector(selectSceneStylePrimary); const secondaryStyle = useSelector(selectSceneStyleSecondary); useEffect(() => { - if (!enabled || !viewer || currentSceneStyle === undefined) return; + if ( + !enabled || + !viewerRef.current || + viewerRef.current.isDestroyed() || + !isViewerReady || + currentSceneStyle === undefined + ) + return; console.debug("currentSceneStyle change", currentSceneStyle); if (currentSceneStyle === "primary") { setupPrimaryStyle(ctx, primaryStyle); @@ -39,7 +45,8 @@ export const useSceneStyles = (enabled = true) => { }, [ dispatch, enabled, - viewer, + viewerRef, + isViewerReady, currentSceneStyle, primaryStyle, secondaryStyle, diff --git a/libraries/mapping/engines/cesium/src/lib/hooks/useTilesets.ts b/libraries/mapping/engines/cesium/src/lib/hooks/useTilesets.ts index 62fa34bd3..373da6d12 100644 --- a/libraries/mapping/engines/cesium/src/lib/hooks/useTilesets.ts +++ b/libraries/mapping/engines/cesium/src/lib/hooks/useTilesets.ts @@ -8,7 +8,6 @@ import { } from "../slices/cesium"; import { useCesiumContext } from "./useCesiumContext"; -import { useCesiumViewer } from "./useCesiumViewer"; import { useSecondaryStyleTilesetClickHandler } from "./useSecondaryStyleTilesetClickHandler"; import { TRANSITION_DELAY } from "../CustomViewer"; @@ -17,8 +16,7 @@ import { useBaseTilesetsTweakpane } from "./useBaseTilesetsTweakpane"; export const useTilesets = () => { const showPrimary = useSelector(selectShowPrimaryTileset); - const { tilesetsRefs } = useCesiumContext(); - const viewer = useCesiumViewer(); + const { tilesetsRefs, viewerRef } = useCesiumContext(); let tilesetPrimary = tilesetsRefs.primaryRef.current; let tilesetSecondary = tilesetsRefs.secondaryRef.current; const showSecondary = useSelector(selectShowSecondaryTileset); @@ -27,24 +25,34 @@ export const useTilesets = () => { useBaseTilesetsTweakpane(); useEffect(() => { - if (viewer && tilesetPrimary) { + if ( + viewerRef.current && + !viewerRef.current.isDestroyed() && + tilesetPrimary + ) { + const viewer = viewerRef.current; viewer.scene.primitives.add(tilesetPrimary); console.debug( "[CESIUM|DEBUG] Adding primary tileset to viewer", viewer.scene.primitives.length ); } - }, [tilesetPrimary, viewer]); + }, [tilesetPrimary, viewerRef]); useEffect(() => { - if (viewer && tilesetSecondary) { + if ( + viewerRef.current && + !viewerRef.current.isDestroyed() && + tilesetSecondary + ) { + const viewer = viewerRef.current; viewer.scene.primitives.add(tilesetSecondary); console.debug( "[CESIUM|DEBUG] Adding secondary tileset to viewer", viewer.scene.primitives.length ); } - }, [tilesetSecondary, viewer]); + }, [tilesetSecondary, viewerRef]); useEffect(() => { console.debug("HOOK BaseTilesets: showSecondary", showSecondary); @@ -78,7 +86,7 @@ export const useTilesets = () => { tilesetSecondary.show = false; } }; - if (viewer) { + if (viewerRef.current && !viewerRef.current.isDestroyed()) { if (isMode2d) { setTimeout(() => { hideTilesets(); @@ -97,7 +105,7 @@ export const useTilesets = () => { } }, [ isMode2d, - viewer, + viewerRef, showPrimary, showSecondary, tilesetPrimary, diff --git a/libraries/mapping/engines/cesium/src/lib/utils/cesiumHashParamsCodec.ts b/libraries/mapping/engines/cesium/src/lib/utils/cesiumHashParamsCodec.ts new file mode 100644 index 000000000..70af8f33e --- /dev/null +++ b/libraries/mapping/engines/cesium/src/lib/utils/cesiumHashParamsCodec.ts @@ -0,0 +1,152 @@ +import { + Camera, + Cartographic, + Math as CesiumMath, + PerspectiveFrustum, +} from "cesium"; + +// Constants for URL parameter formatting +const DEGREE_DIGITS = 7; +const CAMERA_DEGREE_DIGITS = 2; + +type HashCodec = { + key: string; + decode: (value: string) => number; + encode: (value: number) => string; +}; + +export type CameraState = { + position: Cartographic; + heading?: number; + pitch?: number; + fov?: number; +}; +export type StringifiedCameraState = { key: string; value: string }[]; + +/** + * Format a radian value to degrees with specified precision + */ +const formatRadians = (value: number, fixed = DEGREE_DIGITS): string => + parseFloat(CesiumMath.toDegrees(value).toFixed(fixed)).toString(); // parse float removes trailing zeros for shorter urls + +/** + * Common parameter codecs for URL hash state + */ + +const cameraCodec: Record = { + // Cesium Camera position and orientation codecs + longitude: { + key: "lng", + decode: (value: string) => CesiumMath.toRadians(Number(value)), + encode: (value: number) => formatRadians(value), + }, + latitude: { + key: "lat", + decode: (value: string) => CesiumMath.toRadians(Number(value)), + encode: (value: number) => formatRadians(value), + }, + height: { + key: "h", + decode: (value: string) => Number(value), + encode: (value: number) => parseFloat(value.toFixed(2)).toString(), + }, + heading: { + key: "heading", + decode: (value: string) => CesiumMath.toRadians(Number(value)), + encode: (value: number) => + formatRadians(CesiumMath.zeroToTwoPi(value), CAMERA_DEGREE_DIGITS), + }, + pitch: { + key: "pitch", + decode: (value: string) => CesiumMath.toRadians(Number(value)), + encode: (value: number) => + formatRadians(CesiumMath.zeroToTwoPi(value), CAMERA_DEGREE_DIGITS), + }, + fov: { + key: "fov", + decode: (value: string) => CesiumMath.toRadians(Number(value)), + encode: (value: number) => formatRadians(value, CAMERA_DEGREE_DIGITS), + }, +}; + +export const cesiumCameraParamsKeyList = Object.values(cameraCodec).map( + (codec) => codec.key +); + +export const getClearCesiumCameraParams = (emptyValue = "") => { + const only3d = cesiumCameraParamsKeyList.filter( + (k) => !["lng", "lat"].includes(k) // keep lng and lat for consistency + ); + + const clearValues = only3d.reduce( + (acc, key) => { + acc[key] = emptyValue; + return acc; + }, + { is3d: emptyValue } + ); + return clearValues; +}; + +function isNumber(value: unknown): value is number { + return ( + value !== undefined && + value !== null && + !isNaN(Number(value)) && + isFinite(Number(value)) + ); +} + +export const encodeCesiumCamera = (camera: Camera): StringifiedCameraState => { + const { positionCartographic, pitch, heading, frustum } = camera; + const { longitude, latitude, height } = positionCartographic; + const fov = frustum instanceof PerspectiveFrustum ? frustum.fov : undefined; + + const orderedParams: [number | undefined, HashCodec][] = [ + [longitude, cameraCodec.longitude], + [latitude, cameraCodec.latitude], + [height, cameraCodec.height], + [heading, cameraCodec.heading], + [pitch, cameraCodec.pitch], + [fov, cameraCodec.fov], + ]; + + const stringifiedOrderedParams = orderedParams + .filter(([numberValue]) => isNumber(numberValue)) + .map(([numberValue, codec]) => ({ + key: codec.key, + value: codec.encode(numberValue as number), + })); + + return stringifiedOrderedParams; +}; + +export const decodeCesiumCamera = ( + hashParams: URLSearchParams +): CameraState | null => { + const decoded = Object.keys(cameraCodec).reduce((acc, key) => { + const shortKey = cameraCodec[key].key; + const value = hashParams.get(shortKey); + acc[key] = + value !== null && value !== undefined + ? cameraCodec[key].decode(value) + : null; + return acc; + }, {} as Record); + + const { longitude, latitude, height, heading, pitch, fov } = decoded; + + if (!isNumber(longitude) || !isNumber(latitude) || !isNumber(height)) { + return null; + } + + const position = Cartographic.fromRadians(longitude, latitude, height); + + const cameraState = { + position, + heading: heading ?? undefined, + pitch: pitch ?? undefined, + fov: fov ?? undefined, + }; + return cameraState; +}; diff --git a/libraries/mapping/engines/cesium/src/lib/utils/cesiumTilesetProviders.ts b/libraries/mapping/engines/cesium/src/lib/utils/cesiumTilesetProviders.ts index f1b3fde4f..cf3cad674 100644 --- a/libraries/mapping/engines/cesium/src/lib/utils/cesiumTilesetProviders.ts +++ b/libraries/mapping/engines/cesium/src/lib/utils/cesiumTilesetProviders.ts @@ -42,10 +42,11 @@ const DEFAULT_LOD2_OPTIONS: Cesium3DTileset.ConstructorOptions = { }; const loadLOD2Tileset = async (tileset: TilesetConfig) => { - const lod2 = await Cesium3DTileset.fromUrl(tileset.url, { + const lod2Options = { ...tileset.constructorOptions, ...DEFAULT_LOD2_OPTIONS, - }); + }; + const lod2 = await Cesium3DTileset.fromUrl(tileset.url, lod2Options); return lod2; }; @@ -54,10 +55,11 @@ const loadMeshTileset = async (tileset: TilesetConfig) => { const shader = new CustomShader( CUSTOM_SHADERS_DEFINITIONS[CustomShaderKeys.UNLIT_ENHANCED_2024] ); - const mesh = await Cesium3DTileset.fromUrl(tileset.url, { + const meshOptions = { ...tileset.constructorOptions, ...DEFAULT_MESH_OPTIONS, - }); + }; + const mesh = await Cesium3DTileset.fromUrl(tileset.url, meshOptions); mesh.customShader = shader; return mesh; }; diff --git a/libraries/mapping/engines/cesium/src/lib/utils/hashHelpers.ts b/libraries/mapping/engines/cesium/src/lib/utils/hashHelpers.ts deleted file mode 100644 index 4434e3f6a..000000000 --- a/libraries/mapping/engines/cesium/src/lib/utils/hashHelpers.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { - Cartesian3, - Cartographic, - Viewer, - Math as CesiumMath, - Scene, -} from "cesium"; -import { - AppState, - EncodedSceneParams, - FlatDecodedSceneHash, - SceneStateDescription, -} from "../.."; - -// 5 DEGREES OF FREEDOM CAMERA encoding/decoding -// lon, lat, height, heading, pitch -// does not always map to a point on the ground so there is no way to encode only the position on the ground at center -// the camera height is the ellipsoidal height of the camera position, so it works well even if the terrain is not loaded - -// Zoom is not encoded in the camera position, but can be passed as a separate parameter, only used as reference for leaflet/slippy map zoom level - -// TODO implement zoom level to camera height conversion with terrain height for reverse use case. - -const DEGREE_DIGITS = 7; -const CAMERA_DEGREE_DIGITS = 2; -const FRACTIONAL_ZOOM_DIGITS = 3; -const MINZOOM = 6; -const MAXZOOM = 20; - -const formatRadians = (value: number, fixed = DEGREE_DIGITS) => - parseFloat(CesiumMath.toDegrees(value).toFixed(fixed)); // parse float removes trailing zeros for shorter urls - -export const hashcodecs = { - // parsefloat removes trailing zeros for shorter urls - longitude: { - key: "lng", - decode: (value: string) => CesiumMath.toRadians(Number(value)), - encode: (value: number) => formatRadians(value), - }, - latitude: { - key: "lat", - decode: (value: string) => CesiumMath.toRadians(Number(value)), - encode: (value: number) => formatRadians(value), - }, - height: { - key: "h", - decode: (value: string) => Number(value), - encode: (value: number) => parseFloat(value.toFixed(2)), - }, - heading: { - key: "heading", - decode: (value: string) => CesiumMath.toRadians(Number(value)), - encode: (value: number) => formatRadians(value, CAMERA_DEGREE_DIGITS) % 360, - }, - pitch: { - key: "pitch", - decode: (value: string) => CesiumMath.toRadians(Number(value)), - encode: (value: number) => formatRadians(value, CAMERA_DEGREE_DIGITS) % 360, - }, - zoom: { - key: "zoom", - decode: (value: string) => undefined, - encode: (value: number) => - parseFloat( - Math.min(Math.max(MINZOOM, value), MAXZOOM).toFixed( - FRACTIONAL_ZOOM_DIGITS - ) - ), - }, - isSecondaryStyle: { - key: "m", - decode: (value: string) => value === "true" || value === "1", - encode: (value: boolean) => (value ? "1" : null), - }, - isMode2d: { - // isMode2d is the inverse of is3d - key: "is3d", - decode: (value: string) => !(value === "true" || value === "1"), - encode: (value: boolean) => (!value ? "1" : null), - }, -}; - -export function encodeScene( - scene: Scene, - appState: AppState = {} -): EncodedSceneParams { - const { camera } = scene; - const { x, y, z } = camera.position; - - const { longitude, latitude, height } = Cartographic.fromCartesian( - new Cartesian3(x, y, z) - ); - - const heading = camera.heading; - const pitch = camera.pitch; - - const { isSecondaryStyle, zoom, isMode2d } = appState; - // set param order here - const hashParams = [ - longitude, - latitude, - height, - heading, - pitch, - zoom, - isSecondaryStyle, - isMode2d, - ].reduce((acc, value, index) => { - const codec = hashcodecs[Object.keys(hashcodecs)[index]]; - if (value !== undefined && value !== null) { - const encoded = codec.encode(value); - if (encoded !== null) { - acc[codec.key] = encoded; - } - } - return acc; - }, {}); - //console.debug('hashparams', hashparams); - //const hash = new URLSearchParams(hashParams).toString(); - return { - hashParams, - state: { - camera: { - longitude, - latitude, - height, - heading, - pitch, - }, - zoom, - isSecondaryStyle, - }, - }; -} - -export function decodeSceneFromLocation( - location: string -): SceneStateDescription { - const params = new URLSearchParams(location); - const decoded = Object.keys(hashcodecs).reduce((acc, key) => { - const codec = hashcodecs[key]; - const value = params.get(codec.key); - acc[key] = - value !== null && value !== undefined ? codec.decode(value) : null; - return acc; - }, {} as FlatDecodedSceneHash); - const camera = { - longitude: decoded["longitude"] as number, - latitude: decoded["latitude"] as number, - height: decoded["height"] as number, - heading: decoded["heading"] as number, - pitch: decoded["pitch"] as number, - }; - return { - camera, - ...decoded, - }; -} diff --git a/playgrounds/carmamap/src/app/components/CarmaMap/CarmaMap.tsx b/playgrounds/carmamap/src/app/components/CarmaMap/CarmaMap.tsx index fa130edea..d5aafccb9 100644 --- a/playgrounds/carmamap/src/app/components/CarmaMap/CarmaMap.tsx +++ b/playgrounds/carmamap/src/app/components/CarmaMap/CarmaMap.tsx @@ -570,7 +570,7 @@ export const CarmaMap = ({ > { console.debug( "[GEOPORTALMAP|HASH|SCENE|CESIUM]cesium scene changed", diff --git a/playgrounds/cesium-reference/src/views/NavigationControl.tsx b/playgrounds/cesium-reference/src/views/NavigationControl.tsx index 846fba15f..063651dfa 100644 --- a/playgrounds/cesium-reference/src/views/NavigationControl.tsx +++ b/playgrounds/cesium-reference/src/views/NavigationControl.tsx @@ -114,6 +114,7 @@ const NavigationControlView: FC = () => { diff --git a/playgrounds/cesium/src/app/views/tests/standalone/HQ500.tsx b/playgrounds/cesium/src/app/views/tests/standalone/HQ500.tsx index 82d489e75..09422e4c1 100644 --- a/playgrounds/cesium/src/app/views/tests/standalone/HQ500.tsx +++ b/playgrounds/cesium/src/app/views/tests/standalone/HQ500.tsx @@ -155,7 +155,7 @@ export const HQ500 = () => { >