diff --git a/src/Map/cesiumIonDetection.test.ts b/src/Map/cesiumIonDetection.test.ts new file mode 100644 index 0000000..8c6aaa0 --- /dev/null +++ b/src/Map/cesiumIonDetection.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, test } from "vitest"; + +import { computeHasCesiumIonAsset } from "./cesiumIonDetection"; + +const makeSimple = (data?: object) => ({ + id: "layer1", + type: "simple" as const, + ...(data ? { data } : {}), +}); + +const makeGroup = (children: object[]) => ({ + id: "group1", + type: "group" as const, + children, +}); + +describe("computeHasCesiumIonAsset", () => { + describe("tiles", () => { + test("returns false when no tiles", () => { + expect(computeHasCesiumIonAsset({ tiles: [] })).toBe(false); + }); + + test("returns true for cesium_ion tile type", () => { + expect( + computeHasCesiumIonAsset({ tiles: [{ id: "", type: "cesium_ion" }] }), + ).toBe(true); + }); + + test("returns true for cesium_ion_default tile type", () => { + expect( + computeHasCesiumIonAsset({ + tiles: [{ id: "", type: "cesium_ion_default" }], + }), + ).toBe(true); + }); + + test("returns true for legacy tile types", () => { + for (const type of [ + "default", + "default_road", + "default_label", + "black_marble", + ]) { + expect(computeHasCesiumIonAsset({ tiles: [{ id: "", type }] })).toBe( + true, + ); + } + }); + + test("returns false for non-ion tile type", () => { + expect( + computeHasCesiumIonAsset({ + tiles: [{ id: "", type: "open_street_map" }], + }), + ).toBe(false); + }); + }); + + describe("terrain", () => { + test("returns true for cesium terrain type when enabled", () => { + expect( + computeHasCesiumIonAsset({ + terrain: { enabled: true, type: "cesium" }, + }), + ).toBe(true); + }); + + test("returns true for cesiumion terrain type when enabled", () => { + expect( + computeHasCesiumIonAsset({ + terrain: { enabled: true, type: "cesiumion" }, + }), + ).toBe(true); + }); + + test("returns false for cesium terrain when disabled", () => { + expect( + computeHasCesiumIonAsset({ + terrain: { enabled: false, type: "cesium" }, + }), + ).toBe(false); + }); + + test("returns true for ion terrain URL", () => { + expect( + computeHasCesiumIonAsset({ + terrain: { enabled: true, type: "url" }, + assets: { + cesium: { + terrain: { + ionUrl: "https://assets.ion.cesium.com/1/tileset.json", + }, + }, + }, + } as any), + ).toBe(true); + }); + + test("returns false for non-ion terrain URL", () => { + expect( + computeHasCesiumIonAsset({ + terrain: { enabled: true, type: "url" }, + assets: { + cesium: { terrain: { ionUrl: "https://example.com/terrain" } }, + }, + } as any), + ).toBe(false); + }); + }); + + describe("layers", () => { + test("returns true for osm-buildings layer", () => { + expect( + computeHasCesiumIonAsset(undefined, [ + makeSimple({ type: "osm-buildings" }), + ] as any), + ).toBe(true); + }); + + test("returns true for google-photorealistic with cesium-ion provider", () => { + expect( + computeHasCesiumIonAsset(undefined, [ + makeSimple({ type: "google-photorealistic", provider: "cesium-ion" }), + ] as any), + ).toBe(true); + }); + + test("returns false for google-photorealistic with reearth provider", () => { + expect( + computeHasCesiumIonAsset(undefined, [ + makeSimple({ type: "google-photorealistic", provider: "reearth" }), + ] as any), + ).toBe(false); + }); + + test("returns false for google-photorealistic with no provider (google API path)", () => { + expect( + computeHasCesiumIonAsset(undefined, [ + makeSimple({ type: "google-photorealistic" }), + ] as any), + ).toBe(false); + }); + + test("returns true for layer with ion URL", () => { + expect( + computeHasCesiumIonAsset(undefined, [ + makeSimple({ + type: "3dtiles", + url: "https://assets.ion.cesium.com/123/tileset.json", + }), + ] as any), + ).toBe(true); + }); + + test("returns true for any layer type with ion URL", () => { + expect( + computeHasCesiumIonAsset(undefined, [ + makeSimple({ + type: "geojson", + url: "https://assets.ion.cesium.com/456/data.json", + }), + ] as any), + ).toBe(true); + }); + + test("returns false for layer with non-ion URL", () => { + expect( + computeHasCesiumIonAsset(undefined, [ + makeSimple({ + type: "3dtiles", + url: "https://example.com/tileset.json", + }), + ] as any), + ).toBe(false); + }); + + test("returns false for layer with no data", () => { + expect(computeHasCesiumIonAsset(undefined, [makeSimple()] as any)).toBe( + false, + ); + }); + }); + + describe("layer groups (recursion)", () => { + test("returns true when nested layer uses ion", () => { + const group = makeGroup([makeSimple({ type: "osm-buildings" })]); + expect(computeHasCesiumIonAsset(undefined, [group] as any)).toBe(true); + }); + + test("returns false when nested layer does not use ion", () => { + const group = makeGroup([ + makeSimple({ type: "geojson", url: "https://example.com/data.json" }), + ]); + expect(computeHasCesiumIonAsset(undefined, [group] as any)).toBe(false); + }); + + test("returns true when deeply nested layer uses ion", () => { + const inner = makeGroup([makeSimple({ type: "osm-buildings" })]); + const outer = makeGroup([inner]); + expect(computeHasCesiumIonAsset(undefined, [outer] as any)).toBe(true); + }); + }); + + describe("combined", () => { + test("returns false when nothing uses ion", () => { + expect( + computeHasCesiumIonAsset( + { + tiles: [{ id: "", type: "open_street_map" }], + terrain: { enabled: false, type: "cesium" }, + }, + [ + makeSimple({ + type: "geojson", + url: "https://example.com/data.json", + }), + ] as any, + ), + ).toBe(false); + }); + + test("returns true when only tile uses ion", () => { + expect( + computeHasCesiumIonAsset({ tiles: [{ id: "", type: "default" }] }, [ + makeSimple({ type: "geojson" }), + ] as any), + ).toBe(true); + }); + + test("returns true when only terrain uses ion", () => { + expect( + computeHasCesiumIonAsset( + { terrain: { enabled: true, type: "cesium" } }, + [makeSimple({ type: "geojson" })] as any, + ), + ).toBe(true); + }); + + test("returns undefined-safe (no property, no layers)", () => { + expect(computeHasCesiumIonAsset()).toBe(false); + expect(computeHasCesiumIonAsset(undefined, undefined)).toBe(false); + expect(computeHasCesiumIonAsset(undefined, [])).toBe(false); + }); + }); +}); diff --git a/src/Map/cesiumIonDetection.ts b/src/Map/cesiumIonDetection.ts new file mode 100644 index 0000000..ad3290f --- /dev/null +++ b/src/Map/cesiumIonDetection.ts @@ -0,0 +1,55 @@ +import type { Layer, LayerSimple } from "../mantle"; + +import type { TileProperty, ViewerProperty } from "./types/viewerProperty"; + +const CESIUM_ION_URL_PATTERN = "ion.cesium.com"; +const CESIUM_ION_LEGACY_TILE_TYPES = new Set([ + "default", + "default_road", + "default_label", + "black_marble", +]); + +function isIonUrl(url?: string | null): boolean { + return !!url && url.includes(CESIUM_ION_URL_PATTERN); +} + +function tileUsesIon(tile: TileProperty): boolean { + if (!tile.type) return false; + if (tile.type.startsWith("cesium_ion")) return true; + if (CESIUM_ION_LEGACY_TILE_TYPES.has(tile.type)) return true; + return false; +} + +function terrainUsesIon(property?: ViewerProperty): boolean { + const terrain = property?.terrain; + if (!terrain?.enabled) return false; + if (terrain.type === "cesium" || terrain.type === "cesiumion") return true; + if (isIonUrl(property?.assets?.cesium?.terrain?.ionUrl)) return true; + return false; +} + +function layerUsesIon(layer: LayerSimple): boolean { + const data = layer.data; + if (!data) return false; + if (data.type === "osm-buildings") return true; + if (data.type === "google-photorealistic") { + return data.provider === "cesium-ion"; + } + if (isIonUrl(data.url)) return true; + return false; +} + +function anyLayerUsesIon(layer: Layer): boolean { + if (layer.type === "group") { + return layer.children.some(anyLayerUsesIon); + } + return layerUsesIon(layer); +} + +export function computeHasCesiumIonAsset(property?: ViewerProperty, layers?: Layer[]): boolean { + if (property?.tiles?.some(tileUsesIon)) return true; + if (terrainUsesIon(property)) return true; + if (layers?.some(anyLayerUsesIon)) return true; + return false; +} diff --git a/src/Map/index.tsx b/src/Map/index.tsx index 1325257..351a852 100644 --- a/src/Map/index.tsx +++ b/src/Map/index.tsx @@ -2,6 +2,7 @@ import { forwardRef, useMemo, type Ref, type JSX } from "react"; import { INTERACTION_MODES } from "../shared/interactionMode"; +import { computeHasCesiumIonAsset } from "./cesiumIonDetection"; import Geoid from "./Geoid"; import useHooks, { MapRef } from "./hooks"; import Layers, { type Props as LayersProps } from "./Layers"; @@ -44,7 +45,10 @@ export type Props = { | "selectedLayerId" | "viewerProperty" > & - Omit & + Omit< + EngineProps, + "onLayerSelect" | "layerSelectionReason" | "selectedLayerId" | "hasCesiumIonAsset" + > & Omit; function MapFn( @@ -102,6 +106,11 @@ function MapFn( onAPIReady, }); + const hasCesiumIonAsset = useMemo( + () => computeHasCesiumIonAsset(props.property, layers), + [props.property, layers], + ); + const selectedLayerIds = useMemo( () => ({ layerId: selectedLayer.layerId, @@ -125,7 +134,8 @@ function MapFn( onLayerSelect={handleEngineLayerSelect} featureFlags={featureFlags} onMount={handleEngineMount} - {...props}> + {...props} + hasCesiumIonAsset={hasCesiumIonAsset}> ; customProvider?: CustomProviderConfig; displayCredits?: boolean; + hasCesiumIonAsset?: boolean; layersRef?: RefObject; requestingRenderMode?: MutableRefObject; timelineManagerRef?: TimelineManagerRef; diff --git a/src/engines/Cesium/common.ts b/src/engines/Cesium/common.ts index 67f26ea..d37748e 100644 --- a/src/engines/Cesium/common.ts +++ b/src/engines/Cesium/common.ts @@ -905,7 +905,7 @@ export function getExtrudedHeight( return; } -export function getCredits(viewer: Viewer) { +export function getCredits(viewer: Viewer, hasCesiumIonAsset?: boolean) { if (!viewer) return emptyCredites; const creditDisplay = viewer.creditDisplay as | (CreditDisplay & { @@ -924,7 +924,14 @@ export function getCredits(viewer: Viewer) { const credits: Credits = { engine: { - cesium: cesiumCredits?.html ? { html: cesiumCredits.html } : undefined, + // Only include Cesium-ion credit when Ion assets are actually in use. + // hasCesiumIonAsset === false means explicitly no Ion assets; undefined preserves existing behavior. + cesium: + hasCesiumIonAsset === false + ? undefined + : cesiumCredits?.html + ? { html: cesiumCredits.html } + : undefined, }, lightbox: Array.from(lightboxCredits?._array ?? []).map(c => ({ html: c?.credit?.html, diff --git a/src/engines/Cesium/hooks.ts b/src/engines/Cesium/hooks.ts index 1820148..c66f3d0 100644 --- a/src/engines/Cesium/hooks.ts +++ b/src/engines/Cesium/hooks.ts @@ -92,6 +92,7 @@ export default ({ onCameraChange, onMount, onCreditsUpdate, + hasCesiumIonAsset, }: { ref: React.ForwardedRef; property?: ViewerProperty; @@ -133,6 +134,7 @@ export default ({ onCameraChange?: (camera: Camera) => void; onMount?: () => void; onCreditsUpdate?: (credits: Credits) => void; + hasCesiumIonAsset?: boolean; }) => { const cesium = useRef>(null); @@ -141,8 +143,11 @@ export default ({ ? meta.cesiumIonAccessToken : undefined; + const hasCesiumIonAssetRef = useRef(hasCesiumIonAsset); + hasCesiumIonAssetRef.current = hasCesiumIonAsset; + // expose ref - const engineAPI = useEngineRef(ref, cesium); + const engineAPI = useEngineRef(ref, cesium, hasCesiumIonAssetRef); const layerSelectWithRectEventHandlers = useLayerSelectWithRect({ cesium, @@ -667,7 +672,7 @@ export default ({ if (!onCreditsUpdateRef.current) return; const viewer = cesium.current?.cesiumElement; if (!viewer || viewer.isDestroyed()) return; - const credits = getCredits(viewer); + const credits = getCredits(viewer, hasCesiumIonAssetRef.current); onCreditsUpdateRef.current(credits); }, 3000); }, []); diff --git a/src/engines/Cesium/hooks/useEngineRef.ts b/src/engines/Cesium/hooks/useEngineRef.ts index 819f920..3dd4d22 100644 --- a/src/engines/Cesium/hooks/useEngineRef.ts +++ b/src/engines/Cesium/hooks/useEngineRef.ts @@ -51,6 +51,7 @@ import { export default function useEngineRef( ref: Ref, cesium: RefObject | null>, + hasCesiumIonAssetRef?: RefObject, ): EngineRef { const cancelCameraFlight = useRef<() => void>(undefined); const mouseEventCallbacks = useRef({ @@ -983,10 +984,10 @@ export default function useEngineRef( getCredits: () => { const viewer = cesium.current?.cesiumElement; if (!viewer || viewer.isDestroyed()) return; - return getCredits(viewer); + return getCredits(viewer, hasCesiumIonAssetRef?.current); }, }; - }, [cesium]); + }, [cesium, hasCesiumIonAssetRef]); useImperativeHandle(ref, () => e, [e]); diff --git a/src/engines/Cesium/index.tsx b/src/engines/Cesium/index.tsx index 6a920e1..022cfec 100644 --- a/src/engines/Cesium/index.tsx +++ b/src/engines/Cesium/index.tsx @@ -49,6 +49,7 @@ const Cesium: React.ForwardRefRenderFunction = ( meta, customProvider, displayCredits, + hasCesiumIonAsset, layersRef, featureFlags, requestingRenderMode, @@ -119,6 +120,7 @@ const Cesium: React.ForwardRefRenderFunction = ( onCameraChange, onMount, onCreditsUpdate, + hasCesiumIonAsset, }); const verticalExaggeration = property?.terrain?.enabled