feat(foundry): refine map application controls

This commit is contained in:
Codex
2026-08-09 14:24:18 +03:00
parent 7025fd20b5
commit beb84f6de6
18 changed files with 633 additions and 186 deletions
+114 -137
View File
@@ -1,5 +1,6 @@
import { forwardRef, lazy, Suspense, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent } from "react";
import { Button, Checker, ColorField, ControlRow, Dropdown, Icon, IconButton, Inspector, InspectorSelectField, RangeControl, SegmentedControl, WorkspaceWindow } from "@nodedc/ui-react";
import { createPortal } from "react-dom";
import { ApplicationSidePanel, Button, Checker, ColorField, ControlRow, Dropdown, Icon, IconButton, Inspector, InspectorSelectField, RangeControl, SegmentedControl, WorkspaceWindow } from "@nodedc/ui-react";
import type { SelectOption, WorkspaceWindowRect } from "@nodedc/ui-react";
import type {
CameraSpiralState,
@@ -461,7 +462,7 @@ export type MapSubjectWindowState = {
zIndex: number;
};
type MapWorkspaceWindowId = "settings" | "layers" | "sector" | "subject-card" | `binding:${string}`;
type MapWorkspaceWindowId = "sector" | "subject-card" | `binding:${string}`;
export type MapSubjectState = {
bindingId: string;
@@ -641,20 +642,6 @@ function defaultSubjectWindowState(index: number): MapSubjectWindowState {
};
}
const defaultLayersWindowRect: WorkspaceWindowRect = {
x: 24,
y: 72,
width: 336,
height: 500,
};
const defaultSettingsWindowRect: WorkspaceWindowRect = {
x: 930,
y: 72,
width: 420,
height: 530,
};
const defaultSectorWindowRect: WorkspaceWindowRect = {
x: 24,
y: 72,
@@ -718,7 +705,10 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
initialLayout?: MapPageLayout | null;
applicationId?: string;
pageId?: string;
}>(function MapFixturePreview({ features = { inspector: true, toolbar: true, assistant: false }, expanded = false, initialLayout = null, applicationId, pageId }, ref) {
settingsPanelHost?: HTMLElement | null;
headerActionsHost?: HTMLElement | null;
onSettingsPanelOpenChange?: (open: boolean) => void;
}>(function MapFixturePreview({ features = { inspector: true, toolbar: true, assistant: false }, expanded = false, initialLayout = null, applicationId, pageId, settingsPanelHost, headerActionsHost, onSettingsPanelOpenChange }, ref) {
const workspaceRef = useRef<HTMLDivElement>(null);
const [selectedId, setSelectedId] = useState<string>();
const [selectedGridSector, setSelectedGridSector] = useState<GridSectorSelection | null>(null);
@@ -730,16 +720,10 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
const [subjectCardTabId, setSubjectCardTabId] = useState("overview");
const [expandedFacetRows, setExpandedFacetRows] = useState<Record<string, boolean>>({});
const [inspectorOpen, setInspectorOpen] = useState(false);
const [settingsWindowRect, setSettingsWindowRect] = useState<WorkspaceWindowRect>(defaultSettingsWindowRect);
const [settingsWindowMaximized, setSettingsWindowMaximized] = useState(false);
const [settingsWindowZIndex, setSettingsWindowZIndex] = useState(13);
const [inspectorOpenSections, setInspectorOpenSections] = useState<string[]>(() => (
initialLayout?.inspectorOpenSections ?? ["map-base"]
));
const [layersOpen, setLayersOpen] = useState(false);
const [layersWindowRect, setLayersWindowRect] = useState<WorkspaceWindowRect>(defaultLayersWindowRect);
const [layersWindowMaximized, setLayersWindowMaximized] = useState(false);
const [layersWindowZIndex, setLayersWindowZIndex] = useState(12);
const [sectorWindowRect, setSectorWindowRect] = useState<WorkspaceWindowRect>(defaultSectorWindowRect);
const [sectorWindowMaximized, setSectorWindowMaximized] = useState(false);
const [sectorWindowZIndex, setSectorWindowZIndex] = useState(142);
@@ -1385,15 +1369,11 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
if (activeWorkspaceWindowId === windowId) return;
const nextZIndex = Math.max(
20,
settingsWindowZIndex,
layersWindowZIndex,
sectorWindowZIndex,
subjectCardZIndex,
...Object.values(subjectStates).map((state) => state.window.zIndex),
) + 1;
if (windowId === "settings") setSettingsWindowZIndex(nextZIndex);
else if (windowId === "layers") setLayersWindowZIndex(nextZIndex);
else if (windowId === "sector") setSectorWindowZIndex(nextZIndex);
if (windowId === "sector") setSectorWindowZIndex(nextZIndex);
else if (windowId === "subject-card") setSubjectCardZIndex(nextZIndex);
else if (windowId.startsWith("binding:")) {
const bindingId = windowId.slice("binding:".length);
@@ -1403,7 +1383,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
}));
}
setActiveWorkspaceWindowId(windowId);
}, [activeWorkspaceWindowId, layersWindowZIndex, sectorWindowZIndex, settingsWindowZIndex, subjectCardZIndex, subjectStates, updateSubjectState]);
}, [activeWorkspaceWindowId, sectorWindowZIndex, subjectCardZIndex, subjectStates, updateSubjectState]);
const clearActiveWorkspaceWindow = (windowId: MapWorkspaceWindowId) => {
setActiveWorkspaceWindowId((current) => current === windowId ? undefined : current);
@@ -1422,24 +1402,15 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
clearActiveWorkspaceWindow(`binding:${bindingId}`);
};
const activateLayersWindow = () => {
activateWorkspaceWindow("layers");
};
const closeSettingsPanel = useCallback(() => setInspectorOpen(false), []);
const toggleLayersWindow = () => {
if (layersOpen) {
setLayersOpen(false);
clearActiveWorkspaceWindow("layers");
return;
}
setLayersOpen(true);
activateLayersWindow();
};
const toggleSettingsPanel = () => setInspectorOpen((current) => !current);
const openSettingsWindow = () => {
setInspectorOpen(true);
activateWorkspaceWindow("settings");
};
useEffect(() => {
onSettingsPanelOpenChange?.(inspectorOpen && Boolean(features.inspector));
}, [features.inspector, inspectorOpen, onSettingsPanelOpenChange]);
useEffect(() => () => onSettingsPanelOpenChange?.(false), [onSettingsPanelOpenChange]);
const deactivateGridSector = () => {
setSelectedGridSector(null);
@@ -1718,6 +1689,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
label: "Подложка и terrain",
description: "provider-neutral surface",
group: "Карта",
icon: <Icon name="globe" />,
content: <>
<ControlRow label="Подложка"><strong>Cesium World Imagery</strong></ControlRow>
<small className="catalog-map-inspector__note">Текущий официальный provider. Другие provider-слои появятся только после отдельного asset-контракта Platform.</small>
@@ -1743,6 +1715,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
label: "Атмосфера и освещение",
description: "scene / color correction",
group: "Карта",
icon: <Icon name="activity" />,
content: <>
<Checker checked={mapSettings.atmosphereEnabled} label="Показывать атмосферу" onChange={(atmosphereEnabled) => updateMapSettings({ atmosphereEnabled })} />
<RangeControl label="Атмосфера: оттенок" value={mapSettings.atmosphereHue} min={-100} max={100} formatValue={(value) => `${value}%`} onChange={(atmosphereHue) => updateMapSettings({ atmosphereHue })} />
@@ -1761,6 +1734,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
label: "3D здания",
description: "3D Tiles / detail",
group: "Карта",
icon: <Icon name="building" />,
content: <>
<Checker checked={mapSettings.buildingsVisible} label="Показывать 3D здания" onChange={(buildingsVisible) => updateMapSettings({ buildingsVisible })} />
<ControlRow label="Цвет"><ColorField label="Цвет зданий" value={mapSettings.buildingsColor} onChange={(buildingsColor) => updateMapSettings({ buildingsColor })} /></ControlRow>
@@ -1777,6 +1751,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
label: referenceProfile ? profile.title : profile.target.variant === "surface-fill" ? "HGeoZone" : "Таргет",
description: profile.target.variant === "surface-fill" ? `проекция · ${profile.title}` : profile.title,
group: referenceProfile ? "Станции" : profile.target.variant === "surface-fill" ? "Слои" : "Таргеты",
icon: <Icon name={referenceProfile ? "globe" : profile.target.variant === "surface-fill" ? "grid" : "target"} />,
content: <>
<small className="catalog-map-inspector__note">Профиль принадлежит этой странице Application и управляется тем же provider-neutral MCP-контрактом. Исходный API в настройках отсутствует.</small>
{referenceLayer ? (
@@ -1831,6 +1806,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
label: "Классы состояния",
description: "нормализованные фасеты онтологии",
group: "Таргеты",
icon: <Icon name="sliders" />,
content: <>
<small className="catalog-map-inspector__note">Цвета назначены семантическим классам после нормализации данных. Здесь нет названий provider-статусов и привязки к транспорту.</small>
{profile.styles.map((style) => {
@@ -1850,6 +1826,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
label: "Сетка и LOD",
description: "first adapter control",
group: "Слои",
icon: <Icon name="grid" />,
content: <div className="catalog-map-grid-inspector">
<small className="catalog-map-inspector__note">Фиксированная московская ENU-адресация задаёт неизменные сектора на LOD 13. LOD 45 используют глобальную WGS84-гратику́лу; камера выбирает только LOD и видимую область.</small>
<Checker checked={mapSettings.gridVisible} label="Сетка" onChange={(gridVisible) => updateMapSettings({ gridVisible })} />
@@ -2108,6 +2085,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
label: "Анимация камеры",
description: "geodesic spiral survey",
group: "Камера",
icon: <Icon name="activity" />,
content: <>
<Checker checked={animationModeEnabled} label="Режим анимации" onChange={setAnimationMode} />
{animationModeEnabled ? <>
@@ -2186,6 +2164,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
label: "TileCache",
description: "Platform Map Gateway",
group: "Хранение",
icon: <Icon name="database" />,
content: <>
<small className="catalog-map-inspector__note">Общий persistent cache Platform: он не принадлежит приложению, странице или пользователю.</small>
<Checker checked={mapSettings.cacheEnabled} label="Кэшировать live-данные" onChange={setCacheEnabled} />
@@ -2211,6 +2190,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
label: "Выбранная сущность",
description: "selection contract",
group: "Данные",
icon: <Icon name="target" />,
content: <>
<ControlRow label="Сущность"><strong>{selected?.title ?? "Нет выбора"}</strong></ControlRow>
<ControlRow label="Тип"><span>{selected?.kind ?? "—"}{selected?.status ? ` · ${selected.status}` : ""}</span></ControlRow>
@@ -2218,6 +2198,31 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
},
];
const headerActions = (
<div className="catalog-map-header-actions" aria-label="Действия карты">
<IconButton className="catalog-map-header-action" label="Настройки карты" aria-pressed={inspectorOpen} data-active={inspectorOpen || undefined} onClick={toggleSettingsPanel}><Icon name="settings" /></IconButton>
{features.toolbar ? <IconButton className="catalog-map-header-action" label="Toolbar" aria-pressed={toolbarOpen} data-active={toolbarOpen || undefined} onClick={() => setToolbarOpen((value) => !value)}><Icon name="panel" /></IconButton> : null}
</div>
);
const settingsPanel = inspectorOpen && Boolean(features.inspector) ? (
<ApplicationSidePanel
eyebrow="MAP / SETTINGS"
title="Настройки карты"
description="Application-owned layout"
onClose={closeSettingsPanel}
aria-label="Настройки карты"
>
<Inspector
variant="panel"
sections={inspectorSections}
openSections={inspectorOpenSections}
singleOpen
onOpenSectionsChange={setInspectorOpenSections}
/>
</ApplicationSidePanel>
) : null;
return (
<div
ref={workspaceRef}
@@ -2225,71 +2230,33 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
style={{ "--catalog-map-height": `${mapHeight}px` } as CSSProperties}
aria-label="Map Page Cesium adapter"
>
<Suspense fallback={<div className="catalog-map-fixture__loading">Загрузка карты</div>}>
<CesiumMapRenderer
key={rendererRevision}
ref={mapRendererRef}
onSelect={handleSelect}
onGridSectorSelect={handleGridSectorSelect}
selectedGridSector={selectedGridSector}
onGatewayHealth={handleRendererGatewayHealth}
onProviderStatus={setProviderStatus}
onCameraChange={handleCameraChange}
onCacheRefreshConsumed={handleCacheRefreshConsumed}
onReadyChange={setMapRendererReady}
onSpiralStateChange={handleSpiralStateChange}
initialCamera={mapCamera ?? undefined}
presentation={presentation}
runtimeBindings={[...sectorScopedPrimaryRuntimeBindings, ...referenceRuntimeBindings]}
presentationProfiles={presentationProfiles}
presentationFilters={rendererPresentationFilters}
/>
</Suspense>
<div className="catalog-map-fixture__actions">
<IconButton label="Настройки карты" aria-pressed={inspectorOpen} data-active={inspectorOpen || undefined} onClick={openSettingsWindow}><Icon name="settings" /></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 className="catalog-map-fixture__renderer">
<Suspense fallback={<div className="catalog-map-fixture__loading">Загрузка карты</div>}>
<CesiumMapRenderer
key={rendererRevision}
ref={mapRendererRef}
onSelect={handleSelect}
onGridSectorSelect={handleGridSectorSelect}
selectedGridSector={selectedGridSector}
onGatewayHealth={handleRendererGatewayHealth}
onProviderStatus={setProviderStatus}
onCameraChange={handleCameraChange}
onCacheRefreshConsumed={handleCacheRefreshConsumed}
onReadyChange={setMapRendererReady}
onSpiralStateChange={handleSpiralStateChange}
initialCamera={mapCamera ?? undefined}
presentation={presentation}
runtimeBindings={[...sectorScopedPrimaryRuntimeBindings, ...referenceRuntimeBindings]}
presentationProfiles={presentationProfiles}
presentationFilters={rendererPresentationFilters}
/>
</Suspense>
</div>
{layersOpen ? (
<WorkspaceWindow
boundsRef={workspaceRef}
rect={layersWindowRect}
onRectChange={setLayersWindowRect}
maximized={layersWindowMaximized}
onMaximizedChange={setLayersWindowMaximized}
onActivate={activateLayersWindow}
onClose={() => {
setLayersOpen(false);
clearActiveWorkspaceWindow("layers");
}}
title="Слои карты"
active={activeWorkspaceWindowId === "layers"}
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>
</WorkspaceWindow>
{features.assistant ? (
<div className="catalog-map-fixture__actions">
<IconButton label="Assistant" aria-pressed={assistantOpen} data-active={assistantOpen || undefined} onClick={() => setAssistantOpen((value) => !value)}><Icon name="apps" /></IconButton>
</div>
) : null}
{selectedGridSector ? (
@@ -2497,6 +2464,39 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
</div>
)}
</Dropdown>
<Dropdown
placement="top-start"
width={320}
minWidth={240}
offset={10}
surfaceRole="dialog"
surfaceClassName="catalog-map-fixture__objects-menu catalog-map-fixture__layers-menu nodedc-map-glass"
onOpenChange={setLayersOpen}
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="grid" /></IconButton>
)}
>
<div className="catalog-map-fixture__layers-content" aria-label="Настройки слоёв карты">
<div className="catalog-map-fixture__objects-menu-head">
<strong>Слои карты</strong>
<small>Подложка, рельеф и визуальные слои</small>
</div>
<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>
</Dropdown>
<IconButton label="Обзор объектов" onClick={() => mapRendererRef.current?.fitRuntimeEntities(visibleTargetEntityIds)}><Icon name="globe" /></IconButton>
<IconButton
label={searchOpen ? "Закрыть поиск" : "Поиск"}
@@ -2741,35 +2741,12 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
{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>
{inspectorOpen && Boolean(features.inspector) ? (
<WorkspaceWindow
boundsRef={workspaceRef}
rect={settingsWindowRect}
onRectChange={setSettingsWindowRect}
maximized={settingsWindowMaximized}
onMaximizedChange={setSettingsWindowMaximized}
onActivate={() => activateWorkspaceWindow("settings")}
onClose={() => {
setInspectorOpen(false);
clearActiveWorkspaceWindow("settings");
}}
title="Настройки карты"
subtitle="MAP / inspector"
active={activeWorkspaceWindowId === "settings"}
zIndex={settingsWindowZIndex}
minWidth={360}
minHeight={380}
className="catalog-map-fixture__map-settings-window catalog-map-fixture__map-glass-window"
aria-label="Настройки карты"
>
<Inspector
sections={inspectorSections}
openSections={inspectorOpenSections}
singleOpen
onOpenSectionsChange={setInspectorOpenSections}
/>
</WorkspaceWindow>
) : null}
{settingsPanel
? settingsPanelHost
? createPortal(settingsPanel, settingsPanelHost)
: <div className="catalog-map-fixture__settings-panel-fallback">{settingsPanel}</div>
: null}
{headerActionsHost ? createPortal(headerActions, headerActionsHost) : null}
</div>
);
});