feat(map): add reference layers and stabilize workspace controls
This commit is contained in:
parent
c859ae4db0
commit
eaecce9f56
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -39,7 +39,7 @@ FieldFrame объединяет label, control, hint и description. TextField/T
|
|||
|
||||
## RangeControl
|
||||
|
||||
Pill-range с заполнением акцентным цветом, встроенной подписью и значением. Домен определяет min/max/step и формат числа.
|
||||
Pill-range с заполнением акцентным цветом, встроенной подписью и значением. Домен определяет min/max/step и формат числа. Drag всегда принадлежит невидимому native range и продолжается под областью значения. Над числом постоянно смонтирован один прозрачный native text input: клик включает редактирование без замены DOM-узла, браузер ставит каретку в фактическое место клика, а drag-selection остаётся нативным. Поле не получает собственной подложки, select-all или второго focus-ring. Enter/blur применяют значение с clamp/step-нормализацией, Escape отменяет ввод; события редактора не всплывают в оконные shortcuts. Оконный focus-manager выполняет начальный autofocus только при открытии и не отбирает фокус при последующих ререндерах.
|
||||
|
||||
## ColorField
|
||||
|
||||
|
|
@ -92,7 +92,19 @@ Window — единая механика открытия modal и правой
|
|||
|
||||
`WorkspaceWindow` — modeless-окно внутри рабочей сцены приложения. Оно рендерится непосредственно в переданном workspace, не использует portal и не может перекрыть шапку, навигацию или соседние панели за пределами этого workspace.
|
||||
|
||||
Приложение контролирует `rect`, `maximized`, видимость, active-state и `zIndex`. Компонент владеет pointer/keyboard-механикой перемещения и изменения размера, кнопками maximize/restore и close, а также повторно ограничивает геометрию при изменении размеров workspace через `ResizeObserver`. Родительский bounds-контейнер должен быть позиционированным и обрезать содержимое (`position: relative; overflow: hidden`).
|
||||
На Map Page `WorkspaceWindow` используется и для facet-групп, и для
|
||||
provider-neutral карточки выбранного subject. Chevron внутри facet-строки
|
||||
владеет только раскрытием дерева subjects; тело строки сохраняет действие
|
||||
фильтра. Смена совместимого subject не пересоздаёт карточку и не сбрасывает
|
||||
активную вкладку. Joined data-product aspects не создают отдельные окна или
|
||||
слои, а наполняют декларативные секции существующей карточки.
|
||||
|
||||
Справочные точки транспорта используют тот же renderer adapter, но отдельный
|
||||
slot `reference-points`. Их три presentation profiles (`Метро`, `Вокзалы`,
|
||||
`Станции РЖД`) являются живыми каноническими Inspector sections: приложение
|
||||
может менять высоту, размер, label и LOD, не создавая provider-specific UI.
|
||||
|
||||
Приложение контролирует `rect`, `maximized`, видимость, active-state и `zIndex`. Компонент владеет pointer/keyboard-механикой перемещения и изменения размера, кнопками maximize/restore и close, а также повторно ограничивает геометрию при изменении размеров workspace через `ResizeObserver`. Опциональный `autoHeight` подгоняет высоту под живое содержимое и сжимает её обратно, не выходя за нижнюю границу workspace. Родительский bounds-контейнер должен быть позиционированным и обрезать содержимое (`position: relative; overflow: hidden`).
|
||||
|
||||
Окно предназначено для вспомогательных камер, инструментов и сопоставляемых представлений внутри сцены. Modal workflow, подтверждение и viewport-level Inspector по-прежнему используют `Window`.
|
||||
|
||||
|
|
@ -228,6 +240,11 @@ Inspector владеет:
|
|||
- вертикальным ритмом;
|
||||
- раскладкой label/control.
|
||||
|
||||
В uncontrolled-режиме начальные секции задаёт `defaultOpen`. Для layout-aware
|
||||
приложений Inspector поддерживает controlled-пару
|
||||
`openSections/onOpenSectionsChange`: она позволяет общей кнопке Save сохранять
|
||||
точную конфигурацию раскрытых секций и восстанавливать её без remount-default.
|
||||
|
||||
Для Engine Environment Settings desktop-контракт точный: окно `390 px`, рабочая колонка `330 px`, строка `154 + 14 + 162 px`, высота контрола `46 px`, section header `50 px` с радиусом `12 px`. Accent-filled section headers и полноширинные range/checker/select сохраняют геометрию исходника.
|
||||
|
||||
Engine продолжает владеть определениями полей, типами нод и сохранением значений. В живом catalog эти collapsible sections находятся в `Guideline → Контролы`; отдельной product-вкладки Inspector нет.
|
||||
|
|
|
|||
|
|
@ -59,8 +59,70 @@ profile; renderer не содержит provider-specific условий.
|
|||
`aspectId`. Соединение выполняется по стабильному `sourceId`; provider endpoint,
|
||||
credentials и raw payload в Application Manifest не попадают.
|
||||
|
||||
В меню `Объекты` показываются только primary bindings. Joined aspects
|
||||
(`technical profile`, `contacts`, `identity` и следующие типизированные
|
||||
проекции) не являются самостоятельными слоями карты: они дополняют карточку
|
||||
выбранного primary subject и потому не дублируются в меню. Если пользователь
|
||||
переключает один subject на другой с совместимым detail profile, активная
|
||||
вкладка карточки сохраняется; отсутствующие поля и секции не создаются.
|
||||
|
||||
Строка facet в окне объектов разделяет два действия. Клик по телу по-прежнему
|
||||
применяет facet-фильтр, а отдельный chevron раскрывает список совпавших
|
||||
subjects. Выбор subject в списке открывает его карточку и переносит текущую
|
||||
композицию камеры к точке. Адаптер повторяет проверенную механику legacy
|
||||
MMAP/AIS: сохраняет `heading`, `pitch`, `roll` и смещение камеры относительно
|
||||
центра viewport; длительность перелёта для текущего канонического workflow —
|
||||
`2.1 s`.
|
||||
|
||||
Facet-фильтры поддерживают мультивыбор: OR внутри одного facet и AND между facets. Missing facet означает отсутствие ограничения, а явно пустой список — ноль совпадений. Отжатие последнего chip не включает `Все`; `Все` включается и выключается только явным кликом. Переключение фильтра не двигает камеру, обзор выполняется отдельным действием.
|
||||
|
||||
## Platform reference layers
|
||||
|
||||
Станции метро, железнодорожные станции и вокзалы не являются L1/L2 workflow и
|
||||
не материализуются как пользовательский Data Product. Это медленно меняющийся
|
||||
provider-neutral справочный слой Platform Map Gateway:
|
||||
|
||||
```text
|
||||
OSM/approved seed → Map Gateway spatial cache → map.station | map.terminal
|
||||
→ reference-points → Foundry presentation profile → renderer
|
||||
```
|
||||
|
||||
Контракт `transport-stations.v1` возвращает только allowlisted проекцию:
|
||||
стабильный `sourceId`, нормализованный semantic type, категорию, названия,
|
||||
оператора/сеть, допустимые официальные идентификаторы и координаты. Raw OSM
|
||||
payload, upstream endpoint, credentials и произвольные tags в Foundry не
|
||||
попадают. Начальный snapshot Москвы получен из legacy L1 MMAP как donor и
|
||||
разделён на три категории: `metro`, `railway_station`,
|
||||
`railway_terminal`. За пределами snapshot Map Gateway может последовательно
|
||||
загрузить spatial cells из OSM и сохраняет нормализованный результат в
|
||||
persistent cache; повторный viewport читается локально.
|
||||
|
||||
Page Layout хранит три независимых reference bindings:
|
||||
|
||||
- `reference.transport.metro` → `map.reference.transport.metro.v1`;
|
||||
- `reference.transport.railway_terminal` →
|
||||
`map.reference.transport.terminal.v1`;
|
||||
- `reference.transport.railway_station` →
|
||||
`map.reference.transport.railway-station.v1`.
|
||||
|
||||
Каждый профиль отдельно управляет видимостью, высотой таргета, размером точки,
|
||||
подписью и высотой скрытия через обычный Inspector. Старые сохранённые Map
|
||||
Layout получают зарегистрированные reference bindings и profiles при чтении;
|
||||
их файл не переписывается до явного сохранения пользователем.
|
||||
|
||||
Тот же Page Layout хранит `inspectorOpenSections`. В текущем single-open
|
||||
Inspector это пустой список либо id ровно одной раскрытой секции. Поэтому
|
||||
закрытие окна настроек и повторное открытие не сбрасывает аккордеон, а общая
|
||||
кнопка Application Save сохраняет его вместе с камерой и остальным layout.
|
||||
|
||||
Cesium adapter рендерит elevated targets и label plates как единый overlay над
|
||||
terrain, buildings и imagery (`disableDepthTestDistance`). Цветная обводка
|
||||
головки остаётся частью `elevated-spike` profile. Головка рисуется как
|
||||
billboard до label-слоя, поэтому непрозрачная плашка нормально перекрывает
|
||||
лежащий под ней маркер вместо «рентгеновского» проступания его обводки. Text
|
||||
outline плашки отключён и не создаёт дополнительный halo. Контуры
|
||||
`surface-fill` остаются частью отдельного контракта геозон.
|
||||
|
||||
## Acceptance fixtures
|
||||
|
||||
- `registry/fixtures/map/map-empty-offline-v0.1.json` — отсутствие provider/live data без разрушения shell и управляющих действий.
|
||||
|
|
@ -118,6 +180,8 @@ semantic types, field projection, `presentationProfileId`, optional
|
|||
`subjectDetailProfileId`, `aspectId`/`joinToBindingId` и `slotId`: `points`
|
||||
для live point entities, `zones` для polygon/multipolygon `map.zone` или
|
||||
`subject-details` для non-spatial current aspects выбранного объекта.
|
||||
Platform-owned справочные сущности используют отдельный `reference-points`
|
||||
slot и не подменяют этот Data Product contract.
|
||||
При открытии Application page browser делает same-origin запрос к Foundry:
|
||||
|
||||
```text
|
||||
|
|
|
|||
|
|
@ -95,8 +95,9 @@ Foundry и без неописанного generic JSON.
|
|||
|
||||
`foundry_save_map_page_view_state` является MCP-эквивалентом одной Application
|
||||
кнопки `Сохранить`. Операция атомарно сохраняет camera/map height, typed patch
|
||||
base settings, exact visibility/facet selections и все canonical binding
|
||||
windows (`open`, `rect`, `maximized`, `zIndex`). Состояние keyed только по
|
||||
base settings, точный список раскрытых секций Inspector, exact
|
||||
visibility/facet selections и все canonical binding windows (`open`, `rect`,
|
||||
`maximized`, `zIndex`). Состояние keyed только по
|
||||
`bindingId`: editable `displayName` не участвует в identity. Missing facet не
|
||||
ограничивает выборку, явно пустой список остаётся empty после reopen и не
|
||||
мигрирует обратно в `Все`.
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
"scripts": {
|
||||
"build": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/ui-catalog",
|
||||
"build:packages": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns",
|
||||
"check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:inspector-select && npm run test:hgeozone-projection && npm run test:map-object-layers && npm run test:map-subject-card && npm run test:map-subject-detail-profile",
|
||||
"check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:inspector-select && npm run test:range-control && npm run test:hgeozone-projection && npm run test:map-object-layers && npm run test:map-inspector-overlay-state && npm run test:map-reference-stations && npm run test:map-subject-card && npm run test:map-subject-detail-profile",
|
||||
"dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog",
|
||||
"serve": "node server/catalog-server.mjs",
|
||||
"validate:registry": "node scripts/validate-registry.mjs",
|
||||
|
|
@ -24,10 +24,13 @@
|
|||
"test:map-filters": "node --test scripts/map-presentation-filters.test.mjs",
|
||||
"test:hgeozone-projection": "node --test scripts/hgeozone-projection.test.mjs",
|
||||
"test:map-object-layers": "node --test scripts/map-object-layers.test.mjs",
|
||||
"test:map-inspector-overlay-state": "node --test scripts/map-inspector-overlay-state.test.mjs",
|
||||
"test:map-reference-stations": "node --test scripts/map-reference-stations.test.mjs",
|
||||
"test:map-subject-card": "node --test scripts/map-subject-card.test.mjs",
|
||||
"test:map-subject-detail-profile": "node --test server/map-subject-detail-profile.test.mjs server/map-live-data-slot.test.mjs",
|
||||
"test:map-cache-contract": "node --test scripts/map-cache-resource-contract.test.mjs",
|
||||
"test:inspector-select": "node --test scripts/inspector-select-contract.test.mjs"
|
||||
"test:inspector-select": "node --test scripts/inspector-select-contract.test.mjs",
|
||||
"test:range-control": "node --test scripts/range-control-contract.test.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export type PageTemplateFeature = {
|
|||
required?: boolean;
|
||||
};
|
||||
|
||||
export type PageTemplateSlotKind = "entity-stream" | "trace-stream" | "route-stream" | "zone-stream" | "selection" | "command" | "assistant";
|
||||
export type PageTemplateSlotKind = "entity-stream" | "trace-stream" | "route-stream" | "zone-stream" | "subject-aspect-stream" | "selection" | "command" | "assistant";
|
||||
|
||||
export type PageTemplateSlot = {
|
||||
id: string;
|
||||
|
|
@ -75,6 +75,8 @@ export const mapPageTemplate = {
|
|||
{ id: "traces", kind: "trace-stream", label: "Traces", description: "Time-ordered entity traces.", required: false, multiple: true },
|
||||
{ id: "routes", kind: "route-stream", label: "Routes", description: "Planned or computed routes.", required: false, multiple: true },
|
||||
{ id: "zones", kind: "zone-stream", label: "Zones", description: "Polygons, geofences and operational areas.", required: false, multiple: true },
|
||||
{ id: "reference-points", kind: "entity-stream", label: "Reference points", description: "Platform-owned provider-neutral reference entities such as stations and terminals.", required: false, multiple: true },
|
||||
{ id: "subject-details", kind: "subject-aspect-stream", label: "Subject details", description: "Provider-neutral current aspects joined to a stable selected subject.", required: false, multiple: true },
|
||||
{ id: "selection", kind: "selection", label: "Selection", description: "Selected map entity and contextual state.", required: false, multiple: false },
|
||||
{ id: "commands", kind: "command", label: "Commands", description: "Capability-backed actions for selected entities.", required: false, multiple: true },
|
||||
{ id: "assistant", kind: "assistant", label: "Assistant context", description: "Context exposed to the assistant overlay.", required: false, multiple: false },
|
||||
|
|
|
|||
|
|
@ -694,6 +694,10 @@ textarea.nodedc-field__control {
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nodedc-range input[type="range"]:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.nodedc-range__label,
|
||||
.nodedc-range__value {
|
||||
position: absolute;
|
||||
|
|
@ -733,6 +737,43 @@ textarea.nodedc-field__control {
|
|||
color: rgb(var(--nodedc-on-accent-rgb));
|
||||
}
|
||||
|
||||
.nodedc-range__editor {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
top: 50%;
|
||||
right: 0.75rem;
|
||||
width: max(3rem, calc((var(--nodedc-range-editor-characters) + 0.75) * 1ch));
|
||||
max-width: min(42%, 8rem);
|
||||
height: 2rem;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: transparent;
|
||||
caret-color: transparent;
|
||||
padding: 0 0.25rem;
|
||||
font: inherit;
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
font-weight: var(--nodedc-font-weight-medium);
|
||||
line-height: 1;
|
||||
text-align: right;
|
||||
transform: translateY(-50%);
|
||||
box-shadow: none;
|
||||
cursor: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.nodedc-range__editor[data-active] {
|
||||
color: var(--nodedc-text-primary);
|
||||
caret-color: currentColor;
|
||||
}
|
||||
|
||||
.nodedc-range[data-editing] > .nodedc-range__value,
|
||||
.nodedc-range[data-editing] > .nodedc-range__fill-text .nodedc-range__value {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.nodedc-color-field {
|
||||
display: grid;
|
||||
min-height: var(--nodedc-control-height);
|
||||
|
|
@ -2515,6 +2556,10 @@ textarea.nodedc-field__control {
|
|||
touch-action: none;
|
||||
}
|
||||
|
||||
.nodedc-workspace-window[data-auto-height="true"]:not([data-interaction]) {
|
||||
transition: height var(--nodedc-duration-normal) var(--nodedc-ease-standard);
|
||||
}
|
||||
|
||||
.nodedc-workspace-window[data-active="true"] {
|
||||
box-shadow: var(--nodedc-modal-shadow), 0 0 0 1px color-mix(in srgb, var(--nodedc-text-primary) 16%, transparent);
|
||||
}
|
||||
|
|
@ -2623,6 +2668,10 @@ textarea.nodedc-field__control {
|
|||
touch-action: auto;
|
||||
}
|
||||
|
||||
.nodedc-workspace-window__body-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nodedc-workspace-window__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -16,21 +16,29 @@ export interface InspectorSectionSpec {
|
|||
export interface InspectorProps {
|
||||
sections: InspectorSectionSpec[];
|
||||
defaultOpen?: string[];
|
||||
openSections?: string[];
|
||||
activeId?: string;
|
||||
singleOpen?: boolean;
|
||||
className?: string;
|
||||
onOpenSectionsChange?: (ids: string[]) => void;
|
||||
onActiveChange?: (id: string) => void;
|
||||
}
|
||||
|
||||
export function Inspector({
|
||||
sections,
|
||||
defaultOpen = [],
|
||||
openSections,
|
||||
activeId,
|
||||
singleOpen = false,
|
||||
className,
|
||||
onOpenSectionsChange,
|
||||
onActiveChange,
|
||||
}: InspectorProps) {
|
||||
const [openIds, setOpenIds] = useState(() => new Set(defaultOpen));
|
||||
const [internalOpenIds, setInternalOpenIds] = useState(() => new Set(defaultOpen));
|
||||
const openIds = useMemo(
|
||||
() => openSections === undefined ? internalOpenIds : new Set(openSections),
|
||||
[internalOpenIds, openSections],
|
||||
);
|
||||
const groups = useMemo(() => {
|
||||
const result: Array<{ label?: string; sections: InspectorSectionSpec[] }> = [];
|
||||
sections.forEach((section) => {
|
||||
|
|
@ -45,12 +53,11 @@ export function Inspector({
|
|||
}, [sections]);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setOpenIds((current) => {
|
||||
const next = singleOpen ? new Set<string>() : new Set(current);
|
||||
if (current.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
const next = singleOpen ? new Set<string>() : new Set(openIds);
|
||||
if (openIds.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
if (openSections === undefined) setInternalOpenIds(next);
|
||||
onOpenSectionsChange?.([...next]);
|
||||
onActiveChange?.(id);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState, type CSSProperties, type InputHTMLAttributes, type PointerEvent } from "react";
|
||||
import { useEffect, useRef, useState, type CSSProperties, type InputHTMLAttributes, type KeyboardEvent, type PointerEvent } from "react";
|
||||
import { cn } from "./cn.js";
|
||||
import { Dropdown } from "./Dropdown.js";
|
||||
|
||||
|
|
@ -11,6 +11,38 @@ export interface RangeControlProps extends Omit<InputHTMLAttributes<HTMLInputEle
|
|||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
const clampRangeValue = (value: number, min: number, max: number) => (
|
||||
Math.max(min, Math.min(max, value))
|
||||
);
|
||||
|
||||
const decimalPlaces = (value: number) => {
|
||||
const [coefficient, exponentText] = String(value).toLowerCase().split("e");
|
||||
const coefficientDecimals = coefficient?.split(".")[1]?.length ?? 0;
|
||||
if (exponentText === undefined) return coefficientDecimals;
|
||||
const exponent = Number.parseInt(exponentText, 10);
|
||||
if (!Number.isFinite(exponent)) return coefficientDecimals;
|
||||
return Math.max(0, coefficientDecimals - exponent);
|
||||
};
|
||||
|
||||
const normalizeEditedRangeValue = (
|
||||
value: number,
|
||||
min: number,
|
||||
max: number,
|
||||
step: RangeControlProps["step"],
|
||||
) => {
|
||||
const clamped = clampRangeValue(value, min, max);
|
||||
if (step === "any") return clamped;
|
||||
const numericStep = step === undefined ? 1 : Number(step);
|
||||
if (!Number.isFinite(numericStep) || numericStep <= 0) return clamped;
|
||||
const precision = Math.min(12, Math.max(decimalPlaces(min), decimalPlaces(numericStep)));
|
||||
const aligned = min + Math.round((clamped - min) / numericStep) * numericStep;
|
||||
return clampRangeValue(Number(aligned.toFixed(precision)), min, max);
|
||||
};
|
||||
|
||||
const editableNumber = (value: number) => (
|
||||
Number.isInteger(value) ? String(value) : String(Number(value.toFixed(12)))
|
||||
);
|
||||
|
||||
export function RangeControl({
|
||||
label,
|
||||
value,
|
||||
|
|
@ -20,31 +52,111 @@ export function RangeControl({
|
|||
formatValue = String,
|
||||
onChange,
|
||||
className,
|
||||
disabled = false,
|
||||
tabIndex,
|
||||
...props
|
||||
}: RangeControlProps) {
|
||||
const rangeRef = useRef<HTMLInputElement>(null);
|
||||
const editorRef = useRef<HTMLInputElement>(null);
|
||||
const cancelNextBlurRef = useRef(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(() => editableNumber(value));
|
||||
const safeMax = max === min ? min + 1 : max;
|
||||
const progress = Math.max(0, Math.min(100, ((value - min) / (safeMax - min)) * 100));
|
||||
const style = { "--nodedc-range-progress": `${progress}%` } as CSSProperties;
|
||||
const displayValue = formatValue(value);
|
||||
const editorCharacters = Math.max(
|
||||
1,
|
||||
Math.min(14, editing ? draft.length || editableNumber(value).length : displayValue.length),
|
||||
);
|
||||
const style = {
|
||||
"--nodedc-range-progress": `${progress}%`,
|
||||
"--nodedc-range-editor-characters": editorCharacters,
|
||||
} as CSSProperties;
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) setDraft(editableNumber(value));
|
||||
}, [editing, value]);
|
||||
|
||||
const finishEditing = (commit: boolean, restoreRangeFocus: boolean) => {
|
||||
if (commit) {
|
||||
const parsed = Number(draft.trim().replace(",", "."));
|
||||
if (Number.isFinite(parsed)) onChange(normalizeEditedRangeValue(parsed, min, max, step));
|
||||
}
|
||||
setEditing(false);
|
||||
if (restoreRangeFocus) requestAnimationFrame(() => rangeRef.current?.focus());
|
||||
};
|
||||
|
||||
const handleEditorKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
cancelNextBlurRef.current = true;
|
||||
setDraft(editableNumber(value));
|
||||
setEditing(false);
|
||||
event.currentTarget.blur();
|
||||
requestAnimationFrame(() => rangeRef.current?.focus());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<label className={cn("nodedc-range", className)} style={style}>
|
||||
<div className={cn("nodedc-range", className)} style={style} data-editing={editing || undefined}>
|
||||
<input
|
||||
ref={rangeRef}
|
||||
{...props}
|
||||
type="range"
|
||||
value={value}
|
||||
min={min}
|
||||
max={safeMax}
|
||||
step={step}
|
||||
disabled={disabled}
|
||||
tabIndex={editing ? -1 : tabIndex}
|
||||
aria-label={label}
|
||||
aria-valuetext={formatValue(value)}
|
||||
aria-valuetext={displayValue}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
{...props}
|
||||
/>
|
||||
<span className="nodedc-range__label">{label}</span>
|
||||
<span className="nodedc-range__value">{formatValue(value)}</span>
|
||||
<span className="nodedc-range__value">{displayValue}</span>
|
||||
<span className="nodedc-range__fill-text" aria-hidden="true">
|
||||
<span className="nodedc-range__label">{label}</span>
|
||||
<span className="nodedc-range__value">{formatValue(value)}</span>
|
||||
<span className="nodedc-range__value">{displayValue}</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
ref={editorRef}
|
||||
className="nodedc-range__editor"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={editing ? draft : editableNumber(value)}
|
||||
aria-label={`${label}: точное значение`}
|
||||
disabled={disabled}
|
||||
spellCheck={false}
|
||||
data-active={editing || undefined}
|
||||
onPointerDown={() => {
|
||||
if (!editing) {
|
||||
setDraft(editableNumber(value));
|
||||
setEditing(true);
|
||||
}
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (!editing) {
|
||||
setDraft(editableNumber(value));
|
||||
setEditing(true);
|
||||
}
|
||||
}}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={handleEditorKeyDown}
|
||||
onBlur={() => {
|
||||
if (cancelNextBlurRef.current) {
|
||||
cancelNextBlurRef.current = false;
|
||||
return;
|
||||
}
|
||||
finishEditing(true, false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -63,10 +63,12 @@ export function Window({
|
|||
const titleId = useId();
|
||||
const descriptionId = useId();
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const onCloseRef = useRef(onClose);
|
||||
const dragRef = useRef<{ pointerId: number; offsetX: number; offsetY: number } | null>(null);
|
||||
const [dragPosition, setDragPosition] = useState<{ left: number; top: number } | null>(null);
|
||||
const shouldLockBodyScroll = lockBodyScroll ?? placement === "center";
|
||||
const shouldTrapFocus = trapFocus ?? placement === "center";
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || typeof document === "undefined") return;
|
||||
|
|
@ -82,7 +84,7 @@ export function Window({
|
|||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && closeOnEscape) {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
onCloseRef.current();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab" || !shouldTrapFocus || !dialogRef.current) return;
|
||||
|
|
@ -110,7 +112,7 @@ export function Window({
|
|||
if (shouldLockBodyScroll) document.body.style.overflow = previousOverflow;
|
||||
previousActiveElement?.focus();
|
||||
};
|
||||
}, [closeOnEscape, onClose, open, shouldLockBodyScroll, shouldTrapFocus]);
|
||||
}, [closeOnEscape, open, shouldLockBodyScroll, shouldTrapFocus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setDragPosition(null);
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export interface WorkspaceWindowProps extends Omit<HTMLAttributes<HTMLDivElement
|
|||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
resizable?: boolean;
|
||||
autoHeight?: boolean;
|
||||
active?: boolean;
|
||||
zIndex?: number;
|
||||
closeLabel?: string;
|
||||
|
|
@ -121,6 +122,7 @@ export function WorkspaceWindow({
|
|||
minWidth = 260,
|
||||
minHeight = 180,
|
||||
resizable = true,
|
||||
autoHeight = false,
|
||||
active = false,
|
||||
zIndex,
|
||||
closeLabel = "Закрыть окно",
|
||||
|
|
@ -139,6 +141,10 @@ export function WorkspaceWindow({
|
|||
const interactionRef = useRef<WorkspaceInteraction | null>(null);
|
||||
const rectRef = useRef(rect);
|
||||
const onRectChangeRef = useRef(onRectChange);
|
||||
const headRef = useRef<HTMLElement>(null);
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
const bodyContentRef = useRef<HTMLDivElement>(null);
|
||||
const footerRef = useRef<HTMLElement>(null);
|
||||
const [interactionKind, setInteractionKind] = useState<WorkspaceInteraction["kind"] | null>(null);
|
||||
|
||||
rectRef.current = rect;
|
||||
|
|
@ -175,6 +181,52 @@ export function WorkspaceWindow({
|
|||
};
|
||||
}, [boundsRef, maximized, minHeight, minWidth, rect.height, rect.width, rect.x, rect.y]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoHeight || maximized) return;
|
||||
const bounds = boundsRef.current;
|
||||
const head = headRef.current;
|
||||
const body = bodyRef.current;
|
||||
const content = bodyContentRef.current;
|
||||
if (!bounds || !head || !body || !content) return;
|
||||
let frame = 0;
|
||||
|
||||
const fitContent = () => {
|
||||
frame = 0;
|
||||
const nextBounds = readBounds();
|
||||
if (!nextBounds) return;
|
||||
const normalized = normalizeRect(rectRef.current, nextBounds, minWidth, minHeight);
|
||||
const bodyStyle = window.getComputedStyle(body);
|
||||
const bodyPadding = Number.parseFloat(bodyStyle.paddingTop || "0")
|
||||
+ Number.parseFloat(bodyStyle.paddingBottom || "0");
|
||||
const desiredHeight = Math.ceil(
|
||||
head.offsetHeight
|
||||
+ content.scrollHeight
|
||||
+ bodyPadding
|
||||
+ (footerRef.current?.offsetHeight ?? 0),
|
||||
);
|
||||
const availableHeight = Math.max(0, nextBounds.height - normalized.y);
|
||||
emitRect({
|
||||
...normalized,
|
||||
height: clampDimension(desiredHeight, minHeight, availableHeight),
|
||||
});
|
||||
};
|
||||
|
||||
const scheduleFit = () => {
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(fitContent);
|
||||
};
|
||||
|
||||
scheduleFit();
|
||||
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(scheduleFit);
|
||||
observer?.observe(content);
|
||||
observer?.observe(head);
|
||||
if (footerRef.current) observer?.observe(footerRef.current);
|
||||
return () => {
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
observer?.disconnect();
|
||||
};
|
||||
}, [autoHeight, boundsRef, maximized, minHeight, minWidth, rect.width]);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerMove = (event: globalThis.PointerEvent) => {
|
||||
const interaction = interactionRef.current;
|
||||
|
|
@ -273,6 +325,7 @@ export function WorkspaceWindow({
|
|||
data-active={active ? "true" : undefined}
|
||||
data-maximized={maximized ? "true" : undefined}
|
||||
data-resizable={resizable ? "true" : undefined}
|
||||
data-auto-height={autoHeight ? "true" : undefined}
|
||||
data-interaction={interactionKind ?? undefined}
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
|
|
@ -289,6 +342,7 @@ export function WorkspaceWindow({
|
|||
}}
|
||||
>
|
||||
<header
|
||||
ref={headRef}
|
||||
className="nodedc-workspace-window__head"
|
||||
tabIndex={maximized ? -1 : 0}
|
||||
role="group"
|
||||
|
|
@ -326,8 +380,10 @@ export function WorkspaceWindow({
|
|||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="nodedc-workspace-window__body">{children}</div>
|
||||
{footer ? <footer className="nodedc-workspace-window__footer">{footer}</footer> : null}
|
||||
<div ref={bodyRef} className="nodedc-workspace-window__body">
|
||||
{autoHeight ? <div ref={bodyContentRef} className="nodedc-workspace-window__body-content">{children}</div> : children}
|
||||
</div>
|
||||
{footer ? <footer ref={footerRef} className="nodedc-workspace-window__footer">{footer}</footer> : null}
|
||||
{resizable && !maximized ? (
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -64,11 +64,17 @@
|
|||
"package": "@nodedc/ui-react",
|
||||
"exports": ["RangeControl"],
|
||||
"domContract": ["nodedc-range"],
|
||||
"summary": "Filled pill range control with embedded label and value from redesigned Engine settings.",
|
||||
"summary": "Filled pill range control with embedded label, drag interaction and a persistent native inline exact-value editor.",
|
||||
"behavior": ["native range drag", "value click exact editing", "Enter/blur commit", "Escape cancel", "min/max clamp", "step normalization"],
|
||||
"rules": [
|
||||
"The accent fill follows the active application theme.",
|
||||
"The native range remains the accessible input while its chrome is visually replaced."
|
||||
]
|
||||
"The native range remains the accessible drag input while its chrome is visually replaced.",
|
||||
"The fill travels beneath the visible value; the persistent transparent native editor above the value receives exact-edit clicks without remounting.",
|
||||
"Exact editing stays inside the existing value area and never expands the control.",
|
||||
"The browser places the caret at the clicked character and keeps native drag selection; entering exact editing never forces select-all or adds a second focus ring.",
|
||||
"The editor has no separate background box and keeps focus across parent window rerenders and pointer leave.",
|
||||
"Editor keyboard events do not bubble into enclosing Window shortcuts."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "color-field",
|
||||
|
|
@ -381,6 +387,7 @@
|
|||
"Legacy Engine inspectors are not reference implementations.",
|
||||
"Domain-specific field definitions remain in Engine; reusable layout and controls live here.",
|
||||
"Desktop Engine geometry is fixed at 390 px panel, 330 px content, 154/14/162 px control rows and 50 px section headers with a 12 px radius.",
|
||||
"Inspector supports controlled openSections/onOpenSectionsChange state so a consumer can persist the exact accordion layout without remount defaults.",
|
||||
"Inspector selection fields use InspectorSelectField only: a stacked visible label and the 276/8/46 px split control; integrated pills are prohibited.",
|
||||
"Accent-filled section headers belong to Environment Settings; neutral headers remain available for other approved inspector contexts."
|
||||
]
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"profiles": [
|
||||
{
|
||||
"id": "map.moving-object.operational.default",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"title": "Флот",
|
||||
"semanticTypes": ["map.moving_object"],
|
||||
"label": {
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
"sizePx": 18,
|
||||
"color": "#f5f5f5",
|
||||
"outlineColor": "#0c0d12",
|
||||
"outlineWidthPx": 1,
|
||||
"outlineWidthPx": 0,
|
||||
"backgroundColor": "#0c0d12",
|
||||
"backgroundOpacity": 0.86,
|
||||
"paddingX": 8,
|
||||
|
|
@ -30,7 +30,7 @@
|
|||
"stemWidthPx": 5,
|
||||
"outlineColor": "#0c0d12",
|
||||
"outlineOpacity": 0.6,
|
||||
"outlineWidthPx": 1,
|
||||
"outlineWidthPx": 0,
|
||||
"hideCameraHeightMeters": 100000
|
||||
},
|
||||
"facets": [
|
||||
|
|
@ -115,7 +115,7 @@
|
|||
"sizePx": 14,
|
||||
"color": "#f5f5f5",
|
||||
"outlineColor": "#0c0d12",
|
||||
"outlineWidthPx": 1,
|
||||
"outlineWidthPx": 0,
|
||||
"backgroundColor": "#0c0d12",
|
||||
"backgroundOpacity": 0.82,
|
||||
"paddingX": 8,
|
||||
|
|
@ -193,6 +193,162 @@
|
|||
{ "field": "geometry_kind", "order": ["polygon", "circle", "corridor"] },
|
||||
{ "field": "source_kind", "order": ["live_api", "versioned_snapshot"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "map.reference.transport.metro.v1",
|
||||
"version": "1.2.0",
|
||||
"title": "Метро",
|
||||
"semanticTypes": ["map.station"],
|
||||
"label": {
|
||||
"mode": "attributes",
|
||||
"fields": ["name", "official_name", "local_name"],
|
||||
"fontWeight": 600,
|
||||
"sizePx": 20,
|
||||
"color": "#cccccc",
|
||||
"outlineColor": "#0c0d12",
|
||||
"outlineWidthPx": 0,
|
||||
"backgroundColor": "#000000",
|
||||
"backgroundOpacity": 1,
|
||||
"paddingX": 6,
|
||||
"paddingY": 3,
|
||||
"maxLength": 80,
|
||||
"offsetX": 15,
|
||||
"offsetY": 0,
|
||||
"hideCameraHeightMeters": 10000
|
||||
},
|
||||
"target": {
|
||||
"variant": "elevated-spike",
|
||||
"stemHeightMeters": 500,
|
||||
"headSizePx": 20,
|
||||
"stemWidthPx": 2,
|
||||
"outlineColor": "#ff00c8",
|
||||
"outlineOpacity": 1,
|
||||
"outlineWidthPx": 2,
|
||||
"hideCameraHeightMeters": 10000
|
||||
},
|
||||
"facets": [{
|
||||
"id": "category",
|
||||
"field": "category",
|
||||
"label": "Категория",
|
||||
"filterable": false,
|
||||
"counter": false,
|
||||
"values": [{ "value": "metro", "label": "Метро", "order": 0 }]
|
||||
}],
|
||||
"styles": [{ "id": "reference", "color": "#ffffff", "opacity": 1 }],
|
||||
"classes": [{
|
||||
"id": "metro",
|
||||
"label": "Метро",
|
||||
"priority": 100,
|
||||
"match": [{ "field": "category", "equals": "metro" }],
|
||||
"styleId": "reference",
|
||||
"renderable": true
|
||||
}],
|
||||
"defaultClassId": "metro",
|
||||
"sort": [{ "field": "category", "order": ["metro"] }]
|
||||
},
|
||||
{
|
||||
"id": "map.reference.transport.terminal.v1",
|
||||
"version": "1.2.0",
|
||||
"title": "Вокзалы",
|
||||
"semanticTypes": ["map.terminal"],
|
||||
"label": {
|
||||
"mode": "attributes",
|
||||
"fields": ["name", "official_name", "local_name"],
|
||||
"fontWeight": 600,
|
||||
"sizePx": 20,
|
||||
"color": "#cccccc",
|
||||
"outlineColor": "#0c0d12",
|
||||
"outlineWidthPx": 0,
|
||||
"backgroundColor": "#000000",
|
||||
"backgroundOpacity": 1,
|
||||
"paddingX": 6,
|
||||
"paddingY": 3,
|
||||
"maxLength": 80,
|
||||
"offsetX": 15,
|
||||
"offsetY": 0,
|
||||
"hideCameraHeightMeters": 35000
|
||||
},
|
||||
"target": {
|
||||
"variant": "elevated-spike",
|
||||
"stemHeightMeters": 500,
|
||||
"headSizePx": 20,
|
||||
"stemWidthPx": 2,
|
||||
"outlineColor": "#ff00c8",
|
||||
"outlineOpacity": 1,
|
||||
"outlineWidthPx": 2,
|
||||
"hideCameraHeightMeters": 35000
|
||||
},
|
||||
"facets": [{
|
||||
"id": "category",
|
||||
"field": "category",
|
||||
"label": "Категория",
|
||||
"filterable": false,
|
||||
"counter": false,
|
||||
"values": [{ "value": "railway_terminal", "label": "Вокзалы", "order": 0 }]
|
||||
}],
|
||||
"styles": [{ "id": "reference", "color": "#ffffff", "opacity": 1 }],
|
||||
"classes": [{
|
||||
"id": "railway_terminal",
|
||||
"label": "Вокзалы",
|
||||
"priority": 100,
|
||||
"match": [{ "field": "category", "equals": "railway_terminal" }],
|
||||
"styleId": "reference",
|
||||
"renderable": true
|
||||
}],
|
||||
"defaultClassId": "railway_terminal",
|
||||
"sort": [{ "field": "category", "order": ["railway_terminal"] }]
|
||||
},
|
||||
{
|
||||
"id": "map.reference.transport.railway-station.v1",
|
||||
"version": "1.2.0",
|
||||
"title": "Станции РЖД",
|
||||
"semanticTypes": ["map.station"],
|
||||
"label": {
|
||||
"mode": "attributes",
|
||||
"fields": ["name", "official_name", "local_name"],
|
||||
"fontWeight": 600,
|
||||
"sizePx": 20,
|
||||
"color": "#cccccc",
|
||||
"outlineColor": "#0c0d12",
|
||||
"outlineWidthPx": 0,
|
||||
"backgroundColor": "#000000",
|
||||
"backgroundOpacity": 1,
|
||||
"paddingX": 6,
|
||||
"paddingY": 3,
|
||||
"maxLength": 80,
|
||||
"offsetX": 15,
|
||||
"offsetY": 0,
|
||||
"hideCameraHeightMeters": 15000
|
||||
},
|
||||
"target": {
|
||||
"variant": "elevated-spike",
|
||||
"stemHeightMeters": 500,
|
||||
"headSizePx": 20,
|
||||
"stemWidthPx": 2,
|
||||
"outlineColor": "#ff00c8",
|
||||
"outlineOpacity": 1,
|
||||
"outlineWidthPx": 2,
|
||||
"hideCameraHeightMeters": 15000
|
||||
},
|
||||
"facets": [{
|
||||
"id": "category",
|
||||
"field": "category",
|
||||
"label": "Категория",
|
||||
"filterable": false,
|
||||
"counter": false,
|
||||
"values": [{ "value": "railway_station", "label": "Станции РЖД", "order": 0 }]
|
||||
}],
|
||||
"styles": [{ "id": "reference", "color": "#ffffff", "opacity": 1 }],
|
||||
"classes": [{
|
||||
"id": "railway_station",
|
||||
"label": "Станции РЖД",
|
||||
"priority": 100,
|
||||
"match": [{ "field": "category", "equals": "railway_station" }],
|
||||
"styleId": "reference",
|
||||
"renderable": true
|
||||
}],
|
||||
"defaultClassId": "railway_station",
|
||||
"sort": [{ "field": "category", "order": ["railway_station"] }]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
{ "id": "traces", "kind": "trace-stream", "label": "Traces", "description": "Time-ordered entity traces.", "required": false, "multiple": true },
|
||||
{ "id": "routes", "kind": "route-stream", "label": "Routes", "description": "Planned or computed routes.", "required": false, "multiple": true },
|
||||
{ "id": "zones", "kind": "zone-stream", "label": "Zones", "description": "Polygons, geofences and operational areas.", "required": false, "multiple": true },
|
||||
{ "id": "reference-points", "kind": "entity-stream", "label": "Reference points", "description": "Platform-owned provider-neutral reference entities such as stations and terminals.", "required": false, "multiple": true },
|
||||
{ "id": "subject-details", "kind": "subject-aspect-stream", "label": "Subject details", "description": "Provider-neutral current aspects joined to a stable selected subject.", "required": false, "multiple": true },
|
||||
{ "id": "selection", "kind": "selection", "label": "Selection", "description": "Selected map entity and contextual state.", "required": false, "multiple": false },
|
||||
{ "id": "commands", "kind": "command", "label": "Commands", "description": "Capability-backed actions for selected entities.", "required": false, "multiple": true },
|
||||
|
|
|
|||
|
|
@ -72,6 +72,12 @@
|
|||
"map": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"inspectorOpenSections": {
|
||||
"type": "array",
|
||||
"maxItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": { "type": "string", "minLength": 1, "maxLength": 256 }
|
||||
},
|
||||
"dataProductBindings": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
test("Cesium elevated targets keep their profile border while labels occlude them inside the scene overlay", async () => {
|
||||
const [renderer, cesiumDisplay] = await Promise.all([
|
||||
readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../node_modules/@cesium/engine/Source/DataSources/DataSourceDisplay.js", import.meta.url), "utf8"),
|
||||
]);
|
||||
|
||||
assert.match(renderer, /BillboardGraphics/);
|
||||
assert.match(renderer, /elevatedTargetImage\(\s*color,\s*Color\.fromCssColorString\(target\.outlineColor\)\.withAlpha\(target\.outlineOpacity\),\s*target\.outlineWidthPx,\s*target\.headSizePx/);
|
||||
assert.match(renderer, /entity\.point = undefined;\s*entity\.billboard = new BillboardGraphics/);
|
||||
assert.match(renderer, /disableDepthTestDistance: Number\.POSITIVE_INFINITY/);
|
||||
assert.match(renderer, /outlineWidth: 0,\s*style: LabelStyle\.FILL/);
|
||||
assert.doesNotMatch(renderer, /style: 2/);
|
||||
assert.ok(cesiumDisplay.indexOf("new BillboardVisualizer") < cesiumDisplay.indexOf("new LabelVisualizer"));
|
||||
});
|
||||
|
||||
test("Map Inspector open section is controlled and included in the saved page layout", async () => {
|
||||
const [inspector, preview, server, mcpSchema, manifestSchema] = await Promise.all([
|
||||
readFile(new URL("../packages/ui-react/src/Inspector.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../server/catalog-server.mjs", import.meta.url), "utf8"),
|
||||
readFile(new URL("../server/foundry-mcp.mjs", import.meta.url), "utf8"),
|
||||
readFile(new URL("../registry/schemas/application-manifest-v0.1.schema.json", import.meta.url), "utf8"),
|
||||
]);
|
||||
|
||||
assert.match(inspector, /openSections\?: string\[\]/);
|
||||
assert.match(inspector, /onOpenSectionsChange\?: \(ids: string\[\]\) => void/);
|
||||
assert.match(inspector, /onOpenSectionsChange\?\.\(\[\.\.\.next\]\)/);
|
||||
assert.match(preview, /const \[inspectorOpenSections, setInspectorOpenSections\]/);
|
||||
assert.match(preview, /openSections=\{inspectorOpenSections\}/);
|
||||
assert.match(preview, /onOpenSectionsChange=\{setInspectorOpenSections\}/);
|
||||
assert.match(preview, /referenceLayers,\s*inspectorOpenSections,\s*subjectStates/);
|
||||
assert.match(server, /validateMapInspectorOpenSections/);
|
||||
assert.match(server, /inspectorOpenSections: \["map-base"\]/);
|
||||
assert.match(mcpSchema, /inspectorOpenSections:[\s\S]*?maxItems: 1/);
|
||||
assert.match(manifestSchema, /"inspectorOpenSections"[\s\S]*?"maxItems": 1/);
|
||||
});
|
||||
|
||||
test("canonical station profiles retain visible target borders while label text stays halo-free", async () => {
|
||||
const registry = JSON.parse(await readFile(
|
||||
new URL("../registry/map-presentation-profiles.json", import.meta.url),
|
||||
"utf8",
|
||||
));
|
||||
|
||||
for (const profile of registry.profiles) {
|
||||
assert.equal(profile.label.outlineWidthPx, 0);
|
||||
}
|
||||
const stationProfiles = registry.profiles.filter((profile) => profile.id.startsWith("map.reference.transport."));
|
||||
assert.equal(stationProfiles.length, 3);
|
||||
stationProfiles.forEach((profile) => assert.ok(profile.target.outlineWidthPx > 0));
|
||||
const zone = registry.profiles.find((profile) => profile.target.variant === "surface-fill");
|
||||
assert.ok(zone.target.outlineWidthPx > 0);
|
||||
});
|
||||
|
|
@ -2,22 +2,22 @@ import assert from "node:assert/strict";
|
|||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
test("Objects menu treats each binding as a persisted visibility layer", async () => {
|
||||
test("Objects menu opens controllable groups from the row and keeps plain bindings as visibility layers", async () => {
|
||||
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(preview, /const toggleSubjectVisibility = \(bindingId: string\)/);
|
||||
assert.match(preview, /role="menuitemcheckbox"/);
|
||||
assert.match(preview, /aria-checked=\{visible\}/);
|
||||
assert.match(preview, /onClick=\{\(\) => toggleSubjectVisibility\(summary\.bindingId\)\}/);
|
||||
assert.match(preview, /role=\{hasControls \? "menuitem" : "menuitemcheckbox"\}/);
|
||||
assert.match(preview, /if \(hasControls\) \{\s*openSubjectWindow\(summary\.bindingId\);\s*close\(\)/);
|
||||
assert.match(preview, /toggleSubjectVisibility\(summary\.bindingId\)/);
|
||||
assert.match(preview, /visible: !state\.visible/);
|
||||
assert.match(preview, /слой скрыт · \$\{summary\.total\} объектов/);
|
||||
assert.doesNotMatch(preview, /Фильтры и счётчики: \$\{summary\.displayName\}/);
|
||||
});
|
||||
|
||||
test("a subject window exists only when the presentation profile exposes controls", async () => {
|
||||
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(preview, /function hasSubjectWindowControls\(profile: MapPresentationProfile\)/);
|
||||
assert.match(preview, /\{hasControls \? \(/);
|
||||
assert.match(preview, /if \(!state\?\.window\.open \|\| !hasSubjectWindowControls\(summary\.profile\)\) return null/);
|
||||
assert.match(preview, /window: \{ \.\.\.state\.window, open: false \}/);
|
||||
});
|
||||
|
|
@ -28,3 +28,52 @@ test("hidden and visible layers have an explicit visual state", async () => {
|
|||
assert.match(styles, /\.catalog-map-fixture__objects-menu-item\[data-visible\]/);
|
||||
assert.match(styles, /\.catalog-map-fixture__objects-menu-item:not\(\[data-visible\]\)/);
|
||||
});
|
||||
|
||||
test("joined detail aspects do not become independent map layers", async () => {
|
||||
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(preview, /dataProductBindings\.filter\(\(binding\) => !binding\.joinToBindingId\)/);
|
||||
assert.match(preview, /runtimeBindings\.filter\(\(binding\) => primaryBindingIds\.has\(binding\.bindingId\)\)/);
|
||||
assert.match(preview, /runtimeBindings=\{\[\.\.\.primaryRuntimeBindings, \.\.\.referenceRuntimeBindings\]\}/);
|
||||
});
|
||||
|
||||
test("reference stations are first-class Objects menu layers without provider settings actions", async () => {
|
||||
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(preview, /const referenceObjectSummaries = useMemo/);
|
||||
assert.match(preview, /const objectLayerCount = presentationSummaries\.length \+ referenceObjectSummaries\.length/);
|
||||
assert.match(preview, /referenceObjectSummaries\.map\(\(\{ layer, displayName, total \}\)/);
|
||||
assert.match(preview, /candidate\.id === layer\.id \? \{ \.\.\.candidate, visible: !candidate\.visible \} : candidate/);
|
||||
assert.match(preview, /role="menuitemcheckbox"/);
|
||||
});
|
||||
|
||||
test("facet window auto-fits expanded and collapsed content inside map workspace", async () => {
|
||||
const [preview, workspace] = await Promise.all([
|
||||
readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../packages/ui-react/src/WorkspaceWindow.tsx", import.meta.url), "utf8"),
|
||||
]);
|
||||
|
||||
assert.match(preview, /minHeight=\{220\}\s*autoHeight/);
|
||||
assert.match(workspace, /autoHeight\?: boolean/);
|
||||
assert.match(workspace, /content\.scrollHeight/);
|
||||
assert.match(workspace, /availableHeight = Math\.max\(0, nextBounds\.height - normalized\.y\)/);
|
||||
assert.match(workspace, /new ResizeObserver\(scheduleFit\)/);
|
||||
});
|
||||
|
||||
test("facet tree selection focuses the subject without resetting a compatible detail tab", async () => {
|
||||
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
||||
const renderer = await readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(preview, /const \[expandedFacetRows, setExpandedFacetRows\]/);
|
||||
assert.match(preview, /const handleSelectAndFocus = useCallback/);
|
||||
assert.match(preview, /mapRendererRef\.current\?\.focusRuntimeEntity\(entityId\)/);
|
||||
assert.match(preview, /setSubjectCardTabId\(\(current\) =>/);
|
||||
assert.match(preview, /profile\?\.tabs\.some\(\(tab\) => tab\.id === current\)/);
|
||||
assert.match(preview, /\(profile\?\.defaultTabId \?\? "overview"\)/);
|
||||
assert.match(renderer, /camera\.pickEllipsoid/);
|
||||
assert.match(renderer, /Cartesian3\.subtract\(camera\.position, viewportCenter/);
|
||||
assert.match(renderer, /heading: camera\.heading/);
|
||||
assert.match(renderer, /pitch: camera\.pitch/);
|
||||
assert.match(renderer, /roll: camera\.roll/);
|
||||
assert.match(renderer, /duration: 2\.1/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
const root = new URL("../", import.meta.url);
|
||||
|
||||
test("Map Page registers a provider-neutral reference-points slot and three station profiles", async () => {
|
||||
const pages = JSON.parse(await readFile(new URL("registry/pages.json", root), "utf8"));
|
||||
const profiles = JSON.parse(await readFile(new URL("registry/map-presentation-profiles.json", root), "utf8"));
|
||||
const mapPage = pages.templates.find((template) => template.id === "map");
|
||||
const stationProfiles = profiles.profiles.filter((profile) => profile.id.startsWith("map.reference.transport."));
|
||||
|
||||
assert.ok(mapPage.slots.some((slot) => slot.id === "reference-points" && slot.kind === "entity-stream"));
|
||||
assert.deepEqual(
|
||||
stationProfiles.map((profile) => profile.id).sort(),
|
||||
[
|
||||
"map.reference.transport.metro.v1",
|
||||
"map.reference.transport.railway-station.v1",
|
||||
"map.reference.transport.terminal.v1",
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
new Set(stationProfiles.flatMap((profile) => profile.semanticTypes)),
|
||||
new Set(["map.station", "map.terminal"]),
|
||||
);
|
||||
assert.ok(stationProfiles.every((profile) => profile.target.variant === "elevated-spike"));
|
||||
});
|
||||
|
||||
test("existing layouts upgrade reference layers on read and renderer accepts their slot", async () => {
|
||||
const server = await readFile(new URL("server/catalog-server.mjs", root), "utf8");
|
||||
const renderer = await readFile(new URL("apps/catalog/src/CesiumMapRenderer.tsx", root), "utf8");
|
||||
const runtime = await readFile(new URL("apps/catalog/src/useMapReferenceRuntime.ts", root), "utf8");
|
||||
|
||||
assert.match(server, /pageLayoutMatch\[1\] === "map" \? validateMapPageLayout\(stored\) : stored/);
|
||||
assert.match(server, /referenceLayers: structuredClone\(canonicalMapReferenceLayers\)/);
|
||||
assert.match(renderer, /binding\.slotId === "reference-points"/);
|
||||
assert.match(runtime, /\/api\/map-gateway\/api\/map\/reference-sources\/v1\/profiles\//);
|
||||
assert.match(runtime, /allowedAttributes = new Set\(\["name", "category", "network", "operator", "official_name", "local_name", "uic_ref", "wheelchair"\]\)/);
|
||||
assert.doesNotMatch(runtime, /credential|accessToken|providerEndpoint|rawPayload/);
|
||||
});
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { RangeControl } from "../packages/ui-react/dist/index.js";
|
||||
|
||||
test("RangeControl keeps native drag semantics and exposes one persistent exact-value editor", () => {
|
||||
const markup = renderToStaticMarkup(createElement(RangeControl, {
|
||||
label: "Размер головки",
|
||||
value: 20,
|
||||
min: 1,
|
||||
max: 32,
|
||||
step: 1,
|
||||
formatValue: (value) => `${value} px`,
|
||||
onChange: () => {},
|
||||
}));
|
||||
|
||||
assert.match(markup, /type="range"/);
|
||||
assert.match(markup, /aria-valuetext="20 px"/);
|
||||
assert.match(markup, /class="nodedc-range__editor"/);
|
||||
assert.match(markup, /aria-label="Размер головки: точное значение"/);
|
||||
assert.doesNotMatch(markup, /nodedc-range__value-hit/);
|
||||
});
|
||||
|
||||
test("exact editing is a stable native caret field without remount, forced selection or a dark editor box", async () => {
|
||||
const [component, styles, windowComponent] = await Promise.all([
|
||||
readFile(new URL("../packages/ui-react/src/RangeControl.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../packages/ui-core/styles.css", import.meta.url), "utf8"),
|
||||
readFile(new URL("../packages/ui-react/src/Window.tsx", import.meta.url), "utf8"),
|
||||
]);
|
||||
|
||||
assert.match(component, /normalizeEditedRangeValue\(parsed, min, max, step\)/);
|
||||
assert.match(component, /event\.key === "Enter"/);
|
||||
assert.match(component, /event\.key === "Escape"/);
|
||||
assert.match(component, /event\.stopPropagation\(\)/);
|
||||
assert.match(component, /cancelNextBlurRef/);
|
||||
assert.match(component, /event\.currentTarget\.blur\(\)/);
|
||||
assert.match(component, /onBlur=\{\(\) => \{/);
|
||||
assert.doesNotMatch(component, /\.select\(\)/);
|
||||
assert.doesNotMatch(component, /setSelectionRange/);
|
||||
assert.doesNotMatch(component, /\{editing \? \(\s*<input/);
|
||||
assert.match(styles, /\.nodedc-range input\[type="range"\][\s\S]*?z-index: 4/);
|
||||
assert.match(styles, /\.nodedc-range__editor[\s\S]*?z-index: 5[\s\S]*?background: transparent[\s\S]*?color: transparent[\s\S]*?caret-color: transparent[\s\S]*?box-shadow: none/);
|
||||
assert.match(styles, /\.nodedc-range__editor\[data-active\][\s\S]*?caret-color: currentColor/);
|
||||
assert.match(windowComponent, /const onCloseRef = useRef\(onClose\)/);
|
||||
assert.match(windowComponent, /onCloseRef\.current = onClose/);
|
||||
assert.match(windowComponent, /onCloseRef\.current\(\)/);
|
||||
assert.doesNotMatch(windowComponent, /\[closeOnEscape, onClose, open, shouldLockBodyScroll, shouldTrapFocus\]/);
|
||||
});
|
||||
|
|
@ -55,6 +55,32 @@ if (mapPresentationProfileRegistry?.schemaVersion !== "nodedc.map-presentation-p
|
|||
throw new Error("map_presentation_profile_registry_invalid");
|
||||
}
|
||||
const canonicalMapPresentationProfiles = normalizeMapPresentationProfiles(mapPresentationProfileRegistry.profiles);
|
||||
const canonicalMapReferencePresentationProfiles = canonicalMapPresentationProfiles.filter((profile) => (
|
||||
profile.id.startsWith("map.reference.transport.")
|
||||
));
|
||||
const canonicalMapReferenceLayers = Object.freeze([
|
||||
Object.freeze({
|
||||
id: "reference.transport.metro",
|
||||
referenceProfileId: "transport-stations.v1",
|
||||
category: "metro",
|
||||
presentationProfileId: "map.reference.transport.metro.v1",
|
||||
visible: true,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: "reference.transport.railway_terminal",
|
||||
referenceProfileId: "transport-stations.v1",
|
||||
category: "railway_terminal",
|
||||
presentationProfileId: "map.reference.transport.terminal.v1",
|
||||
visible: true,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: "reference.transport.railway_station",
|
||||
referenceProfileId: "transport-stations.v1",
|
||||
category: "railway_station",
|
||||
presentationProfileId: "map.reference.transport.railway-station.v1",
|
||||
visible: true,
|
||||
}),
|
||||
]);
|
||||
const mapSubjectDetailProfileRegistry = JSON.parse(await readFile(join(root, "registry", "map-subject-detail-profiles.json"), "utf8"));
|
||||
if (mapSubjectDetailProfileRegistry?.schemaVersion !== "nodedc.map-subject-detail-profiles/v1") {
|
||||
throw new Error("map_subject_detail_profile_registry_invalid");
|
||||
|
|
@ -458,6 +484,33 @@ function validateMapSubjectStates(value, dataProductBindings) {
|
|||
return dataProductBindings.map((binding, index) => states.get(binding.id) ?? defaultMapSubjectState(binding.id, index));
|
||||
}
|
||||
|
||||
function validateMapReferenceLayers(value) {
|
||||
if (!Array.isArray(value) || value.length > canonicalMapReferenceLayers.length) {
|
||||
throw applicationError("invalid_map_reference_layers");
|
||||
}
|
||||
const canonicalById = new Map(canonicalMapReferenceLayers.map((layer) => [layer.id, layer]));
|
||||
const visibilityById = new Map();
|
||||
for (const raw of value) {
|
||||
if (!isObject(raw)) throw applicationError("invalid_map_reference_layer");
|
||||
if (Object.keys(raw).some((key) => !new Set([
|
||||
"id", "referenceProfileId", "category", "presentationProfileId", "visible",
|
||||
]).has(key))) throw applicationError("invalid_map_reference_layer_field");
|
||||
const canonical = canonicalById.get(raw.id);
|
||||
if (!canonical
|
||||
|| raw.referenceProfileId !== canonical.referenceProfileId
|
||||
|| raw.category !== canonical.category
|
||||
|| raw.presentationProfileId !== canonical.presentationProfileId) {
|
||||
throw applicationError("map_reference_layer_not_registered");
|
||||
}
|
||||
if (visibilityById.has(raw.id)) throw applicationError("duplicate_map_reference_layer_id");
|
||||
visibilityById.set(raw.id, requireBoolean(raw.visible, "invalid_map_reference_layer_visibility"));
|
||||
}
|
||||
return canonicalMapReferenceLayers.map((layer) => ({
|
||||
...layer,
|
||||
visible: visibilityById.get(layer.id) ?? layer.visible,
|
||||
}));
|
||||
}
|
||||
|
||||
const MAP_PAGE_SETTING_KEYS = new Set([
|
||||
"imagerySource", "imageryVisible", "cacheEnabled", "cacheNoOverwrite", "terrainEnabled",
|
||||
"terrainExaggeration", "monochrome", "monochromeColor", "imageryGamma", "imageryHue",
|
||||
|
|
@ -477,6 +530,21 @@ function validateMapPageSettingsPatch(value) {
|
|||
return value;
|
||||
}
|
||||
|
||||
function validateMapInspectorOpenSections(value) {
|
||||
if (!Array.isArray(value) || value.length > 1) {
|
||||
throw applicationError("invalid_map_inspector_open_sections");
|
||||
}
|
||||
const sections = value.map((section) => requireNonEmptyString(
|
||||
section,
|
||||
"invalid_map_inspector_open_section",
|
||||
256,
|
||||
));
|
||||
if (new Set(sections).size !== sections.length) {
|
||||
throw applicationError("duplicate_map_inspector_open_section");
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
function validateMapPageLayout(value) {
|
||||
if (!isObject(value)) throw applicationError("invalid_map_page_layout");
|
||||
if (value.schemaVersion !== 1 || value.pageId !== "map") throw applicationError("unsupported_map_page_layout");
|
||||
|
|
@ -501,7 +569,12 @@ function validateMapPageLayout(value) {
|
|||
for (const key of ["longitude", "latitude", "height", "heading", "pitch", "roll"]) {
|
||||
requireNumber(camera[key], -1_000_000_000, 1_000_000_000, `invalid_map_page_camera_${key}`);
|
||||
}
|
||||
const presentationProfiles = normalizeMapPresentationProfiles(value.presentationProfiles === undefined ? [] : value.presentationProfiles);
|
||||
const suppliedPresentationProfiles = normalizeMapPresentationProfiles(value.presentationProfiles === undefined ? [] : value.presentationProfiles);
|
||||
const suppliedPresentationProfileIds = new Set(suppliedPresentationProfiles.map((profile) => profile.id));
|
||||
const presentationProfiles = normalizeMapPresentationProfiles([
|
||||
...suppliedPresentationProfiles,
|
||||
...canonicalMapReferencePresentationProfiles.filter((profile) => !suppliedPresentationProfileIds.has(profile.id)),
|
||||
]);
|
||||
const subjectDetailProfiles = normalizeMapSubjectDetailProfiles(
|
||||
value.subjectDetailProfiles === undefined
|
||||
? structuredClone(canonicalMapSubjectDetailProfiles)
|
||||
|
|
@ -509,6 +582,7 @@ function validateMapPageLayout(value) {
|
|||
);
|
||||
const dataProductBindings = validateMapDataProductBindings(value.dataProductBindings === undefined ? [] : value.dataProductBindings);
|
||||
const subjectStates = validateMapSubjectStates(value.subjectStates === undefined ? [] : value.subjectStates, dataProductBindings);
|
||||
const referenceLayers = validateMapReferenceLayers(value.referenceLayers === undefined ? [] : value.referenceLayers);
|
||||
const presentationProfileIds = new Set(presentationProfiles.map((profile) => profile.id));
|
||||
if (dataProductBindings.some((binding) => binding.presentationProfileId && !presentationProfileIds.has(binding.presentationProfileId))) {
|
||||
throw applicationError("map_data_product_presentation_profile_not_found");
|
||||
|
|
@ -571,6 +645,10 @@ function validateMapPageLayout(value) {
|
|||
subjectDetailProfiles,
|
||||
dataProductBindings,
|
||||
subjectStates,
|
||||
referenceLayers,
|
||||
inspectorOpenSections: validateMapInspectorOpenSections(
|
||||
value.inspectorOpenSections === undefined ? ["map-base"] : value.inspectorOpenSections,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -663,6 +741,8 @@ function defaultMapPageLayout() {
|
|||
subjectDetailProfiles: structuredClone(canonicalMapSubjectDetailProfiles),
|
||||
dataProductBindings: [],
|
||||
subjectStates: [],
|
||||
referenceLayers: structuredClone(canonicalMapReferenceLayers),
|
||||
inspectorOpenSections: ["map-base"],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -2159,6 +2239,9 @@ const foundryMcpOperations = {
|
|||
mapHeight: input.viewState.mapHeight,
|
||||
camera: input.viewState.camera,
|
||||
subjectStates: input.viewState.subjectStates,
|
||||
inspectorOpenSections: input.viewState.inspectorOpenSections === undefined
|
||||
? layout.inspectorOpenSections
|
||||
: input.viewState.inspectorOpenSections,
|
||||
}) },
|
||||
};
|
||||
return updatedPage;
|
||||
|
|
@ -2170,6 +2253,7 @@ const foundryMcpOperations = {
|
|||
mapHeight: updatedPage.layout.map.mapHeight,
|
||||
camera: updatedPage.layout.map.camera,
|
||||
subjectStates: updatedPage.layout.map.subjectStates,
|
||||
inspectorOpenSections: updatedPage.layout.map.inspectorOpenSections,
|
||||
} };
|
||||
},
|
||||
});
|
||||
|
|
@ -2703,7 +2787,8 @@ const server = createServer(async (request, response) => {
|
|||
const pageLayoutMatch = url.pathname.match(/^\/api\/page-layouts\/([a-z0-9-]+)$/i);
|
||||
if (pageLayoutMatch && request.method === "GET") {
|
||||
try {
|
||||
json(response, 200, JSON.parse(await readFile(pageLayoutPath(pageLayoutMatch[1]), "utf8")));
|
||||
const stored = JSON.parse(await readFile(pageLayoutPath(pageLayoutMatch[1]), "utf8"));
|
||||
json(response, 200, pageLayoutMatch[1] === "map" ? validateMapPageLayout(stored) : stored);
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") return json(response, 200, null);
|
||||
throw error;
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ test("one setup command provisions independent Foundry and Ontology MCP transpor
|
|||
assert.deepEqual(subjectDetailTool.inputSchema.properties.profile.required, ["id", "version", "title", "semanticTypes", "defaultTabId", "tabs"]);
|
||||
assert.deepEqual(
|
||||
subjectDetailTool.inputSchema.properties.profile.properties.tabs.items.properties.sections.items.properties.fields.items.properties.format.enum,
|
||||
["text", "number", "timestamp", "boolean", "coordinate", "signal_state", "movement_state", "telemetry_readings"],
|
||||
["text", "number", "timestamp", "boolean", "coordinate", "signal_state", "movement_state", "string_list", "telemetry_readings"],
|
||||
);
|
||||
const mapSettingsTool = foundryList.result.tools.find((tool) => tool.name === "foundry_update_map_page_settings");
|
||||
assert.ok(mapSettingsTool);
|
||||
|
|
@ -140,6 +140,8 @@ test("one setup command provisions independent Foundry and Ontology MCP transpor
|
|||
assert.equal(Object.hasOwn(mapSettingsTool.inputSchema.properties.settings.properties, "providerUrl"), false);
|
||||
const mapViewStateTool = foundryList.result.tools.find((tool) => tool.name === "foundry_save_map_page_view_state");
|
||||
assert.ok(mapViewStateTool);
|
||||
assert.equal(mapViewStateTool.inputSchema.properties.viewState.properties.inspectorOpenSections.maxItems, 1);
|
||||
assert.equal(mapViewStateTool.inputSchema.properties.viewState.properties.inspectorOpenSections.uniqueItems, true);
|
||||
const subjectStateSchema = mapViewStateTool.inputSchema.properties.viewState.properties.subjectStates.items;
|
||||
assert.deepEqual(subjectStateSchema.required, ["bindingId", "visible", "filters", "window"]);
|
||||
assert.equal(subjectStateSchema.properties.filters.additionalProperties.items.type, "string");
|
||||
|
|
|
|||
|
|
@ -688,6 +688,13 @@ const tools = [
|
|||
},
|
||||
},
|
||||
subjectStates: { type: "array", maxItems: 64, items: mapSubjectStateInputSchema },
|
||||
inspectorOpenSections: {
|
||||
type: "array",
|
||||
maxItems: 1,
|
||||
uniqueItems: true,
|
||||
description: "Exact expanded Map Inspector section ids. Empty means every section is collapsed.",
|
||||
items: { type: "string", minLength: 1, maxLength: 256 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue