feat(map): add reference layers and stabilize workspace controls
This commit is contained in:
@@ -118,13 +118,6 @@ interface FoundrySessionProfile {
|
||||
};
|
||||
}
|
||||
|
||||
interface CesiumIonSecretStatus {
|
||||
configured: boolean;
|
||||
updatedAt: string | null;
|
||||
updatedBy: string | null;
|
||||
verification?: "verified" | "failed" | "not-configured";
|
||||
}
|
||||
|
||||
const materialDefaults: Record<NodedcTheme, MaterialDraft> = {
|
||||
dark: {
|
||||
panelHex: "#151517",
|
||||
@@ -320,12 +313,7 @@ export function CatalogApp() {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
const toastSequenceRef = useRef(0);
|
||||
const [sessionProfile, setSessionProfile] = useState<FoundrySessionProfile | null>(null);
|
||||
const [platformSettingsOpen, setPlatformSettingsOpen] = useState(false);
|
||||
const [foundrySettingsOpen, setFoundrySettingsOpen] = useState(false);
|
||||
const [cesiumIonToken, setCesiumIonToken] = useState("");
|
||||
const [cesiumIonStatus, setCesiumIonStatus] = useState<CesiumIonSecretStatus | null>(null);
|
||||
const [cesiumIonSaveState, setCesiumIonSaveState] = useState<"idle" | "loading" | "saving" | "saved" | "error">("idle");
|
||||
const [cesiumIonError, setCesiumIonError] = useState("");
|
||||
const [mapTemplateLayout, setMapTemplateLayout] = useState<MapPageLayout | null>(null);
|
||||
const [mapTemplateSaveState, setMapTemplateSaveState] = useState<"idle" | "loading" | "saving" | "saved" | "error">("loading");
|
||||
const mapTemplatePreviewRef = useRef<MapFixturePreviewHandle>(null);
|
||||
@@ -527,60 +515,6 @@ export function CatalogApp() {
|
||||
|
||||
const isFoundryAdmin = sessionProfile?.access?.role === "admin";
|
||||
|
||||
const openPlatformSettings = () => {
|
||||
setPlatformSettingsOpen(true);
|
||||
setCesiumIonToken("");
|
||||
setCesiumIonError("");
|
||||
setCesiumIonSaveState("loading");
|
||||
void fetch("/api/platform-settings/cesium-ion", { cache: "no-store" })
|
||||
.then(async (response) => {
|
||||
if (!response.ok) throw new Error("platform_settings_load_failed");
|
||||
return await response.json() as CesiumIonSecretStatus;
|
||||
})
|
||||
.then((status) => {
|
||||
setCesiumIonStatus(status);
|
||||
setCesiumIonSaveState("idle");
|
||||
})
|
||||
.catch(() => setCesiumIonSaveState("error"));
|
||||
};
|
||||
|
||||
const closePlatformSettings = () => {
|
||||
setPlatformSettingsOpen(false);
|
||||
setCesiumIonToken("");
|
||||
setCesiumIonError("");
|
||||
};
|
||||
|
||||
const saveCesiumIonToken = async () => {
|
||||
const token = cesiumIonToken.trim();
|
||||
if (token.length < 16) return;
|
||||
setCesiumIonSaveState("saving");
|
||||
setCesiumIonError("");
|
||||
try {
|
||||
const response = await fetch("/api/platform-settings/cesium-ion", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const result = await response.json().catch(() => null) as { error?: string } | null;
|
||||
throw new Error(result?.error || "platform_settings_save_failed");
|
||||
}
|
||||
setCesiumIonStatus(await response.json() as CesiumIonSecretStatus);
|
||||
setCesiumIonToken("");
|
||||
setCesiumIonSaveState("saved");
|
||||
} catch (error) {
|
||||
const code = error instanceof Error ? error.message : "platform_settings_save_failed";
|
||||
setCesiumIonError(code === "cesium_ion_token_verification_failed"
|
||||
? "Token не прошёл проверку Cesium Ion для terrain, imagery или 3D Buildings. Значение не сохранено."
|
||||
: code === "map_gateway_admin_unauthorized"
|
||||
? "Внутренний signing profile Gateway не совпадает с Foundry. Это исправляется только runner-managed deployment, не вводом значений в интерфейс."
|
||||
: code === "map_gateway_admin_not_configured"
|
||||
? "Runner-managed signing file Gateway недоступен."
|
||||
: "Не удалось применить token. Проверьте права администратора и доступность Gateway.");
|
||||
setCesiumIonSaveState("error");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
fetch("/api/layout", { cache: "no-store" })
|
||||
@@ -2019,8 +1953,6 @@ export function CatalogApp() {
|
||||
onClose={closeGuideline}
|
||||
headerActions={studioContext === "applications" ? (
|
||||
<IconButton label="Создать модуль" onClick={() => setCreateModuleOpen(true)}><Icon name="plus" /></IconButton>
|
||||
) : studioContext === "pages" && isFoundryAdmin ? (
|
||||
<IconButton label="Настройки Platform" onClick={openPlatformSettings}><Icon name="settings" /></IconButton>
|
||||
) : undefined}
|
||||
contextSlot={studioContext === "applications" ? (
|
||||
<Select
|
||||
@@ -2270,57 +2202,7 @@ export function CatalogApp() {
|
||||
onConfirm={confirmPageRemoval}
|
||||
/>
|
||||
|
||||
<Window
|
||||
open={platformSettingsOpen}
|
||||
title="Настройки Platform"
|
||||
subtitle="Map Gateway · provider credentials"
|
||||
size="sm"
|
||||
onClose={closePlatformSettings}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={closePlatformSettings}>Отмена</Button>
|
||||
<WindowFooterActions>
|
||||
<Button
|
||||
variant="primary"
|
||||
shape="pill"
|
||||
icon={<Icon name="save" />}
|
||||
disabled={cesiumIonSaveState === "loading" || cesiumIonSaveState === "saving" || cesiumIonToken.trim().length < 16}
|
||||
onClick={() => { void saveCesiumIonToken(); }}
|
||||
>{cesiumIonSaveState === "saving" ? "Применение…" : "Применить"}</Button>
|
||||
</WindowFooterActions>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="catalog-form">
|
||||
<SettingsCard
|
||||
title="Cesium Ion"
|
||||
description="Master token хранится только в закрытом хранилище Platform Map Gateway. После применения он не отображается и не попадает в browser/env или artifact."
|
||||
>
|
||||
<ControlRow label="Состояние">
|
||||
<strong>{cesiumIonSaveState === "loading" ? "Проверяем…" : cesiumIonStatus?.configured ? "Настроен" : "Не настроен"}</strong>
|
||||
</ControlRow>
|
||||
{cesiumIonStatus?.configured ? <ControlRow label="Проверка provider"><strong>{cesiumIonStatus.verification === "verified" ? "Пройдена: terrain и imagery доступны" : cesiumIonStatus.verification === "failed" ? "Не пройдена: проверьте token" : "Будет выполнена при следующем применении"}</strong></ControlRow> : null}
|
||||
{cesiumIonStatus?.updatedAt ? <small>Последнее изменение: {new Date(cesiumIonStatus.updatedAt).toLocaleString()}</small> : null}
|
||||
</SettingsCard>
|
||||
<TextField
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
label="Cesium Ion token"
|
||||
hint="обязательно"
|
||||
description="Вставьте новый token. Существующее значение намеренно нельзя прочитать обратно."
|
||||
value={cesiumIonToken}
|
||||
onChange={(event) => {
|
||||
setCesiumIonToken(event.target.value);
|
||||
if (cesiumIonSaveState === "saved" || cesiumIonSaveState === "error") setCesiumIonSaveState("idle");
|
||||
setCesiumIonError("");
|
||||
}}
|
||||
/>
|
||||
{cesiumIonSaveState === "saved" ? <small className="catalog-application-draft__status">Token применён в Platform Map Gateway.</small> : null}
|
||||
{cesiumIonSaveState === "error" ? <small className="catalog-application-draft__status" data-state="error">{cesiumIonError}</small> : null}
|
||||
</div>
|
||||
</Window>
|
||||
|
||||
<FoundrySettingsModal open={foundrySettingsOpen} onClose={() => setFoundrySettingsOpen(false)} />
|
||||
<FoundrySettingsModal open={foundrySettingsOpen} isAdmin={isFoundryAdmin} onClose={() => setFoundrySettingsOpen(false)} />
|
||||
|
||||
<Window
|
||||
open={createModalOpen}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Cartesian3,
|
||||
BingMapsImageryProvider,
|
||||
BingMapsStyle,
|
||||
BillboardGraphics,
|
||||
Color,
|
||||
Credit,
|
||||
Cesium3DTileset,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
ImageryLayer,
|
||||
JulianDate,
|
||||
LabelGraphics,
|
||||
LabelStyle,
|
||||
Matrix4,
|
||||
Math as CesiumMath,
|
||||
PerInstanceColorAppearance,
|
||||
@@ -64,6 +66,41 @@ const MAX_SPIRAL_SUBSTEPS_PER_FRAME = 300;
|
||||
const TERRAIN_SAMPLE_TIMEOUT_MS = 12_000;
|
||||
const SPIRAL_TILE_WAIT_TIMEOUT_MS = 45_000;
|
||||
const MAX_HGEOZONE_INSTANCES_PER_BATCH = 256;
|
||||
const elevatedTargetImageCache = new Map<string, string>();
|
||||
|
||||
function elevatedTargetImage(
|
||||
fillColor: Color,
|
||||
outlineColor: Color,
|
||||
outlineWidthPx: number,
|
||||
headSizePx: number,
|
||||
) {
|
||||
const safeHeadSize = Math.max(1, headSizePx);
|
||||
const safeOutlineWidth = Math.max(0, outlineWidthPx);
|
||||
const imageSize = Math.max(1, Math.ceil(safeHeadSize + safeOutlineWidth * 2));
|
||||
const key = [
|
||||
fillColor.toCssColorString(),
|
||||
outlineColor.toCssColorString(),
|
||||
safeOutlineWidth,
|
||||
safeHeadSize,
|
||||
imageSize,
|
||||
].join("|");
|
||||
const cached = elevatedTargetImageCache.get(key);
|
||||
if (cached) return { image: cached, size: imageSize };
|
||||
const center = imageSize / 2;
|
||||
const radius = Math.max(0.5, safeHeadSize / 2);
|
||||
const svg = [
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${imageSize}" height="${imageSize}" viewBox="0 0 ${imageSize} ${imageSize}">`,
|
||||
`<circle cx="${center}" cy="${center}" r="${radius}" fill="${fillColor.toCssColorString()}"`,
|
||||
safeOutlineWidth > 0
|
||||
? ` stroke="${outlineColor.toCssColorString()}" stroke-width="${safeOutlineWidth}"/>`
|
||||
: "/>",
|
||||
"</svg>",
|
||||
].join("");
|
||||
const image = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||
elevatedTargetImageCache.set(key, image);
|
||||
return { image, size: imageSize };
|
||||
}
|
||||
|
||||
type RuntimeConfig = {
|
||||
cesiumVersion: string;
|
||||
provider: string;
|
||||
@@ -622,7 +659,11 @@ function syncRuntimeDataSources(
|
||||
faultedHGeoZoneGeometryKeys: Set<string>,
|
||||
) {
|
||||
const activeBindings = new Map(bindings
|
||||
.filter((binding) => binding.slotId === "points" || binding.slotId === "zones")
|
||||
.filter((binding) => (
|
||||
binding.slotId === "points"
|
||||
|| binding.slotId === "reference-points"
|
||||
|| binding.slotId === "zones"
|
||||
))
|
||||
.map((binding) => [binding.bindingId, binding]));
|
||||
|
||||
for (const [bindingId, dataSource] of dataSources) {
|
||||
@@ -657,7 +698,7 @@ function syncRuntimeDataSources(
|
||||
const label = mapRuntimeDisplayLabel(fact, profile);
|
||||
const baseEntityId = mapRuntimeEntityId(binding.bindingId, fact);
|
||||
|
||||
if (fact.geometry.type === "Point" && binding.slotId === "points") {
|
||||
if (fact.geometry.type === "Point" && (binding.slotId === "points" || binding.slotId === "reference-points")) {
|
||||
if (profile && profile.target.variant !== "elevated-spike") continue;
|
||||
const entityId = baseEntityId;
|
||||
wanted.add(entityId);
|
||||
@@ -666,19 +707,23 @@ function syncRuntimeDataSources(
|
||||
entity.name = label;
|
||||
entity.polygon = undefined;
|
||||
if (!profile) {
|
||||
entity.billboard = undefined;
|
||||
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,
|
||||
outlineColor: Color.TRANSPARENT,
|
||||
outlineWidth: 0,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
entity.label = new LabelGraphics({
|
||||
text: label,
|
||||
font: "700 13px Arial",
|
||||
fillColor: Color.WHITE,
|
||||
outlineColor: Color.TRANSPARENT,
|
||||
outlineWidth: 0,
|
||||
style: LabelStyle.FILL,
|
||||
showBackground: true,
|
||||
backgroundColor: Color.BLACK.withAlpha(0.72),
|
||||
backgroundPadding: new Cartesian2(10, 7),
|
||||
@@ -714,11 +759,19 @@ function syncRuntimeDataSources(
|
||||
material: color,
|
||||
show: showBelowCameraHeight(viewer, target.hideCameraHeightMeters),
|
||||
});
|
||||
entity.point = new PointGraphics({
|
||||
pixelSize: target.headSizePx,
|
||||
const targetImage = elevatedTargetImage(
|
||||
color,
|
||||
outlineColor: Color.fromCssColorString(target.outlineColor).withAlpha(target.outlineOpacity),
|
||||
outlineWidth: target.outlineWidthPx,
|
||||
Color.fromCssColorString(target.outlineColor).withAlpha(target.outlineOpacity),
|
||||
target.outlineWidthPx,
|
||||
target.headSizePx,
|
||||
);
|
||||
entity.point = undefined;
|
||||
entity.billboard = new BillboardGraphics({
|
||||
image: targetImage.image,
|
||||
width: targetImage.size,
|
||||
height: targetImage.size,
|
||||
horizontalOrigin: HorizontalOrigin.CENTER,
|
||||
verticalOrigin: VerticalOrigin.CENTER,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
show: showBelowCameraHeight(viewer, target.hideCameraHeightMeters),
|
||||
});
|
||||
@@ -726,9 +779,9 @@ function syncRuntimeDataSources(
|
||||
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,
|
||||
outlineColor: Color.TRANSPARENT,
|
||||
outlineWidth: 0,
|
||||
style: LabelStyle.FILL,
|
||||
showBackground: profile.label.backgroundOpacity > 0,
|
||||
backgroundColor: Color.fromCssColorString(profile.label.backgroundColor).withAlpha(profile.label.backgroundOpacity),
|
||||
backgroundPadding: new Cartesian2(profile.label.paddingX, profile.label.paddingY),
|
||||
@@ -759,6 +812,7 @@ function syncRuntimeDataSources(
|
||||
const entity = dataSource.entities.getById(baseEntityId) ?? dataSource.entities.add({ id: baseEntityId });
|
||||
entity.name = label;
|
||||
entity.position = new ConstantPositionProperty(Cartesian3.fromDegrees(labelAnchor[0] / divisor, labelAnchor[1] / divisor, 0));
|
||||
entity.billboard = undefined;
|
||||
entity.point = undefined;
|
||||
entity.polygon = undefined;
|
||||
entity.polyline = undefined;
|
||||
@@ -766,9 +820,9 @@ function syncRuntimeDataSources(
|
||||
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,
|
||||
outlineColor: Color.TRANSPARENT,
|
||||
outlineWidth: 0,
|
||||
style: LabelStyle.FILL,
|
||||
showBackground: (profile?.label.backgroundOpacity ?? 0.72) > 0,
|
||||
backgroundColor: profile
|
||||
? Color.fromCssColorString(profile.label.backgroundColor).withAlpha(profile.label.backgroundOpacity)
|
||||
@@ -1330,10 +1384,46 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||
const viewer = viewerRef.current;
|
||||
if (!viewer || viewer.isDestroyed()) return false;
|
||||
const entity = runtimeEntities([entityId])[0];
|
||||
if (!entity) return false;
|
||||
void viewer.flyTo(entity, {
|
||||
duration: 0.45,
|
||||
offset: new HeadingPitchRange(0, -0.9, 8_000),
|
||||
const position = entity?.position?.getValue(viewer.clock.currentTime);
|
||||
if (!position) return false;
|
||||
|
||||
// Preserve the observer's current composition exactly as the proven
|
||||
// legacy MMAP/AIS interaction does: move the camera/viewport frame to the
|
||||
// selected point without replacing heading, pitch, roll or ground offset.
|
||||
const camera = viewer.camera;
|
||||
const canvas = viewer.scene.canvas;
|
||||
const viewportCenter = camera.pickEllipsoid(
|
||||
new Cartesian2(canvas.clientWidth / 2, canvas.clientHeight / 2),
|
||||
viewer.scene.globe.ellipsoid,
|
||||
);
|
||||
const cartographic = Cartographic.fromCartesian(position);
|
||||
const groundTarget = Cartesian3.fromRadians(
|
||||
cartographic.longitude,
|
||||
cartographic.latitude,
|
||||
0,
|
||||
viewer.scene.globe.ellipsoid,
|
||||
);
|
||||
const destination = viewportCenter
|
||||
? Cartesian3.add(
|
||||
groundTarget,
|
||||
Cartesian3.subtract(camera.position, viewportCenter, new Cartesian3()),
|
||||
new Cartesian3(),
|
||||
)
|
||||
: Cartesian3.fromRadians(
|
||||
cartographic.longitude,
|
||||
cartographic.latitude,
|
||||
camera.positionCartographic.height,
|
||||
viewer.scene.globe.ellipsoid,
|
||||
);
|
||||
|
||||
camera.flyTo({
|
||||
destination,
|
||||
orientation: {
|
||||
heading: camera.heading,
|
||||
pitch: camera.pitch,
|
||||
roll: camera.roll,
|
||||
},
|
||||
duration: 2.1,
|
||||
});
|
||||
return true;
|
||||
}, [runtimeEntities]);
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, ControlRow, Icon, SettingsCard, TextField } from "@nodedc/ui-react";
|
||||
|
||||
type CesiumIonSecretStatus = {
|
||||
configured: boolean;
|
||||
updatedAt: string | null;
|
||||
verification?: "verified" | "failed" | "not-configured";
|
||||
};
|
||||
|
||||
type MapGatewayHealth = {
|
||||
referenceSources?: {
|
||||
transportStations?: {
|
||||
profileId?: string;
|
||||
seedFactCount?: number;
|
||||
fetchEnabled?: boolean;
|
||||
cellDegrees?: number;
|
||||
cachedCellCount?: number;
|
||||
lastRefreshAt?: string | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export function FoundryMapProviderSettings() {
|
||||
const [token, setToken] = useState("");
|
||||
const [status, setStatus] = useState<CesiumIonSecretStatus | null>(null);
|
||||
const [gateway, setGateway] = useState<MapGatewayHealth | null>(null);
|
||||
const [saveState, setSaveState] = useState<"loading" | "idle" | "saving" | "saved" | "error">("loading");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setSaveState("loading");
|
||||
void Promise.all([
|
||||
fetch("/api/platform-settings/cesium-ion", { cache: "no-store" })
|
||||
.then((response) => response.ok ? response.json() as Promise<CesiumIonSecretStatus> : Promise.reject()),
|
||||
fetch("/api/map-gateway/healthz", { cache: "no-store" })
|
||||
.then((response) => response.ok ? response.json() as Promise<MapGatewayHealth> : null),
|
||||
]).then(([nextStatus, nextGateway]) => {
|
||||
if (!active) return;
|
||||
setStatus(nextStatus);
|
||||
setGateway(nextGateway);
|
||||
setSaveState("idle");
|
||||
}).catch(() => {
|
||||
if (active) setSaveState("error");
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, []);
|
||||
|
||||
const save = async () => {
|
||||
const value = token.trim();
|
||||
if (value.length < 16) return;
|
||||
setSaveState("saving");
|
||||
setError("");
|
||||
try {
|
||||
const response = await fetch("/api/platform-settings/cesium-ion", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ token: value }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const result = await response.json().catch(() => null) as { error?: string } | null;
|
||||
throw new Error(result?.error || "platform_settings_save_failed");
|
||||
}
|
||||
setStatus(await response.json() as CesiumIonSecretStatus);
|
||||
setToken("");
|
||||
setSaveState("saved");
|
||||
} catch (cause) {
|
||||
const code = cause instanceof Error ? cause.message : "platform_settings_save_failed";
|
||||
setError(code === "cesium_ion_token_verification_failed"
|
||||
? "Token не прошёл проверку terrain, imagery и 3D Buildings. Старое значение сохранено."
|
||||
: code === "map_gateway_admin_unauthorized"
|
||||
? "Внутренний signing profile Gateway не совпадает с Foundry."
|
||||
: "Не удалось применить token. Проверьте права администратора и доступность Gateway.");
|
||||
setSaveState("error");
|
||||
}
|
||||
};
|
||||
|
||||
const stations = gateway?.referenceSources?.transportStations;
|
||||
return (
|
||||
<div className="catalog-form">
|
||||
<SettingsCard
|
||||
eyebrow="MAP PROVIDERS"
|
||||
title="Cesium Ion"
|
||||
description="Master token хранится только в закрытом хранилище Platform Map Gateway. После применения он не отображается и не попадает в browser, env ответа или Application artifact."
|
||||
>
|
||||
<ControlRow label="Состояние">
|
||||
<strong>{saveState === "loading" ? "Проверяем…" : status?.configured ? "Настроен" : "Не настроен"}</strong>
|
||||
</ControlRow>
|
||||
{status?.configured ? (
|
||||
<ControlRow label="Проверка provider">
|
||||
<strong>{status.verification === "verified" ? "Пройдена: terrain, imagery и 3D доступны" : "Не пройдена"}</strong>
|
||||
</ControlRow>
|
||||
) : null}
|
||||
{status?.updatedAt ? <small>Последнее изменение: {new Date(status.updatedAt).toLocaleString()}</small> : null}
|
||||
</SettingsCard>
|
||||
<TextField
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
label="Cesium Ion token"
|
||||
hint="обязательно"
|
||||
description="Вставьте новый token. Существующее значение намеренно нельзя прочитать обратно."
|
||||
value={token}
|
||||
onChange={(event) => {
|
||||
setToken(event.target.value);
|
||||
if (saveState === "saved" || saveState === "error") setSaveState("idle");
|
||||
setError("");
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
shape="pill"
|
||||
icon={<Icon name="save" />}
|
||||
disabled={saveState === "loading" || saveState === "saving" || token.trim().length < 16}
|
||||
onClick={() => { void save(); }}
|
||||
>{saveState === "saving" ? "Применение…" : "Применить Cesium token"}</Button>
|
||||
{saveState === "saved" ? <small className="catalog-application-draft__status">Token применён в Platform Map Gateway.</small> : null}
|
||||
{saveState === "error" && error ? <small className="catalog-application-draft__status" data-state="error">{error}</small> : null}
|
||||
|
||||
<SettingsCard
|
||||
eyebrow="MAP REFERENCE SOURCE"
|
||||
title="OpenStreetMap · транспортные станции"
|
||||
description="Token не требуется. Map Gateway нормализует только разрешённые поля map.station/map.terminal; raw OSM payload и provider endpoint не попадают в Foundry."
|
||||
>
|
||||
<ControlRow label="Профиль"><strong>{stations?.profileId ?? "transport-stations.v1"}</strong></ControlRow>
|
||||
<ControlRow label="Начальный snapshot"><strong>{stations?.seedFactCount ?? 638} точек</strong></ControlRow>
|
||||
<ControlRow label="Пространственный кэш"><strong>{stations ? `${stations.cachedCellCount ?? 0} ячеек · ${stations.cellDegrees ?? 0.5}°` : "проверяется…"}</strong></ControlRow>
|
||||
<ControlRow label="Подгрузка"><strong>{stations?.fetchEnabled === false ? "только snapshot" : "по viewport, через Gateway"}</strong></ControlRow>
|
||||
{stations?.lastRefreshAt ? <small>Последнее пополнение: {new Date(stations.lastRefreshAt).toLocaleString()}</small> : null}
|
||||
</SettingsCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,44 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { FeatureSettingsWindow } from "@nodedc/ui-react";
|
||||
import { FoundryCodexAgentSettings } from "./FoundryCodexAgentSettings.js";
|
||||
import { FoundryMapProviderSettings } from "./FoundryMapProviderSettings.js";
|
||||
|
||||
interface FoundrySettingsModalProps {
|
||||
open: boolean;
|
||||
isAdmin: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function FoundrySettingsModal({ open, onClose }: FoundrySettingsModalProps) {
|
||||
type FoundrySettingsSection = "codex-agent-api" | "map-providers";
|
||||
|
||||
export function FoundrySettingsModal({ open, isAdmin, onClose }: FoundrySettingsModalProps) {
|
||||
const [activeSection, setActiveSection] = useState<FoundrySettingsSection>("codex-agent-api");
|
||||
useEffect(() => {
|
||||
if (!isAdmin && activeSection === "map-providers") setActiveSection("codex-agent-api");
|
||||
}, [activeSection, isAdmin]);
|
||||
const sections = [
|
||||
{ id: "codex-agent-api" as const, label: "Codex Agent API", group: "Features", icon: "network" as const },
|
||||
...(isAdmin ? [{
|
||||
id: "map-providers" as const,
|
||||
label: "Картографические источники",
|
||||
group: "Admin",
|
||||
icon: "globe" as const,
|
||||
}] : []),
|
||||
];
|
||||
return (
|
||||
<FeatureSettingsWindow
|
||||
open={open}
|
||||
title="Настройки Foundry"
|
||||
subtitle="NODE DC / Codex Agent API"
|
||||
identity={{ title: "NODE.DC Foundry", subtitle: "Текущий пользователь", avatarLabel: "NF" }}
|
||||
sections={[{ id: "codex-agent-api", label: "Codex Agent API", group: "Features", icon: "network" }]}
|
||||
activeSection="codex-agent-api"
|
||||
onSectionChange={() => undefined}
|
||||
sections={sections}
|
||||
activeSection={activeSection}
|
||||
onSectionChange={setActiveSection}
|
||||
onClose={onClose}
|
||||
>
|
||||
<FoundryCodexAgentSettings />
|
||||
{activeSection === "map-providers" && isAdmin
|
||||
? <FoundryMapProviderSettings />
|
||||
: <FoundryCodexAgentSettings />}
|
||||
</FeatureSettingsWindow>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,13 @@ import {
|
||||
type CameraSurveySelection,
|
||||
} from "./mapCameraPresets.js";
|
||||
import { buildMapSubjectCardModel, DEFAULT_MAP_SUBJECT_DETAIL_PROFILE } from "./mapSubjectCard.mjs";
|
||||
import {
|
||||
ensureMapReferencePresentationProfiles,
|
||||
initialMapReferenceLayers,
|
||||
isMapReferencePresentationProfile,
|
||||
type MapReferenceLayer,
|
||||
} from "./mapReferenceStations.js";
|
||||
import { useMapReferenceRuntime } from "./useMapReferenceRuntime.js";
|
||||
const CesiumMapRenderer = lazy(() => import("./CesiumMapRenderer.js").then((module) => ({ default: module.CesiumMapRenderer })));
|
||||
|
||||
type PreviewFeatures = { inspector?: boolean; toolbar?: boolean; assistant?: boolean };
|
||||
@@ -171,6 +178,8 @@ export type MapPageLayout = {
|
||||
subjectDetailProfiles: MapSubjectDetailProfile[];
|
||||
dataProductBindings: MapDataProductBinding[];
|
||||
subjectStates: MapSubjectState[];
|
||||
referenceLayers: MapReferenceLayer[];
|
||||
inspectorOpenSections: string[];
|
||||
savedAt?: string;
|
||||
};
|
||||
|
||||
@@ -247,10 +256,12 @@ export function createDefaultMapPageLayout(expanded = false): MapPageLayout {
|
||||
mapHeight: expanded ? 620 : 470,
|
||||
camera: { ...fallbackMapCamera },
|
||||
pinBindings: [],
|
||||
presentationProfiles: [],
|
||||
presentationProfiles: ensureMapReferencePresentationProfiles([]),
|
||||
subjectDetailProfiles: [structuredClone(DEFAULT_MAP_SUBJECT_DETAIL_PROFILE) as MapSubjectDetailProfile],
|
||||
dataProductBindings: [],
|
||||
subjectStates: [],
|
||||
referenceLayers: initialMapReferenceLayers(),
|
||||
inspectorOpenSections: ["map-base"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -354,7 +365,11 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
const [subjectCardZIndex, setSubjectCardZIndex] = useState(140);
|
||||
const [subjectCardActive, setSubjectCardActive] = useState(false);
|
||||
const [subjectCardTabId, setSubjectCardTabId] = useState("overview");
|
||||
const [expandedFacetRows, setExpandedFacetRows] = useState<Record<string, boolean>>({});
|
||||
const [inspectorOpen, setInspectorOpen] = useState(false);
|
||||
const [inspectorOpenSections, setInspectorOpenSections] = useState<string[]>(() => (
|
||||
initialLayout?.inspectorOpenSections ?? ["map-base"]
|
||||
));
|
||||
const [layersOpen, setLayersOpen] = useState(false);
|
||||
const [layersWindowRect, setLayersWindowRect] = useState<WorkspaceWindowRect>(defaultLayersWindowRect);
|
||||
const [layersWindowMaximized, setLayersWindowMaximized] = useState(false);
|
||||
@@ -388,7 +403,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
// Presentation profiles are application/page-owned, versioned map.style_profile
|
||||
// values. A human camera/settings save must preserve profiles provisioned by MCP.
|
||||
const [presentationProfiles, setPresentationProfiles] = useState<MapPresentationProfile[]>(() => (
|
||||
normalizeClientMapPresentationProfiles(initialLayout?.presentationProfiles ?? [])
|
||||
ensureMapReferencePresentationProfiles(normalizeClientMapPresentationProfiles(initialLayout?.presentationProfiles ?? []))
|
||||
));
|
||||
const [subjectDetailProfiles] = useState<MapSubjectDetailProfile[]>(() => (
|
||||
initialLayout?.subjectDetailProfiles?.length
|
||||
@@ -399,6 +414,9 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
// not belong to the visual inspector. Preserve them verbatim when a human
|
||||
// edits camera or presentation settings and saves the page layout.
|
||||
const [dataProductBindings] = useState<MapDataProductBinding[]>(() => initialLayout?.dataProductBindings ?? []);
|
||||
const [referenceLayers, setReferenceLayers] = useState<MapReferenceLayer[]>(() => (
|
||||
initialMapReferenceLayers(initialLayout?.referenceLayers)
|
||||
));
|
||||
const [subjectStates, setSubjectStates] = useState<Record<string, MapSubjectState>>(() => (
|
||||
initialSubjectState(initialLayout?.dataProductBindings ?? [], initialLayout?.subjectStates)
|
||||
));
|
||||
@@ -415,10 +433,23 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
bindings: dataProductBindings,
|
||||
enabled: Boolean(applicationId && pageId),
|
||||
});
|
||||
const referenceRuntimeBindings = useMapReferenceRuntime(referenceLayers, mapCamera, true);
|
||||
const referencePresentationFilters = useMemo<MapPresentationFilters>(() => Object.fromEntries(
|
||||
referenceLayers.map((layer) => [layer.id, { visible: layer.visible, facets: {} }]),
|
||||
), [referenceLayers]);
|
||||
const rendererPresentationFilters = useMemo<MapPresentationFilters>(() => ({
|
||||
...presentationFilters,
|
||||
...referencePresentationFilters,
|
||||
}), [presentationFilters, referencePresentationFilters]);
|
||||
const primaryBindingIds = useMemo(() => new Set(
|
||||
dataProductBindings.filter((binding) => !binding.joinToBindingId).map((binding) => binding.id),
|
||||
), [dataProductBindings]);
|
||||
const primaryRuntimeBindings = useMemo(() => (
|
||||
runtimeBindings.filter((binding) => primaryBindingIds.has(binding.bindingId))
|
||||
), [primaryBindingIds, runtimeBindings]);
|
||||
const selectable = useMemo(() => (
|
||||
runtimeBindings.flatMap((binding) => {
|
||||
primaryRuntimeBindings.flatMap((binding) => {
|
||||
const bindingConfig = dataProductBindings.find((candidate) => candidate.id === binding.bindingId);
|
||||
if (bindingConfig?.joinToBindingId) return [];
|
||||
const facts = [...binding.facts];
|
||||
const primaryProfile = mapPresentationProfileForFact(
|
||||
presentationProfiles,
|
||||
@@ -440,8 +471,9 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
};
|
||||
});
|
||||
})
|
||||
), [dataProductBindings, presentationProfiles, runtimeBindings]);
|
||||
), [dataProductBindings, presentationProfiles, primaryRuntimeBindings]);
|
||||
const presentationSummaries = useMemo(() => [...dataProductBindings]
|
||||
.filter((binding) => !binding.joinToBindingId)
|
||||
.sort((left, right) => (left.order ?? 0) - (right.order ?? 0) || left.id.localeCompare(right.id))
|
||||
.flatMap((bindingConfig) => {
|
||||
const binding = runtimeBindings.find((candidate) => candidate.bindingId === bindingConfig.id);
|
||||
@@ -457,7 +489,18 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
counts: mapPresentationFacetCounts(facts, profile),
|
||||
}];
|
||||
}), [dataProductBindings, presentationProfiles, runtimeBindings]);
|
||||
const filteredTargets = useMemo(() => runtimeBindings.flatMap((binding) => {
|
||||
const referenceObjectSummaries = useMemo(() => referenceLayers.flatMap((layer) => {
|
||||
const profile = presentationProfiles.find((candidate) => candidate.id === layer.presentationProfileId);
|
||||
if (!profile) return [];
|
||||
const runtime = referenceRuntimeBindings.find((candidate) => candidate.bindingId === layer.id);
|
||||
return [{
|
||||
layer,
|
||||
displayName: profile.title,
|
||||
total: runtime?.facts.length ?? 0,
|
||||
}];
|
||||
}), [presentationProfiles, referenceLayers, referenceRuntimeBindings]);
|
||||
const objectLayerCount = presentationSummaries.length + referenceObjectSummaries.length;
|
||||
const filteredTargets = useMemo(() => primaryRuntimeBindings.flatMap((binding) => {
|
||||
const bindingConfig = dataProductBindings.find((candidate) => candidate.id === binding.bindingId);
|
||||
return binding.facts.flatMap((fact) => {
|
||||
const profile = mapPresentationProfileForFact(
|
||||
@@ -475,7 +518,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
renderable: mapRuntimeFactIsRenderable(fact, profile),
|
||||
}];
|
||||
});
|
||||
}).sort((left, right) => left.title.localeCompare(right.title, "ru")), [dataProductBindings, presentationFilters, presentationProfiles, runtimeBindings]);
|
||||
}).sort((left, right) => left.title.localeCompare(right.title, "ru")), [dataProductBindings, presentationFilters, presentationProfiles, primaryRuntimeBindings]);
|
||||
const visibleTargetEntityIds = useMemo(() => (
|
||||
filteredTargets.filter((target) => target.renderable).map((target) => target.entityId)
|
||||
), [filteredTargets]);
|
||||
@@ -714,6 +757,8 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
presentationProfiles,
|
||||
subjectDetailProfiles,
|
||||
dataProductBindings,
|
||||
referenceLayers,
|
||||
inspectorOpenSections,
|
||||
subjectStates: dataProductBindings.map((binding) => subjectStates[binding.id] ?? {
|
||||
bindingId: binding.id,
|
||||
visible: true,
|
||||
@@ -726,7 +771,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
: state;
|
||||
}),
|
||||
}),
|
||||
}), [dataProductBindings, mapCamera, mapHeight, mapSettings, pinBindings, presentationProfiles, presentationSummaries, subjectDetailProfiles, subjectStates]);
|
||||
}), [dataProductBindings, inspectorOpenSections, mapCamera, mapHeight, mapSettings, pinBindings, presentationProfiles, presentationSummaries, referenceLayers, subjectDetailProfiles, subjectStates]);
|
||||
|
||||
const updateSubjectState = (bindingId: string, update: (state: MapSubjectState) => MapSubjectState) => {
|
||||
setSubjectStates((current) => {
|
||||
@@ -811,7 +856,11 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
const binding = dataProductBindings.find((candidate) => candidate.id === entity?.bindingId);
|
||||
const profile = subjectDetailProfiles.find((candidate) => candidate.id === binding?.subjectDetailProfileId)
|
||||
?? subjectDetailProfiles.find((candidate) => candidate.semanticTypes.includes(entity?.fact.semanticType ?? ""));
|
||||
setSubjectCardTabId(profile?.defaultTabId ?? "overview");
|
||||
setSubjectCardTabId((current) => (
|
||||
profile?.tabs.some((tab) => tab.id === current)
|
||||
? current
|
||||
: (profile?.defaultTabId ?? "overview")
|
||||
));
|
||||
setSubjectCardOpen(true);
|
||||
setSubjectCardActive(true);
|
||||
setLayersWindowActive(false);
|
||||
@@ -819,6 +868,11 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
setSubjectCardZIndex((current) => Math.max(current, layersWindowZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1);
|
||||
}, [dataProductBindings, layersWindowZIndex, selectable, subjectDetailProfiles, subjectStates]);
|
||||
|
||||
const handleSelectAndFocus = useCallback((entityId: string) => {
|
||||
handleSelect(entityId);
|
||||
mapRendererRef.current?.focusRuntimeEntity(entityId);
|
||||
}, [handleSelect]);
|
||||
|
||||
const rememberGatewayHealth = useCallback((health: MapGatewayHealth) => {
|
||||
gatewayHealthRef.current = health;
|
||||
setGatewayHealth(health);
|
||||
@@ -1010,14 +1064,26 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
<RangeControl label="Детализация" value={mapSettings.buildingsDetail} min={4} max={32} formatValue={(value) => `SSE ${value}`} onChange={(buildingsDetail) => updateMapSettings({ buildingsDetail })} />
|
||||
</>,
|
||||
},
|
||||
...presentationProfiles.flatMap((profile) => [
|
||||
...presentationProfiles.flatMap((profile) => {
|
||||
const referenceProfile = isMapReferencePresentationProfile(profile);
|
||||
const referenceLayer = referenceLayers.find((layer) => layer.presentationProfileId === profile.id);
|
||||
return [
|
||||
{
|
||||
id: `map-target-${profile.id}`,
|
||||
label: profile.target.variant === "surface-fill" ? "HGeoZone" : "Таргет",
|
||||
label: referenceProfile ? profile.title : profile.target.variant === "surface-fill" ? "HGeoZone" : "Таргет",
|
||||
description: profile.target.variant === "surface-fill" ? `проекция · ${profile.title}` : profile.title,
|
||||
group: profile.target.variant === "surface-fill" ? "Слои" : "Таргеты",
|
||||
group: referenceProfile ? "Станции" : profile.target.variant === "surface-fill" ? "Слои" : "Таргеты",
|
||||
content: <>
|
||||
<small className="catalog-map-inspector__note">Профиль принадлежит этой странице Application и управляется тем же provider-neutral MCP-контрактом. Исходный API в настройках отсутствует.</small>
|
||||
{referenceLayer ? (
|
||||
<Checker
|
||||
checked={referenceLayer.visible}
|
||||
label={`Показывать слой «${profile.title}»`}
|
||||
onChange={(visible) => setReferenceLayers((current) => current.map((layer) => (
|
||||
layer.id === referenceLayer.id ? { ...layer, visible } : layer
|
||||
)))}
|
||||
/>
|
||||
) : null}
|
||||
{profile.target.variant === "surface-fill" && <>
|
||||
<ControlRow label="Тип слоя"><strong>HGeoZone · ground projection</strong></ControlRow>
|
||||
{profile.styles.map((style) => {
|
||||
@@ -1054,14 +1120,9 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
<RangeControl label={profile.target.variant === "surface-fill" ? "Скрывать HGeoZone выше" : "Скрывать таргет выше"} value={profile.target.hideCameraHeightMeters} min={1_000} max={500_000} step={1_000} formatValue={(value) => `${Math.round(value / 1_000)} км`} onChange={(hideCameraHeightMeters) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, hideCameraHeightMeters } }))} />
|
||||
<ControlRow label="Фон плашки"><ColorField label="Цвет фона подписи" value={profile.label.backgroundColor} onChange={(backgroundColor) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, backgroundColor } }))} /></ControlRow>
|
||||
<RangeControl label="Прозрачность плашки" value={Math.round(profile.label.backgroundOpacity * 100)} min={0} max={100} step={1} formatValue={(value) => `${value}%`} onChange={(value) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, backgroundOpacity: value / 100 } }))} />
|
||||
{profile.target.variant === "elevated-spike" && <>
|
||||
<ControlRow label="Обводка таргета"><ColorField label="Цвет обводки таргета" value={profile.target.outlineColor} onChange={(outlineColor) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, outlineColor } }))} /></ControlRow>
|
||||
<RangeControl label="Прозрачность обводки" value={Math.round(profile.target.outlineOpacity * 100)} min={0} max={100} step={1} formatValue={(value) => `${value}%`} onChange={(value) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, outlineOpacity: value / 100 } }))} />
|
||||
<RangeControl label="Толщина обводки" value={profile.target.outlineWidthPx} min={0} max={8} step={0.5} formatValue={(value) => `${value} px`} onChange={(outlineWidthPx) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, outlineWidthPx } }))} />
|
||||
</>}
|
||||
</>,
|
||||
},
|
||||
...(profile.target.variant === "surface-fill" ? [] : [{
|
||||
...(profile.target.variant === "surface-fill" || referenceProfile ? [] : [{
|
||||
id: `map-state-classes-${profile.id}`,
|
||||
label: "Классы состояния",
|
||||
description: "нормализованные фасеты онтологии",
|
||||
@@ -1078,7 +1139,8 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
})}
|
||||
</>,
|
||||
}]),
|
||||
]),
|
||||
];
|
||||
}),
|
||||
{
|
||||
id: "map-grid",
|
||||
label: "Сетка и LOD",
|
||||
@@ -1239,9 +1301,9 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
onSpiralStateChange={handleSpiralStateChange}
|
||||
initialCamera={mapCamera ?? undefined}
|
||||
presentation={presentation}
|
||||
runtimeBindings={runtimeBindings}
|
||||
runtimeBindings={[...primaryRuntimeBindings, ...referenceRuntimeBindings]}
|
||||
presentationProfiles={presentationProfiles}
|
||||
presentationFilters={presentationFilters}
|
||||
presentationFilters={rendererPresentationFilters}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
@@ -1308,7 +1370,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
<div className="catalog-map-fixture__objects-menu-list">
|
||||
<div className="catalog-map-fixture__objects-menu-head">
|
||||
<strong>Объекты</strong>
|
||||
<small>{presentationSummaries.length} {presentationSummaries.length === 1 ? "группа" : "групп"}</small>
|
||||
<small>{objectLayerCount} {objectLayerCount === 1 ? "группа" : "групп"}</small>
|
||||
</div>
|
||||
{presentationSummaries.map((summary) => {
|
||||
const state = subjectStates[summary.bindingId];
|
||||
@@ -1324,30 +1386,45 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={visible}
|
||||
role={hasControls ? "menuitem" : "menuitemcheckbox"}
|
||||
aria-checked={hasControls ? undefined : visible}
|
||||
className="catalog-map-fixture__objects-menu-toggle"
|
||||
onClick={() => toggleSubjectVisibility(summary.bindingId)}
|
||||
onClick={() => {
|
||||
if (hasControls) {
|
||||
openSubjectWindow(summary.bindingId);
|
||||
close();
|
||||
return;
|
||||
}
|
||||
toggleSubjectVisibility(summary.bindingId);
|
||||
}}
|
||||
>
|
||||
<span>{summary.displayName}</span>
|
||||
<small>{visible ? `на карте: ${visibleCount}` : `слой скрыт · ${summary.total} объектов`}</small>
|
||||
</button>
|
||||
{hasControls ? (
|
||||
<IconButton
|
||||
label={`Фильтры и счётчики: ${summary.displayName}`}
|
||||
role="menuitem"
|
||||
aria-pressed={state?.window.open || false}
|
||||
data-active={state?.window.open || undefined}
|
||||
onClick={() => {
|
||||
openSubjectWindow(summary.bindingId);
|
||||
close();
|
||||
}}
|
||||
><Icon name="settings" /></IconButton>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!presentationSummaries.length ? <small className="catalog-map-fixture__objects-menu-empty">Нет подключённых объектов.</small> : null}
|
||||
{referenceObjectSummaries.map(({ layer, displayName, total }) => (
|
||||
<div
|
||||
className="catalog-map-fixture__objects-menu-item"
|
||||
key={layer.id}
|
||||
data-visible={layer.visible || undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={layer.visible}
|
||||
className="catalog-map-fixture__objects-menu-toggle"
|
||||
onClick={() => setReferenceLayers((current) => current.map((candidate) => (
|
||||
candidate.id === layer.id ? { ...candidate, visible: !candidate.visible } : candidate
|
||||
)))}
|
||||
>
|
||||
<span>{displayName}</span>
|
||||
<small>{layer.visible ? `на карте: ${total}` : `слой скрыт · ${total} объектов`}</small>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{!objectLayerCount ? <small className="catalog-map-fixture__objects-menu-empty">Нет подключённых объектов.</small> : null}
|
||||
</div>
|
||||
)}
|
||||
</Dropdown>
|
||||
@@ -1382,6 +1459,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
zIndex={state.window.zIndex}
|
||||
minWidth={240}
|
||||
minHeight={220}
|
||||
autoHeight
|
||||
className="catalog-map-fixture__subject-window catalog-map-fixture__map-glass-window"
|
||||
>
|
||||
<div className="catalog-map-fixture__target-filters">
|
||||
@@ -1390,17 +1468,63 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
{summary.profile.facets.filter((facet) => facet.counter || facet.filterable).flatMap((facet) => (
|
||||
facet.values.map((item) => {
|
||||
const active = state.filters[facet.field]?.includes(item.value) ?? false;
|
||||
const rowId = `${summary.bindingId}:${facet.field}:${item.value}`;
|
||||
const expanded = Boolean(expandedFacetRows[rowId]);
|
||||
const matchingEntities = selectable.filter((entity) => (
|
||||
entity.bindingId === summary.bindingId
|
||||
&& mapFactMatchesFilters(
|
||||
entity.fact,
|
||||
summary.profile,
|
||||
{
|
||||
[summary.bindingId]: {
|
||||
visible: true,
|
||||
facets: { [facet.field]: [item.value] },
|
||||
},
|
||||
},
|
||||
summary.bindingId,
|
||||
)
|
||||
));
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={`${facet.field}:${item.value}`}
|
||||
aria-pressed={active}
|
||||
data-active={active || undefined}
|
||||
disabled={!facet.filterable}
|
||||
onClick={() => togglePresentationFilter(summary.bindingId, facet.field, item.value)}
|
||||
>
|
||||
{item.label} <span>{summary.counts[facet.field]?.[item.value] ?? 0}</span>
|
||||
</button>
|
||||
<div className="catalog-map-fixture__target-filter-branch" key={`${facet.field}:${item.value}`}>
|
||||
<div className="catalog-map-fixture__target-filter-row" data-active={active || undefined}>
|
||||
<button
|
||||
type="button"
|
||||
className="catalog-map-fixture__target-filter-body"
|
||||
aria-pressed={active}
|
||||
disabled={!facet.filterable}
|
||||
onClick={() => togglePresentationFilter(summary.bindingId, facet.field, item.value)}
|
||||
>
|
||||
<span className="catalog-map-fixture__target-filter-label">{item.label}</span>
|
||||
<span className="catalog-map-fixture__target-filter-count">{summary.counts[facet.field]?.[item.value] ?? 0}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="catalog-map-fixture__target-filter-expander"
|
||||
aria-label={`${expanded ? "Свернуть" : "Развернуть"} ${item.label}`}
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpandedFacetRows((current) => ({ ...current, [rowId]: !current[rowId] }))}
|
||||
>
|
||||
<Icon name="chevron-right" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{expanded ? (
|
||||
<div className="catalog-map-fixture__target-filter-children">
|
||||
{matchingEntities.map((entity) => (
|
||||
<button
|
||||
type="button"
|
||||
className="catalog-map-fixture__target-filter-entity"
|
||||
key={entity.id}
|
||||
data-selected={entity.id === selectedId || undefined}
|
||||
onClick={() => handleSelectAndFocus(entity.id)}
|
||||
>
|
||||
<span>{entity.title}</span>
|
||||
{entity.status ? <small>{entity.status}</small> : null}
|
||||
</button>
|
||||
))}
|
||||
{!matchingEntities.length ? <small>Нет объектов в группе.</small> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
))}
|
||||
@@ -1501,7 +1625,12 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||
className="catalog-map-fixture__map-settings-window"
|
||||
onClose={() => setInspectorOpen(false)}
|
||||
>
|
||||
<Inspector sections={inspectorSections} defaultOpen={["map-base"]} singleOpen />
|
||||
<Inspector
|
||||
sections={inspectorSections}
|
||||
openSections={inspectorOpenSections}
|
||||
singleOpen
|
||||
onOpenSectionsChange={setInspectorOpenSections}
|
||||
/>
|
||||
</Window>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { MapPresentationProfile } from "./mapPresentationProfile.js";
|
||||
|
||||
export const TRANSPORT_STATION_REFERENCE_PROFILE_ID = "transport-stations.v1";
|
||||
|
||||
export type MapReferenceStationCategory = "metro" | "railway_terminal" | "railway_station";
|
||||
|
||||
export type MapReferenceLayer = {
|
||||
id: string;
|
||||
referenceProfileId: typeof TRANSPORT_STATION_REFERENCE_PROFILE_ID;
|
||||
category: MapReferenceStationCategory;
|
||||
presentationProfileId: string;
|
||||
visible: boolean;
|
||||
};
|
||||
|
||||
const profileDefinitions = [
|
||||
{
|
||||
id: "map.reference.transport.metro.v1",
|
||||
title: "Метро",
|
||||
category: "metro",
|
||||
semanticTypes: ["map.station"],
|
||||
hideCameraHeightMeters: 10_000,
|
||||
},
|
||||
{
|
||||
id: "map.reference.transport.terminal.v1",
|
||||
title: "Вокзалы",
|
||||
category: "railway_terminal",
|
||||
semanticTypes: ["map.terminal"],
|
||||
hideCameraHeightMeters: 35_000,
|
||||
},
|
||||
{
|
||||
id: "map.reference.transport.railway-station.v1",
|
||||
title: "Станции РЖД",
|
||||
category: "railway_station",
|
||||
semanticTypes: ["map.station"],
|
||||
hideCameraHeightMeters: 15_000,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const defaultMapReferenceLayers: readonly MapReferenceLayer[] = profileDefinitions.map((definition) => ({
|
||||
id: `reference.transport.${definition.category}`,
|
||||
referenceProfileId: TRANSPORT_STATION_REFERENCE_PROFILE_ID,
|
||||
category: definition.category,
|
||||
presentationProfileId: definition.id,
|
||||
visible: true,
|
||||
}));
|
||||
|
||||
export const defaultMapReferencePresentationProfiles: readonly MapPresentationProfile[] = profileDefinitions.map((definition) => ({
|
||||
id: definition.id,
|
||||
version: "1.2.0",
|
||||
title: definition.title,
|
||||
semanticTypes: [...definition.semanticTypes],
|
||||
label: {
|
||||
mode: "attributes",
|
||||
fields: ["name", "official_name", "local_name"],
|
||||
fontWeight: 600,
|
||||
sizePx: 20,
|
||||
color: "#cccccc",
|
||||
outlineColor: "#0c0d12",
|
||||
outlineWidthPx: 1,
|
||||
backgroundColor: "#000000",
|
||||
backgroundOpacity: 1,
|
||||
paddingX: 6,
|
||||
paddingY: 3,
|
||||
maxLength: 80,
|
||||
offsetX: 15,
|
||||
offsetY: 0,
|
||||
hideCameraHeightMeters: definition.hideCameraHeightMeters,
|
||||
},
|
||||
target: {
|
||||
variant: "elevated-spike",
|
||||
stemHeightMeters: 500,
|
||||
headSizePx: 20,
|
||||
stemWidthPx: 2,
|
||||
outlineColor: "#ff00c8",
|
||||
outlineOpacity: 1,
|
||||
outlineWidthPx: 2,
|
||||
hideCameraHeightMeters: definition.hideCameraHeightMeters,
|
||||
},
|
||||
facets: [{
|
||||
id: "category",
|
||||
field: "category",
|
||||
label: "Категория",
|
||||
filterable: false,
|
||||
counter: false,
|
||||
values: [{ value: definition.category, label: definition.title, order: 0 }],
|
||||
}],
|
||||
styles: [{ id: "reference", color: "#ffffff", opacity: 1 }],
|
||||
classes: [{
|
||||
id: definition.category,
|
||||
label: definition.title,
|
||||
priority: 100,
|
||||
match: [{ field: "category", equals: definition.category }],
|
||||
styleId: "reference",
|
||||
renderable: true,
|
||||
}],
|
||||
defaultClassId: definition.category,
|
||||
sort: [{ field: "category", order: [definition.category] }],
|
||||
}));
|
||||
|
||||
export function ensureMapReferencePresentationProfiles(profiles: MapPresentationProfile[]) {
|
||||
const ids = new Set(profiles.map((profile) => profile.id));
|
||||
return [
|
||||
...profiles,
|
||||
...defaultMapReferencePresentationProfiles
|
||||
.filter((profile) => !ids.has(profile.id))
|
||||
.map((profile) => structuredClone(profile) as MapPresentationProfile),
|
||||
];
|
||||
}
|
||||
|
||||
export function initialMapReferenceLayers(value?: MapReferenceLayer[]) {
|
||||
const byId = new Map((value ?? []).map((layer) => [layer.id, layer]));
|
||||
return defaultMapReferenceLayers.map((layer) => structuredClone(byId.get(layer.id) ?? layer));
|
||||
}
|
||||
|
||||
export function isMapReferencePresentationProfile(profile: MapPresentationProfile) {
|
||||
return defaultMapReferencePresentationProfiles.some((candidate) => candidate.id === profile.id);
|
||||
}
|
||||
+93
-15
@@ -746,7 +746,7 @@ textarea {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 0.28rem;
|
||||
border-radius: 0.78rem;
|
||||
@@ -791,11 +791,6 @@ textarea {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.catalog-map-fixture__objects-menu-item > .nodedc-icon-button {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
}
|
||||
|
||||
.catalog-map-fixture__objects-menu-empty {
|
||||
padding: 0.72rem;
|
||||
}
|
||||
@@ -938,44 +933,127 @@ textarea {
|
||||
gap: 0.36rem;
|
||||
}
|
||||
|
||||
.catalog-map-fixture__target-filter-list button {
|
||||
.catalog-map-fixture__target-filter-row {
|
||||
display: inline-flex;
|
||||
width: 100%;
|
||||
min-height: 2.05rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
gap: 0;
|
||||
border: 0;
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
padding: 0.42rem 0.72rem;
|
||||
color: rgba(8, 8, 10, 0.88);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.catalog-map-fixture__target-filter-row button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.catalog-map-fixture__target-filter-list button > span {
|
||||
.catalog-map-fixture__target-filter-body {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
min-height: 2.05rem;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.42rem 0.28rem 0.42rem 0.72rem;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.catalog-map-fixture__target-filter-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.catalog-map-fixture__target-filter-count {
|
||||
margin-left: auto;
|
||||
color: rgba(8, 8, 10, 0.96);
|
||||
font-weight: 800;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.catalog-map-fixture__target-filter-list button:hover,
|
||||
.catalog-map-fixture__target-filter-list button[data-active] {
|
||||
.catalog-map-fixture__target-filter-expander {
|
||||
display: inline-grid;
|
||||
width: 2rem;
|
||||
min-width: 2rem;
|
||||
min-height: 2.05rem;
|
||||
place-items: center;
|
||||
border-radius: 0 var(--nodedc-radius-circle) var(--nodedc-radius-circle) 0;
|
||||
color: rgba(8, 8, 10, 0.75);
|
||||
}
|
||||
|
||||
.catalog-map-fixture__target-filter-expander[aria-expanded="true"] svg {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.catalog-map-fixture__target-filter-row:hover,
|
||||
.catalog-map-fixture__target-filter-row[data-active] {
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
color: rgba(8, 8, 10, 0.96);
|
||||
}
|
||||
|
||||
.catalog-map-fixture__target-filter-list button:disabled {
|
||||
.catalog-map-fixture__target-filter-body:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.catalog-map-fixture__target-filter-branch,
|
||||
.catalog-map-fixture__target-filter-children {
|
||||
display: grid;
|
||||
gap: 0.28rem;
|
||||
}
|
||||
|
||||
.catalog-map-fixture__target-filter-children {
|
||||
padding: 0.08rem 0.18rem 0.18rem 0.72rem;
|
||||
}
|
||||
|
||||
.catalog-map-fixture__target-filter-entity {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.15rem;
|
||||
border: 0;
|
||||
border-left: 2px solid rgba(255, 255, 255, 0.24);
|
||||
background: transparent;
|
||||
padding: 0.38rem 0.48rem;
|
||||
color: var(--nodedc-map-glass-text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.catalog-map-fixture__target-filter-entity:hover,
|
||||
.catalog-map-fixture__target-filter-entity[data-selected] {
|
||||
border-left-color: var(--nodedc-map-glass-text);
|
||||
background: rgba(255, 255, 255, 0.11);
|
||||
}
|
||||
|
||||
.catalog-map-fixture__target-filter-entity > span,
|
||||
.catalog-map-fixture__target-filter-entity > small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.catalog-map-fixture__target-filter-entity > span {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.catalog-map-fixture__target-filter-entity > small,
|
||||
.catalog-map-fixture__target-filter-children > small {
|
||||
color: var(--nodedc-map-glass-muted);
|
||||
font-size: 0.61rem;
|
||||
}
|
||||
|
||||
.catalog-map-inspector__style {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { MapRuntimeBinding, MapRuntimeFact } from "./useMapDataProductRuntime.js";
|
||||
import {
|
||||
TRANSPORT_STATION_REFERENCE_PROFILE_ID,
|
||||
type MapReferenceLayer,
|
||||
type MapReferenceStationCategory,
|
||||
} from "./mapReferenceStations.js";
|
||||
|
||||
type ReferenceSnapshot = {
|
||||
schemaVersion: "nodedc.map-reference.snapshot/v1";
|
||||
profileId: typeof TRANSPORT_STATION_REFERENCE_PROFILE_ID;
|
||||
sourceRevision: string;
|
||||
facts: MapRuntimeFact[];
|
||||
};
|
||||
|
||||
const identifier = /^[A-Za-z0-9._:-]{1,160}$/;
|
||||
const categories = new Set<MapReferenceStationCategory>(["metro", "railway_terminal", "railway_station"]);
|
||||
|
||||
export function useMapReferenceRuntime(
|
||||
layers: MapReferenceLayer[],
|
||||
camera: { longitude: number; latitude: number; height: number } | null,
|
||||
enabled = true,
|
||||
) {
|
||||
const [snapshot, setSnapshot] = useState<ReferenceSnapshot | null>(null);
|
||||
const signature = useMemo(() => layers.map((layer) => `${layer.id}:${layer.category}:${layer.presentationProfileId}`).join("|"), [layers]);
|
||||
const bbox = useMemo(() => referenceBbox(camera), [camera?.height, camera?.latitude, camera?.longitude]);
|
||||
const bboxSignature = bbox?.join(",") ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !layers.length) {
|
||||
setSnapshot(null);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const debounce = window.setTimeout(() => {
|
||||
const query = bbox ? `?bbox=${encodeURIComponent(bbox.join(","))}` : "";
|
||||
void fetch(`/api/map-gateway/api/map/reference-sources/v1/profiles/${TRANSPORT_STATION_REFERENCE_PROFILE_ID}/current${query}`, {
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) throw new Error(`map_reference_http_${response.status}`);
|
||||
const parsed = asSnapshot(await response.json());
|
||||
if (!parsed) throw new Error("map_reference_snapshot_invalid");
|
||||
setSnapshot(parsed);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted) setSnapshot(null);
|
||||
});
|
||||
}, 450);
|
||||
return () => {
|
||||
window.clearTimeout(debounce);
|
||||
controller.abort();
|
||||
};
|
||||
}, [bboxSignature, enabled, signature]);
|
||||
|
||||
return useMemo<MapRuntimeBinding[]>(() => layers.map((layer) => ({
|
||||
bindingId: layer.id,
|
||||
dataProductId: `platform-reference.${layer.referenceProfileId}`,
|
||||
slotId: "reference-points",
|
||||
presentationProfileId: layer.presentationProfileId,
|
||||
facts: snapshot?.facts.filter((fact) => fact.attributes.category === layer.category) ?? [],
|
||||
cursor: snapshot?.sourceRevision ?? null,
|
||||
state: snapshot ? "ready" : "loading",
|
||||
})), [layers, snapshot]);
|
||||
}
|
||||
|
||||
function referenceBbox(camera: { longitude: number; latitude: number; height: number } | null) {
|
||||
if (!camera || camera.height > 150_000 || !Number.isFinite(camera.height)) return null;
|
||||
const radiusKm = Math.max(10, Math.min(40, camera.height / 1_000 * 0.8));
|
||||
const latitudeDelta = radiusKm / 111.32;
|
||||
const longitudeScale = Math.max(0.2, Math.cos(camera.latitude * Math.PI / 180));
|
||||
const longitudeDelta = radiusKm / (111.32 * longitudeScale);
|
||||
return [
|
||||
Math.max(-180, camera.longitude - longitudeDelta),
|
||||
Math.max(-90, camera.latitude - latitudeDelta),
|
||||
Math.min(180, camera.longitude + longitudeDelta),
|
||||
Math.min(90, camera.latitude + latitudeDelta),
|
||||
].map((value) => Number(value.toFixed(5)));
|
||||
}
|
||||
|
||||
function asSnapshot(value: unknown): ReferenceSnapshot | null {
|
||||
if (!isObject(value) || value.schemaVersion !== "nodedc.map-reference.snapshot/v1"
|
||||
|| value.profileId !== TRANSPORT_STATION_REFERENCE_PROFILE_ID
|
||||
|| typeof value.sourceRevision !== "string" || !Array.isArray(value.facts)) return null;
|
||||
const facts = value.facts.map(asFact).filter((fact): fact is MapRuntimeFact => Boolean(fact));
|
||||
return {
|
||||
schemaVersion: "nodedc.map-reference.snapshot/v1",
|
||||
profileId: TRANSPORT_STATION_REFERENCE_PROFILE_ID,
|
||||
sourceRevision: value.sourceRevision,
|
||||
facts,
|
||||
};
|
||||
}
|
||||
|
||||
function asFact(value: unknown): MapRuntimeFact | null {
|
||||
if (!isObject(value) || typeof value.sourceId !== "string" || !identifier.test(value.sourceId)
|
||||
|| !["map.station", "map.terminal"].includes(String(value.semanticType))
|
||||
|| !isIso(value.observedAt) || !isIso(value.receivedAt)
|
||||
|| !isObject(value.attributes) || !categories.has(value.attributes.category as MapReferenceStationCategory)
|
||||
|| !isPoint(value.geometry)) return null;
|
||||
const allowedAttributes = new Set(["name", "category", "network", "operator", "official_name", "local_name", "uic_ref", "wheelchair"]);
|
||||
return {
|
||||
sourceId: value.sourceId,
|
||||
semanticType: String(value.semanticType),
|
||||
observedAt: value.observedAt,
|
||||
receivedAt: value.receivedAt,
|
||||
attributes: Object.fromEntries(Object.entries(value.attributes).filter(([key]) => allowedAttributes.has(key))),
|
||||
geometry: { type: "Point", coordinates: [...value.geometry.coordinates] as [number, number] },
|
||||
presentationStatus: "active",
|
||||
};
|
||||
}
|
||||
|
||||
function isPoint(value: unknown): value is { type: "Point"; coordinates: [number, number] } {
|
||||
if (!isObject(value) || value.type !== "Point" || !Array.isArray(value.coordinates) || value.coordinates.length !== 2) return false;
|
||||
const [longitude, latitude] = value.coordinates;
|
||||
return typeof longitude === "number" && Number.isFinite(longitude) && longitude >= -180 && longitude <= 180
|
||||
&& typeof latitude === "number" && Number.isFinite(latitude) && latitude >= -90 && latitude <= 90;
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, any> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isIso(value: unknown): value is string {
|
||||
return typeof value === "string" && !Number.isNaN(Date.parse(value));
|
||||
}
|
||||
Reference in New Issue
Block a user