feat(map): add reference layers and stabilize workspace controls

This commit is contained in:
Codex 2026-07-25 13:12:24 +03:00
parent c859ae4db0
commit eaecce9f56
29 changed files with 1593 additions and 247 deletions

View File

@ -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> = { const materialDefaults: Record<NodedcTheme, MaterialDraft> = {
dark: { dark: {
panelHex: "#151517", panelHex: "#151517",
@ -320,12 +313,7 @@ export function CatalogApp() {
const [toasts, setToasts] = useState<ToastItem[]>([]); const [toasts, setToasts] = useState<ToastItem[]>([]);
const toastSequenceRef = useRef(0); const toastSequenceRef = useRef(0);
const [sessionProfile, setSessionProfile] = useState<FoundrySessionProfile | null>(null); const [sessionProfile, setSessionProfile] = useState<FoundrySessionProfile | null>(null);
const [platformSettingsOpen, setPlatformSettingsOpen] = useState(false);
const [foundrySettingsOpen, setFoundrySettingsOpen] = 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 [mapTemplateLayout, setMapTemplateLayout] = useState<MapPageLayout | null>(null);
const [mapTemplateSaveState, setMapTemplateSaveState] = useState<"idle" | "loading" | "saving" | "saved" | "error">("loading"); const [mapTemplateSaveState, setMapTemplateSaveState] = useState<"idle" | "loading" | "saving" | "saved" | "error">("loading");
const mapTemplatePreviewRef = useRef<MapFixturePreviewHandle>(null); const mapTemplatePreviewRef = useRef<MapFixturePreviewHandle>(null);
@ -527,60 +515,6 @@ export function CatalogApp() {
const isFoundryAdmin = sessionProfile?.access?.role === "admin"; 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(() => { useEffect(() => {
let active = true; let active = true;
fetch("/api/layout", { cache: "no-store" }) fetch("/api/layout", { cache: "no-store" })
@ -2019,8 +1953,6 @@ export function CatalogApp() {
onClose={closeGuideline} onClose={closeGuideline}
headerActions={studioContext === "applications" ? ( headerActions={studioContext === "applications" ? (
<IconButton label="Создать модуль" onClick={() => setCreateModuleOpen(true)}><Icon name="plus" /></IconButton> <IconButton label="Создать модуль" onClick={() => setCreateModuleOpen(true)}><Icon name="plus" /></IconButton>
) : studioContext === "pages" && isFoundryAdmin ? (
<IconButton label="Настройки Platform" onClick={openPlatformSettings}><Icon name="settings" /></IconButton>
) : undefined} ) : undefined}
contextSlot={studioContext === "applications" ? ( contextSlot={studioContext === "applications" ? (
<Select <Select
@ -2270,57 +2202,7 @@ export function CatalogApp() {
onConfirm={confirmPageRemoval} onConfirm={confirmPageRemoval}
/> />
<Window <FoundrySettingsModal open={foundrySettingsOpen} isAdmin={isFoundryAdmin} onClose={() => setFoundrySettingsOpen(false)} />
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)} />
<Window <Window
open={createModalOpen} open={createModalOpen}

View File

@ -4,6 +4,7 @@ import {
Cartesian3, Cartesian3,
BingMapsImageryProvider, BingMapsImageryProvider,
BingMapsStyle, BingMapsStyle,
BillboardGraphics,
Color, Color,
Credit, Credit,
Cesium3DTileset, Cesium3DTileset,
@ -29,6 +30,7 @@ import {
ImageryLayer, ImageryLayer,
JulianDate, JulianDate,
LabelGraphics, LabelGraphics,
LabelStyle,
Matrix4, Matrix4,
Math as CesiumMath, Math as CesiumMath,
PerInstanceColorAppearance, PerInstanceColorAppearance,
@ -64,6 +66,41 @@ const MAX_SPIRAL_SUBSTEPS_PER_FRAME = 300;
const TERRAIN_SAMPLE_TIMEOUT_MS = 12_000; const TERRAIN_SAMPLE_TIMEOUT_MS = 12_000;
const SPIRAL_TILE_WAIT_TIMEOUT_MS = 45_000; const SPIRAL_TILE_WAIT_TIMEOUT_MS = 45_000;
const MAX_HGEOZONE_INSTANCES_PER_BATCH = 256; 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 = { type RuntimeConfig = {
cesiumVersion: string; cesiumVersion: string;
provider: string; provider: string;
@ -622,7 +659,11 @@ function syncRuntimeDataSources(
faultedHGeoZoneGeometryKeys: Set<string>, faultedHGeoZoneGeometryKeys: Set<string>,
) { ) {
const activeBindings = new Map(bindings 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])); .map((binding) => [binding.bindingId, binding]));
for (const [bindingId, dataSource] of dataSources) { for (const [bindingId, dataSource] of dataSources) {
@ -657,7 +698,7 @@ function syncRuntimeDataSources(
const label = mapRuntimeDisplayLabel(fact, profile); const label = mapRuntimeDisplayLabel(fact, profile);
const baseEntityId = mapRuntimeEntityId(binding.bindingId, fact); 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; if (profile && profile.target.variant !== "elevated-spike") continue;
const entityId = baseEntityId; const entityId = baseEntityId;
wanted.add(entityId); wanted.add(entityId);
@ -666,19 +707,23 @@ function syncRuntimeDataSources(
entity.name = label; entity.name = label;
entity.polygon = undefined; entity.polygon = undefined;
if (!profile) { if (!profile) {
entity.billboard = undefined;
entity.position = new ConstantPositionProperty(Cartesian3.fromDegrees(longitude, latitude, 0)); entity.position = new ConstantPositionProperty(Cartesian3.fromDegrees(longitude, latitude, 0));
entity.polyline = undefined; entity.polyline = undefined;
entity.point = new PointGraphics({ entity.point = new PointGraphics({
pixelSize: 10, pixelSize: 10,
color, color,
outlineColor: Color.fromCssColorString("#0c0d12").withAlpha(0.72), outlineColor: Color.TRANSPARENT,
outlineWidth: 2, outlineWidth: 0,
disableDepthTestDistance: Number.POSITIVE_INFINITY, disableDepthTestDistance: Number.POSITIVE_INFINITY,
}); });
entity.label = new LabelGraphics({ entity.label = new LabelGraphics({
text: label, text: label,
font: "700 13px Arial", font: "700 13px Arial",
fillColor: Color.WHITE, fillColor: Color.WHITE,
outlineColor: Color.TRANSPARENT,
outlineWidth: 0,
style: LabelStyle.FILL,
showBackground: true, showBackground: true,
backgroundColor: Color.BLACK.withAlpha(0.72), backgroundColor: Color.BLACK.withAlpha(0.72),
backgroundPadding: new Cartesian2(10, 7), backgroundPadding: new Cartesian2(10, 7),
@ -714,11 +759,19 @@ function syncRuntimeDataSources(
material: color, material: color,
show: showBelowCameraHeight(viewer, target.hideCameraHeightMeters), show: showBelowCameraHeight(viewer, target.hideCameraHeightMeters),
}); });
entity.point = new PointGraphics({ const targetImage = elevatedTargetImage(
pixelSize: target.headSizePx,
color, color,
outlineColor: Color.fromCssColorString(target.outlineColor).withAlpha(target.outlineOpacity), Color.fromCssColorString(target.outlineColor).withAlpha(target.outlineOpacity),
outlineWidth: target.outlineWidthPx, 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, disableDepthTestDistance: Number.POSITIVE_INFINITY,
show: showBelowCameraHeight(viewer, target.hideCameraHeightMeters), show: showBelowCameraHeight(viewer, target.hideCameraHeightMeters),
}); });
@ -726,9 +779,9 @@ function syncRuntimeDataSources(
text: label, text: label,
font: `${profile.label.fontWeight} ${profile.label.sizePx}px Arial`, font: `${profile.label.fontWeight} ${profile.label.sizePx}px Arial`,
fillColor: Color.fromCssColorString(profile.label.color), fillColor: Color.fromCssColorString(profile.label.color),
outlineColor: Color.fromCssColorString(profile.label.outlineColor), outlineColor: Color.TRANSPARENT,
outlineWidth: profile.label.outlineWidthPx, outlineWidth: 0,
style: 2, style: LabelStyle.FILL,
showBackground: profile.label.backgroundOpacity > 0, showBackground: profile.label.backgroundOpacity > 0,
backgroundColor: Color.fromCssColorString(profile.label.backgroundColor).withAlpha(profile.label.backgroundOpacity), backgroundColor: Color.fromCssColorString(profile.label.backgroundColor).withAlpha(profile.label.backgroundOpacity),
backgroundPadding: new Cartesian2(profile.label.paddingX, profile.label.paddingY), 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 }); const entity = dataSource.entities.getById(baseEntityId) ?? dataSource.entities.add({ id: baseEntityId });
entity.name = label; entity.name = label;
entity.position = new ConstantPositionProperty(Cartesian3.fromDegrees(labelAnchor[0] / divisor, labelAnchor[1] / divisor, 0)); entity.position = new ConstantPositionProperty(Cartesian3.fromDegrees(labelAnchor[0] / divisor, labelAnchor[1] / divisor, 0));
entity.billboard = undefined;
entity.point = undefined; entity.point = undefined;
entity.polygon = undefined; entity.polygon = undefined;
entity.polyline = undefined; entity.polyline = undefined;
@ -766,9 +820,9 @@ function syncRuntimeDataSources(
text: label, text: label,
font: profile ? `${profile.label.fontWeight} ${profile.label.sizePx}px Arial` : "700 13px Arial", font: profile ? `${profile.label.fontWeight} ${profile.label.sizePx}px Arial` : "700 13px Arial",
fillColor: profile ? Color.fromCssColorString(profile.label.color) : Color.WHITE, fillColor: profile ? Color.fromCssColorString(profile.label.color) : Color.WHITE,
outlineColor: profile ? Color.fromCssColorString(profile.label.outlineColor) : Color.BLACK, outlineColor: Color.TRANSPARENT,
outlineWidth: profile?.label.outlineWidthPx ?? 1, outlineWidth: 0,
style: 2, style: LabelStyle.FILL,
showBackground: (profile?.label.backgroundOpacity ?? 0.72) > 0, showBackground: (profile?.label.backgroundOpacity ?? 0.72) > 0,
backgroundColor: profile backgroundColor: profile
? Color.fromCssColorString(profile.label.backgroundColor).withAlpha(profile.label.backgroundOpacity) ? Color.fromCssColorString(profile.label.backgroundColor).withAlpha(profile.label.backgroundOpacity)
@ -1330,10 +1384,46 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
const viewer = viewerRef.current; const viewer = viewerRef.current;
if (!viewer || viewer.isDestroyed()) return false; if (!viewer || viewer.isDestroyed()) return false;
const entity = runtimeEntities([entityId])[0]; const entity = runtimeEntities([entityId])[0];
if (!entity) return false; const position = entity?.position?.getValue(viewer.clock.currentTime);
void viewer.flyTo(entity, { if (!position) return false;
duration: 0.45,
offset: new HeadingPitchRange(0, -0.9, 8_000), // 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; return true;
}, [runtimeEntities]); }, [runtimeEntities]);

View File

@ -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>
);
}

View File

@ -1,24 +1,44 @@
import { useEffect, useState } from "react";
import { FeatureSettingsWindow } from "@nodedc/ui-react"; import { FeatureSettingsWindow } from "@nodedc/ui-react";
import { FoundryCodexAgentSettings } from "./FoundryCodexAgentSettings.js"; import { FoundryCodexAgentSettings } from "./FoundryCodexAgentSettings.js";
import { FoundryMapProviderSettings } from "./FoundryMapProviderSettings.js";
interface FoundrySettingsModalProps { interface FoundrySettingsModalProps {
open: boolean; open: boolean;
isAdmin: boolean;
onClose: () => void; 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 ( return (
<FeatureSettingsWindow <FeatureSettingsWindow
open={open} open={open}
title="Настройки Foundry" title="Настройки Foundry"
subtitle="NODE DC / Codex Agent API" subtitle="NODE DC / Codex Agent API"
identity={{ title: "NODE.DC Foundry", subtitle: "Текущий пользователь", avatarLabel: "NF" }} identity={{ title: "NODE.DC Foundry", subtitle: "Текущий пользователь", avatarLabel: "NF" }}
sections={[{ id: "codex-agent-api", label: "Codex Agent API", group: "Features", icon: "network" }]} sections={sections}
activeSection="codex-agent-api" activeSection={activeSection}
onSectionChange={() => undefined} onSectionChange={setActiveSection}
onClose={onClose} onClose={onClose}
> >
<FoundryCodexAgentSettings /> {activeSection === "map-providers" && isAdmin
? <FoundryMapProviderSettings />
: <FoundryCodexAgentSettings />}
</FeatureSettingsWindow> </FeatureSettingsWindow>
); );
} }

View File

@ -32,6 +32,13 @@ import {
type CameraSurveySelection, type CameraSurveySelection,
} from "./mapCameraPresets.js"; } from "./mapCameraPresets.js";
import { buildMapSubjectCardModel, DEFAULT_MAP_SUBJECT_DETAIL_PROFILE } from "./mapSubjectCard.mjs"; 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 }))); const CesiumMapRenderer = lazy(() => import("./CesiumMapRenderer.js").then((module) => ({ default: module.CesiumMapRenderer })));
type PreviewFeatures = { inspector?: boolean; toolbar?: boolean; assistant?: boolean }; type PreviewFeatures = { inspector?: boolean; toolbar?: boolean; assistant?: boolean };
@ -171,6 +178,8 @@ export type MapPageLayout = {
subjectDetailProfiles: MapSubjectDetailProfile[]; subjectDetailProfiles: MapSubjectDetailProfile[];
dataProductBindings: MapDataProductBinding[]; dataProductBindings: MapDataProductBinding[];
subjectStates: MapSubjectState[]; subjectStates: MapSubjectState[];
referenceLayers: MapReferenceLayer[];
inspectorOpenSections: string[];
savedAt?: string; savedAt?: string;
}; };
@ -247,10 +256,12 @@ export function createDefaultMapPageLayout(expanded = false): MapPageLayout {
mapHeight: expanded ? 620 : 470, mapHeight: expanded ? 620 : 470,
camera: { ...fallbackMapCamera }, camera: { ...fallbackMapCamera },
pinBindings: [], pinBindings: [],
presentationProfiles: [], presentationProfiles: ensureMapReferencePresentationProfiles([]),
subjectDetailProfiles: [structuredClone(DEFAULT_MAP_SUBJECT_DETAIL_PROFILE) as MapSubjectDetailProfile], subjectDetailProfiles: [structuredClone(DEFAULT_MAP_SUBJECT_DETAIL_PROFILE) as MapSubjectDetailProfile],
dataProductBindings: [], dataProductBindings: [],
subjectStates: [], subjectStates: [],
referenceLayers: initialMapReferenceLayers(),
inspectorOpenSections: ["map-base"],
}; };
} }
@ -354,7 +365,11 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
const [subjectCardZIndex, setSubjectCardZIndex] = useState(140); const [subjectCardZIndex, setSubjectCardZIndex] = useState(140);
const [subjectCardActive, setSubjectCardActive] = useState(false); const [subjectCardActive, setSubjectCardActive] = useState(false);
const [subjectCardTabId, setSubjectCardTabId] = useState("overview"); const [subjectCardTabId, setSubjectCardTabId] = useState("overview");
const [expandedFacetRows, setExpandedFacetRows] = useState<Record<string, boolean>>({});
const [inspectorOpen, setInspectorOpen] = useState(false); const [inspectorOpen, setInspectorOpen] = useState(false);
const [inspectorOpenSections, setInspectorOpenSections] = useState<string[]>(() => (
initialLayout?.inspectorOpenSections ?? ["map-base"]
));
const [layersOpen, setLayersOpen] = useState(false); const [layersOpen, setLayersOpen] = useState(false);
const [layersWindowRect, setLayersWindowRect] = useState<WorkspaceWindowRect>(defaultLayersWindowRect); const [layersWindowRect, setLayersWindowRect] = useState<WorkspaceWindowRect>(defaultLayersWindowRect);
const [layersWindowMaximized, setLayersWindowMaximized] = useState(false); const [layersWindowMaximized, setLayersWindowMaximized] = useState(false);
@ -388,7 +403,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
// Presentation profiles are application/page-owned, versioned map.style_profile // Presentation profiles are application/page-owned, versioned map.style_profile
// values. A human camera/settings save must preserve profiles provisioned by MCP. // values. A human camera/settings save must preserve profiles provisioned by MCP.
const [presentationProfiles, setPresentationProfiles] = useState<MapPresentationProfile[]>(() => ( const [presentationProfiles, setPresentationProfiles] = useState<MapPresentationProfile[]>(() => (
normalizeClientMapPresentationProfiles(initialLayout?.presentationProfiles ?? []) ensureMapReferencePresentationProfiles(normalizeClientMapPresentationProfiles(initialLayout?.presentationProfiles ?? []))
)); ));
const [subjectDetailProfiles] = useState<MapSubjectDetailProfile[]>(() => ( const [subjectDetailProfiles] = useState<MapSubjectDetailProfile[]>(() => (
initialLayout?.subjectDetailProfiles?.length initialLayout?.subjectDetailProfiles?.length
@ -399,6 +414,9 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
// not belong to the visual inspector. Preserve them verbatim when a human // not belong to the visual inspector. Preserve them verbatim when a human
// edits camera or presentation settings and saves the page layout. // edits camera or presentation settings and saves the page layout.
const [dataProductBindings] = useState<MapDataProductBinding[]>(() => initialLayout?.dataProductBindings ?? []); const [dataProductBindings] = useState<MapDataProductBinding[]>(() => initialLayout?.dataProductBindings ?? []);
const [referenceLayers, setReferenceLayers] = useState<MapReferenceLayer[]>(() => (
initialMapReferenceLayers(initialLayout?.referenceLayers)
));
const [subjectStates, setSubjectStates] = useState<Record<string, MapSubjectState>>(() => ( const [subjectStates, setSubjectStates] = useState<Record<string, MapSubjectState>>(() => (
initialSubjectState(initialLayout?.dataProductBindings ?? [], initialLayout?.subjectStates) initialSubjectState(initialLayout?.dataProductBindings ?? [], initialLayout?.subjectStates)
)); ));
@ -415,10 +433,23 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
bindings: dataProductBindings, bindings: dataProductBindings,
enabled: Boolean(applicationId && pageId), 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(() => ( const selectable = useMemo(() => (
runtimeBindings.flatMap((binding) => { primaryRuntimeBindings.flatMap((binding) => {
const bindingConfig = dataProductBindings.find((candidate) => candidate.id === binding.bindingId); const bindingConfig = dataProductBindings.find((candidate) => candidate.id === binding.bindingId);
if (bindingConfig?.joinToBindingId) return [];
const facts = [...binding.facts]; const facts = [...binding.facts];
const primaryProfile = mapPresentationProfileForFact( const primaryProfile = mapPresentationProfileForFact(
presentationProfiles, presentationProfiles,
@ -440,8 +471,9 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
}; };
}); });
}) })
), [dataProductBindings, presentationProfiles, runtimeBindings]); ), [dataProductBindings, presentationProfiles, primaryRuntimeBindings]);
const presentationSummaries = useMemo(() => [...dataProductBindings] const presentationSummaries = useMemo(() => [...dataProductBindings]
.filter((binding) => !binding.joinToBindingId)
.sort((left, right) => (left.order ?? 0) - (right.order ?? 0) || left.id.localeCompare(right.id)) .sort((left, right) => (left.order ?? 0) - (right.order ?? 0) || left.id.localeCompare(right.id))
.flatMap((bindingConfig) => { .flatMap((bindingConfig) => {
const binding = runtimeBindings.find((candidate) => candidate.bindingId === bindingConfig.id); const binding = runtimeBindings.find((candidate) => candidate.bindingId === bindingConfig.id);
@ -457,7 +489,18 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
counts: mapPresentationFacetCounts(facts, profile), counts: mapPresentationFacetCounts(facts, profile),
}]; }];
}), [dataProductBindings, presentationProfiles, runtimeBindings]); }), [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); const bindingConfig = dataProductBindings.find((candidate) => candidate.id === binding.bindingId);
return binding.facts.flatMap((fact) => { return binding.facts.flatMap((fact) => {
const profile = mapPresentationProfileForFact( const profile = mapPresentationProfileForFact(
@ -475,7 +518,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
renderable: mapRuntimeFactIsRenderable(fact, profile), 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(() => ( const visibleTargetEntityIds = useMemo(() => (
filteredTargets.filter((target) => target.renderable).map((target) => target.entityId) filteredTargets.filter((target) => target.renderable).map((target) => target.entityId)
), [filteredTargets]); ), [filteredTargets]);
@ -714,6 +757,8 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
presentationProfiles, presentationProfiles,
subjectDetailProfiles, subjectDetailProfiles,
dataProductBindings, dataProductBindings,
referenceLayers,
inspectorOpenSections,
subjectStates: dataProductBindings.map((binding) => subjectStates[binding.id] ?? { subjectStates: dataProductBindings.map((binding) => subjectStates[binding.id] ?? {
bindingId: binding.id, bindingId: binding.id,
visible: true, visible: true,
@ -726,7 +771,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
: state; : 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) => { const updateSubjectState = (bindingId: string, update: (state: MapSubjectState) => MapSubjectState) => {
setSubjectStates((current) => { setSubjectStates((current) => {
@ -811,7 +856,11 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
const binding = dataProductBindings.find((candidate) => candidate.id === entity?.bindingId); const binding = dataProductBindings.find((candidate) => candidate.id === entity?.bindingId);
const profile = subjectDetailProfiles.find((candidate) => candidate.id === binding?.subjectDetailProfileId) const profile = subjectDetailProfiles.find((candidate) => candidate.id === binding?.subjectDetailProfileId)
?? subjectDetailProfiles.find((candidate) => candidate.semanticTypes.includes(entity?.fact.semanticType ?? "")); ?? 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); setSubjectCardOpen(true);
setSubjectCardActive(true); setSubjectCardActive(true);
setLayersWindowActive(false); 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); setSubjectCardZIndex((current) => Math.max(current, layersWindowZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1);
}, [dataProductBindings, layersWindowZIndex, selectable, subjectDetailProfiles, subjectStates]); }, [dataProductBindings, layersWindowZIndex, selectable, subjectDetailProfiles, subjectStates]);
const handleSelectAndFocus = useCallback((entityId: string) => {
handleSelect(entityId);
mapRendererRef.current?.focusRuntimeEntity(entityId);
}, [handleSelect]);
const rememberGatewayHealth = useCallback((health: MapGatewayHealth) => { const rememberGatewayHealth = useCallback((health: MapGatewayHealth) => {
gatewayHealthRef.current = health; gatewayHealthRef.current = health;
setGatewayHealth(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 })} /> <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}`, 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, description: profile.target.variant === "surface-fill" ? `проекция · ${profile.title}` : profile.title,
group: profile.target.variant === "surface-fill" ? "Слои" : "Таргеты", group: referenceProfile ? "Станции" : profile.target.variant === "surface-fill" ? "Слои" : "Таргеты",
content: <> content: <>
<small className="catalog-map-inspector__note">Профиль принадлежит этой странице Application и управляется тем же provider-neutral MCP-контрактом. Исходный API в настройках отсутствует.</small> <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" && <> {profile.target.variant === "surface-fill" && <>
<ControlRow label="Тип слоя"><strong>HGeoZone · ground projection</strong></ControlRow> <ControlRow label="Тип слоя"><strong>HGeoZone · ground projection</strong></ControlRow>
{profile.styles.map((style) => { {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 } }))} /> <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> <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 } }))} /> <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}`, id: `map-state-classes-${profile.id}`,
label: "Классы состояния", label: "Классы состояния",
description: "нормализованные фасеты онтологии", description: "нормализованные фасеты онтологии",
@ -1078,7 +1139,8 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
})} })}
</>, </>,
}]), }]),
]), ];
}),
{ {
id: "map-grid", id: "map-grid",
label: "Сетка и LOD", label: "Сетка и LOD",
@ -1239,9 +1301,9 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
onSpiralStateChange={handleSpiralStateChange} onSpiralStateChange={handleSpiralStateChange}
initialCamera={mapCamera ?? undefined} initialCamera={mapCamera ?? undefined}
presentation={presentation} presentation={presentation}
runtimeBindings={runtimeBindings} runtimeBindings={[...primaryRuntimeBindings, ...referenceRuntimeBindings]}
presentationProfiles={presentationProfiles} presentationProfiles={presentationProfiles}
presentationFilters={presentationFilters} presentationFilters={rendererPresentationFilters}
/> />
</Suspense> </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-list">
<div className="catalog-map-fixture__objects-menu-head"> <div className="catalog-map-fixture__objects-menu-head">
<strong>Объекты</strong> <strong>Объекты</strong>
<small>{presentationSummaries.length} {presentationSummaries.length === 1 ? "группа" : "групп"}</small> <small>{objectLayerCount} {objectLayerCount === 1 ? "группа" : "групп"}</small>
</div> </div>
{presentationSummaries.map((summary) => { {presentationSummaries.map((summary) => {
const state = subjectStates[summary.bindingId]; const state = subjectStates[summary.bindingId];
@ -1324,30 +1386,45 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
> >
<button <button
type="button" type="button"
role="menuitemcheckbox" role={hasControls ? "menuitem" : "menuitemcheckbox"}
aria-checked={visible} aria-checked={hasControls ? undefined : visible}
className="catalog-map-fixture__objects-menu-toggle" 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> <span>{summary.displayName}</span>
<small>{visible ? `на карте: ${visibleCount}` : `слой скрыт · ${summary.total} объектов`}</small> <small>{visible ? `на карте: ${visibleCount}` : `слой скрыт · ${summary.total} объектов`}</small>
</button> </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> </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> </div>
)} )}
</Dropdown> </Dropdown>
@ -1382,6 +1459,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
zIndex={state.window.zIndex} zIndex={state.window.zIndex}
minWidth={240} minWidth={240}
minHeight={220} minHeight={220}
autoHeight
className="catalog-map-fixture__subject-window catalog-map-fixture__map-glass-window" className="catalog-map-fixture__subject-window catalog-map-fixture__map-glass-window"
> >
<div className="catalog-map-fixture__target-filters"> <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) => ( {summary.profile.facets.filter((facet) => facet.counter || facet.filterable).flatMap((facet) => (
facet.values.map((item) => { facet.values.map((item) => {
const active = state.filters[facet.field]?.includes(item.value) ?? false; 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 ( return (
<button <div className="catalog-map-fixture__target-filter-branch" key={`${facet.field}:${item.value}`}>
type="button" <div className="catalog-map-fixture__target-filter-row" data-active={active || undefined}>
key={`${facet.field}:${item.value}`} <button
aria-pressed={active} type="button"
data-active={active || undefined} className="catalog-map-fixture__target-filter-body"
disabled={!facet.filterable} aria-pressed={active}
onClick={() => togglePresentationFilter(summary.bindingId, facet.field, item.value)} disabled={!facet.filterable}
> onClick={() => togglePresentationFilter(summary.bindingId, facet.field, item.value)}
{item.label} <span>{summary.counts[facet.field]?.[item.value] ?? 0}</span> >
</button> <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" className="catalog-map-fixture__map-settings-window"
onClose={() => setInspectorOpen(false)} onClose={() => setInspectorOpen(false)}
> >
<Inspector sections={inspectorSections} defaultOpen={["map-base"]} singleOpen /> <Inspector
sections={inspectorSections}
openSections={inspectorOpenSections}
singleOpen
onOpenSectionsChange={setInspectorOpenSections}
/>
</Window> </Window>
</div> </div>
); );

View File

@ -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);
}

View File

@ -746,7 +746,7 @@ textarea {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr);
align-items: center; align-items: center;
gap: 0.28rem; gap: 0.28rem;
border-radius: 0.78rem; border-radius: 0.78rem;
@ -791,11 +791,6 @@ textarea {
white-space: nowrap; white-space: nowrap;
} }
.catalog-map-fixture__objects-menu-item > .nodedc-icon-button {
width: 2.25rem;
height: 2.25rem;
}
.catalog-map-fixture__objects-menu-empty { .catalog-map-fixture__objects-menu-empty {
padding: 0.72rem; padding: 0.72rem;
} }
@ -938,44 +933,127 @@ textarea {
gap: 0.36rem; gap: 0.36rem;
} }
.catalog-map-fixture__target-filter-list button { .catalog-map-fixture__target-filter-row {
display: inline-flex; display: inline-flex;
width: 100%; width: 100%;
min-height: 2.05rem; min-height: 2.05rem;
align-items: center; align-items: center;
justify-content: space-between; gap: 0;
gap: 0.75rem;
border: 0; border: 0;
border-radius: var(--nodedc-radius-circle); border-radius: var(--nodedc-radius-circle);
background: rgba(255, 255, 255, 0.72); background: rgba(255, 255, 255, 0.72);
padding: 0.42rem 0.72rem;
color: rgba(8, 8, 10, 0.88); color: rgba(8, 8, 10, 0.88);
overflow: hidden;
}
.catalog-map-fixture__target-filter-row button {
border: 0;
background: transparent;
font: inherit; font: inherit;
font-size: 0.72rem; font-size: 0.72rem;
font-weight: 700; font-weight: 700;
line-height: 1; line-height: 1;
white-space: nowrap;
cursor: pointer; 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; margin-left: auto;
color: rgba(8, 8, 10, 0.96); color: rgba(8, 8, 10, 0.96);
font-weight: 800; font-weight: 800;
text-align: right; text-align: right;
} }
.catalog-map-fixture__target-filter-list button:hover, .catalog-map-fixture__target-filter-expander {
.catalog-map-fixture__target-filter-list button[data-active] { 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); background: rgba(255, 255, 255, 0.96);
color: rgba(8, 8, 10, 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; cursor: default;
opacity: 0.55; 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 { .catalog-map-inspector__style {
display: grid; display: grid;
gap: 0.5rem; gap: 0.5rem;

View File

@ -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));
}

View File

@ -39,7 +39,7 @@ FieldFrame объединяет label, control, hint и description. TextField/T
## RangeControl ## 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 ## ColorField
@ -92,7 +92,19 @@ Window — единая механика открытия modal и правой
`WorkspaceWindow` — modeless-окно внутри рабочей сцены приложения. Оно рендерится непосредственно в переданном workspace, не использует portal и не может перекрыть шапку, навигацию или соседние панели за пределами этого workspace. `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`. Окно предназначено для вспомогательных камер, инструментов и сопоставляемых представлений внутри сцены. Modal workflow, подтверждение и viewport-level Inspector по-прежнему используют `Window`.
@ -228,6 +240,11 @@ Inspector владеет:
- вертикальным ритмом; - вертикальным ритмом;
- раскладкой label/control. - раскладкой 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 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 нет. Engine продолжает владеть определениями полей, типами нод и сохранением значений. В живом catalog эти collapsible sections находятся в `Guideline → Контролы`; отдельной product-вкладки Inspector нет.

View File

@ -59,8 +59,70 @@ profile; renderer не содержит provider-specific условий.
`aspectId`. Соединение выполняется по стабильному `sourceId`; provider endpoint, `aspectId`. Соединение выполняется по стабильному `sourceId`; provider endpoint,
credentials и raw payload в Application Manifest не попадают. 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 не включает `Все`; `Все` включается и выключается только явным кликом. Переключение фильтра не двигает камеру, обзор выполняется отдельным действием. 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 ## Acceptance fixtures
- `registry/fixtures/map/map-empty-offline-v0.1.json` — отсутствие provider/live data без разрушения shell и управляющих действий. - `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` `subjectDetailProfileId`, `aspectId`/`joinToBindingId` и `slotId`: `points`
для live point entities, `zones` для polygon/multipolygon `map.zone` или для live point entities, `zones` для polygon/multipolygon `map.zone` или
`subject-details` для non-spatial current aspects выбранного объекта. `subject-details` для non-spatial current aspects выбранного объекта.
Platform-owned справочные сущности используют отдельный `reference-points`
slot и не подменяют этот Data Product contract.
При открытии Application page browser делает same-origin запрос к Foundry: При открытии Application page browser делает same-origin запрос к Foundry:
```text ```text

View File

@ -95,8 +95,9 @@ Foundry и без неописанного generic JSON.
`foundry_save_map_page_view_state` является MCP-эквивалентом одной Application `foundry_save_map_page_view_state` является MCP-эквивалентом одной Application
кнопки `Сохранить`. Операция атомарно сохраняет camera/map height, typed patch кнопки `Сохранить`. Операция атомарно сохраняет camera/map height, typed patch
base settings, exact visibility/facet selections и все canonical binding base settings, точный список раскрытых секций Inspector, exact
windows (`open`, `rect`, `maximized`, `zIndex`). Состояние keyed только по visibility/facet selections и все canonical binding windows (`open`, `rect`,
`maximized`, `zIndex`). Состояние keyed только по
`bindingId`: editable `displayName` не участвует в identity. Missing facet не `bindingId`: editable `displayName` не участвует в identity. Missing facet не
ограничивает выборку, явно пустой список остаётся empty после reopen и не ограничивает выборку, явно пустой список остаётся empty после reopen и не
мигрирует обратно в `Все`. мигрирует обратно в `Все`.

View File

@ -11,7 +11,7 @@
"scripts": { "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": "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", "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", "dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog",
"serve": "node server/catalog-server.mjs", "serve": "node server/catalog-server.mjs",
"validate:registry": "node scripts/validate-registry.mjs", "validate:registry": "node scripts/validate-registry.mjs",
@ -24,10 +24,13 @@
"test:map-filters": "node --test scripts/map-presentation-filters.test.mjs", "test:map-filters": "node --test scripts/map-presentation-filters.test.mjs",
"test:hgeozone-projection": "node --test scripts/hgeozone-projection.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-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-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-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: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": { "engines": {
"node": ">=20" "node": ">=20"

View File

@ -8,7 +8,7 @@ export type PageTemplateFeature = {
required?: boolean; 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 = { export type PageTemplateSlot = {
id: string; 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: "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: "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: "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: "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: "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 }, { id: "assistant", kind: "assistant", label: "Assistant context", description: "Context exposed to the assistant overlay.", required: false, multiple: false },

View File

@ -694,6 +694,10 @@ textarea.nodedc-field__control {
cursor: pointer; cursor: pointer;
} }
.nodedc-range input[type="range"]:disabled {
cursor: default;
}
.nodedc-range__label, .nodedc-range__label,
.nodedc-range__value { .nodedc-range__value {
position: absolute; position: absolute;
@ -733,6 +737,43 @@ textarea.nodedc-field__control {
color: rgb(var(--nodedc-on-accent-rgb)); 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 { .nodedc-color-field {
display: grid; display: grid;
min-height: var(--nodedc-control-height); min-height: var(--nodedc-control-height);
@ -2515,6 +2556,10 @@ textarea.nodedc-field__control {
touch-action: none; 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"] { .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); 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; touch-action: auto;
} }
.nodedc-workspace-window__body-content {
min-width: 0;
}
.nodedc-workspace-window__footer { .nodedc-workspace-window__footer {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -16,21 +16,29 @@ export interface InspectorSectionSpec {
export interface InspectorProps { export interface InspectorProps {
sections: InspectorSectionSpec[]; sections: InspectorSectionSpec[];
defaultOpen?: string[]; defaultOpen?: string[];
openSections?: string[];
activeId?: string; activeId?: string;
singleOpen?: boolean; singleOpen?: boolean;
className?: string; className?: string;
onOpenSectionsChange?: (ids: string[]) => void;
onActiveChange?: (id: string) => void; onActiveChange?: (id: string) => void;
} }
export function Inspector({ export function Inspector({
sections, sections,
defaultOpen = [], defaultOpen = [],
openSections,
activeId, activeId,
singleOpen = false, singleOpen = false,
className, className,
onOpenSectionsChange,
onActiveChange, onActiveChange,
}: InspectorProps) { }: 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 groups = useMemo(() => {
const result: Array<{ label?: string; sections: InspectorSectionSpec[] }> = []; const result: Array<{ label?: string; sections: InspectorSectionSpec[] }> = [];
sections.forEach((section) => { sections.forEach((section) => {
@ -45,12 +53,11 @@ export function Inspector({
}, [sections]); }, [sections]);
const toggle = (id: string) => { const toggle = (id: string) => {
setOpenIds((current) => { const next = singleOpen ? new Set<string>() : new Set(openIds);
const next = singleOpen ? new Set<string>() : new Set(current); if (openIds.has(id)) next.delete(id);
if (current.has(id)) next.delete(id); else next.add(id);
else next.add(id); if (openSections === undefined) setInternalOpenIds(next);
return next; onOpenSectionsChange?.([...next]);
});
onActiveChange?.(id); onActiveChange?.(id);
}; };

View File

@ -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 { cn } from "./cn.js";
import { Dropdown } from "./Dropdown.js"; import { Dropdown } from "./Dropdown.js";
@ -11,6 +11,38 @@ export interface RangeControlProps extends Omit<InputHTMLAttributes<HTMLInputEle
onChange: (value: number) => void; 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({ export function RangeControl({
label, label,
value, value,
@ -20,31 +52,111 @@ export function RangeControl({
formatValue = String, formatValue = String,
onChange, onChange,
className, className,
disabled = false,
tabIndex,
...props ...props
}: RangeControlProps) { }: 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 safeMax = max === min ? min + 1 : max;
const progress = Math.max(0, Math.min(100, ((value - min) / (safeMax - min)) * 100)); 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 ( return (
<label className={cn("nodedc-range", className)} style={style}> <div className={cn("nodedc-range", className)} style={style} data-editing={editing || undefined}>
<input <input
ref={rangeRef}
{...props}
type="range" type="range"
value={value} value={value}
min={min} min={min}
max={safeMax} max={safeMax}
step={step} step={step}
disabled={disabled}
tabIndex={editing ? -1 : tabIndex}
aria-label={label} aria-label={label}
aria-valuetext={formatValue(value)} aria-valuetext={displayValue}
onChange={(event) => onChange(Number(event.target.value))} onChange={(event) => onChange(Number(event.target.value))}
{...props}
/> />
<span className="nodedc-range__label">{label}</span> <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__fill-text" aria-hidden="true">
<span className="nodedc-range__label">{label}</span> <span className="nodedc-range__label">{label}</span>
<span className="nodedc-range__value">{formatValue(value)}</span> <span className="nodedc-range__value">{displayValue}</span>
</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>
); );
} }

View File

@ -63,10 +63,12 @@ export function Window({
const titleId = useId(); const titleId = useId();
const descriptionId = useId(); const descriptionId = useId();
const dialogRef = useRef<HTMLDivElement>(null); const dialogRef = useRef<HTMLDivElement>(null);
const onCloseRef = useRef(onClose);
const dragRef = useRef<{ pointerId: number; offsetX: number; offsetY: number } | null>(null); const dragRef = useRef<{ pointerId: number; offsetX: number; offsetY: number } | null>(null);
const [dragPosition, setDragPosition] = useState<{ left: number; top: number } | null>(null); const [dragPosition, setDragPosition] = useState<{ left: number; top: number } | null>(null);
const shouldLockBodyScroll = lockBodyScroll ?? placement === "center"; const shouldLockBodyScroll = lockBodyScroll ?? placement === "center";
const shouldTrapFocus = trapFocus ?? placement === "center"; const shouldTrapFocus = trapFocus ?? placement === "center";
onCloseRef.current = onClose;
useEffect(() => { useEffect(() => {
if (!open || typeof document === "undefined") return; if (!open || typeof document === "undefined") return;
@ -82,7 +84,7 @@ export function Window({
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && closeOnEscape) { if (event.key === "Escape" && closeOnEscape) {
event.preventDefault(); event.preventDefault();
onClose(); onCloseRef.current();
return; return;
} }
if (event.key !== "Tab" || !shouldTrapFocus || !dialogRef.current) return; if (event.key !== "Tab" || !shouldTrapFocus || !dialogRef.current) return;
@ -110,7 +112,7 @@ export function Window({
if (shouldLockBodyScroll) document.body.style.overflow = previousOverflow; if (shouldLockBodyScroll) document.body.style.overflow = previousOverflow;
previousActiveElement?.focus(); previousActiveElement?.focus();
}; };
}, [closeOnEscape, onClose, open, shouldLockBodyScroll, shouldTrapFocus]); }, [closeOnEscape, open, shouldLockBodyScroll, shouldTrapFocus]);
useEffect(() => { useEffect(() => {
if (!open) setDragPosition(null); if (!open) setDragPosition(null);

View File

@ -35,6 +35,7 @@ export interface WorkspaceWindowProps extends Omit<HTMLAttributes<HTMLDivElement
minWidth?: number; minWidth?: number;
minHeight?: number; minHeight?: number;
resizable?: boolean; resizable?: boolean;
autoHeight?: boolean;
active?: boolean; active?: boolean;
zIndex?: number; zIndex?: number;
closeLabel?: string; closeLabel?: string;
@ -121,6 +122,7 @@ export function WorkspaceWindow({
minWidth = 260, minWidth = 260,
minHeight = 180, minHeight = 180,
resizable = true, resizable = true,
autoHeight = false,
active = false, active = false,
zIndex, zIndex,
closeLabel = "Закрыть окно", closeLabel = "Закрыть окно",
@ -139,6 +141,10 @@ export function WorkspaceWindow({
const interactionRef = useRef<WorkspaceInteraction | null>(null); const interactionRef = useRef<WorkspaceInteraction | null>(null);
const rectRef = useRef(rect); const rectRef = useRef(rect);
const onRectChangeRef = useRef(onRectChange); 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); const [interactionKind, setInteractionKind] = useState<WorkspaceInteraction["kind"] | null>(null);
rectRef.current = rect; rectRef.current = rect;
@ -175,6 +181,52 @@ export function WorkspaceWindow({
}; };
}, [boundsRef, maximized, minHeight, minWidth, rect.height, rect.width, rect.x, rect.y]); }, [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(() => { useEffect(() => {
const handlePointerMove = (event: globalThis.PointerEvent) => { const handlePointerMove = (event: globalThis.PointerEvent) => {
const interaction = interactionRef.current; const interaction = interactionRef.current;
@ -273,6 +325,7 @@ export function WorkspaceWindow({
data-active={active ? "true" : undefined} data-active={active ? "true" : undefined}
data-maximized={maximized ? "true" : undefined} data-maximized={maximized ? "true" : undefined}
data-resizable={resizable ? "true" : undefined} data-resizable={resizable ? "true" : undefined}
data-auto-height={autoHeight ? "true" : undefined}
data-interaction={interactionKind ?? undefined} data-interaction={interactionKind ?? undefined}
role="dialog" role="dialog"
aria-modal="false" aria-modal="false"
@ -289,6 +342,7 @@ export function WorkspaceWindow({
}} }}
> >
<header <header
ref={headRef}
className="nodedc-workspace-window__head" className="nodedc-workspace-window__head"
tabIndex={maximized ? -1 : 0} tabIndex={maximized ? -1 : 0}
role="group" role="group"
@ -326,8 +380,10 @@ export function WorkspaceWindow({
</button> </button>
</div> </div>
</header> </header>
<div className="nodedc-workspace-window__body">{children}</div> <div ref={bodyRef} className="nodedc-workspace-window__body">
{footer ? <footer className="nodedc-workspace-window__footer">{footer}</footer> : null} {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 ? ( {resizable && !maximized ? (
<button <button
type="button" type="button"

View File

@ -64,11 +64,17 @@
"package": "@nodedc/ui-react", "package": "@nodedc/ui-react",
"exports": ["RangeControl"], "exports": ["RangeControl"],
"domContract": ["nodedc-range"], "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": [ "rules": [
"The accent fill follows the active application theme.", "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", "id": "color-field",
@ -381,6 +387,7 @@
"Legacy Engine inspectors are not reference implementations.", "Legacy Engine inspectors are not reference implementations.",
"Domain-specific field definitions remain in Engine; reusable layout and controls live here.", "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.", "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.", "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." "Accent-filled section headers belong to Environment Settings; neutral headers remain available for other approved inspector contexts."
] ]

View File

@ -3,7 +3,7 @@
"profiles": [ "profiles": [
{ {
"id": "map.moving-object.operational.default", "id": "map.moving-object.operational.default",
"version": "1.3.0", "version": "1.4.0",
"title": "Флот", "title": "Флот",
"semanticTypes": ["map.moving_object"], "semanticTypes": ["map.moving_object"],
"label": { "label": {
@ -13,7 +13,7 @@
"sizePx": 18, "sizePx": 18,
"color": "#f5f5f5", "color": "#f5f5f5",
"outlineColor": "#0c0d12", "outlineColor": "#0c0d12",
"outlineWidthPx": 1, "outlineWidthPx": 0,
"backgroundColor": "#0c0d12", "backgroundColor": "#0c0d12",
"backgroundOpacity": 0.86, "backgroundOpacity": 0.86,
"paddingX": 8, "paddingX": 8,
@ -30,7 +30,7 @@
"stemWidthPx": 5, "stemWidthPx": 5,
"outlineColor": "#0c0d12", "outlineColor": "#0c0d12",
"outlineOpacity": 0.6, "outlineOpacity": 0.6,
"outlineWidthPx": 1, "outlineWidthPx": 0,
"hideCameraHeightMeters": 100000 "hideCameraHeightMeters": 100000
}, },
"facets": [ "facets": [
@ -115,7 +115,7 @@
"sizePx": 14, "sizePx": 14,
"color": "#f5f5f5", "color": "#f5f5f5",
"outlineColor": "#0c0d12", "outlineColor": "#0c0d12",
"outlineWidthPx": 1, "outlineWidthPx": 0,
"backgroundColor": "#0c0d12", "backgroundColor": "#0c0d12",
"backgroundOpacity": 0.82, "backgroundOpacity": 0.82,
"paddingX": 8, "paddingX": 8,
@ -193,6 +193,162 @@
{ "field": "geometry_kind", "order": ["polygon", "circle", "corridor"] }, { "field": "geometry_kind", "order": ["polygon", "circle", "corridor"] },
{ "field": "source_kind", "order": ["live_api", "versioned_snapshot"] } { "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"] }]
} }
] ]
} }

View File

@ -24,6 +24,7 @@
{ "id": "traces", "kind": "trace-stream", "label": "Traces", "description": "Time-ordered entity traces.", "required": false, "multiple": true }, { "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": "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": "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": "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": "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": "commands", "kind": "command", "label": "Commands", "description": "Capability-backed actions for selected entities.", "required": false, "multiple": true },

View File

@ -72,6 +72,12 @@
"map": { "map": {
"type": "object", "type": "object",
"properties": { "properties": {
"inspectorOpenSections": {
"type": "array",
"maxItems": 1,
"uniqueItems": true,
"items": { "type": "string", "minLength": 1, "maxLength": 256 }
},
"dataProductBindings": { "dataProductBindings": {
"type": "array", "type": "array",
"maxItems": 64, "maxItems": 64,

View File

@ -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);
});

View File

@ -2,22 +2,22 @@ import assert from "node:assert/strict";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import test from "node:test"; 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"); 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, /const toggleSubjectVisibility = \(bindingId: string\)/);
assert.match(preview, /role="menuitemcheckbox"/); assert.match(preview, /role=\{hasControls \? "menuitem" : "menuitemcheckbox"\}/);
assert.match(preview, /aria-checked=\{visible\}/); assert.match(preview, /if \(hasControls\) \{\s*openSubjectWindow\(summary\.bindingId\);\s*close\(\)/);
assert.match(preview, /onClick=\{\(\) => toggleSubjectVisibility\(summary\.bindingId\)\}/); assert.match(preview, /toggleSubjectVisibility\(summary\.bindingId\)/);
assert.match(preview, /visible: !state\.visible/); assert.match(preview, /visible: !state\.visible/);
assert.match(preview, /слой скрыт · \$\{summary\.total\} объектов/); assert.match(preview, /слой скрыт · \$\{summary\.total\} объектов/);
assert.doesNotMatch(preview, /Фильтры и счётчики: \$\{summary\.displayName\}/);
}); });
test("a subject window exists only when the presentation profile exposes controls", async () => { 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"); 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, /function hasSubjectWindowControls\(profile: MapPresentationProfile\)/);
assert.match(preview, /\{hasControls \? \(/);
assert.match(preview, /if \(!state\?\.window\.open \|\| !hasSubjectWindowControls\(summary\.profile\)\) return null/); assert.match(preview, /if \(!state\?\.window\.open \|\| !hasSubjectWindowControls\(summary\.profile\)\) return null/);
assert.match(preview, /window: \{ \.\.\.state\.window, open: false \}/); 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\[data-visible\]/);
assert.match(styles, /\.catalog-map-fixture__objects-menu-item:not\(\[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/);
});

View File

@ -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/);
});

View File

@ -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\]/);
});

View File

@ -55,6 +55,32 @@ if (mapPresentationProfileRegistry?.schemaVersion !== "nodedc.map-presentation-p
throw new Error("map_presentation_profile_registry_invalid"); throw new Error("map_presentation_profile_registry_invalid");
} }
const canonicalMapPresentationProfiles = normalizeMapPresentationProfiles(mapPresentationProfileRegistry.profiles); 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")); const mapSubjectDetailProfileRegistry = JSON.parse(await readFile(join(root, "registry", "map-subject-detail-profiles.json"), "utf8"));
if (mapSubjectDetailProfileRegistry?.schemaVersion !== "nodedc.map-subject-detail-profiles/v1") { if (mapSubjectDetailProfileRegistry?.schemaVersion !== "nodedc.map-subject-detail-profiles/v1") {
throw new Error("map_subject_detail_profile_registry_invalid"); 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)); 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([ const MAP_PAGE_SETTING_KEYS = new Set([
"imagerySource", "imageryVisible", "cacheEnabled", "cacheNoOverwrite", "terrainEnabled", "imagerySource", "imageryVisible", "cacheEnabled", "cacheNoOverwrite", "terrainEnabled",
"terrainExaggeration", "monochrome", "monochromeColor", "imageryGamma", "imageryHue", "terrainExaggeration", "monochrome", "monochromeColor", "imageryGamma", "imageryHue",
@ -477,6 +530,21 @@ function validateMapPageSettingsPatch(value) {
return 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) { function validateMapPageLayout(value) {
if (!isObject(value)) throw applicationError("invalid_map_page_layout"); if (!isObject(value)) throw applicationError("invalid_map_page_layout");
if (value.schemaVersion !== 1 || value.pageId !== "map") throw applicationError("unsupported_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"]) { 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}`); 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( const subjectDetailProfiles = normalizeMapSubjectDetailProfiles(
value.subjectDetailProfiles === undefined value.subjectDetailProfiles === undefined
? structuredClone(canonicalMapSubjectDetailProfiles) ? structuredClone(canonicalMapSubjectDetailProfiles)
@ -509,6 +582,7 @@ function validateMapPageLayout(value) {
); );
const dataProductBindings = validateMapDataProductBindings(value.dataProductBindings === undefined ? [] : value.dataProductBindings); const dataProductBindings = validateMapDataProductBindings(value.dataProductBindings === undefined ? [] : value.dataProductBindings);
const subjectStates = validateMapSubjectStates(value.subjectStates === undefined ? [] : value.subjectStates, 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)); const presentationProfileIds = new Set(presentationProfiles.map((profile) => profile.id));
if (dataProductBindings.some((binding) => binding.presentationProfileId && !presentationProfileIds.has(binding.presentationProfileId))) { if (dataProductBindings.some((binding) => binding.presentationProfileId && !presentationProfileIds.has(binding.presentationProfileId))) {
throw applicationError("map_data_product_presentation_profile_not_found"); throw applicationError("map_data_product_presentation_profile_not_found");
@ -571,6 +645,10 @@ function validateMapPageLayout(value) {
subjectDetailProfiles, subjectDetailProfiles,
dataProductBindings, dataProductBindings,
subjectStates, subjectStates,
referenceLayers,
inspectorOpenSections: validateMapInspectorOpenSections(
value.inspectorOpenSections === undefined ? ["map-base"] : value.inspectorOpenSections,
),
}; };
} }
@ -663,6 +741,8 @@ function defaultMapPageLayout() {
subjectDetailProfiles: structuredClone(canonicalMapSubjectDetailProfiles), subjectDetailProfiles: structuredClone(canonicalMapSubjectDetailProfiles),
dataProductBindings: [], dataProductBindings: [],
subjectStates: [], subjectStates: [],
referenceLayers: structuredClone(canonicalMapReferenceLayers),
inspectorOpenSections: ["map-base"],
}; };
} }
@ -2159,6 +2239,9 @@ const foundryMcpOperations = {
mapHeight: input.viewState.mapHeight, mapHeight: input.viewState.mapHeight,
camera: input.viewState.camera, camera: input.viewState.camera,
subjectStates: input.viewState.subjectStates, subjectStates: input.viewState.subjectStates,
inspectorOpenSections: input.viewState.inspectorOpenSections === undefined
? layout.inspectorOpenSections
: input.viewState.inspectorOpenSections,
}) }, }) },
}; };
return updatedPage; return updatedPage;
@ -2170,6 +2253,7 @@ const foundryMcpOperations = {
mapHeight: updatedPage.layout.map.mapHeight, mapHeight: updatedPage.layout.map.mapHeight,
camera: updatedPage.layout.map.camera, camera: updatedPage.layout.map.camera,
subjectStates: updatedPage.layout.map.subjectStates, 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); const pageLayoutMatch = url.pathname.match(/^\/api\/page-layouts\/([a-z0-9-]+)$/i);
if (pageLayoutMatch && request.method === "GET") { if (pageLayoutMatch && request.method === "GET") {
try { 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) { } catch (error) {
if (error?.code === "ENOENT") return json(response, 200, null); if (error?.code === "ENOENT") return json(response, 200, null);
throw error; throw error;

View File

@ -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.required, ["id", "version", "title", "semanticTypes", "defaultTabId", "tabs"]);
assert.deepEqual( assert.deepEqual(
subjectDetailTool.inputSchema.properties.profile.properties.tabs.items.properties.sections.items.properties.fields.items.properties.format.enum, 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"); const mapSettingsTool = foundryList.result.tools.find((tool) => tool.name === "foundry_update_map_page_settings");
assert.ok(mapSettingsTool); 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); 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"); const mapViewStateTool = foundryList.result.tools.find((tool) => tool.name === "foundry_save_map_page_view_state");
assert.ok(mapViewStateTool); 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; const subjectStateSchema = mapViewStateTool.inputSchema.properties.viewState.properties.subjectStates.items;
assert.deepEqual(subjectStateSchema.required, ["bindingId", "visible", "filters", "window"]); assert.deepEqual(subjectStateSchema.required, ["bindingId", "visible", "filters", "window"]);
assert.equal(subjectStateSchema.properties.filters.additionalProperties.items.type, "string"); assert.equal(subjectStateSchema.properties.filters.additionalProperties.items.type, "string");

View File

@ -688,6 +688,13 @@ const tools = [
}, },
}, },
subjectStates: { type: "array", maxItems: 64, items: mapSubjectStateInputSchema }, 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 },
},
}, },
}, },
}, },