feat(foundry): render geozones as independent map layer
This commit is contained in:
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user