feat(foundry): complete HGeoZone live layer
This commit is contained in:
parent
de060c105b
commit
309cddef73
|
|
@ -12,11 +12,17 @@ import {
|
||||||
CallbackProperty,
|
CallbackProperty,
|
||||||
CallbackPositionProperty,
|
CallbackPositionProperty,
|
||||||
Cartographic,
|
Cartographic,
|
||||||
|
ClassificationType,
|
||||||
|
ColorGeometryInstanceAttribute,
|
||||||
ConstantPositionProperty,
|
ConstantPositionProperty,
|
||||||
CustomDataSource,
|
CustomDataSource,
|
||||||
DefaultProxy,
|
DefaultProxy,
|
||||||
EllipsoidTerrainProvider,
|
EllipsoidTerrainProvider,
|
||||||
Entity,
|
Entity,
|
||||||
|
GeometryInstance,
|
||||||
|
GroundPolylineGeometry,
|
||||||
|
GroundPolylinePrimitive,
|
||||||
|
GroundPrimitive,
|
||||||
HeightReference,
|
HeightReference,
|
||||||
HeadingPitchRange,
|
HeadingPitchRange,
|
||||||
HorizontalOrigin,
|
HorizontalOrigin,
|
||||||
|
|
@ -25,9 +31,11 @@ import {
|
||||||
LabelGraphics,
|
LabelGraphics,
|
||||||
Matrix4,
|
Matrix4,
|
||||||
Math as CesiumMath,
|
Math as CesiumMath,
|
||||||
|
PerInstanceColorAppearance,
|
||||||
PointGraphics,
|
PointGraphics,
|
||||||
PolygonGraphics,
|
PolygonGeometry,
|
||||||
PolygonHierarchy,
|
PolygonHierarchy,
|
||||||
|
PolylineColorAppearance,
|
||||||
PolylineGraphics,
|
PolylineGraphics,
|
||||||
Resource,
|
Resource,
|
||||||
sampleTerrainMostDetailed,
|
sampleTerrainMostDetailed,
|
||||||
|
|
@ -54,6 +62,7 @@ import {
|
||||||
const MAX_SPIRAL_SUBSTEPS_PER_FRAME = 300;
|
const MAX_SPIRAL_SUBSTEPS_PER_FRAME = 300;
|
||||||
const TERRAIN_SAMPLE_TIMEOUT_MS = 12_000;
|
const TERRAIN_SAMPLE_TIMEOUT_MS = 12_000;
|
||||||
const SPIRAL_TILE_WAIT_TIMEOUT_MS = 45_000;
|
const SPIRAL_TILE_WAIT_TIMEOUT_MS = 45_000;
|
||||||
|
const MAX_HGEOZONE_INSTANCES_PER_BATCH = 256;
|
||||||
type RuntimeConfig = {
|
type RuntimeConfig = {
|
||||||
cesiumVersion: string;
|
cesiumVersion: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
|
|
@ -325,9 +334,255 @@ function runtimePointColor(fact: MapRuntimeFact) {
|
||||||
return fact.semanticType === "map.moving_object" ? accent : violet;
|
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(
|
function syncRuntimeDataSources(
|
||||||
viewer: Viewer,
|
viewer: Viewer,
|
||||||
dataSources: Map<string, CustomDataSource>,
|
dataSources: Map<string, CustomDataSource>,
|
||||||
|
hGeoZoneLayers: Map<string, HGeoZoneProjectionLayer>,
|
||||||
bindings: MapRuntimeBinding[],
|
bindings: MapRuntimeBinding[],
|
||||||
presentationProfiles: MapPresentationProfile[],
|
presentationProfiles: MapPresentationProfile[],
|
||||||
presentationFilters: MapPresentationFilters,
|
presentationFilters: MapPresentationFilters,
|
||||||
|
|
@ -459,44 +714,20 @@ function syncRuntimeDataSources(
|
||||||
|| (profile && profile.target.variant !== "surface-fill")
|
|| (profile && profile.target.variant !== "surface-fill")
|
||||||
) continue;
|
) continue;
|
||||||
const polygons = fact.geometry.type === "Polygon" ? [fact.geometry.coordinates] : fact.geometry.coordinates;
|
const polygons = fact.geometry.type === "Polygon" ? [fact.geometry.coordinates] : fact.geometry.coordinates;
|
||||||
for (const [polygonIndex, polygon] of polygons.entries()) {
|
const outerRing = polygons[0]?.[0] ?? [];
|
||||||
const entityId = polygonIndex === 0 ? baseEntityId : `${baseEntityId}:part:${polygonIndex}`;
|
if (!outerRing.length) continue;
|
||||||
wanted.add(entityId);
|
wanted.add(baseEntityId);
|
||||||
const outerRing = polygon[0];
|
const labelAnchor = outerRing.reduce(
|
||||||
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],
|
(accumulator, [longitude, latitude]) => [accumulator[0] + longitude, accumulator[1] + latitude] as [number, number],
|
||||||
[0, 0] as [number, number],
|
[0, 0] as [number, number],
|
||||||
);
|
);
|
||||||
const divisor = Math.max(1, outerRing.length - 1);
|
const divisor = Math.max(1, outerRing.length);
|
||||||
const entity = dataSource.entities.getById(entityId) ?? dataSource.entities.add({ id: entityId });
|
const entity = dataSource.entities.getById(baseEntityId) ?? dataSource.entities.add({ id: baseEntityId });
|
||||||
const target = profile?.target.variant === "surface-fill" ? profile.target : null;
|
|
||||||
const visibleBelowLod = target ? showBelowCameraHeight(viewer, target.hideCameraHeightMeters) : true;
|
|
||||||
entity.name = label;
|
entity.name = label;
|
||||||
entity.position = new ConstantPositionProperty(Cartesian3.fromDegrees(labelAnchor[0] / divisor, labelAnchor[1] / divisor, 0));
|
entity.position = new ConstantPositionProperty(Cartesian3.fromDegrees(labelAnchor[0] / divisor, labelAnchor[1] / divisor, 0));
|
||||||
entity.point = undefined;
|
entity.point = undefined;
|
||||||
entity.polygon = new PolygonGraphics({
|
entity.polygon = undefined;
|
||||||
hierarchy,
|
entity.polyline = undefined;
|
||||||
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({
|
entity.label = new LabelGraphics({
|
||||||
text: label,
|
text: label,
|
||||||
font: profile ? `${profile.label.fontWeight} ${profile.label.sizePx}px Arial` : "700 13px Arial",
|
font: profile ? `${profile.label.fontWeight} ${profile.label.sizePx}px Arial` : "700 13px Arial",
|
||||||
|
|
@ -514,16 +745,15 @@ function syncRuntimeDataSources(
|
||||||
verticalOrigin: VerticalOrigin.BOTTOM,
|
verticalOrigin: VerticalOrigin.BOTTOM,
|
||||||
heightReference: HeightReference.CLAMP_TO_GROUND,
|
heightReference: HeightReference.CLAMP_TO_GROUND,
|
||||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||||
show: polygonIndex === 0
|
show: (profile?.label.mode ?? "attributes") !== "none"
|
||||||
&& (profile?.label.mode ?? "attributes") !== "none"
|
|
||||||
&& (profile ? showBelowCameraHeight(viewer, profile.label.hideCameraHeightMeters) : true),
|
&& (profile ? showBelowCameraHeight(viewer, profile.label.hideCameraHeightMeters) : true),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
for (const entity of [...dataSource.entities.values]) {
|
for (const entity of [...dataSource.entities.values]) {
|
||||||
if (typeof entity.id === "string" && !wanted.has(entity.id)) dataSource.entities.remove(entity);
|
if (typeof entity.id === "string" && !wanted.has(entity.id)) dataSource.entities.remove(entity);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
syncHGeoZoneLayers(viewer, hGeoZoneLayers, bindings, presentationProfiles, presentationFilters);
|
||||||
viewer.scene.requestRender();
|
viewer.scene.requestRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -681,6 +911,7 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||||
const terrainRef = useRef<{ world: CesiumTerrainProvider | null; ellipsoid: EllipsoidTerrainProvider } | null>(null);
|
const terrainRef = useRef<{ world: CesiumTerrainProvider | null; ellipsoid: EllipsoidTerrainProvider } | null>(null);
|
||||||
const rebuildGridRef = useRef<(() => void) | null>(null);
|
const rebuildGridRef = useRef<(() => void) | null>(null);
|
||||||
const runtimeDataSourcesRef = useRef(new Map<string, CustomDataSource>());
|
const runtimeDataSourcesRef = useRef(new Map<string, CustomDataSource>());
|
||||||
|
const hGeoZoneLayersRef = useRef(new Map<string, HGeoZoneProjectionLayer>());
|
||||||
const presentationRef = useRef(presentation);
|
const presentationRef = useRef(presentation);
|
||||||
const runtimeBindingsRef = useRef(runtimeBindings);
|
const runtimeBindingsRef = useRef(runtimeBindings);
|
||||||
const presentationProfilesRef = useRef(presentationProfiles);
|
const presentationProfilesRef = useRef(presentationProfiles);
|
||||||
|
|
@ -1119,6 +1350,7 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||||
syncRuntimeDataSources(
|
syncRuntimeDataSources(
|
||||||
viewerRef.current,
|
viewerRef.current,
|
||||||
runtimeDataSourcesRef.current,
|
runtimeDataSourcesRef.current,
|
||||||
|
hGeoZoneLayersRef.current,
|
||||||
runtimeBindings,
|
runtimeBindings,
|
||||||
presentationProfiles,
|
presentationProfiles,
|
||||||
presentationFilters,
|
presentationFilters,
|
||||||
|
|
@ -1204,6 +1436,7 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||||
syncRuntimeDataSources(
|
syncRuntimeDataSources(
|
||||||
viewer,
|
viewer,
|
||||||
runtimeDataSourcesRef.current,
|
runtimeDataSourcesRef.current,
|
||||||
|
hGeoZoneLayersRef.current,
|
||||||
runtimeBindingsRef.current,
|
runtimeBindingsRef.current,
|
||||||
presentationProfilesRef.current,
|
presentationProfilesRef.current,
|
||||||
presentationFiltersRef.current,
|
presentationFiltersRef.current,
|
||||||
|
|
@ -1214,6 +1447,7 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||||
rebuildGridRef.current = rebuildGrid;
|
rebuildGridRef.current = rebuildGrid;
|
||||||
removeGridCameraListener = viewer.camera.moveEnd.addEventListener(() => {
|
removeGridCameraListener = viewer.camera.moveEnd.addEventListener(() => {
|
||||||
rebuildGrid();
|
rebuildGrid();
|
||||||
|
syncHGeoZoneVisibility(viewer!, hGeoZoneLayersRef.current);
|
||||||
onCameraChangeRef.current?.(getCameraView(viewer!));
|
onCameraChangeRef.current?.(getCameraView(viewer!));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1361,8 +1595,17 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||||
handler = new ScreenSpaceEventHandler(viewer.scene.canvas);
|
handler = new ScreenSpaceEventHandler(viewer.scene.canvas);
|
||||||
handler.setInputAction((movement: { position: Cartesian2 }) => {
|
handler.setInputAction((movement: { position: Cartesian2 }) => {
|
||||||
const picked = viewer?.scene.pick(movement.position);
|
const picked = viewer?.scene.pick(movement.position);
|
||||||
const entity = picked?.id instanceof Entity ? picked.id : undefined;
|
const pickedId = picked?.id;
|
||||||
if (entity?.id) onSelectRef.current?.(entity.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);
|
}, ScreenSpaceEventType.LEFT_CLICK);
|
||||||
resizeObserver = new ResizeObserver(() => {
|
resizeObserver = new ResizeObserver(() => {
|
||||||
if (!viewer || viewer.isDestroyed()) return;
|
if (!viewer || viewer.isDestroyed()) return;
|
||||||
|
|
@ -1404,6 +1647,7 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||||
terrainRef.current = null;
|
terrainRef.current = null;
|
||||||
rebuildGridRef.current = null;
|
rebuildGridRef.current = null;
|
||||||
runtimeDataSourcesRef.current.clear();
|
runtimeDataSourcesRef.current.clear();
|
||||||
|
hGeoZoneLayersRef.current.clear();
|
||||||
};
|
};
|
||||||
}, [onGatewayHealth, onProviderStatus, stopSpiralAnimation]);
|
}, [onGatewayHealth, onProviderStatus, stopSpiralAnimation]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -901,11 +901,25 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||||
...presentationProfiles.flatMap((profile) => [
|
...presentationProfiles.flatMap((profile) => [
|
||||||
{
|
{
|
||||||
id: `map-target-${profile.id}`,
|
id: `map-target-${profile.id}`,
|
||||||
label: profile.target.variant === "surface-fill" ? "Геозоны" : "Таргет",
|
label: profile.target.variant === "surface-fill" ? "HGeoZone" : "Таргет",
|
||||||
description: profile.title,
|
description: profile.target.variant === "surface-fill" ? `проекция · ${profile.title}` : profile.title,
|
||||||
group: profile.target.variant === "surface-fill" ? "Слои" : "Таргеты",
|
group: profile.target.variant === "surface-fill" ? "Слои" : "Таргеты",
|
||||||
content: <>
|
content: <>
|
||||||
<small className="catalog-map-inspector__note">Профиль принадлежит этой странице Application и управляется тем же provider-neutral MCP-контрактом. Исходный API в настройках отсутствует.</small>
|
<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" && <>
|
{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.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)} />
|
<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="Смещение подписи 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="Смещение подписи 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.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>
|
<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 } }))} />
|
<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 } }))} />
|
||||||
|
{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>
|
<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={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 } }))} />
|
<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}`,
|
id: `map-state-classes-${profile.id}`,
|
||||||
label: "Классы состояния",
|
label: "Классы состояния",
|
||||||
description: "нормализованные фасеты онтологии",
|
description: "нормализованные фасеты онтологии",
|
||||||
|
|
@ -949,7 +965,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||||
</div>;
|
</div>;
|
||||||
})}
|
})}
|
||||||
</>,
|
</>,
|
||||||
},
|
}]),
|
||||||
]),
|
]),
|
||||||
{
|
{
|
||||||
id: "map-grid",
|
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.
|
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. Credential model
|
||||||
|
|
||||||
### 5.1 Cesium Ion master token
|
### 5.1 Cesium Ion master token
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ Engine/NDC остаётся средой создания и исполнени
|
||||||
- ready/loading/empty/stale/error/offline states.
|
- ready/loading/empty/stale/error/offline states.
|
||||||
|
|
||||||
Размеры и внешний вид разновидностей сущностей задаются ссылками на
|
Размеры и внешний вид разновидностей сущностей задаются ссылками на
|
||||||
`styleProfiles`. Для live entity-stream Application page хранит отдельные
|
`styleProfiles`. Для live `entity-stream` и `zone-stream` Application page хранит отдельные
|
||||||
versioned `presentationProfiles`, а binding выбирает один из них через
|
versioned `presentationProfiles`, а binding выбирает один из них через
|
||||||
`presentationProfileId`. Это позволяет одному доменному типу иметь разные
|
`presentationProfileId`. Это позволяет одному доменному типу иметь разные
|
||||||
подтверждённые варианты, не создавая новый renderer-specific тип.
|
подтверждённые варианты, не создавая новый renderer-specific тип.
|
||||||
|
|
@ -89,17 +89,17 @@ Runtime tile cache не является исходным кодом и не х
|
||||||
Дальше:
|
Дальше:
|
||||||
|
|
||||||
1. уточнить единый Label Style contract и варианты размеров уже на живой сцене;
|
1. уточнить единый Label Style contract и варианты размеров уже на живой сцене;
|
||||||
2. спроектировать batching/instancing contract для больших потоков объектов и геозон;
|
2. расширить существующий HGeoZone batching contract явной spatial tiling-политикой для десятков тысяч геозон;
|
||||||
3. вынести development gateway в `platform/services/map-gateway` и подключить persistent cache;
|
3. вынести development gateway в `platform/services/map-gateway` и подключить persistent cache;
|
||||||
4. добавить реальный Gaussian Splat 3D Tiles acceptance asset;
|
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.
|
6. оформить renderer adapter capability matrix для Cesium и будущих providers.
|
||||||
|
|
||||||
## Live data-product runtime
|
## Live data-product runtime
|
||||||
|
|
||||||
`Map Page` хранит только provider-neutral binding: `dataProductId`, approved
|
`Map Page` хранит только provider-neutral binding: `dataProductId`, approved
|
||||||
semantic types, field projection, `presentationProfileId` и `slotId` (`points`
|
semantic types, field projection, `presentationProfileId` и `slotId`: `points`
|
||||||
для live point entities).
|
для live point entities или `zones` для polygon/multipolygon `map.zone`.
|
||||||
При открытии Application page browser делает same-origin запрос к Foundry:
|
При открытии Application page browser делает same-origin запрос к Foundry:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
@ -122,8 +122,13 @@ events с exact cursor и только после commit делает fan-out в
|
||||||
создают несколько upstream EDP subscriptions. Timeline использует provider-neutral
|
создают несколько upstream EDP subscriptions. Timeline использует provider-neutral
|
||||||
history route (`from/to/resolution/sourceIds/cursor`) через тот же BFF и не
|
history route (`from/to/resolution/sourceIds/cursor`) через тот же BFF и не
|
||||||
занимает общую L2 execution queue. Renderer держит отдельный `CustomDataSource`
|
занимает общую L2 execution queue. Renderer держит отдельный `CustomDataSource`
|
||||||
на binding и обновляет stable entity id без пересоздания viewer. Sampling и
|
на point binding и обновляет stable entity id без пересоздания viewer. `zones`
|
||||||
retention остаются политикой Data Plane, а не Map Template.
|
рендерятся отдельным 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
|
`fleet.positions.current.v2@2.0.0` является первым state-aware moving-object
|
||||||
контрактом. Пользовательский status contract ограничен
|
контрактом. Пользовательский status contract ограничен
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { createFoundryAgentStore } from "./foundry-agent-store.mjs";
|
||||||
import { createFoundryDataProductConsumerManager } from "./foundry-data-product-consumer.mjs";
|
import { createFoundryDataProductConsumerManager } from "./foundry-data-product-consumer.mjs";
|
||||||
import { createFoundryReaderGrantProvisioner } from "./foundry-reader-grant-provisioner.mjs";
|
import { createFoundryReaderGrantProvisioner } from "./foundry-reader-grant-provisioner.mjs";
|
||||||
import { handleFoundryEntitlementRequest, handleFoundryMcpRequest } from "./foundry-mcp.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 { normalizeMapPresentationProfile, normalizeMapPresentationProfiles } from "./map-presentation-profile.mjs";
|
||||||
import { createFoundryAuth } from "./nodedc-auth.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);
|
const binding = layout.dataProductBindings.find((candidate) => candidate.id === bindingId);
|
||||||
if (!binding) throw applicationError("data_product_binding_not_found", 404);
|
if (!binding) throw applicationError("data_product_binding_not_found", 404);
|
||||||
const slot = template?.slots?.find((candidate) => candidate.id === binding.slotId);
|
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 };
|
return { application, page, binding };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2076,7 +2077,7 @@ const foundryMcpOperations = {
|
||||||
if (page.template?.id !== "map") throw applicationError("map_page_required");
|
if (page.template?.id !== "map") throw applicationError("map_page_required");
|
||||||
const template = findPageTemplate(page.template.id, page.template.version);
|
const template = findPageTemplate(page.template.id, page.template.version);
|
||||||
const slot = template?.slots?.find((candidate) => candidate.id === binding.slotId);
|
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 layout = validateMapPageLayout(page.layout?.map || defaultMapPageLayout());
|
||||||
const existingBinding = layout.dataProductBindings.find((item) => item.id === binding.id);
|
const existingBinding = layout.dataProductBindings.find((item) => item.id === binding.id);
|
||||||
const resolvedBinding = {
|
const resolvedBinding = {
|
||||||
|
|
|
||||||
|
|
@ -678,7 +678,7 @@ function zoneV2Target(fieldProjection = zoneV2Projection) {
|
||||||
binding: {
|
binding: {
|
||||||
id: "depttrans-pmd-slow-zones",
|
id: "depttrans-pmd-slow-zones",
|
||||||
dataProductId: zoneV2Product.id,
|
dataProductId: zoneV2Product.id,
|
||||||
slotId: "points",
|
slotId: "zones",
|
||||||
delivery: "snapshot+patch",
|
delivery: "snapshot+patch",
|
||||||
semanticTypes: ["map.zone"],
|
semanticTypes: ["map.zone"],
|
||||||
fieldProjection: [...fieldProjection],
|
fieldProjection: [...fieldProjection],
|
||||||
|
|
|
||||||
|
|
@ -608,7 +608,7 @@ const tools = [
|
||||||
{
|
{
|
||||||
name: "foundry_upsert_map_data_product_binding",
|
name: "foundry_upsert_map_data_product_binding",
|
||||||
title: "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: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
additionalProperties: false,
|
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." },
|
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." },
|
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." },
|
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" } },
|
semanticTypes: { type: "array", items: { type: "string" } },
|
||||||
fieldProjection: { type: "array", items: { type: "string" } },
|
fieldProjection: { type: "array", items: { type: "string" } },
|
||||||
presentationProfileId: { type: "string", description: "Existing page-owned provider-neutral Map presentation profile id." },
|
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