feat(foundry): render geozones as independent map layer
This commit is contained in:
parent
a02c3ff3dd
commit
aa681ca069
|
|
@ -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]) {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
|
|
|
|||
Loading…
Reference in New Issue