feat(foundry): complete HGeoZone live layer

This commit is contained in:
Codex 2026-07-22 09:25:32 +03:00
parent de060c105b
commit 309cddef73
9 changed files with 391 additions and 83 deletions

View File

@ -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,71 +714,46 @@ 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]) => ( (accumulator, [longitude, latitude]) => [accumulator[0] + longitude, accumulator[1] + latitude] as [number, number],
Cartesian3.fromDegrees(longitude, latitude, 0) [0, 0] as [number, number],
)); );
const hierarchy = new PolygonHierarchy( const divisor = Math.max(1, outerRing.length);
toCartesian(outerRing), const entity = dataSource.entities.getById(baseEntityId) ?? dataSource.entities.add({ id: baseEntityId });
polygon.slice(1).map((ring) => new PolygonHierarchy(toCartesian(ring))), entity.name = label;
); entity.position = new ConstantPositionProperty(Cartesian3.fromDegrees(labelAnchor[0] / divisor, labelAnchor[1] / divisor, 0));
const labelAnchor = outerRing.slice(0, -1).reduce( entity.point = undefined;
(accumulator, [longitude, latitude]) => [accumulator[0] + longitude, accumulator[1] + latitude] as [number, number], entity.polygon = undefined;
[0, 0] as [number, number], entity.polyline = undefined;
); entity.label = new LabelGraphics({
const divisor = Math.max(1, outerRing.length - 1); text: label,
const entity = dataSource.entities.getById(entityId) ?? dataSource.entities.add({ id: entityId }); font: profile ? `${profile.label.fontWeight} ${profile.label.sizePx}px Arial` : "700 13px Arial",
const target = profile?.target.variant === "surface-fill" ? profile.target : null; fillColor: profile ? Color.fromCssColorString(profile.label.color) : Color.WHITE,
const visibleBelowLod = target ? showBelowCameraHeight(viewer, target.hideCameraHeightMeters) : true; outlineColor: profile ? Color.fromCssColorString(profile.label.outlineColor) : Color.BLACK,
entity.name = label; outlineWidth: profile?.label.outlineWidthPx ?? 1,
entity.position = new ConstantPositionProperty(Cartesian3.fromDegrees(labelAnchor[0] / divisor, labelAnchor[1] / divisor, 0)); style: 2,
entity.point = undefined; showBackground: (profile?.label.backgroundOpacity ?? 0.72) > 0,
entity.polygon = new PolygonGraphics({ backgroundColor: profile
hierarchy, ? Color.fromCssColorString(profile.label.backgroundColor).withAlpha(profile.label.backgroundOpacity)
material: color, : Color.BLACK.withAlpha(0.72),
height: 0, backgroundPadding: new Cartesian2(profile?.label.paddingX ?? 10, profile?.label.paddingY ?? 7),
heightReference: HeightReference.CLAMP_TO_GROUND, pixelOffset: new Cartesian2(profile?.label.offsetX ?? 10, profile?.label.offsetY ?? 0),
show: visibleBelowLod, horizontalOrigin: HorizontalOrigin.LEFT,
}); verticalOrigin: VerticalOrigin.BOTTOM,
entity.polyline = new PolylineGraphics({ heightReference: HeightReference.CLAMP_TO_GROUND,
positions: toCartesian(outerRing), disableDepthTestDistance: Number.POSITIVE_INFINITY,
width: target?.outlineWidthPx ?? 1.5, show: (profile?.label.mode ?? "attributes") !== "none"
material: target && (profile ? showBelowCameraHeight(viewer, profile.label.hideCameraHeightMeters) : true),
? 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),
});
}
} }
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]);

View File

@ -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 } }))} />
<ControlRow label="Обводка таргета"><ColorField label="Цвет обводки таргета" value={profile.target.outlineColor} onChange={(outlineColor) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, outlineColor } }))} /></ControlRow> {profile.target.variant === "elevated-spike" && <>
<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 } }))} /> <ControlRow label="Обводка таргета"><ColorField label="Цвет обводки таргета" value={profile.target.outlineColor} onChange={(outlineColor) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, outlineColor } }))} /></ControlRow>
<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={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}`, 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",

View File

@ -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

View File

@ -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 ограничен

View File

@ -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 = {

View File

@ -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],

View File

@ -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." },

View File

@ -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));
}

View File

@ -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);
}
});