diff --git a/apps/catalog/src/CesiumMapRenderer.tsx b/apps/catalog/src/CesiumMapRenderer.tsx index ce3654d..accdd65 100644 --- a/apps/catalog/src/CesiumMapRenderer.tsx +++ b/apps/catalog/src/CesiumMapRenderer.tsx @@ -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,17 +361,48 @@ 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 entity = dataSource.entities.getById(entityId) ?? dataSource.entities.add({ id: entityId }); - entity.name = label; - if (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; + entity.polygon = undefined; + if (!profile) { + entity.position = new ConstantPositionProperty(Cartesian3.fromDegrees(longitude, latitude, 0)); + entity.polyline = undefined; + entity.point = new PointGraphics({ + pixelSize: 10, + color, + outlineColor: Color.fromCssColorString("#0c0d12").withAlpha(0.72), + outlineWidth: 2, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }); + entity.label = new LabelGraphics({ + text: label, + font: "700 13px Arial", + fillColor: Color.WHITE, + showBackground: true, + backgroundColor: Color.BLACK.withAlpha(0.72), + backgroundPadding: new Cartesian2(10, 7), + pixelOffset: new Cartesian2(10, 0), + horizontalOrigin: HorizontalOrigin.LEFT, + verticalOrigin: VerticalOrigin.BOTTOM, + 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; @@ -377,7 +410,7 @@ function syncRuntimeDataSources( viewer, longitude, latitude, - profile.target.stemHeightMeters, + target.stemHeightMeters, fallbackHeightMeters, ); entity.polyline = new PolylineGraphics({ @@ -385,20 +418,20 @@ function syncRuntimeDataSources( viewer, longitude, latitude, - profile.target.stemHeightMeters, + target.stemHeightMeters, fallbackHeightMeters, ), - width: profile.target.stemWidthPx, + width: target.stemWidthPx, material: color, - show: showBelowCameraHeight(viewer, profile.target.hideCameraHeightMeters), + show: showBelowCameraHeight(viewer, target.hideCameraHeightMeters), }); entity.point = new PointGraphics({ - pixelSize: profile.target.headSizePx, + pixelSize: target.headSizePx, color, - outlineColor: Color.fromCssColorString(profile.target.outlineColor).withAlpha(profile.target.outlineOpacity), - outlineWidth: profile.target.outlineWidthPx, + outlineColor: Color.fromCssColorString(target.outlineColor).withAlpha(target.outlineOpacity), + outlineWidth: target.outlineWidthPx, disableDepthTestDistance: Number.POSITIVE_INFINITY, - show: showBelowCameraHeight(viewer, profile.target.hideCameraHeightMeters), + show: showBelowCameraHeight(viewer, target.hideCameraHeightMeters), }); entity.label = new LabelGraphics({ text: label, @@ -417,28 +450,73 @@ function syncRuntimeDataSources( disableDepthTestDistance: Number.POSITIVE_INFINITY, show: profile.label.mode !== "none" && showBelowCameraHeight(viewer, profile.label.hideCameraHeightMeters), }); - } else { - entity.position = new ConstantPositionProperty(Cartesian3.fromDegrees(longitude, latitude, 0)); - entity.polyline = undefined; - entity.point = new PointGraphics({ - pixelSize: 10, - color, - outlineColor: Color.fromCssColorString("#0c0d12").withAlpha(0.72), - outlineWidth: 2, - disableDepthTestDistance: Number.POSITIVE_INFINITY, + 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: "700 13px Arial", - fillColor: Color.WHITE, - showBackground: true, - backgroundColor: Color.BLACK.withAlpha(0.72), - backgroundPadding: new Cartesian2(10, 7), - pixelOffset: new Cartesian2(10, 0), + 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.NONE, + heightReference: HeightReference.CLAMP_TO_GROUND, disableDepthTestDistance: Number.POSITIVE_INFINITY, + show: polygonIndex === 0 + && (profile?.label.mode ?? "attributes") !== "none" + && (profile ? showBelowCameraHeight(viewer, profile.label.hideCameraHeightMeters) : true), }); } } diff --git a/apps/catalog/src/MapFixturePreview.tsx b/apps/catalog/src/MapFixturePreview.tsx index affa3a9..7a841a8 100644 --- a/apps/catalog/src/MapFixturePreview.tsx +++ b/apps/catalog/src/MapFixturePreview.tsx @@ -901,14 +901,16 @@ export const MapFixturePreview = forwardRef [ { id: `map-target-${profile.id}`, - label: "Таргет", + label: profile.target.variant === "surface-fill" ? "Геозоны" : "Таргет", description: profile.title, - group: "Таргеты", + group: profile.target.variant === "surface-fill" ? "Слои" : "Таргеты", content: <> Профиль принадлежит этой странице Application и управляется тем же provider-neutral MCP-контрактом. Исходный API в настройках отсутствует. - `${value} м`} onChange={(stemHeightMeters) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, stemHeightMeters } }))} /> - `${value} px`} onChange={(headSizePx) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, headSizePx } }))} /> - `${value} px`} onChange={(stemWidthPx) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, stemWidthPx } }))} /> + {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)} /> + `${value} px`} onChange={(stemWidthPx) => updatePresentationProfile(profile.id, (current) => current.target.variant === "elevated-spike" ? ({ ...current, target: { ...current.target, stemWidthPx } }) : current)} /> + } >; +}; + +export type DataProductMultiPolygon = { + type: "MultiPolygon"; + coordinates: Array>>; +}; + +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; - 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> | 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>; +} + +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>> }; + } + 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 { diff --git a/registry/data-product-consumer-policies.json b/registry/data-product-consumer-policies.json index 048d2be..74814ca 100644 --- a/registry/data-product-consumer-policies.json +++ b/registry/data-product-consumer-policies.json @@ -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" } ] } diff --git a/registry/map-presentation-profiles.json b/registry/map-presentation-profiles.json index 00e5ee5..2a4b273 100644 --- a/registry/map-presentation-profiles.json +++ b/registry/map-presentation-profiles.json @@ -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"] } + ] } ] } diff --git a/scripts/validate-registry.mjs b/scripts/validate-registry.mjs index 7fafd6c..4c87aca 100644 --- a/scripts/validate-registry.mjs +++ b/scripts/validate-registry.mjs @@ -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"}`); } diff --git a/server/catalog-server.mjs b/server/catalog-server.mjs index 7087f7d..081dd06 100644 --- a/server/catalog-server.mjs +++ b/server/catalog-server.mjs @@ -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 []; diff --git a/server/foundry-data-product-consumer.mjs b/server/foundry-data-product-consumer.mjs index 7106170..b1167d8 100644 --- a/server/foundry-data-product-consumer.mjs +++ b/server/foundry-data-product-consumer.mjs @@ -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 } : {}), diff --git a/server/foundry-mcp.mjs b/server/foundry-mcp.mjs index e642218..220d8b4 100644 --- a/server/foundry-mcp.mjs +++ b/server/foundry-mcp.mjs @@ -174,26 +174,42 @@ const mapPresentationProfileInputSchema = { }, }, target: { - type: "object", - additionalProperties: false, - required: [ - "variant", "stemHeightMeters", "headSizePx", "stemWidthPx", "outlineColor", - "outlineOpacity", "outlineWidthPx", "hideCameraHeightMeters", - ], - properties: { - variant: { - type: "string", - enum: ["elevated-spike"], - description: "Renderer-neutral presentation preset interpreted by the active Map adapter.", + oneOf: [ + { + type: "object", + additionalProperties: false, + required: [ + "variant", "stemHeightMeters", "headSizePx", "stemWidthPx", "outlineColor", + "outlineOpacity", "outlineWidthPx", "hideCameraHeightMeters", + ], + properties: { + 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 }, + 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 }, + }, }, - stemHeightMeters: { type: "number", minimum: 1, maximum: 100000 }, - headSizePx: { type: "number", minimum: 1, maximum: 64 }, - stemWidthPx: { type: "number", minimum: 0.25, maximum: 16 }, - 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 }, - }, + { + 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", diff --git a/server/map-presentation-profile.mjs b/server/map-presentation-profile.mjs index 7222268..2dd6fa0 100644 --- a/server/map-presentation-profile.mjs +++ b/server/map-presentation-profile.mjs @@ -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, }; } diff --git a/server/map-presentation-profile.test.mjs b/server/map-presentation-profile.test.mjs index 46c8476..bb21e21 100644 --- a/server/map-presentation-profile.test.mjs +++ b/server/map-presentation-profile.test.mjs @@ -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/);