Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88cbef1aba | ||
|
|
f645725e8c | ||
|
|
b70e165910 |
@@ -2,9 +2,6 @@ import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties,
|
|||||||
import { applyGlassMaterial, applyNodedcTheme, defaultGlassMaterial, type GlassMaterialSettings, type NodedcTheme, type RgbTuple } from "@nodedc/ui-core";
|
import { applyGlassMaterial, applyNodedcTheme, defaultGlassMaterial, type GlassMaterialSettings, type NodedcTheme, type RgbTuple } from "@nodedc/ui-core";
|
||||||
import { createTemplateFeatures, getPageTemplate, pageTemplates, type PageTemplateDefinition } from "@nodedc/page-patterns";
|
import { createTemplateFeatures, getPageTemplate, pageTemplates, type PageTemplateDefinition } from "@nodedc/page-patterns";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
|
||||||
StatusBadge,
|
|
||||||
ProgressBar,
|
|
||||||
AdminNavigationPanel,
|
AdminNavigationPanel,
|
||||||
AppHeader,
|
AppHeader,
|
||||||
ApplicationPanel,
|
ApplicationPanel,
|
||||||
@@ -31,13 +28,10 @@ import {
|
|||||||
InspectorSelectField,
|
InspectorSelectField,
|
||||||
MediaSourceField,
|
MediaSourceField,
|
||||||
RangeControl,
|
RangeControl,
|
||||||
ResourceRow,
|
|
||||||
ResourceList,
|
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
Select,
|
Select,
|
||||||
ShareAccessModal,
|
ShareAccessModal,
|
||||||
ShareLinkModal,
|
ShareLinkModal,
|
||||||
SplitPane,
|
|
||||||
SortableItem,
|
SortableItem,
|
||||||
SortableScope,
|
SortableScope,
|
||||||
SettingsCard,
|
SettingsCard,
|
||||||
@@ -71,7 +65,8 @@ import {
|
|||||||
type DesignProfileSummary,
|
type DesignProfileSummary,
|
||||||
type DesignProfileStatus,
|
type DesignProfileStatus,
|
||||||
} from "./applicationManifest.js";
|
} from "./applicationManifest.js";
|
||||||
import { createDefaultMapPageLayout, MapFixturePreview, type MapFixturePreviewHandle, type MapPageLayout } from "./MapFixturePreview.js";
|
import { MapFixturePreview } from "./MapFixturePreview.js";
|
||||||
|
import { createDefaultMapPageLayout, type MapFixturePreviewHandle, type MapPageLayout } from "./mapPageContract.js";
|
||||||
import {
|
import {
|
||||||
mapDesignFragmentForLayout,
|
mapDesignFragmentForLayout,
|
||||||
mapDesignFragmentFromLayout,
|
mapDesignFragmentFromLayout,
|
||||||
@@ -214,7 +209,7 @@ const iconGroups: Array<{ title: string; note: string; icons: IconName[] }> = [
|
|||||||
{
|
{
|
||||||
title: "Состояние и доступ",
|
title: "Состояние и доступ",
|
||||||
note: "Вся платформа",
|
note: "Вся платформа",
|
||||||
icons: ["check", "alert", "activity", "lock", "key", "shield", "circle", "eye", "eye-off"],
|
icons: ["check", "alert", "activity", "lock", "key", "shield", "circle"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Сущности",
|
title: "Сущности",
|
||||||
@@ -224,7 +219,7 @@ const iconGroups: Array<{ title: string; note: string; icons: IconName[] }> = [
|
|||||||
{
|
{
|
||||||
title: "Контент",
|
title: "Контент",
|
||||||
note: "SEO / BIM / Engine",
|
note: "SEO / BIM / Engine",
|
||||||
icons: ["camera", "plan", "play", "stop", "image", "video", "file", "folder", "clipboard", "settings"],
|
icons: ["image", "video", "file", "folder", "clipboard", "settings"],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -233,7 +228,6 @@ const iconLabels: Record<IconName, string> = {
|
|||||||
alert: "Предупреждение",
|
alert: "Предупреждение",
|
||||||
apps: "Приложения",
|
apps: "Приложения",
|
||||||
building: "Компания",
|
building: "Компания",
|
||||||
camera: "Камера",
|
|
||||||
check: "Готово",
|
check: "Готово",
|
||||||
"chevron-down": "Раскрыть",
|
"chevron-down": "Раскрыть",
|
||||||
"chevron-left": "Назад",
|
"chevron-left": "Назад",
|
||||||
@@ -245,8 +239,6 @@ const iconLabels: Record<IconName, string> = {
|
|||||||
database: "База данных",
|
database: "База данных",
|
||||||
download: "Скачать",
|
download: "Скачать",
|
||||||
edit: "Редактировать",
|
edit: "Редактировать",
|
||||||
eye: "Показать",
|
|
||||||
"eye-off": "Скрыть",
|
|
||||||
expand: "Развернуть",
|
expand: "Развернуть",
|
||||||
external: "Открыть снаружи",
|
external: "Открыть снаружи",
|
||||||
file: "Файл",
|
file: "Файл",
|
||||||
@@ -262,8 +254,6 @@ const iconLabels: Record<IconName, string> = {
|
|||||||
minimize: "Свернуть",
|
minimize: "Свернуть",
|
||||||
network: "Связи",
|
network: "Связи",
|
||||||
panel: "Панель",
|
panel: "Панель",
|
||||||
plan: "План",
|
|
||||||
play: "Воспроизвести",
|
|
||||||
target: "Таргеты",
|
target: "Таргеты",
|
||||||
plus: "Добавить",
|
plus: "Добавить",
|
||||||
profile: "Профиль",
|
profile: "Профиль",
|
||||||
@@ -273,7 +263,6 @@ const iconLabels: Record<IconName, string> = {
|
|||||||
settings: "Настройки",
|
settings: "Настройки",
|
||||||
shield: "Доступ",
|
shield: "Доступ",
|
||||||
sliders: "Параметры",
|
sliders: "Параметры",
|
||||||
stop: "Остановить",
|
|
||||||
trash: "Удалить",
|
trash: "Удалить",
|
||||||
upload: "Загрузить",
|
upload: "Загрузить",
|
||||||
users: "Участники",
|
users: "Участники",
|
||||||
@@ -371,7 +360,6 @@ export function CatalogApp() {
|
|||||||
const [workspaceWindowDemoOpen, setWorkspaceWindowDemoOpen] = useState(true);
|
const [workspaceWindowDemoOpen, setWorkspaceWindowDemoOpen] = useState(true);
|
||||||
const [workspaceWindowDemoMaximized, setWorkspaceWindowDemoMaximized] = useState(false);
|
const [workspaceWindowDemoMaximized, setWorkspaceWindowDemoMaximized] = useState(false);
|
||||||
const [workspaceWindowDemoRect, setWorkspaceWindowDemoRect] = useState<WorkspaceWindowRect>({ x: 24, y: 24, width: 340, height: 230 });
|
const [workspaceWindowDemoRect, setWorkspaceWindowDemoRect] = useState<WorkspaceWindowRect>({ x: 24, y: 24, width: 340, height: 230 });
|
||||||
const [splitPaneDemoSize, setSplitPaneDemoSize] = useState(50);
|
|
||||||
const [sidePanelDemoOpen, setSidePanelDemoOpen] = useState(true);
|
const [sidePanelDemoOpen, setSidePanelDemoOpen] = useState(true);
|
||||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
@@ -1291,7 +1279,7 @@ export function CatalogApp() {
|
|||||||
<>
|
<>
|
||||||
<ControlRow label="Цвет"><ColorField value={lightColor} onChange={setLightColor} /></ControlRow>
|
<ControlRow label="Цвет"><ColorField value={lightColor} onChange={setLightColor} /></ControlRow>
|
||||||
<RangeControl label="Яркость" value={brightness} min={0} max={100} formatValue={(value) => `${value}%`} onChange={setBrightness} />
|
<RangeControl label="Яркость" value={brightness} min={0} max={100} formatValue={(value) => `${value}%`} onChange={setBrightness} />
|
||||||
<RangeControl label="Дистанция свечения" value={glowDistance} min={0} max={200} exactValueBounds={{ min: 0 }} formatValue={(value) => `${value}%`} onChange={setGlowDistance} />
|
<RangeControl label="Дистанция свечения" value={glowDistance} min={0} max={200} formatValue={(value) => `${value}%`} onChange={setGlowDistance} />
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -1337,7 +1325,7 @@ export function CatalogApp() {
|
|||||||
case "controls":
|
case "controls":
|
||||||
return (
|
return (
|
||||||
<div className="catalog-grid">
|
<div className="catalog-grid">
|
||||||
<Preview title="Selection" note="integrated / split / inline">
|
<Preview title="Selection" note="integrated / split">
|
||||||
<div className="catalog-form">
|
<div className="catalog-form">
|
||||||
<FieldFrame label="Hub / integrated select">
|
<FieldFrame label="Hub / integrated select">
|
||||||
<Select label="Статус" value={selectedStatus} options={[...selectOptions]} searchable onChange={setSelectedStatus} />
|
<Select label="Статус" value={selectedStatus} options={[...selectOptions]} searchable onChange={setSelectedStatus} />
|
||||||
@@ -1355,19 +1343,6 @@ export function CatalogApp() {
|
|||||||
onChange={setConnectionType}
|
onChange={setConnectionType}
|
||||||
/>
|
/>
|
||||||
</FieldFrame>
|
</FieldFrame>
|
||||||
<FieldFrame label="Toolbar / inline select">
|
|
||||||
<Select
|
|
||||||
variant="inline"
|
|
||||||
label="Скорость воспроизведения"
|
|
||||||
value="1"
|
|
||||||
options={[
|
|
||||||
{ value: "0.5", label: "0,5×" },
|
|
||||||
{ value: "1", label: "1×" },
|
|
||||||
{ value: "2", label: "2×" },
|
|
||||||
]}
|
|
||||||
onChange={() => undefined}
|
|
||||||
/>
|
|
||||||
</FieldFrame>
|
|
||||||
<Dropdown
|
<Dropdown
|
||||||
trigger={({ open, toggle, setTriggerRef, surfaceId }) => (
|
trigger={({ open, toggle, setTriggerRef, surfaceId }) => (
|
||||||
<Button ref={setTriggerRef} aria-expanded={open} aria-controls={surfaceId} icon={<Icon name="chevron-down" />} onClick={toggle}>Действия</Button>
|
<Button ref={setTriggerRef} aria-expanded={open} aria-controls={surfaceId} icon={<Icon name="chevron-down" />} onClick={toggle}>Действия</Button>
|
||||||
@@ -1398,34 +1373,6 @@ export function CatalogApp() {
|
|||||||
<IconButton label="Добавить"><Icon name="plus" /></IconButton>
|
<IconButton label="Добавить"><Icon name="plus" /></IconButton>
|
||||||
</div>
|
</div>
|
||||||
</Preview>
|
</Preview>
|
||||||
<Preview title="Активность" note="default / compact" className="catalog-preview--compact">
|
|
||||||
<div className="catalog-inline catalog-inline--wrap">
|
|
||||||
<span className="catalog-inline">
|
|
||||||
<ActivityIndicator label="Загружаем данные" />
|
|
||||||
<span>Загружаем данные</span>
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
aria-busy="true"
|
|
||||||
disabled
|
|
||||||
icon={<ActivityIndicator size="compact" />}
|
|
||||||
>Подключаем…</Button>
|
|
||||||
</div>
|
|
||||||
<p className="catalog-preview__explanation">При reduced motion кольцо остаётся видимым без вращения; процесс и его завершение принадлежат приложению.</p>
|
|
||||||
</Preview>
|
|
||||||
<Preview title="Лампы состояния" note="один индикатор без дублирующего значка"><StatusBadge variant="indicator" tone="success" aria-label="Готово" title="Готово" /><StatusBadge variant="indicator" aria-label="Недоступно" title="Недоступно" /></Preview>
|
|
||||||
<Preview title="Линейный прогресс" note="измеренный / неизвестный / завершённый">
|
|
||||||
<ProgressBar label="Подготовка" value={0.6} valueText="Три этапа из пяти" />
|
|
||||||
<ProgressBar label="Получение сведений" />
|
|
||||||
<ProgressBar label="Завершено" value={1} />
|
|
||||||
</Preview>
|
|
||||||
<Preview title="Строки ресурсов" note="Mission Core / общий список">
|
|
||||||
<ResourceList aria-label="Пример списка ресурсов">
|
|
||||||
<li><ResourceRow icon={<Icon name="file" />} title="Сохранённый результат" metadata="Сегодня · доступен для просмотра" actions={<IconButton label="Просмотреть пример" onClick={() => setProjectName("Сохранённый результат")}><Icon name="eye" /></IconButton>} /></li>
|
|
||||||
<li><ResourceRow icon={<Icon name="camera" />} title="Подключённая камера" description="Индикатор перед названием" statusPlacement="leading" status={<StatusBadge variant="indicator" tone="success" aria-label="Подключено" title="Подключено" />} actions={<IconButton label="Просмотреть камеру" onClick={() => setProjectName("Подключённая камера")}><Icon name="eye" /></IconButton>} /></li>
|
|
||||||
<li><ResourceRow icon={<Icon name="camera" />} title="Подготовка камеры" metadata="Проверка потоков" progress={{label:"Подготовка камеры",value:0.8}} aria-busy="true" actions={<IconButton label="Просмотр недоступен" disabled><Icon name="eye" /></IconButton>} /></li>
|
|
||||||
</ResourceList>
|
|
||||||
<SettingsCard title="Устройства не обнаружены" description="Подключите устройство, чтобы оно появилось в списке." />
|
|
||||||
</Preview>
|
|
||||||
<Preview title="Оконные действия" note="круг `46 px`" className="catalog-preview--compact">
|
<Preview title="Оконные действия" note="круг `46 px`" className="catalog-preview--compact">
|
||||||
<div className="catalog-window-actions-demo">
|
<div className="catalog-window-actions-demo">
|
||||||
<Button shape="pill" icon={<Icon name="refresh" />}>Обновить источник</Button>
|
<Button shape="pill" icon={<Icon name="refresh" />}>Обновить источник</Button>
|
||||||
@@ -1434,21 +1381,6 @@ export function CatalogApp() {
|
|||||||
<IconButton label="Закрыть"><Icon name="close" /></IconButton>
|
<IconButton label="Закрыть"><Icon name="close" /></IconButton>
|
||||||
</div>
|
</div>
|
||||||
</Preview>
|
</Preview>
|
||||||
<Preview title="Вертикальная рейка" note="glass pill / круг `46 px`" className="catalog-preview--compact">
|
|
||||||
<GlassSurface
|
|
||||||
className="catalog-icon-rail-demo"
|
|
||||||
tone="strong"
|
|
||||||
radius="pill"
|
|
||||||
padding="sm"
|
|
||||||
materialRim={false}
|
|
||||||
role="toolbar"
|
|
||||||
aria-label="Режимы просмотра"
|
|
||||||
>
|
|
||||||
<IconButton label="Камера" aria-pressed="true"><Icon name="camera" /></IconButton>
|
|
||||||
<IconButton label="3D"><span aria-hidden="true">3D</span></IconButton>
|
|
||||||
<IconButton label="План"><Icon name="plan" /></IconButton>
|
|
||||||
</GlassSurface>
|
|
||||||
</Preview>
|
|
||||||
<Preview title="Workspace window" note="inline / bounded / controlled" className="catalog-preview--wide catalog-workspace-window-preview">
|
<Preview title="Workspace window" note="inline / bounded / controlled" className="catalog-preview--wide catalog-workspace-window-preview">
|
||||||
<div ref={workspaceWindowDemoRef} className="catalog-workspace-window-demo">
|
<div ref={workspaceWindowDemoRef} className="catalog-workspace-window-demo">
|
||||||
{workspaceWindowDemoOpen ? (
|
{workspaceWindowDemoOpen ? (
|
||||||
@@ -1487,20 +1419,6 @@ export function CatalogApp() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="catalog-preview__explanation">Rectangle, maximize, close и stacking остаются состоянием приложения; дизайн-система владеет одинаковой bounded-геометрией и доступным управлением.</p>
|
<p className="catalog-preview__explanation">Rectangle, maximize, close и stacking остаются состоянием приложения; дизайн-система владеет одинаковой bounded-геометрией и доступным управлением.</p>
|
||||||
</Preview>
|
</Preview>
|
||||||
<Preview title="Split pane" note="pointer / keyboard / controlled" className="catalog-preview--wide catalog-split-pane-preview">
|
|
||||||
<div className="catalog-split-pane-demo">
|
|
||||||
<SplitPane
|
|
||||||
primarySize={splitPaneDemoSize}
|
|
||||||
onPrimarySizeChange={setSplitPaneDemoSize}
|
|
||||||
minPrimarySize={25}
|
|
||||||
minSecondarySize={25}
|
|
||||||
separatorLabel="Изменить ширину синхронных представлений"
|
|
||||||
primary={<div className="catalog-split-pane-demo__panel"><Icon name="video" /><strong>Видео</strong></div>}
|
|
||||||
secondary={<div className="catalog-split-pane-demo__panel"><Icon name="grid" /><strong>Пространственная сцена</strong></div>}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="catalog-preview__explanation">Граница изменяет контролируемую долю панелей; клавиши и pointer используют одни ограничения, а resize контейнера не сбрасывает выбранное соотношение.</p>
|
|
||||||
</Preview>
|
|
||||||
<Preview title="Application side panel" note="end / push / controlled" className="catalog-preview--wide catalog-side-panel-preview">
|
<Preview title="Application side panel" note="end / push / controlled" className="catalog-preview--wide catalog-side-panel-preview">
|
||||||
<div className="catalog-side-panel-demo">
|
<div className="catalog-side-panel-demo">
|
||||||
{sidePanelDemoOpen ? (
|
{sidePanelDemoOpen ? (
|
||||||
@@ -1704,8 +1622,6 @@ export function CatalogApp() {
|
|||||||
<div className="catalog-icon-catalog">
|
<div className="catalog-icon-catalog">
|
||||||
<Preview title="Размеры и поверхности" note="glyph 16 px" className="catalog-preview--wide">
|
<Preview title="Размеры и поверхности" note="glyph 16 px" className="catalog-preview--wide">
|
||||||
<div className="catalog-window-actions-demo">
|
<div className="catalog-window-actions-demo">
|
||||||
<IconButton label="Камера включена" aria-pressed="true"><Icon name="camera" /></IconButton>
|
|
||||||
<IconButton label="План"><Icon name="plan" /></IconButton>
|
|
||||||
<IconButton label="Добавить"><Icon name="plus" /></IconButton>
|
<IconButton label="Добавить"><Icon name="plus" /></IconButton>
|
||||||
<IconButton label="Обновить"><Icon name="refresh" /></IconButton>
|
<IconButton label="Обновить"><Icon name="refresh" /></IconButton>
|
||||||
<IconButton label="Развернуть"><Icon name="expand" /></IconButton>
|
<IconButton label="Развернуть"><Icon name="expand" /></IconButton>
|
||||||
@@ -2010,7 +1926,6 @@ export function CatalogApp() {
|
|||||||
endPanel={<div ref={setMapSettingsPanelHost} className="catalog-map-settings-panel-host" />}
|
endPanel={<div ref={setMapSettingsPanelHost} className="catalog-map-settings-panel-host" />}
|
||||||
header={
|
header={
|
||||||
<AppHeader
|
<AppHeader
|
||||||
brandMonochrome
|
|
||||||
brand={<img src="/nodedc-logo.svg" alt="NODE.DC" />}
|
brand={<img src="/nodedc-logo.svg" alt="NODE.DC" />}
|
||||||
brandHref="/"
|
brandHref="/"
|
||||||
center={
|
center={
|
||||||
|
|||||||
+124
-1991
File diff suppressed because it is too large
Load Diff
+396
-1844
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,212 @@
|
|||||||
|
import type { RefObject } from "react";
|
||||||
|
import { Button, Checker, Icon, WorkspaceWindow } from "@nodedc/ui-react";
|
||||||
|
import type { WorkspaceWindowRect } from "@nodedc/ui-react";
|
||||||
|
import type { GridSectorSelection } from "./mapRendererContract.js";
|
||||||
|
import {
|
||||||
|
MAP_SCOPE_MISSING_VALUE,
|
||||||
|
MAP_SCOPE_OBJECT_KIND_FIELD,
|
||||||
|
MAP_SCOPE_PROVIDER_FIELD,
|
||||||
|
formatGridSectorArea,
|
||||||
|
gridSectorBoundsLabel,
|
||||||
|
normalizedSectorScopeValue,
|
||||||
|
} from "./mapSectorWorkspace.js";
|
||||||
|
import type { MapSelectableEntity } from "./mapWorkspaceModel.mjs";
|
||||||
|
|
||||||
|
type ScopeOption = { value: string; label: string; count: number };
|
||||||
|
|
||||||
|
export function MapSectorWorkspaceWindow({
|
||||||
|
boundsRef,
|
||||||
|
selection,
|
||||||
|
rect,
|
||||||
|
maximized,
|
||||||
|
active,
|
||||||
|
zIndex,
|
||||||
|
copyState,
|
||||||
|
spatialEntities,
|
||||||
|
visibleEntities,
|
||||||
|
bindingOptions,
|
||||||
|
providerOptions,
|
||||||
|
objectKindOptions,
|
||||||
|
excludedBindingIds,
|
||||||
|
excludedProviders,
|
||||||
|
excludedObjectKinds,
|
||||||
|
hideOutside,
|
||||||
|
selectedEntityId,
|
||||||
|
onRectChange,
|
||||||
|
onMaximizedChange,
|
||||||
|
onActivate,
|
||||||
|
onDeactivate,
|
||||||
|
onCopyStableId,
|
||||||
|
onHideOutsideChange,
|
||||||
|
onBindingEnabledChange,
|
||||||
|
onProviderEnabledChange,
|
||||||
|
onObjectKindEnabledChange,
|
||||||
|
onSelectEntity,
|
||||||
|
}: {
|
||||||
|
boundsRef: RefObject<HTMLElement | null>;
|
||||||
|
selection: GridSectorSelection;
|
||||||
|
rect: WorkspaceWindowRect;
|
||||||
|
maximized: boolean;
|
||||||
|
active: boolean;
|
||||||
|
zIndex: number;
|
||||||
|
copyState: "idle" | "copied" | "error";
|
||||||
|
spatialEntities: MapSelectableEntity[];
|
||||||
|
visibleEntities: MapSelectableEntity[];
|
||||||
|
bindingOptions: ScopeOption[];
|
||||||
|
providerOptions: ScopeOption[];
|
||||||
|
objectKindOptions: ScopeOption[];
|
||||||
|
excludedBindingIds: string[];
|
||||||
|
excludedProviders: string[];
|
||||||
|
excludedObjectKinds: string[];
|
||||||
|
hideOutside: boolean;
|
||||||
|
selectedEntityId?: string;
|
||||||
|
onRectChange: (rect: WorkspaceWindowRect) => void;
|
||||||
|
onMaximizedChange: (value: boolean) => void;
|
||||||
|
onActivate: () => void;
|
||||||
|
onDeactivate: () => void;
|
||||||
|
onCopyStableId: () => void;
|
||||||
|
onHideOutsideChange: (value: boolean) => void;
|
||||||
|
onBindingEnabledChange: (bindingId: string, enabled: boolean) => void;
|
||||||
|
onProviderEnabledChange: (provider: string, enabled: boolean) => void;
|
||||||
|
onObjectKindEnabledChange: (objectKind: string, enabled: boolean) => void;
|
||||||
|
onSelectEntity: (entityId: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<WorkspaceWindow
|
||||||
|
boundsRef={boundsRef}
|
||||||
|
rect={rect}
|
||||||
|
onRectChange={onRectChange}
|
||||||
|
maximized={maximized}
|
||||||
|
onMaximizedChange={onMaximizedChange}
|
||||||
|
onActivate={onActivate}
|
||||||
|
onClose={onDeactivate}
|
||||||
|
title={`Активный сектор · LOD ${selection.lod}`}
|
||||||
|
subtitle={selection.label}
|
||||||
|
status={`${visibleEntities.length} / ${spatialEntities.length}`}
|
||||||
|
active={active}
|
||||||
|
zIndex={zIndex}
|
||||||
|
minWidth={340}
|
||||||
|
minHeight={380}
|
||||||
|
footer={(
|
||||||
|
<Button variant="secondary" size="compact" width="full" onClick={onDeactivate}>
|
||||||
|
Деактивировать сектор
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
className="catalog-map-fixture__sector-window catalog-map-fixture__map-glass-window"
|
||||||
|
aria-label={`Активный сектор: ${selection.id}`}
|
||||||
|
>
|
||||||
|
<div className="catalog-map-sector-window">
|
||||||
|
<section className="catalog-map-sector-window__summary" aria-label="Параметры сектора">
|
||||||
|
<code title={selection.id}>{selection.id}</code>
|
||||||
|
<span>{gridSectorBoundsLabel(selection)}</span>
|
||||||
|
<span>{formatGridSectorArea(selection.areaSquareMeters)}</span>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="compact"
|
||||||
|
width="full"
|
||||||
|
icon={<Icon name={copyState === "copied" ? "check" : "copy"} />}
|
||||||
|
onClick={onCopyStableId}
|
||||||
|
>{copyState === "copied" ? "ID скопирован" : "Копировать stable ID"}</Button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Checker checked={hideOutside} label="Скрыть объекты за сектором" onChange={onHideOutsideChange} />
|
||||||
|
|
||||||
|
<ScopeFilterSection
|
||||||
|
id="map-sector-domains"
|
||||||
|
title="Домены данных"
|
||||||
|
options={bindingOptions}
|
||||||
|
excluded={excludedBindingIds}
|
||||||
|
onEnabledChange={onBindingEnabledChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{providerOptions.length ? (
|
||||||
|
<ScopeFilterSection
|
||||||
|
id="map-sector-providers"
|
||||||
|
title="Провайдеры"
|
||||||
|
options={providerOptions}
|
||||||
|
excluded={excludedProviders}
|
||||||
|
exposeRawValue
|
||||||
|
onEnabledChange={onProviderEnabledChange}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{objectKindOptions.length ? (
|
||||||
|
<ScopeFilterSection
|
||||||
|
id="map-sector-kinds"
|
||||||
|
title="Типы объектов"
|
||||||
|
options={objectKindOptions}
|
||||||
|
excluded={excludedObjectKinds}
|
||||||
|
exposeRawValue
|
||||||
|
onEnabledChange={onObjectKindEnabledChange}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="catalog-map-sector-window__objects" aria-labelledby="map-sector-objects-title">
|
||||||
|
<div className="catalog-map-sector-window__section-title">
|
||||||
|
<strong id="map-sector-objects-title">Объекты сектора</strong>
|
||||||
|
<small>{visibleEntities.length} / {spatialEntities.length}</small>
|
||||||
|
</div>
|
||||||
|
<div className="catalog-map-sector-window__object-list">
|
||||||
|
{visibleEntities.map((entity) => {
|
||||||
|
const provider = normalizedSectorScopeValue(entity.fact.attributes[MAP_SCOPE_PROVIDER_FIELD]);
|
||||||
|
const objectKind = normalizedSectorScopeValue(entity.fact.attributes[MAP_SCOPE_OBJECT_KIND_FIELD]);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={entity.id}
|
||||||
|
data-selected={entity.id === selectedEntityId || undefined}
|
||||||
|
onClick={() => onSelectEntity(entity.id)}
|
||||||
|
>
|
||||||
|
<span>{entity.title}</span>
|
||||||
|
<code>{entity.fact.sourceId}</code>
|
||||||
|
<small>{[provider, objectKind, entity.status].filter(Boolean).join(" · ")}</small>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!visibleEntities.length ? (
|
||||||
|
<small className="catalog-map-sector-window__empty">
|
||||||
|
{spatialEntities.length
|
||||||
|
? "Объекты скрыты текущими фильтрами."
|
||||||
|
: "В секторе нет точечных объектов подключённых Data Products."}
|
||||||
|
</small>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</WorkspaceWindow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScopeFilterSection({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
options,
|
||||||
|
excluded,
|
||||||
|
exposeRawValue = false,
|
||||||
|
onEnabledChange,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
options: ScopeOption[];
|
||||||
|
excluded: string[];
|
||||||
|
exposeRawValue?: boolean;
|
||||||
|
onEnabledChange: (value: string, enabled: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="catalog-map-sector-window__filters" aria-labelledby={`${id}-title`}>
|
||||||
|
<div className="catalog-map-sector-window__section-title">
|
||||||
|
<strong id={`${id}-title`}>{title}</strong>
|
||||||
|
<small>{options.length}</small>
|
||||||
|
</div>
|
||||||
|
{options.map((option) => (
|
||||||
|
<Checker
|
||||||
|
key={option.value}
|
||||||
|
checked={!excluded.includes(option.value)}
|
||||||
|
label={`${option.label} · ${option.count}`}
|
||||||
|
title={exposeRawValue && option.value !== MAP_SCOPE_MISSING_VALUE ? option.value : undefined}
|
||||||
|
onChange={(enabled) => onEnabledChange(option.value, enabled)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import type { Dispatch, RefObject } from "react";
|
||||||
|
import { Icon, SegmentedControl, WorkspaceWindow } from "@nodedc/ui-react";
|
||||||
|
import {
|
||||||
|
mapFactMatchesFilters,
|
||||||
|
mapPresentationFacetValueIsEnabled,
|
||||||
|
} from "./mapPresentationProfile.mjs";
|
||||||
|
import type { MapSubjectCardModel } from "./mapSubjectCard.mjs";
|
||||||
|
import {
|
||||||
|
mapProfileHasSubjectWindowControls,
|
||||||
|
type MapFilteredTarget,
|
||||||
|
type MapPresentationSummary,
|
||||||
|
type MapSelectableEntity,
|
||||||
|
} from "./mapWorkspaceModel.mjs";
|
||||||
|
import type {
|
||||||
|
MapWorkspaceAction,
|
||||||
|
MapWorkspaceState,
|
||||||
|
} from "./mapWorkspaceState.mjs";
|
||||||
|
|
||||||
|
export function MapSubjectWorkspaceWindows({
|
||||||
|
boundsRef,
|
||||||
|
workspaceState,
|
||||||
|
dispatch,
|
||||||
|
summaries,
|
||||||
|
filteredTargets,
|
||||||
|
selectable,
|
||||||
|
expandedFacetRows,
|
||||||
|
selectedSubjectCard,
|
||||||
|
onToggleFacetRow,
|
||||||
|
onTogglePresentationFilter,
|
||||||
|
onSelectEntity,
|
||||||
|
}: {
|
||||||
|
boundsRef: RefObject<HTMLElement | null>;
|
||||||
|
workspaceState: MapWorkspaceState;
|
||||||
|
dispatch: Dispatch<MapWorkspaceAction>;
|
||||||
|
summaries: MapPresentationSummary[];
|
||||||
|
filteredTargets: MapFilteredTarget[];
|
||||||
|
selectable: MapSelectableEntity[];
|
||||||
|
expandedFacetRows: Record<string, boolean>;
|
||||||
|
selectedSubjectCard: MapSubjectCardModel | null;
|
||||||
|
onToggleFacetRow: (rowId: string) => void;
|
||||||
|
onTogglePresentationFilter: (bindingId: string, field: string, value: string, availableValues: string[]) => void;
|
||||||
|
onSelectEntity: (entityId: string) => void;
|
||||||
|
}) {
|
||||||
|
const selectedEntityId = workspaceState.selectedEntityId;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{summaries.map((summary) => {
|
||||||
|
const state = workspaceState.subjectStates[summary.bindingId];
|
||||||
|
if (!state?.window.open || !mapProfileHasSubjectWindowControls(summary.profile)) return null;
|
||||||
|
const visibleCount = filteredTargets.filter((target) => target.bindingId === summary.bindingId && target.renderable).length;
|
||||||
|
return (
|
||||||
|
<WorkspaceWindow
|
||||||
|
key={summary.bindingId}
|
||||||
|
boundsRef={boundsRef}
|
||||||
|
rect={state.window.rect}
|
||||||
|
onRectChange={(rect) => dispatch({
|
||||||
|
type: "set-subject-window-rect",
|
||||||
|
bindingId: summary.bindingId,
|
||||||
|
rect,
|
||||||
|
})}
|
||||||
|
maximized={state.window.maximized}
|
||||||
|
onMaximizedChange={(value) => dispatch({
|
||||||
|
type: "set-subject-window-maximized",
|
||||||
|
bindingId: summary.bindingId,
|
||||||
|
value,
|
||||||
|
})}
|
||||||
|
onActivate={() => dispatch({ type: "activate-window", windowId: `binding:${summary.bindingId}` })}
|
||||||
|
onClose={() => dispatch({ type: "close-subject-window", bindingId: summary.bindingId })}
|
||||||
|
title={summary.displayName}
|
||||||
|
subtitle={`${summary.total} всего · ${visibleCount} на карте`}
|
||||||
|
active={workspaceState.activeWindowId === `binding:${summary.bindingId}`}
|
||||||
|
zIndex={state.window.zIndex}
|
||||||
|
minWidth={240}
|
||||||
|
minHeight={220}
|
||||||
|
autoHeight
|
||||||
|
className="catalog-map-fixture__subject-window catalog-map-fixture__map-glass-window"
|
||||||
|
>
|
||||||
|
<div className="catalog-map-fixture__target-filters">
|
||||||
|
<section aria-label={`${summary.displayName}: фильтры и счётчики`}>
|
||||||
|
<div className="catalog-map-fixture__target-filter-list">
|
||||||
|
{summary.profile.facets.filter((facet) => facet.counter || facet.filterable).flatMap((facet) => (
|
||||||
|
facet.values.map((item) => {
|
||||||
|
const active = mapPresentationFacetValueIsEnabled(state.filters, facet.field, item.value);
|
||||||
|
const rowId = `${summary.bindingId}:${facet.field}:${item.value}`;
|
||||||
|
const expanded = Boolean(expandedFacetRows[rowId]);
|
||||||
|
const matchingEntities = selectable.filter((entity) => (
|
||||||
|
entity.bindingId === summary.bindingId
|
||||||
|
&& mapFactMatchesFilters(
|
||||||
|
entity.fact,
|
||||||
|
summary.profile,
|
||||||
|
{
|
||||||
|
[summary.bindingId]: {
|
||||||
|
visible: true,
|
||||||
|
facets: { [facet.field]: [item.value] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
summary.bindingId,
|
||||||
|
)
|
||||||
|
));
|
||||||
|
return (
|
||||||
|
<div className="catalog-map-fixture__target-filter-branch" key={`${facet.field}:${item.value}`}>
|
||||||
|
<div className="catalog-map-fixture__target-filter-row" data-active={active || undefined}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="catalog-map-fixture__target-filter-body"
|
||||||
|
aria-pressed={active}
|
||||||
|
disabled={!facet.filterable}
|
||||||
|
onClick={() => onTogglePresentationFilter(
|
||||||
|
summary.bindingId,
|
||||||
|
facet.field,
|
||||||
|
item.value,
|
||||||
|
facet.values.map((value) => value.value),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="catalog-map-fixture__target-filter-label">{item.label}</span>
|
||||||
|
<span className="catalog-map-fixture__target-filter-count">{summary.counts[facet.field]?.[item.value] ?? 0}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="catalog-map-fixture__target-filter-expander"
|
||||||
|
aria-label={`${expanded ? "Свернуть" : "Развернуть"} ${item.label}`}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
onClick={() => onToggleFacetRow(rowId)}
|
||||||
|
>
|
||||||
|
<Icon name="chevron-right" size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{expanded ? (
|
||||||
|
<div className="catalog-map-fixture__target-filter-children">
|
||||||
|
{matchingEntities.map((entity) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="catalog-map-fixture__target-filter-entity"
|
||||||
|
key={entity.id}
|
||||||
|
data-selected={entity.id === selectedEntityId || undefined}
|
||||||
|
onClick={() => onSelectEntity(entity.id)}
|
||||||
|
>
|
||||||
|
<span>{entity.title}</span>
|
||||||
|
{entity.status ? <small>{entity.status}</small> : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{!matchingEntities.length ? <small>Нет объектов в группе.</small> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</WorkspaceWindow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{workspaceState.subjectCard.open && selectedSubjectCard ? (
|
||||||
|
<WorkspaceWindow
|
||||||
|
boundsRef={boundsRef}
|
||||||
|
rect={workspaceState.subjectCard.rect}
|
||||||
|
onRectChange={(rect) => dispatch({ type: "set-subject-card-rect", rect })}
|
||||||
|
maximized={workspaceState.subjectCard.maximized}
|
||||||
|
onMaximizedChange={(value) => dispatch({ type: "set-subject-card-maximized", value })}
|
||||||
|
onActivate={() => dispatch({ type: "activate-window", windowId: "subject-card" })}
|
||||||
|
onClose={() => dispatch({ type: "close-subject-card" })}
|
||||||
|
title={selectedSubjectCard.title}
|
||||||
|
subtitle={selectedSubjectCard.sourceId}
|
||||||
|
active={workspaceState.activeWindowId === "subject-card"}
|
||||||
|
zIndex={workspaceState.subjectCard.zIndex}
|
||||||
|
minWidth={320}
|
||||||
|
minHeight={320}
|
||||||
|
className="catalog-map-fixture__subject-card catalog-map-fixture__map-glass-window"
|
||||||
|
aria-label={`Карточка объекта: ${selectedSubjectCard.title}`}
|
||||||
|
>
|
||||||
|
<MapSubjectCard
|
||||||
|
model={selectedSubjectCard}
|
||||||
|
tabId={workspaceState.subjectCard.tabId}
|
||||||
|
onTabChange={(tabId) => dispatch({ type: "set-subject-card-tab", tabId })}
|
||||||
|
/>
|
||||||
|
</WorkspaceWindow>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MapSubjectCard({
|
||||||
|
model,
|
||||||
|
tabId,
|
||||||
|
onTabChange,
|
||||||
|
}: {
|
||||||
|
model: MapSubjectCardModel;
|
||||||
|
tabId: string;
|
||||||
|
onTabChange: (tabId: string) => void;
|
||||||
|
}) {
|
||||||
|
const activeTabId = model.tabs.some((tab) => tab.id === tabId) ? tabId : model.defaultTabId;
|
||||||
|
return (
|
||||||
|
<div className="catalog-map-subject-card">
|
||||||
|
<div className="catalog-map-subject-card__tabs">
|
||||||
|
<SegmentedControl
|
||||||
|
value={activeTabId}
|
||||||
|
items={model.tabs.map((tab) => ({ value: tab.id, label: tab.label }))}
|
||||||
|
label="Разделы карточки объекта"
|
||||||
|
onChange={onTabChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{model.tabs.filter((tab) => tab.id === activeTabId).map((tab) => (
|
||||||
|
<div key={tab.id} className="catalog-map-subject-card__tab-panel" role="tabpanel">
|
||||||
|
{tab.empty ? <div className="catalog-map-subject-card__empty">{tab.emptyMessage}</div> : null}
|
||||||
|
{tab.sections.map((section) => (
|
||||||
|
<section key={section.id} className="catalog-map-subject-card__section" aria-labelledby={`subject-card-${tab.id}-${section.id}`}>
|
||||||
|
<h3 id={`subject-card-${tab.id}-${section.id}`}>{section.label}</h3>
|
||||||
|
{section.rows.length ? (
|
||||||
|
<dl>
|
||||||
|
{section.rows.map((row) => (
|
||||||
|
<div key={row.key} className="catalog-map-subject-card__row">
|
||||||
|
<dt>{row.label}</dt>
|
||||||
|
<dd>{row.value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
) : null}
|
||||||
|
{section.readings.length ? (
|
||||||
|
<div className="catalog-map-subject-card__readings">
|
||||||
|
{section.readings.map((reading) => (
|
||||||
|
<div className="catalog-map-subject-card__reading" key={reading.id}>
|
||||||
|
<span>{reading.label}</span>
|
||||||
|
<strong>{reading.value}</strong>
|
||||||
|
{reading.observedAt ? <time dateTime={reading.observedAt}>{new Date(reading.observedAt).toLocaleString("ru-RU")}</time> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
import type { KeyboardEvent, RefObject } from "react";
|
||||||
|
import { Checker, Dropdown, Icon, IconButton } from "@nodedc/ui-react";
|
||||||
|
import type { MapPageSettings, MapSubjectState } from "./mapPageContract.js";
|
||||||
|
import type { MapProviderStatus } from "./mapRendererContract.js";
|
||||||
|
import type { MapSearchDocument } from "./mapSearch.mjs";
|
||||||
|
import {
|
||||||
|
mapProfileHasSubjectWindowControls,
|
||||||
|
type MapFilteredTarget,
|
||||||
|
type MapPresentationSummary,
|
||||||
|
} from "./mapWorkspaceModel.mjs";
|
||||||
|
|
||||||
|
type ReferenceObjectSummary = {
|
||||||
|
layer: { id: string; visible: boolean };
|
||||||
|
displayName: string;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const providerStateLabel: Record<MapProviderStatus["imagery"], string> = {
|
||||||
|
loading: "загружается",
|
||||||
|
ready: "готов",
|
||||||
|
error: "недоступен",
|
||||||
|
"not-configured": "не настроен",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MapWorkspaceToolbar({
|
||||||
|
searchOpen,
|
||||||
|
searchQuery,
|
||||||
|
searchInputRef,
|
||||||
|
searchResults,
|
||||||
|
searchActiveIndex,
|
||||||
|
remoteSearchQuery,
|
||||||
|
referenceSearchState,
|
||||||
|
summaries,
|
||||||
|
referenceSummaries,
|
||||||
|
subjectStates,
|
||||||
|
filteredTargets,
|
||||||
|
providerStatus,
|
||||||
|
mapSettings,
|
||||||
|
liveCacheSummary,
|
||||||
|
transportDiagnostic,
|
||||||
|
gatewayHealthAge,
|
||||||
|
gatewayCheckState,
|
||||||
|
gatewayCheckError,
|
||||||
|
onOpenSubjectWindow,
|
||||||
|
onToggleSubjectVisibility,
|
||||||
|
onToggleReferenceLayer,
|
||||||
|
onLayersOpenChange,
|
||||||
|
onTerrainChange,
|
||||||
|
onBuildingsVisibleChange,
|
||||||
|
onGridVisibleChange,
|
||||||
|
onCacheEnabledChange,
|
||||||
|
onCacheNoOverwriteChange,
|
||||||
|
onFitVisibleTargets,
|
||||||
|
onToggleSearch,
|
||||||
|
onSearchQueryChange,
|
||||||
|
onSearchKeyDown,
|
||||||
|
onSearchActiveIndexChange,
|
||||||
|
onSearchResult,
|
||||||
|
}: {
|
||||||
|
searchOpen: boolean;
|
||||||
|
searchQuery: string;
|
||||||
|
searchInputRef: RefObject<HTMLInputElement | null>;
|
||||||
|
searchResults: MapSearchDocument[];
|
||||||
|
searchActiveIndex: number;
|
||||||
|
remoteSearchQuery: string;
|
||||||
|
referenceSearchState: "idle" | "loading" | "ready" | "error";
|
||||||
|
summaries: MapPresentationSummary[];
|
||||||
|
referenceSummaries: ReferenceObjectSummary[];
|
||||||
|
subjectStates: Record<string, MapSubjectState>;
|
||||||
|
filteredTargets: MapFilteredTarget[];
|
||||||
|
providerStatus: MapProviderStatus;
|
||||||
|
mapSettings: MapPageSettings;
|
||||||
|
liveCacheSummary: string;
|
||||||
|
transportDiagnostic: string | null;
|
||||||
|
gatewayHealthAge: string | null;
|
||||||
|
gatewayCheckState: "idle" | "checking" | "ready" | "stale" | "error";
|
||||||
|
gatewayCheckError: string | null;
|
||||||
|
onOpenSubjectWindow: (bindingId: string) => void;
|
||||||
|
onToggleSubjectVisibility: (bindingId: string) => void;
|
||||||
|
onToggleReferenceLayer: (layerId: string) => void;
|
||||||
|
onLayersOpenChange: (open: boolean) => void;
|
||||||
|
onTerrainChange: (value: boolean) => void;
|
||||||
|
onBuildingsVisibleChange: (value: boolean) => void;
|
||||||
|
onGridVisibleChange: (value: boolean) => void;
|
||||||
|
onCacheEnabledChange: (value: boolean) => void;
|
||||||
|
onCacheNoOverwriteChange: (value: boolean) => void;
|
||||||
|
onFitVisibleTargets: () => void;
|
||||||
|
onToggleSearch: () => void;
|
||||||
|
onSearchQueryChange: (value: string) => void;
|
||||||
|
onSearchKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void;
|
||||||
|
onSearchActiveIndexChange: (index: number) => void;
|
||||||
|
onSearchResult: (result: MapSearchDocument) => void;
|
||||||
|
}) {
|
||||||
|
const objectLayerCount = summaries.length + referenceSummaries.length;
|
||||||
|
return (
|
||||||
|
<div className="catalog-map-fixture__toolbar" aria-label="Map toolbar" data-search-open={searchOpen || undefined}>
|
||||||
|
<MapObjectsMenu
|
||||||
|
objectLayerCount={objectLayerCount}
|
||||||
|
summaries={summaries}
|
||||||
|
referenceSummaries={referenceSummaries}
|
||||||
|
subjectStates={subjectStates}
|
||||||
|
filteredTargets={filteredTargets}
|
||||||
|
onOpenSubjectWindow={onOpenSubjectWindow}
|
||||||
|
onToggleSubjectVisibility={onToggleSubjectVisibility}
|
||||||
|
onToggleReferenceLayer={onToggleReferenceLayer}
|
||||||
|
/>
|
||||||
|
<MapLayersMenu
|
||||||
|
providerStatus={providerStatus}
|
||||||
|
mapSettings={mapSettings}
|
||||||
|
liveCacheSummary={liveCacheSummary}
|
||||||
|
transportDiagnostic={transportDiagnostic}
|
||||||
|
gatewayHealthAge={gatewayHealthAge}
|
||||||
|
gatewayCheckState={gatewayCheckState}
|
||||||
|
gatewayCheckError={gatewayCheckError}
|
||||||
|
onOpenChange={onLayersOpenChange}
|
||||||
|
onTerrainChange={onTerrainChange}
|
||||||
|
onBuildingsVisibleChange={onBuildingsVisibleChange}
|
||||||
|
onGridVisibleChange={onGridVisibleChange}
|
||||||
|
onCacheEnabledChange={onCacheEnabledChange}
|
||||||
|
onCacheNoOverwriteChange={onCacheNoOverwriteChange}
|
||||||
|
/>
|
||||||
|
<IconButton label="Обзор объектов" onClick={onFitVisibleTargets}><Icon name="globe" /></IconButton>
|
||||||
|
<IconButton
|
||||||
|
label={searchOpen ? "Закрыть поиск" : "Поиск"}
|
||||||
|
aria-expanded={searchOpen}
|
||||||
|
aria-controls="map-subject-search"
|
||||||
|
data-active={searchOpen || undefined}
|
||||||
|
onClick={onToggleSearch}
|
||||||
|
><Icon name="search" /></IconButton>
|
||||||
|
<MapSearch
|
||||||
|
open={searchOpen}
|
||||||
|
query={searchQuery}
|
||||||
|
inputRef={searchInputRef}
|
||||||
|
results={searchResults}
|
||||||
|
activeIndex={searchActiveIndex}
|
||||||
|
remoteQuery={remoteSearchQuery}
|
||||||
|
referenceSearchState={referenceSearchState}
|
||||||
|
onQueryChange={onSearchQueryChange}
|
||||||
|
onKeyDown={onSearchKeyDown}
|
||||||
|
onActiveIndexChange={onSearchActiveIndexChange}
|
||||||
|
onResult={onSearchResult}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MapObjectsMenu({
|
||||||
|
objectLayerCount,
|
||||||
|
summaries,
|
||||||
|
referenceSummaries,
|
||||||
|
subjectStates,
|
||||||
|
filteredTargets,
|
||||||
|
onOpenSubjectWindow,
|
||||||
|
onToggleSubjectVisibility,
|
||||||
|
onToggleReferenceLayer,
|
||||||
|
}: {
|
||||||
|
objectLayerCount: number;
|
||||||
|
summaries: MapPresentationSummary[];
|
||||||
|
referenceSummaries: ReferenceObjectSummary[];
|
||||||
|
subjectStates: Record<string, MapSubjectState>;
|
||||||
|
filteredTargets: MapFilteredTarget[];
|
||||||
|
onOpenSubjectWindow: (bindingId: string) => void;
|
||||||
|
onToggleSubjectVisibility: (bindingId: string) => void;
|
||||||
|
onToggleReferenceLayer: (layerId: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dropdown
|
||||||
|
placement="top-start"
|
||||||
|
width={320}
|
||||||
|
minWidth={240}
|
||||||
|
offset={10}
|
||||||
|
surfaceRole="menu"
|
||||||
|
surfaceClassName="catalog-map-fixture__objects-menu nodedc-map-glass"
|
||||||
|
trigger={({ open, toggle, setTriggerRef, surfaceId }) => (
|
||||||
|
<IconButton ref={setTriggerRef} label="Объекты" aria-controls={surfaceId} aria-expanded={open} aria-pressed={open} data-active={open || undefined} onClick={toggle}><Icon name="target" /></IconButton>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{({ close }) => (
|
||||||
|
<div className="catalog-map-fixture__objects-menu-list">
|
||||||
|
<div className="catalog-map-fixture__objects-menu-head">
|
||||||
|
<strong>Объекты</strong>
|
||||||
|
<small>{objectLayerCount} {objectLayerCount === 1 ? "группа" : "групп"}</small>
|
||||||
|
</div>
|
||||||
|
{summaries.map((summary) => {
|
||||||
|
const state = subjectStates[summary.bindingId];
|
||||||
|
const visibleCount = filteredTargets.filter((target) => target.bindingId === summary.bindingId && target.renderable).length;
|
||||||
|
const visible = state?.visible !== false;
|
||||||
|
const hasControls = mapProfileHasSubjectWindowControls(summary.profile);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="catalog-map-fixture__objects-menu-item"
|
||||||
|
key={summary.bindingId}
|
||||||
|
data-visible={visible || undefined}
|
||||||
|
data-open={hasControls && state?.window.open || undefined}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role={hasControls ? "menuitem" : "menuitemcheckbox"}
|
||||||
|
aria-checked={hasControls ? undefined : visible}
|
||||||
|
className="catalog-map-fixture__objects-menu-toggle"
|
||||||
|
onClick={() => {
|
||||||
|
if (hasControls) {
|
||||||
|
onOpenSubjectWindow(summary.bindingId);
|
||||||
|
close();
|
||||||
|
} else onToggleSubjectVisibility(summary.bindingId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{summary.displayName}</span>
|
||||||
|
<small>{visible ? `на карте: ${visibleCount}` : `слой скрыт · ${summary.total} объектов`}</small>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{referenceSummaries.map(({ layer, displayName, total }) => (
|
||||||
|
<div className="catalog-map-fixture__objects-menu-item" key={layer.id} data-visible={layer.visible || undefined}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitemcheckbox"
|
||||||
|
aria-checked={layer.visible}
|
||||||
|
className="catalog-map-fixture__objects-menu-toggle"
|
||||||
|
onClick={() => onToggleReferenceLayer(layer.id)}
|
||||||
|
>
|
||||||
|
<span>{displayName}</span>
|
||||||
|
<small>{layer.visible ? `на карте: ${total}` : `слой скрыт · ${total} объектов`}</small>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!objectLayerCount ? <small className="catalog-map-fixture__objects-menu-empty">Нет подключённых объектов.</small> : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MapLayersMenu({
|
||||||
|
providerStatus,
|
||||||
|
mapSettings,
|
||||||
|
liveCacheSummary,
|
||||||
|
transportDiagnostic,
|
||||||
|
gatewayHealthAge,
|
||||||
|
gatewayCheckState,
|
||||||
|
gatewayCheckError,
|
||||||
|
onOpenChange,
|
||||||
|
onTerrainChange,
|
||||||
|
onBuildingsVisibleChange,
|
||||||
|
onGridVisibleChange,
|
||||||
|
onCacheEnabledChange,
|
||||||
|
onCacheNoOverwriteChange,
|
||||||
|
}: {
|
||||||
|
providerStatus: MapProviderStatus;
|
||||||
|
mapSettings: MapPageSettings;
|
||||||
|
liveCacheSummary: string;
|
||||||
|
transportDiagnostic: string | null;
|
||||||
|
gatewayHealthAge: string | null;
|
||||||
|
gatewayCheckState: "idle" | "checking" | "ready" | "stale" | "error";
|
||||||
|
gatewayCheckError: string | null;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onTerrainChange: (value: boolean) => void;
|
||||||
|
onBuildingsVisibleChange: (value: boolean) => void;
|
||||||
|
onGridVisibleChange: (value: boolean) => void;
|
||||||
|
onCacheEnabledChange: (value: boolean) => void;
|
||||||
|
onCacheNoOverwriteChange: (value: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<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={onOpenChange}
|
||||||
|
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={onTerrainChange} />
|
||||||
|
<Checker checked={mapSettings.buildingsVisible} label="3D здания" onChange={onBuildingsVisibleChange} />
|
||||||
|
<Checker checked={mapSettings.gridVisible} label="Планетарная сетка" onChange={onGridVisibleChange} />
|
||||||
|
<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={onCacheEnabledChange} />
|
||||||
|
<Checker className="catalog-map-fixture__cache-toggle" checked={mapSettings.cacheNoOverwrite} disabled={!mapSettings.cacheEnabled} label="Не перезаписывать cache" onChange={onCacheNoOverwriteChange} />
|
||||||
|
</div>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MapSearch({
|
||||||
|
open,
|
||||||
|
query,
|
||||||
|
inputRef,
|
||||||
|
results,
|
||||||
|
activeIndex,
|
||||||
|
remoteQuery,
|
||||||
|
referenceSearchState,
|
||||||
|
onQueryChange,
|
||||||
|
onKeyDown,
|
||||||
|
onActiveIndexChange,
|
||||||
|
onResult,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
query: string;
|
||||||
|
inputRef: RefObject<HTMLInputElement | null>;
|
||||||
|
results: MapSearchDocument[];
|
||||||
|
activeIndex: number;
|
||||||
|
remoteQuery: string;
|
||||||
|
referenceSearchState: "idle" | "loading" | "ready" | "error";
|
||||||
|
onQueryChange: (value: string) => void;
|
||||||
|
onKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void;
|
||||||
|
onActiveIndexChange: (index: number) => void;
|
||||||
|
onResult: (result: MapSearchDocument) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="catalog-map-search" data-open={open || undefined}>
|
||||||
|
<label className="catalog-map-search__field" htmlFor="map-subject-search">
|
||||||
|
<Icon name="search" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
id="map-subject-search"
|
||||||
|
type="search"
|
||||||
|
value={query}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder="Название, ID объекта или трекера"
|
||||||
|
aria-label="Поиск объектов карты"
|
||||||
|
aria-controls="map-subject-search-results"
|
||||||
|
aria-activedescendant={results.length ? `map-subject-search-result-${activeIndex}` : undefined}
|
||||||
|
onChange={(event) => onQueryChange(event.target.value)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{query.trim() ? (
|
||||||
|
<div id="map-subject-search-results" className="catalog-map-search__results nodedc-map-glass" role="listbox" aria-label="Результаты поиска">
|
||||||
|
{results.map((result, index) => (
|
||||||
|
<button
|
||||||
|
key={`${result.bindingId}:${result.sourceId}`}
|
||||||
|
id={`map-subject-search-result-${index}`}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={index === activeIndex}
|
||||||
|
data-active={index === activeIndex || undefined}
|
||||||
|
onPointerEnter={() => onActiveIndexChange(index)}
|
||||||
|
onClick={() => onResult(result)}
|
||||||
|
>
|
||||||
|
<span>{result.title}</span>
|
||||||
|
<small>{result.groupTitle}</small>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{!results.length ? (
|
||||||
|
<small className="catalog-map-search__empty">
|
||||||
|
{remoteQuery === query.trim()
|
||||||
|
? (referenceSearchState === "loading"
|
||||||
|
? "Ищем станцию в OSM…"
|
||||||
|
: referenceSearchState === "error"
|
||||||
|
? "Поиск OSM временно недоступен; локальные данные сохранены."
|
||||||
|
: "Станции с таким точным названием не найдены.")
|
||||||
|
: "Совпадений нет. Enter — найти станцию по точному названию в OSM."}
|
||||||
|
</small>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { NodedcTheme } from "@nodedc/ui-core";
|
import type { NodedcTheme } from "@nodedc/ui-core";
|
||||||
import type { MapPageLayout, MapPageSettings } from "./MapFixturePreview.js";
|
import type { MapPageLayout, MapPageSettings } from "./mapPageContract.js";
|
||||||
import type { MapPresentationProfile } from "./mapPresentationProfile.js";
|
import type { MapPresentationProfile } from "./mapPresentationProfile.js";
|
||||||
|
|
||||||
export const applicationManifestSchemaVersion = "0.1.0" as const;
|
export const applicationManifestSchemaVersion = "0.1.0" as const;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { GlassMaterialSettings, NodedcTheme } from "@nodedc/ui-core";
|
import type { GlassMaterialSettings, NodedcTheme } from "@nodedc/ui-core";
|
||||||
import type { ToolbarPlacement } from "@nodedc/ui-react";
|
import type { ToolbarPlacement } from "@nodedc/ui-react";
|
||||||
import type { FaviconAssetUrls } from "./favicon.js";
|
import type { FaviconAssetUrls } from "./favicon.js";
|
||||||
import type { MapPageLayout, MapPageSettings } from "./MapFixturePreview.js";
|
import type { MapPageLayout, MapPageSettings } from "./mapPageContract.js";
|
||||||
import type { MapPresentationProfile } from "./mapPresentationProfile.js";
|
import type { MapPresentationProfile } from "./mapPresentationProfile.js";
|
||||||
|
|
||||||
export type MaterialDraft = {
|
export type MaterialDraft = {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,610 @@
|
|||||||
|
import {
|
||||||
|
BillboardGraphics,
|
||||||
|
CallbackProperty,
|
||||||
|
CallbackPositionProperty,
|
||||||
|
Cartesian2,
|
||||||
|
Cartesian3,
|
||||||
|
Cartographic,
|
||||||
|
ClassificationType,
|
||||||
|
Color,
|
||||||
|
ColorGeometryInstanceAttribute,
|
||||||
|
ConstantPositionProperty,
|
||||||
|
CustomDataSource,
|
||||||
|
GeometryInstance,
|
||||||
|
GroundPolylineGeometry,
|
||||||
|
GroundPolylinePrimitive,
|
||||||
|
GroundPrimitive,
|
||||||
|
HeightReference,
|
||||||
|
HorizontalOrigin,
|
||||||
|
LabelGraphics,
|
||||||
|
LabelStyle,
|
||||||
|
PerInstanceColorAppearance,
|
||||||
|
PointGraphics,
|
||||||
|
PolygonGeometry,
|
||||||
|
PolygonHierarchy,
|
||||||
|
PolylineColorAppearance,
|
||||||
|
PolylineGraphics,
|
||||||
|
VerticalOrigin,
|
||||||
|
Viewer,
|
||||||
|
} from "cesium";
|
||||||
|
import { mapRuntimeEntityId, type MapRuntimeBinding, type MapRuntimeFact } from "./useMapDataProductRuntime.js";
|
||||||
|
import {
|
||||||
|
mapPresentationProfileForFact,
|
||||||
|
mapRuntimeDisplayLabel,
|
||||||
|
mapRuntimeFactIsVisible,
|
||||||
|
resolveMapPresentationClass,
|
||||||
|
resolveMapPresentationStyle,
|
||||||
|
type MapPresentationFilters,
|
||||||
|
type MapPresentationProfile,
|
||||||
|
} from "./mapPresentationProfile.js";
|
||||||
|
import { normalizeHGeoZoneRing } from "./hGeoZoneProjection.mjs";
|
||||||
|
|
||||||
|
const MAX_HGEOZONE_INSTANCES_PER_BATCH = 256;
|
||||||
|
const elevatedTargetImageCache = new Map<string, string>();
|
||||||
|
|
||||||
|
function elevatedTargetImage(
|
||||||
|
fillColor: Color,
|
||||||
|
outlineColor: Color,
|
||||||
|
outlineWidthPx: number,
|
||||||
|
headSizePx: number,
|
||||||
|
) {
|
||||||
|
const safeHeadSize = Math.max(1, headSizePx);
|
||||||
|
const safeOutlineWidth = Math.max(0, outlineWidthPx);
|
||||||
|
const imageSize = Math.max(1, Math.ceil(safeHeadSize + safeOutlineWidth * 2));
|
||||||
|
const key = [
|
||||||
|
fillColor.toCssColorString(),
|
||||||
|
outlineColor.toCssColorString(),
|
||||||
|
safeOutlineWidth,
|
||||||
|
safeHeadSize,
|
||||||
|
imageSize,
|
||||||
|
].join("|");
|
||||||
|
const cached = elevatedTargetImageCache.get(key);
|
||||||
|
if (cached) return { image: cached, size: imageSize };
|
||||||
|
const center = imageSize / 2;
|
||||||
|
const radius = Math.max(0.5, safeHeadSize / 2);
|
||||||
|
const svg = [
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" width="${imageSize}" height="${imageSize}" viewBox="0 0 ${imageSize} ${imageSize}">`,
|
||||||
|
`<circle cx="${center}" cy="${center}" r="${radius}" fill="${fillColor.toCssColorString()}"`,
|
||||||
|
safeOutlineWidth > 0
|
||||||
|
? ` stroke="${outlineColor.toCssColorString()}" stroke-width="${safeOutlineWidth}"/>`
|
||||||
|
: "/>",
|
||||||
|
"</svg>",
|
||||||
|
].join("");
|
||||||
|
const image = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||||
|
elevatedTargetImageCache.set(key, image);
|
||||||
|
return { image, size: imageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
const accent = Color.fromCssColorString("#ff2f92");
|
||||||
|
const violet = Color.fromCssColorString("#8f72dc");
|
||||||
|
function showBelowCameraHeight(viewer: Viewer, limit?: number) {
|
||||||
|
if (!limit) return true;
|
||||||
|
return new CallbackProperty(() => Number(viewer.camera.positionCartographic?.height || 0) <= limit, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function elevatedPinGroundHeight(viewer: Viewer, longitude: number, latitude: number, fallbackHeightMeters = 0) {
|
||||||
|
const sampled = viewer.scene.globe.getHeight(Cartographic.fromDegrees(longitude, latitude));
|
||||||
|
return Number.isFinite(sampled) ? Number(sampled) : fallbackHeightMeters;
|
||||||
|
}
|
||||||
|
|
||||||
|
function elevatedPinTopPosition(
|
||||||
|
viewer: Viewer,
|
||||||
|
longitude: number,
|
||||||
|
latitude: number,
|
||||||
|
stemHeightMeters: number,
|
||||||
|
fallbackHeightMeters = 0,
|
||||||
|
) {
|
||||||
|
return new CallbackPositionProperty(() => Cartesian3.fromDegrees(
|
||||||
|
longitude,
|
||||||
|
latitude,
|
||||||
|
elevatedPinGroundHeight(viewer, longitude, latitude, fallbackHeightMeters) + stemHeightMeters,
|
||||||
|
), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function elevatedPinStemPositions(
|
||||||
|
viewer: Viewer,
|
||||||
|
longitude: number,
|
||||||
|
latitude: number,
|
||||||
|
stemHeightMeters: number,
|
||||||
|
fallbackHeightMeters = 0,
|
||||||
|
) {
|
||||||
|
return new CallbackProperty(() => {
|
||||||
|
const groundHeight = elevatedPinGroundHeight(viewer, longitude, latitude, fallbackHeightMeters);
|
||||||
|
return [
|
||||||
|
Cartesian3.fromDegrees(longitude, latitude, groundHeight),
|
||||||
|
Cartesian3.fromDegrees(longitude, latitude, groundHeight + stemHeightMeters),
|
||||||
|
];
|
||||||
|
}, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimePointColor(fact: MapRuntimeFact) {
|
||||||
|
// This is a semantic default for the generic Map entity-stream adapter,
|
||||||
|
// not a provider style. A renderer-neutral style profile can refine it
|
||||||
|
// later without changing a data product or its L2 workflow.
|
||||||
|
if (fact.presentationStatus === "stale") return Color.fromCssColorString("#f5a623");
|
||||||
|
if (["inactive", "no-position", "no_position"].includes(fact.presentationStatus)) {
|
||||||
|
return Color.fromCssColorString("#7d8491");
|
||||||
|
}
|
||||||
|
return fact.semanticType === "map.moving_object" ? accent : violet;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HGeoZonePickId = {
|
||||||
|
kind: "nodedc-hgeozone";
|
||||||
|
entityId: string;
|
||||||
|
instanceId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HGeoZoneFillPart = {
|
||||||
|
pickId: HGeoZonePickId;
|
||||||
|
hierarchy: PolygonHierarchy;
|
||||||
|
color: Color;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HGeoZoneOutlinePart = {
|
||||||
|
pickId: HGeoZonePickId;
|
||||||
|
positions: Cartesian3[];
|
||||||
|
color: Color;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HGeoZonePrimitiveBatch<TPart> = {
|
||||||
|
primitive: GroundPrimitive | GroundPolylinePrimitive;
|
||||||
|
parts: TPart[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HGeoZoneProjectionLayer = {
|
||||||
|
geometryKey: string;
|
||||||
|
styleKey: string;
|
||||||
|
hideCameraHeightMeters: number | null;
|
||||||
|
fills: Array<HGeoZonePrimitiveBatch<HGeoZoneFillPart>>;
|
||||||
|
outlines: Array<HGeoZonePrimitiveBatch<HGeoZoneOutlinePart>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function hGeoZoneRingPositions(ring: Array<[number, number]>) {
|
||||||
|
return normalizeHGeoZoneRing(ring)
|
||||||
|
.map(([longitude, latitude]) => Cartesian3.fromDegrees(longitude, latitude, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hGeoZoneHierarchy(polygon: Array<Array<[number, number]>>) {
|
||||||
|
const outer = hGeoZoneRingPositions(polygon[0]);
|
||||||
|
if (outer.length < 3) return null;
|
||||||
|
const holes = polygon.slice(1)
|
||||||
|
.map((ring) => hGeoZoneRingPositions(ring))
|
||||||
|
.filter((ring) => ring.length >= 3)
|
||||||
|
.map((ring) => new PolygonHierarchy(ring));
|
||||||
|
return new PolygonHierarchy(outer, holes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hGeoZoneBatches<T>(items: T[]) {
|
||||||
|
const batches: T[][] = [];
|
||||||
|
for (let index = 0; index < items.length; index += MAX_HGEOZONE_INSTANCES_PER_BATCH) {
|
||||||
|
batches.push(items.slice(index, index + MAX_HGEOZONE_INSTANCES_PER_BATCH));
|
||||||
|
}
|
||||||
|
return batches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeHGeoZoneLayer(viewer: Viewer, layer: HGeoZoneProjectionLayer) {
|
||||||
|
for (const batch of [...layer.fills, ...layer.outlines]) {
|
||||||
|
viewer.scene.groundPrimitives.remove(batch.primitive);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hGeoZoneFaultKey(bindingId: string, geometryKey: string) {
|
||||||
|
return `${bindingId}\u0000${geometryKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function quarantineHGeoZoneLayers(
|
||||||
|
viewer: Viewer,
|
||||||
|
layers: Map<string, HGeoZoneProjectionLayer>,
|
||||||
|
faultedGeometryKeys: Set<string>,
|
||||||
|
) {
|
||||||
|
if (!layers.size) return false;
|
||||||
|
const hasPendingGeometry = [...layers.values()].some((layer) => (
|
||||||
|
[...layer.fills, ...layer.outlines].some((batch) => !batch.primitive.ready)
|
||||||
|
));
|
||||||
|
if (!hasPendingGeometry) return false;
|
||||||
|
for (const [bindingId, layer] of layers) {
|
||||||
|
faultedGeometryKeys.add(hGeoZoneFaultKey(bindingId, layer.geometryKey));
|
||||||
|
removeHGeoZoneLayer(viewer, layer);
|
||||||
|
}
|
||||||
|
layers.clear();
|
||||||
|
viewer.scene.requestRender();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncHGeoZoneVisibility(viewer: Viewer, layers: Map<string, HGeoZoneProjectionLayer>) {
|
||||||
|
const cameraHeight = Number(viewer.camera.positionCartographic?.height || 0);
|
||||||
|
for (const layer of layers.values()) {
|
||||||
|
const show = layer.hideCameraHeightMeters === null || cameraHeight <= layer.hideCameraHeightMeters;
|
||||||
|
for (const batch of [...layer.fills, ...layer.outlines]) batch.primitive.show = show;
|
||||||
|
}
|
||||||
|
viewer.scene.requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHGeoZoneColors<TPart extends { pickId: HGeoZonePickId; color: Color }>(
|
||||||
|
batches: Array<HGeoZonePrimitiveBatch<TPart>>,
|
||||||
|
nextParts: TPart[],
|
||||||
|
) {
|
||||||
|
const nextColors = new Map(nextParts.map((part) => [part.pickId.instanceId, part.color]));
|
||||||
|
for (const batch of batches) {
|
||||||
|
for (const part of batch.parts) {
|
||||||
|
const color = nextColors.get(part.pickId.instanceId);
|
||||||
|
if (!color) continue;
|
||||||
|
const attributes = batch.primitive.getGeometryInstanceAttributes(part.pickId);
|
||||||
|
if (attributes) attributes.color = ColorGeometryInstanceAttribute.toValue(color);
|
||||||
|
part.color = color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHGeoZoneLayer(
|
||||||
|
viewer: Viewer,
|
||||||
|
geometryKey: string,
|
||||||
|
styleKey: string,
|
||||||
|
hideCameraHeightMeters: number | null,
|
||||||
|
fillParts: HGeoZoneFillPart[],
|
||||||
|
outlineParts: HGeoZoneOutlinePart[],
|
||||||
|
outlineWidthPx: number,
|
||||||
|
) {
|
||||||
|
const fills = hGeoZoneBatches(fillParts).map((parts) => {
|
||||||
|
const primitive = viewer.scene.groundPrimitives.add(new GroundPrimitive({
|
||||||
|
geometryInstances: parts.map((part) => new GeometryInstance({
|
||||||
|
id: part.pickId,
|
||||||
|
geometry: new PolygonGeometry({
|
||||||
|
polygonHierarchy: part.hierarchy,
|
||||||
|
vertexFormat: PerInstanceColorAppearance.FLAT_VERTEX_FORMAT,
|
||||||
|
}),
|
||||||
|
attributes: { color: ColorGeometryInstanceAttribute.fromColor(part.color) },
|
||||||
|
})),
|
||||||
|
appearance: new PerInstanceColorAppearance({ flat: true, translucent: true }),
|
||||||
|
allowPicking: true,
|
||||||
|
asynchronous: true,
|
||||||
|
classificationType: ClassificationType.TERRAIN,
|
||||||
|
releaseGeometryInstances: true,
|
||||||
|
}));
|
||||||
|
return { primitive, parts };
|
||||||
|
});
|
||||||
|
const outlines = outlineWidthPx <= 0 ? [] : hGeoZoneBatches(outlineParts).map((parts) => {
|
||||||
|
const primitive = viewer.scene.groundPrimitives.add(new GroundPolylinePrimitive({
|
||||||
|
geometryInstances: parts.map((part) => new GeometryInstance({
|
||||||
|
id: part.pickId,
|
||||||
|
geometry: new GroundPolylineGeometry({
|
||||||
|
positions: part.positions,
|
||||||
|
width: outlineWidthPx,
|
||||||
|
loop: true,
|
||||||
|
}),
|
||||||
|
attributes: { color: ColorGeometryInstanceAttribute.fromColor(part.color) },
|
||||||
|
})),
|
||||||
|
appearance: new PolylineColorAppearance({ translucent: true }),
|
||||||
|
allowPicking: true,
|
||||||
|
asynchronous: true,
|
||||||
|
classificationType: ClassificationType.TERRAIN,
|
||||||
|
releaseGeometryInstances: true,
|
||||||
|
}));
|
||||||
|
return { primitive, parts };
|
||||||
|
});
|
||||||
|
const layer = { geometryKey, styleKey, hideCameraHeightMeters, fills, outlines };
|
||||||
|
syncHGeoZoneVisibility(viewer, new Map([["layer", layer]]));
|
||||||
|
return layer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncHGeoZoneLayers(
|
||||||
|
viewer: Viewer,
|
||||||
|
layers: Map<string, HGeoZoneProjectionLayer>,
|
||||||
|
bindings: MapRuntimeBinding[],
|
||||||
|
presentationProfiles: MapPresentationProfile[],
|
||||||
|
presentationFilters: MapPresentationFilters,
|
||||||
|
faultedGeometryKeys: Set<string>,
|
||||||
|
) {
|
||||||
|
const activeBindings = new Set(bindings.filter((binding) => binding.slotId === "zones").map((binding) => binding.bindingId));
|
||||||
|
for (const [bindingId, layer] of layers) {
|
||||||
|
if (activeBindings.has(bindingId)) continue;
|
||||||
|
removeHGeoZoneLayer(viewer, layer);
|
||||||
|
layers.delete(bindingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const binding of bindings) {
|
||||||
|
if (binding.slotId !== "zones") continue;
|
||||||
|
const fillParts: HGeoZoneFillPart[] = [];
|
||||||
|
const outlineParts: HGeoZoneOutlinePart[] = [];
|
||||||
|
const geometryMembers: string[] = [];
|
||||||
|
let outlineWidthPx = 1.5;
|
||||||
|
let hideCameraHeightMeters: number | null = null;
|
||||||
|
let outlineColor = Color.fromCssColorString("#c9b6ff").withAlpha(0.9);
|
||||||
|
const styleMembers: string[] = [];
|
||||||
|
|
||||||
|
for (const fact of binding.facts) {
|
||||||
|
const profile = mapPresentationProfileForFact(
|
||||||
|
presentationProfiles,
|
||||||
|
binding.presentationProfileId,
|
||||||
|
fact.semanticType,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!fact.geometry
|
||||||
|
|| fact.geometry.type === "Point"
|
||||||
|
|| (profile && profile.target.variant !== "surface-fill")
|
||||||
|
|| (profile && !mapRuntimeFactIsVisible(fact, profile, presentationFilters, binding.bindingId))
|
||||||
|
) continue;
|
||||||
|
const presentationClass = profile ? resolveMapPresentationClass(fact, profile) : undefined;
|
||||||
|
const resolvedStyle = profile ? resolveMapPresentationStyle(profile, presentationClass) : undefined;
|
||||||
|
const fillColor = resolvedStyle
|
||||||
|
? Color.fromCssColorString(resolvedStyle.color).withAlpha(resolvedStyle.opacity)
|
||||||
|
: runtimePointColor(fact).withAlpha(0.28);
|
||||||
|
if (profile?.target.variant === "surface-fill") {
|
||||||
|
outlineWidthPx = profile.target.outlineWidthPx;
|
||||||
|
hideCameraHeightMeters = profile.target.hideCameraHeightMeters;
|
||||||
|
outlineColor = Color.fromCssColorString(profile.target.outlineColor).withAlpha(profile.target.outlineOpacity);
|
||||||
|
}
|
||||||
|
const polygons = fact.geometry.type === "Polygon" ? [fact.geometry.coordinates] : fact.geometry.coordinates;
|
||||||
|
const baseEntityId = mapRuntimeEntityId(binding.bindingId, fact);
|
||||||
|
for (const [polygonIndex, polygon] of polygons.entries()) {
|
||||||
|
const hierarchy = hGeoZoneHierarchy(polygon);
|
||||||
|
if (!hierarchy) continue;
|
||||||
|
const instanceBase = `${baseEntityId}:part:${polygonIndex}`;
|
||||||
|
const fillPickId: HGeoZonePickId = {
|
||||||
|
kind: "nodedc-hgeozone",
|
||||||
|
entityId: baseEntityId,
|
||||||
|
instanceId: `${instanceBase}:fill`,
|
||||||
|
};
|
||||||
|
fillParts.push({ pickId: fillPickId, hierarchy, color: fillColor });
|
||||||
|
geometryMembers.push(fillPickId.instanceId);
|
||||||
|
styleMembers.push(`${fillPickId.instanceId}:${resolvedStyle?.id ?? "default"}:${fillColor.toCssHexString()}:${fillColor.alpha}`);
|
||||||
|
for (const [ringIndex, ring] of polygon.entries()) {
|
||||||
|
const positions = hGeoZoneRingPositions(ring);
|
||||||
|
if (positions.length < 3) continue;
|
||||||
|
const outlinePickId: HGeoZonePickId = {
|
||||||
|
kind: "nodedc-hgeozone",
|
||||||
|
entityId: baseEntityId,
|
||||||
|
instanceId: `${instanceBase}:ring:${ringIndex}`,
|
||||||
|
};
|
||||||
|
outlineParts.push({ pickId: outlinePickId, positions, color: outlineColor });
|
||||||
|
geometryMembers.push(outlinePickId.instanceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const geometryKey = JSON.stringify([binding.cursor, outlineWidthPx, geometryMembers]);
|
||||||
|
const styleKey = JSON.stringify([styleMembers, outlineColor.toCssHexString(), outlineColor.alpha]);
|
||||||
|
const current = layers.get(binding.bindingId);
|
||||||
|
if (!fillParts.length) {
|
||||||
|
if (current) removeHGeoZoneLayer(viewer, current);
|
||||||
|
layers.delete(binding.bindingId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (faultedGeometryKeys.has(hGeoZoneFaultKey(binding.bindingId, geometryKey))) {
|
||||||
|
if (current) removeHGeoZoneLayer(viewer, current);
|
||||||
|
layers.delete(binding.bindingId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (current?.geometryKey === geometryKey) {
|
||||||
|
current.hideCameraHeightMeters = hideCameraHeightMeters;
|
||||||
|
if (current.styleKey !== styleKey) {
|
||||||
|
const ready = [...current.fills, ...current.outlines].every((batch) => batch.primitive.ready);
|
||||||
|
if (ready) {
|
||||||
|
updateHGeoZoneColors(current.fills, fillParts);
|
||||||
|
updateHGeoZoneColors(current.outlines, outlineParts);
|
||||||
|
current.styleKey = styleKey;
|
||||||
|
syncHGeoZoneVisibility(viewer, layers);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
syncHGeoZoneVisibility(viewer, layers);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current) removeHGeoZoneLayer(viewer, current);
|
||||||
|
layers.set(binding.bindingId, createHGeoZoneLayer(
|
||||||
|
viewer,
|
||||||
|
geometryKey,
|
||||||
|
styleKey,
|
||||||
|
hideCameraHeightMeters,
|
||||||
|
fillParts,
|
||||||
|
outlineParts,
|
||||||
|
outlineWidthPx,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
viewer.scene.requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncRuntimeDataSources(
|
||||||
|
viewer: Viewer,
|
||||||
|
dataSources: Map<string, CustomDataSource>,
|
||||||
|
hGeoZoneLayers: Map<string, HGeoZoneProjectionLayer>,
|
||||||
|
bindings: MapRuntimeBinding[],
|
||||||
|
presentationProfiles: MapPresentationProfile[],
|
||||||
|
presentationFilters: MapPresentationFilters,
|
||||||
|
faultedHGeoZoneGeometryKeys: Set<string>,
|
||||||
|
) {
|
||||||
|
const activeBindings = new Map(bindings
|
||||||
|
.filter((binding) => (
|
||||||
|
binding.slotId === "points"
|
||||||
|
|| binding.slotId === "reference-points"
|
||||||
|
|| binding.slotId === "zones"
|
||||||
|
))
|
||||||
|
.map((binding) => [binding.bindingId, binding]));
|
||||||
|
|
||||||
|
for (const [bindingId, dataSource] of dataSources) {
|
||||||
|
if (activeBindings.has(bindingId)) continue;
|
||||||
|
viewer.dataSources.remove(dataSource, true);
|
||||||
|
dataSources.delete(bindingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const binding of activeBindings.values()) {
|
||||||
|
let dataSource = dataSources.get(binding.bindingId);
|
||||||
|
if (!dataSource) {
|
||||||
|
dataSource = new CustomDataSource(`nodedc-map-slot:${binding.bindingId}`);
|
||||||
|
viewer.dataSources.add(dataSource);
|
||||||
|
dataSources.set(binding.bindingId, dataSource);
|
||||||
|
}
|
||||||
|
const wanted = new Set<string>();
|
||||||
|
for (const fact of binding.facts) {
|
||||||
|
const profile = mapPresentationProfileForFact(
|
||||||
|
presentationProfiles,
|
||||||
|
binding.presentationProfileId,
|
||||||
|
fact.semanticType,
|
||||||
|
);
|
||||||
|
const presentationClass = profile ? resolveMapPresentationClass(fact, profile) : undefined;
|
||||||
|
if (
|
||||||
|
!fact.geometry
|
||||||
|
|| (profile && !mapRuntimeFactIsVisible(fact, profile, presentationFilters, binding.bindingId))
|
||||||
|
) continue;
|
||||||
|
const resolvedStyle = profile ? resolveMapPresentationStyle(profile, presentationClass) : undefined;
|
||||||
|
const color = resolvedStyle
|
||||||
|
? Color.fromCssColorString(resolvedStyle.color).withAlpha(resolvedStyle.opacity)
|
||||||
|
: runtimePointColor(fact);
|
||||||
|
const label = mapRuntimeDisplayLabel(fact, profile);
|
||||||
|
const baseEntityId = mapRuntimeEntityId(binding.bindingId, fact);
|
||||||
|
|
||||||
|
if (fact.geometry.type === "Point" && (binding.slotId === "points" || binding.slotId === "reference-points")) {
|
||||||
|
if (profile && profile.target.variant !== "elevated-spike") continue;
|
||||||
|
const entityId = baseEntityId;
|
||||||
|
wanted.add(entityId);
|
||||||
|
const [longitude, latitude] = fact.geometry.coordinates;
|
||||||
|
const entity = dataSource.entities.getById(entityId) ?? dataSource.entities.add({ id: entityId });
|
||||||
|
entity.name = label;
|
||||||
|
entity.polygon = undefined;
|
||||||
|
if (!profile) {
|
||||||
|
entity.billboard = undefined;
|
||||||
|
entity.position = new ConstantPositionProperty(Cartesian3.fromDegrees(longitude, latitude, 0));
|
||||||
|
entity.polyline = undefined;
|
||||||
|
entity.point = new PointGraphics({
|
||||||
|
pixelSize: 10,
|
||||||
|
color,
|
||||||
|
outlineColor: Color.TRANSPARENT,
|
||||||
|
outlineWidth: 0,
|
||||||
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||||
|
});
|
||||||
|
entity.label = new LabelGraphics({
|
||||||
|
text: label,
|
||||||
|
font: "700 13px Arial",
|
||||||
|
fillColor: Color.WHITE,
|
||||||
|
outlineColor: Color.TRANSPARENT,
|
||||||
|
outlineWidth: 0,
|
||||||
|
style: LabelStyle.FILL,
|
||||||
|
showBackground: true,
|
||||||
|
backgroundColor: Color.BLACK.withAlpha(0.72),
|
||||||
|
backgroundPadding: new Cartesian2(10, 7),
|
||||||
|
pixelOffset: new Cartesian2(10, 0),
|
||||||
|
horizontalOrigin: HorizontalOrigin.LEFT,
|
||||||
|
verticalOrigin: VerticalOrigin.BOTTOM,
|
||||||
|
heightReference: HeightReference.NONE,
|
||||||
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (profile.target.variant !== "elevated-spike") continue;
|
||||||
|
const target = profile.target;
|
||||||
|
const fallbackHeightMeters = typeof fact.attributes.elevation_meters === "number" && Number.isFinite(fact.attributes.elevation_meters)
|
||||||
|
? fact.attributes.elevation_meters
|
||||||
|
: 0;
|
||||||
|
entity.position = elevatedPinTopPosition(
|
||||||
|
viewer,
|
||||||
|
longitude,
|
||||||
|
latitude,
|
||||||
|
target.stemHeightMeters,
|
||||||
|
fallbackHeightMeters,
|
||||||
|
);
|
||||||
|
entity.polyline = new PolylineGraphics({
|
||||||
|
positions: elevatedPinStemPositions(
|
||||||
|
viewer,
|
||||||
|
longitude,
|
||||||
|
latitude,
|
||||||
|
target.stemHeightMeters,
|
||||||
|
fallbackHeightMeters,
|
||||||
|
),
|
||||||
|
width: target.stemWidthPx,
|
||||||
|
material: color,
|
||||||
|
show: showBelowCameraHeight(viewer, target.hideCameraHeightMeters),
|
||||||
|
});
|
||||||
|
const targetImage = elevatedTargetImage(
|
||||||
|
color,
|
||||||
|
Color.fromCssColorString(target.outlineColor).withAlpha(target.outlineOpacity),
|
||||||
|
target.outlineWidthPx,
|
||||||
|
target.headSizePx,
|
||||||
|
);
|
||||||
|
entity.point = undefined;
|
||||||
|
entity.billboard = new BillboardGraphics({
|
||||||
|
image: targetImage.image,
|
||||||
|
width: targetImage.size,
|
||||||
|
height: targetImage.size,
|
||||||
|
horizontalOrigin: HorizontalOrigin.CENTER,
|
||||||
|
verticalOrigin: VerticalOrigin.CENTER,
|
||||||
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||||
|
show: showBelowCameraHeight(viewer, target.hideCameraHeightMeters),
|
||||||
|
});
|
||||||
|
entity.label = new LabelGraphics({
|
||||||
|
text: label,
|
||||||
|
font: `${profile.label.fontWeight} ${profile.label.sizePx}px Arial`,
|
||||||
|
fillColor: Color.fromCssColorString(profile.label.color),
|
||||||
|
outlineColor: Color.TRANSPARENT,
|
||||||
|
outlineWidth: 0,
|
||||||
|
style: LabelStyle.FILL,
|
||||||
|
showBackground: profile.label.backgroundOpacity > 0,
|
||||||
|
backgroundColor: Color.fromCssColorString(profile.label.backgroundColor).withAlpha(profile.label.backgroundOpacity),
|
||||||
|
backgroundPadding: new Cartesian2(profile.label.paddingX, profile.label.paddingY),
|
||||||
|
pixelOffset: new Cartesian2(profile.label.offsetX, profile.label.offsetY),
|
||||||
|
horizontalOrigin: HorizontalOrigin.LEFT,
|
||||||
|
verticalOrigin: VerticalOrigin.BOTTOM,
|
||||||
|
heightReference: HeightReference.NONE,
|
||||||
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||||
|
show: profile.label.mode !== "none" && showBelowCameraHeight(viewer, profile.label.hideCameraHeightMeters),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
binding.slotId !== "zones"
|
||||||
|
|| fact.geometry.type === "Point"
|
||||||
|
|| (profile && profile.target.variant !== "surface-fill")
|
||||||
|
) continue;
|
||||||
|
const polygons = fact.geometry.type === "Polygon" ? [fact.geometry.coordinates] : fact.geometry.coordinates;
|
||||||
|
const outerRing = normalizeHGeoZoneRing(polygons[0]?.[0] ?? []);
|
||||||
|
if (!outerRing.length) continue;
|
||||||
|
wanted.add(baseEntityId);
|
||||||
|
const labelAnchor = outerRing.reduce(
|
||||||
|
(accumulator, [longitude, latitude]) => [accumulator[0] + longitude, accumulator[1] + latitude] as [number, number],
|
||||||
|
[0, 0] as [number, number],
|
||||||
|
);
|
||||||
|
const divisor = Math.max(1, outerRing.length);
|
||||||
|
const entity = dataSource.entities.getById(baseEntityId) ?? dataSource.entities.add({ id: baseEntityId });
|
||||||
|
entity.name = label;
|
||||||
|
entity.position = new ConstantPositionProperty(Cartesian3.fromDegrees(labelAnchor[0] / divisor, labelAnchor[1] / divisor, 0));
|
||||||
|
entity.billboard = undefined;
|
||||||
|
entity.point = undefined;
|
||||||
|
entity.polygon = undefined;
|
||||||
|
entity.polyline = undefined;
|
||||||
|
entity.label = new LabelGraphics({
|
||||||
|
text: label,
|
||||||
|
font: profile ? `${profile.label.fontWeight} ${profile.label.sizePx}px Arial` : "700 13px Arial",
|
||||||
|
fillColor: profile ? Color.fromCssColorString(profile.label.color) : Color.WHITE,
|
||||||
|
outlineColor: Color.TRANSPARENT,
|
||||||
|
outlineWidth: 0,
|
||||||
|
style: LabelStyle.FILL,
|
||||||
|
showBackground: (profile?.label.backgroundOpacity ?? 0.72) > 0,
|
||||||
|
backgroundColor: profile
|
||||||
|
? Color.fromCssColorString(profile.label.backgroundColor).withAlpha(profile.label.backgroundOpacity)
|
||||||
|
: Color.BLACK.withAlpha(0.72),
|
||||||
|
backgroundPadding: new Cartesian2(profile?.label.paddingX ?? 10, profile?.label.paddingY ?? 7),
|
||||||
|
pixelOffset: new Cartesian2(profile?.label.offsetX ?? 10, profile?.label.offsetY ?? 0),
|
||||||
|
horizontalOrigin: HorizontalOrigin.LEFT,
|
||||||
|
verticalOrigin: VerticalOrigin.BOTTOM,
|
||||||
|
heightReference: HeightReference.CLAMP_TO_GROUND,
|
||||||
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||||
|
show: (profile?.label.mode ?? "attributes") !== "none"
|
||||||
|
&& (profile ? showBelowCameraHeight(viewer, profile.label.hideCameraHeightMeters) : true),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const entity of [...dataSource.entities.values]) {
|
||||||
|
if (typeof entity.id === "string" && !wanted.has(entity.id)) dataSource.entities.remove(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
syncHGeoZoneLayers(
|
||||||
|
viewer,
|
||||||
|
hGeoZoneLayers,
|
||||||
|
bindings,
|
||||||
|
presentationProfiles,
|
||||||
|
presentationFilters,
|
||||||
|
faultedHGeoZoneGeometryKeys,
|
||||||
|
);
|
||||||
|
viewer.scene.requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Checker,
|
||||||
|
ColorField,
|
||||||
|
ControlRow,
|
||||||
|
Icon,
|
||||||
|
InspectorSelectField,
|
||||||
|
RangeControl,
|
||||||
|
SegmentedControl,
|
||||||
|
} from "@nodedc/ui-react";
|
||||||
|
import type { SelectOption } from "@nodedc/ui-react";
|
||||||
|
import { DEFAULT_GRID_LOD_PROFILES } from "./mapGridPolicy.mjs";
|
||||||
|
import { MAX_LOCAL_GRID_INDEX } from "./mapSectorGrid.mjs";
|
||||||
|
import {
|
||||||
|
GRID_SECTOR_DIRECTIONS,
|
||||||
|
formatGridSectorArea,
|
||||||
|
gridSectorBoundsLabel,
|
||||||
|
gridSectorCenterLabel,
|
||||||
|
normalizedGraticuleStepDegrees,
|
||||||
|
normalizedMajorTileSizeKm,
|
||||||
|
type GridSectorDirection,
|
||||||
|
type SectorGridLodProfile,
|
||||||
|
} from "./mapSectorWorkspace.js";
|
||||||
|
import type {
|
||||||
|
GridMajorTileSelection,
|
||||||
|
GridSectorSelection,
|
||||||
|
} from "./mapRendererContract.js";
|
||||||
|
import type { MapPageSettings } from "./mapPageContract.js";
|
||||||
|
import type { MapInspectorSection } from "./mapInspectorSections.js";
|
||||||
|
|
||||||
|
const GRID_MODE_OPTIONS: Array<SelectOption<"3d" | "graticule">> = [
|
||||||
|
{ value: "3d", label: "3D", description: "Приподнятая пространственная сетка" },
|
||||||
|
{ value: "graticule", label: "Гратикула", description: "Проекция по поверхности" },
|
||||||
|
];
|
||||||
|
|
||||||
|
type GridSectorCopyState = "idle" | "copied" | "error";
|
||||||
|
|
||||||
|
export function buildMapGridInspectorSections({
|
||||||
|
mapSettings,
|
||||||
|
updateMapSettings,
|
||||||
|
selectedGridLod,
|
||||||
|
setSelectedGridLod,
|
||||||
|
selectedGridLodIndex,
|
||||||
|
activeGridLod,
|
||||||
|
minimumGridLodHeight,
|
||||||
|
maximumGridLodHeight,
|
||||||
|
updateGridLod,
|
||||||
|
updateGridVolumeRange,
|
||||||
|
activeGraticuleMajorStepDegrees,
|
||||||
|
selectedGridSector,
|
||||||
|
gridSectorCopyState,
|
||||||
|
copySelectedGridSectorId,
|
||||||
|
mapRendererReady,
|
||||||
|
focusGridMajorTile,
|
||||||
|
selectedGridParentLod,
|
||||||
|
sectorGridLodProfiles,
|
||||||
|
focusGridSector,
|
||||||
|
selectedGridNeighborTargets,
|
||||||
|
selectedGridSectorProfile,
|
||||||
|
selectedGridVolumeTargets,
|
||||||
|
}: {
|
||||||
|
mapSettings: MapPageSettings;
|
||||||
|
updateMapSettings: (patch: Partial<MapPageSettings>) => void;
|
||||||
|
selectedGridLod: string;
|
||||||
|
setSelectedGridLod: Dispatch<SetStateAction<string>>;
|
||||||
|
selectedGridLodIndex: number;
|
||||||
|
activeGridLod: SectorGridLodProfile;
|
||||||
|
minimumGridLodHeight: number;
|
||||||
|
maximumGridLodHeight: number;
|
||||||
|
updateGridLod: (patch: Partial<SectorGridLodProfile>) => void;
|
||||||
|
updateGridVolumeRange: (patch: Partial<Pick<SectorGridLodProfile,
|
||||||
|
"volumeMinimumHeightMeters" | "volumeMaximumHeightMeters" | "volumeBandHeightMeters">>) => void;
|
||||||
|
activeGraticuleMajorStepDegrees: number | null;
|
||||||
|
selectedGridSector: GridSectorSelection | null;
|
||||||
|
gridSectorCopyState: GridSectorCopyState;
|
||||||
|
copySelectedGridSectorId: () => Promise<void>;
|
||||||
|
mapRendererReady: boolean;
|
||||||
|
focusGridMajorTile: (tile: GridMajorTileSelection) => void;
|
||||||
|
selectedGridParentLod: GridSectorSelection | null;
|
||||||
|
sectorGridLodProfiles: SectorGridLodProfile[];
|
||||||
|
focusGridSector: (sector: GridSectorSelection | null) => void;
|
||||||
|
selectedGridNeighborTargets: Record<GridSectorDirection, GridSectorSelection | null>;
|
||||||
|
selectedGridSectorProfile: SectorGridLodProfile | null;
|
||||||
|
selectedGridVolumeTargets: { above: GridSectorSelection | null; below: GridSectorSelection | null };
|
||||||
|
}): MapInspectorSection[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "map-grid",
|
||||||
|
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 })} />
|
||||||
|
<Checker checked={mapSettings.grid3dEnabled} label="3D-сетка" onChange={(grid3dEnabled) => updateMapSettings({ grid3dEnabled })} />
|
||||||
|
<Checker checked={mapSettings.gridGraticuleEnabled} label="Гратикула" onChange={(gridGraticuleEnabled) => updateMapSettings({ gridGraticuleEnabled })} />
|
||||||
|
<Checker checked={mapSettings.gridLodEnabled} label="LOD по высоте камеры" onChange={(gridLodEnabled) => updateMapSettings({ gridLodEnabled })} />
|
||||||
|
<Checker checked={mapSettings.gridRebuildOnMoveEnd} label="Перестраивать после движения" onChange={(gridRebuildOnMoveEnd) => updateMapSettings({ gridRebuildOnMoveEnd })} />
|
||||||
|
<ControlRow label="Система координат"><strong>Fixed ENU · WGS84</strong></ControlRow>
|
||||||
|
<RangeControl label="Origin: широта" value={mapSettings.gridCenterLatitude} min={-89.9} max={89.9} step={0.000001} formatValue={(value) => value.toFixed(6)} onChange={(gridCenterLatitude) => updateMapSettings({ gridCenterLatitude })} />
|
||||||
|
<RangeControl label="Origin: долгота" value={mapSettings.gridCenterLongitude} min={-180} max={180} step={0.000001} formatValue={(value) => value.toFixed(6)} onChange={(gridCenterLongitude) => updateMapSettings({ gridCenterLongitude })} />
|
||||||
|
<RangeControl label="Автовыключение выше" value={mapSettings.gridAutoDisableHeightKm} min={0} max={50_000} step={100} formatValue={(value) => value === 0 ? "выкл" : `${value} км`} onChange={(gridAutoDisableHeightKm) => updateMapSettings({ gridAutoDisableHeightKm })} />
|
||||||
|
<div className="catalog-map-grid-lod-tabs">
|
||||||
|
<SegmentedControl value={selectedGridLod} items={DEFAULT_GRID_LOD_PROFILES.map((_profile, index) => ({ value: String(index), label: `LOD ${index + 1}` }))} label="Уровень детализации сетки" onChange={setSelectedGridLod} />
|
||||||
|
</div>
|
||||||
|
<RangeControl label={selectedGridLodIndex === 4 ? "Порог профиля" : "До высоты"} value={activeGridLod.maxHeightKm} min={minimumGridLodHeight} max={maximumGridLodHeight} step={0.1} formatValue={(value) => `${value} км`} onChange={(maxHeightKm) => updateGridLod({ maxHeightKm })} />
|
||||||
|
{selectedGridLodIndex === 4 ? <small className="catalog-map-inspector__note">Последний LOD остаётся активным выше своего порога до общего автовыключения.</small> : null}
|
||||||
|
<InspectorSelectField
|
||||||
|
label="Режим"
|
||||||
|
value={activeGridLod.mode}
|
||||||
|
options={GRID_MODE_OPTIONS}
|
||||||
|
onChange={(mode) => updateGridLod({
|
||||||
|
mode,
|
||||||
|
volumeEnabled: mode === "3d" && activeGridLod.volumeEnabled,
|
||||||
|
...(mode === "3d" ? {
|
||||||
|
stepKm: Math.min(50, activeGridLod.stepKm),
|
||||||
|
tileSizeKm: normalizedMajorTileSizeKm(Math.min(50, activeGridLod.stepKm), activeGridLod.tileSizeKm),
|
||||||
|
} : {
|
||||||
|
graticuleStepDegrees: normalizedGraticuleStepDegrees(activeGridLod.graticuleStepDegrees),
|
||||||
|
}),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<RangeControl label="Высота WGS84" value={activeGridLod.heightMeters} min={0} max={5_000} step={10} formatValue={(value) => `${value} м`} onChange={(heightMeters) => updateGridLod({ heightMeters })} />
|
||||||
|
<RangeControl label="Конус видимости 3D" value={activeGridLod.max3dViewAngleDegrees} min={30} max={170} step={1} formatValue={(value) => `${value}°`} onChange={(max3dViewAngleDegrees) => updateGridLod({ max3dViewAngleDegrees })} />
|
||||||
|
<RangeControl
|
||||||
|
label="Шаг ENU-секторов"
|
||||||
|
value={activeGridLod.stepKm}
|
||||||
|
min={0.1}
|
||||||
|
max={activeGridLod.mode === "3d" ? 50 : 5_000}
|
||||||
|
step={0.1}
|
||||||
|
formatValue={(value) => `${value} км`}
|
||||||
|
onChange={(stepKm) => updateGridLod({
|
||||||
|
stepKm,
|
||||||
|
tileSizeKm: normalizedMajorTileSizeKm(stepKm, Math.max(stepKm, activeGridLod.tileSizeKm)),
|
||||||
|
radiusKm: Math.min(activeGridLod.radiusKm, stepKm * MAX_LOCAL_GRID_INDEX),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<RangeControl
|
||||||
|
label="Размер major-тайла ENU"
|
||||||
|
value={activeGridLod.tileSizeKm}
|
||||||
|
min={activeGridLod.stepKm}
|
||||||
|
max={Math.max(activeGridLod.stepKm, 50)}
|
||||||
|
step={activeGridLod.stepKm}
|
||||||
|
formatValue={(value) => `${value} км`}
|
||||||
|
onChange={(tileSizeKm) => updateGridLod({ tileSizeKm: normalizedMajorTileSizeKm(activeGridLod.stepKm, tileSizeKm) })}
|
||||||
|
/>
|
||||||
|
<small className="catalog-map-inspector__note">Major-тайл содержит целое число ENU-секторов. Для гратикулы major-шаг равен пяти minor-шагам.</small>
|
||||||
|
<Checker
|
||||||
|
checked={activeGridLod.majorLinesEnabled}
|
||||||
|
label="Major-линии"
|
||||||
|
onChange={(majorLinesEnabled) => updateGridLod({
|
||||||
|
majorLinesEnabled,
|
||||||
|
majorLabelsEnabled: majorLinesEnabled && activeGridLod.majorLabelsEnabled,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<Checker checked={activeGridLod.majorLabelsEnabled} disabled={!activeGridLod.majorLinesEnabled} label="Подписи major-тайлов" onChange={(majorLabelsEnabled) => updateGridLod({ majorLabelsEnabled })} />
|
||||||
|
<RangeControl label="Толщина major-линий" value={activeGridLod.majorLineWidthMultiplier} min={1} max={8} step={0.1} formatValue={(value) => `×${value.toFixed(1)}`} onChange={(majorLineWidthMultiplier) => updateGridLod({ majorLineWidthMultiplier })} />
|
||||||
|
<small className="catalog-map-inspector__note">Прозрачность major-линий наследует прозрачность линий текущего LOD.</small>
|
||||||
|
{activeGridLod.mode === "graticule" && activeGridLod.majorLinesEnabled && activeGraticuleMajorStepDegrees === null
|
||||||
|
? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">Major-разметка недоступна для этого шага: пять minor-интервалов должны точно делить 90°-квадрант.</small>
|
||||||
|
: null}
|
||||||
|
<RangeControl label="Радиус ENU-поля" value={activeGridLod.radiusKm} min={1} max={Math.min(100_000, activeGridLod.stepKm * MAX_LOCAL_GRID_INDEX)} step={1} formatValue={(value) => `${value} км`} onChange={(radiusKm) => updateGridLod({ radiusKm })} />
|
||||||
|
<RangeControl label="Диаметр 3D-линий" value={activeGridLod.lineDiameterMeters} min={1} max={100} step={1} formatValue={(value) => `${value} м`} onChange={(lineDiameterMeters) => updateGridLod({ lineDiameterMeters })} />
|
||||||
|
<ControlRow label="Цвет 3D-линий"><ColorField label="Цвет линий ENU-сетки" value={activeGridLod.lineColor} onChange={(lineColor) => updateGridLod({ lineColor })} /></ControlRow>
|
||||||
|
<RangeControl label="Прозрачность 3D-линий" value={activeGridLod.lineOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(lineOpacity) => updateGridLod({ lineOpacity })} />
|
||||||
|
<Checker checked={activeGridLod.dotsEnabled} label="Кружки" onChange={(dotsEnabled) => updateGridLod({ dotsEnabled })} />
|
||||||
|
<RangeControl label="Кружки: диаметр" value={activeGridLod.dotsDiameterMeters} min={1} max={1_000} step={1} formatValue={(value) => `${value} м`} onChange={(dotsDiameterMeters) => updateGridLod({ dotsDiameterMeters })} />
|
||||||
|
<ControlRow label="Кружки: цвет"><ColorField label="Цвет кружков сетки" value={activeGridLod.dotsColor} onChange={(dotsColor) => updateGridLod({ dotsColor })} /></ControlRow>
|
||||||
|
<RangeControl label="Кружки: прозрачность" value={activeGridLod.dotsOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(dotsOpacity) => updateGridLod({ dotsOpacity })} />
|
||||||
|
<Checker checked={activeGridLod.crossesEnabled} label="Кресты" onChange={(crossesEnabled) => updateGridLod({ crossesEnabled })} />
|
||||||
|
<RangeControl label="Кресты: длина" value={activeGridLod.crossesLengthMeters} min={2} max={5_000} step={2} formatValue={(value) => `${value} м`} onChange={(crossesLengthMeters) => updateGridLod({ crossesLengthMeters })} />
|
||||||
|
<RangeControl label="Кресты: ширина" value={activeGridLod.crossesWidthMeters} min={1} max={500} step={1} formatValue={(value) => `${value} м`} onChange={(crossesWidthMeters) => updateGridLod({ crossesWidthMeters })} />
|
||||||
|
<ControlRow label="Кресты: цвет"><ColorField label="Цвет крестов сетки" value={activeGridLod.crossesColor} onChange={(crossesColor) => updateGridLod({ crossesColor })} /></ControlRow>
|
||||||
|
<RangeControl label="Кресты: прозрачность" value={activeGridLod.crossesOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(crossesOpacity) => updateGridLod({ crossesOpacity })} />
|
||||||
|
<RangeControl label="Шаг гратикулы" value={activeGridLod.graticuleStepDegrees} min={0.1} max={10} step={0.05} formatValue={(value) => `${value}°`} onChange={(graticuleStepDegrees) => updateGridLod({ graticuleStepDegrees: normalizedGraticuleStepDegrees(graticuleStepDegrees) })} />
|
||||||
|
<RangeControl label="Толщина гратикулы" value={activeGridLod.graticuleLineWidthPx} min={1} max={3} step={1} formatValue={(value) => `${value} px`} onChange={(graticuleLineWidthPx) => updateGridLod({ graticuleLineWidthPx })} />
|
||||||
|
<ControlRow label="Цвет гратикулы"><ColorField label="Цвет WGS84-гратику́лы" value={activeGridLod.graticuleColor} onChange={(graticuleColor) => updateGridLod({ graticuleColor })} /></ControlRow>
|
||||||
|
<RangeControl label="Прозрачность гратикулы" value={activeGridLod.graticuleOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(graticuleOpacity) => updateGridLod({ graticuleOpacity })} />
|
||||||
|
{activeGridLod.mode === "3d" ? <>
|
||||||
|
<Checker checked={activeGridLod.volumeEnabled} label="Объёмный выбор сектора" onChange={(volumeEnabled) => updateGridLod({ volumeEnabled })} />
|
||||||
|
<RangeControl
|
||||||
|
label="Нижняя отметка объёма"
|
||||||
|
value={activeGridLod.volumeMinimumHeightMeters}
|
||||||
|
min={-1_000}
|
||||||
|
max={activeGridLod.volumeMaximumHeightMeters - 1}
|
||||||
|
step={10}
|
||||||
|
formatValue={(value) => `${value} м WGS84`}
|
||||||
|
onChange={(volumeMinimumHeightMeters) => updateGridVolumeRange({ volumeMinimumHeightMeters })}
|
||||||
|
/>
|
||||||
|
<RangeControl
|
||||||
|
label="Верхняя отметка объёма"
|
||||||
|
value={activeGridLod.volumeMaximumHeightMeters}
|
||||||
|
min={activeGridLod.volumeMinimumHeightMeters + 1}
|
||||||
|
max={10_000}
|
||||||
|
step={10}
|
||||||
|
formatValue={(value) => `${value} м WGS84`}
|
||||||
|
onChange={(volumeMaximumHeightMeters) => updateGridVolumeRange({ volumeMaximumHeightMeters })}
|
||||||
|
/>
|
||||||
|
<RangeControl
|
||||||
|
label="Высота адресного диапазона"
|
||||||
|
value={activeGridLod.volumeBandHeightMeters}
|
||||||
|
min={1}
|
||||||
|
max={1_000}
|
||||||
|
step={10}
|
||||||
|
formatValue={(value) => `${value} м`}
|
||||||
|
onChange={(volumeBandHeightMeters) => updateGridVolumeRange({ volumeBandHeightMeters })}
|
||||||
|
/>
|
||||||
|
<small className="catalog-map-inspector__note">Горизонтальный ID сектора остаётся стабильным. Высотный band добавляется как отдельный адрес внутри выбранной ENU-ячейки.</small>
|
||||||
|
</> : null}
|
||||||
|
<ControlRow label="Цвет заливки сектора">
|
||||||
|
<ColorField
|
||||||
|
label="Цвет заливки выбранного сектора"
|
||||||
|
value={activeGridLod.selectionFillColor}
|
||||||
|
onChange={(selectionFillColor) => updateGridLod({ selectionFillColor })}
|
||||||
|
/>
|
||||||
|
</ControlRow>
|
||||||
|
<RangeControl
|
||||||
|
label="Прозрачность заливки"
|
||||||
|
value={activeGridLod.selectionFillOpacityPercent}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
formatValue={(value) => `${value}%`}
|
||||||
|
onChange={(selectionFillOpacityPercent) => updateGridLod({ selectionFillOpacityPercent })}
|
||||||
|
/>
|
||||||
|
<ControlRow label="Цвет линии сектора">
|
||||||
|
<ColorField
|
||||||
|
label="Цвет линии выбранного сектора"
|
||||||
|
value={activeGridLod.selectionOutlineColor}
|
||||||
|
onChange={(selectionOutlineColor) => updateGridLod({ selectionOutlineColor })}
|
||||||
|
/>
|
||||||
|
</ControlRow>
|
||||||
|
<RangeControl
|
||||||
|
label="Толщина линии"
|
||||||
|
value={activeGridLod.selectionOutlineWidthPx}
|
||||||
|
min={1}
|
||||||
|
max={12}
|
||||||
|
step={0.25}
|
||||||
|
formatValue={(value) => `${value} px`}
|
||||||
|
onChange={(selectionOutlineWidthPx) => updateGridLod({ selectionOutlineWidthPx })}
|
||||||
|
/>
|
||||||
|
<RangeControl
|
||||||
|
label="Прозрачность линии"
|
||||||
|
value={activeGridLod.selectionOutlineOpacityPercent}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
formatValue={(value) => `${value}%`}
|
||||||
|
onChange={(selectionOutlineOpacityPercent) => updateGridLod({ selectionOutlineOpacityPercent })}
|
||||||
|
/>
|
||||||
|
<small className="catalog-map-inspector__note">Оформление применяется к выбранному сектору текущего LOD.</small>
|
||||||
|
<section className="catalog-map-grid-sector" aria-label="Выбранный сектор">
|
||||||
|
<ControlRow label="Выбранный сектор"><strong className="catalog-map-grid-sector-id">{selectedGridSector?.id ?? "Нажмите сектор на карте"}</strong></ControlRow>
|
||||||
|
{selectedGridSector ? <>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="compact"
|
||||||
|
width="full"
|
||||||
|
shape="pill"
|
||||||
|
icon={<Icon name={gridSectorCopyState === "copied" ? "check" : "copy"} />}
|
||||||
|
onClick={() => void copySelectedGridSectorId()}
|
||||||
|
>{gridSectorCopyState === "copied" ? "ID скопирован" : "Копировать stable ID"}</Button>
|
||||||
|
{gridSectorCopyState === "error" ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">Не удалось записать ID в буфер обмена.</small> : null}
|
||||||
|
<div className="catalog-map-grid-sector__facts">
|
||||||
|
<ControlRow label="Family / LOD"><strong>{selectedGridSector.mode === "3d" ? "Local ENU" : "WGS84 graticule"} · LOD {selectedGridSector.lod}</strong></ControlRow>
|
||||||
|
<ControlRow label="Адрес"><span>{selectedGridSector.label}</span></ControlRow>
|
||||||
|
<ControlRow label="Границы"><span>{gridSectorBoundsLabel(selectedGridSector)}</span></ControlRow>
|
||||||
|
<ControlRow label="Центр"><span>{gridSectorCenterLabel(selectedGridSector)}</span></ControlRow>
|
||||||
|
<ControlRow label="Площадь"><strong>{formatGridSectorArea(selectedGridSector.areaSquareMeters)}</strong></ControlRow>
|
||||||
|
</div>
|
||||||
|
{selectedGridSector.parentMajorTile ? <div className="catalog-map-grid-sector__relation">
|
||||||
|
<small>Parent major tile · {selectedGridSector.parentMajorTile.label}</small>
|
||||||
|
<code title={selectedGridSector.parentMajorTile.id}>{selectedGridSector.parentMajorTile.id}</code>
|
||||||
|
<small>{selectedGridSector.parentMajorTile.minorPerSide} × {selectedGridSector.parentMajorTile.minorPerSide} · {selectedGridSector.parentMajorTile.childCount} дочерних секторов · {formatGridSectorArea(selectedGridSector.parentMajorTile.areaSquareMeters)}</small>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="compact"
|
||||||
|
width="full"
|
||||||
|
data-grid-navigation-intent="parent-major"
|
||||||
|
disabled={!mapRendererReady}
|
||||||
|
onClick={() => focusGridMajorTile(selectedGridSector.parentMajorTile!)}
|
||||||
|
>Фокус major-тайла</Button>
|
||||||
|
</div> : <small className="catalog-map-inspector__note">Parent major tile выключен или недоступен для текущей топологии.</small>}
|
||||||
|
<div className="catalog-map-grid-sector__relation">
|
||||||
|
<small>Следующий LOD</small>
|
||||||
|
{selectedGridParentLod ? <>
|
||||||
|
<code title={selectedGridParentLod.id}>{selectedGridParentLod.id}</code>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="compact"
|
||||||
|
width="full"
|
||||||
|
data-grid-navigation-intent="next-lod"
|
||||||
|
disabled={!mapRendererReady}
|
||||||
|
onClick={() => focusGridSector(selectedGridParentLod)}
|
||||||
|
>Перейти в LOD {selectedGridParentLod.lod}</Button>
|
||||||
|
</> : <span>{selectedGridSector.lod >= sectorGridLodProfiles.length
|
||||||
|
? "Верхний уровень иерархии"
|
||||||
|
: `LOD ${selectedGridSector.lod + 1} меняет систему адресации`}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="catalog-map-grid-sector__neighbors" aria-label="Соседние сектора">
|
||||||
|
{GRID_SECTOR_DIRECTIONS.map(({ id, label }) => {
|
||||||
|
const target = selectedGridNeighborTargets[id];
|
||||||
|
return <div className="catalog-map-grid-sector__neighbor" key={id}>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="compact"
|
||||||
|
width="full"
|
||||||
|
data-grid-navigation-intent={id}
|
||||||
|
disabled={!target || !mapRendererReady}
|
||||||
|
onClick={() => focusGridSector(target)}
|
||||||
|
>{label}</Button>
|
||||||
|
<code title={target?.id}>{target?.id ?? "Граница адресного пространства"}</code>
|
||||||
|
</div>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{selectedGridSector.mode === "3d" && selectedGridSectorProfile ? <div className="catalog-map-grid-sector__volume" data-enabled={selectedGridSectorProfile.volumeEnabled || undefined}>
|
||||||
|
<ControlRow label="Высотный выбор"><strong>{selectedGridSectorProfile.volumeEnabled ? "Включён" : "Выключен"}</strong></ControlRow>
|
||||||
|
<ControlRow label="Floor"><span>{selectedGridSector.volume?.floor ?? selectedGridSectorProfile.volumeMinimumHeightMeters} м WGS84</span></ControlRow>
|
||||||
|
<ControlRow label="Ceiling"><span>{selectedGridSector.volume?.ceiling ?? selectedGridSectorProfile.volumeMaximumHeightMeters} м WGS84</span></ControlRow>
|
||||||
|
<ControlRow label="Height band"><span>{selectedGridSector.volume?.bandHeight ?? selectedGridSectorProfile.volumeBandHeightMeters} м</span></ControlRow>
|
||||||
|
{selectedGridSector.volume ? <code title={selectedGridSector.volume.id}>{selectedGridSector.volume.id}</code> : null}
|
||||||
|
{selectedGridSectorProfile.volumeEnabled ? <div className="catalog-map-grid-sector__volume-actions">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="compact"
|
||||||
|
width="full"
|
||||||
|
data-grid-navigation-intent="below"
|
||||||
|
disabled={!selectedGridVolumeTargets.below || !mapRendererReady}
|
||||||
|
onClick={() => focusGridSector(selectedGridVolumeTargets.below)}
|
||||||
|
>Ниже</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="compact"
|
||||||
|
width="full"
|
||||||
|
data-grid-navigation-intent="above"
|
||||||
|
disabled={!selectedGridVolumeTargets.above || !mapRendererReady}
|
||||||
|
onClick={() => focusGridSector(selectedGridVolumeTargets.above)}
|
||||||
|
>Выше</Button>
|
||||||
|
</div> : null}
|
||||||
|
</div> : null}
|
||||||
|
</> : <small className="catalog-map-inspector__note">Кликните ячейку, чтобы получить устойчивый адрес, геометрию и навигацию по соседям.</small>}
|
||||||
|
</section>
|
||||||
|
</div>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { MapPresentation } from "./CesiumMapRenderer.js";
|
import type { MapPresentation } from "./mapRendererContract.js";
|
||||||
|
|
||||||
export type GridLodMode = "3d" | "graticule";
|
export type GridLodMode = "3d" | "graticule";
|
||||||
export type GridLodProfile = {
|
export type GridLodProfile = {
|
||||||
|
|||||||
@@ -0,0 +1,428 @@
|
|||||||
|
import type { Dispatch, ReactNode, SetStateAction } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Checker,
|
||||||
|
ColorField,
|
||||||
|
ControlRow,
|
||||||
|
Icon,
|
||||||
|
InspectorSelectField,
|
||||||
|
RangeControl,
|
||||||
|
} from "@nodedc/ui-react";
|
||||||
|
import type { SelectOption } from "@nodedc/ui-react";
|
||||||
|
import {
|
||||||
|
OSM_BUILDINGS_OBSERVED_BAND_COUNT,
|
||||||
|
cameraSurveySpiralDistance,
|
||||||
|
type CameraSurveySelection,
|
||||||
|
} from "./mapCameraPresets.js";
|
||||||
|
import {
|
||||||
|
isMapReferencePresentationProfile,
|
||||||
|
type MapReferenceLayer,
|
||||||
|
} from "./mapReferenceStations.js";
|
||||||
|
import type { MapPageSettings } from "./mapPageContract.js";
|
||||||
|
import type { MapGatewayHealth, MapProviderStatus } from "./mapRendererContract.js";
|
||||||
|
import type { MapPresentationProfile } from "./mapPresentationProfile.js";
|
||||||
|
import type { MapSelectableEntity } from "./mapWorkspaceModel.mjs";
|
||||||
|
|
||||||
|
export type MapInspectorSection = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
group: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
content: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UpdateMapSettings = (patch: Partial<MapPageSettings>) => void;
|
||||||
|
type UpdatePresentationProfile = (
|
||||||
|
profileId: string,
|
||||||
|
updater: (profile: MapPresentationProfile) => MapPresentationProfile,
|
||||||
|
) => void;
|
||||||
|
type UpdatePresentationStyle = (
|
||||||
|
profileId: string,
|
||||||
|
styleId: string,
|
||||||
|
patch: Partial<MapPresentationProfile["styles"][number]>,
|
||||||
|
) => void;
|
||||||
|
type GatewayCheckState = "idle" | "checking" | "ready" | "stale" | "error";
|
||||||
|
|
||||||
|
const providerStateLabel: Record<MapProviderStatus["imagery"], string> = {
|
||||||
|
loading: "загружается",
|
||||||
|
ready: "готов",
|
||||||
|
error: "недоступен",
|
||||||
|
"not-configured": "не настроен",
|
||||||
|
};
|
||||||
|
|
||||||
|
const logarithmicControlValue = (value: number) => Math.log10(Math.max(Number.MIN_VALUE, value));
|
||||||
|
const valueFromLogarithmicControl = (value: number) => Math.max(1, Math.round(10 ** value));
|
||||||
|
|
||||||
|
export const formatMetricDistance = (value: number) => value >= 1000
|
||||||
|
? `${(value / 1000).toLocaleString("ru-RU", { maximumFractionDigits: value >= 10_000 ? 0 : 1 })} км`
|
||||||
|
: `${Math.round(value)} м`;
|
||||||
|
|
||||||
|
const formatMetricSpeed = (value: number) => value >= 1000
|
||||||
|
? `${(value / 1000).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} км/с`
|
||||||
|
: `${Math.round(value)} м/с`;
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number) => {
|
||||||
|
if (seconds >= 86_400) return `${(seconds / 86_400).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} сут`;
|
||||||
|
if (seconds >= 3_600) return `${(seconds / 3_600).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} ч`;
|
||||||
|
if (seconds >= 60) return `${Math.round(seconds / 60)} мин`;
|
||||||
|
return `${Math.max(1, Math.round(seconds))} с`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildMapSurfaceInspectorSections({
|
||||||
|
mapSettings,
|
||||||
|
providerStatus,
|
||||||
|
updateMapSettings,
|
||||||
|
}: {
|
||||||
|
mapSettings: MapPageSettings;
|
||||||
|
providerStatus: MapProviderStatus;
|
||||||
|
updateMapSettings: UpdateMapSettings;
|
||||||
|
}): MapInspectorSection[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "map-base",
|
||||||
|
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>
|
||||||
|
<ControlRow label="Live providers"><span>Imagery: {providerStateLabel[providerStatus.imagery]} · Terrain: {providerStateLabel[providerStatus.terrain]} · 3D: {providerStateLabel[providerStatus.buildings]}</span></ControlRow>
|
||||||
|
{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>)}
|
||||||
|
<small className="catalog-map-inspector__note">Рельеф — отдельный слой под imagery.</small>
|
||||||
|
<Checker checked={mapSettings.terrainEnabled} label="Terrain" onChange={(terrainEnabled) => updateMapSettings({ terrainEnabled })} />
|
||||||
|
<RangeControl label="Вертикальное преувеличение рельефа" value={mapSettings.terrainExaggeration * 100} min={25} max={300} formatValue={(value) => `${(value / 100).toFixed(2)}×`} onChange={(value) => updateMapSettings({ terrainExaggeration: value / 100 })} />
|
||||||
|
<Checker checked={mapSettings.monochrome} label="Монохромная поверхность" onChange={(monochrome) => updateMapSettings({ monochrome })} />
|
||||||
|
<ControlRow label="Цвет монохрома"><ColorField label="Цвет монохромной поверхности" value={mapSettings.monochromeColor} onChange={(monochromeColor) => updateMapSettings({ monochromeColor })} /></ControlRow>
|
||||||
|
<RangeControl label="Яркость" value={mapSettings.imageryBrightness} min={0} max={200} formatValue={(value) => `${value}%`} onChange={(imageryBrightness) => updateMapSettings({ imageryBrightness })} />
|
||||||
|
<RangeControl label="Контраст" value={mapSettings.imageryContrast} min={0} max={200} formatValue={(value) => `${value}%`} onChange={(imageryContrast) => updateMapSettings({ imageryContrast })} />
|
||||||
|
<RangeControl label="Насыщенность" value={mapSettings.imagerySaturation} min={0} max={200} formatValue={(value) => `${value}%`} onChange={(imagerySaturation) => updateMapSettings({ imagerySaturation })} />
|
||||||
|
<RangeControl label="Гамма" value={mapSettings.imageryGamma} min={0} max={300} formatValue={(value) => `${value}%`} onChange={(imageryGamma) => updateMapSettings({ imageryGamma })} />
|
||||||
|
<RangeControl label="Оттенок" value={mapSettings.imageryHue} min={-180} max={180} formatValue={(value) => `${value}°`} onChange={(imageryHue) => updateMapSettings({ imageryHue })} />
|
||||||
|
<RangeControl label="Прозрачность imagery" value={mapSettings.imageryAlpha} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(imageryAlpha) => updateMapSettings({ imageryAlpha })} />
|
||||||
|
<ControlRow label="Цвет планеты"><ColorField label="Цвет terrain без imagery" value={mapSettings.globeColor} onChange={(globeColor) => updateMapSettings({ globeColor })} /></ControlRow>
|
||||||
|
<ControlRow label="Фон сцены"><ColorField label="Цвет фона сцены" value={mapSettings.backgroundColor} onChange={(backgroundColor) => updateMapSettings({ backgroundColor })} /></ControlRow>
|
||||||
|
</>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "map-atmosphere",
|
||||||
|
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 })} />
|
||||||
|
<RangeControl label="Атмосфера: насыщенность" value={mapSettings.atmosphereSaturation} min={-100} max={100} formatValue={(value) => `${value}%`} onChange={(atmosphereSaturation) => updateMapSettings({ atmosphereSaturation })} />
|
||||||
|
<RangeControl label="Атмосфера: яркость" value={mapSettings.atmosphereBrightness} min={-100} max={100} formatValue={(value) => `${value}%`} onChange={(atmosphereBrightness) => updateMapSettings({ atmosphereBrightness })} />
|
||||||
|
<Checker checked={mapSettings.fogEnabled} label="Туман" onChange={(fogEnabled) => updateMapSettings({ fogEnabled })} />
|
||||||
|
<RangeControl label="Плотность тумана" value={mapSettings.fogDensity} min={0} max={100} formatValue={(value) => `${(value / 10000).toFixed(4)}`} onChange={(fogDensity) => updateMapSettings({ fogDensity })} />
|
||||||
|
<Checker checked={mapSettings.sunEnabled} label="Солнечное освещение" onChange={(sunEnabled) => updateMapSettings({ sunEnabled })} />
|
||||||
|
<RangeControl label="Час солнца" value={mapSettings.sunHour} min={0} max={24} formatValue={(value) => `${value}:00 UTC`} onChange={(sunHour) => updateMapSettings({ sunHour })} />
|
||||||
|
<RangeControl label="Интенсивность света" value={mapSettings.sunIntensity} min={0} max={200} formatValue={(value) => `${value}%`} onChange={(sunIntensity) => updateMapSettings({ sunIntensity })} />
|
||||||
|
<Checker checked={mapSettings.shadowsEnabled} label="Тени" onChange={(shadowsEnabled) => updateMapSettings({ shadowsEnabled })} />
|
||||||
|
</>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "map-buildings",
|
||||||
|
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>
|
||||||
|
<RangeControl label="Прозрачность" value={Math.round(mapSettings.buildingsOpacity * 100)} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(value) => updateMapSettings({ buildingsOpacity: value / 100 })} />
|
||||||
|
<RangeControl label="Детализация" value={mapSettings.buildingsDetail} min={4} max={32} formatValue={(value) => `SSE ${value}`} onChange={(buildingsDetail) => updateMapSettings({ buildingsDetail })} />
|
||||||
|
</>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMapPresentationInspectorSections({
|
||||||
|
presentationProfiles,
|
||||||
|
referenceLayers,
|
||||||
|
setReferenceLayers,
|
||||||
|
updatePresentationProfile,
|
||||||
|
updatePresentationStyle,
|
||||||
|
}: {
|
||||||
|
presentationProfiles: MapPresentationProfile[];
|
||||||
|
referenceLayers: MapReferenceLayer[];
|
||||||
|
setReferenceLayers: Dispatch<SetStateAction<MapReferenceLayer[]>>;
|
||||||
|
updatePresentationProfile: UpdatePresentationProfile;
|
||||||
|
updatePresentationStyle: UpdatePresentationStyle;
|
||||||
|
}): MapInspectorSection[] {
|
||||||
|
return [
|
||||||
|
...presentationProfiles.flatMap((profile) => {
|
||||||
|
const referenceProfile = isMapReferencePresentationProfile(profile);
|
||||||
|
const referenceLayer = referenceLayers.find((layer) => layer.presentationProfileId === profile.id);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: `map-target-${profile.id}`,
|
||||||
|
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 ? (
|
||||||
|
<Checker
|
||||||
|
checked={referenceLayer.visible}
|
||||||
|
label={`Показывать слой «${profile.title}»`}
|
||||||
|
onChange={(visible) => setReferenceLayers((current) => current.map((layer) => (
|
||||||
|
layer.id === referenceLayer.id ? { ...layer, visible } : layer
|
||||||
|
)))}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{profile.target.variant === "surface-fill" && <>
|
||||||
|
<ControlRow label="Тип слоя"><strong>HGeoZone · ground projection</strong></ControlRow>
|
||||||
|
{profile.styles.map((style) => {
|
||||||
|
const classLabels = profile.classes.filter((item) => item.styleId === style.id).map((item) => item.label);
|
||||||
|
const label = classLabels.length ? classLabels.join(" · ") : style.id;
|
||||||
|
return <div className="catalog-map-inspector__style" key={style.id}>
|
||||||
|
<ControlRow label={`Заливка · ${label}`}><ColorField label={`Цвет заливки: ${label}`} value={style.color} onChange={(color) => updatePresentationStyle(profile.id, style.id, { color })} /></ControlRow>
|
||||||
|
<RangeControl label={`Прозрачность заливки · ${label}`} value={Math.round(style.opacity * 100)} min={0} max={100} step={1} formatValue={(value) => `${value}%`} onChange={(value) => updatePresentationStyle(profile.id, style.id, { opacity: value / 100 })} />
|
||||||
|
</div>;
|
||||||
|
})}
|
||||||
|
<ControlRow label="Граница"><ColorField label="Цвет границы HGeoZone" value={profile.target.outlineColor} onChange={(outlineColor) => updatePresentationProfile(profile.id, (current) => current.target.variant === "surface-fill" ? ({ ...current, target: { ...current.target, outlineColor } }) : current)} /></ControlRow>
|
||||||
|
<RangeControl label="Прозрачность границы" value={Math.round(profile.target.outlineOpacity * 100)} min={0} max={100} step={1} formatValue={(value) => `${value}%`} onChange={(value) => updatePresentationProfile(profile.id, (current) => current.target.variant === "surface-fill" ? ({ ...current, target: { ...current.target, outlineOpacity: value / 100 } }) : current)} />
|
||||||
|
<RangeControl label="Толщина границы" value={profile.target.outlineWidthPx} min={0} max={8} step={0.5} formatValue={(value) => `${value} px`} onChange={(outlineWidthPx) => updatePresentationProfile(profile.id, (current) => current.target.variant === "surface-fill" ? ({ ...current, target: { ...current.target, outlineWidthPx } }) : current)} />
|
||||||
|
</>}
|
||||||
|
{profile.target.variant === "elevated-spike" && <>
|
||||||
|
<RangeControl label="Высота таргета" value={profile.target.stemHeightMeters} min={100} max={10_000} step={50} formatValue={(value) => `${value} м`} onChange={(stemHeightMeters) => updatePresentationProfile(profile.id, (current) => current.target.variant === "elevated-spike" ? ({ ...current, target: { ...current.target, stemHeightMeters } }) : current)} />
|
||||||
|
<RangeControl label="Размер головки" value={profile.target.headSizePx} min={1} max={32} step={1} formatValue={(value) => `${value} px`} onChange={(headSizePx) => updatePresentationProfile(profile.id, (current) => current.target.variant === "elevated-spike" ? ({ ...current, target: { ...current.target, headSizePx } }) : current)} />
|
||||||
|
<RangeControl label="Толщина стержня" value={profile.target.stemWidthPx} min={0.25} max={12} step={0.25} formatValue={(value) => `${value} px`} onChange={(stemWidthPx) => updatePresentationProfile(profile.id, (current) => current.target.variant === "elevated-spike" ? ({ ...current, target: { ...current.target, stemWidthPx } }) : current)} />
|
||||||
|
</>}
|
||||||
|
<InspectorSelectField
|
||||||
|
label="Подпись"
|
||||||
|
value={profile.label.mode}
|
||||||
|
options={[
|
||||||
|
{ value: "subject_id", label: "ID", description: "Стабильный идентификатор сущности" },
|
||||||
|
{ value: "attributes", label: "Имя", description: "Первое доступное display-поле" },
|
||||||
|
{ value: "none", label: "Нет", description: "Не показывать плашку" },
|
||||||
|
]}
|
||||||
|
onChange={(mode) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, mode } }))}
|
||||||
|
/>
|
||||||
|
<RangeControl label="Размер подписи" value={profile.label.sizePx} min={8} max={32} step={1} formatValue={(value) => `${value} px`} onChange={(sizePx) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, sizePx } }))} />
|
||||||
|
<RangeControl label="Смещение подписи X" value={profile.label.offsetX} min={-100} max={100} step={1} formatValue={(value) => `${value} px`} onChange={(offsetX) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, offsetX } }))} />
|
||||||
|
<RangeControl label="Смещение подписи Y" value={profile.label.offsetY} min={-100} max={100} step={1} formatValue={(value) => `${value} px`} onChange={(offsetY) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, offsetY } }))} />
|
||||||
|
<RangeControl label="Скрывать подпись выше" value={profile.label.hideCameraHeightMeters} min={1_000} max={500_000} step={1_000} formatValue={(value) => `${Math.round(value / 1_000)} км`} onChange={(hideCameraHeightMeters) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, hideCameraHeightMeters } }))} />
|
||||||
|
<RangeControl label={profile.target.variant === "surface-fill" ? "Скрывать HGeoZone выше" : "Скрывать таргет выше"} value={profile.target.hideCameraHeightMeters} min={1_000} max={500_000} step={1_000} formatValue={(value) => `${Math.round(value / 1_000)} км`} onChange={(hideCameraHeightMeters) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, hideCameraHeightMeters } }))} />
|
||||||
|
<ControlRow label="Фон плашки"><ColorField label="Цвет фона подписи" value={profile.label.backgroundColor} onChange={(backgroundColor) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, backgroundColor } }))} /></ControlRow>
|
||||||
|
<RangeControl label="Прозрачность плашки" value={Math.round(profile.label.backgroundOpacity * 100)} min={0} max={100} step={1} formatValue={(value) => `${value}%`} onChange={(value) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, backgroundOpacity: value / 100 } }))} />
|
||||||
|
</>,
|
||||||
|
},
|
||||||
|
...(profile.target.variant === "surface-fill" || referenceProfile ? [] : [{
|
||||||
|
id: `map-state-classes-${profile.id}`,
|
||||||
|
label: "Классы состояния",
|
||||||
|
description: "нормализованные фасеты онтологии",
|
||||||
|
group: "Таргеты",
|
||||||
|
icon: <Icon name="sliders" />,
|
||||||
|
content: <>
|
||||||
|
<small className="catalog-map-inspector__note">Цвета назначены семантическим классам после нормализации данных. Здесь нет названий provider-статусов и привязки к транспорту.</small>
|
||||||
|
{profile.styles.map((style) => {
|
||||||
|
const classLabels = profile.classes.filter((item) => item.styleId === style.id).map((item) => item.label);
|
||||||
|
const label = classLabels.length ? classLabels.join(" · ") : style.id;
|
||||||
|
return <div className="catalog-map-inspector__style" key={style.id}>
|
||||||
|
<ControlRow label={label}><ColorField label={`Цвет: ${label}`} value={style.color} onChange={(color) => updatePresentationStyle(profile.id, style.id, { color })} /></ControlRow>
|
||||||
|
<RangeControl label={`${label}: прозрачность`} value={Math.round(style.opacity * 100)} min={0} max={100} step={1} formatValue={(value) => `${value}%`} onChange={(value) => updatePresentationStyle(profile.id, style.id, { opacity: value / 100 })} />
|
||||||
|
</div>;
|
||||||
|
})}
|
||||||
|
</>,
|
||||||
|
}]),
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMapRuntimeInspectorSections({
|
||||||
|
animationModeEnabled,
|
||||||
|
setAnimationMode,
|
||||||
|
spiralPresetId,
|
||||||
|
spiralPresetOptions,
|
||||||
|
spiralRunning,
|
||||||
|
selectSpiralPreset,
|
||||||
|
spiralHeightMeters,
|
||||||
|
setSpiralHeightMeters,
|
||||||
|
spiralSpeedMetersPerSecond,
|
||||||
|
setSpiralSpeedMetersPerSecond,
|
||||||
|
spiralPitchMetersPerTurn,
|
||||||
|
setSpiralPitchMetersPerTurn,
|
||||||
|
spiralTargetRadiusMeters,
|
||||||
|
setSpiralTargetRadiusMeters,
|
||||||
|
setSpiralPresetId,
|
||||||
|
spiralCanStart,
|
||||||
|
spiralTileCacheReady,
|
||||||
|
providerStatus,
|
||||||
|
gatewayHealth,
|
||||||
|
gatewayCheckState,
|
||||||
|
toggleSpiralAnimation,
|
||||||
|
spiralMessage,
|
||||||
|
mapSettings,
|
||||||
|
setCacheEnabled,
|
||||||
|
setCacheNoOverwrite,
|
||||||
|
gatewayEndpoint,
|
||||||
|
liveCacheSummary,
|
||||||
|
refreshCurrentViewport,
|
||||||
|
cacheRefresh,
|
||||||
|
verifyGateway,
|
||||||
|
transportDiagnostic,
|
||||||
|
gatewayHealthAge,
|
||||||
|
gatewayCheckError,
|
||||||
|
selected,
|
||||||
|
}: {
|
||||||
|
animationModeEnabled: boolean;
|
||||||
|
setAnimationMode: (enabled: boolean) => void;
|
||||||
|
spiralPresetId: CameraSurveySelection;
|
||||||
|
spiralPresetOptions: Array<SelectOption<CameraSurveySelection>>;
|
||||||
|
spiralRunning: boolean;
|
||||||
|
selectSpiralPreset: (presetId: CameraSurveySelection) => void;
|
||||||
|
spiralHeightMeters: number;
|
||||||
|
setSpiralHeightMeters: Dispatch<SetStateAction<number>>;
|
||||||
|
spiralSpeedMetersPerSecond: number;
|
||||||
|
setSpiralSpeedMetersPerSecond: Dispatch<SetStateAction<number>>;
|
||||||
|
spiralPitchMetersPerTurn: number;
|
||||||
|
setSpiralPitchMetersPerTurn: Dispatch<SetStateAction<number>>;
|
||||||
|
spiralTargetRadiusMeters: number;
|
||||||
|
setSpiralTargetRadiusMeters: Dispatch<SetStateAction<number>>;
|
||||||
|
setSpiralPresetId: Dispatch<SetStateAction<CameraSurveySelection>>;
|
||||||
|
spiralCanStart: boolean;
|
||||||
|
spiralTileCacheReady: boolean;
|
||||||
|
providerStatus: MapProviderStatus;
|
||||||
|
gatewayHealth: MapGatewayHealth | null;
|
||||||
|
gatewayCheckState: GatewayCheckState;
|
||||||
|
toggleSpiralAnimation: () => void;
|
||||||
|
spiralMessage: string | null;
|
||||||
|
mapSettings: MapPageSettings;
|
||||||
|
setCacheEnabled: (enabled: boolean) => void;
|
||||||
|
setCacheNoOverwrite: (enabled: boolean) => void;
|
||||||
|
gatewayEndpoint: string | null;
|
||||||
|
liveCacheSummary: string;
|
||||||
|
refreshCurrentViewport: () => void;
|
||||||
|
cacheRefresh: boolean;
|
||||||
|
verifyGateway: () => void | Promise<void>;
|
||||||
|
transportDiagnostic: string | null;
|
||||||
|
gatewayHealthAge: string | null;
|
||||||
|
gatewayCheckError: string | null;
|
||||||
|
selected: MapSelectableEntity | undefined;
|
||||||
|
}): MapInspectorSection[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "map-camera-animation",
|
||||||
|
label: "Анимация камеры",
|
||||||
|
description: "geodesic spiral survey",
|
||||||
|
group: "Камера",
|
||||||
|
icon: <Icon name="activity" />,
|
||||||
|
content: <>
|
||||||
|
<Checker checked={animationModeEnabled} label="Режим анимации" onChange={setAnimationMode} />
|
||||||
|
{animationModeEnabled ? <>
|
||||||
|
<small className="catalog-map-inspector__note">Стартовая точка берётся из текущей позиции камеры. Камера смотрит почти в надир, а маршрут ждёт текущие tiles перед продолжением. Движение идёт по региональной геодезической спирали WGS84 до выбранного радиуса.</small>
|
||||||
|
<InspectorSelectField
|
||||||
|
label="Профиль покрытия"
|
||||||
|
value={spiralPresetId}
|
||||||
|
options={spiralPresetOptions}
|
||||||
|
disabled={spiralRunning}
|
||||||
|
onChange={selectSpiralPreset}
|
||||||
|
/>
|
||||||
|
<small className="catalog-map-inspector__note">У текущего OSM Buildings подтверждено {OSM_BUILDINGS_OBSERVED_BAND_COUNT} иерархических bands. Десять профилей управляют высотой и покрытием; фактический LOD Cesium выбирает по SSE, viewport и расстоянию.</small>
|
||||||
|
<ControlRow label="Слои прохода"><small>Imagery · Terrain · OSM Buildings</small></ControlRow>
|
||||||
|
<RangeControl
|
||||||
|
label="Высота над землёй"
|
||||||
|
value={logarithmicControlValue(spiralHeightMeters)}
|
||||||
|
min={logarithmicControlValue(10)}
|
||||||
|
max={logarithmicControlValue(100_000)}
|
||||||
|
step={0.01}
|
||||||
|
disabled={spiralRunning}
|
||||||
|
formatValue={(value) => formatMetricDistance(10 ** value)}
|
||||||
|
onChange={(value) => {
|
||||||
|
setSpiralPresetId("custom");
|
||||||
|
setSpiralHeightMeters(valueFromLogarithmicControl(value));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<RangeControl
|
||||||
|
label="Скорость камеры"
|
||||||
|
value={logarithmicControlValue(spiralSpeedMetersPerSecond)}
|
||||||
|
min={logarithmicControlValue(1)}
|
||||||
|
max={logarithmicControlValue(5_000)}
|
||||||
|
step={0.01}
|
||||||
|
disabled={spiralRunning}
|
||||||
|
formatValue={(value) => formatMetricSpeed(10 ** value)}
|
||||||
|
onChange={(value) => {
|
||||||
|
setSpiralPresetId("custom");
|
||||||
|
setSpiralSpeedMetersPerSecond(valueFromLogarithmicControl(value));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<RangeControl
|
||||||
|
label="Шаг спирали"
|
||||||
|
value={logarithmicControlValue(spiralPitchMetersPerTurn)}
|
||||||
|
min={logarithmicControlValue(20)}
|
||||||
|
max={logarithmicControlValue(100_000)}
|
||||||
|
step={0.01}
|
||||||
|
disabled={spiralRunning}
|
||||||
|
formatValue={(value) => formatMetricDistance(10 ** value)}
|
||||||
|
onChange={(value) => {
|
||||||
|
setSpiralPresetId("custom");
|
||||||
|
setSpiralPitchMetersPerTurn(valueFromLogarithmicControl(value));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<RangeControl
|
||||||
|
label="Радиус прохода"
|
||||||
|
value={logarithmicControlValue(spiralTargetRadiusMeters)}
|
||||||
|
min={logarithmicControlValue(1_000)}
|
||||||
|
max={logarithmicControlValue(250_000)}
|
||||||
|
step={0.01}
|
||||||
|
disabled={spiralRunning}
|
||||||
|
formatValue={(value) => formatMetricDistance(10 ** value)}
|
||||||
|
onChange={(value) => {
|
||||||
|
setSpiralPresetId("custom");
|
||||||
|
setSpiralTargetRadiusMeters(valueFromLogarithmicControl(value));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<small className="catalog-map-inspector__note">Расчётное движение без ожидания сети: {formatDuration(cameraSurveySpiralDistance(spiralTargetRadiusMeters, spiralPitchMetersPerTurn) / spiralSpeedMetersPerSecond)}. Tile waits и автоматическое сужение шага под viewport увеличат фактическое время.</small>
|
||||||
|
{!spiralCanStart && !spiralRunning ? <small className="catalog-map-inspector__note" role="status">Подготовка: imagery — {providerStateLabel[providerStatus.imagery]}, terrain — {providerStateLabel[providerStatus.terrain]}, OSM Buildings — {providerStateLabel[providerStatus.buildings]}, TileCache — {spiralTileCacheReady ? "готов" : gatewayHealth?.cache?.atCapacity ? "заполнен" : gatewayCheckState === "checking" ? "проверяется" : "недоступен для записи"}.</small> : null}
|
||||||
|
<Button variant="secondary" shape="pill" onClick={toggleSpiralAnimation} disabled={!spiralRunning && !spiralCanStart}>{spiralRunning ? "Остановить" : "Запустить режим анимации"}</Button>
|
||||||
|
{spiralRunning ? <small className="catalog-map-inspector__note">Камера движется от исходной точки. Выключение режима, уход со страницы или reload остановят сессию.</small> : null}
|
||||||
|
{spiralMessage ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="status">{spiralMessage}</small> : null}
|
||||||
|
</> : null}
|
||||||
|
</>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "map-cache",
|
||||||
|
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} />
|
||||||
|
<small className="catalog-map-inspector__note">Cache hit отдаётся как есть; новый tile записывается только при miss.</small>
|
||||||
|
<Checker checked={mapSettings.cacheNoOverwrite} disabled={!mapSettings.cacheEnabled} label="Не перезаписывать уже полученное" onChange={setCacheNoOverwrite} />
|
||||||
|
<ControlRow className="catalog-map-inspector__cache-fact" label="Режим"><span>{mapSettings.cacheEnabled ? mapSettings.cacheNoOverwrite ? "Live + Cache · append-only" : "Live + Cache · обновление разрешено" : "Live без persistent cache"}</span></ControlRow>
|
||||||
|
<ControlRow className="catalog-map-inspector__cache-fact" label="Хранилище"><span>Platform Map Gateway</span></ControlRow>
|
||||||
|
<ControlRow className="catalog-map-inspector__cache-fact" label="Подключение"><span>{gatewayEndpoint ?? "runtime profile · не проверено"}</span></ControlRow>
|
||||||
|
<ControlRow className="catalog-map-inspector__cache-fact" label="Записано"><span>{liveCacheSummary}</span></ControlRow>
|
||||||
|
<ControlRow className="catalog-map-inspector__cache-fact" label="Политика"><span>{gatewayHealth?.cache?.writePolicy ?? "append-only · проверяется"}</span></ControlRow>
|
||||||
|
<Button variant="secondary" shape="pill" icon={<Icon name="refresh" />} onClick={refreshCurrentViewport} disabled={!mapSettings.cacheEnabled || cacheRefresh}> {cacheRefresh ? "Обновляем viewport…" : "Обновить текущий viewport"}</Button>
|
||||||
|
<Button variant="secondary" shape="pill" icon={<Icon name="refresh" />} onClick={() => void verifyGateway()} disabled={gatewayCheckState === "checking"}>{gatewayCheckState === "checking" ? "Проверяем Gateway…" : "Проверить подключение"}</Button>
|
||||||
|
<small className="catalog-map-inspector__note">Live Cache: {liveCacheSummary} · {gatewayHealth?.cache?.mode ?? "проверяется"}</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}
|
||||||
|
<small className="catalog-map-inspector__note">{mapSettings.cacheEnabled ? mapSettings.cacheNoOverwrite ? "Новые miss дописываются; при заполнении объёма Gateway продолжит live-маршрут без удаления прежних tiles." : "Новые запросы этого Application могут явно обновлять уже записанные tiles." : "Real-time: provider остаётся официальным, чтение и запись persistent cache выключены."}</small>
|
||||||
|
{gatewayHealth?.cache?.atCapacity ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">TileCache заполнен: новые tiles показываются live, но не записываются. Существующий cache не удаляется.</small> : null}
|
||||||
|
{gatewayCheckState === "error" || gatewayCheckState === "stale" ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{gatewayCheckError}</small> : null}
|
||||||
|
</>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "map-selection",
|
||||||
|
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>
|
||||||
|
</>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import type { WorkspaceWindowRect } from "@nodedc/ui-react";
|
||||||
|
import type { GridLodProfile, MapCameraView, MapPresentation } from "./mapRendererContract.js";
|
||||||
|
import { DEFAULT_GRID_LOD_PROFILES } from "./mapGridPolicy.mjs";
|
||||||
|
import type { MapPresentationProfile } from "./mapPresentationProfile.js";
|
||||||
|
import {
|
||||||
|
ensureMapReferencePresentationProfiles,
|
||||||
|
initialMapReferenceLayers,
|
||||||
|
type MapReferenceLayer,
|
||||||
|
} from "./mapReferenceStations.js";
|
||||||
|
import { DEFAULT_MAP_SUBJECT_DETAIL_PROFILE } from "./mapSubjectCard.mjs";
|
||||||
|
import type { MapSubjectDetailProfile } from "./mapSubjectCard.mjs";
|
||||||
|
|
||||||
|
export type MapPageSettings = Omit<MapPresentation, "cacheRefresh">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stable, provider-neutral visual binding owned by one Map Page instance.
|
||||||
|
*
|
||||||
|
* This is intentionally distinct from a live Data Product subject. Engine can
|
||||||
|
* identify the source entity, while Foundry owns the pin presentation and its
|
||||||
|
* page-local placement contract.
|
||||||
|
*/
|
||||||
|
export type MapPinBinding = {
|
||||||
|
id: string;
|
||||||
|
subjectId: string;
|
||||||
|
kind: "elevated-spike";
|
||||||
|
label: string;
|
||||||
|
status: string;
|
||||||
|
coordinates: { longitude: number; latitude: number; heightMeters: number };
|
||||||
|
source: { entityId: string; streamId: string; displayFields: string[] };
|
||||||
|
attributes: Record<string, string | number | boolean>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A renderer-neutral declaration of a Data Product assigned to a Map slot.
|
||||||
|
*
|
||||||
|
* It contains no provider endpoint, tenant/connection scope, credential,
|
||||||
|
* browser token or raw payload. Foundry resolves the target-scoped server
|
||||||
|
* consumer from application/page/binding identity.
|
||||||
|
*/
|
||||||
|
export type MapDataProductBinding = {
|
||||||
|
id: string;
|
||||||
|
displayName?: string;
|
||||||
|
order?: number;
|
||||||
|
dataProductId: string;
|
||||||
|
slotId: string;
|
||||||
|
delivery: "snapshot+patch";
|
||||||
|
semanticTypes: string[];
|
||||||
|
fieldProjection: string[];
|
||||||
|
presentationProfileId?: string;
|
||||||
|
subjectDetailProfileId?: string;
|
||||||
|
aspectId?: string;
|
||||||
|
joinToBindingId?: string;
|
||||||
|
dataClass?: "operational" | "restricted";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapSubjectWindowState = {
|
||||||
|
open: boolean;
|
||||||
|
rect: WorkspaceWindowRect;
|
||||||
|
maximized: boolean;
|
||||||
|
zIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapWorkspaceWindowId = "sector" | "subject-card" | `binding:${string}`;
|
||||||
|
|
||||||
|
export type MapSubjectState = {
|
||||||
|
bindingId: string;
|
||||||
|
visible: boolean;
|
||||||
|
/** Missing facet means unconstrained; an explicit empty list means no matches. */
|
||||||
|
filters: Record<string, string[]>;
|
||||||
|
window: MapSubjectWindowState;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapPageLayout = {
|
||||||
|
schemaVersion: 1;
|
||||||
|
pageId: "map";
|
||||||
|
settings: MapPageSettings;
|
||||||
|
mapHeight: number;
|
||||||
|
camera: MapCameraView;
|
||||||
|
pinBindings: MapPinBinding[];
|
||||||
|
presentationProfiles: MapPresentationProfile[];
|
||||||
|
subjectDetailProfiles: MapSubjectDetailProfile[];
|
||||||
|
dataProductBindings: MapDataProductBinding[];
|
||||||
|
subjectStates: MapSubjectState[];
|
||||||
|
referenceLayers: MapReferenceLayer[];
|
||||||
|
inspectorOpenSections: string[];
|
||||||
|
savedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapFixturePreviewHandle = {
|
||||||
|
getLayout: () => MapPageLayout | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const initialMapSettings: MapPageSettings = {
|
||||||
|
imagerySource: "cesium-live",
|
||||||
|
imageryVisible: true,
|
||||||
|
cacheEnabled: true,
|
||||||
|
cacheNoOverwrite: true,
|
||||||
|
terrainEnabled: true,
|
||||||
|
terrainExaggeration: 1,
|
||||||
|
monochrome: false,
|
||||||
|
monochromeColor: "#15151b",
|
||||||
|
imageryGamma: 57,
|
||||||
|
imageryHue: 13,
|
||||||
|
imageryAlpha: 27,
|
||||||
|
globeColor: "#15151b",
|
||||||
|
backgroundColor: "#08090d",
|
||||||
|
atmosphereEnabled: false,
|
||||||
|
atmosphereHue: 0,
|
||||||
|
atmosphereSaturation: 0,
|
||||||
|
atmosphereBrightness: 0,
|
||||||
|
fogEnabled: true,
|
||||||
|
fogDensity: 2,
|
||||||
|
sunEnabled: true,
|
||||||
|
sunHour: 12,
|
||||||
|
sunIntensity: 200,
|
||||||
|
shadowsEnabled: true,
|
||||||
|
buildingsVisible: true,
|
||||||
|
buildingsColor: "#a27aff",
|
||||||
|
buildingsOpacity: 1,
|
||||||
|
buildingsDetail: 4,
|
||||||
|
imageryBrightness: 118,
|
||||||
|
imageryContrast: 102,
|
||||||
|
imagerySaturation: 0,
|
||||||
|
gridVisible: true,
|
||||||
|
gridLodEnabled: true,
|
||||||
|
grid3dEnabled: true,
|
||||||
|
gridGraticuleEnabled: true,
|
||||||
|
gridCenterMode: "fixed",
|
||||||
|
gridCenterLatitude: 55.7558,
|
||||||
|
gridCenterLongitude: 37.6173,
|
||||||
|
gridTileSizeKm: 10,
|
||||||
|
gridAutoDisableHeightKm: 10_000,
|
||||||
|
gridRebuildOnMoveEnd: true,
|
||||||
|
gridLegacyMode: false,
|
||||||
|
gridMax3dViewAngleDegrees: 30,
|
||||||
|
gridHeightMeters: 500,
|
||||||
|
gridLod1MaxHeightKm: 10,
|
||||||
|
gridLod1StepKm: 1,
|
||||||
|
gridLod1Mode: "3d",
|
||||||
|
gridLod2MaxHeightKm: 50,
|
||||||
|
gridLod2StepKm: 5,
|
||||||
|
gridLod2Mode: "3d",
|
||||||
|
gridLod3MaxHeightKm: 200,
|
||||||
|
gridLod3StepKm: 25,
|
||||||
|
gridLod3Mode: "3d",
|
||||||
|
gridLod4MaxHeightKm: 800,
|
||||||
|
gridLod4StepKm: 50,
|
||||||
|
gridLod4Mode: "graticule",
|
||||||
|
gridLod5MaxHeightKm: 3_000,
|
||||||
|
gridLod5StepKm: 50,
|
||||||
|
gridLod5Mode: "graticule",
|
||||||
|
gridRadiusKm: 40,
|
||||||
|
gridLineWidth: 1,
|
||||||
|
gridLineDiameterMeters: 7,
|
||||||
|
gridColor: "#9c9c9c",
|
||||||
|
gridOpacity: 12,
|
||||||
|
gridDotsEnabled: true,
|
||||||
|
gridDotsSize: 7,
|
||||||
|
gridDotsDiameterMeters: 10,
|
||||||
|
gridDotsColor: "#9c9c9c",
|
||||||
|
gridDotsOpacity: 58,
|
||||||
|
gridCrossesEnabled: false,
|
||||||
|
gridCrossesLengthMeters: 60,
|
||||||
|
gridCrossesWidthMeters: 10,
|
||||||
|
gridCrossesColor: "#9c9c9c",
|
||||||
|
gridCrossesOpacity: 46,
|
||||||
|
gridLodProfiles: structuredClone(DEFAULT_GRID_LOD_PROFILES) as GridLodProfile[],
|
||||||
|
};
|
||||||
|
|
||||||
|
// A deterministic provider-neutral viewport exists before the renderer emits
|
||||||
|
// its first camera update, so a freshly created page is immediately saveable.
|
||||||
|
export const fallbackMapCamera: MapCameraView = {
|
||||||
|
longitude: 37.618423,
|
||||||
|
latitude: 55.751244,
|
||||||
|
height: 40_000,
|
||||||
|
heading: 0,
|
||||||
|
pitch: -0.9,
|
||||||
|
roll: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createDefaultMapPageLayout(expanded = false): MapPageLayout {
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
pageId: "map",
|
||||||
|
settings: structuredClone(initialMapSettings),
|
||||||
|
mapHeight: expanded ? 620 : 470,
|
||||||
|
camera: { ...fallbackMapCamera },
|
||||||
|
pinBindings: [],
|
||||||
|
presentationProfiles: ensureMapReferencePresentationProfiles([]),
|
||||||
|
subjectDetailProfiles: [structuredClone(DEFAULT_MAP_SUBJECT_DETAIL_PROFILE) as MapSubjectDetailProfile],
|
||||||
|
dataProductBindings: [],
|
||||||
|
subjectStates: [],
|
||||||
|
referenceLayers: initialMapReferenceLayers(),
|
||||||
|
inspectorOpenSections: ["map-base"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultSubjectWindowState(index: number): MapSubjectWindowState {
|
||||||
|
return {
|
||||||
|
open: false,
|
||||||
|
rect: {
|
||||||
|
x: 24 + (index % 5) * 28,
|
||||||
|
y: 56 + (index % 5) * 28,
|
||||||
|
width: 280,
|
||||||
|
height: 260,
|
||||||
|
},
|
||||||
|
maximized: false,
|
||||||
|
zIndex: 20 + index,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultSectorWindowRect: WorkspaceWindowRect = {
|
||||||
|
x: 24,
|
||||||
|
y: 72,
|
||||||
|
width: 380,
|
||||||
|
height: 530,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const defaultSubjectCardRect: WorkspaceWindowRect = {
|
||||||
|
x: 940,
|
||||||
|
y: 72,
|
||||||
|
width: 390,
|
||||||
|
height: 520,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type { MapSubjectDetailProfile } from "./mapSubjectCard.mjs";
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import type { MapRuntimeFact } from "./useMapDataProductRuntime.js";
|
||||||
|
|
||||||
|
export type MapPresentationFacetValue = {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
order: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapPresentationFacet = {
|
||||||
|
id: string;
|
||||||
|
field: string;
|
||||||
|
label: string;
|
||||||
|
filterable: boolean;
|
||||||
|
counter: boolean;
|
||||||
|
values: MapPresentationFacetValue[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapPresentationStyle = {
|
||||||
|
id: string;
|
||||||
|
color: string;
|
||||||
|
opacity: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapPresentationClass = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
priority: number;
|
||||||
|
match: Array<{ field: string; equals: string }>;
|
||||||
|
styleId: string;
|
||||||
|
renderable: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapPresentationProfile = {
|
||||||
|
id: string;
|
||||||
|
version: string;
|
||||||
|
title: string;
|
||||||
|
semanticTypes: string[];
|
||||||
|
label: {
|
||||||
|
mode: "subject_id" | "attributes" | "none";
|
||||||
|
fields: string[];
|
||||||
|
fontWeight: number;
|
||||||
|
sizePx: number;
|
||||||
|
color: string;
|
||||||
|
outlineColor: string;
|
||||||
|
outlineWidthPx: number;
|
||||||
|
backgroundColor: string;
|
||||||
|
backgroundOpacity: number;
|
||||||
|
paddingX: number;
|
||||||
|
paddingY: number;
|
||||||
|
maxLength: number;
|
||||||
|
offsetX: number;
|
||||||
|
offsetY: number;
|
||||||
|
hideCameraHeightMeters: number;
|
||||||
|
};
|
||||||
|
target: ({
|
||||||
|
variant: "elevated-spike";
|
||||||
|
stemHeightMeters: number;
|
||||||
|
headSizePx: number;
|
||||||
|
stemWidthPx: number;
|
||||||
|
} | {
|
||||||
|
variant: "surface-fill";
|
||||||
|
}) & {
|
||||||
|
outlineColor: string;
|
||||||
|
outlineOpacity: number;
|
||||||
|
outlineWidthPx: number;
|
||||||
|
hideCameraHeightMeters: number;
|
||||||
|
};
|
||||||
|
facets: MapPresentationFacet[];
|
||||||
|
styles: MapPresentationStyle[];
|
||||||
|
classes: MapPresentationClass[];
|
||||||
|
defaultClassId: string;
|
||||||
|
sort: Array<{ field: string; order: string[] }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapSubjectFilterState = {
|
||||||
|
/** False is an explicit empty map state. It must never normalize to all. */
|
||||||
|
visible: boolean;
|
||||||
|
/** Missing facet is unconstrained; explicit empty facet matches nothing. */
|
||||||
|
facets: Record<string, string[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapPresentationFilters = Record<string, MapSubjectFilterState>;
|
||||||
|
|
||||||
|
export function normalizeClientMapPresentationProfiles(profiles: MapPresentationProfile[]): MapPresentationProfile[];
|
||||||
|
export function mapPresentationBindingIsAll(bindingId: string, filters: MapPresentationFilters): boolean;
|
||||||
|
export function mapPresentationFacetValueIsEnabled(facets: Record<string, string[]>, field: string, value: string): boolean;
|
||||||
|
export function normalizeMapPresentationFacetSelections(facets: Record<string, string[]>, profile: MapPresentationProfile): Record<string, string[]>;
|
||||||
|
export function toggleMapPresentationFacetSelection(facets: Record<string, string[]>, field: string, value: string, availableValues: string[]): Record<string, string[]>;
|
||||||
|
export function mapPresentationProfileForFact(profiles: MapPresentationProfile[], presentationProfileId: string | undefined, semanticType: string): MapPresentationProfile | undefined;
|
||||||
|
export function resolveMapPresentationClass(fact: MapRuntimeFact, profile: MapPresentationProfile): MapPresentationClass | undefined;
|
||||||
|
export function resolveMapPresentationStyle(profile: MapPresentationProfile, presentationClass?: MapPresentationClass): MapPresentationStyle | undefined;
|
||||||
|
export function mapRuntimeDisplayLabel(fact: MapRuntimeFact, profile?: MapPresentationProfile): string;
|
||||||
|
export function mapRuntimeFactIsRenderable(fact: MapRuntimeFact, profile: MapPresentationProfile): boolean;
|
||||||
|
export function mapRuntimeFactIsVisible(fact: MapRuntimeFact, profile: MapPresentationProfile, filters: MapPresentationFilters, bindingId: string): boolean;
|
||||||
|
export function mapFactMatchesFilters(fact: MapRuntimeFact, profile: MapPresentationProfile, filters: MapPresentationFilters, bindingId: string): boolean;
|
||||||
|
export function compareMapRuntimeFacts(left: MapRuntimeFact, right: MapRuntimeFact, profile: MapPresentationProfile): number;
|
||||||
|
export function mapPresentationFacetCounts(facts: MapRuntimeFact[], profile: MapPresentationProfile): Record<string, Record<string, number>>;
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* Runtime-only implementation of the provider-neutral presentation model.
|
||||||
|
* Types live beside it in mapPresentationProfile.d.mts so Node tests can
|
||||||
|
* execute the same code that the browser uses without a TypeScript loader.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function normalizeClientMapPresentationProfiles(profiles) {
|
||||||
|
return profiles.flatMap((profile) => {
|
||||||
|
const legacyPin = profile.pin;
|
||||||
|
const target = profile.target ?? legacyPin;
|
||||||
|
if (!target) return [];
|
||||||
|
const normalized = { ...profile, target };
|
||||||
|
delete normalized.pin;
|
||||||
|
return [normalized];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapPresentationBindingIsAll(bindingId, filters) {
|
||||||
|
const state = filters[bindingId];
|
||||||
|
return state?.visible !== false && Object.keys(state?.facets ?? {}).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A missing facet is compact canonical “all”. An explicit array is the exact
|
||||||
|
* enabled subset; an empty array therefore remains intentional “none”.
|
||||||
|
*/
|
||||||
|
export function mapPresentationFacetValueIsEnabled(facets, field, value) {
|
||||||
|
const selected = facets[field];
|
||||||
|
return selected === undefined || selected.includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeMapPresentationFacetSelections(facets, profile) {
|
||||||
|
return Object.fromEntries(profile.facets.flatMap((facet) => {
|
||||||
|
const selected = facets[facet.field];
|
||||||
|
if (selected === undefined) return [];
|
||||||
|
const availableValues = [...new Set(facet.values.map((item) => item.value))];
|
||||||
|
const enabledValues = availableValues.filter((value) => selected.includes(value));
|
||||||
|
// Legacy layouts could persist every value explicitly. Canonicalize that
|
||||||
|
// to unconstrained so a scoped facet cannot suppress unrelated subjects.
|
||||||
|
return availableValues.length > 0 && enabledValues.length === availableValues.length
|
||||||
|
? []
|
||||||
|
: [[facet.field, enabledValues]];
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleMapPresentationFacetSelection(facets, field, value, availableValues) {
|
||||||
|
const values = [...new Set(availableValues)];
|
||||||
|
if (!values.includes(value)) return facets;
|
||||||
|
const selected = facets[field];
|
||||||
|
const enabled = new Set(selected === undefined
|
||||||
|
? values
|
||||||
|
: values.filter((item) => selected.includes(item)));
|
||||||
|
if (enabled.has(value)) enabled.delete(value);
|
||||||
|
else enabled.add(value);
|
||||||
|
const nextEnabled = values.filter((item) => enabled.has(item));
|
||||||
|
const next = { ...facets };
|
||||||
|
if (nextEnabled.length === values.length) delete next[field];
|
||||||
|
else next[field] = nextEnabled;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapPresentationProfileForFact(profiles, presentationProfileId, semanticType) {
|
||||||
|
const exact = presentationProfileId
|
||||||
|
? profiles.find((profile) => profile.id === presentationProfileId)
|
||||||
|
: undefined;
|
||||||
|
if (exact?.semanticTypes.includes(semanticType)) return exact;
|
||||||
|
return profiles.find((profile) => profile.semanticTypes.includes(semanticType));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMapPresentationClass(fact, profile) {
|
||||||
|
const classes = [...profile.classes].sort((left, right) => right.priority - left.priority || left.id.localeCompare(right.id));
|
||||||
|
return classes.find((item) => item.match.every((condition) => (
|
||||||
|
normalizedFacetValue(fact.attributes[condition.field]) === condition.equals
|
||||||
|
))) ?? classes.find((item) => item.id === profile.defaultClassId) ?? classes.at(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMapPresentationStyle(profile, presentationClass) {
|
||||||
|
const selected = presentationClass ?? profile.classes.find((item) => item.id === profile.defaultClassId);
|
||||||
|
return profile.styles.find((style) => style.id === selected?.styleId) ?? profile.styles[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapRuntimeDisplayLabel(fact, profile) {
|
||||||
|
if (profile?.label.mode === "subject_id") return fact.sourceId;
|
||||||
|
const fields = profile?.label.fields ?? ["display_name", "label", "name", "title"];
|
||||||
|
for (const key of fields) {
|
||||||
|
const value = fact.attributes[key];
|
||||||
|
if (typeof value === "string" && value.trim()) {
|
||||||
|
const normalized = value.trim();
|
||||||
|
const limit = profile?.label.maxLength ?? 80;
|
||||||
|
return normalized.length > limit ? `${normalized.slice(0, Math.max(1, limit - 1))}…` : normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fact.sourceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapRuntimeFactIsRenderable(fact, profile) {
|
||||||
|
return Boolean(fact.geometry) && resolveMapPresentationClass(fact, profile)?.renderable === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapRuntimeFactIsVisible(fact, profile, filters, bindingId) {
|
||||||
|
return mapRuntimeFactIsRenderable(fact, profile) && mapFactMatchesFilters(fact, profile, filters, bindingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapFactMatchesFilters(fact, profile, filters, bindingId) {
|
||||||
|
const state = filters[bindingId];
|
||||||
|
if (state?.visible === false) return false;
|
||||||
|
|
||||||
|
const selectedFacets = profile.facets.flatMap((facet) => {
|
||||||
|
const selected = state?.facets?.[facet.field];
|
||||||
|
return selected === undefined ? [] : [{ facet, selected }];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selectedFacets.some(({ selected }) => selected.length === 0)) return false;
|
||||||
|
if (selectedFacets.length === 0) return true;
|
||||||
|
|
||||||
|
return selectedFacets.every(({ facet, selected }) => (
|
||||||
|
mapFactParticipatesInFacet(fact, profile, facet)
|
||||||
|
&& selected.includes(normalizedFacetValue(fact.attributes[facet.field]))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareMapRuntimeFacts(left, right, profile) {
|
||||||
|
for (const rule of profile.sort) {
|
||||||
|
const leftRank = sortRank(rule.order, normalizedFacetValue(left.attributes[rule.field]));
|
||||||
|
const rightRank = sortRank(rule.order, normalizedFacetValue(right.attributes[rule.field]));
|
||||||
|
if (leftRank !== rightRank) return leftRank - rightRank;
|
||||||
|
}
|
||||||
|
return mapRuntimeDisplayLabel(left, profile).localeCompare(mapRuntimeDisplayLabel(right, profile), "ru");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapPresentationFacetCounts(facts, profile) {
|
||||||
|
return Object.fromEntries(profile.facets.map((facet) => {
|
||||||
|
const counts = Object.fromEntries(facet.values.map((item) => [item.value, 0]));
|
||||||
|
for (const fact of facts) {
|
||||||
|
if (!mapFactParticipatesInFacet(fact, profile, facet)) continue;
|
||||||
|
const value = normalizedFacetValue(fact.attributes[facet.field]);
|
||||||
|
if (Object.hasOwn(counts, value)) counts[value] += 1;
|
||||||
|
}
|
||||||
|
return [facet.field, counts];
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Movement remains an independent fact, but an inactive subject must not
|
||||||
|
* present a stale last speed as current movement.
|
||||||
|
*/
|
||||||
|
function mapFactParticipatesInFacet(fact, profile, facet) {
|
||||||
|
if (facet.field !== "movement_state") return true;
|
||||||
|
if (!profile.facets.some((item) => item.field === "signal_state")) return true;
|
||||||
|
return normalizedFacetValue(fact.attributes.signal_state) === "active";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedFacetValue(value) {
|
||||||
|
return typeof value === "string" ? value.trim().toLowerCase() : "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortRank(order, value) {
|
||||||
|
const index = order.indexOf(value);
|
||||||
|
return index === -1 ? order.length : index;
|
||||||
|
}
|
||||||
@@ -1,287 +1,3 @@
|
|||||||
import type { MapRuntimeFact } from "./useMapDataProductRuntime.js";
|
// Stable TypeScript facade for existing `.js` specifiers. Runtime behavior is
|
||||||
|
// executable JavaScript so browser code and Node behavioral tests share it.
|
||||||
export type MapPresentationFacetValue = {
|
export * from "./mapPresentationProfile.mjs";
|
||||||
value: string;
|
|
||||||
label: string;
|
|
||||||
order: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MapPresentationFacet = {
|
|
||||||
id: string;
|
|
||||||
field: string;
|
|
||||||
label: string;
|
|
||||||
filterable: boolean;
|
|
||||||
counter: boolean;
|
|
||||||
values: MapPresentationFacetValue[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MapPresentationStyle = {
|
|
||||||
id: string;
|
|
||||||
color: string;
|
|
||||||
opacity: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MapPresentationClass = {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
priority: number;
|
|
||||||
match: Array<{ field: string; equals: string }>;
|
|
||||||
styleId: string;
|
|
||||||
renderable: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MapPresentationProfile = {
|
|
||||||
id: string;
|
|
||||||
version: string;
|
|
||||||
title: string;
|
|
||||||
semanticTypes: string[];
|
|
||||||
label: {
|
|
||||||
mode: "subject_id" | "attributes" | "none";
|
|
||||||
fields: string[];
|
|
||||||
fontWeight: number;
|
|
||||||
sizePx: number;
|
|
||||||
color: string;
|
|
||||||
outlineColor: string;
|
|
||||||
outlineWidthPx: number;
|
|
||||||
backgroundColor: string;
|
|
||||||
backgroundOpacity: number;
|
|
||||||
paddingX: number;
|
|
||||||
paddingY: number;
|
|
||||||
maxLength: number;
|
|
||||||
offsetX: number;
|
|
||||||
offsetY: number;
|
|
||||||
hideCameraHeightMeters: number;
|
|
||||||
};
|
|
||||||
target: ({
|
|
||||||
variant: "elevated-spike";
|
|
||||||
stemHeightMeters: number;
|
|
||||||
headSizePx: number;
|
|
||||||
stemWidthPx: number;
|
|
||||||
} | {
|
|
||||||
variant: "surface-fill";
|
|
||||||
}) & {
|
|
||||||
outlineColor: string;
|
|
||||||
outlineOpacity: number;
|
|
||||||
outlineWidthPx: number;
|
|
||||||
hideCameraHeightMeters: number;
|
|
||||||
};
|
|
||||||
facets: MapPresentationFacet[];
|
|
||||||
styles: MapPresentationStyle[];
|
|
||||||
classes: MapPresentationClass[];
|
|
||||||
defaultClassId: string;
|
|
||||||
sort: Array<{ field: string; order: string[] }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MapSubjectFilterState = {
|
|
||||||
/** False is an explicit empty map state. It must never be normalized to all. */
|
|
||||||
visible: boolean;
|
|
||||||
/** Missing facet = no constraint; an explicitly empty facet = match nothing. */
|
|
||||||
facets: Record<string, string[]>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Application view state is keyed by stable binding id, never by editable labels. */
|
|
||||||
export type MapPresentationFilters = Record<string, MapSubjectFilterState>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Application manifests persisted before profile v1.1 used the internal key
|
|
||||||
* `pin`. Normalize that storage shape before the first React render so an old
|
|
||||||
* application cannot crash while it is being upgraded to the public `target`
|
|
||||||
* contract through MCP.
|
|
||||||
*/
|
|
||||||
export function normalizeClientMapPresentationProfiles(profiles: MapPresentationProfile[]) {
|
|
||||||
return profiles.flatMap((profile) => {
|
|
||||||
const legacyPin = (profile as MapPresentationProfile & { pin?: MapPresentationProfile["target"] }).pin;
|
|
||||||
const target = profile.target ?? legacyPin;
|
|
||||||
if (!target) return [];
|
|
||||||
const normalized = { ...profile, target } as MapPresentationProfile & { pin?: MapPresentationProfile["target"] };
|
|
||||||
delete normalized.pin;
|
|
||||||
return [normalized];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapPresentationBindingIsAll(bindingId: string, filters: MapPresentationFilters) {
|
|
||||||
const state = filters[bindingId];
|
|
||||||
return state?.visible !== false && Object.keys(state?.facets ?? {}).length === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A missing facet is the compact canonical representation of every configured
|
|
||||||
* value being enabled. An explicit array is the exact enabled subset; an empty
|
|
||||||
* array therefore remains an intentional match-nothing state.
|
|
||||||
*/
|
|
||||||
export function mapPresentationFacetValueIsEnabled(
|
|
||||||
facets: Record<string, string[]>,
|
|
||||||
field: string,
|
|
||||||
value: string,
|
|
||||||
) {
|
|
||||||
const selected = facets[field];
|
|
||||||
return selected === undefined || selected.includes(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeMapPresentationFacetSelections(
|
|
||||||
facets: Record<string, string[]>,
|
|
||||||
profile: MapPresentationProfile,
|
|
||||||
) {
|
|
||||||
return Object.fromEntries(profile.facets.flatMap((facet) => {
|
|
||||||
const selected = facets[facet.field];
|
|
||||||
if (selected === undefined) return [];
|
|
||||||
const availableValues = [...new Set(facet.values.map((item) => item.value))];
|
|
||||||
const enabledValues = availableValues.filter((value) => selected.includes(value));
|
|
||||||
// Legacy layouts could persist every value explicitly. Canonicalize that
|
|
||||||
// shape to an unconstrained facet so scoped facets (for example movement
|
|
||||||
// on online subjects) cannot accidentally suppress unrelated subjects.
|
|
||||||
return availableValues.length > 0 && enabledValues.length === availableValues.length
|
|
||||||
? []
|
|
||||||
: [[facet.field, enabledValues] as const];
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toggleMapPresentationFacetSelection(
|
|
||||||
facets: Record<string, string[]>,
|
|
||||||
field: string,
|
|
||||||
value: string,
|
|
||||||
availableValues: string[],
|
|
||||||
) {
|
|
||||||
const values = [...new Set(availableValues)];
|
|
||||||
if (!values.includes(value)) return facets;
|
|
||||||
const selected = facets[field];
|
|
||||||
const enabled = new Set(selected === undefined
|
|
||||||
? values
|
|
||||||
: values.filter((item) => selected.includes(item)));
|
|
||||||
if (enabled.has(value)) enabled.delete(value);
|
|
||||||
else enabled.add(value);
|
|
||||||
const nextEnabled = values.filter((item) => enabled.has(item));
|
|
||||||
const next = { ...facets };
|
|
||||||
if (nextEnabled.length === values.length) delete next[field];
|
|
||||||
else next[field] = nextEnabled;
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapPresentationProfileForFact(
|
|
||||||
profiles: MapPresentationProfile[],
|
|
||||||
presentationProfileId: string | undefined,
|
|
||||||
semanticType: string,
|
|
||||||
) {
|
|
||||||
const exact = presentationProfileId
|
|
||||||
? profiles.find((profile) => profile.id === presentationProfileId)
|
|
||||||
: undefined;
|
|
||||||
if (exact?.semanticTypes.includes(semanticType)) return exact;
|
|
||||||
return profiles.find((profile) => profile.semanticTypes.includes(semanticType));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveMapPresentationClass(fact: MapRuntimeFact, profile: MapPresentationProfile) {
|
|
||||||
const classes = [...profile.classes].sort((left, right) => right.priority - left.priority || left.id.localeCompare(right.id));
|
|
||||||
return classes.find((item) => item.match.every((condition) => (
|
|
||||||
normalizedFacetValue(fact.attributes[condition.field]) === condition.equals
|
|
||||||
))) ?? classes.find((item) => item.id === profile.defaultClassId) ?? classes.at(-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveMapPresentationStyle(profile: MapPresentationProfile, presentationClass?: MapPresentationClass) {
|
|
||||||
const selected = presentationClass ?? profile.classes.find((item) => item.id === profile.defaultClassId);
|
|
||||||
return profile.styles.find((style) => style.id === selected?.styleId) ?? profile.styles[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapRuntimeDisplayLabel(fact: MapRuntimeFact, profile?: MapPresentationProfile) {
|
|
||||||
if (profile?.label.mode === "subject_id") return fact.sourceId;
|
|
||||||
const fields = profile?.label.fields ?? ["display_name", "label", "name", "title"];
|
|
||||||
for (const key of fields) {
|
|
||||||
const value = fact.attributes[key];
|
|
||||||
if (typeof value === "string" && value.trim()) {
|
|
||||||
const normalized = value.trim();
|
|
||||||
const limit = profile?.label.maxLength ?? 80;
|
|
||||||
return normalized.length > limit ? `${normalized.slice(0, Math.max(1, limit - 1))}…` : normalized;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fact.sourceId;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapRuntimeFactIsRenderable(fact: MapRuntimeFact, profile: MapPresentationProfile) {
|
|
||||||
return Boolean(fact.geometry) && resolveMapPresentationClass(fact, profile)?.renderable === true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapRuntimeFactIsVisible(
|
|
||||||
fact: MapRuntimeFact,
|
|
||||||
profile: MapPresentationProfile,
|
|
||||||
filters: MapPresentationFilters,
|
|
||||||
bindingId: string,
|
|
||||||
) {
|
|
||||||
return mapRuntimeFactIsRenderable(fact, profile) && mapFactMatchesFilters(fact, profile, filters, bindingId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapFactMatchesFilters(
|
|
||||||
fact: MapRuntimeFact,
|
|
||||||
profile: MapPresentationProfile,
|
|
||||||
filters: MapPresentationFilters,
|
|
||||||
bindingId: string,
|
|
||||||
) {
|
|
||||||
const state = filters[bindingId];
|
|
||||||
if (state?.visible === false) return false;
|
|
||||||
|
|
||||||
const selectedFacets = profile.facets.flatMap((facet) => {
|
|
||||||
const selected = state?.facets?.[facet.field];
|
|
||||||
if (selected === undefined) return [];
|
|
||||||
return [{ facet, selected }];
|
|
||||||
});
|
|
||||||
|
|
||||||
// Persisted empty arrays and the final interactive deselection are an
|
|
||||||
// explicit match-nothing state.
|
|
||||||
if (selectedFacets.some(({ selected }) => selected.length === 0)) return false;
|
|
||||||
if (selectedFacets.length === 0) return true;
|
|
||||||
|
|
||||||
// Values inside one facet form a union; independently selected facets are
|
|
||||||
// conjunctive. This keeps provider/type/state filters composable.
|
|
||||||
return selectedFacets.every(({ facet, selected }) => (
|
|
||||||
mapFactParticipatesInFacet(fact, profile, facet)
|
|
||||||
&& selected.includes(normalizedFacetValue(fact.attributes[facet.field]))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function compareMapRuntimeFacts(left: MapRuntimeFact, right: MapRuntimeFact, profile: MapPresentationProfile) {
|
|
||||||
for (const rule of profile.sort) {
|
|
||||||
const leftRank = sortRank(rule.order, normalizedFacetValue(left.attributes[rule.field]));
|
|
||||||
const rightRank = sortRank(rule.order, normalizedFacetValue(right.attributes[rule.field]));
|
|
||||||
if (leftRank !== rightRank) return leftRank - rightRank;
|
|
||||||
}
|
|
||||||
return mapRuntimeDisplayLabel(left, profile).localeCompare(mapRuntimeDisplayLabel(right, profile), "ru");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapPresentationFacetCounts(
|
|
||||||
facts: MapRuntimeFact[],
|
|
||||||
profile: MapPresentationProfile,
|
|
||||||
) {
|
|
||||||
return Object.fromEntries(profile.facets.map((facet) => {
|
|
||||||
const counts = Object.fromEntries(facet.values.map((item) => [item.value, 0]));
|
|
||||||
for (const fact of facts) {
|
|
||||||
if (!mapFactParticipatesInFacet(fact, profile, facet)) continue;
|
|
||||||
const value = normalizedFacetValue(fact.attributes[facet.field]);
|
|
||||||
if (Object.hasOwn(counts, value)) counts[value] += 1;
|
|
||||||
}
|
|
||||||
return [facet.field, counts];
|
|
||||||
})) as Record<string, Record<string, number>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `signal_state` and `movement_state` remain orthogonal Data Product facts.
|
|
||||||
* The operational Map, however, must not present a stale last speed as a
|
|
||||||
* current movement state. When both canonical facets exist, the movement
|
|
||||||
* facet is therefore scoped to currently active subjects. Other profiles and
|
|
||||||
* fields keep their ordinary independent-facet behaviour.
|
|
||||||
*/
|
|
||||||
function mapFactParticipatesInFacet(
|
|
||||||
fact: MapRuntimeFact,
|
|
||||||
profile: MapPresentationProfile,
|
|
||||||
facet: MapPresentationFacet,
|
|
||||||
) {
|
|
||||||
if (facet.field !== "movement_state") return true;
|
|
||||||
if (!profile.facets.some((item) => item.field === "signal_state")) return true;
|
|
||||||
return normalizedFacetValue(fact.attributes.signal_state) === "active";
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizedFacetValue(value: unknown) {
|
|
||||||
return typeof value === "string" ? value.trim().toLowerCase() : "unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortRank(order: string[], value: string) {
|
|
||||||
const index = order.indexOf(value);
|
|
||||||
return index === -1 ? order.length : index;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
export type MapProviderStartupPolicy = Readonly<{
|
||||||
|
providerInitializationTimeoutMs: number;
|
||||||
|
imageryViewportTimeoutMs: number;
|
||||||
|
terrainViewportTimeoutMs: number;
|
||||||
|
minimumRenderFrames: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type MapProviderStartupReason = "loaded" | "timeout" | "cancelled";
|
||||||
|
export type MapProviderStartupResult = "complete" | "cancelled";
|
||||||
|
export type MapProviderName = "imagery" | "terrain" | "buildings";
|
||||||
|
|
||||||
|
type CesiumEventLike<TListener extends (...args: never[]) => void> = {
|
||||||
|
addEventListener(listener: TListener): () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MAP_PROVIDER_STARTUP_POLICY: MapProviderStartupPolicy;
|
||||||
|
|
||||||
|
export function waitForGlobeViewportReady(input: {
|
||||||
|
globe: {
|
||||||
|
readonly tilesLoaded: boolean;
|
||||||
|
tileLoadProgressEvent: CesiumEventLike<(pendingRequests: number) => void>;
|
||||||
|
};
|
||||||
|
scene: {
|
||||||
|
postRender: CesiumEventLike<() => void>;
|
||||||
|
requestRender(): void;
|
||||||
|
};
|
||||||
|
signal?: AbortSignal;
|
||||||
|
timeoutMs: number;
|
||||||
|
minimumRenderFrames?: number;
|
||||||
|
}): Promise<MapProviderStartupReason>;
|
||||||
|
|
||||||
|
export function runStagedMapProviders(input: {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
isCancelled?: () => boolean;
|
||||||
|
providerInitializationTimeoutMs?: number;
|
||||||
|
loadImagery: (context: { signal: AbortSignal }) => void | Promise<void>;
|
||||||
|
waitAfterImagery?: () => void | Promise<void>;
|
||||||
|
loadTerrain: (context: { signal: AbortSignal }) => void | Promise<void>;
|
||||||
|
waitAfterTerrain?: () => void | Promise<void>;
|
||||||
|
loadBuildings: (context: { signal: AbortSignal }) => void | Promise<void>;
|
||||||
|
loadDeferred?: () => void | Promise<void>;
|
||||||
|
onProviderError?: (provider: MapProviderName, error: unknown) => void;
|
||||||
|
onDeferredError?: (error: unknown) => void;
|
||||||
|
}): Promise<MapProviderStartupResult>;
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
export const MAP_PROVIDER_STARTUP_POLICY = Object.freeze({
|
||||||
|
providerInitializationTimeoutMs: 8_000,
|
||||||
|
imageryViewportTimeoutMs: 6_000,
|
||||||
|
terrainViewportTimeoutMs: 5_000,
|
||||||
|
minimumRenderFrames: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait until Cesium has rendered enough frames to discover the current
|
||||||
|
* viewport and the public Globe queue reports that its terrain and imagery
|
||||||
|
* are loaded. The deadline is deliberate: a slow or unavailable provider
|
||||||
|
* must never block the next independent layer.
|
||||||
|
*/
|
||||||
|
export function waitForGlobeViewportReady({
|
||||||
|
globe,
|
||||||
|
scene,
|
||||||
|
signal,
|
||||||
|
timeoutMs,
|
||||||
|
minimumRenderFrames = MAP_PROVIDER_STARTUP_POLICY.minimumRenderFrames,
|
||||||
|
}) {
|
||||||
|
if (signal?.aborted) return Promise.resolve("cancelled");
|
||||||
|
|
||||||
|
const frameTarget = Math.max(1, Math.trunc(minimumRenderFrames));
|
||||||
|
const deadlineMs = Math.max(0, Math.trunc(timeoutMs));
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
let renderFrames = 0;
|
||||||
|
let removeProgressListener;
|
||||||
|
let removePostRenderListener;
|
||||||
|
let timeout;
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
removeProgressListener?.();
|
||||||
|
removePostRenderListener?.();
|
||||||
|
if (timeout !== undefined) clearTimeout(timeout);
|
||||||
|
signal?.removeEventListener("abort", onAbort);
|
||||||
|
};
|
||||||
|
const finish = (reason) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
resolve(reason);
|
||||||
|
};
|
||||||
|
const inspect = () => {
|
||||||
|
if (renderFrames >= frameTarget && globe.tilesLoaded) finish("loaded");
|
||||||
|
};
|
||||||
|
const onAbort = () => finish("cancelled");
|
||||||
|
const onTileLoadProgress = () => {
|
||||||
|
inspect();
|
||||||
|
if (!settled) scene.requestRender();
|
||||||
|
};
|
||||||
|
const onPostRender = () => {
|
||||||
|
renderFrames += 1;
|
||||||
|
inspect();
|
||||||
|
if (!settled && renderFrames < frameTarget) scene.requestRender();
|
||||||
|
};
|
||||||
|
|
||||||
|
removeProgressListener = globe.tileLoadProgressEvent.addEventListener(onTileLoadProgress);
|
||||||
|
removePostRenderListener = scene.postRender.addEventListener(onPostRender);
|
||||||
|
signal?.addEventListener("abort", onAbort, { once: true });
|
||||||
|
timeout = setTimeout(() => finish("timeout"), deadlineMs);
|
||||||
|
scene.requestRender();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preserve independent failure domains while giving the base map first use
|
||||||
|
* of network and decode capacity. Each provider owns its error; viewport
|
||||||
|
* gates are scheduling hints and therefore never become provider failures.
|
||||||
|
*/
|
||||||
|
export async function runStagedMapProviders({
|
||||||
|
signal,
|
||||||
|
isCancelled,
|
||||||
|
providerInitializationTimeoutMs = MAP_PROVIDER_STARTUP_POLICY.providerInitializationTimeoutMs,
|
||||||
|
loadImagery,
|
||||||
|
waitAfterImagery,
|
||||||
|
loadTerrain,
|
||||||
|
waitAfterTerrain,
|
||||||
|
loadBuildings,
|
||||||
|
loadDeferred,
|
||||||
|
onProviderError,
|
||||||
|
onDeferredError,
|
||||||
|
}) {
|
||||||
|
const cancelled = () => Boolean(signal?.aborted || isCancelled?.());
|
||||||
|
const loadProvider = async (provider, load) => {
|
||||||
|
if (cancelled()) return false;
|
||||||
|
const stageAbort = new AbortController();
|
||||||
|
let timeout;
|
||||||
|
let rejectDeadline;
|
||||||
|
const abortStage = () => {
|
||||||
|
rejectDeadline?.(new Error(`${provider}_startup_cancelled`));
|
||||||
|
stageAbort.abort();
|
||||||
|
};
|
||||||
|
signal?.addEventListener("abort", abortStage, { once: true });
|
||||||
|
const deadline = new Promise((_, reject) => {
|
||||||
|
rejectDeadline = reject;
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
reject(new Error(`${provider}_startup_timeout`));
|
||||||
|
stageAbort.abort();
|
||||||
|
}, Math.max(0, Math.trunc(providerInitializationTimeoutMs)));
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await Promise.race([load({ signal: stageAbort.signal }), deadline]);
|
||||||
|
return !cancelled() && !stageAbort.signal.aborted;
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled()) onProviderError?.(provider, error);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
if (timeout !== undefined) clearTimeout(timeout);
|
||||||
|
signal?.removeEventListener("abort", abortStage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const waitForViewport = async (wait) => {
|
||||||
|
if (!wait || cancelled()) return;
|
||||||
|
try {
|
||||||
|
await wait();
|
||||||
|
} catch {
|
||||||
|
// A readiness gate controls ordering only. The provider's own error
|
||||||
|
// event remains the authority for availability and user diagnostics.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const imageryReady = await loadProvider("imagery", loadImagery);
|
||||||
|
if (imageryReady) await waitForViewport(waitAfterImagery);
|
||||||
|
|
||||||
|
const terrainReady = await loadProvider("terrain", loadTerrain);
|
||||||
|
if (terrainReady) await waitForViewport(waitAfterTerrain);
|
||||||
|
|
||||||
|
await loadProvider("buildings", loadBuildings);
|
||||||
|
|
||||||
|
if (loadDeferred && !cancelled()) {
|
||||||
|
try {
|
||||||
|
await loadDeferred();
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled()) onDeferredError?.(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cancelled() ? "cancelled" : "complete";
|
||||||
|
}
|
||||||
@@ -51,7 +51,7 @@ export const defaultMapReferencePresentationProfiles: readonly MapPresentationPr
|
|||||||
semanticTypes: [...definition.semanticTypes],
|
semanticTypes: [...definition.semanticTypes],
|
||||||
label: {
|
label: {
|
||||||
mode: "attributes",
|
mode: "attributes",
|
||||||
fields: ["name", "official_name", "local_name"],
|
fields: ["name", "official_name", "local_name", "alternate_names"],
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
sizePx: 20,
|
sizePx: 20,
|
||||||
color: "#cccccc",
|
color: "#cccccc",
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import type {
|
||||||
|
GraticuleSectorSummary,
|
||||||
|
LocalSectorSummary,
|
||||||
|
} from "./mapSectorGrid.mjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider-neutral scene settings consumed by a renderer adapter.
|
||||||
|
*
|
||||||
|
* The persisted Map Page contract may use these fields, but it never stores
|
||||||
|
* Cesium entities, provider endpoints, credentials or raw provider payloads.
|
||||||
|
*/
|
||||||
|
export type MapPresentation = {
|
||||||
|
imagerySource: "cesium-live";
|
||||||
|
imageryVisible: boolean;
|
||||||
|
cacheEnabled: boolean;
|
||||||
|
cacheNoOverwrite: boolean;
|
||||||
|
terrainEnabled: boolean;
|
||||||
|
terrainExaggeration: number;
|
||||||
|
monochrome: boolean;
|
||||||
|
monochromeColor: string;
|
||||||
|
imageryGamma: number;
|
||||||
|
imageryHue: number;
|
||||||
|
imageryAlpha: number;
|
||||||
|
globeColor: string;
|
||||||
|
backgroundColor: string;
|
||||||
|
atmosphereEnabled: boolean;
|
||||||
|
atmosphereHue: number;
|
||||||
|
atmosphereSaturation: number;
|
||||||
|
atmosphereBrightness: number;
|
||||||
|
fogEnabled: boolean;
|
||||||
|
fogDensity: number;
|
||||||
|
sunEnabled: boolean;
|
||||||
|
sunHour: number;
|
||||||
|
sunIntensity: number;
|
||||||
|
shadowsEnabled: boolean;
|
||||||
|
buildingsVisible: boolean;
|
||||||
|
buildingsColor: string;
|
||||||
|
buildingsOpacity: number;
|
||||||
|
buildingsDetail: number;
|
||||||
|
imageryBrightness: number;
|
||||||
|
imageryContrast: number;
|
||||||
|
imagerySaturation: number;
|
||||||
|
gridVisible: boolean;
|
||||||
|
gridLodEnabled: boolean;
|
||||||
|
grid3dEnabled: boolean;
|
||||||
|
gridGraticuleEnabled: boolean;
|
||||||
|
gridCenterMode: "fixed";
|
||||||
|
gridCenterLatitude: number;
|
||||||
|
gridCenterLongitude: number;
|
||||||
|
gridTileSizeKm: number;
|
||||||
|
gridAutoDisableHeightKm: number;
|
||||||
|
gridRebuildOnMoveEnd: boolean;
|
||||||
|
gridLegacyMode: boolean;
|
||||||
|
gridMax3dViewAngleDegrees: number;
|
||||||
|
gridHeightMeters: number;
|
||||||
|
gridLod1MaxHeightKm: number;
|
||||||
|
gridLod1StepKm: number;
|
||||||
|
gridLod1Mode: "3d" | "graticule";
|
||||||
|
gridLod2MaxHeightKm: number;
|
||||||
|
gridLod2StepKm: number;
|
||||||
|
gridLod2Mode: "3d" | "graticule";
|
||||||
|
gridLod3MaxHeightKm: number;
|
||||||
|
gridLod3StepKm: number;
|
||||||
|
gridLod3Mode: "3d" | "graticule";
|
||||||
|
gridLod4MaxHeightKm: number;
|
||||||
|
gridLod4StepKm: number;
|
||||||
|
gridLod4Mode: "3d" | "graticule";
|
||||||
|
gridLod5StepKm: number;
|
||||||
|
gridLod5MaxHeightKm: number;
|
||||||
|
gridLod5Mode: "3d" | "graticule";
|
||||||
|
gridRadiusKm: number;
|
||||||
|
gridLineWidth: number;
|
||||||
|
gridLineDiameterMeters: number;
|
||||||
|
gridColor: string;
|
||||||
|
gridOpacity: number;
|
||||||
|
gridDotsEnabled: boolean;
|
||||||
|
gridDotsSize: number;
|
||||||
|
gridDotsDiameterMeters: number;
|
||||||
|
gridDotsColor: string;
|
||||||
|
gridDotsOpacity: number;
|
||||||
|
gridCrossesEnabled: boolean;
|
||||||
|
gridCrossesLengthMeters: number;
|
||||||
|
gridCrossesWidthMeters: number;
|
||||||
|
gridCrossesColor: string;
|
||||||
|
gridCrossesOpacity: number;
|
||||||
|
gridLodProfiles: GridLodProfile[];
|
||||||
|
/** One-shot renderer command; it is intentionally excluded from persistence. */
|
||||||
|
cacheRefresh: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GridLodProfile = {
|
||||||
|
maxHeightKm: number;
|
||||||
|
stepKm: number;
|
||||||
|
mode: "3d" | "graticule";
|
||||||
|
heightMeters: number;
|
||||||
|
max3dViewAngleDegrees: number;
|
||||||
|
tileSizeKm: number;
|
||||||
|
radiusKm: number;
|
||||||
|
lineDiameterMeters: number;
|
||||||
|
lineColor: string;
|
||||||
|
lineOpacity: number;
|
||||||
|
dotsEnabled: boolean;
|
||||||
|
dotsDiameterMeters: number;
|
||||||
|
dotsColor: string;
|
||||||
|
dotsOpacity: number;
|
||||||
|
crossesEnabled: boolean;
|
||||||
|
crossesLengthMeters: number;
|
||||||
|
crossesWidthMeters: number;
|
||||||
|
crossesColor: string;
|
||||||
|
crossesOpacity: number;
|
||||||
|
graticuleStepDegrees: number;
|
||||||
|
graticuleLineWidthPx: number;
|
||||||
|
graticuleColor: string;
|
||||||
|
graticuleOpacity: number;
|
||||||
|
majorLinesEnabled: boolean;
|
||||||
|
majorLabelsEnabled: boolean;
|
||||||
|
majorLineWidthMultiplier: number;
|
||||||
|
volumeEnabled: boolean;
|
||||||
|
volumeMinimumHeightMeters: number;
|
||||||
|
volumeMaximumHeightMeters: number;
|
||||||
|
volumeBandHeightMeters: number;
|
||||||
|
selectionFillColor: string;
|
||||||
|
selectionFillOpacityPercent: number;
|
||||||
|
selectionOutlineColor: string;
|
||||||
|
selectionOutlineWidthPx: number;
|
||||||
|
selectionOutlineOpacityPercent: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GridVolumeSelection = {
|
||||||
|
id: string;
|
||||||
|
index: number;
|
||||||
|
floor: number;
|
||||||
|
ceiling: number;
|
||||||
|
bandHeight: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LocalGridSectorSelection = LocalSectorSummary & {
|
||||||
|
mode: "3d";
|
||||||
|
units: "meters-enu";
|
||||||
|
volume: GridVolumeSelection | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GraticuleGridSectorSelection = GraticuleSectorSummary & {
|
||||||
|
mode: "graticule";
|
||||||
|
units: "degrees-wgs84";
|
||||||
|
volume: null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GridSectorSelection = LocalGridSectorSelection | GraticuleGridSectorSelection;
|
||||||
|
export type GridMajorTileSelection = NonNullable<GridSectorSelection["parentMajorTile"]>;
|
||||||
|
|
||||||
|
export type MapCameraView = {
|
||||||
|
longitude: number;
|
||||||
|
latitude: number;
|
||||||
|
height: number;
|
||||||
|
heading: number;
|
||||||
|
pitch: number;
|
||||||
|
roll: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CameraSpiralConfig = {
|
||||||
|
heightAboveGroundMeters: number;
|
||||||
|
speedMetersPerSecond: number;
|
||||||
|
pitchMetersPerTurn: number;
|
||||||
|
targetRadiusMeters?: number;
|
||||||
|
viewPitchRadians?: number;
|
||||||
|
waitForTiles?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CameraSpiralState = {
|
||||||
|
running: boolean;
|
||||||
|
reason?: "stopped" | "mode_disabled" | "renderer_restarted" | "page_hidden" | "render_error" | "spiral_extent_limit" | "spiral_runtime_error" | "terrain_sampling_error" | "tile_loading_timeout" | "tile_loading_error" | "target_radius_reached";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapGatewayHealth = {
|
||||||
|
cache?: {
|
||||||
|
mode?: string;
|
||||||
|
writePolicy?: string;
|
||||||
|
entries?: number;
|
||||||
|
bytes?: number;
|
||||||
|
maxBytes?: number;
|
||||||
|
atCapacity?: boolean;
|
||||||
|
persistent?: boolean;
|
||||||
|
byResourceKind?: Record<string, { entries?: number; bytes?: number }>;
|
||||||
|
};
|
||||||
|
diagnostics?: {
|
||||||
|
cacheHits?: number;
|
||||||
|
cacheMisses?: number;
|
||||||
|
cacheRefreshes?: number;
|
||||||
|
upstreamRequests?: number;
|
||||||
|
egressRequests?: number;
|
||||||
|
upstreamFailures?: number;
|
||||||
|
slowUpstreamRequests?: number;
|
||||||
|
lastFailure?: string | null;
|
||||||
|
lastFailureAt?: string | null;
|
||||||
|
};
|
||||||
|
referenceSources?: {
|
||||||
|
transportStations?: {
|
||||||
|
profileId?: string;
|
||||||
|
seedFactCount?: number;
|
||||||
|
fetchEnabled?: boolean;
|
||||||
|
cellDegrees?: number;
|
||||||
|
cachedCellCount?: number;
|
||||||
|
indexedFactCount?: number;
|
||||||
|
upstreamRequests?: number;
|
||||||
|
upstreamFailures?: number;
|
||||||
|
searchRequests?: number;
|
||||||
|
searchFailures?: number;
|
||||||
|
upstreamState?: "idle" | "ready" | "degraded";
|
||||||
|
activeFetches?: number;
|
||||||
|
queuedFetches?: number;
|
||||||
|
lastRefreshAt?: string | null;
|
||||||
|
lastFailure?: string | null;
|
||||||
|
lastFailureAt?: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
providerCache?: Array<{
|
||||||
|
assetId?: number;
|
||||||
|
type?: string;
|
||||||
|
cachedEndpoint?: boolean;
|
||||||
|
credentialUsable?: boolean;
|
||||||
|
entryPointCached?: boolean;
|
||||||
|
}>;
|
||||||
|
ionConfigured?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapProviderState = "loading" | "ready" | "error" | "not-configured";
|
||||||
|
|
||||||
|
export type MapProviderStatus = {
|
||||||
|
imagery: MapProviderState;
|
||||||
|
terrain: MapProviderState;
|
||||||
|
buildings: MapProviderState;
|
||||||
|
errors: Partial<Record<"imagery" | "terrain" | "buildings" | "projection", string>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Imperative adapter surface; callers never receive a Cesium object. */
|
||||||
|
export type MapRendererHandle = {
|
||||||
|
startSpiralAnimation: (config: CameraSpiralConfig) => boolean;
|
||||||
|
stopSpiralAnimation: (reason?: CameraSpiralState["reason"]) => void;
|
||||||
|
getCameraView: () => MapCameraView | null;
|
||||||
|
fitRuntimeEntities: (entityIds?: string[]) => boolean;
|
||||||
|
focusRuntimeEntity: (entityId: string) => boolean;
|
||||||
|
focusSubjectCoordinates: (longitude: number, latitude: number) => boolean;
|
||||||
|
focusCoordinates: (longitude: number, latitude: number) => boolean;
|
||||||
|
focusGridSector: (sector: GridSectorSelection) => boolean;
|
||||||
|
focusGridMajorTile: (tile: GridMajorTileSelection) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @deprecated Use the provider-neutral MapRendererHandle name. */
|
||||||
|
export type CesiumMapRendererHandle = MapRendererHandle;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import type { MapRuntimeFact } from "./useMapDataProductRuntime.js";
|
||||||
|
|
||||||
|
export function mapRuntimeFactKey(fact: Pick<MapRuntimeFact, "sourceId" | "semanticType">): string;
|
||||||
|
export function mapRuntimeEntityId(bindingId: string, fact: Pick<MapRuntimeFact, "sourceId" | "semanticType">): string;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/** Stable Data Product fact identity: semantic type plus source id. */
|
||||||
|
export function mapRuntimeFactKey(fact) {
|
||||||
|
return `${fact.semanticType}\u0000${fact.sourceId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable renderer adapter identity. It never depends on a provider message id,
|
||||||
|
* display label, current geometry or transient Cesium entity instance.
|
||||||
|
*/
|
||||||
|
export function mapRuntimeEntityId(bindingId, fact) {
|
||||||
|
return `nodedc-runtime:${bindingId}:${fact.semanticType}:${fact.sourceId}`;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { MapDataProductBinding } from "./MapFixturePreview.js";
|
import type { MapDataProductBinding } from "./mapPageContract.js";
|
||||||
import type { MapPresentationProfile } from "./mapPresentationProfile.js";
|
import type { MapPresentationProfile } from "./mapPresentationProfile.js";
|
||||||
import type { MapRuntimeBinding } from "./useMapDataProductRuntime.js";
|
import type { MapRuntimeBinding } from "./useMapDataProductRuntime.js";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { GridSectorSelection } from "./mapRendererContract.js";
|
||||||
|
import type { SectorGridLodProfile } from "./mapSectorWorkspace.js";
|
||||||
|
import type { MapRuntimeFact } from "./useMapDataProductRuntime.js";
|
||||||
|
|
||||||
|
export const MAP_SCOPE_PROVIDER_FIELD: "position_source";
|
||||||
|
export const MAP_SCOPE_OBJECT_KIND_FIELD: "object_kind";
|
||||||
|
export const MAP_SCOPE_MISSING_VALUE: "__nodedc_missing__";
|
||||||
|
|
||||||
|
export function normalizedSectorScopeValue(value: unknown): string | null;
|
||||||
|
export function sectorScopeValueLabel(value: string): string;
|
||||||
|
export function mapFactSectorScopeValue(fact: MapRuntimeFact, field: string): string;
|
||||||
|
export function mapFactPointCoordinates(fact: MapRuntimeFact | undefined): [number, number] | null;
|
||||||
|
export function mapFactInsideGridSector(
|
||||||
|
fact: MapRuntimeFact,
|
||||||
|
selection: GridSectorSelection,
|
||||||
|
profiles: SectorGridLodProfile[],
|
||||||
|
origin: { latitude: number; longitude: number },
|
||||||
|
): boolean;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { graticuleSectorAt, localSectorAtGeodetic } from "./mapSectorGrid.mjs";
|
||||||
|
|
||||||
|
export const MAP_SCOPE_PROVIDER_FIELD = "position_source";
|
||||||
|
export const MAP_SCOPE_OBJECT_KIND_FIELD = "object_kind";
|
||||||
|
export const MAP_SCOPE_MISSING_VALUE = "__nodedc_missing__";
|
||||||
|
|
||||||
|
export function normalizedSectorScopeValue(value) {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const normalized = value.trim();
|
||||||
|
if (!normalized || normalized.length > 120 || /[\u0000-\u001f\u007f]/.test(normalized)) return null;
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sectorScopeValueLabel(value) {
|
||||||
|
if (value === MAP_SCOPE_MISSING_VALUE) return "Не указано";
|
||||||
|
return value.replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapFactSectorScopeValue(fact, field) {
|
||||||
|
return normalizedSectorScopeValue(fact.attributes[field]) ?? MAP_SCOPE_MISSING_VALUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapFactPointCoordinates(fact) {
|
||||||
|
if (fact?.geometry?.type !== "Point") return null;
|
||||||
|
const [longitude, latitude] = fact.geometry.coordinates;
|
||||||
|
return Number.isFinite(longitude) && longitude >= -180 && longitude <= 180
|
||||||
|
&& Number.isFinite(latitude) && latitude >= -90 && latitude <= 90
|
||||||
|
? [longitude, latitude]
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapFactInsideGridSector(fact, selection, profiles, origin) {
|
||||||
|
if (fact.geometry?.type !== "Point") return false;
|
||||||
|
const [longitude, latitude] = fact.geometry.coordinates;
|
||||||
|
const profile = profiles[selection.lod - 1];
|
||||||
|
if (!profile || profile.mode !== selection.mode) return false;
|
||||||
|
if (selection.mode === "graticule") {
|
||||||
|
return graticuleSectorAt({ longitude, latitude }, {
|
||||||
|
lod: selection.lod,
|
||||||
|
stepDegrees: profile.graticuleStepDegrees,
|
||||||
|
}).id === selection.id;
|
||||||
|
}
|
||||||
|
return localSectorAtGeodetic({ longitude, latitude }, {
|
||||||
|
lod: selection.lod,
|
||||||
|
originLatitude: origin.latitude,
|
||||||
|
originLongitude: origin.longitude,
|
||||||
|
stepMeters: profile.stepKm * 1_000,
|
||||||
|
}).id === selection.id;
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import { gridLodProfile } from "./mapGridPolicy.mjs";
|
||||||
|
import {
|
||||||
|
graticuleSectorAt,
|
||||||
|
graticuleSectorSummary,
|
||||||
|
localSectorAt,
|
||||||
|
localSectorSummary,
|
||||||
|
localVolumeAt,
|
||||||
|
type GraticuleSectorAddress,
|
||||||
|
type LocalSectorAddress,
|
||||||
|
} from "./mapSectorGrid.mjs";
|
||||||
|
import { initialMapSettings, type MapPageSettings } from "./mapPageContract.js";
|
||||||
|
import type {
|
||||||
|
GridLodProfile,
|
||||||
|
GridSectorSelection,
|
||||||
|
MapPresentation,
|
||||||
|
} from "./mapRendererContract.js";
|
||||||
|
export {
|
||||||
|
MAP_SCOPE_MISSING_VALUE,
|
||||||
|
MAP_SCOPE_OBJECT_KIND_FIELD,
|
||||||
|
MAP_SCOPE_PROVIDER_FIELD,
|
||||||
|
mapFactInsideGridSector,
|
||||||
|
mapFactPointCoordinates,
|
||||||
|
mapFactSectorScopeValue,
|
||||||
|
normalizedSectorScopeValue,
|
||||||
|
sectorScopeValueLabel,
|
||||||
|
} from "./mapSectorRuntime.mjs";
|
||||||
|
|
||||||
|
export type SectorGridLodProfile = GridLodProfile & {
|
||||||
|
majorLinesEnabled: boolean;
|
||||||
|
majorLabelsEnabled: boolean;
|
||||||
|
majorLineWidthMultiplier: number;
|
||||||
|
selectionFillColor: string;
|
||||||
|
selectionFillOpacityPercent: number;
|
||||||
|
selectionOutlineColor: string;
|
||||||
|
selectionOutlineWidthPx: number;
|
||||||
|
selectionOutlineOpacityPercent: number;
|
||||||
|
volumeEnabled: boolean;
|
||||||
|
volumeMinimumHeightMeters: number;
|
||||||
|
volumeMaximumHeightMeters: number;
|
||||||
|
volumeBandHeightMeters: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GridSectorDirection = "north" | "east" | "south" | "west";
|
||||||
|
|
||||||
|
export const GRID_SECTOR_DIRECTIONS: Array<{ id: GridSectorDirection; label: string }> = [
|
||||||
|
{ id: "north", label: "Север" },
|
||||||
|
{ id: "east", label: "Восток" },
|
||||||
|
{ id: "south", label: "Юг" },
|
||||||
|
{ id: "west", label: "Запад" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function normalizedMajorTileSizeKm(stepKm: number, requestedTileSizeKm: number) {
|
||||||
|
const safeStepKm = Math.min(50, Math.max(0.1, stepKm));
|
||||||
|
const maximumRatio = Math.max(1, Math.floor((50 + Number.EPSILON) / safeStepKm));
|
||||||
|
const requestedRatio = Math.max(1, Math.ceil((requestedTileSizeKm - Number.EPSILON) / safeStepKm));
|
||||||
|
const ratio = Math.min(maximumRatio, requestedRatio);
|
||||||
|
return Number((safeStepKm * ratio).toFixed(6));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizedGraticuleStepDegrees(requestedStepDegrees: number) {
|
||||||
|
const safeStepDegrees = Math.min(10, Math.max(0.1, requestedStepDegrees));
|
||||||
|
const requestedDivisions = Math.max(1, Math.round(180 / safeStepDegrees));
|
||||||
|
// Five minor intervals form one major tile and every 90° quadrant must end
|
||||||
|
// on a major boundary. A hemisphere therefore uses a multiple of ten.
|
||||||
|
const hemisphereDivisions = Math.max(10, Math.round(requestedDivisions / 10) * 10);
|
||||||
|
return 180 / hemisphereDivisions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function graticuleMajorStepDegrees(stepDegrees: number) {
|
||||||
|
const candidate = stepDegrees * 5;
|
||||||
|
const quadrantBands = 90 / candidate;
|
||||||
|
return Math.abs(quadrantBands - Math.round(quadrantBands)) <= 1e-9 * Math.max(1, Math.abs(quadrantBands))
|
||||||
|
? candidate
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSectorGridLodProfile(profile: SectorGridLodProfile): SectorGridLodProfile {
|
||||||
|
const stepKm = profile.mode === "3d" ? Math.min(50, profile.stepKm) : profile.stepKm;
|
||||||
|
const volumeMinimumHeightMeters = profile.volumeMinimumHeightMeters;
|
||||||
|
const volumeMaximumHeightMeters = Math.max(volumeMinimumHeightMeters + 1, profile.volumeMaximumHeightMeters);
|
||||||
|
return {
|
||||||
|
...profile,
|
||||||
|
stepKm,
|
||||||
|
tileSizeKm: profile.mode === "3d"
|
||||||
|
? normalizedMajorTileSizeKm(stepKm, Math.max(stepKm, profile.tileSizeKm))
|
||||||
|
: profile.tileSizeKm,
|
||||||
|
graticuleStepDegrees: profile.mode === "graticule"
|
||||||
|
? normalizedGraticuleStepDegrees(profile.graticuleStepDegrees)
|
||||||
|
: profile.graticuleStepDegrees,
|
||||||
|
majorLabelsEnabled: profile.majorLinesEnabled && profile.majorLabelsEnabled,
|
||||||
|
volumeEnabled: profile.mode === "3d" && profile.volumeEnabled,
|
||||||
|
volumeMinimumHeightMeters,
|
||||||
|
volumeMaximumHeightMeters,
|
||||||
|
volumeBandHeightMeters: Math.min(
|
||||||
|
volumeMaximumHeightMeters - volumeMinimumHeightMeters,
|
||||||
|
Math.max(1, profile.volumeBandHeightMeters),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveGridLodProfiles(settings?: Partial<MapPageSettings>): GridLodProfile[] {
|
||||||
|
// Layouts saved by the previous flat contract retain their tuned values.
|
||||||
|
// The next ordinary page save persists the canonical five-profile array.
|
||||||
|
const legacySettings: MapPresentation = {
|
||||||
|
...initialMapSettings,
|
||||||
|
...settings,
|
||||||
|
gridLodProfiles: Array.isArray(settings?.gridLodProfiles) ? settings.gridLodProfiles : [],
|
||||||
|
cacheRefresh: false,
|
||||||
|
};
|
||||||
|
return Array.from({ length: 5 }, (_unused, index) => normalizeSectorGridLodProfile(
|
||||||
|
gridLodProfile(legacySettings, index) as SectorGridLodProfile,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function localGridSectorSelection(
|
||||||
|
address: LocalSectorAddress,
|
||||||
|
profile: SectorGridLodProfile,
|
||||||
|
origin: { latitude: number; longitude: number },
|
||||||
|
preferredAltitudeMeters?: number,
|
||||||
|
): GridSectorSelection {
|
||||||
|
const definition = {
|
||||||
|
lod: address.lod,
|
||||||
|
originLatitude: origin.latitude,
|
||||||
|
originLongitude: origin.longitude,
|
||||||
|
stepMeters: profile.stepKm * 1_000,
|
||||||
|
tileSizeMeters: profile.tileSizeKm * 1_000,
|
||||||
|
};
|
||||||
|
const summary = localSectorSummary(address, definition);
|
||||||
|
const volumeSpan = profile.volumeMaximumHeightMeters - profile.volumeMinimumHeightMeters;
|
||||||
|
const volume = profile.volumeEnabled && volumeSpan > 0
|
||||||
|
? (() => {
|
||||||
|
const altitudeMeters = Math.min(
|
||||||
|
profile.volumeMaximumHeightMeters - Number.EPSILON,
|
||||||
|
Math.max(
|
||||||
|
profile.volumeMinimumHeightMeters,
|
||||||
|
preferredAltitudeMeters ?? profile.volumeMinimumHeightMeters + Math.min(profile.volumeBandHeightMeters, volumeSpan) / 2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const volumeAddress = localVolumeAt({ ...summary.center, altitudeMeters }, {
|
||||||
|
lod: address.lod,
|
||||||
|
originLatitude: origin.latitude,
|
||||||
|
originLongitude: origin.longitude,
|
||||||
|
stepMeters: profile.stepKm * 1_000,
|
||||||
|
altitudeFloorMeters: profile.volumeMinimumHeightMeters,
|
||||||
|
altitudeCeilingMeters: profile.volumeMaximumHeightMeters,
|
||||||
|
altitudeBandMeters: profile.volumeBandHeightMeters,
|
||||||
|
});
|
||||||
|
if (!volumeAddress) return null;
|
||||||
|
return {
|
||||||
|
id: volumeAddress.id,
|
||||||
|
index: volumeAddress.bandIndex,
|
||||||
|
floor: Math.max(profile.volumeMinimumHeightMeters, volumeAddress.altitudeFloorMeters),
|
||||||
|
ceiling: Math.min(profile.volumeMaximumHeightMeters, volumeAddress.altitudeCeilingMeters),
|
||||||
|
bandHeight: volumeAddress.altitudeBandMeters,
|
||||||
|
};
|
||||||
|
})()
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
...summary,
|
||||||
|
mode: "3d",
|
||||||
|
address,
|
||||||
|
units: "meters-enu",
|
||||||
|
volume,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function graticuleGridSectorSelection(
|
||||||
|
address: GraticuleSectorAddress,
|
||||||
|
profile: SectorGridLodProfile,
|
||||||
|
): GridSectorSelection {
|
||||||
|
const majorStepDegrees = profile.majorLinesEnabled
|
||||||
|
? graticuleMajorStepDegrees(profile.graticuleStepDegrees) ?? undefined
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
...graticuleSectorSummary(address, {
|
||||||
|
lod: address.lod,
|
||||||
|
stepDegrees: profile.graticuleStepDegrees,
|
||||||
|
majorStepDegrees,
|
||||||
|
}),
|
||||||
|
mode: "graticule",
|
||||||
|
address,
|
||||||
|
units: "degrees-wgs84",
|
||||||
|
volume: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gridSectorNeighborSelection(
|
||||||
|
selection: GridSectorSelection,
|
||||||
|
direction: GridSectorDirection,
|
||||||
|
profiles: SectorGridLodProfile[],
|
||||||
|
origin: { latitude: number; longitude: number },
|
||||||
|
) {
|
||||||
|
const profile = profiles[selection.lod - 1];
|
||||||
|
if (!profile) return null;
|
||||||
|
if (selection.mode === "3d") {
|
||||||
|
const neighbor = selection.neighbors[direction];
|
||||||
|
const preferredAltitudeMeters = selection.volume
|
||||||
|
? (selection.volume.floor + selection.volume.ceiling) / 2
|
||||||
|
: undefined;
|
||||||
|
return neighbor ? localGridSectorSelection(neighbor.address, profile, origin, preferredAltitudeMeters) : null;
|
||||||
|
}
|
||||||
|
const neighbor = selection.neighbors[direction];
|
||||||
|
return neighbor ? graticuleGridSectorSelection(neighbor.address, profile) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gridSectorParentLodSelection(
|
||||||
|
selection: GridSectorSelection,
|
||||||
|
profiles: SectorGridLodProfile[],
|
||||||
|
origin: { latitude: number; longitude: number },
|
||||||
|
) {
|
||||||
|
const parentProfile = profiles[selection.lod];
|
||||||
|
if (!parentProfile || parentProfile.mode !== selection.mode) return null;
|
||||||
|
if (selection.mode === "3d") {
|
||||||
|
const address = localSectorAt(selection.center, {
|
||||||
|
lod: selection.lod + 1,
|
||||||
|
originLatitude: origin.latitude,
|
||||||
|
originLongitude: origin.longitude,
|
||||||
|
stepMeters: parentProfile.stepKm * 1_000,
|
||||||
|
});
|
||||||
|
const preferredAltitudeMeters = selection.volume
|
||||||
|
? (selection.volume.floor + selection.volume.ceiling) / 2
|
||||||
|
: undefined;
|
||||||
|
return localGridSectorSelection(address, parentProfile, origin, preferredAltitudeMeters);
|
||||||
|
}
|
||||||
|
return graticuleGridSectorSelection(graticuleSectorAt(selection.center, {
|
||||||
|
lod: selection.lod + 1,
|
||||||
|
stepDegrees: parentProfile.graticuleStepDegrees,
|
||||||
|
}), parentProfile);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gridSectorVolumeNeighborSelection(
|
||||||
|
selection: GridSectorSelection,
|
||||||
|
direction: "above" | "below",
|
||||||
|
profile: SectorGridLodProfile | null,
|
||||||
|
origin: { latitude: number; longitude: number },
|
||||||
|
) {
|
||||||
|
if (selection.mode !== "3d" || !selection.volume || !profile?.volumeEnabled) return null;
|
||||||
|
const targetIndex = selection.volume.index + (direction === "above" ? 1 : -1);
|
||||||
|
const targetFloorMeters = profile.volumeMinimumHeightMeters + targetIndex * profile.volumeBandHeightMeters;
|
||||||
|
if (targetIndex < 0 || targetFloorMeters >= profile.volumeMaximumHeightMeters) return null;
|
||||||
|
const targetCeilingMeters = Math.min(
|
||||||
|
profile.volumeMaximumHeightMeters,
|
||||||
|
targetFloorMeters + profile.volumeBandHeightMeters,
|
||||||
|
);
|
||||||
|
return localGridSectorSelection(
|
||||||
|
selection.address,
|
||||||
|
profile,
|
||||||
|
origin,
|
||||||
|
(targetFloorMeters + targetCeilingMeters) / 2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatGridMetric = (value: number, maximumFractionDigits = 1) => value.toLocaleString("ru-RU", {
|
||||||
|
maximumFractionDigits,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const formatGridSectorArea = (areaSquareMeters: number) => areaSquareMeters >= 1_000_000
|
||||||
|
? `${formatGridMetric(areaSquareMeters / 1_000_000, areaSquareMeters >= 1_000_000_000 ? 0 : 2)} км²`
|
||||||
|
: `${formatGridMetric(areaSquareMeters, 0)} м²`;
|
||||||
|
|
||||||
|
export function gridSectorBoundsLabel(selection: GridSectorSelection) {
|
||||||
|
const { west, east, south, north } = selection.bounds;
|
||||||
|
return selection.mode === "3d"
|
||||||
|
? `E ${formatGridMetric(west)}…${formatGridMetric(east)} м · N ${formatGridMetric(south)}…${formatGridMetric(north)} м`
|
||||||
|
: `λ ${formatGridMetric(west, 6)}…${formatGridMetric(east, 6)}° · φ ${formatGridMetric(south, 6)}…${formatGridMetric(north, 6)}°`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gridSectorCenterLabel(selection: GridSectorSelection) {
|
||||||
|
return selection.mode === "3d"
|
||||||
|
? `E ${formatGridMetric(selection.center.eastMeters)} м · N ${formatGridMetric(selection.center.northMeters)} м`
|
||||||
|
: `${formatGridMetric(selection.center.latitude, 6)}°, ${formatGridMetric(selection.center.longitude, 6)}°`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { MapDataProductBinding, MapSubjectState } from "./mapPageContract.js";
|
||||||
|
import type { MapPresentationFilters, MapPresentationProfile } from "./mapPresentationProfile.js";
|
||||||
|
import type { GridSectorSelection } from "./mapRendererContract.js";
|
||||||
|
import type { SectorGridLodProfile } from "./mapSectorWorkspace.js";
|
||||||
|
import type { MapRuntimeBinding, MapRuntimeFact } from "./useMapDataProductRuntime.js";
|
||||||
|
|
||||||
|
export type MapSelectableEntity = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
kind: string;
|
||||||
|
status: string;
|
||||||
|
bindingId: string;
|
||||||
|
dataProductId: string;
|
||||||
|
fact: MapRuntimeFact;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapSectorScope = {
|
||||||
|
excludedBindingIds: string[];
|
||||||
|
excludedProviders: string[];
|
||||||
|
excludedObjectKinds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapPresentationSummary = {
|
||||||
|
bindingId: string;
|
||||||
|
displayName: string;
|
||||||
|
profile: MapPresentationProfile;
|
||||||
|
total: number;
|
||||||
|
counts: Record<string, Record<string, number>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapFilteredTarget = {
|
||||||
|
bindingId: string;
|
||||||
|
entityId: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
renderable: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function mapProfileHasSubjectWindowControls(profile: MapPresentationProfile): boolean;
|
||||||
|
export function primaryMapBindingConfigs(bindingConfigs: MapDataProductBinding[]): MapDataProductBinding[];
|
||||||
|
export function primaryMapRuntimeBindings(runtimeBindings: MapRuntimeBinding[], bindingConfigs: MapDataProductBinding[]): MapRuntimeBinding[];
|
||||||
|
export function buildMapPresentationFilters(subjectStates: Record<string, MapSubjectState>, bindingConfigs: MapDataProductBinding[], profiles: MapPresentationProfile[]): MapPresentationFilters;
|
||||||
|
export function buildSelectableMapEntities(runtimeBindings: MapRuntimeBinding[], bindingConfigs: MapDataProductBinding[], profiles: MapPresentationProfile[]): MapSelectableEntity[];
|
||||||
|
export function buildSectorSpatialEntities(selectable: MapSelectableEntity[], selection: GridSectorSelection | null, profiles: SectorGridLodProfile[], origin: { latitude: number; longitude: number }): MapSelectableEntity[];
|
||||||
|
export function buildSectorBindingOptions(bindingConfigs: MapDataProductBinding[], sectorEntities: MapSelectableEntity[]): Array<{ value: string; label: string; count: number }>;
|
||||||
|
export function bindingProjectsField(bindingConfigs: MapDataProductBinding[], field: string): boolean;
|
||||||
|
export function buildSectorScopeOptions(sectorEntities: MapSelectableEntity[], field: string, enabled?: boolean): Array<{ value: string; label: string; count: number }>;
|
||||||
|
export function sectorEntityIsExcluded(entity: MapSelectableEntity, scope: MapSectorScope): boolean;
|
||||||
|
export function buildSectorVisibleEntities(input: { sectorEntities: MapSelectableEntity[]; bindingConfigs: MapDataProductBinding[]; presentationProfiles: MapPresentationProfile[]; presentationFilters: MapPresentationFilters; scope: MapSectorScope }): MapSelectableEntity[];
|
||||||
|
export function scopeMapRuntimeBindings(input: { runtimeBindings: MapRuntimeBinding[]; selection: GridSectorSelection | null; gridProfiles: SectorGridLodProfile[]; origin: { latitude: number; longitude: number }; hideOutsideSector: boolean; scope: MapSectorScope }): MapRuntimeBinding[];
|
||||||
|
export function buildMapPresentationSummaries(bindingConfigs: MapDataProductBinding[], runtimeBindings: MapRuntimeBinding[], profiles: MapPresentationProfile[]): MapPresentationSummary[];
|
||||||
|
export function buildFilteredMapTargets(runtimeBindings: MapRuntimeBinding[], bindingConfigs: MapDataProductBinding[], profiles: MapPresentationProfile[], filters: MapPresentationFilters): MapFilteredTarget[];
|
||||||
|
export function planMapSubjectReveal(input: { entity: MapSelectableEntity; subjectState: MapSubjectState; profile: MapPresentationProfile | undefined; selectedSector: GridSectorSelection | null; gridProfiles: SectorGridLodProfile[]; origin: { latitude: number; longitude: number }; hideOutsideSector: boolean; scope: MapSectorScope }): { subjectState: MapSubjectState; hideOutsideSector: boolean; scope: MapSectorScope };
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import {
|
||||||
|
compareMapRuntimeFacts,
|
||||||
|
mapFactMatchesFilters,
|
||||||
|
mapPresentationFacetCounts,
|
||||||
|
mapPresentationProfileForFact,
|
||||||
|
mapRuntimeDisplayLabel,
|
||||||
|
mapRuntimeFactIsRenderable,
|
||||||
|
normalizeMapPresentationFacetSelections,
|
||||||
|
resolveMapPresentationClass,
|
||||||
|
} from "./mapPresentationProfile.mjs";
|
||||||
|
import { mapRuntimeEntityId } from "./mapRuntimeIdentity.mjs";
|
||||||
|
import {
|
||||||
|
MAP_SCOPE_OBJECT_KIND_FIELD,
|
||||||
|
MAP_SCOPE_PROVIDER_FIELD,
|
||||||
|
mapFactInsideGridSector,
|
||||||
|
mapFactSectorScopeValue,
|
||||||
|
sectorScopeValueLabel,
|
||||||
|
} from "./mapSectorRuntime.mjs";
|
||||||
|
|
||||||
|
export function mapProfileHasSubjectWindowControls(profile) {
|
||||||
|
return profile.facets.some((facet) => facet.counter || facet.filterable);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Primary bindings create map subjects; joined aspects only enrich them. */
|
||||||
|
export function primaryMapBindingConfigs(bindingConfigs) {
|
||||||
|
return [...bindingConfigs]
|
||||||
|
.filter((binding) => !binding.joinToBindingId)
|
||||||
|
.sort(bindingOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function primaryMapRuntimeBindings(runtimeBindings, bindingConfigs) {
|
||||||
|
const runtimeById = new Map(runtimeBindings.map((binding) => [binding.bindingId, binding]));
|
||||||
|
return primaryMapBindingConfigs(bindingConfigs)
|
||||||
|
.map((binding) => runtimeById.get(binding.id))
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMapPresentationFilters(subjectStates, bindingConfigs, profiles) {
|
||||||
|
return Object.fromEntries(Object.entries(subjectStates).map(([bindingId, state]) => {
|
||||||
|
const binding = bindingConfigs.find((candidate) => candidate.id === bindingId);
|
||||||
|
const profile = mapPresentationProfileForFact(
|
||||||
|
profiles,
|
||||||
|
binding?.presentationProfileId,
|
||||||
|
binding?.semanticTypes?.[0] ?? "",
|
||||||
|
);
|
||||||
|
return [bindingId, {
|
||||||
|
visible: state.visible,
|
||||||
|
facets: profile ? normalizeMapPresentationFacetSelections(state.filters, profile) : state.filters,
|
||||||
|
}];
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSelectableMapEntities(runtimeBindings, bindingConfigs, profiles) {
|
||||||
|
const configById = new Map(bindingConfigs.map((binding) => [binding.id, binding]));
|
||||||
|
return primaryMapRuntimeBindings(runtimeBindings, bindingConfigs).flatMap((binding) => {
|
||||||
|
const bindingConfig = configById.get(binding.bindingId);
|
||||||
|
const facts = [...binding.facts];
|
||||||
|
const primaryProfile = mapPresentationProfileForFact(
|
||||||
|
profiles,
|
||||||
|
bindingConfig?.presentationProfileId,
|
||||||
|
bindingConfig?.semanticTypes?.[0] ?? facts[0]?.semanticType ?? "",
|
||||||
|
);
|
||||||
|
if (primaryProfile) facts.sort((left, right) => compareMapRuntimeFacts(left, right, primaryProfile));
|
||||||
|
return facts.map((fact) => {
|
||||||
|
const profile = mapPresentationProfileForFact(profiles, bindingConfig?.presentationProfileId, fact.semanticType);
|
||||||
|
const presentationClass = profile ? resolveMapPresentationClass(fact, profile) : undefined;
|
||||||
|
return {
|
||||||
|
id: mapRuntimeEntityId(binding.bindingId, fact),
|
||||||
|
title: mapRuntimeDisplayLabel(fact, profile),
|
||||||
|
kind: fact.semanticType,
|
||||||
|
status: presentationClass?.label ?? fact.presentationStatus,
|
||||||
|
bindingId: binding.bindingId,
|
||||||
|
dataProductId: binding.dataProductId,
|
||||||
|
fact,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSectorSpatialEntities(selectable, selection, profiles, origin) {
|
||||||
|
return selection
|
||||||
|
? selectable.filter((entity) => mapFactInsideGridSector(entity.fact, selection, profiles, origin))
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSectorBindingOptions(bindingConfigs, sectorEntities) {
|
||||||
|
return primaryMapBindingConfigs(bindingConfigs).map((binding) => ({
|
||||||
|
value: binding.id,
|
||||||
|
label: binding.displayName?.trim() || binding.id,
|
||||||
|
count: sectorEntities.filter((entity) => entity.bindingId === binding.id).length,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindingProjectsField(bindingConfigs, field) {
|
||||||
|
return bindingConfigs.some((binding) => !binding.joinToBindingId && binding.fieldProjection.includes(field));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSectorScopeOptions(sectorEntities, field, enabled = true) {
|
||||||
|
if (!enabled) return [];
|
||||||
|
const counts = new Map();
|
||||||
|
for (const { fact } of sectorEntities) {
|
||||||
|
const value = mapFactSectorScopeValue(fact, field);
|
||||||
|
counts.set(value, (counts.get(value) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return [...counts]
|
||||||
|
.map(([value, count]) => ({ value, count, label: sectorScopeValueLabel(value) }))
|
||||||
|
.sort((left, right) => left.label.localeCompare(right.label, "ru"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sectorEntityIsExcluded(entity, scope) {
|
||||||
|
return scope.excludedBindingIds.includes(entity.bindingId)
|
||||||
|
|| scope.excludedProviders.includes(mapFactSectorScopeValue(entity.fact, MAP_SCOPE_PROVIDER_FIELD))
|
||||||
|
|| scope.excludedObjectKinds.includes(mapFactSectorScopeValue(entity.fact, MAP_SCOPE_OBJECT_KIND_FIELD));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSectorVisibleEntities({
|
||||||
|
sectorEntities,
|
||||||
|
bindingConfigs,
|
||||||
|
presentationProfiles,
|
||||||
|
presentationFilters,
|
||||||
|
scope,
|
||||||
|
}) {
|
||||||
|
const configById = new Map(bindingConfigs.map((binding) => [binding.id, binding]));
|
||||||
|
return sectorEntities.filter((entity) => {
|
||||||
|
if (sectorEntityIsExcluded(entity, scope)) return false;
|
||||||
|
const binding = configById.get(entity.bindingId);
|
||||||
|
const profile = mapPresentationProfileForFact(
|
||||||
|
presentationProfiles,
|
||||||
|
binding?.presentationProfileId,
|
||||||
|
entity.fact.semanticType,
|
||||||
|
);
|
||||||
|
return Boolean(profile && mapFactMatchesFilters(
|
||||||
|
entity.fact,
|
||||||
|
profile,
|
||||||
|
presentationFilters,
|
||||||
|
entity.bindingId,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scopeMapRuntimeBindings({
|
||||||
|
runtimeBindings,
|
||||||
|
selection,
|
||||||
|
gridProfiles,
|
||||||
|
origin,
|
||||||
|
hideOutsideSector,
|
||||||
|
scope,
|
||||||
|
}) {
|
||||||
|
if (!selection) return runtimeBindings;
|
||||||
|
return runtimeBindings.map((binding) => ({
|
||||||
|
...binding,
|
||||||
|
facts: binding.facts.filter((fact) => {
|
||||||
|
if (scope.excludedBindingIds.includes(binding.bindingId)) return false;
|
||||||
|
if (scope.excludedProviders.includes(mapFactSectorScopeValue(fact, MAP_SCOPE_PROVIDER_FIELD))) return false;
|
||||||
|
if (scope.excludedObjectKinds.includes(mapFactSectorScopeValue(fact, MAP_SCOPE_OBJECT_KIND_FIELD))) return false;
|
||||||
|
return !hideOutsideSector || mapFactInsideGridSector(fact, selection, gridProfiles, origin);
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMapPresentationSummaries(bindingConfigs, runtimeBindings, profiles) {
|
||||||
|
const runtimeById = new Map(runtimeBindings.map((binding) => [binding.bindingId, binding]));
|
||||||
|
return primaryMapBindingConfigs(bindingConfigs).flatMap((bindingConfig) => {
|
||||||
|
const binding = runtimeById.get(bindingConfig.id);
|
||||||
|
const facts = binding?.facts ?? [];
|
||||||
|
const semanticType = bindingConfig.semanticTypes[0] ?? facts[0]?.semanticType ?? "";
|
||||||
|
const profile = mapPresentationProfileForFact(profiles, bindingConfig.presentationProfileId, semanticType);
|
||||||
|
return profile ? [{
|
||||||
|
bindingId: bindingConfig.id,
|
||||||
|
displayName: bindingConfig.displayName?.trim() || profile.title || bindingConfig.id,
|
||||||
|
profile,
|
||||||
|
total: facts.length,
|
||||||
|
counts: mapPresentationFacetCounts(facts, profile),
|
||||||
|
}] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Object overview order is the same declared profile order used by facets and
|
||||||
|
* renderer subjects. A title-only resort must not silently override MCP sort.
|
||||||
|
*/
|
||||||
|
export function buildFilteredMapTargets(runtimeBindings, bindingConfigs, profiles, filters) {
|
||||||
|
return buildSelectableMapEntities(runtimeBindings, bindingConfigs, profiles).flatMap((entity) => {
|
||||||
|
const binding = bindingConfigs.find((candidate) => candidate.id === entity.bindingId);
|
||||||
|
const profile = mapPresentationProfileForFact(profiles, binding?.presentationProfileId, entity.fact.semanticType);
|
||||||
|
if (!profile || !mapFactMatchesFilters(entity.fact, profile, filters, entity.bindingId)) return [];
|
||||||
|
return [{
|
||||||
|
bindingId: entity.bindingId,
|
||||||
|
entityId: entity.id,
|
||||||
|
title: entity.title,
|
||||||
|
status: resolveMapPresentationClass(entity.fact, profile)?.label ?? "",
|
||||||
|
renderable: mapRuntimeFactIsRenderable(entity.fact, profile),
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search reveal changes only constraints that hide the selected subject. The
|
||||||
|
* active sector identity is preserved; outside-sector clipping is relaxed only
|
||||||
|
* when needed so focus and the rendered subject cannot diverge.
|
||||||
|
*/
|
||||||
|
export function planMapSubjectReveal({
|
||||||
|
entity,
|
||||||
|
subjectState,
|
||||||
|
profile,
|
||||||
|
selectedSector,
|
||||||
|
gridProfiles,
|
||||||
|
origin,
|
||||||
|
hideOutsideSector,
|
||||||
|
scope,
|
||||||
|
}) {
|
||||||
|
let filters = { ...(subjectState?.filters ?? {}) };
|
||||||
|
for (const facet of profile?.facets ?? []) {
|
||||||
|
const selected = filters[facet.field];
|
||||||
|
if (selected === undefined) continue;
|
||||||
|
const factValue = normalizedFacetValue(entity.fact.attributes[facet.field]);
|
||||||
|
const available = facet.values.map((item) => item.value);
|
||||||
|
if (!available.includes(factValue)) {
|
||||||
|
delete filters[facet.field];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
filters[facet.field] = available.filter((value) => selected.includes(value) || value === factValue);
|
||||||
|
}
|
||||||
|
if (profile) filters = normalizeMapPresentationFacetSelections(filters, profile);
|
||||||
|
if (profile && !mapFactMatchesFilters(entity.fact, profile, {
|
||||||
|
[entity.bindingId]: { visible: true, facets: filters },
|
||||||
|
}, entity.bindingId)) filters = {};
|
||||||
|
|
||||||
|
const provider = mapFactSectorScopeValue(entity.fact, MAP_SCOPE_PROVIDER_FIELD);
|
||||||
|
const objectKind = mapFactSectorScopeValue(entity.fact, MAP_SCOPE_OBJECT_KIND_FIELD);
|
||||||
|
const insideSector = selectedSector
|
||||||
|
? mapFactInsideGridSector(entity.fact, selectedSector, gridProfiles, origin)
|
||||||
|
: true;
|
||||||
|
return {
|
||||||
|
subjectState: {
|
||||||
|
...subjectState,
|
||||||
|
bindingId: entity.bindingId,
|
||||||
|
visible: true,
|
||||||
|
filters,
|
||||||
|
},
|
||||||
|
hideOutsideSector: hideOutsideSector && (!selectedSector || insideSector),
|
||||||
|
scope: {
|
||||||
|
excludedBindingIds: scope.excludedBindingIds.filter((value) => value !== entity.bindingId),
|
||||||
|
excludedProviders: scope.excludedProviders.filter((value) => value !== provider),
|
||||||
|
excludedObjectKinds: scope.excludedObjectKinds.filter((value) => value !== objectKind),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindingOrder(left, right) {
|
||||||
|
return (left.order ?? 0) - (right.order ?? 0) || left.id.localeCompare(right.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedFacetValue(value) {
|
||||||
|
return typeof value === "string" ? value.trim().toLowerCase() : "unknown";
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { WorkspaceWindowRect } from "@nodedc/ui-react";
|
||||||
|
import type { MapSubjectState, MapWorkspaceWindowId } from "./mapPageContract.js";
|
||||||
|
import type { GridSectorSelection } from "./mapRendererContract.js";
|
||||||
|
import type { MapSectorScope } from "./mapWorkspaceModel.mjs";
|
||||||
|
|
||||||
|
export type MapWorkspaceState = {
|
||||||
|
selectedEntityId?: string;
|
||||||
|
selectedSector: GridSectorSelection | null;
|
||||||
|
subjectStates: Record<string, MapSubjectState>;
|
||||||
|
activeWindowId?: MapWorkspaceWindowId;
|
||||||
|
sector: {
|
||||||
|
window: { rect: WorkspaceWindowRect; maximized: boolean; zIndex: number };
|
||||||
|
hideOutside: boolean;
|
||||||
|
scope: MapSectorScope;
|
||||||
|
};
|
||||||
|
subjectCard: {
|
||||||
|
open: boolean;
|
||||||
|
rect: WorkspaceWindowRect;
|
||||||
|
maximized: boolean;
|
||||||
|
zIndex: number;
|
||||||
|
tabId: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MapWorkspaceAction =
|
||||||
|
| { type: "reset-sector-definition" }
|
||||||
|
| { type: "deactivate-sector" }
|
||||||
|
| { type: "set-sector-selection"; selection: GridSectorSelection | null }
|
||||||
|
| { type: "select-sector"; selection: GridSectorSelection | null }
|
||||||
|
| { type: "set-sector-hide-outside"; value: boolean }
|
||||||
|
| { type: "set-sector-scope"; scope: MapSectorScope }
|
||||||
|
| { type: "set-sector-scope-enabled"; dimension: "binding" | "provider" | "object-kind"; value: string; enabled: boolean }
|
||||||
|
| { type: "set-sector-window-rect"; rect: WorkspaceWindowRect }
|
||||||
|
| { type: "set-sector-window-maximized"; value: boolean }
|
||||||
|
| { type: "toggle-subject-filter"; bindingId: string; field: string; value: string; availableValues: string[] }
|
||||||
|
| { type: "toggle-subject-visibility"; bindingId: string }
|
||||||
|
| { type: "replace-subject-state"; bindingId: string; subjectState: MapSubjectState }
|
||||||
|
| { type: "open-subject-window"; bindingId: string }
|
||||||
|
| { type: "close-subject-window"; bindingId: string }
|
||||||
|
| { type: "set-subject-window-rect"; bindingId: string; rect: WorkspaceWindowRect }
|
||||||
|
| { type: "set-subject-window-maximized"; bindingId: string; value: boolean }
|
||||||
|
| { type: "activate-window"; windowId: MapWorkspaceWindowId }
|
||||||
|
| { type: "clear-active-window"; windowId: MapWorkspaceWindowId }
|
||||||
|
| { type: "select-entity"; entityId: string; validTabIds: string[]; defaultTabId: string }
|
||||||
|
| { type: "close-subject-card" }
|
||||||
|
| { type: "set-subject-card-rect"; rect: WorkspaceWindowRect }
|
||||||
|
| { type: "set-subject-card-maximized"; value: boolean }
|
||||||
|
| { type: "set-subject-card-tab"; tabId: string }
|
||||||
|
| { type: "reveal-subject"; bindingId: string; subjectState: MapSubjectState; hideOutsideSector: boolean; scope: MapSectorScope };
|
||||||
|
|
||||||
|
export function createMapWorkspaceState(input: {
|
||||||
|
subjectStates: Record<string, MapSubjectState>;
|
||||||
|
sectorWindowRect: WorkspaceWindowRect;
|
||||||
|
subjectCardRect: WorkspaceWindowRect;
|
||||||
|
}): MapWorkspaceState;
|
||||||
|
export function mapWorkspaceReducer(state: MapWorkspaceState, action: MapWorkspaceAction): MapWorkspaceState;
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { toggleMapPresentationFacetSelection } from "./mapPresentationProfile.mjs";
|
||||||
|
|
||||||
|
export function createMapWorkspaceState({ subjectStates, sectorWindowRect, subjectCardRect }) {
|
||||||
|
return {
|
||||||
|
selectedEntityId: undefined,
|
||||||
|
selectedSector: null,
|
||||||
|
subjectStates,
|
||||||
|
activeWindowId: undefined,
|
||||||
|
sector: {
|
||||||
|
window: { rect: sectorWindowRect, maximized: false, zIndex: 142 },
|
||||||
|
hideOutside: false,
|
||||||
|
scope: {
|
||||||
|
excludedBindingIds: [],
|
||||||
|
excludedProviders: [],
|
||||||
|
excludedObjectKinds: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
subjectCard: {
|
||||||
|
open: false,
|
||||||
|
rect: subjectCardRect,
|
||||||
|
maximized: false,
|
||||||
|
zIndex: 140,
|
||||||
|
tabId: "overview",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapWorkspaceReducer(state, action) {
|
||||||
|
switch (action.type) {
|
||||||
|
case "reset-sector-definition":
|
||||||
|
case "deactivate-sector":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
selectedSector: null,
|
||||||
|
activeWindowId: state.activeWindowId === "sector" ? undefined : state.activeWindowId,
|
||||||
|
sector: {
|
||||||
|
...state.sector,
|
||||||
|
scope: { excludedBindingIds: [], excludedProviders: [], excludedObjectKinds: [] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case "set-sector-selection":
|
||||||
|
return { ...state, selectedSector: action.selection };
|
||||||
|
case "select-sector":
|
||||||
|
return action.selection
|
||||||
|
? activateWindow({ ...state, selectedSector: action.selection }, "sector")
|
||||||
|
: mapWorkspaceReducer(state, { type: "deactivate-sector" });
|
||||||
|
case "set-sector-hide-outside":
|
||||||
|
return { ...state, sector: { ...state.sector, hideOutside: action.value } };
|
||||||
|
case "set-sector-scope":
|
||||||
|
return { ...state, sector: { ...state.sector, scope: action.scope } };
|
||||||
|
case "set-sector-scope-enabled": {
|
||||||
|
const key = action.dimension === "binding"
|
||||||
|
? "excludedBindingIds"
|
||||||
|
: action.dimension === "provider"
|
||||||
|
? "excludedProviders"
|
||||||
|
: "excludedObjectKinds";
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
sector: {
|
||||||
|
...state.sector,
|
||||||
|
scope: {
|
||||||
|
...state.sector.scope,
|
||||||
|
[key]: setScopeValueEnabled(state.sector.scope[key], action.value, action.enabled),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "set-sector-window-rect":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
sector: { ...state.sector, window: { ...state.sector.window, rect: action.rect } },
|
||||||
|
};
|
||||||
|
case "set-sector-window-maximized":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
sector: { ...state.sector, window: { ...state.sector.window, maximized: action.value } },
|
||||||
|
};
|
||||||
|
case "toggle-subject-filter": {
|
||||||
|
const subject = state.subjectStates[action.bindingId];
|
||||||
|
if (!subject) return state;
|
||||||
|
const filters = subject.visible ? subject.filters : {};
|
||||||
|
return replaceSubjectState(state, action.bindingId, {
|
||||||
|
...subject,
|
||||||
|
visible: true,
|
||||||
|
filters: toggleMapPresentationFacetSelection(
|
||||||
|
filters,
|
||||||
|
action.field,
|
||||||
|
action.value,
|
||||||
|
action.availableValues,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case "toggle-subject-visibility": {
|
||||||
|
const subject = state.subjectStates[action.bindingId];
|
||||||
|
return subject
|
||||||
|
? replaceSubjectState(state, action.bindingId, { ...subject, visible: !subject.visible })
|
||||||
|
: state;
|
||||||
|
}
|
||||||
|
case "replace-subject-state":
|
||||||
|
return replaceSubjectState(state, action.bindingId, action.subjectState);
|
||||||
|
case "open-subject-window": {
|
||||||
|
const subject = state.subjectStates[action.bindingId];
|
||||||
|
if (!subject) return state;
|
||||||
|
return activateWindow(replaceSubjectState(state, action.bindingId, {
|
||||||
|
...subject,
|
||||||
|
window: { ...subject.window, open: true },
|
||||||
|
}), `binding:${action.bindingId}`);
|
||||||
|
}
|
||||||
|
case "close-subject-window": {
|
||||||
|
const subject = state.subjectStates[action.bindingId];
|
||||||
|
if (!subject) return state;
|
||||||
|
const next = replaceSubjectState(state, action.bindingId, {
|
||||||
|
...subject,
|
||||||
|
window: { ...subject.window, open: false },
|
||||||
|
});
|
||||||
|
const windowId = `binding:${action.bindingId}`;
|
||||||
|
return { ...next, activeWindowId: next.activeWindowId === windowId ? undefined : next.activeWindowId };
|
||||||
|
}
|
||||||
|
case "set-subject-window-rect": {
|
||||||
|
const subject = state.subjectStates[action.bindingId];
|
||||||
|
return subject ? replaceSubjectState(state, action.bindingId, {
|
||||||
|
...subject,
|
||||||
|
window: { ...subject.window, rect: action.rect },
|
||||||
|
}) : state;
|
||||||
|
}
|
||||||
|
case "set-subject-window-maximized": {
|
||||||
|
const subject = state.subjectStates[action.bindingId];
|
||||||
|
return subject ? replaceSubjectState(state, action.bindingId, {
|
||||||
|
...subject,
|
||||||
|
window: { ...subject.window, maximized: action.value },
|
||||||
|
}) : state;
|
||||||
|
}
|
||||||
|
case "activate-window":
|
||||||
|
return activateWindow(state, action.windowId);
|
||||||
|
case "clear-active-window":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
activeWindowId: state.activeWindowId === action.windowId ? undefined : state.activeWindowId,
|
||||||
|
};
|
||||||
|
case "select-entity": {
|
||||||
|
const tabId = action.validTabIds.includes(state.subjectCard.tabId)
|
||||||
|
? state.subjectCard.tabId
|
||||||
|
: action.defaultTabId;
|
||||||
|
return activateWindow({
|
||||||
|
...state,
|
||||||
|
selectedEntityId: action.entityId,
|
||||||
|
subjectCard: { ...state.subjectCard, open: true, tabId },
|
||||||
|
}, "subject-card");
|
||||||
|
}
|
||||||
|
case "close-subject-card":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
activeWindowId: state.activeWindowId === "subject-card" ? undefined : state.activeWindowId,
|
||||||
|
subjectCard: { ...state.subjectCard, open: false },
|
||||||
|
};
|
||||||
|
case "set-subject-card-rect":
|
||||||
|
return { ...state, subjectCard: { ...state.subjectCard, rect: action.rect } };
|
||||||
|
case "set-subject-card-maximized":
|
||||||
|
return { ...state, subjectCard: { ...state.subjectCard, maximized: action.value } };
|
||||||
|
case "set-subject-card-tab":
|
||||||
|
return { ...state, subjectCard: { ...state.subjectCard, tabId: action.tabId } };
|
||||||
|
case "reveal-subject":
|
||||||
|
return {
|
||||||
|
...replaceSubjectState(state, action.bindingId, action.subjectState),
|
||||||
|
sector: {
|
||||||
|
...state.sector,
|
||||||
|
hideOutside: action.hideOutsideSector,
|
||||||
|
scope: action.scope,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceSubjectState(state, bindingId, subjectState) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
subjectStates: { ...state.subjectStates, [bindingId]: subjectState },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateWindow(state, windowId) {
|
||||||
|
if (state.activeWindowId === windowId) return state;
|
||||||
|
const nextZIndex = Math.max(
|
||||||
|
20,
|
||||||
|
state.sector.window.zIndex,
|
||||||
|
state.subjectCard.zIndex,
|
||||||
|
...Object.values(state.subjectStates).map((subject) => subject.window.zIndex),
|
||||||
|
) + 1;
|
||||||
|
if (windowId === "sector") {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
activeWindowId: windowId,
|
||||||
|
sector: { ...state.sector, window: { ...state.sector.window, zIndex: nextZIndex } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (windowId === "subject-card") {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
activeWindowId: windowId,
|
||||||
|
subjectCard: { ...state.subjectCard, zIndex: nextZIndex },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (windowId.startsWith("binding:")) {
|
||||||
|
const bindingId = windowId.slice("binding:".length);
|
||||||
|
const subject = state.subjectStates[bindingId];
|
||||||
|
return subject ? {
|
||||||
|
...replaceSubjectState(state, bindingId, {
|
||||||
|
...subject,
|
||||||
|
window: { ...subject.window, zIndex: nextZIndex },
|
||||||
|
}),
|
||||||
|
activeWindowId: windowId,
|
||||||
|
} : state;
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setScopeValueEnabled(values, value, enabled) {
|
||||||
|
return enabled
|
||||||
|
? values.filter((candidate) => candidate !== value)
|
||||||
|
: [...new Set([...values, value])];
|
||||||
|
}
|
||||||
@@ -1678,33 +1678,6 @@ textarea {
|
|||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.catalog-split-pane-preview .catalog-preview__body {
|
|
||||||
align-content: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.catalog-split-pane-demo {
|
|
||||||
min-height: 18rem;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: var(--nodedc-radius-card);
|
|
||||||
background: var(--nodedc-nested-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.catalog-split-pane-demo__panel {
|
|
||||||
display: grid;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
place-content: center;
|
|
||||||
justify-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
background: color-mix(in srgb, var(--nodedc-canvas) 82%, transparent);
|
|
||||||
color: var(--nodedc-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.catalog-split-pane-demo__panel strong {
|
|
||||||
color: var(--nodedc-text-primary);
|
|
||||||
font-size: 0.82rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.catalog-side-panel-preview .catalog-preview__body {
|
.catalog-side-panel-preview .catalog-preview__body {
|
||||||
align-content: stretch;
|
align-content: stretch;
|
||||||
}
|
}
|
||||||
@@ -1781,14 +1754,6 @@ textarea {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.catalog-icon-rail-demo {
|
|
||||||
display: flex;
|
|
||||||
width: max-content;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--nodedc-space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.catalog-form {
|
.catalog-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import type { MapDataProductBinding } from "./MapFixturePreview.js";
|
import type { MapDataProductBinding } from "./mapPageContract.js";
|
||||||
|
import { mapRuntimeFactKey } from "./mapRuntimeIdentity.mjs";
|
||||||
|
|
||||||
|
export { mapRuntimeEntityId } from "./mapRuntimeIdentity.mjs";
|
||||||
|
|
||||||
export type DataProductPoint = {
|
export type DataProductPoint = {
|
||||||
type: "Point";
|
type: "Point";
|
||||||
@@ -78,14 +81,6 @@ type BindingState = {
|
|||||||
const identifier = /^[A-Za-z0-9._:-]{1,160}$/;
|
const identifier = /^[A-Za-z0-9._:-]{1,160}$/;
|
||||||
const cursorPattern = /^(?:0|[1-9]\d*)$/;
|
const cursorPattern = /^(?:0|[1-9]\d*)$/;
|
||||||
|
|
||||||
function factKey(fact: MapRuntimeFact) {
|
|
||||||
return `${fact.semanticType}\u0000${fact.sourceId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapRuntimeEntityId(bindingId: string, fact: Pick<MapRuntimeFact, "sourceId" | "semanticType">) {
|
|
||||||
return `nodedc-runtime:${bindingId}:${fact.semanticType}:${fact.sourceId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isIsoTimestamp(value: unknown): value is string {
|
function isIsoTimestamp(value: unknown): value is string {
|
||||||
return typeof value === "string" && !Number.isNaN(Date.parse(value));
|
return typeof value === "string" && !Number.isNaN(Date.parse(value));
|
||||||
}
|
}
|
||||||
@@ -246,7 +241,7 @@ function replaceSnapshot(current: BindingState, snapshot: SnapshotEnvelope): Bin
|
|||||||
...current,
|
...current,
|
||||||
cursor: snapshot.cursor,
|
cursor: snapshot.cursor,
|
||||||
state: "ready",
|
state: "ready",
|
||||||
facts: Object.fromEntries(snapshot.facts.map((fact) => [factKey(fact), fact])),
|
facts: Object.fromEntries(snapshot.facts.map((fact) => [mapRuntimeFactKey(fact), fact])),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,8 +252,8 @@ function applyPatch(current: BindingState, patch: PatchEnvelope): BindingState |
|
|||||||
if (current.cursor !== patch.previousCursor) return null;
|
if (current.cursor !== patch.previousCursor) return null;
|
||||||
const facts = { ...current.facts };
|
const facts = { ...current.facts };
|
||||||
for (const operation of patch.operations) {
|
for (const operation of patch.operations) {
|
||||||
if (operation.op === "upsert") facts[factKey(operation.fact)] = operation.fact;
|
if (operation.op === "upsert") facts[mapRuntimeFactKey(operation.fact)] = operation.fact;
|
||||||
else delete facts[`${operation.semanticType}\u0000${operation.sourceId}`];
|
else delete facts[mapRuntimeFactKey(operation)];
|
||||||
}
|
}
|
||||||
return { ...current, facts, cursor: patch.cursor, state: "ready" };
|
return { ...current, facts, cursor: patch.cursor, state: "ready" };
|
||||||
}
|
}
|
||||||
@@ -266,7 +261,7 @@ function applyPatch(current: BindingState, patch: PatchEnvelope): BindingState |
|
|||||||
function applyPresentationPatch(current: BindingState, patch: PresentationPatchEnvelope): BindingState {
|
function applyPresentationPatch(current: BindingState, patch: PresentationPatchEnvelope): BindingState {
|
||||||
const facts = { ...current.facts };
|
const facts = { ...current.facts };
|
||||||
for (const operation of patch.operations) {
|
for (const operation of patch.operations) {
|
||||||
const key = `${operation.semanticType}\u0000${operation.sourceId}`;
|
const key = mapRuntimeFactKey(operation);
|
||||||
if (facts[key]) facts[key] = { ...facts[key], presentationStatus: operation.status };
|
if (facts[key]) facts[key] = { ...facts[key], presentationStatus: operation.status };
|
||||||
}
|
}
|
||||||
return { ...current, facts };
|
return { ...current, facts };
|
||||||
@@ -421,7 +416,7 @@ export function useMapDataProductRuntime({
|
|||||||
dataProductId: binding.dataProductId,
|
dataProductId: binding.dataProductId,
|
||||||
slotId: binding.slotId,
|
slotId: binding.slotId,
|
||||||
presentationProfileId: binding.presentationProfileId,
|
presentationProfileId: binding.presentationProfileId,
|
||||||
facts: Object.values(record.facts).sort((left, right) => factKey(left).localeCompare(factKey(right))),
|
facts: Object.values(record.facts).sort((left, right) => mapRuntimeFactKey(left).localeCompare(mapRuntimeFactKey(right))),
|
||||||
cursor: record.cursor,
|
cursor: record.cursor,
|
||||||
state: record.state,
|
state: record.state,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -211,7 +211,13 @@ function asFact(value: unknown): MapRuntimeFact | null {
|
|||||||
|| !isIso(value.observedAt) || !isIso(value.receivedAt)
|
|| !isIso(value.observedAt) || !isIso(value.receivedAt)
|
||||||
|| !isObject(value.attributes) || !categories.has(value.attributes.category as MapReferenceStationCategory)
|
|| !isObject(value.attributes) || !categories.has(value.attributes.category as MapReferenceStationCategory)
|
||||||
|| !isPoint(value.geometry)) return null;
|
|| !isPoint(value.geometry)) return null;
|
||||||
const allowedAttributes = new Set(["name", "category", "network", "operator", "official_name", "local_name", "uic_ref", "wheelchair"]);
|
if (value.attributes.alternate_names !== undefined
|
||||||
|
&& (!Array.isArray(value.attributes.alternate_names)
|
||||||
|
|| value.attributes.alternate_names.length > 16
|
||||||
|
|| value.attributes.alternate_names.some((name) => typeof name !== "string" || !name.trim() || name.length > 256))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const allowedAttributes = new Set(["name", "category", "network", "operator", "official_name", "local_name", "alternate_names", "uic_ref", "wheelchair"]);
|
||||||
return {
|
return {
|
||||||
sourceId: value.sourceId,
|
sourceId: value.sourceId,
|
||||||
semanticType: String(value.semanticType),
|
semanticType: String(value.semanticType),
|
||||||
|
|||||||
@@ -44,7 +44,3 @@ Modal остаётся portal-слоем. Его тело прокручивае
|
|||||||
- предметное содержимое content window.
|
- предметное содержимое content window.
|
||||||
|
|
||||||
Shell владеет геометрией, слоями, переходами и breakpoint-поведением. См. готовый путь подключения в `docs/CONSUMPTION.md`.
|
Shell владеет геометрией, слоями, переходами и breakpoint-поведением. См. готовый путь подключения в `docs/CONSUMPTION.md`.
|
||||||
|
|
||||||
На узком экране скрывается именно действие `data-action="expand"`.
|
|
||||||
Порядок utility actions не является признаком типа действия: плюс и обновление
|
|
||||||
остаются доступны, даже когда стоят первыми в шапке. Исправлено при QA Node 0.3.
|
|
||||||
|
|||||||
+4
-53
@@ -10,8 +10,6 @@
|
|||||||
- `strong` — dropdown, modal и поверхность над сложным фоном;
|
- `strong` — dropdown, modal и поверхность над сложным фоном;
|
||||||
- `soft` — вложенная или вторичная область.
|
- `soft` — вложенная или вторичная область.
|
||||||
|
|
||||||
`radius="pill"` задаёт каноническую капсульную геометрию через `--nodedc-radius-circle`. Она предназначена, в частности, для компактных вертикальных и горизонтальных реек из круглых действий: верхняя и нижняя части поверхности повторяют круг кнопок без прямых торцевых пролётов. Consumer не воспроизводит этот радиус локальным CSS.
|
|
||||||
|
|
||||||
Material rim допустим только как часть floating glass. Жёсткая цветная рамка, случайный browser outline или debug border не являются rim.
|
Material rim допустим только как часть floating glass. Жёсткая цветная рамка, случайный browser outline или debug border не являются rim.
|
||||||
|
|
||||||
`GlassMaterialSurface` — отдельный переносимый контракт Engine Glass V4. Он централизует tint/opacity, blur, saturation, brightness, gradient rim и shadow. Его используют только modal `Window` и modeless draggable Inspector; обычные Launcher/SEO панели остаются непрозрачными. Сам `Window` всегда получает класс `nodedc-glass-material` и `data-material="glass-v4"`, поэтому sharing, confirmation и остальные modal-паттерны физически используют тот же surface, что Inspector и лабораторный preview. Настройки применяются через `applyGlassMaterial`, поэтому consumer не пересобирает CSS материала вручную.
|
`GlassMaterialSurface` — отдельный переносимый контракт Engine Glass V4. Он централизует tint/opacity, blur, saturation, brightness, gradient rim и shadow. Его используют только modal `Window` и modeless draggable Inspector; обычные Launcher/SEO панели остаются непрозрачными. Сам `Window` всегда получает класс `nodedc-glass-material` и `data-material="glass-v4"`, поэтому sharing, confirmation и остальные modal-паттерны физически используют тот же surface, что Inspector и лабораторный preview. Настройки применяются через `applyGlassMaterial`, поэтому consumer не пересобирает CSS материала вручную.
|
||||||
@@ -27,16 +25,6 @@ Button используется для всех текстовых действ
|
|||||||
|
|
||||||
Icon-only action по умолчанию круглый. Квадратная кнопка с маленьким радиусом допустима только как кнопка закрытия окна или плотный инструмент, где это зафиксировано контрактом.
|
Icon-only action по умолчанию круглый. Квадратная кнопка с маленьким радиусом допустима только как кнопка закрытия окна или плотный инструмент, где это зафиксировано контрактом.
|
||||||
|
|
||||||
`size="dense"` — каноническая плотность для текстовых layer/mode-действий внутри визуализатора. Она уменьшает площадь и подпись контрола, но не меняет hover, active, disabled и focus-состояния. В шапках приложения и обычных формах эта плотность не используется.
|
|
||||||
|
|
||||||
Переключаемый IconButton передаёт контролируемое состояние через `aria-pressed`. Активная поверхность и контраст принадлежат дизайн-системе; размер круга и glyph при переключении не меняются.
|
|
||||||
|
|
||||||
## ActivityIndicator
|
|
||||||
|
|
||||||
`ActivityIndicator` — общий индикатор неопределённого по длительности процесса. `default` используется рядом с самостоятельным статусом, `compact` — в icon-slot кнопки. Владелец операции по-прежнему задаёт видимый текст pending-состояния и `aria-busy`; индикатор не хранит таймер и не определяет завершение операции.
|
|
||||||
|
|
||||||
Без `label` индикатор декоративный и скрыт от accessibility tree. `label` включает `role="status"` только когда сам индикатор должен объявить процесс. При `prefers-reduced-motion: reduce` кольцо остаётся видимым, но не вращается.
|
|
||||||
|
|
||||||
## Field
|
## Field
|
||||||
|
|
||||||
FieldFrame объединяет label, control, hint и description. TextField/TextAreaField реализуют стандартные текстовые поля.
|
FieldFrame объединяет label, control, hint и description. TextField/TextAreaField реализуют стандартные текстовые поля.
|
||||||
@@ -51,9 +39,7 @@ FieldFrame объединяет label, control, hint и description. TextField/T
|
|||||||
|
|
||||||
## RangeControl
|
## RangeControl
|
||||||
|
|
||||||
Pill-range с заполнением акцентным цветом, встроенной подписью и значением. Домен определяет min/max/step и формат числа. `min/max` задают только рабочий диапазон перетаскивания; ручной ввод по умолчанию принимает любое конечное число и не меняет геометрию ползунка. Опциональный `exactValueBounds` независимо задаёт жёсткие границы ручного ввода: например, `{ min: 0 }` сохраняет неотрицательное значение, но не ограничивает верхнюю границу ползунком.
|
Pill-range с заполнением акцентным цветом, встроенной подписью и значением. Домен определяет min/max/step и формат числа. Drag всегда принадлежит невидимому native range и продолжается под областью значения. Над числом постоянно смонтирован один прозрачный native text input: клик включает редактирование без замены DOM-узла, браузер ставит каретку в фактическое место клика, а drag-selection остаётся нативным. Поле не получает собственной подложки, select-all или второго focus-ring. Enter/blur применяют значение с clamp/step-нормализацией, Escape отменяет ввод; события редактора не всплывают в оконные shortcuts. Оконный focus-manager выполняет начальный autofocus только при открытии и не отбирает фокус при последующих ререндерах.
|
||||||
|
|
||||||
Drag всегда принадлежит невидимому native range и продолжается под областью значения. Над числом постоянно смонтирован один прозрачный native text input: фокус включает редактирование без замены DOM-узла и без pointer-state mutation, браузер ставит каретку в фактическое место клика, а drag/keyboard-selection и замена выделенного текста остаются полностью нативными. Поле не получает собственной подложки, select-all или второго focus-ring. Цвет текста и каретки автоматически выбирается между theme text и `on-accent` по фактической границе заливки под областью значения, поэтому светлая заливка получает тёмный контраст без application-local цвета. Enter/blur применяют значение с exact-bound/step-нормализацией, Escape отменяет ввод; события редактора не всплывают в оконные shortcuts. Оконный focus-manager выполняет начальный autofocus только при открытии и не отбирает фокус при последующих ререндерах.
|
|
||||||
|
|
||||||
## ColorField
|
## ColorField
|
||||||
|
|
||||||
@@ -83,11 +69,10 @@ Dropdown владеет floating-layer поведением:
|
|||||||
|
|
||||||
## Select
|
## Select
|
||||||
|
|
||||||
Select добавляет к Dropdown контролируемое значение, options и необязательный поиск. У него три канонические формы:
|
Select добавляет к Dropdown контролируемое значение, options и необязательный поиск. У него две канонические формы:
|
||||||
|
|
||||||
- `integrated` — единая pill-поверхность Hub/Launcher: label слева, chevron строго у правого края;
|
- `integrated` — единая pill-поверхность Hub/Launcher: label слева, chevron строго у правого края;
|
||||||
- `split` — форма Engine с отдельной областью значения и отдельной кнопкой раскрытия `46 px`, между ними зазор `8 px`;
|
- `split` — форма Engine с отдельной областью значения и отдельной кнопкой раскрытия `46 px`, между ними зазор `8 px`.
|
||||||
- `inline` — компактный toolbar-trigger: только текущее значение без постоянной подложки и chevron; всё значение открывает обычное portal-меню.
|
|
||||||
|
|
||||||
Native select не используется как видимый runtime UI. Меню рендерится через portal; у Engine-варианта меню имеет радиус `20 px`, а строки — `14 px` и высоту `42 px`.
|
Native select не используется как видимый runtime UI. Меню рендерится через portal; у Engine-варианта меню имеет радиус `20 px`, а строки — `14 px` и высоту `42 px`.
|
||||||
|
|
||||||
@@ -104,8 +89,6 @@ Window — единая механика открытия modal и правой
|
|||||||
|
|
||||||
Содержание, сохранение и запросы принадлежат приложению.
|
Содержание, сохранение и запросы принадлежат приложению.
|
||||||
|
|
||||||
Modeless Inspector закрывается независимо от результата автосохранения. Приложение сначала закрывает controlled `Window`, затем завершает сохранение асинхронно; сбой сообщает через компактный `ToastStack` с tone `error`, сохраняя draft текущего просмотра. Нельзя удерживать окно открытым, выводить внутри него browser-default `p`/heading или увеличивать текст ошибки до title-размера.
|
|
||||||
|
|
||||||
Для modeless Inspector доступен `draggable`: окно двигается за header, не блокирует приложение и ограничивается viewport. Modal-окна не становятся draggable автоматически.
|
Для modeless Inspector доступен `draggable`: окно двигается за header, не блокирует приложение и ограничивается viewport. Modal-окна не становятся draggable автоматически.
|
||||||
|
|
||||||
## WorkspaceWindow
|
## WorkspaceWindow
|
||||||
@@ -156,16 +139,6 @@ Enter при отсутствии совпадения разрешает оди
|
|||||||
|
|
||||||
ConfirmationModal оборачивает Window и защищает async-confirm от повторного запуска. До завершения операции закрытие можно заблокировать.
|
ConfirmationModal оборачивает Window и защищает async-confirm от повторного запуска. До завершения операции закрытие можно заблокировать.
|
||||||
|
|
||||||
## SplitPane
|
|
||||||
|
|
||||||
`SplitPane` — контролируемая раскладка двух синхронных представлений с общей регулируемой границей. Приложение передаёт содержимое обеих панелей, процент primary-панели и callback изменения; дизайн-система владеет pointer capture, ограничениями размеров, focus-state и доступностью `role="separator"`.
|
|
||||||
|
|
||||||
Локальные toolbar и overlay каждого viewport являются содержимым соответствующей панели. Их нельзя позиционировать от общего workspace или вычислять по cursor/event-эвристике вложенного renderer. Если renderer хранит свою раскладку, приложение передаёт ему тот же controlled процент из `SplitPane`, чтобы renderer и локальный UI использовали одну геометрию.
|
|
||||||
|
|
||||||
Вертикальная граница меняется мышью или клавишами `←/→`, горизонтальная — `↑/↓`; `Home/End` переходят к разрешённым границам, а `Shift` ускоряет клавиатурный шаг. Доля сохраняется при resize контейнера. Минимальные проценты обеих панелей применяются одинаково к pointer и keyboard interaction.
|
|
||||||
|
|
||||||
Разделитель является структурной границей между двумя рабочими представлениями и не создаёт декоративную рамку вокруг контента. При `resizable={false}` панели сохраняют ту же стабильную DOM-композицию, а separator полностью отсутствует в визуальном слое и accessibility tree; это позволяет приложению сворачивать одну из панелей без remount тяжёлого renderer.
|
|
||||||
|
|
||||||
## ShareAccessModal
|
## ShareAccessModal
|
||||||
|
|
||||||
Workflow-sharing modal из нового Engine: заголовок ресурса, compact avatar stack, список участников, редактирование ролей, удаление доступа, email и роль нового участника. Компонент сохраняет Engine-геометрию `600 px` и controls `46 px`, но не импортирует ACL API. Consumer передаёт members, permissions и callbacks.
|
Workflow-sharing modal из нового Engine: заголовок ресурса, compact avatar stack, список участников, редактирование ролей, удаление доступа, email и роль нового участника. Компонент сохраняет Engine-геометрию `600 px` и controls `46 px`, но не импортирует ACL API. Consumer передаёт members, permissions и callbacks.
|
||||||
@@ -180,8 +153,6 @@ Read-only link/copy окно из BIM Viewer. React-компонент испо
|
|||||||
|
|
||||||
Pill navigation для верхней панели и компактного переключения режимов. Active segment использует активную поверхность темы; это не обязательно основной accent приложения.
|
Pill navigation для верхней панели и компактного переключения режимов. Active segment использует активную поверхность темы; это не обязательно основной accent приложения.
|
||||||
|
|
||||||
`size="dense"` применяется только в насыщенной панели слоёв или режимов непосредственно над viewer. Шапка приложения сохраняет default-геометрию; consumer не воспроизводит dense-отступы или шрифт локальным CSS.
|
|
||||||
|
|
||||||
## AppHeader
|
## AppHeader
|
||||||
|
|
||||||
Трёхосевая верхняя панель:
|
Трёхосевая верхняя панель:
|
||||||
@@ -294,7 +265,7 @@ Sortable-строки ограничены вертикальной осью: г
|
|||||||
|
|
||||||
## ToastStack
|
## ToastStack
|
||||||
|
|
||||||
`ToastCard` и `ToastStack` фиксируют Tasker-derived bottom-right уведомления для `success`, `error`, `warning`, `info` и `loading`. Приложение владеет текстом и состоянием операции; стек владеет portal, геометрией, aria-live и таймерами. Обычный terminal toast по умолчанию живёт `10 000 ms`; каждый стабильный `id` получает собственный таймер, поэтому добавление соседнего уведомления не продлевает уже показанное. Loading не закрывается автоматически и обновляется тем же id после завершения операции; после перехода в terminal tone для него запускается обычный независимый таймер. Toast не используется вместо modal confirmation.
|
`ToastCard` и `ToastStack` фиксируют Tasker-derived bottom-right уведомления для `success`, `error`, `warning`, `info` и `loading`. Приложение владеет текстом и состоянием операции; стек владеет portal, геометрией, aria-live и таймерами. Loading не закрывается автоматически и обновляется тем же id после завершения операции. Toast не используется вместо modal confirmation.
|
||||||
|
|
||||||
## Environment Controls: Inspector и ControlRow
|
## Environment Controls: Inspector и ControlRow
|
||||||
|
|
||||||
@@ -316,23 +287,3 @@ Inspector владеет:
|
|||||||
Для Engine Environment Settings desktop-контракт точный: окно `390 px`, рабочая колонка `330 px`, строка `154 + 14 + 162 px`, высота контрола `46 px`, section header `50 px` с радиусом `12 px`. Accent-filled section headers и полноширинные range/checker/select сохраняют геометрию исходника.
|
Для Engine Environment Settings desktop-контракт точный: окно `390 px`, рабочая колонка `330 px`, строка `154 + 14 + 162 px`, высота контрола `46 px`, section header `50 px` с радиусом `12 px`. Accent-filled section headers и полноширинные range/checker/select сохраняют геометрию исходника.
|
||||||
|
|
||||||
Engine продолжает владеть определениями полей, типами нод и сохранением значений. В живом catalog эти collapsible sections находятся в `Guideline → Контролы`; отдельной product-вкладки Inspector нет.
|
Engine продолжает владеть определениями полей, типами нод и сохранением значений. В живом catalog эти collapsible sections находятся в `Guideline → Контролы`; отдельной product-вкладки Inspector нет.
|
||||||
|
|
||||||
## ResourceRow и ResourceList
|
|
||||||
|
|
||||||
Общие компактные строки ресурсов, выделенные из Mission Core AI Inference.
|
|
||||||
Контракт, состояния и происхождение: [RESOURCE_ROW.md](RESOURCE_ROW.md).
|
|
||||||
|
|
||||||
Светлый одноцветный знак шапки явно помечается `AppHeader.brandMonochrome` и
|
|
||||||
`HeaderWorkspace.monochrome`: общая тема обеспечивает контраст на светлом фоне.
|
|
||||||
По умолчанию флаг выключен; цветные изображения и аватары не меняются.
|
|
||||||
|
|
||||||
## ProgressBar
|
|
||||||
|
|
||||||
Линейная полоса выполнения: `label`, измеренная доля `value` 0…1 или
|
|
||||||
неопределённая продолжительность без `value`, доступный `valueText`.
|
|
||||||
В `ResourceRow` передаётся через `progress` и заменяет статус между
|
|
||||||
наименованием и кнопками. Процент принадлежит приложению, анимация его не выдумывает.
|
|
||||||
См. [контракт](PROGRESS_BAR.md).
|
|
||||||
|
|
||||||
StatusBadge `variant="indicator"` отображает только одну лампу состояния.
|
|
||||||
Передавайте `aria-label` и `title`; дополнительный текст и значки скрываются.
|
|
||||||
|
|||||||
+1
-6
@@ -29,12 +29,6 @@
|
|||||||
|
|
||||||
Допустимы density variants, если они имеют устойчивое назначение (`default`, `compact`) и тестируются как часть API.
|
Допустимы density variants, если они имеют устойчивое назначение (`default`, `compact`) и тестируются как часть API.
|
||||||
|
|
||||||
## Типографика и операционные сообщения
|
|
||||||
|
|
||||||
Продуктовый интерфейс использует только типографические токены. Browser-default размеры у `h1`–`h6`, `p`, `strong` и form controls не являются допустимым стилем. `--nodedc-font-size-title` разрешён только для заголовка страницы или окна; названия конфигураций, статусы, ошибки, подписи полей и сообщения внутри рабочей сцены используют `md`, `sm`, `xs` или готовую типографику канонического компонента.
|
|
||||||
|
|
||||||
Ошибка сохранения или запроса не рендерится свободным текстовым блоком поверх рабочей сцены. Приложение использует семантический error-state канонического поля либо `ToastStack`, который владеет размером текста, цветом, положением и закрытием. Ошибка автосохранения modeless-инспектора не блокирует закрытие окна: draft остаётся в текущем просмотре, окно закрывается сразу, а сбой показывается неблокирующим error toast.
|
|
||||||
|
|
||||||
## Deprecated
|
## Deprecated
|
||||||
|
|
||||||
Перед удалением export:
|
Перед удалением export:
|
||||||
@@ -59,3 +53,4 @@
|
|||||||
## Владение
|
## Владение
|
||||||
|
|
||||||
У дизайн-системы должен быть явный code owner. Product team может предлагать компоненты, но общий API и theme contract проходят отдельное review, потому что изменение распространяется на все будущие приложения.
|
У дизайн-системы должен быть явный code owner. Product team может предлагать компоненты, но общий API и theme contract проходят отдельное review, потому что изменение распространяется на все будущие приложения.
|
||||||
|
|
||||||
|
|||||||
+2
-6
@@ -19,16 +19,12 @@
|
|||||||
| Окно и слой | `close`, `plus`, `expand`, `minimize`, `refresh`, `panel`, `apps` |
|
| Окно и слой | `close`, `plus`, `expand`, `minimize`, `refresh`, `panel`, `apps` |
|
||||||
| Навигация | `chevron-left`, `chevron-right`, `chevron-down`, `grid`, `list`, `sliders`, `search` |
|
| Навигация | `chevron-left`, `chevron-right`, `chevron-down`, `grid`, `list`, `sliders`, `search` |
|
||||||
| Редактирование | `save`, `edit`, `trash`, `copy`, `upload`, `download`, `external` |
|
| Редактирование | `save`, `edit`, `trash`, `copy`, `upload`, `download`, `external` |
|
||||||
| Состояние и доступ | `check`, `alert`, `activity`, `lock`, `key`, `shield`, `circle`, `eye`, `eye-off` |
|
| Состояние и доступ | `check`, `alert`, `activity`, `lock`, `key`, `shield`, `circle` |
|
||||||
| Сущности | `profile`, `users`, `building`, `globe`, `database`, `network`, `inbox`, `mail` |
|
| Сущности | `profile`, `users`, `building`, `globe`, `database`, `network`, `inbox`, `mail` |
|
||||||
| Контент | `camera`, `plan`, `play`, `stop`, `image`, `video`, `file`, `folder`, `clipboard`, `settings` |
|
| Контент | `image`, `video`, `file`, `folder`, `clipboard`, `settings` |
|
||||||
|
|
||||||
Живая таблица с названиями, поверхностями и размерами находится в разделе `Guideline → Иконки`. Машинный список — в `registry/icons.json`.
|
Живая таблица с названиями, поверхностями и размерами находится в разделе `Guideline → Иконки`. Машинный список — в `registry/icons.json`.
|
||||||
|
|
||||||
## Правило расширения
|
## Правило расширения
|
||||||
|
|
||||||
Новая иконка добавляется только после подтверждённого применения в продукте. Нужно выбрать семантическое имя, добавить export в `Icon`, запись в registry и specimen в каталоге. Локальный импорт иконки только ради немного другой формы запрещён.
|
Новая иконка добавляется только после подтверждённого применения в продукте. Нужно выбрать семантическое имя, добавить export в `Icon`, запись в registry и specimen в каталоге. Локальный импорт иконки только ради немного другой формы запрещён.
|
||||||
|
|
||||||
`camera` обозначает оптический канал, а `plan` — пространственное представление сверху. Оба glyph контурные и используют общую stroke-геометрию набора.
|
|
||||||
|
|
||||||
`play` и `stop` — компактные полнотелые транспортные glyph: залитый треугольник запуска и залитый квадрат остановки/паузы. Видимая текстовая подпись не требуется, но action обязан сохранить доступный `aria-label`.
|
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
# Компактная операционная типографика
|
|
||||||
|
|
||||||
Уточнение владельца 06.09.2026 для Mission Core / Node, обязательное для
|
|
||||||
общих компонентов NODE.DC. Повторяющиеся сообщения и подписи не оформляются
|
|
||||||
крупными заголовками. Этот документ зарегистрирован в registry/registry.json.
|
|
||||||
|
|
||||||
- Заголовок страницы или окна: только предусмотренный шаблоном title-токен.
|
|
||||||
- Заголовок секции, SettingsCard, короткое сообщение пустого списка: md
|
|
||||||
(0.8125rem / 13 px при стандартном корневом размере), без увеличения через h2.
|
|
||||||
- Описание, пояснение, сведения о состоянии: sm (0.75rem / 12 px).
|
|
||||||
- Технические метаданные: xs либо типографика ResourceRow.
|
|
||||||
- Browser-default h1–h6/p/strong и локальные крупные размеры запрещены.
|
|
||||||
- Размер не умножается из-за вложенности карточек, ошибки или отсутствия данных.
|
|
||||||
|
|
||||||
SettingsCard и nodedc-empty-state закрепляют эти размеры в ui-core. Текст
|
|
||||||
«Устройства не обнаружены» не является заголовком нового раздела. В живом
|
|
||||||
каталоге рядом со строками ресурсов есть пример пустого списка.
|
|
||||||
|
|
||||||
В завершённом этапе настройки окружения status-слот ResourceRow содержит
|
|
||||||
только Icon check с доступным названием. Галочка не оборачивается в StatusBadge,
|
|
||||||
Checker, круг, pill или кнопку. Это знак результата, а не интерактивный контрол.
|
|
||||||
Лампочка состояния устройства отдельно использует StatusBadge indicator.
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# ProgressBar
|
|
||||||
|
|
||||||
Владелец Mission Core 05.09.2026 явно запросил горизонтальную заполняющуюся
|
|
||||||
полосу вместо кружка и точки в строке подготовки устройства. Общий компонент
|
|
||||||
добавлен в Design Guideline до подключения в Node/Core.
|
|
||||||
|
|
||||||
`value` — завершённая доля 0…1; NaN/Infinity трактуются как неизвестная величина,
|
|
||||||
выход за границы ограничивается. Без value используется движущийся сегмент,
|
|
||||||
aria-valuenow отсутствует. `label` обязателен; `valueText` описывает текущий этап.
|
|
||||||
Нет интерактивности и focus. Темы используют существующие семантические токены.
|
|
||||||
При reduced motion движение выключено, видимый сегмент сохраняется.
|
|
||||||
|
|
||||||
ResourceRow.progress занимает свободное место между текстом и actions, скрывает
|
|
||||||
status, на узком экране переносится ниже текста. Готовность не вычисляется
|
|
||||||
компонентом. Каталог содержит завершённый, определённый и неизвестный прогресс.
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# ResourceRow и ResourceList
|
|
||||||
|
|
||||||
Владелец 2026-09-05 запросил перенос существующих длинных строк Mission Core
|
|
||||||
AI Inference в Node с повторным использованием Design Guideline. Источник:
|
|
||||||
ObservatoryWorkspace / observatory-evidence-card. Это выделение существующего
|
|
||||||
оформления в общий компонент, без лабораторной модели данных.
|
|
||||||
|
|
||||||
`ResourceList` содержит обычные `li`; внутри — `ResourceRow` с обязательным
|
|
||||||
`title`, опциональными `icon`, `description`, `metadata`, `status`, `actions`.
|
|
||||||
Слоты действий принимают канонические Button/IconButton. Клик по всей строке
|
|
||||||
не назначается: самостоятельные действия имеют свои доступные подписи и focus.
|
|
||||||
|
|
||||||
Геометрия источника сохранена: минимум 4.25rem, отступы .7/.85rem, значок
|
|
||||||
2.15rem, компактный радиус и существующие токены поверхности/типографики.
|
|
||||||
Длинное название переносится; метаданные сокращаются визуально, полный текст
|
|
||||||
можно передать через title. На узком экране статус и действия переходят ниже.
|
|
||||||
|
|
||||||
Состояния поставляет consumer: нормальное, пустой список, загрузка, ошибка,
|
|
||||||
недоступное действие. Pending отмечается aria-busy и ActivityIndicator;
|
|
||||||
disabled и keyboard/focus остаются у общего контрола. Строка не объявляет
|
|
||||||
объект доступным или подключённым самостоятельно.
|
|
||||||
|
|
||||||
Пример находится в живом каталоге, раздел «Контролы → Строки ресурсов».
|
|
||||||
|
|
||||||
Для длительных операций с линейной индикацией используйте `progress`
|
|
||||||
(ProgressBar: label, value, valueText). Он заменяет status, оставляет действия
|
|
||||||
справа и занимает промежуток после названия.
|
|
||||||
|
|
||||||
`statusPlacement="leading"` размещает компактный индикатор после иконки и
|
|
||||||
перед всем текстовым блоком, с выравниванием по вертикальному центру строки.
|
|
||||||
По умолчанию остаётся `trailing` перед действиями. Статус не дублируется;
|
|
||||||
во время `progress` он скрыт в любом положении. Для одной лампы используйте
|
|
||||||
StatusBadge variant="indicator" с aria-label и title. Это согласованное
|
|
||||||
06.09.2026 расположение индикатора устройств Mission Core / Node.
|
|
||||||
+4
-8
@@ -11,7 +11,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react && npm run build --workspace @nodedc/ui-catalog",
|
"build": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react && npm run build --workspace @nodedc/ui-catalog",
|
||||||
"build:packages": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react",
|
"build:packages": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react",
|
||||||
"check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:spark-governance && npm run test:activity-indicator && npm run test:control-density && npm run test:icon-contract && npm run test:toast-contract && npm run test:floating-position && npm run test:inspector-select && npm run test:range-control && npm run test:split-pane && npm run test:hgeozone-projection && npm run test:map-grid-lod && npm run test:map-filters && npm run test:map-object-layers && npm run test:map-inspector-overlay-state && npm run test:map-reference-stations && npm run test:map-search && npm run test:map-subject-card && npm run test:map-subject-detail-profile",
|
"check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:floating-position && npm run test:inspector-select && npm run test:range-control && npm run test:hgeozone-projection && npm run test:map-provider-startup && npm run test:map-grid-lod && npm run test:map-filters && npm run test:map-workspace-model && npm run test:map-object-layers && npm run test:map-inspector-overlay-state && npm run test:map-reference-stations && npm run test:map-search && npm run test:map-subject-card && npm run test:map-subject-detail-profile",
|
||||||
"dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog",
|
"dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog",
|
||||||
"serve": "node server/catalog-server.mjs",
|
"serve": "node server/catalog-server.mjs",
|
||||||
"validate:registry": "node scripts/validate-registry.mjs",
|
"validate:registry": "node scripts/validate-registry.mjs",
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
"test:foundry-agent": "node --test server/foundry-agent-store.test.mjs server/foundry-agent-gateway.test.mjs scripts/foundry-agent-installer.test.mjs",
|
"test:foundry-agent": "node --test server/foundry-agent-store.test.mjs server/foundry-agent-gateway.test.mjs scripts/foundry-agent-installer.test.mjs",
|
||||||
"test:map-animation": "node --test scripts/map-spiral.test.mjs scripts/map-camera-presets.test.mjs",
|
"test:map-animation": "node --test scripts/map-spiral.test.mjs scripts/map-camera-presets.test.mjs",
|
||||||
"test:map-filters": "node --test scripts/map-presentation-filters.test.mjs",
|
"test:map-filters": "node --test scripts/map-presentation-filters.test.mjs",
|
||||||
|
"test:map-workspace-model": "node --test scripts/map-workspace-model.test.mjs scripts/map-workspace-state.test.mjs",
|
||||||
"test:hgeozone-projection": "node --test scripts/hgeozone-projection.test.mjs",
|
"test:hgeozone-projection": "node --test scripts/hgeozone-projection.test.mjs",
|
||||||
"test:map-grid-lod": "node --test scripts/map-grid-lod.test.mjs scripts/map-sector-grid.test.mjs server/map-grid-persistence.test.mjs",
|
"test:map-grid-lod": "node --test scripts/map-grid-lod.test.mjs scripts/map-sector-grid.test.mjs server/map-grid-persistence.test.mjs",
|
||||||
"test:map-object-layers": "node --test scripts/map-object-layers.test.mjs",
|
"test:map-object-layers": "node --test scripts/map-object-layers.test.mjs",
|
||||||
@@ -31,15 +32,10 @@
|
|||||||
"test:map-subject-card": "node --test scripts/map-subject-card.test.mjs",
|
"test:map-subject-card": "node --test scripts/map-subject-card.test.mjs",
|
||||||
"test:map-subject-detail-profile": "node --test server/map-subject-detail-profile.test.mjs server/map-live-data-slot.test.mjs",
|
"test:map-subject-detail-profile": "node --test server/map-subject-detail-profile.test.mjs server/map-live-data-slot.test.mjs",
|
||||||
"test:map-cache-contract": "node --test scripts/map-cache-resource-contract.test.mjs",
|
"test:map-cache-contract": "node --test scripts/map-cache-resource-contract.test.mjs",
|
||||||
"test:spark-governance": "node --test scripts/spark-governance-contract.test.mjs",
|
"test:map-provider-startup": "node --test scripts/map-provider-startup.test.mjs",
|
||||||
"test:activity-indicator": "node --test scripts/activity-indicator-contract.test.mjs",
|
|
||||||
"test:control-density": "node --test scripts/control-density-contract.test.mjs",
|
|
||||||
"test:icon-contract": "node --test scripts/icon-contract.test.mjs",
|
|
||||||
"test:toast-contract": "node --test scripts/toast-contract.test.mjs",
|
|
||||||
"test:floating-position": "node --test scripts/floating-position-contract.test.mjs",
|
"test:floating-position": "node --test scripts/floating-position-contract.test.mjs",
|
||||||
"test:inspector-select": "node --test scripts/inspector-select-contract.test.mjs",
|
"test:inspector-select": "node --test scripts/inspector-select-contract.test.mjs",
|
||||||
"test:range-control": "node --test scripts/range-control-contract.test.mjs",
|
"test:range-control": "node --test scripts/range-control-contract.test.mjs"
|
||||||
"test:split-pane": "node --test scripts/split-pane-contract.test.mjs"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
|
|
||||||
--nodedc-control-height: 2.875rem;
|
--nodedc-control-height: 2.875rem;
|
||||||
--nodedc-control-height-compact: 2.375rem;
|
--nodedc-control-height-compact: 2.375rem;
|
||||||
--nodedc-control-height-dense: 1.875rem;
|
|
||||||
--nodedc-icon-button-size: 2.875rem;
|
--nodedc-icon-button-size: 2.875rem;
|
||||||
--nodedc-header-row-height: 3rem;
|
--nodedc-header-row-height: 3rem;
|
||||||
--nodedc-header-pill-height: 3.45rem;
|
--nodedc-header-pill-height: 3.45rem;
|
||||||
@@ -62,7 +61,6 @@
|
|||||||
|
|
||||||
--nodedc-font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
--nodedc-font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
--nodedc-font-size-xs: 0.6875rem;
|
--nodedc-font-size-xs: 0.6875rem;
|
||||||
--nodedc-font-size-dense: 0.45rem;
|
|
||||||
--nodedc-font-size-sm: 0.75rem;
|
--nodedc-font-size-sm: 0.75rem;
|
||||||
--nodedc-font-size-md: 0.8125rem;
|
--nodedc-font-size-md: 0.8125rem;
|
||||||
--nodedc-font-size-lg: 1rem;
|
--nodedc-font-size-lg: 1rem;
|
||||||
|
|||||||
+6
-222
@@ -15,13 +15,13 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
:where(.nodedc-button, .nodedc-icon-button, .nodedc-checker, .nodedc-select-trigger, .nodedc-select-inline, .nodedc-select__toggle, .nodedc-dropdown-option, .nodedc-segmented__item, .nodedc-header__nav-item, .nodedc-inspector__section-trigger, .nodedc-window__close, .nodedc-workspace-window__action, .nodedc-workspace-window__resize) {
|
:where(.nodedc-button, .nodedc-icon-button, .nodedc-checker, .nodedc-select-trigger, .nodedc-select__toggle, .nodedc-dropdown-option, .nodedc-segmented__item, .nodedc-header__nav-item, .nodedc-inspector__section-trigger, .nodedc-window__close, .nodedc-workspace-window__action, .nodedc-workspace-window__resize) {
|
||||||
border: 0;
|
border: 0;
|
||||||
outline: 0;
|
outline: 0;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
:where(.nodedc-button, .nodedc-icon-button, .nodedc-checker, .nodedc-select-trigger, .nodedc-select-inline, .nodedc-select__toggle, .nodedc-dropdown-option, .nodedc-segmented__item, .nodedc-header__nav-item, .nodedc-inspector__section-trigger, .nodedc-window__close, .nodedc-workspace-window__action, .nodedc-workspace-window__resize):focus-visible {
|
:where(.nodedc-button, .nodedc-icon-button, .nodedc-checker, .nodedc-select-trigger, .nodedc-select__toggle, .nodedc-dropdown-option, .nodedc-segmented__item, .nodedc-header__nav-item, .nodedc-inspector__section-trigger, .nodedc-window__close, .nodedc-workspace-window__action, .nodedc-workspace-window__resize):focus-visible {
|
||||||
background-color: var(--nodedc-focus-surface);
|
background-color: var(--nodedc-focus-surface);
|
||||||
box-shadow: var(--nodedc-glass-control-shadow), inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.22);
|
box-shadow: var(--nodedc-glass-control-shadow), inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.22);
|
||||||
}
|
}
|
||||||
@@ -73,10 +73,6 @@
|
|||||||
border-radius: var(--nodedc-radius-modal);
|
border-radius: var(--nodedc-radius-modal);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nodedc-glass[data-radius="pill"] {
|
|
||||||
border-radius: var(--nodedc-radius-circle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nodedc-glass[data-padding="sm"] {
|
.nodedc-glass[data-padding="sm"] {
|
||||||
padding: var(--nodedc-space-3);
|
padding: var(--nodedc-space-3);
|
||||||
}
|
}
|
||||||
@@ -142,13 +138,6 @@
|
|||||||
font-size: var(--nodedc-font-size-sm);
|
font-size: var(--nodedc-font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nodedc-button[data-size="dense"] {
|
|
||||||
min-height: var(--nodedc-control-height-dense);
|
|
||||||
border-radius: var(--nodedc-radius-control-compact);
|
|
||||||
padding-inline: 0.65rem;
|
|
||||||
font-size: var(--nodedc-font-size-dense);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nodedc-button[data-width="full"] {
|
.nodedc-button[data-width="full"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -222,30 +211,6 @@
|
|||||||
transform: scale(var(--nodedc-icon-glyph-scale));
|
transform: scale(var(--nodedc-icon-glyph-scale));
|
||||||
}
|
}
|
||||||
|
|
||||||
.nodedc-activity-indicator {
|
|
||||||
display: inline-block;
|
|
||||||
width: 1.125rem;
|
|
||||||
height: 1.125rem;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
box-sizing: border-box;
|
|
||||||
border: 2px solid currentColor;
|
|
||||||
border-inline-end-color: transparent;
|
|
||||||
border-radius: var(--nodedc-radius-circle);
|
|
||||||
color: inherit;
|
|
||||||
opacity: 0.78;
|
|
||||||
animation: nodedc-activity-indicator-spin 780ms linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nodedc-activity-indicator[data-size="compact"] {
|
|
||||||
width: 0.875rem;
|
|
||||||
height: 0.875rem;
|
|
||||||
border-width: 1.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes nodedc-activity-indicator-spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.nodedc-icon-button {
|
.nodedc-icon-button {
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
width: var(--nodedc-icon-button-size);
|
width: var(--nodedc-icon-button-size);
|
||||||
@@ -266,11 +231,6 @@
|
|||||||
color: var(--nodedc-text-primary);
|
color: var(--nodedc-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nodedc-icon-button[aria-pressed="true"] {
|
|
||||||
background: var(--nodedc-glass-control-active);
|
|
||||||
color: var(--nodedc-glass-control-active-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nodedc-icon-button:active:not(:disabled) {
|
.nodedc-icon-button:active:not(:disabled) {
|
||||||
transform: translateY(1px);
|
transform: translateY(1px);
|
||||||
}
|
}
|
||||||
@@ -585,13 +545,13 @@ textarea.nodedc-field__control {
|
|||||||
|
|
||||||
.nodedc-settings-card__titles h2 {
|
.nodedc-settings-card__titles h2 {
|
||||||
color: var(--nodedc-text-primary);
|
color: var(--nodedc-text-primary);
|
||||||
font-size: var(--nodedc-font-size-md);
|
font-size: 1rem;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nodedc-settings-card__titles p {
|
.nodedc-settings-card__titles p {
|
||||||
color: var(--nodedc-text-muted);
|
color: var(--nodedc-text-muted);
|
||||||
font-size: var(--nodedc-font-size-sm);
|
font-size: 0.75rem;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -809,10 +769,6 @@ textarea.nodedc-field__control {
|
|||||||
caret-color: currentColor;
|
caret-color: currentColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nodedc-range__editor[data-active][data-contrast="fill"] {
|
|
||||||
color: rgb(var(--nodedc-on-accent-rgb));
|
|
||||||
}
|
|
||||||
|
|
||||||
.nodedc-range[data-editing] > .nodedc-range__value,
|
.nodedc-range[data-editing] > .nodedc-range__value,
|
||||||
.nodedc-range[data-editing] > .nodedc-range__fill-text .nodedc-range__value {
|
.nodedc-range[data-editing] > .nodedc-range__fill-text .nodedc-range__value {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -1046,19 +1002,6 @@ textarea.nodedc-field__control {
|
|||||||
color: var(--nodedc-glass-control-active-text);
|
color: var(--nodedc-glass-control-active-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nodedc-segmented[data-size="dense"] {
|
|
||||||
min-height: var(--nodedc-control-height-dense);
|
|
||||||
gap: 0.1rem;
|
|
||||||
padding: 0.16rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nodedc-segmented[data-size="dense"] .nodedc-segmented__item {
|
|
||||||
min-height: calc(var(--nodedc-control-height-dense) - 0.32rem);
|
|
||||||
gap: 0.25rem;
|
|
||||||
padding: 0.1rem 0.68rem;
|
|
||||||
font-size: var(--nodedc-font-size-dense);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nodedc-header-shell {
|
.nodedc-header-shell {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: var(--nodedc-layer-header);
|
z-index: var(--nodedc-layer-header);
|
||||||
@@ -2239,33 +2182,6 @@ textarea.nodedc-field__control {
|
|||||||
transform: translateY(2px) rotate(225deg);
|
transform: translateY(2px) rotate(225deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nodedc-select-inline {
|
|
||||||
display: inline-flex;
|
|
||||||
min-height: var(--nodedc-control-height-compact);
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--nodedc-space-2);
|
|
||||||
border-radius: var(--nodedc-radius-circle);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--nodedc-text-secondary);
|
|
||||||
padding: 0 var(--nodedc-space-3);
|
|
||||||
font-size: var(--nodedc-font-size-sm);
|
|
||||||
font-weight: var(--nodedc-font-weight-strong);
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nodedc-select-inline:hover:not(:disabled),
|
|
||||||
.nodedc-select-inline[aria-expanded="true"] {
|
|
||||||
background: var(--nodedc-glass-control-hover);
|
|
||||||
color: var(--nodedc-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nodedc-select-inline:disabled {
|
|
||||||
opacity: 0.42;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nodedc-select__toggle[aria-expanded="true"] .nodedc-select-trigger__chevron {
|
.nodedc-select__toggle[aria-expanded="true"] .nodedc-select-trigger__chevron {
|
||||||
transform: translateY(2px) rotate(225deg);
|
transform: translateY(2px) rotate(225deg);
|
||||||
}
|
}
|
||||||
@@ -2889,93 +2805,6 @@ textarea.nodedc-field__control {
|
|||||||
color: var(--nodedc-text-primary);
|
color: var(--nodedc-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nodedc-split-pane {
|
|
||||||
--nodedc-split-pane-primary: 50%;
|
|
||||||
position: relative;
|
|
||||||
display: grid;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nodedc-split-pane[data-orientation="vertical"] {
|
|
||||||
grid-template-columns: minmax(0, var(--nodedc-split-pane-primary)) minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nodedc-split-pane[data-orientation="horizontal"] {
|
|
||||||
grid-template-rows: minmax(0, var(--nodedc-split-pane-primary)) minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nodedc-split-pane__panel {
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nodedc-split-pane__separator {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 5;
|
|
||||||
border: 0;
|
|
||||||
outline: 0;
|
|
||||||
background: transparent;
|
|
||||||
padding: 0;
|
|
||||||
touch-action: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nodedc-split-pane[data-orientation="vertical"] > .nodedc-split-pane__separator {
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: var(--nodedc-split-pane-primary);
|
|
||||||
width: 0.9rem;
|
|
||||||
cursor: col-resize;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nodedc-split-pane[data-orientation="horizontal"] > .nodedc-split-pane__separator {
|
|
||||||
right: 0;
|
|
||||||
bottom: calc(100% - var(--nodedc-split-pane-primary));
|
|
||||||
left: 0;
|
|
||||||
height: 0.9rem;
|
|
||||||
cursor: row-resize;
|
|
||||||
transform: translateY(50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nodedc-split-pane__separator::before {
|
|
||||||
position: absolute;
|
|
||||||
background: var(--nodedc-glass-outline);
|
|
||||||
content: "";
|
|
||||||
transition: background var(--nodedc-duration-fast) var(--nodedc-ease-standard), box-shadow var(--nodedc-duration-fast) var(--nodedc-ease-standard);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nodedc-split-pane[data-orientation="vertical"] > .nodedc-split-pane__separator::before {
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: 50%;
|
|
||||||
width: 1px;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nodedc-split-pane[data-orientation="horizontal"] > .nodedc-split-pane__separator::before {
|
|
||||||
right: 0;
|
|
||||||
bottom: 50%;
|
|
||||||
left: 0;
|
|
||||||
height: 1px;
|
|
||||||
transform: translateY(50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nodedc-split-pane__separator:hover::before,
|
|
||||||
.nodedc-split-pane__separator:focus-visible::before,
|
|
||||||
.nodedc-split-pane[data-dragging="true"] > .nodedc-split-pane__separator::before {
|
|
||||||
background: rgb(var(--nodedc-accent-rgb));
|
|
||||||
box-shadow: 0 0 0 2px rgb(var(--nodedc-accent-rgb) / 0.16);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nodedc-split-pane[data-dragging="true"] {
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nodedc-toolbar-wrap {
|
.nodedc-toolbar-wrap {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: calc(var(--nodedc-layer-header) + 20);
|
z-index: calc(var(--nodedc-layer-header) + 20);
|
||||||
@@ -3569,7 +3398,6 @@ textarea.nodedc-field__control {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nodedc-empty-state {
|
.nodedc-empty-state {
|
||||||
font-size: var(--nodedc-font-size-sm);
|
|
||||||
display: grid;
|
display: grid;
|
||||||
min-height: 12rem;
|
min-height: 12rem;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
@@ -3581,7 +3409,7 @@ textarea.nodedc-field__control {
|
|||||||
|
|
||||||
.nodedc-empty-state strong {
|
.nodedc-empty-state strong {
|
||||||
color: var(--nodedc-text-primary);
|
color: var(--nodedc-text-primary);
|
||||||
font-size: var(--nodedc-font-size-md);
|
font-size: var(--nodedc-font-size-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
@@ -3681,7 +3509,7 @@ textarea.nodedc-field__control {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nodedc-application-panel__action[data-action="expand"] {
|
.nodedc-application-panel__action:first-child {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3757,7 +3585,6 @@ textarea.nodedc-field__control {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.nodedc-activity-indicator,
|
|
||||||
.nodedc-dropdown-surface,
|
.nodedc-dropdown-surface,
|
||||||
.nodedc-overlay,
|
.nodedc-overlay,
|
||||||
.nodedc-window,
|
.nodedc-window,
|
||||||
@@ -3778,46 +3605,3 @@ textarea.nodedc-field__control {
|
|||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ResourceRow: admitted extraction of Mission Core AI Inference list geometry. */
|
|
||||||
.nodedc-resource-list { display: grid; gap: .4rem; margin: 0; padding: 0; list-style: none; }
|
|
||||||
.nodedc-resource-row { display: flex; align-items: center; gap: .8rem; min-height: 4.25rem; padding: .7rem .85rem; border-radius: var(--nodedc-radius-control-compact); background: var(--nodedc-glass-control-bg); }
|
|
||||||
.nodedc-resource-row__icon { display: grid; flex: 0 0 2.15rem; height: 2.15rem; place-items: center; border-radius: var(--nodedc-radius-circle); background: var(--nodedc-panel-icon-bg); color: var(--nodedc-text-secondary); }
|
|
||||||
.nodedc-resource-row__copy { display: grid; flex: 1; min-width: 0; gap: .16rem; }
|
|
||||||
.nodedc-resource-row__copy strong { font-size: var(--nodedc-font-size-sm); line-height: 1.3; overflow-wrap: anywhere; }
|
|
||||||
.nodedc-resource-row__copy > span, .nodedc-resource-row__copy > small { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.nodedc-resource-row__copy > span { color: var(--nodedc-text-secondary); font-size: var(--nodedc-font-size-sm); }
|
|
||||||
.nodedc-resource-row__copy > small { color: var(--nodedc-text-muted); font-size: var(--nodedc-font-size-xs); }
|
|
||||||
.nodedc-resource-row__actions { display: flex; align-items: center; gap: .85rem; }
|
|
||||||
.nodedc-resource-row__status { font-size: var(--nodedc-font-size-sm); }
|
|
||||||
.nodedc-resource-row--leading-status > .nodedc-resource-row__status { display: flex; align-items: center; flex: 0 0 auto; }
|
|
||||||
@media (max-width: 540px) {
|
|
||||||
.nodedc-resource-row { flex-wrap: wrap; }
|
|
||||||
.nodedc-resource-row__copy { flex-basis: calc(100% - 3rem); }
|
|
||||||
.nodedc-resource-row--leading-status > .nodedc-resource-row__copy { flex: 1 1 0; }
|
|
||||||
.nodedc-resource-row__actions { margin-left: auto; }
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-nodedc-theme="light"] :is(.nodedc-header__brand, .nodedc-header__workspace)[data-monochrome="true"] img { filter: brightness(0); }
|
|
||||||
|
|
||||||
/* Owner-admitted linear progress in the shared resource row. */
|
|
||||||
.nodedc-progress-bar { min-width: 3rem; height: var(--nodedc-space-2); overflow: hidden; border-radius: var(--nodedc-radius-circle); background: var(--nodedc-glass-control-bg); }
|
|
||||||
.nodedc-progress-bar__fill { display: block; width: 100%; height: 100%; background: var(--nodedc-text-secondary); border-radius: inherit; transform: scaleX(var(--nodedc-progress-fraction)); transform-origin: left; transition: transform var(--nodedc-duration-normal) var(--nodedc-ease-standard); }
|
|
||||||
.nodedc-progress-bar[data-indeterminate] > .nodedc-progress-bar__fill { width: 35%; animation: nodedc-progress-travel 1.5s ease-in-out infinite alternate; }
|
|
||||||
@keyframes nodedc-progress-travel { from { transform: translateX(-100%); } to { transform: translateX(285%); } }
|
|
||||||
.nodedc-resource-row--progress > .nodedc-resource-row__copy { flex: 0 1 35%; }
|
|
||||||
.nodedc-resource-row--progress > .nodedc-progress-bar { flex: 1; }
|
|
||||||
@media (max-width: 40rem) {
|
|
||||||
.nodedc-resource-row--progress > .nodedc-resource-row__copy { flex-basis: calc(100% - 3rem); }
|
|
||||||
}
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.nodedc-progress-bar__fill { transition: none; }
|
|
||||||
.nodedc-progress-bar[data-indeterminate] > .nodedc-progress-bar__fill { animation: none; width: 100%; transform: scaleX(0.5); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.nodedc-status[data-variant="indicator"] { min-height: 0; width: .42rem; height: .42rem; padding: 0; gap: 0; background: transparent; }
|
|
||||||
.nodedc-status[data-variant="indicator"]::before { content: ""; display: block; width: .42rem; height: .42rem; flex: 0 0 .42rem; border-radius: var(--nodedc-radius-circle); background: var(--nodedc-text-muted); }
|
|
||||||
.nodedc-status[data-variant="indicator"][data-tone="success"]::before { background: rgb(var(--nodedc-success-rgb)); }
|
|
||||||
.nodedc-status[data-variant="indicator"][data-tone="warning"]::before { background: rgb(var(--nodedc-warning-rgb)); }
|
|
||||||
.nodedc-status[data-variant="indicator"][data-tone="danger"]::before { background: rgb(var(--nodedc-danger-rgb)); }
|
|
||||||
.nodedc-status[data-variant="indicator"][data-tone="accent"]::before { background: rgb(var(--nodedc-accent-rgb)); }
|
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import type { HTMLAttributes } from "react";
|
|
||||||
import { cn } from "./cn.js";
|
|
||||||
|
|
||||||
export type ActivityIndicatorSize = "default" | "compact";
|
|
||||||
|
|
||||||
export interface ActivityIndicatorProps extends Omit<
|
|
||||||
HTMLAttributes<HTMLSpanElement>,
|
|
||||||
"aria-hidden" | "aria-label" | "children" | "role"
|
|
||||||
> {
|
|
||||||
size?: ActivityIndicatorSize;
|
|
||||||
label?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ActivityIndicator({
|
|
||||||
size = "default",
|
|
||||||
label,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: ActivityIndicatorProps) {
|
|
||||||
const accessibleLabel = label?.trim() || undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
{...props}
|
|
||||||
className={cn("nodedc-activity-indicator", className)}
|
|
||||||
data-size={size === "default" ? undefined : size}
|
|
||||||
role={accessibleLabel ? "status" : undefined}
|
|
||||||
aria-label={accessibleLabel}
|
|
||||||
aria-hidden={accessibleLabel ? undefined : "true"}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -4,8 +4,6 @@ export interface AppHeaderProps {
|
|||||||
brand: ReactNode;
|
brand: ReactNode;
|
||||||
brandHref?: string;
|
brandHref?: string;
|
||||||
brandLabel?: string;
|
brandLabel?: string;
|
||||||
/** Opt in only for a light, single-color mark, never a full-color image. */
|
|
||||||
brandMonochrome?: boolean;
|
|
||||||
left?: ReactNode;
|
left?: ReactNode;
|
||||||
center?: ReactNode;
|
center?: ReactNode;
|
||||||
right?: ReactNode;
|
right?: ReactNode;
|
||||||
@@ -15,15 +13,14 @@ export function AppHeader({
|
|||||||
brand,
|
brand,
|
||||||
brandHref,
|
brandHref,
|
||||||
brandLabel = "NODE.DC",
|
brandLabel = "NODE.DC",
|
||||||
brandMonochrome = false,
|
|
||||||
left,
|
left,
|
||||||
center,
|
center,
|
||||||
right,
|
right,
|
||||||
}: AppHeaderProps) {
|
}: AppHeaderProps) {
|
||||||
const brandNode = brandHref ? (
|
const brandNode = brandHref ? (
|
||||||
<a className="nodedc-header__brand" data-monochrome={brandMonochrome || undefined} href={brandHref} aria-label={brandLabel}>{brand}</a>
|
<a className="nodedc-header__brand" href={brandHref} aria-label={brandLabel}>{brand}</a>
|
||||||
) : (
|
) : (
|
||||||
<span className="nodedc-header__brand" data-monochrome={brandMonochrome || undefined} aria-label={brandLabel}>{brand}</span>
|
<span className="nodedc-header__brand" aria-label={brandLabel}>{brand}</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -103,12 +100,11 @@ export interface HeaderWorkspaceProps {
|
|||||||
label: string;
|
label: string;
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
kind?: "mark" | "avatar";
|
kind?: "mark" | "avatar";
|
||||||
monochrome?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HeaderWorkspace({ label, imageUrl, kind = "mark", monochrome = false }: HeaderWorkspaceProps) {
|
export function HeaderWorkspace({ label, imageUrl, kind = "mark" }: HeaderWorkspaceProps) {
|
||||||
return (
|
return (
|
||||||
<span className="nodedc-header__workspace" data-kind={kind} data-monochrome={monochrome || undefined} title={label}>
|
<span className="nodedc-header__workspace" data-kind={kind} title={label}>
|
||||||
{imageUrl ? <img src={imageUrl} alt="" /> : label.slice(0, 2).toUpperCase()}
|
{imageUrl ? <img src={imageUrl} alt="" /> : label.slice(0, 2).toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { createAccentVariables, type RgbTuple } from "@nodedc/ui-core";
|
|||||||
import { cn } from "./cn.js";
|
import { cn } from "./cn.js";
|
||||||
|
|
||||||
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "accent";
|
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "accent";
|
||||||
export type ButtonSize = "default" | "compact" | "dense";
|
export type ButtonSize = "default" | "compact";
|
||||||
export type ButtonShape = "default" | "pill" | "rounded";
|
export type ButtonShape = "default" | "pill" | "rounded";
|
||||||
|
|
||||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
|||||||
@@ -107,7 +107,32 @@ export function Dropdown({
|
|||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
updatePosition();
|
updatePosition();
|
||||||
}, [isOpen, updatePosition]);
|
|
||||||
|
const surface = surfaceRef.current;
|
||||||
|
const anchor = anchorElement ?? triggerElement;
|
||||||
|
if (!surface || typeof ResizeObserver === "undefined") {
|
||||||
|
const frame = window.requestAnimationFrame(updatePosition);
|
||||||
|
return () => window.cancelAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
let frame: number | null = null;
|
||||||
|
const schedulePositionUpdate = () => {
|
||||||
|
if (frame !== null) return;
|
||||||
|
frame = window.requestAnimationFrame(() => {
|
||||||
|
frame = null;
|
||||||
|
updatePosition();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const resizeObserver = new ResizeObserver(schedulePositionUpdate);
|
||||||
|
resizeObserver.observe(surface);
|
||||||
|
if (anchor) resizeObserver.observe(anchor);
|
||||||
|
schedulePositionUpdate();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
if (frame !== null) window.cancelAnimationFrame(frame);
|
||||||
|
};
|
||||||
|
}, [anchorElement, isOpen, triggerElement, updatePosition]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { HTMLAttributes, ReactNode } from "react";
|
|||||||
import { cn } from "./cn.js";
|
import { cn } from "./cn.js";
|
||||||
|
|
||||||
export type GlassTone = "default" | "strong" | "soft";
|
export type GlassTone = "default" | "strong" | "soft";
|
||||||
export type GlassRadius = "card" | "panel" | "modal" | "pill";
|
export type GlassRadius = "card" | "panel" | "modal";
|
||||||
export type GlassPadding = "none" | "sm" | "md" | "lg";
|
export type GlassPadding = "none" | "sm" | "md" | "lg";
|
||||||
|
|
||||||
export interface GlassSurfaceProps extends HTMLAttributes<HTMLDivElement> {
|
export interface GlassSurfaceProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Boxes,
|
Boxes,
|
||||||
Building2,
|
Building2,
|
||||||
Camera,
|
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@@ -13,8 +12,6 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
Database,
|
Database,
|
||||||
Download,
|
Download,
|
||||||
Eye,
|
|
||||||
EyeOff,
|
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
FileText,
|
FileText,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
@@ -27,13 +24,11 @@ import {
|
|||||||
LocateFixed,
|
LocateFixed,
|
||||||
LockKeyhole,
|
LockKeyhole,
|
||||||
MailPlus,
|
MailPlus,
|
||||||
Map,
|
|
||||||
Maximize2,
|
Maximize2,
|
||||||
Minimize2,
|
Minimize2,
|
||||||
Network,
|
Network,
|
||||||
PanelTop,
|
PanelTop,
|
||||||
Pencil,
|
Pencil,
|
||||||
Play,
|
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Save,
|
Save,
|
||||||
@@ -41,7 +36,6 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
Square,
|
|
||||||
Trash2,
|
Trash2,
|
||||||
UploadCloud,
|
UploadCloud,
|
||||||
UserCircle,
|
UserCircle,
|
||||||
@@ -57,7 +51,6 @@ const icons = {
|
|||||||
alert: AlertTriangle,
|
alert: AlertTriangle,
|
||||||
apps: Boxes,
|
apps: Boxes,
|
||||||
building: Building2,
|
building: Building2,
|
||||||
camera: Camera,
|
|
||||||
check: Check,
|
check: Check,
|
||||||
"chevron-down": ChevronDown,
|
"chevron-down": ChevronDown,
|
||||||
"chevron-left": ChevronLeft,
|
"chevron-left": ChevronLeft,
|
||||||
@@ -69,8 +62,6 @@ const icons = {
|
|||||||
database: Database,
|
database: Database,
|
||||||
download: Download,
|
download: Download,
|
||||||
edit: Pencil,
|
edit: Pencil,
|
||||||
eye: Eye,
|
|
||||||
"eye-off": EyeOff,
|
|
||||||
expand: Maximize2,
|
expand: Maximize2,
|
||||||
external: ExternalLink,
|
external: ExternalLink,
|
||||||
file: FileText,
|
file: FileText,
|
||||||
@@ -87,8 +78,6 @@ const icons = {
|
|||||||
minimize: Minimize2,
|
minimize: Minimize2,
|
||||||
network: Network,
|
network: Network,
|
||||||
panel: PanelTop,
|
panel: PanelTop,
|
||||||
plan: Map,
|
|
||||||
play: Play,
|
|
||||||
plus: Plus,
|
plus: Plus,
|
||||||
profile: UserCircle,
|
profile: UserCircle,
|
||||||
refresh: RefreshCw,
|
refresh: RefreshCw,
|
||||||
@@ -97,7 +86,6 @@ const icons = {
|
|||||||
settings: Settings,
|
settings: Settings,
|
||||||
shield: ShieldCheck,
|
shield: ShieldCheck,
|
||||||
sliders: SlidersHorizontal,
|
sliders: SlidersHorizontal,
|
||||||
stop: Square,
|
|
||||||
trash: Trash2,
|
trash: Trash2,
|
||||||
upload: UploadCloud,
|
upload: UploadCloud,
|
||||||
users: UsersRound,
|
users: UsersRound,
|
||||||
@@ -111,15 +99,13 @@ export interface IconProps extends Omit<LucideProps, "ref"> {
|
|||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Icon({ name, label, size = 16, strokeWidth = 1.6, fill, ...props }: IconProps) {
|
export function Icon({ name, label, size = 16, strokeWidth = 1.6, ...props }: IconProps) {
|
||||||
const IconComponent = icons[name];
|
const IconComponent = icons[name];
|
||||||
const filledTransport = name === "play" || name === "stop";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IconComponent
|
<IconComponent
|
||||||
size={size}
|
size={size}
|
||||||
strokeWidth={filledTransport ? 0 : strokeWidth}
|
strokeWidth={strokeWidth}
|
||||||
fill={fill ?? (filledTransport ? "currentColor" : "none")}
|
|
||||||
aria-hidden={label ? undefined : "true"}
|
aria-hidden={label ? undefined : "true"}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
role={label ? "img" : undefined}
|
role={label ? "img" : undefined}
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
import type { CSSProperties, HTMLAttributes } from "react";
|
|
||||||
import { cn } from "./cn.js";
|
|
||||||
|
|
||||||
export interface ProgressBarProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
|
|
||||||
label: string;
|
|
||||||
/** Completed fraction from 0 to 1. Omit when the amount is unknown. */
|
|
||||||
value?: number;
|
|
||||||
valueText?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProgressBar({ label, value, valueText, className, style, ...props }: ProgressBarProps) {
|
|
||||||
const fraction = typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : undefined;
|
|
||||||
return <div {...props} className={cn("nodedc-progress-bar", className)} role="progressbar"
|
|
||||||
aria-label={label} aria-valuemin={0} aria-valuemax={100}
|
|
||||||
aria-valuenow={fraction === undefined ? undefined : Math.round(fraction * 100)}
|
|
||||||
aria-valuetext={valueText} data-indeterminate={fraction === undefined || undefined}
|
|
||||||
style={{ ...style, "--nodedc-progress-fraction": fraction ?? 0 } as CSSProperties}>
|
|
||||||
<span className="nodedc-progress-bar__fill" />
|
|
||||||
</div>;
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type InputHTMLAttributes, type KeyboardEvent, type PointerEvent } from "react";
|
import { useEffect, useRef, useState, type CSSProperties, type InputHTMLAttributes, type KeyboardEvent, type PointerEvent } from "react";
|
||||||
import { cn } from "./cn.js";
|
import { cn } from "./cn.js";
|
||||||
import { Dropdown } from "./Dropdown.js";
|
import { Dropdown } from "./Dropdown.js";
|
||||||
|
|
||||||
@@ -7,10 +7,6 @@ export interface RangeControlProps extends Omit<InputHTMLAttributes<HTMLInputEle
|
|||||||
value: number;
|
value: number;
|
||||||
min: number;
|
min: number;
|
||||||
max: number;
|
max: number;
|
||||||
exactValueBounds?: {
|
|
||||||
min?: number;
|
|
||||||
max?: number;
|
|
||||||
};
|
|
||||||
formatValue?: (value: number) => string;
|
formatValue?: (value: number) => string;
|
||||||
onChange: (value: number) => void;
|
onChange: (value: number) => void;
|
||||||
}
|
}
|
||||||
@@ -33,17 +29,14 @@ const normalizeEditedRangeValue = (
|
|||||||
min: number,
|
min: number,
|
||||||
max: number,
|
max: number,
|
||||||
step: RangeControlProps["step"],
|
step: RangeControlProps["step"],
|
||||||
exactValueBounds: RangeControlProps["exactValueBounds"],
|
|
||||||
) => {
|
) => {
|
||||||
const exactMin = exactValueBounds?.min ?? Number.NEGATIVE_INFINITY;
|
const clamped = clampRangeValue(value, min, max);
|
||||||
const exactMax = exactValueBounds?.max ?? Number.POSITIVE_INFINITY;
|
|
||||||
const clamped = clampRangeValue(value, exactMin, exactMax);
|
|
||||||
if (step === "any") return clamped;
|
if (step === "any") return clamped;
|
||||||
const numericStep = step === undefined ? 1 : Number(step);
|
const numericStep = step === undefined ? 1 : Number(step);
|
||||||
if (!Number.isFinite(numericStep) || numericStep <= 0) return clamped;
|
if (!Number.isFinite(numericStep) || numericStep <= 0) return clamped;
|
||||||
const precision = Math.min(12, Math.max(decimalPlaces(min), decimalPlaces(numericStep)));
|
const precision = Math.min(12, Math.max(decimalPlaces(min), decimalPlaces(numericStep)));
|
||||||
const aligned = min + Math.round((clamped - min) / numericStep) * numericStep;
|
const aligned = min + Math.round((clamped - min) / numericStep) * numericStep;
|
||||||
return clampRangeValue(Number(aligned.toFixed(precision)), exactMin, exactMax);
|
return clampRangeValue(Number(aligned.toFixed(precision)), min, max);
|
||||||
};
|
};
|
||||||
|
|
||||||
const editableNumber = (value: number) => (
|
const editableNumber = (value: number) => (
|
||||||
@@ -55,7 +48,6 @@ export function RangeControl({
|
|||||||
value,
|
value,
|
||||||
min,
|
min,
|
||||||
max,
|
max,
|
||||||
exactValueBounds,
|
|
||||||
step,
|
step,
|
||||||
formatValue = String,
|
formatValue = String,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -64,13 +56,11 @@ export function RangeControl({
|
|||||||
tabIndex,
|
tabIndex,
|
||||||
...props
|
...props
|
||||||
}: RangeControlProps) {
|
}: RangeControlProps) {
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
|
||||||
const rangeRef = useRef<HTMLInputElement>(null);
|
const rangeRef = useRef<HTMLInputElement>(null);
|
||||||
const editorRef = useRef<HTMLInputElement>(null);
|
const editorRef = useRef<HTMLInputElement>(null);
|
||||||
const cancelNextBlurRef = useRef(false);
|
const cancelNextBlurRef = useRef(false);
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState(() => editableNumber(value));
|
const [draft, setDraft] = useState(() => editableNumber(value));
|
||||||
const [editorContrast, setEditorContrast] = useState<"base" | "fill">("base");
|
|
||||||
const safeMax = max === min ? min + 1 : max;
|
const safeMax = max === min ? min + 1 : max;
|
||||||
const progress = Math.max(0, Math.min(100, ((value - min) / (safeMax - min)) * 100));
|
const progress = Math.max(0, Math.min(100, ((value - min) / (safeMax - min)) * 100));
|
||||||
const displayValue = formatValue(value);
|
const displayValue = formatValue(value);
|
||||||
@@ -87,30 +77,10 @@ export function RangeControl({
|
|||||||
if (!editing) setDraft(editableNumber(value));
|
if (!editing) setDraft(editableNumber(value));
|
||||||
}, [editing, value]);
|
}, [editing, value]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const root = rootRef.current;
|
|
||||||
const editor = editorRef.current;
|
|
||||||
if (!root || !editor) return undefined;
|
|
||||||
const updateContrast = () => {
|
|
||||||
const rootBounds = root.getBoundingClientRect();
|
|
||||||
const editorBounds = editor.getBoundingClientRect();
|
|
||||||
const paddingRight = Number.parseFloat(getComputedStyle(editor).paddingRight) || 0;
|
|
||||||
const fillRight = rootBounds.left + rootBounds.width * progress / 100;
|
|
||||||
const textRight = editorBounds.right - paddingRight;
|
|
||||||
setEditorContrast(fillRight >= textRight ? "fill" : "base");
|
|
||||||
};
|
|
||||||
updateContrast();
|
|
||||||
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(updateContrast);
|
|
||||||
observer?.observe(root);
|
|
||||||
return () => observer?.disconnect();
|
|
||||||
}, [editorCharacters, progress]);
|
|
||||||
|
|
||||||
const finishEditing = (commit: boolean, restoreRangeFocus: boolean) => {
|
const finishEditing = (commit: boolean, restoreRangeFocus: boolean) => {
|
||||||
if (commit) {
|
if (commit) {
|
||||||
const parsed = Number(draft.trim().replace(",", "."));
|
const parsed = Number(draft.trim().replace(",", "."));
|
||||||
if (Number.isFinite(parsed)) {
|
if (Number.isFinite(parsed)) onChange(normalizeEditedRangeValue(parsed, min, max, step));
|
||||||
onChange(normalizeEditedRangeValue(parsed, min, max, step, exactValueBounds));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
if (restoreRangeFocus) requestAnimationFrame(() => rangeRef.current?.focus());
|
if (restoreRangeFocus) requestAnimationFrame(() => rangeRef.current?.focus());
|
||||||
@@ -133,7 +103,7 @@ export function RangeControl({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={rootRef} className={cn("nodedc-range", className)} style={style} data-editing={editing || undefined}>
|
<div className={cn("nodedc-range", className)} style={style} data-editing={editing || undefined}>
|
||||||
<input
|
<input
|
||||||
ref={rangeRef}
|
ref={rangeRef}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -159,14 +129,22 @@ export function RangeControl({
|
|||||||
className="nodedc-range__editor"
|
className="nodedc-range__editor"
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
value={draft}
|
value={editing ? draft : editableNumber(value)}
|
||||||
aria-label={`${label}: точное значение`}
|
aria-label={`${label}: точное значение`}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
data-active={editing || undefined}
|
data-active={editing || undefined}
|
||||||
data-contrast={editorContrast}
|
onPointerDown={() => {
|
||||||
|
if (!editing) {
|
||||||
|
setDraft(editableNumber(value));
|
||||||
|
setEditing(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
if (!editing) setEditing(true);
|
if (!editing) {
|
||||||
|
setDraft(editableNumber(value));
|
||||||
|
setEditing(true);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onChange={(event) => setDraft(event.target.value)}
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
onKeyDown={handleEditorKeyDown}
|
onKeyDown={handleEditorKeyDown}
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import type { HTMLAttributes, ReactNode } from "react";
|
|
||||||
import { ProgressBar, type ProgressBarProps } from "./ProgressBar.js";
|
|
||||||
import { cn } from "./cn.js";
|
|
||||||
|
|
||||||
export interface ResourceRowProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
|
|
||||||
title: ReactNode;
|
|
||||||
icon?: ReactNode;
|
|
||||||
description?: ReactNode;
|
|
||||||
metadata?: ReactNode;
|
|
||||||
status?: ReactNode;
|
|
||||||
statusPlacement?: "leading" | "trailing";
|
|
||||||
actions?: ReactNode;
|
|
||||||
progress?: Pick<ProgressBarProps, "label" | "value" | "valueText">;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Compact resource presentation extracted from Mission Core AI Inference.
|
|
||||||
* Actions stay canonical controls; the row itself is not a nested button. */
|
|
||||||
export function ResourceRow({ title, icon, description, metadata, status, statusPlacement = "trailing", actions, progress, className, ...props }: ResourceRowProps) {
|
|
||||||
const leadingStatus = !progress && !!status && statusPlacement === "leading";
|
|
||||||
return <div className={cn("nodedc-resource-row", progress && "nodedc-resource-row--progress", leadingStatus && "nodedc-resource-row--leading-status", className)} {...props}>
|
|
||||||
{icon ? <span className="nodedc-resource-row__icon" aria-hidden="true">{icon}</span> : null}
|
|
||||||
{leadingStatus ? <div className="nodedc-resource-row__status">{status}</div> : null}
|
|
||||||
<div className="nodedc-resource-row__copy">
|
|
||||||
<strong>{title}</strong>
|
|
||||||
{description ? <span>{description}</span> : null}
|
|
||||||
{metadata ? <small>{metadata}</small> : null}
|
|
||||||
</div>
|
|
||||||
{progress ? <ProgressBar {...progress} /> : null}
|
|
||||||
{!progress && status && statusPlacement === "trailing" ? <div className="nodedc-resource-row__status">{status}</div> : null}
|
|
||||||
{actions ? <div className="nodedc-resource-row__actions">{actions}</div> : null}
|
|
||||||
</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ResourceList({ className, children, ...props }: HTMLAttributes<HTMLUListElement>) {
|
|
||||||
return <ul className={cn("nodedc-resource-list", className)} {...props}>{children}</ul>;
|
|
||||||
}
|
|
||||||
@@ -8,13 +8,10 @@ export interface SegmentedItem<T extends string> {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SegmentedControlSize = "default" | "dense";
|
|
||||||
|
|
||||||
export interface SegmentedControlProps<T extends string> {
|
export interface SegmentedControlProps<T extends string> {
|
||||||
value: T;
|
value: T;
|
||||||
items: Array<SegmentedItem<T>>;
|
items: Array<SegmentedItem<T>>;
|
||||||
label: string;
|
label: string;
|
||||||
size?: SegmentedControlSize;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
onChange: (value: T) => void;
|
onChange: (value: T) => void;
|
||||||
}
|
}
|
||||||
@@ -23,17 +20,11 @@ export function SegmentedControl<T extends string>({
|
|||||||
value,
|
value,
|
||||||
items,
|
items,
|
||||||
label,
|
label,
|
||||||
size = "default",
|
|
||||||
className,
|
className,
|
||||||
onChange,
|
onChange,
|
||||||
}: SegmentedControlProps<T>) {
|
}: SegmentedControlProps<T>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={cn("nodedc-segmented", className)} role="tablist" aria-label={label}>
|
||||||
className={cn("nodedc-segmented", className)}
|
|
||||||
role="tablist"
|
|
||||||
aria-label={label}
|
|
||||||
data-size={size === "default" ? undefined : size}
|
|
||||||
>
|
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.value}
|
key={item.value}
|
||||||
@@ -52,3 +43,4 @@ export function SegmentedControl<T extends string>({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export interface SelectOption<T extends string> {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SelectVariant = "integrated" | "split" | "inline";
|
export type SelectVariant = "integrated" | "split";
|
||||||
|
|
||||||
export interface SelectProps<T extends string> {
|
export interface SelectProps<T extends string> {
|
||||||
value: T;
|
value: T;
|
||||||
@@ -102,26 +102,6 @@ export function Select<T extends string>({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resolvedVariant === "inline") {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
ref={setTriggerRef}
|
|
||||||
type="button"
|
|
||||||
className={cn("nodedc-select-inline", triggerClassName)}
|
|
||||||
aria-label={label}
|
|
||||||
aria-haspopup="listbox"
|
|
||||||
aria-controls={surfaceId}
|
|
||||||
aria-expanded={open}
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={toggle}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
>
|
|
||||||
{selected?.icon ? <span className="nodedc-select-trigger__icon">{selected.icon}</span> : null}
|
|
||||||
<span>{selected?.label ?? "—"}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
ref={setTriggerRef}
|
ref={setTriggerRef}
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
import {
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
type CSSProperties,
|
|
||||||
type HTMLAttributes,
|
|
||||||
type KeyboardEvent,
|
|
||||||
type PointerEvent,
|
|
||||||
type ReactNode,
|
|
||||||
} from "react";
|
|
||||||
|
|
||||||
import { cn } from "./cn.js";
|
|
||||||
|
|
||||||
export type SplitPaneOrientation = "vertical" | "horizontal";
|
|
||||||
|
|
||||||
export interface SplitPaneProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
|
|
||||||
primary: ReactNode;
|
|
||||||
secondary: ReactNode;
|
|
||||||
primarySize: number;
|
|
||||||
onPrimarySizeChange: (primarySize: number) => void;
|
|
||||||
orientation?: SplitPaneOrientation;
|
|
||||||
minPrimarySize?: number;
|
|
||||||
minSecondarySize?: number;
|
|
||||||
step?: number;
|
|
||||||
resizable?: boolean;
|
|
||||||
separatorLabel: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const clamp = (value: number, minimum: number, maximum: number) => (
|
|
||||||
Math.min(Math.max(value, minimum), maximum)
|
|
||||||
);
|
|
||||||
|
|
||||||
const normalizedLimits = (minPrimarySize: number, minSecondarySize: number) => {
|
|
||||||
const primary = clamp(minPrimarySize, 0, 100);
|
|
||||||
const secondary = clamp(minSecondarySize, 0, 100);
|
|
||||||
if (primary + secondary <= 100) return { minimum: primary, maximum: 100 - secondary };
|
|
||||||
const scale = 100 / (primary + secondary);
|
|
||||||
return { minimum: primary * scale, maximum: 100 - secondary * scale };
|
|
||||||
};
|
|
||||||
|
|
||||||
export function SplitPane({
|
|
||||||
primary,
|
|
||||||
secondary,
|
|
||||||
primarySize,
|
|
||||||
onPrimarySizeChange,
|
|
||||||
orientation = "vertical",
|
|
||||||
minPrimarySize = 20,
|
|
||||||
minSecondarySize = 20,
|
|
||||||
step = 2,
|
|
||||||
resizable = true,
|
|
||||||
separatorLabel,
|
|
||||||
className,
|
|
||||||
style,
|
|
||||||
...props
|
|
||||||
}: SplitPaneProps) {
|
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
|
||||||
const activePointerIdRef = useRef<number | null>(null);
|
|
||||||
const [dragging, setDragging] = useState(false);
|
|
||||||
const limits = normalizedLimits(minPrimarySize, minSecondarySize);
|
|
||||||
const size = clamp(Number.isFinite(primarySize) ? primarySize : 50, limits.minimum, limits.maximum);
|
|
||||||
const keyboardIncrement = Number.isFinite(step) && step > 0 ? step : 2;
|
|
||||||
|
|
||||||
const emitPointerSize = (event: PointerEvent<HTMLElement>) => {
|
|
||||||
const root = rootRef.current;
|
|
||||||
if (!root) return;
|
|
||||||
const rect = root.getBoundingClientRect();
|
|
||||||
const available = orientation === "vertical" ? rect.width : rect.height;
|
|
||||||
if (available <= 0) return;
|
|
||||||
const offset = orientation === "vertical"
|
|
||||||
? event.clientX - rect.left
|
|
||||||
: event.clientY - rect.top;
|
|
||||||
onPrimarySizeChange(clamp(offset / available * 100, limits.minimum, limits.maximum));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent<HTMLElement>) => {
|
|
||||||
const multiplier = event.shiftKey ? 5 : 1;
|
|
||||||
const decrement = orientation === "vertical" ? "ArrowLeft" : "ArrowUp";
|
|
||||||
const increment = orientation === "vertical" ? "ArrowRight" : "ArrowDown";
|
|
||||||
let next: number | null = null;
|
|
||||||
if (event.key === decrement) next = size - keyboardIncrement * multiplier;
|
|
||||||
if (event.key === increment) next = size + keyboardIncrement * multiplier;
|
|
||||||
if (event.key === "Home") next = limits.minimum;
|
|
||||||
if (event.key === "End") next = limits.maximum;
|
|
||||||
if (next === null) return;
|
|
||||||
event.preventDefault();
|
|
||||||
onPrimarySizeChange(clamp(next, limits.minimum, limits.maximum));
|
|
||||||
};
|
|
||||||
|
|
||||||
const splitStyle = {
|
|
||||||
...style,
|
|
||||||
"--nodedc-split-pane-primary": `${size}%`,
|
|
||||||
} as CSSProperties;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={rootRef}
|
|
||||||
className={cn("nodedc-split-pane", className)}
|
|
||||||
data-orientation={orientation}
|
|
||||||
data-dragging={dragging ? "true" : undefined}
|
|
||||||
style={splitStyle}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<div className="nodedc-split-pane__panel" data-pane="primary">
|
|
||||||
{primary}
|
|
||||||
</div>
|
|
||||||
<div className="nodedc-split-pane__panel" data-pane="secondary">
|
|
||||||
{secondary}
|
|
||||||
</div>
|
|
||||||
{resizable ? (
|
|
||||||
<div
|
|
||||||
className="nodedc-split-pane__separator"
|
|
||||||
role="separator"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label={separatorLabel}
|
|
||||||
aria-orientation={orientation}
|
|
||||||
aria-valuemin={Math.round(limits.minimum)}
|
|
||||||
aria-valuemax={Math.round(limits.maximum)}
|
|
||||||
aria-valuenow={Math.round(size)}
|
|
||||||
aria-valuetext={`${Math.round(size)}% / ${Math.round(100 - size)}%`}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onPointerDown={(event) => {
|
|
||||||
if (event.button !== 0) return;
|
|
||||||
event.currentTarget.focus();
|
|
||||||
activePointerIdRef.current = event.pointerId;
|
|
||||||
event.currentTarget.setPointerCapture(event.pointerId);
|
|
||||||
setDragging(true);
|
|
||||||
emitPointerSize(event);
|
|
||||||
event.preventDefault();
|
|
||||||
}}
|
|
||||||
onPointerMove={(event) => {
|
|
||||||
if (activePointerIdRef.current !== event.pointerId) return;
|
|
||||||
emitPointerSize(event);
|
|
||||||
event.preventDefault();
|
|
||||||
}}
|
|
||||||
onPointerUp={(event) => {
|
|
||||||
if (activePointerIdRef.current !== event.pointerId) return;
|
|
||||||
activePointerIdRef.current = null;
|
|
||||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
|
||||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
||||||
}
|
|
||||||
setDragging(false);
|
|
||||||
}}
|
|
||||||
onPointerCancel={(event) => {
|
|
||||||
if (activePointerIdRef.current !== event.pointerId) return;
|
|
||||||
activePointerIdRef.current = null;
|
|
||||||
setDragging(false);
|
|
||||||
}}
|
|
||||||
onLostPointerCapture={(event) => {
|
|
||||||
if (activePointerIdRef.current !== event.pointerId) return;
|
|
||||||
activePointerIdRef.current = null;
|
|
||||||
setDragging(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5,13 +5,12 @@ export type StatusTone = "neutral" | "success" | "warning" | "danger" | "accent"
|
|||||||
|
|
||||||
export interface StatusBadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
export interface StatusBadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||||
tone?: StatusTone;
|
tone?: StatusTone;
|
||||||
variant?: "badge" | "indicator";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatusBadge({ tone = "neutral", variant = "badge", className, children, ...props }: StatusBadgeProps) {
|
export function StatusBadge({ tone = "neutral", className, children, ...props }: StatusBadgeProps) {
|
||||||
return (
|
return (
|
||||||
<span className={cn("nodedc-status", className)} data-tone={tone === "neutral" ? undefined : tone} data-variant={variant === "indicator" ? variant : undefined} {...props}>
|
<span className={cn("nodedc-status", className)} data-tone={tone === "neutral" ? undefined : tone} {...props}>
|
||||||
{variant === "indicator" ? null : children}
|
{children}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, type HTMLAttributes } from "react";
|
import { useEffect, type HTMLAttributes } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { cn } from "./cn.js";
|
import { cn } from "./cn.js";
|
||||||
import { Icon, type IconName } from "./Icon.js";
|
import { Icon, type IconName } from "./Icon.js";
|
||||||
@@ -21,8 +21,6 @@ const toastIcons: Record<ToastTone, IconName> = {
|
|||||||
loading: "refresh",
|
loading: "refresh",
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_TOAST_DURATION_MS = 10_000;
|
|
||||||
|
|
||||||
export interface ToastCardProps extends HTMLAttributes<HTMLDivElement> {
|
export interface ToastCardProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
item: ToastItem;
|
item: ToastItem;
|
||||||
onDismiss?: (id: string) => void;
|
onDismiss?: (id: string) => void;
|
||||||
@@ -50,30 +48,21 @@ export function ToastCard({ item, onDismiss, className, ...props }: ToastCardPro
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TimedToastCard({ item, onDismiss }: { item: ToastItem; onDismiss: (id: string) => void }) {
|
|
||||||
const dismissRef = useRef(onDismiss);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
dismissRef.current = onDismiss;
|
|
||||||
}, [onDismiss]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const duration = item.durationMs === undefined
|
|
||||||
? item.tone === "loading" ? null : DEFAULT_TOAST_DURATION_MS
|
|
||||||
: item.durationMs;
|
|
||||||
if (typeof duration !== "number" || duration <= 0) return undefined;
|
|
||||||
const timer = window.setTimeout(() => dismissRef.current(item.id), duration);
|
|
||||||
return () => window.clearTimeout(timer);
|
|
||||||
}, [item.durationMs, item.id, item.tone]);
|
|
||||||
|
|
||||||
return <ToastCard item={item} onDismiss={onDismiss} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ToastStack({ items, onDismiss }: { items: ToastItem[]; onDismiss: (id: string) => void }) {
|
export function ToastStack({ items, onDismiss }: { items: ToastItem[]; onDismiss: (id: string) => void }) {
|
||||||
|
useEffect(() => {
|
||||||
|
const timers = items.flatMap((item) => {
|
||||||
|
const duration = item.durationMs === undefined ? 4200 : item.durationMs;
|
||||||
|
return typeof duration === "number" && duration > 0
|
||||||
|
? [window.setTimeout(() => onDismiss(item.id), duration)]
|
||||||
|
: [];
|
||||||
|
});
|
||||||
|
return () => timers.forEach((timer) => window.clearTimeout(timer));
|
||||||
|
}, [items, onDismiss]);
|
||||||
|
|
||||||
if (typeof document === "undefined" || items.length === 0) return null;
|
if (typeof document === "undefined" || items.length === 0) return null;
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="nodedc-toast-viewport nodedc-ui-root" aria-live="polite" aria-relevant="additions removals">
|
<div className="nodedc-toast-viewport nodedc-ui-root" aria-live="polite" aria-relevant="additions removals">
|
||||||
{items.map((item) => <TimedToastCard key={item.id} item={item} onDismiss={onDismiss} />)}
|
{items.map((item) => <ToastCard key={item.id} item={item} onDismiss={onDismiss} />)}
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
export * from "./AppHeader.js";
|
export * from "./AppHeader.js";
|
||||||
export * from "./ActivityIndicator.js";
|
|
||||||
export * from "./AdminNavigationPanel.js";
|
export * from "./AdminNavigationPanel.js";
|
||||||
export * from "./ApplicationShell.js";
|
export * from "./ApplicationShell.js";
|
||||||
export * from "./ApplicationSidePanel.js";
|
export * from "./ApplicationSidePanel.js";
|
||||||
@@ -16,17 +15,13 @@ export * from "./Inspector.js";
|
|||||||
export * from "./Icon.js";
|
export * from "./Icon.js";
|
||||||
export * from "./MediaSourceField.js";
|
export * from "./MediaSourceField.js";
|
||||||
export * from "./RangeControl.js";
|
export * from "./RangeControl.js";
|
||||||
export * from "./ResourceRow.js";
|
|
||||||
export * from "./SegmentedControl.js";
|
export * from "./SegmentedControl.js";
|
||||||
export * from "./Select.js";
|
export * from "./Select.js";
|
||||||
export * from "./StatusBadge.js";
|
export * from "./StatusBadge.js";
|
||||||
export * from "./Settings.js";
|
export * from "./Settings.js";
|
||||||
export * from "./SharingModals.js";
|
export * from "./SharingModals.js";
|
||||||
export * from "./SplitPane.js";
|
|
||||||
export * from "./Toolbar.js";
|
export * from "./Toolbar.js";
|
||||||
export * from "./Toast.js";
|
export * from "./Toast.js";
|
||||||
export * from "./UserProfileMenu.js";
|
export * from "./UserProfileMenu.js";
|
||||||
export * from "./Window.js";
|
export * from "./Window.js";
|
||||||
export * from "./WorkspaceWindow.js";
|
export * from "./WorkspaceWindow.js";
|
||||||
|
|
||||||
export { ProgressBar, type ProgressBarProps } from "./ProgressBar.js";
|
|
||||||
|
|||||||
+12
-111
@@ -1,53 +1,6 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": "1.0.0",
|
"schemaVersion": "1.0.0",
|
||||||
"components": [
|
"components": [
|
||||||
{
|
|
||||||
"id": "progress-bar",
|
|
||||||
"status": "baseline",
|
|
||||||
"package": "@nodedc/ui-react",
|
|
||||||
"exports": [
|
|
||||||
"ProgressBar",
|
|
||||||
"ProgressBarProps"
|
|
||||||
],
|
|
||||||
"domContract": [
|
|
||||||
"nodedc-progress-bar"
|
|
||||||
],
|
|
||||||
"summary": "Accessible determinate or indeterminate linear progress, including the central ResourceRow slot.",
|
|
||||||
"variants": [
|
|
||||||
"determinate",
|
|
||||||
"indeterminate"
|
|
||||||
],
|
|
||||||
"rules": [
|
|
||||||
"Consumer supplies measured progress; elapsed time must not invent completion.",
|
|
||||||
"ResourceRow progress replaces status and occupies space between copy and actions.",
|
|
||||||
"Reduced motion preserves a visible non-animated indicator."
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "resource-row",
|
|
||||||
"status": "baseline",
|
|
||||||
"package": "@nodedc/ui-react",
|
|
||||||
"exports": [
|
|
||||||
"ResourceRow",
|
|
||||||
"ResourceList"
|
|
||||||
],
|
|
||||||
"domContract": [
|
|
||||||
"nodedc-resource-row",
|
|
||||||
"nodedc-resource-list"
|
|
||||||
],
|
|
||||||
"summary": "Mission Core AI Inference compact rows extracted for shared resource lists.",
|
|
||||||
"behavior": [
|
|
||||||
"wrapping title",
|
|
||||||
"optional metadata and status",
|
|
||||||
"optional leading status between icon and copy, vertically centered",
|
|
||||||
"canonical action controls",
|
|
||||||
"responsive action placement"
|
|
||||||
],
|
|
||||||
"rules": [
|
|
||||||
"The consumer supplies identity and states; the row owns presentation only.",
|
|
||||||
"Use ordinary list items and canonical buttons; do not nest actions inside a clickable row."
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "glass-surface",
|
"id": "glass-surface",
|
||||||
"status": "baseline",
|
"status": "baseline",
|
||||||
@@ -57,10 +10,8 @@
|
|||||||
"summary": "Matte application surfaces, the Engine V4 modal material and the light translucent surface used over Cesium imagery.",
|
"summary": "Matte application surfaces, the Engine V4 modal material and the light translucent surface used over Cesium imagery.",
|
||||||
"anatomy": ["theme-provided surface", "modal tint", "map-only light translucency", "gradient rim", "backdrop blur/saturation/brightness", "content"],
|
"anatomy": ["theme-provided surface", "modal tint", "map-only light translucency", "gradient rim", "backdrop blur/saturation/brightness", "content"],
|
||||||
"variants": ["default", "strong", "soft", "map"],
|
"variants": ["default", "strong", "soft", "map"],
|
||||||
"radiusVariants": ["card", "panel", "modal", "pill"],
|
|
||||||
"rules": [
|
"rules": [
|
||||||
"Geometry is invariant across application themes.",
|
"Geometry is invariant across application themes.",
|
||||||
"Compact rails of canonical round actions use the pill radius; consumers do not recreate it with a local border radius.",
|
|
||||||
"A material rim is a glass highlight, not a hard product-colored border.",
|
"A material rim is a glass highlight, not a hard product-colored border.",
|
||||||
"Nested surfaces must use a softer tone to avoid card-inside-card noise.",
|
"Nested surfaces must use a softer tone to avoid card-inside-card noise.",
|
||||||
"GlassyMaterialSurface is restricted to modal Windows and the draggable Inspector.",
|
"GlassyMaterialSurface is restricted to modal Windows and the draggable Inspector.",
|
||||||
@@ -75,31 +26,13 @@
|
|||||||
"domContract": ["nodedc-button", "nodedc-icon-button"],
|
"domContract": ["nodedc-button", "nodedc-icon-button"],
|
||||||
"summary": "Text and icon actions with shared sizing, states and computed accent contrast.",
|
"summary": "Text and icon actions with shared sizing, states and computed accent contrast.",
|
||||||
"variants": ["primary", "secondary", "ghost", "danger", "accent"],
|
"variants": ["primary", "secondary", "ghost", "danger", "accent"],
|
||||||
"sizes": ["default", "compact", "dense"],
|
|
||||||
"shapes": ["default", "pill", "rounded"],
|
"shapes": ["default", "pill", "rounded"],
|
||||||
"rules": [
|
"rules": [
|
||||||
"Icon-only actions are circular by default.",
|
"Icon-only actions are circular by default.",
|
||||||
"Controlled toggle actions expose aria-pressed and use the canonical active surface without changing geometry.",
|
|
||||||
"Filled accent actions derive foreground contrast from the actual accent.",
|
"Filled accent actions derive foreground contrast from the actual accent.",
|
||||||
"Destructive actions remain neutral until confirmation unless danger is the principal message."
|
"Destructive actions remain neutral until confirmation unless danger is the principal message."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "activity-indicator",
|
|
||||||
"status": "baseline",
|
|
||||||
"package": "@nodedc/ui-react",
|
|
||||||
"exports": ["ActivityIndicator", "ActivityIndicatorProps", "ActivityIndicatorSize"],
|
|
||||||
"domContract": ["nodedc-activity-indicator"],
|
|
||||||
"summary": "Theme-independent indeterminate progress indicator for inline operations and pending action controls.",
|
|
||||||
"variants": ["default", "compact"],
|
|
||||||
"behavior": ["decorative by default", "optional status semantics", "static reduced-motion presentation"],
|
|
||||||
"rules": [
|
|
||||||
"Use compact inside a Button icon slot and default for standalone inline progress.",
|
|
||||||
"The process owner exposes aria-busy and visible pending copy; provide label only when the indicator itself is the status announcement.",
|
|
||||||
"The component never owns operation state, timing or completion.",
|
|
||||||
"Reduced-motion preferences stop rotation without hiding the pending-state affordance."
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "field",
|
"id": "field",
|
||||||
"status": "baseline",
|
"status": "baseline",
|
||||||
@@ -131,20 +64,17 @@
|
|||||||
"package": "@nodedc/ui-react",
|
"package": "@nodedc/ui-react",
|
||||||
"exports": ["RangeControl"],
|
"exports": ["RangeControl"],
|
||||||
"domContract": ["nodedc-range"],
|
"domContract": ["nodedc-range"],
|
||||||
"summary": "Filled pill range control with embedded label, drag interaction and a persistent native inline exact-value editor.",
|
"summary": "Filled pill range control with embedded label, drag interaction and a persistent native inline exact-value editor.",
|
||||||
"behavior": ["native range drag", "native caret and selection replacement", "automatic fill/base contrast", "unbounded finite exact editing by default", "Enter/blur commit", "Escape cancel", "independent optional exact-value bounds", "step normalization"],
|
"behavior": ["native range drag", "value click exact editing", "Enter/blur commit", "Escape cancel", "min/max clamp", "step normalization"],
|
||||||
"rules": [
|
"rules": [
|
||||||
"The accent fill follows the active application theme.",
|
"The accent fill follows the active application theme.",
|
||||||
"The native range remains the accessible drag input while its chrome is visually replaced.",
|
"The native range remains the accessible drag input while its chrome is visually replaced.",
|
||||||
"The fill travels beneath the visible value; the persistent transparent native editor above the value receives exact-edit clicks without remounting.",
|
"The fill travels beneath the visible value; the persistent transparent native editor above the value receives exact-edit clicks without remounting.",
|
||||||
"Exact editing stays inside the existing value area and never expands the control.",
|
"Exact editing stays inside the existing value area and never expands the control.",
|
||||||
"Slider min/max define drag travel only; finite typed values are independent and may exceed that geometry.",
|
"The browser places the caret at the clicked character and keeps native drag selection; entering exact editing never forces select-all or adds a second focus ring.",
|
||||||
"exactValueBounds adds explicit domain limits when a parameter must reject values beyond them.",
|
"The editor has no separate background box and keeps focus across parent window rerenders and pointer leave.",
|
||||||
"The browser places the caret at the clicked character, preserves native drag or keyboard selection, and replaces the selected substring on typing without pointer-state interception.",
|
"Editor keyboard events do not bubble into enclosing Window shortcuts."
|
||||||
"Editor foreground and caret switch automatically between theme text and on-accent tokens according to the actual fill edge beneath the value area.",
|
]
|
||||||
"The editor has no separate background box and keeps focus across parent window rerenders and pointer leave.",
|
|
||||||
"Editor keyboard events do not bubble into enclosing Window shortcuts."
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "color-field",
|
"id": "color-field",
|
||||||
@@ -183,12 +113,11 @@
|
|||||||
"domPackage": "@nodedc/ui-dom",
|
"domPackage": "@nodedc/ui-dom",
|
||||||
"domExports": ["createSelectController"],
|
"domExports": ["createSelectController"],
|
||||||
"summary": "Theme-independent selection control using the canonical dropdown layer.",
|
"summary": "Theme-independent selection control using the canonical dropdown layer.",
|
||||||
"variants": ["integrated", "split", "inline"],
|
"variants": ["integrated", "split"],
|
||||||
"behavior": ["controlled value", "optional search", "disabled options", "selected state", "portal menu"],
|
"behavior": ["controlled value", "optional search", "disabled options", "selected state", "portal menu"],
|
||||||
"rules": [
|
"rules": [
|
||||||
"Integrated is the single-pill Hub/Launcher form.",
|
"Integrated is the single-pill Hub/Launcher form.",
|
||||||
"Split is the Engine form: a separate value surface and a 46 px toggle separated by an 8 px gap.",
|
"Split is the Engine form: a separate value surface and a 46 px toggle separated by an 8 px gap.",
|
||||||
"Inline is a compact label-only toolbar trigger without a persistent surface or chevron; its menu and keyboard behavior remain canonical.",
|
|
||||||
"Integrated Select is forbidden inside Inspector; use InspectorSelectField so the visible label is above a full-width split control.",
|
"Integrated Select is forbidden inside Inspector; use InspectorSelectField so the visible label is above a full-width split control.",
|
||||||
"The portal menu preserves its source-family row geometry instead of inheriting card radii."
|
"The portal menu preserves its source-family row geometry instead of inheriting card radii."
|
||||||
]
|
]
|
||||||
@@ -207,9 +136,7 @@
|
|||||||
"Open state belongs to the application; focus/layer behavior belongs to the component.",
|
"Open state belongs to the application; focus/layer behavior belongs to the component.",
|
||||||
"The same window contract is used by Launcher, CMS and SEO; theme variables supply color differences.",
|
"The same window contract is used by Launcher, CMS and SEO; theme variables supply color differences.",
|
||||||
"A right-side inspector is modeless by default: no dimming, no backdrop blur, no backdrop close, no focus trap and no body scroll lock.",
|
"A right-side inspector is modeless by default: no dimming, no backdrop blur, no backdrop close, no focus trap and no body scroll lock.",
|
||||||
"A draggable inspector moves by its header and remains clamped to the viewport.",
|
"A draggable inspector moves by its header and remains clamped to the viewport."
|
||||||
"Closing a modeless inspector is never gated by autosave; preserve the draft, close immediately and report persistence failure through ToastStack.",
|
|
||||||
"Operational copy inside Window uses component typography tokens; browser-default paragraph or heading sizes are forbidden."
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -236,23 +163,6 @@
|
|||||||
"summary": "Async-safe confirmation window for destructive and consequential operations.",
|
"summary": "Async-safe confirmation window for destructive and consequential operations.",
|
||||||
"behavior": ["pending state", "double-submit protection", "disabled close policy while pending"]
|
"behavior": ["pending state", "double-submit protection", "disabled close policy while pending"]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "split-pane",
|
|
||||||
"status": "baseline",
|
|
||||||
"package": "@nodedc/ui-react",
|
|
||||||
"exports": ["SplitPane", "SplitPaneProps", "SplitPaneOrientation"],
|
|
||||||
"domContract": ["nodedc-split-pane", "nodedc-split-pane__panel", "nodedc-split-pane__separator"],
|
|
||||||
"summary": "Controlled two-panel layout with a pointer- and keyboard-resizable accessible separator.",
|
|
||||||
"anatomy": ["primary panel", "structural separator", "secondary panel"],
|
|
||||||
"variants": ["vertical", "horizontal"],
|
|
||||||
"behavior": ["controlled percentage", "optional resizable separator", "pointer capture drag", "Arrow/Home/End keyboard sizing", "ARIA separator range", "ratio preservation across host resize"],
|
|
||||||
"rules": [
|
|
||||||
"The application owns the controlled primary percentage and panel content; the component owns separator interaction and accessibility.",
|
|
||||||
"Minimum primary and secondary percentages clamp both pointer and keyboard changes.",
|
|
||||||
"The separator is a necessary structural divider, never a decorative perimeter border.",
|
|
||||||
"Viewport-local toolbars belong to their SplitPane panel, and embedded renderers receive the same controlled percentage instead of exposing cursor or pointer-position heuristics."
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "share-access-modal",
|
"id": "share-access-modal",
|
||||||
"status": "baseline",
|
"status": "baseline",
|
||||||
@@ -288,11 +198,7 @@
|
|||||||
"package": "@nodedc/ui-react",
|
"package": "@nodedc/ui-react",
|
||||||
"exports": ["SegmentedControl"],
|
"exports": ["SegmentedControl"],
|
||||||
"domContract": ["nodedc-segmented"],
|
"domContract": ["nodedc-segmented"],
|
||||||
"summary": "Pill navigation used in the shared top header and compact mode switches.",
|
"summary": "Pill navigation used in the shared top header and compact mode switches."
|
||||||
"sizes": ["default", "dense"],
|
|
||||||
"rules": [
|
|
||||||
"Dense is reserved for high-density in-viewer layer and mode toolbars; application headers retain default geometry."
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "app-header",
|
"id": "app-header",
|
||||||
@@ -304,7 +210,6 @@
|
|||||||
"anatomy": ["left brand", "optional left context", "center navigation/workspace", "right actions/profile"],
|
"anatomy": ["left brand", "optional left context", "center navigation/workspace", "right actions/profile"],
|
||||||
"rules": [
|
"rules": [
|
||||||
"The logo occupies the same visual position and geometry across applications.",
|
"The logo occupies the same visual position and geometry across applications.",
|
||||||
"Light single-color assets may opt into brandMonochrome / monochrome for theme contrast; full-color images are unchanged by default.",
|
|
||||||
"Product routes and profile content are application data passed into stable slots.",
|
"Product routes and profile content are application data passed into stable slots.",
|
||||||
"The canonical application header is fixed and preserves the Launcher three-axis logo / navigation / profile positions.",
|
"The canonical application header is fixed and preserves the Launcher three-axis logo / navigation / profile positions.",
|
||||||
"Consumers supply data and actions but cannot override preset geometry through local className or style props."
|
"Consumers supply data and actions but cannot override preset geometry through local className or style props."
|
||||||
@@ -445,7 +350,6 @@
|
|||||||
"summary": "Neutral administration group with title metadata, actions, content and a compact binary switch.",
|
"summary": "Neutral administration group with title metadata, actions, content and a compact binary switch.",
|
||||||
"rules": [
|
"rules": [
|
||||||
"The card owns grouping geometry but not product form schemas.",
|
"The card owns grouping geometry but not product form schemas.",
|
||||||
"Section and empty-state titles use the compact md token; descriptions use sm. Page/window title sizes and browser heading defaults are forbidden inside cards.",
|
|
||||||
"Use Switch for compact visibility/enable states; use Checker for the Engine inspector treatment."
|
"Use Switch for compact visibility/enable states; use Checker for the Engine inspector treatment."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -454,7 +358,6 @@
|
|||||||
"status": "baseline",
|
"status": "baseline",
|
||||||
"package": "@nodedc/ui-react",
|
"package": "@nodedc/ui-react",
|
||||||
"exports": ["StatusBadge"],
|
"exports": ["StatusBadge"],
|
||||||
"variants": ["badge", "indicator"],
|
|
||||||
"domContract": ["nodedc-status"],
|
"domContract": ["nodedc-status"],
|
||||||
"summary": "Semantic status pill whose tone is independent from the application accent."
|
"summary": "Semantic status pill whose tone is independent from the application accent."
|
||||||
},
|
},
|
||||||
@@ -466,11 +369,9 @@
|
|||||||
"domContract": ["nodedc-toast-viewport", "nodedc-toast", "nodedc-toast__icon", "nodedc-toast__copy"],
|
"domContract": ["nodedc-toast-viewport", "nodedc-toast", "nodedc-toast__icon", "nodedc-toast__copy"],
|
||||||
"summary": "Tasker-derived non-blocking status notification with a bottom-right glass stack.",
|
"summary": "Tasker-derived non-blocking status notification with a bottom-right glass stack.",
|
||||||
"variants": ["success", "error", "warning", "info", "loading"],
|
"variants": ["success", "error", "warning", "info", "loading"],
|
||||||
"behavior": ["controlled items", "independent 10 second default auto-dismiss per terminal item", "manual dismiss", "portal viewport", "polite live region"],
|
"behavior": ["controlled items", "optional auto-dismiss", "manual dismiss", "portal viewport", "polite live region"],
|
||||||
"rules": [
|
"rules": [
|
||||||
"Applications own operation state and message copy; ToastStack owns viewport geometry and dismissal timing.",
|
"Applications own operation state and message copy; ToastStack owns viewport geometry and dismissal timing.",
|
||||||
"Errors and status copy use the compact Toast typography; consumers do not substitute browser-default paragraphs or title-sized text.",
|
|
||||||
"Adding or updating another notification never restarts the lifetime of an existing item; each item owns its timer by stable id.",
|
|
||||||
"Loading notifications remain until the operation updates or dismisses them.",
|
"Loading notifications remain until the operation updates or dismisses them.",
|
||||||
"Status notifications never replace a blocking confirmation modal."
|
"Status notifications never replace a blocking confirmation modal."
|
||||||
]
|
]
|
||||||
|
|||||||
+3
-5
@@ -6,7 +6,6 @@
|
|||||||
"defaultSize": 18,
|
"defaultSize": 18,
|
||||||
"supportedSizes": [16, 18, 20],
|
"supportedSizes": [16, 18, 20],
|
||||||
"strokeWidth": 1.8,
|
"strokeWidth": 1.8,
|
||||||
"filledNames": ["play", "stop"],
|
|
||||||
"groups": [
|
"groups": [
|
||||||
{
|
{
|
||||||
"id": "window-layer",
|
"id": "window-layer",
|
||||||
@@ -30,7 +29,7 @@
|
|||||||
"id": "state-access",
|
"id": "state-access",
|
||||||
"label": "Состояние и доступ",
|
"label": "Состояние и доступ",
|
||||||
"referenceSources": ["launcher", "seo", "task-manager"],
|
"referenceSources": ["launcher", "seo", "task-manager"],
|
||||||
"names": ["check", "alert", "activity", "lock", "key", "shield", "circle", "eye", "eye-off"]
|
"names": ["check", "alert", "activity", "lock", "key", "shield", "circle"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "entities",
|
"id": "entities",
|
||||||
@@ -42,14 +41,13 @@
|
|||||||
"id": "content",
|
"id": "content",
|
||||||
"label": "Контент",
|
"label": "Контент",
|
||||||
"referenceSources": ["seo", "bim-viewer", "engine"],
|
"referenceSources": ["seo", "bim-viewer", "engine"],
|
||||||
"names": ["camera", "plan", "play", "stop", "image", "video", "file", "folder", "clipboard", "settings"]
|
"names": ["image", "video", "file", "folder", "clipboard", "settings"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"rules": [
|
"rules": [
|
||||||
"Use semantic icon names from this registry instead of importing an application-local icon by shape.",
|
"Use semantic icon names from this registry instead of importing an application-local icon by shape.",
|
||||||
"Icons inherit currentColor; their button surface, active state and hit target are owned by Button, IconButton or the containing pattern.",
|
"Icons inherit currentColor; their button surface, active state and hit target are owned by Button, IconButton or the containing pattern.",
|
||||||
"Do not copy the complete Font Awesome or Lucide vendor catalog into application code.",
|
"Do not copy the complete Font Awesome or Lucide vendor catalog into application code.",
|
||||||
"A new icon requires a confirmed product use, a registry entry and a catalog specimen.",
|
"A new icon requires a confirmed product use, a registry entry and a catalog specimen."
|
||||||
"Playback icons play and stop are filled semantic glyphs; all other canonical glyphs remain outline unless their semantic contract says otherwise."
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,6 @@
|
|||||||
"architecture": "../docs/ARCHITECTURE.md",
|
"architecture": "../docs/ARCHITECTURE.md",
|
||||||
"components": "../docs/COMPONENTS.md",
|
"components": "../docs/COMPONENTS.md",
|
||||||
"theming": "../docs/THEMING.md",
|
"theming": "../docs/THEMING.md",
|
||||||
"operationalTypography": "../docs/OPERATIONAL_TYPOGRAPHY.md",
|
|
||||||
"windows": "../docs/WINDOWS_AND_LAYERS.md",
|
"windows": "../docs/WINDOWS_AND_LAYERS.md",
|
||||||
"baseline": "../docs/SOURCE_BASELINE.md",
|
"baseline": "../docs/SOURCE_BASELINE.md",
|
||||||
"governance": "../docs/GOVERNANCE.md",
|
"governance": "../docs/GOVERNANCE.md",
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { readFile } from "node:fs/promises";
|
|
||||||
import test from "node:test";
|
|
||||||
import { createElement } from "react";
|
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
|
||||||
import { ActivityIndicator } from "../packages/ui-react/dist/index.js";
|
|
||||||
|
|
||||||
test("ActivityIndicator separates decorative and announced progress", () => {
|
|
||||||
const decorative = renderToStaticMarkup(createElement(ActivityIndicator, { size: "compact" }));
|
|
||||||
const announced = renderToStaticMarkup(createElement(ActivityIndicator, { label: "Подключаем устройство" }));
|
|
||||||
|
|
||||||
assert.match(decorative, /class="nodedc-activity-indicator"/);
|
|
||||||
assert.match(decorative, /data-size="compact"/);
|
|
||||||
assert.match(decorative, /aria-hidden="true"/);
|
|
||||||
assert.doesNotMatch(decorative, /role="status"/);
|
|
||||||
|
|
||||||
assert.match(announced, /role="status"/);
|
|
||||||
assert.match(announced, /aria-label="Подключаем устройство"/);
|
|
||||||
assert.doesNotMatch(announced, /aria-hidden/);
|
|
||||||
assert.doesNotMatch(announced, /data-size=/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("ActivityIndicator is registered, cataloged and motion-safe", async () => {
|
|
||||||
const [styles, registrySource, docs, catalog] = await Promise.all([
|
|
||||||
readFile(new URL("../packages/ui-core/styles.css", import.meta.url), "utf8"),
|
|
||||||
readFile(new URL("../registry/components.json", import.meta.url), "utf8"),
|
|
||||||
readFile(new URL("../docs/COMPONENTS.md", import.meta.url), "utf8"),
|
|
||||||
readFile(new URL("../apps/catalog/src/CatalogApp.tsx", import.meta.url), "utf8"),
|
|
||||||
]);
|
|
||||||
const registry = JSON.parse(registrySource);
|
|
||||||
const entry = registry.components.find((component) => component.id === "activity-indicator");
|
|
||||||
|
|
||||||
assert.deepEqual(entry?.variants, ["default", "compact"]);
|
|
||||||
assert.ok(entry?.exports.includes("ActivityIndicator"));
|
|
||||||
assert.match(styles, /\.nodedc-activity-indicator\s*\{[\s\S]*?animation: nodedc-activity-indicator-spin/);
|
|
||||||
assert.match(styles, /\.nodedc-activity-indicator\[data-size="compact"\]/);
|
|
||||||
assert.match(styles, /@media \(prefers-reduced-motion: reduce\) \{\s*\.nodedc-activity-indicator,[\s\S]*?animation: none/);
|
|
||||||
assert.match(docs, /## ActivityIndicator/);
|
|
||||||
assert.match(catalog, /<ActivityIndicator label="Загружаем данные"/);
|
|
||||||
assert.match(catalog, /icon=\{<ActivityIndicator size="compact" \/>\}/);
|
|
||||||
});
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { readFile } from "node:fs/promises";
|
|
||||||
import test from "node:test";
|
|
||||||
import { createElement } from "react";
|
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
|
||||||
import { Button, SegmentedControl } from "../packages/ui-react/dist/index.js";
|
|
||||||
|
|
||||||
test("dense viewer controls remain a public Button and SegmentedControl contract", async () => {
|
|
||||||
const button = renderToStaticMarkup(createElement(Button, { size: "dense" }, "Source points"));
|
|
||||||
const segmented = renderToStaticMarkup(createElement(SegmentedControl, {
|
|
||||||
size: "dense",
|
|
||||||
value: "source",
|
|
||||||
items: [{ label: "Source points", value: "source" }],
|
|
||||||
label: "Viewer layer",
|
|
||||||
onChange: () => {},
|
|
||||||
}));
|
|
||||||
const [tokens, styles, registrySource, docs] = await Promise.all([
|
|
||||||
readFile(new URL("../packages/tokens/tokens.css", import.meta.url), "utf8"),
|
|
||||||
readFile(new URL("../packages/ui-core/styles.css", import.meta.url), "utf8"),
|
|
||||||
readFile(new URL("../registry/components.json", import.meta.url), "utf8"),
|
|
||||||
readFile(new URL("../docs/COMPONENTS.md", import.meta.url), "utf8"),
|
|
||||||
]);
|
|
||||||
const registry = JSON.parse(registrySource);
|
|
||||||
const buttonEntry = registry.components.find((component) => component.id === "button");
|
|
||||||
const segmentedEntry = registry.components.find((component) => component.id === "segmented-control");
|
|
||||||
|
|
||||||
assert.match(button, /data-size="dense"/);
|
|
||||||
assert.match(segmented, /data-size="dense"/);
|
|
||||||
assert.ok(buttonEntry?.sizes.includes("dense"));
|
|
||||||
assert.ok(segmentedEntry?.sizes.includes("dense"));
|
|
||||||
assert.match(tokens, /--nodedc-control-height-dense: 1\.875rem/);
|
|
||||||
assert.match(tokens, /--nodedc-font-size-dense: 0\.45rem/);
|
|
||||||
assert.match(styles, /\.nodedc-button\[data-size="dense"\]/);
|
|
||||||
assert.match(styles, /\.nodedc-segmented\[data-size="dense"\]/);
|
|
||||||
assert.match(docs, /size="dense"/);
|
|
||||||
assert.match(docs, /viewer/);
|
|
||||||
});
|
|
||||||
@@ -49,15 +49,18 @@ test("floating surface remains bounded when its anchor is below the viewport", (
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("dropdown scroll keeps portal geometry stable and contained", async () => {
|
test("dropdown scroll keeps portal geometry stable and contained", async () => {
|
||||||
const styles = await readFile(
|
const [styles, dropdown] = await Promise.all([
|
||||||
new URL("../packages/ui-core/styles.css", import.meta.url),
|
readFile(new URL("../packages/ui-core/styles.css", import.meta.url), "utf8"),
|
||||||
"utf8",
|
readFile(new URL("../packages/ui-react/src/Dropdown.tsx", import.meta.url), "utf8"),
|
||||||
);
|
]);
|
||||||
const dropdownRule = styles.match(/\.nodedc-dropdown-surface \{(?<body>[\s\S]*?)\n\}/)?.groups?.body ?? "";
|
const dropdownRule = styles.match(/\.nodedc-dropdown-surface \{(?<body>[\s\S]*?)\n\}/)?.groups?.body ?? "";
|
||||||
|
|
||||||
assert.match(dropdownRule, /box-sizing:\s*border-box;/);
|
assert.match(dropdownRule, /box-sizing:\s*border-box;/);
|
||||||
assert.match(dropdownRule, /overflow:\s*auto;/);
|
assert.match(dropdownRule, /overflow:\s*auto;/);
|
||||||
assert.match(dropdownRule, /overscroll-behavior:\s*contain;/);
|
assert.match(dropdownRule, /overscroll-behavior:\s*contain;/);
|
||||||
|
assert.match(dropdown, /new ResizeObserver\(schedulePositionUpdate\)/);
|
||||||
|
assert.match(dropdown, /resizeObserver\.observe\(surface\)/);
|
||||||
|
assert.match(dropdown, /window\.requestAnimationFrame\(updatePosition\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("workspace windows keep their glass rim without masked compositor overlays", async () => {
|
test("workspace windows keep their glass rim without masked compositor overlays", async () => {
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { readFile } from "node:fs/promises";
|
|
||||||
import test from "node:test";
|
|
||||||
import { createElement } from "react";
|
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
|
||||||
import { Icon } from "../packages/ui-react/dist/index.js";
|
|
||||||
|
|
||||||
test("only registered playback glyphs are filled", async () => {
|
|
||||||
const registry = JSON.parse(await readFile(
|
|
||||||
new URL("../registry/icons.json", import.meta.url),
|
|
||||||
"utf8",
|
|
||||||
));
|
|
||||||
const names = registry.groups.flatMap((group) => group.names);
|
|
||||||
const filledNames = new Set(registry.filledNames);
|
|
||||||
|
|
||||||
assert.deepEqual([...filledNames], ["play", "stop"]);
|
|
||||||
assert.equal(new Set(names).size, names.length);
|
|
||||||
|
|
||||||
for (const name of names) {
|
|
||||||
const markup = renderToStaticMarkup(createElement(Icon, { name }));
|
|
||||||
const rootTag = markup.slice(0, markup.indexOf(">") + 1);
|
|
||||||
if (filledNames.has(name)) {
|
|
||||||
assert.match(rootTag, /fill="currentColor"/, `${name} must be filled`);
|
|
||||||
assert.match(rootTag, /stroke-width="0"/, `${name} must not retain an outline`);
|
|
||||||
} else {
|
|
||||||
assert.match(rootTag, /fill="none"/, `${name} must remain outline-only`);
|
|
||||||
assert.doesNotMatch(rootTag, /fill="currentColor"/, `${name} must not be filled`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -48,14 +48,14 @@ test("Inspector hard-forces a raw integrated Select to the split presentation",
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("Foundry Inspector screens consume the semantic selection field", async () => {
|
test("Foundry Inspector screens consume the semantic selection field", async () => {
|
||||||
const [mapPreview, catalog, coreStyles] = await Promise.all([
|
const [mapInspector, catalog, coreStyles] = await Promise.all([
|
||||||
readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"),
|
readFile(new URL("../apps/catalog/src/mapInspectorSections.tsx", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../apps/catalog/src/CatalogApp.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("../packages/ui-core/styles.css", import.meta.url), "utf8"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.match(mapPreview, /<InspectorSelectField\s+label="Профиль покрытия"/);
|
assert.match(mapInspector, /<InspectorSelectField\s+label="Профиль покрытия"/);
|
||||||
assert.doesNotMatch(mapPreview, /<ControlRow label="Профиль покрытия">/);
|
assert.doesNotMatch(mapInspector, /<ControlRow label="Профиль покрытия">/);
|
||||||
assert.equal((catalog.match(/<InspectorSelectField/g) ?? []).length, 2);
|
assert.equal((catalog.match(/<InspectorSelectField/g) ?? []).length, 2);
|
||||||
assert.match(coreStyles, /\.nodedc-inspector \.nodedc-control-row:has\(\.nodedc-select-anchor\)/);
|
assert.match(coreStyles, /\.nodedc-inspector \.nodedc-control-row:has\(\.nodedc-select-anchor\)/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -315,29 +315,32 @@ test("grid center is fixed and camera-independent, including for legacy camera-m
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("renderer uses fixed ENU sectors, angular graticules and a non-blank double-buffer swap", async () => {
|
test("renderer uses fixed ENU sectors, angular graticules and a non-blank double-buffer swap", async () => {
|
||||||
const source = await readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8");
|
const [renderer, gridLayer] = await Promise.all([
|
||||||
assert.match(source, /class GridLayerController/);
|
readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"),
|
||||||
assert.match(source, /dataSourceDisplay\.ready/);
|
readFile(new URL("../apps/catalog/src/mapCesiumGridLayer.ts", import.meta.url), "utf8"),
|
||||||
assert.match(source, /fixedGridOrigin\(presentation\)/);
|
]);
|
||||||
assert.match(source, /Transforms\.eastNorthUpToFixedFrame\(anchor\)/);
|
assert.match(gridLayer, /class GridLayerController/);
|
||||||
assert.match(source, /localSectorAt\(/);
|
assert.match(gridLayer, /dataSourceDisplay\.ready/);
|
||||||
assert.match(source, /gridController\?\.pick\(worldPosition\)/);
|
assert.match(gridLayer, /fixedGridOrigin\(presentation\)/);
|
||||||
assert.match(source, /lod\.stepKm \* 1_000/);
|
assert.match(gridLayer, /Transforms\.eastNorthUpToFixedFrame\(anchor\)/);
|
||||||
assert.match(source, /lod\.graticuleStepDegrees/);
|
assert.match(gridLayer, /localSectorAt\(/);
|
||||||
assert.match(source, /lod\.graticuleLineWidthPx/);
|
assert.match(renderer, /gridController\?\.pick\(worldPosition\)/);
|
||||||
assert.match(source, /arcType: ArcType\.RHUMB/);
|
assert.match(gridLayer, /lod\.stepKm \* 1_000/);
|
||||||
assert.match(source, /clampToGround: false/);
|
assert.match(gridLayer, /lod\.graticuleStepDegrees/);
|
||||||
assert.match(source, /majorLineWidthMultiplier/);
|
assert.match(gridLayer, /lod\.graticuleLineWidthPx/);
|
||||||
assert.match(source, /isLocalMajorLineIndex/);
|
assert.match(gridLayer, /arcType: ArcType\.RHUMB/);
|
||||||
assert.match(source, /isGraticuleMajorLineValue/);
|
assert.match(gridLayer, /clampToGround: false/);
|
||||||
assert.match(source, /new LabelCollection\(\)/);
|
assert.match(gridLayer, /majorLineWidthMultiplier/);
|
||||||
assert.match(source, /const maximumLabels = 48/);
|
assert.match(gridLayer, /isLocalMajorLineIndex/);
|
||||||
assert.match(source, /materializeLocalSelection/);
|
assert.match(gridLayer, /isGraticuleMajorLineValue/);
|
||||||
assert.match(source, /localVolumeAt\(/);
|
assert.match(gridLayer, /new LabelCollection\(\)/);
|
||||||
assert.match(source, /focusGridSector/);
|
assert.match(gridLayer, /const maximumLabels = 48/);
|
||||||
assert.match(source, /selectedGridSector/);
|
assert.match(gridLayer, /materializeLocalSelection/);
|
||||||
assert.match(source, /mountResources\(resources\)[\s\S]*?const previous = this\.current;[\s\S]*?removeResources\(previous\.resources\)/);
|
assert.match(gridLayer, /localVolumeAt\(/);
|
||||||
assert.doesNotMatch(source, /function rebuildElevatedGrid[\s\S]*?entities\.removeAll\(\)/);
|
assert.match(renderer, /focusGridSector/);
|
||||||
assert.doesNotMatch(source, /Math\.min\(40,[\s\S]*?safeRadiusKm \/ safeStepKm/);
|
assert.match(renderer, /selectedGridSector/);
|
||||||
assert.doesNotMatch(source, /snapGridCenter\(/);
|
assert.match(gridLayer, /mountResources\(resources\)[\s\S]*?const previous = this\.current;[\s\S]*?removeResources\(previous\.resources\)/);
|
||||||
|
assert.doesNotMatch(gridLayer, /function rebuildElevatedGrid[\s\S]*?entities\.removeAll\(\)/);
|
||||||
|
assert.doesNotMatch(gridLayer, /Math\.min\(40,[\s\S]*?safeRadiusKm \/ safeStepKm/);
|
||||||
|
assert.doesNotMatch(gridLayer, /snapGridCenter\(/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,17 +3,17 @@ import { readFile } from "node:fs/promises";
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
test("Cesium elevated targets keep their profile border while labels occlude them inside the scene overlay", async () => {
|
test("Cesium elevated targets keep their profile border while labels occlude them inside the scene overlay", async () => {
|
||||||
const [renderer, cesiumDisplay] = await Promise.all([
|
const [runtimeLayers, cesiumDisplay] = await Promise.all([
|
||||||
readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"),
|
readFile(new URL("../apps/catalog/src/mapCesiumRuntimeLayers.ts", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../node_modules/@cesium/engine/Source/DataSources/DataSourceDisplay.js", import.meta.url), "utf8"),
|
readFile(new URL("../node_modules/@cesium/engine/Source/DataSources/DataSourceDisplay.js", import.meta.url), "utf8"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.match(renderer, /BillboardGraphics/);
|
assert.match(runtimeLayers, /BillboardGraphics/);
|
||||||
assert.match(renderer, /elevatedTargetImage\(\s*color,\s*Color\.fromCssColorString\(target\.outlineColor\)\.withAlpha\(target\.outlineOpacity\),\s*target\.outlineWidthPx,\s*target\.headSizePx/);
|
assert.match(runtimeLayers, /elevatedTargetImage\(\s*color,\s*Color\.fromCssColorString\(target\.outlineColor\)\.withAlpha\(target\.outlineOpacity\),\s*target\.outlineWidthPx,\s*target\.headSizePx/);
|
||||||
assert.match(renderer, /entity\.point = undefined;\s*entity\.billboard = new BillboardGraphics/);
|
assert.match(runtimeLayers, /entity\.point = undefined;\s*entity\.billboard = new BillboardGraphics/);
|
||||||
assert.match(renderer, /disableDepthTestDistance: Number\.POSITIVE_INFINITY/);
|
assert.match(runtimeLayers, /disableDepthTestDistance: Number\.POSITIVE_INFINITY/);
|
||||||
assert.match(renderer, /outlineWidth: 0,\s*style: LabelStyle\.FILL/);
|
assert.match(runtimeLayers, /outlineWidth: 0,\s*style: LabelStyle\.FILL/);
|
||||||
assert.doesNotMatch(renderer, /style: 2/);
|
assert.doesNotMatch(runtimeLayers, /style: 2/);
|
||||||
assert.ok(cesiumDisplay.indexOf("new BillboardVisualizer") < cesiumDisplay.indexOf("new LabelVisualizer"));
|
assert.ok(cesiumDisplay.indexOf("new BillboardVisualizer") < cesiumDisplay.indexOf("new LabelVisualizer"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,71 +2,92 @@ import assert from "node:assert/strict";
|
|||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
test("Objects menu opens controllable groups from the row and keeps plain bindings as visibility layers", async () => {
|
const source = (path) => readFile(new URL(path, import.meta.url), "utf8");
|
||||||
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
|
||||||
|
|
||||||
assert.match(preview, /const toggleSubjectVisibility = \(bindingId: string\)/);
|
test("Objects menu delegates controlled layers to the workspace reducer", async () => {
|
||||||
assert.match(preview, /role=\{hasControls \? "menuitem" : "menuitemcheckbox"\}/);
|
const [preview, toolbar, state] = await Promise.all([
|
||||||
assert.match(preview, /if \(hasControls\) \{\s*openSubjectWindow\(summary\.bindingId\);\s*close\(\)/);
|
source("../apps/catalog/src/MapFixturePreview.tsx"),
|
||||||
assert.match(preview, /toggleSubjectVisibility\(summary\.bindingId\)/);
|
source("../apps/catalog/src/MapWorkspaceToolbar.tsx"),
|
||||||
assert.match(preview, /visible: !state\.visible/);
|
source("../apps/catalog/src/mapWorkspaceState.mjs"),
|
||||||
assert.match(preview, /слой скрыт · \$\{summary\.total\} объектов/);
|
]);
|
||||||
assert.doesNotMatch(preview, /Фильтры и счётчики: \$\{summary\.displayName\}/);
|
|
||||||
|
assert.match(toolbar, /role=\{hasControls \? "menuitem" : "menuitemcheckbox"\}/);
|
||||||
|
assert.match(toolbar, /onOpenSubjectWindow\(summary\.bindingId\)/);
|
||||||
|
assert.match(toolbar, /onToggleSubjectVisibility\(summary\.bindingId\)/);
|
||||||
|
assert.match(toolbar, /слой скрыт · \$\{summary\.total\} объектов/);
|
||||||
|
assert.match(preview, /type: "toggle-subject-visibility", bindingId/);
|
||||||
|
assert.match(state, /visible: !subject\.visible/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a subject window exists only when the presentation profile exposes controls", async () => {
|
test("a subject window exists only when the MCP presentation profile exposes controls", async () => {
|
||||||
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
const [windows, model, state] = await Promise.all([
|
||||||
|
source("../apps/catalog/src/MapSubjectWorkspaceWindows.tsx"),
|
||||||
|
source("../apps/catalog/src/mapWorkspaceModel.mjs"),
|
||||||
|
source("../apps/catalog/src/mapWorkspaceState.mjs"),
|
||||||
|
]);
|
||||||
|
|
||||||
assert.match(preview, /function hasSubjectWindowControls\(profile: MapPresentationProfile\)/);
|
assert.match(model, /export function mapProfileHasSubjectWindowControls/);
|
||||||
assert.match(preview, /if \(!state\?\.window\.open \|\| !hasSubjectWindowControls\(summary\.profile\)\) return null/);
|
assert.match(windows, /!state\?\.window\.open \|\| !mapProfileHasSubjectWindowControls\(summary\.profile\)/);
|
||||||
assert.match(preview, /window: \{ \.\.\.state\.window, open: false \}/);
|
assert.match(windows, /type: "close-subject-window"/);
|
||||||
|
assert.match(state, /window: \{ \.\.\.subject\.window, open: false \}/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("hidden and visible layers have an explicit visual state", async () => {
|
test("hidden and visible layers have an explicit visual state", async () => {
|
||||||
const styles = await readFile(new URL("../apps/catalog/src/styles.css", import.meta.url), "utf8");
|
const styles = await source("../apps/catalog/src/styles.css");
|
||||||
|
|
||||||
assert.match(styles, /\.catalog-map-fixture__objects-menu-item\[data-visible\]/);
|
assert.match(styles, /\.catalog-map-fixture__objects-menu-item\[data-visible\]/);
|
||||||
assert.match(styles, /\.catalog-map-fixture__objects-menu-item:not\(\[data-visible\]\)/);
|
assert.match(styles, /\.catalog-map-fixture__objects-menu-item:not\(\[data-visible\]\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("facet controls and renderer consume the same canonical enabled-value state", async () => {
|
test("facet controls and renderer consume one canonical enabled-value state", async () => {
|
||||||
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
const [preview, windows, state] = await Promise.all([
|
||||||
|
source("../apps/catalog/src/MapFixturePreview.tsx"),
|
||||||
|
source("../apps/catalog/src/MapSubjectWorkspaceWindows.tsx"),
|
||||||
|
source("../apps/catalog/src/mapWorkspaceState.mjs"),
|
||||||
|
]);
|
||||||
|
|
||||||
assert.match(preview, /initialSubjectState\(initialLayout\?\.dataProductBindings[\s\S]*?presentationProfiles\)/);
|
assert.match(preview, /normalizeMapPresentationFacetSelections\(state\.filters, profile\)/);
|
||||||
assert.match(preview, /facets: profile \? normalizeMapPresentationFacetSelections\(state\.filters, profile\) : state\.filters/);
|
assert.match(preview, /presentationFilters=\{rendererPresentationFilters\}/);
|
||||||
assert.match(preview, /filters: normalizeMapPresentationFacetSelections\(state\.filters, summary\.profile\)/);
|
assert.match(windows, /mapPresentationFacetValueIsEnabled\(state\.filters, facet\.field, item\.value\)/);
|
||||||
assert.match(preview, /const active = mapPresentationFacetValueIsEnabled\(state\.filters, facet\.field, item\.value\)/);
|
assert.match(windows, /facet\.values\.map\(\(value\) => value\.value\)/);
|
||||||
assert.match(preview, /toggleMapPresentationFacetSelection\(filters, field, value, availableValues\)/);
|
assert.match(state, /toggleMapPresentationFacetSelection\(/);
|
||||||
assert.match(preview, /facet\.values\.map\(\(value\) => value\.value\)/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("joined detail aspects do not become independent map layers", async () => {
|
test("joined detail aspects enrich subjects but never become independent renderer layers", async () => {
|
||||||
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
const [preview, model] = await Promise.all([
|
||||||
|
source("../apps/catalog/src/MapFixturePreview.tsx"),
|
||||||
|
source("../apps/catalog/src/mapWorkspaceModel.mjs"),
|
||||||
|
]);
|
||||||
|
|
||||||
assert.match(preview, /dataProductBindings\.filter\(\(binding\) => !binding\.joinToBindingId\)/);
|
assert.match(model, /filter\(\(binding\) => !binding\.joinToBindingId\)/);
|
||||||
assert.match(preview, /runtimeBindings\.filter\(\(binding\) => primaryBindingIds\.has\(binding\.bindingId\)\)/);
|
assert.match(model, /primaryMapBindingConfigs\(bindingConfigs\)/);
|
||||||
assert.match(preview, /runtimeBindings=\{\[\.\.\.sectorScopedPrimaryRuntimeBindings, \.\.\.referenceRuntimeBindings\]\}/);
|
assert.match(preview, /runtimeBindings=\{\[\.\.\.sectorScopedPrimaryRuntimeBindings, \.\.\.referenceRuntimeBindings\]\}/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("bounded map windows keep one stack while layers and settings use their canonical surfaces", async () => {
|
test("bounded map windows share one reducer-owned z-stack while settings and layers keep canonical surfaces", async () => {
|
||||||
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
const [preview, toolbar, windows, sectorWindow, state, contract] = await Promise.all([
|
||||||
|
source("../apps/catalog/src/MapFixturePreview.tsx"),
|
||||||
|
source("../apps/catalog/src/MapWorkspaceToolbar.tsx"),
|
||||||
|
source("../apps/catalog/src/MapSubjectWorkspaceWindows.tsx"),
|
||||||
|
source("../apps/catalog/src/MapSectorWorkspaceWindow.tsx"),
|
||||||
|
source("../apps/catalog/src/mapWorkspaceState.mjs"),
|
||||||
|
source("../apps/catalog/src/mapPageContract.ts"),
|
||||||
|
]);
|
||||||
|
|
||||||
assert.match(preview, /type MapWorkspaceWindowId = "sector" \| "subject-card" \| `binding:\$\{string\}`/);
|
assert.match(contract, /type MapWorkspaceWindowId = "sector" \| "subject-card" \| `binding:\$\{string\}`/);
|
||||||
assert.match(preview, /const activateWorkspaceWindow = useCallback/);
|
assert.match(state, /function activateWindow\(state, windowId\)/);
|
||||||
assert.match(preview, /onActivate=\{\(\) => activateWorkspaceWindow\(`binding:\$\{summary\.bindingId\}`\)\}/);
|
assert.match(state, /Math\.max\([\s\S]*state\.sector\.window\.zIndex[\s\S]*state\.subjectCard\.zIndex/);
|
||||||
assert.doesNotMatch(preview, /onActivate=\{\(\) => openSubjectWindow\(summary\.bindingId\)\}/);
|
assert.match(windows, /type: "activate-window", windowId: `binding:\$\{summary\.bindingId\}`/);
|
||||||
assert.match(preview, /sectorWindowZIndex[\s\S]*subjectCardZIndex/);
|
assert.match(sectorWindow, /onActivate=\{onActivate\}/);
|
||||||
assert.match(preview, /<ApplicationSidePanel[\s\S]*title="Настройки карты"[\s\S]*onClose=\{closeSettingsPanel\}/);
|
assert.match(preview, /<ApplicationSidePanel[\s\S]*title="Настройки карты"[\s\S]*onClose=\{closeSettingsPanel\}/);
|
||||||
assert.match(preview, /createPortal\(headerActions, headerActionsHost\)/);
|
assert.match(preview, /createPortal\(headerActions, headerActionsHost\)/);
|
||||||
assert.doesNotMatch(preview, /settingsPanelPinned|onPinnedChange/);
|
assert.match(toolbar, /surfaceClassName="catalog-map-fixture__objects-menu catalog-map-fixture__layers-menu nodedc-map-glass"/);
|
||||||
assert.match(preview, /surfaceClassName="catalog-map-fixture__objects-menu catalog-map-fixture__layers-menu nodedc-map-glass"/);
|
assert.match(toolbar, /onOpenChange=\{onOpenChange\}/);
|
||||||
assert.match(preview, /onOpenChange=\{setLayersOpen\}/);
|
assert.doesNotMatch(preview, /settingsPanelPinned|settingsWindowZIndex|layersWindowZIndex/);
|
||||||
assert.doesNotMatch(preview, /settingsWindowZIndex|layersWindowZIndex/);
|
|
||||||
assert.doesNotMatch(preview, /<Window\b/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("selecting a point or HGeoZone preserves the active grid sector", async () => {
|
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 renderer = await source("../apps/catalog/src/CesiumMapRenderer.tsx");
|
||||||
const pointBranch = renderer.match(/if \(pickedId instanceof Entity[\s\S]*?return;\s*\}/)?.[0] ?? "";
|
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] ?? "";
|
const zoneBranch = renderer.match(/if \(\s*pickedId[\s\S]*?HGeoZonePickId\)\.entityId\);\s*return;\s*\}/)?.[0] ?? "";
|
||||||
|
|
||||||
@@ -77,59 +98,65 @@ test("selecting a point or HGeoZone preserves the active grid sector", async ()
|
|||||||
assert.match(renderer, /gridController\?\.setSelection\(gridSelection\);\s*onGridSectorSelectRef\.current\?\.\(gridSelection\)/);
|
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 () => {
|
test("active sector scope is geometric, filterable and reducer-deactivated", async () => {
|
||||||
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
const [runtime, sectorWindow, state] = await Promise.all([
|
||||||
|
source("../apps/catalog/src/mapSectorRuntime.mjs"),
|
||||||
assert.match(preview, /function mapFactInsideGridSector/);
|
source("../apps/catalog/src/MapSectorWorkspaceWindow.tsx"),
|
||||||
assert.match(preview, /localSectorAtGeodetic/);
|
source("../apps/catalog/src/mapWorkspaceState.mjs"),
|
||||||
assert.match(preview, /label="Скрыть объекты за сектором"/);
|
|
||||||
assert.match(preview, />Домены данных</);
|
|
||||||
assert.match(preview, />Провайдеры</);
|
|
||||||
assert.match(preview, />Типы объектов</);
|
|
||||||
assert.match(preview, />Объекты сектора</);
|
|
||||||
assert.match(preview, /onClose=\{deactivateGridSector\}/);
|
|
||||||
assert.match(preview, />\s*Деактивировать сектор\s*</);
|
|
||||||
assert.match(preview, /setSelectedGridSector\(null\)/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("reference stations are first-class Objects menu layers without provider settings actions", async () => {
|
|
||||||
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
|
||||||
|
|
||||||
assert.match(preview, /const referenceObjectSummaries = useMemo/);
|
|
||||||
assert.match(preview, /const objectLayerCount = presentationSummaries\.length \+ referenceObjectSummaries\.length/);
|
|
||||||
assert.match(preview, /referenceObjectSummaries\.map\(\(\{ layer, displayName, total \}\)/);
|
|
||||||
assert.match(preview, /candidate\.id === layer\.id \? \{ \.\.\.candidate, visible: !candidate\.visible \} : candidate/);
|
|
||||||
assert.match(preview, /role="menuitemcheckbox"/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("facet window auto-fits expanded and collapsed content inside map workspace", async () => {
|
|
||||||
const [preview, workspace] = await Promise.all([
|
|
||||||
readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"),
|
|
||||||
readFile(new URL("../packages/ui-react/src/WorkspaceWindow.tsx", import.meta.url), "utf8"),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.match(preview, /minHeight=\{220\}\s*autoHeight/);
|
assert.match(runtime, /export function mapFactInsideGridSector/);
|
||||||
|
assert.match(runtime, /localSectorAtGeodetic/);
|
||||||
|
assert.match(sectorWindow, /label="Скрыть объекты за сектором"/);
|
||||||
|
assert.match(sectorWindow, /title="Домены данных"/);
|
||||||
|
assert.match(sectorWindow, /title="Провайдеры"/);
|
||||||
|
assert.match(sectorWindow, /title="Типы объектов"/);
|
||||||
|
assert.match(sectorWindow, /id="map-sector-objects-title">Объекты сектора/);
|
||||||
|
assert.match(sectorWindow, /onClose=\{onDeactivate\}/);
|
||||||
|
assert.match(sectorWindow, />\s*Деактивировать сектор\s*</);
|
||||||
|
assert.match(state, /case "deactivate-sector"/);
|
||||||
|
assert.match(state, /selectedSector: null/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reference stations remain first-class visibility layers without provider settings actions", async () => {
|
||||||
|
const [preview, toolbar] = await Promise.all([
|
||||||
|
source("../apps/catalog/src/MapFixturePreview.tsx"),
|
||||||
|
source("../apps/catalog/src/MapWorkspaceToolbar.tsx"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.match(preview, /const referenceObjectSummaries = useMemo/);
|
||||||
|
assert.match(toolbar, /const objectLayerCount = summaries\.length \+ referenceSummaries\.length/);
|
||||||
|
assert.match(toolbar, /referenceSummaries\.map\(\(\{ layer, displayName, total \}\)/);
|
||||||
|
assert.match(toolbar, /onToggleReferenceLayer\(layer\.id\)/);
|
||||||
|
assert.match(toolbar, /role="menuitemcheckbox"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("facet windows auto-fit expanded and collapsed content inside the map workspace", async () => {
|
||||||
|
const [windows, workspace] = await Promise.all([
|
||||||
|
source("../apps/catalog/src/MapSubjectWorkspaceWindows.tsx"),
|
||||||
|
source("../packages/ui-react/src/WorkspaceWindow.tsx"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.match(windows, /minHeight=\{220\}\s*autoHeight/);
|
||||||
assert.match(workspace, /autoHeight\?: boolean/);
|
assert.match(workspace, /autoHeight\?: boolean/);
|
||||||
assert.match(workspace, /content\.scrollHeight/);
|
assert.match(workspace, /content\.scrollHeight/);
|
||||||
assert.match(workspace, /availableHeight = Math\.max\(0, nextBounds\.height - normalized\.y\)/);
|
assert.match(workspace, /availableHeight = Math\.max\(0, nextBounds\.height - normalized\.y\)/);
|
||||||
assert.match(workspace, /new ResizeObserver\(scheduleFit\)/);
|
assert.match(workspace, /new ResizeObserver\(scheduleFit\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("facet tree selection focuses the subject without resetting a compatible detail tab", async () => {
|
test("facet and search selection focus the subject while the reducer preserves a valid detail tab", async () => {
|
||||||
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
const [preview, renderer, state] = await Promise.all([
|
||||||
const renderer = await readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8");
|
source("../apps/catalog/src/MapFixturePreview.tsx"),
|
||||||
|
source("../apps/catalog/src/CesiumMapRenderer.tsx"),
|
||||||
|
source("../apps/catalog/src/mapWorkspaceState.mjs"),
|
||||||
|
]);
|
||||||
|
|
||||||
assert.match(preview, /const \[expandedFacetRows, setExpandedFacetRows\]/);
|
|
||||||
assert.match(preview, /const handleSelectAndFocus = useCallback/);
|
assert.match(preview, /const handleSelectAndFocus = useCallback/);
|
||||||
assert.match(preview, /const focusSubject = useCallback/);
|
|
||||||
assert.match(preview, /if \(renderer\.focusRuntimeEntity\(entityId\)\) return true/);
|
assert.match(preview, /if \(renderer\.focusRuntimeEntity\(entityId\)\) return true/);
|
||||||
assert.match(preview, /mapFactPointCoordinates\(selectable\.find/);
|
|
||||||
assert.match(preview, /renderer\.focusSubjectCoordinates\(fallbackCoordinates\[0\], fallbackCoordinates\[1\]\)/);
|
assert.match(preview, /renderer\.focusSubjectCoordinates\(fallbackCoordinates\[0\], fallbackCoordinates\[1\]\)/);
|
||||||
assert.match(preview, /focusSubject\(entityId\)/);
|
|
||||||
assert.match(preview, /focusSubject\(result\.entityId, result\.coordinates\)/);
|
assert.match(preview, /focusSubject\(result\.entityId, result\.coordinates\)/);
|
||||||
assert.match(preview, /setSubjectCardTabId\(\(current\) =>/);
|
assert.match(preview, /validTabIds: profile\?\.tabs\.map\(\(tab\) => tab\.id\) \?\? \["overview"\]/);
|
||||||
assert.match(preview, /profile\?\.tabs\.some\(\(tab\) => tab\.id === current\)/);
|
assert.match(state, /action\.validTabIds\.includes\(state\.subjectCard\.tabId\)/);
|
||||||
assert.match(preview, /\(profile\?\.defaultTabId \?\? "overview"\)/);
|
|
||||||
assert.match(renderer, /void viewer\.flyTo\(entity, \{/);
|
assert.match(renderer, /void viewer\.flyTo\(entity, \{/);
|
||||||
assert.match(renderer, /const focusSubjectCoordinates = useCallback/);
|
assert.match(renderer, /const focusSubjectCoordinates = useCallback/);
|
||||||
assert.match(renderer, /viewer\.camera\.flyToBoundingSphere\(new BoundingSphere\(target, 1\), \{/);
|
assert.match(renderer, /viewer\.camera\.flyToBoundingSphere\(new BoundingSphere\(target, 1\), \{/);
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { readFile } from "node:fs/promises";
|
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import ts from "typescript";
|
|
||||||
|
|
||||||
const source = await readFile(new URL("../apps/catalog/src/mapPresentationProfile.ts", import.meta.url), "utf8");
|
|
||||||
const transpiled = ts.transpileModule(source, {
|
|
||||||
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
|
|
||||||
}).outputText;
|
|
||||||
const moduleUrl = `data:text/javascript;base64,${Buffer.from(transpiled).toString("base64")}`;
|
|
||||||
const {
|
const {
|
||||||
mapFactMatchesFilters,
|
mapFactMatchesFilters,
|
||||||
mapPresentationBindingIsAll,
|
mapPresentationBindingIsAll,
|
||||||
@@ -15,7 +7,7 @@ const {
|
|||||||
mapPresentationFacetValueIsEnabled,
|
mapPresentationFacetValueIsEnabled,
|
||||||
normalizeMapPresentationFacetSelections,
|
normalizeMapPresentationFacetSelections,
|
||||||
toggleMapPresentationFacetSelection,
|
toggleMapPresentationFacetSelection,
|
||||||
} = await import(moduleUrl);
|
} = await import("../apps/catalog/src/mapPresentationProfile.mjs");
|
||||||
|
|
||||||
const profile = {
|
const profile = {
|
||||||
id: "map.moving-object.operational.default",
|
id: "map.moving-object.operational.default",
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
runStagedMapProviders,
|
||||||
|
waitForGlobeViewportReady,
|
||||||
|
} from "../apps/catalog/src/mapProviderStartup.mjs";
|
||||||
|
|
||||||
|
function eventHarness() {
|
||||||
|
const listeners = new Set();
|
||||||
|
return {
|
||||||
|
event: {
|
||||||
|
addEventListener(listener) {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
raise(...args) {
|
||||||
|
for (const listener of [...listeners]) listener(...args);
|
||||||
|
},
|
||||||
|
get size() {
|
||||||
|
return listeners.size;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("providers start in base-map order", async () => {
|
||||||
|
const calls = [];
|
||||||
|
const result = await runStagedMapProviders({
|
||||||
|
loadImagery: () => calls.push("imagery"),
|
||||||
|
waitAfterImagery: () => calls.push("imagery-ready"),
|
||||||
|
loadTerrain: () => calls.push("terrain"),
|
||||||
|
waitAfterTerrain: () => calls.push("terrain-ready"),
|
||||||
|
loadBuildings: () => calls.push("buildings"),
|
||||||
|
loadDeferred: () => calls.push("deferred"),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result, "complete");
|
||||||
|
assert.deepEqual(calls, ["imagery", "imagery-ready", "terrain", "terrain-ready", "buildings", "deferred"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("imagery failure is reported without blocking terrain or buildings", async () => {
|
||||||
|
const calls = [];
|
||||||
|
const errors = [];
|
||||||
|
await runStagedMapProviders({
|
||||||
|
loadImagery: () => { calls.push("imagery"); throw new Error("imagery failed"); },
|
||||||
|
waitAfterImagery: () => calls.push("imagery-ready"),
|
||||||
|
loadTerrain: () => calls.push("terrain"),
|
||||||
|
waitAfterTerrain: () => calls.push("terrain-ready"),
|
||||||
|
loadBuildings: () => calls.push("buildings"),
|
||||||
|
onProviderError: (provider, error) => errors.push([provider, error.message]),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ["imagery", "terrain", "terrain-ready", "buildings"]);
|
||||||
|
assert.deepEqual(errors, [["imagery", "imagery failed"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("terrain failure is reported without blocking buildings", async () => {
|
||||||
|
const calls = [];
|
||||||
|
const errors = [];
|
||||||
|
await runStagedMapProviders({
|
||||||
|
loadImagery: () => calls.push("imagery"),
|
||||||
|
waitAfterImagery: () => calls.push("imagery-ready"),
|
||||||
|
loadTerrain: () => { calls.push("terrain"); throw new Error("terrain failed"); },
|
||||||
|
waitAfterTerrain: () => calls.push("terrain-ready"),
|
||||||
|
loadBuildings: () => calls.push("buildings"),
|
||||||
|
onProviderError: (provider, error) => errors.push([provider, error.message]),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ["imagery", "imagery-ready", "terrain", "buildings"]);
|
||||||
|
assert.deepEqual(errors, [["terrain", "terrain failed"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cancellation prevents all later stages", async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const calls = [];
|
||||||
|
const result = await runStagedMapProviders({
|
||||||
|
signal: controller.signal,
|
||||||
|
loadImagery: () => { calls.push("imagery"); controller.abort(); },
|
||||||
|
waitAfterImagery: () => calls.push("imagery-ready"),
|
||||||
|
loadTerrain: () => calls.push("terrain"),
|
||||||
|
loadBuildings: () => calls.push("buildings"),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result, "cancelled");
|
||||||
|
assert.deepEqual(calls, ["imagery"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a stalled provider times out without inserting late or blocking later stages", async () => {
|
||||||
|
const calls = [];
|
||||||
|
const errors = [];
|
||||||
|
let releaseImagery;
|
||||||
|
const stalledImagery = new Promise((resolve) => { releaseImagery = resolve; });
|
||||||
|
await runStagedMapProviders({
|
||||||
|
providerInitializationTimeoutMs: 1,
|
||||||
|
loadImagery: async ({ signal }) => {
|
||||||
|
calls.push("imagery-start");
|
||||||
|
await stalledImagery;
|
||||||
|
if (!signal.aborted) calls.push("imagery-insert");
|
||||||
|
},
|
||||||
|
loadTerrain: () => calls.push("terrain"),
|
||||||
|
loadBuildings: () => calls.push("buildings"),
|
||||||
|
onProviderError: (provider, error) => errors.push([provider, error.message]),
|
||||||
|
});
|
||||||
|
releaseImagery();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ["imagery-start", "terrain", "buildings"]);
|
||||||
|
assert.deepEqual(errors, [["imagery", "imagery_startup_timeout"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("viewport readiness waits for discovery frames and cleans listeners", async () => {
|
||||||
|
const progress = eventHarness();
|
||||||
|
const postRender = eventHarness();
|
||||||
|
const globe = { tilesLoaded: true, tileLoadProgressEvent: progress.event };
|
||||||
|
const scene = { postRender: postRender.event, requestRender() {} };
|
||||||
|
let settled = false;
|
||||||
|
const ready = waitForGlobeViewportReady({ globe, scene, timeoutMs: 100, minimumRenderFrames: 2 })
|
||||||
|
.then((reason) => { settled = true; return reason; });
|
||||||
|
|
||||||
|
postRender.raise();
|
||||||
|
await Promise.resolve();
|
||||||
|
assert.equal(settled, false);
|
||||||
|
postRender.raise();
|
||||||
|
assert.equal(await ready, "loaded");
|
||||||
|
assert.equal(progress.size, 0);
|
||||||
|
assert.equal(postRender.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("viewport readiness times out and aborts without leaking listeners", async () => {
|
||||||
|
const timeoutProgress = eventHarness();
|
||||||
|
const timeoutPostRender = eventHarness();
|
||||||
|
assert.equal(await waitForGlobeViewportReady({
|
||||||
|
globe: { tilesLoaded: false, tileLoadProgressEvent: timeoutProgress.event },
|
||||||
|
scene: { postRender: timeoutPostRender.event, requestRender() {} },
|
||||||
|
timeoutMs: 1,
|
||||||
|
}), "timeout");
|
||||||
|
assert.equal(timeoutProgress.size, 0);
|
||||||
|
assert.equal(timeoutPostRender.size, 0);
|
||||||
|
|
||||||
|
const abortProgress = eventHarness();
|
||||||
|
const abortPostRender = eventHarness();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const pending = waitForGlobeViewportReady({
|
||||||
|
globe: { tilesLoaded: false, tileLoadProgressEvent: abortProgress.event },
|
||||||
|
scene: { postRender: abortPostRender.event, requestRender() {} },
|
||||||
|
signal: controller.signal,
|
||||||
|
timeoutMs: 100,
|
||||||
|
});
|
||||||
|
controller.abort();
|
||||||
|
assert.equal(await pending, "cancelled");
|
||||||
|
assert.equal(abortProgress.size, 0);
|
||||||
|
assert.equal(abortPostRender.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the live renderer uses the staged provider coordinator", async () => {
|
||||||
|
const renderer = await readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8");
|
||||||
|
assert.match(renderer, /runStagedMapProviders\(\{/);
|
||||||
|
assert.match(renderer, /waitForGlobeViewportReady\(\{/);
|
||||||
|
assert.match(renderer, /providerStartupAbort\.abort\(\)/);
|
||||||
|
assert.doesNotMatch(renderer, /Do not serialize provider startup/);
|
||||||
|
});
|
||||||
@@ -28,13 +28,14 @@ test("Map Page registers a provider-neutral reference-points slot and three stat
|
|||||||
|
|
||||||
test("existing layouts upgrade reference layers on read and renderer accepts their slot", async () => {
|
test("existing layouts upgrade reference layers on read and renderer accepts their slot", async () => {
|
||||||
const server = await readFile(new URL("server/catalog-server.mjs", root), "utf8");
|
const server = await readFile(new URL("server/catalog-server.mjs", root), "utf8");
|
||||||
const renderer = await readFile(new URL("apps/catalog/src/CesiumMapRenderer.tsx", root), "utf8");
|
const runtimeLayers = await readFile(new URL("apps/catalog/src/mapCesiumRuntimeLayers.ts", root), "utf8");
|
||||||
const runtime = await readFile(new URL("apps/catalog/src/useMapReferenceRuntime.ts", root), "utf8");
|
const runtime = await readFile(new URL("apps/catalog/src/useMapReferenceRuntime.ts", root), "utf8");
|
||||||
|
|
||||||
assert.match(server, /pageLayoutMatch\[1\] === "map" \? validateMapPageLayout\(stored\) : stored/);
|
assert.match(server, /pageLayoutMatch\[1\] === "map" \? validateMapPageLayout\(stored\) : stored/);
|
||||||
assert.match(server, /referenceLayers: structuredClone\(canonicalMapReferenceLayers\)/);
|
assert.match(server, /referenceLayers: structuredClone\(canonicalMapReferenceLayers\)/);
|
||||||
assert.match(renderer, /binding\.slotId === "reference-points"/);
|
assert.match(runtimeLayers, /binding\.slotId === "reference-points"/);
|
||||||
assert.match(runtime, /\/api\/map-gateway\/api\/map\/reference-sources\/v1\/profiles\//);
|
assert.match(runtime, /\/api\/map-gateway\/api\/map\/reference-sources\/v1\/profiles\//);
|
||||||
assert.match(runtime, /allowedAttributes = new Set\(\["name", "category", "network", "operator", "official_name", "local_name", "uic_ref", "wheelchair"\]\)/);
|
assert.match(runtime, /allowedAttributes = new Set\(\["name", "category", "network", "operator", "official_name", "local_name", "alternate_names", "uic_ref", "wheelchair"\]\)/);
|
||||||
|
assert.match(runtime, /alternate_names/);
|
||||||
assert.doesNotMatch(runtime, /credential|accessToken|providerEndpoint|rawPayload/);
|
assert.doesNotMatch(runtime, /credential|accessToken|providerEndpoint|rawPayload/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ test("reference subjects remain searchable by profile labels without a provider
|
|||||||
id: "map.reference.station.v1",
|
id: "map.reference.station.v1",
|
||||||
title: "Метро",
|
title: "Метро",
|
||||||
semanticTypes: ["map.station"],
|
semanticTypes: ["map.station"],
|
||||||
label: { mode: "attributes", fields: ["name", "official_name"] },
|
label: { mode: "attributes", fields: ["name", "official_name", "alternate_names"] },
|
||||||
};
|
};
|
||||||
const index = buildMapSearchIndex({
|
const index = buildMapSearchIndex({
|
||||||
runtimeBindings: [{
|
runtimeBindings: [{
|
||||||
@@ -93,6 +93,7 @@ test("reference subjects remain searchable by profile labels without a provider
|
|||||||
presentationProfileId: stationProfile.id,
|
presentationProfileId: stationProfile.id,
|
||||||
facts: [point("osm.node.1", "map.station", {
|
facts: [point("osm.node.1", "map.station", {
|
||||||
name: "Петроградская",
|
name: "Петроградская",
|
||||||
|
alternate_names: ["Petrogradskaya station"],
|
||||||
provider_note: "not searchable",
|
provider_note: "not searchable",
|
||||||
})],
|
})],
|
||||||
}],
|
}],
|
||||||
@@ -100,6 +101,7 @@ test("reference subjects remain searchable by profile labels without a provider
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(searchMapSubjects(index, "петрог").at(0)?.title, "Петроградская");
|
assert.equal(searchMapSubjects(index, "петрог").at(0)?.title, "Петроградская");
|
||||||
|
assert.equal(searchMapSubjects(index, "Petrogradskaya").at(0)?.sourceId, "osm.node.1");
|
||||||
assert.equal(searchMapSubjects(index, "osm.node.1").at(0)?.groupTitle, "Метро");
|
assert.equal(searchMapSubjects(index, "osm.node.1").at(0)?.groupTitle, "Метро");
|
||||||
assert.deepEqual(searchMapSubjects(index, "provider_note"), []);
|
assert.deepEqual(searchMapSubjects(index, "provider_note"), []);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -275,17 +275,20 @@ test("restricted identity aspects render full identifiers and bounded string lis
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("Cesium pin and label share one entity selection that opens the subject card", async () => {
|
test("Cesium pin and label share one entity selection that opens the subject card", async () => {
|
||||||
const [renderer, preview] = await Promise.all([
|
const [renderer, preview, windows, state] = await Promise.all([
|
||||||
readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"),
|
readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"),
|
readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../apps/catalog/src/MapSubjectWorkspaceWindows.tsx", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../apps/catalog/src/mapWorkspaceState.mjs", import.meta.url), "utf8"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.match(renderer, /viewer\?\.scene\.pick/);
|
assert.match(renderer, /viewer\?\.scene\.pick/);
|
||||||
assert.match(renderer, /pickedId instanceof Entity/);
|
assert.match(renderer, /pickedId instanceof Entity/);
|
||||||
assert.match(renderer, /onSelectRef\.current\?\.\(pickedId\.id\)/);
|
assert.match(renderer, /onSelectRef\.current\?\.\(pickedId\.id\)/);
|
||||||
assert.match(preview, /const handleSelect = useCallback/);
|
assert.match(preview, /const handleSelect = useCallback/);
|
||||||
assert.match(preview, /setSubjectCardOpen\(true\)/);
|
assert.match(preview, /type: "select-entity"/);
|
||||||
assert.match(preview, /title=\{selectedSubjectCard\.title\}/);
|
assert.match(state, /subjectCard: \{ \.\.\.state\.subjectCard, open: true, tabId \}/);
|
||||||
assert.match(preview, /Карточка объекта:/);
|
assert.match(windows, /title=\{selectedSubjectCard\.title\}/);
|
||||||
assert.match(preview, /SegmentedControl/);
|
assert.match(windows, /Карточка объекта:/);
|
||||||
|
assert.match(windows, /SegmentedControl/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildFilteredMapTargets,
|
||||||
|
buildMapPresentationFilters,
|
||||||
|
buildSelectableMapEntities,
|
||||||
|
planMapSubjectReveal,
|
||||||
|
primaryMapRuntimeBindings,
|
||||||
|
} from "../apps/catalog/src/mapWorkspaceModel.mjs";
|
||||||
|
import { mapRuntimeEntityId, mapRuntimeFactKey } from "../apps/catalog/src/mapRuntimeIdentity.mjs";
|
||||||
|
import { localSectorAtGeodetic, localSectorSummary } from "../apps/catalog/src/mapSectorGrid.mjs";
|
||||||
|
|
||||||
|
const origin = { latitude: 55.7558, longitude: 37.6173 };
|
||||||
|
const gridProfile = {
|
||||||
|
mode: "3d",
|
||||||
|
stepKm: 1,
|
||||||
|
tileSizeKm: 5,
|
||||||
|
graticuleStepDegrees: 1,
|
||||||
|
};
|
||||||
|
const primaryBinding = {
|
||||||
|
id: "positions",
|
||||||
|
order: 1,
|
||||||
|
dataProductId: "fleet.positions.current.v5",
|
||||||
|
slotId: "points",
|
||||||
|
delivery: "snapshot+patch",
|
||||||
|
semanticTypes: ["map.moving_object"],
|
||||||
|
fieldProjection: ["display_name", "signal_state", "position_source", "object_kind"],
|
||||||
|
presentationProfileId: "moving",
|
||||||
|
};
|
||||||
|
const joinedBinding = {
|
||||||
|
...primaryBinding,
|
||||||
|
id: "identity",
|
||||||
|
order: 2,
|
||||||
|
dataProductId: "fleet.units.identity.current.v1",
|
||||||
|
joinToBindingId: "positions",
|
||||||
|
};
|
||||||
|
const profile = {
|
||||||
|
id: "moving",
|
||||||
|
title: "Current positions",
|
||||||
|
semanticTypes: ["map.moving_object"],
|
||||||
|
label: { mode: "attributes", fields: ["display_name"], maxLength: 80 },
|
||||||
|
target: { variant: "elevated-spike" },
|
||||||
|
facets: [{
|
||||||
|
id: "signal",
|
||||||
|
field: "signal_state",
|
||||||
|
filterable: true,
|
||||||
|
counter: true,
|
||||||
|
values: [
|
||||||
|
{ value: "active", label: "Online", order: 1 },
|
||||||
|
{ value: "inactive", label: "Offline", order: 2 },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
styles: [{ id: "active", color: "#fff", opacity: 1 }],
|
||||||
|
classes: [{ id: "active", label: "Online", priority: 1, match: [], styleId: "active", renderable: true }],
|
||||||
|
defaultClassId: "active",
|
||||||
|
sort: [{ field: "signal_state", order: ["active", "inactive"] }],
|
||||||
|
};
|
||||||
|
|
||||||
|
function pointFact(sourceId, signalState, coordinates, displayName = sourceId) {
|
||||||
|
return {
|
||||||
|
sourceId,
|
||||||
|
semanticType: "map.moving_object",
|
||||||
|
observedAt: "2026-08-09T12:00:00.000Z",
|
||||||
|
receivedAt: "2026-08-09T12:00:01.000Z",
|
||||||
|
attributes: {
|
||||||
|
display_name: displayName,
|
||||||
|
signal_state: signalState,
|
||||||
|
position_source: "gelios",
|
||||||
|
object_kind: "tracked_unit",
|
||||||
|
},
|
||||||
|
geometry: { type: "Point", coordinates },
|
||||||
|
presentationStatus: signalState,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const inactive = pointFact("unit-offline", "inactive", [37.6173, 55.7558], "Альфа");
|
||||||
|
const active = pointFact("unit-online", "active", [37.618, 55.756], "Янтарь");
|
||||||
|
const runtimeBindings = [{
|
||||||
|
bindingId: "positions",
|
||||||
|
dataProductId: primaryBinding.dataProductId,
|
||||||
|
slotId: "points",
|
||||||
|
presentationProfileId: "moving",
|
||||||
|
facts: [inactive, active],
|
||||||
|
cursor: "1",
|
||||||
|
state: "ready",
|
||||||
|
}, {
|
||||||
|
bindingId: "identity",
|
||||||
|
dataProductId: joinedBinding.dataProductId,
|
||||||
|
slotId: "subject-aspect",
|
||||||
|
presentationProfileId: "moving",
|
||||||
|
facts: [active],
|
||||||
|
cursor: "1",
|
||||||
|
state: "ready",
|
||||||
|
}];
|
||||||
|
|
||||||
|
test("stable runtime identity uses binding, semantic type and source id only", () => {
|
||||||
|
const moved = { ...active, geometry: { type: "Point", coordinates: [40, 60] } };
|
||||||
|
assert.equal(mapRuntimeFactKey(active), "map.moving_object\u0000unit-online");
|
||||||
|
assert.equal(mapRuntimeEntityId("positions", active), mapRuntimeEntityId("positions", moved));
|
||||||
|
assert.equal(mapRuntimeEntityId("positions", active), "nodedc-runtime:positions:map.moving_object:unit-online");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("joined aspects enrich a primary subject and never become map entities", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
primaryMapRuntimeBindings(runtimeBindings, [joinedBinding, primaryBinding]).map((binding) => binding.bindingId),
|
||||||
|
["positions"],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
buildSelectableMapEntities(runtimeBindings, [joinedBinding, primaryBinding], [profile]).map((entity) => entity.fact.sourceId),
|
||||||
|
["unit-online", "unit-offline"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("object overview preserves the same MCP profile sort as facets and renderer", () => {
|
||||||
|
const filters = { positions: { visible: true, facets: {} } };
|
||||||
|
const selectable = buildSelectableMapEntities(runtimeBindings, [primaryBinding, joinedBinding], [profile]);
|
||||||
|
const targets = buildFilteredMapTargets(runtimeBindings, [primaryBinding, joinedBinding], [profile], filters);
|
||||||
|
|
||||||
|
// Alphabetical title order would be Альфа, Янтарь. Declared state order is
|
||||||
|
// active, inactive and must remain authoritative everywhere.
|
||||||
|
assert.deepEqual(selectable.map((entity) => entity.title), ["Янтарь", "Альфа"]);
|
||||||
|
assert.deepEqual(targets.map((target) => target.title), ["Янтарь", "Альфа"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("explicit empty facets remain match-nothing through workspace projection", () => {
|
||||||
|
const subjectStates = {
|
||||||
|
positions: {
|
||||||
|
bindingId: "positions",
|
||||||
|
visible: true,
|
||||||
|
filters: { signal_state: [] },
|
||||||
|
window: { open: false, rect: { x: 0, y: 0, width: 280, height: 260 }, maximized: false, zIndex: 20 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const filters = buildMapPresentationFilters(subjectStates, [primaryBinding], [profile]);
|
||||||
|
assert.deepEqual(filters.positions.facets, { signal_state: [] });
|
||||||
|
assert.deepEqual(buildFilteredMapTargets(runtimeBindings, [primaryBinding], [profile], filters), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("search reveal removes only constraints hiding the selected subject", () => {
|
||||||
|
const address = localSectorAtGeodetic(origin, {
|
||||||
|
lod: 1,
|
||||||
|
originLatitude: origin.latitude,
|
||||||
|
originLongitude: origin.longitude,
|
||||||
|
stepMeters: 1_000,
|
||||||
|
});
|
||||||
|
const selectedSector = {
|
||||||
|
...localSectorSummary(address, {
|
||||||
|
lod: 1,
|
||||||
|
originLatitude: origin.latitude,
|
||||||
|
originLongitude: origin.longitude,
|
||||||
|
stepMeters: 1_000,
|
||||||
|
tileSizeMeters: 5_000,
|
||||||
|
}),
|
||||||
|
mode: "3d",
|
||||||
|
units: "meters-enu",
|
||||||
|
volume: null,
|
||||||
|
};
|
||||||
|
const entity = buildSelectableMapEntities(runtimeBindings, [primaryBinding], [profile])
|
||||||
|
.find((candidate) => candidate.fact.sourceId === "unit-offline");
|
||||||
|
const subjectState = {
|
||||||
|
bindingId: "positions",
|
||||||
|
visible: false,
|
||||||
|
filters: { signal_state: ["active"] },
|
||||||
|
window: { open: true, rect: { x: 10, y: 20, width: 280, height: 260 }, maximized: false, zIndex: 42 },
|
||||||
|
};
|
||||||
|
const reveal = planMapSubjectReveal({
|
||||||
|
entity,
|
||||||
|
subjectState,
|
||||||
|
profile,
|
||||||
|
selectedSector,
|
||||||
|
gridProfiles: [gridProfile],
|
||||||
|
origin,
|
||||||
|
hideOutsideSector: true,
|
||||||
|
scope: {
|
||||||
|
excludedBindingIds: ["positions", "other"],
|
||||||
|
excludedProviders: ["gelios", "other"],
|
||||||
|
excludedObjectKinds: ["tracked_unit", "other"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(reveal.subjectState.visible, true);
|
||||||
|
assert.deepEqual(reveal.subjectState.filters, {});
|
||||||
|
assert.deepEqual(reveal.subjectState.window, subjectState.window);
|
||||||
|
assert.equal(reveal.hideOutsideSector, true);
|
||||||
|
assert.deepEqual(reveal.scope, {
|
||||||
|
excludedBindingIds: ["other"],
|
||||||
|
excludedProviders: ["other"],
|
||||||
|
excludedObjectKinds: ["other"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("search keeps the active sector but relaxes outside clipping for an outside subject", () => {
|
||||||
|
const address = localSectorAtGeodetic(origin, {
|
||||||
|
lod: 1,
|
||||||
|
originLatitude: origin.latitude,
|
||||||
|
originLongitude: origin.longitude,
|
||||||
|
stepMeters: 1_000,
|
||||||
|
});
|
||||||
|
const selectedSector = {
|
||||||
|
...localSectorSummary(address, {
|
||||||
|
lod: 1,
|
||||||
|
originLatitude: origin.latitude,
|
||||||
|
originLongitude: origin.longitude,
|
||||||
|
stepMeters: 1_000,
|
||||||
|
tileSizeMeters: 5_000,
|
||||||
|
}),
|
||||||
|
mode: "3d",
|
||||||
|
units: "meters-enu",
|
||||||
|
volume: null,
|
||||||
|
};
|
||||||
|
const outsideEntity = {
|
||||||
|
...buildSelectableMapEntities(runtimeBindings, [primaryBinding], [profile])[0],
|
||||||
|
fact: pointFact("far-away", "active", [50, 60]),
|
||||||
|
};
|
||||||
|
const subjectState = {
|
||||||
|
bindingId: "positions",
|
||||||
|
visible: true,
|
||||||
|
filters: {},
|
||||||
|
window: { open: false, rect: { x: 0, y: 0, width: 280, height: 260 }, maximized: false, zIndex: 20 },
|
||||||
|
};
|
||||||
|
const reveal = planMapSubjectReveal({
|
||||||
|
entity: outsideEntity,
|
||||||
|
subjectState,
|
||||||
|
profile,
|
||||||
|
selectedSector,
|
||||||
|
gridProfiles: [gridProfile],
|
||||||
|
origin,
|
||||||
|
hideOutsideSector: true,
|
||||||
|
scope: { excludedBindingIds: [], excludedProviders: [], excludedObjectKinds: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(reveal.hideOutsideSector, false);
|
||||||
|
assert.equal(selectedSector.id, reveal ? selectedSector.id : null);
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { createMapWorkspaceState, mapWorkspaceReducer } from "../apps/catalog/src/mapWorkspaceState.mjs";
|
||||||
|
|
||||||
|
const rect = (x, y, width = 280, height = 260) => ({ x, y, width, height });
|
||||||
|
const subjectStates = {
|
||||||
|
positions: {
|
||||||
|
bindingId: "positions",
|
||||||
|
visible: true,
|
||||||
|
filters: {},
|
||||||
|
window: { open: false, rect: rect(24, 56), maximized: false, zIndex: 20 },
|
||||||
|
},
|
||||||
|
zones: {
|
||||||
|
bindingId: "zones",
|
||||||
|
visible: true,
|
||||||
|
filters: {},
|
||||||
|
window: { open: false, rect: rect(52, 84), maximized: false, zIndex: 21 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function initialState() {
|
||||||
|
return createMapWorkspaceState({
|
||||||
|
subjectStates: structuredClone(subjectStates),
|
||||||
|
sectorWindowRect: rect(24, 72, 380, 530),
|
||||||
|
subjectCardRect: rect(940, 72, 390, 520),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("one reducer owns the complete floating-window stack", () => {
|
||||||
|
const opened = mapWorkspaceReducer(initialState(), { type: "open-subject-window", bindingId: "positions" });
|
||||||
|
assert.equal(opened.subjectStates.positions.window.open, true);
|
||||||
|
assert.equal(opened.activeWindowId, "binding:positions");
|
||||||
|
assert.ok(opened.subjectStates.positions.window.zIndex > opened.sector.window.zIndex);
|
||||||
|
|
||||||
|
const activatedCard = mapWorkspaceReducer(opened, {
|
||||||
|
type: "select-entity",
|
||||||
|
entityId: "nodedc-runtime:positions:map.moving_object:unit-1",
|
||||||
|
validTabIds: ["overview", "position"],
|
||||||
|
defaultTabId: "overview",
|
||||||
|
});
|
||||||
|
assert.equal(activatedCard.subjectCard.open, true);
|
||||||
|
assert.equal(activatedCard.activeWindowId, "subject-card");
|
||||||
|
assert.ok(activatedCard.subjectCard.zIndex > opened.subjectStates.positions.window.zIndex);
|
||||||
|
|
||||||
|
// Re-activating the active window is stable and does not inflate z-index.
|
||||||
|
assert.equal(
|
||||||
|
mapWorkspaceReducer(activatedCard, { type: "activate-window", windowId: "subject-card" }),
|
||||||
|
activatedCard,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("selecting a domain subject never clears the active grid sector", () => {
|
||||||
|
const sector = { id: "grid/local/sector", lod: 2, mode: "3d" };
|
||||||
|
const withSector = mapWorkspaceReducer(initialState(), { type: "select-sector", selection: sector });
|
||||||
|
const withSubject = mapWorkspaceReducer(withSector, {
|
||||||
|
type: "select-entity",
|
||||||
|
entityId: "nodedc-runtime:positions:map.moving_object:unit-1",
|
||||||
|
validTabIds: ["overview"],
|
||||||
|
defaultTabId: "overview",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(withSubject.selectedSector, sector);
|
||||||
|
assert.equal(withSubject.selectedEntityId, "nodedc-runtime:positions:map.moving_object:unit-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sector deactivation clears only sector scope and keeps subject UI state", () => {
|
||||||
|
let state = mapWorkspaceReducer(initialState(), { type: "select-sector", selection: { id: "sector", lod: 1, mode: "3d" } });
|
||||||
|
state = mapWorkspaceReducer(state, { type: "set-sector-scope-enabled", dimension: "binding", value: "positions", enabled: false });
|
||||||
|
state = mapWorkspaceReducer(state, { type: "open-subject-window", bindingId: "positions" });
|
||||||
|
state = mapWorkspaceReducer(state, { type: "deactivate-sector" });
|
||||||
|
|
||||||
|
assert.equal(state.selectedSector, null);
|
||||||
|
assert.deepEqual(state.sector.scope, {
|
||||||
|
excludedBindingIds: [],
|
||||||
|
excludedProviders: [],
|
||||||
|
excludedObjectKinds: [],
|
||||||
|
});
|
||||||
|
assert.equal(state.subjectStates.positions.window.open, true);
|
||||||
|
assert.equal(state.subjectStates.positions.visible, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("search reveal state is applied atomically without replacing window geometry", () => {
|
||||||
|
const state = initialState();
|
||||||
|
const originalWindow = state.subjectStates.positions.window;
|
||||||
|
const revealedSubject = {
|
||||||
|
...state.subjectStates.positions,
|
||||||
|
visible: true,
|
||||||
|
filters: { signal_state: ["inactive"] },
|
||||||
|
};
|
||||||
|
const next = mapWorkspaceReducer(state, {
|
||||||
|
type: "reveal-subject",
|
||||||
|
bindingId: "positions",
|
||||||
|
subjectState: revealedSubject,
|
||||||
|
hideOutsideSector: false,
|
||||||
|
scope: { excludedBindingIds: [], excludedProviders: ["other"], excludedObjectKinds: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(next.subjectStates.positions.window, originalWindow);
|
||||||
|
assert.deepEqual(next.subjectStates.positions.filters, { signal_state: ["inactive"] });
|
||||||
|
assert.deepEqual(next.sector.scope.excludedProviders, ["other"]);
|
||||||
|
});
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
import {createElement} from "react";
|
|
||||||
import {renderToStaticMarkup} from "react-dom/server";
|
|
||||||
import {ProgressBar,ResourceRow} from "../packages/ui-react/dist/index.js";
|
|
||||||
|
|
||||||
test("progress exposes bounded measured values and unknown state without false percentages",()=>{
|
|
||||||
for(const [value, expected] of [[-1,0],[.5,50],[2,100]]) {
|
|
||||||
const html=renderToStaticMarkup(createElement(ProgressBar,{label:"Этапы",value}));
|
|
||||||
assert.match(html,new RegExp(`aria-valuenow="${expected}"`));
|
|
||||||
}
|
|
||||||
for(const value of [undefined,NaN,Infinity]) {
|
|
||||||
const html=renderToStaticMarkup(createElement(ProgressBar,{label:"Ожидание",value}));
|
|
||||||
assert.doesNotMatch(html,/aria-valuenow/);assert.match(html,/data-indeterminate="true"/);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
test("row keeps progress between name and actions and suppresses duplicate status",()=>{
|
|
||||||
const html=renderToStaticMarkup(createElement(ResourceRow,{title:"Камера",status:"Old status",actions:"Actions",progress:{label:"Загрузка",value:.2}}));
|
|
||||||
assert.ok(html.indexOf("Камера")<html.indexOf('role="progressbar"'));
|
|
||||||
assert.ok(html.indexOf('role="progressbar"')<html.indexOf("Actions"));
|
|
||||||
assert.doesNotMatch(html,/Old status/);
|
|
||||||
});
|
|
||||||
@@ -30,17 +30,7 @@ test("exact editing is a stable native caret field without remount, forced selec
|
|||||||
readFile(new URL("../packages/ui-react/src/Window.tsx", import.meta.url), "utf8"),
|
readFile(new URL("../packages/ui-react/src/Window.tsx", import.meta.url), "utf8"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.match(component, /exactValueBounds\?: \{/);
|
assert.match(component, /normalizeEditedRangeValue\(parsed, min, max, step\)/);
|
||||||
assert.match(component, /exactValueBounds\?\.min \?\? Number\.NEGATIVE_INFINITY/);
|
|
||||||
assert.match(component, /exactValueBounds\?\.max \?\? Number\.POSITIVE_INFINITY/);
|
|
||||||
assert.match(component, /normalizeEditedRangeValue\(parsed, min, max, step, exactValueBounds\)/);
|
|
||||||
assert.match(component, /value=\{draft\}/);
|
|
||||||
assert.match(component, /data-contrast=\{editorContrast\}/);
|
|
||||||
assert.match(component, /fillRight >= textRight \? "fill" : "base"/);
|
|
||||||
assert.match(component, /new ResizeObserver\(updateContrast\)/);
|
|
||||||
const editorSource = component.match(/className="nodedc-range__editor"[\s\S]*?onBlur=\{\(\) => \{[\s\S]*?\n \}\}\n \/>/)?.[0] ?? "";
|
|
||||||
assert.notEqual(editorSource, "");
|
|
||||||
assert.doesNotMatch(editorSource, /onPointerDown=/);
|
|
||||||
assert.match(component, /event\.key === "Enter"/);
|
assert.match(component, /event\.key === "Enter"/);
|
||||||
assert.match(component, /event\.key === "Escape"/);
|
assert.match(component, /event\.key === "Escape"/);
|
||||||
assert.match(component, /event\.stopPropagation\(\)/);
|
assert.match(component, /event\.stopPropagation\(\)/);
|
||||||
@@ -53,7 +43,6 @@ test("exact editing is a stable native caret field without remount, forced selec
|
|||||||
assert.match(styles, /\.nodedc-range input\[type="range"\][\s\S]*?z-index: 4/);
|
assert.match(styles, /\.nodedc-range input\[type="range"\][\s\S]*?z-index: 4/);
|
||||||
assert.match(styles, /\.nodedc-range__editor[\s\S]*?z-index: 5[\s\S]*?background: transparent[\s\S]*?color: transparent[\s\S]*?caret-color: transparent[\s\S]*?box-shadow: none/);
|
assert.match(styles, /\.nodedc-range__editor[\s\S]*?z-index: 5[\s\S]*?background: transparent[\s\S]*?color: transparent[\s\S]*?caret-color: transparent[\s\S]*?box-shadow: none/);
|
||||||
assert.match(styles, /\.nodedc-range__editor\[data-active\][\s\S]*?caret-color: currentColor/);
|
assert.match(styles, /\.nodedc-range__editor\[data-active\][\s\S]*?caret-color: currentColor/);
|
||||||
assert.match(styles, /\.nodedc-range__editor\[data-active\]\[data-contrast="fill"\][\s\S]*?rgb\(var\(--nodedc-on-accent-rgb\)\)/);
|
|
||||||
assert.match(windowComponent, /const onCloseRef = useRef\(onClose\)/);
|
assert.match(windowComponent, /const onCloseRef = useRef\(onClose\)/);
|
||||||
assert.match(windowComponent, /onCloseRef\.current = onClose/);
|
assert.match(windowComponent, /onCloseRef\.current = onClose/);
|
||||||
assert.match(windowComponent, /onCloseRef\.current\(\)/);
|
assert.match(windowComponent, /onCloseRef\.current\(\)/);
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { readFile } from "node:fs/promises";
|
|
||||||
import test from "node:test";
|
|
||||||
|
|
||||||
function lineNumber(lines, value) {
|
|
||||||
return lines.findIndex((line) => line.includes(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertLine(lines, value, message) {
|
|
||||||
const index = lineNumber(lines, value);
|
|
||||||
assert.ok(index >= 0, `${message} (expected near line 1+, missing: ${value})`);
|
|
||||||
return index + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractBlock(lines, key) {
|
|
||||||
const start = lines.findIndex((line) => line.trim() === `${key} = """`);
|
|
||||||
assert.ok(start >= 0, `missing ${key} block start`);
|
|
||||||
let end = start + 1;
|
|
||||||
while (end < lines.length && lines[end] !== "\"\"\"") {
|
|
||||||
end += 1;
|
|
||||||
}
|
|
||||||
assert.ok(end < lines.length, `unterminated ${key} block`);
|
|
||||||
return lines.slice(start + 1, end).join("\n").replace(/\s+/g, " ").trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
test("global spark agent configuration matches governance contract", async () => {
|
|
||||||
const configLines = (await readFile(new URL("../.codex/config.toml", import.meta.url), "utf8")).split(/\r?\n/);
|
|
||||||
const agentsLine = assertLine(configLines, "[agents]", "global config should declare [agents]");
|
|
||||||
const enabledLine = assertLine(configLines, 'enabled = true', "global config should enable agents");
|
|
||||||
const threadLine = assertLine(configLines, "max_concurrent_threads_per_session = 2", "global config should cap threads at 2");
|
|
||||||
const defaultModelLine = assertLine(configLines, 'default_subagent_model = "gpt-5.6-terra"', "global config should set default subagent model");
|
|
||||||
const effortLine = assertLine(configLines, 'default_subagent_reasoning_effort = "low"', "global config should set default low reasoning for unpinned subagents");
|
|
||||||
const interruptLine = assertLine(configLines, "interrupt_message = true", "global config should keep interruption messages enabled");
|
|
||||||
|
|
||||||
assert.equal(configLines[agentsLine - 1].trim(), "[agents]", "[agents] block should be present");
|
|
||||||
assert.equal(configLines[enabledLine - 1].trim(), 'enabled = true');
|
|
||||||
assert.equal(configLines[threadLine - 1].trim(), "max_concurrent_threads_per_session = 2");
|
|
||||||
assert.equal(configLines[defaultModelLine - 1].trim(), 'default_subagent_model = "gpt-5.6-terra"');
|
|
||||||
assert.equal(configLines[effortLine - 1].trim(), 'default_subagent_reasoning_effort = "low"');
|
|
||||||
assert.equal(configLines[interruptLine - 1].trim(), "interrupt_message = true");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("spark_explorer contract", async () => {
|
|
||||||
const explorerLines = (await readFile(new URL("../.codex/agents/spark-explorer.toml", import.meta.url), "utf8")).split(/\r?\n/);
|
|
||||||
const instructions = extractBlock(explorerLines, "developer_instructions");
|
|
||||||
|
|
||||||
const nameLine = assertLine(explorerLines, 'name = "spark_explorer"', "explorer name should be spark_explorer");
|
|
||||||
const modelLine = assertLine(explorerLines, 'model = "gpt-5.3-codex-spark"', "explorer should pin model gpt-5.3-codex-spark");
|
|
||||||
const effortLine = assertLine(explorerLines, 'model_reasoning_effort = "medium"', "explorer should use medium reasoning");
|
|
||||||
const sandboxLine = assertLine(explorerLines, 'sandbox_mode = "read-only"', "explorer should use read-only sandbox mode");
|
|
||||||
|
|
||||||
assert.equal(explorerLines[nameLine - 1].trim(), 'name = "spark_explorer"');
|
|
||||||
assert.equal(explorerLines[modelLine - 1].trim(), 'model = "gpt-5.3-codex-spark"');
|
|
||||||
assert.equal(explorerLines[effortLine - 1].trim(), 'model_reasoning_effort = "medium"');
|
|
||||||
assert.equal(explorerLines[sandboxLine - 1].trim(), 'sandbox_mode = "read-only"');
|
|
||||||
assert.ok(
|
|
||||||
instructions.includes("Never edit, create, move, or delete files."),
|
|
||||||
"explorer instructions must prohibit edits",
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
instructions.includes("Never spawn another agent."),
|
|
||||||
"explorer instructions must prohibit nested agents",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("spark_worker contract", async () => {
|
|
||||||
const workerLines = (await readFile(new URL("../.codex/agents/spark-worker.toml", import.meta.url), "utf8")).split(/\r?\n/);
|
|
||||||
const instructions = extractBlock(workerLines, "developer_instructions");
|
|
||||||
|
|
||||||
const nameLine = assertLine(workerLines, 'name = "spark_worker"', "worker name should be spark_worker");
|
|
||||||
const modelLine = assertLine(workerLines, 'model = "gpt-5.3-codex-spark"', "worker should pin model gpt-5.3-codex-spark");
|
|
||||||
const effortLine = assertLine(workerLines, 'model_reasoning_effort = "medium"', "worker should use medium reasoning");
|
|
||||||
const sandboxLine = assertLine(workerLines, 'sandbox_mode = "workspace-write"', "worker should use workspace-write sandbox mode");
|
|
||||||
|
|
||||||
assert.equal(workerLines[nameLine - 1].trim(), 'name = "spark_worker"');
|
|
||||||
assert.equal(workerLines[modelLine - 1].trim(), 'model = "gpt-5.3-codex-spark"');
|
|
||||||
assert.equal(workerLines[effortLine - 1].trim(), 'model_reasoning_effort = "medium"');
|
|
||||||
assert.equal(workerLines[sandboxLine - 1].trim(), 'sandbox_mode = "workspace-write"');
|
|
||||||
assert.ok(
|
|
||||||
instructions.includes("exact allowed file"),
|
|
||||||
"worker instructions must require exact allowed path allowlist",
|
|
||||||
);
|
|
||||||
assert.ok(instructions.includes("Never commit, push, deploy, install dependencies, or change external systems."), "worker must not commit, push, deploy, or install");
|
|
||||||
assert.ok(instructions.includes("Never spawn another agent."), "worker must prohibit nested agents");
|
|
||||||
assert.ok(instructions.includes("One corrective retry is allowed after a failed check; then stop and return the failure evidence."), "worker must limit corrective retries");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("AGENTS and governance contract documents", async () => {
|
|
||||||
const agentsLines = (await readFile(new URL("../AGENTS.md", import.meta.url), "utf8")).split(/\r?\n/);
|
|
||||||
const governanceLines = (await readFile(new URL("../docs/CODEX_SUBAGENT_GOVERNANCE.md", import.meta.url), "utf8")).split(/\r?\n/);
|
|
||||||
|
|
||||||
assert.ok(
|
|
||||||
agentsLines.some((line) => line.includes("`docs/CODEX_SUBAGENT_GOVERNANCE.md`")),
|
|
||||||
"AGENTS.md must point to governance document",
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
agentsLines.some((line) => line.toLowerCase().includes("at most two subagents")),
|
|
||||||
"AGENTS.md should state max two subagents",
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
agentsLines.some((line) => line.toLowerCase().includes("one write-capable")),
|
|
||||||
"AGENTS.md should state max one writer subagent",
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.ok(
|
|
||||||
governanceLines.some((line) => line.includes("## Mandatory task packet")),
|
|
||||||
"governance should define mandatory task packet",
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
governanceLines.some((line) => line.includes("1. Objective: one concrete outcome.")),
|
|
||||||
"governance should include mandatory objective field",
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
governanceLines.some((line) => line.includes("2. Allowed scope: exact files, directories, or read-only tools.")),
|
|
||||||
"governance should include allowed scope field",
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
governanceLines.some((line) => line.includes("3. Forbidden actions: especially external writes, commits, pushes, and deploys.")),
|
|
||||||
"governance should include forbidden actions field",
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
governanceLines.some((line) => line.includes("4. Acceptance criteria: observable evidence of completion.")),
|
|
||||||
"governance should include acceptance criteria field",
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
governanceLines.some((line) => line.includes("5. Verification: exact checks the worker may run.")),
|
|
||||||
"governance should include verification field",
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
governanceLines.some((line) => line.includes("6. Output: a short structured summary, not raw logs.")),
|
|
||||||
"governance should include output field",
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
governanceLines.some((line) => line.includes("Reviews the diff and performs final verification itself.")),
|
|
||||||
"governance should retain final primary review and verification ownership",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { readFile } from "node:fs/promises";
|
|
||||||
import test from "node:test";
|
|
||||||
import { createElement } from "react";
|
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
|
||||||
import { SplitPane } from "../packages/ui-react/dist/index.js";
|
|
||||||
|
|
||||||
test("SplitPane exposes one controlled accessible separator", () => {
|
|
||||||
const markup = renderToStaticMarkup(createElement(SplitPane, {
|
|
||||||
primary: createElement("div", null, "Видео"),
|
|
||||||
secondary: createElement("div", null, "3D"),
|
|
||||||
primarySize: 64,
|
|
||||||
onPrimarySizeChange: () => {},
|
|
||||||
minPrimarySize: 25,
|
|
||||||
minSecondarySize: 30,
|
|
||||||
separatorLabel: "Изменить ширину представлений",
|
|
||||||
}));
|
|
||||||
|
|
||||||
assert.match(markup, /class="nodedc-split-pane"/);
|
|
||||||
assert.match(markup, /role="separator"/);
|
|
||||||
assert.match(markup, /aria-orientation="vertical"/);
|
|
||||||
assert.match(markup, /aria-valuemin="25"/);
|
|
||||||
assert.match(markup, /aria-valuemax="70"/);
|
|
||||||
assert.match(markup, /aria-valuenow="64"/);
|
|
||||||
assert.match(markup, /aria-valuetext="64% \/ 36%"/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("SplitPane can keep panel ownership stable without an inactive separator", () => {
|
|
||||||
const markup = renderToStaticMarkup(createElement(SplitPane, {
|
|
||||||
primary: createElement("div", null, "Видео"),
|
|
||||||
secondary: createElement("div", null, "3D"),
|
|
||||||
primarySize: 100,
|
|
||||||
onPrimarySizeChange: () => {},
|
|
||||||
separatorLabel: "Изменить ширину представлений",
|
|
||||||
resizable: false,
|
|
||||||
}));
|
|
||||||
assert.match(markup, /data-pane="primary"/);
|
|
||||||
assert.match(markup, /data-pane="secondary"/);
|
|
||||||
assert.doesNotMatch(markup, /role="separator"/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("SplitPane is registered, documented, cataloged and keyboard-addressable", async () => {
|
|
||||||
const [component, styles, registrySource, docs, catalog] = await Promise.all([
|
|
||||||
readFile(new URL("../packages/ui-react/src/SplitPane.tsx", import.meta.url), "utf8"),
|
|
||||||
readFile(new URL("../packages/ui-core/styles.css", import.meta.url), "utf8"),
|
|
||||||
readFile(new URL("../registry/components.json", import.meta.url), "utf8"),
|
|
||||||
readFile(new URL("../docs/COMPONENTS.md", import.meta.url), "utf8"),
|
|
||||||
readFile(new URL("../apps/catalog/src/CatalogApp.tsx", import.meta.url), "utf8"),
|
|
||||||
]);
|
|
||||||
const registry = JSON.parse(registrySource);
|
|
||||||
const entry = registry.components.find((item) => item.id === "split-pane");
|
|
||||||
|
|
||||||
assert.ok(entry?.exports.includes("SplitPane"));
|
|
||||||
assert.deepEqual(entry?.variants, ["vertical", "horizontal"]);
|
|
||||||
assert.match(component, /event\.key === "Home"/);
|
|
||||||
assert.match(component, /event\.key === "End"/);
|
|
||||||
assert.match(component, /setPointerCapture/);
|
|
||||||
assert.match(component, /activePointerIdRef\.current !== event\.pointerId/);
|
|
||||||
assert.match(component, /role="separator"/);
|
|
||||||
assert.match(styles, /\.nodedc-split-pane__separator:focus-visible::before/);
|
|
||||||
assert.match(docs, /## SplitPane/);
|
|
||||||
assert.match(catalog, /<SplitPane/);
|
|
||||||
});
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import test from "node:test";
|
|
||||||
|
|
||||||
const source = readFileSync(
|
|
||||||
new URL("../packages/ui-react/src/Toast.tsx", import.meta.url),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
|
|
||||||
test("toast items own independent ten-second terminal lifetimes", () => {
|
|
||||||
assert.match(source, /const DEFAULT_TOAST_DURATION_MS = 10_000/);
|
|
||||||
assert.match(source, /function TimedToastCard/);
|
|
||||||
assert.match(source, /item\.tone === "loading" \? null : DEFAULT_TOAST_DURATION_MS/);
|
|
||||||
assert.match(source, /window\.setTimeout\(\(\) => dismissRef\.current\(item\.id\), duration\)/);
|
|
||||||
assert.match(source, /\[item\.durationMs, item\.id, item\.tone\]/);
|
|
||||||
assert.match(source, /items\.map\(\(item\) => <TimedToastCard key=\{item\.id\}/);
|
|
||||||
assert.doesNotMatch(source, /items\.flatMap/);
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user