feat(module-studio): add configurable map page
This commit is contained in:
@@ -0,0 +1,572 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
Cartesian2,
|
||||
Cartesian3,
|
||||
BingMapsImageryProvider,
|
||||
BingMapsStyle,
|
||||
Color,
|
||||
Credit,
|
||||
Cesium3DTileset,
|
||||
Cesium3DTileStyle,
|
||||
CesiumTerrainProvider,
|
||||
CustomDataSource,
|
||||
DefaultProxy,
|
||||
DistanceDisplayCondition,
|
||||
EllipsoidTerrainProvider,
|
||||
Entity,
|
||||
HeadingPitchRange,
|
||||
ImageryLayer,
|
||||
JulianDate,
|
||||
Matrix4,
|
||||
Math as CesiumMath,
|
||||
PolygonHierarchy,
|
||||
Resource,
|
||||
ScreenSpaceEventHandler,
|
||||
ScreenSpaceEventType,
|
||||
SunLight,
|
||||
Viewer,
|
||||
} from "cesium";
|
||||
import "cesium/Build/Cesium/Widgets/widgets.css";
|
||||
import sceneFixture from "../../../registry/fixtures/map/map-operational-v0.1.json";
|
||||
|
||||
type Position = [number, number, number?];
|
||||
type RuntimeConfig = {
|
||||
cesiumVersion: string;
|
||||
provider: string;
|
||||
ionReady: boolean;
|
||||
gatewayReady: boolean;
|
||||
osmBuildingsReady: boolean;
|
||||
gaussianSplatsReady: boolean;
|
||||
gaussianAssetId?: string | null;
|
||||
assetEndpointBase: string;
|
||||
resourceProxyBase?: string | null;
|
||||
gatewayHealthUrl?: string | null;
|
||||
};
|
||||
|
||||
export type MapGatewayHealth = {
|
||||
cache?: { mode?: string; entries?: number; bytes?: number; maxBytes?: number; persistent?: boolean };
|
||||
ionConfigured?: boolean;
|
||||
};
|
||||
|
||||
type IonAssetEndpoint = {
|
||||
assetId: string;
|
||||
type: "TERRAIN" | "3DTILES" | "IMAGERY";
|
||||
url?: string;
|
||||
accessToken?: string;
|
||||
externalType?: "BING";
|
||||
options?: { url?: string; key?: string; mapStyle?: string };
|
||||
attributions: Array<{ html?: string; collapsible?: boolean }>;
|
||||
};
|
||||
|
||||
export type MapPresentation = {
|
||||
imagerySource: "cesium-live";
|
||||
imageryVisible: boolean;
|
||||
cacheEnabled: boolean;
|
||||
terrainEnabled: boolean;
|
||||
terrainExaggeration: number;
|
||||
monochrome: boolean;
|
||||
monochromeColor: string;
|
||||
imageryGamma: number;
|
||||
imageryHue: number;
|
||||
imageryAlpha: number;
|
||||
globeColor: string;
|
||||
backgroundColor: string;
|
||||
atmosphereEnabled: boolean;
|
||||
atmosphereHue: number;
|
||||
atmosphereSaturation: number;
|
||||
atmosphereBrightness: number;
|
||||
fogEnabled: boolean;
|
||||
fogDensity: number;
|
||||
sunEnabled: boolean;
|
||||
sunHour: number;
|
||||
sunIntensity: number;
|
||||
shadowsEnabled: boolean;
|
||||
buildingsVisible: boolean;
|
||||
buildingsColor: string;
|
||||
buildingsOpacity: number;
|
||||
buildingsDetail: number;
|
||||
imageryBrightness: number;
|
||||
imageryContrast: number;
|
||||
imagerySaturation: number;
|
||||
gridVisible: boolean;
|
||||
gridLodEnabled: boolean;
|
||||
gridHeightMeters: number;
|
||||
gridLod1MaxHeightKm: number;
|
||||
gridLod1StepKm: number;
|
||||
gridLod2MaxHeightKm: number;
|
||||
gridLod2StepKm: number;
|
||||
gridLod3StepKm: number;
|
||||
gridRadiusKm: number;
|
||||
gridLineWidth: number;
|
||||
gridColor: string;
|
||||
gridOpacity: number;
|
||||
gridDotsEnabled: boolean;
|
||||
gridDotsSize: number;
|
||||
gridDotsColor: string;
|
||||
gridDotsOpacity: number;
|
||||
cacheRefresh: boolean;
|
||||
};
|
||||
|
||||
export type MapCameraView = {
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
height: number;
|
||||
heading: number;
|
||||
pitch: number;
|
||||
roll: number;
|
||||
};
|
||||
|
||||
const toCartesian = ([longitude, latitude, height = 0]: Position) => Cartesian3.fromDegrees(longitude, latitude, height);
|
||||
const toCartesianArray = (positions: Position[]) => positions.map(toCartesian);
|
||||
const accent = Color.fromCssColorString("#ff2f92");
|
||||
const violet = Color.fromCssColorString("#8f72dc");
|
||||
const clamp = (value: number, minimum: number, maximum: number) => Math.max(minimum, Math.min(maximum, value));
|
||||
|
||||
function getCameraView(viewer: Viewer): MapCameraView {
|
||||
const position = viewer.camera.positionCartographic;
|
||||
return {
|
||||
longitude: CesiumMath.toDegrees(position.longitude),
|
||||
latitude: CesiumMath.toDegrees(position.latitude),
|
||||
height: position.height,
|
||||
heading: viewer.camera.heading,
|
||||
pitch: viewer.camera.pitch,
|
||||
roll: viewer.camera.roll,
|
||||
};
|
||||
}
|
||||
|
||||
function addFixtureEntities(viewer: Viewer) {
|
||||
for (const place of sceneFixture.scene.places) {
|
||||
viewer.entities.add({
|
||||
id: place.id,
|
||||
position: toCartesian(place.position as Position),
|
||||
label: {
|
||||
text: place.label.text,
|
||||
font: "700 28px Arial",
|
||||
fillColor: Color.WHITE,
|
||||
outlineColor: Color.BLACK.withAlpha(0.8),
|
||||
outlineWidth: 4,
|
||||
style: 2,
|
||||
distanceDisplayCondition: new DistanceDisplayCondition(100_000, 50_000_000),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const route of sceneFixture.scene.routes) {
|
||||
viewer.entities.add({
|
||||
id: route.id,
|
||||
polyline: {
|
||||
positions: toCartesianArray(route.coordinates as Position[]),
|
||||
width: 4,
|
||||
material: accent.withAlpha(0.92),
|
||||
clampToGround: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const track of sceneFixture.scene.tracks) {
|
||||
viewer.entities.add({
|
||||
id: track.id,
|
||||
polyline: {
|
||||
positions: toCartesianArray(track.coordinates as Position[]),
|
||||
width: 2,
|
||||
material: violet.withAlpha(0.88),
|
||||
clampToGround: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const zone of sceneFixture.scene.zones) {
|
||||
const ring = zone.geometry.type === "Polygon" ? zone.geometry.coordinates[0] : zone.geometry.coordinates[0][0];
|
||||
viewer.entities.add({
|
||||
id: zone.id,
|
||||
polygon: {
|
||||
hierarchy: new PolygonHierarchy(toCartesianArray(ring as Position[])),
|
||||
material: accent.withAlpha(0.2),
|
||||
},
|
||||
});
|
||||
viewer.entities.add({
|
||||
id: `${zone.id}:boundary`,
|
||||
polyline: {
|
||||
positions: toCartesianArray(ring as Position[]),
|
||||
width: 2,
|
||||
material: accent.withAlpha(0.78),
|
||||
clampToGround: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const object of sceneFixture.scene.movingObjects) {
|
||||
if (object.trace) {
|
||||
viewer.entities.add({
|
||||
id: `${object.id}:trace`,
|
||||
polyline: {
|
||||
positions: toCartesianArray(object.trace as Position[]),
|
||||
width: 2,
|
||||
material: accent.withAlpha(0.52),
|
||||
clampToGround: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
viewer.entities.add({
|
||||
id: object.id,
|
||||
position: toCartesian(object.position as Position),
|
||||
point: { pixelSize: 18, color: accent, outlineColor: Color.WHITE, outlineWidth: 3 },
|
||||
label: {
|
||||
text: object.label.text,
|
||||
font: "700 13px Arial",
|
||||
fillColor: Color.WHITE,
|
||||
showBackground: true,
|
||||
backgroundColor: Color.BLACK.withAlpha(0.72),
|
||||
backgroundPadding: new Cartesian2(10, 7),
|
||||
pixelOffset: new Cartesian2(0, -31),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const station of sceneFixture.scene.stations) {
|
||||
viewer.entities.add({
|
||||
id: station.id,
|
||||
position: toCartesian(station.position as Position),
|
||||
point: { pixelSize: station.stationType === "metro" ? 21 : 18, color: violet, outlineColor: Color.WHITE, outlineWidth: 3 },
|
||||
label: {
|
||||
text: station.label.text,
|
||||
font: "700 14px Arial",
|
||||
fillColor: Color.WHITE,
|
||||
showBackground: true,
|
||||
backgroundColor: Color.BLACK.withAlpha(0.72),
|
||||
backgroundPadding: new Cartesian2(10, 7),
|
||||
pixelOffset: new Cartesian2(0, -33),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function rebuildElevatedGrid(viewer: Viewer, dataSource: CustomDataSource, presentation: MapPresentation) {
|
||||
const entities = dataSource.entities;
|
||||
entities.removeAll();
|
||||
if (!presentation.gridVisible) return;
|
||||
|
||||
const cameraHeightKm = Math.max(0, Number(viewer.camera.positionCartographic?.height || 0) / 1000);
|
||||
const stepKm = !presentation.gridLodEnabled || cameraHeightKm <= presentation.gridLod1MaxHeightKm
|
||||
? presentation.gridLod1StepKm
|
||||
: cameraHeightKm <= presentation.gridLod2MaxHeightKm
|
||||
? presentation.gridLod2StepKm
|
||||
: presentation.gridLod3StepKm;
|
||||
const safeStepKm = clamp(stepKm, 0.25, 100);
|
||||
const safeRadiusKm = clamp(presentation.gridRadiusKm, safeStepKm, 150);
|
||||
const stepsPerSide = Math.min(32, Math.max(1, Math.floor(safeRadiusKm / safeStepKm)));
|
||||
const center = sceneFixture.viewport.center as Position;
|
||||
const latitude = center[1];
|
||||
const longitude = center[0];
|
||||
const metersPerLatitudeDegree = 110_574;
|
||||
const metersPerLongitudeDegree = Math.max(1, 111_320 * Math.cos(CesiumMath.toRadians(latitude)));
|
||||
const stepMeters = safeStepKm * 1000;
|
||||
const radiusMeters = stepsPerSide * stepMeters;
|
||||
const deltaLatitude = stepMeters / metersPerLatitudeDegree;
|
||||
const deltaLongitude = stepMeters / metersPerLongitudeDegree;
|
||||
const radiusLatitude = radiusMeters / metersPerLatitudeDegree;
|
||||
const radiusLongitude = radiusMeters / metersPerLongitudeDegree;
|
||||
const lineColor = Color.fromCssColorString(presentation.gridColor).withAlpha(clamp(presentation.gridOpacity / 100, 0, 1));
|
||||
const dotColor = Color.fromCssColorString(presentation.gridDotsColor).withAlpha(clamp(presentation.gridDotsOpacity / 100, 0, 1));
|
||||
const elevation = Math.max(0, presentation.gridHeightMeters);
|
||||
|
||||
for (let index = -stepsPerSide; index <= stepsPerSide; index += 1) {
|
||||
const nextLatitude = latitude + index * deltaLatitude;
|
||||
const nextLongitude = longitude + index * deltaLongitude;
|
||||
entities.add({
|
||||
polyline: {
|
||||
positions: [
|
||||
Cartesian3.fromDegrees(longitude - radiusLongitude, nextLatitude, elevation),
|
||||
Cartesian3.fromDegrees(longitude + radiusLongitude, nextLatitude, elevation),
|
||||
],
|
||||
width: clamp(presentation.gridLineWidth, 1, 8),
|
||||
material: lineColor,
|
||||
},
|
||||
});
|
||||
entities.add({
|
||||
polyline: {
|
||||
positions: [
|
||||
Cartesian3.fromDegrees(nextLongitude, latitude - radiusLatitude, elevation),
|
||||
Cartesian3.fromDegrees(nextLongitude, latitude + radiusLatitude, elevation),
|
||||
],
|
||||
width: clamp(presentation.gridLineWidth, 1, 8),
|
||||
material: lineColor,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!presentation.gridDotsEnabled) return;
|
||||
const dotStride = Math.max(1, Math.ceil((stepsPerSide * 2 + 1) / 25));
|
||||
for (let row = -stepsPerSide; row <= stepsPerSide; row += dotStride) {
|
||||
for (let column = -stepsPerSide; column <= stepsPerSide; column += dotStride) {
|
||||
entities.add({
|
||||
position: Cartesian3.fromDegrees(longitude + column * deltaLongitude, latitude + row * deltaLatitude, elevation),
|
||||
point: {
|
||||
pixelSize: clamp(presentation.gridDotsSize, 2, 28),
|
||||
color: dotColor,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyPresentation(
|
||||
viewer: Viewer,
|
||||
imageryLayer: ImageryLayer | null,
|
||||
buildings: Cesium3DTileset | null,
|
||||
terrain: { world: CesiumTerrainProvider | null; ellipsoid: EllipsoidTerrainProvider },
|
||||
presentation: MapPresentation,
|
||||
) {
|
||||
if (imageryLayer) {
|
||||
imageryLayer.show = presentation.imageryVisible && !presentation.monochrome;
|
||||
imageryLayer.brightness = presentation.imageryBrightness / 100;
|
||||
imageryLayer.contrast = presentation.imageryContrast / 100;
|
||||
imageryLayer.saturation = presentation.imagerySaturation / 100;
|
||||
imageryLayer.gamma = presentation.imageryGamma / 100;
|
||||
imageryLayer.hue = CesiumMath.toRadians(presentation.imageryHue);
|
||||
imageryLayer.alpha = presentation.imageryAlpha / 100;
|
||||
}
|
||||
if (buildings) {
|
||||
buildings.show = presentation.buildingsVisible;
|
||||
buildings.maximumScreenSpaceError = presentation.buildingsDetail;
|
||||
buildings.style = new Cesium3DTileStyle({
|
||||
color: `color('${presentation.buildingsColor}', ${presentation.buildingsOpacity})`,
|
||||
});
|
||||
}
|
||||
viewer.terrainProvider = presentation.terrainEnabled && terrain.world ? terrain.world : terrain.ellipsoid;
|
||||
viewer.scene.globe.show = true;
|
||||
viewer.scene.globe.baseColor = Color.fromCssColorString(presentation.monochrome ? presentation.monochromeColor : presentation.globeColor);
|
||||
viewer.scene.globe.enableLighting = presentation.sunEnabled;
|
||||
(viewer.scene as unknown as { verticalExaggeration?: number }).verticalExaggeration = clamp(presentation.terrainExaggeration, 0.25, 3);
|
||||
viewer.scene.backgroundColor = Color.fromCssColorString(presentation.backgroundColor);
|
||||
viewer.scene.fog.enabled = presentation.fogEnabled;
|
||||
viewer.scene.fog.density = clamp(presentation.fogDensity / 10_000, 0, 0.01);
|
||||
const atmosphere = viewer.scene.skyAtmosphere;
|
||||
if (atmosphere) {
|
||||
atmosphere.show = presentation.atmosphereEnabled;
|
||||
atmosphere.hueShift = clamp(presentation.atmosphereHue / 100, -1, 1);
|
||||
atmosphere.saturationShift = clamp(presentation.atmosphereSaturation / 100, -1, 1);
|
||||
atmosphere.brightnessShift = clamp(presentation.atmosphereBrightness / 100, -1, 1);
|
||||
}
|
||||
viewer.shadows = presentation.shadowsEnabled;
|
||||
const sunDate = JulianDate.toDate(JulianDate.now());
|
||||
sunDate.setUTCHours(clamp(Math.round(presentation.sunHour), 0, 24), 0, 0, 0);
|
||||
viewer.clock.currentTime = JulianDate.fromDate(sunDate);
|
||||
viewer.clock.shouldAnimate = false;
|
||||
const sun = new SunLight();
|
||||
(sun as unknown as { intensity?: number }).intensity = clamp(presentation.sunIntensity / 100, 0, 2);
|
||||
viewer.scene.light = sun;
|
||||
viewer.scene.requestRender();
|
||||
}
|
||||
|
||||
export function CesiumMapRenderer({
|
||||
onSelect,
|
||||
onGatewayHealth,
|
||||
onCameraChange,
|
||||
initialCamera,
|
||||
presentation,
|
||||
}: {
|
||||
onSelect?: (entityId: string) => void;
|
||||
onGatewayHealth?: (health: MapGatewayHealth | null) => void;
|
||||
onCameraChange?: (camera: MapCameraView) => void;
|
||||
initialCamera?: MapCameraView;
|
||||
presentation: MapPresentation;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const creditContainerRef = useRef<HTMLDivElement>(null);
|
||||
const viewerRef = useRef<Viewer | null>(null);
|
||||
const imageryLayerRef = useRef<ImageryLayer | null>(null);
|
||||
const buildingsRef = useRef<Cesium3DTileset | null>(null);
|
||||
const terrainRef = useRef<{ world: CesiumTerrainProvider | null; ellipsoid: EllipsoidTerrainProvider } | null>(null);
|
||||
const rebuildGridRef = useRef<(() => void) | null>(null);
|
||||
const presentationRef = useRef(presentation);
|
||||
const onCameraChangeRef = useRef(onCameraChange);
|
||||
|
||||
useEffect(() => {
|
||||
onCameraChangeRef.current = onCameraChange;
|
||||
}, [onCameraChange]);
|
||||
|
||||
useEffect(() => {
|
||||
presentationRef.current = presentation;
|
||||
if (viewerRef.current && terrainRef.current) applyPresentation(viewerRef.current, imageryLayerRef.current, buildingsRef.current, terrainRef.current, presentation);
|
||||
rebuildGridRef.current?.();
|
||||
}, [presentation]);
|
||||
|
||||
useEffect(() => {
|
||||
let viewer: Viewer | undefined;
|
||||
let handler: ScreenSpaceEventHandler | undefined;
|
||||
let resizeObserver: ResizeObserver | undefined;
|
||||
let removeGridCameraListener: (() => void) | undefined;
|
||||
let cancelled = false;
|
||||
|
||||
const start = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/map/runtime-config");
|
||||
const config = response.ok ? await response.json() as RuntimeConfig : null;
|
||||
if (config?.gatewayHealthUrl) {
|
||||
void fetch(config.gatewayHealthUrl)
|
||||
.then((healthResponse) => healthResponse.ok ? healthResponse.json() as Promise<MapGatewayHealth> : null)
|
||||
.then((health) => { if (!cancelled) onGatewayHealth?.(health); })
|
||||
.catch(() => { if (!cancelled) onGatewayHealth?.(null); });
|
||||
}
|
||||
if (!containerRef.current || cancelled) return;
|
||||
|
||||
viewer = new Viewer(containerRef.current, {
|
||||
animation: false,
|
||||
baseLayer: false,
|
||||
baseLayerPicker: false,
|
||||
fullscreenButton: false,
|
||||
geocoder: false,
|
||||
homeButton: false,
|
||||
infoBox: false,
|
||||
navigationHelpButton: false,
|
||||
sceneModePicker: false,
|
||||
selectionIndicator: false,
|
||||
timeline: false,
|
||||
requestRenderMode: true,
|
||||
maximumRenderTimeChange: Number.POSITIVE_INFINITY,
|
||||
// Sandbox-only: Cesium writes credits into a dedicated, visually
|
||||
// suppressed container. Runtime attribution metadata is preserved;
|
||||
// external and commercial surfaces must provide visible credits.
|
||||
creditContainer: creditContainerRef.current ?? undefined,
|
||||
});
|
||||
const resourceProxy = config?.resourceProxyBase ? new DefaultProxy(config.resourceProxyBase) : undefined;
|
||||
const buildResource = (url: string, accessToken?: string) => {
|
||||
// Put cache intent into the upstream URL itself. Cesium providers
|
||||
// derive child resources (metadata, imagery tiles, terrain and 3D
|
||||
// tiles) from this URL; DefaultProxy then forwards the exact intent
|
||||
// to Gateway for every derived request.
|
||||
const routedUrl = new URL(url);
|
||||
if (!presentationRef.current.cacheEnabled) routedUrl.searchParams.set("nodedc_cache_mode", "passthrough");
|
||||
if (presentationRef.current.cacheRefresh) routedUrl.searchParams.set("nodedc_cache_refresh", "1");
|
||||
return new Resource({
|
||||
url: routedUrl.toString(),
|
||||
queryParameters: {
|
||||
...(accessToken ? { access_token: accessToken } : {}),
|
||||
},
|
||||
proxy: resourceProxy,
|
||||
});
|
||||
};
|
||||
const loadEndpoint = async (assetId: string) => {
|
||||
if (!config?.gatewayReady) throw new Error("map_gateway_not_ready");
|
||||
const endpointResponse = await fetch(`${config.assetEndpointBase}/${assetId}/endpoint`);
|
||||
if (!endpointResponse.ok) throw new Error(`Map Gateway asset ${assetId}: ${endpointResponse.status}`);
|
||||
return endpointResponse.json() as Promise<IonAssetEndpoint>;
|
||||
};
|
||||
const endpoint = await loadEndpoint("2");
|
||||
if (endpoint.externalType !== "BING" || !endpoint.options?.url || !endpoint.options?.key) throw new Error("cesium_live_imagery_endpoint_invalid");
|
||||
const imageryProvider = await BingMapsImageryProvider.fromUrl(buildResource(endpoint.options.url), {
|
||||
key: endpoint.options.key,
|
||||
mapStyle: (endpoint.options.mapStyle || "Aerial") as BingMapsStyle,
|
||||
tileProtocol: "https",
|
||||
});
|
||||
for (const attribution of endpoint.attributions) if (attribution.html) viewer.creditDisplay.addStaticCredit(new Credit(attribution.html, attribution.collapsible));
|
||||
const imageryLayer = viewer.imageryLayers.addImageryProvider(imageryProvider);
|
||||
const gridDataSource = new CustomDataSource("nodedc-map-grid");
|
||||
viewer.dataSources.add(gridDataSource);
|
||||
const terrain = { world: null as CesiumTerrainProvider | null, ellipsoid: new EllipsoidTerrainProvider() };
|
||||
viewer.terrainProvider = terrain.ellipsoid;
|
||||
viewer.scene.globe.depthTestAgainstTerrain = true;
|
||||
addFixtureEntities(viewer);
|
||||
viewerRef.current = viewer;
|
||||
imageryLayerRef.current = imageryLayer;
|
||||
terrainRef.current = terrain;
|
||||
const rebuildGrid = () => rebuildElevatedGrid(viewer!, gridDataSource, presentationRef.current);
|
||||
rebuildGridRef.current = rebuildGrid;
|
||||
removeGridCameraListener = viewer.camera.moveEnd.addEventListener(() => {
|
||||
rebuildGrid();
|
||||
onCameraChangeRef.current?.(getCameraView(viewer!));
|
||||
});
|
||||
|
||||
if (config?.gatewayReady) {
|
||||
void loadEndpoint("1").then(async (terrainEndpoint) => {
|
||||
if (!terrainEndpoint.url || !terrainEndpoint.accessToken) throw new Error("terrain_endpoint_invalid");
|
||||
const terrainResource = buildResource(terrainEndpoint.url, terrainEndpoint.accessToken);
|
||||
const world = await CesiumTerrainProvider.fromUrl(terrainResource, { requestVertexNormals: true, requestWaterMask: true });
|
||||
if (cancelled || !viewer || viewer.isDestroyed()) return;
|
||||
terrain.world = world;
|
||||
for (const attribution of terrainEndpoint.attributions) if (attribution.html) viewer.creditDisplay.addStaticCredit(new Credit(attribution.html, attribution.collapsible));
|
||||
applyPresentation(viewer, imageryLayer, buildingsRef.current, terrain, presentationRef.current);
|
||||
}).catch(() => undefined);
|
||||
void loadEndpoint("96188").then(async (buildingsEndpoint) => {
|
||||
if (!buildingsEndpoint.url || !buildingsEndpoint.accessToken) throw new Error("buildings_endpoint_invalid");
|
||||
const buildingsResource = buildResource(buildingsEndpoint.url, buildingsEndpoint.accessToken);
|
||||
const buildings = await Cesium3DTileset.fromUrl(buildingsResource);
|
||||
if (cancelled || !viewer || viewer.isDestroyed()) return;
|
||||
viewer.scene.primitives.add(buildings);
|
||||
buildingsRef.current = buildings;
|
||||
for (const attribution of buildingsEndpoint.attributions) if (attribution.html) viewer.creditDisplay.addStaticCredit(new Credit(attribution.html, attribution.collapsible));
|
||||
applyPresentation(viewer, imageryLayer, buildings, terrain, presentationRef.current);
|
||||
}).catch(() => undefined);
|
||||
if (config.gaussianSplatsReady && config.gaussianAssetId) {
|
||||
const gaussianEndpoint = await loadEndpoint(config.gaussianAssetId);
|
||||
if (!gaussianEndpoint.url || !gaussianEndpoint.accessToken) throw new Error("gaussian_endpoint_invalid");
|
||||
const gaussianResource = buildResource(gaussianEndpoint.url, gaussianEndpoint.accessToken);
|
||||
viewer.scene.primitives.add(await Cesium3DTileset.fromUrl(gaussianResource));
|
||||
}
|
||||
}
|
||||
|
||||
applyPresentation(viewer, imageryLayer, buildingsRef.current, terrain, presentationRef.current);
|
||||
|
||||
if (initialCamera) {
|
||||
viewer.camera.setView({
|
||||
destination: Cartesian3.fromDegrees(initialCamera.longitude, initialCamera.latitude, initialCamera.height),
|
||||
orientation: {
|
||||
heading: initialCamera.heading,
|
||||
pitch: initialCamera.pitch,
|
||||
roll: initialCamera.roll,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const center = toCartesian(sceneFixture.viewport.center as Position);
|
||||
viewer.camera.lookAt(center, new HeadingPitchRange(
|
||||
CesiumMath.toRadians(sceneFixture.viewport.heading),
|
||||
CesiumMath.toRadians(sceneFixture.viewport.pitch),
|
||||
sceneFixture.viewport.range,
|
||||
));
|
||||
viewer.camera.lookAtTransform(Matrix4.IDENTITY);
|
||||
}
|
||||
rebuildGrid();
|
||||
onCameraChangeRef.current?.(getCameraView(viewer));
|
||||
|
||||
handler = new ScreenSpaceEventHandler(viewer.scene.canvas);
|
||||
handler.setInputAction((movement: { position: Cartesian2 }) => {
|
||||
const picked = viewer?.scene.pick(movement.position);
|
||||
const entity = picked?.id instanceof Entity ? picked.id : undefined;
|
||||
if (entity?.id) onSelect?.(entity.id);
|
||||
}, ScreenSpaceEventType.LEFT_CLICK);
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
if (!viewer || viewer.isDestroyed()) return;
|
||||
viewer.resize();
|
||||
viewer.scene.requestRender();
|
||||
});
|
||||
resizeObserver.observe(containerRef.current);
|
||||
} catch {
|
||||
// The canvas stays available even if an optional provider fails. Its
|
||||
// detailed state belongs to the Inspector, not to a map overlay label.
|
||||
}
|
||||
};
|
||||
|
||||
void start();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
resizeObserver?.disconnect();
|
||||
removeGridCameraListener?.();
|
||||
handler?.destroy();
|
||||
if (viewer && !viewer.isDestroyed()) viewer.destroy();
|
||||
viewerRef.current = null;
|
||||
imageryLayerRef.current = null;
|
||||
buildingsRef.current = null;
|
||||
terrainRef.current = null;
|
||||
rebuildGridRef.current = null;
|
||||
};
|
||||
}, [onGatewayHealth, onSelect]);
|
||||
|
||||
return (
|
||||
<div className="catalog-cesium-map">
|
||||
<div ref={containerRef} className="catalog-cesium-map__canvas" />
|
||||
<div ref={creditContainerRef} className="catalog-cesium-map__credits" aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user