From 309cddef731be9c43abfe1e7332825f81f3ccf00 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 22 Jul 2026 09:25:32 +0300 Subject: [PATCH] feat(foundry): complete HGeoZone live layer --- apps/catalog/src/CesiumMapRenderer.tsx | 370 +++++++++++++++--- apps/catalog/src/MapFixturePreview.tsx | 32 +- docs/FOUNDRY_MAP_CESIUM_CANON.md | 16 + docs/MAP_TEMPLATE.md | 19 +- server/catalog-server.mjs | 5 +- server/foundry-data-product-consumer.test.mjs | 2 +- server/foundry-mcp.mjs | 4 +- server/map-live-data-slot.mjs | 11 + server/map-live-data-slot.test.mjs | 15 + 9 files changed, 391 insertions(+), 83 deletions(-) create mode 100644 server/map-live-data-slot.mjs create mode 100644 server/map-live-data-slot.test.mjs diff --git a/apps/catalog/src/CesiumMapRenderer.tsx b/apps/catalog/src/CesiumMapRenderer.tsx index accdd65..1486573 100644 --- a/apps/catalog/src/CesiumMapRenderer.tsx +++ b/apps/catalog/src/CesiumMapRenderer.tsx @@ -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 = { + primitive: GroundPrimitive | GroundPolylinePrimitive; + parts: TPart[]; +}; + +type HGeoZoneProjectionLayer = { + geometryKey: string; + styleKey: string; + hideCameraHeightMeters: number | null; + fills: Array>; + outlines: Array>; +}; + +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>) { + return new PolygonHierarchy( + hGeoZoneRingPositions(polygon[0]), + polygon.slice(1).map((ring) => new PolygonHierarchy(hGeoZoneRingPositions(ring))), + ); +} + +function hGeoZoneBatches(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) { + 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( + batches: Array>, + 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, + 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, + hGeoZoneLayers: Map, 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(null); const rebuildGridRef = useRef<(() => void) | null>(null); const runtimeDataSourcesRef = useRef(new Map()); + const hGeoZoneLayersRef = useRef(new Map()); const presentationRef = useRef(presentation); const runtimeBindingsRef = useRef(runtimeBindings); const presentationProfilesRef = useRef(presentationProfiles); @@ -1119,6 +1350,7 @@ export const CesiumMapRenderer = forwardRef { rebuildGrid(); + syncHGeoZoneVisibility(viewer!, hGeoZoneLayersRef.current); onCameraChangeRef.current?.(getCameraView(viewer!)); }); @@ -1361,8 +1595,17 @@ export const CesiumMapRenderer = forwardRef { 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).kind === "nodedc-hgeozone" + && typeof (pickedId as Partial).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 [ { 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: <> Профиль принадлежит этой странице Application и управляется тем же provider-neutral MCP-контрактом. Исходный API в настройках отсутствует. + {profile.target.variant === "surface-fill" && <> + HGeoZone · ground projection + {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
+ updatePresentationStyle(profile.id, style.id, { color })} /> + `${value}%`} onChange={(value) => updatePresentationStyle(profile.id, style.id, { opacity: value / 100 })} /> +
; + })} + updatePresentationProfile(profile.id, (current) => current.target.variant === "surface-fill" ? ({ ...current, target: { ...current.target, outlineColor } }) : current)} /> + `${value}%`} onChange={(value) => updatePresentationProfile(profile.id, (current) => current.target.variant === "surface-fill" ? ({ ...current, target: { ...current.target, outlineOpacity: value / 100 } }) : current)} /> + `${value} px`} onChange={(outlineWidthPx) => updatePresentationProfile(profile.id, (current) => current.target.variant === "surface-fill" ? ({ ...current, target: { ...current.target, outlineWidthPx } }) : current)} /> + } {profile.target.variant === "elevated-spike" && <> `${value} м`} onChange={(stemHeightMeters) => updatePresentationProfile(profile.id, (current) => current.target.variant === "elevated-spike" ? ({ ...current, target: { ...current.target, stemHeightMeters } }) : current)} /> `${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 `${value} px`} onChange={(offsetX) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, offsetX } }))} /> `${value} px`} onChange={(offsetY) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, offsetY } }))} /> `${Math.round(value / 1_000)} км`} onChange={(hideCameraHeightMeters) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, hideCameraHeightMeters } }))} /> - `${Math.round(value / 1_000)} км`} onChange={(hideCameraHeightMeters) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, hideCameraHeightMeters } }))} /> + `${Math.round(value / 1_000)} км`} onChange={(hideCameraHeightMeters) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, hideCameraHeightMeters } }))} /> updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, backgroundColor } }))} /> `${value}%`} onChange={(value) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, backgroundOpacity: value / 100 } }))} /> - updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, outlineColor } }))} /> - `${value}%`} onChange={(value) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, outlineOpacity: value / 100 } }))} /> - `${value} px`} onChange={(outlineWidthPx) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, outlineWidthPx } }))} /> + {profile.target.variant === "elevated-spike" && <> + updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, outlineColor } }))} /> + `${value}%`} onChange={(value) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, outlineOpacity: value / 100 } }))} /> + `${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; })} , - }, + }]), ]), { id: "map-grid", diff --git a/docs/FOUNDRY_MAP_CESIUM_CANON.md b/docs/FOUNDRY_MAP_CESIUM_CANON.md index 285040a..883e62c 100644 --- a/docs/FOUNDRY_MAP_CESIUM_CANON.md +++ b/docs/FOUNDRY_MAP_CESIUM_CANON.md @@ -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 diff --git a/docs/MAP_TEMPLATE.md b/docs/MAP_TEMPLATE.md index 7aa078c..ad9f856 100644 --- a/docs/MAP_TEMPLATE.md +++ b/docs/MAP_TEMPLATE.md @@ -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 ограничен diff --git a/server/catalog-server.mjs b/server/catalog-server.mjs index 081dd06..32e6bca 100644 --- a/server/catalog-server.mjs +++ b/server/catalog-server.mjs @@ -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 = { diff --git a/server/foundry-data-product-consumer.test.mjs b/server/foundry-data-product-consumer.test.mjs index 3586b68..4b5653c 100644 --- a/server/foundry-data-product-consumer.test.mjs +++ b/server/foundry-data-product-consumer.test.mjs @@ -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], diff --git a/server/foundry-mcp.mjs b/server/foundry-mcp.mjs index 220d8b4..6c4b4a1 100644 --- a/server/foundry-mcp.mjs +++ b/server/foundry-mcp.mjs @@ -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." }, diff --git a/server/map-live-data-slot.mjs b/server/map-live-data-slot.mjs new file mode 100644 index 0000000..347dab3 --- /dev/null +++ b/server/map-live-data-slot.mjs @@ -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)); +} + diff --git a/server/map-live-data-slot.test.mjs b/server/map-live-data-slot.test.mjs new file mode 100644 index 0000000..2b6c716 --- /dev/null +++ b/server/map-live-data-slot.test.mjs @@ -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); + } +}); +