Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bdfc6c240 | ||
|
|
70ce00f2d0 | ||
|
|
999864e5b0 | ||
|
|
3adeb33b1b | ||
|
|
be6463fd59 | ||
|
|
47d4f19d99 | ||
|
|
17e150b1c7 | ||
|
|
8a79dfe84d | ||
|
|
d51d8bb7f6 | ||
|
|
c8f4916423 | ||
|
|
6e7255ecdb | ||
|
|
c7e136cc14 | ||
|
|
51cb426c6b | ||
|
|
8f38c76f79 | ||
|
|
2fa1951f51 | ||
|
|
19f0d97e23 | ||
|
|
bbb50e06b4 | ||
|
|
d6c62da470 | ||
|
|
a3385e83c4 | ||
|
|
9fa81fde9a | ||
|
|
117bfe0c3a | ||
|
|
1c5246afe8 | ||
|
|
4116f5ba95 |
@@ -2,6 +2,9 @@ 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,
|
||||
StatusBadge,
|
||||
ProgressBar,
|
||||
AdminNavigationPanel,
|
||||
AppHeader,
|
||||
ApplicationPanel,
|
||||
@@ -28,10 +31,13 @@ import {
|
||||
InspectorSelectField,
|
||||
MediaSourceField,
|
||||
RangeControl,
|
||||
ResourceRow,
|
||||
ResourceList,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
ShareAccessModal,
|
||||
ShareLinkModal,
|
||||
SplitPane,
|
||||
SortableItem,
|
||||
SortableScope,
|
||||
SettingsCard,
|
||||
@@ -65,8 +71,7 @@ import {
|
||||
type DesignProfileSummary,
|
||||
type DesignProfileStatus,
|
||||
} from "./applicationManifest.js";
|
||||
import { MapFixturePreview } from "./MapFixturePreview.js";
|
||||
import { createDefaultMapPageLayout, type MapFixturePreviewHandle, type MapPageLayout } from "./mapPageContract.js";
|
||||
import { createDefaultMapPageLayout, MapFixturePreview, type MapFixturePreviewHandle, type MapPageLayout } from "./MapFixturePreview.js";
|
||||
import {
|
||||
mapDesignFragmentForLayout,
|
||||
mapDesignFragmentFromLayout,
|
||||
@@ -209,7 +214,7 @@ const iconGroups: Array<{ title: string; note: string; icons: IconName[] }> = [
|
||||
{
|
||||
title: "Состояние и доступ",
|
||||
note: "Вся платформа",
|
||||
icons: ["check", "alert", "activity", "lock", "key", "shield", "circle"],
|
||||
icons: ["check", "alert", "activity", "lock", "key", "shield", "circle", "eye", "eye-off"],
|
||||
},
|
||||
{
|
||||
title: "Сущности",
|
||||
@@ -219,7 +224,7 @@ const iconGroups: Array<{ title: string; note: string; icons: IconName[] }> = [
|
||||
{
|
||||
title: "Контент",
|
||||
note: "SEO / BIM / Engine",
|
||||
icons: ["image", "video", "file", "folder", "clipboard", "settings"],
|
||||
icons: ["camera", "plan", "play", "stop", "image", "video", "file", "folder", "clipboard", "settings"],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -228,6 +233,7 @@ const iconLabels: Record<IconName, string> = {
|
||||
alert: "Предупреждение",
|
||||
apps: "Приложения",
|
||||
building: "Компания",
|
||||
camera: "Камера",
|
||||
check: "Готово",
|
||||
"chevron-down": "Раскрыть",
|
||||
"chevron-left": "Назад",
|
||||
@@ -239,6 +245,8 @@ const iconLabels: Record<IconName, string> = {
|
||||
database: "База данных",
|
||||
download: "Скачать",
|
||||
edit: "Редактировать",
|
||||
eye: "Показать",
|
||||
"eye-off": "Скрыть",
|
||||
expand: "Развернуть",
|
||||
external: "Открыть снаружи",
|
||||
file: "Файл",
|
||||
@@ -254,6 +262,8 @@ const iconLabels: Record<IconName, string> = {
|
||||
minimize: "Свернуть",
|
||||
network: "Связи",
|
||||
panel: "Панель",
|
||||
plan: "План",
|
||||
play: "Воспроизвести",
|
||||
target: "Таргеты",
|
||||
plus: "Добавить",
|
||||
profile: "Профиль",
|
||||
@@ -263,6 +273,7 @@ const iconLabels: Record<IconName, string> = {
|
||||
settings: "Настройки",
|
||||
shield: "Доступ",
|
||||
sliders: "Параметры",
|
||||
stop: "Остановить",
|
||||
trash: "Удалить",
|
||||
upload: "Загрузить",
|
||||
users: "Участники",
|
||||
@@ -360,6 +371,7 @@ export function CatalogApp() {
|
||||
const [workspaceWindowDemoOpen, setWorkspaceWindowDemoOpen] = useState(true);
|
||||
const [workspaceWindowDemoMaximized, setWorkspaceWindowDemoMaximized] = useState(false);
|
||||
const [workspaceWindowDemoRect, setWorkspaceWindowDemoRect] = useState<WorkspaceWindowRect>({ x: 24, y: 24, width: 340, height: 230 });
|
||||
const [splitPaneDemoSize, setSplitPaneDemoSize] = useState(50);
|
||||
const [sidePanelDemoOpen, setSidePanelDemoOpen] = useState(true);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
@@ -1279,7 +1291,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} />
|
||||
<RangeControl label="Дистанция свечения" value={glowDistance} min={0} max={200} formatValue={(value) => `${value}%`} onChange={setGlowDistance} />
|
||||
<RangeControl label="Дистанция свечения" value={glowDistance} min={0} max={200} exactValueBounds={{ min: 0 }} formatValue={(value) => `${value}%`} onChange={setGlowDistance} />
|
||||
</>
|
||||
),
|
||||
},
|
||||
@@ -1325,7 +1337,7 @@ export function CatalogApp() {
|
||||
case "controls":
|
||||
return (
|
||||
<div className="catalog-grid">
|
||||
<Preview title="Selection" note="integrated / split">
|
||||
<Preview title="Selection" note="integrated / split / inline">
|
||||
<div className="catalog-form">
|
||||
<FieldFrame label="Hub / integrated select">
|
||||
<Select label="Статус" value={selectedStatus} options={[...selectOptions]} searchable onChange={setSelectedStatus} />
|
||||
@@ -1343,6 +1355,19 @@ 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>
|
||||
@@ -1373,6 +1398,34 @@ 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
|
||||
aria-busy="true"
|
||||
disabled
|
||||
icon={<ActivityIndicator size="compact" />}
|
||||
>Подключаем…</Button>
|
||||
</div>
|
||||
<p className="catalog-preview__explanation">При reduced motion кольцо остаётся видимым без вращения; процесс и его завершение принадлежат приложению.</p>
|
||||
</Preview>
|
||||
<Preview title="Лампы состояния" note="один индикатор без дублирующего значка"><StatusBadge variant="indicator" tone="success" aria-label="Готово" title="Готово" /><StatusBadge variant="indicator" aria-label="Недоступно" title="Недоступно" /></Preview>
|
||||
<Preview title="Линейный прогресс" note="измеренный / неизвестный / завершённый">
|
||||
<ProgressBar label="Подготовка" value={0.6} valueText="Три этапа из пяти" />
|
||||
<ProgressBar label="Получение сведений" />
|
||||
<ProgressBar label="Завершено" value={1} />
|
||||
</Preview>
|
||||
<Preview title="Строки ресурсов" note="Mission Core / общий список">
|
||||
<ResourceList aria-label="Пример списка ресурсов">
|
||||
<li><ResourceRow icon={<Icon name="file" />} title="Сохранённый результат" metadata="Сегодня · доступен для просмотра" actions={<IconButton label="Просмотреть пример" onClick={() => setProjectName("Сохранённый результат")}><Icon name="eye" /></IconButton>} /></li>
|
||||
<li><ResourceRow icon={<Icon name="camera" />} title="Подключённая камера" description="Индикатор перед названием" statusPlacement="leading" status={<StatusBadge variant="indicator" tone="success" aria-label="Подключено" title="Подключено" />} actions={<IconButton label="Просмотреть камеру" onClick={() => setProjectName("Подключённая камера")}><Icon name="eye" /></IconButton>} /></li>
|
||||
<li><ResourceRow icon={<Icon name="camera" />} title="Подготовка камеры" metadata="Проверка потоков" progress={{label:"Подготовка камеры",value:0.8}} aria-busy="true" actions={<IconButton label="Просмотр недоступен" disabled><Icon name="eye" /></IconButton>} /></li>
|
||||
</ResourceList>
|
||||
<SettingsCard title="Устройства не обнаружены" description="Подключите устройство, чтобы оно появилось в списке." />
|
||||
</Preview>
|
||||
<Preview title="Оконные действия" note="круг `46 px`" className="catalog-preview--compact">
|
||||
<div className="catalog-window-actions-demo">
|
||||
<Button shape="pill" icon={<Icon name="refresh" />}>Обновить источник</Button>
|
||||
@@ -1381,6 +1434,21 @@ 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 ? (
|
||||
@@ -1419,6 +1487,20 @@ 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 ? (
|
||||
@@ -1622,6 +1704,8 @@ 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>
|
||||
@@ -1926,6 +2010,7 @@ 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={
|
||||
|
||||
+1991
-124
File diff suppressed because it is too large
Load Diff
+1844
-396
File diff suppressed because it is too large
Load Diff
@@ -1,212 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,377 +0,0 @@
|
||||
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 "./mapPageContract.js";
|
||||
import type { MapPageLayout, MapPageSettings } from "./MapFixturePreview.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 "./mapPageContract.js";
|
||||
import type { MapPageLayout, MapPageSettings } from "./MapFixturePreview.js";
|
||||
import type { MapPresentationProfile } from "./mapPresentationProfile.js";
|
||||
|
||||
export type MaterialDraft = {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,610 +0,0 @@
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -1,348 +0,0 @@
|
||||
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 "./mapRendererContract.js";
|
||||
import type { MapPresentation } from "./CesiumMapRenderer.js";
|
||||
|
||||
export type GridLodMode = "3d" | "graticule";
|
||||
export type GridLodProfile = {
|
||||
|
||||
@@ -1,428 +0,0 @@
|
||||
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>
|
||||
</>,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
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";
|
||||
@@ -1,97 +0,0 @@
|
||||
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>>;
|
||||
@@ -1,160 +0,0 @@
|
||||
/**
|
||||
* 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,3 +1,287 @@
|
||||
// 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";
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
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>;
|
||||
@@ -1,141 +0,0 @@
|
||||
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", "alternate_names"],
|
||||
fields: ["name", "official_name", "local_name"],
|
||||
fontWeight: 600,
|
||||
sizePx: 20,
|
||||
color: "#cccccc",
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
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;
|
||||
@@ -1,4 +0,0 @@
|
||||
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;
|
||||
@@ -1,12 +0,0 @@
|
||||
/** 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 "./mapPageContract.js";
|
||||
import type { MapDataProductBinding } from "./MapFixturePreview.js";
|
||||
import type { MapPresentationProfile } from "./mapPresentationProfile.js";
|
||||
import type { MapRuntimeBinding } from "./useMapDataProductRuntime.js";
|
||||
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
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;
|
||||
@@ -1,49 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
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)}°`;
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
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 };
|
||||
@@ -1,256 +0,0 @@
|
||||
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";
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
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;
|
||||
@@ -1,223 +0,0 @@
|
||||
import { toggleMapPresentationFacetSelection } from "./mapPresentationProfile.mjs";
|
||||
|
||||
export function createMapWorkspaceState({ subjectStates, sectorWindowRect, subjectCardRect }) {
|
||||
return {
|
||||
selectedEntityId: undefined,
|
||||
selectedSector: null,
|
||||
subjectStates,
|
||||
activeWindowId: undefined,
|
||||
sector: {
|
||||
window: { rect: sectorWindowRect, maximized: false, zIndex: 142 },
|
||||
hideOutside: false,
|
||||
scope: {
|
||||
excludedBindingIds: [],
|
||||
excludedProviders: [],
|
||||
excludedObjectKinds: [],
|
||||
},
|
||||
},
|
||||
subjectCard: {
|
||||
open: false,
|
||||
rect: subjectCardRect,
|
||||
maximized: false,
|
||||
zIndex: 140,
|
||||
tabId: "overview",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function mapWorkspaceReducer(state, action) {
|
||||
switch (action.type) {
|
||||
case "reset-sector-definition":
|
||||
case "deactivate-sector":
|
||||
return {
|
||||
...state,
|
||||
selectedSector: null,
|
||||
activeWindowId: state.activeWindowId === "sector" ? undefined : state.activeWindowId,
|
||||
sector: {
|
||||
...state.sector,
|
||||
scope: { excludedBindingIds: [], excludedProviders: [], excludedObjectKinds: [] },
|
||||
},
|
||||
};
|
||||
case "set-sector-selection":
|
||||
return { ...state, selectedSector: action.selection };
|
||||
case "select-sector":
|
||||
return action.selection
|
||||
? activateWindow({ ...state, selectedSector: action.selection }, "sector")
|
||||
: mapWorkspaceReducer(state, { type: "deactivate-sector" });
|
||||
case "set-sector-hide-outside":
|
||||
return { ...state, sector: { ...state.sector, hideOutside: action.value } };
|
||||
case "set-sector-scope":
|
||||
return { ...state, sector: { ...state.sector, scope: action.scope } };
|
||||
case "set-sector-scope-enabled": {
|
||||
const key = action.dimension === "binding"
|
||||
? "excludedBindingIds"
|
||||
: action.dimension === "provider"
|
||||
? "excludedProviders"
|
||||
: "excludedObjectKinds";
|
||||
return {
|
||||
...state,
|
||||
sector: {
|
||||
...state.sector,
|
||||
scope: {
|
||||
...state.sector.scope,
|
||||
[key]: setScopeValueEnabled(state.sector.scope[key], action.value, action.enabled),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
case "set-sector-window-rect":
|
||||
return {
|
||||
...state,
|
||||
sector: { ...state.sector, window: { ...state.sector.window, rect: action.rect } },
|
||||
};
|
||||
case "set-sector-window-maximized":
|
||||
return {
|
||||
...state,
|
||||
sector: { ...state.sector, window: { ...state.sector.window, maximized: action.value } },
|
||||
};
|
||||
case "toggle-subject-filter": {
|
||||
const subject = state.subjectStates[action.bindingId];
|
||||
if (!subject) return state;
|
||||
const filters = subject.visible ? subject.filters : {};
|
||||
return replaceSubjectState(state, action.bindingId, {
|
||||
...subject,
|
||||
visible: true,
|
||||
filters: toggleMapPresentationFacetSelection(
|
||||
filters,
|
||||
action.field,
|
||||
action.value,
|
||||
action.availableValues,
|
||||
),
|
||||
});
|
||||
}
|
||||
case "toggle-subject-visibility": {
|
||||
const subject = state.subjectStates[action.bindingId];
|
||||
return subject
|
||||
? replaceSubjectState(state, action.bindingId, { ...subject, visible: !subject.visible })
|
||||
: state;
|
||||
}
|
||||
case "replace-subject-state":
|
||||
return replaceSubjectState(state, action.bindingId, action.subjectState);
|
||||
case "open-subject-window": {
|
||||
const subject = state.subjectStates[action.bindingId];
|
||||
if (!subject) return state;
|
||||
return activateWindow(replaceSubjectState(state, action.bindingId, {
|
||||
...subject,
|
||||
window: { ...subject.window, open: true },
|
||||
}), `binding:${action.bindingId}`);
|
||||
}
|
||||
case "close-subject-window": {
|
||||
const subject = state.subjectStates[action.bindingId];
|
||||
if (!subject) return state;
|
||||
const next = replaceSubjectState(state, action.bindingId, {
|
||||
...subject,
|
||||
window: { ...subject.window, open: false },
|
||||
});
|
||||
const windowId = `binding:${action.bindingId}`;
|
||||
return { ...next, activeWindowId: next.activeWindowId === windowId ? undefined : next.activeWindowId };
|
||||
}
|
||||
case "set-subject-window-rect": {
|
||||
const subject = state.subjectStates[action.bindingId];
|
||||
return subject ? replaceSubjectState(state, action.bindingId, {
|
||||
...subject,
|
||||
window: { ...subject.window, rect: action.rect },
|
||||
}) : state;
|
||||
}
|
||||
case "set-subject-window-maximized": {
|
||||
const subject = state.subjectStates[action.bindingId];
|
||||
return subject ? replaceSubjectState(state, action.bindingId, {
|
||||
...subject,
|
||||
window: { ...subject.window, maximized: action.value },
|
||||
}) : state;
|
||||
}
|
||||
case "activate-window":
|
||||
return activateWindow(state, action.windowId);
|
||||
case "clear-active-window":
|
||||
return {
|
||||
...state,
|
||||
activeWindowId: state.activeWindowId === action.windowId ? undefined : state.activeWindowId,
|
||||
};
|
||||
case "select-entity": {
|
||||
const tabId = action.validTabIds.includes(state.subjectCard.tabId)
|
||||
? state.subjectCard.tabId
|
||||
: action.defaultTabId;
|
||||
return activateWindow({
|
||||
...state,
|
||||
selectedEntityId: action.entityId,
|
||||
subjectCard: { ...state.subjectCard, open: true, tabId },
|
||||
}, "subject-card");
|
||||
}
|
||||
case "close-subject-card":
|
||||
return {
|
||||
...state,
|
||||
activeWindowId: state.activeWindowId === "subject-card" ? undefined : state.activeWindowId,
|
||||
subjectCard: { ...state.subjectCard, open: false },
|
||||
};
|
||||
case "set-subject-card-rect":
|
||||
return { ...state, subjectCard: { ...state.subjectCard, rect: action.rect } };
|
||||
case "set-subject-card-maximized":
|
||||
return { ...state, subjectCard: { ...state.subjectCard, maximized: action.value } };
|
||||
case "set-subject-card-tab":
|
||||
return { ...state, subjectCard: { ...state.subjectCard, tabId: action.tabId } };
|
||||
case "reveal-subject":
|
||||
return {
|
||||
...replaceSubjectState(state, action.bindingId, action.subjectState),
|
||||
sector: {
|
||||
...state.sector,
|
||||
hideOutside: action.hideOutsideSector,
|
||||
scope: action.scope,
|
||||
},
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function replaceSubjectState(state, bindingId, subjectState) {
|
||||
return {
|
||||
...state,
|
||||
subjectStates: { ...state.subjectStates, [bindingId]: subjectState },
|
||||
};
|
||||
}
|
||||
|
||||
function activateWindow(state, windowId) {
|
||||
if (state.activeWindowId === windowId) return state;
|
||||
const nextZIndex = Math.max(
|
||||
20,
|
||||
state.sector.window.zIndex,
|
||||
state.subjectCard.zIndex,
|
||||
...Object.values(state.subjectStates).map((subject) => subject.window.zIndex),
|
||||
) + 1;
|
||||
if (windowId === "sector") {
|
||||
return {
|
||||
...state,
|
||||
activeWindowId: windowId,
|
||||
sector: { ...state.sector, window: { ...state.sector.window, zIndex: nextZIndex } },
|
||||
};
|
||||
}
|
||||
if (windowId === "subject-card") {
|
||||
return {
|
||||
...state,
|
||||
activeWindowId: windowId,
|
||||
subjectCard: { ...state.subjectCard, zIndex: nextZIndex },
|
||||
};
|
||||
}
|
||||
if (windowId.startsWith("binding:")) {
|
||||
const bindingId = windowId.slice("binding:".length);
|
||||
const subject = state.subjectStates[bindingId];
|
||||
return subject ? {
|
||||
...replaceSubjectState(state, bindingId, {
|
||||
...subject,
|
||||
window: { ...subject.window, zIndex: nextZIndex },
|
||||
}),
|
||||
activeWindowId: windowId,
|
||||
} : state;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function setScopeValueEnabled(values, value, enabled) {
|
||||
return enabled
|
||||
? values.filter((candidate) => candidate !== value)
|
||||
: [...new Set([...values, value])];
|
||||
}
|
||||
@@ -1678,6 +1678,33 @@ 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;
|
||||
}
|
||||
@@ -1754,6 +1781,14 @@ 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,8 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { MapDataProductBinding } from "./mapPageContract.js";
|
||||
import { mapRuntimeFactKey } from "./mapRuntimeIdentity.mjs";
|
||||
|
||||
export { mapRuntimeEntityId } from "./mapRuntimeIdentity.mjs";
|
||||
import type { MapDataProductBinding } from "./MapFixturePreview.js";
|
||||
|
||||
export type DataProductPoint = {
|
||||
type: "Point";
|
||||
@@ -81,6 +78,14 @@ 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));
|
||||
}
|
||||
@@ -241,7 +246,7 @@ function replaceSnapshot(current: BindingState, snapshot: SnapshotEnvelope): Bin
|
||||
...current,
|
||||
cursor: snapshot.cursor,
|
||||
state: "ready",
|
||||
facts: Object.fromEntries(snapshot.facts.map((fact) => [mapRuntimeFactKey(fact), fact])),
|
||||
facts: Object.fromEntries(snapshot.facts.map((fact) => [factKey(fact), fact])),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -252,8 +257,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[mapRuntimeFactKey(operation.fact)] = operation.fact;
|
||||
else delete facts[mapRuntimeFactKey(operation)];
|
||||
if (operation.op === "upsert") facts[factKey(operation.fact)] = operation.fact;
|
||||
else delete facts[`${operation.semanticType}\u0000${operation.sourceId}`];
|
||||
}
|
||||
return { ...current, facts, cursor: patch.cursor, state: "ready" };
|
||||
}
|
||||
@@ -261,7 +266,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 = mapRuntimeFactKey(operation);
|
||||
const key = `${operation.semanticType}\u0000${operation.sourceId}`;
|
||||
if (facts[key]) facts[key] = { ...facts[key], presentationStatus: operation.status };
|
||||
}
|
||||
return { ...current, facts };
|
||||
@@ -416,7 +421,7 @@ export function useMapDataProductRuntime({
|
||||
dataProductId: binding.dataProductId,
|
||||
slotId: binding.slotId,
|
||||
presentationProfileId: binding.presentationProfileId,
|
||||
facts: Object.values(record.facts).sort((left, right) => mapRuntimeFactKey(left).localeCompare(mapRuntimeFactKey(right))),
|
||||
facts: Object.values(record.facts).sort((left, right) => factKey(left).localeCompare(factKey(right))),
|
||||
cursor: record.cursor,
|
||||
state: record.state,
|
||||
};
|
||||
|
||||
@@ -211,13 +211,7 @@ 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;
|
||||
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"]);
|
||||
const allowedAttributes = new Set(["name", "category", "network", "operator", "official_name", "local_name", "uic_ref", "wheelchair"]);
|
||||
return {
|
||||
sourceId: value.sourceId,
|
||||
semanticType: String(value.semanticType),
|
||||
|
||||
@@ -44,3 +44,7 @@ Modal остаётся portal-слоем. Его тело прокручивае
|
||||
- предметное содержимое content window.
|
||||
|
||||
Shell владеет геометрией, слоями, переходами и breakpoint-поведением. См. готовый путь подключения в `docs/CONSUMPTION.md`.
|
||||
|
||||
На узком экране скрывается именно действие `data-action="expand"`.
|
||||
Порядок utility actions не является признаком типа действия: плюс и обновление
|
||||
остаются доступны, даже когда стоят первыми в шапке. Исправлено при QA Node 0.3.
|
||||
|
||||
+53
-4
@@ -10,6 +10,8 @@
|
||||
- `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 материала вручную.
|
||||
@@ -25,6 +27,16 @@ Button используется для всех текстовых действ
|
||||
|
||||
Icon-only action по умолчанию круглый. Квадратная кнопка с маленьким радиусом допустима только как кнопка закрытия окна или плотный инструмент, где это зафиксировано контрактом.
|
||||
|
||||
`size="dense"` — каноническая плотность для текстовых layer/mode-действий внутри визуализатора. Она уменьшает площадь и подпись контрола, но не меняет hover, active, disabled и focus-состояния. В шапках приложения и обычных формах эта плотность не используется.
|
||||
|
||||
Переключаемый IconButton передаёт контролируемое состояние через `aria-pressed`. Активная поверхность и контраст принадлежат дизайн-системе; размер круга и glyph при переключении не меняются.
|
||||
|
||||
## ActivityIndicator
|
||||
|
||||
`ActivityIndicator` — общий индикатор неопределённого по длительности процесса. `default` используется рядом с самостоятельным статусом, `compact` — в icon-slot кнопки. Владелец операции по-прежнему задаёт видимый текст pending-состояния и `aria-busy`; индикатор не хранит таймер и не определяет завершение операции.
|
||||
|
||||
Без `label` индикатор декоративный и скрыт от accessibility tree. `label` включает `role="status"` только когда сам индикатор должен объявить процесс. При `prefers-reduced-motion: reduce` кольцо остаётся видимым, но не вращается.
|
||||
|
||||
## Field
|
||||
|
||||
FieldFrame объединяет label, control, hint и description. TextField/TextAreaField реализуют стандартные текстовые поля.
|
||||
@@ -39,7 +51,9 @@ FieldFrame объединяет label, control, hint и description. TextField/T
|
||||
|
||||
## RangeControl
|
||||
|
||||
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 только при открытии и не отбирает фокус при последующих ререндерах.
|
||||
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 только при открытии и не отбирает фокус при последующих ререндерах.
|
||||
|
||||
## ColorField
|
||||
|
||||
@@ -69,10 +83,11 @@ Dropdown владеет floating-layer поведением:
|
||||
|
||||
## Select
|
||||
|
||||
Select добавляет к Dropdown контролируемое значение, options и необязательный поиск. У него две канонические формы:
|
||||
Select добавляет к Dropdown контролируемое значение, options и необязательный поиск. У него три канонические формы:
|
||||
|
||||
- `integrated` — единая pill-поверхность Hub/Launcher: label слева, chevron строго у правого края;
|
||||
- `split` — форма Engine с отдельной областью значения и отдельной кнопкой раскрытия `46 px`, между ними зазор `8 px`.
|
||||
- `split` — форма Engine с отдельной областью значения и отдельной кнопкой раскрытия `46 px`, между ними зазор `8 px`;
|
||||
- `inline` — компактный toolbar-trigger: только текущее значение без постоянной подложки и chevron; всё значение открывает обычное portal-меню.
|
||||
|
||||
Native select не используется как видимый runtime UI. Меню рендерится через portal; у Engine-варианта меню имеет радиус `20 px`, а строки — `14 px` и высоту `42 px`.
|
||||
|
||||
@@ -89,6 +104,8 @@ Window — единая механика открытия modal и правой
|
||||
|
||||
Содержание, сохранение и запросы принадлежат приложению.
|
||||
|
||||
Modeless Inspector закрывается независимо от результата автосохранения. Приложение сначала закрывает controlled `Window`, затем завершает сохранение асинхронно; сбой сообщает через компактный `ToastStack` с tone `error`, сохраняя draft текущего просмотра. Нельзя удерживать окно открытым, выводить внутри него browser-default `p`/heading или увеличивать текст ошибки до title-размера.
|
||||
|
||||
Для modeless Inspector доступен `draggable`: окно двигается за header, не блокирует приложение и ограничивается viewport. Modal-окна не становятся draggable автоматически.
|
||||
|
||||
## WorkspaceWindow
|
||||
@@ -139,6 +156,16 @@ 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.
|
||||
@@ -153,6 +180,8 @@ Read-only link/copy окно из BIM Viewer. React-компонент испо
|
||||
|
||||
Pill navigation для верхней панели и компактного переключения режимов. Active segment использует активную поверхность темы; это не обязательно основной accent приложения.
|
||||
|
||||
`size="dense"` применяется только в насыщенной панели слоёв или режимов непосредственно над viewer. Шапка приложения сохраняет default-геометрию; consumer не воспроизводит dense-отступы или шрифт локальным CSS.
|
||||
|
||||
## AppHeader
|
||||
|
||||
Трёхосевая верхняя панель:
|
||||
@@ -265,7 +294,7 @@ Sortable-строки ограничены вертикальной осью: г
|
||||
|
||||
## ToastStack
|
||||
|
||||
`ToastCard` и `ToastStack` фиксируют Tasker-derived bottom-right уведомления для `success`, `error`, `warning`, `info` и `loading`. Приложение владеет текстом и состоянием операции; стек владеет portal, геометрией, aria-live и таймерами. Loading не закрывается автоматически и обновляется тем же id после завершения операции. Toast не используется вместо modal confirmation.
|
||||
`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.
|
||||
|
||||
## Environment Controls: Inspector и ControlRow
|
||||
|
||||
@@ -287,3 +316,23 @@ 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`; дополнительный текст и значки скрываются.
|
||||
|
||||
+6
-1
@@ -29,6 +29,12 @@
|
||||
|
||||
Допустимы 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:
|
||||
@@ -53,4 +59,3 @@
|
||||
## Владение
|
||||
|
||||
У дизайн-системы должен быть явный code owner. Product team может предлагать компоненты, но общий API и theme contract проходят отдельное review, потому что изменение распространяется на все будущие приложения.
|
||||
|
||||
|
||||
+6
-2
@@ -19,12 +19,16 @@
|
||||
| Окно и слой | `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` |
|
||||
| Состояние и доступ | `check`, `alert`, `activity`, `lock`, `key`, `shield`, `circle`, `eye`, `eye-off` |
|
||||
| Сущности | `profile`, `users`, `building`, `globe`, `database`, `network`, `inbox`, `mail` |
|
||||
| Контент | `image`, `video`, `file`, `folder`, `clipboard`, `settings` |
|
||||
| Контент | `camera`, `plan`, `play`, `stop`, `image`, `video`, `file`, `folder`, `clipboard`, `settings` |
|
||||
|
||||
Живая таблица с названиями, поверхностями и размерами находится в разделе `Guideline → Иконки`. Машинный список — в `registry/icons.json`.
|
||||
|
||||
## Правило расширения
|
||||
|
||||
Новая иконка добавляется только после подтверждённого применения в продукте. Нужно выбрать семантическое имя, добавить export в `Icon`, запись в registry и specimen в каталоге. Локальный импорт иконки только ради немного другой формы запрещён.
|
||||
|
||||
`camera` обозначает оптический канал, а `plan` — пространственное представление сверху. Оба glyph контурные и используют общую stroke-геометрию набора.
|
||||
|
||||
`play` и `stop` — компактные полнотелые транспортные glyph: залитый треугольник запуска и залитый квадрат остановки/паузы. Видимая текстовая подпись не требуется, но action обязан сохранить доступный `aria-label`.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Компактная операционная типографика
|
||||
|
||||
Уточнение владельца 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.
|
||||
@@ -0,0 +1,15 @@
|
||||
# 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, на узком экране переносится ниже текста. Готовность не вычисляется
|
||||
компонентом. Каталог содержит завершённый, определённый и неизвестный прогресс.
|
||||
@@ -0,0 +1,34 @@
|
||||
# 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.
|
||||
+8
-4
@@ -11,7 +11,7 @@
|
||||
"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: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",
|
||||
"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",
|
||||
"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",
|
||||
@@ -22,7 +22,6 @@
|
||||
"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,10 +31,15 @@
|
||||
"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:map-provider-startup": "node --test scripts/map-provider-startup.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:floating-position": "node --test scripts/floating-position-contract.test.mjs",
|
||||
"test:inspector-select": "node --test scripts/inspector-select-contract.test.mjs",
|
||||
"test:range-control": "node --test scripts/range-control-contract.test.mjs"
|
||||
"test:range-control": "node --test scripts/range-control-contract.test.mjs",
|
||||
"test:split-pane": "node --test scripts/split-pane-contract.test.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
--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;
|
||||
@@ -61,6 +62,7 @@
|
||||
|
||||
--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;
|
||||
|
||||
+222
-6
@@ -15,13 +15,13 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
: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) {
|
||||
: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) {
|
||||
border: 0;
|
||||
outline: 0;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
: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 {
|
||||
: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 {
|
||||
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,6 +73,10 @@
|
||||
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);
|
||||
}
|
||||
@@ -138,6 +142,13 @@
|
||||
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%;
|
||||
}
|
||||
@@ -211,6 +222,30 @@
|
||||
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 {
|
||||
display: inline-grid;
|
||||
width: var(--nodedc-icon-button-size);
|
||||
@@ -231,6 +266,11 @@
|
||||
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);
|
||||
}
|
||||
@@ -545,13 +585,13 @@ textarea.nodedc-field__control {
|
||||
|
||||
.nodedc-settings-card__titles h2 {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 1rem;
|
||||
font-size: var(--nodedc-font-size-md);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.nodedc-settings-card__titles p {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.75rem;
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@@ -769,6 +809,10 @@ 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;
|
||||
@@ -1002,6 +1046,19 @@ 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);
|
||||
@@ -2182,6 +2239,33 @@ 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);
|
||||
}
|
||||
@@ -2805,6 +2889,93 @@ 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);
|
||||
@@ -3398,6 +3569,7 @@ textarea.nodedc-field__control {
|
||||
}
|
||||
|
||||
.nodedc-empty-state {
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
display: grid;
|
||||
min-height: 12rem;
|
||||
place-items: center;
|
||||
@@ -3409,7 +3581,7 @@ textarea.nodedc-field__control {
|
||||
|
||||
.nodedc-empty-state strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: var(--nodedc-font-size-lg);
|
||||
font-size: var(--nodedc-font-size-md);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
@@ -3509,7 +3681,7 @@ textarea.nodedc-field__control {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nodedc-application-panel__action:first-child {
|
||||
.nodedc-application-panel__action[data-action="expand"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -3585,6 +3757,7 @@ textarea.nodedc-field__control {
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.nodedc-activity-indicator,
|
||||
.nodedc-dropdown-surface,
|
||||
.nodedc-overlay,
|
||||
.nodedc-window,
|
||||
@@ -3605,3 +3778,46 @@ 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)); }
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
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,6 +4,8 @@ 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;
|
||||
@@ -13,14 +15,15 @@ export function AppHeader({
|
||||
brand,
|
||||
brandHref,
|
||||
brandLabel = "NODE.DC",
|
||||
brandMonochrome = false,
|
||||
left,
|
||||
center,
|
||||
right,
|
||||
}: AppHeaderProps) {
|
||||
const brandNode = brandHref ? (
|
||||
<a className="nodedc-header__brand" href={brandHref} aria-label={brandLabel}>{brand}</a>
|
||||
<a className="nodedc-header__brand" data-monochrome={brandMonochrome || undefined} href={brandHref} aria-label={brandLabel}>{brand}</a>
|
||||
) : (
|
||||
<span className="nodedc-header__brand" aria-label={brandLabel}>{brand}</span>
|
||||
<span className="nodedc-header__brand" data-monochrome={brandMonochrome || undefined} aria-label={brandLabel}>{brand}</span>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -100,11 +103,12 @@ export interface HeaderWorkspaceProps {
|
||||
label: string;
|
||||
imageUrl?: string;
|
||||
kind?: "mark" | "avatar";
|
||||
monochrome?: boolean;
|
||||
}
|
||||
|
||||
export function HeaderWorkspace({ label, imageUrl, kind = "mark" }: HeaderWorkspaceProps) {
|
||||
export function HeaderWorkspace({ label, imageUrl, kind = "mark", monochrome = false }: HeaderWorkspaceProps) {
|
||||
return (
|
||||
<span className="nodedc-header__workspace" data-kind={kind} title={label}>
|
||||
<span className="nodedc-header__workspace" data-kind={kind} data-monochrome={monochrome || undefined} title={label}>
|
||||
{imageUrl ? <img src={imageUrl} alt="" /> : label.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createAccentVariables, type RgbTuple } from "@nodedc/ui-core";
|
||||
import { cn } from "./cn.js";
|
||||
|
||||
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "accent";
|
||||
export type ButtonSize = "default" | "compact";
|
||||
export type ButtonSize = "default" | "compact" | "dense";
|
||||
export type ButtonShape = "default" | "pill" | "rounded";
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
|
||||
@@ -107,32 +107,7 @@ export function Dropdown({
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen) return;
|
||||
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]);
|
||||
}, [isOpen, updatePosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
@@ -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";
|
||||
export type GlassRadius = "card" | "panel" | "modal" | "pill";
|
||||
export type GlassPadding = "none" | "sm" | "md" | "lg";
|
||||
|
||||
export interface GlassSurfaceProps extends HTMLAttributes<HTMLDivElement> {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
AlertTriangle,
|
||||
Boxes,
|
||||
Building2,
|
||||
Camera,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
@@ -12,6 +13,8 @@ import {
|
||||
Copy,
|
||||
Database,
|
||||
Download,
|
||||
Eye,
|
||||
EyeOff,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
@@ -24,11 +27,13 @@ import {
|
||||
LocateFixed,
|
||||
LockKeyhole,
|
||||
MailPlus,
|
||||
Map,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Network,
|
||||
PanelTop,
|
||||
Pencil,
|
||||
Play,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Save,
|
||||
@@ -36,6 +41,7 @@ import {
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
Square,
|
||||
Trash2,
|
||||
UploadCloud,
|
||||
UserCircle,
|
||||
@@ -51,6 +57,7 @@ const icons = {
|
||||
alert: AlertTriangle,
|
||||
apps: Boxes,
|
||||
building: Building2,
|
||||
camera: Camera,
|
||||
check: Check,
|
||||
"chevron-down": ChevronDown,
|
||||
"chevron-left": ChevronLeft,
|
||||
@@ -62,6 +69,8 @@ const icons = {
|
||||
database: Database,
|
||||
download: Download,
|
||||
edit: Pencil,
|
||||
eye: Eye,
|
||||
"eye-off": EyeOff,
|
||||
expand: Maximize2,
|
||||
external: ExternalLink,
|
||||
file: FileText,
|
||||
@@ -78,6 +87,8 @@ const icons = {
|
||||
minimize: Minimize2,
|
||||
network: Network,
|
||||
panel: PanelTop,
|
||||
plan: Map,
|
||||
play: Play,
|
||||
plus: Plus,
|
||||
profile: UserCircle,
|
||||
refresh: RefreshCw,
|
||||
@@ -86,6 +97,7 @@ const icons = {
|
||||
settings: Settings,
|
||||
shield: ShieldCheck,
|
||||
sliders: SlidersHorizontal,
|
||||
stop: Square,
|
||||
trash: Trash2,
|
||||
upload: UploadCloud,
|
||||
users: UsersRound,
|
||||
@@ -99,13 +111,15 @@ export interface IconProps extends Omit<LucideProps, "ref"> {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function Icon({ name, label, size = 16, strokeWidth = 1.6, ...props }: IconProps) {
|
||||
export function Icon({ name, label, size = 16, strokeWidth = 1.6, fill, ...props }: IconProps) {
|
||||
const IconComponent = icons[name];
|
||||
const filledTransport = name === "play" || name === "stop";
|
||||
|
||||
return (
|
||||
<IconComponent
|
||||
size={size}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeWidth={filledTransport ? 0 : strokeWidth}
|
||||
fill={fill ?? (filledTransport ? "currentColor" : "none")}
|
||||
aria-hidden={label ? undefined : "true"}
|
||||
aria-label={label}
|
||||
role={label ? "img" : undefined}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { CSSProperties, HTMLAttributes } from "react";
|
||||
import { cn } from "./cn.js";
|
||||
|
||||
export interface ProgressBarProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
|
||||
label: string;
|
||||
/** Completed fraction from 0 to 1. Omit when the amount is unknown. */
|
||||
value?: number;
|
||||
valueText?: string;
|
||||
}
|
||||
|
||||
export function ProgressBar({ label, value, valueText, className, style, ...props }: ProgressBarProps) {
|
||||
const fraction = typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : undefined;
|
||||
return <div {...props} className={cn("nodedc-progress-bar", className)} role="progressbar"
|
||||
aria-label={label} aria-valuemin={0} aria-valuemax={100}
|
||||
aria-valuenow={fraction === undefined ? undefined : Math.round(fraction * 100)}
|
||||
aria-valuetext={valueText} data-indeterminate={fraction === undefined || undefined}
|
||||
style={{ ...style, "--nodedc-progress-fraction": fraction ?? 0 } as CSSProperties}>
|
||||
<span className="nodedc-progress-bar__fill" />
|
||||
</div>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState, type CSSProperties, type InputHTMLAttributes, type KeyboardEvent, type PointerEvent } from "react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type InputHTMLAttributes, type KeyboardEvent, type PointerEvent } from "react";
|
||||
import { cn } from "./cn.js";
|
||||
import { Dropdown } from "./Dropdown.js";
|
||||
|
||||
@@ -7,6 +7,10 @@ export interface RangeControlProps extends Omit<InputHTMLAttributes<HTMLInputEle
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
exactValueBounds?: {
|
||||
min?: number;
|
||||
max?: number;
|
||||
};
|
||||
formatValue?: (value: number) => string;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
@@ -29,14 +33,17 @@ const normalizeEditedRangeValue = (
|
||||
min: number,
|
||||
max: number,
|
||||
step: RangeControlProps["step"],
|
||||
exactValueBounds: RangeControlProps["exactValueBounds"],
|
||||
) => {
|
||||
const clamped = clampRangeValue(value, min, max);
|
||||
const exactMin = exactValueBounds?.min ?? Number.NEGATIVE_INFINITY;
|
||||
const exactMax = exactValueBounds?.max ?? Number.POSITIVE_INFINITY;
|
||||
const clamped = clampRangeValue(value, exactMin, exactMax);
|
||||
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)), min, max);
|
||||
return clampRangeValue(Number(aligned.toFixed(precision)), exactMin, exactMax);
|
||||
};
|
||||
|
||||
const editableNumber = (value: number) => (
|
||||
@@ -48,6 +55,7 @@ export function RangeControl({
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
exactValueBounds,
|
||||
step,
|
||||
formatValue = String,
|
||||
onChange,
|
||||
@@ -56,11 +64,13 @@ export function RangeControl({
|
||||
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);
|
||||
@@ -77,10 +87,30 @@ 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));
|
||||
if (Number.isFinite(parsed)) {
|
||||
onChange(normalizeEditedRangeValue(parsed, min, max, step, exactValueBounds));
|
||||
}
|
||||
}
|
||||
setEditing(false);
|
||||
if (restoreRangeFocus) requestAnimationFrame(() => rangeRef.current?.focus());
|
||||
@@ -103,7 +133,7 @@ export function RangeControl({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("nodedc-range", className)} style={style} data-editing={editing || undefined}>
|
||||
<div ref={rootRef} className={cn("nodedc-range", className)} style={style} data-editing={editing || undefined}>
|
||||
<input
|
||||
ref={rangeRef}
|
||||
{...props}
|
||||
@@ -129,22 +159,14 @@ export function RangeControl({
|
||||
className="nodedc-range__editor"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={editing ? draft : editableNumber(value)}
|
||||
value={draft}
|
||||
aria-label={`${label}: точное значение`}
|
||||
disabled={disabled}
|
||||
spellCheck={false}
|
||||
data-active={editing || undefined}
|
||||
onPointerDown={() => {
|
||||
if (!editing) {
|
||||
setDraft(editableNumber(value));
|
||||
setEditing(true);
|
||||
}
|
||||
}}
|
||||
data-contrast={editorContrast}
|
||||
onFocus={() => {
|
||||
if (!editing) {
|
||||
setDraft(editableNumber(value));
|
||||
setEditing(true);
|
||||
}
|
||||
if (!editing) setEditing(true);
|
||||
}}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={handleEditorKeyDown}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
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,10 +8,13 @@ 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;
|
||||
}
|
||||
@@ -20,11 +23,17 @@ 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}>
|
||||
<div
|
||||
className={cn("nodedc-segmented", className)}
|
||||
role="tablist"
|
||||
aria-label={label}
|
||||
data-size={size === "default" ? undefined : size}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
@@ -43,4 +52,3 @@ export function SegmentedControl<T extends string>({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface SelectOption<T extends string> {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export type SelectVariant = "integrated" | "split";
|
||||
export type SelectVariant = "integrated" | "split" | "inline";
|
||||
|
||||
export interface SelectProps<T extends string> {
|
||||
value: T;
|
||||
@@ -102,6 +102,26 @@ export function Select<T extends string>({
|
||||
);
|
||||
}
|
||||
|
||||
if (resolvedVariant === "inline") {
|
||||
return (
|
||||
<button
|
||||
ref={setTriggerRef}
|
||||
type="button"
|
||||
className={cn("nodedc-select-inline", triggerClassName)}
|
||||
aria-label={label}
|
||||
aria-haspopup="listbox"
|
||||
aria-controls={surfaceId}
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
onClick={toggle}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{selected?.icon ? <span className="nodedc-select-trigger__icon">{selected.icon}</span> : null}
|
||||
<span>{selected?.label ?? "—"}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={setTriggerRef}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
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,12 +5,13 @@ export type StatusTone = "neutral" | "success" | "warning" | "danger" | "accent"
|
||||
|
||||
export interface StatusBadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
tone?: StatusTone;
|
||||
variant?: "badge" | "indicator";
|
||||
}
|
||||
|
||||
export function StatusBadge({ tone = "neutral", className, children, ...props }: StatusBadgeProps) {
|
||||
export function StatusBadge({ tone = "neutral", variant = "badge", className, children, ...props }: StatusBadgeProps) {
|
||||
return (
|
||||
<span className={cn("nodedc-status", className)} data-tone={tone === "neutral" ? undefined : tone} {...props}>
|
||||
{children}
|
||||
<span className={cn("nodedc-status", className)} data-tone={tone === "neutral" ? undefined : tone} data-variant={variant === "indicator" ? variant : undefined} {...props}>
|
||||
{variant === "indicator" ? null : children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, type HTMLAttributes } from "react";
|
||||
import { useEffect, useRef, type HTMLAttributes } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { cn } from "./cn.js";
|
||||
import { Icon, type IconName } from "./Icon.js";
|
||||
@@ -21,6 +21,8 @@ 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;
|
||||
@@ -48,21 +50,30 @@ export function ToastCard({ item, onDismiss, className, ...props }: ToastCardPro
|
||||
);
|
||||
}
|
||||
|
||||
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]);
|
||||
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 }) {
|
||||
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) => <ToastCard key={item.id} item={item} onDismiss={onDismiss} />)}
|
||||
{items.map((item) => <TimedToastCard key={item.id} item={item} onDismiss={onDismiss} />)}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./AppHeader.js";
|
||||
export * from "./ActivityIndicator.js";
|
||||
export * from "./AdminNavigationPanel.js";
|
||||
export * from "./ApplicationShell.js";
|
||||
export * from "./ApplicationSidePanel.js";
|
||||
@@ -15,13 +16,17 @@ 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";
|
||||
|
||||
+111
-12
@@ -1,6 +1,53 @@
|
||||
{
|
||||
"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",
|
||||
@@ -10,8 +57,10 @@
|
||||
"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.",
|
||||
@@ -26,13 +75,31 @@
|
||||
"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"],
|
||||
"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."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "activity-indicator",
|
||||
"status": "baseline",
|
||||
"package": "@nodedc/ui-react",
|
||||
"exports": ["ActivityIndicator", "ActivityIndicatorProps", "ActivityIndicatorSize"],
|
||||
"domContract": ["nodedc-activity-indicator"],
|
||||
"summary": "Theme-independent indeterminate progress indicator for inline operations and pending action controls.",
|
||||
"variants": ["default", "compact"],
|
||||
"behavior": ["decorative by default", "optional status semantics", "static reduced-motion presentation"],
|
||||
"rules": [
|
||||
"Use compact inside a Button icon slot and default for standalone inline progress.",
|
||||
"The process owner exposes aria-busy and visible pending copy; provide label only when the indicator itself is the status announcement.",
|
||||
"The component never owns operation state, timing or completion.",
|
||||
"Reduced-motion preferences stop rotation without hiding the pending-state affordance."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "field",
|
||||
"status": "baseline",
|
||||
@@ -64,17 +131,20 @@
|
||||
"package": "@nodedc/ui-react",
|
||||
"exports": ["RangeControl"],
|
||||
"domContract": ["nodedc-range"],
|
||||
"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"],
|
||||
"summary": "Filled pill range control with embedded label, drag interaction and a persistent native inline exact-value editor.",
|
||||
"behavior": ["native range drag", "native caret and selection replacement", "automatic fill/base contrast", "unbounded finite exact editing by default", "Enter/blur commit", "Escape cancel", "independent optional exact-value bounds", "step normalization"],
|
||||
"rules": [
|
||||
"The accent fill follows the active application theme.",
|
||||
"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.",
|
||||
"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."
|
||||
]
|
||||
"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."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "color-field",
|
||||
@@ -113,11 +183,12 @@
|
||||
"domPackage": "@nodedc/ui-dom",
|
||||
"domExports": ["createSelectController"],
|
||||
"summary": "Theme-independent selection control using the canonical dropdown layer.",
|
||||
"variants": ["integrated", "split"],
|
||||
"variants": ["integrated", "split", "inline"],
|
||||
"behavior": ["controlled value", "optional search", "disabled options", "selected state", "portal menu"],
|
||||
"rules": [
|
||||
"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."
|
||||
]
|
||||
@@ -136,7 +207,9 @@
|
||||
"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."
|
||||
"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."
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -163,6 +236,23 @@
|
||||
"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",
|
||||
@@ -198,7 +288,11 @@
|
||||
"package": "@nodedc/ui-react",
|
||||
"exports": ["SegmentedControl"],
|
||||
"domContract": ["nodedc-segmented"],
|
||||
"summary": "Pill navigation used in the shared top header and compact mode switches."
|
||||
"summary": "Pill navigation used in the shared top header and compact mode switches.",
|
||||
"sizes": ["default", "dense"],
|
||||
"rules": [
|
||||
"Dense is reserved for high-density in-viewer layer and mode toolbars; application headers retain default geometry."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "app-header",
|
||||
@@ -210,6 +304,7 @@
|
||||
"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."
|
||||
@@ -350,6 +445,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.",
|
||||
"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."
|
||||
]
|
||||
},
|
||||
@@ -358,6 +454,7 @@
|
||||
"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."
|
||||
},
|
||||
@@ -369,9 +466,11 @@
|
||||
"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", "optional auto-dismiss", "manual dismiss", "portal viewport", "polite live region"],
|
||||
"behavior": ["controlled items", "independent 10 second default auto-dismiss per terminal item", "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."
|
||||
]
|
||||
|
||||
+5
-3
@@ -6,6 +6,7 @@
|
||||
"defaultSize": 18,
|
||||
"supportedSizes": [16, 18, 20],
|
||||
"strokeWidth": 1.8,
|
||||
"filledNames": ["play", "stop"],
|
||||
"groups": [
|
||||
{
|
||||
"id": "window-layer",
|
||||
@@ -29,7 +30,7 @@
|
||||
"id": "state-access",
|
||||
"label": "Состояние и доступ",
|
||||
"referenceSources": ["launcher", "seo", "task-manager"],
|
||||
"names": ["check", "alert", "activity", "lock", "key", "shield", "circle"]
|
||||
"names": ["check", "alert", "activity", "lock", "key", "shield", "circle", "eye", "eye-off"]
|
||||
},
|
||||
{
|
||||
"id": "entities",
|
||||
@@ -41,13 +42,14 @@
|
||||
"id": "content",
|
||||
"label": "Контент",
|
||||
"referenceSources": ["seo", "bim-viewer", "engine"],
|
||||
"names": ["image", "video", "file", "folder", "clipboard", "settings"]
|
||||
"names": ["camera", "plan", "play", "stop", "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."
|
||||
"A new icon requires a confirmed product use, a registry entry and a catalog specimen.",
|
||||
"Playback icons play and stop are filled semantic glyphs; all other canonical glyphs remain outline unless their semantic contract says otherwise."
|
||||
]
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"architecture": "../docs/ARCHITECTURE.md",
|
||||
"components": "../docs/COMPONENTS.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",
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { ActivityIndicator } from "../packages/ui-react/dist/index.js";
|
||||
|
||||
test("ActivityIndicator separates decorative and announced progress", () => {
|
||||
const decorative = renderToStaticMarkup(createElement(ActivityIndicator, { size: "compact" }));
|
||||
const announced = renderToStaticMarkup(createElement(ActivityIndicator, { label: "Подключаем устройство" }));
|
||||
|
||||
assert.match(decorative, /class="nodedc-activity-indicator"/);
|
||||
assert.match(decorative, /data-size="compact"/);
|
||||
assert.match(decorative, /aria-hidden="true"/);
|
||||
assert.doesNotMatch(decorative, /role="status"/);
|
||||
|
||||
assert.match(announced, /role="status"/);
|
||||
assert.match(announced, /aria-label="Подключаем устройство"/);
|
||||
assert.doesNotMatch(announced, /aria-hidden/);
|
||||
assert.doesNotMatch(announced, /data-size=/);
|
||||
});
|
||||
|
||||
test("ActivityIndicator is registered, cataloged and motion-safe", async () => {
|
||||
const [styles, registrySource, docs, catalog] = await Promise.all([
|
||||
readFile(new URL("../packages/ui-core/styles.css", import.meta.url), "utf8"),
|
||||
readFile(new URL("../registry/components.json", import.meta.url), "utf8"),
|
||||
readFile(new URL("../docs/COMPONENTS.md", import.meta.url), "utf8"),
|
||||
readFile(new URL("../apps/catalog/src/CatalogApp.tsx", import.meta.url), "utf8"),
|
||||
]);
|
||||
const registry = JSON.parse(registrySource);
|
||||
const entry = registry.components.find((component) => component.id === "activity-indicator");
|
||||
|
||||
assert.deepEqual(entry?.variants, ["default", "compact"]);
|
||||
assert.ok(entry?.exports.includes("ActivityIndicator"));
|
||||
assert.match(styles, /\.nodedc-activity-indicator\s*\{[\s\S]*?animation: nodedc-activity-indicator-spin/);
|
||||
assert.match(styles, /\.nodedc-activity-indicator\[data-size="compact"\]/);
|
||||
assert.match(styles, /@media \(prefers-reduced-motion: reduce\) \{\s*\.nodedc-activity-indicator,[\s\S]*?animation: none/);
|
||||
assert.match(docs, /## ActivityIndicator/);
|
||||
assert.match(catalog, /<ActivityIndicator label="Загружаем данные"/);
|
||||
assert.match(catalog, /icon=\{<ActivityIndicator size="compact" \/>\}/);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { Button, SegmentedControl } from "../packages/ui-react/dist/index.js";
|
||||
|
||||
test("dense viewer controls remain a public Button and SegmentedControl contract", async () => {
|
||||
const button = renderToStaticMarkup(createElement(Button, { size: "dense" }, "Source points"));
|
||||
const segmented = renderToStaticMarkup(createElement(SegmentedControl, {
|
||||
size: "dense",
|
||||
value: "source",
|
||||
items: [{ label: "Source points", value: "source" }],
|
||||
label: "Viewer layer",
|
||||
onChange: () => {},
|
||||
}));
|
||||
const [tokens, styles, registrySource, docs] = await Promise.all([
|
||||
readFile(new URL("../packages/tokens/tokens.css", import.meta.url), "utf8"),
|
||||
readFile(new URL("../packages/ui-core/styles.css", import.meta.url), "utf8"),
|
||||
readFile(new URL("../registry/components.json", import.meta.url), "utf8"),
|
||||
readFile(new URL("../docs/COMPONENTS.md", import.meta.url), "utf8"),
|
||||
]);
|
||||
const registry = JSON.parse(registrySource);
|
||||
const buttonEntry = registry.components.find((component) => component.id === "button");
|
||||
const segmentedEntry = registry.components.find((component) => component.id === "segmented-control");
|
||||
|
||||
assert.match(button, /data-size="dense"/);
|
||||
assert.match(segmented, /data-size="dense"/);
|
||||
assert.ok(buttonEntry?.sizes.includes("dense"));
|
||||
assert.ok(segmentedEntry?.sizes.includes("dense"));
|
||||
assert.match(tokens, /--nodedc-control-height-dense: 1\.875rem/);
|
||||
assert.match(tokens, /--nodedc-font-size-dense: 0\.45rem/);
|
||||
assert.match(styles, /\.nodedc-button\[data-size="dense"\]/);
|
||||
assert.match(styles, /\.nodedc-segmented\[data-size="dense"\]/);
|
||||
assert.match(docs, /size="dense"/);
|
||||
assert.match(docs, /viewer/);
|
||||
});
|
||||
@@ -49,18 +49,15 @@ test("floating surface remains bounded when its anchor is below the viewport", (
|
||||
});
|
||||
|
||||
test("dropdown scroll keeps portal geometry stable and contained", async () => {
|
||||
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 styles = await readFile(
|
||||
new URL("../packages/ui-core/styles.css", 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 () => {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
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 [mapInspector, catalog, coreStyles] = await Promise.all([
|
||||
readFile(new URL("../apps/catalog/src/mapInspectorSections.tsx", import.meta.url), "utf8"),
|
||||
const [mapPreview, catalog, coreStyles] = await Promise.all([
|
||||
readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../apps/catalog/src/CatalogApp.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../packages/ui-core/styles.css", import.meta.url), "utf8"),
|
||||
]);
|
||||
|
||||
assert.match(mapInspector, /<InspectorSelectField\s+label="Профиль покрытия"/);
|
||||
assert.doesNotMatch(mapInspector, /<ControlRow label="Профиль покрытия">/);
|
||||
assert.match(mapPreview, /<InspectorSelectField\s+label="Профиль покрытия"/);
|
||||
assert.doesNotMatch(mapPreview, /<ControlRow label="Профиль покрытия">/);
|
||||
assert.equal((catalog.match(/<InspectorSelectField/g) ?? []).length, 2);
|
||||
assert.match(coreStyles, /\.nodedc-inspector \.nodedc-control-row:has\(\.nodedc-select-anchor\)/);
|
||||
});
|
||||
|
||||
@@ -315,32 +315,29 @@ 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 [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\(/);
|
||||
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\(/);
|
||||
});
|
||||
|
||||
@@ -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 [runtimeLayers, cesiumDisplay] = await Promise.all([
|
||||
readFile(new URL("../apps/catalog/src/mapCesiumRuntimeLayers.ts", import.meta.url), "utf8"),
|
||||
const [renderer, cesiumDisplay] = await Promise.all([
|
||||
readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../node_modules/@cesium/engine/Source/DataSources/DataSourceDisplay.js", import.meta.url), "utf8"),
|
||||
]);
|
||||
|
||||
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.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.ok(cesiumDisplay.indexOf("new BillboardVisualizer") < cesiumDisplay.indexOf("new LabelVisualizer"));
|
||||
});
|
||||
|
||||
|
||||
@@ -2,92 +2,71 @@ import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
const source = (path) => readFile(new URL(path, import.meta.url), "utf8");
|
||||
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");
|
||||
|
||||
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/);
|
||||
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("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"),
|
||||
]);
|
||||
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");
|
||||
|
||||
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 \}/);
|
||||
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 \}/);
|
||||
});
|
||||
|
||||
test("hidden and visible layers have an explicit visual state", async () => {
|
||||
const styles = await source("../apps/catalog/src/styles.css");
|
||||
const styles = await readFile(new URL("../apps/catalog/src/styles.css", import.meta.url), "utf8");
|
||||
|
||||
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 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"),
|
||||
]);
|
||||
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");
|
||||
|
||||
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\(/);
|
||||
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\)/);
|
||||
});
|
||||
|
||||
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"),
|
||||
]);
|
||||
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");
|
||||
|
||||
assert.match(model, /filter\(\(binding\) => !binding\.joinToBindingId\)/);
|
||||
assert.match(model, /primaryMapBindingConfigs\(bindingConfigs\)/);
|
||||
assert.match(preview, /dataProductBindings\.filter\(\(binding\) => !binding\.joinToBindingId\)/);
|
||||
assert.match(preview, /runtimeBindings\.filter\(\(binding\) => primaryBindingIds\.has\(binding\.bindingId\)\)/);
|
||||
assert.match(preview, /runtimeBindings=\{\[\.\.\.sectorScopedPrimaryRuntimeBindings, \.\.\.referenceRuntimeBindings\]\}/);
|
||||
});
|
||||
|
||||
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"),
|
||||
]);
|
||||
test("bounded map windows keep one stack while layers and settings use their canonical surfaces", async () => {
|
||||
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(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, /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(preview, /<ApplicationSidePanel[\s\S]*title="Настройки карты"[\s\S]*onClose=\{closeSettingsPanel\}/);
|
||||
assert.match(preview, /createPortal\(headerActions, headerActionsHost\)/);
|
||||
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/);
|
||||
assert.doesNotMatch(preview, /settingsPanelPinned|onPinnedChange/);
|
||||
assert.match(preview, /surfaceClassName="catalog-map-fixture__objects-menu catalog-map-fixture__layers-menu nodedc-map-glass"/);
|
||||
assert.match(preview, /onOpenChange=\{setLayersOpen\}/);
|
||||
assert.doesNotMatch(preview, /settingsWindowZIndex|layersWindowZIndex/);
|
||||
assert.doesNotMatch(preview, /<Window\b/);
|
||||
});
|
||||
|
||||
test("selecting a point or HGeoZone preserves the active grid sector", async () => {
|
||||
const renderer = await source("../apps/catalog/src/CesiumMapRenderer.tsx");
|
||||
const renderer = await readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8");
|
||||
const pointBranch = renderer.match(/if \(pickedId instanceof Entity[\s\S]*?return;\s*\}/)?.[0] ?? "";
|
||||
const zoneBranch = renderer.match(/if \(\s*pickedId[\s\S]*?HGeoZonePickId\)\.entityId\);\s*return;\s*\}/)?.[0] ?? "";
|
||||
|
||||
@@ -98,65 +77,59 @@ test("selecting a point or HGeoZone preserves the active grid sector", async ()
|
||||
assert.match(renderer, /gridController\?\.setSelection\(gridSelection\);\s*onGridSectorSelectRef\.current\?\.\(gridSelection\)/);
|
||||
});
|
||||
|
||||
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"),
|
||||
]);
|
||||
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(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/);
|
||||
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 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"),
|
||||
]);
|
||||
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(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"/);
|
||||
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 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"),
|
||||
test("facet window auto-fits expanded and collapsed content inside map workspace", async () => {
|
||||
const [preview, workspace] = await Promise.all([
|
||||
readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../packages/ui-react/src/WorkspaceWindow.tsx", import.meta.url), "utf8"),
|
||||
]);
|
||||
|
||||
assert.match(windows, /minHeight=\{220\}\s*autoHeight/);
|
||||
assert.match(preview, /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 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"),
|
||||
]);
|
||||
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");
|
||||
|
||||
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, /validTabIds: profile\?\.tabs\.map\(\(tab\) => tab\.id\) \?\? \["overview"\]/);
|
||||
assert.match(state, /action\.validTabIds\.includes\(state\.subjectCard\.tabId\)/);
|
||||
assert.match(preview, /setSubjectCardTabId\(\(current\) =>/);
|
||||
assert.match(preview, /profile\?\.tabs\.some\(\(tab\) => tab\.id === current\)/);
|
||||
assert.match(preview, /\(profile\?\.defaultTabId \?\? "overview"\)/);
|
||||
assert.match(renderer, /void viewer\.flyTo\(entity, \{/);
|
||||
assert.match(renderer, /const focusSubjectCoordinates = useCallback/);
|
||||
assert.match(renderer, /viewer\.camera\.flyToBoundingSphere\(new BoundingSphere\(target, 1\), \{/);
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
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,
|
||||
@@ -7,7 +15,7 @@ const {
|
||||
mapPresentationFacetValueIsEnabled,
|
||||
normalizeMapPresentationFacetSelections,
|
||||
toggleMapPresentationFacetSelection,
|
||||
} = await import("../apps/catalog/src/mapPresentationProfile.mjs");
|
||||
} = await import(moduleUrl);
|
||||
|
||||
const profile = {
|
||||
id: "map.moving-object.operational.default",
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
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,14 +28,13 @@ 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 runtimeLayers = await readFile(new URL("apps/catalog/src/mapCesiumRuntimeLayers.ts", root), "utf8");
|
||||
const renderer = await readFile(new URL("apps/catalog/src/CesiumMapRenderer.tsx", 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(runtimeLayers, /binding\.slotId === "reference-points"/);
|
||||
assert.match(renderer, /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", "alternate_names", "uic_ref", "wheelchair"\]\)/);
|
||||
assert.match(runtime, /alternate_names/);
|
||||
assert.match(runtime, /allowedAttributes = new Set\(\["name", "category", "network", "operator", "official_name", "local_name", "uic_ref", "wheelchair"\]\)/);
|
||||
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", "alternate_names"] },
|
||||
label: { mode: "attributes", fields: ["name", "official_name"] },
|
||||
};
|
||||
const index = buildMapSearchIndex({
|
||||
runtimeBindings: [{
|
||||
@@ -93,7 +93,6 @@ 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",
|
||||
})],
|
||||
}],
|
||||
@@ -101,7 +100,6 @@ 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,20 +275,17 @@ 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, windows, state] = await Promise.all([
|
||||
const [renderer, preview] = 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, /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/);
|
||||
assert.match(preview, /setSubjectCardOpen\(true\)/);
|
||||
assert.match(preview, /title=\{selectedSubjectCard\.title\}/);
|
||||
assert.match(preview, /Карточка объекта:/);
|
||||
assert.match(preview, /SegmentedControl/);
|
||||
});
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,102 +0,0 @@
|
||||
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"]);
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {createElement} from "react";
|
||||
import {renderToStaticMarkup} from "react-dom/server";
|
||||
import {ProgressBar,ResourceRow} from "../packages/ui-react/dist/index.js";
|
||||
|
||||
test("progress exposes bounded measured values and unknown state without false percentages",()=>{
|
||||
for(const [value, expected] of [[-1,0],[.5,50],[2,100]]) {
|
||||
const html=renderToStaticMarkup(createElement(ProgressBar,{label:"Этапы",value}));
|
||||
assert.match(html,new RegExp(`aria-valuenow="${expected}"`));
|
||||
}
|
||||
for(const value of [undefined,NaN,Infinity]) {
|
||||
const html=renderToStaticMarkup(createElement(ProgressBar,{label:"Ожидание",value}));
|
||||
assert.doesNotMatch(html,/aria-valuenow/);assert.match(html,/data-indeterminate="true"/);
|
||||
}
|
||||
});
|
||||
test("row keeps progress between name and actions and suppresses duplicate status",()=>{
|
||||
const html=renderToStaticMarkup(createElement(ResourceRow,{title:"Камера",status:"Old status",actions:"Actions",progress:{label:"Загрузка",value:.2}}));
|
||||
assert.ok(html.indexOf("Камера")<html.indexOf('role="progressbar"'));
|
||||
assert.ok(html.indexOf('role="progressbar"')<html.indexOf("Actions"));
|
||||
assert.doesNotMatch(html,/Old status/);
|
||||
});
|
||||
@@ -30,7 +30,17 @@ 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, /normalizeEditedRangeValue\(parsed, min, max, step\)/);
|
||||
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, /event\.key === "Enter"/);
|
||||
assert.match(component, /event\.key === "Escape"/);
|
||||
assert.match(component, /event\.stopPropagation\(\)/);
|
||||
@@ -43,6 +53,7 @@ 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\(\)/);
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
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",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
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/);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
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