feat(foundry): refine map application controls
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
AppHeader,
|
||||
ApplicationPanel,
|
||||
ApplicationShell,
|
||||
ApplicationSidePanel,
|
||||
Button,
|
||||
Checker,
|
||||
ColorField,
|
||||
@@ -318,6 +319,9 @@ export function CatalogApp() {
|
||||
const [mapTemplateSaveState, setMapTemplateSaveState] = useState<"idle" | "loading" | "saving" | "saved" | "error">("loading");
|
||||
const mapTemplatePreviewRef = useRef<MapFixturePreviewHandle>(null);
|
||||
const applicationMapPreviewRef = useRef<MapFixturePreviewHandle>(null);
|
||||
const [mapSettingsPanelHost, setMapSettingsPanelHost] = useState<HTMLDivElement | null>(null);
|
||||
const [mapHeaderActionsHost, setMapHeaderActionsHost] = useState<HTMLDivElement | null>(null);
|
||||
const [mapSettingsPanelOpen, setMapSettingsPanelOpen] = useState(false);
|
||||
const [createModuleOpen, setCreateModuleOpen] = useState(false);
|
||||
const [createModuleName, setCreateModuleName] = useState("");
|
||||
const [createModuleSlug, setCreateModuleSlug] = useState("");
|
||||
@@ -355,6 +359,7 @@ export function CatalogApp() {
|
||||
const [workspaceWindowDemoOpen, setWorkspaceWindowDemoOpen] = useState(true);
|
||||
const [workspaceWindowDemoMaximized, setWorkspaceWindowDemoMaximized] = useState(false);
|
||||
const [workspaceWindowDemoRect, setWorkspaceWindowDemoRect] = useState<WorkspaceWindowRect>({ x: 24, y: 24, width: 340, height: 230 });
|
||||
const [sidePanelDemoOpen, setSidePanelDemoOpen] = useState(true);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [shareAccessOpen, setShareAccessOpen] = useState(false);
|
||||
@@ -1413,6 +1418,23 @@ export function CatalogApp() {
|
||||
</div>
|
||||
<p className="catalog-preview__explanation">Rectangle, maximize, close и stacking остаются состоянием приложения; дизайн-система владеет одинаковой bounded-геометрией и доступным управлением.</p>
|
||||
</Preview>
|
||||
<Preview title="Application side panel" note="end / push / controlled" className="catalog-preview--wide catalog-side-panel-preview">
|
||||
<div className="catalog-side-panel-demo">
|
||||
{sidePanelDemoOpen ? (
|
||||
<ApplicationSidePanel
|
||||
eyebrow="MAP / SETTINGS"
|
||||
title="Настройки карты"
|
||||
description="Theme-aware application panel"
|
||||
onClose={() => setSidePanelDemoOpen(false)}
|
||||
>
|
||||
<Inspector variant="panel" sections={environmentSections.slice(0, 3)} defaultOpen={[environmentSections[0]?.id ?? ""]} singleOpen />
|
||||
</ApplicationSidePanel>
|
||||
) : (
|
||||
<Button shape="pill" icon={<Icon name="panel" />} onClick={() => setSidePanelDemoOpen(true)}>Открыть правую панель</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="catalog-preview__explanation">Панель занимает отдельную end-колонку ApplicationShell, сдвигает рабочую область, наследует тему и не входит в z-index stack окон карты.</p>
|
||||
</Preview>
|
||||
<Preview title="Inspector" note="modeless / draggable" className="catalog-preview--wide catalog-preview--compact catalog-inspector-launcher">
|
||||
<p className="catalog-preview__explanation">Настройки приложения и Toolbar открываются в отдельном перемещаемом Inspector и сохраняются одним серверным layout.</p>
|
||||
<Button variant="primary" shape="pill" icon={<Icon name="panel" />} onClick={() => setInspectorOpen(true)}>Открыть Inspector</Button>
|
||||
@@ -1794,6 +1816,9 @@ export function CatalogApp() {
|
||||
expanded
|
||||
initialLayout={mapDesignLayout}
|
||||
features={Object.fromEntries(template.features.map((feature) => [feature.id, feature.required ? true : feature.defaultVisible]))}
|
||||
settingsPanelHost={mapSettingsPanelHost}
|
||||
headerActionsHost={mapHeaderActionsHost}
|
||||
onSettingsPanelOpenChange={setMapSettingsPanelOpen}
|
||||
/>
|
||||
<div className="catalog-page-template__actions">
|
||||
<Button variant="primary" shape="pill" icon={<Icon name="plus" />} onClick={() => { void createApplicationDraft(template); }}>Создать Application Draft</Button>
|
||||
@@ -1807,7 +1832,12 @@ export function CatalogApp() {
|
||||
title={template.title}
|
||||
description={template.description}
|
||||
>
|
||||
<MapFixturePreview features={Object.fromEntries(template.features.map((feature) => [feature.id, feature.required ? true : feature.defaultVisible]))} />
|
||||
<MapFixturePreview
|
||||
features={Object.fromEntries(template.features.map((feature) => [feature.id, feature.required ? true : feature.defaultVisible]))}
|
||||
settingsPanelHost={mapSettingsPanelHost}
|
||||
headerActionsHost={mapHeaderActionsHost}
|
||||
onSettingsPanelOpenChange={setMapSettingsPanelOpen}
|
||||
/>
|
||||
<div className="catalog-page-template__actions">
|
||||
<Button variant="primary" shape="pill" icon={<Icon name="plus" />} onClick={() => { void createApplicationDraft(template); }}>Создать Application Draft</Button>
|
||||
<span>Только утверждённые features и slots — без свободного canvas.</span>
|
||||
@@ -1860,6 +1890,9 @@ export function CatalogApp() {
|
||||
initialLayout={resolvedMapLayout}
|
||||
features={page.features}
|
||||
expanded
|
||||
settingsPanelHost={mapSettingsPanelHost}
|
||||
headerActionsHost={mapHeaderActionsHost}
|
||||
onSettingsPanelOpenChange={setMapSettingsPanelOpen}
|
||||
/>
|
||||
{applicationMode === "edit" ? (
|
||||
<SettingsCard eyebrow="PAGE SETTINGS" title={page.title} description={`${template.title} · ${template.version}`}>
|
||||
@@ -1888,6 +1921,8 @@ export function CatalogApp() {
|
||||
navigationOpen={guidelineOpen}
|
||||
contentOpen={workspace.contentOpen}
|
||||
contentExpanded={panelExpanded}
|
||||
endPanelOpen={mapSettingsPanelOpen}
|
||||
endPanel={<div ref={setMapSettingsPanelHost} className="catalog-map-settings-panel-host" />}
|
||||
header={
|
||||
<AppHeader
|
||||
brand={<img src="/nodedc-logo.svg" alt="NODE.DC" />}
|
||||
@@ -2035,6 +2070,7 @@ export function CatalogApp() {
|
||||
description={`${activePageTemplate.category} · contract ${activePageTemplate.schemaVersion} · template ${activePageTemplate.version}`}
|
||||
expanded={panelExpanded}
|
||||
onExpandedChange={workspace.setContentExpanded}
|
||||
headerTools={activePageTemplate.id === "map" ? <div ref={setMapHeaderActionsHost} className="catalog-map-header-actions-host" /> : undefined}
|
||||
utilityActions={activePageTemplate.id === "map" ? [{
|
||||
label: mapTemplateSaveState === "saving" ? "Сохраняем Map-фрагмент в Design Profile" : mapTemplateSaveState === "error" ? "Повторить сохранение Map-фрагмента" : "Сохранить дизайн Map в Design Profile",
|
||||
icon: "save",
|
||||
@@ -2053,6 +2089,7 @@ export function CatalogApp() {
|
||||
description={activeApplicationPage ? `${activeApplication.metadata.name} · ${activeApplicationPage.template.id}@${activeApplicationPage.template.version}` : `/${activeApplication.metadata.slug} · manifest ${activeApplication.schemaVersion}`}
|
||||
expanded={panelExpanded}
|
||||
onExpandedChange={workspace.setContentExpanded}
|
||||
headerTools={activeApplicationPage?.template.id === "map" ? <div ref={setMapHeaderActionsHost} className="catalog-map-header-actions-host" /> : undefined}
|
||||
utilityActions={[{
|
||||
label: applicationSaveState === "saving" ? "Сохранение…" : applicationSaveState === "error" ? "Повторить сохранение" : "Сохранить",
|
||||
icon: "save",
|
||||
|
||||
@@ -3080,8 +3080,6 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||
const picked = viewer?.scene.pick(movement.position);
|
||||
const pickedId = picked?.id;
|
||||
if (pickedId instanceof Entity && pickedId.id && !String(pickedId.id).startsWith("grid/")) {
|
||||
gridController?.setSelection(null);
|
||||
onGridSectorSelectRef.current?.(null);
|
||||
onSelectRef.current?.(pickedId.id);
|
||||
return;
|
||||
}
|
||||
@@ -3091,8 +3089,6 @@ export const CesiumMapRenderer = forwardRef<CesiumMapRendererHandle, {
|
||||
&& (pickedId as Partial<HGeoZonePickId>).kind === "nodedc-hgeozone"
|
||||
&& typeof (pickedId as Partial<HGeoZonePickId>).entityId === "string"
|
||||
) {
|
||||
gridController?.setSelection(null);
|
||||
onGridSectorSelectRef.current?.(null);
|
||||
onSelectRef.current?.((pickedId as HGeoZonePickId).entityId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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 1–3. LOD 4–5 используют глобальную 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>
|
||||
);
|
||||
});
|
||||
|
||||
+83
-16
@@ -568,6 +568,11 @@ textarea {
|
||||
|
||||
.catalog-map-fixture--expanded { min-height: 22.5rem; }
|
||||
|
||||
.catalog-map-fixture__renderer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.catalog-map-fixture__loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -612,6 +617,36 @@ textarea {
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.catalog-map-header-actions-host,
|
||||
.catalog-map-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.catalog-map-header-action.nodedc-icon-button {
|
||||
width: 2.92rem;
|
||||
height: 2.92rem;
|
||||
flex-basis: 2.92rem;
|
||||
border: 1px solid var(--nodedc-panel-action-border);
|
||||
background: var(--nodedc-panel-action-bg);
|
||||
color: var(--nodedc-text-secondary);
|
||||
box-shadow: var(--nodedc-panel-action-shadow);
|
||||
}
|
||||
|
||||
.catalog-map-header-action.nodedc-icon-button:hover,
|
||||
.catalog-map-header-action.nodedc-icon-button:focus-visible {
|
||||
border-color: var(--nodedc-panel-action-border-hover);
|
||||
background: var(--nodedc-glass-control-hover);
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.catalog-map-header-action.nodedc-icon-button[data-active="true"] {
|
||||
border-color: transparent;
|
||||
background: var(--nodedc-glass-control-active);
|
||||
color: var(--nodedc-glass-control-active-text);
|
||||
}
|
||||
|
||||
.catalog-map-fixture__actions .nodedc-icon-button,
|
||||
.catalog-map-fixture__toolbar .nodedc-icon-button {
|
||||
border: 0;
|
||||
@@ -633,14 +668,11 @@ textarea {
|
||||
color: var(--nodedc-glass-control-active-text);
|
||||
}
|
||||
|
||||
.catalog-map-fixture__layers,
|
||||
.catalog-map-fixture__map-settings-window,
|
||||
.catalog-map-fixture__sector-window {
|
||||
--nodedc-radius-modal: 1.45rem;
|
||||
}
|
||||
|
||||
.catalog-map-fixture__map-glass-window,
|
||||
.catalog-map-fixture__map-settings-window {
|
||||
.catalog-map-fixture__map-glass-window {
|
||||
--nodedc-canvas: #111216;
|
||||
--nodedc-text-primary: rgba(13, 14, 17, 0.96);
|
||||
--nodedc-text-secondary: rgba(13, 14, 17, 0.72);
|
||||
@@ -667,10 +699,6 @@ textarea {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.catalog-map-fixture__map-settings-window .nodedc-workspace-window__body {
|
||||
padding: 0.3rem 0.75rem 0.8rem;
|
||||
}
|
||||
|
||||
.catalog-map-fixture__sector-window .nodedc-workspace-window__body {
|
||||
padding: 0.25rem 0.72rem 0.7rem;
|
||||
}
|
||||
@@ -765,12 +793,17 @@ textarea {
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.catalog-map-fixture__layers .nodedc-workspace-window__body {
|
||||
padding-top: 0.35rem;
|
||||
.catalog-map-fixture__layers-menu .nodedc-checker {
|
||||
min-height: 3rem;
|
||||
}
|
||||
|
||||
.catalog-map-fixture__layers .nodedc-checker {
|
||||
min-height: 3rem;
|
||||
.catalog-map-fixture__layers-menu {
|
||||
--nodedc-text-primary: var(--nodedc-map-glass-text);
|
||||
--nodedc-text-secondary: var(--nodedc-map-glass-text);
|
||||
--nodedc-text-muted: var(--nodedc-map-glass-text-muted);
|
||||
--nodedc-glass-control-bg: rgb(255 255 255 / 0.14);
|
||||
--nodedc-glass-control-hover: rgb(255 255 255 / 0.22);
|
||||
--nodedc-field-bg: rgb(255 255 255 / 0.14);
|
||||
}
|
||||
|
||||
.catalog-map-fixture__provider {
|
||||
@@ -796,7 +829,7 @@ textarea {
|
||||
bottom: 1rem;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
width: 10.8rem;
|
||||
width: 13.45rem;
|
||||
gap: 0.35rem;
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
background: var(--nodedc-map-glass-bg);
|
||||
@@ -809,6 +842,7 @@ textarea {
|
||||
|
||||
.catalog-map-fixture__toolbar[data-search-open] {
|
||||
width: min(42rem, calc(100% - 2rem));
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.catalog-map-search {
|
||||
@@ -822,7 +856,7 @@ textarea {
|
||||
}
|
||||
|
||||
.catalog-map-search[data-open] {
|
||||
flex-basis: 28rem;
|
||||
flex: 1 1 0;
|
||||
overflow: visible;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
@@ -837,7 +871,7 @@ textarea {
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
background: rgb(255 255 255 / 0.84);
|
||||
padding: 0 0.82rem;
|
||||
color: var(--nodedc-map-glass-text);
|
||||
color: var(--nodedc-map-field-text);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.34);
|
||||
}
|
||||
|
||||
@@ -857,7 +891,7 @@ textarea {
|
||||
}
|
||||
|
||||
.catalog-map-search__field input::placeholder {
|
||||
color: var(--nodedc-map-glass-text-muted);
|
||||
color: var(--nodedc-map-field-text-muted);
|
||||
}
|
||||
|
||||
.catalog-map-search__field input::-webkit-search-cancel-button {
|
||||
@@ -938,6 +972,13 @@ textarea {
|
||||
padding: 0.48rem;
|
||||
}
|
||||
|
||||
.catalog-map-fixture__settings-panel-fallback {
|
||||
position: absolute;
|
||||
z-index: 500;
|
||||
inset: 0 0 0 auto;
|
||||
width: min(22rem, calc(100% - 1rem));
|
||||
}
|
||||
|
||||
.catalog-map-fixture__objects-menu-list,
|
||||
.catalog-map-fixture__objects-menu-head,
|
||||
.catalog-map-fixture__objects-menu-toggle {
|
||||
@@ -1630,6 +1671,32 @@ textarea {
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.catalog-side-panel-preview .catalog-preview__body {
|
||||
align-content: stretch;
|
||||
}
|
||||
|
||||
.catalog-side-panel-demo {
|
||||
display: grid;
|
||||
min-height: 31rem;
|
||||
justify-content: end;
|
||||
align-items: stretch;
|
||||
overflow: hidden;
|
||||
border-radius: var(--nodedc-radius-card);
|
||||
background:
|
||||
radial-gradient(circle at 30% 24%, color-mix(in srgb, var(--catalog-accent) 20%, transparent), transparent 36%),
|
||||
var(--nodedc-nested-surface);
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.catalog-side-panel-demo > .nodedc-application-side-panel {
|
||||
width: min(22rem, 100%);
|
||||
}
|
||||
|
||||
.catalog-side-panel-demo > .nodedc-button {
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.catalog-inspector-launcher .nodedc-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
+29
-4
@@ -95,8 +95,11 @@ Window — единая механика открытия modal и правой
|
||||
|
||||
`WorkspaceWindow` — modeless-окно внутри рабочей сцены приложения. Оно рендерится непосредственно в переданном workspace, не использует portal и не может перекрыть шапку, навигацию или соседние панели за пределами этого workspace.
|
||||
|
||||
На Map Page `WorkspaceWindow` используется и для facet-групп, и для
|
||||
provider-neutral карточки выбранного subject. Chevron внутри facet-строки
|
||||
На Map Page `WorkspaceWindow` используется для facet-групп, активного сектора
|
||||
и provider-neutral карточки выбранного subject. Настройки карты являются
|
||||
application-level панелью `ApplicationSidePanel`, а слои открываются через
|
||||
канонический toolbar `Dropdown`; они не участвуют в bounded z-index stack.
|
||||
Chevron внутри facet-строки
|
||||
владеет только раскрытием дерева subjects; тело строки сохраняет действие
|
||||
фильтра. Смена совместимого subject не пересоздаёт карточку и не сбрасывает
|
||||
активную вкладку. Joined data-product aspects не создают отдельные окна или
|
||||
@@ -107,8 +110,10 @@ slot `reference-points`. Их три presentation profiles (`Метро`, `Во
|
||||
`Станции РЖД`) являются живыми каноническими Inspector sections: приложение
|
||||
может менять высоту, размер, label и LOD, не создавая provider-specific UI.
|
||||
|
||||
Центральный Map Toolbar содержит template-owned универсальный поиск. В
|
||||
закрытом состоянии это три круглых действия; при открытии общий pill
|
||||
Центральный Map Toolbar содержит Objects, Layers и template-owned
|
||||
универсальный поиск. Слои используют тот же map-glass dropdown и геометрию,
|
||||
что Objects, вместо отдельного плавающего окна. В закрытом состоянии toolbar
|
||||
содержит четыре круглых действия; при открытии поиска общий pill
|
||||
симметрично расширяется, действия остаются слева, а справа появляется
|
||||
search-field и список результатов. Индекс строится по domain subjects,
|
||||
presentation label fields и разрешённым Data Product projections. Компонент не
|
||||
@@ -220,6 +225,26 @@ setup-команды, сохранение, API и права принадлеж
|
||||
|
||||
`AdminNavigationPanel` поддерживает сортируемые route-items через общий Drag & Drop contract. Приложение передаёт новый порядок id, а панель использует каноническую шеститочечную ручку Engine. Порядок не хранится отдельно внутри navigation-компонента.
|
||||
|
||||
## ApplicationSidePanel
|
||||
|
||||
`ApplicationSidePanel` — правая application-level пара к `AdminNavigationPanel`.
|
||||
Она использует ту же ширину `332–352 px`, радиус, theme-aware surface, тень и
|
||||
круглые действия, но появляется справа налево. `ApplicationShell` размещает её
|
||||
в отдельной end-колонке и на desktop уменьшает доступную ширину раскрытого
|
||||
content/stage на ширину панели плюс канонический gap `20 px`; overlay карты не
|
||||
создаётся.
|
||||
|
||||
Панель контролируется приложением. Она открывается и закрывается явным action
|
||||
в header `ApplicationPanel`; Close всегда закрывает её, а взаимодействие с
|
||||
картой не меняет состояние панели. Внутри настроек используется `Inspector
|
||||
variant="panel"`: accordion сохраняет свой layout-state, а его заголовки
|
||||
получают pill/icon geometry и цвета левой панели из theme tokens.
|
||||
|
||||
Этот формат предназначен для настроек и detail-контента уровня Application.
|
||||
Инструменты, сектор, facet-окна и карточки, ограниченные самой сценой, остаются
|
||||
`WorkspaceWindow`. Floating dropdown для короткого списка слоёв не заменяется
|
||||
полноразмерной side panel.
|
||||
|
||||
## Drag & Drop
|
||||
|
||||
`DragHandle`, `DragDropRoot`, `DraggableItem`, `DropZone`, `SortableScope`, `SortableItem` и `SortableList` образуют общий React-контракт переноса и сортировки. Он перенесён из рабочего Engine-паттерна: drag начинается только за шесть точек и только после движения на `6 px`, поэтому обычный клик по строке не конфликтует с навигацией.
|
||||
|
||||
+25
-7
@@ -43,6 +43,11 @@ profile; renderer не содержит provider-specific условий.
|
||||
|
||||
Кнопка `Объекты` открывает вверх текстовый dropdown из Application bindings. Выбор строки открывает или фокусирует отдельное canonical `WorkspaceWindow`, а его универсальное содержимое строится из presentation profile/facets. Несколько binding-окон могут быть открыты одновременно; stable identity всегда `bindingId`, поэтому пользовательское переименование не ломает сохранённый layout.
|
||||
|
||||
Соседняя кнопка `Слои карты` открывает вверх dropdown той же ширины,
|
||||
map-glass поверхности и row geometry, что `Объекты`. Terrain, 3D buildings,
|
||||
planetary grid и cache policy больше не создают отдельное draggable-окно и не
|
||||
занимают место в bounded window stack.
|
||||
|
||||
Клик по pin или его label открывает второе canonical `WorkspaceWindow` — карточку
|
||||
выбранного объекта. Её вкладки и поля задаёт versioned provider-neutral
|
||||
`subjectDetailProfile`, а не renderer и не provider payload. Базовый профиль
|
||||
@@ -74,7 +79,7 @@ MMAP/AIS: сохраняет `heading`, `pitch`, `roll` и смещение ка
|
||||
центра viewport; длительность перелёта для текущего канонического workflow —
|
||||
`0.45 s`.
|
||||
|
||||
Кнопка поиска разворачивает центральный Toolbar симметрично, оставляя три
|
||||
Кнопка поиска разворачивает центральный Toolbar симметрично, оставляя четыре
|
||||
системных действия слева и открывая единое поле справа. Поиск не знает Gelios,
|
||||
OSM или Cesium: он строит локальный индекс по всем подключённым map subjects.
|
||||
Для primary binding индексируются стабильный `sourceId`, label fields
|
||||
@@ -118,10 +123,22 @@ graticule используется тот же глобальный адреса
|
||||
кнопка `Деактивировать сектор` выполняют одну операцию: снимают selection и
|
||||
очищают transient sector-фильтры.
|
||||
|
||||
Настройки карты, слои, окно сектора, binding-окна и карточка subject используют
|
||||
один application-owned workspace stack. Pointer/focus поднимает выбранное окно
|
||||
выше остальных независимо от его типа; ни одно из этих map-stage окон не
|
||||
рендерится в глобальный viewport overlay.
|
||||
Клик по точечному subject, его elevated spike, label или HGeoZone меняет
|
||||
`map.selection` и открывает карточку, но не снимает активный grid sector.
|
||||
Сектор деактивируется только явным background/grid selection либо одним из
|
||||
двух действий деактивации выше.
|
||||
|
||||
Окно сектора, binding-окна и карточка subject используют один
|
||||
application-owned workspace stack. Pointer/focus поднимает выбранное окно выше
|
||||
остальных независимо от его типа; ни одно из этих map-stage окон не рендерится
|
||||
в глобальный viewport overlay. Настройки карты находятся уровнем выше в
|
||||
`ApplicationSidePanel`: она выезжает справа налево, занимает отдельную колонку
|
||||
`ApplicationShell` и сжимает центральный content вместе с картой. Settings и
|
||||
Toolbar являются theme-aware actions в header `ApplicationPanel`: активное
|
||||
состояние использует белую canonical surface, неактивное — panel surface с
|
||||
обводкой. Панель закрывается повторным Settings action либо собственным Close;
|
||||
клик по карте её не закрывает. Слои остаются коротким portal-dropdown нижнего
|
||||
toolbar.
|
||||
|
||||
## Platform reference layers
|
||||
|
||||
@@ -165,8 +182,9 @@ Layout получают зарегистрированные reference bindings
|
||||
их файл не переписывается до явного сохранения пользователем.
|
||||
|
||||
Тот же Page Layout хранит `inspectorOpenSections`. В текущем single-open
|
||||
Inspector это пустой список либо id ровно одной раскрытой секции. Поэтому
|
||||
закрытие окна настроек и повторное открытие не сбрасывает аккордеон, а общая
|
||||
`Inspector variant="panel"` это пустой список либо id ровно одной раскрытой
|
||||
секции. Поэтому закрытие правой панели настроек и повторное открытие не
|
||||
сбрасывает аккордеон, а общая
|
||||
кнопка Application Save сохраняет его вместе с камерой и остальным layout.
|
||||
|
||||
Cesium adapter рендерит elevated targets и label plates как единый overlay над
|
||||
|
||||
@@ -52,6 +52,15 @@ Semantic status не обязан совпадать с accent. Например
|
||||
|
||||
Portal-компоненты рендерятся в `document.body`, поэтому приложение должно либо задавать тему на `document.documentElement`, либо передавать согласованные переменные на body. Локальная тема глубоко внутри React subtree не сможет автоматически охватить portal без отдельного theme portal root.
|
||||
|
||||
Map-glass overlays являются отдельной, независимой от application theme
|
||||
поверхностью поверх imagery. Их светлые translucent controls используют
|
||||
`--nodedc-map-glass-text`, а непрозрачное светлое поле поиска обязано брать
|
||||
тёмный foreground из `--nodedc-map-field-text` и placeholder из
|
||||
`--nodedc-map-field-text-muted`. Светлый field не наследует белый текст
|
||||
map-glass родителя. `ApplicationSidePanel`, напротив, находится вне imagery и
|
||||
всегда наследует обычные theme-aware application panel/text tokens; при смене
|
||||
dark/light она меняется синхронно с `AdminNavigationPanel`.
|
||||
|
||||
## Совместимость
|
||||
|
||||
Существующие `--nodedc-*` имена сохранены намеренно. Это уменьшает стоимость будущего подключения Launcher, Engine, Task Manager и BIM Viewer.
|
||||
|
||||
@@ -70,6 +70,20 @@ Side window использует ту же механику, но placement `end
|
||||
|
||||
По умолчанию это modeless-слой: он не затемняет страницу, не перехватывает клики по основной области, не закрывается по backdrop, не блокирует body scroll и не удерживает Tab внутри панели. Escape и кнопка закрытия остаются доступны. Если конкретный workflow должен быть modal, это задаётся явно, а не получается случайно из placement.
|
||||
|
||||
### ApplicationSidePanel: отдельная push-колонка
|
||||
|
||||
Когда settings/detail принадлежат всему Application и должны физически
|
||||
уменьшать рабочую область, используется `ApplicationSidePanel` внутри
|
||||
`ApplicationShell.endPanel`. На desktop панель имеет ту же ширину, surface,
|
||||
радиус и тень, что `AdminNavigationPanel`, входит справа налево и сдвигает
|
||||
content/stage на panel width + page gap. Она не является `Window`, не получает
|
||||
backdrop и не входит в stack bounded-окон сцены.
|
||||
|
||||
Открытие панели контролирует action в header владельца, закрытие — тот же
|
||||
action или обязательный close action самой панели. Клик по рабочей области не
|
||||
закрывает её неявно. На mobile отдельная колонка становится полноширинным
|
||||
верхним panel-layer, потому что сохранять две узкие колонки там невозможно.
|
||||
|
||||
## Workspace window
|
||||
|
||||
`WorkspaceWindow` отличается от modal и side inspector границей слоя: это inline modeless-окно, ограниченное конкретной рабочей сценой приложения.
|
||||
@@ -89,10 +103,12 @@ Side window использует ту же механику, но placement `end
|
||||
Workspace window используется для вспомогательных камер, инструментов и сопоставляемых представлений внутри stage. Оно не заменяет modal `Window`, viewport-level Inspector или `ApplicationPanel`.
|
||||
|
||||
Если Inspector принадлежит конкретной bounded-сцене и должен конкурировать по
|
||||
z-order с её слоями, карточками и инструментами, он является содержимым
|
||||
`WorkspaceWindow`, а не viewport-level side window. Map Page следует именно
|
||||
этому варианту: настройки карты, слои, активный сектор, окна bindings и
|
||||
карточка объекта входят в один контролируемый stack.
|
||||
z-order с её карточками и инструментами, он является содержимым
|
||||
`WorkspaceWindow`, а не application-level side panel. Текущий Map Page
|
||||
разделяет уровни явно: активный сектор, окна bindings и карточка объекта входят
|
||||
в один bounded stack; настройки карты живут в `ApplicationSidePanel`; короткие
|
||||
настройки слоёв открываются из нижнего toolbar через `Dropdown` в том же
|
||||
map-glass дизайне, что Objects.
|
||||
|
||||
## Управление состоянием
|
||||
|
||||
|
||||
@@ -37,6 +37,8 @@
|
||||
--nodedc-map-glass-active: rgba(255, 255, 255, 0.92);
|
||||
--nodedc-map-glass-text: rgba(255, 255, 255, 0.96);
|
||||
--nodedc-map-glass-text-muted: rgba(255, 255, 255, 0.72);
|
||||
--nodedc-map-field-text: rgba(13, 14, 17, 0.96);
|
||||
--nodedc-map-field-text-muted: rgba(13, 14, 17, 0.54);
|
||||
--nodedc-field-bg: rgb(var(--nodedc-field-material-rgb) / var(--nodedc-field-material-opacity));
|
||||
--nodedc-overlay-bg: rgba(0, 0, 0, 0.46);
|
||||
--nodedc-glass-rim: transparent;
|
||||
@@ -108,6 +110,8 @@
|
||||
--nodedc-map-glass-active: rgba(255, 255, 255, 0.92);
|
||||
--nodedc-map-glass-text: rgba(255, 255, 255, 0.96);
|
||||
--nodedc-map-glass-text-muted: rgba(255, 255, 255, 0.72);
|
||||
--nodedc-map-field-text: rgba(13, 14, 17, 0.96);
|
||||
--nodedc-map-field-text-muted: rgba(13, 14, 17, 0.54);
|
||||
--nodedc-field-bg: rgb(var(--nodedc-field-material-rgb) / var(--nodedc-field-material-opacity));
|
||||
--nodedc-overlay-bg: rgba(24, 32, 29, 0.22);
|
||||
--nodedc-glass-rim: transparent;
|
||||
|
||||
+163
-3
@@ -1376,7 +1376,8 @@ textarea.nodedc-field__control {
|
||||
}
|
||||
|
||||
.nodedc-app-shell__navigation,
|
||||
.nodedc-app-shell__content {
|
||||
.nodedc-app-shell__content,
|
||||
.nodedc-app-shell__end-panel {
|
||||
position: absolute;
|
||||
z-index: var(--nodedc-layer-panel);
|
||||
top: 0;
|
||||
@@ -1395,6 +1396,27 @@ textarea.nodedc-field__control {
|
||||
transition: width 500ms cubic-bezier(0.22, 1, 0.36, 1), right 500ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.nodedc-app-shell__end-panel {
|
||||
right: var(--nodedc-app-page-pad);
|
||||
width: var(--nodedc-app-nav-width);
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nodedc-app-shell__end-panel > * {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.nodedc-app-shell[data-end-panel-open="true"] .nodedc-app-shell__end-panel {
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.nodedc-app-shell[data-end-panel-open="true"] .nodedc-app-shell__stage {
|
||||
padding-right: calc(var(--nodedc-app-page-pad) + var(--nodedc-app-nav-width) + var(--nodedc-app-panel-gap));
|
||||
}
|
||||
|
||||
.nodedc-app-shell[data-navigation-open="true"] .nodedc-app-shell__stage {
|
||||
padding-left: calc(var(--nodedc-app-page-pad) + var(--nodedc-app-nav-width) + var(--nodedc-app-panel-gap));
|
||||
}
|
||||
@@ -1411,6 +1433,10 @@ textarea.nodedc-field__control {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.nodedc-app-shell[data-content-open="true"][data-content-expanded="true"][data-end-panel-open="true"] .nodedc-app-shell__content {
|
||||
right: calc(var(--nodedc-app-page-pad) + var(--nodedc-app-nav-width) + var(--nodedc-app-panel-gap));
|
||||
}
|
||||
|
||||
.nodedc-app-shell[data-content-open="true"][data-content-expanded="true"] .nodedc-app-shell__stage {
|
||||
pointer-events: none;
|
||||
transform: translateX(calc(100vw + var(--nodedc-app-page-pad)));
|
||||
@@ -1559,6 +1585,34 @@ textarea.nodedc-field__control {
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.nodedc-application-side-panel {
|
||||
animation-name: nodedc-application-side-panel-in;
|
||||
}
|
||||
|
||||
@keyframes nodedc-application-side-panel-in {
|
||||
from { opacity: 0; transform: translateX(1.6rem); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
.nodedc-application-side-panel__description {
|
||||
display: block;
|
||||
margin-top: 0.3rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.nodedc-application-side-panel__body {
|
||||
width: calc(100% + 2.2rem);
|
||||
min-height: 0;
|
||||
margin-inline: -1.1rem;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding-inline: 1.1rem;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--nodedc-scrollbar-thumb) transparent;
|
||||
}
|
||||
|
||||
@keyframes nodedc-admin-panel-in {
|
||||
from { opacity: 0; transform: translateX(-1.6rem); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
@@ -3140,6 +3194,17 @@ textarea.nodedc-field__control {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nodedc-inspector__section-icon,
|
||||
.nodedc-inspector__section-chevron {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nodedc-inspector__section-body {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.nodedc-inspector__section-trigger:not([data-open="true"]):hover {
|
||||
background: var(--nodedc-glass-control-hover);
|
||||
color: var(--nodedc-text-primary);
|
||||
@@ -3179,6 +3244,89 @@ textarea.nodedc-field__control {
|
||||
animation: nodedc-section-in var(--nodedc-duration-fast) ease;
|
||||
}
|
||||
|
||||
.nodedc-inspector[data-variant="panel"] {
|
||||
width: 100%;
|
||||
gap: 0.22rem;
|
||||
}
|
||||
|
||||
.nodedc-inspector[data-variant="panel"] :where(.nodedc-inspector__group, .nodedc-inspector__section) {
|
||||
gap: 0.22rem;
|
||||
}
|
||||
|
||||
.nodedc-inspector[data-variant="panel"] .nodedc-inspector__section {
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.nodedc-inspector[data-variant="panel"] .nodedc-inspector__section-trigger {
|
||||
min-height: 3.55rem;
|
||||
grid-template-columns: 2.92rem minmax(0, 1fr) 1.4rem;
|
||||
align-items: center;
|
||||
align-content: initial;
|
||||
gap: 0.65rem;
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
background: var(--nodedc-panel-item-bg);
|
||||
color: var(--nodedc-text-secondary);
|
||||
padding: 5px 0.72rem 5px 5px;
|
||||
box-shadow: none;
|
||||
opacity: 0.72;
|
||||
transition: background 160ms ease, color 160ms ease, opacity 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
.nodedc-inspector[data-variant="panel"] .nodedc-inspector__section-trigger:hover:not(:disabled) {
|
||||
background: var(--nodedc-panel-item-hover-bg);
|
||||
color: var(--nodedc-text-primary);
|
||||
opacity: 0.92;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.nodedc-inspector[data-variant="panel"] .nodedc-inspector__section-trigger:is([data-open="true"], [data-active="true"], [data-tone="accent"]) {
|
||||
background: var(--nodedc-panel-item-active-bg);
|
||||
color: var(--nodedc-text-primary);
|
||||
opacity: 1;
|
||||
box-shadow: inset 0 1px 0 color-mix(in srgb, white 12%, transparent), inset 0 -14px 26px color-mix(in srgb, var(--nodedc-canvas) 8%, transparent);
|
||||
}
|
||||
|
||||
.nodedc-inspector[data-variant="panel"] .nodedc-inspector__section-trigger:is([data-open="true"], [data-active="true"], [data-tone="accent"]) .nodedc-inspector__section-description {
|
||||
color: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.nodedc-inspector[data-variant="panel"] .nodedc-inspector__section-icon {
|
||||
display: grid;
|
||||
width: 2.92rem;
|
||||
height: 2.92rem;
|
||||
place-items: center;
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
background: var(--nodedc-panel-icon-bg);
|
||||
color: var(--nodedc-text-secondary);
|
||||
}
|
||||
|
||||
.nodedc-inspector[data-variant="panel"] .nodedc-inspector__section-trigger[data-open="true"] .nodedc-inspector__section-icon {
|
||||
background: var(--nodedc-panel-active-icon-bg);
|
||||
color: var(--nodedc-panel-active-icon-color);
|
||||
}
|
||||
|
||||
.nodedc-inspector[data-variant="panel"] .nodedc-inspector__section-chevron {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--nodedc-text-muted);
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
|
||||
.nodedc-inspector[data-variant="panel"] .nodedc-inspector__section-trigger[data-open="true"] .nodedc-inspector__section-chevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.nodedc-inspector[data-variant="panel"] .nodedc-inspector__section-content {
|
||||
padding: 0.45rem 0.15rem 0.85rem;
|
||||
}
|
||||
|
||||
.nodedc-inspector[data-variant="panel"] .nodedc-control-row {
|
||||
width: 100%;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: stretch;
|
||||
gap: var(--nodedc-space-2);
|
||||
}
|
||||
|
||||
@keyframes nodedc-section-in {
|
||||
from { opacity: 0; transform: translateY(-3px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
@@ -3243,6 +3391,10 @@ textarea.nodedc-field__control {
|
||||
pointer-events: none;
|
||||
transform: translateX(calc(100vw + var(--nodedc-app-page-pad)));
|
||||
}
|
||||
|
||||
.nodedc-app-shell[data-content-open="true"][data-end-panel-open="true"] .nodedc-app-shell__content {
|
||||
right: calc(var(--nodedc-app-page-pad) + var(--nodedc-app-nav-width) + var(--nodedc-app-panel-gap));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
@@ -3286,13 +3438,19 @@ textarea.nodedc-field__control {
|
||||
}
|
||||
|
||||
.nodedc-app-shell__navigation,
|
||||
.nodedc-app-shell__content {
|
||||
.nodedc-app-shell__content,
|
||||
.nodedc-app-shell__end-panel {
|
||||
right: var(--nodedc-app-page-pad);
|
||||
bottom: var(--nodedc-app-page-pad);
|
||||
left: var(--nodedc-app-page-pad);
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.nodedc-app-shell[data-end-panel-open="true"] .nodedc-app-shell__content {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nodedc-app-shell[data-navigation-open="true"] .nodedc-app-shell__stage,
|
||||
.nodedc-app-shell[data-content-open="true"] .nodedc-app-shell__stage {
|
||||
padding-left: var(--nodedc-app-page-pad);
|
||||
@@ -3306,7 +3464,8 @@ textarea.nodedc-field__control {
|
||||
}
|
||||
|
||||
.nodedc-application-panel,
|
||||
.nodedc-admin-panel {
|
||||
.nodedc-admin-panel,
|
||||
.nodedc-application-side-panel {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
@@ -3400,6 +3559,7 @@ textarea.nodedc-field__control {
|
||||
.nodedc-window,
|
||||
.nodedc-workspace-window,
|
||||
.nodedc-application-panel,
|
||||
.nodedc-application-side-panel,
|
||||
.nodedc-inspector__section-content {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@@ -7,9 +7,11 @@ export interface ApplicationShellProps extends Omit<HTMLAttributes<HTMLDivElemen
|
||||
stage: ReactNode;
|
||||
navigation?: ReactNode;
|
||||
content?: ReactNode;
|
||||
endPanel?: ReactNode;
|
||||
navigationOpen?: boolean;
|
||||
contentOpen?: boolean;
|
||||
contentExpanded?: boolean;
|
||||
endPanelOpen?: boolean;
|
||||
}
|
||||
|
||||
export function ApplicationShell({
|
||||
@@ -17,9 +19,11 @@ export function ApplicationShell({
|
||||
stage,
|
||||
navigation,
|
||||
content,
|
||||
endPanel,
|
||||
navigationOpen = false,
|
||||
contentOpen = false,
|
||||
contentExpanded = false,
|
||||
endPanelOpen = false,
|
||||
className,
|
||||
...props
|
||||
}: ApplicationShellProps) {
|
||||
@@ -29,6 +33,7 @@ export function ApplicationShell({
|
||||
data-navigation-open={navigationOpen ? "true" : undefined}
|
||||
data-content-open={contentOpen ? "true" : undefined}
|
||||
data-content-expanded={contentOpen && contentExpanded ? "true" : undefined}
|
||||
data-end-panel-open={endPanelOpen ? "true" : undefined}
|
||||
{...props}
|
||||
>
|
||||
{header}
|
||||
@@ -36,6 +41,7 @@ export function ApplicationShell({
|
||||
<div className="nodedc-app-shell__stage">{stage}</div>
|
||||
{navigationOpen && navigation ? <div className="nodedc-app-shell__navigation">{navigation}</div> : null}
|
||||
{contentOpen && content ? <div className="nodedc-app-shell__content">{content}</div> : null}
|
||||
{endPanel ? <div className="nodedc-app-shell__end-panel">{endPanel}</div> : null}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import { cn } from "./cn.js";
|
||||
import { Icon } from "./Icon.js";
|
||||
|
||||
export interface ApplicationSidePanelProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
|
||||
eyebrow?: ReactNode;
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
closeLabel?: string;
|
||||
footer?: ReactNode;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme-aware trailing application panel paired with AdminNavigationPanel.
|
||||
*
|
||||
* The shell owns the separate end column; this component owns its matching
|
||||
* surface, actions and right-to-left entrance animation.
|
||||
*/
|
||||
export function ApplicationSidePanel({
|
||||
eyebrow = "NODE.DC",
|
||||
title,
|
||||
description,
|
||||
closeLabel = "Закрыть панель",
|
||||
footer,
|
||||
onClose,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ApplicationSidePanelProps) {
|
||||
return (
|
||||
<aside
|
||||
className={cn("nodedc-admin-panel nodedc-application-side-panel", className)}
|
||||
data-placement="end"
|
||||
{...props}
|
||||
>
|
||||
<header className="nodedc-admin-panel__head">
|
||||
<div>
|
||||
<p>{eyebrow}</p>
|
||||
<h2>{title}</h2>
|
||||
{description ? <span className="nodedc-application-side-panel__description">{description}</span> : null}
|
||||
</div>
|
||||
<div className="nodedc-admin-panel__head-actions">
|
||||
<button type="button" className="nodedc-admin-panel__close" aria-label={closeLabel} onClick={onClose}>
|
||||
<Icon name="close" size={16} strokeWidth={1.6} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="nodedc-application-side-panel__body">{children}</div>
|
||||
{footer ? <footer className="nodedc-admin-panel__footer">{footer}</footer> : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,7 @@ export interface DropdownProps {
|
||||
className?: string;
|
||||
surfaceClassName?: string;
|
||||
surfaceRole?: "menu" | "listbox" | "dialog";
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function Dropdown({
|
||||
@@ -46,6 +47,7 @@ export function Dropdown({
|
||||
className,
|
||||
surfaceClassName,
|
||||
surfaceRole = "menu",
|
||||
onOpenChange,
|
||||
}: DropdownProps) {
|
||||
const instanceId = useId();
|
||||
const surfaceId = `${instanceId.replaceAll(":", "")}-surface`;
|
||||
@@ -55,6 +57,10 @@ export function Dropdown({
|
||||
const surfaceRef = useRef<HTMLDivElement>(null);
|
||||
const [surfaceStyle, setSurfaceStyle] = useState<CSSProperties>({ visibility: "hidden" });
|
||||
|
||||
useEffect(() => {
|
||||
onOpenChange?.(isOpen);
|
||||
}, [isOpen, onOpenChange]);
|
||||
|
||||
const close = useCallback(() => setIsOpen(false), []);
|
||||
const show = useCallback(() => {
|
||||
if (!disabled) setIsOpen(true);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo, useState, type ReactNode } from "react";
|
||||
import { InspectorSelectPolicyContext } from "./InspectorContext.js";
|
||||
import { Select, type SelectProps } from "./Select.js";
|
||||
import { cn } from "./cn.js";
|
||||
import { Icon } from "./Icon.js";
|
||||
|
||||
export interface InspectorSectionSpec {
|
||||
id: string;
|
||||
@@ -11,6 +12,7 @@ export interface InspectorSectionSpec {
|
||||
content: ReactNode;
|
||||
disabled?: boolean;
|
||||
tone?: "default" | "accent";
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
export interface InspectorProps {
|
||||
@@ -19,6 +21,7 @@ export interface InspectorProps {
|
||||
openSections?: string[];
|
||||
activeId?: string;
|
||||
singleOpen?: boolean;
|
||||
variant?: "default" | "panel";
|
||||
className?: string;
|
||||
onOpenSectionsChange?: (ids: string[]) => void;
|
||||
onActiveChange?: (id: string) => void;
|
||||
@@ -30,6 +33,7 @@ export function Inspector({
|
||||
openSections,
|
||||
activeId,
|
||||
singleOpen = false,
|
||||
variant = "default",
|
||||
className,
|
||||
onOpenSectionsChange,
|
||||
onActiveChange,
|
||||
@@ -63,7 +67,7 @@ export function Inspector({
|
||||
|
||||
return (
|
||||
<InspectorSelectPolicyContext.Provider value>
|
||||
<div className={cn("nodedc-inspector", className)}>
|
||||
<div className={cn("nodedc-inspector", className)} data-variant={variant === "default" ? undefined : variant}>
|
||||
{groups.map((group, groupIndex) => (
|
||||
<div className="nodedc-inspector__group" key={`${group.label ?? "root"}-${groupIndex}`}>
|
||||
{group.sections.map((section) => {
|
||||
@@ -80,10 +84,14 @@ export function Inspector({
|
||||
disabled={section.disabled}
|
||||
onClick={() => toggle(section.id)}
|
||||
>
|
||||
<span className="nodedc-inspector__section-label">{section.label}</span>
|
||||
{section.description ? (
|
||||
<span className="nodedc-inspector__section-description">{section.description}</span>
|
||||
) : null}
|
||||
<span className="nodedc-inspector__section-icon" aria-hidden="true">{section.icon ?? <Icon name="settings" />}</span>
|
||||
<span className="nodedc-inspector__section-body">
|
||||
<span className="nodedc-inspector__section-label">{section.label}</span>
|
||||
{section.description ? (
|
||||
<span className="nodedc-inspector__section-description">{section.description}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="nodedc-inspector__section-chevron" aria-hidden="true"><Icon name="chevron-right" size={14} /></span>
|
||||
</button>
|
||||
{isOpen ? <div className="nodedc-inspector__section-content">{section.content}</div> : null}
|
||||
</section>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from "./AppHeader.js";
|
||||
export * from "./AdminNavigationPanel.js";
|
||||
export * from "./ApplicationShell.js";
|
||||
export * from "./ApplicationSidePanel.js";
|
||||
export * from "./ApplicationWorkspace.js";
|
||||
export * from "./Button.js";
|
||||
export * from "./Checker.js";
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
"domExports": ["createFloatingLayer"],
|
||||
"domContract": ["nodedc-dropdown-surface", "nodedc-dropdown-option"],
|
||||
"summary": "Shared portal/fixed floating layer for action and selection menus.",
|
||||
"behavior": ["portal to document body", "viewport clamp", "vertical flip", "outside pointer close", "Escape close with trigger focus restore", "scroll and resize reposition", "contained menu scroll without geometry growth", "offscreen trigger close", "one open dropdown per window"],
|
||||
"behavior": ["portal to document body", "viewport clamp", "vertical flip", "outside pointer close", "Escape close with trigger focus restore", "scroll and resize reposition", "contained menu scroll without geometry growth", "offscreen trigger close", "one open dropdown per window", "optional open-state notification"],
|
||||
"rules": [
|
||||
"Never render a runtime dropdown as an absolute child of a card, sticky header or scroll container.",
|
||||
"Action menus and selection menus share floating behavior even when their row content differs.",
|
||||
@@ -308,6 +308,22 @@
|
||||
"Active route state uses the active surface and icon contrast from the theme."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "application-side-panel",
|
||||
"status": "baseline",
|
||||
"package": "@nodedc/ui-react",
|
||||
"exports": ["ApplicationSidePanel", "ApplicationSidePanelProps"],
|
||||
"domContract": ["nodedc-application-side-panel", "nodedc-app-shell__end-panel"],
|
||||
"summary": "Theme-aware trailing application panel paired with AdminNavigationPanel and hosted in a separate ApplicationShell end column.",
|
||||
"anatomy": ["eyebrow and title", "optional description", "close action", "scrollable body", "optional footer"],
|
||||
"behavior": ["right-to-left entrance", "separate push column", "controlled visibility", "theme token inheritance"],
|
||||
"rules": [
|
||||
"The panel uses the same 332–352 px geometry, radius, surface, shadow and circular actions as AdminNavigationPanel.",
|
||||
"ApplicationShell shrinks the expanded content/stage by one panel width plus the canonical page gap; the side panel never overlays the desktop work area.",
|
||||
"The owning ApplicationPanel header action controls visibility; close always remains available and workspace interaction does not dismiss the panel implicitly.",
|
||||
"Use this panel for application-level settings/detail. Bounded scene tools and subject cards remain WorkspaceWindow instances."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "media-source-field",
|
||||
"status": "baseline",
|
||||
@@ -390,6 +406,7 @@
|
||||
"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.",
|
||||
"The panel variant reuses AdminNavigationPanel pill, icon and theme tokens while retaining the same controlled accordion state and domain-owned content.",
|
||||
"Accent-filled section headers belong to Environment Settings; neutral headers remain available for other approved inspector contexts."
|
||||
]
|
||||
}
|
||||
|
||||
@@ -39,6 +39,34 @@ test("Map Inspector open section is controlled and included in the saved page la
|
||||
assert.match(manifestSchema, /"inspectorOpenSections"[\s\S]*?"maxItems": 1/);
|
||||
});
|
||||
|
||||
test("Map settings use a canonical trailing push panel and ApplicationPanel header actions", async () => {
|
||||
const [shell, panel, inspector, preview, catalog, styles, catalogStyles] = await Promise.all([
|
||||
readFile(new URL("../packages/ui-react/src/ApplicationShell.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../packages/ui-react/src/ApplicationSidePanel.tsx", import.meta.url), "utf8"),
|
||||
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("../apps/catalog/src/CatalogApp.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../packages/ui-core/styles.css", import.meta.url), "utf8"),
|
||||
readFile(new URL("../apps/catalog/src/styles.css", import.meta.url), "utf8"),
|
||||
]);
|
||||
|
||||
assert.match(shell, /endPanelOpen\?: boolean/);
|
||||
assert.match(shell, /nodedc-app-shell__end-panel/);
|
||||
assert.match(panel, /data-placement="end"/);
|
||||
assert.doesNotMatch(panel, /pinned|onPinnedChange|Icon name="pin"/);
|
||||
assert.match(inspector, /variant\?: "default" \| "panel"/);
|
||||
assert.match(preview, /createPortal\(settingsPanel, settingsPanelHost\)/);
|
||||
assert.match(preview, /createPortal\(headerActions, headerActionsHost\)/);
|
||||
assert.match(preview, /variant="panel"/);
|
||||
assert.doesNotMatch(preview, /settingsPanelPinned|onPointerDownCapture/);
|
||||
assert.match(catalog, /endPanelOpen=\{mapSettingsPanelOpen\}/);
|
||||
assert.match(catalog, /headerTools=\{activePageTemplate\.id === "map"[\s\S]*?setMapHeaderActionsHost/);
|
||||
assert.match(styles, /data-content-expanded="true"\]\[data-end-panel-open="true"\][\s\S]*?right: calc\(/);
|
||||
assert.match(styles, /@keyframes nodedc-application-side-panel-in[\s\S]*?translateX\(1\.6rem\)/);
|
||||
assert.match(catalogStyles, /\.catalog-map-header-action\.nodedc-icon-button[\s\S]*?--nodedc-panel-action-bg/);
|
||||
assert.match(catalogStyles, /\.catalog-map-header-action\.nodedc-icon-button\[data-active="true"\][\s\S]*?--nodedc-glass-control-active/);
|
||||
});
|
||||
|
||||
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),
|
||||
|
||||
@@ -37,16 +37,33 @@ test("joined detail aspects do not become independent map layers", async () => {
|
||||
assert.match(preview, /runtimeBindings=\{\[\.\.\.sectorScopedPrimaryRuntimeBindings, \.\.\.referenceRuntimeBindings\]\}/);
|
||||
});
|
||||
|
||||
test("map windows share one bounded activation and z-index stack", async () => {
|
||||
test("bounded map windows keep one stack while layers and settings use their canonical surfaces", async () => {
|
||||
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(preview, /type MapWorkspaceWindowId = "settings" \| "layers" \| "sector" \| "subject-card" \| `binding:\$\{string\}`/);
|
||||
assert.match(preview, /type MapWorkspaceWindowId = "sector" \| "subject-card" \| `binding:\$\{string\}`/);
|
||||
assert.match(preview, /const activateWorkspaceWindow = useCallback/);
|
||||
assert.match(preview, /settingsWindowZIndex[\s\S]*layersWindowZIndex[\s\S]*sectorWindowZIndex[\s\S]*subjectCardZIndex/);
|
||||
assert.match(preview, /title="Настройки карты"[\s\S]*active=\{activeWorkspaceWindowId === "settings"\}/);
|
||||
assert.match(preview, /sectorWindowZIndex[\s\S]*subjectCardZIndex/);
|
||||
assert.match(preview, /<ApplicationSidePanel[\s\S]*title="Настройки карты"[\s\S]*onClose=\{closeSettingsPanel\}/);
|
||||
assert.match(preview, /createPortal\(headerActions, headerActionsHost\)/);
|
||||
assert.doesNotMatch(preview, /settingsPanelPinned|onPinnedChange/);
|
||||
assert.match(preview, /surfaceClassName="catalog-map-fixture__objects-menu catalog-map-fixture__layers-menu nodedc-map-glass"/);
|
||||
assert.match(preview, /onOpenChange=\{setLayersOpen\}/);
|
||||
assert.doesNotMatch(preview, /settingsWindowZIndex|layersWindowZIndex/);
|
||||
assert.doesNotMatch(preview, /<Window\b/);
|
||||
});
|
||||
|
||||
test("selecting a point or HGeoZone preserves the active grid sector", async () => {
|
||||
const renderer = await readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8");
|
||||
const pointBranch = renderer.match(/if \(pickedId instanceof Entity[\s\S]*?return;\s*\}/)?.[0] ?? "";
|
||||
const zoneBranch = renderer.match(/if \(\s*pickedId[\s\S]*?HGeoZonePickId\)\.entityId\);\s*return;\s*\}/)?.[0] ?? "";
|
||||
|
||||
assert.match(pointBranch, /onSelectRef\.current\?\.\(pickedId\.id\)/);
|
||||
assert.match(zoneBranch, /onSelectRef\.current\?\.\(\(pickedId as HGeoZonePickId\)\.entityId\)/);
|
||||
assert.doesNotMatch(pointBranch, /setSelection\(null\)|onGridSectorSelectRef\.current\?\.\(null\)/);
|
||||
assert.doesNotMatch(zoneBranch, /setSelection\(null\)|onGridSectorSelectRef\.current\?\.\(null\)/);
|
||||
assert.match(renderer, /gridController\?\.setSelection\(gridSelection\);\s*onGridSectorSelectRef\.current\?\.\(gridSelection\)/);
|
||||
});
|
||||
|
||||
test("an active sector scopes point facts, exposes data facets and deactivates on close", async () => {
|
||||
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user