feat(foundry): close the operational map data loop

This commit is contained in:
Codex
2026-07-20 20:45:05 +03:00
parent aac44d057f
commit a02c3ff3dd
44 changed files with 4158 additions and 811 deletions
+443 -56
View File
@@ -1,6 +1,6 @@
import { forwardRef, lazy, Suspense, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type CSSProperties, type PointerEvent } from "react";
import { Button, Checker, ColorField, ControlRow, GlassSurface, Icon, IconButton, Inspector, InspectorSelectField, RangeControl, Window } from "@nodedc/ui-react";
import type { SelectOption } from "@nodedc/ui-react";
import { Button, Checker, ColorField, ControlRow, Dropdown, Icon, IconButton, Inspector, InspectorSelectField, RangeControl, Window, WorkspaceWindow } from "@nodedc/ui-react";
import type { SelectOption, WorkspaceWindowRect } from "@nodedc/ui-react";
import type {
CameraSpiralState,
CesiumMapRendererHandle,
@@ -10,6 +10,19 @@ import type {
MapProviderStatus,
} from "./CesiumMapRenderer.js";
import { mapRuntimeEntityId, useMapDataProductRuntime } from "./useMapDataProductRuntime.js";
import {
compareMapRuntimeFacts,
mapFactMatchesFilters,
mapPresentationFacetCounts,
mapPresentationProfileForFact,
mapRuntimeDisplayLabel,
mapRuntimeFactIsRenderable,
normalizeClientMapPresentationProfiles,
resolveMapPresentationClass,
toggleMapPresentationFacetSelection,
type MapPresentationFilters,
type MapPresentationProfile,
} from "./mapPresentationProfile.js";
import {
CAMERA_SURVEY_PRESETS,
DEFAULT_CAMERA_SURVEY_PRESET,
@@ -18,8 +31,6 @@ import {
findCameraSurveyPreset,
type CameraSurveySelection,
} from "./mapCameraPresets.js";
import sceneFixture from "../../../registry/fixtures/map/map-operational-v0.1.json";
const CesiumMapRenderer = lazy(() => import("./CesiumMapRenderer.js").then((module) => ({ default: module.CesiumMapRenderer })));
type PreviewFeatures = { inspector?: boolean; toolbar?: boolean; assistant?: boolean };
@@ -92,11 +103,29 @@ export type MapPinBinding = {
*/
export type MapDataProductBinding = {
id: string;
displayName?: string;
order?: number;
dataProductId: string;
slotId: string;
delivery: "snapshot+patch";
semanticTypes: string[];
fieldProjection: string[];
presentationProfileId?: string;
};
export type MapSubjectWindowState = {
open: boolean;
rect: WorkspaceWindowRect;
maximized: boolean;
zIndex: number;
};
export type MapSubjectState = {
bindingId: string;
visible: boolean;
/** Missing facet means unconstrained; an explicit empty list means no matches. */
filters: Record<string, string[]>;
window: MapSubjectWindowState;
};
export type MapPageLayout = {
@@ -106,7 +135,9 @@ export type MapPageLayout = {
mapHeight: number;
camera: MapCameraView;
pinBindings: MapPinBinding[];
presentationProfiles: MapPresentationProfile[];
dataProductBindings: MapDataProductBinding[];
subjectStates: MapSubjectState[];
savedAt?: string;
};
@@ -123,9 +154,9 @@ const initialMapSettings: MapPageSettings = {
terrainExaggeration: 1,
monochrome: false,
monochromeColor: "#15151b",
imageryGamma: 100,
imageryHue: 0,
imageryAlpha: 100,
imageryGamma: 57,
imageryHue: 13,
imageryAlpha: 27,
globeColor: "#15151b",
backgroundColor: "#08090d",
atmosphereEnabled: false,
@@ -140,11 +171,11 @@ const initialMapSettings: MapPageSettings = {
shadowsEnabled: true,
buildingsVisible: true,
buildingsColor: "#a27aff",
buildingsOpacity: 0.82,
buildingsDetail: 16,
imageryBrightness: 100,
imageryContrast: 100,
imagerySaturation: 100,
buildingsOpacity: 1,
buildingsDetail: 4,
imageryBrightness: 118,
imageryContrast: 102,
imagerySaturation: 0,
gridVisible: true,
gridLodEnabled: true,
gridHeightMeters: 500,
@@ -167,14 +198,28 @@ const initialMapSettings: MapPageSettings = {
// first move-end event. It makes the page contract immediately saveable;
// the renderer replaces it with the exact live camera as soon as it is ready.
const fallbackMapCamera: MapCameraView = {
longitude: sceneFixture.viewport.center[0],
latitude: sceneFixture.viewport.center[1],
height: sceneFixture.viewport.range,
heading: (sceneFixture.viewport.heading * Math.PI) / 180,
pitch: (sceneFixture.viewport.pitch * Math.PI) / 180,
longitude: 37.618423,
latitude: 55.751244,
height: 40_000,
heading: 0,
pitch: -0.9,
roll: 0,
};
export function createDefaultMapPageLayout(expanded = false): MapPageLayout {
return {
schemaVersion: 1,
pageId: "map",
settings: structuredClone(initialMapSettings),
mapHeight: expanded ? 620 : 470,
camera: { ...fallbackMapCamera },
pinBindings: [],
presentationProfiles: [],
dataProductBindings: [],
subjectStates: [],
};
}
const initialProviderStatus: MapProviderStatus = {
imagery: "loading",
terrain: "loading",
@@ -189,6 +234,40 @@ const providerStateLabel: Record<MapProviderStatus["imagery"], string> = {
"not-configured": "не настроен",
};
function defaultSubjectWindowState(index: number): MapSubjectWindowState {
return {
open: false,
rect: {
x: 24 + (index % 5) * 28,
y: 56 + (index % 5) * 28,
width: 280,
height: 260,
},
maximized: false,
zIndex: 20 + index,
};
}
const defaultLayersWindowRect: WorkspaceWindowRect = {
x: 1024,
y: 72,
width: 336,
height: 500,
};
function initialSubjectState(bindings: MapDataProductBinding[], saved: MapSubjectState[] | undefined) {
const savedByBinding = new Map((saved ?? []).map((state) => [state.bindingId, state]));
return Object.fromEntries(bindings.map((binding, index) => {
const state = savedByBinding.get(binding.id);
return [binding.id, state ?? {
bindingId: binding.id,
visible: true,
filters: {},
window: defaultSubjectWindowState(index),
}];
})) as Record<string, MapSubjectState>;
}
const logarithmicControlValue = (value: number) => Math.log10(Math.max(Number.MIN_VALUE, value));
const valueFromLogarithmicControl = (value: number) => Math.max(1, Math.round(10 ** value));
const formatMetricDistance = (value: number) => value >= 1000
@@ -222,13 +301,14 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
applicationId?: string;
pageId?: string;
}>(function MapFixturePreview({ features = { inspector: true, toolbar: true, assistant: false }, expanded = false, initialLayout = null, applicationId, pageId }, ref) {
const fixtureSelectable = useMemo(() => [
...sceneFixture.scene.movingObjects.map((entity) => ({ id: entity.id, title: entity.label.text, kind: entity.objectType, status: entity.status })),
...sceneFixture.scene.stations.map((entity) => ({ id: entity.id, title: entity.label.text, kind: `${entity.stationType} station`, status: undefined })),
], []);
const [selectedId, setSelectedId] = useState(sceneFixture.selection.entityId ?? fixtureSelectable[0]?.id);
const workspaceRef = useRef<HTMLDivElement>(null);
const [selectedId, setSelectedId] = useState<string>();
const [inspectorOpen, setInspectorOpen] = useState(false);
const [layersOpen, setLayersOpen] = useState(false);
const [layersWindowRect, setLayersWindowRect] = useState<WorkspaceWindowRect>(defaultLayersWindowRect);
const [layersWindowMaximized, setLayersWindowMaximized] = useState(false);
const [layersWindowZIndex, setLayersWindowZIndex] = useState(12);
const [layersWindowActive, setLayersWindowActive] = useState(false);
const [toolbarOpen, setToolbarOpen] = useState(Boolean(features.toolbar));
const [assistantOpen, setAssistantOpen] = useState(false);
const [mapSettings, setMapSettings] = useState<MapPageSettings>(() => ({
@@ -254,31 +334,91 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
// Map pin bindings belong to the application page instance. They are kept
// intact when a human changes camera or visual settings and presses Save.
const [pinBindings] = useState<MapPinBinding[]>(() => initialLayout?.pinBindings ?? []);
// Presentation profiles are application/page-owned, versioned map.style_profile
// values. A human camera/settings save must preserve profiles provisioned by MCP.
const [presentationProfiles, setPresentationProfiles] = useState<MapPresentationProfile[]>(() => (
normalizeClientMapPresentationProfiles(initialLayout?.presentationProfiles ?? [])
));
// Data-product bindings are provisioned by Foundry MCP / Platform and do
// not belong to the visual inspector. Preserve them verbatim when a human
// edits camera or presentation settings and saves the page layout.
const [dataProductBindings] = useState<MapDataProductBinding[]>(() => initialLayout?.dataProductBindings ?? []);
const [subjectStates, setSubjectStates] = useState<Record<string, MapSubjectState>>(() => (
initialSubjectState(initialLayout?.dataProductBindings ?? [], initialLayout?.subjectStates)
));
const [activeSubjectBindingId, setActiveSubjectBindingId] = useState<string>();
const presentationFilters = useMemo<MapPresentationFilters>(() => Object.fromEntries(
Object.entries(subjectStates).map(([bindingId, state]) => [bindingId, {
visible: state.visible,
facets: state.filters,
}]),
), [subjectStates]);
const runtimeBindings = useMapDataProductRuntime({
applicationId,
pageId,
bindings: dataProductBindings,
enabled: Boolean(applicationId && pageId),
});
const selectable = useMemo(() => [
...fixtureSelectable,
...runtimeBindings.flatMap((binding) => binding.facts.map((fact) => {
const attributes = fact.attributes;
const label = [attributes.label, attributes.name, attributes.title, attributes.subject_id]
.find((value) => typeof value === "string" && value.trim());
const status = fact.presentationStatus || (typeof attributes.status === "string" ? attributes.status : undefined);
return {
id: mapRuntimeEntityId(binding.bindingId, fact),
title: typeof label === "string" ? label : fact.sourceId,
kind: fact.semanticType,
status,
};
})),
], [fixtureSelectable, runtimeBindings]);
const selectable = useMemo(() => (
runtimeBindings.flatMap((binding) => {
const bindingConfig = dataProductBindings.find((candidate) => candidate.id === binding.bindingId);
const facts = [...binding.facts];
const primaryProfile = mapPresentationProfileForFact(
presentationProfiles,
bindingConfig?.presentationProfileId,
bindingConfig?.semanticTypes[0] ?? facts[0]?.semanticType ?? "",
);
if (primaryProfile) facts.sort((left, right) => compareMapRuntimeFacts(left, right, primaryProfile));
return facts.map((fact) => {
const profile = mapPresentationProfileForFact(presentationProfiles, bindingConfig?.presentationProfileId, fact.semanticType);
const presentationClass = profile ? resolveMapPresentationClass(fact, profile) : undefined;
return {
id: mapRuntimeEntityId(binding.bindingId, fact),
title: mapRuntimeDisplayLabel(fact, profile),
kind: fact.semanticType,
status: presentationClass?.label ?? fact.presentationStatus,
};
});
})
), [dataProductBindings, presentationProfiles, runtimeBindings]);
const presentationSummaries = useMemo(() => [...dataProductBindings]
.sort((left, right) => (left.order ?? 0) - (right.order ?? 0) || left.id.localeCompare(right.id))
.flatMap((bindingConfig) => {
const binding = runtimeBindings.find((candidate) => candidate.bindingId === bindingConfig.id);
const facts = binding?.facts ?? [];
const semanticType = bindingConfig.semanticTypes[0] ?? facts[0]?.semanticType ?? "";
const profile = mapPresentationProfileForFact(presentationProfiles, bindingConfig?.presentationProfileId, semanticType);
if (!profile) return [];
return [{
bindingId: bindingConfig.id,
displayName: bindingConfig.displayName?.trim() || profile.title || bindingConfig.id,
profile,
total: facts.length,
counts: mapPresentationFacetCounts(facts, profile),
}];
}), [dataProductBindings, presentationProfiles, runtimeBindings]);
const filteredTargets = useMemo(() => runtimeBindings.flatMap((binding) => {
const bindingConfig = dataProductBindings.find((candidate) => candidate.id === binding.bindingId);
return binding.facts.flatMap((fact) => {
const profile = mapPresentationProfileForFact(
presentationProfiles,
bindingConfig?.presentationProfileId,
fact.semanticType,
);
if (!profile || !mapFactMatchesFilters(fact, profile, presentationFilters, binding.bindingId)) return [];
const presentationClass = resolveMapPresentationClass(fact, profile);
return [{
bindingId: binding.bindingId,
entityId: mapRuntimeEntityId(binding.bindingId, fact),
title: mapRuntimeDisplayLabel(fact, profile),
status: presentationClass?.label ?? "",
renderable: mapRuntimeFactIsRenderable(fact, profile),
}];
});
}).sort((left, right) => left.title.localeCompare(right.title, "ru")), [dataProductBindings, presentationFilters, presentationProfiles, runtimeBindings]);
const visibleTargetEntityIds = useMemo(() => (
filteredTargets.filter((target) => target.renderable).map((target) => target.entityId)
), [filteredTargets]);
// The header Save action can be pressed immediately after Cesium finishes
// constructing the scene. Keep the last camera synchronously as well as in
// state, so the imperative page-layout contract never waits for React's
@@ -481,9 +621,86 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
mapHeight: Math.round(mapHeight),
camera: mapRendererRef.current?.getCameraView() ?? mapCameraRef.current ?? mapCamera,
pinBindings,
presentationProfiles,
dataProductBindings,
subjectStates: dataProductBindings.map((binding) => subjectStates[binding.id] ?? {
bindingId: binding.id,
visible: true,
filters: {},
window: defaultSubjectWindowState(0),
}),
}),
}), [dataProductBindings, mapCamera, mapHeight, mapSettings, pinBindings]);
}), [dataProductBindings, mapCamera, mapHeight, mapSettings, pinBindings, presentationProfiles, subjectStates]);
const updateSubjectState = (bindingId: string, update: (state: MapSubjectState) => MapSubjectState) => {
setSubjectStates((current) => {
const index = dataProductBindings.findIndex((binding) => binding.id === bindingId);
const state = current[bindingId] ?? {
bindingId,
visible: true,
filters: {},
window: defaultSubjectWindowState(Math.max(0, index)),
};
return { ...current, [bindingId]: update(state) };
});
};
const togglePresentationFilter = (bindingId: string, field: string, value: string) => {
updateSubjectState(bindingId, (state) => {
const filters = state.visible ? state.filters : {};
return {
...state,
visible: true,
filters: toggleMapPresentationFacetSelection(filters, field, value),
};
});
};
const openSubjectWindow = (bindingId: string) => {
const nextZIndex = Math.max(20, layersWindowZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1;
updateSubjectState(bindingId, (state) => ({
...state,
window: { ...state.window, open: true, zIndex: nextZIndex },
}));
setLayersWindowActive(false);
setActiveSubjectBindingId(bindingId);
};
const closeSubjectWindow = (bindingId: string) => {
updateSubjectState(bindingId, (state) => ({ ...state, window: { ...state.window, open: false } }));
setActiveSubjectBindingId((current) => current === bindingId ? undefined : current);
};
const activateLayersWindow = () => {
const nextZIndex = Math.max(20, layersWindowZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1;
setLayersWindowZIndex(nextZIndex);
setLayersWindowActive(true);
setActiveSubjectBindingId(undefined);
};
const toggleLayersWindow = () => {
if (layersOpen) {
setLayersOpen(false);
setLayersWindowActive(false);
return;
}
setLayersOpen(true);
activateLayersWindow();
};
const updatePresentationProfile = (
profileId: string,
update: (profile: MapPresentationProfile) => MapPresentationProfile,
) => setPresentationProfiles((current) => current.map((profile) => (
profile.id === profileId ? update(profile) : profile
)));
const updatePresentationStyle = (profileId: string, styleId: string, patch: Partial<MapPresentationProfile["styles"][number]>) => {
updatePresentationProfile(profileId, (profile) => ({
...profile,
styles: profile.styles.map((style) => style.id === styleId ? { ...style, ...patch } : style),
}));
};
const handleSelect = useCallback((entityId: string) => {
if (!selectable.some((entity) => entity.id === entityId)) return;
@@ -681,6 +898,57 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
<RangeControl label="Детализация" value={mapSettings.buildingsDetail} min={4} max={32} formatValue={(value) => `SSE ${value}`} onChange={(buildingsDetail) => updateMapSettings({ buildingsDetail })} />
</>,
},
...presentationProfiles.flatMap((profile) => [
{
id: `map-target-${profile.id}`,
label: "Таргет",
description: profile.title,
group: "Таргеты",
content: <>
<small className="catalog-map-inspector__note">Профиль принадлежит этой странице Application и управляется тем же provider-neutral MCP-контрактом. Исходный API в настройках отсутствует.</small>
<RangeControl label="Высота таргета" value={profile.target.stemHeightMeters} min={100} max={10_000} step={50} formatValue={(value) => `${value} м`} onChange={(stemHeightMeters) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, stemHeightMeters } }))} />
<RangeControl label="Размер головки" value={profile.target.headSizePx} min={1} max={32} step={1} formatValue={(value) => `${value} px`} onChange={(headSizePx) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, headSizePx } }))} />
<RangeControl label="Толщина стержня" value={profile.target.stemWidthPx} min={0.25} max={12} step={0.25} formatValue={(value) => `${value} px`} onChange={(stemWidthPx) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, stemWidthPx } }))} />
<InspectorSelectField
label="Подпись"
value={profile.label.mode}
options={[
{ value: "subject_id", label: "ID", description: "Стабильный идентификатор сущности" },
{ value: "attributes", label: "Имя", description: "Первое доступное display-поле" },
{ value: "none", label: "Нет", description: "Не показывать плашку" },
]}
onChange={(mode) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, mode } }))}
/>
<RangeControl label="Размер подписи" value={profile.label.sizePx} min={8} max={32} step={1} formatValue={(value) => `${value} px`} onChange={(sizePx) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, sizePx } }))} />
<RangeControl label="Смещение подписи X" value={profile.label.offsetX} min={-100} max={100} step={1} formatValue={(value) => `${value} px`} onChange={(offsetX) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, offsetX } }))} />
<RangeControl label="Смещение подписи Y" value={profile.label.offsetY} min={-100} max={100} step={1} formatValue={(value) => `${value} px`} onChange={(offsetY) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, offsetY } }))} />
<RangeControl label="Скрывать подпись выше" value={profile.label.hideCameraHeightMeters} min={1_000} max={500_000} step={1_000} formatValue={(value) => `${Math.round(value / 1_000)} км`} onChange={(hideCameraHeightMeters) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, hideCameraHeightMeters } }))} />
<RangeControl label="Скрывать таргет выше" value={profile.target.hideCameraHeightMeters} min={1_000} max={500_000} step={1_000} formatValue={(value) => `${Math.round(value / 1_000)} км`} onChange={(hideCameraHeightMeters) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, hideCameraHeightMeters } }))} />
<ControlRow label="Фон плашки"><ColorField label="Цвет фона подписи" value={profile.label.backgroundColor} onChange={(backgroundColor) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, backgroundColor } }))} /></ControlRow>
<RangeControl label="Прозрачность плашки" value={Math.round(profile.label.backgroundOpacity * 100)} min={0} max={100} step={1} formatValue={(value) => `${value}%`} onChange={(value) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, backgroundOpacity: value / 100 } }))} />
<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 } }))} />
</>,
},
{
id: `map-state-classes-${profile.id}`,
label: "Классы состояния",
description: "нормализованные фасеты онтологии",
group: "Таргеты",
content: <>
<small className="catalog-map-inspector__note">Цвета назначены семантическим классам после нормализации данных. Здесь нет названий provider-статусов и привязки к транспорту.</small>
{profile.styles.map((style) => {
const classLabels = profile.classes.filter((item) => item.styleId === style.id).map((item) => item.label);
const label = classLabels.length ? classLabels.join(" · ") : style.id;
return <div className="catalog-map-inspector__style" key={style.id}>
<ControlRow label={label}><ColorField label={`Цвет: ${label}`} value={style.color} onChange={(color) => updatePresentationStyle(profile.id, style.id, { color })} /></ControlRow>
<RangeControl label={`${label}: прозрачность`} value={Math.round(style.opacity * 100)} min={0} max={100} step={1} formatValue={(value) => `${value}%`} onChange={(value) => updatePresentationStyle(profile.id, style.id, { opacity: value / 100 })} />
</div>;
})}
</>,
},
]),
{
id: "map-grid",
label: "Сетка и LOD",
@@ -823,6 +1091,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
return (
<div
ref={workspaceRef}
className={`catalog-map-fixture${expanded ? " catalog-map-fixture--expanded" : ""}`}
style={{ "--catalog-map-height": `${mapHeight}px` } as CSSProperties}
aria-label="Map Page Cesium adapter"
@@ -841,43 +1110,160 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
initialCamera={mapCamera ?? undefined}
presentation={presentation}
runtimeBindings={runtimeBindings}
presentationProfiles={presentationProfiles}
presentationFilters={presentationFilters}
/>
</Suspense>
<div className="catalog-map-fixture__actions">
<IconButton label="Настройки карты" aria-pressed={inspectorOpen} data-active={inspectorOpen || undefined} onClick={() => setInspectorOpen(true)}><Icon name="settings" /></IconButton>
<IconButton label="Слои карты" aria-pressed={layersOpen} data-active={layersOpen || undefined} onClick={() => setLayersOpen((value) => !value)}><Icon name="grid" /></IconButton>
<IconButton label="Слои карты" aria-pressed={layersOpen} data-active={layersOpen || undefined} onClick={toggleLayersWindow}><Icon name="grid" /></IconButton>
{features.toolbar ? <IconButton label="Toolbar" aria-pressed={toolbarOpen} data-active={toolbarOpen || undefined} onClick={() => setToolbarOpen((value) => !value)}><Icon name="panel" /></IconButton> : null}
{features.assistant ? <IconButton label="Assistant" aria-pressed={assistantOpen} data-active={assistantOpen || undefined} onClick={() => setAssistantOpen((value) => !value)}><Icon name="apps" /></IconButton> : null}
</div>
{layersOpen ? (
<GlassSurface className="catalog-map-fixture__layers" tone="strong" radius="card" padding="sm" aria-label="Настройки слоёв карты">
<div className="catalog-map-fixture__layers-head"><strong>Слои карты</strong><IconButton label="Закрыть слои" onClick={() => setLayersOpen(false)}><Icon name="close" /></IconButton></div>
<div className="catalog-map-fixture__provider">
<strong>Cesium World Imagery</strong>
<small>официальный live provider · imagery: {providerStateLabel[providerStatus.imagery]} · terrain: {providerStateLabel[providerStatus.terrain]}</small>
<WorkspaceWindow
boundsRef={workspaceRef}
rect={layersWindowRect}
onRectChange={setLayersWindowRect}
maximized={layersWindowMaximized}
onMaximizedChange={setLayersWindowMaximized}
onActivate={activateLayersWindow}
onClose={() => {
setLayersOpen(false);
setLayersWindowActive(false);
}}
title="Слои карты"
active={layersWindowActive}
zIndex={layersWindowZIndex}
minWidth={320}
minHeight={360}
className="catalog-map-fixture__layers catalog-map-fixture__map-glass-window"
aria-label="Настройки слоёв карты"
>
<div className="catalog-map-fixture__layers-content">
<div className="catalog-map-fixture__provider">
<strong>Cesium World Imagery</strong>
<small>официальный live provider · imagery: {providerStateLabel[providerStatus.imagery]} · terrain: {providerStateLabel[providerStatus.terrain]}</small>
</div>
{Object.entries(providerStatus.errors).map(([provider, error]) => <small key={provider} className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{provider}: {error}</small>)}
<Checker checked={mapSettings.terrainEnabled} label="Terrain" onChange={(terrainEnabled) => updateMapSettings({ terrainEnabled })} />
<Checker checked={mapSettings.buildingsVisible} label="3D здания" onChange={(buildingsVisible) => updateMapSettings({ buildingsVisible })} />
<Checker checked={mapSettings.gridVisible} label="Планетарная сетка" onChange={(gridVisible) => updateMapSettings({ gridVisible })} />
<small className="catalog-map-inspector__note">Live Cache: {liveCacheSummary}</small>
{transportDiagnostic ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{transportDiagnostic}</small> : null}
{gatewayHealthAge ? <small className="catalog-map-inspector__note">{gatewayHealthAge}</small> : null}
{gatewayCheckState === "error" || gatewayCheckState === "stale" ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{gatewayCheckError}</small> : null}
<Checker className="catalog-map-fixture__cache-toggle" checked={mapSettings.cacheEnabled} label="Кэшировать live-данные" onChange={setCacheEnabled} />
<Checker className="catalog-map-fixture__cache-toggle" checked={mapSettings.cacheNoOverwrite} disabled={!mapSettings.cacheEnabled} label="Не перезаписывать cache" onChange={setCacheNoOverwrite} />
</div>
{Object.entries(providerStatus.errors).map(([provider, error]) => <small key={provider} className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{provider}: {error}</small>)}
<Checker checked={mapSettings.terrainEnabled} label="Terrain" onChange={(terrainEnabled) => updateMapSettings({ terrainEnabled })} />
<Checker checked={mapSettings.buildingsVisible} label="3D здания" onChange={(buildingsVisible) => updateMapSettings({ buildingsVisible })} />
<Checker checked={mapSettings.gridVisible} label="Планетарная сетка" onChange={(gridVisible) => updateMapSettings({ gridVisible })} />
<small className="catalog-map-inspector__note">Live Cache: {liveCacheSummary}</small>
{transportDiagnostic ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{transportDiagnostic}</small> : null}
{gatewayHealthAge ? <small className="catalog-map-inspector__note">{gatewayHealthAge}</small> : null}
{gatewayCheckState === "error" || gatewayCheckState === "stale" ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{gatewayCheckError}</small> : null}
<Checker className="catalog-map-fixture__cache-toggle" checked={mapSettings.cacheEnabled} label="Кэшировать live-данные" onChange={setCacheEnabled} />
<Checker className="catalog-map-fixture__cache-toggle" checked={mapSettings.cacheNoOverwrite} disabled={!mapSettings.cacheEnabled} label="Не перезаписывать cache" onChange={setCacheNoOverwrite} />
</GlassSurface>
</WorkspaceWindow>
) : null}
{toolbarOpen ? (
<div className="catalog-map-fixture__toolbar" aria-label="Map toolbar">
<IconButton label="Обзор"><Icon name="globe" /></IconButton>
<Dropdown
placement="top-start"
width={320}
minWidth={240}
offset={10}
surfaceRole="menu"
surfaceClassName="catalog-map-fixture__objects-menu nodedc-map-glass"
trigger={({ open, toggle, setTriggerRef, surfaceId }) => (
<IconButton ref={setTriggerRef} label="Объекты" aria-controls={surfaceId} aria-expanded={open} aria-pressed={open} data-active={open || undefined} onClick={toggle}><Icon name="target" /></IconButton>
)}
>
{({ close }) => (
<div className="catalog-map-fixture__objects-menu-list">
<div className="catalog-map-fixture__objects-menu-head">
<strong>Объекты</strong>
<small>{presentationSummaries.length} {presentationSummaries.length === 1 ? "группа" : "групп"}</small>
</div>
{presentationSummaries.map((summary) => {
const state = subjectStates[summary.bindingId];
const visibleCount = filteredTargets.filter((target) => target.bindingId === summary.bindingId && target.renderable).length;
return (
<button
type="button"
role="menuitem"
className="catalog-map-fixture__objects-menu-item"
key={summary.bindingId}
data-open={state?.window.open || undefined}
onClick={() => {
openSubjectWindow(summary.bindingId);
close();
}}
>
<span>{summary.displayName}</span>
<small>{state?.visible === false || visibleCount === 0 ? "на карте: 0" : `на карте: ${visibleCount}`}{state?.window.open ? " · окно открыто" : ""}</small>
</button>
);
})}
{!presentationSummaries.length ? <small className="catalog-map-fixture__objects-menu-empty">Нет подключённых объектов.</small> : null}
</div>
)}
</Dropdown>
<IconButton label="Обзор объектов" onClick={() => mapRendererRef.current?.fitRuntimeEntities(visibleTargetEntityIds)}><Icon name="globe" /></IconButton>
<IconButton label="Поиск"><Icon name="search" /></IconButton>
</div>
) : null}
{presentationSummaries.map((summary) => {
const state = subjectStates[summary.bindingId];
if (!state?.window.open) return null;
const visibleCount = filteredTargets.filter((target) => target.bindingId === summary.bindingId && target.renderable).length;
return (
<WorkspaceWindow
key={summary.bindingId}
boundsRef={workspaceRef}
rect={state.window.rect}
onRectChange={(rect) => updateSubjectState(summary.bindingId, (current) => ({
...current,
window: { ...current.window, rect },
}))}
maximized={state.window.maximized}
onMaximizedChange={(maximized) => updateSubjectState(summary.bindingId, (current) => ({
...current,
window: { ...current.window, maximized },
}))}
onActivate={() => openSubjectWindow(summary.bindingId)}
onClose={() => closeSubjectWindow(summary.bindingId)}
title={summary.displayName}
subtitle={`${summary.total} всего · ${visibleCount} на карте`}
active={activeSubjectBindingId === summary.bindingId}
zIndex={state.window.zIndex}
minWidth={240}
minHeight={220}
className="catalog-map-fixture__subject-window catalog-map-fixture__map-glass-window"
>
<div className="catalog-map-fixture__target-filters">
<section aria-label={`${summary.displayName}: фильтры и счётчики`}>
<div className="catalog-map-fixture__target-filter-list">
{summary.profile.facets.filter((facet) => facet.counter || facet.filterable).flatMap((facet) => (
facet.values.map((item) => {
const active = state.filters[facet.field]?.includes(item.value) ?? false;
return (
<button
type="button"
key={`${facet.field}:${item.value}`}
aria-pressed={active}
data-active={active || undefined}
disabled={!facet.filterable}
onClick={() => togglePresentationFilter(summary.bindingId, facet.field, item.value)}
>
{item.label} <span>{summary.counts[facet.field]?.[item.value] ?? 0}</span>
</button>
);
})
))}
</div>
</section>
</div>
</WorkspaceWindow>
);
})}
{assistantOpen ? <div className="catalog-map-fixture__assistant"><strong>NODE.DC Assistant</strong><span>Контекст выбранной сущности готов к передаче.</span></div> : null}
<button type="button" className="catalog-map-fixture__resize" aria-label="Изменить высоту карты" onPointerDown={startResize}><span /></button>
@@ -890,6 +1276,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
closeOnBackdrop={false}
lockBodyScroll={false}
trapFocus={false}
className="catalog-map-fixture__map-settings-window"
onClose={() => setInspectorOpen(false)}
>
<Inspector sections={inspectorSections} defaultOpen={["map-base"]} singleOpen />