diff --git a/.vscode/settings.json b/.vscode/settings.json index a94a81e6..86fbabd5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { "cSpell.words": [ "RMWC" - ] + ], + "files.eol": "\n" } \ No newline at end of file diff --git a/packages/react-components/src/components/cesium-map/active-layers/active-layers-panel.tsx b/packages/react-components/src/components/cesium-map/active-layers/active-layers-panel.tsx index cff2b789..71fe862c 100644 --- a/packages/react-components/src/components/cesium-map/active-layers/active-layers-panel.tsx +++ b/packages/react-components/src/components/cesium-map/active-layers/active-layers-panel.tsx @@ -64,7 +64,7 @@ export const ActiveLayersPanel: React.FC = ({ locale }) { id: IMAGERY, values: [] }, { id: SERVICE, values: [] }, { id: DATA, values: [] }, - { id: THREE_D, values: [] } + { id: THREE_D, values: [] }, ]); const [collapsedSections, setCollapsedSections] = useState>({}); @@ -79,47 +79,54 @@ export const ActiveLayersPanel: React.FC = ({ locale }) const getImageryLayers = (): IActiveLayer[] => { const layerList = getLayerList(); return layerList.length > 0 - ? layerList.map((layer): IActiveLayer | undefined => { - const meta = get(layer, 'meta'); - const layerId = getLayerId(layer); - if (!isManagedImageryLayer(layerId)) { - return undefined; - } - return { - id: layerId as string, - name: (getLayerName(layer) ?? layerId) as string, - rect: layer.rectangle, - isDisabled: isBaseMapLayer(meta as Record) - }; - }).filter((item): item is IActiveLayer => item !== undefined) + ? layerList + .map((layer): IActiveLayer | undefined => { + const meta = get(layer, 'meta'); + const layerId = getLayerId(layer); + if (!isManagedImageryLayer(layerId)) { + return undefined; + } + return { + id: layerId as string, + name: (getLayerName(layer) ?? layerId) as string, + rect: layer.rectangle, + isDisabled: isBaseMapLayer(meta as Record), + }; + }) + .filter((item): item is IActiveLayer => item !== undefined) : []; }; const getServiceLayers = (): IActiveLayer[] => { const layerList = getLayerList(); return layerList.length > 0 - ? layerList.map((layer, i): IActiveLayer | undefined => { - if (!isServiceLayer(getLayerId(layer))) { - return undefined; - } - return { - id: getServiceLayerId(layer, i), - name: getServiceLayerName(layer, i), - rect: layer.rectangle, - isDisabled: true - }; - }).filter((item): item is IActiveLayer => item !== undefined) + ? layerList + .map((layer, i): IActiveLayer | undefined => { + if (!isServiceLayer(getLayerId(layer))) { + return undefined; + } + return { + id: getServiceLayerId(layer, i), + name: getServiceLayerName(layer, i), + rect: layer.rectangle, + isDisabled: true, + }; + }) + .filter((item): item is IActiveLayer => item !== undefined) : []; }; const getDataLayers = (): IActiveLayer[] => { - return mapViewer.layersManager?.dataLayerList.map((dataLayer) => { - return { - id: getLayerId(dataLayer) as string, - name: (getDataLayerName(dataLayer.meta) ?? getLayerName(dataLayer)) as string, - rect: Rectangle.fromDegrees(...bbox(getLayerFootprint(dataLayer.meta))), - isDisabled: false - }; }) || []; + return ( + mapViewer.layersManager?.dataLayerList.map((dataLayer) => { + return { + id: getLayerId(dataLayer) as string, + name: (getDataLayerName(dataLayer.meta) ?? getLayerName(dataLayer)) as string, + rect: Rectangle.fromDegrees(...bbox(getLayerFootprint(dataLayer.meta))), + isDisabled: false, + }; + }) || [] + ); }; const get3DModels = (): IActiveLayer[] => { @@ -167,7 +174,9 @@ export const ActiveLayersPanel: React.FC = ({ locale }) }, []); useEffect(() => { - if (!mapViewer.layersManager) { return; } + if (!mapViewer.layersManager) { + return; + } const handleLayerEvent = (): void => { refreshSections(); }; @@ -186,14 +195,16 @@ export const ActiveLayersPanel: React.FC = ({ locale }) }, [mapViewer.layersManager]); useEffect(() => { - if (!mapViewer.layersManager) { return; } + if (!mapViewer.layersManager) { + return; + } const handleDataLayerEvent = (): void => { setSections((prev) => prev.map((item) => item.id === DATA ? { ...item, - values: getDataLayers() + values: getDataLayers(), } : item ) @@ -206,14 +217,16 @@ export const ActiveLayersPanel: React.FC = ({ locale }) }, [mapViewer.layersManager?.dataLayerList]); useEffect(() => { - if (!mapViewer.layersManager) { return; } + if (!mapViewer.layersManager) { + return; + } const handle3DModelEvent = (): void => { setSections((prev) => prev.map((item) => item.id === THREE_D ? { ...item, - values: get3DModels() + values: get3DModels(), } : item ) @@ -241,8 +254,9 @@ export const ActiveLayersPanel: React.FC = ({ locale }) return ( - { - sections.filter(item => item.values.length > 0).map((section) => ( + {sections + .filter((item) => item.values.length > 0) + .map((section) => ( = ({ locale }) {getLabel(section.id)} - { - section.values.map((activeLayer: IActiveLayer) => ( - - - {activeLayer.name} + {section.values.map((activeLayer: IActiveLayer) => ( + + + + {activeLayer.name} + + + + + { + event.stopPropagation(); + handleFlyTo(activeLayer); + }} + > + + + + + - - - { event.stopPropagation(); handleFlyTo(activeLayer); }}> - - - - - - - {/* + {/* { event.stopPropagation(); }}> */} - - )) - } + + ))} - )) - } + ))} ); }; diff --git a/packages/react-components/src/components/cesium-map/geocoder/geocoder-panel.tsx b/packages/react-components/src/components/cesium-map/geocoder/geocoder-panel.tsx index 7c3ecb56..eebaaa37 100644 --- a/packages/react-components/src/components/cesium-map/geocoder/geocoder-panel.tsx +++ b/packages/react-components/src/components/cesium-map/geocoder/geocoder-panel.tsx @@ -49,7 +49,7 @@ type RequestResult = { status: number; url: string; headers: Record; -} +}; export const GeocoderPanel: React.FC = ({ options, isOpen, locale }) => { const mapViewer = useCesiumMap(); @@ -213,55 +213,61 @@ export const GeocoderPanel: React.FC = ({ options, isOpen, l [mapViewer] ); - const fetchData = useCallback(async (text: string, isInMapExtent: boolean) => { - if (!text) { - setSearchResults([]); - return; - } - const queryPromises = options.map(async (option) => { - if (option.url) { - const url = buildQueryParams(option.url, option.params, text, isInMapExtent); - return fetch(url, { - method: 'GET', - }); - } else { - return Promise.reject({ - message: "No URL provided. Please provide one", - }); + const fetchData = useCallback( + async (text: string, isInMapExtent: boolean) => { + if (!text) { + setSearchResults([]); + return; } - }); - const rawResponses = await Promise.all(queryPromises); - const parsedResponses = await Promise.all( - rawResponses - .filter((res): res is Response => res !== undefined) - .map(async (res) => { - const body = await res.json(); - body.features = body.features?.map((feat: Feature) => { - return { - ...feat, - properties: { - ...feat.properties, - headers: res.headers - } - } as Feature; + const queryPromises = options.map(async (option) => { + if (option.url) { + const url = buildQueryParams(option.url, option.params, text, isInMapExtent); + return fetch(url, { + method: 'GET', + }); + } else { + return Promise.reject({ + message: 'No URL provided. Please provide one', }); + } + }); + const rawResponses = await Promise.all(queryPromises); + const parsedResponses = await Promise.all( + rawResponses + .filter((res): res is Response => res !== undefined) + .map(async (res) => { + const body = await res.json(); + body.features = body.features?.map((feat: Feature) => { + return { + ...feat, + properties: { + ...feat.properties, + headers: res.headers, + }, + } as Feature; + }); - return { - body, - status: res.status, - url: res.url, - headers: Object.fromEntries(res.headers.entries()), - }; - }) - ); + return { + body, + status: res.status, + url: res.url, + headers: Object.fromEntries(res.headers.entries()), + }; + }) + ); - setSearchResults(parsedResponses); - }, [buildQueryParams, options]); + setSearchResults(parsedResponses); + }, + [buildQueryParams, options] + ); - const debouncedSearch = useMemo(() => - debounce((value: string, isInMapExtent: boolean) => { - fetchData(value, isInMapExtent); - }, DEFAULT_DEBOUNCE), [fetchData]); + const debouncedSearch = useMemo( + () => + debounce((value: string, isInMapExtent: boolean) => { + fetchData(value, isInMapExtent); + }, DEFAULT_DEBOUNCE), + [fetchData] + ); useEffect(() => { return () => debouncedSearch.cancel(); @@ -290,14 +296,28 @@ export const GeocoderPanel: React.FC = ({ options, isOpen, l case 'LineString': typedIcon = ( - + ); break; default: typedIcon = ( - + ); @@ -384,7 +404,11 @@ export const GeocoderPanel: React.FC = ({ options, isOpen, l } else if (featuresLength === 0) { return {noResults}; } else if (message) { - return {message}; + return ( + + {message} + + ); } else { return ; } diff --git a/packages/react-components/src/components/cesium-map/helpers/utils.ts b/packages/react-components/src/components/cesium-map/helpers/utils.ts index 460352dd..46fe9b6f 100644 --- a/packages/react-components/src/components/cesium-map/helpers/utils.ts +++ b/packages/react-components/src/components/cesium-map/helpers/utils.ts @@ -30,11 +30,12 @@ import intersect from '@turf/intersect'; import pointToPolygonDistance from '@turf/point-to-polygon-distance'; import { ICesiumWFSLayerLabelingOptions } from '../layers'; import { CesiumViewer } from '../map'; -import { CesiumMath, CesiumRectangle } from '../proxied.types'; +import { CesiumCartesian2, CesiumCartographic, CesiumMath, CesiumRectangle } from '../proxied.types'; import { CustomImageryProvider } from './customImageryProviders'; const canvasElem = document.createElement('canvas'); const canvasCtx = canvasElem.getContext('2d'); +const FALLBACK_GRID_SIZE = 5; /** * Checks if image data has at least one transparent pixel. @@ -193,26 +194,73 @@ export const rectangle2Feature = (rect: Rectangle): Feature => { }; }; -export const customComputeViewRectangle = (mapViewer: CesiumViewer) => { - const scene = mapViewer.scene; - const camera = mapViewer.camera; +// camera.computeViewRectangle() ray-casts only the 4 screen corners against the ellipsoid. At +// high camera altitude combined with a wide field of view, those corner rays can point past the +// horizon into space even though most of the screen still shows the globe - this happens after +// flying to a layer whose footprint spans a wide area (e.g. a footprint with far-apart parts, +// which needs a much higher altitude to fit in view than a compact one). When that happens, fall +// back to sampling a grid of points across the screen and keeping whichever ones actually hit the +// ellipsoid, since interior points are far less likely to miss than the corners. +export const customComputeViewRectangle = (mapViewer: CesiumViewer, gridSize = FALLBACK_GRID_SIZE): Rectangle | undefined => { + const { scene, camera } = mapViewer; + const ellipsoid = scene.globe.ellipsoid; + + const nativeRect = camera.computeViewRectangle(ellipsoid); + if (defined(nativeRect) && nativeRect) { + return nativeRect; + } - let viewRect = camera.computeViewRectangle(scene.globe.ellipsoid); + const width = scene.canvas.clientWidth; + const height = scene.canvas.clientHeight; + + const samples: CesiumCartographic[] = []; + for (let y = 0; y < gridSize; y++) { + const sy = (y / (gridSize - 1)) * height; + for (let x = 0; x < gridSize; x++) { + const sx = (x / (gridSize - 1)) * width; + const windowPosition = new CesiumCartesian2(sx, sy); + const ray = camera.getPickRay(windowPosition); + if (!ray) { + continue; + } + const cartesian = scene.globe.pick(ray, scene) ?? camera.pickEllipsoid(windowPosition, ellipsoid); + if (!cartesian) { + continue; + } + samples.push(CesiumCartographic.fromCartesian(cartesian, ellipsoid)); + } + } - if (!defined(viewRect) || !viewRect) { - console.error('cesium native computeViewRectangle returned invalid rectangle, fallback to custom calculation '); - const cl2 = new Cartesian2(0, 0); - const leftTop = scene.camera.pickEllipsoid(cl2, scene.globe.ellipsoid); + if (samples.length === 0) { + return undefined; + } - const cr2 = new Cartesian2(scene.canvas.width, scene.canvas.height); - const rightDown = scene.camera.pickEllipsoid(cr2, scene.globe.ellipsoid); + let west = Number.POSITIVE_INFINITY; + let east = Number.NEGATIVE_INFINITY; + let south = Number.POSITIVE_INFINITY; + let north = Number.NEGATIVE_INFINITY; + for (const { longitude, latitude } of samples) { + west = Math.min(west, longitude); + east = Math.max(east, longitude); + south = Math.min(south, latitude); + north = Math.max(north, latitude); + } - const cartoLeftTop = scene.globe.ellipsoid.cartesianToCartographic(leftTop as Cartesian3); - const cartoRightDown = scene.globe.ellipsoid.cartesianToCartographic(rightDown as Cartesian3); - viewRect = new Rectangle(cartoLeftTop.longitude, cartoRightDown.latitude, cartoRightDown.longitude, cartoLeftTop.latitude); + // Handle views crossing the antimeridian: re-derive west/east in a longitude space shifted so + // the true (contiguous) cluster of samples isn't split across the +-Pi wrap point. + if (east - west > Math.PI) { + west = Number.POSITIVE_INFINITY; + east = Number.NEGATIVE_INFINITY; + for (const { longitude } of samples) { + const shiftedLongitude = longitude < 0 ? longitude + CesiumMath.TWO_PI : longitude; + west = Math.min(west, shiftedLongitude); + east = Math.max(east, shiftedLongitude); + } + west = CesiumMath.negativePiToPi(west); + east = CesiumMath.negativePiToPi(east); } - return viewRect; + return new Rectangle(west, south, east, north); }; /** diff --git a/packages/react-components/src/components/cesium-map/layers-manager.ts b/packages/react-components/src/components/cesium-map/layers-manager.ts index 07a11430..c9f1b954 100644 --- a/packages/react-components/src/components/cesium-map/layers-manager.ts +++ b/packages/react-components/src/components/cesium-map/layers-manager.ts @@ -8,6 +8,8 @@ import { Event, Rectangle, SingleTileImageryProvider, + TileCoordinatesImageryProvider, + OpenStreetMapImageryProvider, } from 'cesium'; import { get, isEmpty, set } from 'lodash'; import { Feature, Point, Polygon } from 'geojson'; @@ -19,7 +21,7 @@ import { HAS_TRANSPARENCY_META_PROP, } from './helpers/customImageryProviders'; import { pointToGeoJSON } from './helpers/geojson/point.geojson'; -import { cesiumRectangleContained } from './helpers/utils'; +import { cesiumRectangleContained, customComputeViewRectangle } from './helpers/utils'; import { RCesiumOSMLayerOptions, RCesiumWMSLayerOptions, RCesiumWMTSLayerOptions, RCesiumXYZLayerOptions } from './layers'; import type { ICesiumWFSLayer, ICesiumWFSLayerMeta } from './layers/wfs.layer'; import { IMapLegend } from './legend'; @@ -170,8 +172,19 @@ export const getImageryProviderUrl = (layer: ICesiumImageryLayer): string | unde return get(layer, '_imageryProvider._resource._url'); }; +type ImageryProviderConstructor = new (...args: never[]) => CesiumImageryProvider; +const KNOWN_IMAGERY_PROVIDER_TYPES: Array<{ ctor: ImageryProviderConstructor; name: string }> = [ + { ctor: TileCoordinatesImageryProvider, name: 'TileCoordinatesImageryProvider' }, + { ctor: UrlTemplateImageryProvider, name: 'UrlTemplateImageryProvider' }, + { ctor: WebMapServiceImageryProvider, name: 'WebMapServiceImageryProvider' }, + { ctor: WebMapTileServiceImageryProvider, name: 'WebMapTileServiceImageryProvider' }, + { ctor: SingleTileImageryProvider, name: 'SingleTileImageryProvider' }, + { ctor: OpenStreetMapImageryProvider, name: 'OpenStreetMapImageryProvider' }, +]; + export const getImageryProviderName = (provider: CesiumImageryProvider): string => { - return provider.constructor.name; + const knownType = KNOWN_IMAGERY_PROVIDER_TYPES.find(({ ctor }) => provider instanceof ctor); + return knownType?.name ?? provider.constructor.name; }; class LayerManager { @@ -653,8 +666,12 @@ class LayerManager { private addDrapingOverlaysForModel(model: ICesium3DModel): void { for (const layer of this.layers) { - if (!layer.meta) { continue; } - if (!this.drapingLayerPredicate!(layer.meta as ICesiumImageryLayerMeta)) { continue; } + if (!layer.meta) { + continue; + } + if (!this.drapingLayerPredicate!(layer.meta as ICesiumImageryLayerMeta)) { + continue; + } const provider = layer.imageryProvider; const overlayLayer = new ImageryLayer(provider); this.applyDrapingOverlayConfig(overlayLayer, layer); @@ -666,7 +683,9 @@ class LayerManager { } private addDrapingOverlaysByLayer(layer: ICesiumImageryLayer): void { - if (this.models.length === 0) { return; } + if (this.models.length === 0) { + return; + } const provider = layer.imageryProvider; const overlays: { tileset: CesiumTileset; overlay: ImageryLayer }[] = []; for (const model of this.models) { @@ -686,8 +705,12 @@ class LayerManager { const newZIndex = (layer.meta?.zIndex as number | undefined) ?? 0; let index = 0; for (const [existingLayer, existingOverlays] of this.layerToOverlaysMapping.entries()) { - if (!isBaseMapLayer(existingLayer.meta)) { continue; } - if (!existingOverlays.some(({ tileset }) => tileset === model.tileset)) { continue; } + if (!isBaseMapLayer(existingLayer.meta)) { + continue; + } + if (!existingOverlays.some(({ tileset }) => tileset === model.tileset)) { + continue; + } const existingZIndex = (existingLayer.meta?.zIndex as number | undefined) ?? 0; if (existingZIndex < newZIndex) { index++; @@ -704,7 +727,9 @@ class LayerManager { private removeDrapingOverlaysByLayer(layer: ICesiumImageryLayer): void { const overlays = this.layerToOverlaysMapping.get(layer); - if (!overlays) { return; } + if (!overlays) { + return; + } for (const { tileset, overlay } of overlays) { if (!tileset.isDestroyed()) { tileset.imageryLayers.remove(overlay, true); @@ -861,8 +886,11 @@ class LayerManager { return; } try { - const extent = this.mapViewer.camera.computeViewRectangle() as Rectangle; + const extent = customComputeViewRectangle(this.mapViewer) as Rectangle; if (isEmpty(extent)) { + for (const layer of this.layers) { + layer.meta = { ...(layer.meta ?? {}), isRelevantToExtent: true }; + } return; } // Iterating in reverse order so that top layer is first diff --git a/packages/react-components/src/components/cesium-map/layers/3d.tileset.with.update.tsx b/packages/react-components/src/components/cesium-map/layers/3d.tileset.with.update.tsx index aaef90a0..ad7f9e3d 100644 --- a/packages/react-components/src/components/cesium-map/layers/3d.tileset.with.update.tsx +++ b/packages/react-components/src/components/cesium-map/layers/3d.tileset.with.update.tsx @@ -59,15 +59,17 @@ export const Cesium3DTilesetWithUpdate: React.FC = ( const normal = scene.globe.ellipsoid.geodeticSurfaceNormal(center, new Cartesian3()); const offset = Cartesian3.multiplyByScalar(normal, height, new Cartesian3()); const carto = Cartographic.fromCartesian(center); - void sampleTerrainMostDetailed(scene.terrainProvider, [carto]).then((results) => { - const result = results[0]; - return defined(result) ? result : carto; - }).then((result) => { - const resultCartesian = Cartographic.toCartesian(result as Cartographic); - const position = Cartesian3.subtract(resultCartesian, offset, new Cartesian3()); - // @ts-ignore - model._rtcCenter = Cartesian3.clone(position, model._rtcCenter); - }); + void sampleTerrainMostDetailed(scene.terrainProvider, [carto]) + .then((results) => { + const result = results[0]; + return defined(result) ? result : carto; + }) + .then((result) => { + const resultCartesian = Cartographic.toCartesian(result as Cartographic); + const position = Cartesian3.subtract(resultCartesian, offset, new Cartesian3()); + // @ts-ignore + model._rtcCenter = Cartesian3.clone(position, model._rtcCenter); + }); }; const updateTile = (ts: Cesium3DTileset, tile: Cesium3DTile): void => { diff --git a/packages/react-components/src/components/cesium-map/tools/scale-tracker.tool.tsx b/packages/react-components/src/components/cesium-map/tools/scale-tracker.tool.tsx index 91ea7671..b9f78b8a 100644 --- a/packages/react-components/src/components/cesium-map/tools/scale-tracker.tool.tsx +++ b/packages/react-components/src/components/cesium-map/tools/scale-tracker.tool.tsx @@ -133,8 +133,7 @@ export const ScaleTrackerTool: React.FC = (props) => { return (
- { - isNumber(scaleData.barWidth) && + {isNumber(scaleData.barWidth) && ( <>
{scaleData.distanceLabel} @@ -148,7 +147,7 @@ export const ScaleTrackerTool: React.FC = (props) => { }} /> - } + )}
); };