feat(foundry): render geozones as independent map layer

This commit is contained in:
Codex 2026-07-20 21:48:27 +03:00
parent a02c3ff3dd
commit aa681ca069
12 changed files with 418 additions and 90 deletions

View File

@ -26,6 +26,8 @@ import {
Matrix4,
Math as CesiumMath,
PointGraphics,
PolygonGraphics,
PolygonHierarchy,
PolylineGraphics,
Resource,
sampleTerrainMostDetailed,
@ -331,7 +333,7 @@ function syncRuntimeDataSources(
presentationFilters: MapPresentationFilters,
) {
const activeBindings = new Map(bindings
.filter((binding) => binding.slotId === "points")
.filter((binding) => binding.slotId === "points" || binding.slotId === "zones")
.map((binding) => [binding.bindingId, binding]));
for (const [bindingId, dataSource] of dataSources) {
@ -359,65 +361,22 @@ function syncRuntimeDataSources(
!fact.geometry
|| (profile && !mapRuntimeFactIsVisible(fact, profile, presentationFilters, binding.bindingId))
) continue;
const entityId = mapRuntimeEntityId(binding.bindingId, fact);
wanted.add(entityId);
const [longitude, latitude] = fact.geometry.coordinates;
const resolvedStyle = profile ? resolveMapPresentationStyle(profile, presentationClass) : undefined;
const color = resolvedStyle
? Color.fromCssColorString(resolvedStyle.color).withAlpha(resolvedStyle.opacity)
: runtimePointColor(fact);
const label = mapRuntimeDisplayLabel(fact, profile);
const baseEntityId = mapRuntimeEntityId(binding.bindingId, fact);
if (fact.geometry.type === "Point" && binding.slotId === "points") {
if (profile && profile.target.variant !== "elevated-spike") continue;
const entityId = baseEntityId;
wanted.add(entityId);
const [longitude, latitude] = fact.geometry.coordinates;
const entity = dataSource.entities.getById(entityId) ?? dataSource.entities.add({ id: entityId });
entity.name = label;
if (profile) {
const fallbackHeightMeters = typeof fact.attributes.elevation_meters === "number" && Number.isFinite(fact.attributes.elevation_meters)
? fact.attributes.elevation_meters
: 0;
entity.position = elevatedPinTopPosition(
viewer,
longitude,
latitude,
profile.target.stemHeightMeters,
fallbackHeightMeters,
);
entity.polyline = new PolylineGraphics({
positions: elevatedPinStemPositions(
viewer,
longitude,
latitude,
profile.target.stemHeightMeters,
fallbackHeightMeters,
),
width: profile.target.stemWidthPx,
material: color,
show: showBelowCameraHeight(viewer, profile.target.hideCameraHeightMeters),
});
entity.point = new PointGraphics({
pixelSize: profile.target.headSizePx,
color,
outlineColor: Color.fromCssColorString(profile.target.outlineColor).withAlpha(profile.target.outlineOpacity),
outlineWidth: profile.target.outlineWidthPx,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
show: showBelowCameraHeight(viewer, profile.target.hideCameraHeightMeters),
});
entity.label = new LabelGraphics({
text: label,
font: `${profile.label.fontWeight} ${profile.label.sizePx}px Arial`,
fillColor: Color.fromCssColorString(profile.label.color),
outlineColor: Color.fromCssColorString(profile.label.outlineColor),
outlineWidth: profile.label.outlineWidthPx,
style: 2,
showBackground: profile.label.backgroundOpacity > 0,
backgroundColor: Color.fromCssColorString(profile.label.backgroundColor).withAlpha(profile.label.backgroundOpacity),
backgroundPadding: new Cartesian2(profile.label.paddingX, profile.label.paddingY),
pixelOffset: new Cartesian2(profile.label.offsetX, profile.label.offsetY),
horizontalOrigin: HorizontalOrigin.LEFT,
verticalOrigin: VerticalOrigin.BOTTOM,
heightReference: HeightReference.NONE,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
show: profile.label.mode !== "none" && showBelowCameraHeight(viewer, profile.label.hideCameraHeightMeters),
});
} else {
entity.polygon = undefined;
if (!profile) {
entity.position = new ConstantPositionProperty(Cartesian3.fromDegrees(longitude, latitude, 0));
entity.polyline = undefined;
entity.point = new PointGraphics({
@ -440,6 +399,125 @@ function syncRuntimeDataSources(
heightReference: HeightReference.NONE,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
continue;
}
if (profile.target.variant !== "elevated-spike") continue;
const target = profile.target;
const fallbackHeightMeters = typeof fact.attributes.elevation_meters === "number" && Number.isFinite(fact.attributes.elevation_meters)
? fact.attributes.elevation_meters
: 0;
entity.position = elevatedPinTopPosition(
viewer,
longitude,
latitude,
target.stemHeightMeters,
fallbackHeightMeters,
);
entity.polyline = new PolylineGraphics({
positions: elevatedPinStemPositions(
viewer,
longitude,
latitude,
target.stemHeightMeters,
fallbackHeightMeters,
),
width: target.stemWidthPx,
material: color,
show: showBelowCameraHeight(viewer, target.hideCameraHeightMeters),
});
entity.point = new PointGraphics({
pixelSize: target.headSizePx,
color,
outlineColor: Color.fromCssColorString(target.outlineColor).withAlpha(target.outlineOpacity),
outlineWidth: target.outlineWidthPx,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
show: showBelowCameraHeight(viewer, target.hideCameraHeightMeters),
});
entity.label = new LabelGraphics({
text: label,
font: `${profile.label.fontWeight} ${profile.label.sizePx}px Arial`,
fillColor: Color.fromCssColorString(profile.label.color),
outlineColor: Color.fromCssColorString(profile.label.outlineColor),
outlineWidth: profile.label.outlineWidthPx,
style: 2,
showBackground: profile.label.backgroundOpacity > 0,
backgroundColor: Color.fromCssColorString(profile.label.backgroundColor).withAlpha(profile.label.backgroundOpacity),
backgroundPadding: new Cartesian2(profile.label.paddingX, profile.label.paddingY),
pixelOffset: new Cartesian2(profile.label.offsetX, profile.label.offsetY),
horizontalOrigin: HorizontalOrigin.LEFT,
verticalOrigin: VerticalOrigin.BOTTOM,
heightReference: HeightReference.NONE,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
show: profile.label.mode !== "none" && showBelowCameraHeight(viewer, profile.label.hideCameraHeightMeters),
});
continue;
}
if (
binding.slotId !== "zones"
|| fact.geometry.type === "Point"
|| (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),
});
}
}
for (const entity of [...dataSource.entities.values]) {

View File

@ -901,14 +901,16 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
...presentationProfiles.flatMap((profile) => [
{
id: `map-target-${profile.id}`,
label: "Таргет",
label: profile.target.variant === "surface-fill" ? "Геозоны" : "Таргет",
description: profile.title,
group: "Таргеты",
group: profile.target.variant === "surface-fill" ? "Слои" : "Таргеты",
content: <>
<small className="catalog-map-inspector__note">Профиль принадлежит этой странице Application и управляется тем же provider-neutral MCP-контрактом. Исходный API в настройках отсутствует.</small>
<RangeControl label="Высота таргета" value={profile.target.stemHeightMeters} min={100} max={10_000} step={50} formatValue={(value) => `${value} м`} onChange={(stemHeightMeters) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, stemHeightMeters } }))} />
<RangeControl label="Размер головки" value={profile.target.headSizePx} min={1} max={32} step={1} formatValue={(value) => `${value} px`} onChange={(headSizePx) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, headSizePx } }))} />
<RangeControl label="Толщина стержня" value={profile.target.stemWidthPx} min={0.25} max={12} step={0.25} formatValue={(value) => `${value} px`} onChange={(stemWidthPx) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, stemWidthPx } }))} />
{profile.target.variant === "elevated-spike" && <>
<RangeControl label="Высота таргета" value={profile.target.stemHeightMeters} min={100} max={10_000} step={50} formatValue={(value) => `${value} м`} onChange={(stemHeightMeters) => updatePresentationProfile(profile.id, (current) => current.target.variant === "elevated-spike" ? ({ ...current, target: { ...current.target, stemHeightMeters } }) : current)} />
<RangeControl label="Размер головки" value={profile.target.headSizePx} min={1} max={32} step={1} formatValue={(value) => `${value} px`} onChange={(headSizePx) => updatePresentationProfile(profile.id, (current) => current.target.variant === "elevated-spike" ? ({ ...current, target: { ...current.target, headSizePx } }) : current)} />
<RangeControl label="Толщина стержня" value={profile.target.stemWidthPx} min={0.25} max={12} step={0.25} formatValue={(value) => `${value} px`} onChange={(stemWidthPx) => updatePresentationProfile(profile.id, (current) => current.target.variant === "elevated-spike" ? ({ ...current, target: { ...current.target, stemWidthPx } }) : current)} />
</>}
<InspectorSelectField
label="Подпись"
value={profile.label.mode}

View File

@ -52,11 +52,14 @@ export type MapPresentationProfile = {
offsetY: number;
hideCameraHeightMeters: number;
};
target: {
target: ({
variant: "elevated-spike";
stemHeightMeters: number;
headSizePx: number;
stemWidthPx: number;
} | {
variant: "surface-fill";
}) & {
outlineColor: string;
outlineOpacity: number;
outlineWidthPx: number;

View File

@ -6,6 +6,18 @@ export type DataProductPoint = {
coordinates: [number, number];
};
export type DataProductPolygon = {
type: "Polygon";
coordinates: Array<Array<[number, number]>>;
};
export type DataProductMultiPolygon = {
type: "MultiPolygon";
coordinates: Array<Array<Array<[number, number]>>>;
};
export type DataProductGeometry = DataProductPoint | DataProductPolygon | DataProductMultiPolygon;
/**
* The only entity shape the map runtime accepts from Platform. It is the
* canonical data-product fact, not a provider payload and not a Cesium model.
@ -16,7 +28,7 @@ export type MapRuntimeFact = {
observedAt: string;
receivedAt: string;
attributes: Record<string, unknown>;
geometry: DataProductPoint | null;
geometry: DataProductGeometry | null;
presentationStatus: string;
};
@ -46,7 +58,7 @@ type PatchEnvelope = {
emittedAt: string;
operations: Array<
| { op: "upsert"; fact: MapRuntimeFact }
| { op: "remove"; sourceId: string; semanticType: string; removedAt: string; reason: "tombstone" | "revoked" }
| { op: "remove"; sourceId: string; semanticType: string }
>;
};
@ -78,13 +90,57 @@ function isIsoTimestamp(value: unknown): value is string {
return typeof value === "string" && !Number.isNaN(Date.parse(value));
}
function asPoint(value: unknown): DataProductPoint | null {
function asPosition(value: unknown): [number, number] | null {
if (!Array.isArray(value) || value.length !== 2) return null;
const [longitude, latitude] = value;
if (
typeof longitude !== "number"
|| !Number.isFinite(longitude)
|| longitude < -180
|| longitude > 180
|| typeof latitude !== "number"
|| !Number.isFinite(latitude)
|| latitude < -90
|| latitude > 90
) return null;
return [longitude, latitude];
}
function asLinearRing(value: unknown): Array<[number, number]> | null {
if (!Array.isArray(value) || value.length < 4 || value.length > 10_000) return null;
const positions = value.map(asPosition);
if (positions.some((position) => !position)) return null;
const normalized = positions as Array<[number, number]>;
const first = normalized[0];
const last = normalized.at(-1);
if (!last || first[0] !== last[0] || first[1] !== last[1]) return null;
return normalized;
}
function asPolygonCoordinates(value: unknown): Array<Array<[number, number]>> | null {
if (!Array.isArray(value) || value.length < 1 || value.length > 256) return null;
const rings = value.map(asLinearRing);
if (rings.some((ring) => !ring)) return null;
return rings as Array<Array<[number, number]>>;
}
function asGeometry(value: unknown): DataProductGeometry | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const candidate = value as { type?: unknown; coordinates?: unknown };
if (candidate.type !== "Point" || !Array.isArray(candidate.coordinates) || candidate.coordinates.length !== 2) return null;
const [longitude, latitude] = candidate.coordinates;
if (typeof longitude !== "number" || !Number.isFinite(longitude) || typeof latitude !== "number" || !Number.isFinite(latitude)) return null;
return { type: "Point", coordinates: [longitude, latitude] };
if (candidate.type === "Point") {
const coordinates = asPosition(candidate.coordinates);
return coordinates ? { type: "Point", coordinates } : null;
}
if (candidate.type === "Polygon") {
const coordinates = asPolygonCoordinates(candidate.coordinates);
return coordinates ? { type: "Polygon", coordinates } : null;
}
if (candidate.type === "MultiPolygon" && Array.isArray(candidate.coordinates) && candidate.coordinates.length >= 1 && candidate.coordinates.length <= 256) {
const coordinates = candidate.coordinates.map(asPolygonCoordinates);
if (coordinates.some((polygon) => !polygon)) return null;
return { type: "MultiPolygon", coordinates: coordinates as Array<Array<Array<[number, number]>>> };
}
return null;
}
function asFact(value: unknown, binding: MapDataProductBinding): MapRuntimeFact | null {
@ -107,7 +163,7 @@ function asFact(value: unknown, binding: MapDataProductBinding): MapRuntimeFact
observedAt: candidate.observedAt,
receivedAt: candidate.receivedAt,
attributes,
geometry: asPoint(candidate.geometry),
geometry: asGeometry(candidate.geometry),
presentationStatus: typeof candidate.presentationStatus === "string" && /^[a-z0-9_-]{1,64}$/.test(candidate.presentationStatus)
? candidate.presentationStatus
: "active",
@ -138,7 +194,7 @@ function asPatch(value: unknown, binding: MapDataProductBinding): PatchEnvelope
const operations: PatchEnvelope["operations"] = [];
for (const operation of candidate.operations) {
if (!operation || typeof operation !== "object" || Array.isArray(operation)) continue;
const value = operation as { op?: unknown; fact?: unknown; sourceId?: unknown; semanticType?: unknown; removedAt?: unknown; reason?: unknown };
const value = operation as { op?: unknown; fact?: unknown; sourceId?: unknown; semanticType?: unknown };
if (value.op === "upsert") {
const fact = asFact(value.fact, binding);
if (fact) operations.push({ op: "upsert", fact });
@ -150,10 +206,8 @@ function asPatch(value: unknown, binding: MapDataProductBinding): PatchEnvelope
&& identifier.test(value.sourceId)
&& typeof value.semanticType === "string"
&& binding.semanticTypes.includes(value.semanticType)
&& isIsoTimestamp(value.removedAt)
&& (value.reason === "tombstone" || value.reason === "revoked")
) {
operations.push({ op: "remove", sourceId: value.sourceId, semanticType: value.semanticType, removedAt: value.removedAt, reason: value.reason });
operations.push({ op: "remove", sourceId: value.sourceId, semanticType: value.semanticType });
}
}
return {

View File

@ -47,6 +47,16 @@
"freshness": "none"
},
"removeMode": "canonical-tombstone-or-snapshot-rebase"
},
{
"id": "map-zone-current-v1",
"version": "1.0.0",
"dataProductId": "map.zones.current.v1",
"productVersion": "1.0.0",
"freshness": "none",
"staleAfterMs": null,
"terminalStatuses": [],
"removeMode": "canonical-tombstone-or-snapshot-rebase"
}
]
}

View File

@ -102,6 +102,97 @@
{ "field": "availability_state", "order": ["online", "offline"] },
{ "field": "motion_state", "order": ["moving", "stationary"] }
]
},
{
"id": "map.zone.operational.default",
"version": "1.0.0",
"title": "Геозоны",
"semanticTypes": ["map.zone"],
"label": {
"mode": "attributes",
"fields": ["display_name"],
"fontWeight": 600,
"sizePx": 14,
"color": "#f5f5f5",
"outlineColor": "#0c0d12",
"outlineWidthPx": 1,
"backgroundColor": "#0c0d12",
"backgroundOpacity": 0.82,
"paddingX": 8,
"paddingY": 5,
"maxLength": 80,
"offsetX": 10,
"offsetY": 8,
"hideCameraHeightMeters": 160000
},
"target": {
"variant": "surface-fill",
"outlineColor": "#f5f5f5",
"outlineOpacity": 0.72,
"outlineWidthPx": 1.5,
"hideCameraHeightMeters": 220000
},
"facets": [
{
"id": "geometry-kind",
"field": "geometry_kind",
"label": "Форма",
"filterable": true,
"counter": true,
"values": [
{ "value": "polygon", "label": "Полигоны", "order": 0 },
{ "value": "circle", "label": "Окружности", "order": 1 },
{ "value": "corridor", "label": "Коридоры", "order": 2 }
]
},
{
"id": "source-kind",
"field": "source_kind",
"label": "Источник",
"filterable": true,
"counter": true,
"values": [
{ "value": "live_api", "label": "Live API", "order": 0 },
{ "value": "versioned_snapshot", "label": "Версионный snapshot", "order": 1 }
]
}
],
"styles": [
{ "id": "polygon", "color": "#8e63ef", "opacity": 0.28 },
{ "id": "circle", "color": "#52c7b8", "opacity": 0.25 },
{ "id": "corridor", "color": "#6fb5fb", "opacity": 0.25 }
],
"classes": [
{
"id": "geometry-polygon",
"label": "Полигоны",
"priority": 300,
"match": [{ "field": "geometry_kind", "equals": "polygon" }],
"styleId": "polygon",
"renderable": true
},
{
"id": "geometry-circle",
"label": "Окружности",
"priority": 200,
"match": [{ "field": "geometry_kind", "equals": "circle" }],
"styleId": "circle",
"renderable": true
},
{
"id": "geometry-corridor",
"label": "Коридоры",
"priority": 100,
"match": [{ "field": "geometry_kind", "equals": "corridor" }],
"styleId": "corridor",
"renderable": true
}
],
"defaultClassId": "geometry-polygon",
"sort": [
{ "field": "geometry_kind", "order": ["polygon", "circle", "corridor"] },
{ "field": "source_kind", "order": ["live_api", "versioned_snapshot"] }
]
}
]
}

View File

@ -59,14 +59,21 @@ requireValue(new Set(consumerPolicyIds).size === consumerPolicyIds.length, "data
requireValue(new Set(consumerPolicyProducts).size === consumerPolicyProducts.length, "data product consumer policy product/version pairs must be unique");
for (const policy of consumerPolicies) {
const statusContract = policy.statusContract;
const freshness = policy.freshness ?? statusContract?.freshness ?? "observed-at";
requireValue(typeof policy.id === "string" && /^[a-z0-9._-]{1,160}$/.test(policy.id), `invalid data product consumer policy id: ${policy.id ?? "unknown"}`);
requireValue(typeof policy.version === "string" && /^\d+\.\d+\.\d+$/.test(policy.version), `invalid data product consumer policy version: ${policy.id ?? "unknown"}`);
requireValue(typeof policy.dataProductId === "string" && /^[A-Za-z0-9._:-]{1,160}$/.test(policy.dataProductId), `invalid data product consumer policy product: ${policy.id ?? "unknown"}`);
requireValue(typeof policy.productVersion === "string" && /^\d+\.\d+\.\d+$/.test(policy.productVersion), `invalid data product consumer product version: ${policy.id ?? "unknown"}`);
requireValue(Array.isArray(policy.terminalStatuses) && policy.terminalStatuses.every((value) => typeof value === "string" && /^[a-z0-9_-]{1,64}$/.test(value)), `invalid terminal statuses: ${policy.id ?? "unknown"}`);
requireValue(policy.removeMode === "canonical-tombstone-or-snapshot-rebase", `invalid remove mode: ${policy.id ?? "unknown"}`);
requireValue(["none", "observed-at"].includes(freshness), `invalid freshness mode: ${policy.id ?? "unknown"}`);
if (statusContract === undefined) {
requireValue(Number.isInteger(policy.staleAfterMs) && policy.staleAfterMs >= 1_000 && policy.staleAfterMs <= 7 * 24 * 60 * 60 * 1000, `invalid stale window: ${policy.id ?? "unknown"}`);
requireValue(
freshness === "none"
? policy.staleAfterMs === null
: Number.isInteger(policy.staleAfterMs) && policy.staleAfterMs >= 1_000 && policy.staleAfterMs <= 7 * 24 * 60 * 60 * 1000,
`invalid stale window: ${policy.id ?? "unknown"}`,
);
continue;
}
requireValue(statusContract && typeof statusContract === "object" && !Array.isArray(statusContract), `invalid status contract: ${policy.id ?? "unknown"}`);
@ -75,6 +82,7 @@ for (const policy of consumerPolicies) {
requireValue(statusContract?.allowedValues?.every((value) => typeof value === "string" && /^[a-z0-9_-]{1,64}$/.test(value)), `invalid allowed status value: ${policy.id ?? "unknown"}`);
requireValue(statusContract?.missing === "reject", `status contract must reject missing values: ${policy.id ?? "unknown"}`);
requireValue(["none", "observed-at"].includes(statusContract?.freshness), `invalid status freshness mode: ${policy.id ?? "unknown"}`);
requireValue(statusContract?.freshness === freshness, `status and policy freshness modes must match: ${policy.id ?? "unknown"}`);
requireValue(statusContract?.freshness === "none" ? policy.staleAfterMs === null : Number.isInteger(policy.staleAfterMs), `status freshness/stale window mismatch: ${policy.id ?? "unknown"}`);
requireValue(policy.terminalStatuses.every((value) => statusContract.allowedValues.includes(value)), `terminal status is outside the closed value contract: ${policy.id ?? "unknown"}`);
}

View File

@ -1166,11 +1166,53 @@ async function inspectFoundryConsumerReaderGrant(target, { generation = 1 } = {}
return { product: planned.product, readerGrantAction: "ensure", readerGrantGeneration: generation };
}
function runtimePointGeometry(value) {
if (!isObject(value) || value.type !== "Point" || !Array.isArray(value.coordinates) || value.coordinates.length !== 2) return null;
const [longitude, latitude] = value.coordinates;
if (typeof longitude !== "number" || !Number.isFinite(longitude) || typeof latitude !== "number" || !Number.isFinite(latitude)) return null;
return { type: "Point", coordinates: [longitude, latitude] };
function runtimePosition(value) {
if (!Array.isArray(value) || value.length !== 2) return null;
const [longitude, latitude] = value;
if (
typeof longitude !== "number"
|| !Number.isFinite(longitude)
|| longitude < -180
|| longitude > 180
|| typeof latitude !== "number"
|| !Number.isFinite(latitude)
|| latitude < -90
|| latitude > 90
) return null;
return [longitude, latitude];
}
function runtimeLinearRing(value) {
if (!Array.isArray(value) || value.length < 4 || value.length > 10_000) return null;
const positions = value.map(runtimePosition);
if (positions.some((position) => !position)) return null;
const first = positions[0];
const last = positions.at(-1);
if (!last || first[0] !== last[0] || first[1] !== last[1]) return null;
return positions;
}
function runtimePolygonCoordinates(value) {
if (!Array.isArray(value) || value.length < 1 || value.length > 256) return null;
const rings = value.map(runtimeLinearRing);
return rings.some((ring) => !ring) ? null : rings;
}
function runtimeGeometry(value) {
if (!isObject(value) || JSON.stringify(value).length > 196_608) return null;
if (value.type === "Point") {
const coordinates = runtimePosition(value.coordinates);
return coordinates ? { type: "Point", coordinates } : null;
}
if (value.type === "Polygon") {
const coordinates = runtimePolygonCoordinates(value.coordinates);
return coordinates ? { type: "Polygon", coordinates } : null;
}
if (value.type === "MultiPolygon" && Array.isArray(value.coordinates) && value.coordinates.length >= 1 && value.coordinates.length <= 256) {
const coordinates = value.coordinates.map(runtimePolygonCoordinates);
return coordinates.some((polygon) => !polygon) ? null : { type: "MultiPolygon", coordinates };
}
return null;
}
const RUNTIME_SECRET_LIKE_KEY = /(token|secret|password|authorization|access[_-]?token|refresh[_-]?token|api[_-]?key)/i;
@ -1203,7 +1245,7 @@ function sanitizeRuntimeFact(value, binding) {
observedAt: value.observedAt,
receivedAt: value.receivedAt,
attributes,
geometry: runtimePointGeometry(value.geometry),
geometry: runtimeGeometry(value.geometry),
};
}
@ -1285,23 +1327,16 @@ function sanitizeRuntimePatch(value, binding) {
const fact = sanitizeRuntimeFact(operation.fact, binding);
return fact ? [{ op: "upsert", fact }] : [];
}
// Current EDP v1 emits only upserts. Foundry already understands the
// canonical removal shape so a future versioned product can revoke a
// subject without turning a transient stream failure into deletion.
if (
operation.op === "remove"
&& isRuntimeIdentifier(operation.sourceId)
&& isRuntimeIdentifier(operation.semanticType)
&& binding.semanticTypes.includes(operation.semanticType)
&& isRuntimeTimestamp(operation.removedAt)
&& ["tombstone", "revoked"].includes(operation.reason)
) {
return [{
op: "remove",
sourceId: operation.sourceId,
semanticType: operation.semanticType,
removedAt: operation.removedAt,
reason: operation.reason,
}];
}
return [];

View File

@ -49,7 +49,7 @@ function safeStatusFromFact(fact, policy, nowMs) {
if (typeof sourceStatus !== "string" || !statusContract.allowedValues.includes(sourceStatus)) {
throw consumerError("data_product_consumer_fact_status_invalid", 502);
}
if (statusContract.freshness === "none" || policy.terminalStatuses.includes(sourceStatus)) {
if (policy.freshness === "none" || policy.terminalStatuses.includes(sourceStatus)) {
return sourceStatus;
}
const observedAt = Date.parse(String(fact?.observedAt || ""));
@ -60,6 +60,7 @@ function safeStatusFromFact(fact, policy, nowMs) {
.find((value) => typeof value === "string" && value.trim());
const normalized = String(sourceStatus || "active").trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-").slice(0, 64) || "active";
if (policy.terminalStatuses.includes(normalized)) return normalized;
if (policy.freshness === "none") return normalized;
const observedAt = Date.parse(String(fact?.observedAt || ""));
if (Number.isFinite(observedAt) && nowMs - observedAt > policy.staleAfterMs) return "stale";
return normalized;
@ -144,7 +145,14 @@ function validatePolicy(policy, product) {
const statusContract = policy.statusContract === undefined
? null
: validateStatusContract(policy.statusContract);
const staleDisabled = statusContract?.freshness === "none";
const freshness = policy.freshness ?? statusContract?.freshness ?? "observed-at";
if (!["none", "observed-at"].includes(freshness)) {
throw consumerError("data_product_consumer_policy_invalid", 500);
}
if (statusContract && statusContract.freshness !== freshness) {
throw consumerError("data_product_consumer_policy_invalid", 500);
}
const staleDisabled = freshness === "none";
if (
(staleDisabled && policy.staleAfterMs !== null)
|| (!staleDisabled && (!Number.isInteger(policy.staleAfterMs) || policy.staleAfterMs < 1_000 || policy.staleAfterMs > 7 * 24 * 60 * 60 * 1000))
@ -166,6 +174,7 @@ function validatePolicy(policy, product) {
version: String(policy.version || ""),
dataProductId: product.id,
productVersion: product.version,
freshness,
staleAfterMs: policy.staleAfterMs,
terminalStatuses: [...terminalStatuses],
...(statusContract ? { statusContract } : {}),

View File

@ -174,6 +174,8 @@ const mapPresentationProfileInputSchema = {
},
},
target: {
oneOf: [
{
type: "object",
additionalProperties: false,
required: [
@ -181,11 +183,7 @@ const mapPresentationProfileInputSchema = {
"outlineOpacity", "outlineWidthPx", "hideCameraHeightMeters",
],
properties: {
variant: {
type: "string",
enum: ["elevated-spike"],
description: "Renderer-neutral presentation preset interpreted by the active Map adapter.",
},
variant: { type: "string", enum: ["elevated-spike"] },
stemHeightMeters: { type: "number", minimum: 1, maximum: 100000 },
headSizePx: { type: "number", minimum: 1, maximum: 64 },
stemWidthPx: { type: "number", minimum: 0.25, maximum: 16 },
@ -195,6 +193,24 @@ const mapPresentationProfileInputSchema = {
hideCameraHeightMeters: { type: "number", minimum: 1, maximum: 100000000 },
},
},
{
type: "object",
additionalProperties: false,
required: ["variant", "outlineColor", "outlineOpacity", "outlineWidthPx", "hideCameraHeightMeters"],
properties: {
variant: {
type: "string",
enum: ["surface-fill"],
description: "Provider-neutral filled surface interpreted by the active Map adapter.",
},
outlineColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" },
outlineOpacity: { type: "number", minimum: 0, maximum: 1 },
outlineWidthPx: { type: "number", minimum: 0, maximum: 8 },
hideCameraHeightMeters: { type: "number", minimum: 1, maximum: 100000000 },
},
},
],
},
facets: {
type: "array",
minItems: 1,

View File

@ -92,16 +92,25 @@ function normalizeLabel(value) {
function normalizeTarget(value) {
object(value, "invalid_map_presentation_profile_target");
onlyKeys(value, TARGET_KEYS, "invalid_map_presentation_profile_target_fields");
const shared = {
outlineColor: hex(value.outlineColor, "invalid_map_presentation_profile_target_outline"),
outlineOpacity: number(value.outlineOpacity, 0, 1, "invalid_map_presentation_profile_target_outline_opacity"),
outlineWidthPx: number(value.outlineWidthPx, 0, 8, "invalid_map_presentation_profile_target_outline_width"),
hideCameraHeightMeters: number(value.hideCameraHeightMeters, 1, 100_000_000, "invalid_map_presentation_profile_target_lod"),
};
if (value.variant === "surface-fill") {
if (value.stemHeightMeters !== undefined || value.headSizePx !== undefined || value.stemWidthPx !== undefined) {
fail("invalid_map_presentation_profile_surface_target_fields");
}
return { variant: "surface-fill", ...shared };
}
if (value.variant !== "elevated-spike") fail("invalid_map_presentation_profile_target_variant");
return {
variant: "elevated-spike",
stemHeightMeters: number(value.stemHeightMeters, 1, 100_000, "invalid_map_presentation_profile_target_stem_height"),
headSizePx: number(value.headSizePx, 1, 64, "invalid_map_presentation_profile_target_head_size"),
stemWidthPx: number(value.stemWidthPx, 0.25, 16, "invalid_map_presentation_profile_target_stem_width"),
outlineColor: hex(value.outlineColor, "invalid_map_presentation_profile_target_outline"),
outlineOpacity: number(value.outlineOpacity, 0, 1, "invalid_map_presentation_profile_target_outline_opacity"),
outlineWidthPx: number(value.outlineWidthPx, 0, 8, "invalid_map_presentation_profile_target_outline_width"),
hideCameraHeightMeters: number(value.hideCameraHeightMeters, 1, 100_000_000, "invalid_map_presentation_profile_target_lod"),
...shared,
};
}

View File

@ -10,8 +10,9 @@ const catalogStyles = await readFile(new URL("../apps/catalog/src/styles.css", i
test("canonical moving-object profile is provider-neutral and internally consistent", () => {
assert.equal(registry.schemaVersion, "nodedc.map-presentation-profiles/v1");
const profiles = normalizeMapPresentationProfiles(registry.profiles);
assert.equal(profiles.length, 1);
const profile = profiles[0];
assert.equal(profiles.length, 2);
const profile = profiles.find((item) => item.id === "map.moving-object.operational.default");
assert.ok(profile);
assert.equal(profile.id, "map.moving-object.operational.default");
assert.deepEqual(profile.semanticTypes, ["map.moving_object"]);
assert.equal(profile.target.variant, "elevated-spike");
@ -37,6 +38,18 @@ test("canonical moving-object profile is provider-neutral and internally consist
assert.equal(JSON.stringify(profile).includes("Неизвестно"), false);
});
test("canonical zone profile renders provider-neutral surfaces", () => {
const profiles = normalizeMapPresentationProfiles(registry.profiles);
const profile = profiles.find((item) => item.id === "map.zone.operational.default");
assert.ok(profile);
assert.deepEqual(profile.semanticTypes, ["map.zone"]);
assert.equal(profile.target.variant, "surface-fill");
assert.equal(profile.label.mode, "attributes");
assert.deepEqual(profile.facets.map((facet) => facet.field), ["geometry_kind", "source_kind"]);
assert.equal(JSON.stringify(profile).toLowerCase().includes("gelios"), false);
assert.equal(JSON.stringify(profile).includes("http"), false);
});
test("fleet filter window is one compact vertical list without group rows or All", () => {
assert.match(mapFixtureSource, /catalog-map-fixture__target-filter-list/);
assert.doesNotMatch(mapFixtureSource, /catalog-map-fixture__target-profile/);