feat(foundry): complete HGeoZone live layer
This commit is contained in:
parent
de060c105b
commit
309cddef73
|
|
@ -12,11 +12,17 @@ import {
|
|||
CallbackProperty,
|
||||
CallbackPositionProperty,
|
||||
Cartographic,
|
||||
ClassificationType,
|
||||
ColorGeometryInstanceAttribute,
|
||||
ConstantPositionProperty,
|
||||
CustomDataSource,
|
||||
DefaultProxy,
|
||||
EllipsoidTerrainProvider,
|
||||
Entity,
|
||||
GeometryInstance,
|
||||
GroundPolylineGeometry,
|
||||
GroundPolylinePrimitive,
|
||||
GroundPrimitive,
|
||||
HeightReference,
|
||||
HeadingPitchRange,
|
||||
HorizontalOrigin,
|
||||
|
|
@ -25,9 +31,11 @@ import {
|
|||
LabelGraphics,
|
||||
Matrix4,
|
||||
Math as CesiumMath,
|
||||
PerInstanceColorAppearance,
|
||||
PointGraphics,
|
||||
PolygonGraphics,
|
||||
PolygonGeometry,
|
||||
PolygonHierarchy,
|
||||
PolylineColorAppearance,
|
||||
PolylineGraphics,
|
||||
Resource,
|
||||
sampleTerrainMostDetailed,
|
||||
|
|
@ -54,6 +62,7 @@ import {
|
|||
const MAX_SPIRAL_SUBSTEPS_PER_FRAME = 300;
|
||||
const TERRAIN_SAMPLE_TIMEOUT_MS = 12_000;
|
||||
const SPIRAL_TILE_WAIT_TIMEOUT_MS = 45_000;
|
||||
const MAX_HGEOZONE_INSTANCES_PER_BATCH = 256;
|
||||
type RuntimeConfig = {
|
||||
cesiumVersion: string;
|
||||
provider: string;
|
||||
|
|
@ -325,9 +334,255 @@ function runtimePointColor(fact: MapRuntimeFact) {
|
|||
return fact.semanticType === "map.moving_object" ? accent : violet;
|
||||
}
|
||||
|
||||
type HGeoZonePickId = {
|
||||
kind: "nodedc-hgeozone";
|
||||
entityId: string;
|
||||
instanceId: string;
|
||||
};
|
||||
|
||||
type HGeoZoneFillPart = {
|
||||
pickId: HGeoZonePickId;
|
||||
hierarchy: PolygonHierarchy;
|
||||
color: Color;
|
||||
};
|
||||
|
||||
type HGeoZoneOutlinePart = {
|
||||
pickId: HGeoZonePickId;
|
||||
positions: Cartesian3[];
|
||||
color: Color;
|
||||
};
|
||||
|
||||
type HGeoZonePrimitiveBatch<TPart> = {
|
||||
primitive: GroundPrimitive | GroundPolylinePrimitive;
|
||||
parts: TPart[];
|
||||
};
|
||||
|
||||
type HGeoZoneProjectionLayer = {
|
||||
geometryKey: string;
|
||||
styleKey: string;
|
||||
hideCameraHeightMeters: number | null;
|
||||
fills: Array<HGeoZonePrimitiveBatch<HGeoZoneFillPart>>;
|
||||
outlines: Array<HGeoZonePrimitiveBatch<HGeoZoneOutlinePart>>;
|
||||
};
|
||||
|
||||
function hGeoZoneRingPositions(ring: Array<[number, number]>) {
|
||||
const first = ring[0];
|
||||
const last = ring[ring.length - 1];
|
||||
const openRing = first && last && first[0] === last[0] && first[1] === last[1]
|
||||
? ring.slice(0, -1)
|
||||
: ring;
|
||||
return openRing.map(([longitude, latitude]) => Cartesian3.fromDegrees(longitude, latitude, 0));
|
||||
}
|
||||
|
||||
function hGeoZoneHierarchy(polygon: Array<Array<[number, number]>>) {
|
||||
return new PolygonHierarchy(
|
||||
hGeoZoneRingPositions(polygon[0]),
|
||||
polygon.slice(1).map((ring) => new PolygonHierarchy(hGeoZoneRingPositions(ring))),
|
||||
);
|
||||
}
|
||||
|
||||
function hGeoZoneBatches<T>(items: T[]) {
|
||||
const batches: T[][] = [];
|
||||
for (let index = 0; index < items.length; index += MAX_HGEOZONE_INSTANCES_PER_BATCH) {
|
||||
batches.push(items.slice(index, index + MAX_HGEOZONE_INSTANCES_PER_BATCH));
|
||||
}
|
||||
return batches;
|
||||
}
|
||||
|
||||
function removeHGeoZoneLayer(viewer: Viewer, layer: HGeoZoneProjectionLayer) {
|
||||
for (const batch of [...layer.fills, ...layer.outlines]) {
|
||||
viewer.scene.groundPrimitives.remove(batch.primitive);
|
||||
}
|
||||
}
|
||||
|
||||
function syncHGeoZoneVisibility(viewer: Viewer, layers: Map<string, HGeoZoneProjectionLayer>) {
|
||||
const cameraHeight = Number(viewer.camera.positionCartographic?.height || 0);
|
||||
for (const layer of layers.values()) {
|
||||
const show = layer.hideCameraHeightMeters === null || cameraHeight <= layer.hideCameraHeightMeters;
|
||||
for (const batch of [...layer.fills, ...layer.outlines]) batch.primitive.show = show;
|
||||
}
|
||||
viewer.scene.requestRender();
|
||||
}
|
||||
|
||||
function updateHGeoZoneColors<TPart extends { pickId: HGeoZonePickId; color: Color }>(
|
||||
batches: Array<HGeoZonePrimitiveBatch<TPart>>,
|
||||
nextParts: TPart[],
|
||||
) {
|
||||
const nextColors = new Map(nextParts.map((part) => [part.pickId.instanceId, part.color]));
|
||||
for (const batch of batches) {
|
||||
for (const part of batch.parts) {
|
||||
const color = nextColors.get(part.pickId.instanceId);
|
||||
if (!color) continue;
|
||||
const attributes = batch.primitive.getGeometryInstanceAttributes(part.pickId);
|
||||
if (attributes) attributes.color = ColorGeometryInstanceAttribute.toValue(color);
|
||||
part.color = color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createHGeoZoneLayer(
|
||||
viewer: Viewer,
|
||||
geometryKey: string,
|
||||
styleKey: string,
|
||||
hideCameraHeightMeters: number | null,
|
||||
fillParts: HGeoZoneFillPart[],
|
||||
outlineParts: HGeoZoneOutlinePart[],
|
||||
outlineWidthPx: number,
|
||||
) {
|
||||
const fills = hGeoZoneBatches(fillParts).map((parts) => {
|
||||
const primitive = viewer.scene.groundPrimitives.add(new GroundPrimitive({
|
||||
geometryInstances: parts.map((part) => new GeometryInstance({
|
||||
id: part.pickId,
|
||||
geometry: new PolygonGeometry({
|
||||
polygonHierarchy: part.hierarchy,
|
||||
vertexFormat: PerInstanceColorAppearance.FLAT_VERTEX_FORMAT,
|
||||
}),
|
||||
attributes: { color: ColorGeometryInstanceAttribute.fromColor(part.color) },
|
||||
})),
|
||||
appearance: new PerInstanceColorAppearance({ flat: true, translucent: true }),
|
||||
allowPicking: true,
|
||||
asynchronous: true,
|
||||
classificationType: ClassificationType.TERRAIN,
|
||||
releaseGeometryInstances: true,
|
||||
}));
|
||||
return { primitive, parts };
|
||||
});
|
||||
const outlines = outlineWidthPx <= 0 ? [] : hGeoZoneBatches(outlineParts).map((parts) => {
|
||||
const primitive = viewer.scene.groundPrimitives.add(new GroundPolylinePrimitive({
|
||||
geometryInstances: parts.map((part) => new GeometryInstance({
|
||||
id: part.pickId,
|
||||
geometry: new GroundPolylineGeometry({
|
||||
positions: part.positions,
|
||||
width: outlineWidthPx,
|
||||
loop: true,
|
||||
}),
|
||||
attributes: { color: ColorGeometryInstanceAttribute.fromColor(part.color) },
|
||||
})),
|
||||
appearance: new PolylineColorAppearance({ translucent: true }),
|
||||
allowPicking: true,
|
||||
asynchronous: true,
|
||||
classificationType: ClassificationType.TERRAIN,
|
||||
releaseGeometryInstances: true,
|
||||
}));
|
||||
return { primitive, parts };
|
||||
});
|
||||
const layer = { geometryKey, styleKey, hideCameraHeightMeters, fills, outlines };
|
||||
syncHGeoZoneVisibility(viewer, new Map([["layer", layer]]));
|
||||
return layer;
|
||||
}
|
||||
|
||||
function syncHGeoZoneLayers(
|
||||
viewer: Viewer,
|
||||
layers: Map<string, HGeoZoneProjectionLayer>,
|
||||
bindings: MapRuntimeBinding[],
|
||||
presentationProfiles: MapPresentationProfile[],
|
||||
presentationFilters: MapPresentationFilters,
|
||||
) {
|
||||
const activeBindings = new Set(bindings.filter((binding) => binding.slotId === "zones").map((binding) => binding.bindingId));
|
||||
for (const [bindingId, layer] of layers) {
|
||||
if (activeBindings.has(bindingId)) continue;
|
||||
removeHGeoZoneLayer(viewer, layer);
|
||||
layers.delete(bindingId);
|
||||
}
|
||||
|
||||
for (const binding of bindings) {
|
||||
if (binding.slotId !== "zones") continue;
|
||||
const fillParts: HGeoZoneFillPart[] = [];
|
||||
const outlineParts: HGeoZoneOutlinePart[] = [];
|
||||
const geometryMembers: string[] = [];
|
||||
let outlineWidthPx = 1.5;
|
||||
let hideCameraHeightMeters: number | null = null;
|
||||
let outlineColor = Color.fromCssColorString("#c9b6ff").withAlpha(0.9);
|
||||
const styleMembers: string[] = [];
|
||||
|
||||
for (const fact of binding.facts) {
|
||||
const profile = mapPresentationProfileForFact(
|
||||
presentationProfiles,
|
||||
binding.presentationProfileId,
|
||||
fact.semanticType,
|
||||
);
|
||||
if (
|
||||
!fact.geometry
|
||||
|| fact.geometry.type === "Point"
|
||||
|| (profile && profile.target.variant !== "surface-fill")
|
||||
|| (profile && !mapRuntimeFactIsVisible(fact, profile, presentationFilters, binding.bindingId))
|
||||
) continue;
|
||||
const presentationClass = profile ? resolveMapPresentationClass(fact, profile) : undefined;
|
||||
const resolvedStyle = profile ? resolveMapPresentationStyle(profile, presentationClass) : undefined;
|
||||
const fillColor = resolvedStyle
|
||||
? Color.fromCssColorString(resolvedStyle.color).withAlpha(resolvedStyle.opacity)
|
||||
: runtimePointColor(fact).withAlpha(0.28);
|
||||
if (profile?.target.variant === "surface-fill") {
|
||||
outlineWidthPx = profile.target.outlineWidthPx;
|
||||
hideCameraHeightMeters = profile.target.hideCameraHeightMeters;
|
||||
outlineColor = Color.fromCssColorString(profile.target.outlineColor).withAlpha(profile.target.outlineOpacity);
|
||||
}
|
||||
const polygons = fact.geometry.type === "Polygon" ? [fact.geometry.coordinates] : fact.geometry.coordinates;
|
||||
const baseEntityId = mapRuntimeEntityId(binding.bindingId, fact);
|
||||
for (const [polygonIndex, polygon] of polygons.entries()) {
|
||||
const instanceBase = `${baseEntityId}:part:${polygonIndex}`;
|
||||
const fillPickId: HGeoZonePickId = {
|
||||
kind: "nodedc-hgeozone",
|
||||
entityId: baseEntityId,
|
||||
instanceId: `${instanceBase}:fill`,
|
||||
};
|
||||
fillParts.push({ pickId: fillPickId, hierarchy: hGeoZoneHierarchy(polygon), color: fillColor });
|
||||
geometryMembers.push(fillPickId.instanceId);
|
||||
styleMembers.push(`${fillPickId.instanceId}:${resolvedStyle?.id ?? "default"}:${fillColor.toCssHexString()}:${fillColor.alpha}`);
|
||||
for (const [ringIndex, ring] of polygon.entries()) {
|
||||
const outlinePickId: HGeoZonePickId = {
|
||||
kind: "nodedc-hgeozone",
|
||||
entityId: baseEntityId,
|
||||
instanceId: `${instanceBase}:ring:${ringIndex}`,
|
||||
};
|
||||
outlineParts.push({ pickId: outlinePickId, positions: hGeoZoneRingPositions(ring), color: outlineColor });
|
||||
geometryMembers.push(outlinePickId.instanceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const geometryKey = JSON.stringify([binding.cursor, outlineWidthPx, geometryMembers]);
|
||||
const styleKey = JSON.stringify([styleMembers, outlineColor.toCssHexString(), outlineColor.alpha]);
|
||||
const current = layers.get(binding.bindingId);
|
||||
if (!fillParts.length) {
|
||||
if (current) removeHGeoZoneLayer(viewer, current);
|
||||
layers.delete(binding.bindingId);
|
||||
continue;
|
||||
}
|
||||
if (current?.geometryKey === geometryKey) {
|
||||
current.hideCameraHeightMeters = hideCameraHeightMeters;
|
||||
if (current.styleKey !== styleKey) {
|
||||
const ready = [...current.fills, ...current.outlines].every((batch) => batch.primitive.ready);
|
||||
if (ready) {
|
||||
updateHGeoZoneColors(current.fills, fillParts);
|
||||
updateHGeoZoneColors(current.outlines, outlineParts);
|
||||
current.styleKey = styleKey;
|
||||
syncHGeoZoneVisibility(viewer, layers);
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
syncHGeoZoneVisibility(viewer, layers);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (current) removeHGeoZoneLayer(viewer, current);
|
||||
layers.set(binding.bindingId, createHGeoZoneLayer(
|
||||
viewer,
|
||||
geometryKey,
|
||||
styleKey,
|
||||
hideCameraHeightMeters,
|
||||
fillParts,
|
||||
outlineParts,
|
||||
outlineWidthPx,
|
||||
));
|
||||
}
|
||||
viewer.scene.requestRender();
|
||||
}
|
||||
|
||||
function syncRuntimeDataSources(
|
||||
viewer: Viewer,
|
||||
dataSources: Map<string, CustomDataSource>,
|
||||
hGeoZoneLayers: Map<string, HGeoZoneProjectionLayer>,
|
||||
bindings: MapRuntimeBinding[],
|
||||
presentationProfiles: MapPresentationProfile[],
|
||||
presentationFilters: MapPresentationFilters,
|
||||
|
|
@ -459,71 +714,46 @@ function syncRuntimeDataSources(
|
|||
|| (profile && profile.target.variant !== "surface-fill")
|
||||
) continue;
|
||||
const polygons = fact.geometry.type === "Polygon" ? [fact.geometry.coordinates] : fact.geometry.coordinates;
|
||||
for (const [polygonIndex, polygon] of polygons.entries()) {
|
||||
const entityId = polygonIndex === 0 ? baseEntityId : `${baseEntityId}:part:${polygonIndex}`;
|
||||
wanted.add(entityId);
|
||||
const outerRing = polygon[0];
|
||||
const toCartesian = (ring: Array<[number, number]>) => ring.map(([longitude, latitude]) => (
|
||||
Cartesian3.fromDegrees(longitude, latitude, 0)
|
||||
));
|
||||
const hierarchy = new PolygonHierarchy(
|
||||
toCartesian(outerRing),
|
||||
polygon.slice(1).map((ring) => new PolygonHierarchy(toCartesian(ring))),
|
||||
);
|
||||
const labelAnchor = outerRing.slice(0, -1).reduce(
|
||||
(accumulator, [longitude, latitude]) => [accumulator[0] + longitude, accumulator[1] + latitude] as [number, number],
|
||||
[0, 0] as [number, number],
|
||||
);
|
||||
const divisor = Math.max(1, outerRing.length - 1);
|
||||
const entity = dataSource.entities.getById(entityId) ?? dataSource.entities.add({ id: entityId });
|
||||
const target = profile?.target.variant === "surface-fill" ? profile.target : null;
|
||||
const visibleBelowLod = target ? showBelowCameraHeight(viewer, target.hideCameraHeightMeters) : true;
|
||||
entity.name = label;
|
||||
entity.position = new ConstantPositionProperty(Cartesian3.fromDegrees(labelAnchor[0] / divisor, labelAnchor[1] / divisor, 0));
|
||||
entity.point = undefined;
|
||||
entity.polygon = new PolygonGraphics({
|
||||
hierarchy,
|
||||
material: color,
|
||||
height: 0,
|
||||
heightReference: HeightReference.CLAMP_TO_GROUND,
|
||||
show: visibleBelowLod,
|
||||
});
|
||||
entity.polyline = new PolylineGraphics({
|
||||
positions: toCartesian(outerRing),
|
||||
width: target?.outlineWidthPx ?? 1.5,
|
||||
material: target
|
||||
? Color.fromCssColorString(target.outlineColor).withAlpha(target.outlineOpacity)
|
||||
: Color.fromCssColorString("#c9b6ff").withAlpha(0.9),
|
||||
clampToGround: true,
|
||||
show: visibleBelowLod,
|
||||
});
|
||||
entity.label = new LabelGraphics({
|
||||
text: label,
|
||||
font: profile ? `${profile.label.fontWeight} ${profile.label.sizePx}px Arial` : "700 13px Arial",
|
||||
fillColor: profile ? Color.fromCssColorString(profile.label.color) : Color.WHITE,
|
||||
outlineColor: profile ? Color.fromCssColorString(profile.label.outlineColor) : Color.BLACK,
|
||||
outlineWidth: profile?.label.outlineWidthPx ?? 1,
|
||||
style: 2,
|
||||
showBackground: (profile?.label.backgroundOpacity ?? 0.72) > 0,
|
||||
backgroundColor: profile
|
||||
? Color.fromCssColorString(profile.label.backgroundColor).withAlpha(profile.label.backgroundOpacity)
|
||||
: Color.BLACK.withAlpha(0.72),
|
||||
backgroundPadding: new Cartesian2(profile?.label.paddingX ?? 10, profile?.label.paddingY ?? 7),
|
||||
pixelOffset: new Cartesian2(profile?.label.offsetX ?? 10, profile?.label.offsetY ?? 0),
|
||||
horizontalOrigin: HorizontalOrigin.LEFT,
|
||||
verticalOrigin: VerticalOrigin.BOTTOM,
|
||||
heightReference: HeightReference.CLAMP_TO_GROUND,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
show: polygonIndex === 0
|
||||
&& (profile?.label.mode ?? "attributes") !== "none"
|
||||
&& (profile ? showBelowCameraHeight(viewer, profile.label.hideCameraHeightMeters) : true),
|
||||
});
|
||||
}
|
||||
const outerRing = polygons[0]?.[0] ?? [];
|
||||
if (!outerRing.length) continue;
|
||||
wanted.add(baseEntityId);
|
||||
const labelAnchor = outerRing.reduce(
|
||||
(accumulator, [longitude, latitude]) => [accumulator[0] + longitude, accumulator[1] + latitude] as [number, number],
|
||||
[0, 0] as [number, number],
|
||||
);
|
||||
const divisor = Math.max(1, outerRing.length);
|
||||
const entity = dataSource.entities.getById(baseEntityId) ?? dataSource.entities.add({ id: baseEntityId });
|
||||
entity.name = label;
|
||||
entity.position = new ConstantPositionProperty(Cartesian3.fromDegrees(labelAnchor[0] / divisor, labelAnchor[1] / divisor, 0));
|
||||
entity.point = undefined;
|
||||
entity.polygon = undefined;
|
||||
entity.polyline = undefined;
|
||||
entity.label = new LabelGraphics({
|
||||
text: label,
|
||||
font: profile ? `${profile.label.fontWeight} ${profile.label.sizePx}px Arial` : "700 13px Arial",
|
||||
fillColor: profile ? Color.fromCssColorString(profile.label.color) : Color.WHITE,
|
||||
outlineColor: profile ? Color.fromCssColorString(profile.label.outlineColor) : Color.BLACK,
|
||||
outlineWidth: profile?.label.outlineWidthPx ?? 1,
|
||||
style: 2,
|
||||
showBackground: (profile?.label.backgroundOpacity ?? 0.72) > 0,
|
||||
backgroundColor: profile
|
||||
? Color.fromCssColorString(profile.label.backgroundColor).withAlpha(profile.label.backgroundOpacity)
|
||||
: Color.BLACK.withAlpha(0.72),
|
||||
backgroundPadding: new Cartesian2(profile?.label.paddingX ?? 10, profile?.label.paddingY ?? 7),
|
||||
pixelOffset: new Cartesian2(profile?.label.offsetX ?? 10, profile?.label.offsetY ?? 0),
|
||||
horizontalOrigin: HorizontalOrigin.LEFT,
|
||||
verticalOrigin: VerticalOrigin.BOTTOM,
|
||||
heightReference: HeightReference.CLAMP_TO_GROUND,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
show: (profile?.label.mode ?? "attributes") !== "none"
|
||||
&& (profile ? showBelowCameraHeight(viewer, profile.label.hideCameraHeightMeters) : true),
|
||||
});
|
||||
}
|
||||
for (const entity of [...dataSource.entities.values]) {
|
||||
if (typeof entity.id === "string" && !wanted.has(entity.id)) dataSource.entities.remove(entity);
|
||||
}
|
||||
}
|
||||
syncHGeoZoneLayers(viewer, hGeoZoneLayers, bindings, presentationProfiles, presentationFilters);
|
||||
viewer.scene.requestRender();
|
||||
}
|
||||
|
||||
|
|
@ -681,6 +911,7 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
|||
const terrainRef = useRef<{ world: CesiumTerrainProvider | null; ellipsoid: EllipsoidTerrainProvider } | null>(null);
|
||||
const rebuildGridRef = useRef<(() => void) | null>(null);
|
||||
const runtimeDataSourcesRef = useRef(new Map<string, CustomDataSource>());
|
||||
const hGeoZoneLayersRef = useRef(new Map<string, HGeoZoneProjectionLayer>());
|
||||
const presentationRef = useRef(presentation);
|
||||
const runtimeBindingsRef = useRef(runtimeBindings);
|
||||
const presentationProfilesRef = useRef(presentationProfiles);
|
||||
|
|
@ -1119,6 +1350,7 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
|||
syncRuntimeDataSources(
|
||||
viewerRef.current,
|
||||
runtimeDataSourcesRef.current,
|
||||
hGeoZoneLayersRef.current,
|
||||
runtimeBindings,
|
||||
presentationProfiles,
|
||||
presentationFilters,
|
||||
|
|
@ -1204,6 +1436,7 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
|||
syncRuntimeDataSources(
|
||||
viewer,
|
||||
runtimeDataSourcesRef.current,
|
||||
hGeoZoneLayersRef.current,
|
||||
runtimeBindingsRef.current,
|
||||
presentationProfilesRef.current,
|
||||
presentationFiltersRef.current,
|
||||
|
|
@ -1214,6 +1447,7 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
|||
rebuildGridRef.current = rebuildGrid;
|
||||
removeGridCameraListener = viewer.camera.moveEnd.addEventListener(() => {
|
||||
rebuildGrid();
|
||||
syncHGeoZoneVisibility(viewer!, hGeoZoneLayersRef.current);
|
||||
onCameraChangeRef.current?.(getCameraView(viewer!));
|
||||
});
|
||||
|
||||
|
|
@ -1361,8 +1595,17 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
|||
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) onSelectRef.current?.(entity.id);
|
||||
const pickedId = picked?.id;
|
||||
if (pickedId instanceof Entity && pickedId.id) {
|
||||
onSelectRef.current?.(pickedId.id);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
pickedId
|
||||
&& typeof pickedId === "object"
|
||||
&& (pickedId as Partial<HGeoZonePickId>).kind === "nodedc-hgeozone"
|
||||
&& typeof (pickedId as Partial<HGeoZonePickId>).entityId === "string"
|
||||
) onSelectRef.current?.((pickedId as HGeoZonePickId).entityId);
|
||||
}, ScreenSpaceEventType.LEFT_CLICK);
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
if (!viewer || viewer.isDestroyed()) return;
|
||||
|
|
@ -1404,6 +1647,7 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
|||
terrainRef.current = null;
|
||||
rebuildGridRef.current = null;
|
||||
runtimeDataSourcesRef.current.clear();
|
||||
hGeoZoneLayersRef.current.clear();
|
||||
};
|
||||
}, [onGatewayHealth, onProviderStatus, stopSpiralAnimation]);
|
||||
|
||||
|
|
|
|||
|
|
@ -901,11 +901,25 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
|||
...presentationProfiles.flatMap((profile) => [
|
||||
{
|
||||
id: `map-target-${profile.id}`,
|
||||
label: profile.target.variant === "surface-fill" ? "Геозоны" : "Таргет",
|
||||
description: profile.title,
|
||||
label: profile.target.variant === "surface-fill" ? "HGeoZone" : "Таргет",
|
||||
description: profile.target.variant === "surface-fill" ? `проекция · ${profile.title}` : profile.title,
|
||||
group: profile.target.variant === "surface-fill" ? "Слои" : "Таргеты",
|
||||
content: <>
|
||||
<small className="catalog-map-inspector__note">Профиль принадлежит этой странице Application и управляется тем же provider-neutral MCP-контрактом. Исходный API в настройках отсутствует.</small>
|
||||
{profile.target.variant === "surface-fill" && <>
|
||||
<ControlRow label="Тип слоя"><strong>HGeoZone · ground projection</strong></ControlRow>
|
||||
{profile.styles.map((style) => {
|
||||
const classLabels = profile.classes.filter((item) => item.styleId === style.id).map((item) => item.label);
|
||||
const label = classLabels.length ? classLabels.join(" · ") : style.id;
|
||||
return <div className="catalog-map-inspector__style" key={style.id}>
|
||||
<ControlRow label={`Заливка · ${label}`}><ColorField label={`Цвет заливки: ${label}`} value={style.color} onChange={(color) => updatePresentationStyle(profile.id, style.id, { color })} /></ControlRow>
|
||||
<RangeControl label={`Прозрачность заливки · ${label}`} value={Math.round(style.opacity * 100)} min={0} max={100} step={1} formatValue={(value) => `${value}%`} onChange={(value) => updatePresentationStyle(profile.id, style.id, { opacity: value / 100 })} />
|
||||
</div>;
|
||||
})}
|
||||
<ControlRow label="Граница"><ColorField label="Цвет границы HGeoZone" value={profile.target.outlineColor} onChange={(outlineColor) => updatePresentationProfile(profile.id, (current) => current.target.variant === "surface-fill" ? ({ ...current, target: { ...current.target, outlineColor } }) : current)} /></ControlRow>
|
||||
<RangeControl label="Прозрачность границы" value={Math.round(profile.target.outlineOpacity * 100)} min={0} max={100} step={1} formatValue={(value) => `${value}%`} onChange={(value) => updatePresentationProfile(profile.id, (current) => current.target.variant === "surface-fill" ? ({ ...current, target: { ...current.target, outlineOpacity: value / 100 } }) : current)} />
|
||||
<RangeControl label="Толщина границы" value={profile.target.outlineWidthPx} min={0} max={8} step={0.5} formatValue={(value) => `${value} px`} onChange={(outlineWidthPx) => updatePresentationProfile(profile.id, (current) => current.target.variant === "surface-fill" ? ({ ...current, target: { ...current.target, outlineWidthPx } }) : current)} />
|
||||
</>}
|
||||
{profile.target.variant === "elevated-spike" && <>
|
||||
<RangeControl label="Высота таргета" value={profile.target.stemHeightMeters} min={100} max={10_000} step={50} formatValue={(value) => `${value} м`} onChange={(stemHeightMeters) => updatePresentationProfile(profile.id, (current) => current.target.variant === "elevated-spike" ? ({ ...current, target: { ...current.target, stemHeightMeters } }) : current)} />
|
||||
<RangeControl label="Размер головки" value={profile.target.headSizePx} min={1} max={32} step={1} formatValue={(value) => `${value} px`} onChange={(headSizePx) => updatePresentationProfile(profile.id, (current) => current.target.variant === "elevated-spike" ? ({ ...current, target: { ...current.target, headSizePx } }) : current)} />
|
||||
|
|
@ -925,15 +939,17 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
|||
<RangeControl label="Смещение подписи X" value={profile.label.offsetX} min={-100} max={100} step={1} formatValue={(value) => `${value} px`} onChange={(offsetX) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, offsetX } }))} />
|
||||
<RangeControl label="Смещение подписи Y" value={profile.label.offsetY} min={-100} max={100} step={1} formatValue={(value) => `${value} px`} onChange={(offsetY) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, offsetY } }))} />
|
||||
<RangeControl label="Скрывать подпись выше" value={profile.label.hideCameraHeightMeters} min={1_000} max={500_000} step={1_000} formatValue={(value) => `${Math.round(value / 1_000)} км`} onChange={(hideCameraHeightMeters) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, hideCameraHeightMeters } }))} />
|
||||
<RangeControl label="Скрывать таргет выше" value={profile.target.hideCameraHeightMeters} min={1_000} max={500_000} step={1_000} formatValue={(value) => `${Math.round(value / 1_000)} км`} onChange={(hideCameraHeightMeters) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, hideCameraHeightMeters } }))} />
|
||||
<RangeControl label={profile.target.variant === "surface-fill" ? "Скрывать HGeoZone выше" : "Скрывать таргет выше"} value={profile.target.hideCameraHeightMeters} min={1_000} max={500_000} step={1_000} formatValue={(value) => `${Math.round(value / 1_000)} км`} onChange={(hideCameraHeightMeters) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, hideCameraHeightMeters } }))} />
|
||||
<ControlRow label="Фон плашки"><ColorField label="Цвет фона подписи" value={profile.label.backgroundColor} onChange={(backgroundColor) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, backgroundColor } }))} /></ControlRow>
|
||||
<RangeControl label="Прозрачность плашки" value={Math.round(profile.label.backgroundOpacity * 100)} min={0} max={100} step={1} formatValue={(value) => `${value}%`} onChange={(value) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, backgroundOpacity: value / 100 } }))} />
|
||||
<ControlRow label="Обводка таргета"><ColorField label="Цвет обводки таргета" value={profile.target.outlineColor} onChange={(outlineColor) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, outlineColor } }))} /></ControlRow>
|
||||
<RangeControl label="Прозрачность обводки" value={Math.round(profile.target.outlineOpacity * 100)} min={0} max={100} step={1} formatValue={(value) => `${value}%`} onChange={(value) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, outlineOpacity: value / 100 } }))} />
|
||||
<RangeControl label="Толщина обводки" value={profile.target.outlineWidthPx} min={0} max={8} step={0.5} formatValue={(value) => `${value} px`} onChange={(outlineWidthPx) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, outlineWidthPx } }))} />
|
||||
{profile.target.variant === "elevated-spike" && <>
|
||||
<ControlRow label="Обводка таргета"><ColorField label="Цвет обводки таргета" value={profile.target.outlineColor} onChange={(outlineColor) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, outlineColor } }))} /></ControlRow>
|
||||
<RangeControl label="Прозрачность обводки" value={Math.round(profile.target.outlineOpacity * 100)} min={0} max={100} step={1} formatValue={(value) => `${value}%`} onChange={(value) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, outlineOpacity: value / 100 } }))} />
|
||||
<RangeControl label="Толщина обводки" value={profile.target.outlineWidthPx} min={0} max={8} step={0.5} formatValue={(value) => `${value} px`} onChange={(outlineWidthPx) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, outlineWidthPx } }))} />
|
||||
</>}
|
||||
</>,
|
||||
},
|
||||
{
|
||||
...(profile.target.variant === "surface-fill" ? [] : [{
|
||||
id: `map-state-classes-${profile.id}`,
|
||||
label: "Классы состояния",
|
||||
description: "нормализованные фасеты онтологии",
|
||||
|
|
@ -949,7 +965,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
|||
</div>;
|
||||
})}
|
||||
</>,
|
||||
},
|
||||
}]),
|
||||
]),
|
||||
{
|
||||
id: "map-grid",
|
||||
|
|
|
|||
|
|
@ -144,6 +144,22 @@ Endpoint response содержит публичный URL, attribution, тип
|
|||
|
||||
Master token или asset credential в этом URL отсутствует. Map Gateway добавляет credential server-side только после cache lookup и только если действительно нужен upstream request.
|
||||
|
||||
### 4.3 Live domain layers
|
||||
|
||||
Map data-product binding разрешён только для live-data слотов шаблона:
|
||||
`points` (`entity-stream`) и `zones` (`zone-stream`). Оба слота используют один
|
||||
provider-neutral snapshot/patch transport, но разные renderer adapters.
|
||||
|
||||
`map.zone` не превращается в Cesium-specific сущность и не получает отдельный
|
||||
ontology type `HGeoZone`. HGeoZone — имя проекционного adapter layer в Foundry:
|
||||
Polygon/MultiPolygon facts пакетируются группами ограниченного размера в
|
||||
`GroundPrimitive`, все кольца границ — в `GroundPolylinePrimitive`. Заливка,
|
||||
граница и их прозрачности читаются из versioned Map presentation profile.
|
||||
Per-entity `PolygonGraphics` для массового слоя геозон не является production
|
||||
путём. Stable runtime entity id сохраняется для selection/focus, а изменение
|
||||
цвета обновляет per-instance attributes без перестройки геометрии; изменение
|
||||
толщины границы или cursor создаёт новое bounded batch generation.
|
||||
|
||||
## 5. Credential model
|
||||
|
||||
### 5.1 Cesium Ion master token
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ Engine/NDC остаётся средой создания и исполнени
|
|||
- ready/loading/empty/stale/error/offline states.
|
||||
|
||||
Размеры и внешний вид разновидностей сущностей задаются ссылками на
|
||||
`styleProfiles`. Для live entity-stream Application page хранит отдельные
|
||||
`styleProfiles`. Для live `entity-stream` и `zone-stream` Application page хранит отдельные
|
||||
versioned `presentationProfiles`, а binding выбирает один из них через
|
||||
`presentationProfileId`. Это позволяет одному доменному типу иметь разные
|
||||
подтверждённые варианты, не создавая новый renderer-specific тип.
|
||||
|
|
@ -89,17 +89,17 @@ Runtime tile cache не является исходным кодом и не х
|
|||
Дальше:
|
||||
|
||||
1. уточнить единый Label Style contract и варианты размеров уже на живой сцене;
|
||||
2. спроектировать batching/instancing contract для больших потоков объектов и геозон;
|
||||
2. расширить существующий HGeoZone batching contract явной spatial tiling-политикой для десятков тысяч геозон;
|
||||
3. вынести development gateway в `platform/services/map-gateway` и подключить persistent cache;
|
||||
4. добавить реальный Gaussian Splat 3D Tiles acceptance asset;
|
||||
5. расширить entity-stream adapter с `points` на traces, routes и zones;
|
||||
5. добавить специализированные adapters для traces и routes;
|
||||
6. оформить renderer adapter capability matrix для Cesium и будущих providers.
|
||||
|
||||
## Live data-product runtime
|
||||
|
||||
`Map Page` хранит только provider-neutral binding: `dataProductId`, approved
|
||||
semantic types, field projection, `presentationProfileId` и `slotId` (`points`
|
||||
для live point entities).
|
||||
semantic types, field projection, `presentationProfileId` и `slotId`: `points`
|
||||
для live point entities или `zones` для polygon/multipolygon `map.zone`.
|
||||
При открытии Application page browser делает same-origin запрос к Foundry:
|
||||
|
||||
```text
|
||||
|
|
@ -122,8 +122,13 @@ events с exact cursor и только после commit делает fan-out в
|
|||
создают несколько upstream EDP subscriptions. Timeline использует provider-neutral
|
||||
history route (`from/to/resolution/sourceIds/cursor`) через тот же BFF и не
|
||||
занимает общую L2 execution queue. Renderer держит отдельный `CustomDataSource`
|
||||
на binding и обновляет stable entity id без пересоздания viewer. Sampling и
|
||||
retention остаются политикой Data Plane, а не Map Template.
|
||||
на point binding и обновляет stable entity id без пересоздания viewer. `zones`
|
||||
рендерятся отдельным HGeoZone ground-projection layer: Polygon/MultiPolygon
|
||||
пакуются ограниченными batches в Cesium ground primitives, а границы — в
|
||||
ground-polyline primitives. Это renderer adapter, не новый тип онтологии:
|
||||
источником истины остаётся `map.zone`, а цвета и прозрачности остаются в
|
||||
provider-neutral presentation profile. Sampling и retention остаются политикой
|
||||
Data Plane, а не Map Template.
|
||||
|
||||
`fleet.positions.current.v2@2.0.0` является первым state-aware moving-object
|
||||
контрактом. Пользовательский status contract ограничен
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { createFoundryAgentStore } from "./foundry-agent-store.mjs";
|
|||
import { createFoundryDataProductConsumerManager } from "./foundry-data-product-consumer.mjs";
|
||||
import { createFoundryReaderGrantProvisioner } from "./foundry-reader-grant-provisioner.mjs";
|
||||
import { handleFoundryEntitlementRequest, handleFoundryMcpRequest } from "./foundry-mcp.mjs";
|
||||
import { isMapLiveDataSlot } from "./map-live-data-slot.mjs";
|
||||
import { normalizeMapPresentationProfile, normalizeMapPresentationProfiles } from "./map-presentation-profile.mjs";
|
||||
import { createFoundryAuth } from "./nodedc-auth.mjs";
|
||||
|
||||
|
|
@ -1049,7 +1050,7 @@ async function resolveRuntimeMapDataProductBinding(applicationId, pageId, bindin
|
|||
const binding = layout.dataProductBindings.find((candidate) => candidate.id === bindingId);
|
||||
if (!binding) throw applicationError("data_product_binding_not_found", 404);
|
||||
const slot = template?.slots?.find((candidate) => candidate.id === binding.slotId);
|
||||
if (!slot || slot.kind !== "entity-stream") throw applicationError("map_entity_stream_slot_required", 409);
|
||||
if (!isMapLiveDataSlot(slot)) throw applicationError("map_live_data_slot_required", 409);
|
||||
return { application, page, binding };
|
||||
}
|
||||
|
||||
|
|
@ -2076,7 +2077,7 @@ const foundryMcpOperations = {
|
|||
if (page.template?.id !== "map") throw applicationError("map_page_required");
|
||||
const template = findPageTemplate(page.template.id, page.template.version);
|
||||
const slot = template?.slots?.find((candidate) => candidate.id === binding.slotId);
|
||||
if (!slot || slot.kind !== "entity-stream") throw applicationError("map_entity_stream_slot_required");
|
||||
if (!isMapLiveDataSlot(slot)) throw applicationError("map_live_data_slot_required");
|
||||
const layout = validateMapPageLayout(page.layout?.map || defaultMapPageLayout());
|
||||
const existingBinding = layout.dataProductBindings.find((item) => item.id === binding.id);
|
||||
const resolvedBinding = {
|
||||
|
|
|
|||
|
|
@ -678,7 +678,7 @@ function zoneV2Target(fieldProjection = zoneV2Projection) {
|
|||
binding: {
|
||||
id: "depttrans-pmd-slow-zones",
|
||||
dataProductId: zoneV2Product.id,
|
||||
slotId: "points",
|
||||
slotId: "zones",
|
||||
delivery: "snapshot+patch",
|
||||
semanticTypes: ["map.zone"],
|
||||
fieldProjection: [...fieldProjection],
|
||||
|
|
|
|||
|
|
@ -608,7 +608,7 @@ const tools = [
|
|||
{
|
||||
name: "foundry_upsert_map_data_product_binding",
|
||||
title: "Upsert Map data product binding",
|
||||
description: "Bind one approved provider-neutral data product to an entity-stream slot of a Map Page instance. The binding stores no provider transport, endpoint, tenant, connection or credential.",
|
||||
description: "Bind one approved provider-neutral data product to a live-data slot (points or zones) of a Map Page instance. The binding stores no provider transport, endpoint, tenant, connection or credential.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
|
|
@ -625,7 +625,7 @@ const tools = [
|
|||
displayName: { type: "string", minLength: 1, maxLength: 120, description: "User-defined text label shown in the Objects menu and window header." },
|
||||
order: { type: "integer", minimum: 0, maximum: 10000, description: "Application composition order inside the Objects menu." },
|
||||
dataProductId: { type: "string", description: "Versioned provider-neutral product, for example fleet.positions.current.v1." },
|
||||
slotId: { type: "string", description: "Approved Map Page entity-stream slot." },
|
||||
slotId: { type: "string", description: "Approved Map Page live-data slot (entity-stream or zone-stream)." },
|
||||
semanticTypes: { type: "array", items: { type: "string" } },
|
||||
fieldProjection: { type: "array", items: { type: "string" } },
|
||||
presentationProfileId: { type: "string", description: "Existing page-owned provider-neutral Map presentation profile id." },
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
const MAP_LIVE_DATA_SLOT_KINDS = new Set(["entity-stream", "zone-stream"]);
|
||||
|
||||
/**
|
||||
* Map runtime bindings may only target slots that deliver canonical live
|
||||
* entity facts. Point entities and zones have distinct template slots, but
|
||||
* share the same provider-neutral snapshot/patch transport contract.
|
||||
*/
|
||||
export function isMapLiveDataSlot(slot) {
|
||||
return Boolean(slot && MAP_LIVE_DATA_SLOT_KINDS.has(slot.kind));
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { isMapLiveDataSlot } from "./map-live-data-slot.mjs";
|
||||
|
||||
test("point and zone streams are approved Map live-data slots", () => {
|
||||
assert.equal(isMapLiveDataSlot({ id: "points", kind: "entity-stream" }), true);
|
||||
assert.equal(isMapLiveDataSlot({ id: "zones", kind: "zone-stream" }), true);
|
||||
});
|
||||
|
||||
test("non-entity Map slots cannot receive a data-product binding", () => {
|
||||
for (const kind of ["trace-stream", "route-stream", "selection", "command", "assistant", undefined]) {
|
||||
assert.equal(isMapLiveDataSlot(kind ? { id: "other", kind } : null), false);
|
||||
}
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue