Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88cbef1aba | ||
|
|
f645725e8c | ||
|
|
b70e165910 |
@@ -2,10 +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 { createTemplateFeatures, getPageTemplate, pageTemplates, type PageTemplateDefinition } from "@nodedc/page-patterns";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
LoadingRegion,
|
||||
StatusBadge,
|
||||
ProgressBar,
|
||||
AdminNavigationPanel,
|
||||
AppHeader,
|
||||
ApplicationPanel,
|
||||
@@ -32,13 +28,10 @@ import {
|
||||
InspectorSelectField,
|
||||
MediaSourceField,
|
||||
RangeControl,
|
||||
ResourceRow,
|
||||
ResourceList,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
ShareAccessModal,
|
||||
ShareLinkModal,
|
||||
SplitPane,
|
||||
SortableItem,
|
||||
SortableScope,
|
||||
SettingsCard,
|
||||
@@ -72,7 +65,8 @@ import {
|
||||
type DesignProfileSummary,
|
||||
type DesignProfileStatus,
|
||||
} 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 {
|
||||
mapDesignFragmentForLayout,
|
||||
mapDesignFragmentFromLayout,
|
||||
@@ -83,7 +77,6 @@ import {
|
||||
type StoredLayout,
|
||||
} from "./designProfile.js";
|
||||
import { FoundrySettingsModal } from "./FoundrySettingsModal.js";
|
||||
import { EnvironmentCatalogDemo } from "./EnvironmentCatalogDemo.js";
|
||||
|
||||
type CatalogSection = "controls" | "media" | "glass" | "status" | "modals" | "icons";
|
||||
type StudioContext = "visual" | "pages" | "applications";
|
||||
@@ -201,7 +194,7 @@ const iconGroups: Array<{ title: string; note: string; icons: IconName[] }> = [
|
||||
{
|
||||
title: "Окно и слой",
|
||||
note: "Launcher / Hub",
|
||||
icons: ["close", "plus", "expand", "minimize", "refresh", "panel", "apps", "layers"],
|
||||
icons: ["close", "plus", "expand", "minimize", "refresh", "panel", "apps"],
|
||||
},
|
||||
{
|
||||
title: "Навигация",
|
||||
@@ -216,7 +209,7 @@ const iconGroups: Array<{ title: string; note: string; icons: IconName[] }> = [
|
||||
{
|
||||
title: "Состояние и доступ",
|
||||
note: "Вся платформа",
|
||||
icons: ["check", "alert", "info", "activity", "lock", "key", "shield", "circle", "eye", "eye-off"],
|
||||
icons: ["check", "alert", "activity", "lock", "key", "shield", "circle"],
|
||||
},
|
||||
{
|
||||
title: "Сущности",
|
||||
@@ -226,7 +219,7 @@ const iconGroups: Array<{ title: string; note: string; icons: IconName[] }> = [
|
||||
{
|
||||
title: "Контент",
|
||||
note: "SEO / BIM / Engine",
|
||||
icons: ["camera", "plan", "play", "stop", "image", "video", "file", "folder", "clipboard", "settings"],
|
||||
icons: ["image", "video", "file", "folder", "clipboard", "settings"],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -235,7 +228,6 @@ const iconLabels: Record<IconName, string> = {
|
||||
alert: "Предупреждение",
|
||||
apps: "Приложения",
|
||||
building: "Компания",
|
||||
camera: "Камера",
|
||||
check: "Готово",
|
||||
"chevron-down": "Раскрыть",
|
||||
"chevron-left": "Назад",
|
||||
@@ -247,8 +239,6 @@ const iconLabels: Record<IconName, string> = {
|
||||
database: "База данных",
|
||||
download: "Скачать",
|
||||
edit: "Редактировать",
|
||||
eye: "Показать",
|
||||
"eye-off": "Скрыть",
|
||||
expand: "Развернуть",
|
||||
external: "Открыть снаружи",
|
||||
file: "Файл",
|
||||
@@ -257,17 +247,13 @@ const iconLabels: Record<IconName, string> = {
|
||||
grid: "Обзор",
|
||||
image: "Изображение",
|
||||
inbox: "Уведомления",
|
||||
info: "Информация",
|
||||
key: "Ключ",
|
||||
layers: "Слои",
|
||||
list: "Список задач",
|
||||
lock: "Закрыто",
|
||||
mail: "Приглашение",
|
||||
minimize: "Свернуть",
|
||||
network: "Связи",
|
||||
panel: "Панель",
|
||||
plan: "План",
|
||||
play: "Воспроизвести",
|
||||
target: "Таргеты",
|
||||
plus: "Добавить",
|
||||
profile: "Профиль",
|
||||
@@ -277,7 +263,6 @@ const iconLabels: Record<IconName, string> = {
|
||||
settings: "Настройки",
|
||||
shield: "Доступ",
|
||||
sliders: "Параметры",
|
||||
stop: "Остановить",
|
||||
trash: "Удалить",
|
||||
upload: "Загрузить",
|
||||
users: "Участники",
|
||||
@@ -375,7 +360,6 @@ export function CatalogApp() {
|
||||
const [workspaceWindowDemoOpen, setWorkspaceWindowDemoOpen] = useState(true);
|
||||
const [workspaceWindowDemoMaximized, setWorkspaceWindowDemoMaximized] = useState(false);
|
||||
const [workspaceWindowDemoRect, setWorkspaceWindowDemoRect] = useState<WorkspaceWindowRect>({ x: 24, y: 24, width: 340, height: 230 });
|
||||
const [splitPaneDemoSize, setSplitPaneDemoSize] = useState(50);
|
||||
const [sidePanelDemoOpen, setSidePanelDemoOpen] = useState(true);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
@@ -1295,9 +1279,7 @@ export function CatalogApp() {
|
||||
<>
|
||||
<ControlRow label="Цвет"><ColorField value={lightColor} onChange={setLightColor} /></ControlRow>
|
||||
<RangeControl label="Яркость" value={brightness} min={0} max={100} formatValue={(value) => `${value}%`} onChange={setBrightness} />
|
||||
<div style={{ height: 180 }} title="Тонкая шкала: границы справа; подпись и значение закреплены в центре, контраст следует заливке"><RangeControl orientation="vertical" label="Срез" value={brightness / 100 * 52.7 - 1.2} min={-1.2} max={51.5} step="any" formatValue={(value) => `${value.toFixed(1).replace('.', ',')} м`} formatLimit={(value) => value.toFixed(1).replace('.', ',')} onChange={(value) => setBrightness((value + 1.2) / 52.7 * 100)} /></div>
|
||||
<div style={{ height: 180, display: 'flex', justifyContent: 'flex-end' }} title="Шкала справа: границы слева; без контура при drag и фокусе, фокус подчёркивает подпись"><RangeControl orientation="vertical" limitSide="left" label="Срез" value={brightness / 100 * 52.7 - 1.2} min={-1.2} max={51.5} step="any" formatValue={(value) => `${value.toFixed(1).replace('.', ',')} м`} formatLimit={(value) => value.toFixed(1).replace('.', ',')} onChange={(value) => setBrightness((value + 1.2) / 52.7 * 100)} /></div>
|
||||
<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} />
|
||||
</>
|
||||
),
|
||||
},
|
||||
@@ -1343,19 +1325,11 @@ export function CatalogApp() {
|
||||
case "controls":
|
||||
return (
|
||||
<div className="catalog-grid">
|
||||
<Preview title="Selection" note="integrated / split / inline">
|
||||
<Preview title="Selection" note="integrated / split">
|
||||
<div className="catalog-form">
|
||||
<FieldFrame label="Hub / integrated select">
|
||||
<Select label="Статус" value={selectedStatus} options={[...selectOptions]} searchable onChange={setSelectedStatus} />
|
||||
</FieldFrame>
|
||||
<FieldFrame label="Select / независимое действие строки">
|
||||
<Select label="Записи" value={selectedStatus} searchable onChange={setSelectedStatus}
|
||||
options={selectOptions.map(option => ({ ...option, action: {
|
||||
label: `Удалить ${option.label}`, icon: <Icon name="trash" />, tone: 'danger' as const,
|
||||
disabled: option.value === 'active',
|
||||
onAction: () => setConfirmOpen(true),
|
||||
} }))} />
|
||||
</FieldFrame>
|
||||
<FieldFrame label="Engine / split select">
|
||||
<Select
|
||||
variant="split"
|
||||
@@ -1369,19 +1343,6 @@ export function CatalogApp() {
|
||||
onChange={setConnectionType}
|
||||
/>
|
||||
</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
|
||||
trigger={({ open, toggle, setTriggerRef, surfaceId }) => (
|
||||
<Button ref={setTriggerRef} aria-expanded={open} aria-controls={surfaceId} icon={<Icon name="chevron-down" />} onClick={toggle}>Действия</Button>
|
||||
@@ -1412,38 +1373,6 @@ export function CatalogApp() {
|
||||
<IconButton label="Добавить"><Icon name="plus" /></IconButton>
|
||||
</div>
|
||||
</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 loading>Подключить</Button>
|
||||
<IconButton label="Обновить" loading><Icon name="refresh" /></IconButton>
|
||||
</div>
|
||||
<p className="catalog-preview__explanation">При reduced motion кольцо остаётся видимым без вращения; процесс и его завершение принадлежат приложению.</p>
|
||||
</Preview>
|
||||
<Preview title="Загрузка содержимого" note="в центре собственной области">
|
||||
<LoadingRegion loading label="Ожидаем содержимое" />
|
||||
</Preview>
|
||||
<Preview title="Лампы состояния" note="один индикатор без дублирующего значка"><StatusBadge variant="indicator" tone="success" aria-label="Готово" title="Готово" /><StatusBadge variant="indicator" aria-label="Недоступно" title="Недоступно" /><StatusBadge tone="danger" pulse>Требуется внимание</StatusBadge></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="Подключите устройство, чтобы оно появилось в списке." />
|
||||
<SettingsCard align="center" role="status" title="Настройки применены" description="Проверьте подключение устройства." />
|
||||
</Preview>
|
||||
<Preview title="Главная и окружение" note="единые компоненты · сохранение в памяти каталога">
|
||||
<EnvironmentCatalogDemo />
|
||||
</Preview>
|
||||
<Preview title="Оконные действия" note="круг `46 px`" className="catalog-preview--compact">
|
||||
<div className="catalog-window-actions-demo">
|
||||
<Button shape="pill" icon={<Icon name="refresh" />}>Обновить источник</Button>
|
||||
@@ -1452,21 +1381,6 @@ export function CatalogApp() {
|
||||
<IconButton label="Закрыть"><Icon name="close" /></IconButton>
|
||||
</div>
|
||||
</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">
|
||||
<div ref={workspaceWindowDemoRef} className="catalog-workspace-window-demo">
|
||||
{workspaceWindowDemoOpen ? (
|
||||
@@ -1505,20 +1419,6 @@ export function CatalogApp() {
|
||||
</div>
|
||||
<p className="catalog-preview__explanation">Rectangle, maximize, close и stacking остаются состоянием приложения; дизайн-система владеет одинаковой bounded-геометрией и доступным управлением.</p>
|
||||
</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">
|
||||
<div className="catalog-side-panel-demo">
|
||||
{sidePanelDemoOpen ? (
|
||||
@@ -1551,10 +1451,6 @@ export function CatalogApp() {
|
||||
description="MP4 или URL становятся частью единого server-side layout вместе с темой, материалом и Toolbar."
|
||||
actions={<Switch checked={mediaVisible} label="Показать" onChange={setMediaVisible} />}
|
||||
>
|
||||
<div style={{"--nodedc-accent-rgb":"247 248 244","--nodedc-on-accent-rgb":"8 8 10"} as CSSProperties}>
|
||||
<Switch checked={mediaVisible} label="Белый акцент · видимость" onChange={setMediaVisible} />
|
||||
<Switch checked={false} label="Выключено" disabled onChange={()=>{}} />
|
||||
</div>
|
||||
<MediaSourceField
|
||||
label="Видео / картинка"
|
||||
source={mediaSource}
|
||||
@@ -1631,7 +1527,7 @@ export function CatalogApp() {
|
||||
<span>CANONICAL GLASS</span>
|
||||
<h2>Модальное окно и Inspector</h2>
|
||||
<p>Фон остаётся различимым, но текст и контролы сохраняют контраст. Этот материал не применяется к обычным панелям приложения.</p>
|
||||
<div className="catalog-inline"><TextField label="Пример поля" name="organization" autoComplete="organization" defaultValue="NODE.DC" description="Автозаполнение сохраняет материал поля и клавиатурный фокус текущей темы." /><Button variant="primary" shape="pill">Применить</Button></div>
|
||||
<div className="catalog-inline"><TextField label="Пример поля" defaultValue="NODE.DC" /><Button variant="primary" shape="pill">Применить</Button></div>
|
||||
</GlassMaterialSurface>
|
||||
</section>
|
||||
<SettingsCard eyebrow="ENGINE / GLASS V4" title="Параметры материала" description="Общий token-контракт ui-core; изменения сразу видны на preview, модалках и Inspector.">
|
||||
@@ -1726,8 +1622,6 @@ export function CatalogApp() {
|
||||
<div className="catalog-icon-catalog">
|
||||
<Preview title="Размеры и поверхности" note="glyph 16 px" className="catalog-preview--wide">
|
||||
<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="refresh" /></IconButton>
|
||||
<IconButton label="Развернуть"><Icon name="expand" /></IconButton>
|
||||
@@ -2032,7 +1926,6 @@ export function CatalogApp() {
|
||||
endPanel={<div ref={setMapSettingsPanelHost} className="catalog-map-settings-panel-host" />}
|
||||
header={
|
||||
<AppHeader
|
||||
brandMonochrome
|
||||
brand={<img src="/nodedc-logo.svg" alt="NODE.DC" />}
|
||||
brandHref="/"
|
||||
center={
|
||||
|
||||
+124
-1991
File diff suppressed because it is too large
Load Diff
@@ -1,33 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { EnvironmentSettings } from "@nodedc/ui-core";
|
||||
import { Button, EnvironmentSettingsWindow, LandingStage, UserProfileMenu } from "@nodedc/ui-react";
|
||||
|
||||
const initial: EnvironmentSettings = { revision: 0, pages: { home: {
|
||||
headerLabel: "Продукт", eyebrow: "NODE.DC / ГЛАВНАЯ", title: "Главная продукта",
|
||||
description: "Общая композиция страницы и настроек окружения.", primaryWorkspaceId: null, secondaryWorkspaceId: null,
|
||||
background: { enabled: false, imageDurationSeconds: 10, items: [] },
|
||||
} } };
|
||||
|
||||
export function EnvironmentCatalogDemo() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [settings, setSettings] = useState(initial);
|
||||
const urls = useRef<string[]>([]);
|
||||
useEffect(() => () => urls.current.forEach(url => URL.revokeObjectURL(url)), []);
|
||||
return <>
|
||||
<div className="catalog-environment-actions">
|
||||
<UserProfileMenu displayName="DC" subtitle="Каталог компонентов" triggerLabel={null}
|
||||
actions={[{ id: "settings", label: "Настройки", icon: "settings", onSelect: () => setOpen(true) }]} />
|
||||
<Button variant="primary" tone="neutral" onClick={() => setOpen(true)}>Настройки окружения</Button>
|
||||
<Button variant="primary" tone="neutral" disabled>Неактивное действие</Button>
|
||||
</div>
|
||||
<div className="catalog-environment-stage"><LandingStage page={settings.pages.home} /></div>
|
||||
<EnvironmentSettingsWindow open={open} onClose={() => setOpen(false)} productName="Продукт"
|
||||
surfaces={[{ id: "home", home: true, description: "Главная страница продукта", actions: [] }]}
|
||||
settings={settings} state="ready" error={null}
|
||||
onSave={async draft => { const next = { ...draft, revision: draft.revision + 1 }; setSettings(next); return next; }}
|
||||
onUpload={async (_surface, _item, file) => {
|
||||
const url = URL.createObjectURL(file); urls.current.push(url);
|
||||
return { url, fileName: file.name, mediaKind: file.type.startsWith("video/") ? "video" : "image" };
|
||||
}} />
|
||||
</>;
|
||||
}
|
||||
+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 { MapPageLayout, MapPageSettings } from "./MapFixturePreview.js";
|
||||
import type { MapPageLayout, MapPageSettings } from "./mapPageContract.js";
|
||||
import type { MapPresentationProfile } from "./mapPresentationProfile.js";
|
||||
|
||||
export const applicationManifestSchemaVersion = "0.1.0" as const;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { GlassMaterialSettings, NodedcTheme } from "@nodedc/ui-core";
|
||||
import type { ToolbarPlacement } from "@nodedc/ui-react";
|
||||
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";
|
||||
|
||||
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 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";
|
||||
|
||||
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 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;
|
||||
}
|
||||
// Stable TypeScript facade for existing `.js` specifiers. Runtime behavior is
|
||||
// executable JavaScript so browser code and Node behavioral tests share it.
|
||||
export * from "./mapPresentationProfile.mjs";
|
||||
|
||||
@@ -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],
|
||||
label: {
|
||||
mode: "attributes",
|
||||
fields: ["name", "official_name", "local_name"],
|
||||
fields: ["name", "official_name", "local_name", "alternate_names"],
|
||||
fontWeight: 600,
|
||||
sizePx: 20,
|
||||
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 { 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])];
|
||||
}
|
||||
@@ -1,6 +1,3 @@
|
||||
.catalog-environment-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; }
|
||||
.catalog-environment-stage { display: grid; width: 100%; min-height: 480px; }
|
||||
|
||||
:root {
|
||||
background: var(--nodedc-canvas);
|
||||
}
|
||||
@@ -1681,33 +1678,6 @@ textarea {
|
||||
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 {
|
||||
align-content: stretch;
|
||||
}
|
||||
@@ -1784,14 +1754,6 @@ textarea {
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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 = {
|
||||
type: "Point";
|
||||
@@ -78,14 +81,6 @@ type BindingState = {
|
||||
const identifier = /^[A-Za-z0-9._:-]{1,160}$/;
|
||||
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 {
|
||||
return typeof value === "string" && !Number.isNaN(Date.parse(value));
|
||||
}
|
||||
@@ -246,7 +241,7 @@ function replaceSnapshot(current: BindingState, snapshot: SnapshotEnvelope): Bin
|
||||
...current,
|
||||
cursor: snapshot.cursor,
|
||||
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;
|
||||
const facts = { ...current.facts };
|
||||
for (const operation of patch.operations) {
|
||||
if (operation.op === "upsert") facts[factKey(operation.fact)] = operation.fact;
|
||||
else delete facts[`${operation.semanticType}\u0000${operation.sourceId}`];
|
||||
if (operation.op === "upsert") facts[mapRuntimeFactKey(operation.fact)] = operation.fact;
|
||||
else delete facts[mapRuntimeFactKey(operation)];
|
||||
}
|
||||
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 {
|
||||
const facts = { ...current.facts };
|
||||
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 };
|
||||
}
|
||||
return { ...current, facts };
|
||||
@@ -421,7 +416,7 @@ export function useMapDataProductRuntime({
|
||||
dataProductId: binding.dataProductId,
|
||||
slotId: binding.slotId,
|
||||
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,
|
||||
state: record.state,
|
||||
};
|
||||
|
||||
@@ -211,7 +211,13 @@ function asFact(value: unknown): MapRuntimeFact | null {
|
||||
|| !isIso(value.observedAt) || !isIso(value.receivedAt)
|
||||
|| !isObject(value.attributes) || !categories.has(value.attributes.category as MapReferenceStationCategory)
|
||||
|| !isPoint(value.geometry)) return null;
|
||||
const allowedAttributes = new Set(["name", "category", "network", "operator", "official_name", "local_name", "uic_ref", "wheelchair"]);
|
||||
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 {
|
||||
sourceId: value.sourceId,
|
||||
semanticType: String(value.semanticType),
|
||||
|
||||
@@ -74,16 +74,3 @@
|
||||
- не поддерживать отдельную тему путём fork компонента;
|
||||
- не использовать legacy Engine inspector как промежуточный канон;
|
||||
- не удалять production local component до проверки package replacement.
|
||||
# Mission Core and onboard Home (R17)
|
||||
|
||||
Mission Core and Mission Core Node consume `EnvironmentSettingsWindow`,
|
||||
`EnvironmentMediaPlaylistEditor`, `EnvironmentBackgroundMedia` and `LandingStage`
|
||||
from `@nodedc/ui-react`. The former Control Station files are thin API/product
|
||||
registry adapters or compatibility re-exports. They own no copied form markup or
|
||||
presentation CSS. Node supplies Home only; Core retains all existing pages and
|
||||
its wire/storage schema. Both use the existing `UserProfileMenu` avatar dropdown.
|
||||
The component contract and catalog example are in `ENVIRONMENT_SETTINGS.md`.
|
||||
|
||||
## Mission Core loading (2026-09-08)
|
||||
|
||||
Core и Node используют общий sensor-ui/X4 frontend с Button.loading, IconButton.loading и LoadingRegion из Design Guideline. SDK/network semantics остаются в consumer, геометрия ожидания принадлежит DG. Этот локальный кандидат передаётся через source manifest с hashes вместе с Node installer; public npm release не публиковался, исходники компонентов в приложение не копируются.
|
||||
|
||||
@@ -44,7 +44,3 @@ Modal остаётся portal-слоем. Его тело прокручивае
|
||||
- предметное содержимое content window.
|
||||
|
||||
Shell владеет геометрией, слоями, переходами и breakpoint-поведением. См. готовый путь подключения в `docs/CONSUMPTION.md`.
|
||||
|
||||
На узком экране скрывается именно действие `data-action="expand"`.
|
||||
Порядок utility actions не является признаком типа действия: плюс и обновление
|
||||
остаются доступны, даже когда стоят первыми в шапке. Исправлено при QA Node 0.3.
|
||||
|
||||
+5
-98
@@ -10,8 +10,6 @@
|
||||
- `strong` — dropdown, modal и поверхность над сложным фоном;
|
||||
- `soft` — вложенная или вторичная область.
|
||||
|
||||
`radius="pill"` задаёт каноническую капсульную геометрию через `--nodedc-radius-circle`. Она предназначена, в частности, для компактных вертикальных и горизонтальных реек из круглых действий: верхняя и нижняя части поверхности повторяют круг кнопок без прямых торцевых пролётов. Consumer не воспроизводит этот радиус локальным CSS.
|
||||
|
||||
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 материала вручную.
|
||||
@@ -27,25 +25,9 @@ Button используется для всех текстовых действ
|
||||
|
||||
Icon-only action по умолчанию круглый. Квадратная кнопка с маленьким радиусом допустима только как кнопка закрытия окна или плотный инструмент, где это зафиксировано контрактом.
|
||||
|
||||
`size="dense"` — каноническая плотность для текстовых layer/mode-действий внутри визуализатора. Она уменьшает площадь и подпись контрола, но не меняет hover, active, disabled и focus-состояния. В шапках приложения и обычных формах эта плотность не используется.
|
||||
|
||||
Переключаемый IconButton передаёт контролируемое состояние через `aria-pressed`. Активная поверхность и контраст принадлежат дизайн-системе; размер круга и glyph при переключении не меняются.
|
||||
|
||||
`loading` в `Button` и `IconButton` помещает индикатор по центру нажатой кнопки, сохраняет её размеры и accessible name, выставляет `aria-busy` и блокирует повторное нажатие. Подробный контракт — [состояния загрузки](LOADING_STATES.md).
|
||||
|
||||
## LoadingRegion
|
||||
|
||||
`LoadingRegion loading={pending} label="Получаем данные"` центрирует ожидание внутри области содержимого, сохраняя её детей смонтированными. Приложение резервирует размер визуализатора и снимает loading после первого пригодного содержимого, ошибки или timeout. Командный spinner принадлежит кнопке и сюда не дублируется.
|
||||
|
||||
## ActivityIndicator
|
||||
|
||||
`ActivityIndicator` — общий индикатор неопределённого по длительности процесса. `default` используется внутри `LoadingRegion` или явного inline-статуса, `compact` — внутри `Button.loading`/`IconButton.loading` либо фиксированного слота строки ресурса. Владелец операции по-прежнему задаёт видимый текст pending-состояния и `aria-busy`; индикатор не хранит таймер и не определяет завершение операции.
|
||||
|
||||
Без `label` индикатор декоративный и скрыт от accessibility tree. `label` включает `role="status"` только когда сам индикатор должен объявить процесс. При `prefers-reduced-motion: reduce` кольцо остаётся видимым, но не вращается.
|
||||
|
||||
## Field
|
||||
|
||||
FieldFrame объединяет label, control, hint и description. TextField/TextAreaField реализуют стандартные текстовые поля. Автозаполнение браузера сохраняет материал поля, цвет текста и каретки текущей темы; синяя системная заливка не используется. Клавиатурный фокус остаётся видимым.
|
||||
FieldFrame объединяет label, control, hint и description. TextField/TextAreaField реализуют стандартные текстовые поля.
|
||||
|
||||
Приложение не должно вручную собирать label и input, если подходит Field. Ошибка и validation state будут расширением этого контракта, а не локальным классом приложения.
|
||||
|
||||
@@ -57,17 +39,7 @@ FieldFrame объединяет label, control, hint и description. TextField/T
|
||||
|
||||
## RangeControl
|
||||
|
||||
Вертикальная шкала занимает `0.4 × --nodedc-control-height` по ширине (18.4 px при базовой высоте 46 px): на 20% тоньше первоначального варианта 11.09.2026. Высота, диапазон и управление сохраняются.
|
||||
|
||||
Pill-range с заполнением акцентным цветом, встроенной подписью и значением. Домен определяет min/max/step и формат числа. `min/max` задают только рабочий диапазон перетаскивания; ручной ввод по умолчанию принимает любое конечное число и не меняет геометрию ползунка. Опциональный `exactValueBounds` независимо задаёт жёсткие границы ручного ввода: например, `{ min: 0 }` сохраняет неотрицательное значение, но не ограничивает верхнюю границу ползунком.
|
||||
|
||||
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 только при открытии и не отбирает фокус при последующих ререндерах.
|
||||
|
||||
`RangeControl orientation="vertical"` — утверждённый владельцем 11.09.2026 вертикальный вариант для высоты среза. Максимум расположен сверху, заливка растёт снизу, стрелки вверх/вниз меняют значение. Подпись, числовое значение, контраст и focus принадлежат тому же компоненту. Узкий вертикальный вариант использует drag/клавиатуру без inline-редактора; точные границы задаёт домен.
|
||||
|
||||
Последнее уточнение владельца от 19.09.2026 заменяет промежуточный вариант с границами внутри: верхняя и нижняя границы вынесены с внутренней стороны сцены, читаются горизонтально и сохраняют цвет theme text, не меняясь от заливки. `formatLimit` форматирует их отдельно от текущего значения, без единицы измерения по умолчанию. По умолчанию это правая сторона; `limitSide="left"` зеркалит только внешние границы для шкалы, закреплённой справа от сцены. В центре неподвижно расположена строка «подпись · значение»; единица остаётся в `formatValue`. Внутренний и наружный шрифт уменьшены на 10% относительно токенов sm/xs. Два совпадающих центральных текстовых слоя и маска формы заливки меняют контраст ровно по её границе, в том числе посередине символа.
|
||||
|
||||
Уточнение от 20.09.2026: вертикальная шкала не получает border, outline или тень по периметру ни в одном состоянии. Native range может сохранять `:focus-visible` и после pointer-ввода, поэтому этот селектор больше не рисует контур. Доступный фокус обозначается подчёркиванием центральной подписи в обоих контрастных слоях; стрелки и Home/End продолжают работать нативно.
|
||||
Pill-range с заполнением акцентным цветом, встроенной подписью и значением. Домен определяет min/max/step и формат числа. Drag всегда принадлежит невидимому native range и продолжается под областью значения. Над числом постоянно смонтирован один прозрачный native text input: клик включает редактирование без замены DOM-узла, браузер ставит каретку в фактическое место клика, а drag-selection остаётся нативным. Поле не получает собственной подложки, select-all или второго focus-ring. Enter/blur применяют значение с clamp/step-нормализацией, Escape отменяет ввод; события редактора не всплывают в оконные shortcuts. Оконный focus-manager выполняет начальный autofocus только при открытии и не отбирает фокус при последующих ререндерах.
|
||||
|
||||
## ColorField
|
||||
|
||||
@@ -97,20 +69,15 @@ Dropdown владеет floating-layer поведением:
|
||||
|
||||
## Select
|
||||
|
||||
Select добавляет к Dropdown контролируемое значение, options и необязательный поиск. У него три канонические формы:
|
||||
Select добавляет к Dropdown контролируемое значение, options и необязательный поиск. У него две канонические формы:
|
||||
|
||||
- `integrated` — единая pill-поверхность Hub/Launcher: label слева, chevron строго у правого края;
|
||||
- `split` — форма Engine с отдельной областью значения и отдельной кнопкой раскрытия `46 px`, между ними зазор `8 px`;
|
||||
- `inline` — компактный toolbar-trigger: только текущее значение без постоянной подложки и chevron; всё значение открывает обычное portal-меню.
|
||||
- `split` — форма Engine с отдельной областью значения и отдельной кнопкой раскрытия `46 px`, между ними зазор `8 px`.
|
||||
|
||||
Native select не используется как видимый runtime UI. Меню рендерится через portal; у Engine-варианта меню имеет радиус `20 px`, а строки — `14 px` и высоту `42 px`.
|
||||
|
||||
Внутри `Inspector` форма `integrated` запрещена. Selection-поле собирается только через `InspectorSelectField`: видимая подпись находится сверху, а полноширинный control всегда использует `split`. Компонент намеренно не принимает `layout` и `variant`, поэтому consumer не может вернуть Inspector к Hub/Launcher pill-форме.
|
||||
|
||||
`SelectOption.action` добавляет независимое действие справа от строки (`label`, `icon`, `disabled`, `onAction`), например корзину. Это соседний канонический `IconButton`, не вложенная кнопка. Действие закрывает меню, не выбирает строку и не вызывает `onChange`; подтверждение и запрос принадлежат приложению. Меню с действиями использует dialog-семантику и отдельные доступные кнопки выбора/действия; обычный Select сохраняет listbox. Состояние `option.disabled` не блокирует независимое действие — для него предусмотрен `action.disabled`.
|
||||
|
||||
Действие строки повторяет принятый образец каталога сессий Mission Core: прозрачная колонка нажатия `2.35rem`, иконка `15 px`, приглушённый цвет, без круглой подложки, скругления или тени. Опциональный `tone="danger"` меняет цвет при наведении и клавиатурном фокусе, но не окрашивает корзину постоянно. Доступный focus-индикатор общего IconButton сохраняется.
|
||||
|
||||
## Window
|
||||
|
||||
Window — единая механика открытия modal и правой panel:
|
||||
@@ -122,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 автоматически.
|
||||
|
||||
## WorkspaceWindow
|
||||
@@ -174,16 +139,6 @@ Enter при отсутствии совпадения разрешает оди
|
||||
|
||||
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
|
||||
|
||||
Workflow-sharing modal из нового Engine: заголовок ресурса, compact avatar stack, список участников, редактирование ролей, удаление доступа, email и роль нового участника. Компонент сохраняет Engine-геометрию `600 px` и controls `46 px`, но не импортирует ACL API. Consumer передаёт members, permissions и callbacks.
|
||||
@@ -198,8 +153,6 @@ Read-only link/copy окно из BIM Viewer. React-компонент испо
|
||||
|
||||
Pill navigation для верхней панели и компактного переключения режимов. Active segment использует активную поверхность темы; это не обязательно основной accent приложения.
|
||||
|
||||
`size="dense"` применяется только в насыщенной панели слоёв или режимов непосредственно над viewer. Шапка приложения сохраняет default-геометрию; consumer не воспроизводит dense-отступы или шрифт локальным CSS.
|
||||
|
||||
## AppHeader
|
||||
|
||||
Трёхосевая верхняя панель:
|
||||
@@ -252,10 +205,6 @@ setup-команды, сохранение, API и права принадлеж
|
||||
|
||||
Постоянные действия всего рабочего окна передаются через `ApplicationPanel.utilityActions` и всегда находятся в правой части шапки перед expand/close. Сохранение общего layout или настроек приложения — одно такое действие для всего окна; оно не дублируется внутри отдельных карточек и секций.
|
||||
|
||||
Переключение режима выбранного объекта может передать `pressed` в utility action: состояние получает `aria-pressed` и общую активную поверхность. Например, `info` открывает обзор текущей сохранённой сессии Mission Core.
|
||||
|
||||
Если заголовок и действия не помещаются в одну строку, шапка переносит действия на следующую строку с выравниванием вправо. Кнопки не обрезаются, заголовок сохраняет читаемую ширину. Состав действий одинаков в обычном и развёрнутом окне.
|
||||
|
||||
Точные состояния и размеры зафиксированы в `docs/APPLICATION_TEMPLATE.md`.
|
||||
|
||||
## MediaSourceField
|
||||
@@ -268,16 +217,6 @@ setup-команды, сохранение, API и права принадлеж
|
||||
|
||||
`SettingsCard` фиксирует нейтральную структуру админской группы: eyebrow/title/description/actions/body. `Switch` покрывает компактное включение/видимость внутри таких групп. Данные секции, сохранение и права остаются в consumer.
|
||||
|
||||
Бегунок включённого `Switch` использует `--nodedc-on-accent-rgb`, поэтому
|
||||
сохраняет контраст с белым, цветным и тёмным акцентом. Выключенный track
|
||||
наследует тон темы от text-primary. Длина track не сжимается при переносе
|
||||
подписи; положение бегунка остаётся дополнительным признаком состояния.
|
||||
|
||||
Для компактных сообщений о результате и пустых состояний `SettingsCard align="center"`
|
||||
центрирует содержимое по горизонтали и вертикали. Пустой body не создаёт
|
||||
асимметричный нижний отступ. Обычные группы настроек сохраняют `align="start"`;
|
||||
вариант не задаёт высоту и не меняет токены темы.
|
||||
|
||||
## AdminNavigationPanel
|
||||
|
||||
Левая выезжающая панель Hub/Launcher. Библиотека владеет оболочкой `352 px`, радиусом `21.6 px`, внутренними отступами, full-bleed context/navigation pills, круглыми icon surfaces и анимацией появления. Приложение передаёт workspace/company, маршруты, active id и footer identity.
|
||||
@@ -324,11 +263,9 @@ Sortable-строки ограничены вертикальной осью: г
|
||||
|
||||
Статус использует semantic tone: neutral, success, warning, danger или accent. Бизнес-статус приложения маппится на tone в самом приложении.
|
||||
|
||||
`pulse` добавляет пульсацию только лампы, не текста и не фона. Подходит для потери подтверждения или другого состояния, требующего внимания. При `prefers-reduced-motion: reduce` лампа остаётся постоянной. Текст статуса обязателен рядом с отдельным индикатором или в самом badge.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -350,33 +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 продолжает владеть определениями полей, типами нод и сохранением значений. В живом 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`; дополнительный текст и значки скрываются.
|
||||
|
||||
## EnvironmentSettingsWindow и EnvironmentMediaPlaylistEditor
|
||||
|
||||
Единый редактор окружения перенесён из принятой композиции Mission Core. Принимает страницы, quick-action варианты, документ, состояние, upload/save adapters и название продукта. Использует тот же FeatureSettingsWindow, поля, переключатели, медиаконтент и перестановку; новое приложение передаёт данные, а не копирует форму. Для бортового приложения передаётся только home. Контракты и пример: [ENVIRONMENT_SETTINGS.md](ENVIRONMENT_SETTINGS.md).
|
||||
|
||||
## LandingStage и EnvironmentBackgroundMedia
|
||||
|
||||
Общая главная: надзаголовок, заголовок, описание, быстрые действия, фон и необязательные status/footer slots. Изображения меняются по таймеру, видео — после завершения; ошибочные источники пропускаются. Пустой фон однотонный, без декоративного круга. Состояние страниц и хранение принадлежат приложению.
|
||||
|
||||
Для primary Button `tone="neutral"` фиксирует белый enabled и серый disabled, независимо от темы и акцента; keyboard focus остаётся видимым. MediaSourceField принимает `disabled` и блокирует все операции выбора источника, пока родитель сохраняет документ или загружает файл.
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
# Shared environment and home
|
||||
|
||||
The owner-approved Mission Core settings anatomy is now canonical, not a screenshot to reproduce. Consumers use UserProfileMenu for the avatar dropdown, EnvironmentSettingsWindow for the settings composition, EnvironmentMediaPlaylistEditor for the list and LandingStage/EnvironmentBackgroundMedia for the home. All geometry and responsive styles live in ui-core.
|
||||
|
||||
The neutral primary Button tone comes from shared tokens, stays white when enabled and gray when disabled across dark/light and accent changes. It does not alter other applications' default primary theme.
|
||||
|
||||
## Consumer contract
|
||||
|
||||
Pass productName, a nonempty stable surfaces list, optional initialSurfaceId, a revision/pages document and save/upload adapters. Each surface references one present page and declares its available quick actions. Workspace IDs are opaque application-owned identifiers. The library owns only draft/form/list/playback behavior; it never imports Mission Core's registry or calls product APIs. Shared data shapes/list functions are exported by ui-core so nonvisual application contracts do not depend on React.
|
||||
|
||||
Settings uses the existing FeatureSettingsWindow/Window focus and Escape behavior. A busy parent disables editing and save; failed saves retain the draft. Each playlist has up to 24 items. Images use the configured delay; a video advances on ended, with one video looping. Failed media is skipped. List display reverses playback order to keep newly added media at the top, as in the accepted source UI.
|
||||
|
||||
The application validates URLs/media bytes, persists uploads and document revisions, handles authentication and rejects stale saves. An upload response provides URL, fileName and mediaKind. A file URL belongs to the storage adapter; an external source must use HTTP(S). No media defaults to an invented graphic. The product may supply status/footer slots without forking the stage.
|
||||
|
||||
## Adoption and validation
|
||||
|
||||
Mission Core passes all existing page definitions and its current operator-environment API. Mission Core Node passes home only and authenticated local presentation storage. Device control never depends on this document. The catalog EnvironmentCatalogDemo exercises the same exported components with an explicitly in-memory save/upload adapter; it is not a persistence service.
|
||||
|
||||
Validate the source migration, TypeScript builds, playlist functions, neutral enabled/disabled DOM contract, dark/light and multiple accents, modal Escape, media order/timing and both consumer persistence adapters. Long-running or physical device acceptance remains application-owned.
|
||||
+1
-6
@@ -29,12 +29,6 @@
|
||||
|
||||
Допустимы 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
|
||||
|
||||
Перед удалением export:
|
||||
@@ -59,3 +53,4 @@
|
||||
## Владение
|
||||
|
||||
У дизайн-системы должен быть явный code owner. Product team может предлагать компоненты, но общий API и theme contract проходят отдельное review, потому что изменение распространяется на все будущие приложения.
|
||||
|
||||
|
||||
+3
-14
@@ -1,7 +1,5 @@
|
||||
# Иконки
|
||||
|
||||
`info` — информация о выбранном объекте или записи. Иконка используется в стандартном круглом `IconButton`, с доступной подписью и `aria-pressed` для переключаемого обзора. Продуктовое применение: обзор сохранённой сессии Mission Core; одобрено владельцем 2026-09-11.
|
||||
|
||||
Канонический набор собран по реально используемым действиям Launcher/Hub, SEO, BIM Viewer и разрешённым новым участкам Engine. Полные vendor-наборы не являются частью дизайн-системы: наличие SVG в Font Awesome или export в Lucide не делает иконку канонической.
|
||||
|
||||
## Геометрия
|
||||
@@ -18,24 +16,15 @@
|
||||
|
||||
| Группа | Канонические имена |
|
||||
| --- | --- |
|
||||
| Окно и слой | `close`, `plus`, `expand`, `minimize`, `refresh`, `panel`, `apps`, `layers` |
|
||||
| Окно и слой | `close`, `plus`, `expand`, `minimize`, `refresh`, `panel`, `apps` |
|
||||
| Навигация | `chevron-left`, `chevron-right`, `chevron-down`, `grid`, `list`, `sliders`, `search` |
|
||||
| Редактирование | `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` |
|
||||
| Контент | `camera`, `plan`, `play`, `stop`, `image`, `video`, `file`, `folder`, `clipboard`, `settings` |
|
||||
| Контент | `image`, `video`, `file`, `folder`, `clipboard`, `settings` |
|
||||
|
||||
Живая таблица с названиями, поверхностями и размерами находится в разделе `Guideline → Иконки`. Машинный список — в `registry/icons.json`.
|
||||
|
||||
## Правило расширения
|
||||
|
||||
Новая иконка добавляется только после подтверждённого применения в продукте. Нужно выбрать семантическое имя, добавить export в `Icon`, запись в registry и specimen в каталоге. Локальный импорт иконки только ради немного другой формы запрещён.
|
||||
|
||||
`camera` обозначает оптический канал, а `plan` — пространственное представление сверху. Оба glyph контурные и используют общую stroke-геометрию набора.
|
||||
|
||||
`layers` открывает управление видимостью слоёв карты или визуального источника.
|
||||
Применение подтверждено владельцем Mission Core 10.09.2026: кнопка слоёв в
|
||||
заголовке карты Центра наблюдения. `settings` остаётся общей конфигурацией.
|
||||
Кнопка наследует обычные hover, focus, disabled и active-состояния `IconButton`.
|
||||
|
||||
`play` и `stop` — компактные полнотелые транспортные glyph: залитый треугольник запуска и залитый квадрат остановки/паузы. Видимая текстовая подпись не требуется, но action обязан сохранить доступный `aria-label`.
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
# Состояния загрузки
|
||||
|
||||
Общий контракт для Mission Core, Mission Core Node и других NODE.DC consumers. Кольцо рисует ActivityIndicator; место и семантику определяют Button.loading, IconButton.loading или LoadingRegion. Локальные absolute-спиннеры и свободные ActivityIndicator под группой действий запрещены.
|
||||
|
||||
| Причина ожидания | Компонент и место | Завершение |
|
||||
|---|---|---|
|
||||
| Нажата команда | loading только у инициирующей Button/IconButton, по центру внутри её прежних границ | Подтверждение результата, ошибка или timeout команды |
|
||||
| Начальная загрузка содержимого | LoadingRegion вокруг конкретной области, по центру её границ | Первое пригодное содержимое, ошибка или timeout |
|
||||
| Подключение видео | LoadingRegion вокруг смонтированного video/canvas, включая расширенный вид | Первый декодированный кадр; одного ICE connected недостаточно |
|
||||
| Фоновое обновление с доступным содержимым | loading у явной кнопки обновления; без overlay при автоматическом polling | Ответ запроса или ошибка |
|
||||
| Измеряемая многошаговая подготовка | Существующий ProgressBar/ResourceRow.progress и статусы этапов | Результат этапа/операции |
|
||||
| Ошибка, offline, отсутствие свежих кадров | Конечный статус и разрешённое действие повторения | Не оставлять бесконечный spinner |
|
||||
|
||||
Приложение хранит pending по device ID, session и конкретному action key. Блокировка соседних несовместимых действий не делает их загружающимися. Для одной команды показывается один индикатор; последующее получение содержимого — отдельный этап. Элемент управления сохраняет размеры, надпись для screen reader и свой порядок в layout; spinner не добавляет строку и не сдвигает соседей. Повторная активация pending-кнопки блокируется native disabled.
|
||||
|
||||
LoadingRegion не размонтирует video/canvas и не присваивает область страницы целиком. Визуализатор задаёт собственную стабильную высоту/aspect ratio; индикатор центрируется относительно этой области и автоматически следует её normal/expanded размеру. Компонент не блокирует управление сам: consumer отключает только конфликтующие действия. Закрытие просмотра остаётся доступным.
|
||||
|
||||
Владелец операции задаёт начало и конец ожидания. Компоненты не запускают запросы, не выдумывают проценты, не повторяют операции и не скрывают ошибки по таймеру. Reduced motion сохраняет статическое кольцо. Button сохраняет accessible name и aria-busy; LoadingRegion имеет один status с label, декоративное кольцо скрыто от accessibility tree.
|
||||
|
||||
```tsx
|
||||
<Button loading={pending === 'save'} onClick={save}>Сохранить</Button>
|
||||
<IconButton label="Обновить" loading={pending === 'refresh'} onClick={refresh}><Icon name="refresh" /></IconButton>
|
||||
<LoadingRegion loading={connecting || awaitingFirstFrame} label="Ожидаем изображение">
|
||||
<video autoPlay muted playsInline />
|
||||
</LoadingRegion>
|
||||
```
|
||||
|
||||
Living catalog показывает action, icon-action и content loading. Изменение согласовано владельцем08.09.2026 для обоих Mission Core consumers. Локальный кандидат включается в hash-bound Ubuntu source artifact; внешний package release в рамках этой задачи не публикуется.
|
||||
|
||||
## Проверка кандидата 08.09.2026
|
||||
|
||||
N09: typecheck, registry validation, четыре loading contract tests и production catalog build прошли. В браузере проверены light/dark и два accent colors: loading не меняет размер Button/IconButton, кольцо сохраняет нейтральный цвет, content status центрируется вместе с подписью. В Mission Core проверены pending read-only action, video first frame, normal/expanded/Escape и terminal error: mounted video сохраняется, после кадра или ошибки лишних индикаторов нет. Тот же shared frontend включён в установленный Node0.8.18; отдельная визуальная проверка native Node shell остаётся задачей consumer acceptance.
|
||||
@@ -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.
|
||||
+5
-10
@@ -11,11 +11,10 @@
|
||||
"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: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 && npm run test:environment-settings",
|
||||
"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",
|
||||
"serve": "node server/catalog-server.mjs",
|
||||
"validate:registry": "node scripts/validate-registry.mjs",
|
||||
"test:environment-settings": "node --test scripts/environment-settings.test.mjs",
|
||||
"test:platform-settings": "node scripts/smoke-platform-settings.mjs",
|
||||
"test:data-product-runtime": "node scripts/smoke-data-product-runtime.mjs",
|
||||
"test:data-product-consumer": "node --test server/foundry-data-product-consumer.test.mjs",
|
||||
@@ -23,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: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-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: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",
|
||||
@@ -32,15 +32,10 @@
|
||||
"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-cache-contract": "node --test scripts/map-cache-resource-contract.test.mjs",
|
||||
"test:spark-governance": "node --test scripts/spark-governance-contract.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:map-provider-startup": "node --test scripts/map-provider-startup.test.mjs",
|
||||
"test:floating-position": "node --test scripts/floating-position-contract.test.mjs",
|
||||
"test:inspector-select": "node --test scripts/inspector-select-contract.test.mjs scripts/select-actions-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"
|
||||
"test:inspector-select": "node --test scripts/inspector-select-contract.test.mjs",
|
||||
"test:range-control": "node --test scripts/range-control-contract.test.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
:root {
|
||||
--nodedc-neutral-action-bg: rgba(247, 248, 244, 0.96);
|
||||
--nodedc-neutral-action-hover: #fff;
|
||||
--nodedc-neutral-action-color: rgba(8, 8, 10, 0.96);
|
||||
--nodedc-neutral-action-disabled-bg: #757575;
|
||||
--nodedc-neutral-action-disabled-color: #242424;
|
||||
}
|
||||
|
||||
:root,
|
||||
[data-nodedc-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
|
||||
--nodedc-control-height: 2.875rem;
|
||||
--nodedc-control-height-compact: 2.375rem;
|
||||
--nodedc-control-height-dense: 1.875rem;
|
||||
--nodedc-icon-button-size: 2.875rem;
|
||||
--nodedc-header-row-height: 3rem;
|
||||
--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-size-xs: 0.6875rem;
|
||||
--nodedc-font-size-dense: 0.45rem;
|
||||
--nodedc-font-size-sm: 0.75rem;
|
||||
--nodedc-font-size-md: 0.8125rem;
|
||||
--nodedc-font-size-lg: 1rem;
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
export type EnvironmentMediaKind = "image" | "video";
|
||||
export type EnvironmentMediaSource = "file" | "url";
|
||||
|
||||
export interface EnvironmentMediaItem {
|
||||
id: string;
|
||||
source: EnvironmentMediaSource;
|
||||
url: string | null;
|
||||
mediaKind: EnvironmentMediaKind | null;
|
||||
fileName: string | null;
|
||||
}
|
||||
|
||||
export interface EnvironmentBackground {
|
||||
enabled: boolean;
|
||||
imageDurationSeconds: number;
|
||||
items: EnvironmentMediaItem[];
|
||||
}
|
||||
|
||||
export interface EnvironmentPage {
|
||||
headerLabel: string;
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryWorkspaceId: string | null;
|
||||
secondaryWorkspaceId: string | null;
|
||||
background: EnvironmentBackground;
|
||||
}
|
||||
|
||||
export interface EnvironmentSettings { revision: number; pages: Record<string, EnvironmentPage> }
|
||||
export interface UploadedEnvironmentMedia { url: string; fileName: string; mediaKind: EnvironmentMediaKind }
|
||||
export interface EnvironmentAction { id: string; label: string; description?: string }
|
||||
export interface EnvironmentSurface { id: string; home?: boolean; description?: string; actions: readonly EnvironmentAction[] }
|
||||
|
||||
export const defaultEnvironmentImageDurationSeconds = 10;
|
||||
export const maxEnvironmentMediaItems = 24;
|
||||
|
||||
export function createEnvironmentMediaItem(): EnvironmentMediaItem {
|
||||
return {
|
||||
id: `media-${crypto.randomUUID()}`,
|
||||
source: "file",
|
||||
url: null,
|
||||
mediaKind: null,
|
||||
fileName: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function appendEnvironmentMediaItem(
|
||||
background: EnvironmentBackground,
|
||||
item: EnvironmentMediaItem = createEnvironmentMediaItem(),
|
||||
): EnvironmentBackground {
|
||||
if (
|
||||
background.items.length >= maxEnvironmentMediaItems
|
||||
|| background.items.some((candidate) => candidate.id === item.id)
|
||||
) {
|
||||
return background;
|
||||
}
|
||||
return {
|
||||
...background,
|
||||
enabled: background.items.length === 0 ? true : background.enabled,
|
||||
items: [...background.items, item],
|
||||
};
|
||||
}
|
||||
|
||||
export function removeEnvironmentMediaItem(
|
||||
background: EnvironmentBackground,
|
||||
itemId: string,
|
||||
): EnvironmentBackground {
|
||||
const items = background.items.filter((item) => item.id !== itemId);
|
||||
if (items.length === background.items.length) return background;
|
||||
return {
|
||||
...background,
|
||||
enabled: items.length > 0 && background.enabled,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
export function inferEnvironmentMediaKind(url: string): EnvironmentMediaKind {
|
||||
return /\.(mp4|webm|mov)(?:[?#].*)?$/i.test(url) ? "video" : "image";
|
||||
}
|
||||
|
||||
export function cloneEnvironmentSettings(settings: EnvironmentSettings): EnvironmentSettings {
|
||||
return { ...settings, pages: Object.fromEntries(Object.entries(settings.pages).map(([id, page]) => [id, {
|
||||
...page, background: { ...page.background, items: page.background.items.map(item => ({ ...item })) },
|
||||
}])) };
|
||||
}
|
||||
@@ -3,4 +3,3 @@ export * from "./floating.js";
|
||||
export * from "./glass.js";
|
||||
export * from "./theme.js";
|
||||
export * from "./toolbar.js";
|
||||
export * from "./environment.js";
|
||||
|
||||
+7
-704
@@ -15,13 +15,13 @@
|
||||
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;
|
||||
outline: 0;
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
.nodedc-glass[data-radius="pill"] {
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
}
|
||||
|
||||
.nodedc-glass[data-padding="sm"] {
|
||||
padding: var(--nodedc-space-3);
|
||||
}
|
||||
@@ -108,7 +104,6 @@
|
||||
}
|
||||
|
||||
.nodedc-button {
|
||||
position: relative;
|
||||
--nodedc-button-bg: var(--nodedc-glass-control-bg);
|
||||
--nodedc-button-color: var(--nodedc-text-primary);
|
||||
display: inline-flex;
|
||||
@@ -143,13 +138,6 @@
|
||||
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"] {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -186,26 +174,6 @@
|
||||
background: color-mix(in srgb, rgb(var(--nodedc-accent-rgb)) 84%, white);
|
||||
}
|
||||
|
||||
.nodedc-button[data-variant="primary"][data-tone="neutral"] {
|
||||
--nodedc-button-bg: var(--nodedc-neutral-action-bg);
|
||||
--nodedc-button-color: var(--nodedc-neutral-action-color);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.nodedc-button[data-variant="primary"][data-tone="neutral"]:hover:not(:disabled) {
|
||||
background: var(--nodedc-neutral-action-hover);
|
||||
}
|
||||
|
||||
.nodedc-button[data-variant="primary"][data-tone="neutral"]:disabled {
|
||||
background: var(--nodedc-neutral-action-disabled-bg);
|
||||
color: var(--nodedc-neutral-action-disabled-color);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.nodedc-button[data-variant="primary"][data-tone="neutral"]:focus-visible {
|
||||
box-shadow: inset 0 0 0 2px var(--nodedc-neutral-action-color);
|
||||
}
|
||||
|
||||
.nodedc-button[data-variant="ghost"] {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
@@ -237,89 +205,13 @@
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.nodedc-button__content {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: inherit;
|
||||
gap: inherit;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nodedc-button[data-loading="true"] > .nodedc-button__content,
|
||||
.nodedc-icon-button[data-loading="true"] > :not(.nodedc-action-loading) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.nodedc-action-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nodedc-button[data-loading="true"],
|
||||
.nodedc-icon-button[data-loading="true"] {
|
||||
opacity: 1;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.nodedc-loading-region {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nodedc-loading-region[aria-busy="true"] {
|
||||
min-block-size: calc(var(--nodedc-control-height) * 2);
|
||||
}
|
||||
|
||||
.nodedc-loading-region__status {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--nodedc-space-2);
|
||||
padding: var(--nodedc-space-3);
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nodedc-button__icon > svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
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 {
|
||||
position: relative;
|
||||
display: inline-grid;
|
||||
width: var(--nodedc-icon-button-size);
|
||||
height: var(--nodedc-icon-button-size);
|
||||
@@ -339,11 +231,6 @@
|
||||
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) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
@@ -423,20 +310,6 @@
|
||||
box-shadow: var(--nodedc-glass-control-shadow), inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.22);
|
||||
}
|
||||
|
||||
/* Browsers enforce their autofill background with an internal !important rule.
|
||||
Paint the canonical field material above it; keep text, caret and keyboard
|
||||
focus in the same theme as manually entered values. */
|
||||
.nodedc-field__control:is(:autofill, :-webkit-autofill) {
|
||||
-webkit-text-fill-color: var(--nodedc-text-primary);
|
||||
caret-color: var(--nodedc-text-primary);
|
||||
box-shadow: inset 0 0 0 100vmax var(--nodedc-field-bg);
|
||||
}
|
||||
|
||||
.nodedc-field__control:is(:autofill, :-webkit-autofill):focus-visible {
|
||||
box-shadow: inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.22),
|
||||
inset 0 0 0 100vmax var(--nodedc-field-bg);
|
||||
}
|
||||
|
||||
textarea.nodedc-field__control {
|
||||
min-height: 6rem;
|
||||
padding-block: 0.85rem;
|
||||
@@ -672,13 +545,13 @@ textarea.nodedc-field__control {
|
||||
|
||||
.nodedc-settings-card__titles h2 {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: var(--nodedc-font-size-md);
|
||||
font-size: 1rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.nodedc-settings-card__titles p {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@@ -695,21 +568,6 @@ textarea.nodedc-field__control {
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.nodedc-settings-card[data-align="center"] {
|
||||
align-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nodedc-settings-card[data-align="center"] > .nodedc-settings-card__head {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nodedc-settings-card[data-align="center"] > .nodedc-settings-card__body {
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.nodedc-switch {
|
||||
display: inline-flex;
|
||||
width: max-content;
|
||||
@@ -726,12 +584,11 @@ textarea.nodedc-field__control {
|
||||
|
||||
.nodedc-switch__track {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
width: 2rem;
|
||||
height: 1.12rem;
|
||||
align-items: center;
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
background: color-mix(in srgb, var(--nodedc-text-primary) 14%, transparent);
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
padding: 0.14rem;
|
||||
transition: background var(--nodedc-duration-normal) var(--nodedc-ease-standard);
|
||||
}
|
||||
@@ -749,7 +606,6 @@ textarea.nodedc-field__control {
|
||||
}
|
||||
|
||||
.nodedc-switch[aria-checked="true"] .nodedc-switch__track > span {
|
||||
background: rgb(var(--nodedc-on-accent-rgb));
|
||||
transform: translateX(0.88rem);
|
||||
}
|
||||
|
||||
@@ -842,33 +698,6 @@ textarea.nodedc-field__control {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.nodedc-select-option-row { display: flex; align-items: stretch; gap: 0.2rem; }
|
||||
.nodedc-select-option-row > .nodedc-dropdown-option { flex: 1; min-width: 0; }
|
||||
.nodedc-select-option-row > .nodedc-icon-button {
|
||||
width: 2.35rem; height: auto; flex: 0 0 2.35rem;
|
||||
border-radius: 0; background: transparent; box-shadow: none;
|
||||
color: var(--nodedc-text-muted);
|
||||
}
|
||||
.nodedc-select-option-row > .nodedc-icon-button > svg { width: 15px; height: 15px; transform: none; }
|
||||
.nodedc-select-option-row > .nodedc-icon-button:hover:not(:disabled),
|
||||
.nodedc-select-option-row > .nodedc-icon-button:focus-visible { background: transparent; color: var(--nodedc-text-primary); }
|
||||
.nodedc-select-option-row > .nodedc-icon-button[data-tone="danger"]:hover:not(:disabled),
|
||||
.nodedc-select-option-row > .nodedc-icon-button[data-tone="danger"]:focus-visible { color: rgb(var(--nodedc-danger-rgb)); }
|
||||
.nodedc-select-option-row > .nodedc-icon-button:active:not(:disabled) { transform: none; }
|
||||
.nodedc-range-scale { position: relative; width: calc(var(--nodedc-control-height) * 0.4); height: 100%; min-height: 6rem; }
|
||||
.nodedc-range[data-orientation="vertical"] { width: 100%; height: 100%; border: 0; outline: 0; box-shadow: none; }
|
||||
.nodedc-range[data-orientation="vertical"]::before { inset: auto 0 0; width: 100%; height: var(--nodedc-range-progress); }
|
||||
.nodedc-range[data-orientation="vertical"] input[type="range"] { writing-mode: vertical-lr; direction: rtl; }
|
||||
.nodedc-range[data-orientation="vertical"] .nodedc-range__label { left: 50%; max-width: none; font-size: calc(var(--nodedc-font-size-sm) * 0.9); writing-mode: vertical-rl; transform: translate(-50%, -50%) rotate(180deg); }
|
||||
.nodedc-range-scale__limit { position: absolute; left: calc(100% + 0.4rem); color: var(--nodedc-text-primary); font-size: calc(var(--nodedc-font-size-xs) * 0.9); font-weight: var(--nodedc-font-weight-medium); line-height: 1; font-variant-numeric: tabular-nums; white-space: nowrap; pointer-events: none; }
|
||||
.nodedc-range-scale[data-limit-side="left"] .nodedc-range-scale__limit { right: calc(100% + 0.4rem); left: auto; text-align: right; }
|
||||
.nodedc-range-scale__limit--max { top: 0.75rem; }
|
||||
.nodedc-range-scale__limit--min { bottom: 0.75rem; }
|
||||
.nodedc-range[data-orientation="vertical"] .nodedc-range__fill-text { clip-path: inset(calc(100% - var(--nodedc-range-progress)) 0 0 round var(--nodedc-radius-circle)); }
|
||||
/* Native ranges may match focus-visible after pointer input too. Keep every
|
||||
state perimeter-free; underline both contrast layers to identify focus. */
|
||||
.nodedc-range[data-orientation="vertical"]:has(input:focus-visible) .nodedc-range__label { text-decoration: underline; text-decoration-thickness: 2px; text-underline-offset: 2px; }
|
||||
|
||||
.nodedc-range__label,
|
||||
.nodedc-range__value {
|
||||
position: absolute;
|
||||
@@ -940,10 +769,6 @@ textarea.nodedc-field__control {
|
||||
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__fill-text .nodedc-range__value {
|
||||
opacity: 0;
|
||||
@@ -1177,19 +1002,6 @@ textarea.nodedc-field__control {
|
||||
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 {
|
||||
position: relative;
|
||||
z-index: var(--nodedc-layer-header);
|
||||
@@ -1654,8 +1466,6 @@ textarea.nodedc-field__control {
|
||||
|
||||
.nodedc-application-panel__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
min-height: 3.55rem;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
@@ -1664,7 +1474,6 @@ textarea.nodedc-field__control {
|
||||
|
||||
.nodedc-application-panel__titles {
|
||||
display: grid;
|
||||
flex: 1 1 18rem;
|
||||
min-width: 0;
|
||||
gap: 0.24rem;
|
||||
padding: 0.2rem 0.25rem;
|
||||
@@ -1699,17 +1508,12 @@ textarea.nodedc-field__control {
|
||||
.nodedc-application-panel__actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: wrap;
|
||||
max-width: 100%;
|
||||
margin-left: auto;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.nodedc-application-panel__tools {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
margin-right: 0.1rem;
|
||||
}
|
||||
@@ -2378,33 +2182,6 @@ textarea.nodedc-field__control {
|
||||
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 {
|
||||
transform: translateY(2px) rotate(225deg);
|
||||
}
|
||||
@@ -3028,93 +2805,6 @@ textarea.nodedc-field__control {
|
||||
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 {
|
||||
position: fixed;
|
||||
z-index: calc(var(--nodedc-layer-header) + 20);
|
||||
@@ -3708,7 +3398,6 @@ textarea.nodedc-field__control {
|
||||
}
|
||||
|
||||
.nodedc-empty-state {
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
display: grid;
|
||||
min-height: 12rem;
|
||||
place-items: center;
|
||||
@@ -3720,7 +3409,7 @@ textarea.nodedc-field__control {
|
||||
|
||||
.nodedc-empty-state strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: var(--nodedc-font-size-md);
|
||||
font-size: var(--nodedc-font-size-lg);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
@@ -3820,7 +3509,7 @@ textarea.nodedc-field__control {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nodedc-application-panel__action[data-action="expand"] {
|
||||
.nodedc-application-panel__action:first-child {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -3896,7 +3585,6 @@ textarea.nodedc-field__control {
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.nodedc-activity-indicator,
|
||||
.nodedc-dropdown-surface,
|
||||
.nodedc-overlay,
|
||||
.nodedc-window,
|
||||
@@ -3917,388 +3605,3 @@ textarea.nodedc-field__control {
|
||||
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)); }
|
||||
|
||||
.nodedc-status[data-pulse="true"]::before {
|
||||
content: ""; display: block; width: .42rem; height: .42rem; flex: 0 0 .42rem;
|
||||
border-radius: var(--nodedc-radius-circle); background: currentColor;
|
||||
animation: nodedc-status-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
.nodedc-status[data-pulse="true"]:not([data-variant="indicator"]) { gap: .42rem; }
|
||||
@keyframes nodedc-status-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: .35; transform: scale(.75); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.nodedc-status[data-pulse="true"]::before { animation: none; }
|
||||
}
|
||||
|
||||
/* Shared product landing and environment settings. */
|
||||
.nodedc-landing-stage {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: var(--nodedc-radius-card);
|
||||
background: var(--nodedc-canvas-soft);
|
||||
}
|
||||
|
||||
.nodedc-landing-stage__media,
|
||||
.nodedc-landing-stage__shade {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.nodedc-landing-stage__media img,
|
||||
.nodedc-landing-stage__media video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.nodedc-landing-stage__shade {
|
||||
z-index: 1;
|
||||
background:
|
||||
linear-gradient(90deg, rgb(5 6 8 / 0.82) 0%, rgb(5 6 8 / 0.54) 46%, rgb(5 6 8 / 0.24) 100%),
|
||||
linear-gradient(0deg, rgb(5 6 8 / 0.58), transparent 38%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nodedc-landing-stage:not([data-has-media="true"]) .nodedc-landing-stage__shade {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.nodedc-landing-stage__copy {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 50%;
|
||||
left: clamp(2rem, 5vw, 6rem);
|
||||
width: min(39rem, 50%);
|
||||
transform: translateY(-55%);
|
||||
}
|
||||
|
||||
.nodedc-landing-stage__copy h1 {
|
||||
margin: 0.75rem 0 1rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: clamp(3rem, 7vw, 7.6rem);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.072em;
|
||||
line-height: 0.87;
|
||||
}
|
||||
|
||||
.nodedc-landing-stage__copy p {
|
||||
max-width: 34rem;
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: clamp(0.8rem, 1vw, 1rem);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.nodedc-landing-stage__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.65rem;
|
||||
margin-top: 1.6rem;
|
||||
}
|
||||
|
||||
.nodedc-landing-stage__status {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 2.4rem;
|
||||
right: 2.4rem;
|
||||
display: grid;
|
||||
width: min(24rem, 30vw);
|
||||
gap: 0.6rem;
|
||||
border-radius: 1.25rem;
|
||||
background: rgb(10 10 12 / 0.58);
|
||||
padding: 1rem;
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.nodedc-landing-stage__status > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nodedc-landing-stage__status p {
|
||||
margin: 0.15rem 0 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.66rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.nodedc-landing-stage__footer {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
right: 2.4rem;
|
||||
bottom: 1.8rem;
|
||||
left: 2.4rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.57rem;
|
||||
font-weight: 780;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.nodedc-environment-settings {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nodedc-environment-settings__copy {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.nodedc-environment-settings__editor {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nodedc-environment-settings__surface {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(18rem, 24rem);
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nodedc-environment-settings__surface > span {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.nodedc-environment-settings__surface .nodedc-select-anchor,
|
||||
.nodedc-environment-settings__surface .nodedc-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nodedc-environment-settings__quick-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.nodedc-environment-settings__quick-actions > div {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.nodedc-environment-settings__quick-actions span {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.nodedc-environment-settings__quick-actions .nodedc-select-anchor,
|
||||
.nodedc-environment-settings__quick-actions .nodedc-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nodedc-environment-media-playlist {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.nodedc-environment-media-playlist__head {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nodedc-environment-media-playlist__head > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.22rem;
|
||||
}
|
||||
|
||||
.nodedc-environment-media-playlist__head span {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
font-weight: var(--nodedc-font-weight-medium);
|
||||
}
|
||||
|
||||
.nodedc-environment-media-playlist__head p,
|
||||
.nodedc-environment-media-playlist__empty,
|
||||
.nodedc-environment-media-playlist__timing > span,
|
||||
.nodedc-environment-media-playlist__error {
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.nodedc-environment-media-playlist__error {
|
||||
color: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
.nodedc-environment-media-playlist__items {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.nodedc-environment-media-playlist__item {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.nodedc-environment-media-playlist__item-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.1rem;
|
||||
padding-top: 1.65rem;
|
||||
}
|
||||
|
||||
.nodedc-environment-media-playlist__timing {
|
||||
display: grid;
|
||||
gap: 0.42rem;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.nodedc-environment-settings__copy,
|
||||
.nodedc-environment-settings__quick-actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.nodedc-environment-settings__surface {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.nodedc-environment-media-playlist__item {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.nodedc-environment-media-playlist__item-actions {
|
||||
justify-self: end;
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1480px) {
|
||||
.nodedc-landing-stage__copy {
|
||||
width: min(35rem, 54%);
|
||||
}
|
||||
}
|
||||
@media (max-width: 1040px) {
|
||||
.nodedc-landing-stage__status {
|
||||
width: 20rem;
|
||||
max-width: 36vw;
|
||||
}
|
||||
}
|
||||
@media (max-width: 1040px) {
|
||||
.nodedc-landing-stage__copy h1 {
|
||||
font-size: clamp(2.7rem, 7vw, 5.6rem);
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.nodedc-landing-stage__copy {
|
||||
top: 42%;
|
||||
right: 1.2rem;
|
||||
left: 1.2rem;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.nodedc-landing-stage__copy h1 {
|
||||
font-size: clamp(2.8rem, 14vw, 5rem);
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.nodedc-landing-stage__status {
|
||||
top: auto;
|
||||
right: 1rem;
|
||||
bottom: 3.3rem;
|
||||
left: 1rem;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.nodedc-landing-stage__footer {
|
||||
right: 1rem;
|
||||
bottom: 1.1rem;
|
||||
left: 1rem;
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.nodedc-landing-stage__footer span:first-child {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@media (max-height: 720px) and (min-width: 761px) {
|
||||
.nodedc-landing-stage__copy h1 {
|
||||
font-size: clamp(2.7rem, 6vw, 5rem);
|
||||
}
|
||||
}
|
||||
@media (max-height: 720px) and (min-width: 761px) {
|
||||
.nodedc-landing-stage__copy p {
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
}
|
||||
@media (max-height: 720px) and (min-width: 761px) {
|
||||
.nodedc-landing-stage__actions {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
@media (max-height: 720px) and (min-width: 761px) {
|
||||
.nodedc-landing-stage__status {
|
||||
top: 1.2rem;
|
||||
right: 1.2rem;
|
||||
}
|
||||
}
|
||||
.nodedc-landing-stage__eyebrow { color: var(--nodedc-text-muted); font-size: var(--nodedc-font-size-xs); font-weight: var(--nodedc-font-weight-strong); letter-spacing: .12em; }
|
||||
|
||||
@@ -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;
|
||||
brandHref?: string;
|
||||
brandLabel?: string;
|
||||
/** Opt in only for a light, single-color mark, never a full-color image. */
|
||||
brandMonochrome?: boolean;
|
||||
left?: ReactNode;
|
||||
center?: ReactNode;
|
||||
right?: ReactNode;
|
||||
@@ -15,15 +13,14 @@ export function AppHeader({
|
||||
brand,
|
||||
brandHref,
|
||||
brandLabel = "NODE.DC",
|
||||
brandMonochrome = false,
|
||||
left,
|
||||
center,
|
||||
right,
|
||||
}: AppHeaderProps) {
|
||||
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 (
|
||||
@@ -103,12 +100,11 @@ export interface HeaderWorkspaceProps {
|
||||
label: string;
|
||||
imageUrl?: string;
|
||||
kind?: "mark" | "avatar";
|
||||
monochrome?: boolean;
|
||||
}
|
||||
|
||||
export function HeaderWorkspace({ label, imageUrl, kind = "mark", monochrome = false }: HeaderWorkspaceProps) {
|
||||
export function HeaderWorkspace({ label, imageUrl, kind = "mark" }: HeaderWorkspaceProps) {
|
||||
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()}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -68,7 +68,6 @@ export interface ApplicationPanelUtilityAction {
|
||||
icon: IconName;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
pressed?: boolean;
|
||||
}
|
||||
|
||||
export function ApplicationPanel({
|
||||
@@ -103,8 +102,6 @@ export function ApplicationPanel({
|
||||
className="nodedc-application-panel__action"
|
||||
data-action="utility"
|
||||
aria-label={action.label}
|
||||
aria-pressed={action.pressed}
|
||||
data-active={action.pressed ? "true" : undefined}
|
||||
disabled={action.disabled}
|
||||
onClick={action.onClick}
|
||||
>
|
||||
|
||||
@@ -1,28 +1,22 @@
|
||||
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
|
||||
import { createAccentVariables, type RgbTuple } from "@nodedc/ui-core";
|
||||
import { cn } from "./cn.js";
|
||||
import { ActivityIndicator } from "./ActivityIndicator.js";
|
||||
|
||||
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 interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
/** Neutral primary actions remain white/gray independently of the product accent. */
|
||||
tone?: "theme" | "neutral";
|
||||
size?: ButtonSize;
|
||||
width?: "auto" | "full";
|
||||
shape?: ButtonShape;
|
||||
accent?: RgbTuple;
|
||||
icon?: ReactNode;
|
||||
/** Pending state belongs to this action; dimensions and accessible name stay unchanged. */
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button({
|
||||
variant = "secondary",
|
||||
tone = "theme",
|
||||
size = "default",
|
||||
width = "auto",
|
||||
shape = "default",
|
||||
@@ -32,8 +26,6 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
children,
|
||||
style,
|
||||
type = "button",
|
||||
loading = false,
|
||||
disabled,
|
||||
...props
|
||||
}, ref) {
|
||||
const accentStyle = accent ? createAccentVariables(accent) : undefined;
|
||||
@@ -43,21 +35,14 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
type={type}
|
||||
className={cn("nodedc-button", className)}
|
||||
data-variant={variant}
|
||||
data-tone={tone === "theme" ? undefined : tone}
|
||||
data-size={size === "default" ? undefined : size}
|
||||
data-width={width === "auto" ? undefined : width}
|
||||
data-shape={shape === "default" ? undefined : shape}
|
||||
style={accentStyle ? { ...accentStyle, ...style } : style}
|
||||
{...props}
|
||||
disabled={disabled || loading}
|
||||
aria-busy={loading || props["aria-busy"]}
|
||||
data-loading={loading || undefined}
|
||||
>
|
||||
<span className="nodedc-button__content">
|
||||
{icon ? <span className="nodedc-button__icon" aria-hidden="true">{icon}</span> : null}
|
||||
{children}
|
||||
</span>
|
||||
{loading ? <span className="nodedc-action-loading" aria-hidden="true"><ActivityIndicator size="compact" /></span> : null}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
@@ -65,7 +50,6 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
label: string;
|
||||
shape?: "circle" | "rounded";
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton({
|
||||
@@ -74,8 +58,6 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
|
||||
className,
|
||||
children,
|
||||
type = "button",
|
||||
loading = false,
|
||||
disabled,
|
||||
...props
|
||||
}, ref) {
|
||||
return (
|
||||
@@ -87,12 +69,8 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
|
||||
aria-label={label}
|
||||
title={label}
|
||||
{...props}
|
||||
disabled={disabled || loading}
|
||||
aria-busy={loading || props["aria-busy"]}
|
||||
data-loading={loading || undefined}
|
||||
>
|
||||
{children}
|
||||
{loading ? <span className="nodedc-action-loading" aria-hidden="true"><ActivityIndicator size="compact" /></span> : null}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -107,7 +107,32 @@ export function Dropdown({
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen) return;
|
||||
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(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type {
|
||||
EnvironmentBackground,
|
||||
EnvironmentMediaItem,
|
||||
} from "@nodedc/ui-core";
|
||||
|
||||
type ReadyEnvironmentMediaItem = EnvironmentMediaItem & {
|
||||
url: string;
|
||||
mediaKind: "image" | "video";
|
||||
};
|
||||
|
||||
function isReadyMediaItem(
|
||||
item: EnvironmentMediaItem,
|
||||
): item is ReadyEnvironmentMediaItem {
|
||||
return Boolean(item.url && item.mediaKind);
|
||||
}
|
||||
|
||||
export function EnvironmentBackgroundMedia({
|
||||
background,
|
||||
}: {
|
||||
background: EnvironmentBackground;
|
||||
}) {
|
||||
const items = useMemo(
|
||||
() => background.items.filter(isReadyMediaItem),
|
||||
[background.items],
|
||||
);
|
||||
const playlistIdentity = items.map((item) => `${item.id}:${item.url}`).join("|");
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [failedIds, setFailedIds] = useState<Set<string>>(new Set());
|
||||
const playableItems = items.filter((item) => !failedIds.has(item.id));
|
||||
const activeItem = playableItems[activeIndex] ?? playableItems[0] ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
setActiveIndex(0);
|
||||
setFailedIds(new Set());
|
||||
}, [playlistIdentity]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!background.enabled
|
||||
|| !activeItem
|
||||
|| activeItem.mediaKind !== "image"
|
||||
|| playableItems.length < 2
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
setActiveIndex((current) => (current + 1) % playableItems.length);
|
||||
}, background.imageDurationSeconds * 1_000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [
|
||||
activeItem,
|
||||
background.enabled,
|
||||
background.imageDurationSeconds,
|
||||
playableItems.length,
|
||||
]);
|
||||
|
||||
if (!background.enabled || !activeItem) return null;
|
||||
|
||||
return (
|
||||
<div className="nodedc-landing-stage__media" aria-hidden="true">
|
||||
{activeItem.mediaKind === "video" ? (
|
||||
<video
|
||||
key={activeItem.url}
|
||||
src={activeItem.url}
|
||||
autoPlay
|
||||
muted
|
||||
loop={playableItems.length === 1}
|
||||
playsInline
|
||||
onEnded={() => setActiveIndex((current) => (
|
||||
(current + 1) % playableItems.length
|
||||
))}
|
||||
onError={() => {
|
||||
setFailedIds((current) => new Set(current).add(activeItem.id));
|
||||
setActiveIndex(0);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
key={activeItem.url}
|
||||
src={activeItem.url}
|
||||
alt=""
|
||||
onError={() => {
|
||||
setFailedIds((current) => new Set(current).add(activeItem.id));
|
||||
setActiveIndex(0);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Icon,
|
||||
IconButton,
|
||||
MediaSourceField,
|
||||
RangeControl,
|
||||
SortableList,
|
||||
} from "./index.js";
|
||||
|
||||
import {
|
||||
appendEnvironmentMediaItem,
|
||||
inferEnvironmentMediaKind,
|
||||
maxEnvironmentMediaItems,
|
||||
removeEnvironmentMediaItem,
|
||||
type EnvironmentBackground,
|
||||
type EnvironmentMediaItem,
|
||||
type UploadedEnvironmentMedia,
|
||||
} from "@nodedc/ui-core";
|
||||
|
||||
export interface EnvironmentMediaPlaylistEditorProps {
|
||||
surfaceId: string;
|
||||
background: EnvironmentBackground;
|
||||
disabled: boolean;
|
||||
error: string | null;
|
||||
onChange: (background: EnvironmentBackground) => void;
|
||||
onBusyChange: (busy: boolean) => void;
|
||||
onUpload: (
|
||||
surfaceId: string,
|
||||
itemId: string,
|
||||
file: File,
|
||||
) => Promise<UploadedEnvironmentMedia>;
|
||||
}
|
||||
|
||||
const acceptedEnvironmentMedia = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/avif",
|
||||
"video/mp4",
|
||||
"video/webm",
|
||||
"video/quicktime",
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".gif",
|
||||
".webp",
|
||||
".avif",
|
||||
".mp4",
|
||||
".webm",
|
||||
".mov",
|
||||
].join(",");
|
||||
|
||||
function patchItem(
|
||||
background: EnvironmentBackground,
|
||||
itemId: string,
|
||||
patch: Partial<EnvironmentMediaItem>,
|
||||
): EnvironmentBackground {
|
||||
return {
|
||||
...background,
|
||||
items: background.items.map((item) => (
|
||||
item.id === itemId ? { ...item, ...patch } : item
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
export function EnvironmentMediaPlaylistEditor({
|
||||
surfaceId,
|
||||
background,
|
||||
disabled,
|
||||
error,
|
||||
onChange,
|
||||
onBusyChange,
|
||||
onUpload,
|
||||
}: EnvironmentMediaPlaylistEditorProps) {
|
||||
const [uploadingIds, setUploadingIds] = useState<Set<string>>(new Set());
|
||||
const [itemErrors, setItemErrors] = useState<Record<string, string>>({});
|
||||
const mounted = useRef(true);
|
||||
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
|
||||
const backgroundRef = useRef(background);
|
||||
backgroundRef.current = background;
|
||||
const displayedItems = useMemo(
|
||||
() => [...background.items].reverse(),
|
||||
[background.items],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onBusyChange(uploadingIds.size > 0);
|
||||
}, [onBusyChange, uploadingIds.size]);
|
||||
|
||||
useEffect(() => () => onBusyChange(false), [onBusyChange]);
|
||||
|
||||
const setItemError = (itemId: string, message?: string) => {
|
||||
setItemErrors((current) => {
|
||||
const next = { ...current };
|
||||
if (message) next[itemId] = message;
|
||||
else delete next[itemId];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const uploadFile = async (itemId: string, file?: File) => {
|
||||
if (!file) return;
|
||||
setUploadingIds((current) => new Set(current).add(itemId));
|
||||
setItemError(itemId);
|
||||
try {
|
||||
const uploaded = await onUpload(surfaceId, itemId, file);
|
||||
if (!mounted.current) return;
|
||||
onChange(patchItem(backgroundRef.current, itemId, {
|
||||
source: "file",
|
||||
url: uploaded.url,
|
||||
mediaKind: uploaded.mediaKind,
|
||||
fileName: uploaded.fileName,
|
||||
}));
|
||||
} catch (reason) {
|
||||
if (!mounted.current) return;
|
||||
setItemError(
|
||||
itemId,
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: "Не удалось загрузить медиаконтент.",
|
||||
);
|
||||
} finally {
|
||||
if (mounted.current) setUploadingIds((current) => {
|
||||
const next = new Set(current);
|
||||
next.delete(itemId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="nodedc-environment-media-playlist">
|
||||
<div className="nodedc-environment-media-playlist__head">
|
||||
<div>
|
||||
<span>Видео / картинка</span>
|
||||
<p>MP4, WebM, MOV, PNG, JPEG, GIF, WebP или AVIF · до 256 МБ.</p>
|
||||
</div>
|
||||
<IconButton
|
||||
label="Добавить медиаконтент"
|
||||
disabled={disabled || background.items.length >= maxEnvironmentMediaItems}
|
||||
onClick={() => onChange(appendEnvironmentMediaItem(background))}
|
||||
>
|
||||
<Icon name="plus" />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
{displayedItems.length ? (
|
||||
<SortableList
|
||||
items={displayedItems}
|
||||
getId={(item) => item.id}
|
||||
className="nodedc-environment-media-playlist__items"
|
||||
onReorder={(items) => !disabled && onChange({
|
||||
...background,
|
||||
items: [...items].reverse(),
|
||||
})}
|
||||
>
|
||||
{(item, { handle }) => {
|
||||
const playbackIndex = background.items.findIndex(
|
||||
(candidate) => candidate.id === item.id,
|
||||
);
|
||||
return (
|
||||
<div className="nodedc-environment-media-playlist__item">
|
||||
<MediaSourceField
|
||||
label={`Медиаконтент ${String(playbackIndex + 1).padStart(2, "0")}`}
|
||||
kindLabel={item.mediaKind ?? "media"}
|
||||
source={item.source}
|
||||
url={item.url ?? ""}
|
||||
fileName={item.fileName}
|
||||
disabled={disabled}
|
||||
uploading={uploadingIds.has(item.id)}
|
||||
previewSrc={item.url}
|
||||
previewKind={item.mediaKind}
|
||||
accept={acceptedEnvironmentMedia}
|
||||
hint="Файл сохраняется в приложении. Ссылка должна вести прямо на изображение или видео по HTTP(S)."
|
||||
error={itemErrors[item.id] ?? (
|
||||
playbackIndex === background.items.length - 1 ? error : null
|
||||
)}
|
||||
onSourceChange={(source) => {
|
||||
if (source === item.source) return;
|
||||
setItemError(item.id);
|
||||
onChange(patchItem(background, item.id, {
|
||||
source,
|
||||
url: null,
|
||||
mediaKind: null,
|
||||
fileName: null,
|
||||
}));
|
||||
}}
|
||||
onUrlChange={(url) => {
|
||||
setItemError(item.id);
|
||||
onChange(patchItem(background, item.id, {
|
||||
source: "url",
|
||||
url: url || null,
|
||||
mediaKind: url ? inferEnvironmentMediaKind(url) : null,
|
||||
fileName: null,
|
||||
}));
|
||||
}}
|
||||
onFileChange={(file) => void uploadFile(item.id, file)}
|
||||
/>
|
||||
<div className="nodedc-environment-media-playlist__item-actions">
|
||||
<IconButton
|
||||
label={`Удалить медиаконтент ${playbackIndex + 1}`}
|
||||
disabled={disabled || uploadingIds.has(item.id)}
|
||||
onClick={() => {
|
||||
setItemError(item.id);
|
||||
onChange(removeEnvironmentMediaItem(background, item.id));
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" />
|
||||
</IconButton>
|
||||
{handle}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</SortableList>
|
||||
) : (
|
||||
<>
|
||||
<p className="nodedc-environment-media-playlist__empty">
|
||||
Добавьте первый файл или прямую ссылку на медиаконтент.
|
||||
</p>
|
||||
{error ? (
|
||||
<p className="nodedc-environment-media-playlist__error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="nodedc-environment-media-playlist__timing">
|
||||
<RangeControl
|
||||
label="Показывать изображение"
|
||||
value={background.imageDurationSeconds}
|
||||
min={1}
|
||||
max={60}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
formatValue={(value) => `${value} с`}
|
||||
onChange={(imageDurationSeconds) => onChange({
|
||||
...background,
|
||||
imageDurationSeconds,
|
||||
})}
|
||||
/>
|
||||
<span>
|
||||
Новые элементы появляются сверху. Воспроизведение начинается снизу;
|
||||
перетаскивание меняет порядок.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,355 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
FeatureSettingsWindow,
|
||||
Select,
|
||||
SettingsCard,
|
||||
Switch,
|
||||
TextAreaField,
|
||||
TextField,
|
||||
WindowFooterActions,
|
||||
} from "./index.js";
|
||||
|
||||
import {
|
||||
cloneEnvironmentSettings,
|
||||
type EnvironmentBackground,
|
||||
type EnvironmentPage,
|
||||
type EnvironmentSettings,
|
||||
type EnvironmentSurface,
|
||||
type UploadedEnvironmentMedia,
|
||||
} from "@nodedc/ui-core";
|
||||
import { EnvironmentMediaPlaylistEditor } from "./EnvironmentMediaPlaylistEditor.js";
|
||||
|
||||
export interface EnvironmentSettingsWindowProps {
|
||||
productName: string;
|
||||
surfaces: readonly EnvironmentSurface[];
|
||||
initialSurfaceId?: string;
|
||||
open: boolean;
|
||||
settings: EnvironmentSettings;
|
||||
state: "loading" | "ready" | "saving" | "error";
|
||||
error: string | null;
|
||||
onClose: () => void;
|
||||
onSave: (settings: EnvironmentSettings) => Promise<EnvironmentSettings>;
|
||||
onUpload: (
|
||||
surfaceId: string,
|
||||
itemId: string,
|
||||
file: File,
|
||||
) => Promise<UploadedEnvironmentMedia>;
|
||||
}
|
||||
|
||||
function patchPage(
|
||||
draft: EnvironmentSettings,
|
||||
surfaceId: string,
|
||||
patch: Partial<EnvironmentPage>,
|
||||
): EnvironmentSettings {
|
||||
return {
|
||||
...draft,
|
||||
pages: {
|
||||
...draft.pages,
|
||||
[surfaceId]: {
|
||||
...draft.pages[surfaceId],
|
||||
...patch,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function patchBackground(
|
||||
draft: EnvironmentSettings,
|
||||
surfaceId: string,
|
||||
patch: Partial<EnvironmentBackground>,
|
||||
): EnvironmentSettings {
|
||||
const page = draft.pages[surfaceId];
|
||||
return patchPage(draft, surfaceId, {
|
||||
background: {
|
||||
...page.background,
|
||||
...patch,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function EnvironmentSettingsWindow({
|
||||
productName,
|
||||
surfaces,
|
||||
initialSurfaceId,
|
||||
open,
|
||||
settings,
|
||||
state,
|
||||
error,
|
||||
onClose,
|
||||
onSave,
|
||||
onUpload,
|
||||
}: EnvironmentSettingsWindowProps) {
|
||||
const [draft, setDraft] = useState(() => cloneEnvironmentSettings(settings));
|
||||
const [surfaceId, setSurfaceId] = useState(initialSurfaceId ?? surfaces[0]?.id ?? "home");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setDraft(cloneEnvironmentSettings(settings));
|
||||
setLocalError(null);
|
||||
}, [open, settings]);
|
||||
|
||||
const selectedPage = draft.pages[surfaceId];
|
||||
const selectedBackground = selectedPage.background;
|
||||
const pageOptions = surfaces.map(surface => ({ value: surface.id, label: draft.pages[surface.id].headerLabel, description: surface.description }));
|
||||
const selectedSurface = surfaces.find(surface => surface.id === surfaceId) ?? surfaces[0];
|
||||
const quickActionWorkspaces = selectedSurface?.actions ?? [];
|
||||
const quickActionOptions = useMemo(() => [
|
||||
{
|
||||
value: "none",
|
||||
label: "Не показывать",
|
||||
description: "Кнопка скрыта на стартовом экране",
|
||||
},
|
||||
...quickActionWorkspaces.map((workspace) => ({
|
||||
value: workspace.id,
|
||||
label: workspace.label,
|
||||
description: workspace.description,
|
||||
})),
|
||||
], [quickActionWorkspaces]);
|
||||
const dirty = useMemo(
|
||||
() => JSON.stringify(draft) !== JSON.stringify(settings),
|
||||
[draft, settings],
|
||||
);
|
||||
const busy = state === "loading" || state === "saving" || uploading;
|
||||
|
||||
const updatePage = (patch: Partial<EnvironmentPage>) => {
|
||||
setDraft((current) => patchPage(current, surfaceId, patch));
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const invalid = Object.entries(draft.pages).find(([, page]) => (
|
||||
!page.headerLabel.trim()
|
||||
|| !page.eyebrow.trim()
|
||||
|| !page.title.trim()
|
||||
|| !page.description.trim()
|
||||
));
|
||||
if (invalid) {
|
||||
setLocalError("Название, надзаголовок, заголовок и описание не могут быть пустыми.");
|
||||
return;
|
||||
}
|
||||
const duplicateActions = Object.entries(draft.pages).find(([, page]) => (
|
||||
page.primaryWorkspaceId && page.primaryWorkspaceId === page.secondaryWorkspaceId
|
||||
));
|
||||
if (duplicateActions) {
|
||||
setSurfaceId(duplicateActions[0]);
|
||||
setLocalError("Быстрые кнопки должны вести на разные рабочие поверхности.");
|
||||
return;
|
||||
}
|
||||
const invalidMedia = Object.entries(draft.pages).find(([, page]) => (
|
||||
(page.background.enabled && !page.background.items.length)
|
||||
|| page.background.items.some((item) => {
|
||||
if (!item.url || !item.mediaKind) return true;
|
||||
if (item.source !== "url") return false;
|
||||
try {
|
||||
const parsed = new URL(item.url);
|
||||
return !["http:", "https:"].includes(parsed.protocol);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
})
|
||||
));
|
||||
if (invalidMedia) {
|
||||
setSurfaceId(invalidMedia[0] as string);
|
||||
setLocalError(
|
||||
"Каждый элемент фона должен содержать загруженный файл или прямой HTTP(S) URL.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
try {
|
||||
await onSave({
|
||||
...draft,
|
||||
pages: Object.fromEntries(
|
||||
Object.entries(draft.pages).map(([id, page]) => [id, {
|
||||
...page,
|
||||
headerLabel: page.headerLabel.trim(),
|
||||
eyebrow: page.eyebrow.trim(),
|
||||
title: page.title.trim(),
|
||||
description: page.description.trim(),
|
||||
}]),
|
||||
) as EnvironmentSettings["pages"],
|
||||
});
|
||||
onClose();
|
||||
} catch (reason) {
|
||||
setLocalError(reason instanceof Error
|
||||
? reason.message
|
||||
: "Не удалось сохранить настройки окружения.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FeatureSettingsWindow
|
||||
open={open}
|
||||
title={`Настройки ${productName}`}
|
||||
subtitle="Локальное операторское окружение"
|
||||
identity={{
|
||||
title: "DC",
|
||||
subtitle: productName,
|
||||
avatarLabel: "DC",
|
||||
}}
|
||||
sections={[
|
||||
{
|
||||
id: "environment",
|
||||
label: "Окружение",
|
||||
group: productName.toUpperCase(),
|
||||
icon: "settings",
|
||||
},
|
||||
]}
|
||||
activeSection="environment"
|
||||
onSectionChange={() => undefined}
|
||||
onClose={onClose}
|
||||
footer={(
|
||||
<WindowFooterActions>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={!dirty || busy}
|
||||
onClick={() => {
|
||||
setDraft(cloneEnvironmentSettings(settings));
|
||||
setLocalError(null);
|
||||
}}
|
||||
>
|
||||
Сбросить изменения
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
tone="neutral"
|
||||
disabled={!dirty || busy}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{state === "saving" ? "Сохраняем…" : "Сохранить"}
|
||||
</Button>
|
||||
</WindowFooterActions>
|
||||
)}
|
||||
>
|
||||
<div className="nodedc-environment-settings">
|
||||
<SettingsCard
|
||||
eyebrow="ОКРУЖЕНИЕ"
|
||||
title="Основные элементы управления"
|
||||
description="Выберите страницу и настройте её название в шапке, содержание стартового экрана, подложку и быстрые переходы."
|
||||
actions={(
|
||||
<Switch
|
||||
disabled={busy}
|
||||
checked={selectedBackground.enabled}
|
||||
label="Показывать фон"
|
||||
onChange={(enabled) => {
|
||||
if (enabled && !selectedBackground.items.length) {
|
||||
setLocalError("Сначала добавьте медиаконтент.");
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
setDraft((current) =>
|
||||
patchBackground(current, surfaceId, { enabled }));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<div className="nodedc-environment-settings__editor">
|
||||
<div className="nodedc-environment-settings__surface">
|
||||
<span>Страница</span>
|
||||
<Select
|
||||
disabled={busy}
|
||||
label="Выбрать страницу окружения"
|
||||
value={surfaceId}
|
||||
options={pageOptions}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
onChange={(value) => {
|
||||
setSurfaceId(value);
|
||||
setLocalError(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="nodedc-environment-settings__copy">
|
||||
<TextField
|
||||
disabled={busy}
|
||||
label={selectedSurface?.home ? "Название продукта" : "Название в шапке"}
|
||||
value={selectedPage.headerLabel}
|
||||
maxLength={40}
|
||||
onChange={(event) => updatePage({
|
||||
headerLabel: event.currentTarget.value,
|
||||
})}
|
||||
/>
|
||||
<TextField
|
||||
disabled={busy}
|
||||
label="Надзаголовок"
|
||||
value={selectedPage.eyebrow}
|
||||
maxLength={80}
|
||||
onChange={(event) => updatePage({
|
||||
eyebrow: event.currentTarget.value,
|
||||
})}
|
||||
/>
|
||||
<TextField
|
||||
disabled={busy}
|
||||
label="Основной заголовок"
|
||||
value={selectedPage.title}
|
||||
maxLength={120}
|
||||
onChange={(event) => updatePage({
|
||||
title: event.currentTarget.value,
|
||||
})}
|
||||
/>
|
||||
<TextAreaField
|
||||
disabled={busy}
|
||||
label="Описание"
|
||||
value={selectedPage.description}
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
onChange={(event) => updatePage({
|
||||
description: event.currentTarget.value,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="nodedc-environment-settings__quick-actions">
|
||||
<div>
|
||||
<span>Кнопка 1</span>
|
||||
<Select
|
||||
disabled={busy}
|
||||
label="Выбрать первую быструю кнопку"
|
||||
value={selectedPage.primaryWorkspaceId ?? "none"}
|
||||
options={quickActionOptions}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
onChange={(value) => updatePage({
|
||||
primaryWorkspaceId: value === "none" ? null : value,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span>Кнопка 2</span>
|
||||
<Select
|
||||
disabled={busy}
|
||||
label="Выбрать вторую быструю кнопку"
|
||||
value={selectedPage.secondaryWorkspaceId ?? "none"}
|
||||
options={quickActionOptions}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
onChange={(value) => updatePage({
|
||||
secondaryWorkspaceId: value === "none" ? null : value,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EnvironmentMediaPlaylistEditor
|
||||
key={surfaceId}
|
||||
surfaceId={surfaceId}
|
||||
background={selectedBackground}
|
||||
disabled={busy}
|
||||
error={localError ?? error}
|
||||
onBusyChange={setUploading}
|
||||
onChange={(background) => {
|
||||
setLocalError(null);
|
||||
setDraft((current) =>
|
||||
patchBackground(current, surfaceId, background));
|
||||
}}
|
||||
onUpload={onUpload}
|
||||
/>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
</FeatureSettingsWindow>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import type { HTMLAttributes, ReactNode } from "react";
|
||||
import { cn } from "./cn.js";
|
||||
|
||||
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 interface GlassSurfaceProps extends HTMLAttributes<HTMLDivElement> {
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
AlertTriangle,
|
||||
Boxes,
|
||||
Building2,
|
||||
Camera,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
@@ -13,29 +12,23 @@ import {
|
||||
Copy,
|
||||
Database,
|
||||
Download,
|
||||
Eye,
|
||||
EyeOff,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Globe2,
|
||||
Image,
|
||||
Inbox,
|
||||
Info,
|
||||
KeyRound,
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
ListChecks,
|
||||
LocateFixed,
|
||||
LockKeyhole,
|
||||
MailPlus,
|
||||
Map,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Network,
|
||||
PanelTop,
|
||||
Pencil,
|
||||
Play,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Save,
|
||||
@@ -43,7 +36,6 @@ import {
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
Square,
|
||||
Trash2,
|
||||
UploadCloud,
|
||||
UserCircle,
|
||||
@@ -59,7 +51,6 @@ const icons = {
|
||||
alert: AlertTriangle,
|
||||
apps: Boxes,
|
||||
building: Building2,
|
||||
camera: Camera,
|
||||
check: Check,
|
||||
"chevron-down": ChevronDown,
|
||||
"chevron-left": ChevronLeft,
|
||||
@@ -71,8 +62,6 @@ const icons = {
|
||||
database: Database,
|
||||
download: Download,
|
||||
edit: Pencil,
|
||||
eye: Eye,
|
||||
"eye-off": EyeOff,
|
||||
expand: Maximize2,
|
||||
external: ExternalLink,
|
||||
file: FileText,
|
||||
@@ -81,9 +70,7 @@ const icons = {
|
||||
grid: LayoutDashboard,
|
||||
image: Image,
|
||||
inbox: Inbox,
|
||||
info: Info,
|
||||
key: KeyRound,
|
||||
layers: Layers,
|
||||
list: ListChecks,
|
||||
target: LocateFixed,
|
||||
lock: LockKeyhole,
|
||||
@@ -91,8 +78,6 @@ const icons = {
|
||||
minimize: Minimize2,
|
||||
network: Network,
|
||||
panel: PanelTop,
|
||||
plan: Map,
|
||||
play: Play,
|
||||
plus: Plus,
|
||||
profile: UserCircle,
|
||||
refresh: RefreshCw,
|
||||
@@ -101,7 +86,6 @@ const icons = {
|
||||
settings: Settings,
|
||||
shield: ShieldCheck,
|
||||
sliders: SlidersHorizontal,
|
||||
stop: Square,
|
||||
trash: Trash2,
|
||||
upload: UploadCloud,
|
||||
users: UsersRound,
|
||||
@@ -115,15 +99,13 @@ export interface IconProps extends Omit<LucideProps, "ref"> {
|
||||
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 filledTransport = name === "play" || name === "stop";
|
||||
|
||||
return (
|
||||
<IconComponent
|
||||
size={size}
|
||||
strokeWidth={filledTransport ? 0 : strokeWidth}
|
||||
fill={fill ?? (filledTransport ? "currentColor" : "none")}
|
||||
strokeWidth={strokeWidth}
|
||||
aria-hidden={label ? undefined : "true"}
|
||||
aria-label={label}
|
||||
role={label ? "img" : undefined}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { EnvironmentPage } from "@nodedc/ui-core";
|
||||
import { Button } from "./Button.js";
|
||||
import { Icon, type IconName } from "./Icon.js";
|
||||
import { EnvironmentBackgroundMedia } from "./EnvironmentBackgroundMedia.js";
|
||||
export interface LandingStageProps {
|
||||
page: EnvironmentPage;
|
||||
pageId?: string;
|
||||
actions?: readonly { id: string; label: string; icon?: IconName; onSelect: () => void }[];
|
||||
status?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
}
|
||||
export function LandingStage({ page, pageId = "home", actions = [], status, footer }: LandingStageProps) {
|
||||
const { background } = page;
|
||||
const hasMedia = background.enabled && background.items.some(
|
||||
(item) => item.url && item.mediaKind,
|
||||
);
|
||||
return (
|
||||
<section
|
||||
className="nodedc-landing-stage"
|
||||
data-page={pageId}
|
||||
data-has-media={hasMedia ? "true" : undefined}
|
||||
>
|
||||
<EnvironmentBackgroundMedia background={background} />
|
||||
<div className="nodedc-landing-stage__shade" aria-hidden="true" />
|
||||
<div className="nodedc-landing-stage__copy">
|
||||
<span className="nodedc-landing-stage__eyebrow">{page.eyebrow}</span>
|
||||
<h1>{page.title}</h1>
|
||||
<p>{page.description}</p>
|
||||
{actions.length ? (
|
||||
<div className="nodedc-landing-stage__actions">
|
||||
{actions.map((workspace, index) => (
|
||||
<Button
|
||||
key={workspace.id}
|
||||
variant={index === 0 ? "primary" : "secondary"}
|
||||
tone="neutral"
|
||||
icon={workspace.icon ? <Icon name={workspace.icon} /> : undefined}
|
||||
onClick={() => workspace.onSelect()}
|
||||
>
|
||||
{workspace.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{status ? <div className="nodedc-landing-stage__status">{status}</div> : null}
|
||||
{footer ? <footer className="nodedc-landing-stage__footer">{footer}</footer> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { ActivityIndicator } from "./ActivityIndicator.js";
|
||||
import { cn } from "./cn.js";
|
||||
|
||||
export interface LoadingRegionProps extends HTMLAttributes<HTMLDivElement> {
|
||||
loading: boolean;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** Keeps content mounted and centers pending feedback inside its own bounds. */
|
||||
export function LoadingRegion({ loading, label, children, className, ...props }: LoadingRegionProps) {
|
||||
return (
|
||||
<div {...props} className={cn("nodedc-loading-region", className)} aria-busy={loading}>
|
||||
{children}
|
||||
{loading ? (
|
||||
<div className="nodedc-loading-region__status" role="status" aria-label={label}>
|
||||
<ActivityIndicator />
|
||||
<span aria-hidden="true">{label}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,6 @@ export interface MediaSourceFieldProps {
|
||||
url: string;
|
||||
fileName?: string | null;
|
||||
uploading?: boolean;
|
||||
disabled?: boolean;
|
||||
previewSrc?: string | null;
|
||||
previewKind?: MediaPreviewKind | null;
|
||||
accept?: string;
|
||||
@@ -44,7 +43,6 @@ export function MediaSourceField({
|
||||
url,
|
||||
fileName,
|
||||
uploading = false,
|
||||
disabled = false,
|
||||
previewSrc,
|
||||
previewKind,
|
||||
accept = "image/*,video/*",
|
||||
@@ -75,12 +73,11 @@ export function MediaSourceField({
|
||||
<div className="nodedc-media-file" hidden={source !== "file"} data-nodedc-media-source-panel="file">
|
||||
<label className="nodedc-media-file__button" htmlFor={inputId}>{fileButtonLabel}</label>
|
||||
<span className="nodedc-media-file__name" title={displayFileName}>{displayFileName}</span>
|
||||
<input id={inputId} type="file" accept={accept} disabled={disabled || uploading} onChange={handleFileChange} />
|
||||
<input id={inputId} type="file" accept={accept} disabled={uploading} onChange={handleFileChange} />
|
||||
</div>
|
||||
<input
|
||||
className="nodedc-media-url"
|
||||
type="url"
|
||||
disabled={disabled || uploading}
|
||||
value={url}
|
||||
hidden={source !== "url"}
|
||||
data-nodedc-media-source-panel="url"
|
||||
@@ -92,7 +89,6 @@ export function MediaSourceField({
|
||||
<div className="nodedc-media-source-switch" aria-label={`${label}: источник`}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || uploading}
|
||||
className="nodedc-media-source-button"
|
||||
data-active={source === "file" ? "true" : undefined}
|
||||
data-nodedc-media-source-option="file"
|
||||
@@ -102,7 +98,6 @@ export function MediaSourceField({
|
||||
>HD</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || uploading}
|
||||
className="nodedc-media-source-button"
|
||||
data-active={source === "url" ? "true" : undefined}
|
||||
data-nodedc-media-source-option="url"
|
||||
|
||||
@@ -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,22 +1,13 @@
|
||||
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 { Dropdown } from "./Dropdown.js";
|
||||
|
||||
export interface RangeControlProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> {
|
||||
label: string;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
exactValueBounds?: {
|
||||
min?: number;
|
||||
max?: number;
|
||||
};
|
||||
formatValue?: (value: number) => string;
|
||||
/** Unit-free endpoint labels beside the vertical scale. */
|
||||
formatLimit?: (value: number) => string;
|
||||
/** Physical side for vertical endpoint labels; use the inward side of a scene rail. */
|
||||
limitSide?: "left" | "right";
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
@@ -38,17 +29,14 @@ const normalizeEditedRangeValue = (
|
||||
min: number,
|
||||
max: number,
|
||||
step: RangeControlProps["step"],
|
||||
exactValueBounds: RangeControlProps["exactValueBounds"],
|
||||
) => {
|
||||
const exactMin = exactValueBounds?.min ?? Number.NEGATIVE_INFINITY;
|
||||
const exactMax = exactValueBounds?.max ?? Number.POSITIVE_INFINITY;
|
||||
const clamped = clampRangeValue(value, exactMin, exactMax);
|
||||
const clamped = clampRangeValue(value, min, max);
|
||||
if (step === "any") return clamped;
|
||||
const numericStep = step === undefined ? 1 : Number(step);
|
||||
if (!Number.isFinite(numericStep) || numericStep <= 0) return clamped;
|
||||
const precision = Math.min(12, Math.max(decimalPlaces(min), decimalPlaces(numericStep)));
|
||||
const aligned = min + Math.round((clamped - min) / numericStep) * numericStep;
|
||||
return clampRangeValue(Number(aligned.toFixed(precision)), exactMin, exactMax);
|
||||
return clampRangeValue(Number(aligned.toFixed(precision)), min, max);
|
||||
};
|
||||
|
||||
const editableNumber = (value: number) => (
|
||||
@@ -57,28 +45,22 @@ const editableNumber = (value: number) => (
|
||||
|
||||
export function RangeControl({
|
||||
label,
|
||||
orientation = "horizontal",
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
exactValueBounds,
|
||||
step,
|
||||
formatValue = String,
|
||||
formatLimit = String,
|
||||
limitSide = "right",
|
||||
onChange,
|
||||
className,
|
||||
disabled = false,
|
||||
tabIndex,
|
||||
...props
|
||||
}: RangeControlProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const rangeRef = useRef<HTMLInputElement>(null);
|
||||
const editorRef = useRef<HTMLInputElement>(null);
|
||||
const cancelNextBlurRef = useRef(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(() => editableNumber(value));
|
||||
const [editorContrast, setEditorContrast] = useState<"base" | "fill">("base");
|
||||
const safeMax = max === min ? min + 1 : max;
|
||||
const progress = Math.max(0, Math.min(100, ((value - min) / (safeMax - min)) * 100));
|
||||
const displayValue = formatValue(value);
|
||||
@@ -95,30 +77,10 @@ export function RangeControl({
|
||||
if (!editing) setDraft(editableNumber(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) => {
|
||||
if (commit) {
|
||||
const parsed = Number(draft.trim().replace(",", "."));
|
||||
if (Number.isFinite(parsed)) {
|
||||
onChange(normalizeEditedRangeValue(parsed, min, max, step, exactValueBounds));
|
||||
}
|
||||
if (Number.isFinite(parsed)) onChange(normalizeEditedRangeValue(parsed, min, max, step));
|
||||
}
|
||||
setEditing(false);
|
||||
if (restoreRangeFocus) requestAnimationFrame(() => rangeRef.current?.focus());
|
||||
@@ -140,24 +102,8 @@ export function RangeControl({
|
||||
}
|
||||
};
|
||||
|
||||
if (orientation === "vertical") return (
|
||||
<div className="nodedc-range-scale" data-limit-side={limitSide}>
|
||||
<div ref={rootRef} className={cn("nodedc-range", className)} style={style} data-orientation="vertical">
|
||||
<input ref={rangeRef} {...props} type="range" value={value} min={min} max={safeMax} step={step}
|
||||
disabled={disabled} tabIndex={tabIndex} aria-label={label} aria-orientation="vertical"
|
||||
aria-valuetext={displayValue} onChange={(event) => onChange(Number(event.target.value))} />
|
||||
<span className="nodedc-range__label">{label} · {displayValue}</span>
|
||||
<span className="nodedc-range__fill-text" aria-hidden="true">
|
||||
<span className="nodedc-range__label">{label} · {displayValue}</span>
|
||||
</span>
|
||||
</div>
|
||||
<span className="nodedc-range-scale__limit nodedc-range-scale__limit--max">{formatLimit(max)}</span>
|
||||
<span className="nodedc-range-scale__limit nodedc-range-scale__limit--min">{formatLimit(min)}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
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
|
||||
ref={rangeRef}
|
||||
{...props}
|
||||
@@ -183,14 +129,22 @@ export function RangeControl({
|
||||
className="nodedc-range__editor"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={draft}
|
||||
value={editing ? draft : editableNumber(value)}
|
||||
aria-label={`${label}: точное значение`}
|
||||
disabled={disabled}
|
||||
spellCheck={false}
|
||||
data-active={editing || undefined}
|
||||
data-contrast={editorContrast}
|
||||
onPointerDown={() => {
|
||||
if (!editing) {
|
||||
setDraft(editableNumber(value));
|
||||
setEditing(true);
|
||||
}
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (!editing) setEditing(true);
|
||||
if (!editing) {
|
||||
setDraft(editableNumber(value));
|
||||
setEditing(true);
|
||||
}
|
||||
}}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
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;
|
||||
}
|
||||
|
||||
export type SegmentedControlSize = "default" | "dense";
|
||||
|
||||
export interface SegmentedControlProps<T extends string> {
|
||||
value: T;
|
||||
items: Array<SegmentedItem<T>>;
|
||||
label: string;
|
||||
size?: SegmentedControlSize;
|
||||
className?: string;
|
||||
onChange: (value: T) => void;
|
||||
}
|
||||
@@ -23,17 +20,11 @@ export function SegmentedControl<T extends string>({
|
||||
value,
|
||||
items,
|
||||
label,
|
||||
size = "default",
|
||||
className,
|
||||
onChange,
|
||||
}: SegmentedControlProps<T>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("nodedc-segmented", className)}
|
||||
role="tablist"
|
||||
aria-label={label}
|
||||
data-size={size === "default" ? undefined : size}
|
||||
>
|
||||
<div className={cn("nodedc-segmented", className)} role="tablist" aria-label={label}>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
@@ -52,3 +43,4 @@ export function SegmentedControl<T extends string>({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useContext, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from "react";
|
||||
import { useContext, useMemo, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import type { FloatingPlacement } from "@nodedc/ui-core";
|
||||
import { Dropdown } from "./Dropdown.js";
|
||||
import { IconButton } from "./Button.js";
|
||||
import { InspectorSelectPolicyContext } from "./InspectorContext.js";
|
||||
import { cn } from "./cn.js";
|
||||
|
||||
@@ -11,10 +10,9 @@ export interface SelectOption<T extends string> {
|
||||
description?: string;
|
||||
icon?: ReactNode;
|
||||
disabled?: boolean;
|
||||
action?: { label: string; icon: ReactNode; disabled?: boolean; tone?: "neutral" | "danger"; onAction: () => void };
|
||||
}
|
||||
|
||||
export type SelectVariant = "integrated" | "split" | "inline";
|
||||
export type SelectVariant = "integrated" | "split";
|
||||
|
||||
export interface SelectProps<T extends string> {
|
||||
value: T;
|
||||
@@ -54,11 +52,7 @@ export function Select<T extends string>({
|
||||
const inspectorSplitRequired = useContext(InspectorSelectPolicyContext);
|
||||
const resolvedVariant: SelectVariant = inspectorSplitRequired ? "split" : variant;
|
||||
const [query, setQuery] = useState("");
|
||||
const triggerButton = useRef<HTMLButtonElement | null>(null);
|
||||
const selected = options.find((option) => option.value === value) ?? options[0];
|
||||
// Row actions are siblings, never nested interactive listbox options.
|
||||
const withActions = options.some((option) => option.action);
|
||||
const popupRole = withActions ? "dialog" : "listbox";
|
||||
const visibleOptions = useMemo(() => {
|
||||
const normalized = query.trim().toLocaleLowerCase();
|
||||
if (!normalized) return options;
|
||||
@@ -72,15 +66,10 @@ export function Select<T extends string>({
|
||||
minWidth={minMenuWidth}
|
||||
width={menuWidth}
|
||||
disabled={disabled}
|
||||
surfaceRole={popupRole}
|
||||
surfaceRole="listbox"
|
||||
surfaceClassName={cn(resolvedVariant === "split" && "nodedc-select__menu", menuClassName)}
|
||||
trigger={({ open, toggle, setAnchorRef, setTriggerRef, surfaceId }) => {
|
||||
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
triggerButton.current = event.currentTarget;
|
||||
toggle();
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
triggerButton.current = event.currentTarget;
|
||||
if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
if (!open) toggle();
|
||||
@@ -99,11 +88,11 @@ export function Select<T extends string>({
|
||||
type="button"
|
||||
className="nodedc-select__toggle"
|
||||
aria-label={label}
|
||||
aria-haspopup={popupRole}
|
||||
aria-haspopup="listbox"
|
||||
aria-controls={surfaceId}
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
onClick={handleClick}
|
||||
onClick={toggle}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<span className="nodedc-select-trigger__chevron" aria-hidden="true" />
|
||||
@@ -113,37 +102,17 @@ 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={popupRole}
|
||||
aria-controls={surfaceId}
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{selected?.icon ? <span className="nodedc-select-trigger__icon">{selected.icon}</span> : null}
|
||||
<span>{selected?.label ?? "—"}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={setTriggerRef}
|
||||
type="button"
|
||||
className={cn("nodedc-select-trigger", triggerClassName)}
|
||||
aria-label={label}
|
||||
aria-haspopup={popupRole}
|
||||
aria-haspopup="listbox"
|
||||
aria-controls={surfaceId}
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
onClick={handleClick}
|
||||
onClick={toggle}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{selected?.icon ? <span className="nodedc-select-trigger__icon">{selected.icon}</span> : <span className="nodedc-select-trigger__icon" aria-hidden="true" />}
|
||||
@@ -162,13 +131,12 @@ export function Select<T extends string>({
|
||||
</label>
|
||||
) : null}
|
||||
{visibleOptions.map((option) => (
|
||||
<div key={option.value} className="nodedc-select-option-row">
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className="nodedc-dropdown-option"
|
||||
role={withActions ? undefined : "option"}
|
||||
aria-selected={withActions ? undefined : option.value === value}
|
||||
aria-pressed={withActions ? option.value === value : undefined}
|
||||
role="option"
|
||||
aria-selected={option.value === value}
|
||||
data-selected={option.value === value ? "true" : undefined}
|
||||
disabled={option.disabled}
|
||||
onClick={() => {
|
||||
@@ -185,20 +153,6 @@ export function Select<T extends string>({
|
||||
</span>
|
||||
{option.value === value ? <span className="nodedc-dropdown-option__check" aria-hidden="true">•</span> : null}
|
||||
</button>
|
||||
{option.action ? <IconButton
|
||||
label={option.action.label}
|
||||
data-tone={option.action.tone ?? "neutral"}
|
||||
disabled={option.action.disabled}
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
close();
|
||||
// A modal must restore focus to a surviving trigger, not this
|
||||
// row action which disappears when the menu closes.
|
||||
triggerButton.current?.focus();
|
||||
option.action?.onAction();
|
||||
}}
|
||||
>{option.action.icon}</IconButton> : null}
|
||||
</div>
|
||||
))}
|
||||
{visibleOptions.length === 0 ? <div className="nodedc-empty-state">{emptyLabel}</div> : null}
|
||||
</>
|
||||
|
||||
@@ -6,12 +6,11 @@ export interface SettingsCardProps extends Omit<HTMLAttributes<HTMLElement>, "ti
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
align?: "start" | "center";
|
||||
}
|
||||
|
||||
export function SettingsCard({ eyebrow, title, description, actions, children, align = "start", className, ...props }: SettingsCardProps) {
|
||||
export function SettingsCard({ eyebrow, title, description, actions, children, className, ...props }: SettingsCardProps) {
|
||||
return (
|
||||
<section className={cn("nodedc-settings-card", className)} data-align={align === "center" ? "center" : undefined} {...props}>
|
||||
<section className={cn("nodedc-settings-card", className)} {...props}>
|
||||
<header className="nodedc-settings-card__head">
|
||||
<div className="nodedc-settings-card__titles">
|
||||
{eyebrow ? <span>{eyebrow}</span> : null}
|
||||
@@ -20,7 +19,7 @@ export function SettingsCard({ eyebrow, title, description, actions, children, a
|
||||
</div>
|
||||
{actions ? <div className="nodedc-settings-card__actions">{actions}</div> : null}
|
||||
</header>
|
||||
{align === "center" && (children === undefined || children === null || children === false) ? null : <div className="nodedc-settings-card__body">{children}</div>}
|
||||
<div className="nodedc-settings-card__body">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,15 +5,13 @@ export type StatusTone = "neutral" | "success" | "warning" | "danger" | "accent"
|
||||
|
||||
export interface StatusBadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
tone?: StatusTone;
|
||||
variant?: "badge" | "indicator";
|
||||
/** Attention signal on the lamp only; respects reduced-motion preferences. */
|
||||
pulse?: boolean;
|
||||
}
|
||||
|
||||
export function StatusBadge({ tone = "neutral", variant = "badge", pulse = false, className, children, ...props }: StatusBadgeProps) {
|
||||
export function StatusBadge({ tone = "neutral", className, children, ...props }: StatusBadgeProps) {
|
||||
return (
|
||||
<span className={cn("nodedc-status", className)} data-tone={tone === "neutral" ? undefined : tone} data-variant={variant === "indicator" ? variant : undefined} data-pulse={pulse ? "true" : undefined} {...props}>
|
||||
{variant === "indicator" ? null : children}
|
||||
<span className={cn("nodedc-status", className)} data-tone={tone === "neutral" ? undefined : tone} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, type HTMLAttributes } from "react";
|
||||
import { useEffect, type HTMLAttributes } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { cn } from "./cn.js";
|
||||
import { Icon, type IconName } from "./Icon.js";
|
||||
@@ -21,8 +21,6 @@ const toastIcons: Record<ToastTone, IconName> = {
|
||||
loading: "refresh",
|
||||
};
|
||||
|
||||
const DEFAULT_TOAST_DURATION_MS = 10_000;
|
||||
|
||||
export interface ToastCardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
item: ToastItem;
|
||||
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 }) {
|
||||
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;
|
||||
return createPortal(
|
||||
<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>,
|
||||
document.body,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
export * from "./AppHeader.js";
|
||||
export * from "./ActivityIndicator.js";
|
||||
export * from "./LoadingRegion.js";
|
||||
export * from "./AdminNavigationPanel.js";
|
||||
export * from "./ApplicationShell.js";
|
||||
export * from "./ApplicationSidePanel.js";
|
||||
@@ -17,21 +15,13 @@ export * from "./Inspector.js";
|
||||
export * from "./Icon.js";
|
||||
export * from "./MediaSourceField.js";
|
||||
export * from "./RangeControl.js";
|
||||
export * from "./ResourceRow.js";
|
||||
export * from "./SegmentedControl.js";
|
||||
export * from "./Select.js";
|
||||
export * from "./StatusBadge.js";
|
||||
export * from "./Settings.js";
|
||||
export * from "./SharingModals.js";
|
||||
export * from "./SplitPane.js";
|
||||
export * from "./Toolbar.js";
|
||||
export * from "./Toast.js";
|
||||
export * from "./UserProfileMenu.js";
|
||||
export * from "./Window.js";
|
||||
export * from "./WorkspaceWindow.js";
|
||||
|
||||
export { ProgressBar, type ProgressBarProps } from "./ProgressBar.js";
|
||||
export * from "./EnvironmentSettingsWindow.js";
|
||||
export * from "./EnvironmentMediaPlaylistEditor.js";
|
||||
export * from "./EnvironmentBackgroundMedia.js";
|
||||
export * from "./LandingStage.js";
|
||||
|
||||
+25
-241
@@ -1,53 +1,6 @@
|
||||
{
|
||||
"schemaVersion": "1.0.0",
|
||||
"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",
|
||||
"status": "baseline",
|
||||
@@ -57,10 +10,8 @@
|
||||
"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"],
|
||||
"variants": ["default", "strong", "soft", "map"],
|
||||
"radiusVariants": ["card", "panel", "modal", "pill"],
|
||||
"rules": [
|
||||
"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.",
|
||||
"Nested surfaces must use a softer tone to avoid card-inside-card noise.",
|
||||
"GlassyMaterialSurface is restricted to modal Windows and the draggable Inspector.",
|
||||
@@ -71,70 +22,15 @@
|
||||
"id": "button",
|
||||
"status": "baseline",
|
||||
"package": "@nodedc/ui-react",
|
||||
"exports": [
|
||||
"Button",
|
||||
"IconButton"
|
||||
],
|
||||
"domContract": [
|
||||
"nodedc-button",
|
||||
"nodedc-icon-button"
|
||||
],
|
||||
"exports": ["Button", "IconButton"],
|
||||
"domContract": ["nodedc-button", "nodedc-icon-button"],
|
||||
"summary": "Text and icon actions with shared sizing, states and computed accent contrast.",
|
||||
"variants": [
|
||||
"primary",
|
||||
"secondary",
|
||||
"ghost",
|
||||
"danger",
|
||||
"accent"
|
||||
],
|
||||
"sizes": [
|
||||
"default",
|
||||
"compact",
|
||||
"dense"
|
||||
],
|
||||
"shapes": [
|
||||
"default",
|
||||
"pill",
|
||||
"rounded"
|
||||
],
|
||||
"variants": ["primary", "secondary", "ghost", "danger", "accent"],
|
||||
"shapes": ["default", "pill", "rounded"],
|
||||
"rules": [
|
||||
"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.",
|
||||
"Destructive actions remain neutral until confirmation unless danger is the principal message.",
|
||||
"loading displays one centered indicator inside the initiating action, disables repeat activation and preserves dimensions and accessible name. Never place its spinner beside or below the action group.",
|
||||
"Primary tone=neutral is white when enabled and gray when disabled in every theme/accent; use it for accent-independent operator actions without local CSS overrides."
|
||||
]
|
||||
},
|
||||
{
|
||||
"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 Button.loading or IconButton.loading for actions and LoadingRegion for content. Standalone ActivityIndicator is limited to an explicit inline status or resource-row slot.",
|
||||
"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": "loading-region",
|
||||
"status": "baseline",
|
||||
"package": "@nodedc/ui-react",
|
||||
"exports": ["LoadingRegion", "LoadingRegionProps"],
|
||||
"domContract": ["nodedc-loading-region", "nodedc-loading-region__status"],
|
||||
"summary": "Centered pending feedback within the content region that owns the request, without unmounting content.",
|
||||
"rules": [
|
||||
"The consumer owns loading and its terminal result; no timer or implicit network operation exists in this component.",
|
||||
"Keep content mounted and reserve its dimensions while pending. Center the indicator within this region in normal and expanded layouts.",
|
||||
"A command belongs to its initiating Button.loading; do not duplicate that command in a region spinner.",
|
||||
"Remove loading on first usable content, failure or timeout. A live media connection alone is not usable content.",
|
||||
"Use only for blocking initial content or explicit recovery. Background refresh with usable content does not cover it."
|
||||
"Destructive actions remain neutral until confirmation unless danger is the principal message."
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -146,8 +42,7 @@
|
||||
"summary": "Textual form controls with consistent labels, hints, geometry and focus surface.",
|
||||
"rules": [
|
||||
"Labels and validation copy are part of the field contract.",
|
||||
"Keyboard focus changes the surface and remains visible without a browser-blue outline.",
|
||||
"Browser autofill retains the canonical field material, text and caret; keyboard focus remains visible."
|
||||
"Keyboard focus changes the surface and remains visible without a browser-blue outline."
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -169,23 +64,17 @@
|
||||
"package": "@nodedc/ui-react",
|
||||
"exports": ["RangeControl"],
|
||||
"domContract": ["nodedc-range"],
|
||||
"summary": "Filled pill range control with horizontal exact editing or a slim vertical scale with endpoints on the scene-inward side and a fixed centered label/value.",
|
||||
"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"],
|
||||
"summary": "Filled pill range control with embedded label, drag interaction and a persistent native inline exact-value editor.",
|
||||
"behavior": ["native range drag", "value click exact editing", "Enter/blur commit", "Escape cancel", "min/max clamp", "step normalization"],
|
||||
"rules": [
|
||||
"The accent fill follows the active application theme.",
|
||||
"Vertical scales place formatLimit(min/max) on the configured scene-inward side (right by default, left with limitSide=left) in theme text, without units by default; label and formatValue(value) stay together at the center of the pill.",
|
||||
"The vertical shaft is 40 percent of control height (20 percent slimmer than the original half-width variant); center and endpoint typography are 90 percent of their sm/xs tokens.",
|
||||
"Central vertical text uses two identical layers clipped to the rounded fill, including partially covered glyphs. Vertical scales never have a perimeter border, outline or shadow, including focus; focus-visible underlines both central text layers with their existing contrast.",
|
||||
"The native range remains the accessible drag input while its chrome is visually replaced.",
|
||||
"The fill travels beneath the visible value; the persistent transparent native editor above the value receives exact-edit clicks without remounting.",
|
||||
"Exact editing stays inside the existing value area and never expands the control.",
|
||||
"Slider min/max define drag travel only; finite typed values are independent and may exceed that geometry.",
|
||||
"exactValueBounds adds explicit domain limits when a parameter must reject values beyond them.",
|
||||
"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 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."
|
||||
]
|
||||
"The fill travels beneath the visible value; the persistent transparent native editor above the value receives exact-edit clicks without remounting.",
|
||||
"Exact editing stays inside the existing value area and never expands the control.",
|
||||
"The browser places the caret at the clicked character and keeps native drag selection; entering exact editing never forces select-all or adds a second focus ring.",
|
||||
"The editor has no separate background box and keeps focus across parent window rerenders and pointer leave.",
|
||||
"Editor keyboard events do not bubble into enclosing Window shortcuts."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "color-field",
|
||||
@@ -224,14 +113,11 @@
|
||||
"domPackage": "@nodedc/ui-dom",
|
||||
"domExports": ["createSelectController"],
|
||||
"summary": "Theme-independent selection control using the canonical dropdown layer.",
|
||||
"variants": ["integrated", "split", "inline"],
|
||||
"behavior": ["controlled value", "optional search", "disabled options", "selected state", "portal menu", "optional independent row action"],
|
||||
"variants": ["integrated", "split"],
|
||||
"behavior": ["controlled value", "optional search", "disabled options", "selected state", "portal menu"],
|
||||
"rules": [
|
||||
"An option.action is a sibling canonical IconButton with an independent disabled state; it closes the menu without selecting the option. Action-bearing menus use dialog semantics instead of nesting interactive controls in listbox options. Confirmation and mutation remain consumer-owned.",
|
||||
"Select row actions match the compact Mission Core session-catalog reference: transparent 2.35rem hit column, 15px glyph, muted foreground and no round backing. An optional danger tone changes only hover/focus foreground; canonical keyboard focus remains visible.",
|
||||
"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.",
|
||||
"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.",
|
||||
"The portal menu preserves its source-family row geometry instead of inheriting card radii."
|
||||
]
|
||||
@@ -250,9 +136,7 @@
|
||||
"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.",
|
||||
"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.",
|
||||
"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."
|
||||
"A draggable inspector moves by its header and remains clamped to the viewport."
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -279,23 +163,6 @@
|
||||
"summary": "Async-safe confirmation window for destructive and consequential operations.",
|
||||
"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",
|
||||
"status": "baseline",
|
||||
@@ -331,11 +198,7 @@
|
||||
"package": "@nodedc/ui-react",
|
||||
"exports": ["SegmentedControl"],
|
||||
"domContract": ["nodedc-segmented"],
|
||||
"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."
|
||||
]
|
||||
"summary": "Pill navigation used in the shared top header and compact mode switches."
|
||||
},
|
||||
{
|
||||
"id": "app-header",
|
||||
@@ -347,7 +210,6 @@
|
||||
"anatomy": ["left brand", "optional left context", "center navigation/workspace", "right actions/profile"],
|
||||
"rules": [
|
||||
"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.",
|
||||
"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."
|
||||
@@ -428,7 +290,6 @@
|
||||
"variants": ["14 px", "16 px", "18 px"],
|
||||
"rules": [
|
||||
"Use semantic names from registry/icons.json.",
|
||||
"Use layers for visibility controls of map or visual source layers; settings remains general configuration.",
|
||||
"The containing component owns surface, hit target and state; the icon owns only the glyph and uses the canonical 16 px default without an extra CSS scale.",
|
||||
"Do not copy complete Lucide or Font Awesome catalogs into an application."
|
||||
]
|
||||
@@ -467,28 +328,12 @@
|
||||
"id": "media-source-field",
|
||||
"status": "baseline",
|
||||
"package": "@nodedc/ui-react",
|
||||
"exports": [
|
||||
"MediaSourceField"
|
||||
],
|
||||
"exports": ["MediaSourceField"],
|
||||
"domPackage": "@nodedc/ui-dom",
|
||||
"domExports": [
|
||||
"createMediaSourceController"
|
||||
],
|
||||
"domContract": [
|
||||
"nodedc-media-field",
|
||||
"nodedc-media-control",
|
||||
"nodedc-media-source-button",
|
||||
"nodedc-media-preview"
|
||||
],
|
||||
"domExports": ["createMediaSourceController"],
|
||||
"domContract": ["nodedc-media-field", "nodedc-media-control", "nodedc-media-source-button", "nodedc-media-preview"],
|
||||
"summary": "Launcher/CMS media control for video, image or logo file/URL sources, filename, preview and adapter-provided storage state.",
|
||||
"behavior": [
|
||||
"controlled file/url source",
|
||||
"native accessible file input",
|
||||
"URL input",
|
||||
"image/video preview",
|
||||
"path, hint and error slots",
|
||||
"controlled disabled state locks file, URL and source switching during a parent save/upload"
|
||||
],
|
||||
"behavior": ["controlled file/url source", "native accessible file input", "URL input", "image/video preview", "path, hint and error slots"],
|
||||
"rules": [
|
||||
"The component owns geometry, source switching and accessibility.",
|
||||
"The consumer owns media library, storage upload, validation and the persisted URL.",
|
||||
@@ -505,10 +350,7 @@
|
||||
"summary": "Neutral administration group with title metadata, actions, content and a compact binary switch.",
|
||||
"rules": [
|
||||
"The card owns grouping geometry but not product form schemas.",
|
||||
"Use align=center for compact result or empty messages: center the content on both axes and omit an empty body. The default start layout remains unchanged.",
|
||||
"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.",
|
||||
"Switch keeps its track width beside wrapped labels. The checked thumb uses on-accent contrast; the unchecked track follows theme text instead of a fixed white tint."
|
||||
"Use Switch for compact visibility/enable states; use Checker for the Engine inspector treatment."
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -516,10 +358,8 @@
|
||||
"status": "baseline",
|
||||
"package": "@nodedc/ui-react",
|
||||
"exports": ["StatusBadge"],
|
||||
"variants": ["badge", "indicator"],
|
||||
"domContract": ["nodedc-status"],
|
||||
"summary": "Semantic status pill whose tone is independent from the application accent.",
|
||||
"behavior": ["optional pulse animates only the status lamp", "pulse is static with reduced motion"]
|
||||
"summary": "Semantic status pill whose tone is independent from the application accent."
|
||||
},
|
||||
{
|
||||
"id": "toast",
|
||||
@@ -529,11 +369,9 @@
|
||||
"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.",
|
||||
"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": [
|
||||
"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.",
|
||||
"Status notifications never replace a blocking confirmation modal."
|
||||
]
|
||||
@@ -571,60 +409,6 @@
|
||||
"The panel variant reuses AdminNavigationPanel pill, icon and theme tokens while retaining the same controlled accordion state and domain-owned content.",
|
||||
"Accent-filled section headers belong to Environment Settings; neutral headers remain available for other approved inspector contexts."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "environment-settings",
|
||||
"status": "baseline",
|
||||
"package": "@nodedc/ui-react",
|
||||
"exports": [
|
||||
"EnvironmentSettingsWindow",
|
||||
"EnvironmentMediaPlaylistEditor"
|
||||
],
|
||||
"domContract": [
|
||||
"nodedc-environment-settings",
|
||||
"nodedc-environment-media-playlist"
|
||||
],
|
||||
"summary": "Shared avatar Settings → Environment composition for product/home copy, quick actions and image/video playlists.",
|
||||
"behavior": [
|
||||
"product-configured page list",
|
||||
"draft/reset/save",
|
||||
"upload adapter",
|
||||
"ordered media playlist",
|
||||
"image timing",
|
||||
"parent save/upload disabled state"
|
||||
],
|
||||
"rules": [
|
||||
"Reuse FeatureSettingsWindow and existing form primitives; never fork an application-specific settings form.",
|
||||
"Consumers own page/action IDs, storage, revision conflicts, authentication and upload URLs.",
|
||||
"A single-home application passes one surface; multi-section applications pass all admitted pages.",
|
||||
"Closing the window preserves host authority and never sends device commands."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "landing-stage",
|
||||
"status": "baseline",
|
||||
"package": "@nodedc/ui-react",
|
||||
"exports": [
|
||||
"LandingStage",
|
||||
"EnvironmentBackgroundMedia"
|
||||
],
|
||||
"domContract": [
|
||||
"nodedc-landing-stage",
|
||||
"nodedc-landing-stage__media"
|
||||
],
|
||||
"summary": "Shared product landing stage with editable copy, quick actions and ordered image/video background.",
|
||||
"behavior": [
|
||||
"image timer",
|
||||
"video end advance",
|
||||
"skip failed media",
|
||||
"flat empty background",
|
||||
"product status/footer slots"
|
||||
],
|
||||
"rules": [
|
||||
"No decorative radial gradient or invented graphic appears without configured media.",
|
||||
"Applications supply content and actions, not duplicate stage markup.",
|
||||
"Background playback and scene navigation never own device acquisition."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+4
-6
@@ -6,13 +6,12 @@
|
||||
"defaultSize": 18,
|
||||
"supportedSizes": [16, 18, 20],
|
||||
"strokeWidth": 1.8,
|
||||
"filledNames": ["play", "stop"],
|
||||
"groups": [
|
||||
{
|
||||
"id": "window-layer",
|
||||
"label": "Окно и слой",
|
||||
"referenceSources": ["launcher", "engine"],
|
||||
"names": ["close", "plus", "expand", "minimize", "refresh", "panel", "apps", "layers"]
|
||||
"names": ["close", "plus", "expand", "minimize", "refresh", "panel", "apps"]
|
||||
},
|
||||
{
|
||||
"id": "navigation",
|
||||
@@ -30,7 +29,7 @@
|
||||
"id": "state-access",
|
||||
"label": "Состояние и доступ",
|
||||
"referenceSources": ["launcher", "seo", "task-manager"],
|
||||
"names": ["check", "alert", "info", "activity", "lock", "key", "shield", "circle", "eye", "eye-off"]
|
||||
"names": ["check", "alert", "activity", "lock", "key", "shield", "circle"]
|
||||
},
|
||||
{
|
||||
"id": "entities",
|
||||
@@ -42,14 +41,13 @@
|
||||
"id": "content",
|
||||
"label": "Контент",
|
||||
"referenceSources": ["seo", "bim-viewer", "engine"],
|
||||
"names": ["camera", "plan", "play", "stop", "image", "video", "file", "folder", "clipboard", "settings"]
|
||||
"names": ["image", "video", "file", "folder", "clipboard", "settings"]
|
||||
}
|
||||
],
|
||||
"rules": [
|
||||
"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.",
|
||||
"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.",
|
||||
"Playback icons play and stop are filled semantic glyphs; all other canonical glyphs remain outline unless their semantic contract says otherwise."
|
||||
"A new icon requires a confirmed product use, a registry entry and a catalog specimen."
|
||||
]
|
||||
}
|
||||
|
||||
@@ -43,9 +43,7 @@
|
||||
"documentation": {
|
||||
"architecture": "../docs/ARCHITECTURE.md",
|
||||
"components": "../docs/COMPONENTS.md",
|
||||
"loading": "../docs/LOADING_STATES.md",
|
||||
"theming": "../docs/THEMING.md",
|
||||
"operationalTypography": "../docs/OPERATIONAL_TYPOGRAPHY.md",
|
||||
"windows": "../docs/WINDOWS_AND_LAYERS.md",
|
||||
"baseline": "../docs/SOURCE_BASELINE.md",
|
||||
"governance": "../docs/GOVERNANCE.md",
|
||||
@@ -55,8 +53,7 @@
|
||||
"moduleFoundry": "../docs/MODULE_STUDIO.md",
|
||||
"mapTemplate": "../docs/MAP_TEMPLATE.md",
|
||||
"icons": "../docs/ICONS.md",
|
||||
"consumption": "../docs/CONSUMPTION.md",
|
||||
"environmentSettings": "../docs/ENVIRONMENT_SETTINGS.md"
|
||||
"consumption": "../docs/CONSUMPTION.md"
|
||||
},
|
||||
"applicationManifestSchema": "schemas/application-manifest-v0.1.schema.json",
|
||||
"designProfileSchema": "schemas/design-profile-v0.1.schema.json",
|
||||
|
||||
@@ -1,65 +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, Button, IconButton, LoadingRegion } 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, /<Button loading>/);
|
||||
assert.match(catalog, /<LoadingRegion loading/);
|
||||
});
|
||||
|
||||
|
||||
test("pending actions preserve their name and content while disabling only the pending control", () => {
|
||||
for (const [Component, props] of [[Button, {}], [IconButton, {label: "Обновить"}]]) {
|
||||
const busy = renderToStaticMarkup(createElement(Component, {...props, loading: true, disabled: false}, "Обновить"));
|
||||
const idle = renderToStaticMarkup(createElement(Component, props, "Обновить"));
|
||||
assert.match(busy, /disabled=""/);
|
||||
assert.match(busy, /aria-busy="true"/);
|
||||
assert.match(busy, /Обновить/);
|
||||
assert.equal((busy.match(/class="nodedc-activity-indicator"/g) || []).length, 1);
|
||||
assert.doesNotMatch(idle, /nodedc-action-loading|disabled=""/);
|
||||
}
|
||||
});
|
||||
|
||||
test("content remains mounted throughout loading and the status disappears on completion", () => {
|
||||
const view = createElement("video", {"data-source": "synthetic"});
|
||||
const pending = renderToStaticMarkup(createElement(LoadingRegion, {loading:true, label:"Ожидаем изображение"}, view));
|
||||
const ready = renderToStaticMarkup(createElement(LoadingRegion, {loading:false, label:"Ожидаем изображение"}, view));
|
||||
assert.match(pending, /<video data-source="synthetic"/);
|
||||
assert.match(ready, /<video data-source="synthetic"/);
|
||||
assert.equal((pending.match(/role="status"/g) || []).length, 1);
|
||||
assert.doesNotMatch(ready, /role="status"|nodedc-activity-indicator/);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { appendEnvironmentMediaItem, removeEnvironmentMediaItem, cloneEnvironmentSettings } from "../packages/ui-core/dist/index.js";
|
||||
import { LandingStage, Button, MediaSourceField } from "../packages/ui-react/dist/index.js";
|
||||
|
||||
const image = { id: "media-first", source: "url", url: "https://example.invalid/background.png", mediaKind: "image", fileName: null };
|
||||
const video = { ...image, id: "media-second", url: "https://example.invalid/background.mp4", mediaKind: "video" };
|
||||
const page = { headerLabel: "Product", eyebrow: "Board", title: "Home", description: "Local operator home", primaryWorkspaceId: null, secondaryWorkspaceId: null, background: { enabled: false, imageDurationSeconds: 10, items: [] } };
|
||||
|
||||
test("media edits preserve playback order and leave saved settings untouched", () => {
|
||||
const settings = { revision: 9, pages: { home: { ...page, background: appendEnvironmentMediaItem(page.background, image) } } };
|
||||
const draft = cloneEnvironmentSettings(settings);
|
||||
draft.pages.home.background = appendEnvironmentMediaItem(draft.pages.home.background, video);
|
||||
draft.pages.home.background.items[0].url = "https://example.invalid/edited.png";
|
||||
assert.equal(settings.pages.home.background.items[0].url, image.url);
|
||||
assert.deepEqual(draft.pages.home.background.items.map(item => item.id), [image.id, video.id]);
|
||||
const removed = removeEnvironmentMediaItem(draft.pages.home.background, image.id);
|
||||
assert.deepEqual(removed.items, [video]);
|
||||
assert.equal(removeEnvironmentMediaItem(removed, video.id).enabled, false);
|
||||
assert.equal(appendEnvironmentMediaItem(removed, video), removed);
|
||||
});
|
||||
|
||||
test("shared home shows real configured media, and an empty home creates no media", () => {
|
||||
const empty = renderToStaticMarkup(createElement(LandingStage, { page }));
|
||||
assert.match(empty, /<h1>Home<\/h1>/);
|
||||
assert.doesNotMatch(empty, /<(?:video|img)\b/);
|
||||
const configured = { ...page, background: { ...page.background, enabled: true, items: [video] } };
|
||||
const html = renderToStaticMarkup(createElement(LandingStage, { page: configured }));
|
||||
assert.match(html, /<video[^>]+src="https:\/\/example.invalid\/background.mp4"/);
|
||||
assert.match(html, /loop=""/);
|
||||
assert.match(html, /muted=""/);
|
||||
});
|
||||
|
||||
test("neutral primary retains native disabled semantics and theme default remains opt-in", () => {
|
||||
const disabled = renderToStaticMarkup(createElement(Button, { variant: "primary", tone: "neutral", disabled: true }, "Start"));
|
||||
assert.match(disabled, /data-tone="neutral"/);
|
||||
assert.match(disabled, /disabled=""/);
|
||||
const themed = renderToStaticMarkup(createElement(Button, { variant: "primary" }, "Start"));
|
||||
assert.doesNotMatch(themed, /data-tone/);
|
||||
const field = renderToStaticMarkup(createElement(MediaSourceField, { source: "url", url: "https://example.invalid/a.png", disabled: true, onSourceChange() {}, onUrlChange() {}, onFileChange() {} }));
|
||||
assert.match(field, /<input[^>]*disabled=""/);
|
||||
});
|
||||
@@ -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 () => {
|
||||
const styles = await readFile(
|
||||
new URL("../packages/ui-core/styles.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const [styles, dropdown] = await Promise.all([
|
||||
readFile(new URL("../packages/ui-core/styles.css", import.meta.url), "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 ?? "";
|
||||
|
||||
assert.match(dropdownRule, /box-sizing:\s*border-box;/);
|
||||
assert.match(dropdownRule, /overflow:\s*auto;/);
|
||||
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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
const [mapPreview, catalog, coreStyles] = await Promise.all([
|
||||
readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"),
|
||||
const [mapInspector, catalog, coreStyles] = await Promise.all([
|
||||
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("../packages/ui-core/styles.css", import.meta.url), "utf8"),
|
||||
]);
|
||||
|
||||
assert.match(mapPreview, /<InspectorSelectField\s+label="Профиль покрытия"/);
|
||||
assert.doesNotMatch(mapPreview, /<ControlRow label="Профиль покрытия">/);
|
||||
assert.match(mapInspector, /<InspectorSelectField\s+label="Профиль покрытия"/);
|
||||
assert.doesNotMatch(mapInspector, /<ControlRow label="Профиль покрытия">/);
|
||||
assert.equal((catalog.match(/<InspectorSelectField/g) ?? []).length, 2);
|
||||
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 () => {
|
||||
const source = await readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /class GridLayerController/);
|
||||
assert.match(source, /dataSourceDisplay\.ready/);
|
||||
assert.match(source, /fixedGridOrigin\(presentation\)/);
|
||||
assert.match(source, /Transforms\.eastNorthUpToFixedFrame\(anchor\)/);
|
||||
assert.match(source, /localSectorAt\(/);
|
||||
assert.match(source, /gridController\?\.pick\(worldPosition\)/);
|
||||
assert.match(source, /lod\.stepKm \* 1_000/);
|
||||
assert.match(source, /lod\.graticuleStepDegrees/);
|
||||
assert.match(source, /lod\.graticuleLineWidthPx/);
|
||||
assert.match(source, /arcType: ArcType\.RHUMB/);
|
||||
assert.match(source, /clampToGround: false/);
|
||||
assert.match(source, /majorLineWidthMultiplier/);
|
||||
assert.match(source, /isLocalMajorLineIndex/);
|
||||
assert.match(source, /isGraticuleMajorLineValue/);
|
||||
assert.match(source, /new LabelCollection\(\)/);
|
||||
assert.match(source, /const maximumLabels = 48/);
|
||||
assert.match(source, /materializeLocalSelection/);
|
||||
assert.match(source, /localVolumeAt\(/);
|
||||
assert.match(source, /focusGridSector/);
|
||||
assert.match(source, /selectedGridSector/);
|
||||
assert.match(source, /mountResources\(resources\)[\s\S]*?const previous = this\.current;[\s\S]*?removeResources\(previous\.resources\)/);
|
||||
assert.doesNotMatch(source, /function rebuildElevatedGrid[\s\S]*?entities\.removeAll\(\)/);
|
||||
assert.doesNotMatch(source, /Math\.min\(40,[\s\S]*?safeRadiusKm \/ safeStepKm/);
|
||||
assert.doesNotMatch(source, /snapGridCenter\(/);
|
||||
const [renderer, gridLayer] = await Promise.all([
|
||||
readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../apps/catalog/src/mapCesiumGridLayer.ts", import.meta.url), "utf8"),
|
||||
]);
|
||||
assert.match(gridLayer, /class GridLayerController/);
|
||||
assert.match(gridLayer, /dataSourceDisplay\.ready/);
|
||||
assert.match(gridLayer, /fixedGridOrigin\(presentation\)/);
|
||||
assert.match(gridLayer, /Transforms\.eastNorthUpToFixedFrame\(anchor\)/);
|
||||
assert.match(gridLayer, /localSectorAt\(/);
|
||||
assert.match(renderer, /gridController\?\.pick\(worldPosition\)/);
|
||||
assert.match(gridLayer, /lod\.stepKm \* 1_000/);
|
||||
assert.match(gridLayer, /lod\.graticuleStepDegrees/);
|
||||
assert.match(gridLayer, /lod\.graticuleLineWidthPx/);
|
||||
assert.match(gridLayer, /arcType: ArcType\.RHUMB/);
|
||||
assert.match(gridLayer, /clampToGround: false/);
|
||||
assert.match(gridLayer, /majorLineWidthMultiplier/);
|
||||
assert.match(gridLayer, /isLocalMajorLineIndex/);
|
||||
assert.match(gridLayer, /isGraticuleMajorLineValue/);
|
||||
assert.match(gridLayer, /new LabelCollection\(\)/);
|
||||
assert.match(gridLayer, /const maximumLabels = 48/);
|
||||
assert.match(gridLayer, /materializeLocalSelection/);
|
||||
assert.match(gridLayer, /localVolumeAt\(/);
|
||||
assert.match(renderer, /focusGridSector/);
|
||||
assert.match(renderer, /selectedGridSector/);
|
||||
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";
|
||||
|
||||
test("Cesium elevated targets keep their profile border while labels occlude them inside the scene overlay", async () => {
|
||||
const [renderer, cesiumDisplay] = await Promise.all([
|
||||
readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"),
|
||||
const [runtimeLayers, cesiumDisplay] = await Promise.all([
|
||||
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"),
|
||||
]);
|
||||
|
||||
assert.match(renderer, /BillboardGraphics/);
|
||||
assert.match(renderer, /elevatedTargetImage\(\s*color,\s*Color\.fromCssColorString\(target\.outlineColor\)\.withAlpha\(target\.outlineOpacity\),\s*target\.outlineWidthPx,\s*target\.headSizePx/);
|
||||
assert.match(renderer, /entity\.point = undefined;\s*entity\.billboard = new BillboardGraphics/);
|
||||
assert.match(renderer, /disableDepthTestDistance: Number\.POSITIVE_INFINITY/);
|
||||
assert.match(renderer, /outlineWidth: 0,\s*style: LabelStyle\.FILL/);
|
||||
assert.doesNotMatch(renderer, /style: 2/);
|
||||
assert.match(runtimeLayers, /BillboardGraphics/);
|
||||
assert.match(runtimeLayers, /elevatedTargetImage\(\s*color,\s*Color\.fromCssColorString\(target\.outlineColor\)\.withAlpha\(target\.outlineOpacity\),\s*target\.outlineWidthPx,\s*target\.headSizePx/);
|
||||
assert.match(runtimeLayers, /entity\.point = undefined;\s*entity\.billboard = new BillboardGraphics/);
|
||||
assert.match(runtimeLayers, /disableDepthTestDistance: Number\.POSITIVE_INFINITY/);
|
||||
assert.match(runtimeLayers, /outlineWidth: 0,\s*style: LabelStyle\.FILL/);
|
||||
assert.doesNotMatch(runtimeLayers, /style: 2/);
|
||||
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 test from "node:test";
|
||||
|
||||
test("Objects menu opens controllable groups from the row and keeps plain bindings as visibility layers", async () => {
|
||||
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
||||
const source = (path) => readFile(new URL(path, import.meta.url), "utf8");
|
||||
|
||||
assert.match(preview, /const toggleSubjectVisibility = \(bindingId: string\)/);
|
||||
assert.match(preview, /role=\{hasControls \? "menuitem" : "menuitemcheckbox"\}/);
|
||||
assert.match(preview, /if \(hasControls\) \{\s*openSubjectWindow\(summary\.bindingId\);\s*close\(\)/);
|
||||
assert.match(preview, /toggleSubjectVisibility\(summary\.bindingId\)/);
|
||||
assert.match(preview, /visible: !state\.visible/);
|
||||
assert.match(preview, /слой скрыт · \$\{summary\.total\} объектов/);
|
||||
assert.doesNotMatch(preview, /Фильтры и счётчики: \$\{summary\.displayName\}/);
|
||||
test("Objects menu delegates controlled layers to the workspace reducer", async () => {
|
||||
const [preview, toolbar, state] = await Promise.all([
|
||||
source("../apps/catalog/src/MapFixturePreview.tsx"),
|
||||
source("../apps/catalog/src/MapWorkspaceToolbar.tsx"),
|
||||
source("../apps/catalog/src/mapWorkspaceState.mjs"),
|
||||
]);
|
||||
|
||||
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 () => {
|
||||
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
||||
test("a subject window exists only when the MCP presentation profile exposes controls", async () => {
|
||||
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(preview, /if \(!state\?\.window\.open \|\| !hasSubjectWindowControls\(summary\.profile\)\) return null/);
|
||||
assert.match(preview, /window: \{ \.\.\.state\.window, open: false \}/);
|
||||
assert.match(model, /export function mapProfileHasSubjectWindowControls/);
|
||||
assert.match(windows, /!state\?\.window\.open \|\| !mapProfileHasSubjectWindowControls\(summary\.profile\)/);
|
||||
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 () => {
|
||||
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:not\(\[data-visible\]\)/);
|
||||
});
|
||||
|
||||
test("facet controls and renderer consume the same canonical enabled-value state", async () => {
|
||||
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
||||
test("facet controls and renderer consume one canonical enabled-value state", async () => {
|
||||
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, /facets: profile \? normalizeMapPresentationFacetSelections\(state\.filters, profile\) : state\.filters/);
|
||||
assert.match(preview, /filters: normalizeMapPresentationFacetSelections\(state\.filters, summary\.profile\)/);
|
||||
assert.match(preview, /const active = mapPresentationFacetValueIsEnabled\(state\.filters, facet\.field, item\.value\)/);
|
||||
assert.match(preview, /toggleMapPresentationFacetSelection\(filters, field, value, availableValues\)/);
|
||||
assert.match(preview, /facet\.values\.map\(\(value\) => value\.value\)/);
|
||||
assert.match(preview, /normalizeMapPresentationFacetSelections\(state\.filters, profile\)/);
|
||||
assert.match(preview, /presentationFilters=\{rendererPresentationFilters\}/);
|
||||
assert.match(windows, /mapPresentationFacetValueIsEnabled\(state\.filters, facet\.field, item\.value\)/);
|
||||
assert.match(windows, /facet\.values\.map\(\(value\) => value\.value\)/);
|
||||
assert.match(state, /toggleMapPresentationFacetSelection\(/);
|
||||
});
|
||||
|
||||
test("joined detail aspects do not become independent map layers", async () => {
|
||||
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
||||
test("joined detail aspects enrich subjects but never become independent renderer layers", async () => {
|
||||
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(preview, /runtimeBindings\.filter\(\(binding\) => primaryBindingIds\.has\(binding\.bindingId\)\)/);
|
||||
assert.match(model, /filter\(\(binding\) => !binding\.joinToBindingId\)/);
|
||||
assert.match(model, /primaryMapBindingConfigs\(bindingConfigs\)/);
|
||||
assert.match(preview, /runtimeBindings=\{\[\.\.\.sectorScopedPrimaryRuntimeBindings, \.\.\.referenceRuntimeBindings\]\}/);
|
||||
});
|
||||
|
||||
test("bounded map windows keep one stack while layers and settings use their canonical surfaces", async () => {
|
||||
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
||||
test("bounded map windows share one reducer-owned z-stack while settings and layers keep canonical surfaces", async () => {
|
||||
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(preview, /const activateWorkspaceWindow = useCallback/);
|
||||
assert.match(preview, /onActivate=\{\(\) => activateWorkspaceWindow\(`binding:\$\{summary\.bindingId\}`\)\}/);
|
||||
assert.doesNotMatch(preview, /onActivate=\{\(\) => openSubjectWindow\(summary\.bindingId\)\}/);
|
||||
assert.match(preview, /sectorWindowZIndex[\s\S]*subjectCardZIndex/);
|
||||
assert.match(contract, /type MapWorkspaceWindowId = "sector" \| "subject-card" \| `binding:\$\{string\}`/);
|
||||
assert.match(state, /function activateWindow\(state, windowId\)/);
|
||||
assert.match(state, /Math\.max\([\s\S]*state\.sector\.window\.zIndex[\s\S]*state\.subjectCard\.zIndex/);
|
||||
assert.match(windows, /type: "activate-window", windowId: `binding:\$\{summary\.bindingId\}`/);
|
||||
assert.match(sectorWindow, /onActivate=\{onActivate\}/);
|
||||
assert.match(preview, /<ApplicationSidePanel[\s\S]*title="Настройки карты"[\s\S]*onClose=\{closeSettingsPanel\}/);
|
||||
assert.match(preview, /createPortal\(headerActions, headerActionsHost\)/);
|
||||
assert.doesNotMatch(preview, /settingsPanelPinned|onPinnedChange/);
|
||||
assert.match(preview, /surfaceClassName="catalog-map-fixture__objects-menu catalog-map-fixture__layers-menu nodedc-map-glass"/);
|
||||
assert.match(preview, /onOpenChange=\{setLayersOpen\}/);
|
||||
assert.doesNotMatch(preview, /settingsWindowZIndex|layersWindowZIndex/);
|
||||
assert.doesNotMatch(preview, /<Window\b/);
|
||||
assert.match(toolbar, /surfaceClassName="catalog-map-fixture__objects-menu catalog-map-fixture__layers-menu nodedc-map-glass"/);
|
||||
assert.match(toolbar, /onOpenChange=\{onOpenChange\}/);
|
||||
assert.doesNotMatch(preview, /settingsPanelPinned|settingsWindowZIndex|layersWindowZIndex/);
|
||||
});
|
||||
|
||||
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 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\)/);
|
||||
});
|
||||
|
||||
test("an active sector scopes point facts, exposes data facets and deactivates on close", async () => {
|
||||
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(preview, /function mapFactInsideGridSector/);
|
||||
assert.match(preview, /localSectorAtGeodetic/);
|
||||
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"),
|
||||
test("active sector scope is geometric, filterable and reducer-deactivated", async () => {
|
||||
const [runtime, sectorWindow, state] = await Promise.all([
|
||||
source("../apps/catalog/src/mapSectorRuntime.mjs"),
|
||||
source("../apps/catalog/src/MapSectorWorkspaceWindow.tsx"),
|
||||
source("../apps/catalog/src/mapWorkspaceState.mjs"),
|
||||
]);
|
||||
|
||||
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, /content\.scrollHeight/);
|
||||
assert.match(workspace, /availableHeight = Math\.max\(0, nextBounds\.height - normalized\.y\)/);
|
||||
assert.match(workspace, /new ResizeObserver\(scheduleFit\)/);
|
||||
});
|
||||
|
||||
test("facet tree selection focuses the subject without resetting a compatible detail tab", async () => {
|
||||
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
||||
const renderer = await readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8");
|
||||
test("facet and search selection focus the subject while the reducer preserves a valid detail tab", async () => {
|
||||
const [preview, renderer, state] = await Promise.all([
|
||||
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 focusSubject = useCallback/);
|
||||
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, /focusSubject\(entityId\)/);
|
||||
assert.match(preview, /focusSubject\(result\.entityId, result\.coordinates\)/);
|
||||
assert.match(preview, /setSubjectCardTabId\(\(current\) =>/);
|
||||
assert.match(preview, /profile\?\.tabs\.some\(\(tab\) => tab\.id === current\)/);
|
||||
assert.match(preview, /\(profile\?\.defaultTabId \?\? "overview"\)/);
|
||||
assert.match(preview, /validTabIds: profile\?\.tabs\.map\(\(tab\) => tab\.id\) \?\? \["overview"\]/);
|
||||
assert.match(state, /action\.validTabIds\.includes\(state\.subjectCard\.tabId\)/);
|
||||
assert.match(renderer, /void viewer\.flyTo\(entity, \{/);
|
||||
assert.match(renderer, /const focusSubjectCoordinates = useCallback/);
|
||||
assert.match(renderer, /viewer\.camera\.flyToBoundingSphere\(new BoundingSphere\(target, 1\), \{/);
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
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 {
|
||||
mapFactMatchesFilters,
|
||||
mapPresentationBindingIsAll,
|
||||
@@ -15,7 +7,7 @@ const {
|
||||
mapPresentationFacetValueIsEnabled,
|
||||
normalizeMapPresentationFacetSelections,
|
||||
toggleMapPresentationFacetSelection,
|
||||
} = await import(moduleUrl);
|
||||
} = await import("../apps/catalog/src/mapPresentationProfile.mjs");
|
||||
|
||||
const profile = {
|
||||
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 () => {
|
||||
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");
|
||||
|
||||
assert.match(server, /pageLayoutMatch\[1\] === "map" \? validateMapPageLayout\(stored\) : stored/);
|
||||
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, /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/);
|
||||
});
|
||||
|
||||
@@ -85,7 +85,7 @@ test("reference subjects remain searchable by profile labels without a provider
|
||||
id: "map.reference.station.v1",
|
||||
title: "Метро",
|
||||
semanticTypes: ["map.station"],
|
||||
label: { mode: "attributes", fields: ["name", "official_name"] },
|
||||
label: { mode: "attributes", fields: ["name", "official_name", "alternate_names"] },
|
||||
};
|
||||
const index = buildMapSearchIndex({
|
||||
runtimeBindings: [{
|
||||
@@ -93,6 +93,7 @@ test("reference subjects remain searchable by profile labels without a provider
|
||||
presentationProfileId: stationProfile.id,
|
||||
facts: [point("osm.node.1", "map.station", {
|
||||
name: "Петроградская",
|
||||
alternate_names: ["Petrogradskaya station"],
|
||||
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, "Petrogradskaya").at(0)?.sourceId, "osm.node.1");
|
||||
assert.equal(searchMapSubjects(index, "osm.node.1").at(0)?.groupTitle, "Метро");
|
||||
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 () => {
|
||||
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/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, /pickedId instanceof Entity/);
|
||||
assert.match(renderer, /onSelectRef\.current\?\.\(pickedId\.id\)/);
|
||||
assert.match(preview, /const handleSelect = useCallback/);
|
||||
assert.match(preview, /setSubjectCardOpen\(true\)/);
|
||||
assert.match(preview, /title=\{selectedSubjectCard\.title\}/);
|
||||
assert.match(preview, /Карточка объекта:/);
|
||||
assert.match(preview, /SegmentedControl/);
|
||||
assert.match(preview, /type: "select-entity"/);
|
||||
assert.match(state, /subjectCard: \{ \.\.\.state\.subjectCard, open: true, tabId \}/);
|
||||
assert.match(windows, /title=\{selectedSubjectCard\.title\}/);
|
||||
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/);
|
||||
});
|
||||
@@ -23,49 +23,6 @@ test("RangeControl keeps native drag semantics and exposes one persistent exact-
|
||||
assert.doesNotMatch(markup, /nodedc-range__value-hit/);
|
||||
});
|
||||
|
||||
test("vertical scale keeps endpoints outside and the label/value centered at every fill level", () => {
|
||||
for (const [value, progress] of [[-1.2, 0], [25.15, 50], [51.5, 100]]) {
|
||||
const markup = renderToStaticMarkup(createElement(RangeControl, {
|
||||
label: "Срез", orientation: "vertical", value, min: -1.2, max: 51.5, step: "any",
|
||||
formatValue: (n) => `${n.toFixed(1).replace('.', ',')} м`,
|
||||
formatLimit: (n) => n.toFixed(1).replace('.', ','), onChange: () => {},
|
||||
}));
|
||||
const renderedProgress = Number(markup.match(/--nodedc-range-progress:([\d.]+)%/)?.[1]);
|
||||
assert.ok(Math.abs(renderedProgress - progress) < 1e-9);
|
||||
assert.match(markup, /aria-label="Срез" aria-orientation="vertical"/);
|
||||
assert.match(markup, /min="-1.2" max="51.5" step="any"/);
|
||||
assert.equal((markup.match(/nodedc-range-scale__limit--max">51,5<\/span>/g) ?? []).length, 1);
|
||||
assert.equal((markup.match(/nodedc-range-scale__limit--min">-1,2<\/span>/g) ?? []).length, 1);
|
||||
assert.match(markup, /<\/span><\/span><\/div><span class="nodedc-range-scale__limit/);
|
||||
assert.equal((markup.match(/nodedc-range__label">Срез · /g) ?? []).length, 2);
|
||||
assert.match(markup, /nodedc-range__fill-text" aria-hidden="true"/);
|
||||
assert.doesNotMatch(markup, /nodedc-range__value|nodedc-range__editor|formatLimit=/);
|
||||
}
|
||||
});
|
||||
|
||||
test("vertical scale mirrors only endpoint labels for the opposite scene edge", () => {
|
||||
const markup = renderToStaticMarkup(createElement(RangeControl, {
|
||||
label: "Срез", orientation: "vertical", limitSide: "left", value: 25.15, min: -1.2, max: 51.5,
|
||||
step: "any", formatLimit: (n) => n.toFixed(1).replace('.', ','), onChange: () => {},
|
||||
}));
|
||||
assert.match(markup, /class="nodedc-range-scale" data-limit-side="left"/);
|
||||
assert.match(markup, /nodedc-range-scale__limit--max">51,5<\/span>/);
|
||||
assert.match(markup, /nodedc-range-scale__limit--min">-1,2<\/span>/);
|
||||
});
|
||||
|
||||
test("vertical contrast clips both glyph layers and visible focus has no perimeter", async () => {
|
||||
const styles = await readFile(new URL("../packages/ui-core/styles.css", import.meta.url), "utf8");
|
||||
assert.match(styles, /\.nodedc-range\[data-orientation="vertical"\] \.nodedc-range__fill-text \{ clip-path: inset\(calc\(100% - var\(--nodedc-range-progress\)\) 0 0 round var\(--nodedc-radius-circle\)\)/);
|
||||
assert.match(styles, /\.nodedc-range-scale \{[^}]*width: calc\(var\(--nodedc-control-height\) \* 0\.4\)/);
|
||||
assert.match(styles, /\.nodedc-range-scale__limit \{[^}]*left: calc\(100% \+ 0\.4rem\);[^}]*color: var\(--nodedc-text-primary\);[^}]*font-size: calc\(var\(--nodedc-font-size-xs\) \* 0\.9\)/);
|
||||
assert.match(styles, /\.nodedc-range-scale\[data-limit-side="left"\] \.nodedc-range-scale__limit \{[^}]*right: calc\(100% \+ 0\.4rem\);[^}]*left: auto;[^}]*text-align: right/);
|
||||
assert.match(styles, /\.nodedc-range\[data-orientation="vertical"\] \.nodedc-range__label \{[^}]*font-size: calc\(var\(--nodedc-font-size-sm\) \* 0\.9\)/);
|
||||
assert.match(styles, /\.nodedc-range\[data-orientation="vertical"\] \{[^}]*border: 0; outline: 0; box-shadow: none;/);
|
||||
assert.match(styles, /\.nodedc-range\[data-orientation="vertical"\]:has\(input:focus-visible\) \.nodedc-range__label \{ text-decoration: underline; text-decoration-thickness: 2px;/);
|
||||
assert.doesNotMatch(styles, /\.nodedc-range\[data-orientation="vertical"\]:has\(input:focus-visible\) \{/);
|
||||
assert.doesNotMatch(styles, /\.nodedc-range\[data-orientation="vertical"\]:focus-within/);
|
||||
});
|
||||
|
||||
test("exact editing is a stable native caret field without remount, forced selection or a dark editor box", async () => {
|
||||
const [component, styles, windowComponent] = await Promise.all([
|
||||
readFile(new URL("../packages/ui-react/src/RangeControl.tsx", import.meta.url), "utf8"),
|
||||
@@ -73,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"),
|
||||
]);
|
||||
|
||||
assert.match(component, /exactValueBounds\?: \{/);
|
||||
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, /normalizeEditedRangeValue\(parsed, min, max, step\)/);
|
||||
assert.match(component, /event\.key === "Enter"/);
|
||||
assert.match(component, /event\.key === "Escape"/);
|
||||
assert.match(component, /event\.stopPropagation\(\)/);
|
||||
@@ -96,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__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\]\[data-contrast="fill"\][\s\S]*?rgb\(var\(--nodedc-on-accent-rgb\)\)/);
|
||||
assert.match(windowComponent, /const onCloseRef = useRef\(onClose\)/);
|
||||
assert.match(windowComponent, /onCloseRef\.current = onClose/);
|
||||
assert.match(windowComponent, /onCloseRef\.current\(\)/);
|
||||
|
||||
@@ -1,39 +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 { Select } from '../packages/ui-react/dist/index.js';
|
||||
|
||||
test('select action menus use dialog semantics in all variants; ordinary select stays a listbox', () => {
|
||||
for (const variant of ['integrated', 'split', 'inline']) {
|
||||
for (const withAction of [false, true]) {
|
||||
const markup = renderToStaticMarkup(createElement(Select, {
|
||||
label: 'Records', variant, value: 'one', onChange: () => {},
|
||||
options: [{value: 'one', label: 'One', ...(withAction ? {action: {label: 'Delete One', icon: 'x', onAction: () => {}}} : {})}],
|
||||
}));
|
||||
assert.match(markup, new RegExp(`aria-haspopup="${withAction ? 'dialog' : 'listbox'}"`));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('row action is independent of selection, closes first and uses canonical IconButton', async () => {
|
||||
const source = await readFile(new URL('../packages/ui-react/src/Select.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /<\/button>\s*\{option\.action \? <IconButton/);
|
||||
assert.match(source, /disabled=\{option\.action\.disabled\}/);
|
||||
assert.match(source, /close\(\);[\s\S]*triggerButton.current\?\.focus\(\);\s*option\.action\?\.onAction\(\)/);
|
||||
assert.match(source, /role=\{withActions \? undefined : "option"\}/);
|
||||
});
|
||||
|
||||
test('row action matches the compact session catalog without a circular backing', async () => {
|
||||
const css = await readFile(new URL('../packages/ui-core/styles.css', import.meta.url), 'utf8');
|
||||
const rule = css.match(/\.nodedc-select-option-row > \.nodedc-icon-button \{([^}]+)\}/)?.[1];
|
||||
assert.match(rule, /width: 2\.35rem/);
|
||||
assert.match(rule, /height: auto/);
|
||||
assert.match(rule, /border-radius: 0/);
|
||||
assert.match(rule, /background: transparent/);
|
||||
assert.match(rule, /box-shadow: none/);
|
||||
assert.match(rule, /color: var\(--nodedc-text-muted\)/);
|
||||
assert.match(css, /\.nodedc-select-option-row > \.nodedc-icon-button > svg \{ width: 15px; height: 15px; transform: none/);
|
||||
assert.match(css, /data-tone="danger"\]\:focus-visible \{ color: rgb\(var\(--nodedc-danger-rgb\)\)/);
|
||||
});
|
||||
@@ -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";
|
||||
import {createElement} from "react";
|
||||
import {renderToStaticMarkup} from "react-dom/server";
|
||||
import {StatusBadge} from "../packages/ui-react/dist/index.js";
|
||||
|
||||
test("status pulse is opt-in and does not replace the status text",()=>{
|
||||
const status=renderToStaticMarkup(createElement(StatusBadge,{tone:"danger",pulse:true},"Привязка потеряна"));
|
||||
assert.match(status,/data-pulse="true"/);
|
||||
assert.match(status,/Привязка потеряна/);
|
||||
assert.doesNotMatch(renderToStaticMarkup(createElement(StatusBadge,{},"Готово")),/data-pulse/);
|
||||
});
|
||||
test("only the lamp animates, with static reduced-motion fallback",()=>{
|
||||
const css=readFileSync(new URL("../packages/ui-core/styles.css",import.meta.url),"utf8");
|
||||
assert.match(css,/\.nodedc-status\[data-pulse="true"\]::before\s*\{[^}]*animation: nodedc-status-pulse/);
|
||||
assert.match(css,/@media \(prefers-reduced-motion: reduce\) \{\s*\.nodedc-status\[data-pulse="true"\]::before \{ animation: none;/);
|
||||
});
|
||||
@@ -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