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
+109 -31
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,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),
});
}
}
+7 -5
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}
+4 -1
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;
+66 -12
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 {