feat(foundry): close the operational map data loop

This commit is contained in:
Codex
2026-07-20 20:45:05 +03:00
parent aac44d057f
commit a02c3ff3dd
44 changed files with 4158 additions and 811 deletions
+198 -216
View File
@@ -10,11 +10,11 @@ import {
Cesium3DTileStyle,
CesiumTerrainProvider,
CallbackProperty,
CallbackPositionProperty,
Cartographic,
ConstantPositionProperty,
CustomDataSource,
DefaultProxy,
DistanceDisplayCondition,
EllipsoidTerrainProvider,
Entity,
HeightReference,
@@ -25,8 +25,8 @@ import {
LabelGraphics,
Matrix4,
Math as CesiumMath,
PolygonHierarchy,
PointGraphics,
PolylineGraphics,
Resource,
sampleTerrainMostDetailed,
ScreenSpaceEventHandler,
@@ -36,36 +36,22 @@ import {
Viewer,
} from "cesium";
import "cesium/Build/Cesium/Widgets/widgets.css";
import sceneFixture from "../../../registry/fixtures/map/map-operational-v0.1.json";
import { MAX_SPIRAL_RADIUS_METERS, spiralSurfaceFrame, type GeodeticRadians } from "./mapSpiralMath.js";
import { cameraSurveyPitchForViewport, cameraSurveySampleDistances, cameraSurveySpiralDistance } from "./mapCameraPresets.js";
import { mapRuntimeEntityId, type MapRuntimeBinding, type MapRuntimeFact } from "./useMapDataProductRuntime.js";
import {
mapPresentationProfileForFact,
mapRuntimeDisplayLabel,
mapRuntimeFactIsVisible,
resolveMapPresentationClass,
resolveMapPresentationStyle,
type MapPresentationFilters,
type MapPresentationProfile,
} from "./mapPresentationProfile.js";
type Position = [number, number, number?];
const MAX_SPIRAL_SUBSTEPS_PER_FRAME = 300;
const TERRAIN_SAMPLE_TIMEOUT_MS = 12_000;
const SPIRAL_TILE_WAIT_TIMEOUT_MS = 45_000;
type PinPresentation = {
variant: "elevated-spike";
stemHeightMeters: number;
headSizePx: number;
stemWidthPx: number;
outlineColor: string;
outlineOpacity: number;
outlineWidthPx: number;
labelOffsetX: number;
labelOffsetY: number;
pinHideCameraHeightMeters?: number;
labelHideCameraHeightMeters?: number;
};
type MapStyleProfile = {
id: string;
kind: string;
color?: string;
opacity?: number;
size?: number;
pinPresentation?: PinPresentation;
};
type RuntimeConfig = {
cesiumVersion: string;
provider: string;
@@ -202,6 +188,8 @@ export type CesiumMapRendererHandle = {
startSpiralAnimation: (config: CameraSpiralConfig) => boolean;
stopSpiralAnimation: (reason?: CameraSpiralState["reason"]) => void;
getCameraView: () => MapCameraView | null;
fitRuntimeEntities: (entityIds?: string[]) => boolean;
focusRuntimeEntity: (entityId: string) => boolean;
};
type TerrainRouteSample = {
@@ -237,28 +225,50 @@ type SpiralSession = {
previousBuildingsFoveatedTimeDelay: number | null;
};
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));
const defaultElevatedPin: PinPresentation = {
variant: "elevated-spike",
stemHeightMeters: 120,
headSizePx: 8,
stemWidthPx: 2,
outlineColor: "#0c0d12",
outlineOpacity: 0.6,
outlineWidthPx: 1,
labelOffsetX: 10,
labelOffsetY: 0,
};
function showBelowCameraHeight(viewer: Viewer, limit?: number) {
if (!limit) return true;
return new CallbackProperty(() => Number(viewer.camera.positionCartographic?.height || 0) <= limit, false);
}
function elevatedPinGroundHeight(viewer: Viewer, longitude: number, latitude: number, fallbackHeightMeters = 0) {
const sampled = viewer.scene.globe.getHeight(Cartographic.fromDegrees(longitude, latitude));
return Number.isFinite(sampled) ? Number(sampled) : fallbackHeightMeters;
}
function elevatedPinTopPosition(
viewer: Viewer,
longitude: number,
latitude: number,
stemHeightMeters: number,
fallbackHeightMeters = 0,
) {
return new CallbackPositionProperty(() => Cartesian3.fromDegrees(
longitude,
latitude,
elevatedPinGroundHeight(viewer, longitude, latitude, fallbackHeightMeters) + stemHeightMeters,
), false);
}
function elevatedPinStemPositions(
viewer: Viewer,
longitude: number,
latitude: number,
stemHeightMeters: number,
fallbackHeightMeters = 0,
) {
return new CallbackProperty(() => {
const groundHeight = elevatedPinGroundHeight(viewer, longitude, latitude, fallbackHeightMeters);
return [
Cartesian3.fromDegrees(longitude, latitude, groundHeight),
Cartesian3.fromDegrees(longitude, latitude, groundHeight + stemHeightMeters),
];
}, false);
}
function getCameraView(viewer: Viewer): MapCameraView {
const position = viewer.camera.positionCartographic;
return {
@@ -302,148 +312,6 @@ function interpolateTerrainRouteHeight(samples: TerrainRouteSample[], distanceMe
return null;
}
function addFixtureEntities(viewer: Viewer) {
const styleProfiles = new Map<string, MapStyleProfile>(
(sceneFixture.styleProfiles as unknown as MapStyleProfile[]).map((profile) => [profile.id, profile]),
);
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,
},
});
}
const pinStyle = styleProfiles.get(object.pinStyleProfileId);
const pin = pinStyle?.pinPresentation ?? defaultElevatedPin;
const pinColor = Color.fromCssColorString(pinStyle?.color || "#ff2f92").withAlpha(pinStyle?.opacity ?? 1);
const outlineColor = Color.fromCssColorString(pin.outlineColor).withAlpha(pin.outlineOpacity);
const [longitude, latitude] = object.position as Position;
const base = Cartesian3.fromDegrees(longitude, latitude, 0);
const top = Cartesian3.fromDegrees(longitude, latitude, pin.stemHeightMeters);
viewer.entities.add({
id: object.id,
position: top,
polyline: {
positions: [base, top],
width: pin.stemWidthPx,
material: pinColor,
show: showBelowCameraHeight(viewer, pin.pinHideCameraHeightMeters),
},
point: {
pixelSize: pin.headSizePx,
color: pinColor,
outlineColor,
outlineWidth: pin.outlineWidthPx,
show: showBelowCameraHeight(viewer, pin.pinHideCameraHeightMeters),
},
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(pin.labelOffsetX, pin.labelOffsetY),
horizontalOrigin: HorizontalOrigin.LEFT,
verticalOrigin: VerticalOrigin.BOTTOM,
heightReference: HeightReference.NONE,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
show: showBelowCameraHeight(viewer, pin.labelHideCameraHeightMeters),
},
});
}
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 runtimeDisplayLabel(fact: MapRuntimeFact) {
for (const key of ["label", "name", "title", "subject_id"]) {
const value = fact.attributes[key];
if (typeof value === "string" && value.trim()) return value.trim();
}
return fact.sourceId;
}
function runtimePointColor(fact: MapRuntimeFact) {
// This is a semantic default for the generic Map entity-stream adapter,
// not a provider style. A renderer-neutral style profile can refine it
@@ -459,6 +327,8 @@ function syncRuntimeDataSources(
viewer: Viewer,
dataSources: Map<string, CustomDataSource>,
bindings: MapRuntimeBinding[],
presentationProfiles: MapPresentationProfile[],
presentationFilters: MapPresentationFilters,
) {
const activeBindings = new Map(bindings
.filter((binding) => binding.slotId === "points")
@@ -479,35 +349,98 @@ function syncRuntimeDataSources(
}
const wanted = new Set<string>();
for (const fact of binding.facts) {
if (!fact.geometry) continue;
const profile = mapPresentationProfileForFact(
presentationProfiles,
binding.presentationProfileId,
fact.semanticType,
);
const presentationClass = profile ? resolveMapPresentationClass(fact, profile) : undefined;
if (
!fact.geometry
|| (profile && !mapRuntimeFactIsVisible(fact, profile, presentationFilters, binding.bindingId))
) continue;
const entityId = mapRuntimeEntityId(binding.bindingId, fact);
wanted.add(entityId);
const [longitude, latitude] = fact.geometry.coordinates;
const color = runtimePointColor(fact);
const label = runtimeDisplayLabel(fact);
const resolvedStyle = profile ? resolveMapPresentationStyle(profile, presentationClass) : undefined;
const color = resolvedStyle
? Color.fromCssColorString(resolvedStyle.color).withAlpha(resolvedStyle.opacity)
: runtimePointColor(fact);
const label = mapRuntimeDisplayLabel(fact, profile);
const entity = dataSource.entities.getById(entityId) ?? dataSource.entities.add({ id: entityId });
entity.name = label;
entity.position = new ConstantPositionProperty(Cartesian3.fromDegrees(longitude, latitude, 0));
entity.point = new PointGraphics({
pixelSize: 10,
color,
outlineColor: Color.fromCssColorString("#0c0d12").withAlpha(0.72),
outlineWidth: 2,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
entity.label = new LabelGraphics({
text: label,
font: "700 13px Arial",
fillColor: Color.WHITE,
showBackground: true,
backgroundColor: Color.BLACK.withAlpha(0.72),
backgroundPadding: new Cartesian2(10, 7),
pixelOffset: new Cartesian2(10, 0),
horizontalOrigin: HorizontalOrigin.LEFT,
verticalOrigin: VerticalOrigin.BOTTOM,
heightReference: HeightReference.NONE,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
if (profile) {
const fallbackHeightMeters = typeof fact.attributes.elevation_meters === "number" && Number.isFinite(fact.attributes.elevation_meters)
? fact.attributes.elevation_meters
: 0;
entity.position = elevatedPinTopPosition(
viewer,
longitude,
latitude,
profile.target.stemHeightMeters,
fallbackHeightMeters,
);
entity.polyline = new PolylineGraphics({
positions: elevatedPinStemPositions(
viewer,
longitude,
latitude,
profile.target.stemHeightMeters,
fallbackHeightMeters,
),
width: profile.target.stemWidthPx,
material: color,
show: showBelowCameraHeight(viewer, profile.target.hideCameraHeightMeters),
});
entity.point = new PointGraphics({
pixelSize: profile.target.headSizePx,
color,
outlineColor: Color.fromCssColorString(profile.target.outlineColor).withAlpha(profile.target.outlineOpacity),
outlineWidth: profile.target.outlineWidthPx,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
show: showBelowCameraHeight(viewer, profile.target.hideCameraHeightMeters),
});
entity.label = new LabelGraphics({
text: label,
font: `${profile.label.fontWeight} ${profile.label.sizePx}px Arial`,
fillColor: Color.fromCssColorString(profile.label.color),
outlineColor: Color.fromCssColorString(profile.label.outlineColor),
outlineWidth: profile.label.outlineWidthPx,
style: 2,
showBackground: profile.label.backgroundOpacity > 0,
backgroundColor: Color.fromCssColorString(profile.label.backgroundColor).withAlpha(profile.label.backgroundOpacity),
backgroundPadding: new Cartesian2(profile.label.paddingX, profile.label.paddingY),
pixelOffset: new Cartesian2(profile.label.offsetX, profile.label.offsetY),
horizontalOrigin: HorizontalOrigin.LEFT,
verticalOrigin: VerticalOrigin.BOTTOM,
heightReference: HeightReference.NONE,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
show: profile.label.mode !== "none" && showBelowCameraHeight(viewer, profile.label.hideCameraHeightMeters),
});
} else {
entity.position = new ConstantPositionProperty(Cartesian3.fromDegrees(longitude, latitude, 0));
entity.polyline = undefined;
entity.point = new PointGraphics({
pixelSize: 10,
color,
outlineColor: Color.fromCssColorString("#0c0d12").withAlpha(0.72),
outlineWidth: 2,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
entity.label = new LabelGraphics({
text: label,
font: "700 13px Arial",
fillColor: Color.WHITE,
showBackground: true,
backgroundColor: Color.BLACK.withAlpha(0.72),
backgroundPadding: new Cartesian2(10, 7),
pixelOffset: new Cartesian2(10, 0),
horizontalOrigin: HorizontalOrigin.LEFT,
verticalOrigin: VerticalOrigin.BOTTOM,
heightReference: HeightReference.NONE,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
}
}
for (const entity of [...dataSource.entities.values]) {
if (typeof entity.id === "string" && !wanted.has(entity.id)) dataSource.entities.remove(entity);
@@ -530,9 +463,9 @@ function rebuildElevatedGrid(viewer: Viewer, dataSource: CustomDataSource, prese
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 cameraPosition = viewer.camera.positionCartographic;
const latitude = cameraPosition ? CesiumMath.toDegrees(cameraPosition.latitude) : 55.751244;
const longitude = cameraPosition ? CesiumMath.toDegrees(cameraPosition.longitude) : 37.618423;
const metersPerLatitudeDegree = 110_574;
const metersPerLongitudeDegree = Math.max(1, 111_320 * Math.cos(CesiumMath.toRadians(latitude)));
const stepMeters = safeStepKm * 1000;
@@ -646,6 +579,8 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
initialCamera?: MapCameraView;
presentation: MapPresentation;
runtimeBindings?: MapRuntimeBinding[];
presentationProfiles?: MapPresentationProfile[];
presentationFilters?: MapPresentationFilters;
}>(function CesiumMapRenderer({
onSelect,
onGatewayHealth,
@@ -657,6 +592,8 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
initialCamera,
presentation,
runtimeBindings = [],
presentationProfiles = [],
presentationFilters = {},
}, ref) {
const containerRef = useRef<HTMLDivElement>(null);
const creditContainerRef = useRef<HTMLDivElement>(null);
@@ -668,6 +605,8 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
const runtimeDataSourcesRef = useRef(new Map<string, CustomDataSource>());
const presentationRef = useRef(presentation);
const runtimeBindingsRef = useRef(runtimeBindings);
const presentationProfilesRef = useRef(presentationProfiles);
const presentationFiltersRef = useRef(presentationFilters);
const onSelectRef = useRef(onSelect);
const onCameraChangeRef = useRef(onCameraChange);
const onCacheRefreshConsumedRef = useRef(onCacheRefreshConsumed);
@@ -1020,6 +959,34 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
return true;
}, [stopSpiralAnimation]);
const runtimeEntities = useCallback((entityIds?: string[]) => {
const allowed = entityIds ? new Set(entityIds) : null;
return [...runtimeDataSourcesRef.current.values()].flatMap((dataSource) => (
[...dataSource.entities.values].filter((entity) => !allowed || allowed.has(String(entity.id)))
));
}, []);
const fitRuntimeEntities = useCallback((entityIds?: string[]) => {
const viewer = viewerRef.current;
if (!viewer || viewer.isDestroyed()) return false;
const entities = runtimeEntities(entityIds);
if (!entities.length) return false;
void viewer.flyTo(entities, { duration: 0.55 });
return true;
}, [runtimeEntities]);
const focusRuntimeEntity = useCallback((entityId: string) => {
const viewer = viewerRef.current;
if (!viewer || viewer.isDestroyed()) return false;
const entity = runtimeEntities([entityId])[0];
if (!entity) return false;
void viewer.flyTo(entity, {
duration: 0.45,
offset: new HeadingPitchRange(0, -0.9, 8_000),
});
return true;
}, [runtimeEntities]);
useImperativeHandle(ref, () => ({
startSpiralAnimation,
stopSpiralAnimation,
@@ -1027,7 +994,9 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
const viewer = viewerRef.current;
return viewer && !viewer.isDestroyed() ? getCameraView(viewer) : null;
},
}), [startSpiralAnimation, stopSpiralAnimation]);
fitRuntimeEntities,
focusRuntimeEntity,
}), [fitRuntimeEntities, focusRuntimeEntity, startSpiralAnimation, stopSpiralAnimation]);
useEffect(() => {
const stopForPageLeave = () => stopSpiralAnimation("stopped");
@@ -1066,10 +1035,18 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
useEffect(() => {
runtimeBindingsRef.current = runtimeBindings;
presentationProfilesRef.current = presentationProfiles;
presentationFiltersRef.current = presentationFilters;
if (viewerRef.current && !viewerRef.current.isDestroyed()) {
syncRuntimeDataSources(viewerRef.current, runtimeDataSourcesRef.current, runtimeBindings);
syncRuntimeDataSources(
viewerRef.current,
runtimeDataSourcesRef.current,
runtimeBindings,
presentationProfiles,
presentationFilters,
);
}
}, [runtimeBindings]);
}, [presentationFilters, presentationProfiles, runtimeBindings]);
useEffect(() => {
let viewer: Viewer | undefined;
@@ -1146,8 +1123,13 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
const terrain = { world: null as CesiumTerrainProvider | null, ellipsoid: new EllipsoidTerrainProvider() };
viewer.terrainProvider = terrain.ellipsoid;
viewer.scene.globe.depthTestAgainstTerrain = true;
addFixtureEntities(viewer);
syncRuntimeDataSources(viewer, runtimeDataSourcesRef.current, runtimeBindingsRef.current);
syncRuntimeDataSources(
viewer,
runtimeDataSourcesRef.current,
runtimeBindingsRef.current,
presentationProfilesRef.current,
presentationFiltersRef.current,
);
viewerRef.current = viewer;
terrainRef.current = terrain;
const rebuildGrid = () => rebuildElevatedGrid(viewer!, gridDataSource, presentationRef.current);
@@ -1274,11 +1256,11 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
},
});
} else {
const center = toCartesian(sceneFixture.viewport.center as Position);
const center = Cartesian3.fromDegrees(37.618423, 55.751244, 0);
viewer.camera.lookAt(center, new HeadingPitchRange(
CesiumMath.toRadians(sceneFixture.viewport.heading),
CesiumMath.toRadians(sceneFixture.viewport.pitch),
sceneFixture.viewport.range,
0,
-0.9,
40_000,
));
viewer.camera.lookAtTransform(Matrix4.IDENTITY);
}