Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d51d8bb7f6 | ||
|
|
c8f4916423 | ||
|
|
6e7255ecdb | ||
|
|
c7e136cc14 | ||
|
|
51cb426c6b | ||
|
|
8f38c76f79 | ||
|
|
2fa1951f51 | ||
|
|
19f0d97e23 | ||
|
|
bbb50e06b4 | ||
|
|
d6c62da470 | ||
|
|
a3385e83c4 | ||
|
|
9fa81fde9a | ||
|
|
117bfe0c3a | ||
|
|
1c5246afe8 | ||
|
|
4116f5ba95 |
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties,
|
|||||||
import { applyGlassMaterial, applyNodedcTheme, defaultGlassMaterial, type GlassMaterialSettings, type NodedcTheme, type RgbTuple } from "@nodedc/ui-core";
|
import { applyGlassMaterial, applyNodedcTheme, defaultGlassMaterial, type GlassMaterialSettings, type NodedcTheme, type RgbTuple } from "@nodedc/ui-core";
|
||||||
import { createTemplateFeatures, getPageTemplate, pageTemplates, type PageTemplateDefinition } from "@nodedc/page-patterns";
|
import { createTemplateFeatures, getPageTemplate, pageTemplates, type PageTemplateDefinition } from "@nodedc/page-patterns";
|
||||||
import {
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
AdminNavigationPanel,
|
AdminNavigationPanel,
|
||||||
AppHeader,
|
AppHeader,
|
||||||
ApplicationPanel,
|
ApplicationPanel,
|
||||||
@@ -32,6 +33,7 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
ShareAccessModal,
|
ShareAccessModal,
|
||||||
ShareLinkModal,
|
ShareLinkModal,
|
||||||
|
SplitPane,
|
||||||
SortableItem,
|
SortableItem,
|
||||||
SortableScope,
|
SortableScope,
|
||||||
SettingsCard,
|
SettingsCard,
|
||||||
@@ -65,8 +67,7 @@ import {
|
|||||||
type DesignProfileSummary,
|
type DesignProfileSummary,
|
||||||
type DesignProfileStatus,
|
type DesignProfileStatus,
|
||||||
} from "./applicationManifest.js";
|
} from "./applicationManifest.js";
|
||||||
import { MapFixturePreview } from "./MapFixturePreview.js";
|
import { createDefaultMapPageLayout, MapFixturePreview, type MapFixturePreviewHandle, type MapPageLayout } from "./MapFixturePreview.js";
|
||||||
import { createDefaultMapPageLayout, type MapFixturePreviewHandle, type MapPageLayout } from "./mapPageContract.js";
|
|
||||||
import {
|
import {
|
||||||
mapDesignFragmentForLayout,
|
mapDesignFragmentForLayout,
|
||||||
mapDesignFragmentFromLayout,
|
mapDesignFragmentFromLayout,
|
||||||
@@ -209,7 +210,7 @@ const iconGroups: Array<{ title: string; note: string; icons: IconName[] }> = [
|
|||||||
{
|
{
|
||||||
title: "Состояние и доступ",
|
title: "Состояние и доступ",
|
||||||
note: "Вся платформа",
|
note: "Вся платформа",
|
||||||
icons: ["check", "alert", "activity", "lock", "key", "shield", "circle"],
|
icons: ["check", "alert", "activity", "lock", "key", "shield", "circle", "eye", "eye-off"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Сущности",
|
title: "Сущности",
|
||||||
@@ -219,7 +220,7 @@ const iconGroups: Array<{ title: string; note: string; icons: IconName[] }> = [
|
|||||||
{
|
{
|
||||||
title: "Контент",
|
title: "Контент",
|
||||||
note: "SEO / BIM / Engine",
|
note: "SEO / BIM / Engine",
|
||||||
icons: ["image", "video", "file", "folder", "clipboard", "settings"],
|
icons: ["camera", "plan", "play", "stop", "image", "video", "file", "folder", "clipboard", "settings"],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -228,6 +229,7 @@ const iconLabels: Record<IconName, string> = {
|
|||||||
alert: "Предупреждение",
|
alert: "Предупреждение",
|
||||||
apps: "Приложения",
|
apps: "Приложения",
|
||||||
building: "Компания",
|
building: "Компания",
|
||||||
|
camera: "Камера",
|
||||||
check: "Готово",
|
check: "Готово",
|
||||||
"chevron-down": "Раскрыть",
|
"chevron-down": "Раскрыть",
|
||||||
"chevron-left": "Назад",
|
"chevron-left": "Назад",
|
||||||
@@ -239,6 +241,8 @@ const iconLabels: Record<IconName, string> = {
|
|||||||
database: "База данных",
|
database: "База данных",
|
||||||
download: "Скачать",
|
download: "Скачать",
|
||||||
edit: "Редактировать",
|
edit: "Редактировать",
|
||||||
|
eye: "Показать",
|
||||||
|
"eye-off": "Скрыть",
|
||||||
expand: "Развернуть",
|
expand: "Развернуть",
|
||||||
external: "Открыть снаружи",
|
external: "Открыть снаружи",
|
||||||
file: "Файл",
|
file: "Файл",
|
||||||
@@ -254,6 +258,8 @@ const iconLabels: Record<IconName, string> = {
|
|||||||
minimize: "Свернуть",
|
minimize: "Свернуть",
|
||||||
network: "Связи",
|
network: "Связи",
|
||||||
panel: "Панель",
|
panel: "Панель",
|
||||||
|
plan: "План",
|
||||||
|
play: "Воспроизвести",
|
||||||
target: "Таргеты",
|
target: "Таргеты",
|
||||||
plus: "Добавить",
|
plus: "Добавить",
|
||||||
profile: "Профиль",
|
profile: "Профиль",
|
||||||
@@ -263,6 +269,7 @@ const iconLabels: Record<IconName, string> = {
|
|||||||
settings: "Настройки",
|
settings: "Настройки",
|
||||||
shield: "Доступ",
|
shield: "Доступ",
|
||||||
sliders: "Параметры",
|
sliders: "Параметры",
|
||||||
|
stop: "Остановить",
|
||||||
trash: "Удалить",
|
trash: "Удалить",
|
||||||
upload: "Загрузить",
|
upload: "Загрузить",
|
||||||
users: "Участники",
|
users: "Участники",
|
||||||
@@ -360,6 +367,7 @@ export function CatalogApp() {
|
|||||||
const [workspaceWindowDemoOpen, setWorkspaceWindowDemoOpen] = useState(true);
|
const [workspaceWindowDemoOpen, setWorkspaceWindowDemoOpen] = useState(true);
|
||||||
const [workspaceWindowDemoMaximized, setWorkspaceWindowDemoMaximized] = useState(false);
|
const [workspaceWindowDemoMaximized, setWorkspaceWindowDemoMaximized] = useState(false);
|
||||||
const [workspaceWindowDemoRect, setWorkspaceWindowDemoRect] = useState<WorkspaceWindowRect>({ x: 24, y: 24, width: 340, height: 230 });
|
const [workspaceWindowDemoRect, setWorkspaceWindowDemoRect] = useState<WorkspaceWindowRect>({ x: 24, y: 24, width: 340, height: 230 });
|
||||||
|
const [splitPaneDemoSize, setSplitPaneDemoSize] = useState(50);
|
||||||
const [sidePanelDemoOpen, setSidePanelDemoOpen] = useState(true);
|
const [sidePanelDemoOpen, setSidePanelDemoOpen] = useState(true);
|
||||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
@@ -1279,7 +1287,7 @@ export function CatalogApp() {
|
|||||||
<>
|
<>
|
||||||
<ControlRow label="Цвет"><ColorField value={lightColor} onChange={setLightColor} /></ControlRow>
|
<ControlRow label="Цвет"><ColorField value={lightColor} onChange={setLightColor} /></ControlRow>
|
||||||
<RangeControl label="Яркость" value={brightness} min={0} max={100} formatValue={(value) => `${value}%`} onChange={setBrightness} />
|
<RangeControl label="Яркость" value={brightness} min={0} max={100} formatValue={(value) => `${value}%`} onChange={setBrightness} />
|
||||||
<RangeControl label="Дистанция свечения" value={glowDistance} min={0} max={200} formatValue={(value) => `${value}%`} onChange={setGlowDistance} />
|
<RangeControl label="Дистанция свечения" value={glowDistance} min={0} max={200} exactValueBounds={{ min: 0 }} formatValue={(value) => `${value}%`} onChange={setGlowDistance} />
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -1325,7 +1333,7 @@ export function CatalogApp() {
|
|||||||
case "controls":
|
case "controls":
|
||||||
return (
|
return (
|
||||||
<div className="catalog-grid">
|
<div className="catalog-grid">
|
||||||
<Preview title="Selection" note="integrated / split">
|
<Preview title="Selection" note="integrated / split / inline">
|
||||||
<div className="catalog-form">
|
<div className="catalog-form">
|
||||||
<FieldFrame label="Hub / integrated select">
|
<FieldFrame label="Hub / integrated select">
|
||||||
<Select label="Статус" value={selectedStatus} options={[...selectOptions]} searchable onChange={setSelectedStatus} />
|
<Select label="Статус" value={selectedStatus} options={[...selectOptions]} searchable onChange={setSelectedStatus} />
|
||||||
@@ -1343,6 +1351,19 @@ export function CatalogApp() {
|
|||||||
onChange={setConnectionType}
|
onChange={setConnectionType}
|
||||||
/>
|
/>
|
||||||
</FieldFrame>
|
</FieldFrame>
|
||||||
|
<FieldFrame label="Toolbar / inline select">
|
||||||
|
<Select
|
||||||
|
variant="inline"
|
||||||
|
label="Скорость воспроизведения"
|
||||||
|
value="1"
|
||||||
|
options={[
|
||||||
|
{ value: "0.5", label: "0,5×" },
|
||||||
|
{ value: "1", label: "1×" },
|
||||||
|
{ value: "2", label: "2×" },
|
||||||
|
]}
|
||||||
|
onChange={() => undefined}
|
||||||
|
/>
|
||||||
|
</FieldFrame>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
trigger={({ open, toggle, setTriggerRef, surfaceId }) => (
|
trigger={({ open, toggle, setTriggerRef, surfaceId }) => (
|
||||||
<Button ref={setTriggerRef} aria-expanded={open} aria-controls={surfaceId} icon={<Icon name="chevron-down" />} onClick={toggle}>Действия</Button>
|
<Button ref={setTriggerRef} aria-expanded={open} aria-controls={surfaceId} icon={<Icon name="chevron-down" />} onClick={toggle}>Действия</Button>
|
||||||
@@ -1373,6 +1394,20 @@ export function CatalogApp() {
|
|||||||
<IconButton label="Добавить"><Icon name="plus" /></IconButton>
|
<IconButton label="Добавить"><Icon name="plus" /></IconButton>
|
||||||
</div>
|
</div>
|
||||||
</Preview>
|
</Preview>
|
||||||
|
<Preview title="Активность" note="default / compact" className="catalog-preview--compact">
|
||||||
|
<div className="catalog-inline catalog-inline--wrap">
|
||||||
|
<span className="catalog-inline">
|
||||||
|
<ActivityIndicator label="Загружаем данные" />
|
||||||
|
<span>Загружаем данные</span>
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
aria-busy="true"
|
||||||
|
disabled
|
||||||
|
icon={<ActivityIndicator size="compact" />}
|
||||||
|
>Подключаем…</Button>
|
||||||
|
</div>
|
||||||
|
<p className="catalog-preview__explanation">При reduced motion кольцо остаётся видимым без вращения; процесс и его завершение принадлежат приложению.</p>
|
||||||
|
</Preview>
|
||||||
<Preview title="Оконные действия" note="круг `46 px`" className="catalog-preview--compact">
|
<Preview title="Оконные действия" note="круг `46 px`" className="catalog-preview--compact">
|
||||||
<div className="catalog-window-actions-demo">
|
<div className="catalog-window-actions-demo">
|
||||||
<Button shape="pill" icon={<Icon name="refresh" />}>Обновить источник</Button>
|
<Button shape="pill" icon={<Icon name="refresh" />}>Обновить источник</Button>
|
||||||
@@ -1381,6 +1416,21 @@ export function CatalogApp() {
|
|||||||
<IconButton label="Закрыть"><Icon name="close" /></IconButton>
|
<IconButton label="Закрыть"><Icon name="close" /></IconButton>
|
||||||
</div>
|
</div>
|
||||||
</Preview>
|
</Preview>
|
||||||
|
<Preview title="Вертикальная рейка" note="glass pill / круг `46 px`" className="catalog-preview--compact">
|
||||||
|
<GlassSurface
|
||||||
|
className="catalog-icon-rail-demo"
|
||||||
|
tone="strong"
|
||||||
|
radius="pill"
|
||||||
|
padding="sm"
|
||||||
|
materialRim={false}
|
||||||
|
role="toolbar"
|
||||||
|
aria-label="Режимы просмотра"
|
||||||
|
>
|
||||||
|
<IconButton label="Камера" aria-pressed="true"><Icon name="camera" /></IconButton>
|
||||||
|
<IconButton label="3D"><span aria-hidden="true">3D</span></IconButton>
|
||||||
|
<IconButton label="План"><Icon name="plan" /></IconButton>
|
||||||
|
</GlassSurface>
|
||||||
|
</Preview>
|
||||||
<Preview title="Workspace window" note="inline / bounded / controlled" className="catalog-preview--wide catalog-workspace-window-preview">
|
<Preview title="Workspace window" note="inline / bounded / controlled" className="catalog-preview--wide catalog-workspace-window-preview">
|
||||||
<div ref={workspaceWindowDemoRef} className="catalog-workspace-window-demo">
|
<div ref={workspaceWindowDemoRef} className="catalog-workspace-window-demo">
|
||||||
{workspaceWindowDemoOpen ? (
|
{workspaceWindowDemoOpen ? (
|
||||||
@@ -1419,6 +1469,20 @@ export function CatalogApp() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="catalog-preview__explanation">Rectangle, maximize, close и stacking остаются состоянием приложения; дизайн-система владеет одинаковой bounded-геометрией и доступным управлением.</p>
|
<p className="catalog-preview__explanation">Rectangle, maximize, close и stacking остаются состоянием приложения; дизайн-система владеет одинаковой bounded-геометрией и доступным управлением.</p>
|
||||||
</Preview>
|
</Preview>
|
||||||
|
<Preview title="Split pane" note="pointer / keyboard / controlled" className="catalog-preview--wide catalog-split-pane-preview">
|
||||||
|
<div className="catalog-split-pane-demo">
|
||||||
|
<SplitPane
|
||||||
|
primarySize={splitPaneDemoSize}
|
||||||
|
onPrimarySizeChange={setSplitPaneDemoSize}
|
||||||
|
minPrimarySize={25}
|
||||||
|
minSecondarySize={25}
|
||||||
|
separatorLabel="Изменить ширину синхронных представлений"
|
||||||
|
primary={<div className="catalog-split-pane-demo__panel"><Icon name="video" /><strong>Видео</strong></div>}
|
||||||
|
secondary={<div className="catalog-split-pane-demo__panel"><Icon name="grid" /><strong>Пространственная сцена</strong></div>}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="catalog-preview__explanation">Граница изменяет контролируемую долю панелей; клавиши и pointer используют одни ограничения, а resize контейнера не сбрасывает выбранное соотношение.</p>
|
||||||
|
</Preview>
|
||||||
<Preview title="Application side panel" note="end / push / controlled" className="catalog-preview--wide catalog-side-panel-preview">
|
<Preview title="Application side panel" note="end / push / controlled" className="catalog-preview--wide catalog-side-panel-preview">
|
||||||
<div className="catalog-side-panel-demo">
|
<div className="catalog-side-panel-demo">
|
||||||
{sidePanelDemoOpen ? (
|
{sidePanelDemoOpen ? (
|
||||||
@@ -1622,6 +1686,8 @@ export function CatalogApp() {
|
|||||||
<div className="catalog-icon-catalog">
|
<div className="catalog-icon-catalog">
|
||||||
<Preview title="Размеры и поверхности" note="glyph 16 px" className="catalog-preview--wide">
|
<Preview title="Размеры и поверхности" note="glyph 16 px" className="catalog-preview--wide">
|
||||||
<div className="catalog-window-actions-demo">
|
<div className="catalog-window-actions-demo">
|
||||||
|
<IconButton label="Камера включена" aria-pressed="true"><Icon name="camera" /></IconButton>
|
||||||
|
<IconButton label="План"><Icon name="plan" /></IconButton>
|
||||||
<IconButton label="Добавить"><Icon name="plus" /></IconButton>
|
<IconButton label="Добавить"><Icon name="plus" /></IconButton>
|
||||||
<IconButton label="Обновить"><Icon name="refresh" /></IconButton>
|
<IconButton label="Обновить"><Icon name="refresh" /></IconButton>
|
||||||
<IconButton label="Развернуть"><Icon name="expand" /></IconButton>
|
<IconButton label="Развернуть"><Icon name="expand" /></IconButton>
|
||||||
|
|||||||
+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 { 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";
|
import type { MapPresentationProfile } from "./mapPresentationProfile.js";
|
||||||
|
|
||||||
export const applicationManifestSchemaVersion = "0.1.0" as const;
|
export const applicationManifestSchemaVersion = "0.1.0" as const;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { GlassMaterialSettings, NodedcTheme } from "@nodedc/ui-core";
|
import type { GlassMaterialSettings, NodedcTheme } from "@nodedc/ui-core";
|
||||||
import type { ToolbarPlacement } from "@nodedc/ui-react";
|
import type { ToolbarPlacement } from "@nodedc/ui-react";
|
||||||
import type { FaviconAssetUrls } from "./favicon.js";
|
import type { FaviconAssetUrls } from "./favicon.js";
|
||||||
import type { MapPageLayout, MapPageSettings } from "./mapPageContract.js";
|
import type { MapPageLayout, MapPageSettings } from "./MapFixturePreview.js";
|
||||||
import type { MapPresentationProfile } from "./mapPresentationProfile.js";
|
import type { MapPresentationProfile } from "./mapPresentationProfile.js";
|
||||||
|
|
||||||
export type MaterialDraft = {
|
export type MaterialDraft = {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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 GridLodMode = "3d" | "graticule";
|
||||||
export type GridLodProfile = {
|
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
|
import type { MapRuntimeFact } from "./useMapDataProductRuntime.js";
|
||||||
// executable JavaScript so browser code and Node behavioral tests share it.
|
|
||||||
export * from "./mapPresentationProfile.mjs";
|
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],
|
semanticTypes: [...definition.semanticTypes],
|
||||||
label: {
|
label: {
|
||||||
mode: "attributes",
|
mode: "attributes",
|
||||||
fields: ["name", "official_name", "local_name", "alternate_names"],
|
fields: ["name", "official_name", "local_name"],
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
sizePx: 20,
|
sizePx: 20,
|
||||||
color: "#cccccc",
|
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 { MapPresentationProfile } from "./mapPresentationProfile.js";
|
||||||
import type { MapRuntimeBinding } from "./useMapDataProductRuntime.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%);
|
transform: translate(-50%, -50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.catalog-split-pane-preview .catalog-preview__body {
|
||||||
|
align-content: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-split-pane-demo {
|
||||||
|
min-height: 18rem;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--nodedc-radius-card);
|
||||||
|
background: var(--nodedc-nested-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-split-pane-demo__panel {
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
place-content: center;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: color-mix(in srgb, var(--nodedc-canvas) 82%, transparent);
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-split-pane-demo__panel strong {
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
.catalog-side-panel-preview .catalog-preview__body {
|
.catalog-side-panel-preview .catalog-preview__body {
|
||||||
align-content: stretch;
|
align-content: stretch;
|
||||||
}
|
}
|
||||||
@@ -1754,6 +1781,14 @@ textarea {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.catalog-icon-rail-demo {
|
||||||
|
display: flex;
|
||||||
|
width: max-content;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--nodedc-space-2);
|
||||||
|
}
|
||||||
|
|
||||||
.catalog-form {
|
.catalog-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import type { MapDataProductBinding } from "./mapPageContract.js";
|
import type { MapDataProductBinding } from "./MapFixturePreview.js";
|
||||||
import { mapRuntimeFactKey } from "./mapRuntimeIdentity.mjs";
|
|
||||||
|
|
||||||
export { mapRuntimeEntityId } from "./mapRuntimeIdentity.mjs";
|
|
||||||
|
|
||||||
export type DataProductPoint = {
|
export type DataProductPoint = {
|
||||||
type: "Point";
|
type: "Point";
|
||||||
@@ -81,6 +78,14 @@ type BindingState = {
|
|||||||
const identifier = /^[A-Za-z0-9._:-]{1,160}$/;
|
const identifier = /^[A-Za-z0-9._:-]{1,160}$/;
|
||||||
const cursorPattern = /^(?:0|[1-9]\d*)$/;
|
const cursorPattern = /^(?:0|[1-9]\d*)$/;
|
||||||
|
|
||||||
|
function factKey(fact: MapRuntimeFact) {
|
||||||
|
return `${fact.semanticType}\u0000${fact.sourceId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapRuntimeEntityId(bindingId: string, fact: Pick<MapRuntimeFact, "sourceId" | "semanticType">) {
|
||||||
|
return `nodedc-runtime:${bindingId}:${fact.semanticType}:${fact.sourceId}`;
|
||||||
|
}
|
||||||
|
|
||||||
function isIsoTimestamp(value: unknown): value is string {
|
function isIsoTimestamp(value: unknown): value is string {
|
||||||
return typeof value === "string" && !Number.isNaN(Date.parse(value));
|
return typeof value === "string" && !Number.isNaN(Date.parse(value));
|
||||||
}
|
}
|
||||||
@@ -241,7 +246,7 @@ function replaceSnapshot(current: BindingState, snapshot: SnapshotEnvelope): Bin
|
|||||||
...current,
|
...current,
|
||||||
cursor: snapshot.cursor,
|
cursor: snapshot.cursor,
|
||||||
state: "ready",
|
state: "ready",
|
||||||
facts: Object.fromEntries(snapshot.facts.map((fact) => [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;
|
if (current.cursor !== patch.previousCursor) return null;
|
||||||
const facts = { ...current.facts };
|
const facts = { ...current.facts };
|
||||||
for (const operation of patch.operations) {
|
for (const operation of patch.operations) {
|
||||||
if (operation.op === "upsert") facts[mapRuntimeFactKey(operation.fact)] = operation.fact;
|
if (operation.op === "upsert") facts[factKey(operation.fact)] = operation.fact;
|
||||||
else delete facts[mapRuntimeFactKey(operation)];
|
else delete facts[`${operation.semanticType}\u0000${operation.sourceId}`];
|
||||||
}
|
}
|
||||||
return { ...current, facts, cursor: patch.cursor, state: "ready" };
|
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 {
|
function applyPresentationPatch(current: BindingState, patch: PresentationPatchEnvelope): BindingState {
|
||||||
const facts = { ...current.facts };
|
const facts = { ...current.facts };
|
||||||
for (const operation of patch.operations) {
|
for (const operation of patch.operations) {
|
||||||
const key = mapRuntimeFactKey(operation);
|
const key = `${operation.semanticType}\u0000${operation.sourceId}`;
|
||||||
if (facts[key]) facts[key] = { ...facts[key], presentationStatus: operation.status };
|
if (facts[key]) facts[key] = { ...facts[key], presentationStatus: operation.status };
|
||||||
}
|
}
|
||||||
return { ...current, facts };
|
return { ...current, facts };
|
||||||
@@ -416,7 +421,7 @@ export function useMapDataProductRuntime({
|
|||||||
dataProductId: binding.dataProductId,
|
dataProductId: binding.dataProductId,
|
||||||
slotId: binding.slotId,
|
slotId: binding.slotId,
|
||||||
presentationProfileId: binding.presentationProfileId,
|
presentationProfileId: binding.presentationProfileId,
|
||||||
facts: Object.values(record.facts).sort((left, right) => mapRuntimeFactKey(left).localeCompare(mapRuntimeFactKey(right))),
|
facts: Object.values(record.facts).sort((left, right) => factKey(left).localeCompare(factKey(right))),
|
||||||
cursor: record.cursor,
|
cursor: record.cursor,
|
||||||
state: record.state,
|
state: record.state,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -211,13 +211,7 @@ function asFact(value: unknown): MapRuntimeFact | null {
|
|||||||
|| !isIso(value.observedAt) || !isIso(value.receivedAt)
|
|| !isIso(value.observedAt) || !isIso(value.receivedAt)
|
||||||
|| !isObject(value.attributes) || !categories.has(value.attributes.category as MapReferenceStationCategory)
|
|| !isObject(value.attributes) || !categories.has(value.attributes.category as MapReferenceStationCategory)
|
||||||
|| !isPoint(value.geometry)) return null;
|
|| !isPoint(value.geometry)) return null;
|
||||||
if (value.attributes.alternate_names !== undefined
|
const allowedAttributes = new Set(["name", "category", "network", "operator", "official_name", "local_name", "uic_ref", "wheelchair"]);
|
||||||
&& (!Array.isArray(value.attributes.alternate_names)
|
|
||||||
|| value.attributes.alternate_names.length > 16
|
|
||||||
|| value.attributes.alternate_names.some((name) => typeof name !== "string" || !name.trim() || name.length > 256))) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const allowedAttributes = new Set(["name", "category", "network", "operator", "official_name", "local_name", "alternate_names", "uic_ref", "wheelchair"]);
|
|
||||||
return {
|
return {
|
||||||
sourceId: value.sourceId,
|
sourceId: value.sourceId,
|
||||||
semanticType: String(value.semanticType),
|
semanticType: String(value.semanticType),
|
||||||
|
|||||||
+29
-4
@@ -10,6 +10,8 @@
|
|||||||
- `strong` — dropdown, modal и поверхность над сложным фоном;
|
- `strong` — dropdown, modal и поверхность над сложным фоном;
|
||||||
- `soft` — вложенная или вторичная область.
|
- `soft` — вложенная или вторичная область.
|
||||||
|
|
||||||
|
`radius="pill"` задаёт каноническую капсульную геометрию через `--nodedc-radius-circle`. Она предназначена, в частности, для компактных вертикальных и горизонтальных реек из круглых действий: верхняя и нижняя части поверхности повторяют круг кнопок без прямых торцевых пролётов. Consumer не воспроизводит этот радиус локальным CSS.
|
||||||
|
|
||||||
Material rim допустим только как часть floating glass. Жёсткая цветная рамка, случайный browser outline или debug border не являются rim.
|
Material rim допустим только как часть floating glass. Жёсткая цветная рамка, случайный browser outline или debug border не являются rim.
|
||||||
|
|
||||||
`GlassMaterialSurface` — отдельный переносимый контракт Engine Glass V4. Он централизует tint/opacity, blur, saturation, brightness, gradient rim и shadow. Его используют только modal `Window` и modeless draggable Inspector; обычные Launcher/SEO панели остаются непрозрачными. Сам `Window` всегда получает класс `nodedc-glass-material` и `data-material="glass-v4"`, поэтому sharing, confirmation и остальные modal-паттерны физически используют тот же surface, что Inspector и лабораторный preview. Настройки применяются через `applyGlassMaterial`, поэтому consumer не пересобирает CSS материала вручную.
|
`GlassMaterialSurface` — отдельный переносимый контракт Engine Glass V4. Он централизует tint/opacity, blur, saturation, brightness, gradient rim и shadow. Его используют только modal `Window` и modeless draggable Inspector; обычные Launcher/SEO панели остаются непрозрачными. Сам `Window` всегда получает класс `nodedc-glass-material` и `data-material="glass-v4"`, поэтому sharing, confirmation и остальные modal-паттерны физически используют тот же surface, что Inspector и лабораторный preview. Настройки применяются через `applyGlassMaterial`, поэтому consumer не пересобирает CSS материала вручную.
|
||||||
@@ -25,6 +27,16 @@ Button используется для всех текстовых действ
|
|||||||
|
|
||||||
Icon-only action по умолчанию круглый. Квадратная кнопка с маленьким радиусом допустима только как кнопка закрытия окна или плотный инструмент, где это зафиксировано контрактом.
|
Icon-only action по умолчанию круглый. Квадратная кнопка с маленьким радиусом допустима только как кнопка закрытия окна или плотный инструмент, где это зафиксировано контрактом.
|
||||||
|
|
||||||
|
`size="dense"` — каноническая плотность для текстовых layer/mode-действий внутри визуализатора. Она уменьшает площадь и подпись контрола, но не меняет hover, active, disabled и focus-состояния. В шапках приложения и обычных формах эта плотность не используется.
|
||||||
|
|
||||||
|
Переключаемый IconButton передаёт контролируемое состояние через `aria-pressed`. Активная поверхность и контраст принадлежат дизайн-системе; размер круга и glyph при переключении не меняются.
|
||||||
|
|
||||||
|
## ActivityIndicator
|
||||||
|
|
||||||
|
`ActivityIndicator` — общий индикатор неопределённого по длительности процесса. `default` используется рядом с самостоятельным статусом, `compact` — в icon-slot кнопки. Владелец операции по-прежнему задаёт видимый текст pending-состояния и `aria-busy`; индикатор не хранит таймер и не определяет завершение операции.
|
||||||
|
|
||||||
|
Без `label` индикатор декоративный и скрыт от accessibility tree. `label` включает `role="status"` только когда сам индикатор должен объявить процесс. При `prefers-reduced-motion: reduce` кольцо остаётся видимым, но не вращается.
|
||||||
|
|
||||||
## Field
|
## Field
|
||||||
|
|
||||||
FieldFrame объединяет label, control, hint и description. TextField/TextAreaField реализуют стандартные текстовые поля.
|
FieldFrame объединяет label, control, hint и description. TextField/TextAreaField реализуют стандартные текстовые поля.
|
||||||
@@ -39,7 +51,9 @@ FieldFrame объединяет label, control, hint и description. TextField/T
|
|||||||
|
|
||||||
## RangeControl
|
## 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
|
## ColorField
|
||||||
|
|
||||||
@@ -69,10 +83,11 @@ Dropdown владеет floating-layer поведением:
|
|||||||
|
|
||||||
## Select
|
## Select
|
||||||
|
|
||||||
Select добавляет к Dropdown контролируемое значение, options и необязательный поиск. У него две канонические формы:
|
Select добавляет к Dropdown контролируемое значение, options и необязательный поиск. У него три канонические формы:
|
||||||
|
|
||||||
- `integrated` — единая pill-поверхность Hub/Launcher: label слева, chevron строго у правого края;
|
- `integrated` — единая pill-поверхность Hub/Launcher: label слева, chevron строго у правого края;
|
||||||
- `split` — форма Engine с отдельной областью значения и отдельной кнопкой раскрытия `46 px`, между ними зазор `8 px`.
|
- `split` — форма Engine с отдельной областью значения и отдельной кнопкой раскрытия `46 px`, между ними зазор `8 px`;
|
||||||
|
- `inline` — компактный toolbar-trigger: только текущее значение без постоянной подложки и chevron; всё значение открывает обычное portal-меню.
|
||||||
|
|
||||||
Native select не используется как видимый runtime UI. Меню рендерится через portal; у Engine-варианта меню имеет радиус `20 px`, а строки — `14 px` и высоту `42 px`.
|
Native select не используется как видимый runtime UI. Меню рендерится через portal; у Engine-варианта меню имеет радиус `20 px`, а строки — `14 px` и высоту `42 px`.
|
||||||
|
|
||||||
@@ -139,6 +154,14 @@ Enter при отсутствии совпадения разрешает оди
|
|||||||
|
|
||||||
ConfirmationModal оборачивает Window и защищает async-confirm от повторного запуска. До завершения операции закрытие можно заблокировать.
|
ConfirmationModal оборачивает Window и защищает async-confirm от повторного запуска. До завершения операции закрытие можно заблокировать.
|
||||||
|
|
||||||
|
## SplitPane
|
||||||
|
|
||||||
|
`SplitPane` — контролируемая раскладка двух синхронных представлений с общей регулируемой границей. Приложение передаёт содержимое обеих панелей, процент primary-панели и callback изменения; дизайн-система владеет pointer capture, ограничениями размеров, focus-state и доступностью `role="separator"`.
|
||||||
|
|
||||||
|
Вертикальная граница меняется мышью или клавишами `←/→`, горизонтальная — `↑/↓`; `Home/End` переходят к разрешённым границам, а `Shift` ускоряет клавиатурный шаг. Доля сохраняется при resize контейнера. Минимальные проценты обеих панелей применяются одинаково к pointer и keyboard interaction.
|
||||||
|
|
||||||
|
Разделитель является структурной границей между двумя рабочими представлениями и не создаёт декоративную рамку вокруг контента. При `resizable={false}` панели сохраняют ту же стабильную DOM-композицию, а separator полностью отсутствует в визуальном слое и accessibility tree; это позволяет приложению сворачивать одну из панелей без remount тяжёлого renderer.
|
||||||
|
|
||||||
## ShareAccessModal
|
## ShareAccessModal
|
||||||
|
|
||||||
Workflow-sharing modal из нового Engine: заголовок ресурса, compact avatar stack, список участников, редактирование ролей, удаление доступа, email и роль нового участника. Компонент сохраняет Engine-геометрию `600 px` и controls `46 px`, но не импортирует ACL API. Consumer передаёт members, permissions и callbacks.
|
Workflow-sharing modal из нового Engine: заголовок ресурса, compact avatar stack, список участников, редактирование ролей, удаление доступа, email и роль нового участника. Компонент сохраняет Engine-геометрию `600 px` и controls `46 px`, но не импортирует ACL API. Consumer передаёт members, permissions и callbacks.
|
||||||
@@ -153,6 +176,8 @@ Read-only link/copy окно из BIM Viewer. React-компонент испо
|
|||||||
|
|
||||||
Pill navigation для верхней панели и компактного переключения режимов. Active segment использует активную поверхность темы; это не обязательно основной accent приложения.
|
Pill navigation для верхней панели и компактного переключения режимов. Active segment использует активную поверхность темы; это не обязательно основной accent приложения.
|
||||||
|
|
||||||
|
`size="dense"` применяется только в насыщенной панели слоёв или режимов непосредственно над viewer. Шапка приложения сохраняет default-геометрию; consumer не воспроизводит dense-отступы или шрифт локальным CSS.
|
||||||
|
|
||||||
## AppHeader
|
## AppHeader
|
||||||
|
|
||||||
Трёхосевая верхняя панель:
|
Трёхосевая верхняя панель:
|
||||||
@@ -265,7 +290,7 @@ Sortable-строки ограничены вертикальной осью: г
|
|||||||
|
|
||||||
## ToastStack
|
## 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
|
## Environment Controls: Inspector и ControlRow
|
||||||
|
|
||||||
|
|||||||
+6
-2
@@ -19,12 +19,16 @@
|
|||||||
| Окно и слой | `close`, `plus`, `expand`, `minimize`, `refresh`, `panel`, `apps` |
|
| Окно и слой | `close`, `plus`, `expand`, `minimize`, `refresh`, `panel`, `apps` |
|
||||||
| Навигация | `chevron-left`, `chevron-right`, `chevron-down`, `grid`, `list`, `sliders`, `search` |
|
| Навигация | `chevron-left`, `chevron-right`, `chevron-down`, `grid`, `list`, `sliders`, `search` |
|
||||||
| Редактирование | `save`, `edit`, `trash`, `copy`, `upload`, `download`, `external` |
|
| Редактирование | `save`, `edit`, `trash`, `copy`, `upload`, `download`, `external` |
|
||||||
| Состояние и доступ | `check`, `alert`, `activity`, `lock`, `key`, `shield`, `circle` |
|
| Состояние и доступ | `check`, `alert`, `activity`, `lock`, `key`, `shield`, `circle`, `eye`, `eye-off` |
|
||||||
| Сущности | `profile`, `users`, `building`, `globe`, `database`, `network`, `inbox`, `mail` |
|
| Сущности | `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`.
|
Живая таблица с названиями, поверхностями и размерами находится в разделе `Guideline → Иконки`. Машинный список — в `registry/icons.json`.
|
||||||
|
|
||||||
## Правило расширения
|
## Правило расширения
|
||||||
|
|
||||||
Новая иконка добавляется только после подтверждённого применения в продукте. Нужно выбрать семантическое имя, добавить export в `Icon`, запись в registry и specimen в каталоге. Локальный импорт иконки только ради немного другой формы запрещён.
|
Новая иконка добавляется только после подтверждённого применения в продукте. Нужно выбрать семантическое имя, добавить export в `Icon`, запись в registry и specimen в каталоге. Локальный импорт иконки только ради немного другой формы запрещён.
|
||||||
|
|
||||||
|
`camera` обозначает оптический канал, а `plan` — пространственное представление сверху. Оба glyph контурные и используют общую stroke-геометрию набора.
|
||||||
|
|
||||||
|
`play` и `stop` — компактные полнотелые транспортные glyph: залитый треугольник запуска и залитый квадрат остановки/паузы. Видимая текстовая подпись не требуется, но action обязан сохранить доступный `aria-label`.
|
||||||
|
|||||||
+8
-4
@@ -11,7 +11,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react && npm run build --workspace @nodedc/ui-catalog",
|
"build": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react && npm run build --workspace @nodedc/ui-catalog",
|
||||||
"build:packages": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react",
|
"build:packages": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react",
|
||||||
"check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test: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",
|
"dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog",
|
||||||
"serve": "node server/catalog-server.mjs",
|
"serve": "node server/catalog-server.mjs",
|
||||||
"validate:registry": "node scripts/validate-registry.mjs",
|
"validate:registry": "node scripts/validate-registry.mjs",
|
||||||
@@ -22,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:foundry-agent": "node --test server/foundry-agent-store.test.mjs server/foundry-agent-gateway.test.mjs scripts/foundry-agent-installer.test.mjs",
|
||||||
"test:map-animation": "node --test scripts/map-spiral.test.mjs scripts/map-camera-presets.test.mjs",
|
"test:map-animation": "node --test scripts/map-spiral.test.mjs scripts/map-camera-presets.test.mjs",
|
||||||
"test:map-filters": "node --test scripts/map-presentation-filters.test.mjs",
|
"test:map-filters": "node --test scripts/map-presentation-filters.test.mjs",
|
||||||
"test:map-workspace-model": "node --test scripts/map-workspace-model.test.mjs scripts/map-workspace-state.test.mjs",
|
|
||||||
"test:hgeozone-projection": "node --test scripts/hgeozone-projection.test.mjs",
|
"test:hgeozone-projection": "node --test scripts/hgeozone-projection.test.mjs",
|
||||||
"test:map-grid-lod": "node --test scripts/map-grid-lod.test.mjs scripts/map-sector-grid.test.mjs server/map-grid-persistence.test.mjs",
|
"test:map-grid-lod": "node --test scripts/map-grid-lod.test.mjs scripts/map-sector-grid.test.mjs server/map-grid-persistence.test.mjs",
|
||||||
"test:map-object-layers": "node --test scripts/map-object-layers.test.mjs",
|
"test:map-object-layers": "node --test scripts/map-object-layers.test.mjs",
|
||||||
@@ -32,10 +31,15 @@
|
|||||||
"test:map-subject-card": "node --test scripts/map-subject-card.test.mjs",
|
"test:map-subject-card": "node --test scripts/map-subject-card.test.mjs",
|
||||||
"test:map-subject-detail-profile": "node --test server/map-subject-detail-profile.test.mjs server/map-live-data-slot.test.mjs",
|
"test:map-subject-detail-profile": "node --test server/map-subject-detail-profile.test.mjs server/map-live-data-slot.test.mjs",
|
||||||
"test:map-cache-contract": "node --test scripts/map-cache-resource-contract.test.mjs",
|
"test:map-cache-contract": "node --test scripts/map-cache-resource-contract.test.mjs",
|
||||||
"test: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:floating-position": "node --test scripts/floating-position-contract.test.mjs",
|
||||||
"test:inspector-select": "node --test scripts/inspector-select-contract.test.mjs",
|
"test:inspector-select": "node --test scripts/inspector-select-contract.test.mjs",
|
||||||
"test:range-control": "node --test scripts/range-control-contract.test.mjs"
|
"test:range-control": "node --test scripts/range-control-contract.test.mjs",
|
||||||
|
"test:split-pane": "node --test scripts/split-pane-contract.test.mjs"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
--nodedc-control-height: 2.875rem;
|
--nodedc-control-height: 2.875rem;
|
||||||
--nodedc-control-height-compact: 2.375rem;
|
--nodedc-control-height-compact: 2.375rem;
|
||||||
|
--nodedc-control-height-dense: 1.875rem;
|
||||||
--nodedc-icon-button-size: 2.875rem;
|
--nodedc-icon-button-size: 2.875rem;
|
||||||
--nodedc-header-row-height: 3rem;
|
--nodedc-header-row-height: 3rem;
|
||||||
--nodedc-header-pill-height: 3.45rem;
|
--nodedc-header-pill-height: 3.45rem;
|
||||||
@@ -61,6 +62,7 @@
|
|||||||
|
|
||||||
--nodedc-font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
--nodedc-font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
--nodedc-font-size-xs: 0.6875rem;
|
--nodedc-font-size-xs: 0.6875rem;
|
||||||
|
--nodedc-font-size-dense: 0.45rem;
|
||||||
--nodedc-font-size-sm: 0.75rem;
|
--nodedc-font-size-sm: 0.75rem;
|
||||||
--nodedc-font-size-md: 0.8125rem;
|
--nodedc-font-size-md: 0.8125rem;
|
||||||
--nodedc-font-size-lg: 1rem;
|
--nodedc-font-size-lg: 1rem;
|
||||||
|
|||||||
+174
-2
@@ -15,13 +15,13 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
:where(.nodedc-button, .nodedc-icon-button, .nodedc-checker, .nodedc-select-trigger, .nodedc-select__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;
|
border: 0;
|
||||||
outline: 0;
|
outline: 0;
|
||||||
font: inherit;
|
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);
|
background-color: var(--nodedc-focus-surface);
|
||||||
box-shadow: var(--nodedc-glass-control-shadow), inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.22);
|
box-shadow: var(--nodedc-glass-control-shadow), inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.22);
|
||||||
}
|
}
|
||||||
@@ -73,6 +73,10 @@
|
|||||||
border-radius: var(--nodedc-radius-modal);
|
border-radius: var(--nodedc-radius-modal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nodedc-glass[data-radius="pill"] {
|
||||||
|
border-radius: var(--nodedc-radius-circle);
|
||||||
|
}
|
||||||
|
|
||||||
.nodedc-glass[data-padding="sm"] {
|
.nodedc-glass[data-padding="sm"] {
|
||||||
padding: var(--nodedc-space-3);
|
padding: var(--nodedc-space-3);
|
||||||
}
|
}
|
||||||
@@ -138,6 +142,13 @@
|
|||||||
font-size: var(--nodedc-font-size-sm);
|
font-size: var(--nodedc-font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nodedc-button[data-size="dense"] {
|
||||||
|
min-height: var(--nodedc-control-height-dense);
|
||||||
|
border-radius: var(--nodedc-radius-control-compact);
|
||||||
|
padding-inline: 0.65rem;
|
||||||
|
font-size: var(--nodedc-font-size-dense);
|
||||||
|
}
|
||||||
|
|
||||||
.nodedc-button[data-width="full"] {
|
.nodedc-button[data-width="full"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -211,6 +222,30 @@
|
|||||||
transform: scale(var(--nodedc-icon-glyph-scale));
|
transform: scale(var(--nodedc-icon-glyph-scale));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nodedc-activity-indicator {
|
||||||
|
display: inline-block;
|
||||||
|
width: 1.125rem;
|
||||||
|
height: 1.125rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 2px solid currentColor;
|
||||||
|
border-inline-end-color: transparent;
|
||||||
|
border-radius: var(--nodedc-radius-circle);
|
||||||
|
color: inherit;
|
||||||
|
opacity: 0.78;
|
||||||
|
animation: nodedc-activity-indicator-spin 780ms linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-activity-indicator[data-size="compact"] {
|
||||||
|
width: 0.875rem;
|
||||||
|
height: 0.875rem;
|
||||||
|
border-width: 1.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes nodedc-activity-indicator-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
.nodedc-icon-button {
|
.nodedc-icon-button {
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
width: var(--nodedc-icon-button-size);
|
width: var(--nodedc-icon-button-size);
|
||||||
@@ -231,6 +266,11 @@
|
|||||||
color: var(--nodedc-text-primary);
|
color: var(--nodedc-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nodedc-icon-button[aria-pressed="true"] {
|
||||||
|
background: var(--nodedc-glass-control-active);
|
||||||
|
color: var(--nodedc-glass-control-active-text);
|
||||||
|
}
|
||||||
|
|
||||||
.nodedc-icon-button:active:not(:disabled) {
|
.nodedc-icon-button:active:not(:disabled) {
|
||||||
transform: translateY(1px);
|
transform: translateY(1px);
|
||||||
}
|
}
|
||||||
@@ -769,6 +809,10 @@ textarea.nodedc-field__control {
|
|||||||
caret-color: currentColor;
|
caret-color: currentColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nodedc-range__editor[data-active][data-contrast="fill"] {
|
||||||
|
color: rgb(var(--nodedc-on-accent-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
.nodedc-range[data-editing] > .nodedc-range__value,
|
.nodedc-range[data-editing] > .nodedc-range__value,
|
||||||
.nodedc-range[data-editing] > .nodedc-range__fill-text .nodedc-range__value {
|
.nodedc-range[data-editing] > .nodedc-range__fill-text .nodedc-range__value {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -1002,6 +1046,19 @@ textarea.nodedc-field__control {
|
|||||||
color: var(--nodedc-glass-control-active-text);
|
color: var(--nodedc-glass-control-active-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nodedc-segmented[data-size="dense"] {
|
||||||
|
min-height: var(--nodedc-control-height-dense);
|
||||||
|
gap: 0.1rem;
|
||||||
|
padding: 0.16rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-segmented[data-size="dense"] .nodedc-segmented__item {
|
||||||
|
min-height: calc(var(--nodedc-control-height-dense) - 0.32rem);
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.1rem 0.68rem;
|
||||||
|
font-size: var(--nodedc-font-size-dense);
|
||||||
|
}
|
||||||
|
|
||||||
.nodedc-header-shell {
|
.nodedc-header-shell {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: var(--nodedc-layer-header);
|
z-index: var(--nodedc-layer-header);
|
||||||
@@ -2182,6 +2239,33 @@ textarea.nodedc-field__control {
|
|||||||
transform: translateY(2px) rotate(225deg);
|
transform: translateY(2px) rotate(225deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nodedc-select-inline {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: var(--nodedc-control-height-compact);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--nodedc-space-2);
|
||||||
|
border-radius: var(--nodedc-radius-circle);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
padding: 0 var(--nodedc-space-3);
|
||||||
|
font-size: var(--nodedc-font-size-sm);
|
||||||
|
font-weight: var(--nodedc-font-weight-strong);
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-select-inline:hover:not(:disabled),
|
||||||
|
.nodedc-select-inline[aria-expanded="true"] {
|
||||||
|
background: var(--nodedc-glass-control-hover);
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-select-inline:disabled {
|
||||||
|
opacity: 0.42;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.nodedc-select__toggle[aria-expanded="true"] .nodedc-select-trigger__chevron {
|
.nodedc-select__toggle[aria-expanded="true"] .nodedc-select-trigger__chevron {
|
||||||
transform: translateY(2px) rotate(225deg);
|
transform: translateY(2px) rotate(225deg);
|
||||||
}
|
}
|
||||||
@@ -2805,6 +2889,93 @@ textarea.nodedc-field__control {
|
|||||||
color: var(--nodedc-text-primary);
|
color: var(--nodedc-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nodedc-split-pane {
|
||||||
|
--nodedc-split-pane-primary: 50%;
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-split-pane[data-orientation="vertical"] {
|
||||||
|
grid-template-columns: minmax(0, var(--nodedc-split-pane-primary)) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-split-pane[data-orientation="horizontal"] {
|
||||||
|
grid-template-rows: minmax(0, var(--nodedc-split-pane-primary)) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-split-pane__panel {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-split-pane__separator {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 5;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-split-pane[data-orientation="vertical"] > .nodedc-split-pane__separator {
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: var(--nodedc-split-pane-primary);
|
||||||
|
width: 0.9rem;
|
||||||
|
cursor: col-resize;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-split-pane[data-orientation="horizontal"] > .nodedc-split-pane__separator {
|
||||||
|
right: 0;
|
||||||
|
bottom: calc(100% - var(--nodedc-split-pane-primary));
|
||||||
|
left: 0;
|
||||||
|
height: 0.9rem;
|
||||||
|
cursor: row-resize;
|
||||||
|
transform: translateY(50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-split-pane__separator::before {
|
||||||
|
position: absolute;
|
||||||
|
background: var(--nodedc-glass-outline);
|
||||||
|
content: "";
|
||||||
|
transition: background var(--nodedc-duration-fast) var(--nodedc-ease-standard), box-shadow var(--nodedc-duration-fast) var(--nodedc-ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-split-pane[data-orientation="vertical"] > .nodedc-split-pane__separator::before {
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
width: 1px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-split-pane[data-orientation="horizontal"] > .nodedc-split-pane__separator::before {
|
||||||
|
right: 0;
|
||||||
|
bottom: 50%;
|
||||||
|
left: 0;
|
||||||
|
height: 1px;
|
||||||
|
transform: translateY(50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-split-pane__separator:hover::before,
|
||||||
|
.nodedc-split-pane__separator:focus-visible::before,
|
||||||
|
.nodedc-split-pane[data-dragging="true"] > .nodedc-split-pane__separator::before {
|
||||||
|
background: rgb(var(--nodedc-accent-rgb));
|
||||||
|
box-shadow: 0 0 0 2px rgb(var(--nodedc-accent-rgb) / 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-split-pane[data-dragging="true"] {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
.nodedc-toolbar-wrap {
|
.nodedc-toolbar-wrap {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: calc(var(--nodedc-layer-header) + 20);
|
z-index: calc(var(--nodedc-layer-header) + 20);
|
||||||
@@ -3585,6 +3756,7 @@ textarea.nodedc-field__control {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.nodedc-activity-indicator,
|
||||||
.nodedc-dropdown-surface,
|
.nodedc-dropdown-surface,
|
||||||
.nodedc-overlay,
|
.nodedc-overlay,
|
||||||
.nodedc-window,
|
.nodedc-window,
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { createAccentVariables, type RgbTuple } from "@nodedc/ui-core";
|
|||||||
import { cn } from "./cn.js";
|
import { cn } from "./cn.js";
|
||||||
|
|
||||||
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "accent";
|
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "accent";
|
||||||
export type ButtonSize = "default" | "compact";
|
export type ButtonSize = "default" | "compact" | "dense";
|
||||||
export type ButtonShape = "default" | "pill" | "rounded";
|
export type ButtonShape = "default" | "pill" | "rounded";
|
||||||
|
|
||||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
|||||||
@@ -107,32 +107,7 @@ export function Dropdown({
|
|||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
updatePosition();
|
updatePosition();
|
||||||
|
}, [isOpen, updatePosition]);
|
||||||
const surface = surfaceRef.current;
|
|
||||||
const anchor = anchorElement ?? triggerElement;
|
|
||||||
if (!surface || typeof ResizeObserver === "undefined") {
|
|
||||||
const frame = window.requestAnimationFrame(updatePosition);
|
|
||||||
return () => window.cancelAnimationFrame(frame);
|
|
||||||
}
|
|
||||||
|
|
||||||
let frame: number | null = null;
|
|
||||||
const schedulePositionUpdate = () => {
|
|
||||||
if (frame !== null) return;
|
|
||||||
frame = window.requestAnimationFrame(() => {
|
|
||||||
frame = null;
|
|
||||||
updatePosition();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const resizeObserver = new ResizeObserver(schedulePositionUpdate);
|
|
||||||
resizeObserver.observe(surface);
|
|
||||||
if (anchor) resizeObserver.observe(anchor);
|
|
||||||
schedulePositionUpdate();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
resizeObserver.disconnect();
|
|
||||||
if (frame !== null) window.cancelAnimationFrame(frame);
|
|
||||||
};
|
|
||||||
}, [anchorElement, isOpen, triggerElement, updatePosition]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { HTMLAttributes, ReactNode } from "react";
|
|||||||
import { cn } from "./cn.js";
|
import { cn } from "./cn.js";
|
||||||
|
|
||||||
export type GlassTone = "default" | "strong" | "soft";
|
export type GlassTone = "default" | "strong" | "soft";
|
||||||
export type GlassRadius = "card" | "panel" | "modal";
|
export type GlassRadius = "card" | "panel" | "modal" | "pill";
|
||||||
export type GlassPadding = "none" | "sm" | "md" | "lg";
|
export type GlassPadding = "none" | "sm" | "md" | "lg";
|
||||||
|
|
||||||
export interface GlassSurfaceProps extends HTMLAttributes<HTMLDivElement> {
|
export interface GlassSurfaceProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Boxes,
|
Boxes,
|
||||||
Building2,
|
Building2,
|
||||||
|
Camera,
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@@ -12,6 +13,8 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
Database,
|
Database,
|
||||||
Download,
|
Download,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
FileText,
|
FileText,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
@@ -24,11 +27,13 @@ import {
|
|||||||
LocateFixed,
|
LocateFixed,
|
||||||
LockKeyhole,
|
LockKeyhole,
|
||||||
MailPlus,
|
MailPlus,
|
||||||
|
Map,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
Minimize2,
|
Minimize2,
|
||||||
Network,
|
Network,
|
||||||
PanelTop,
|
PanelTop,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
Play,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Save,
|
Save,
|
||||||
@@ -36,6 +41,7 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
|
Square,
|
||||||
Trash2,
|
Trash2,
|
||||||
UploadCloud,
|
UploadCloud,
|
||||||
UserCircle,
|
UserCircle,
|
||||||
@@ -51,6 +57,7 @@ const icons = {
|
|||||||
alert: AlertTriangle,
|
alert: AlertTriangle,
|
||||||
apps: Boxes,
|
apps: Boxes,
|
||||||
building: Building2,
|
building: Building2,
|
||||||
|
camera: Camera,
|
||||||
check: Check,
|
check: Check,
|
||||||
"chevron-down": ChevronDown,
|
"chevron-down": ChevronDown,
|
||||||
"chevron-left": ChevronLeft,
|
"chevron-left": ChevronLeft,
|
||||||
@@ -62,6 +69,8 @@ const icons = {
|
|||||||
database: Database,
|
database: Database,
|
||||||
download: Download,
|
download: Download,
|
||||||
edit: Pencil,
|
edit: Pencil,
|
||||||
|
eye: Eye,
|
||||||
|
"eye-off": EyeOff,
|
||||||
expand: Maximize2,
|
expand: Maximize2,
|
||||||
external: ExternalLink,
|
external: ExternalLink,
|
||||||
file: FileText,
|
file: FileText,
|
||||||
@@ -78,6 +87,8 @@ const icons = {
|
|||||||
minimize: Minimize2,
|
minimize: Minimize2,
|
||||||
network: Network,
|
network: Network,
|
||||||
panel: PanelTop,
|
panel: PanelTop,
|
||||||
|
plan: Map,
|
||||||
|
play: Play,
|
||||||
plus: Plus,
|
plus: Plus,
|
||||||
profile: UserCircle,
|
profile: UserCircle,
|
||||||
refresh: RefreshCw,
|
refresh: RefreshCw,
|
||||||
@@ -86,6 +97,7 @@ const icons = {
|
|||||||
settings: Settings,
|
settings: Settings,
|
||||||
shield: ShieldCheck,
|
shield: ShieldCheck,
|
||||||
sliders: SlidersHorizontal,
|
sliders: SlidersHorizontal,
|
||||||
|
stop: Square,
|
||||||
trash: Trash2,
|
trash: Trash2,
|
||||||
upload: UploadCloud,
|
upload: UploadCloud,
|
||||||
users: UsersRound,
|
users: UsersRound,
|
||||||
@@ -99,13 +111,15 @@ export interface IconProps extends Omit<LucideProps, "ref"> {
|
|||||||
label?: string;
|
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 IconComponent = icons[name];
|
||||||
|
const filledTransport = name === "play" || name === "stop";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IconComponent
|
<IconComponent
|
||||||
size={size}
|
size={size}
|
||||||
strokeWidth={strokeWidth}
|
strokeWidth={filledTransport ? 0 : strokeWidth}
|
||||||
|
fill={fill ?? (filledTransport ? "currentColor" : "none")}
|
||||||
aria-hidden={label ? undefined : "true"}
|
aria-hidden={label ? undefined : "true"}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
role={label ? "img" : undefined}
|
role={label ? "img" : undefined}
|
||||||
|
|||||||
@@ -1,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 { cn } from "./cn.js";
|
||||||
import { Dropdown } from "./Dropdown.js";
|
import { Dropdown } from "./Dropdown.js";
|
||||||
|
|
||||||
@@ -7,6 +7,10 @@ export interface RangeControlProps extends Omit<InputHTMLAttributes<HTMLInputEle
|
|||||||
value: number;
|
value: number;
|
||||||
min: number;
|
min: number;
|
||||||
max: number;
|
max: number;
|
||||||
|
exactValueBounds?: {
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
};
|
||||||
formatValue?: (value: number) => string;
|
formatValue?: (value: number) => string;
|
||||||
onChange: (value: number) => void;
|
onChange: (value: number) => void;
|
||||||
}
|
}
|
||||||
@@ -29,14 +33,17 @@ const normalizeEditedRangeValue = (
|
|||||||
min: number,
|
min: number,
|
||||||
max: number,
|
max: number,
|
||||||
step: RangeControlProps["step"],
|
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;
|
if (step === "any") return clamped;
|
||||||
const numericStep = step === undefined ? 1 : Number(step);
|
const numericStep = step === undefined ? 1 : Number(step);
|
||||||
if (!Number.isFinite(numericStep) || numericStep <= 0) return clamped;
|
if (!Number.isFinite(numericStep) || numericStep <= 0) return clamped;
|
||||||
const precision = Math.min(12, Math.max(decimalPlaces(min), decimalPlaces(numericStep)));
|
const precision = Math.min(12, Math.max(decimalPlaces(min), decimalPlaces(numericStep)));
|
||||||
const aligned = min + Math.round((clamped - min) / numericStep) * numericStep;
|
const aligned = min + Math.round((clamped - min) / numericStep) * numericStep;
|
||||||
return clampRangeValue(Number(aligned.toFixed(precision)), min, max);
|
return clampRangeValue(Number(aligned.toFixed(precision)), exactMin, exactMax);
|
||||||
};
|
};
|
||||||
|
|
||||||
const editableNumber = (value: number) => (
|
const editableNumber = (value: number) => (
|
||||||
@@ -48,6 +55,7 @@ export function RangeControl({
|
|||||||
value,
|
value,
|
||||||
min,
|
min,
|
||||||
max,
|
max,
|
||||||
|
exactValueBounds,
|
||||||
step,
|
step,
|
||||||
formatValue = String,
|
formatValue = String,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -56,11 +64,13 @@ export function RangeControl({
|
|||||||
tabIndex,
|
tabIndex,
|
||||||
...props
|
...props
|
||||||
}: RangeControlProps) {
|
}: RangeControlProps) {
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const rangeRef = useRef<HTMLInputElement>(null);
|
const rangeRef = useRef<HTMLInputElement>(null);
|
||||||
const editorRef = useRef<HTMLInputElement>(null);
|
const editorRef = useRef<HTMLInputElement>(null);
|
||||||
const cancelNextBlurRef = useRef(false);
|
const cancelNextBlurRef = useRef(false);
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState(() => editableNumber(value));
|
const [draft, setDraft] = useState(() => editableNumber(value));
|
||||||
|
const [editorContrast, setEditorContrast] = useState<"base" | "fill">("base");
|
||||||
const safeMax = max === min ? min + 1 : max;
|
const safeMax = max === min ? min + 1 : max;
|
||||||
const progress = Math.max(0, Math.min(100, ((value - min) / (safeMax - min)) * 100));
|
const progress = Math.max(0, Math.min(100, ((value - min) / (safeMax - min)) * 100));
|
||||||
const displayValue = formatValue(value);
|
const displayValue = formatValue(value);
|
||||||
@@ -77,10 +87,30 @@ export function RangeControl({
|
|||||||
if (!editing) setDraft(editableNumber(value));
|
if (!editing) setDraft(editableNumber(value));
|
||||||
}, [editing, value]);
|
}, [editing, value]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const root = rootRef.current;
|
||||||
|
const editor = editorRef.current;
|
||||||
|
if (!root || !editor) return undefined;
|
||||||
|
const updateContrast = () => {
|
||||||
|
const rootBounds = root.getBoundingClientRect();
|
||||||
|
const editorBounds = editor.getBoundingClientRect();
|
||||||
|
const paddingRight = Number.parseFloat(getComputedStyle(editor).paddingRight) || 0;
|
||||||
|
const fillRight = rootBounds.left + rootBounds.width * progress / 100;
|
||||||
|
const textRight = editorBounds.right - paddingRight;
|
||||||
|
setEditorContrast(fillRight >= textRight ? "fill" : "base");
|
||||||
|
};
|
||||||
|
updateContrast();
|
||||||
|
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(updateContrast);
|
||||||
|
observer?.observe(root);
|
||||||
|
return () => observer?.disconnect();
|
||||||
|
}, [editorCharacters, progress]);
|
||||||
|
|
||||||
const finishEditing = (commit: boolean, restoreRangeFocus: boolean) => {
|
const finishEditing = (commit: boolean, restoreRangeFocus: boolean) => {
|
||||||
if (commit) {
|
if (commit) {
|
||||||
const parsed = Number(draft.trim().replace(",", "."));
|
const parsed = Number(draft.trim().replace(",", "."));
|
||||||
if (Number.isFinite(parsed)) onChange(normalizeEditedRangeValue(parsed, min, max, step));
|
if (Number.isFinite(parsed)) {
|
||||||
|
onChange(normalizeEditedRangeValue(parsed, min, max, step, exactValueBounds));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
if (restoreRangeFocus) requestAnimationFrame(() => rangeRef.current?.focus());
|
if (restoreRangeFocus) requestAnimationFrame(() => rangeRef.current?.focus());
|
||||||
@@ -103,7 +133,7 @@ export function RangeControl({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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
|
<input
|
||||||
ref={rangeRef}
|
ref={rangeRef}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -129,22 +159,14 @@ export function RangeControl({
|
|||||||
className="nodedc-range__editor"
|
className="nodedc-range__editor"
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
value={editing ? draft : editableNumber(value)}
|
value={draft}
|
||||||
aria-label={`${label}: точное значение`}
|
aria-label={`${label}: точное значение`}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
data-active={editing || undefined}
|
data-active={editing || undefined}
|
||||||
onPointerDown={() => {
|
data-contrast={editorContrast}
|
||||||
if (!editing) {
|
|
||||||
setDraft(editableNumber(value));
|
|
||||||
setEditing(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
if (!editing) {
|
if (!editing) setEditing(true);
|
||||||
setDraft(editableNumber(value));
|
|
||||||
setEditing(true);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
onChange={(event) => setDraft(event.target.value)}
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
onKeyDown={handleEditorKeyDown}
|
onKeyDown={handleEditorKeyDown}
|
||||||
|
|||||||
@@ -8,10 +8,13 @@ export interface SegmentedItem<T extends string> {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SegmentedControlSize = "default" | "dense";
|
||||||
|
|
||||||
export interface SegmentedControlProps<T extends string> {
|
export interface SegmentedControlProps<T extends string> {
|
||||||
value: T;
|
value: T;
|
||||||
items: Array<SegmentedItem<T>>;
|
items: Array<SegmentedItem<T>>;
|
||||||
label: string;
|
label: string;
|
||||||
|
size?: SegmentedControlSize;
|
||||||
className?: string;
|
className?: string;
|
||||||
onChange: (value: T) => void;
|
onChange: (value: T) => void;
|
||||||
}
|
}
|
||||||
@@ -20,11 +23,17 @@ export function SegmentedControl<T extends string>({
|
|||||||
value,
|
value,
|
||||||
items,
|
items,
|
||||||
label,
|
label,
|
||||||
|
size = "default",
|
||||||
className,
|
className,
|
||||||
onChange,
|
onChange,
|
||||||
}: SegmentedControlProps<T>) {
|
}: SegmentedControlProps<T>) {
|
||||||
return (
|
return (
|
||||||
<div 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) => (
|
{items.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.value}
|
key={item.value}
|
||||||
@@ -43,4 +52,3 @@ export function SegmentedControl<T extends string>({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export interface SelectOption<T extends string> {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SelectVariant = "integrated" | "split";
|
export type SelectVariant = "integrated" | "split" | "inline";
|
||||||
|
|
||||||
export interface SelectProps<T extends string> {
|
export interface SelectProps<T extends string> {
|
||||||
value: T;
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
ref={setTriggerRef}
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, type HTMLAttributes } from "react";
|
import { useEffect, useRef, type HTMLAttributes } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { cn } from "./cn.js";
|
import { cn } from "./cn.js";
|
||||||
import { Icon, type IconName } from "./Icon.js";
|
import { Icon, type IconName } from "./Icon.js";
|
||||||
@@ -21,6 +21,8 @@ const toastIcons: Record<ToastTone, IconName> = {
|
|||||||
loading: "refresh",
|
loading: "refresh",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DEFAULT_TOAST_DURATION_MS = 10_000;
|
||||||
|
|
||||||
export interface ToastCardProps extends HTMLAttributes<HTMLDivElement> {
|
export interface ToastCardProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
item: ToastItem;
|
item: ToastItem;
|
||||||
onDismiss?: (id: string) => void;
|
onDismiss?: (id: string) => void;
|
||||||
@@ -48,21 +50,30 @@ export function ToastCard({ item, onDismiss, className, ...props }: ToastCardPro
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ToastStack({ items, onDismiss }: { items: ToastItem[]; onDismiss: (id: string) => void }) {
|
function TimedToastCard({ item, onDismiss }: { item: ToastItem; onDismiss: (id: string) => void }) {
|
||||||
useEffect(() => {
|
const dismissRef = useRef(onDismiss);
|
||||||
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]);
|
|
||||||
|
|
||||||
|
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;
|
if (typeof document === "undefined" || items.length === 0) return null;
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="nodedc-toast-viewport nodedc-ui-root" aria-live="polite" aria-relevant="additions removals">
|
<div className="nodedc-toast-viewport nodedc-ui-root" aria-live="polite" aria-relevant="additions removals">
|
||||||
{items.map((item) => <ToastCard key={item.id} item={item} onDismiss={onDismiss} />)}
|
{items.map((item) => <TimedToastCard key={item.id} item={item} onDismiss={onDismiss} />)}
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from "./AppHeader.js";
|
export * from "./AppHeader.js";
|
||||||
|
export * from "./ActivityIndicator.js";
|
||||||
export * from "./AdminNavigationPanel.js";
|
export * from "./AdminNavigationPanel.js";
|
||||||
export * from "./ApplicationShell.js";
|
export * from "./ApplicationShell.js";
|
||||||
export * from "./ApplicationSidePanel.js";
|
export * from "./ApplicationSidePanel.js";
|
||||||
@@ -20,6 +21,7 @@ export * from "./Select.js";
|
|||||||
export * from "./StatusBadge.js";
|
export * from "./StatusBadge.js";
|
||||||
export * from "./Settings.js";
|
export * from "./Settings.js";
|
||||||
export * from "./SharingModals.js";
|
export * from "./SharingModals.js";
|
||||||
|
export * from "./SplitPane.js";
|
||||||
export * from "./Toolbar.js";
|
export * from "./Toolbar.js";
|
||||||
export * from "./Toast.js";
|
export * from "./Toast.js";
|
||||||
export * from "./UserProfileMenu.js";
|
export * from "./UserProfileMenu.js";
|
||||||
|
|||||||
+56
-11
@@ -10,8 +10,10 @@
|
|||||||
"summary": "Matte application surfaces, the Engine V4 modal material and the light translucent surface used over Cesium imagery.",
|
"summary": "Matte application surfaces, the Engine V4 modal material and the light translucent surface used over Cesium imagery.",
|
||||||
"anatomy": ["theme-provided surface", "modal tint", "map-only light translucency", "gradient rim", "backdrop blur/saturation/brightness", "content"],
|
"anatomy": ["theme-provided surface", "modal tint", "map-only light translucency", "gradient rim", "backdrop blur/saturation/brightness", "content"],
|
||||||
"variants": ["default", "strong", "soft", "map"],
|
"variants": ["default", "strong", "soft", "map"],
|
||||||
|
"radiusVariants": ["card", "panel", "modal", "pill"],
|
||||||
"rules": [
|
"rules": [
|
||||||
"Geometry is invariant across application themes.",
|
"Geometry is invariant across application themes.",
|
||||||
|
"Compact rails of canonical round actions use the pill radius; consumers do not recreate it with a local border radius.",
|
||||||
"A material rim is a glass highlight, not a hard product-colored border.",
|
"A material rim is a glass highlight, not a hard product-colored border.",
|
||||||
"Nested surfaces must use a softer tone to avoid card-inside-card noise.",
|
"Nested surfaces must use a softer tone to avoid card-inside-card noise.",
|
||||||
"GlassyMaterialSurface is restricted to modal Windows and the draggable Inspector.",
|
"GlassyMaterialSurface is restricted to modal Windows and the draggable Inspector.",
|
||||||
@@ -26,13 +28,31 @@
|
|||||||
"domContract": ["nodedc-button", "nodedc-icon-button"],
|
"domContract": ["nodedc-button", "nodedc-icon-button"],
|
||||||
"summary": "Text and icon actions with shared sizing, states and computed accent contrast.",
|
"summary": "Text and icon actions with shared sizing, states and computed accent contrast.",
|
||||||
"variants": ["primary", "secondary", "ghost", "danger", "accent"],
|
"variants": ["primary", "secondary", "ghost", "danger", "accent"],
|
||||||
|
"sizes": ["default", "compact", "dense"],
|
||||||
"shapes": ["default", "pill", "rounded"],
|
"shapes": ["default", "pill", "rounded"],
|
||||||
"rules": [
|
"rules": [
|
||||||
"Icon-only actions are circular by default.",
|
"Icon-only actions are circular by default.",
|
||||||
|
"Controlled toggle actions expose aria-pressed and use the canonical active surface without changing geometry.",
|
||||||
"Filled accent actions derive foreground contrast from the actual accent.",
|
"Filled accent actions derive foreground contrast from the actual accent.",
|
||||||
"Destructive actions remain neutral until confirmation unless danger is the principal message."
|
"Destructive actions remain neutral until confirmation unless danger is the principal message."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "activity-indicator",
|
||||||
|
"status": "baseline",
|
||||||
|
"package": "@nodedc/ui-react",
|
||||||
|
"exports": ["ActivityIndicator", "ActivityIndicatorProps", "ActivityIndicatorSize"],
|
||||||
|
"domContract": ["nodedc-activity-indicator"],
|
||||||
|
"summary": "Theme-independent indeterminate progress indicator for inline operations and pending action controls.",
|
||||||
|
"variants": ["default", "compact"],
|
||||||
|
"behavior": ["decorative by default", "optional status semantics", "static reduced-motion presentation"],
|
||||||
|
"rules": [
|
||||||
|
"Use compact inside a Button icon slot and default for standalone inline progress.",
|
||||||
|
"The process owner exposes aria-busy and visible pending copy; provide label only when the indicator itself is the status announcement.",
|
||||||
|
"The component never owns operation state, timing or completion.",
|
||||||
|
"Reduced-motion preferences stop rotation without hiding the pending-state affordance."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "field",
|
"id": "field",
|
||||||
"status": "baseline",
|
"status": "baseline",
|
||||||
@@ -64,17 +84,20 @@
|
|||||||
"package": "@nodedc/ui-react",
|
"package": "@nodedc/ui-react",
|
||||||
"exports": ["RangeControl"],
|
"exports": ["RangeControl"],
|
||||||
"domContract": ["nodedc-range"],
|
"domContract": ["nodedc-range"],
|
||||||
"summary": "Filled pill range control with embedded label, drag interaction and a persistent native inline exact-value editor.",
|
"summary": "Filled pill range control with embedded label, drag interaction and a persistent native inline exact-value editor.",
|
||||||
"behavior": ["native range drag", "value click exact editing", "Enter/blur commit", "Escape cancel", "min/max clamp", "step normalization"],
|
"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": [
|
"rules": [
|
||||||
"The accent fill follows the active application theme.",
|
"The accent fill follows the active application theme.",
|
||||||
"The native range remains the accessible drag input while its chrome is visually replaced.",
|
"The native range remains the accessible drag input while its chrome is visually replaced.",
|
||||||
"The fill travels beneath the visible value; the persistent transparent native editor above the value receives exact-edit clicks without remounting.",
|
"The fill travels beneath the visible value; the persistent transparent native editor above the value receives exact-edit clicks without remounting.",
|
||||||
"Exact editing stays inside the existing value area and never expands the control.",
|
"Exact editing stays inside the existing value area and never expands the control.",
|
||||||
"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.",
|
"Slider min/max define drag travel only; finite typed values are independent and may exceed that geometry.",
|
||||||
"The editor has no separate background box and keeps focus across parent window rerenders and pointer leave.",
|
"exactValueBounds adds explicit domain limits when a parameter must reject values beyond them.",
|
||||||
"Editor keyboard events do not bubble into enclosing Window shortcuts."
|
"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",
|
"id": "color-field",
|
||||||
@@ -113,11 +136,12 @@
|
|||||||
"domPackage": "@nodedc/ui-dom",
|
"domPackage": "@nodedc/ui-dom",
|
||||||
"domExports": ["createSelectController"],
|
"domExports": ["createSelectController"],
|
||||||
"summary": "Theme-independent selection control using the canonical dropdown layer.",
|
"summary": "Theme-independent selection control using the canonical dropdown layer.",
|
||||||
"variants": ["integrated", "split"],
|
"variants": ["integrated", "split", "inline"],
|
||||||
"behavior": ["controlled value", "optional search", "disabled options", "selected state", "portal menu"],
|
"behavior": ["controlled value", "optional search", "disabled options", "selected state", "portal menu"],
|
||||||
"rules": [
|
"rules": [
|
||||||
"Integrated is the single-pill Hub/Launcher form.",
|
"Integrated is the single-pill Hub/Launcher form.",
|
||||||
"Split is the Engine form: a separate value surface and a 46 px toggle separated by an 8 px gap.",
|
"Split is the Engine form: a separate value surface and a 46 px toggle separated by an 8 px gap.",
|
||||||
|
"Inline is a compact label-only toolbar trigger without a persistent surface or chevron; its menu and keyboard behavior remain canonical.",
|
||||||
"Integrated Select is forbidden inside Inspector; use InspectorSelectField so the visible label is above a full-width split control.",
|
"Integrated Select is forbidden inside Inspector; use InspectorSelectField so the visible label is above a full-width split control.",
|
||||||
"The portal menu preserves its source-family row geometry instead of inheriting card radii."
|
"The portal menu preserves its source-family row geometry instead of inheriting card radii."
|
||||||
]
|
]
|
||||||
@@ -163,6 +187,22 @@
|
|||||||
"summary": "Async-safe confirmation window for destructive and consequential operations.",
|
"summary": "Async-safe confirmation window for destructive and consequential operations.",
|
||||||
"behavior": ["pending state", "double-submit protection", "disabled close policy while pending"]
|
"behavior": ["pending state", "double-submit protection", "disabled close policy while pending"]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "split-pane",
|
||||||
|
"status": "baseline",
|
||||||
|
"package": "@nodedc/ui-react",
|
||||||
|
"exports": ["SplitPane", "SplitPaneProps", "SplitPaneOrientation"],
|
||||||
|
"domContract": ["nodedc-split-pane", "nodedc-split-pane__panel", "nodedc-split-pane__separator"],
|
||||||
|
"summary": "Controlled two-panel layout with a pointer- and keyboard-resizable accessible separator.",
|
||||||
|
"anatomy": ["primary panel", "structural separator", "secondary panel"],
|
||||||
|
"variants": ["vertical", "horizontal"],
|
||||||
|
"behavior": ["controlled percentage", "optional resizable separator", "pointer capture drag", "Arrow/Home/End keyboard sizing", "ARIA separator range", "ratio preservation across host resize"],
|
||||||
|
"rules": [
|
||||||
|
"The application owns the controlled primary percentage and panel content; the component owns separator interaction and accessibility.",
|
||||||
|
"Minimum primary and secondary percentages clamp both pointer and keyboard changes.",
|
||||||
|
"The separator is a necessary structural divider, never a decorative perimeter border."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "share-access-modal",
|
"id": "share-access-modal",
|
||||||
"status": "baseline",
|
"status": "baseline",
|
||||||
@@ -198,7 +238,11 @@
|
|||||||
"package": "@nodedc/ui-react",
|
"package": "@nodedc/ui-react",
|
||||||
"exports": ["SegmentedControl"],
|
"exports": ["SegmentedControl"],
|
||||||
"domContract": ["nodedc-segmented"],
|
"domContract": ["nodedc-segmented"],
|
||||||
"summary": "Pill navigation used in the shared top header and compact mode switches."
|
"summary": "Pill navigation used in the shared top header and compact mode switches.",
|
||||||
|
"sizes": ["default", "dense"],
|
||||||
|
"rules": [
|
||||||
|
"Dense is reserved for high-density in-viewer layer and mode toolbars; application headers retain default geometry."
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "app-header",
|
"id": "app-header",
|
||||||
@@ -369,9 +413,10 @@
|
|||||||
"domContract": ["nodedc-toast-viewport", "nodedc-toast", "nodedc-toast__icon", "nodedc-toast__copy"],
|
"domContract": ["nodedc-toast-viewport", "nodedc-toast", "nodedc-toast__icon", "nodedc-toast__copy"],
|
||||||
"summary": "Tasker-derived non-blocking status notification with a bottom-right glass stack.",
|
"summary": "Tasker-derived non-blocking status notification with a bottom-right glass stack.",
|
||||||
"variants": ["success", "error", "warning", "info", "loading"],
|
"variants": ["success", "error", "warning", "info", "loading"],
|
||||||
"behavior": ["controlled items", "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": [
|
"rules": [
|
||||||
"Applications own operation state and message copy; ToastStack owns viewport geometry and dismissal timing.",
|
"Applications own operation state and message copy; ToastStack owns viewport geometry and dismissal timing.",
|
||||||
|
"Adding or updating another notification never restarts the lifetime of an existing item; each item owns its timer by stable id.",
|
||||||
"Loading notifications remain until the operation updates or dismisses them.",
|
"Loading notifications remain until the operation updates or dismisses them.",
|
||||||
"Status notifications never replace a blocking confirmation modal."
|
"Status notifications never replace a blocking confirmation modal."
|
||||||
]
|
]
|
||||||
|
|||||||
+5
-3
@@ -6,6 +6,7 @@
|
|||||||
"defaultSize": 18,
|
"defaultSize": 18,
|
||||||
"supportedSizes": [16, 18, 20],
|
"supportedSizes": [16, 18, 20],
|
||||||
"strokeWidth": 1.8,
|
"strokeWidth": 1.8,
|
||||||
|
"filledNames": ["play", "stop"],
|
||||||
"groups": [
|
"groups": [
|
||||||
{
|
{
|
||||||
"id": "window-layer",
|
"id": "window-layer",
|
||||||
@@ -29,7 +30,7 @@
|
|||||||
"id": "state-access",
|
"id": "state-access",
|
||||||
"label": "Состояние и доступ",
|
"label": "Состояние и доступ",
|
||||||
"referenceSources": ["launcher", "seo", "task-manager"],
|
"referenceSources": ["launcher", "seo", "task-manager"],
|
||||||
"names": ["check", "alert", "activity", "lock", "key", "shield", "circle"]
|
"names": ["check", "alert", "activity", "lock", "key", "shield", "circle", "eye", "eye-off"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "entities",
|
"id": "entities",
|
||||||
@@ -41,13 +42,14 @@
|
|||||||
"id": "content",
|
"id": "content",
|
||||||
"label": "Контент",
|
"label": "Контент",
|
||||||
"referenceSources": ["seo", "bim-viewer", "engine"],
|
"referenceSources": ["seo", "bim-viewer", "engine"],
|
||||||
"names": ["image", "video", "file", "folder", "clipboard", "settings"]
|
"names": ["camera", "plan", "play", "stop", "image", "video", "file", "folder", "clipboard", "settings"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"rules": [
|
"rules": [
|
||||||
"Use semantic icon names from this registry instead of importing an application-local icon by shape.",
|
"Use semantic icon names from this registry instead of importing an application-local icon by shape.",
|
||||||
"Icons inherit currentColor; their button surface, active state and hit target are owned by Button, IconButton or the containing pattern.",
|
"Icons inherit currentColor; their button surface, active state and hit target are owned by Button, IconButton or the containing pattern.",
|
||||||
"Do not copy the complete Font Awesome or Lucide vendor catalog into application code.",
|
"Do not copy the complete Font Awesome or Lucide vendor catalog into application code.",
|
||||||
"A new icon requires a confirmed product use, a registry entry and a catalog specimen."
|
"A new icon requires a confirmed product use, a registry entry and a catalog specimen.",
|
||||||
|
"Playback icons play and stop are filled semantic glyphs; all other canonical glyphs remain outline unless their semantic contract says otherwise."
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 () => {
|
test("dropdown scroll keeps portal geometry stable and contained", async () => {
|
||||||
const [styles, dropdown] = await Promise.all([
|
const styles = await readFile(
|
||||||
readFile(new URL("../packages/ui-core/styles.css", import.meta.url), "utf8"),
|
new URL("../packages/ui-core/styles.css", import.meta.url),
|
||||||
readFile(new URL("../packages/ui-react/src/Dropdown.tsx", import.meta.url), "utf8"),
|
"utf8",
|
||||||
]);
|
);
|
||||||
const dropdownRule = styles.match(/\.nodedc-dropdown-surface \{(?<body>[\s\S]*?)\n\}/)?.groups?.body ?? "";
|
const dropdownRule = styles.match(/\.nodedc-dropdown-surface \{(?<body>[\s\S]*?)\n\}/)?.groups?.body ?? "";
|
||||||
|
|
||||||
assert.match(dropdownRule, /box-sizing:\s*border-box;/);
|
assert.match(dropdownRule, /box-sizing:\s*border-box;/);
|
||||||
assert.match(dropdownRule, /overflow:\s*auto;/);
|
assert.match(dropdownRule, /overflow:\s*auto;/);
|
||||||
assert.match(dropdownRule, /overscroll-behavior:\s*contain;/);
|
assert.match(dropdownRule, /overscroll-behavior:\s*contain;/);
|
||||||
assert.match(dropdown, /new ResizeObserver\(schedulePositionUpdate\)/);
|
|
||||||
assert.match(dropdown, /resizeObserver\.observe\(surface\)/);
|
|
||||||
assert.match(dropdown, /window\.requestAnimationFrame\(updatePosition\)/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("workspace windows keep their glass rim without masked compositor overlays", async () => {
|
test("workspace windows keep their glass rim without masked compositor overlays", async () => {
|
||||||
|
|||||||
@@ -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 () => {
|
test("Foundry Inspector screens consume the semantic selection field", async () => {
|
||||||
const [mapInspector, catalog, coreStyles] = await Promise.all([
|
const [mapPreview, catalog, coreStyles] = await Promise.all([
|
||||||
readFile(new URL("../apps/catalog/src/mapInspectorSections.tsx", import.meta.url), "utf8"),
|
readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../apps/catalog/src/CatalogApp.tsx", import.meta.url), "utf8"),
|
readFile(new URL("../apps/catalog/src/CatalogApp.tsx", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../packages/ui-core/styles.css", import.meta.url), "utf8"),
|
readFile(new URL("../packages/ui-core/styles.css", import.meta.url), "utf8"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.match(mapInspector, /<InspectorSelectField\s+label="Профиль покрытия"/);
|
assert.match(mapPreview, /<InspectorSelectField\s+label="Профиль покрытия"/);
|
||||||
assert.doesNotMatch(mapInspector, /<ControlRow label="Профиль покрытия">/);
|
assert.doesNotMatch(mapPreview, /<ControlRow label="Профиль покрытия">/);
|
||||||
assert.equal((catalog.match(/<InspectorSelectField/g) ?? []).length, 2);
|
assert.equal((catalog.match(/<InspectorSelectField/g) ?? []).length, 2);
|
||||||
assert.match(coreStyles, /\.nodedc-inspector \.nodedc-control-row:has\(\.nodedc-select-anchor\)/);
|
assert.match(coreStyles, /\.nodedc-inspector \.nodedc-control-row:has\(\.nodedc-select-anchor\)/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -315,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 () => {
|
test("renderer uses fixed ENU sectors, angular graticules and a non-blank double-buffer swap", async () => {
|
||||||
const [renderer, gridLayer] = await Promise.all([
|
const source = await readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8");
|
||||||
readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"),
|
assert.match(source, /class GridLayerController/);
|
||||||
readFile(new URL("../apps/catalog/src/mapCesiumGridLayer.ts", import.meta.url), "utf8"),
|
assert.match(source, /dataSourceDisplay\.ready/);
|
||||||
]);
|
assert.match(source, /fixedGridOrigin\(presentation\)/);
|
||||||
assert.match(gridLayer, /class GridLayerController/);
|
assert.match(source, /Transforms\.eastNorthUpToFixedFrame\(anchor\)/);
|
||||||
assert.match(gridLayer, /dataSourceDisplay\.ready/);
|
assert.match(source, /localSectorAt\(/);
|
||||||
assert.match(gridLayer, /fixedGridOrigin\(presentation\)/);
|
assert.match(source, /gridController\?\.pick\(worldPosition\)/);
|
||||||
assert.match(gridLayer, /Transforms\.eastNorthUpToFixedFrame\(anchor\)/);
|
assert.match(source, /lod\.stepKm \* 1_000/);
|
||||||
assert.match(gridLayer, /localSectorAt\(/);
|
assert.match(source, /lod\.graticuleStepDegrees/);
|
||||||
assert.match(renderer, /gridController\?\.pick\(worldPosition\)/);
|
assert.match(source, /lod\.graticuleLineWidthPx/);
|
||||||
assert.match(gridLayer, /lod\.stepKm \* 1_000/);
|
assert.match(source, /arcType: ArcType\.RHUMB/);
|
||||||
assert.match(gridLayer, /lod\.graticuleStepDegrees/);
|
assert.match(source, /clampToGround: false/);
|
||||||
assert.match(gridLayer, /lod\.graticuleLineWidthPx/);
|
assert.match(source, /majorLineWidthMultiplier/);
|
||||||
assert.match(gridLayer, /arcType: ArcType\.RHUMB/);
|
assert.match(source, /isLocalMajorLineIndex/);
|
||||||
assert.match(gridLayer, /clampToGround: false/);
|
assert.match(source, /isGraticuleMajorLineValue/);
|
||||||
assert.match(gridLayer, /majorLineWidthMultiplier/);
|
assert.match(source, /new LabelCollection\(\)/);
|
||||||
assert.match(gridLayer, /isLocalMajorLineIndex/);
|
assert.match(source, /const maximumLabels = 48/);
|
||||||
assert.match(gridLayer, /isGraticuleMajorLineValue/);
|
assert.match(source, /materializeLocalSelection/);
|
||||||
assert.match(gridLayer, /new LabelCollection\(\)/);
|
assert.match(source, /localVolumeAt\(/);
|
||||||
assert.match(gridLayer, /const maximumLabels = 48/);
|
assert.match(source, /focusGridSector/);
|
||||||
assert.match(gridLayer, /materializeLocalSelection/);
|
assert.match(source, /selectedGridSector/);
|
||||||
assert.match(gridLayer, /localVolumeAt\(/);
|
assert.match(source, /mountResources\(resources\)[\s\S]*?const previous = this\.current;[\s\S]*?removeResources\(previous\.resources\)/);
|
||||||
assert.match(renderer, /focusGridSector/);
|
assert.doesNotMatch(source, /function rebuildElevatedGrid[\s\S]*?entities\.removeAll\(\)/);
|
||||||
assert.match(renderer, /selectedGridSector/);
|
assert.doesNotMatch(source, /Math\.min\(40,[\s\S]*?safeRadiusKm \/ safeStepKm/);
|
||||||
assert.match(gridLayer, /mountResources\(resources\)[\s\S]*?const previous = this\.current;[\s\S]*?removeResources\(previous\.resources\)/);
|
assert.doesNotMatch(source, /snapGridCenter\(/);
|
||||||
assert.doesNotMatch(gridLayer, /function rebuildElevatedGrid[\s\S]*?entities\.removeAll\(\)/);
|
|
||||||
assert.doesNotMatch(gridLayer, /Math\.min\(40,[\s\S]*?safeRadiusKm \/ safeStepKm/);
|
|
||||||
assert.doesNotMatch(gridLayer, /snapGridCenter\(/);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,17 +3,17 @@ import { readFile } from "node:fs/promises";
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
test("Cesium elevated targets keep their profile border while labels occlude them inside the scene overlay", async () => {
|
test("Cesium elevated targets keep their profile border while labels occlude them inside the scene overlay", async () => {
|
||||||
const [runtimeLayers, cesiumDisplay] = await Promise.all([
|
const [renderer, cesiumDisplay] = await Promise.all([
|
||||||
readFile(new URL("../apps/catalog/src/mapCesiumRuntimeLayers.ts", import.meta.url), "utf8"),
|
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"),
|
readFile(new URL("../node_modules/@cesium/engine/Source/DataSources/DataSourceDisplay.js", import.meta.url), "utf8"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.match(runtimeLayers, /BillboardGraphics/);
|
assert.match(renderer, /BillboardGraphics/);
|
||||||
assert.match(runtimeLayers, /elevatedTargetImage\(\s*color,\s*Color\.fromCssColorString\(target\.outlineColor\)\.withAlpha\(target\.outlineOpacity\),\s*target\.outlineWidthPx,\s*target\.headSizePx/);
|
assert.match(renderer, /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(renderer, /entity\.point = undefined;\s*entity\.billboard = new BillboardGraphics/);
|
||||||
assert.match(runtimeLayers, /disableDepthTestDistance: Number\.POSITIVE_INFINITY/);
|
assert.match(renderer, /disableDepthTestDistance: Number\.POSITIVE_INFINITY/);
|
||||||
assert.match(runtimeLayers, /outlineWidth: 0,\s*style: LabelStyle\.FILL/);
|
assert.match(renderer, /outlineWidth: 0,\s*style: LabelStyle\.FILL/);
|
||||||
assert.doesNotMatch(runtimeLayers, /style: 2/);
|
assert.doesNotMatch(renderer, /style: 2/);
|
||||||
assert.ok(cesiumDisplay.indexOf("new BillboardVisualizer") < cesiumDisplay.indexOf("new LabelVisualizer"));
|
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 { readFile } from "node:fs/promises";
|
||||||
import test from "node:test";
|
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 () => {
|
assert.match(preview, /const toggleSubjectVisibility = \(bindingId: string\)/);
|
||||||
const [preview, toolbar, state] = await Promise.all([
|
assert.match(preview, /role=\{hasControls \? "menuitem" : "menuitemcheckbox"\}/);
|
||||||
source("../apps/catalog/src/MapFixturePreview.tsx"),
|
assert.match(preview, /if \(hasControls\) \{\s*openSubjectWindow\(summary\.bindingId\);\s*close\(\)/);
|
||||||
source("../apps/catalog/src/MapWorkspaceToolbar.tsx"),
|
assert.match(preview, /toggleSubjectVisibility\(summary\.bindingId\)/);
|
||||||
source("../apps/catalog/src/mapWorkspaceState.mjs"),
|
assert.match(preview, /visible: !state\.visible/);
|
||||||
]);
|
assert.match(preview, /слой скрыт · \$\{summary\.total\} объектов/);
|
||||||
|
assert.doesNotMatch(preview, /Фильтры и счётчики: \$\{summary\.displayName\}/);
|
||||||
assert.match(toolbar, /role=\{hasControls \? "menuitem" : "menuitemcheckbox"\}/);
|
|
||||||
assert.match(toolbar, /onOpenSubjectWindow\(summary\.bindingId\)/);
|
|
||||||
assert.match(toolbar, /onToggleSubjectVisibility\(summary\.bindingId\)/);
|
|
||||||
assert.match(toolbar, /слой скрыт · \$\{summary\.total\} объектов/);
|
|
||||||
assert.match(preview, /type: "toggle-subject-visibility", bindingId/);
|
|
||||||
assert.match(state, /visible: !subject\.visible/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a subject window exists only when the MCP presentation profile exposes controls", async () => {
|
test("a subject window exists only when the presentation profile exposes controls", async () => {
|
||||||
const [windows, model, state] = await Promise.all([
|
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
||||||
source("../apps/catalog/src/MapSubjectWorkspaceWindows.tsx"),
|
|
||||||
source("../apps/catalog/src/mapWorkspaceModel.mjs"),
|
|
||||||
source("../apps/catalog/src/mapWorkspaceState.mjs"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert.match(model, /export function mapProfileHasSubjectWindowControls/);
|
assert.match(preview, /function hasSubjectWindowControls\(profile: MapPresentationProfile\)/);
|
||||||
assert.match(windows, /!state\?\.window\.open \|\| !mapProfileHasSubjectWindowControls\(summary\.profile\)/);
|
assert.match(preview, /if \(!state\?\.window\.open \|\| !hasSubjectWindowControls\(summary\.profile\)\) return null/);
|
||||||
assert.match(windows, /type: "close-subject-window"/);
|
assert.match(preview, /window: \{ \.\.\.state\.window, open: false \}/);
|
||||||
assert.match(state, /window: \{ \.\.\.subject\.window, open: false \}/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("hidden and visible layers have an explicit visual state", async () => {
|
test("hidden and visible layers have an explicit visual state", async () => {
|
||||||
const styles = await 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\[data-visible\]/);
|
||||||
assert.match(styles, /\.catalog-map-fixture__objects-menu-item:not\(\[data-visible\]\)/);
|
assert.match(styles, /\.catalog-map-fixture__objects-menu-item:not\(\[data-visible\]\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("facet controls and renderer consume one canonical enabled-value state", async () => {
|
test("facet controls and renderer consume the same canonical enabled-value state", async () => {
|
||||||
const [preview, windows, state] = await Promise.all([
|
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
||||||
source("../apps/catalog/src/MapFixturePreview.tsx"),
|
|
||||||
source("../apps/catalog/src/MapSubjectWorkspaceWindows.tsx"),
|
|
||||||
source("../apps/catalog/src/mapWorkspaceState.mjs"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert.match(preview, /normalizeMapPresentationFacetSelections\(state\.filters, profile\)/);
|
assert.match(preview, /initialSubjectState\(initialLayout\?\.dataProductBindings[\s\S]*?presentationProfiles\)/);
|
||||||
assert.match(preview, /presentationFilters=\{rendererPresentationFilters\}/);
|
assert.match(preview, /facets: profile \? normalizeMapPresentationFacetSelections\(state\.filters, profile\) : state\.filters/);
|
||||||
assert.match(windows, /mapPresentationFacetValueIsEnabled\(state\.filters, facet\.field, item\.value\)/);
|
assert.match(preview, /filters: normalizeMapPresentationFacetSelections\(state\.filters, summary\.profile\)/);
|
||||||
assert.match(windows, /facet\.values\.map\(\(value\) => value\.value\)/);
|
assert.match(preview, /const active = mapPresentationFacetValueIsEnabled\(state\.filters, facet\.field, item\.value\)/);
|
||||||
assert.match(state, /toggleMapPresentationFacetSelection\(/);
|
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 () => {
|
test("joined detail aspects do not become independent map layers", async () => {
|
||||||
const [preview, model] = await Promise.all([
|
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
||||||
source("../apps/catalog/src/MapFixturePreview.tsx"),
|
|
||||||
source("../apps/catalog/src/mapWorkspaceModel.mjs"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert.match(model, /filter\(\(binding\) => !binding\.joinToBindingId\)/);
|
assert.match(preview, /dataProductBindings\.filter\(\(binding\) => !binding\.joinToBindingId\)/);
|
||||||
assert.match(model, /primaryMapBindingConfigs\(bindingConfigs\)/);
|
assert.match(preview, /runtimeBindings\.filter\(\(binding\) => primaryBindingIds\.has\(binding\.bindingId\)\)/);
|
||||||
assert.match(preview, /runtimeBindings=\{\[\.\.\.sectorScopedPrimaryRuntimeBindings, \.\.\.referenceRuntimeBindings\]\}/);
|
assert.match(preview, /runtimeBindings=\{\[\.\.\.sectorScopedPrimaryRuntimeBindings, \.\.\.referenceRuntimeBindings\]\}/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("bounded map windows share one reducer-owned z-stack while settings and layers keep canonical surfaces", async () => {
|
test("bounded map windows keep one stack while layers and settings use their canonical surfaces", async () => {
|
||||||
const [preview, toolbar, windows, sectorWindow, state, contract] = await Promise.all([
|
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
||||||
source("../apps/catalog/src/MapFixturePreview.tsx"),
|
|
||||||
source("../apps/catalog/src/MapWorkspaceToolbar.tsx"),
|
|
||||||
source("../apps/catalog/src/MapSubjectWorkspaceWindows.tsx"),
|
|
||||||
source("../apps/catalog/src/MapSectorWorkspaceWindow.tsx"),
|
|
||||||
source("../apps/catalog/src/mapWorkspaceState.mjs"),
|
|
||||||
source("../apps/catalog/src/mapPageContract.ts"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert.match(contract, /type MapWorkspaceWindowId = "sector" \| "subject-card" \| `binding:\$\{string\}`/);
|
assert.match(preview, /type MapWorkspaceWindowId = "sector" \| "subject-card" \| `binding:\$\{string\}`/);
|
||||||
assert.match(state, /function activateWindow\(state, windowId\)/);
|
assert.match(preview, /const activateWorkspaceWindow = useCallback/);
|
||||||
assert.match(state, /Math\.max\([\s\S]*state\.sector\.window\.zIndex[\s\S]*state\.subjectCard\.zIndex/);
|
assert.match(preview, /onActivate=\{\(\) => activateWorkspaceWindow\(`binding:\$\{summary\.bindingId\}`\)\}/);
|
||||||
assert.match(windows, /type: "activate-window", windowId: `binding:\$\{summary\.bindingId\}`/);
|
assert.doesNotMatch(preview, /onActivate=\{\(\) => openSubjectWindow\(summary\.bindingId\)\}/);
|
||||||
assert.match(sectorWindow, /onActivate=\{onActivate\}/);
|
assert.match(preview, /sectorWindowZIndex[\s\S]*subjectCardZIndex/);
|
||||||
assert.match(preview, /<ApplicationSidePanel[\s\S]*title="Настройки карты"[\s\S]*onClose=\{closeSettingsPanel\}/);
|
assert.match(preview, /<ApplicationSidePanel[\s\S]*title="Настройки карты"[\s\S]*onClose=\{closeSettingsPanel\}/);
|
||||||
assert.match(preview, /createPortal\(headerActions, headerActionsHost\)/);
|
assert.match(preview, /createPortal\(headerActions, headerActionsHost\)/);
|
||||||
assert.match(toolbar, /surfaceClassName="catalog-map-fixture__objects-menu catalog-map-fixture__layers-menu nodedc-map-glass"/);
|
assert.doesNotMatch(preview, /settingsPanelPinned|onPinnedChange/);
|
||||||
assert.match(toolbar, /onOpenChange=\{onOpenChange\}/);
|
assert.match(preview, /surfaceClassName="catalog-map-fixture__objects-menu catalog-map-fixture__layers-menu nodedc-map-glass"/);
|
||||||
assert.doesNotMatch(preview, /settingsPanelPinned|settingsWindowZIndex|layersWindowZIndex/);
|
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 () => {
|
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 pointBranch = renderer.match(/if \(pickedId instanceof Entity[\s\S]*?return;\s*\}/)?.[0] ?? "";
|
||||||
const zoneBranch = renderer.match(/if \(\s*pickedId[\s\S]*?HGeoZonePickId\)\.entityId\);\s*return;\s*\}/)?.[0] ?? "";
|
const zoneBranch = renderer.match(/if \(\s*pickedId[\s\S]*?HGeoZonePickId\)\.entityId\);\s*return;\s*\}/)?.[0] ?? "";
|
||||||
|
|
||||||
@@ -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\)/);
|
assert.match(renderer, /gridController\?\.setSelection\(gridSelection\);\s*onGridSectorSelectRef\.current\?\.\(gridSelection\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("active sector scope is geometric, filterable and reducer-deactivated", async () => {
|
test("an active sector scopes point facts, exposes data facets and deactivates on close", async () => {
|
||||||
const [runtime, sectorWindow, state] = await Promise.all([
|
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
||||||
source("../apps/catalog/src/mapSectorRuntime.mjs"),
|
|
||||||
source("../apps/catalog/src/MapSectorWorkspaceWindow.tsx"),
|
|
||||||
source("../apps/catalog/src/mapWorkspaceState.mjs"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert.match(runtime, /export function mapFactInsideGridSector/);
|
assert.match(preview, /function mapFactInsideGridSector/);
|
||||||
assert.match(runtime, /localSectorAtGeodetic/);
|
assert.match(preview, /localSectorAtGeodetic/);
|
||||||
assert.match(sectorWindow, /label="Скрыть объекты за сектором"/);
|
assert.match(preview, /label="Скрыть объекты за сектором"/);
|
||||||
assert.match(sectorWindow, /title="Домены данных"/);
|
assert.match(preview, />Домены данных</);
|
||||||
assert.match(sectorWindow, /title="Провайдеры"/);
|
assert.match(preview, />Провайдеры</);
|
||||||
assert.match(sectorWindow, /title="Типы объектов"/);
|
assert.match(preview, />Типы объектов</);
|
||||||
assert.match(sectorWindow, /id="map-sector-objects-title">Объекты сектора/);
|
assert.match(preview, />Объекты сектора</);
|
||||||
assert.match(sectorWindow, /onClose=\{onDeactivate\}/);
|
assert.match(preview, /onClose=\{deactivateGridSector\}/);
|
||||||
assert.match(sectorWindow, />\s*Деактивировать сектор\s*</);
|
assert.match(preview, />\s*Деактивировать сектор\s*</);
|
||||||
assert.match(state, /case "deactivate-sector"/);
|
assert.match(preview, /setSelectedGridSector\(null\)/);
|
||||||
assert.match(state, /selectedSector: null/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("reference stations remain first-class visibility layers without provider settings actions", async () => {
|
test("reference stations are first-class Objects menu layers without provider settings actions", async () => {
|
||||||
const [preview, toolbar] = await Promise.all([
|
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
||||||
source("../apps/catalog/src/MapFixturePreview.tsx"),
|
|
||||||
source("../apps/catalog/src/MapWorkspaceToolbar.tsx"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert.match(preview, /const referenceObjectSummaries = useMemo/);
|
assert.match(preview, /const referenceObjectSummaries = useMemo/);
|
||||||
assert.match(toolbar, /const objectLayerCount = summaries\.length \+ referenceSummaries\.length/);
|
assert.match(preview, /const objectLayerCount = presentationSummaries\.length \+ referenceObjectSummaries\.length/);
|
||||||
assert.match(toolbar, /referenceSummaries\.map\(\(\{ layer, displayName, total \}\)/);
|
assert.match(preview, /referenceObjectSummaries\.map\(\(\{ layer, displayName, total \}\)/);
|
||||||
assert.match(toolbar, /onToggleReferenceLayer\(layer\.id\)/);
|
assert.match(preview, /candidate\.id === layer\.id \? \{ \.\.\.candidate, visible: !candidate\.visible \} : candidate/);
|
||||||
assert.match(toolbar, /role="menuitemcheckbox"/);
|
assert.match(preview, /role="menuitemcheckbox"/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("facet windows auto-fit expanded and collapsed content inside the map workspace", async () => {
|
test("facet window auto-fits expanded and collapsed content inside map workspace", async () => {
|
||||||
const [windows, workspace] = await Promise.all([
|
const [preview, workspace] = await Promise.all([
|
||||||
source("../apps/catalog/src/MapSubjectWorkspaceWindows.tsx"),
|
readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"),
|
||||||
source("../packages/ui-react/src/WorkspaceWindow.tsx"),
|
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, /autoHeight\?: boolean/);
|
||||||
assert.match(workspace, /content\.scrollHeight/);
|
assert.match(workspace, /content\.scrollHeight/);
|
||||||
assert.match(workspace, /availableHeight = Math\.max\(0, nextBounds\.height - normalized\.y\)/);
|
assert.match(workspace, /availableHeight = Math\.max\(0, nextBounds\.height - normalized\.y\)/);
|
||||||
assert.match(workspace, /new ResizeObserver\(scheduleFit\)/);
|
assert.match(workspace, /new ResizeObserver\(scheduleFit\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("facet and search selection focus the subject while the reducer preserves a valid detail tab", async () => {
|
test("facet tree selection focuses the subject without resetting a compatible detail tab", async () => {
|
||||||
const [preview, renderer, state] = await Promise.all([
|
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
||||||
source("../apps/catalog/src/MapFixturePreview.tsx"),
|
const renderer = await readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8");
|
||||||
source("../apps/catalog/src/CesiumMapRenderer.tsx"),
|
|
||||||
source("../apps/catalog/src/mapWorkspaceState.mjs"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
|
assert.match(preview, /const \[expandedFacetRows, setExpandedFacetRows\]/);
|
||||||
assert.match(preview, /const handleSelectAndFocus = useCallback/);
|
assert.match(preview, /const handleSelectAndFocus = useCallback/);
|
||||||
|
assert.match(preview, /const focusSubject = useCallback/);
|
||||||
assert.match(preview, /if \(renderer\.focusRuntimeEntity\(entityId\)\) return true/);
|
assert.match(preview, /if \(renderer\.focusRuntimeEntity\(entityId\)\) return true/);
|
||||||
|
assert.match(preview, /mapFactPointCoordinates\(selectable\.find/);
|
||||||
assert.match(preview, /renderer\.focusSubjectCoordinates\(fallbackCoordinates\[0\], fallbackCoordinates\[1\]\)/);
|
assert.match(preview, /renderer\.focusSubjectCoordinates\(fallbackCoordinates\[0\], fallbackCoordinates\[1\]\)/);
|
||||||
|
assert.match(preview, /focusSubject\(entityId\)/);
|
||||||
assert.match(preview, /focusSubject\(result\.entityId, result\.coordinates\)/);
|
assert.match(preview, /focusSubject\(result\.entityId, result\.coordinates\)/);
|
||||||
assert.match(preview, /validTabIds: profile\?\.tabs\.map\(\(tab\) => tab\.id\) \?\? \["overview"\]/);
|
assert.match(preview, /setSubjectCardTabId\(\(current\) =>/);
|
||||||
assert.match(state, /action\.validTabIds\.includes\(state\.subjectCard\.tabId\)/);
|
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, /void viewer\.flyTo\(entity, \{/);
|
||||||
assert.match(renderer, /const focusSubjectCoordinates = useCallback/);
|
assert.match(renderer, /const focusSubjectCoordinates = useCallback/);
|
||||||
assert.match(renderer, /viewer\.camera\.flyToBoundingSphere\(new BoundingSphere\(target, 1\), \{/);
|
assert.match(renderer, /viewer\.camera\.flyToBoundingSphere\(new BoundingSphere\(target, 1\), \{/);
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
import ts from "typescript";
|
||||||
|
|
||||||
|
const source = await readFile(new URL("../apps/catalog/src/mapPresentationProfile.ts", import.meta.url), "utf8");
|
||||||
|
const transpiled = ts.transpileModule(source, {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
|
||||||
|
}).outputText;
|
||||||
|
const moduleUrl = `data:text/javascript;base64,${Buffer.from(transpiled).toString("base64")}`;
|
||||||
const {
|
const {
|
||||||
mapFactMatchesFilters,
|
mapFactMatchesFilters,
|
||||||
mapPresentationBindingIsAll,
|
mapPresentationBindingIsAll,
|
||||||
@@ -7,7 +15,7 @@ const {
|
|||||||
mapPresentationFacetValueIsEnabled,
|
mapPresentationFacetValueIsEnabled,
|
||||||
normalizeMapPresentationFacetSelections,
|
normalizeMapPresentationFacetSelections,
|
||||||
toggleMapPresentationFacetSelection,
|
toggleMapPresentationFacetSelection,
|
||||||
} = await import("../apps/catalog/src/mapPresentationProfile.mjs");
|
} = await import(moduleUrl);
|
||||||
|
|
||||||
const profile = {
|
const profile = {
|
||||||
id: "map.moving-object.operational.default",
|
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 () => {
|
test("existing layouts upgrade reference layers on read and renderer accepts their slot", async () => {
|
||||||
const server = await readFile(new URL("server/catalog-server.mjs", root), "utf8");
|
const server = await readFile(new URL("server/catalog-server.mjs", root), "utf8");
|
||||||
const 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");
|
const runtime = await readFile(new URL("apps/catalog/src/useMapReferenceRuntime.ts", root), "utf8");
|
||||||
|
|
||||||
assert.match(server, /pageLayoutMatch\[1\] === "map" \? validateMapPageLayout\(stored\) : stored/);
|
assert.match(server, /pageLayoutMatch\[1\] === "map" \? validateMapPageLayout\(stored\) : stored/);
|
||||||
assert.match(server, /referenceLayers: structuredClone\(canonicalMapReferenceLayers\)/);
|
assert.match(server, /referenceLayers: structuredClone\(canonicalMapReferenceLayers\)/);
|
||||||
assert.match(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, /\/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, /allowedAttributes = new Set\(\["name", "category", "network", "operator", "official_name", "local_name", "uic_ref", "wheelchair"\]\)/);
|
||||||
assert.match(runtime, /alternate_names/);
|
|
||||||
assert.doesNotMatch(runtime, /credential|accessToken|providerEndpoint|rawPayload/);
|
assert.doesNotMatch(runtime, /credential|accessToken|providerEndpoint|rawPayload/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ test("reference subjects remain searchable by profile labels without a provider
|
|||||||
id: "map.reference.station.v1",
|
id: "map.reference.station.v1",
|
||||||
title: "Метро",
|
title: "Метро",
|
||||||
semanticTypes: ["map.station"],
|
semanticTypes: ["map.station"],
|
||||||
label: { mode: "attributes", fields: ["name", "official_name", "alternate_names"] },
|
label: { mode: "attributes", fields: ["name", "official_name"] },
|
||||||
};
|
};
|
||||||
const index = buildMapSearchIndex({
|
const index = buildMapSearchIndex({
|
||||||
runtimeBindings: [{
|
runtimeBindings: [{
|
||||||
@@ -93,7 +93,6 @@ test("reference subjects remain searchable by profile labels without a provider
|
|||||||
presentationProfileId: stationProfile.id,
|
presentationProfileId: stationProfile.id,
|
||||||
facts: [point("osm.node.1", "map.station", {
|
facts: [point("osm.node.1", "map.station", {
|
||||||
name: "Петроградская",
|
name: "Петроградская",
|
||||||
alternate_names: ["Petrogradskaya station"],
|
|
||||||
provider_note: "not searchable",
|
provider_note: "not searchable",
|
||||||
})],
|
})],
|
||||||
}],
|
}],
|
||||||
@@ -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, "петрог").at(0)?.title, "Петроградская");
|
||||||
assert.equal(searchMapSubjects(index, "Petrogradskaya").at(0)?.sourceId, "osm.node.1");
|
|
||||||
assert.equal(searchMapSubjects(index, "osm.node.1").at(0)?.groupTitle, "Метро");
|
assert.equal(searchMapSubjects(index, "osm.node.1").at(0)?.groupTitle, "Метро");
|
||||||
assert.deepEqual(searchMapSubjects(index, "provider_note"), []);
|
assert.deepEqual(searchMapSubjects(index, "provider_note"), []);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -275,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 () => {
|
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/CesiumMapRenderer.tsx", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"),
|
readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../apps/catalog/src/MapSubjectWorkspaceWindows.tsx", import.meta.url), "utf8"),
|
|
||||||
readFile(new URL("../apps/catalog/src/mapWorkspaceState.mjs", import.meta.url), "utf8"),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.match(renderer, /viewer\?\.scene\.pick/);
|
assert.match(renderer, /viewer\?\.scene\.pick/);
|
||||||
assert.match(renderer, /pickedId instanceof Entity/);
|
assert.match(renderer, /pickedId instanceof Entity/);
|
||||||
assert.match(renderer, /onSelectRef\.current\?\.\(pickedId\.id\)/);
|
assert.match(renderer, /onSelectRef\.current\?\.\(pickedId\.id\)/);
|
||||||
assert.match(preview, /const handleSelect = useCallback/);
|
assert.match(preview, /const handleSelect = useCallback/);
|
||||||
assert.match(preview, /type: "select-entity"/);
|
assert.match(preview, /setSubjectCardOpen\(true\)/);
|
||||||
assert.match(state, /subjectCard: \{ \.\.\.state\.subjectCard, open: true, tabId \}/);
|
assert.match(preview, /title=\{selectedSubjectCard\.title\}/);
|
||||||
assert.match(windows, /title=\{selectedSubjectCard\.title\}/);
|
assert.match(preview, /Карточка объекта:/);
|
||||||
assert.match(windows, /Карточка объекта:/);
|
assert.match(preview, /SegmentedControl/);
|
||||||
assert.match(windows, /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"]);
|
|
||||||
});
|
|
||||||
@@ -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"),
|
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 === "Enter"/);
|
||||||
assert.match(component, /event\.key === "Escape"/);
|
assert.match(component, /event\.key === "Escape"/);
|
||||||
assert.match(component, /event\.stopPropagation\(\)/);
|
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 input\[type="range"\][\s\S]*?z-index: 4/);
|
||||||
assert.match(styles, /\.nodedc-range__editor[\s\S]*?z-index: 5[\s\S]*?background: transparent[\s\S]*?color: transparent[\s\S]*?caret-color: transparent[\s\S]*?box-shadow: none/);
|
assert.match(styles, /\.nodedc-range__editor[\s\S]*?z-index: 5[\s\S]*?background: transparent[\s\S]*?color: transparent[\s\S]*?caret-color: transparent[\s\S]*?box-shadow: none/);
|
||||||
assert.match(styles, /\.nodedc-range__editor\[data-active\][\s\S]*?caret-color: currentColor/);
|
assert.match(styles, /\.nodedc-range__editor\[data-active\][\s\S]*?caret-color: currentColor/);
|
||||||
|
assert.match(styles, /\.nodedc-range__editor\[data-active\]\[data-contrast="fill"\][\s\S]*?rgb\(var\(--nodedc-on-accent-rgb\)\)/);
|
||||||
assert.match(windowComponent, /const onCloseRef = useRef\(onClose\)/);
|
assert.match(windowComponent, /const onCloseRef = useRef\(onClose\)/);
|
||||||
assert.match(windowComponent, /onCloseRef\.current = onClose/);
|
assert.match(windowComponent, /onCloseRef\.current = onClose/);
|
||||||
assert.match(windowComponent, /onCloseRef\.current\(\)/);
|
assert.match(windowComponent, /onCloseRef\.current\(\)/);
|
||||||
|
|||||||
@@ -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