Compare commits

71 changed files with 6527 additions and 5307 deletions
+6 -72
View File
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties,
import { applyGlassMaterial, applyNodedcTheme, defaultGlassMaterial, type GlassMaterialSettings, type NodedcTheme, type RgbTuple } from "@nodedc/ui-core"; import { applyGlassMaterial, applyNodedcTheme, defaultGlassMaterial, type GlassMaterialSettings, type NodedcTheme, type RgbTuple } from "@nodedc/ui-core";
import { createTemplateFeatures, getPageTemplate, pageTemplates, type PageTemplateDefinition } from "@nodedc/page-patterns"; import { createTemplateFeatures, getPageTemplate, pageTemplates, type PageTemplateDefinition } from "@nodedc/page-patterns";
import { import {
ActivityIndicator,
AdminNavigationPanel, AdminNavigationPanel,
AppHeader, AppHeader,
ApplicationPanel, ApplicationPanel,
@@ -33,7 +32,6 @@ import {
Select, Select,
ShareAccessModal, ShareAccessModal,
ShareLinkModal, ShareLinkModal,
SplitPane,
SortableItem, SortableItem,
SortableScope, SortableScope,
SettingsCard, SettingsCard,
@@ -67,7 +65,8 @@ import {
type DesignProfileSummary, type DesignProfileSummary,
type DesignProfileStatus, type DesignProfileStatus,
} from "./applicationManifest.js"; } from "./applicationManifest.js";
import { createDefaultMapPageLayout, MapFixturePreview, type MapFixturePreviewHandle, type MapPageLayout } from "./MapFixturePreview.js"; import { MapFixturePreview } from "./MapFixturePreview.js";
import { createDefaultMapPageLayout, type MapFixturePreviewHandle, type MapPageLayout } from "./mapPageContract.js";
import { import {
mapDesignFragmentForLayout, mapDesignFragmentForLayout,
mapDesignFragmentFromLayout, mapDesignFragmentFromLayout,
@@ -210,7 +209,7 @@ const iconGroups: Array<{ title: string; note: string; icons: IconName[] }> = [
{ {
title: "Состояние и доступ", title: "Состояние и доступ",
note: "Вся платформа", note: "Вся платформа",
icons: ["check", "alert", "activity", "lock", "key", "shield", "circle", "eye", "eye-off"], icons: ["check", "alert", "activity", "lock", "key", "shield", "circle"],
}, },
{ {
title: "Сущности", title: "Сущности",
@@ -220,7 +219,7 @@ const iconGroups: Array<{ title: string; note: string; icons: IconName[] }> = [
{ {
title: "Контент", title: "Контент",
note: "SEO / BIM / Engine", note: "SEO / BIM / Engine",
icons: ["camera", "plan", "play", "stop", "image", "video", "file", "folder", "clipboard", "settings"], icons: ["image", "video", "file", "folder", "clipboard", "settings"],
}, },
]; ];
@@ -229,7 +228,6 @@ const iconLabels: Record<IconName, string> = {
alert: "Предупреждение", alert: "Предупреждение",
apps: "Приложения", apps: "Приложения",
building: "Компания", building: "Компания",
camera: "Камера",
check: "Готово", check: "Готово",
"chevron-down": "Раскрыть", "chevron-down": "Раскрыть",
"chevron-left": "Назад", "chevron-left": "Назад",
@@ -241,8 +239,6 @@ const iconLabels: Record<IconName, string> = {
database: "База данных", database: "База данных",
download: "Скачать", download: "Скачать",
edit: "Редактировать", edit: "Редактировать",
eye: "Показать",
"eye-off": "Скрыть",
expand: "Развернуть", expand: "Развернуть",
external: "Открыть снаружи", external: "Открыть снаружи",
file: "Файл", file: "Файл",
@@ -258,8 +254,6 @@ const iconLabels: Record<IconName, string> = {
minimize: "Свернуть", minimize: "Свернуть",
network: "Связи", network: "Связи",
panel: "Панель", panel: "Панель",
plan: "План",
play: "Воспроизвести",
target: "Таргеты", target: "Таргеты",
plus: "Добавить", plus: "Добавить",
profile: "Профиль", profile: "Профиль",
@@ -269,7 +263,6 @@ const iconLabels: Record<IconName, string> = {
settings: "Настройки", settings: "Настройки",
shield: "Доступ", shield: "Доступ",
sliders: "Параметры", sliders: "Параметры",
stop: "Остановить",
trash: "Удалить", trash: "Удалить",
upload: "Загрузить", upload: "Загрузить",
users: "Участники", users: "Участники",
@@ -367,7 +360,6 @@ export function CatalogApp() {
const [workspaceWindowDemoOpen, setWorkspaceWindowDemoOpen] = useState(true); const [workspaceWindowDemoOpen, setWorkspaceWindowDemoOpen] = useState(true);
const [workspaceWindowDemoMaximized, setWorkspaceWindowDemoMaximized] = useState(false); const [workspaceWindowDemoMaximized, setWorkspaceWindowDemoMaximized] = useState(false);
const [workspaceWindowDemoRect, setWorkspaceWindowDemoRect] = useState<WorkspaceWindowRect>({ x: 24, y: 24, width: 340, height: 230 }); const [workspaceWindowDemoRect, setWorkspaceWindowDemoRect] = useState<WorkspaceWindowRect>({ x: 24, y: 24, width: 340, height: 230 });
const [splitPaneDemoSize, setSplitPaneDemoSize] = useState(50);
const [sidePanelDemoOpen, setSidePanelDemoOpen] = useState(true); const [sidePanelDemoOpen, setSidePanelDemoOpen] = useState(true);
const [createModalOpen, setCreateModalOpen] = useState(false); const [createModalOpen, setCreateModalOpen] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false);
@@ -1287,7 +1279,7 @@ export function CatalogApp() {
<> <>
<ControlRow label="Цвет"><ColorField value={lightColor} onChange={setLightColor} /></ControlRow> <ControlRow label="Цвет"><ColorField value={lightColor} onChange={setLightColor} /></ControlRow>
<RangeControl label="Яркость" value={brightness} min={0} max={100} formatValue={(value) => `${value}%`} onChange={setBrightness} /> <RangeControl label="Яркость" value={brightness} min={0} max={100} formatValue={(value) => `${value}%`} onChange={setBrightness} />
<RangeControl label="Дистанция свечения" value={glowDistance} min={0} max={200} exactValueBounds={{ min: 0 }} formatValue={(value) => `${value}%`} onChange={setGlowDistance} /> <RangeControl label="Дистанция свечения" value={glowDistance} min={0} max={200} formatValue={(value) => `${value}%`} onChange={setGlowDistance} />
</> </>
), ),
}, },
@@ -1333,7 +1325,7 @@ export function CatalogApp() {
case "controls": case "controls":
return ( return (
<div className="catalog-grid"> <div className="catalog-grid">
<Preview title="Selection" note="integrated / split / inline"> <Preview title="Selection" note="integrated / split">
<div className="catalog-form"> <div className="catalog-form">
<FieldFrame label="Hub / integrated select"> <FieldFrame label="Hub / integrated select">
<Select label="Статус" value={selectedStatus} options={[...selectOptions]} searchable onChange={setSelectedStatus} /> <Select label="Статус" value={selectedStatus} options={[...selectOptions]} searchable onChange={setSelectedStatus} />
@@ -1351,19 +1343,6 @@ export function CatalogApp() {
onChange={setConnectionType} onChange={setConnectionType}
/> />
</FieldFrame> </FieldFrame>
<FieldFrame label="Toolbar / inline select">
<Select
variant="inline"
label="Скорость воспроизведения"
value="1"
options={[
{ value: "0.5", label: "0,5×" },
{ value: "1", label: "1×" },
{ value: "2", label: "2×" },
]}
onChange={() => undefined}
/>
</FieldFrame>
<Dropdown <Dropdown
trigger={({ open, toggle, setTriggerRef, surfaceId }) => ( trigger={({ open, toggle, setTriggerRef, surfaceId }) => (
<Button ref={setTriggerRef} aria-expanded={open} aria-controls={surfaceId} icon={<Icon name="chevron-down" />} onClick={toggle}>Действия</Button> <Button ref={setTriggerRef} aria-expanded={open} aria-controls={surfaceId} icon={<Icon name="chevron-down" />} onClick={toggle}>Действия</Button>
@@ -1394,20 +1373,6 @@ export function CatalogApp() {
<IconButton label="Добавить"><Icon name="plus" /></IconButton> <IconButton label="Добавить"><Icon name="plus" /></IconButton>
</div> </div>
</Preview> </Preview>
<Preview title="Активность" note="default / compact" className="catalog-preview--compact">
<div className="catalog-inline catalog-inline--wrap">
<span className="catalog-inline">
<ActivityIndicator label="Загружаем данные" />
<span>Загружаем данные</span>
</span>
<Button
aria-busy="true"
disabled
icon={<ActivityIndicator size="compact" />}
>Подключаем…</Button>
</div>
<p className="catalog-preview__explanation">При reduced motion кольцо остаётся видимым без вращения; процесс и его завершение принадлежат приложению.</p>
</Preview>
<Preview title="Оконные действия" note="круг `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>
@@ -1416,21 +1381,6 @@ export function CatalogApp() {
<IconButton label="Закрыть"><Icon name="close" /></IconButton> <IconButton label="Закрыть"><Icon name="close" /></IconButton>
</div> </div>
</Preview> </Preview>
<Preview title="Вертикальная рейка" note="glass pill / круг `46 px`" className="catalog-preview--compact">
<GlassSurface
className="catalog-icon-rail-demo"
tone="strong"
radius="pill"
padding="sm"
materialRim={false}
role="toolbar"
aria-label="Режимы просмотра"
>
<IconButton label="Камера" aria-pressed="true"><Icon name="camera" /></IconButton>
<IconButton label="3D"><span aria-hidden="true">3D</span></IconButton>
<IconButton label="План"><Icon name="plan" /></IconButton>
</GlassSurface>
</Preview>
<Preview title="Workspace window" note="inline / bounded / controlled" className="catalog-preview--wide catalog-workspace-window-preview"> <Preview title="Workspace window" note="inline / bounded / controlled" className="catalog-preview--wide catalog-workspace-window-preview">
<div ref={workspaceWindowDemoRef} className="catalog-workspace-window-demo"> <div ref={workspaceWindowDemoRef} className="catalog-workspace-window-demo">
{workspaceWindowDemoOpen ? ( {workspaceWindowDemoOpen ? (
@@ -1469,20 +1419,6 @@ export function CatalogApp() {
</div> </div>
<p className="catalog-preview__explanation">Rectangle, maximize, close и stacking остаются состоянием приложения; дизайн-система владеет одинаковой bounded-геометрией и доступным управлением.</p> <p className="catalog-preview__explanation">Rectangle, maximize, close и stacking остаются состоянием приложения; дизайн-система владеет одинаковой bounded-геометрией и доступным управлением.</p>
</Preview> </Preview>
<Preview title="Split pane" note="pointer / keyboard / controlled" className="catalog-preview--wide catalog-split-pane-preview">
<div className="catalog-split-pane-demo">
<SplitPane
primarySize={splitPaneDemoSize}
onPrimarySizeChange={setSplitPaneDemoSize}
minPrimarySize={25}
minSecondarySize={25}
separatorLabel="Изменить ширину синхронных представлений"
primary={<div className="catalog-split-pane-demo__panel"><Icon name="video" /><strong>Видео</strong></div>}
secondary={<div className="catalog-split-pane-demo__panel"><Icon name="grid" /><strong>Пространственная сцена</strong></div>}
/>
</div>
<p className="catalog-preview__explanation">Граница изменяет контролируемую долю панелей; клавиши и pointer используют одни ограничения, а resize контейнера не сбрасывает выбранное соотношение.</p>
</Preview>
<Preview title="Application side panel" note="end / push / controlled" className="catalog-preview--wide catalog-side-panel-preview"> <Preview title="Application side panel" note="end / push / controlled" className="catalog-preview--wide catalog-side-panel-preview">
<div className="catalog-side-panel-demo"> <div className="catalog-side-panel-demo">
{sidePanelDemoOpen ? ( {sidePanelDemoOpen ? (
@@ -1686,8 +1622,6 @@ export function CatalogApp() {
<div className="catalog-icon-catalog"> <div className="catalog-icon-catalog">
<Preview title="Размеры и поверхности" note="glyph 16 px" className="catalog-preview--wide"> <Preview title="Размеры и поверхности" note="glyph 16 px" className="catalog-preview--wide">
<div className="catalog-window-actions-demo"> <div className="catalog-window-actions-demo">
<IconButton label="Камера включена" aria-pressed="true"><Icon name="camera" /></IconButton>
<IconButton label="План"><Icon name="plan" /></IconButton>
<IconButton label="Добавить"><Icon name="plus" /></IconButton> <IconButton label="Добавить"><Icon name="plus" /></IconButton>
<IconButton label="Обновить"><Icon name="refresh" /></IconButton> <IconButton label="Обновить"><Icon name="refresh" /></IconButton>
<IconButton label="Развернуть"><Icon name="expand" /></IconButton> <IconButton label="Развернуть"><Icon name="expand" /></IconButton>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,212 @@
import type { RefObject } from "react";
import { Button, Checker, Icon, WorkspaceWindow } from "@nodedc/ui-react";
import type { WorkspaceWindowRect } from "@nodedc/ui-react";
import type { GridSectorSelection } from "./mapRendererContract.js";
import {
MAP_SCOPE_MISSING_VALUE,
MAP_SCOPE_OBJECT_KIND_FIELD,
MAP_SCOPE_PROVIDER_FIELD,
formatGridSectorArea,
gridSectorBoundsLabel,
normalizedSectorScopeValue,
} from "./mapSectorWorkspace.js";
import type { MapSelectableEntity } from "./mapWorkspaceModel.mjs";
type ScopeOption = { value: string; label: string; count: number };
export function MapSectorWorkspaceWindow({
boundsRef,
selection,
rect,
maximized,
active,
zIndex,
copyState,
spatialEntities,
visibleEntities,
bindingOptions,
providerOptions,
objectKindOptions,
excludedBindingIds,
excludedProviders,
excludedObjectKinds,
hideOutside,
selectedEntityId,
onRectChange,
onMaximizedChange,
onActivate,
onDeactivate,
onCopyStableId,
onHideOutsideChange,
onBindingEnabledChange,
onProviderEnabledChange,
onObjectKindEnabledChange,
onSelectEntity,
}: {
boundsRef: RefObject<HTMLElement | null>;
selection: GridSectorSelection;
rect: WorkspaceWindowRect;
maximized: boolean;
active: boolean;
zIndex: number;
copyState: "idle" | "copied" | "error";
spatialEntities: MapSelectableEntity[];
visibleEntities: MapSelectableEntity[];
bindingOptions: ScopeOption[];
providerOptions: ScopeOption[];
objectKindOptions: ScopeOption[];
excludedBindingIds: string[];
excludedProviders: string[];
excludedObjectKinds: string[];
hideOutside: boolean;
selectedEntityId?: string;
onRectChange: (rect: WorkspaceWindowRect) => void;
onMaximizedChange: (value: boolean) => void;
onActivate: () => void;
onDeactivate: () => void;
onCopyStableId: () => void;
onHideOutsideChange: (value: boolean) => void;
onBindingEnabledChange: (bindingId: string, enabled: boolean) => void;
onProviderEnabledChange: (provider: string, enabled: boolean) => void;
onObjectKindEnabledChange: (objectKind: string, enabled: boolean) => void;
onSelectEntity: (entityId: string) => void;
}) {
return (
<WorkspaceWindow
boundsRef={boundsRef}
rect={rect}
onRectChange={onRectChange}
maximized={maximized}
onMaximizedChange={onMaximizedChange}
onActivate={onActivate}
onClose={onDeactivate}
title={`Активный сектор · LOD ${selection.lod}`}
subtitle={selection.label}
status={`${visibleEntities.length} / ${spatialEntities.length}`}
active={active}
zIndex={zIndex}
minWidth={340}
minHeight={380}
footer={(
<Button variant="secondary" size="compact" width="full" onClick={onDeactivate}>
Деактивировать сектор
</Button>
)}
className="catalog-map-fixture__sector-window catalog-map-fixture__map-glass-window"
aria-label={`Активный сектор: ${selection.id}`}
>
<div className="catalog-map-sector-window">
<section className="catalog-map-sector-window__summary" aria-label="Параметры сектора">
<code title={selection.id}>{selection.id}</code>
<span>{gridSectorBoundsLabel(selection)}</span>
<span>{formatGridSectorArea(selection.areaSquareMeters)}</span>
<Button
variant="secondary"
size="compact"
width="full"
icon={<Icon name={copyState === "copied" ? "check" : "copy"} />}
onClick={onCopyStableId}
>{copyState === "copied" ? "ID скопирован" : "Копировать stable ID"}</Button>
</section>
<Checker checked={hideOutside} label="Скрыть объекты за сектором" onChange={onHideOutsideChange} />
<ScopeFilterSection
id="map-sector-domains"
title="Домены данных"
options={bindingOptions}
excluded={excludedBindingIds}
onEnabledChange={onBindingEnabledChange}
/>
{providerOptions.length ? (
<ScopeFilterSection
id="map-sector-providers"
title="Провайдеры"
options={providerOptions}
excluded={excludedProviders}
exposeRawValue
onEnabledChange={onProviderEnabledChange}
/>
) : null}
{objectKindOptions.length ? (
<ScopeFilterSection
id="map-sector-kinds"
title="Типы объектов"
options={objectKindOptions}
excluded={excludedObjectKinds}
exposeRawValue
onEnabledChange={onObjectKindEnabledChange}
/>
) : null}
<section className="catalog-map-sector-window__objects" aria-labelledby="map-sector-objects-title">
<div className="catalog-map-sector-window__section-title">
<strong id="map-sector-objects-title">Объекты сектора</strong>
<small>{visibleEntities.length} / {spatialEntities.length}</small>
</div>
<div className="catalog-map-sector-window__object-list">
{visibleEntities.map((entity) => {
const provider = normalizedSectorScopeValue(entity.fact.attributes[MAP_SCOPE_PROVIDER_FIELD]);
const objectKind = normalizedSectorScopeValue(entity.fact.attributes[MAP_SCOPE_OBJECT_KIND_FIELD]);
return (
<button
type="button"
key={entity.id}
data-selected={entity.id === selectedEntityId || undefined}
onClick={() => onSelectEntity(entity.id)}
>
<span>{entity.title}</span>
<code>{entity.fact.sourceId}</code>
<small>{[provider, objectKind, entity.status].filter(Boolean).join(" · ")}</small>
</button>
);
})}
{!visibleEntities.length ? (
<small className="catalog-map-sector-window__empty">
{spatialEntities.length
? "Объекты скрыты текущими фильтрами."
: "В секторе нет точечных объектов подключённых Data Products."}
</small>
) : null}
</div>
</section>
</div>
</WorkspaceWindow>
);
}
function ScopeFilterSection({
id,
title,
options,
excluded,
exposeRawValue = false,
onEnabledChange,
}: {
id: string;
title: string;
options: ScopeOption[];
excluded: string[];
exposeRawValue?: boolean;
onEnabledChange: (value: string, enabled: boolean) => void;
}) {
return (
<section className="catalog-map-sector-window__filters" aria-labelledby={`${id}-title`}>
<div className="catalog-map-sector-window__section-title">
<strong id={`${id}-title`}>{title}</strong>
<small>{options.length}</small>
</div>
{options.map((option) => (
<Checker
key={option.value}
checked={!excluded.includes(option.value)}
label={`${option.label} · ${option.count}`}
title={exposeRawValue && option.value !== MAP_SCOPE_MISSING_VALUE ? option.value : undefined}
onChange={(enabled) => onEnabledChange(option.value, enabled)}
/>
))}
</section>
);
}
@@ -0,0 +1,238 @@
import type { Dispatch, RefObject } from "react";
import { Icon, SegmentedControl, WorkspaceWindow } from "@nodedc/ui-react";
import {
mapFactMatchesFilters,
mapPresentationFacetValueIsEnabled,
} from "./mapPresentationProfile.mjs";
import type { MapSubjectCardModel } from "./mapSubjectCard.mjs";
import {
mapProfileHasSubjectWindowControls,
type MapFilteredTarget,
type MapPresentationSummary,
type MapSelectableEntity,
} from "./mapWorkspaceModel.mjs";
import type {
MapWorkspaceAction,
MapWorkspaceState,
} from "./mapWorkspaceState.mjs";
export function MapSubjectWorkspaceWindows({
boundsRef,
workspaceState,
dispatch,
summaries,
filteredTargets,
selectable,
expandedFacetRows,
selectedSubjectCard,
onToggleFacetRow,
onTogglePresentationFilter,
onSelectEntity,
}: {
boundsRef: RefObject<HTMLElement | null>;
workspaceState: MapWorkspaceState;
dispatch: Dispatch<MapWorkspaceAction>;
summaries: MapPresentationSummary[];
filteredTargets: MapFilteredTarget[];
selectable: MapSelectableEntity[];
expandedFacetRows: Record<string, boolean>;
selectedSubjectCard: MapSubjectCardModel | null;
onToggleFacetRow: (rowId: string) => void;
onTogglePresentationFilter: (bindingId: string, field: string, value: string, availableValues: string[]) => void;
onSelectEntity: (entityId: string) => void;
}) {
const selectedEntityId = workspaceState.selectedEntityId;
return (
<>
{summaries.map((summary) => {
const state = workspaceState.subjectStates[summary.bindingId];
if (!state?.window.open || !mapProfileHasSubjectWindowControls(summary.profile)) return null;
const visibleCount = filteredTargets.filter((target) => target.bindingId === summary.bindingId && target.renderable).length;
return (
<WorkspaceWindow
key={summary.bindingId}
boundsRef={boundsRef}
rect={state.window.rect}
onRectChange={(rect) => dispatch({
type: "set-subject-window-rect",
bindingId: summary.bindingId,
rect,
})}
maximized={state.window.maximized}
onMaximizedChange={(value) => dispatch({
type: "set-subject-window-maximized",
bindingId: summary.bindingId,
value,
})}
onActivate={() => dispatch({ type: "activate-window", windowId: `binding:${summary.bindingId}` })}
onClose={() => dispatch({ type: "close-subject-window", bindingId: summary.bindingId })}
title={summary.displayName}
subtitle={`${summary.total} всего · ${visibleCount} на карте`}
active={workspaceState.activeWindowId === `binding:${summary.bindingId}`}
zIndex={state.window.zIndex}
minWidth={240}
minHeight={220}
autoHeight
className="catalog-map-fixture__subject-window catalog-map-fixture__map-glass-window"
>
<div className="catalog-map-fixture__target-filters">
<section aria-label={`${summary.displayName}: фильтры и счётчики`}>
<div className="catalog-map-fixture__target-filter-list">
{summary.profile.facets.filter((facet) => facet.counter || facet.filterable).flatMap((facet) => (
facet.values.map((item) => {
const active = mapPresentationFacetValueIsEnabled(state.filters, facet.field, item.value);
const rowId = `${summary.bindingId}:${facet.field}:${item.value}`;
const expanded = Boolean(expandedFacetRows[rowId]);
const matchingEntities = selectable.filter((entity) => (
entity.bindingId === summary.bindingId
&& mapFactMatchesFilters(
entity.fact,
summary.profile,
{
[summary.bindingId]: {
visible: true,
facets: { [facet.field]: [item.value] },
},
},
summary.bindingId,
)
));
return (
<div className="catalog-map-fixture__target-filter-branch" key={`${facet.field}:${item.value}`}>
<div className="catalog-map-fixture__target-filter-row" data-active={active || undefined}>
<button
type="button"
className="catalog-map-fixture__target-filter-body"
aria-pressed={active}
disabled={!facet.filterable}
onClick={() => onTogglePresentationFilter(
summary.bindingId,
facet.field,
item.value,
facet.values.map((value) => value.value),
)}
>
<span className="catalog-map-fixture__target-filter-label">{item.label}</span>
<span className="catalog-map-fixture__target-filter-count">{summary.counts[facet.field]?.[item.value] ?? 0}</span>
</button>
<button
type="button"
className="catalog-map-fixture__target-filter-expander"
aria-label={`${expanded ? "Свернуть" : "Развернуть"} ${item.label}`}
aria-expanded={expanded}
onClick={() => onToggleFacetRow(rowId)}
>
<Icon name="chevron-right" size={14} />
</button>
</div>
{expanded ? (
<div className="catalog-map-fixture__target-filter-children">
{matchingEntities.map((entity) => (
<button
type="button"
className="catalog-map-fixture__target-filter-entity"
key={entity.id}
data-selected={entity.id === selectedEntityId || undefined}
onClick={() => onSelectEntity(entity.id)}
>
<span>{entity.title}</span>
{entity.status ? <small>{entity.status}</small> : null}
</button>
))}
{!matchingEntities.length ? <small>Нет объектов в группе.</small> : null}
</div>
) : null}
</div>
);
})
))}
</div>
</section>
</div>
</WorkspaceWindow>
);
})}
{workspaceState.subjectCard.open && selectedSubjectCard ? (
<WorkspaceWindow
boundsRef={boundsRef}
rect={workspaceState.subjectCard.rect}
onRectChange={(rect) => dispatch({ type: "set-subject-card-rect", rect })}
maximized={workspaceState.subjectCard.maximized}
onMaximizedChange={(value) => dispatch({ type: "set-subject-card-maximized", value })}
onActivate={() => dispatch({ type: "activate-window", windowId: "subject-card" })}
onClose={() => dispatch({ type: "close-subject-card" })}
title={selectedSubjectCard.title}
subtitle={selectedSubjectCard.sourceId}
active={workspaceState.activeWindowId === "subject-card"}
zIndex={workspaceState.subjectCard.zIndex}
minWidth={320}
minHeight={320}
className="catalog-map-fixture__subject-card catalog-map-fixture__map-glass-window"
aria-label={`Карточка объекта: ${selectedSubjectCard.title}`}
>
<MapSubjectCard
model={selectedSubjectCard}
tabId={workspaceState.subjectCard.tabId}
onTabChange={(tabId) => dispatch({ type: "set-subject-card-tab", tabId })}
/>
</WorkspaceWindow>
) : null}
</>
);
}
function MapSubjectCard({
model,
tabId,
onTabChange,
}: {
model: MapSubjectCardModel;
tabId: string;
onTabChange: (tabId: string) => void;
}) {
const activeTabId = model.tabs.some((tab) => tab.id === tabId) ? tabId : model.defaultTabId;
return (
<div className="catalog-map-subject-card">
<div className="catalog-map-subject-card__tabs">
<SegmentedControl
value={activeTabId}
items={model.tabs.map((tab) => ({ value: tab.id, label: tab.label }))}
label="Разделы карточки объекта"
onChange={onTabChange}
/>
</div>
{model.tabs.filter((tab) => tab.id === activeTabId).map((tab) => (
<div key={tab.id} className="catalog-map-subject-card__tab-panel" role="tabpanel">
{tab.empty ? <div className="catalog-map-subject-card__empty">{tab.emptyMessage}</div> : null}
{tab.sections.map((section) => (
<section key={section.id} className="catalog-map-subject-card__section" aria-labelledby={`subject-card-${tab.id}-${section.id}`}>
<h3 id={`subject-card-${tab.id}-${section.id}`}>{section.label}</h3>
{section.rows.length ? (
<dl>
{section.rows.map((row) => (
<div key={row.key} className="catalog-map-subject-card__row">
<dt>{row.label}</dt>
<dd>{row.value}</dd>
</div>
))}
</dl>
) : null}
{section.readings.length ? (
<div className="catalog-map-subject-card__readings">
{section.readings.map((reading) => (
<div className="catalog-map-subject-card__reading" key={reading.id}>
<span>{reading.label}</span>
<strong>{reading.value}</strong>
{reading.observedAt ? <time dateTime={reading.observedAt}>{new Date(reading.observedAt).toLocaleString("ru-RU")}</time> : null}
</div>
))}
</div>
) : null}
</section>
))}
</div>
))}
</div>
);
}
+377
View File
@@ -0,0 +1,377 @@
import type { KeyboardEvent, RefObject } from "react";
import { Checker, Dropdown, Icon, IconButton } from "@nodedc/ui-react";
import type { MapPageSettings, MapSubjectState } from "./mapPageContract.js";
import type { MapProviderStatus } from "./mapRendererContract.js";
import type { MapSearchDocument } from "./mapSearch.mjs";
import {
mapProfileHasSubjectWindowControls,
type MapFilteredTarget,
type MapPresentationSummary,
} from "./mapWorkspaceModel.mjs";
type ReferenceObjectSummary = {
layer: { id: string; visible: boolean };
displayName: string;
total: number;
};
const providerStateLabel: Record<MapProviderStatus["imagery"], string> = {
loading: "загружается",
ready: "готов",
error: "недоступен",
"not-configured": "не настроен",
};
export function MapWorkspaceToolbar({
searchOpen,
searchQuery,
searchInputRef,
searchResults,
searchActiveIndex,
remoteSearchQuery,
referenceSearchState,
summaries,
referenceSummaries,
subjectStates,
filteredTargets,
providerStatus,
mapSettings,
liveCacheSummary,
transportDiagnostic,
gatewayHealthAge,
gatewayCheckState,
gatewayCheckError,
onOpenSubjectWindow,
onToggleSubjectVisibility,
onToggleReferenceLayer,
onLayersOpenChange,
onTerrainChange,
onBuildingsVisibleChange,
onGridVisibleChange,
onCacheEnabledChange,
onCacheNoOverwriteChange,
onFitVisibleTargets,
onToggleSearch,
onSearchQueryChange,
onSearchKeyDown,
onSearchActiveIndexChange,
onSearchResult,
}: {
searchOpen: boolean;
searchQuery: string;
searchInputRef: RefObject<HTMLInputElement | null>;
searchResults: MapSearchDocument[];
searchActiveIndex: number;
remoteSearchQuery: string;
referenceSearchState: "idle" | "loading" | "ready" | "error";
summaries: MapPresentationSummary[];
referenceSummaries: ReferenceObjectSummary[];
subjectStates: Record<string, MapSubjectState>;
filteredTargets: MapFilteredTarget[];
providerStatus: MapProviderStatus;
mapSettings: MapPageSettings;
liveCacheSummary: string;
transportDiagnostic: string | null;
gatewayHealthAge: string | null;
gatewayCheckState: "idle" | "checking" | "ready" | "stale" | "error";
gatewayCheckError: string | null;
onOpenSubjectWindow: (bindingId: string) => void;
onToggleSubjectVisibility: (bindingId: string) => void;
onToggleReferenceLayer: (layerId: string) => void;
onLayersOpenChange: (open: boolean) => void;
onTerrainChange: (value: boolean) => void;
onBuildingsVisibleChange: (value: boolean) => void;
onGridVisibleChange: (value: boolean) => void;
onCacheEnabledChange: (value: boolean) => void;
onCacheNoOverwriteChange: (value: boolean) => void;
onFitVisibleTargets: () => void;
onToggleSearch: () => void;
onSearchQueryChange: (value: string) => void;
onSearchKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void;
onSearchActiveIndexChange: (index: number) => void;
onSearchResult: (result: MapSearchDocument) => void;
}) {
const objectLayerCount = summaries.length + referenceSummaries.length;
return (
<div className="catalog-map-fixture__toolbar" aria-label="Map toolbar" data-search-open={searchOpen || undefined}>
<MapObjectsMenu
objectLayerCount={objectLayerCount}
summaries={summaries}
referenceSummaries={referenceSummaries}
subjectStates={subjectStates}
filteredTargets={filteredTargets}
onOpenSubjectWindow={onOpenSubjectWindow}
onToggleSubjectVisibility={onToggleSubjectVisibility}
onToggleReferenceLayer={onToggleReferenceLayer}
/>
<MapLayersMenu
providerStatus={providerStatus}
mapSettings={mapSettings}
liveCacheSummary={liveCacheSummary}
transportDiagnostic={transportDiagnostic}
gatewayHealthAge={gatewayHealthAge}
gatewayCheckState={gatewayCheckState}
gatewayCheckError={gatewayCheckError}
onOpenChange={onLayersOpenChange}
onTerrainChange={onTerrainChange}
onBuildingsVisibleChange={onBuildingsVisibleChange}
onGridVisibleChange={onGridVisibleChange}
onCacheEnabledChange={onCacheEnabledChange}
onCacheNoOverwriteChange={onCacheNoOverwriteChange}
/>
<IconButton label="Обзор объектов" onClick={onFitVisibleTargets}><Icon name="globe" /></IconButton>
<IconButton
label={searchOpen ? "Закрыть поиск" : "Поиск"}
aria-expanded={searchOpen}
aria-controls="map-subject-search"
data-active={searchOpen || undefined}
onClick={onToggleSearch}
><Icon name="search" /></IconButton>
<MapSearch
open={searchOpen}
query={searchQuery}
inputRef={searchInputRef}
results={searchResults}
activeIndex={searchActiveIndex}
remoteQuery={remoteSearchQuery}
referenceSearchState={referenceSearchState}
onQueryChange={onSearchQueryChange}
onKeyDown={onSearchKeyDown}
onActiveIndexChange={onSearchActiveIndexChange}
onResult={onSearchResult}
/>
</div>
);
}
function MapObjectsMenu({
objectLayerCount,
summaries,
referenceSummaries,
subjectStates,
filteredTargets,
onOpenSubjectWindow,
onToggleSubjectVisibility,
onToggleReferenceLayer,
}: {
objectLayerCount: number;
summaries: MapPresentationSummary[];
referenceSummaries: ReferenceObjectSummary[];
subjectStates: Record<string, MapSubjectState>;
filteredTargets: MapFilteredTarget[];
onOpenSubjectWindow: (bindingId: string) => void;
onToggleSubjectVisibility: (bindingId: string) => void;
onToggleReferenceLayer: (layerId: string) => void;
}) {
return (
<Dropdown
placement="top-start"
width={320}
minWidth={240}
offset={10}
surfaceRole="menu"
surfaceClassName="catalog-map-fixture__objects-menu nodedc-map-glass"
trigger={({ open, toggle, setTriggerRef, surfaceId }) => (
<IconButton ref={setTriggerRef} label="Объекты" aria-controls={surfaceId} aria-expanded={open} aria-pressed={open} data-active={open || undefined} onClick={toggle}><Icon name="target" /></IconButton>
)}
>
{({ close }) => (
<div className="catalog-map-fixture__objects-menu-list">
<div className="catalog-map-fixture__objects-menu-head">
<strong>Объекты</strong>
<small>{objectLayerCount} {objectLayerCount === 1 ? "группа" : "групп"}</small>
</div>
{summaries.map((summary) => {
const state = subjectStates[summary.bindingId];
const visibleCount = filteredTargets.filter((target) => target.bindingId === summary.bindingId && target.renderable).length;
const visible = state?.visible !== false;
const hasControls = mapProfileHasSubjectWindowControls(summary.profile);
return (
<div
className="catalog-map-fixture__objects-menu-item"
key={summary.bindingId}
data-visible={visible || undefined}
data-open={hasControls && state?.window.open || undefined}
>
<button
type="button"
role={hasControls ? "menuitem" : "menuitemcheckbox"}
aria-checked={hasControls ? undefined : visible}
className="catalog-map-fixture__objects-menu-toggle"
onClick={() => {
if (hasControls) {
onOpenSubjectWindow(summary.bindingId);
close();
} else onToggleSubjectVisibility(summary.bindingId);
}}
>
<span>{summary.displayName}</span>
<small>{visible ? `на карте: ${visibleCount}` : `слой скрыт · ${summary.total} объектов`}</small>
</button>
</div>
);
})}
{referenceSummaries.map(({ layer, displayName, total }) => (
<div className="catalog-map-fixture__objects-menu-item" key={layer.id} data-visible={layer.visible || undefined}>
<button
type="button"
role="menuitemcheckbox"
aria-checked={layer.visible}
className="catalog-map-fixture__objects-menu-toggle"
onClick={() => onToggleReferenceLayer(layer.id)}
>
<span>{displayName}</span>
<small>{layer.visible ? `на карте: ${total}` : `слой скрыт · ${total} объектов`}</small>
</button>
</div>
))}
{!objectLayerCount ? <small className="catalog-map-fixture__objects-menu-empty">Нет подключённых объектов.</small> : null}
</div>
)}
</Dropdown>
);
}
function MapLayersMenu({
providerStatus,
mapSettings,
liveCacheSummary,
transportDiagnostic,
gatewayHealthAge,
gatewayCheckState,
gatewayCheckError,
onOpenChange,
onTerrainChange,
onBuildingsVisibleChange,
onGridVisibleChange,
onCacheEnabledChange,
onCacheNoOverwriteChange,
}: {
providerStatus: MapProviderStatus;
mapSettings: MapPageSettings;
liveCacheSummary: string;
transportDiagnostic: string | null;
gatewayHealthAge: string | null;
gatewayCheckState: "idle" | "checking" | "ready" | "stale" | "error";
gatewayCheckError: string | null;
onOpenChange: (open: boolean) => void;
onTerrainChange: (value: boolean) => void;
onBuildingsVisibleChange: (value: boolean) => void;
onGridVisibleChange: (value: boolean) => void;
onCacheEnabledChange: (value: boolean) => void;
onCacheNoOverwriteChange: (value: boolean) => void;
}) {
return (
<Dropdown
placement="top-start"
width={320}
minWidth={240}
offset={10}
surfaceRole="dialog"
surfaceClassName="catalog-map-fixture__objects-menu catalog-map-fixture__layers-menu nodedc-map-glass"
onOpenChange={onOpenChange}
trigger={({ open, toggle, setTriggerRef, surfaceId }) => (
<IconButton ref={setTriggerRef} label="Слои карты" aria-controls={surfaceId} aria-expanded={open} aria-pressed={open} data-active={open || undefined} onClick={toggle}><Icon name="grid" /></IconButton>
)}
>
<div className="catalog-map-fixture__layers-content" aria-label="Настройки слоёв карты">
<div className="catalog-map-fixture__objects-menu-head">
<strong>Слои карты</strong>
<small>Подложка, рельеф и визуальные слои</small>
</div>
<div className="catalog-map-fixture__provider">
<strong>Cesium World Imagery</strong>
<small>официальный live provider · imagery: {providerStateLabel[providerStatus.imagery]} · terrain: {providerStateLabel[providerStatus.terrain]}</small>
</div>
{Object.entries(providerStatus.errors).map(([provider, error]) => <small key={provider} className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{provider}: {error}</small>)}
<Checker checked={mapSettings.terrainEnabled} label="Terrain" onChange={onTerrainChange} />
<Checker checked={mapSettings.buildingsVisible} label="3D здания" onChange={onBuildingsVisibleChange} />
<Checker checked={mapSettings.gridVisible} label="Планетарная сетка" onChange={onGridVisibleChange} />
<small className="catalog-map-inspector__note">Live Cache: {liveCacheSummary}</small>
{transportDiagnostic ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{transportDiagnostic}</small> : null}
{gatewayHealthAge ? <small className="catalog-map-inspector__note">{gatewayHealthAge}</small> : null}
{gatewayCheckState === "error" || gatewayCheckState === "stale" ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{gatewayCheckError}</small> : null}
<Checker className="catalog-map-fixture__cache-toggle" checked={mapSettings.cacheEnabled} label="Кэшировать live-данные" onChange={onCacheEnabledChange} />
<Checker className="catalog-map-fixture__cache-toggle" checked={mapSettings.cacheNoOverwrite} disabled={!mapSettings.cacheEnabled} label="Не перезаписывать cache" onChange={onCacheNoOverwriteChange} />
</div>
</Dropdown>
);
}
function MapSearch({
open,
query,
inputRef,
results,
activeIndex,
remoteQuery,
referenceSearchState,
onQueryChange,
onKeyDown,
onActiveIndexChange,
onResult,
}: {
open: boolean;
query: string;
inputRef: RefObject<HTMLInputElement | null>;
results: MapSearchDocument[];
activeIndex: number;
remoteQuery: string;
referenceSearchState: "idle" | "loading" | "ready" | "error";
onQueryChange: (value: string) => void;
onKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void;
onActiveIndexChange: (index: number) => void;
onResult: (result: MapSearchDocument) => void;
}) {
return (
<div className="catalog-map-search" data-open={open || undefined}>
<label className="catalog-map-search__field" htmlFor="map-subject-search">
<Icon name="search" />
<input
ref={inputRef}
id="map-subject-search"
type="search"
value={query}
autoComplete="off"
spellCheck={false}
placeholder="Название, ID объекта или трекера"
aria-label="Поиск объектов карты"
aria-controls="map-subject-search-results"
aria-activedescendant={results.length ? `map-subject-search-result-${activeIndex}` : undefined}
onChange={(event) => onQueryChange(event.target.value)}
onKeyDown={onKeyDown}
/>
</label>
{query.trim() ? (
<div id="map-subject-search-results" className="catalog-map-search__results nodedc-map-glass" role="listbox" aria-label="Результаты поиска">
{results.map((result, index) => (
<button
key={`${result.bindingId}:${result.sourceId}`}
id={`map-subject-search-result-${index}`}
type="button"
role="option"
aria-selected={index === activeIndex}
data-active={index === activeIndex || undefined}
onPointerEnter={() => onActiveIndexChange(index)}
onClick={() => onResult(result)}
>
<span>{result.title}</span>
<small>{result.groupTitle}</small>
</button>
))}
{!results.length ? (
<small className="catalog-map-search__empty">
{remoteQuery === query.trim()
? (referenceSearchState === "loading"
? "Ищем станцию в OSM…"
: referenceSearchState === "error"
? "Поиск OSM временно недоступен; локальные данные сохранены."
: "Станции с таким точным названием не найдены.")
: "Совпадений нет. Enter — найти станцию по точному названию в OSM."}
</small>
) : null}
</div>
) : null}
</div>
);
}
+1 -1
View File
@@ -1,5 +1,5 @@
import type { NodedcTheme } from "@nodedc/ui-core"; import type { NodedcTheme } from "@nodedc/ui-core";
import type { MapPageLayout, MapPageSettings } from "./MapFixturePreview.js"; import type { MapPageLayout, MapPageSettings } from "./mapPageContract.js";
import type { MapPresentationProfile } from "./mapPresentationProfile.js"; import type { MapPresentationProfile } from "./mapPresentationProfile.js";
export const applicationManifestSchemaVersion = "0.1.0" as const; export const applicationManifestSchemaVersion = "0.1.0" as const;
+1 -1
View File
@@ -1,7 +1,7 @@
import type { GlassMaterialSettings, NodedcTheme } from "@nodedc/ui-core"; import type { GlassMaterialSettings, NodedcTheme } from "@nodedc/ui-core";
import type { ToolbarPlacement } from "@nodedc/ui-react"; import type { ToolbarPlacement } from "@nodedc/ui-react";
import type { FaviconAssetUrls } from "./favicon.js"; import type { FaviconAssetUrls } from "./favicon.js";
import type { MapPageLayout, MapPageSettings } from "./MapFixturePreview.js"; import type { MapPageLayout, MapPageSettings } from "./mapPageContract.js";
import type { MapPresentationProfile } from "./mapPresentationProfile.js"; import type { MapPresentationProfile } from "./mapPresentationProfile.js";
export type MaterialDraft = { export type MaterialDraft = {
File diff suppressed because it is too large Load Diff
+610
View File
@@ -0,0 +1,610 @@
import {
BillboardGraphics,
CallbackProperty,
CallbackPositionProperty,
Cartesian2,
Cartesian3,
Cartographic,
ClassificationType,
Color,
ColorGeometryInstanceAttribute,
ConstantPositionProperty,
CustomDataSource,
GeometryInstance,
GroundPolylineGeometry,
GroundPolylinePrimitive,
GroundPrimitive,
HeightReference,
HorizontalOrigin,
LabelGraphics,
LabelStyle,
PerInstanceColorAppearance,
PointGraphics,
PolygonGeometry,
PolygonHierarchy,
PolylineColorAppearance,
PolylineGraphics,
VerticalOrigin,
Viewer,
} from "cesium";
import { mapRuntimeEntityId, type MapRuntimeBinding, type MapRuntimeFact } from "./useMapDataProductRuntime.js";
import {
mapPresentationProfileForFact,
mapRuntimeDisplayLabel,
mapRuntimeFactIsVisible,
resolveMapPresentationClass,
resolveMapPresentationStyle,
type MapPresentationFilters,
type MapPresentationProfile,
} from "./mapPresentationProfile.js";
import { normalizeHGeoZoneRing } from "./hGeoZoneProjection.mjs";
const MAX_HGEOZONE_INSTANCES_PER_BATCH = 256;
const elevatedTargetImageCache = new Map<string, string>();
function elevatedTargetImage(
fillColor: Color,
outlineColor: Color,
outlineWidthPx: number,
headSizePx: number,
) {
const safeHeadSize = Math.max(1, headSizePx);
const safeOutlineWidth = Math.max(0, outlineWidthPx);
const imageSize = Math.max(1, Math.ceil(safeHeadSize + safeOutlineWidth * 2));
const key = [
fillColor.toCssColorString(),
outlineColor.toCssColorString(),
safeOutlineWidth,
safeHeadSize,
imageSize,
].join("|");
const cached = elevatedTargetImageCache.get(key);
if (cached) return { image: cached, size: imageSize };
const center = imageSize / 2;
const radius = Math.max(0.5, safeHeadSize / 2);
const svg = [
`<svg xmlns="http://www.w3.org/2000/svg" width="${imageSize}" height="${imageSize}" viewBox="0 0 ${imageSize} ${imageSize}">`,
`<circle cx="${center}" cy="${center}" r="${radius}" fill="${fillColor.toCssColorString()}"`,
safeOutlineWidth > 0
? ` stroke="${outlineColor.toCssColorString()}" stroke-width="${safeOutlineWidth}"/>`
: "/>",
"</svg>",
].join("");
const image = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
elevatedTargetImageCache.set(key, image);
return { image, size: imageSize };
}
const accent = Color.fromCssColorString("#ff2f92");
const violet = Color.fromCssColorString("#8f72dc");
function showBelowCameraHeight(viewer: Viewer, limit?: number) {
if (!limit) return true;
return new CallbackProperty(() => Number(viewer.camera.positionCartographic?.height || 0) <= limit, false);
}
function elevatedPinGroundHeight(viewer: Viewer, longitude: number, latitude: number, fallbackHeightMeters = 0) {
const sampled = viewer.scene.globe.getHeight(Cartographic.fromDegrees(longitude, latitude));
return Number.isFinite(sampled) ? Number(sampled) : fallbackHeightMeters;
}
function elevatedPinTopPosition(
viewer: Viewer,
longitude: number,
latitude: number,
stemHeightMeters: number,
fallbackHeightMeters = 0,
) {
return new CallbackPositionProperty(() => Cartesian3.fromDegrees(
longitude,
latitude,
elevatedPinGroundHeight(viewer, longitude, latitude, fallbackHeightMeters) + stemHeightMeters,
), false);
}
function elevatedPinStemPositions(
viewer: Viewer,
longitude: number,
latitude: number,
stemHeightMeters: number,
fallbackHeightMeters = 0,
) {
return new CallbackProperty(() => {
const groundHeight = elevatedPinGroundHeight(viewer, longitude, latitude, fallbackHeightMeters);
return [
Cartesian3.fromDegrees(longitude, latitude, groundHeight),
Cartesian3.fromDegrees(longitude, latitude, groundHeight + stemHeightMeters),
];
}, false);
}
function runtimePointColor(fact: MapRuntimeFact) {
// This is a semantic default for the generic Map entity-stream adapter,
// not a provider style. A renderer-neutral style profile can refine it
// later without changing a data product or its L2 workflow.
if (fact.presentationStatus === "stale") return Color.fromCssColorString("#f5a623");
if (["inactive", "no-position", "no_position"].includes(fact.presentationStatus)) {
return Color.fromCssColorString("#7d8491");
}
return fact.semanticType === "map.moving_object" ? accent : violet;
}
export type HGeoZonePickId = {
kind: "nodedc-hgeozone";
entityId: string;
instanceId: string;
};
type HGeoZoneFillPart = {
pickId: HGeoZonePickId;
hierarchy: PolygonHierarchy;
color: Color;
};
type HGeoZoneOutlinePart = {
pickId: HGeoZonePickId;
positions: Cartesian3[];
color: Color;
};
type HGeoZonePrimitiveBatch<TPart> = {
primitive: GroundPrimitive | GroundPolylinePrimitive;
parts: TPart[];
};
export type HGeoZoneProjectionLayer = {
geometryKey: string;
styleKey: string;
hideCameraHeightMeters: number | null;
fills: Array<HGeoZonePrimitiveBatch<HGeoZoneFillPart>>;
outlines: Array<HGeoZonePrimitiveBatch<HGeoZoneOutlinePart>>;
};
function hGeoZoneRingPositions(ring: Array<[number, number]>) {
return normalizeHGeoZoneRing(ring)
.map(([longitude, latitude]) => Cartesian3.fromDegrees(longitude, latitude, 0));
}
function hGeoZoneHierarchy(polygon: Array<Array<[number, number]>>) {
const outer = hGeoZoneRingPositions(polygon[0]);
if (outer.length < 3) return null;
const holes = polygon.slice(1)
.map((ring) => hGeoZoneRingPositions(ring))
.filter((ring) => ring.length >= 3)
.map((ring) => new PolygonHierarchy(ring));
return new PolygonHierarchy(outer, holes);
}
function hGeoZoneBatches<T>(items: T[]) {
const batches: T[][] = [];
for (let index = 0; index < items.length; index += MAX_HGEOZONE_INSTANCES_PER_BATCH) {
batches.push(items.slice(index, index + MAX_HGEOZONE_INSTANCES_PER_BATCH));
}
return batches;
}
function removeHGeoZoneLayer(viewer: Viewer, layer: HGeoZoneProjectionLayer) {
for (const batch of [...layer.fills, ...layer.outlines]) {
viewer.scene.groundPrimitives.remove(batch.primitive);
}
}
function hGeoZoneFaultKey(bindingId: string, geometryKey: string) {
return `${bindingId}\u0000${geometryKey}`;
}
export function quarantineHGeoZoneLayers(
viewer: Viewer,
layers: Map<string, HGeoZoneProjectionLayer>,
faultedGeometryKeys: Set<string>,
) {
if (!layers.size) return false;
const hasPendingGeometry = [...layers.values()].some((layer) => (
[...layer.fills, ...layer.outlines].some((batch) => !batch.primitive.ready)
));
if (!hasPendingGeometry) return false;
for (const [bindingId, layer] of layers) {
faultedGeometryKeys.add(hGeoZoneFaultKey(bindingId, layer.geometryKey));
removeHGeoZoneLayer(viewer, layer);
}
layers.clear();
viewer.scene.requestRender();
return true;
}
export function syncHGeoZoneVisibility(viewer: Viewer, layers: Map<string, HGeoZoneProjectionLayer>) {
const cameraHeight = Number(viewer.camera.positionCartographic?.height || 0);
for (const layer of layers.values()) {
const show = layer.hideCameraHeightMeters === null || cameraHeight <= layer.hideCameraHeightMeters;
for (const batch of [...layer.fills, ...layer.outlines]) batch.primitive.show = show;
}
viewer.scene.requestRender();
}
function updateHGeoZoneColors<TPart extends { pickId: HGeoZonePickId; color: Color }>(
batches: Array<HGeoZonePrimitiveBatch<TPart>>,
nextParts: TPart[],
) {
const nextColors = new Map(nextParts.map((part) => [part.pickId.instanceId, part.color]));
for (const batch of batches) {
for (const part of batch.parts) {
const color = nextColors.get(part.pickId.instanceId);
if (!color) continue;
const attributes = batch.primitive.getGeometryInstanceAttributes(part.pickId);
if (attributes) attributes.color = ColorGeometryInstanceAttribute.toValue(color);
part.color = color;
}
}
}
function createHGeoZoneLayer(
viewer: Viewer,
geometryKey: string,
styleKey: string,
hideCameraHeightMeters: number | null,
fillParts: HGeoZoneFillPart[],
outlineParts: HGeoZoneOutlinePart[],
outlineWidthPx: number,
) {
const fills = hGeoZoneBatches(fillParts).map((parts) => {
const primitive = viewer.scene.groundPrimitives.add(new GroundPrimitive({
geometryInstances: parts.map((part) => new GeometryInstance({
id: part.pickId,
geometry: new PolygonGeometry({
polygonHierarchy: part.hierarchy,
vertexFormat: PerInstanceColorAppearance.FLAT_VERTEX_FORMAT,
}),
attributes: { color: ColorGeometryInstanceAttribute.fromColor(part.color) },
})),
appearance: new PerInstanceColorAppearance({ flat: true, translucent: true }),
allowPicking: true,
asynchronous: true,
classificationType: ClassificationType.TERRAIN,
releaseGeometryInstances: true,
}));
return { primitive, parts };
});
const outlines = outlineWidthPx <= 0 ? [] : hGeoZoneBatches(outlineParts).map((parts) => {
const primitive = viewer.scene.groundPrimitives.add(new GroundPolylinePrimitive({
geometryInstances: parts.map((part) => new GeometryInstance({
id: part.pickId,
geometry: new GroundPolylineGeometry({
positions: part.positions,
width: outlineWidthPx,
loop: true,
}),
attributes: { color: ColorGeometryInstanceAttribute.fromColor(part.color) },
})),
appearance: new PolylineColorAppearance({ translucent: true }),
allowPicking: true,
asynchronous: true,
classificationType: ClassificationType.TERRAIN,
releaseGeometryInstances: true,
}));
return { primitive, parts };
});
const layer = { geometryKey, styleKey, hideCameraHeightMeters, fills, outlines };
syncHGeoZoneVisibility(viewer, new Map([["layer", layer]]));
return layer;
}
function syncHGeoZoneLayers(
viewer: Viewer,
layers: Map<string, HGeoZoneProjectionLayer>,
bindings: MapRuntimeBinding[],
presentationProfiles: MapPresentationProfile[],
presentationFilters: MapPresentationFilters,
faultedGeometryKeys: Set<string>,
) {
const activeBindings = new Set(bindings.filter((binding) => binding.slotId === "zones").map((binding) => binding.bindingId));
for (const [bindingId, layer] of layers) {
if (activeBindings.has(bindingId)) continue;
removeHGeoZoneLayer(viewer, layer);
layers.delete(bindingId);
}
for (const binding of bindings) {
if (binding.slotId !== "zones") continue;
const fillParts: HGeoZoneFillPart[] = [];
const outlineParts: HGeoZoneOutlinePart[] = [];
const geometryMembers: string[] = [];
let outlineWidthPx = 1.5;
let hideCameraHeightMeters: number | null = null;
let outlineColor = Color.fromCssColorString("#c9b6ff").withAlpha(0.9);
const styleMembers: string[] = [];
for (const fact of binding.facts) {
const profile = mapPresentationProfileForFact(
presentationProfiles,
binding.presentationProfileId,
fact.semanticType,
);
if (
!fact.geometry
|| fact.geometry.type === "Point"
|| (profile && profile.target.variant !== "surface-fill")
|| (profile && !mapRuntimeFactIsVisible(fact, profile, presentationFilters, binding.bindingId))
) continue;
const presentationClass = profile ? resolveMapPresentationClass(fact, profile) : undefined;
const resolvedStyle = profile ? resolveMapPresentationStyle(profile, presentationClass) : undefined;
const fillColor = resolvedStyle
? Color.fromCssColorString(resolvedStyle.color).withAlpha(resolvedStyle.opacity)
: runtimePointColor(fact).withAlpha(0.28);
if (profile?.target.variant === "surface-fill") {
outlineWidthPx = profile.target.outlineWidthPx;
hideCameraHeightMeters = profile.target.hideCameraHeightMeters;
outlineColor = Color.fromCssColorString(profile.target.outlineColor).withAlpha(profile.target.outlineOpacity);
}
const polygons = fact.geometry.type === "Polygon" ? [fact.geometry.coordinates] : fact.geometry.coordinates;
const baseEntityId = mapRuntimeEntityId(binding.bindingId, fact);
for (const [polygonIndex, polygon] of polygons.entries()) {
const hierarchy = hGeoZoneHierarchy(polygon);
if (!hierarchy) continue;
const instanceBase = `${baseEntityId}:part:${polygonIndex}`;
const fillPickId: HGeoZonePickId = {
kind: "nodedc-hgeozone",
entityId: baseEntityId,
instanceId: `${instanceBase}:fill`,
};
fillParts.push({ pickId: fillPickId, hierarchy, color: fillColor });
geometryMembers.push(fillPickId.instanceId);
styleMembers.push(`${fillPickId.instanceId}:${resolvedStyle?.id ?? "default"}:${fillColor.toCssHexString()}:${fillColor.alpha}`);
for (const [ringIndex, ring] of polygon.entries()) {
const positions = hGeoZoneRingPositions(ring);
if (positions.length < 3) continue;
const outlinePickId: HGeoZonePickId = {
kind: "nodedc-hgeozone",
entityId: baseEntityId,
instanceId: `${instanceBase}:ring:${ringIndex}`,
};
outlineParts.push({ pickId: outlinePickId, positions, color: outlineColor });
geometryMembers.push(outlinePickId.instanceId);
}
}
}
const geometryKey = JSON.stringify([binding.cursor, outlineWidthPx, geometryMembers]);
const styleKey = JSON.stringify([styleMembers, outlineColor.toCssHexString(), outlineColor.alpha]);
const current = layers.get(binding.bindingId);
if (!fillParts.length) {
if (current) removeHGeoZoneLayer(viewer, current);
layers.delete(binding.bindingId);
continue;
}
if (faultedGeometryKeys.has(hGeoZoneFaultKey(binding.bindingId, geometryKey))) {
if (current) removeHGeoZoneLayer(viewer, current);
layers.delete(binding.bindingId);
continue;
}
if (current?.geometryKey === geometryKey) {
current.hideCameraHeightMeters = hideCameraHeightMeters;
if (current.styleKey !== styleKey) {
const ready = [...current.fills, ...current.outlines].every((batch) => batch.primitive.ready);
if (ready) {
updateHGeoZoneColors(current.fills, fillParts);
updateHGeoZoneColors(current.outlines, outlineParts);
current.styleKey = styleKey;
syncHGeoZoneVisibility(viewer, layers);
continue;
}
} else {
syncHGeoZoneVisibility(viewer, layers);
continue;
}
}
if (current) removeHGeoZoneLayer(viewer, current);
layers.set(binding.bindingId, createHGeoZoneLayer(
viewer,
geometryKey,
styleKey,
hideCameraHeightMeters,
fillParts,
outlineParts,
outlineWidthPx,
));
}
viewer.scene.requestRender();
}
export function syncRuntimeDataSources(
viewer: Viewer,
dataSources: Map<string, CustomDataSource>,
hGeoZoneLayers: Map<string, HGeoZoneProjectionLayer>,
bindings: MapRuntimeBinding[],
presentationProfiles: MapPresentationProfile[],
presentationFilters: MapPresentationFilters,
faultedHGeoZoneGeometryKeys: Set<string>,
) {
const activeBindings = new Map(bindings
.filter((binding) => (
binding.slotId === "points"
|| binding.slotId === "reference-points"
|| binding.slotId === "zones"
))
.map((binding) => [binding.bindingId, binding]));
for (const [bindingId, dataSource] of dataSources) {
if (activeBindings.has(bindingId)) continue;
viewer.dataSources.remove(dataSource, true);
dataSources.delete(bindingId);
}
for (const binding of activeBindings.values()) {
let dataSource = dataSources.get(binding.bindingId);
if (!dataSource) {
dataSource = new CustomDataSource(`nodedc-map-slot:${binding.bindingId}`);
viewer.dataSources.add(dataSource);
dataSources.set(binding.bindingId, dataSource);
}
const wanted = new Set<string>();
for (const fact of binding.facts) {
const profile = mapPresentationProfileForFact(
presentationProfiles,
binding.presentationProfileId,
fact.semanticType,
);
const presentationClass = profile ? resolveMapPresentationClass(fact, profile) : undefined;
if (
!fact.geometry
|| (profile && !mapRuntimeFactIsVisible(fact, profile, presentationFilters, binding.bindingId))
) continue;
const resolvedStyle = profile ? resolveMapPresentationStyle(profile, presentationClass) : undefined;
const color = resolvedStyle
? Color.fromCssColorString(resolvedStyle.color).withAlpha(resolvedStyle.opacity)
: runtimePointColor(fact);
const label = mapRuntimeDisplayLabel(fact, profile);
const baseEntityId = mapRuntimeEntityId(binding.bindingId, fact);
if (fact.geometry.type === "Point" && (binding.slotId === "points" || binding.slotId === "reference-points")) {
if (profile && profile.target.variant !== "elevated-spike") continue;
const entityId = baseEntityId;
wanted.add(entityId);
const [longitude, latitude] = fact.geometry.coordinates;
const entity = dataSource.entities.getById(entityId) ?? dataSource.entities.add({ id: entityId });
entity.name = label;
entity.polygon = undefined;
if (!profile) {
entity.billboard = undefined;
entity.position = new ConstantPositionProperty(Cartesian3.fromDegrees(longitude, latitude, 0));
entity.polyline = undefined;
entity.point = new PointGraphics({
pixelSize: 10,
color,
outlineColor: Color.TRANSPARENT,
outlineWidth: 0,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
entity.label = new LabelGraphics({
text: label,
font: "700 13px Arial",
fillColor: Color.WHITE,
outlineColor: Color.TRANSPARENT,
outlineWidth: 0,
style: LabelStyle.FILL,
showBackground: true,
backgroundColor: Color.BLACK.withAlpha(0.72),
backgroundPadding: new Cartesian2(10, 7),
pixelOffset: new Cartesian2(10, 0),
horizontalOrigin: HorizontalOrigin.LEFT,
verticalOrigin: VerticalOrigin.BOTTOM,
heightReference: HeightReference.NONE,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
continue;
}
if (profile.target.variant !== "elevated-spike") continue;
const target = profile.target;
const fallbackHeightMeters = typeof fact.attributes.elevation_meters === "number" && Number.isFinite(fact.attributes.elevation_meters)
? fact.attributes.elevation_meters
: 0;
entity.position = elevatedPinTopPosition(
viewer,
longitude,
latitude,
target.stemHeightMeters,
fallbackHeightMeters,
);
entity.polyline = new PolylineGraphics({
positions: elevatedPinStemPositions(
viewer,
longitude,
latitude,
target.stemHeightMeters,
fallbackHeightMeters,
),
width: target.stemWidthPx,
material: color,
show: showBelowCameraHeight(viewer, target.hideCameraHeightMeters),
});
const targetImage = elevatedTargetImage(
color,
Color.fromCssColorString(target.outlineColor).withAlpha(target.outlineOpacity),
target.outlineWidthPx,
target.headSizePx,
);
entity.point = undefined;
entity.billboard = new BillboardGraphics({
image: targetImage.image,
width: targetImage.size,
height: targetImage.size,
horizontalOrigin: HorizontalOrigin.CENTER,
verticalOrigin: VerticalOrigin.CENTER,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
show: showBelowCameraHeight(viewer, target.hideCameraHeightMeters),
});
entity.label = new LabelGraphics({
text: label,
font: `${profile.label.fontWeight} ${profile.label.sizePx}px Arial`,
fillColor: Color.fromCssColorString(profile.label.color),
outlineColor: Color.TRANSPARENT,
outlineWidth: 0,
style: LabelStyle.FILL,
showBackground: profile.label.backgroundOpacity > 0,
backgroundColor: Color.fromCssColorString(profile.label.backgroundColor).withAlpha(profile.label.backgroundOpacity),
backgroundPadding: new Cartesian2(profile.label.paddingX, profile.label.paddingY),
pixelOffset: new Cartesian2(profile.label.offsetX, profile.label.offsetY),
horizontalOrigin: HorizontalOrigin.LEFT,
verticalOrigin: VerticalOrigin.BOTTOM,
heightReference: HeightReference.NONE,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
show: profile.label.mode !== "none" && showBelowCameraHeight(viewer, profile.label.hideCameraHeightMeters),
});
continue;
}
if (
binding.slotId !== "zones"
|| fact.geometry.type === "Point"
|| (profile && profile.target.variant !== "surface-fill")
) continue;
const polygons = fact.geometry.type === "Polygon" ? [fact.geometry.coordinates] : fact.geometry.coordinates;
const outerRing = normalizeHGeoZoneRing(polygons[0]?.[0] ?? []);
if (!outerRing.length) continue;
wanted.add(baseEntityId);
const labelAnchor = outerRing.reduce(
(accumulator, [longitude, latitude]) => [accumulator[0] + longitude, accumulator[1] + latitude] as [number, number],
[0, 0] as [number, number],
);
const divisor = Math.max(1, outerRing.length);
const entity = dataSource.entities.getById(baseEntityId) ?? dataSource.entities.add({ id: baseEntityId });
entity.name = label;
entity.position = new ConstantPositionProperty(Cartesian3.fromDegrees(labelAnchor[0] / divisor, labelAnchor[1] / divisor, 0));
entity.billboard = undefined;
entity.point = undefined;
entity.polygon = undefined;
entity.polyline = undefined;
entity.label = new LabelGraphics({
text: label,
font: profile ? `${profile.label.fontWeight} ${profile.label.sizePx}px Arial` : "700 13px Arial",
fillColor: profile ? Color.fromCssColorString(profile.label.color) : Color.WHITE,
outlineColor: Color.TRANSPARENT,
outlineWidth: 0,
style: LabelStyle.FILL,
showBackground: (profile?.label.backgroundOpacity ?? 0.72) > 0,
backgroundColor: profile
? Color.fromCssColorString(profile.label.backgroundColor).withAlpha(profile.label.backgroundOpacity)
: Color.BLACK.withAlpha(0.72),
backgroundPadding: new Cartesian2(profile?.label.paddingX ?? 10, profile?.label.paddingY ?? 7),
pixelOffset: new Cartesian2(profile?.label.offsetX ?? 10, profile?.label.offsetY ?? 0),
horizontalOrigin: HorizontalOrigin.LEFT,
verticalOrigin: VerticalOrigin.BOTTOM,
heightReference: HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
show: (profile?.label.mode ?? "attributes") !== "none"
&& (profile ? showBelowCameraHeight(viewer, profile.label.hideCameraHeightMeters) : true),
});
}
for (const entity of [...dataSource.entities.values]) {
if (typeof entity.id === "string" && !wanted.has(entity.id)) dataSource.entities.remove(entity);
}
}
syncHGeoZoneLayers(
viewer,
hGeoZoneLayers,
bindings,
presentationProfiles,
presentationFilters,
faultedHGeoZoneGeometryKeys,
);
viewer.scene.requestRender();
}
@@ -0,0 +1,348 @@
import type { Dispatch, SetStateAction } from "react";
import {
Button,
Checker,
ColorField,
ControlRow,
Icon,
InspectorSelectField,
RangeControl,
SegmentedControl,
} from "@nodedc/ui-react";
import type { SelectOption } from "@nodedc/ui-react";
import { DEFAULT_GRID_LOD_PROFILES } from "./mapGridPolicy.mjs";
import { MAX_LOCAL_GRID_INDEX } from "./mapSectorGrid.mjs";
import {
GRID_SECTOR_DIRECTIONS,
formatGridSectorArea,
gridSectorBoundsLabel,
gridSectorCenterLabel,
normalizedGraticuleStepDegrees,
normalizedMajorTileSizeKm,
type GridSectorDirection,
type SectorGridLodProfile,
} from "./mapSectorWorkspace.js";
import type {
GridMajorTileSelection,
GridSectorSelection,
} from "./mapRendererContract.js";
import type { MapPageSettings } from "./mapPageContract.js";
import type { MapInspectorSection } from "./mapInspectorSections.js";
const GRID_MODE_OPTIONS: Array<SelectOption<"3d" | "graticule">> = [
{ value: "3d", label: "3D", description: "Приподнятая пространственная сетка" },
{ value: "graticule", label: "Гратикула", description: "Проекция по поверхности" },
];
type GridSectorCopyState = "idle" | "copied" | "error";
export function buildMapGridInspectorSections({
mapSettings,
updateMapSettings,
selectedGridLod,
setSelectedGridLod,
selectedGridLodIndex,
activeGridLod,
minimumGridLodHeight,
maximumGridLodHeight,
updateGridLod,
updateGridVolumeRange,
activeGraticuleMajorStepDegrees,
selectedGridSector,
gridSectorCopyState,
copySelectedGridSectorId,
mapRendererReady,
focusGridMajorTile,
selectedGridParentLod,
sectorGridLodProfiles,
focusGridSector,
selectedGridNeighborTargets,
selectedGridSectorProfile,
selectedGridVolumeTargets,
}: {
mapSettings: MapPageSettings;
updateMapSettings: (patch: Partial<MapPageSettings>) => void;
selectedGridLod: string;
setSelectedGridLod: Dispatch<SetStateAction<string>>;
selectedGridLodIndex: number;
activeGridLod: SectorGridLodProfile;
minimumGridLodHeight: number;
maximumGridLodHeight: number;
updateGridLod: (patch: Partial<SectorGridLodProfile>) => void;
updateGridVolumeRange: (patch: Partial<Pick<SectorGridLodProfile,
"volumeMinimumHeightMeters" | "volumeMaximumHeightMeters" | "volumeBandHeightMeters">>) => void;
activeGraticuleMajorStepDegrees: number | null;
selectedGridSector: GridSectorSelection | null;
gridSectorCopyState: GridSectorCopyState;
copySelectedGridSectorId: () => Promise<void>;
mapRendererReady: boolean;
focusGridMajorTile: (tile: GridMajorTileSelection) => void;
selectedGridParentLod: GridSectorSelection | null;
sectorGridLodProfiles: SectorGridLodProfile[];
focusGridSector: (sector: GridSectorSelection | null) => void;
selectedGridNeighborTargets: Record<GridSectorDirection, GridSectorSelection | null>;
selectedGridSectorProfile: SectorGridLodProfile | null;
selectedGridVolumeTargets: { above: GridSectorSelection | null; below: GridSectorSelection | null };
}): MapInspectorSection[] {
return [
{
id: "map-grid",
label: "Сетка и LOD",
description: "first adapter control",
group: "Слои",
icon: <Icon name="grid" />,
content: <div className="catalog-map-grid-inspector">
<small className="catalog-map-inspector__note">Фиксированная московская ENU-адресация задаёт неизменные сектора на LOD 1–3. LOD 4–5 используют глобальную WGS84-гратику́лу; камера выбирает только LOD и видимую область.</small>
<Checker checked={mapSettings.gridVisible} label="Сетка" onChange={(gridVisible) => updateMapSettings({ gridVisible })} />
<Checker checked={mapSettings.grid3dEnabled} label="3D-сетка" onChange={(grid3dEnabled) => updateMapSettings({ grid3dEnabled })} />
<Checker checked={mapSettings.gridGraticuleEnabled} label="Гратикула" onChange={(gridGraticuleEnabled) => updateMapSettings({ gridGraticuleEnabled })} />
<Checker checked={mapSettings.gridLodEnabled} label="LOD по высоте камеры" onChange={(gridLodEnabled) => updateMapSettings({ gridLodEnabled })} />
<Checker checked={mapSettings.gridRebuildOnMoveEnd} label="Перестраивать после движения" onChange={(gridRebuildOnMoveEnd) => updateMapSettings({ gridRebuildOnMoveEnd })} />
<ControlRow label="Система координат"><strong>Fixed ENU · WGS84</strong></ControlRow>
<RangeControl label="Origin: широта" value={mapSettings.gridCenterLatitude} min={-89.9} max={89.9} step={0.000001} formatValue={(value) => value.toFixed(6)} onChange={(gridCenterLatitude) => updateMapSettings({ gridCenterLatitude })} />
<RangeControl label="Origin: долгота" value={mapSettings.gridCenterLongitude} min={-180} max={180} step={0.000001} formatValue={(value) => value.toFixed(6)} onChange={(gridCenterLongitude) => updateMapSettings({ gridCenterLongitude })} />
<RangeControl label="Автовыключение выше" value={mapSettings.gridAutoDisableHeightKm} min={0} max={50_000} step={100} formatValue={(value) => value === 0 ? "выкл" : `${value} км`} onChange={(gridAutoDisableHeightKm) => updateMapSettings({ gridAutoDisableHeightKm })} />
<div className="catalog-map-grid-lod-tabs">
<SegmentedControl value={selectedGridLod} items={DEFAULT_GRID_LOD_PROFILES.map((_profile, index) => ({ value: String(index), label: `LOD ${index + 1}` }))} label="Уровень детализации сетки" onChange={setSelectedGridLod} />
</div>
<RangeControl label={selectedGridLodIndex === 4 ? "Порог профиля" : "До высоты"} value={activeGridLod.maxHeightKm} min={minimumGridLodHeight} max={maximumGridLodHeight} step={0.1} formatValue={(value) => `${value} км`} onChange={(maxHeightKm) => updateGridLod({ maxHeightKm })} />
{selectedGridLodIndex === 4 ? <small className="catalog-map-inspector__note">Последний LOD остаётся активным выше своего порога до общего автовыключения.</small> : null}
<InspectorSelectField
label="Режим"
value={activeGridLod.mode}
options={GRID_MODE_OPTIONS}
onChange={(mode) => updateGridLod({
mode,
volumeEnabled: mode === "3d" && activeGridLod.volumeEnabled,
...(mode === "3d" ? {
stepKm: Math.min(50, activeGridLod.stepKm),
tileSizeKm: normalizedMajorTileSizeKm(Math.min(50, activeGridLod.stepKm), activeGridLod.tileSizeKm),
} : {
graticuleStepDegrees: normalizedGraticuleStepDegrees(activeGridLod.graticuleStepDegrees),
}),
})}
/>
<RangeControl label="Высота WGS84" value={activeGridLod.heightMeters} min={0} max={5_000} step={10} formatValue={(value) => `${value} м`} onChange={(heightMeters) => updateGridLod({ heightMeters })} />
<RangeControl label="Конус видимости 3D" value={activeGridLod.max3dViewAngleDegrees} min={30} max={170} step={1} formatValue={(value) => `${value}°`} onChange={(max3dViewAngleDegrees) => updateGridLod({ max3dViewAngleDegrees })} />
<RangeControl
label="Шаг ENU-секторов"
value={activeGridLod.stepKm}
min={0.1}
max={activeGridLod.mode === "3d" ? 50 : 5_000}
step={0.1}
formatValue={(value) => `${value} км`}
onChange={(stepKm) => updateGridLod({
stepKm,
tileSizeKm: normalizedMajorTileSizeKm(stepKm, Math.max(stepKm, activeGridLod.tileSizeKm)),
radiusKm: Math.min(activeGridLod.radiusKm, stepKm * MAX_LOCAL_GRID_INDEX),
})}
/>
<RangeControl
label="Размер major-тайла ENU"
value={activeGridLod.tileSizeKm}
min={activeGridLod.stepKm}
max={Math.max(activeGridLod.stepKm, 50)}
step={activeGridLod.stepKm}
formatValue={(value) => `${value} км`}
onChange={(tileSizeKm) => updateGridLod({ tileSizeKm: normalizedMajorTileSizeKm(activeGridLod.stepKm, tileSizeKm) })}
/>
<small className="catalog-map-inspector__note">Major-тайл содержит целое число ENU-секторов. Для гратикулы major-шаг равен пяти minor-шагам.</small>
<Checker
checked={activeGridLod.majorLinesEnabled}
label="Major-линии"
onChange={(majorLinesEnabled) => updateGridLod({
majorLinesEnabled,
majorLabelsEnabled: majorLinesEnabled && activeGridLod.majorLabelsEnabled,
})}
/>
<Checker checked={activeGridLod.majorLabelsEnabled} disabled={!activeGridLod.majorLinesEnabled} label="Подписи major-тайлов" onChange={(majorLabelsEnabled) => updateGridLod({ majorLabelsEnabled })} />
<RangeControl label="Толщина major-линий" value={activeGridLod.majorLineWidthMultiplier} min={1} max={8} step={0.1} formatValue={(value) => `×${value.toFixed(1)}`} onChange={(majorLineWidthMultiplier) => updateGridLod({ majorLineWidthMultiplier })} />
<small className="catalog-map-inspector__note">Прозрачность major-линий наследует прозрачность линий текущего LOD.</small>
{activeGridLod.mode === "graticule" && activeGridLod.majorLinesEnabled && activeGraticuleMajorStepDegrees === null
? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">Major-разметка недоступна для этого шага: пять minor-интервалов должны точно делить 90°-квадрант.</small>
: null}
<RangeControl label="Радиус ENU-поля" value={activeGridLod.radiusKm} min={1} max={Math.min(100_000, activeGridLod.stepKm * MAX_LOCAL_GRID_INDEX)} step={1} formatValue={(value) => `${value} км`} onChange={(radiusKm) => updateGridLod({ radiusKm })} />
<RangeControl label="Диаметр 3D-линий" value={activeGridLod.lineDiameterMeters} min={1} max={100} step={1} formatValue={(value) => `${value} м`} onChange={(lineDiameterMeters) => updateGridLod({ lineDiameterMeters })} />
<ControlRow label="Цвет 3D-линий"><ColorField label="Цвет линий ENU-сетки" value={activeGridLod.lineColor} onChange={(lineColor) => updateGridLod({ lineColor })} /></ControlRow>
<RangeControl label="Прозрачность 3D-линий" value={activeGridLod.lineOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(lineOpacity) => updateGridLod({ lineOpacity })} />
<Checker checked={activeGridLod.dotsEnabled} label="Кружки" onChange={(dotsEnabled) => updateGridLod({ dotsEnabled })} />
<RangeControl label="Кружки: диаметр" value={activeGridLod.dotsDiameterMeters} min={1} max={1_000} step={1} formatValue={(value) => `${value} м`} onChange={(dotsDiameterMeters) => updateGridLod({ dotsDiameterMeters })} />
<ControlRow label="Кружки: цвет"><ColorField label="Цвет кружков сетки" value={activeGridLod.dotsColor} onChange={(dotsColor) => updateGridLod({ dotsColor })} /></ControlRow>
<RangeControl label="Кружки: прозрачность" value={activeGridLod.dotsOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(dotsOpacity) => updateGridLod({ dotsOpacity })} />
<Checker checked={activeGridLod.crossesEnabled} label="Кресты" onChange={(crossesEnabled) => updateGridLod({ crossesEnabled })} />
<RangeControl label="Кресты: длина" value={activeGridLod.crossesLengthMeters} min={2} max={5_000} step={2} formatValue={(value) => `${value} м`} onChange={(crossesLengthMeters) => updateGridLod({ crossesLengthMeters })} />
<RangeControl label="Кресты: ширина" value={activeGridLod.crossesWidthMeters} min={1} max={500} step={1} formatValue={(value) => `${value} м`} onChange={(crossesWidthMeters) => updateGridLod({ crossesWidthMeters })} />
<ControlRow label="Кресты: цвет"><ColorField label="Цвет крестов сетки" value={activeGridLod.crossesColor} onChange={(crossesColor) => updateGridLod({ crossesColor })} /></ControlRow>
<RangeControl label="Кресты: прозрачность" value={activeGridLod.crossesOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(crossesOpacity) => updateGridLod({ crossesOpacity })} />
<RangeControl label="Шаг гратикулы" value={activeGridLod.graticuleStepDegrees} min={0.1} max={10} step={0.05} formatValue={(value) => `${value}°`} onChange={(graticuleStepDegrees) => updateGridLod({ graticuleStepDegrees: normalizedGraticuleStepDegrees(graticuleStepDegrees) })} />
<RangeControl label="Толщина гратикулы" value={activeGridLod.graticuleLineWidthPx} min={1} max={3} step={1} formatValue={(value) => `${value} px`} onChange={(graticuleLineWidthPx) => updateGridLod({ graticuleLineWidthPx })} />
<ControlRow label="Цвет гратикулы"><ColorField label="Цвет WGS84-гратику́лы" value={activeGridLod.graticuleColor} onChange={(graticuleColor) => updateGridLod({ graticuleColor })} /></ControlRow>
<RangeControl label="Прозрачность гратикулы" value={activeGridLod.graticuleOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(graticuleOpacity) => updateGridLod({ graticuleOpacity })} />
{activeGridLod.mode === "3d" ? <>
<Checker checked={activeGridLod.volumeEnabled} label="Объёмный выбор сектора" onChange={(volumeEnabled) => updateGridLod({ volumeEnabled })} />
<RangeControl
label="Нижняя отметка объёма"
value={activeGridLod.volumeMinimumHeightMeters}
min={-1_000}
max={activeGridLod.volumeMaximumHeightMeters - 1}
step={10}
formatValue={(value) => `${value} м WGS84`}
onChange={(volumeMinimumHeightMeters) => updateGridVolumeRange({ volumeMinimumHeightMeters })}
/>
<RangeControl
label="Верхняя отметка объёма"
value={activeGridLod.volumeMaximumHeightMeters}
min={activeGridLod.volumeMinimumHeightMeters + 1}
max={10_000}
step={10}
formatValue={(value) => `${value} м WGS84`}
onChange={(volumeMaximumHeightMeters) => updateGridVolumeRange({ volumeMaximumHeightMeters })}
/>
<RangeControl
label="Высота адресного диапазона"
value={activeGridLod.volumeBandHeightMeters}
min={1}
max={1_000}
step={10}
formatValue={(value) => `${value} м`}
onChange={(volumeBandHeightMeters) => updateGridVolumeRange({ volumeBandHeightMeters })}
/>
<small className="catalog-map-inspector__note">Горизонтальный ID сектора остаётся стабильным. Высотный band добавляется как отдельный адрес внутри выбранной ENU-ячейки.</small>
</> : null}
<ControlRow label="Цвет заливки сектора">
<ColorField
label="Цвет заливки выбранного сектора"
value={activeGridLod.selectionFillColor}
onChange={(selectionFillColor) => updateGridLod({ selectionFillColor })}
/>
</ControlRow>
<RangeControl
label="Прозрачность заливки"
value={activeGridLod.selectionFillOpacityPercent}
min={0}
max={100}
step={1}
formatValue={(value) => `${value}%`}
onChange={(selectionFillOpacityPercent) => updateGridLod({ selectionFillOpacityPercent })}
/>
<ControlRow label="Цвет линии сектора">
<ColorField
label="Цвет линии выбранного сектора"
value={activeGridLod.selectionOutlineColor}
onChange={(selectionOutlineColor) => updateGridLod({ selectionOutlineColor })}
/>
</ControlRow>
<RangeControl
label="Толщина линии"
value={activeGridLod.selectionOutlineWidthPx}
min={1}
max={12}
step={0.25}
formatValue={(value) => `${value} px`}
onChange={(selectionOutlineWidthPx) => updateGridLod({ selectionOutlineWidthPx })}
/>
<RangeControl
label="Прозрачность линии"
value={activeGridLod.selectionOutlineOpacityPercent}
min={0}
max={100}
step={1}
formatValue={(value) => `${value}%`}
onChange={(selectionOutlineOpacityPercent) => updateGridLod({ selectionOutlineOpacityPercent })}
/>
<small className="catalog-map-inspector__note">Оформление применяется к выбранному сектору текущего LOD.</small>
<section className="catalog-map-grid-sector" aria-label="Выбранный сектор">
<ControlRow label="Выбранный сектор"><strong className="catalog-map-grid-sector-id">{selectedGridSector?.id ?? "Нажмите сектор на карте"}</strong></ControlRow>
{selectedGridSector ? <>
<Button
variant="secondary"
size="compact"
width="full"
shape="pill"
icon={<Icon name={gridSectorCopyState === "copied" ? "check" : "copy"} />}
onClick={() => void copySelectedGridSectorId()}
>{gridSectorCopyState === "copied" ? "ID скопирован" : "Копировать stable ID"}</Button>
{gridSectorCopyState === "error" ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">Не удалось записать ID в буфер обмена.</small> : null}
<div className="catalog-map-grid-sector__facts">
<ControlRow label="Family / LOD"><strong>{selectedGridSector.mode === "3d" ? "Local ENU" : "WGS84 graticule"} · LOD {selectedGridSector.lod}</strong></ControlRow>
<ControlRow label="Адрес"><span>{selectedGridSector.label}</span></ControlRow>
<ControlRow label="Границы"><span>{gridSectorBoundsLabel(selectedGridSector)}</span></ControlRow>
<ControlRow label="Центр"><span>{gridSectorCenterLabel(selectedGridSector)}</span></ControlRow>
<ControlRow label="Площадь"><strong>{formatGridSectorArea(selectedGridSector.areaSquareMeters)}</strong></ControlRow>
</div>
{selectedGridSector.parentMajorTile ? <div className="catalog-map-grid-sector__relation">
<small>Parent major tile · {selectedGridSector.parentMajorTile.label}</small>
<code title={selectedGridSector.parentMajorTile.id}>{selectedGridSector.parentMajorTile.id}</code>
<small>{selectedGridSector.parentMajorTile.minorPerSide} × {selectedGridSector.parentMajorTile.minorPerSide} · {selectedGridSector.parentMajorTile.childCount} дочерних секторов · {formatGridSectorArea(selectedGridSector.parentMajorTile.areaSquareMeters)}</small>
<Button
variant="secondary"
size="compact"
width="full"
data-grid-navigation-intent="parent-major"
disabled={!mapRendererReady}
onClick={() => focusGridMajorTile(selectedGridSector.parentMajorTile!)}
>Фокус major-тайла</Button>
</div> : <small className="catalog-map-inspector__note">Parent major tile выключен или недоступен для текущей топологии.</small>}
<div className="catalog-map-grid-sector__relation">
<small>Следующий LOD</small>
{selectedGridParentLod ? <>
<code title={selectedGridParentLod.id}>{selectedGridParentLod.id}</code>
<Button
variant="secondary"
size="compact"
width="full"
data-grid-navigation-intent="next-lod"
disabled={!mapRendererReady}
onClick={() => focusGridSector(selectedGridParentLod)}
>Перейти в LOD {selectedGridParentLod.lod}</Button>
</> : <span>{selectedGridSector.lod >= sectorGridLodProfiles.length
? "Верхний уровень иерархии"
: `LOD ${selectedGridSector.lod + 1} меняет систему адресации`}</span>}
</div>
<div className="catalog-map-grid-sector__neighbors" aria-label="Соседние сектора">
{GRID_SECTOR_DIRECTIONS.map(({ id, label }) => {
const target = selectedGridNeighborTargets[id];
return <div className="catalog-map-grid-sector__neighbor" key={id}>
<Button
variant="secondary"
size="compact"
width="full"
data-grid-navigation-intent={id}
disabled={!target || !mapRendererReady}
onClick={() => focusGridSector(target)}
>{label}</Button>
<code title={target?.id}>{target?.id ?? "Граница адресного пространства"}</code>
</div>;
})}
</div>
{selectedGridSector.mode === "3d" && selectedGridSectorProfile ? <div className="catalog-map-grid-sector__volume" data-enabled={selectedGridSectorProfile.volumeEnabled || undefined}>
<ControlRow label="Высотный выбор"><strong>{selectedGridSectorProfile.volumeEnabled ? "Включён" : "Выключен"}</strong></ControlRow>
<ControlRow label="Floor"><span>{selectedGridSector.volume?.floor ?? selectedGridSectorProfile.volumeMinimumHeightMeters} м WGS84</span></ControlRow>
<ControlRow label="Ceiling"><span>{selectedGridSector.volume?.ceiling ?? selectedGridSectorProfile.volumeMaximumHeightMeters} м WGS84</span></ControlRow>
<ControlRow label="Height band"><span>{selectedGridSector.volume?.bandHeight ?? selectedGridSectorProfile.volumeBandHeightMeters} м</span></ControlRow>
{selectedGridSector.volume ? <code title={selectedGridSector.volume.id}>{selectedGridSector.volume.id}</code> : null}
{selectedGridSectorProfile.volumeEnabled ? <div className="catalog-map-grid-sector__volume-actions">
<Button
variant="secondary"
size="compact"
width="full"
data-grid-navigation-intent="below"
disabled={!selectedGridVolumeTargets.below || !mapRendererReady}
onClick={() => focusGridSector(selectedGridVolumeTargets.below)}
>Ниже</Button>
<Button
variant="secondary"
size="compact"
width="full"
data-grid-navigation-intent="above"
disabled={!selectedGridVolumeTargets.above || !mapRendererReady}
onClick={() => focusGridSector(selectedGridVolumeTargets.above)}
>Выше</Button>
</div> : null}
</div> : null}
</> : <small className="catalog-map-inspector__note">Кликните ячейку, чтобы получить устойчивый адрес, геометрию и навигацию по соседям.</small>}
</section>
</div>,
},
];
}
+1 -1
View File
@@ -1,4 +1,4 @@
import type { MapPresentation } from "./CesiumMapRenderer.js"; import type { MapPresentation } from "./mapRendererContract.js";
export type GridLodMode = "3d" | "graticule"; export type GridLodMode = "3d" | "graticule";
export type GridLodProfile = { export type GridLodProfile = {
+428
View File
@@ -0,0 +1,428 @@
import type { Dispatch, ReactNode, SetStateAction } from "react";
import {
Button,
Checker,
ColorField,
ControlRow,
Icon,
InspectorSelectField,
RangeControl,
} from "@nodedc/ui-react";
import type { SelectOption } from "@nodedc/ui-react";
import {
OSM_BUILDINGS_OBSERVED_BAND_COUNT,
cameraSurveySpiralDistance,
type CameraSurveySelection,
} from "./mapCameraPresets.js";
import {
isMapReferencePresentationProfile,
type MapReferenceLayer,
} from "./mapReferenceStations.js";
import type { MapPageSettings } from "./mapPageContract.js";
import type { MapGatewayHealth, MapProviderStatus } from "./mapRendererContract.js";
import type { MapPresentationProfile } from "./mapPresentationProfile.js";
import type { MapSelectableEntity } from "./mapWorkspaceModel.mjs";
export type MapInspectorSection = {
id: string;
label: string;
description: string;
group: string;
icon: ReactNode;
content: ReactNode;
};
type UpdateMapSettings = (patch: Partial<MapPageSettings>) => void;
type UpdatePresentationProfile = (
profileId: string,
updater: (profile: MapPresentationProfile) => MapPresentationProfile,
) => void;
type UpdatePresentationStyle = (
profileId: string,
styleId: string,
patch: Partial<MapPresentationProfile["styles"][number]>,
) => void;
type GatewayCheckState = "idle" | "checking" | "ready" | "stale" | "error";
const providerStateLabel: Record<MapProviderStatus["imagery"], string> = {
loading: "загружается",
ready: "готов",
error: "недоступен",
"not-configured": "не настроен",
};
const logarithmicControlValue = (value: number) => Math.log10(Math.max(Number.MIN_VALUE, value));
const valueFromLogarithmicControl = (value: number) => Math.max(1, Math.round(10 ** value));
export const formatMetricDistance = (value: number) => value >= 1000
? `${(value / 1000).toLocaleString("ru-RU", { maximumFractionDigits: value >= 10_000 ? 0 : 1 })} км`
: `${Math.round(value)} м`;
const formatMetricSpeed = (value: number) => value >= 1000
? `${(value / 1000).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} км/с`
: `${Math.round(value)} м/с`;
const formatDuration = (seconds: number) => {
if (seconds >= 86_400) return `${(seconds / 86_400).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} сут`;
if (seconds >= 3_600) return `${(seconds / 3_600).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} ч`;
if (seconds >= 60) return `${Math.round(seconds / 60)} мин`;
return `${Math.max(1, Math.round(seconds))} с`;
};
export function buildMapSurfaceInspectorSections({
mapSettings,
providerStatus,
updateMapSettings,
}: {
mapSettings: MapPageSettings;
providerStatus: MapProviderStatus;
updateMapSettings: UpdateMapSettings;
}): MapInspectorSection[] {
return [
{
id: "map-base",
label: "Подложка и terrain",
description: "provider-neutral surface",
group: "Карта",
icon: <Icon name="globe" />,
content: <>
<ControlRow label="Подложка"><strong>Cesium World Imagery</strong></ControlRow>
<small className="catalog-map-inspector__note">Текущий официальный provider. Другие provider-слои появятся только после отдельного asset-контракта Platform.</small>
<ControlRow label="Live providers"><span>Imagery: {providerStateLabel[providerStatus.imagery]} · Terrain: {providerStateLabel[providerStatus.terrain]} · 3D: {providerStateLabel[providerStatus.buildings]}</span></ControlRow>
{Object.entries(providerStatus.errors).map(([provider, error]) => <small key={provider} className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{provider}: {error}</small>)}
<small className="catalog-map-inspector__note">Рельеф — отдельный слой под imagery.</small>
<Checker checked={mapSettings.terrainEnabled} label="Terrain" onChange={(terrainEnabled) => updateMapSettings({ terrainEnabled })} />
<RangeControl label="Вертикальное преувеличение рельефа" value={mapSettings.terrainExaggeration * 100} min={25} max={300} formatValue={(value) => `${(value / 100).toFixed(2)}×`} onChange={(value) => updateMapSettings({ terrainExaggeration: value / 100 })} />
<Checker checked={mapSettings.monochrome} label="Монохромная поверхность" onChange={(monochrome) => updateMapSettings({ monochrome })} />
<ControlRow label="Цвет монохрома"><ColorField label="Цвет монохромной поверхности" value={mapSettings.monochromeColor} onChange={(monochromeColor) => updateMapSettings({ monochromeColor })} /></ControlRow>
<RangeControl label="Яркость" value={mapSettings.imageryBrightness} min={0} max={200} formatValue={(value) => `${value}%`} onChange={(imageryBrightness) => updateMapSettings({ imageryBrightness })} />
<RangeControl label="Контраст" value={mapSettings.imageryContrast} min={0} max={200} formatValue={(value) => `${value}%`} onChange={(imageryContrast) => updateMapSettings({ imageryContrast })} />
<RangeControl label="Насыщенность" value={mapSettings.imagerySaturation} min={0} max={200} formatValue={(value) => `${value}%`} onChange={(imagerySaturation) => updateMapSettings({ imagerySaturation })} />
<RangeControl label="Гамма" value={mapSettings.imageryGamma} min={0} max={300} formatValue={(value) => `${value}%`} onChange={(imageryGamma) => updateMapSettings({ imageryGamma })} />
<RangeControl label="Оттенок" value={mapSettings.imageryHue} min={-180} max={180} formatValue={(value) => `${value}°`} onChange={(imageryHue) => updateMapSettings({ imageryHue })} />
<RangeControl label="Прозрачность imagery" value={mapSettings.imageryAlpha} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(imageryAlpha) => updateMapSettings({ imageryAlpha })} />
<ControlRow label="Цвет планеты"><ColorField label="Цвет terrain без imagery" value={mapSettings.globeColor} onChange={(globeColor) => updateMapSettings({ globeColor })} /></ControlRow>
<ControlRow label="Фон сцены"><ColorField label="Цвет фона сцены" value={mapSettings.backgroundColor} onChange={(backgroundColor) => updateMapSettings({ backgroundColor })} /></ControlRow>
</>,
},
{
id: "map-atmosphere",
label: "Атмосфера и освещение",
description: "scene / color correction",
group: "Карта",
icon: <Icon name="activity" />,
content: <>
<Checker checked={mapSettings.atmosphereEnabled} label="Показывать атмосферу" onChange={(atmosphereEnabled) => updateMapSettings({ atmosphereEnabled })} />
<RangeControl label="Атмосфера: оттенок" value={mapSettings.atmosphereHue} min={-100} max={100} formatValue={(value) => `${value}%`} onChange={(atmosphereHue) => updateMapSettings({ atmosphereHue })} />
<RangeControl label="Атмосфера: насыщенность" value={mapSettings.atmosphereSaturation} min={-100} max={100} formatValue={(value) => `${value}%`} onChange={(atmosphereSaturation) => updateMapSettings({ atmosphereSaturation })} />
<RangeControl label="Атмосфера: яркость" value={mapSettings.atmosphereBrightness} min={-100} max={100} formatValue={(value) => `${value}%`} onChange={(atmosphereBrightness) => updateMapSettings({ atmosphereBrightness })} />
<Checker checked={mapSettings.fogEnabled} label="Туман" onChange={(fogEnabled) => updateMapSettings({ fogEnabled })} />
<RangeControl label="Плотность тумана" value={mapSettings.fogDensity} min={0} max={100} formatValue={(value) => `${(value / 10000).toFixed(4)}`} onChange={(fogDensity) => updateMapSettings({ fogDensity })} />
<Checker checked={mapSettings.sunEnabled} label="Солнечное освещение" onChange={(sunEnabled) => updateMapSettings({ sunEnabled })} />
<RangeControl label="Час солнца" value={mapSettings.sunHour} min={0} max={24} formatValue={(value) => `${value}:00 UTC`} onChange={(sunHour) => updateMapSettings({ sunHour })} />
<RangeControl label="Интенсивность света" value={mapSettings.sunIntensity} min={0} max={200} formatValue={(value) => `${value}%`} onChange={(sunIntensity) => updateMapSettings({ sunIntensity })} />
<Checker checked={mapSettings.shadowsEnabled} label="Тени" onChange={(shadowsEnabled) => updateMapSettings({ shadowsEnabled })} />
</>,
},
{
id: "map-buildings",
label: "3D здания",
description: "3D Tiles / detail",
group: "Карта",
icon: <Icon name="building" />,
content: <>
<Checker checked={mapSettings.buildingsVisible} label="Показывать 3D здания" onChange={(buildingsVisible) => updateMapSettings({ buildingsVisible })} />
<ControlRow label="Цвет"><ColorField label="Цвет зданий" value={mapSettings.buildingsColor} onChange={(buildingsColor) => updateMapSettings({ buildingsColor })} /></ControlRow>
<RangeControl label="Прозрачность" value={Math.round(mapSettings.buildingsOpacity * 100)} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(value) => updateMapSettings({ buildingsOpacity: value / 100 })} />
<RangeControl label="Детализация" value={mapSettings.buildingsDetail} min={4} max={32} formatValue={(value) => `SSE ${value}`} onChange={(buildingsDetail) => updateMapSettings({ buildingsDetail })} />
</>,
},
];
}
export function buildMapPresentationInspectorSections({
presentationProfiles,
referenceLayers,
setReferenceLayers,
updatePresentationProfile,
updatePresentationStyle,
}: {
presentationProfiles: MapPresentationProfile[];
referenceLayers: MapReferenceLayer[];
setReferenceLayers: Dispatch<SetStateAction<MapReferenceLayer[]>>;
updatePresentationProfile: UpdatePresentationProfile;
updatePresentationStyle: UpdatePresentationStyle;
}): MapInspectorSection[] {
return [
...presentationProfiles.flatMap((profile) => {
const referenceProfile = isMapReferencePresentationProfile(profile);
const referenceLayer = referenceLayers.find((layer) => layer.presentationProfileId === profile.id);
return [
{
id: `map-target-${profile.id}`,
label: referenceProfile ? profile.title : profile.target.variant === "surface-fill" ? "HGeoZone" : "Таргет",
description: profile.target.variant === "surface-fill" ? `проекция · ${profile.title}` : profile.title,
group: referenceProfile ? "Станции" : profile.target.variant === "surface-fill" ? "Слои" : "Таргеты",
icon: <Icon name={referenceProfile ? "globe" : profile.target.variant === "surface-fill" ? "grid" : "target"} />,
content: <>
<small className="catalog-map-inspector__note">Профиль принадлежит этой странице Application и управляется тем же provider-neutral MCP-контрактом. Исходный API в настройках отсутствует.</small>
{referenceLayer ? (
<Checker
checked={referenceLayer.visible}
label={`Показывать слой «${profile.title}»`}
onChange={(visible) => setReferenceLayers((current) => current.map((layer) => (
layer.id === referenceLayer.id ? { ...layer, visible } : layer
)))}
/>
) : null}
{profile.target.variant === "surface-fill" && <>
<ControlRow label="Тип слоя"><strong>HGeoZone · ground projection</strong></ControlRow>
{profile.styles.map((style) => {
const classLabels = profile.classes.filter((item) => item.styleId === style.id).map((item) => item.label);
const label = classLabels.length ? classLabels.join(" · ") : style.id;
return <div className="catalog-map-inspector__style" key={style.id}>
<ControlRow label={`Заливка · ${label}`}><ColorField label={`Цвет заливки: ${label}`} value={style.color} onChange={(color) => updatePresentationStyle(profile.id, style.id, { color })} /></ControlRow>
<RangeControl label={`Прозрачность заливки · ${label}`} value={Math.round(style.opacity * 100)} min={0} max={100} step={1} formatValue={(value) => `${value}%`} onChange={(value) => updatePresentationStyle(profile.id, style.id, { opacity: value / 100 })} />
</div>;
})}
<ControlRow label="Граница"><ColorField label="Цвет границы HGeoZone" value={profile.target.outlineColor} onChange={(outlineColor) => updatePresentationProfile(profile.id, (current) => current.target.variant === "surface-fill" ? ({ ...current, target: { ...current.target, outlineColor } }) : current)} /></ControlRow>
<RangeControl label="Прозрачность границы" value={Math.round(profile.target.outlineOpacity * 100)} min={0} max={100} step={1} formatValue={(value) => `${value}%`} onChange={(value) => updatePresentationProfile(profile.id, (current) => current.target.variant === "surface-fill" ? ({ ...current, target: { ...current.target, outlineOpacity: value / 100 } }) : current)} />
<RangeControl label="Толщина границы" value={profile.target.outlineWidthPx} min={0} max={8} step={0.5} formatValue={(value) => `${value} px`} onChange={(outlineWidthPx) => updatePresentationProfile(profile.id, (current) => current.target.variant === "surface-fill" ? ({ ...current, target: { ...current.target, outlineWidthPx } }) : current)} />
</>}
{profile.target.variant === "elevated-spike" && <>
<RangeControl label="Высота таргета" value={profile.target.stemHeightMeters} min={100} max={10_000} step={50} formatValue={(value) => `${value} м`} onChange={(stemHeightMeters) => updatePresentationProfile(profile.id, (current) => current.target.variant === "elevated-spike" ? ({ ...current, target: { ...current.target, stemHeightMeters } }) : current)} />
<RangeControl label="Размер головки" value={profile.target.headSizePx} min={1} max={32} step={1} formatValue={(value) => `${value} px`} onChange={(headSizePx) => updatePresentationProfile(profile.id, (current) => current.target.variant === "elevated-spike" ? ({ ...current, target: { ...current.target, headSizePx } }) : current)} />
<RangeControl label="Толщина стержня" value={profile.target.stemWidthPx} min={0.25} max={12} step={0.25} formatValue={(value) => `${value} px`} onChange={(stemWidthPx) => updatePresentationProfile(profile.id, (current) => current.target.variant === "elevated-spike" ? ({ ...current, target: { ...current.target, stemWidthPx } }) : current)} />
</>}
<InspectorSelectField
label="Подпись"
value={profile.label.mode}
options={[
{ value: "subject_id", label: "ID", description: "Стабильный идентификатор сущности" },
{ value: "attributes", label: "Имя", description: "Первое доступное display-поле" },
{ value: "none", label: "Нет", description: "Не показывать плашку" },
]}
onChange={(mode) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, mode } }))}
/>
<RangeControl label="Размер подписи" value={profile.label.sizePx} min={8} max={32} step={1} formatValue={(value) => `${value} px`} onChange={(sizePx) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, sizePx } }))} />
<RangeControl label="Смещение подписи X" value={profile.label.offsetX} min={-100} max={100} step={1} formatValue={(value) => `${value} px`} onChange={(offsetX) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, offsetX } }))} />
<RangeControl label="Смещение подписи Y" value={profile.label.offsetY} min={-100} max={100} step={1} formatValue={(value) => `${value} px`} onChange={(offsetY) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, offsetY } }))} />
<RangeControl label="Скрывать подпись выше" value={profile.label.hideCameraHeightMeters} min={1_000} max={500_000} step={1_000} formatValue={(value) => `${Math.round(value / 1_000)} км`} onChange={(hideCameraHeightMeters) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, hideCameraHeightMeters } }))} />
<RangeControl label={profile.target.variant === "surface-fill" ? "Скрывать HGeoZone выше" : "Скрывать таргет выше"} value={profile.target.hideCameraHeightMeters} min={1_000} max={500_000} step={1_000} formatValue={(value) => `${Math.round(value / 1_000)} км`} onChange={(hideCameraHeightMeters) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, hideCameraHeightMeters } }))} />
<ControlRow label="Фон плашки"><ColorField label="Цвет фона подписи" value={profile.label.backgroundColor} onChange={(backgroundColor) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, backgroundColor } }))} /></ControlRow>
<RangeControl label="Прозрачность плашки" value={Math.round(profile.label.backgroundOpacity * 100)} min={0} max={100} step={1} formatValue={(value) => `${value}%`} onChange={(value) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, backgroundOpacity: value / 100 } }))} />
</>,
},
...(profile.target.variant === "surface-fill" || referenceProfile ? [] : [{
id: `map-state-classes-${profile.id}`,
label: "Классы состояния",
description: "нормализованные фасеты онтологии",
group: "Таргеты",
icon: <Icon name="sliders" />,
content: <>
<small className="catalog-map-inspector__note">Цвета назначены семантическим классам после нормализации данных. Здесь нет названий provider-статусов и привязки к транспорту.</small>
{profile.styles.map((style) => {
const classLabels = profile.classes.filter((item) => item.styleId === style.id).map((item) => item.label);
const label = classLabels.length ? classLabels.join(" · ") : style.id;
return <div className="catalog-map-inspector__style" key={style.id}>
<ControlRow label={label}><ColorField label={`Цвет: ${label}`} value={style.color} onChange={(color) => updatePresentationStyle(profile.id, style.id, { color })} /></ControlRow>
<RangeControl label={`${label}: прозрачность`} value={Math.round(style.opacity * 100)} min={0} max={100} step={1} formatValue={(value) => `${value}%`} onChange={(value) => updatePresentationStyle(profile.id, style.id, { opacity: value / 100 })} />
</div>;
})}
</>,
}]),
];
}),
];
}
export function buildMapRuntimeInspectorSections({
animationModeEnabled,
setAnimationMode,
spiralPresetId,
spiralPresetOptions,
spiralRunning,
selectSpiralPreset,
spiralHeightMeters,
setSpiralHeightMeters,
spiralSpeedMetersPerSecond,
setSpiralSpeedMetersPerSecond,
spiralPitchMetersPerTurn,
setSpiralPitchMetersPerTurn,
spiralTargetRadiusMeters,
setSpiralTargetRadiusMeters,
setSpiralPresetId,
spiralCanStart,
spiralTileCacheReady,
providerStatus,
gatewayHealth,
gatewayCheckState,
toggleSpiralAnimation,
spiralMessage,
mapSettings,
setCacheEnabled,
setCacheNoOverwrite,
gatewayEndpoint,
liveCacheSummary,
refreshCurrentViewport,
cacheRefresh,
verifyGateway,
transportDiagnostic,
gatewayHealthAge,
gatewayCheckError,
selected,
}: {
animationModeEnabled: boolean;
setAnimationMode: (enabled: boolean) => void;
spiralPresetId: CameraSurveySelection;
spiralPresetOptions: Array<SelectOption<CameraSurveySelection>>;
spiralRunning: boolean;
selectSpiralPreset: (presetId: CameraSurveySelection) => void;
spiralHeightMeters: number;
setSpiralHeightMeters: Dispatch<SetStateAction<number>>;
spiralSpeedMetersPerSecond: number;
setSpiralSpeedMetersPerSecond: Dispatch<SetStateAction<number>>;
spiralPitchMetersPerTurn: number;
setSpiralPitchMetersPerTurn: Dispatch<SetStateAction<number>>;
spiralTargetRadiusMeters: number;
setSpiralTargetRadiusMeters: Dispatch<SetStateAction<number>>;
setSpiralPresetId: Dispatch<SetStateAction<CameraSurveySelection>>;
spiralCanStart: boolean;
spiralTileCacheReady: boolean;
providerStatus: MapProviderStatus;
gatewayHealth: MapGatewayHealth | null;
gatewayCheckState: GatewayCheckState;
toggleSpiralAnimation: () => void;
spiralMessage: string | null;
mapSettings: MapPageSettings;
setCacheEnabled: (enabled: boolean) => void;
setCacheNoOverwrite: (enabled: boolean) => void;
gatewayEndpoint: string | null;
liveCacheSummary: string;
refreshCurrentViewport: () => void;
cacheRefresh: boolean;
verifyGateway: () => void | Promise<void>;
transportDiagnostic: string | null;
gatewayHealthAge: string | null;
gatewayCheckError: string | null;
selected: MapSelectableEntity | undefined;
}): MapInspectorSection[] {
return [
{
id: "map-camera-animation",
label: "Анимация камеры",
description: "geodesic spiral survey",
group: "Камера",
icon: <Icon name="activity" />,
content: <>
<Checker checked={animationModeEnabled} label="Режим анимации" onChange={setAnimationMode} />
{animationModeEnabled ? <>
<small className="catalog-map-inspector__note">Стартовая точка берётся из текущей позиции камеры. Камера смотрит почти в надир, а маршрут ждёт текущие tiles перед продолжением. Движение идёт по региональной геодезической спирали WGS84 до выбранного радиуса.</small>
<InspectorSelectField
label="Профиль покрытия"
value={spiralPresetId}
options={spiralPresetOptions}
disabled={spiralRunning}
onChange={selectSpiralPreset}
/>
<small className="catalog-map-inspector__note">У текущего OSM Buildings подтверждено {OSM_BUILDINGS_OBSERVED_BAND_COUNT} иерархических bands. Десять профилей управляют высотой и покрытием; фактический LOD Cesium выбирает по SSE, viewport и расстоянию.</small>
<ControlRow label="Слои прохода"><small>Imagery · Terrain · OSM Buildings</small></ControlRow>
<RangeControl
label="Высота над землёй"
value={logarithmicControlValue(spiralHeightMeters)}
min={logarithmicControlValue(10)}
max={logarithmicControlValue(100_000)}
step={0.01}
disabled={spiralRunning}
formatValue={(value) => formatMetricDistance(10 ** value)}
onChange={(value) => {
setSpiralPresetId("custom");
setSpiralHeightMeters(valueFromLogarithmicControl(value));
}}
/>
<RangeControl
label="Скорость камеры"
value={logarithmicControlValue(spiralSpeedMetersPerSecond)}
min={logarithmicControlValue(1)}
max={logarithmicControlValue(5_000)}
step={0.01}
disabled={spiralRunning}
formatValue={(value) => formatMetricSpeed(10 ** value)}
onChange={(value) => {
setSpiralPresetId("custom");
setSpiralSpeedMetersPerSecond(valueFromLogarithmicControl(value));
}}
/>
<RangeControl
label="Шаг спирали"
value={logarithmicControlValue(spiralPitchMetersPerTurn)}
min={logarithmicControlValue(20)}
max={logarithmicControlValue(100_000)}
step={0.01}
disabled={spiralRunning}
formatValue={(value) => formatMetricDistance(10 ** value)}
onChange={(value) => {
setSpiralPresetId("custom");
setSpiralPitchMetersPerTurn(valueFromLogarithmicControl(value));
}}
/>
<RangeControl
label="Радиус прохода"
value={logarithmicControlValue(spiralTargetRadiusMeters)}
min={logarithmicControlValue(1_000)}
max={logarithmicControlValue(250_000)}
step={0.01}
disabled={spiralRunning}
formatValue={(value) => formatMetricDistance(10 ** value)}
onChange={(value) => {
setSpiralPresetId("custom");
setSpiralTargetRadiusMeters(valueFromLogarithmicControl(value));
}}
/>
<small className="catalog-map-inspector__note">Расчётное движение без ожидания сети: {formatDuration(cameraSurveySpiralDistance(spiralTargetRadiusMeters, spiralPitchMetersPerTurn) / spiralSpeedMetersPerSecond)}. Tile waits и автоматическое сужение шага под viewport увеличат фактическое время.</small>
{!spiralCanStart && !spiralRunning ? <small className="catalog-map-inspector__note" role="status">Подготовка: imagery — {providerStateLabel[providerStatus.imagery]}, terrain — {providerStateLabel[providerStatus.terrain]}, OSM Buildings — {providerStateLabel[providerStatus.buildings]}, TileCache — {spiralTileCacheReady ? "готов" : gatewayHealth?.cache?.atCapacity ? "заполнен" : gatewayCheckState === "checking" ? "проверяется" : "недоступен для записи"}.</small> : null}
<Button variant="secondary" shape="pill" onClick={toggleSpiralAnimation} disabled={!spiralRunning && !spiralCanStart}>{spiralRunning ? "Остановить" : "Запустить режим анимации"}</Button>
{spiralRunning ? <small className="catalog-map-inspector__note">Камера движется от исходной точки. Выключение режима, уход со страницы или reload остановят сессию.</small> : null}
{spiralMessage ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="status">{spiralMessage}</small> : null}
</> : null}
</>,
},
{
id: "map-cache",
label: "TileCache",
description: "Platform Map Gateway",
group: "Хранение",
icon: <Icon name="database" />,
content: <>
<small className="catalog-map-inspector__note">Общий persistent cache Platform: он не принадлежит приложению, странице или пользователю.</small>
<Checker checked={mapSettings.cacheEnabled} label="Кэшировать live-данные" onChange={setCacheEnabled} />
<small className="catalog-map-inspector__note">Cache hit отдаётся как есть; новый tile записывается только при miss.</small>
<Checker checked={mapSettings.cacheNoOverwrite} disabled={!mapSettings.cacheEnabled} label="Не перезаписывать уже полученное" onChange={setCacheNoOverwrite} />
<ControlRow className="catalog-map-inspector__cache-fact" label="Режим"><span>{mapSettings.cacheEnabled ? mapSettings.cacheNoOverwrite ? "Live + Cache · append-only" : "Live + Cache · обновление разрешено" : "Live без persistent cache"}</span></ControlRow>
<ControlRow className="catalog-map-inspector__cache-fact" label="Хранилище"><span>Platform Map Gateway</span></ControlRow>
<ControlRow className="catalog-map-inspector__cache-fact" label="Подключение"><span>{gatewayEndpoint ?? "runtime profile · не проверено"}</span></ControlRow>
<ControlRow className="catalog-map-inspector__cache-fact" label="Записано"><span>{liveCacheSummary}</span></ControlRow>
<ControlRow className="catalog-map-inspector__cache-fact" label="Политика"><span>{gatewayHealth?.cache?.writePolicy ?? "append-only · проверяется"}</span></ControlRow>
<Button variant="secondary" shape="pill" icon={<Icon name="refresh" />} onClick={refreshCurrentViewport} disabled={!mapSettings.cacheEnabled || cacheRefresh}> {cacheRefresh ? "Обновляем viewport…" : "Обновить текущий viewport"}</Button>
<Button variant="secondary" shape="pill" icon={<Icon name="refresh" />} onClick={() => void verifyGateway()} disabled={gatewayCheckState === "checking"}>{gatewayCheckState === "checking" ? "Проверяем Gateway…" : "Проверить подключение"}</Button>
<small className="catalog-map-inspector__note">Live Cache: {liveCacheSummary} · {gatewayHealth?.cache?.mode ?? "проверяется"}</small>
{transportDiagnostic ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{transportDiagnostic}</small> : null}
{gatewayHealthAge ? <small className="catalog-map-inspector__note">{gatewayHealthAge}</small> : null}
<small className="catalog-map-inspector__note">{mapSettings.cacheEnabled ? mapSettings.cacheNoOverwrite ? "Новые miss дописываются; при заполнении объёма Gateway продолжит live-маршрут без удаления прежних tiles." : "Новые запросы этого Application могут явно обновлять уже записанные tiles." : "Real-time: provider остаётся официальным, чтение и запись persistent cache выключены."}</small>
{gatewayHealth?.cache?.atCapacity ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">TileCache заполнен: новые tiles показываются live, но не записываются. Существующий cache не удаляется.</small> : null}
{gatewayCheckState === "error" || gatewayCheckState === "stale" ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{gatewayCheckError}</small> : null}
</>,
},
{
id: "map-selection",
label: "Выбранная сущность",
description: "selection contract",
group: "Данные",
icon: <Icon name="target" />,
content: <>
<ControlRow label="Сущность"><strong>{selected?.title ?? "Нет выбора"}</strong></ControlRow>
<ControlRow label="Тип"><span>{selected?.kind ?? "—"}{selected?.status ? ` · ${selected.status}` : ""}</span></ControlRow>
</>,
},
];
}
+226
View File
@@ -0,0 +1,226 @@
import type { WorkspaceWindowRect } from "@nodedc/ui-react";
import type { GridLodProfile, MapCameraView, MapPresentation } from "./mapRendererContract.js";
import { DEFAULT_GRID_LOD_PROFILES } from "./mapGridPolicy.mjs";
import type { MapPresentationProfile } from "./mapPresentationProfile.js";
import {
ensureMapReferencePresentationProfiles,
initialMapReferenceLayers,
type MapReferenceLayer,
} from "./mapReferenceStations.js";
import { DEFAULT_MAP_SUBJECT_DETAIL_PROFILE } from "./mapSubjectCard.mjs";
import type { MapSubjectDetailProfile } from "./mapSubjectCard.mjs";
export type MapPageSettings = Omit<MapPresentation, "cacheRefresh">;
/**
* A stable, provider-neutral visual binding owned by one Map Page instance.
*
* This is intentionally distinct from a live Data Product subject. Engine can
* identify the source entity, while Foundry owns the pin presentation and its
* page-local placement contract.
*/
export type MapPinBinding = {
id: string;
subjectId: string;
kind: "elevated-spike";
label: string;
status: string;
coordinates: { longitude: number; latitude: number; heightMeters: number };
source: { entityId: string; streamId: string; displayFields: string[] };
attributes: Record<string, string | number | boolean>;
};
/**
* A renderer-neutral declaration of a Data Product assigned to a Map slot.
*
* It contains no provider endpoint, tenant/connection scope, credential,
* browser token or raw payload. Foundry resolves the target-scoped server
* consumer from application/page/binding identity.
*/
export type MapDataProductBinding = {
id: string;
displayName?: string;
order?: number;
dataProductId: string;
slotId: string;
delivery: "snapshot+patch";
semanticTypes: string[];
fieldProjection: string[];
presentationProfileId?: string;
subjectDetailProfileId?: string;
aspectId?: string;
joinToBindingId?: string;
dataClass?: "operational" | "restricted";
};
export type MapSubjectWindowState = {
open: boolean;
rect: WorkspaceWindowRect;
maximized: boolean;
zIndex: number;
};
export type MapWorkspaceWindowId = "sector" | "subject-card" | `binding:${string}`;
export type MapSubjectState = {
bindingId: string;
visible: boolean;
/** Missing facet means unconstrained; an explicit empty list means no matches. */
filters: Record<string, string[]>;
window: MapSubjectWindowState;
};
export type MapPageLayout = {
schemaVersion: 1;
pageId: "map";
settings: MapPageSettings;
mapHeight: number;
camera: MapCameraView;
pinBindings: MapPinBinding[];
presentationProfiles: MapPresentationProfile[];
subjectDetailProfiles: MapSubjectDetailProfile[];
dataProductBindings: MapDataProductBinding[];
subjectStates: MapSubjectState[];
referenceLayers: MapReferenceLayer[];
inspectorOpenSections: string[];
savedAt?: string;
};
export type MapFixturePreviewHandle = {
getLayout: () => MapPageLayout | null;
};
export const initialMapSettings: MapPageSettings = {
imagerySource: "cesium-live",
imageryVisible: true,
cacheEnabled: true,
cacheNoOverwrite: true,
terrainEnabled: true,
terrainExaggeration: 1,
monochrome: false,
monochromeColor: "#15151b",
imageryGamma: 57,
imageryHue: 13,
imageryAlpha: 27,
globeColor: "#15151b",
backgroundColor: "#08090d",
atmosphereEnabled: false,
atmosphereHue: 0,
atmosphereSaturation: 0,
atmosphereBrightness: 0,
fogEnabled: true,
fogDensity: 2,
sunEnabled: true,
sunHour: 12,
sunIntensity: 200,
shadowsEnabled: true,
buildingsVisible: true,
buildingsColor: "#a27aff",
buildingsOpacity: 1,
buildingsDetail: 4,
imageryBrightness: 118,
imageryContrast: 102,
imagerySaturation: 0,
gridVisible: true,
gridLodEnabled: true,
grid3dEnabled: true,
gridGraticuleEnabled: true,
gridCenterMode: "fixed",
gridCenterLatitude: 55.7558,
gridCenterLongitude: 37.6173,
gridTileSizeKm: 10,
gridAutoDisableHeightKm: 10_000,
gridRebuildOnMoveEnd: true,
gridLegacyMode: false,
gridMax3dViewAngleDegrees: 30,
gridHeightMeters: 500,
gridLod1MaxHeightKm: 10,
gridLod1StepKm: 1,
gridLod1Mode: "3d",
gridLod2MaxHeightKm: 50,
gridLod2StepKm: 5,
gridLod2Mode: "3d",
gridLod3MaxHeightKm: 200,
gridLod3StepKm: 25,
gridLod3Mode: "3d",
gridLod4MaxHeightKm: 800,
gridLod4StepKm: 50,
gridLod4Mode: "graticule",
gridLod5MaxHeightKm: 3_000,
gridLod5StepKm: 50,
gridLod5Mode: "graticule",
gridRadiusKm: 40,
gridLineWidth: 1,
gridLineDiameterMeters: 7,
gridColor: "#9c9c9c",
gridOpacity: 12,
gridDotsEnabled: true,
gridDotsSize: 7,
gridDotsDiameterMeters: 10,
gridDotsColor: "#9c9c9c",
gridDotsOpacity: 58,
gridCrossesEnabled: false,
gridCrossesLengthMeters: 60,
gridCrossesWidthMeters: 10,
gridCrossesColor: "#9c9c9c",
gridCrossesOpacity: 46,
gridLodProfiles: structuredClone(DEFAULT_GRID_LOD_PROFILES) as GridLodProfile[],
};
// A deterministic provider-neutral viewport exists before the renderer emits
// its first camera update, so a freshly created page is immediately saveable.
export const fallbackMapCamera: MapCameraView = {
longitude: 37.618423,
latitude: 55.751244,
height: 40_000,
heading: 0,
pitch: -0.9,
roll: 0,
};
export function createDefaultMapPageLayout(expanded = false): MapPageLayout {
return {
schemaVersion: 1,
pageId: "map",
settings: structuredClone(initialMapSettings),
mapHeight: expanded ? 620 : 470,
camera: { ...fallbackMapCamera },
pinBindings: [],
presentationProfiles: ensureMapReferencePresentationProfiles([]),
subjectDetailProfiles: [structuredClone(DEFAULT_MAP_SUBJECT_DETAIL_PROFILE) as MapSubjectDetailProfile],
dataProductBindings: [],
subjectStates: [],
referenceLayers: initialMapReferenceLayers(),
inspectorOpenSections: ["map-base"],
};
}
export function defaultSubjectWindowState(index: number): MapSubjectWindowState {
return {
open: false,
rect: {
x: 24 + (index % 5) * 28,
y: 56 + (index % 5) * 28,
width: 280,
height: 260,
},
maximized: false,
zIndex: 20 + index,
};
}
export const defaultSectorWindowRect: WorkspaceWindowRect = {
x: 24,
y: 72,
width: 380,
height: 530,
};
export const defaultSubjectCardRect: WorkspaceWindowRect = {
x: 940,
y: 72,
width: 390,
height: 520,
};
export type { MapSubjectDetailProfile } from "./mapSubjectCard.mjs";
@@ -0,0 +1,97 @@
import type { MapRuntimeFact } from "./useMapDataProductRuntime.js";
export type MapPresentationFacetValue = {
value: string;
label: string;
order: number;
};
export type MapPresentationFacet = {
id: string;
field: string;
label: string;
filterable: boolean;
counter: boolean;
values: MapPresentationFacetValue[];
};
export type MapPresentationStyle = {
id: string;
color: string;
opacity: number;
};
export type MapPresentationClass = {
id: string;
label: string;
priority: number;
match: Array<{ field: string; equals: string }>;
styleId: string;
renderable: boolean;
};
export type MapPresentationProfile = {
id: string;
version: string;
title: string;
semanticTypes: string[];
label: {
mode: "subject_id" | "attributes" | "none";
fields: string[];
fontWeight: number;
sizePx: number;
color: string;
outlineColor: string;
outlineWidthPx: number;
backgroundColor: string;
backgroundOpacity: number;
paddingX: number;
paddingY: number;
maxLength: number;
offsetX: number;
offsetY: number;
hideCameraHeightMeters: number;
};
target: ({
variant: "elevated-spike";
stemHeightMeters: number;
headSizePx: number;
stemWidthPx: number;
} | {
variant: "surface-fill";
}) & {
outlineColor: string;
outlineOpacity: number;
outlineWidthPx: number;
hideCameraHeightMeters: number;
};
facets: MapPresentationFacet[];
styles: MapPresentationStyle[];
classes: MapPresentationClass[];
defaultClassId: string;
sort: Array<{ field: string; order: string[] }>;
};
export type MapSubjectFilterState = {
/** False is an explicit empty map state. It must never normalize to all. */
visible: boolean;
/** Missing facet is unconstrained; explicit empty facet matches nothing. */
facets: Record<string, string[]>;
};
export type MapPresentationFilters = Record<string, MapSubjectFilterState>;
export function normalizeClientMapPresentationProfiles(profiles: MapPresentationProfile[]): MapPresentationProfile[];
export function mapPresentationBindingIsAll(bindingId: string, filters: MapPresentationFilters): boolean;
export function mapPresentationFacetValueIsEnabled(facets: Record<string, string[]>, field: string, value: string): boolean;
export function normalizeMapPresentationFacetSelections(facets: Record<string, string[]>, profile: MapPresentationProfile): Record<string, string[]>;
export function toggleMapPresentationFacetSelection(facets: Record<string, string[]>, field: string, value: string, availableValues: string[]): Record<string, string[]>;
export function mapPresentationProfileForFact(profiles: MapPresentationProfile[], presentationProfileId: string | undefined, semanticType: string): MapPresentationProfile | undefined;
export function resolveMapPresentationClass(fact: MapRuntimeFact, profile: MapPresentationProfile): MapPresentationClass | undefined;
export function resolveMapPresentationStyle(profile: MapPresentationProfile, presentationClass?: MapPresentationClass): MapPresentationStyle | undefined;
export function mapRuntimeDisplayLabel(fact: MapRuntimeFact, profile?: MapPresentationProfile): string;
export function mapRuntimeFactIsRenderable(fact: MapRuntimeFact, profile: MapPresentationProfile): boolean;
export function mapRuntimeFactIsVisible(fact: MapRuntimeFact, profile: MapPresentationProfile, filters: MapPresentationFilters, bindingId: string): boolean;
export function mapFactMatchesFilters(fact: MapRuntimeFact, profile: MapPresentationProfile, filters: MapPresentationFilters, bindingId: string): boolean;
export function compareMapRuntimeFacts(left: MapRuntimeFact, right: MapRuntimeFact, profile: MapPresentationProfile): number;
export function mapPresentationFacetCounts(facts: MapRuntimeFact[], profile: MapPresentationProfile): Record<string, Record<string, number>>;
+160
View File
@@ -0,0 +1,160 @@
/**
* Runtime-only implementation of the provider-neutral presentation model.
* Types live beside it in mapPresentationProfile.d.mts so Node tests can
* execute the same code that the browser uses without a TypeScript loader.
*/
export function normalizeClientMapPresentationProfiles(profiles) {
return profiles.flatMap((profile) => {
const legacyPin = profile.pin;
const target = profile.target ?? legacyPin;
if (!target) return [];
const normalized = { ...profile, target };
delete normalized.pin;
return [normalized];
});
}
export function mapPresentationBindingIsAll(bindingId, filters) {
const state = filters[bindingId];
return state?.visible !== false && Object.keys(state?.facets ?? {}).length === 0;
}
/**
* A missing facet is compact canonical “all”. An explicit array is the exact
* enabled subset; an empty array therefore remains intentional “none”.
*/
export function mapPresentationFacetValueIsEnabled(facets, field, value) {
const selected = facets[field];
return selected === undefined || selected.includes(value);
}
export function normalizeMapPresentationFacetSelections(facets, profile) {
return Object.fromEntries(profile.facets.flatMap((facet) => {
const selected = facets[facet.field];
if (selected === undefined) return [];
const availableValues = [...new Set(facet.values.map((item) => item.value))];
const enabledValues = availableValues.filter((value) => selected.includes(value));
// Legacy layouts could persist every value explicitly. Canonicalize that
// to unconstrained so a scoped facet cannot suppress unrelated subjects.
return availableValues.length > 0 && enabledValues.length === availableValues.length
? []
: [[facet.field, enabledValues]];
}));
}
export function toggleMapPresentationFacetSelection(facets, field, value, availableValues) {
const values = [...new Set(availableValues)];
if (!values.includes(value)) return facets;
const selected = facets[field];
const enabled = new Set(selected === undefined
? values
: values.filter((item) => selected.includes(item)));
if (enabled.has(value)) enabled.delete(value);
else enabled.add(value);
const nextEnabled = values.filter((item) => enabled.has(item));
const next = { ...facets };
if (nextEnabled.length === values.length) delete next[field];
else next[field] = nextEnabled;
return next;
}
export function mapPresentationProfileForFact(profiles, presentationProfileId, semanticType) {
const exact = presentationProfileId
? profiles.find((profile) => profile.id === presentationProfileId)
: undefined;
if (exact?.semanticTypes.includes(semanticType)) return exact;
return profiles.find((profile) => profile.semanticTypes.includes(semanticType));
}
export function resolveMapPresentationClass(fact, profile) {
const classes = [...profile.classes].sort((left, right) => right.priority - left.priority || left.id.localeCompare(right.id));
return classes.find((item) => item.match.every((condition) => (
normalizedFacetValue(fact.attributes[condition.field]) === condition.equals
))) ?? classes.find((item) => item.id === profile.defaultClassId) ?? classes.at(-1);
}
export function resolveMapPresentationStyle(profile, presentationClass) {
const selected = presentationClass ?? profile.classes.find((item) => item.id === profile.defaultClassId);
return profile.styles.find((style) => style.id === selected?.styleId) ?? profile.styles[0];
}
export function mapRuntimeDisplayLabel(fact, profile) {
if (profile?.label.mode === "subject_id") return fact.sourceId;
const fields = profile?.label.fields ?? ["display_name", "label", "name", "title"];
for (const key of fields) {
const value = fact.attributes[key];
if (typeof value === "string" && value.trim()) {
const normalized = value.trim();
const limit = profile?.label.maxLength ?? 80;
return normalized.length > limit ? `${normalized.slice(0, Math.max(1, limit - 1))}…` : normalized;
}
}
return fact.sourceId;
}
export function mapRuntimeFactIsRenderable(fact, profile) {
return Boolean(fact.geometry) && resolveMapPresentationClass(fact, profile)?.renderable === true;
}
export function mapRuntimeFactIsVisible(fact, profile, filters, bindingId) {
return mapRuntimeFactIsRenderable(fact, profile) && mapFactMatchesFilters(fact, profile, filters, bindingId);
}
export function mapFactMatchesFilters(fact, profile, filters, bindingId) {
const state = filters[bindingId];
if (state?.visible === false) return false;
const selectedFacets = profile.facets.flatMap((facet) => {
const selected = state?.facets?.[facet.field];
return selected === undefined ? [] : [{ facet, selected }];
});
if (selectedFacets.some(({ selected }) => selected.length === 0)) return false;
if (selectedFacets.length === 0) return true;
return selectedFacets.every(({ facet, selected }) => (
mapFactParticipatesInFacet(fact, profile, facet)
&& selected.includes(normalizedFacetValue(fact.attributes[facet.field]))
));
}
export function compareMapRuntimeFacts(left, right, profile) {
for (const rule of profile.sort) {
const leftRank = sortRank(rule.order, normalizedFacetValue(left.attributes[rule.field]));
const rightRank = sortRank(rule.order, normalizedFacetValue(right.attributes[rule.field]));
if (leftRank !== rightRank) return leftRank - rightRank;
}
return mapRuntimeDisplayLabel(left, profile).localeCompare(mapRuntimeDisplayLabel(right, profile), "ru");
}
export function mapPresentationFacetCounts(facts, profile) {
return Object.fromEntries(profile.facets.map((facet) => {
const counts = Object.fromEntries(facet.values.map((item) => [item.value, 0]));
for (const fact of facts) {
if (!mapFactParticipatesInFacet(fact, profile, facet)) continue;
const value = normalizedFacetValue(fact.attributes[facet.field]);
if (Object.hasOwn(counts, value)) counts[value] += 1;
}
return [facet.field, counts];
}));
}
/**
* Movement remains an independent fact, but an inactive subject must not
* present a stale last speed as current movement.
*/
function mapFactParticipatesInFacet(fact, profile, facet) {
if (facet.field !== "movement_state") return true;
if (!profile.facets.some((item) => item.field === "signal_state")) return true;
return normalizedFacetValue(fact.attributes.signal_state) === "active";
}
function normalizedFacetValue(value) {
return typeof value === "string" ? value.trim().toLowerCase() : "unknown";
}
function sortRank(order, value) {
const index = order.indexOf(value);
return index === -1 ? order.length : index;
}
+3 -287
View File
@@ -1,287 +1,3 @@
import type { MapRuntimeFact } from "./useMapDataProductRuntime.js"; // Stable TypeScript facade for existing `.js` specifiers. Runtime behavior is
// executable JavaScript so browser code and Node behavioral tests share it.
export type MapPresentationFacetValue = { export * from "./mapPresentationProfile.mjs";
value: string;
label: string;
order: number;
};
export type MapPresentationFacet = {
id: string;
field: string;
label: string;
filterable: boolean;
counter: boolean;
values: MapPresentationFacetValue[];
};
export type MapPresentationStyle = {
id: string;
color: string;
opacity: number;
};
export type MapPresentationClass = {
id: string;
label: string;
priority: number;
match: Array<{ field: string; equals: string }>;
styleId: string;
renderable: boolean;
};
export type MapPresentationProfile = {
id: string;
version: string;
title: string;
semanticTypes: string[];
label: {
mode: "subject_id" | "attributes" | "none";
fields: string[];
fontWeight: number;
sizePx: number;
color: string;
outlineColor: string;
outlineWidthPx: number;
backgroundColor: string;
backgroundOpacity: number;
paddingX: number;
paddingY: number;
maxLength: number;
offsetX: number;
offsetY: number;
hideCameraHeightMeters: number;
};
target: ({
variant: "elevated-spike";
stemHeightMeters: number;
headSizePx: number;
stemWidthPx: number;
} | {
variant: "surface-fill";
}) & {
outlineColor: string;
outlineOpacity: number;
outlineWidthPx: number;
hideCameraHeightMeters: number;
};
facets: MapPresentationFacet[];
styles: MapPresentationStyle[];
classes: MapPresentationClass[];
defaultClassId: string;
sort: Array<{ field: string; order: string[] }>;
};
export type MapSubjectFilterState = {
/** False is an explicit empty map state. It must never be normalized to all. */
visible: boolean;
/** Missing facet = no constraint; an explicitly empty facet = match nothing. */
facets: Record<string, string[]>;
};
/** Application view state is keyed by stable binding id, never by editable labels. */
export type MapPresentationFilters = Record<string, MapSubjectFilterState>;
/**
* Application manifests persisted before profile v1.1 used the internal key
* `pin`. Normalize that storage shape before the first React render so an old
* application cannot crash while it is being upgraded to the public `target`
* contract through MCP.
*/
export function normalizeClientMapPresentationProfiles(profiles: MapPresentationProfile[]) {
return profiles.flatMap((profile) => {
const legacyPin = (profile as MapPresentationProfile & { pin?: MapPresentationProfile["target"] }).pin;
const target = profile.target ?? legacyPin;
if (!target) return [];
const normalized = { ...profile, target } as MapPresentationProfile & { pin?: MapPresentationProfile["target"] };
delete normalized.pin;
return [normalized];
});
}
export function mapPresentationBindingIsAll(bindingId: string, filters: MapPresentationFilters) {
const state = filters[bindingId];
return state?.visible !== false && Object.keys(state?.facets ?? {}).length === 0;
}
/**
* A missing facet is the compact canonical representation of every configured
* value being enabled. An explicit array is the exact enabled subset; an empty
* array therefore remains an intentional match-nothing state.
*/
export function mapPresentationFacetValueIsEnabled(
facets: Record<string, string[]>,
field: string,
value: string,
) {
const selected = facets[field];
return selected === undefined || selected.includes(value);
}
export function normalizeMapPresentationFacetSelections(
facets: Record<string, string[]>,
profile: MapPresentationProfile,
) {
return Object.fromEntries(profile.facets.flatMap((facet) => {
const selected = facets[facet.field];
if (selected === undefined) return [];
const availableValues = [...new Set(facet.values.map((item) => item.value))];
const enabledValues = availableValues.filter((value) => selected.includes(value));
// Legacy layouts could persist every value explicitly. Canonicalize that
// shape to an unconstrained facet so scoped facets (for example movement
// on online subjects) cannot accidentally suppress unrelated subjects.
return availableValues.length > 0 && enabledValues.length === availableValues.length
? []
: [[facet.field, enabledValues] as const];
}));
}
export function toggleMapPresentationFacetSelection(
facets: Record<string, string[]>,
field: string,
value: string,
availableValues: string[],
) {
const values = [...new Set(availableValues)];
if (!values.includes(value)) return facets;
const selected = facets[field];
const enabled = new Set(selected === undefined
? values
: values.filter((item) => selected.includes(item)));
if (enabled.has(value)) enabled.delete(value);
else enabled.add(value);
const nextEnabled = values.filter((item) => enabled.has(item));
const next = { ...facets };
if (nextEnabled.length === values.length) delete next[field];
else next[field] = nextEnabled;
return next;
}
export function mapPresentationProfileForFact(
profiles: MapPresentationProfile[],
presentationProfileId: string | undefined,
semanticType: string,
) {
const exact = presentationProfileId
? profiles.find((profile) => profile.id === presentationProfileId)
: undefined;
if (exact?.semanticTypes.includes(semanticType)) return exact;
return profiles.find((profile) => profile.semanticTypes.includes(semanticType));
}
export function resolveMapPresentationClass(fact: MapRuntimeFact, profile: MapPresentationProfile) {
const classes = [...profile.classes].sort((left, right) => right.priority - left.priority || left.id.localeCompare(right.id));
return classes.find((item) => item.match.every((condition) => (
normalizedFacetValue(fact.attributes[condition.field]) === condition.equals
))) ?? classes.find((item) => item.id === profile.defaultClassId) ?? classes.at(-1);
}
export function resolveMapPresentationStyle(profile: MapPresentationProfile, presentationClass?: MapPresentationClass) {
const selected = presentationClass ?? profile.classes.find((item) => item.id === profile.defaultClassId);
return profile.styles.find((style) => style.id === selected?.styleId) ?? profile.styles[0];
}
export function mapRuntimeDisplayLabel(fact: MapRuntimeFact, profile?: MapPresentationProfile) {
if (profile?.label.mode === "subject_id") return fact.sourceId;
const fields = profile?.label.fields ?? ["display_name", "label", "name", "title"];
for (const key of fields) {
const value = fact.attributes[key];
if (typeof value === "string" && value.trim()) {
const normalized = value.trim();
const limit = profile?.label.maxLength ?? 80;
return normalized.length > limit ? `${normalized.slice(0, Math.max(1, limit - 1))}…` : normalized;
}
}
return fact.sourceId;
}
export function mapRuntimeFactIsRenderable(fact: MapRuntimeFact, profile: MapPresentationProfile) {
return Boolean(fact.geometry) && resolveMapPresentationClass(fact, profile)?.renderable === true;
}
export function mapRuntimeFactIsVisible(
fact: MapRuntimeFact,
profile: MapPresentationProfile,
filters: MapPresentationFilters,
bindingId: string,
) {
return mapRuntimeFactIsRenderable(fact, profile) && mapFactMatchesFilters(fact, profile, filters, bindingId);
}
export function mapFactMatchesFilters(
fact: MapRuntimeFact,
profile: MapPresentationProfile,
filters: MapPresentationFilters,
bindingId: string,
) {
const state = filters[bindingId];
if (state?.visible === false) return false;
const selectedFacets = profile.facets.flatMap((facet) => {
const selected = state?.facets?.[facet.field];
if (selected === undefined) return [];
return [{ facet, selected }];
});
// Persisted empty arrays and the final interactive deselection are an
// explicit match-nothing state.
if (selectedFacets.some(({ selected }) => selected.length === 0)) return false;
if (selectedFacets.length === 0) return true;
// Values inside one facet form a union; independently selected facets are
// conjunctive. This keeps provider/type/state filters composable.
return selectedFacets.every(({ facet, selected }) => (
mapFactParticipatesInFacet(fact, profile, facet)
&& selected.includes(normalizedFacetValue(fact.attributes[facet.field]))
));
}
export function compareMapRuntimeFacts(left: MapRuntimeFact, right: MapRuntimeFact, profile: MapPresentationProfile) {
for (const rule of profile.sort) {
const leftRank = sortRank(rule.order, normalizedFacetValue(left.attributes[rule.field]));
const rightRank = sortRank(rule.order, normalizedFacetValue(right.attributes[rule.field]));
if (leftRank !== rightRank) return leftRank - rightRank;
}
return mapRuntimeDisplayLabel(left, profile).localeCompare(mapRuntimeDisplayLabel(right, profile), "ru");
}
export function mapPresentationFacetCounts(
facts: MapRuntimeFact[],
profile: MapPresentationProfile,
) {
return Object.fromEntries(profile.facets.map((facet) => {
const counts = Object.fromEntries(facet.values.map((item) => [item.value, 0]));
for (const fact of facts) {
if (!mapFactParticipatesInFacet(fact, profile, facet)) continue;
const value = normalizedFacetValue(fact.attributes[facet.field]);
if (Object.hasOwn(counts, value)) counts[value] += 1;
}
return [facet.field, counts];
})) as Record<string, Record<string, number>>;
}
/**
* `signal_state` and `movement_state` remain orthogonal Data Product facts.
* The operational Map, however, must not present a stale last speed as a
* current movement state. When both canonical facets exist, the movement
* facet is therefore scoped to currently active subjects. Other profiles and
* fields keep their ordinary independent-facet behaviour.
*/
function mapFactParticipatesInFacet(
fact: MapRuntimeFact,
profile: MapPresentationProfile,
facet: MapPresentationFacet,
) {
if (facet.field !== "movement_state") return true;
if (!profile.facets.some((item) => item.field === "signal_state")) return true;
return normalizedFacetValue(fact.attributes.signal_state) === "active";
}
function normalizedFacetValue(value: unknown) {
return typeof value === "string" ? value.trim().toLowerCase() : "unknown";
}
function sortRank(order: string[], value: string) {
const index = order.indexOf(value);
return index === -1 ? order.length : index;
}
+44
View File
@@ -0,0 +1,44 @@
export type MapProviderStartupPolicy = Readonly<{
providerInitializationTimeoutMs: number;
imageryViewportTimeoutMs: number;
terrainViewportTimeoutMs: number;
minimumRenderFrames: number;
}>;
export type MapProviderStartupReason = "loaded" | "timeout" | "cancelled";
export type MapProviderStartupResult = "complete" | "cancelled";
export type MapProviderName = "imagery" | "terrain" | "buildings";
type CesiumEventLike<TListener extends (...args: never[]) => void> = {
addEventListener(listener: TListener): () => void;
};
export const MAP_PROVIDER_STARTUP_POLICY: MapProviderStartupPolicy;
export function waitForGlobeViewportReady(input: {
globe: {
readonly tilesLoaded: boolean;
tileLoadProgressEvent: CesiumEventLike<(pendingRequests: number) => void>;
};
scene: {
postRender: CesiumEventLike<() => void>;
requestRender(): void;
};
signal?: AbortSignal;
timeoutMs: number;
minimumRenderFrames?: number;
}): Promise<MapProviderStartupReason>;
export function runStagedMapProviders(input: {
signal?: AbortSignal;
isCancelled?: () => boolean;
providerInitializationTimeoutMs?: number;
loadImagery: (context: { signal: AbortSignal }) => void | Promise<void>;
waitAfterImagery?: () => void | Promise<void>;
loadTerrain: (context: { signal: AbortSignal }) => void | Promise<void>;
waitAfterTerrain?: () => void | Promise<void>;
loadBuildings: (context: { signal: AbortSignal }) => void | Promise<void>;
loadDeferred?: () => void | Promise<void>;
onProviderError?: (provider: MapProviderName, error: unknown) => void;
onDeferredError?: (error: unknown) => void;
}): Promise<MapProviderStartupResult>;
+141
View File
@@ -0,0 +1,141 @@
export const MAP_PROVIDER_STARTUP_POLICY = Object.freeze({
providerInitializationTimeoutMs: 8_000,
imageryViewportTimeoutMs: 6_000,
terrainViewportTimeoutMs: 5_000,
minimumRenderFrames: 2,
});
/**
* Wait until Cesium has rendered enough frames to discover the current
* viewport and the public Globe queue reports that its terrain and imagery
* are loaded. The deadline is deliberate: a slow or unavailable provider
* must never block the next independent layer.
*/
export function waitForGlobeViewportReady({
globe,
scene,
signal,
timeoutMs,
minimumRenderFrames = MAP_PROVIDER_STARTUP_POLICY.minimumRenderFrames,
}) {
if (signal?.aborted) return Promise.resolve("cancelled");
const frameTarget = Math.max(1, Math.trunc(minimumRenderFrames));
const deadlineMs = Math.max(0, Math.trunc(timeoutMs));
return new Promise((resolve) => {
let settled = false;
let renderFrames = 0;
let removeProgressListener;
let removePostRenderListener;
let timeout;
const cleanup = () => {
removeProgressListener?.();
removePostRenderListener?.();
if (timeout !== undefined) clearTimeout(timeout);
signal?.removeEventListener("abort", onAbort);
};
const finish = (reason) => {
if (settled) return;
settled = true;
cleanup();
resolve(reason);
};
const inspect = () => {
if (renderFrames >= frameTarget && globe.tilesLoaded) finish("loaded");
};
const onAbort = () => finish("cancelled");
const onTileLoadProgress = () => {
inspect();
if (!settled) scene.requestRender();
};
const onPostRender = () => {
renderFrames += 1;
inspect();
if (!settled && renderFrames < frameTarget) scene.requestRender();
};
removeProgressListener = globe.tileLoadProgressEvent.addEventListener(onTileLoadProgress);
removePostRenderListener = scene.postRender.addEventListener(onPostRender);
signal?.addEventListener("abort", onAbort, { once: true });
timeout = setTimeout(() => finish("timeout"), deadlineMs);
scene.requestRender();
});
}
/**
* Preserve independent failure domains while giving the base map first use
* of network and decode capacity. Each provider owns its error; viewport
* gates are scheduling hints and therefore never become provider failures.
*/
export async function runStagedMapProviders({
signal,
isCancelled,
providerInitializationTimeoutMs = MAP_PROVIDER_STARTUP_POLICY.providerInitializationTimeoutMs,
loadImagery,
waitAfterImagery,
loadTerrain,
waitAfterTerrain,
loadBuildings,
loadDeferred,
onProviderError,
onDeferredError,
}) {
const cancelled = () => Boolean(signal?.aborted || isCancelled?.());
const loadProvider = async (provider, load) => {
if (cancelled()) return false;
const stageAbort = new AbortController();
let timeout;
let rejectDeadline;
const abortStage = () => {
rejectDeadline?.(new Error(`${provider}_startup_cancelled`));
stageAbort.abort();
};
signal?.addEventListener("abort", abortStage, { once: true });
const deadline = new Promise((_, reject) => {
rejectDeadline = reject;
timeout = setTimeout(() => {
reject(new Error(`${provider}_startup_timeout`));
stageAbort.abort();
}, Math.max(0, Math.trunc(providerInitializationTimeoutMs)));
});
try {
await Promise.race([load({ signal: stageAbort.signal }), deadline]);
return !cancelled() && !stageAbort.signal.aborted;
} catch (error) {
if (!cancelled()) onProviderError?.(provider, error);
return false;
} finally {
if (timeout !== undefined) clearTimeout(timeout);
signal?.removeEventListener("abort", abortStage);
}
};
const waitForViewport = async (wait) => {
if (!wait || cancelled()) return;
try {
await wait();
} catch {
// A readiness gate controls ordering only. The provider's own error
// event remains the authority for availability and user diagnostics.
}
};
const imageryReady = await loadProvider("imagery", loadImagery);
if (imageryReady) await waitForViewport(waitAfterImagery);
const terrainReady = await loadProvider("terrain", loadTerrain);
if (terrainReady) await waitForViewport(waitAfterTerrain);
await loadProvider("buildings", loadBuildings);
if (loadDeferred && !cancelled()) {
try {
await loadDeferred();
} catch (error) {
if (!cancelled()) onDeferredError?.(error);
}
}
return cancelled() ? "cancelled" : "complete";
}
+1 -1
View File
@@ -51,7 +51,7 @@ export const defaultMapReferencePresentationProfiles: readonly MapPresentationPr
semanticTypes: [...definition.semanticTypes], semanticTypes: [...definition.semanticTypes],
label: { label: {
mode: "attributes", mode: "attributes",
fields: ["name", "official_name", "local_name"], fields: ["name", "official_name", "local_name", "alternate_names"],
fontWeight: 600, fontWeight: 600,
sizePx: 20, sizePx: 20,
color: "#cccccc", color: "#cccccc",
+250
View File
@@ -0,0 +1,250 @@
import type {
GraticuleSectorSummary,
LocalSectorSummary,
} from "./mapSectorGrid.mjs";
/**
* Provider-neutral scene settings consumed by a renderer adapter.
*
* The persisted Map Page contract may use these fields, but it never stores
* Cesium entities, provider endpoints, credentials or raw provider payloads.
*/
export type MapPresentation = {
imagerySource: "cesium-live";
imageryVisible: boolean;
cacheEnabled: boolean;
cacheNoOverwrite: boolean;
terrainEnabled: boolean;
terrainExaggeration: number;
monochrome: boolean;
monochromeColor: string;
imageryGamma: number;
imageryHue: number;
imageryAlpha: number;
globeColor: string;
backgroundColor: string;
atmosphereEnabled: boolean;
atmosphereHue: number;
atmosphereSaturation: number;
atmosphereBrightness: number;
fogEnabled: boolean;
fogDensity: number;
sunEnabled: boolean;
sunHour: number;
sunIntensity: number;
shadowsEnabled: boolean;
buildingsVisible: boolean;
buildingsColor: string;
buildingsOpacity: number;
buildingsDetail: number;
imageryBrightness: number;
imageryContrast: number;
imagerySaturation: number;
gridVisible: boolean;
gridLodEnabled: boolean;
grid3dEnabled: boolean;
gridGraticuleEnabled: boolean;
gridCenterMode: "fixed";
gridCenterLatitude: number;
gridCenterLongitude: number;
gridTileSizeKm: number;
gridAutoDisableHeightKm: number;
gridRebuildOnMoveEnd: boolean;
gridLegacyMode: boolean;
gridMax3dViewAngleDegrees: number;
gridHeightMeters: number;
gridLod1MaxHeightKm: number;
gridLod1StepKm: number;
gridLod1Mode: "3d" | "graticule";
gridLod2MaxHeightKm: number;
gridLod2StepKm: number;
gridLod2Mode: "3d" | "graticule";
gridLod3MaxHeightKm: number;
gridLod3StepKm: number;
gridLod3Mode: "3d" | "graticule";
gridLod4MaxHeightKm: number;
gridLod4StepKm: number;
gridLod4Mode: "3d" | "graticule";
gridLod5StepKm: number;
gridLod5MaxHeightKm: number;
gridLod5Mode: "3d" | "graticule";
gridRadiusKm: number;
gridLineWidth: number;
gridLineDiameterMeters: number;
gridColor: string;
gridOpacity: number;
gridDotsEnabled: boolean;
gridDotsSize: number;
gridDotsDiameterMeters: number;
gridDotsColor: string;
gridDotsOpacity: number;
gridCrossesEnabled: boolean;
gridCrossesLengthMeters: number;
gridCrossesWidthMeters: number;
gridCrossesColor: string;
gridCrossesOpacity: number;
gridLodProfiles: GridLodProfile[];
/** One-shot renderer command; it is intentionally excluded from persistence. */
cacheRefresh: boolean;
};
export type GridLodProfile = {
maxHeightKm: number;
stepKm: number;
mode: "3d" | "graticule";
heightMeters: number;
max3dViewAngleDegrees: number;
tileSizeKm: number;
radiusKm: number;
lineDiameterMeters: number;
lineColor: string;
lineOpacity: number;
dotsEnabled: boolean;
dotsDiameterMeters: number;
dotsColor: string;
dotsOpacity: number;
crossesEnabled: boolean;
crossesLengthMeters: number;
crossesWidthMeters: number;
crossesColor: string;
crossesOpacity: number;
graticuleStepDegrees: number;
graticuleLineWidthPx: number;
graticuleColor: string;
graticuleOpacity: number;
majorLinesEnabled: boolean;
majorLabelsEnabled: boolean;
majorLineWidthMultiplier: number;
volumeEnabled: boolean;
volumeMinimumHeightMeters: number;
volumeMaximumHeightMeters: number;
volumeBandHeightMeters: number;
selectionFillColor: string;
selectionFillOpacityPercent: number;
selectionOutlineColor: string;
selectionOutlineWidthPx: number;
selectionOutlineOpacityPercent: number;
};
export type GridVolumeSelection = {
id: string;
index: number;
floor: number;
ceiling: number;
bandHeight: number;
};
export type LocalGridSectorSelection = LocalSectorSummary & {
mode: "3d";
units: "meters-enu";
volume: GridVolumeSelection | null;
};
export type GraticuleGridSectorSelection = GraticuleSectorSummary & {
mode: "graticule";
units: "degrees-wgs84";
volume: null;
};
export type GridSectorSelection = LocalGridSectorSelection | GraticuleGridSectorSelection;
export type GridMajorTileSelection = NonNullable<GridSectorSelection["parentMajorTile"]>;
export type MapCameraView = {
longitude: number;
latitude: number;
height: number;
heading: number;
pitch: number;
roll: number;
};
export type CameraSpiralConfig = {
heightAboveGroundMeters: number;
speedMetersPerSecond: number;
pitchMetersPerTurn: number;
targetRadiusMeters?: number;
viewPitchRadians?: number;
waitForTiles?: boolean;
};
export type CameraSpiralState = {
running: boolean;
reason?: "stopped" | "mode_disabled" | "renderer_restarted" | "page_hidden" | "render_error" | "spiral_extent_limit" | "spiral_runtime_error" | "terrain_sampling_error" | "tile_loading_timeout" | "tile_loading_error" | "target_radius_reached";
};
export type MapGatewayHealth = {
cache?: {
mode?: string;
writePolicy?: string;
entries?: number;
bytes?: number;
maxBytes?: number;
atCapacity?: boolean;
persistent?: boolean;
byResourceKind?: Record<string, { entries?: number; bytes?: number }>;
};
diagnostics?: {
cacheHits?: number;
cacheMisses?: number;
cacheRefreshes?: number;
upstreamRequests?: number;
egressRequests?: number;
upstreamFailures?: number;
slowUpstreamRequests?: number;
lastFailure?: string | null;
lastFailureAt?: string | null;
};
referenceSources?: {
transportStations?: {
profileId?: string;
seedFactCount?: number;
fetchEnabled?: boolean;
cellDegrees?: number;
cachedCellCount?: number;
indexedFactCount?: number;
upstreamRequests?: number;
upstreamFailures?: number;
searchRequests?: number;
searchFailures?: number;
upstreamState?: "idle" | "ready" | "degraded";
activeFetches?: number;
queuedFetches?: number;
lastRefreshAt?: string | null;
lastFailure?: string | null;
lastFailureAt?: string | null;
};
};
providerCache?: Array<{
assetId?: number;
type?: string;
cachedEndpoint?: boolean;
credentialUsable?: boolean;
entryPointCached?: boolean;
}>;
ionConfigured?: boolean;
};
export type MapProviderState = "loading" | "ready" | "error" | "not-configured";
export type MapProviderStatus = {
imagery: MapProviderState;
terrain: MapProviderState;
buildings: MapProviderState;
errors: Partial<Record<"imagery" | "terrain" | "buildings" | "projection", string>>;
};
/** Imperative adapter surface; callers never receive a Cesium object. */
export type MapRendererHandle = {
startSpiralAnimation: (config: CameraSpiralConfig) => boolean;
stopSpiralAnimation: (reason?: CameraSpiralState["reason"]) => void;
getCameraView: () => MapCameraView | null;
fitRuntimeEntities: (entityIds?: string[]) => boolean;
focusRuntimeEntity: (entityId: string) => boolean;
focusSubjectCoordinates: (longitude: number, latitude: number) => boolean;
focusCoordinates: (longitude: number, latitude: number) => boolean;
focusGridSector: (sector: GridSectorSelection) => boolean;
focusGridMajorTile: (tile: GridMajorTileSelection) => boolean;
};
/** @deprecated Use the provider-neutral MapRendererHandle name. */
export type CesiumMapRendererHandle = MapRendererHandle;
@@ -0,0 +1,4 @@
import type { MapRuntimeFact } from "./useMapDataProductRuntime.js";
export function mapRuntimeFactKey(fact: Pick<MapRuntimeFact, "sourceId" | "semanticType">): string;
export function mapRuntimeEntityId(bindingId: string, fact: Pick<MapRuntimeFact, "sourceId" | "semanticType">): string;
+12
View File
@@ -0,0 +1,12 @@
/** Stable Data Product fact identity: semantic type plus source id. */
export function mapRuntimeFactKey(fact) {
return `${fact.semanticType}\u0000${fact.sourceId}`;
}
/**
* Stable renderer adapter identity. It never depends on a provider message id,
* display label, current geometry or transient Cesium entity instance.
*/
export function mapRuntimeEntityId(bindingId, fact) {
return `nodedc-runtime:${bindingId}:${fact.semanticType}:${fact.sourceId}`;
}
+1 -1
View File
@@ -1,4 +1,4 @@
import type { MapDataProductBinding } from "./MapFixturePreview.js"; import type { MapDataProductBinding } from "./mapPageContract.js";
import type { MapPresentationProfile } from "./mapPresentationProfile.js"; import type { MapPresentationProfile } from "./mapPresentationProfile.js";
import type { MapRuntimeBinding } from "./useMapDataProductRuntime.js"; import type { MapRuntimeBinding } from "./useMapDataProductRuntime.js";
+18
View File
@@ -0,0 +1,18 @@
import type { GridSectorSelection } from "./mapRendererContract.js";
import type { SectorGridLodProfile } from "./mapSectorWorkspace.js";
import type { MapRuntimeFact } from "./useMapDataProductRuntime.js";
export const MAP_SCOPE_PROVIDER_FIELD: "position_source";
export const MAP_SCOPE_OBJECT_KIND_FIELD: "object_kind";
export const MAP_SCOPE_MISSING_VALUE: "__nodedc_missing__";
export function normalizedSectorScopeValue(value: unknown): string | null;
export function sectorScopeValueLabel(value: string): string;
export function mapFactSectorScopeValue(fact: MapRuntimeFact, field: string): string;
export function mapFactPointCoordinates(fact: MapRuntimeFact | undefined): [number, number] | null;
export function mapFactInsideGridSector(
fact: MapRuntimeFact,
selection: GridSectorSelection,
profiles: SectorGridLodProfile[],
origin: { latitude: number; longitude: number },
): boolean;
+49
View File
@@ -0,0 +1,49 @@
import { graticuleSectorAt, localSectorAtGeodetic } from "./mapSectorGrid.mjs";
export const MAP_SCOPE_PROVIDER_FIELD = "position_source";
export const MAP_SCOPE_OBJECT_KIND_FIELD = "object_kind";
export const MAP_SCOPE_MISSING_VALUE = "__nodedc_missing__";
export function normalizedSectorScopeValue(value) {
if (typeof value !== "string") return null;
const normalized = value.trim();
if (!normalized || normalized.length > 120 || /[\u0000-\u001f\u007f]/.test(normalized)) return null;
return normalized;
}
export function sectorScopeValueLabel(value) {
if (value === MAP_SCOPE_MISSING_VALUE) return "Не указано";
return value.replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim();
}
export function mapFactSectorScopeValue(fact, field) {
return normalizedSectorScopeValue(fact.attributes[field]) ?? MAP_SCOPE_MISSING_VALUE;
}
export function mapFactPointCoordinates(fact) {
if (fact?.geometry?.type !== "Point") return null;
const [longitude, latitude] = fact.geometry.coordinates;
return Number.isFinite(longitude) && longitude >= -180 && longitude <= 180
&& Number.isFinite(latitude) && latitude >= -90 && latitude <= 90
? [longitude, latitude]
: null;
}
export function mapFactInsideGridSector(fact, selection, profiles, origin) {
if (fact.geometry?.type !== "Point") return false;
const [longitude, latitude] = fact.geometry.coordinates;
const profile = profiles[selection.lod - 1];
if (!profile || profile.mode !== selection.mode) return false;
if (selection.mode === "graticule") {
return graticuleSectorAt({ longitude, latitude }, {
lod: selection.lod,
stepDegrees: profile.graticuleStepDegrees,
}).id === selection.id;
}
return localSectorAtGeodetic({ longitude, latitude }, {
lod: selection.lod,
originLatitude: origin.latitude,
originLongitude: origin.longitude,
stepMeters: profile.stepKm * 1_000,
}).id === selection.id;
}
+272
View File
@@ -0,0 +1,272 @@
import { gridLodProfile } from "./mapGridPolicy.mjs";
import {
graticuleSectorAt,
graticuleSectorSummary,
localSectorAt,
localSectorSummary,
localVolumeAt,
type GraticuleSectorAddress,
type LocalSectorAddress,
} from "./mapSectorGrid.mjs";
import { initialMapSettings, type MapPageSettings } from "./mapPageContract.js";
import type {
GridLodProfile,
GridSectorSelection,
MapPresentation,
} from "./mapRendererContract.js";
export {
MAP_SCOPE_MISSING_VALUE,
MAP_SCOPE_OBJECT_KIND_FIELD,
MAP_SCOPE_PROVIDER_FIELD,
mapFactInsideGridSector,
mapFactPointCoordinates,
mapFactSectorScopeValue,
normalizedSectorScopeValue,
sectorScopeValueLabel,
} from "./mapSectorRuntime.mjs";
export type SectorGridLodProfile = GridLodProfile & {
majorLinesEnabled: boolean;
majorLabelsEnabled: boolean;
majorLineWidthMultiplier: number;
selectionFillColor: string;
selectionFillOpacityPercent: number;
selectionOutlineColor: string;
selectionOutlineWidthPx: number;
selectionOutlineOpacityPercent: number;
volumeEnabled: boolean;
volumeMinimumHeightMeters: number;
volumeMaximumHeightMeters: number;
volumeBandHeightMeters: number;
};
export type GridSectorDirection = "north" | "east" | "south" | "west";
export const GRID_SECTOR_DIRECTIONS: Array<{ id: GridSectorDirection; label: string }> = [
{ id: "north", label: "Север" },
{ id: "east", label: "Восток" },
{ id: "south", label: "Юг" },
{ id: "west", label: "Запад" },
];
export function normalizedMajorTileSizeKm(stepKm: number, requestedTileSizeKm: number) {
const safeStepKm = Math.min(50, Math.max(0.1, stepKm));
const maximumRatio = Math.max(1, Math.floor((50 + Number.EPSILON) / safeStepKm));
const requestedRatio = Math.max(1, Math.ceil((requestedTileSizeKm - Number.EPSILON) / safeStepKm));
const ratio = Math.min(maximumRatio, requestedRatio);
return Number((safeStepKm * ratio).toFixed(6));
}
export function normalizedGraticuleStepDegrees(requestedStepDegrees: number) {
const safeStepDegrees = Math.min(10, Math.max(0.1, requestedStepDegrees));
const requestedDivisions = Math.max(1, Math.round(180 / safeStepDegrees));
// Five minor intervals form one major tile and every 90° quadrant must end
// on a major boundary. A hemisphere therefore uses a multiple of ten.
const hemisphereDivisions = Math.max(10, Math.round(requestedDivisions / 10) * 10);
return 180 / hemisphereDivisions;
}
export function graticuleMajorStepDegrees(stepDegrees: number) {
const candidate = stepDegrees * 5;
const quadrantBands = 90 / candidate;
return Math.abs(quadrantBands - Math.round(quadrantBands)) <= 1e-9 * Math.max(1, Math.abs(quadrantBands))
? candidate
: null;
}
export function normalizeSectorGridLodProfile(profile: SectorGridLodProfile): SectorGridLodProfile {
const stepKm = profile.mode === "3d" ? Math.min(50, profile.stepKm) : profile.stepKm;
const volumeMinimumHeightMeters = profile.volumeMinimumHeightMeters;
const volumeMaximumHeightMeters = Math.max(volumeMinimumHeightMeters + 1, profile.volumeMaximumHeightMeters);
return {
...profile,
stepKm,
tileSizeKm: profile.mode === "3d"
? normalizedMajorTileSizeKm(stepKm, Math.max(stepKm, profile.tileSizeKm))
: profile.tileSizeKm,
graticuleStepDegrees: profile.mode === "graticule"
? normalizedGraticuleStepDegrees(profile.graticuleStepDegrees)
: profile.graticuleStepDegrees,
majorLabelsEnabled: profile.majorLinesEnabled && profile.majorLabelsEnabled,
volumeEnabled: profile.mode === "3d" && profile.volumeEnabled,
volumeMinimumHeightMeters,
volumeMaximumHeightMeters,
volumeBandHeightMeters: Math.min(
volumeMaximumHeightMeters - volumeMinimumHeightMeters,
Math.max(1, profile.volumeBandHeightMeters),
),
};
}
export function resolveGridLodProfiles(settings?: Partial<MapPageSettings>): GridLodProfile[] {
// Layouts saved by the previous flat contract retain their tuned values.
// The next ordinary page save persists the canonical five-profile array.
const legacySettings: MapPresentation = {
...initialMapSettings,
...settings,
gridLodProfiles: Array.isArray(settings?.gridLodProfiles) ? settings.gridLodProfiles : [],
cacheRefresh: false,
};
return Array.from({ length: 5 }, (_unused, index) => normalizeSectorGridLodProfile(
gridLodProfile(legacySettings, index) as SectorGridLodProfile,
));
}
export function localGridSectorSelection(
address: LocalSectorAddress,
profile: SectorGridLodProfile,
origin: { latitude: number; longitude: number },
preferredAltitudeMeters?: number,
): GridSectorSelection {
const definition = {
lod: address.lod,
originLatitude: origin.latitude,
originLongitude: origin.longitude,
stepMeters: profile.stepKm * 1_000,
tileSizeMeters: profile.tileSizeKm * 1_000,
};
const summary = localSectorSummary(address, definition);
const volumeSpan = profile.volumeMaximumHeightMeters - profile.volumeMinimumHeightMeters;
const volume = profile.volumeEnabled && volumeSpan > 0
? (() => {
const altitudeMeters = Math.min(
profile.volumeMaximumHeightMeters - Number.EPSILON,
Math.max(
profile.volumeMinimumHeightMeters,
preferredAltitudeMeters ?? profile.volumeMinimumHeightMeters + Math.min(profile.volumeBandHeightMeters, volumeSpan) / 2,
),
);
const volumeAddress = localVolumeAt({ ...summary.center, altitudeMeters }, {
lod: address.lod,
originLatitude: origin.latitude,
originLongitude: origin.longitude,
stepMeters: profile.stepKm * 1_000,
altitudeFloorMeters: profile.volumeMinimumHeightMeters,
altitudeCeilingMeters: profile.volumeMaximumHeightMeters,
altitudeBandMeters: profile.volumeBandHeightMeters,
});
if (!volumeAddress) return null;
return {
id: volumeAddress.id,
index: volumeAddress.bandIndex,
floor: Math.max(profile.volumeMinimumHeightMeters, volumeAddress.altitudeFloorMeters),
ceiling: Math.min(profile.volumeMaximumHeightMeters, volumeAddress.altitudeCeilingMeters),
bandHeight: volumeAddress.altitudeBandMeters,
};
})()
: null;
return {
...summary,
mode: "3d",
address,
units: "meters-enu",
volume,
};
}
export function graticuleGridSectorSelection(
address: GraticuleSectorAddress,
profile: SectorGridLodProfile,
): GridSectorSelection {
const majorStepDegrees = profile.majorLinesEnabled
? graticuleMajorStepDegrees(profile.graticuleStepDegrees) ?? undefined
: undefined;
return {
...graticuleSectorSummary(address, {
lod: address.lod,
stepDegrees: profile.graticuleStepDegrees,
majorStepDegrees,
}),
mode: "graticule",
address,
units: "degrees-wgs84",
volume: null,
};
}
export function gridSectorNeighborSelection(
selection: GridSectorSelection,
direction: GridSectorDirection,
profiles: SectorGridLodProfile[],
origin: { latitude: number; longitude: number },
) {
const profile = profiles[selection.lod - 1];
if (!profile) return null;
if (selection.mode === "3d") {
const neighbor = selection.neighbors[direction];
const preferredAltitudeMeters = selection.volume
? (selection.volume.floor + selection.volume.ceiling) / 2
: undefined;
return neighbor ? localGridSectorSelection(neighbor.address, profile, origin, preferredAltitudeMeters) : null;
}
const neighbor = selection.neighbors[direction];
return neighbor ? graticuleGridSectorSelection(neighbor.address, profile) : null;
}
export function gridSectorParentLodSelection(
selection: GridSectorSelection,
profiles: SectorGridLodProfile[],
origin: { latitude: number; longitude: number },
) {
const parentProfile = profiles[selection.lod];
if (!parentProfile || parentProfile.mode !== selection.mode) return null;
if (selection.mode === "3d") {
const address = localSectorAt(selection.center, {
lod: selection.lod + 1,
originLatitude: origin.latitude,
originLongitude: origin.longitude,
stepMeters: parentProfile.stepKm * 1_000,
});
const preferredAltitudeMeters = selection.volume
? (selection.volume.floor + selection.volume.ceiling) / 2
: undefined;
return localGridSectorSelection(address, parentProfile, origin, preferredAltitudeMeters);
}
return graticuleGridSectorSelection(graticuleSectorAt(selection.center, {
lod: selection.lod + 1,
stepDegrees: parentProfile.graticuleStepDegrees,
}), parentProfile);
}
export function gridSectorVolumeNeighborSelection(
selection: GridSectorSelection,
direction: "above" | "below",
profile: SectorGridLodProfile | null,
origin: { latitude: number; longitude: number },
) {
if (selection.mode !== "3d" || !selection.volume || !profile?.volumeEnabled) return null;
const targetIndex = selection.volume.index + (direction === "above" ? 1 : -1);
const targetFloorMeters = profile.volumeMinimumHeightMeters + targetIndex * profile.volumeBandHeightMeters;
if (targetIndex < 0 || targetFloorMeters >= profile.volumeMaximumHeightMeters) return null;
const targetCeilingMeters = Math.min(
profile.volumeMaximumHeightMeters,
targetFloorMeters + profile.volumeBandHeightMeters,
);
return localGridSectorSelection(
selection.address,
profile,
origin,
(targetFloorMeters + targetCeilingMeters) / 2,
);
}
const formatGridMetric = (value: number, maximumFractionDigits = 1) => value.toLocaleString("ru-RU", {
maximumFractionDigits,
});
export const formatGridSectorArea = (areaSquareMeters: number) => areaSquareMeters >= 1_000_000
? `${formatGridMetric(areaSquareMeters / 1_000_000, areaSquareMeters >= 1_000_000_000 ? 0 : 2)} км²`
: `${formatGridMetric(areaSquareMeters, 0)} м²`;
export function gridSectorBoundsLabel(selection: GridSectorSelection) {
const { west, east, south, north } = selection.bounds;
return selection.mode === "3d"
? `E ${formatGridMetric(west)}…${formatGridMetric(east)} м · N ${formatGridMetric(south)}…${formatGridMetric(north)} м`
: `λ ${formatGridMetric(west, 6)}…${formatGridMetric(east, 6)}° · φ ${formatGridMetric(south, 6)}…${formatGridMetric(north, 6)}°`;
}
export function gridSectorCenterLabel(selection: GridSectorSelection) {
return selection.mode === "3d"
? `E ${formatGridMetric(selection.center.eastMeters)} м · N ${formatGridMetric(selection.center.northMeters)} м`
: `${formatGridMetric(selection.center.latitude, 6)}°, ${formatGridMetric(selection.center.longitude, 6)}°`;
}
+53
View File
@@ -0,0 +1,53 @@
import type { MapDataProductBinding, MapSubjectState } from "./mapPageContract.js";
import type { MapPresentationFilters, MapPresentationProfile } from "./mapPresentationProfile.js";
import type { GridSectorSelection } from "./mapRendererContract.js";
import type { SectorGridLodProfile } from "./mapSectorWorkspace.js";
import type { MapRuntimeBinding, MapRuntimeFact } from "./useMapDataProductRuntime.js";
export type MapSelectableEntity = {
id: string;
title: string;
kind: string;
status: string;
bindingId: string;
dataProductId: string;
fact: MapRuntimeFact;
};
export type MapSectorScope = {
excludedBindingIds: string[];
excludedProviders: string[];
excludedObjectKinds: string[];
};
export type MapPresentationSummary = {
bindingId: string;
displayName: string;
profile: MapPresentationProfile;
total: number;
counts: Record<string, Record<string, number>>;
};
export type MapFilteredTarget = {
bindingId: string;
entityId: string;
title: string;
status: string;
renderable: boolean;
};
export function mapProfileHasSubjectWindowControls(profile: MapPresentationProfile): boolean;
export function primaryMapBindingConfigs(bindingConfigs: MapDataProductBinding[]): MapDataProductBinding[];
export function primaryMapRuntimeBindings(runtimeBindings: MapRuntimeBinding[], bindingConfigs: MapDataProductBinding[]): MapRuntimeBinding[];
export function buildMapPresentationFilters(subjectStates: Record<string, MapSubjectState>, bindingConfigs: MapDataProductBinding[], profiles: MapPresentationProfile[]): MapPresentationFilters;
export function buildSelectableMapEntities(runtimeBindings: MapRuntimeBinding[], bindingConfigs: MapDataProductBinding[], profiles: MapPresentationProfile[]): MapSelectableEntity[];
export function buildSectorSpatialEntities(selectable: MapSelectableEntity[], selection: GridSectorSelection | null, profiles: SectorGridLodProfile[], origin: { latitude: number; longitude: number }): MapSelectableEntity[];
export function buildSectorBindingOptions(bindingConfigs: MapDataProductBinding[], sectorEntities: MapSelectableEntity[]): Array<{ value: string; label: string; count: number }>;
export function bindingProjectsField(bindingConfigs: MapDataProductBinding[], field: string): boolean;
export function buildSectorScopeOptions(sectorEntities: MapSelectableEntity[], field: string, enabled?: boolean): Array<{ value: string; label: string; count: number }>;
export function sectorEntityIsExcluded(entity: MapSelectableEntity, scope: MapSectorScope): boolean;
export function buildSectorVisibleEntities(input: { sectorEntities: MapSelectableEntity[]; bindingConfigs: MapDataProductBinding[]; presentationProfiles: MapPresentationProfile[]; presentationFilters: MapPresentationFilters; scope: MapSectorScope }): MapSelectableEntity[];
export function scopeMapRuntimeBindings(input: { runtimeBindings: MapRuntimeBinding[]; selection: GridSectorSelection | null; gridProfiles: SectorGridLodProfile[]; origin: { latitude: number; longitude: number }; hideOutsideSector: boolean; scope: MapSectorScope }): MapRuntimeBinding[];
export function buildMapPresentationSummaries(bindingConfigs: MapDataProductBinding[], runtimeBindings: MapRuntimeBinding[], profiles: MapPresentationProfile[]): MapPresentationSummary[];
export function buildFilteredMapTargets(runtimeBindings: MapRuntimeBinding[], bindingConfigs: MapDataProductBinding[], profiles: MapPresentationProfile[], filters: MapPresentationFilters): MapFilteredTarget[];
export function planMapSubjectReveal(input: { entity: MapSelectableEntity; subjectState: MapSubjectState; profile: MapPresentationProfile | undefined; selectedSector: GridSectorSelection | null; gridProfiles: SectorGridLodProfile[]; origin: { latitude: number; longitude: number }; hideOutsideSector: boolean; scope: MapSectorScope }): { subjectState: MapSubjectState; hideOutsideSector: boolean; scope: MapSectorScope };
+256
View File
@@ -0,0 +1,256 @@
import {
compareMapRuntimeFacts,
mapFactMatchesFilters,
mapPresentationFacetCounts,
mapPresentationProfileForFact,
mapRuntimeDisplayLabel,
mapRuntimeFactIsRenderable,
normalizeMapPresentationFacetSelections,
resolveMapPresentationClass,
} from "./mapPresentationProfile.mjs";
import { mapRuntimeEntityId } from "./mapRuntimeIdentity.mjs";
import {
MAP_SCOPE_OBJECT_KIND_FIELD,
MAP_SCOPE_PROVIDER_FIELD,
mapFactInsideGridSector,
mapFactSectorScopeValue,
sectorScopeValueLabel,
} from "./mapSectorRuntime.mjs";
export function mapProfileHasSubjectWindowControls(profile) {
return profile.facets.some((facet) => facet.counter || facet.filterable);
}
/** Primary bindings create map subjects; joined aspects only enrich them. */
export function primaryMapBindingConfigs(bindingConfigs) {
return [...bindingConfigs]
.filter((binding) => !binding.joinToBindingId)
.sort(bindingOrder);
}
export function primaryMapRuntimeBindings(runtimeBindings, bindingConfigs) {
const runtimeById = new Map(runtimeBindings.map((binding) => [binding.bindingId, binding]));
return primaryMapBindingConfigs(bindingConfigs)
.map((binding) => runtimeById.get(binding.id))
.filter(Boolean);
}
export function buildMapPresentationFilters(subjectStates, bindingConfigs, profiles) {
return Object.fromEntries(Object.entries(subjectStates).map(([bindingId, state]) => {
const binding = bindingConfigs.find((candidate) => candidate.id === bindingId);
const profile = mapPresentationProfileForFact(
profiles,
binding?.presentationProfileId,
binding?.semanticTypes?.[0] ?? "",
);
return [bindingId, {
visible: state.visible,
facets: profile ? normalizeMapPresentationFacetSelections(state.filters, profile) : state.filters,
}];
}));
}
export function buildSelectableMapEntities(runtimeBindings, bindingConfigs, profiles) {
const configById = new Map(bindingConfigs.map((binding) => [binding.id, binding]));
return primaryMapRuntimeBindings(runtimeBindings, bindingConfigs).flatMap((binding) => {
const bindingConfig = configById.get(binding.bindingId);
const facts = [...binding.facts];
const primaryProfile = mapPresentationProfileForFact(
profiles,
bindingConfig?.presentationProfileId,
bindingConfig?.semanticTypes?.[0] ?? facts[0]?.semanticType ?? "",
);
if (primaryProfile) facts.sort((left, right) => compareMapRuntimeFacts(left, right, primaryProfile));
return facts.map((fact) => {
const profile = mapPresentationProfileForFact(profiles, bindingConfig?.presentationProfileId, fact.semanticType);
const presentationClass = profile ? resolveMapPresentationClass(fact, profile) : undefined;
return {
id: mapRuntimeEntityId(binding.bindingId, fact),
title: mapRuntimeDisplayLabel(fact, profile),
kind: fact.semanticType,
status: presentationClass?.label ?? fact.presentationStatus,
bindingId: binding.bindingId,
dataProductId: binding.dataProductId,
fact,
};
});
});
}
export function buildSectorSpatialEntities(selectable, selection, profiles, origin) {
return selection
? selectable.filter((entity) => mapFactInsideGridSector(entity.fact, selection, profiles, origin))
: [];
}
export function buildSectorBindingOptions(bindingConfigs, sectorEntities) {
return primaryMapBindingConfigs(bindingConfigs).map((binding) => ({
value: binding.id,
label: binding.displayName?.trim() || binding.id,
count: sectorEntities.filter((entity) => entity.bindingId === binding.id).length,
}));
}
export function bindingProjectsField(bindingConfigs, field) {
return bindingConfigs.some((binding) => !binding.joinToBindingId && binding.fieldProjection.includes(field));
}
export function buildSectorScopeOptions(sectorEntities, field, enabled = true) {
if (!enabled) return [];
const counts = new Map();
for (const { fact } of sectorEntities) {
const value = mapFactSectorScopeValue(fact, field);
counts.set(value, (counts.get(value) ?? 0) + 1);
}
return [...counts]
.map(([value, count]) => ({ value, count, label: sectorScopeValueLabel(value) }))
.sort((left, right) => left.label.localeCompare(right.label, "ru"));
}
export function sectorEntityIsExcluded(entity, scope) {
return scope.excludedBindingIds.includes(entity.bindingId)
|| scope.excludedProviders.includes(mapFactSectorScopeValue(entity.fact, MAP_SCOPE_PROVIDER_FIELD))
|| scope.excludedObjectKinds.includes(mapFactSectorScopeValue(entity.fact, MAP_SCOPE_OBJECT_KIND_FIELD));
}
export function buildSectorVisibleEntities({
sectorEntities,
bindingConfigs,
presentationProfiles,
presentationFilters,
scope,
}) {
const configById = new Map(bindingConfigs.map((binding) => [binding.id, binding]));
return sectorEntities.filter((entity) => {
if (sectorEntityIsExcluded(entity, scope)) return false;
const binding = configById.get(entity.bindingId);
const profile = mapPresentationProfileForFact(
presentationProfiles,
binding?.presentationProfileId,
entity.fact.semanticType,
);
return Boolean(profile && mapFactMatchesFilters(
entity.fact,
profile,
presentationFilters,
entity.bindingId,
));
});
}
export function scopeMapRuntimeBindings({
runtimeBindings,
selection,
gridProfiles,
origin,
hideOutsideSector,
scope,
}) {
if (!selection) return runtimeBindings;
return runtimeBindings.map((binding) => ({
...binding,
facts: binding.facts.filter((fact) => {
if (scope.excludedBindingIds.includes(binding.bindingId)) return false;
if (scope.excludedProviders.includes(mapFactSectorScopeValue(fact, MAP_SCOPE_PROVIDER_FIELD))) return false;
if (scope.excludedObjectKinds.includes(mapFactSectorScopeValue(fact, MAP_SCOPE_OBJECT_KIND_FIELD))) return false;
return !hideOutsideSector || mapFactInsideGridSector(fact, selection, gridProfiles, origin);
}),
}));
}
export function buildMapPresentationSummaries(bindingConfigs, runtimeBindings, profiles) {
const runtimeById = new Map(runtimeBindings.map((binding) => [binding.bindingId, binding]));
return primaryMapBindingConfigs(bindingConfigs).flatMap((bindingConfig) => {
const binding = runtimeById.get(bindingConfig.id);
const facts = binding?.facts ?? [];
const semanticType = bindingConfig.semanticTypes[0] ?? facts[0]?.semanticType ?? "";
const profile = mapPresentationProfileForFact(profiles, bindingConfig.presentationProfileId, semanticType);
return profile ? [{
bindingId: bindingConfig.id,
displayName: bindingConfig.displayName?.trim() || profile.title || bindingConfig.id,
profile,
total: facts.length,
counts: mapPresentationFacetCounts(facts, profile),
}] : [];
});
}
/**
* Object overview order is the same declared profile order used by facets and
* renderer subjects. A title-only resort must not silently override MCP sort.
*/
export function buildFilteredMapTargets(runtimeBindings, bindingConfigs, profiles, filters) {
return buildSelectableMapEntities(runtimeBindings, bindingConfigs, profiles).flatMap((entity) => {
const binding = bindingConfigs.find((candidate) => candidate.id === entity.bindingId);
const profile = mapPresentationProfileForFact(profiles, binding?.presentationProfileId, entity.fact.semanticType);
if (!profile || !mapFactMatchesFilters(entity.fact, profile, filters, entity.bindingId)) return [];
return [{
bindingId: entity.bindingId,
entityId: entity.id,
title: entity.title,
status: resolveMapPresentationClass(entity.fact, profile)?.label ?? "",
renderable: mapRuntimeFactIsRenderable(entity.fact, profile),
}];
});
}
/**
* Search reveal changes only constraints that hide the selected subject. The
* active sector identity is preserved; outside-sector clipping is relaxed only
* when needed so focus and the rendered subject cannot diverge.
*/
export function planMapSubjectReveal({
entity,
subjectState,
profile,
selectedSector,
gridProfiles,
origin,
hideOutsideSector,
scope,
}) {
let filters = { ...(subjectState?.filters ?? {}) };
for (const facet of profile?.facets ?? []) {
const selected = filters[facet.field];
if (selected === undefined) continue;
const factValue = normalizedFacetValue(entity.fact.attributes[facet.field]);
const available = facet.values.map((item) => item.value);
if (!available.includes(factValue)) {
delete filters[facet.field];
continue;
}
filters[facet.field] = available.filter((value) => selected.includes(value) || value === factValue);
}
if (profile) filters = normalizeMapPresentationFacetSelections(filters, profile);
if (profile && !mapFactMatchesFilters(entity.fact, profile, {
[entity.bindingId]: { visible: true, facets: filters },
}, entity.bindingId)) filters = {};
const provider = mapFactSectorScopeValue(entity.fact, MAP_SCOPE_PROVIDER_FIELD);
const objectKind = mapFactSectorScopeValue(entity.fact, MAP_SCOPE_OBJECT_KIND_FIELD);
const insideSector = selectedSector
? mapFactInsideGridSector(entity.fact, selectedSector, gridProfiles, origin)
: true;
return {
subjectState: {
...subjectState,
bindingId: entity.bindingId,
visible: true,
filters,
},
hideOutsideSector: hideOutsideSector && (!selectedSector || insideSector),
scope: {
excludedBindingIds: scope.excludedBindingIds.filter((value) => value !== entity.bindingId),
excludedProviders: scope.excludedProviders.filter((value) => value !== provider),
excludedObjectKinds: scope.excludedObjectKinds.filter((value) => value !== objectKind),
},
};
}
function bindingOrder(left, right) {
return (left.order ?? 0) - (right.order ?? 0) || left.id.localeCompare(right.id);
}
function normalizedFacetValue(value) {
return typeof value === "string" ? value.trim().toLowerCase() : "unknown";
}
+56
View File
@@ -0,0 +1,56 @@
import type { WorkspaceWindowRect } from "@nodedc/ui-react";
import type { MapSubjectState, MapWorkspaceWindowId } from "./mapPageContract.js";
import type { GridSectorSelection } from "./mapRendererContract.js";
import type { MapSectorScope } from "./mapWorkspaceModel.mjs";
export type MapWorkspaceState = {
selectedEntityId?: string;
selectedSector: GridSectorSelection | null;
subjectStates: Record<string, MapSubjectState>;
activeWindowId?: MapWorkspaceWindowId;
sector: {
window: { rect: WorkspaceWindowRect; maximized: boolean; zIndex: number };
hideOutside: boolean;
scope: MapSectorScope;
};
subjectCard: {
open: boolean;
rect: WorkspaceWindowRect;
maximized: boolean;
zIndex: number;
tabId: string;
};
};
export type MapWorkspaceAction =
| { type: "reset-sector-definition" }
| { type: "deactivate-sector" }
| { type: "set-sector-selection"; selection: GridSectorSelection | null }
| { type: "select-sector"; selection: GridSectorSelection | null }
| { type: "set-sector-hide-outside"; value: boolean }
| { type: "set-sector-scope"; scope: MapSectorScope }
| { type: "set-sector-scope-enabled"; dimension: "binding" | "provider" | "object-kind"; value: string; enabled: boolean }
| { type: "set-sector-window-rect"; rect: WorkspaceWindowRect }
| { type: "set-sector-window-maximized"; value: boolean }
| { type: "toggle-subject-filter"; bindingId: string; field: string; value: string; availableValues: string[] }
| { type: "toggle-subject-visibility"; bindingId: string }
| { type: "replace-subject-state"; bindingId: string; subjectState: MapSubjectState }
| { type: "open-subject-window"; bindingId: string }
| { type: "close-subject-window"; bindingId: string }
| { type: "set-subject-window-rect"; bindingId: string; rect: WorkspaceWindowRect }
| { type: "set-subject-window-maximized"; bindingId: string; value: boolean }
| { type: "activate-window"; windowId: MapWorkspaceWindowId }
| { type: "clear-active-window"; windowId: MapWorkspaceWindowId }
| { type: "select-entity"; entityId: string; validTabIds: string[]; defaultTabId: string }
| { type: "close-subject-card" }
| { type: "set-subject-card-rect"; rect: WorkspaceWindowRect }
| { type: "set-subject-card-maximized"; value: boolean }
| { type: "set-subject-card-tab"; tabId: string }
| { type: "reveal-subject"; bindingId: string; subjectState: MapSubjectState; hideOutsideSector: boolean; scope: MapSectorScope };
export function createMapWorkspaceState(input: {
subjectStates: Record<string, MapSubjectState>;
sectorWindowRect: WorkspaceWindowRect;
subjectCardRect: WorkspaceWindowRect;
}): MapWorkspaceState;
export function mapWorkspaceReducer(state: MapWorkspaceState, action: MapWorkspaceAction): MapWorkspaceState;
+223
View File
@@ -0,0 +1,223 @@
import { toggleMapPresentationFacetSelection } from "./mapPresentationProfile.mjs";
export function createMapWorkspaceState({ subjectStates, sectorWindowRect, subjectCardRect }) {
return {
selectedEntityId: undefined,
selectedSector: null,
subjectStates,
activeWindowId: undefined,
sector: {
window: { rect: sectorWindowRect, maximized: false, zIndex: 142 },
hideOutside: false,
scope: {
excludedBindingIds: [],
excludedProviders: [],
excludedObjectKinds: [],
},
},
subjectCard: {
open: false,
rect: subjectCardRect,
maximized: false,
zIndex: 140,
tabId: "overview",
},
};
}
export function mapWorkspaceReducer(state, action) {
switch (action.type) {
case "reset-sector-definition":
case "deactivate-sector":
return {
...state,
selectedSector: null,
activeWindowId: state.activeWindowId === "sector" ? undefined : state.activeWindowId,
sector: {
...state.sector,
scope: { excludedBindingIds: [], excludedProviders: [], excludedObjectKinds: [] },
},
};
case "set-sector-selection":
return { ...state, selectedSector: action.selection };
case "select-sector":
return action.selection
? activateWindow({ ...state, selectedSector: action.selection }, "sector")
: mapWorkspaceReducer(state, { type: "deactivate-sector" });
case "set-sector-hide-outside":
return { ...state, sector: { ...state.sector, hideOutside: action.value } };
case "set-sector-scope":
return { ...state, sector: { ...state.sector, scope: action.scope } };
case "set-sector-scope-enabled": {
const key = action.dimension === "binding"
? "excludedBindingIds"
: action.dimension === "provider"
? "excludedProviders"
: "excludedObjectKinds";
return {
...state,
sector: {
...state.sector,
scope: {
...state.sector.scope,
[key]: setScopeValueEnabled(state.sector.scope[key], action.value, action.enabled),
},
},
};
}
case "set-sector-window-rect":
return {
...state,
sector: { ...state.sector, window: { ...state.sector.window, rect: action.rect } },
};
case "set-sector-window-maximized":
return {
...state,
sector: { ...state.sector, window: { ...state.sector.window, maximized: action.value } },
};
case "toggle-subject-filter": {
const subject = state.subjectStates[action.bindingId];
if (!subject) return state;
const filters = subject.visible ? subject.filters : {};
return replaceSubjectState(state, action.bindingId, {
...subject,
visible: true,
filters: toggleMapPresentationFacetSelection(
filters,
action.field,
action.value,
action.availableValues,
),
});
}
case "toggle-subject-visibility": {
const subject = state.subjectStates[action.bindingId];
return subject
? replaceSubjectState(state, action.bindingId, { ...subject, visible: !subject.visible })
: state;
}
case "replace-subject-state":
return replaceSubjectState(state, action.bindingId, action.subjectState);
case "open-subject-window": {
const subject = state.subjectStates[action.bindingId];
if (!subject) return state;
return activateWindow(replaceSubjectState(state, action.bindingId, {
...subject,
window: { ...subject.window, open: true },
}), `binding:${action.bindingId}`);
}
case "close-subject-window": {
const subject = state.subjectStates[action.bindingId];
if (!subject) return state;
const next = replaceSubjectState(state, action.bindingId, {
...subject,
window: { ...subject.window, open: false },
});
const windowId = `binding:${action.bindingId}`;
return { ...next, activeWindowId: next.activeWindowId === windowId ? undefined : next.activeWindowId };
}
case "set-subject-window-rect": {
const subject = state.subjectStates[action.bindingId];
return subject ? replaceSubjectState(state, action.bindingId, {
...subject,
window: { ...subject.window, rect: action.rect },
}) : state;
}
case "set-subject-window-maximized": {
const subject = state.subjectStates[action.bindingId];
return subject ? replaceSubjectState(state, action.bindingId, {
...subject,
window: { ...subject.window, maximized: action.value },
}) : state;
}
case "activate-window":
return activateWindow(state, action.windowId);
case "clear-active-window":
return {
...state,
activeWindowId: state.activeWindowId === action.windowId ? undefined : state.activeWindowId,
};
case "select-entity": {
const tabId = action.validTabIds.includes(state.subjectCard.tabId)
? state.subjectCard.tabId
: action.defaultTabId;
return activateWindow({
...state,
selectedEntityId: action.entityId,
subjectCard: { ...state.subjectCard, open: true, tabId },
}, "subject-card");
}
case "close-subject-card":
return {
...state,
activeWindowId: state.activeWindowId === "subject-card" ? undefined : state.activeWindowId,
subjectCard: { ...state.subjectCard, open: false },
};
case "set-subject-card-rect":
return { ...state, subjectCard: { ...state.subjectCard, rect: action.rect } };
case "set-subject-card-maximized":
return { ...state, subjectCard: { ...state.subjectCard, maximized: action.value } };
case "set-subject-card-tab":
return { ...state, subjectCard: { ...state.subjectCard, tabId: action.tabId } };
case "reveal-subject":
return {
...replaceSubjectState(state, action.bindingId, action.subjectState),
sector: {
...state.sector,
hideOutside: action.hideOutsideSector,
scope: action.scope,
},
};
default:
return state;
}
}
function replaceSubjectState(state, bindingId, subjectState) {
return {
...state,
subjectStates: { ...state.subjectStates, [bindingId]: subjectState },
};
}
function activateWindow(state, windowId) {
if (state.activeWindowId === windowId) return state;
const nextZIndex = Math.max(
20,
state.sector.window.zIndex,
state.subjectCard.zIndex,
...Object.values(state.subjectStates).map((subject) => subject.window.zIndex),
) + 1;
if (windowId === "sector") {
return {
...state,
activeWindowId: windowId,
sector: { ...state.sector, window: { ...state.sector.window, zIndex: nextZIndex } },
};
}
if (windowId === "subject-card") {
return {
...state,
activeWindowId: windowId,
subjectCard: { ...state.subjectCard, zIndex: nextZIndex },
};
}
if (windowId.startsWith("binding:")) {
const bindingId = windowId.slice("binding:".length);
const subject = state.subjectStates[bindingId];
return subject ? {
...replaceSubjectState(state, bindingId, {
...subject,
window: { ...subject.window, zIndex: nextZIndex },
}),
activeWindowId: windowId,
} : state;
}
return state;
}
function setScopeValueEnabled(values, value, enabled) {
return enabled
? values.filter((candidate) => candidate !== value)
: [...new Set([...values, value])];
}
-35
View File
@@ -1678,33 +1678,6 @@ textarea {
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
} }
.catalog-split-pane-preview .catalog-preview__body {
align-content: stretch;
}
.catalog-split-pane-demo {
min-height: 18rem;
overflow: hidden;
border-radius: var(--nodedc-radius-card);
background: var(--nodedc-nested-surface);
}
.catalog-split-pane-demo__panel {
display: grid;
width: 100%;
height: 100%;
place-content: center;
justify-items: center;
gap: 0.5rem;
background: color-mix(in srgb, var(--nodedc-canvas) 82%, transparent);
color: var(--nodedc-text-secondary);
}
.catalog-split-pane-demo__panel strong {
color: var(--nodedc-text-primary);
font-size: 0.82rem;
}
.catalog-side-panel-preview .catalog-preview__body { .catalog-side-panel-preview .catalog-preview__body {
align-content: stretch; align-content: stretch;
} }
@@ -1781,14 +1754,6 @@ textarea {
flex-wrap: wrap; flex-wrap: wrap;
} }
.catalog-icon-rail-demo {
display: flex;
width: max-content;
flex-direction: column;
align-items: center;
gap: var(--nodedc-space-2);
}
.catalog-form { .catalog-form {
display: grid; display: grid;
gap: 0.75rem; gap: 0.75rem;
+9 -14
View File
@@ -1,5 +1,8 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import type { MapDataProductBinding } from "./MapFixturePreview.js"; import type { MapDataProductBinding } from "./mapPageContract.js";
import { mapRuntimeFactKey } from "./mapRuntimeIdentity.mjs";
export { mapRuntimeEntityId } from "./mapRuntimeIdentity.mjs";
export type DataProductPoint = { export type DataProductPoint = {
type: "Point"; type: "Point";
@@ -78,14 +81,6 @@ type BindingState = {
const identifier = /^[A-Za-z0-9._:-]{1,160}$/; const identifier = /^[A-Za-z0-9._:-]{1,160}$/;
const cursorPattern = /^(?:0|[1-9]\d*)$/; const cursorPattern = /^(?:0|[1-9]\d*)$/;
function factKey(fact: MapRuntimeFact) {
return `${fact.semanticType}\u0000${fact.sourceId}`;
}
export function mapRuntimeEntityId(bindingId: string, fact: Pick<MapRuntimeFact, "sourceId" | "semanticType">) {
return `nodedc-runtime:${bindingId}:${fact.semanticType}:${fact.sourceId}`;
}
function isIsoTimestamp(value: unknown): value is string { function isIsoTimestamp(value: unknown): value is string {
return typeof value === "string" && !Number.isNaN(Date.parse(value)); return typeof value === "string" && !Number.isNaN(Date.parse(value));
} }
@@ -246,7 +241,7 @@ function replaceSnapshot(current: BindingState, snapshot: SnapshotEnvelope): Bin
...current, ...current,
cursor: snapshot.cursor, cursor: snapshot.cursor,
state: "ready", state: "ready",
facts: Object.fromEntries(snapshot.facts.map((fact) => [factKey(fact), fact])), facts: Object.fromEntries(snapshot.facts.map((fact) => [mapRuntimeFactKey(fact), fact])),
}; };
} }
@@ -257,8 +252,8 @@ function applyPatch(current: BindingState, patch: PatchEnvelope): BindingState |
if (current.cursor !== patch.previousCursor) return null; if (current.cursor !== patch.previousCursor) return null;
const facts = { ...current.facts }; const facts = { ...current.facts };
for (const operation of patch.operations) { for (const operation of patch.operations) {
if (operation.op === "upsert") facts[factKey(operation.fact)] = operation.fact; if (operation.op === "upsert") facts[mapRuntimeFactKey(operation.fact)] = operation.fact;
else delete facts[`${operation.semanticType}\u0000${operation.sourceId}`]; else delete facts[mapRuntimeFactKey(operation)];
} }
return { ...current, facts, cursor: patch.cursor, state: "ready" }; return { ...current, facts, cursor: patch.cursor, state: "ready" };
} }
@@ -266,7 +261,7 @@ function applyPatch(current: BindingState, patch: PatchEnvelope): BindingState |
function applyPresentationPatch(current: BindingState, patch: PresentationPatchEnvelope): BindingState { function applyPresentationPatch(current: BindingState, patch: PresentationPatchEnvelope): BindingState {
const facts = { ...current.facts }; const facts = { ...current.facts };
for (const operation of patch.operations) { for (const operation of patch.operations) {
const key = `${operation.semanticType}\u0000${operation.sourceId}`; const key = mapRuntimeFactKey(operation);
if (facts[key]) facts[key] = { ...facts[key], presentationStatus: operation.status }; if (facts[key]) facts[key] = { ...facts[key], presentationStatus: operation.status };
} }
return { ...current, facts }; return { ...current, facts };
@@ -421,7 +416,7 @@ export function useMapDataProductRuntime({
dataProductId: binding.dataProductId, dataProductId: binding.dataProductId,
slotId: binding.slotId, slotId: binding.slotId,
presentationProfileId: binding.presentationProfileId, presentationProfileId: binding.presentationProfileId,
facts: Object.values(record.facts).sort((left, right) => factKey(left).localeCompare(factKey(right))), facts: Object.values(record.facts).sort((left, right) => mapRuntimeFactKey(left).localeCompare(mapRuntimeFactKey(right))),
cursor: record.cursor, cursor: record.cursor,
state: record.state, state: record.state,
}; };
+7 -1
View File
@@ -211,7 +211,13 @@ function asFact(value: unknown): MapRuntimeFact | null {
|| !isIso(value.observedAt) || !isIso(value.receivedAt) || !isIso(value.observedAt) || !isIso(value.receivedAt)
|| !isObject(value.attributes) || !categories.has(value.attributes.category as MapReferenceStationCategory) || !isObject(value.attributes) || !categories.has(value.attributes.category as MapReferenceStationCategory)
|| !isPoint(value.geometry)) return null; || !isPoint(value.geometry)) return null;
const allowedAttributes = new Set(["name", "category", "network", "operator", "official_name", "local_name", "uic_ref", "wheelchair"]); if (value.attributes.alternate_names !== undefined
&& (!Array.isArray(value.attributes.alternate_names)
|| value.attributes.alternate_names.length > 16
|| value.attributes.alternate_names.some((name) => typeof name !== "string" || !name.trim() || name.length > 256))) {
return null;
}
const allowedAttributes = new Set(["name", "category", "network", "operator", "official_name", "local_name", "alternate_names", "uic_ref", "wheelchair"]);
return { return {
sourceId: value.sourceId, sourceId: value.sourceId,
semanticType: String(value.semanticType), semanticType: String(value.semanticType),
+4 -29
View File
@@ -10,8 +10,6 @@
- `strong` — dropdown, modal и поверхность над сложным фоном; - `strong` — dropdown, modal и поверхность над сложным фоном;
- `soft` — вложенная или вторичная область. - `soft` — вложенная или вторичная область.
`radius="pill"` задаёт каноническую капсульную геометрию через `--nodedc-radius-circle`. Она предназначена, в частности, для компактных вертикальных и горизонтальных реек из круглых действий: верхняя и нижняя части поверхности повторяют круг кнопок без прямых торцевых пролётов. Consumer не воспроизводит этот радиус локальным CSS.
Material rim допустим только как часть floating glass. Жёсткая цветная рамка, случайный browser outline или debug border не являются rim. Material rim допустим только как часть floating glass. Жёсткая цветная рамка, случайный browser outline или debug border не являются rim.
`GlassMaterialSurface` — отдельный переносимый контракт Engine Glass V4. Он централизует tint/opacity, blur, saturation, brightness, gradient rim и shadow. Его используют только modal `Window` и modeless draggable Inspector; обычные Launcher/SEO панели остаются непрозрачными. Сам `Window` всегда получает класс `nodedc-glass-material` и `data-material="glass-v4"`, поэтому sharing, confirmation и остальные modal-паттерны физически используют тот же surface, что Inspector и лабораторный preview. Настройки применяются через `applyGlassMaterial`, поэтому consumer не пересобирает CSS материала вручную. `GlassMaterialSurface` — отдельный переносимый контракт Engine Glass V4. Он централизует tint/opacity, blur, saturation, brightness, gradient rim и shadow. Его используют только modal `Window` и modeless draggable Inspector; обычные Launcher/SEO панели остаются непрозрачными. Сам `Window` всегда получает класс `nodedc-glass-material` и `data-material="glass-v4"`, поэтому sharing, confirmation и остальные modal-паттерны физически используют тот же surface, что Inspector и лабораторный preview. Настройки применяются через `applyGlassMaterial`, поэтому consumer не пересобирает CSS материала вручную.
@@ -27,16 +25,6 @@ Button используется для всех текстовых действ
Icon-only action по умолчанию круглый. Квадратная кнопка с маленьким радиусом допустима только как кнопка закрытия окна или плотный инструмент, где это зафиксировано контрактом. Icon-only action по умолчанию круглый. Квадратная кнопка с маленьким радиусом допустима только как кнопка закрытия окна или плотный инструмент, где это зафиксировано контрактом.
`size="dense"` — каноническая плотность для текстовых layer/mode-действий внутри визуализатора. Она уменьшает площадь и подпись контрола, но не меняет hover, active, disabled и focus-состояния. В шапках приложения и обычных формах эта плотность не используется.
Переключаемый IconButton передаёт контролируемое состояние через `aria-pressed`. Активная поверхность и контраст принадлежат дизайн-системе; размер круга и glyph при переключении не меняются.
## ActivityIndicator
`ActivityIndicator` — общий индикатор неопределённого по длительности процесса. `default` используется рядом с самостоятельным статусом, `compact` — в icon-slot кнопки. Владелец операции по-прежнему задаёт видимый текст pending-состояния и `aria-busy`; индикатор не хранит таймер и не определяет завершение операции.
Без `label` индикатор декоративный и скрыт от accessibility tree. `label` включает `role="status"` только когда сам индикатор должен объявить процесс. При `prefers-reduced-motion: reduce` кольцо остаётся видимым, но не вращается.
## Field ## Field
FieldFrame объединяет label, control, hint и description. TextField/TextAreaField реализуют стандартные текстовые поля. FieldFrame объединяет label, control, hint и description. TextField/TextAreaField реализуют стандартные текстовые поля.
@@ -51,9 +39,7 @@ FieldFrame объединяет label, control, hint и description. TextField/T
## RangeControl ## RangeControl
Pill-range с заполнением акцентным цветом, встроенной подписью и значением. Домен определяет min/max/step и формат числа. `min/max` задают только рабочий диапазон перетаскивания; ручной ввод по умолчанию принимает любое конечное число и не меняет геометрию ползунка. Опциональный `exactValueBounds` независимо задаёт жёсткие границы ручного ввода: например, `{ min: 0 }` сохраняет неотрицательное значение, но не ограничивает верхнюю границу ползунком. Pill-range с заполнением акцентным цветом, встроенной подписью и значением. Домен определяет min/max/step и формат числа. Drag всегда принадлежит невидимому native range и продолжается под областью значения. Над числом постоянно смонтирован один прозрачный native text input: клик включает редактирование без замены DOM-узла, браузер ставит каретку в фактическое место клика, а drag-selection остаётся нативным. Поле не получает собственной подложки, select-all или второго focus-ring. Enter/blur применяют значение с clamp/step-нормализацией, Escape отменяет ввод; события редактора не всплывают в оконные shortcuts. Оконный focus-manager выполняет начальный autofocus только при открытии и не отбирает фокус при последующих ререндерах.
Drag всегда принадлежит невидимому native range и продолжается под областью значения. Над числом постоянно смонтирован один прозрачный native text input: фокус включает редактирование без замены DOM-узла и без pointer-state mutation, браузер ставит каретку в фактическое место клика, а drag/keyboard-selection и замена выделенного текста остаются полностью нативными. Поле не получает собственной подложки, select-all или второго focus-ring. Цвет текста и каретки автоматически выбирается между theme text и `on-accent` по фактической границе заливки под областью значения, поэтому светлая заливка получает тёмный контраст без application-local цвета. Enter/blur применяют значение с exact-bound/step-нормализацией, Escape отменяет ввод; события редактора не всплывают в оконные shortcuts. Оконный focus-manager выполняет начальный autofocus только при открытии и не отбирает фокус при последующих ререндерах.
## ColorField ## ColorField
@@ -83,11 +69,10 @@ Dropdown владеет floating-layer поведением:
## Select ## Select
Select добавляет к Dropdown контролируемое значение, options и необязательный поиск. У него три канонические формы: Select добавляет к Dropdown контролируемое значение, options и необязательный поиск. У него две канонические формы:
- `integrated` — единая pill-поверхность Hub/Launcher: label слева, chevron строго у правого края; - `integrated` — единая pill-поверхность Hub/Launcher: label слева, chevron строго у правого края;
- `split` — форма Engine с отдельной областью значения и отдельной кнопкой раскрытия `46 px`, между ними зазор `8 px`; - `split` — форма Engine с отдельной областью значения и отдельной кнопкой раскрытия `46 px`, между ними зазор `8 px`.
- `inline` — компактный toolbar-trigger: только текущее значение без постоянной подложки и chevron; всё значение открывает обычное portal-меню.
Native select не используется как видимый runtime UI. Меню рендерится через portal; у Engine-варианта меню имеет радиус `20 px`, а строки — `14 px` и высоту `42 px`. Native select не используется как видимый runtime UI. Меню рендерится через portal; у Engine-варианта меню имеет радиус `20 px`, а строки — `14 px` и высоту `42 px`.
@@ -154,14 +139,6 @@ 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.
@@ -176,8 +153,6 @@ Read-only link/copy окно из BIM Viewer. React-компонент испо
Pill navigation для верхней панели и компактного переключения режимов. Active segment использует активную поверхность темы; это не обязательно основной accent приложения. Pill navigation для верхней панели и компактного переключения режимов. Active segment использует активную поверхность темы; это не обязательно основной accent приложения.
`size="dense"` применяется только в насыщенной панели слоёв или режимов непосредственно над viewer. Шапка приложения сохраняет default-геометрию; consumer не воспроизводит dense-отступы или шрифт локальным CSS.
## AppHeader ## AppHeader
Трёхосевая верхняя панель: Трёхосевая верхняя панель:
@@ -290,7 +265,7 @@ Sortable-строки ограничены вертикальной осью: г
## ToastStack ## ToastStack
`ToastCard` и `ToastStack` фиксируют Tasker-derived bottom-right уведомления для `success`, `error`, `warning`, `info` и `loading`. Приложение владеет текстом и состоянием операции; стек владеет portal, геометрией, aria-live и таймерами. Обычный terminal toast по умолчанию живёт `10 000 ms`; каждый стабильный `id` получает собственный таймер, поэтому добавление соседнего уведомления не продлевает уже показанное. Loading не закрывается автоматически и обновляется тем же id после завершения операции; после перехода в terminal tone для него запускается обычный независимый таймер. Toast не используется вместо modal confirmation. `ToastCard` и `ToastStack` фиксируют Tasker-derived bottom-right уведомления для `success`, `error`, `warning`, `info` и `loading`. Приложение владеет текстом и состоянием операции; стек владеет portal, геометрией, aria-live и таймерами. Loading не закрывается автоматически и обновляется тем же id после завершения операции. Toast не используется вместо modal confirmation.
## Environment Controls: Inspector и ControlRow ## Environment Controls: Inspector и ControlRow
+2 -6
View File
@@ -19,16 +19,12 @@
| Окно и слой | `close`, `plus`, `expand`, `minimize`, `refresh`, `panel`, `apps` | | Окно и слой | `close`, `plus`, `expand`, `minimize`, `refresh`, `panel`, `apps` |
| Навигация | `chevron-left`, `chevron-right`, `chevron-down`, `grid`, `list`, `sliders`, `search` | | Навигация | `chevron-left`, `chevron-right`, `chevron-down`, `grid`, `list`, `sliders`, `search` |
| Редактирование | `save`, `edit`, `trash`, `copy`, `upload`, `download`, `external` | | Редактирование | `save`, `edit`, `trash`, `copy`, `upload`, `download`, `external` |
| Состояние и доступ | `check`, `alert`, `activity`, `lock`, `key`, `shield`, `circle`, `eye`, `eye-off` | | Состояние и доступ | `check`, `alert`, `activity`, `lock`, `key`, `shield`, `circle` |
| Сущности | `profile`, `users`, `building`, `globe`, `database`, `network`, `inbox`, `mail` | | Сущности | `profile`, `users`, `building`, `globe`, `database`, `network`, `inbox`, `mail` |
| Контент | `camera`, `plan`, `play`, `stop`, `image`, `video`, `file`, `folder`, `clipboard`, `settings` | | Контент | `image`, `video`, `file`, `folder`, `clipboard`, `settings` |
Живая таблица с названиями, поверхностями и размерами находится в разделе `Guideline → Иконки`. Машинный список — в `registry/icons.json`. Живая таблица с названиями, поверхностями и размерами находится в разделе `Guideline → Иконки`. Машинный список — в `registry/icons.json`.
## Правило расширения ## Правило расширения
Новая иконка добавляется только после подтверждённого применения в продукте. Нужно выбрать семантическое имя, добавить export в `Icon`, запись в registry и specimen в каталоге. Локальный импорт иконки только ради немного другой формы запрещён. Новая иконка добавляется только после подтверждённого применения в продукте. Нужно выбрать семантическое имя, добавить export в `Icon`, запись в registry и specimen в каталоге. Локальный импорт иконки только ради немного другой формы запрещён.
`camera` обозначает оптический канал, а `plan` — пространственное представление сверху. Оба glyph контурные и используют общую stroke-геометрию набора.
`play` и `stop` — компактные полнотелые транспортные glyph: залитый треугольник запуска и залитый квадрат остановки/паузы. Видимая текстовая подпись не требуется, но action обязан сохранить доступный `aria-label`.
+4 -8
View File
@@ -11,7 +11,7 @@
"scripts": { "scripts": {
"build": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react && npm run build --workspace @nodedc/ui-catalog", "build": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react && npm run build --workspace @nodedc/ui-catalog",
"build:packages": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react", "build:packages": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react",
"check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:spark-governance && npm run test:activity-indicator && npm run test:control-density && npm run test:icon-contract && npm run test:toast-contract && npm run test:floating-position && npm run test:inspector-select && npm run test:range-control && npm run test:split-pane && npm run test:hgeozone-projection && npm run test:map-grid-lod && npm run test:map-filters && npm run test:map-object-layers && npm run test:map-inspector-overlay-state && npm run test:map-reference-stations && npm run test:map-search && npm run test:map-subject-card && npm run test:map-subject-detail-profile", "check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:floating-position && npm run test:inspector-select && npm run test:range-control && npm run test:hgeozone-projection && npm run test:map-provider-startup && npm run test:map-grid-lod && npm run test:map-filters && npm run test:map-workspace-model && npm run test:map-object-layers && npm run test:map-inspector-overlay-state && npm run test:map-reference-stations && npm run test:map-search && npm run test:map-subject-card && npm run test:map-subject-detail-profile",
"dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog", "dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog",
"serve": "node server/catalog-server.mjs", "serve": "node server/catalog-server.mjs",
"validate:registry": "node scripts/validate-registry.mjs", "validate:registry": "node scripts/validate-registry.mjs",
@@ -22,6 +22,7 @@
"test:foundry-agent": "node --test server/foundry-agent-store.test.mjs server/foundry-agent-gateway.test.mjs scripts/foundry-agent-installer.test.mjs", "test:foundry-agent": "node --test server/foundry-agent-store.test.mjs server/foundry-agent-gateway.test.mjs scripts/foundry-agent-installer.test.mjs",
"test:map-animation": "node --test scripts/map-spiral.test.mjs scripts/map-camera-presets.test.mjs", "test:map-animation": "node --test scripts/map-spiral.test.mjs scripts/map-camera-presets.test.mjs",
"test:map-filters": "node --test scripts/map-presentation-filters.test.mjs", "test:map-filters": "node --test scripts/map-presentation-filters.test.mjs",
"test:map-workspace-model": "node --test scripts/map-workspace-model.test.mjs scripts/map-workspace-state.test.mjs",
"test:hgeozone-projection": "node --test scripts/hgeozone-projection.test.mjs", "test:hgeozone-projection": "node --test scripts/hgeozone-projection.test.mjs",
"test:map-grid-lod": "node --test scripts/map-grid-lod.test.mjs scripts/map-sector-grid.test.mjs server/map-grid-persistence.test.mjs", "test:map-grid-lod": "node --test scripts/map-grid-lod.test.mjs scripts/map-sector-grid.test.mjs server/map-grid-persistence.test.mjs",
"test:map-object-layers": "node --test scripts/map-object-layers.test.mjs", "test:map-object-layers": "node --test scripts/map-object-layers.test.mjs",
@@ -31,15 +32,10 @@
"test:map-subject-card": "node --test scripts/map-subject-card.test.mjs", "test:map-subject-card": "node --test scripts/map-subject-card.test.mjs",
"test:map-subject-detail-profile": "node --test server/map-subject-detail-profile.test.mjs server/map-live-data-slot.test.mjs", "test:map-subject-detail-profile": "node --test server/map-subject-detail-profile.test.mjs server/map-live-data-slot.test.mjs",
"test:map-cache-contract": "node --test scripts/map-cache-resource-contract.test.mjs", "test:map-cache-contract": "node --test scripts/map-cache-resource-contract.test.mjs",
"test:spark-governance": "node --test scripts/spark-governance-contract.test.mjs", "test:map-provider-startup": "node --test scripts/map-provider-startup.test.mjs",
"test:activity-indicator": "node --test scripts/activity-indicator-contract.test.mjs",
"test:control-density": "node --test scripts/control-density-contract.test.mjs",
"test:icon-contract": "node --test scripts/icon-contract.test.mjs",
"test:toast-contract": "node --test scripts/toast-contract.test.mjs",
"test:floating-position": "node --test scripts/floating-position-contract.test.mjs", "test:floating-position": "node --test scripts/floating-position-contract.test.mjs",
"test:inspector-select": "node --test scripts/inspector-select-contract.test.mjs", "test:inspector-select": "node --test scripts/inspector-select-contract.test.mjs",
"test:range-control": "node --test scripts/range-control-contract.test.mjs", "test:range-control": "node --test scripts/range-control-contract.test.mjs"
"test:split-pane": "node --test scripts/split-pane-contract.test.mjs"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"
-2
View File
@@ -10,7 +10,6 @@
--nodedc-control-height: 2.875rem; --nodedc-control-height: 2.875rem;
--nodedc-control-height-compact: 2.375rem; --nodedc-control-height-compact: 2.375rem;
--nodedc-control-height-dense: 1.875rem;
--nodedc-icon-button-size: 2.875rem; --nodedc-icon-button-size: 2.875rem;
--nodedc-header-row-height: 3rem; --nodedc-header-row-height: 3rem;
--nodedc-header-pill-height: 3.45rem; --nodedc-header-pill-height: 3.45rem;
@@ -62,7 +61,6 @@
--nodedc-font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; --nodedc-font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--nodedc-font-size-xs: 0.6875rem; --nodedc-font-size-xs: 0.6875rem;
--nodedc-font-size-dense: 0.45rem;
--nodedc-font-size-sm: 0.75rem; --nodedc-font-size-sm: 0.75rem;
--nodedc-font-size-md: 0.8125rem; --nodedc-font-size-md: 0.8125rem;
--nodedc-font-size-lg: 1rem; --nodedc-font-size-lg: 1rem;
+2 -174
View File
@@ -15,13 +15,13 @@
box-sizing: border-box; box-sizing: border-box;
} }
:where(.nodedc-button, .nodedc-icon-button, .nodedc-checker, .nodedc-select-trigger, .nodedc-select-inline, .nodedc-select__toggle, .nodedc-dropdown-option, .nodedc-segmented__item, .nodedc-header__nav-item, .nodedc-inspector__section-trigger, .nodedc-window__close, .nodedc-workspace-window__action, .nodedc-workspace-window__resize) { :where(.nodedc-button, .nodedc-icon-button, .nodedc-checker, .nodedc-select-trigger, .nodedc-select__toggle, .nodedc-dropdown-option, .nodedc-segmented__item, .nodedc-header__nav-item, .nodedc-inspector__section-trigger, .nodedc-window__close, .nodedc-workspace-window__action, .nodedc-workspace-window__resize) {
border: 0; border: 0;
outline: 0; outline: 0;
font: inherit; font: inherit;
} }
:where(.nodedc-button, .nodedc-icon-button, .nodedc-checker, .nodedc-select-trigger, .nodedc-select-inline, .nodedc-select__toggle, .nodedc-dropdown-option, .nodedc-segmented__item, .nodedc-header__nav-item, .nodedc-inspector__section-trigger, .nodedc-window__close, .nodedc-workspace-window__action, .nodedc-workspace-window__resize):focus-visible { :where(.nodedc-button, .nodedc-icon-button, .nodedc-checker, .nodedc-select-trigger, .nodedc-select__toggle, .nodedc-dropdown-option, .nodedc-segmented__item, .nodedc-header__nav-item, .nodedc-inspector__section-trigger, .nodedc-window__close, .nodedc-workspace-window__action, .nodedc-workspace-window__resize):focus-visible {
background-color: var(--nodedc-focus-surface); background-color: var(--nodedc-focus-surface);
box-shadow: var(--nodedc-glass-control-shadow), inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.22); box-shadow: var(--nodedc-glass-control-shadow), inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.22);
} }
@@ -73,10 +73,6 @@
border-radius: var(--nodedc-radius-modal); border-radius: var(--nodedc-radius-modal);
} }
.nodedc-glass[data-radius="pill"] {
border-radius: var(--nodedc-radius-circle);
}
.nodedc-glass[data-padding="sm"] { .nodedc-glass[data-padding="sm"] {
padding: var(--nodedc-space-3); padding: var(--nodedc-space-3);
} }
@@ -142,13 +138,6 @@
font-size: var(--nodedc-font-size-sm); font-size: var(--nodedc-font-size-sm);
} }
.nodedc-button[data-size="dense"] {
min-height: var(--nodedc-control-height-dense);
border-radius: var(--nodedc-radius-control-compact);
padding-inline: 0.65rem;
font-size: var(--nodedc-font-size-dense);
}
.nodedc-button[data-width="full"] { .nodedc-button[data-width="full"] {
width: 100%; width: 100%;
} }
@@ -222,30 +211,6 @@
transform: scale(var(--nodedc-icon-glyph-scale)); transform: scale(var(--nodedc-icon-glyph-scale));
} }
.nodedc-activity-indicator {
display: inline-block;
width: 1.125rem;
height: 1.125rem;
flex: 0 0 auto;
box-sizing: border-box;
border: 2px solid currentColor;
border-inline-end-color: transparent;
border-radius: var(--nodedc-radius-circle);
color: inherit;
opacity: 0.78;
animation: nodedc-activity-indicator-spin 780ms linear infinite;
}
.nodedc-activity-indicator[data-size="compact"] {
width: 0.875rem;
height: 0.875rem;
border-width: 1.5px;
}
@keyframes nodedc-activity-indicator-spin {
to { transform: rotate(360deg); }
}
.nodedc-icon-button { .nodedc-icon-button {
display: inline-grid; display: inline-grid;
width: var(--nodedc-icon-button-size); width: var(--nodedc-icon-button-size);
@@ -266,11 +231,6 @@
color: var(--nodedc-text-primary); color: var(--nodedc-text-primary);
} }
.nodedc-icon-button[aria-pressed="true"] {
background: var(--nodedc-glass-control-active);
color: var(--nodedc-glass-control-active-text);
}
.nodedc-icon-button:active:not(:disabled) { .nodedc-icon-button:active:not(:disabled) {
transform: translateY(1px); transform: translateY(1px);
} }
@@ -809,10 +769,6 @@ textarea.nodedc-field__control {
caret-color: currentColor; caret-color: currentColor;
} }
.nodedc-range__editor[data-active][data-contrast="fill"] {
color: rgb(var(--nodedc-on-accent-rgb));
}
.nodedc-range[data-editing] > .nodedc-range__value, .nodedc-range[data-editing] > .nodedc-range__value,
.nodedc-range[data-editing] > .nodedc-range__fill-text .nodedc-range__value { .nodedc-range[data-editing] > .nodedc-range__fill-text .nodedc-range__value {
opacity: 0; opacity: 0;
@@ -1046,19 +1002,6 @@ textarea.nodedc-field__control {
color: var(--nodedc-glass-control-active-text); color: var(--nodedc-glass-control-active-text);
} }
.nodedc-segmented[data-size="dense"] {
min-height: var(--nodedc-control-height-dense);
gap: 0.1rem;
padding: 0.16rem;
}
.nodedc-segmented[data-size="dense"] .nodedc-segmented__item {
min-height: calc(var(--nodedc-control-height-dense) - 0.32rem);
gap: 0.25rem;
padding: 0.1rem 0.68rem;
font-size: var(--nodedc-font-size-dense);
}
.nodedc-header-shell { .nodedc-header-shell {
position: relative; position: relative;
z-index: var(--nodedc-layer-header); z-index: var(--nodedc-layer-header);
@@ -2239,33 +2182,6 @@ textarea.nodedc-field__control {
transform: translateY(2px) rotate(225deg); transform: translateY(2px) rotate(225deg);
} }
.nodedc-select-inline {
display: inline-flex;
min-height: var(--nodedc-control-height-compact);
align-items: center;
justify-content: center;
gap: var(--nodedc-space-2);
border-radius: var(--nodedc-radius-circle);
background: transparent;
color: var(--nodedc-text-secondary);
padding: 0 var(--nodedc-space-3);
font-size: var(--nodedc-font-size-sm);
font-weight: var(--nodedc-font-weight-strong);
line-height: 1;
cursor: pointer;
}
.nodedc-select-inline:hover:not(:disabled),
.nodedc-select-inline[aria-expanded="true"] {
background: var(--nodedc-glass-control-hover);
color: var(--nodedc-text-primary);
}
.nodedc-select-inline:disabled {
opacity: 0.42;
cursor: not-allowed;
}
.nodedc-select__toggle[aria-expanded="true"] .nodedc-select-trigger__chevron { .nodedc-select__toggle[aria-expanded="true"] .nodedc-select-trigger__chevron {
transform: translateY(2px) rotate(225deg); transform: translateY(2px) rotate(225deg);
} }
@@ -2889,93 +2805,6 @@ textarea.nodedc-field__control {
color: var(--nodedc-text-primary); color: var(--nodedc-text-primary);
} }
.nodedc-split-pane {
--nodedc-split-pane-primary: 50%;
position: relative;
display: grid;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.nodedc-split-pane[data-orientation="vertical"] {
grid-template-columns: minmax(0, var(--nodedc-split-pane-primary)) minmax(0, 1fr);
}
.nodedc-split-pane[data-orientation="horizontal"] {
grid-template-rows: minmax(0, var(--nodedc-split-pane-primary)) minmax(0, 1fr);
}
.nodedc-split-pane__panel {
min-width: 0;
min-height: 0;
overflow: hidden;
}
.nodedc-split-pane__separator {
position: absolute;
z-index: 5;
border: 0;
outline: 0;
background: transparent;
padding: 0;
touch-action: none;
}
.nodedc-split-pane[data-orientation="vertical"] > .nodedc-split-pane__separator {
top: 0;
bottom: 0;
left: var(--nodedc-split-pane-primary);
width: 0.9rem;
cursor: col-resize;
transform: translateX(-50%);
}
.nodedc-split-pane[data-orientation="horizontal"] > .nodedc-split-pane__separator {
right: 0;
bottom: calc(100% - var(--nodedc-split-pane-primary));
left: 0;
height: 0.9rem;
cursor: row-resize;
transform: translateY(50%);
}
.nodedc-split-pane__separator::before {
position: absolute;
background: var(--nodedc-glass-outline);
content: "";
transition: background var(--nodedc-duration-fast) var(--nodedc-ease-standard), box-shadow var(--nodedc-duration-fast) var(--nodedc-ease-standard);
}
.nodedc-split-pane[data-orientation="vertical"] > .nodedc-split-pane__separator::before {
top: 0;
bottom: 0;
left: 50%;
width: 1px;
transform: translateX(-50%);
}
.nodedc-split-pane[data-orientation="horizontal"] > .nodedc-split-pane__separator::before {
right: 0;
bottom: 50%;
left: 0;
height: 1px;
transform: translateY(50%);
}
.nodedc-split-pane__separator:hover::before,
.nodedc-split-pane__separator:focus-visible::before,
.nodedc-split-pane[data-dragging="true"] > .nodedc-split-pane__separator::before {
background: rgb(var(--nodedc-accent-rgb));
box-shadow: 0 0 0 2px rgb(var(--nodedc-accent-rgb) / 0.16);
}
.nodedc-split-pane[data-dragging="true"] {
user-select: none;
}
.nodedc-toolbar-wrap { .nodedc-toolbar-wrap {
position: fixed; position: fixed;
z-index: calc(var(--nodedc-layer-header) + 20); z-index: calc(var(--nodedc-layer-header) + 20);
@@ -3756,7 +3585,6 @@ textarea.nodedc-field__control {
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.nodedc-activity-indicator,
.nodedc-dropdown-surface, .nodedc-dropdown-surface,
.nodedc-overlay, .nodedc-overlay,
.nodedc-window, .nodedc-window,
@@ -1,32 +0,0 @@
import type { HTMLAttributes } from "react";
import { cn } from "./cn.js";
export type ActivityIndicatorSize = "default" | "compact";
export interface ActivityIndicatorProps extends Omit<
HTMLAttributes<HTMLSpanElement>,
"aria-hidden" | "aria-label" | "children" | "role"
> {
size?: ActivityIndicatorSize;
label?: string;
}
export function ActivityIndicator({
size = "default",
label,
className,
...props
}: ActivityIndicatorProps) {
const accessibleLabel = label?.trim() || undefined;
return (
<span
{...props}
className={cn("nodedc-activity-indicator", className)}
data-size={size === "default" ? undefined : size}
role={accessibleLabel ? "status" : undefined}
aria-label={accessibleLabel}
aria-hidden={accessibleLabel ? undefined : "true"}
/>
);
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { createAccentVariables, type RgbTuple } from "@nodedc/ui-core";
import { cn } from "./cn.js"; import { cn } from "./cn.js";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "accent"; export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "accent";
export type ButtonSize = "default" | "compact" | "dense"; export type ButtonSize = "default" | "compact";
export type ButtonShape = "default" | "pill" | "rounded"; export type ButtonShape = "default" | "pill" | "rounded";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
+26 -1
View File
@@ -107,7 +107,32 @@ export function Dropdown({
useLayoutEffect(() => { useLayoutEffect(() => {
if (!isOpen) return; if (!isOpen) return;
updatePosition(); updatePosition();
}, [isOpen, updatePosition]);
const surface = surfaceRef.current;
const anchor = anchorElement ?? triggerElement;
if (!surface || typeof ResizeObserver === "undefined") {
const frame = window.requestAnimationFrame(updatePosition);
return () => window.cancelAnimationFrame(frame);
}
let frame: number | null = null;
const schedulePositionUpdate = () => {
if (frame !== null) return;
frame = window.requestAnimationFrame(() => {
frame = null;
updatePosition();
});
};
const resizeObserver = new ResizeObserver(schedulePositionUpdate);
resizeObserver.observe(surface);
if (anchor) resizeObserver.observe(anchor);
schedulePositionUpdate();
return () => {
resizeObserver.disconnect();
if (frame !== null) window.cancelAnimationFrame(frame);
};
}, [anchorElement, isOpen, triggerElement, updatePosition]);
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return;
+1 -1
View File
@@ -2,7 +2,7 @@ import type { HTMLAttributes, ReactNode } from "react";
import { cn } from "./cn.js"; import { cn } from "./cn.js";
export type GlassTone = "default" | "strong" | "soft"; export type GlassTone = "default" | "strong" | "soft";
export type GlassRadius = "card" | "panel" | "modal" | "pill"; export type GlassRadius = "card" | "panel" | "modal";
export type GlassPadding = "none" | "sm" | "md" | "lg"; export type GlassPadding = "none" | "sm" | "md" | "lg";
export interface GlassSurfaceProps extends HTMLAttributes<HTMLDivElement> { export interface GlassSurfaceProps extends HTMLAttributes<HTMLDivElement> {
+2 -16
View File
@@ -3,7 +3,6 @@ import {
AlertTriangle, AlertTriangle,
Boxes, Boxes,
Building2, Building2,
Camera,
Check, Check,
ChevronDown, ChevronDown,
ChevronLeft, ChevronLeft,
@@ -13,8 +12,6 @@ import {
Copy, Copy,
Database, Database,
Download, Download,
Eye,
EyeOff,
ExternalLink, ExternalLink,
FileText, FileText,
FolderOpen, FolderOpen,
@@ -27,13 +24,11 @@ import {
LocateFixed, LocateFixed,
LockKeyhole, LockKeyhole,
MailPlus, MailPlus,
Map,
Maximize2, Maximize2,
Minimize2, Minimize2,
Network, Network,
PanelTop, PanelTop,
Pencil, Pencil,
Play,
Plus, Plus,
RefreshCw, RefreshCw,
Save, Save,
@@ -41,7 +36,6 @@ import {
Settings, Settings,
ShieldCheck, ShieldCheck,
SlidersHorizontal, SlidersHorizontal,
Square,
Trash2, Trash2,
UploadCloud, UploadCloud,
UserCircle, UserCircle,
@@ -57,7 +51,6 @@ const icons = {
alert: AlertTriangle, alert: AlertTriangle,
apps: Boxes, apps: Boxes,
building: Building2, building: Building2,
camera: Camera,
check: Check, check: Check,
"chevron-down": ChevronDown, "chevron-down": ChevronDown,
"chevron-left": ChevronLeft, "chevron-left": ChevronLeft,
@@ -69,8 +62,6 @@ const icons = {
database: Database, database: Database,
download: Download, download: Download,
edit: Pencil, edit: Pencil,
eye: Eye,
"eye-off": EyeOff,
expand: Maximize2, expand: Maximize2,
external: ExternalLink, external: ExternalLink,
file: FileText, file: FileText,
@@ -87,8 +78,6 @@ const icons = {
minimize: Minimize2, minimize: Minimize2,
network: Network, network: Network,
panel: PanelTop, panel: PanelTop,
plan: Map,
play: Play,
plus: Plus, plus: Plus,
profile: UserCircle, profile: UserCircle,
refresh: RefreshCw, refresh: RefreshCw,
@@ -97,7 +86,6 @@ const icons = {
settings: Settings, settings: Settings,
shield: ShieldCheck, shield: ShieldCheck,
sliders: SlidersHorizontal, sliders: SlidersHorizontal,
stop: Square,
trash: Trash2, trash: Trash2,
upload: UploadCloud, upload: UploadCloud,
users: UsersRound, users: UsersRound,
@@ -111,15 +99,13 @@ export interface IconProps extends Omit<LucideProps, "ref"> {
label?: string; label?: string;
} }
export function Icon({ name, label, size = 16, strokeWidth = 1.6, fill, ...props }: IconProps) { export function Icon({ name, label, size = 16, strokeWidth = 1.6, ...props }: IconProps) {
const IconComponent = icons[name]; const IconComponent = icons[name];
const filledTransport = name === "play" || name === "stop";
return ( return (
<IconComponent <IconComponent
size={size} size={size}
strokeWidth={filledTransport ? 0 : strokeWidth} strokeWidth={strokeWidth}
fill={fill ?? (filledTransport ? "currentColor" : "none")}
aria-hidden={label ? undefined : "true"} aria-hidden={label ? undefined : "true"}
aria-label={label} aria-label={label}
role={label ? "img" : undefined} role={label ? "img" : undefined}
+16 -38
View File
@@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type InputHTMLAttributes, type KeyboardEvent, type PointerEvent } from "react"; import { useEffect, useRef, useState, type CSSProperties, type InputHTMLAttributes, type KeyboardEvent, type PointerEvent } from "react";
import { cn } from "./cn.js"; import { cn } from "./cn.js";
import { Dropdown } from "./Dropdown.js"; import { Dropdown } from "./Dropdown.js";
@@ -7,10 +7,6 @@ export interface RangeControlProps extends Omit<InputHTMLAttributes<HTMLInputEle
value: number; value: number;
min: number; min: number;
max: number; max: number;
exactValueBounds?: {
min?: number;
max?: number;
};
formatValue?: (value: number) => string; formatValue?: (value: number) => string;
onChange: (value: number) => void; onChange: (value: number) => void;
} }
@@ -33,17 +29,14 @@ const normalizeEditedRangeValue = (
min: number, min: number,
max: number, max: number,
step: RangeControlProps["step"], step: RangeControlProps["step"],
exactValueBounds: RangeControlProps["exactValueBounds"],
) => { ) => {
const exactMin = exactValueBounds?.min ?? Number.NEGATIVE_INFINITY; const clamped = clampRangeValue(value, min, max);
const exactMax = exactValueBounds?.max ?? Number.POSITIVE_INFINITY;
const clamped = clampRangeValue(value, exactMin, exactMax);
if (step === "any") return clamped; if (step === "any") return clamped;
const numericStep = step === undefined ? 1 : Number(step); const numericStep = step === undefined ? 1 : Number(step);
if (!Number.isFinite(numericStep) || numericStep <= 0) return clamped; if (!Number.isFinite(numericStep) || numericStep <= 0) return clamped;
const precision = Math.min(12, Math.max(decimalPlaces(min), decimalPlaces(numericStep))); const precision = Math.min(12, Math.max(decimalPlaces(min), decimalPlaces(numericStep)));
const aligned = min + Math.round((clamped - min) / numericStep) * numericStep; const aligned = min + Math.round((clamped - min) / numericStep) * numericStep;
return clampRangeValue(Number(aligned.toFixed(precision)), exactMin, exactMax); return clampRangeValue(Number(aligned.toFixed(precision)), min, max);
}; };
const editableNumber = (value: number) => ( const editableNumber = (value: number) => (
@@ -55,7 +48,6 @@ export function RangeControl({
value, value,
min, min,
max, max,
exactValueBounds,
step, step,
formatValue = String, formatValue = String,
onChange, onChange,
@@ -64,13 +56,11 @@ export function RangeControl({
tabIndex, tabIndex,
...props ...props
}: RangeControlProps) { }: RangeControlProps) {
const rootRef = useRef<HTMLDivElement>(null);
const rangeRef = useRef<HTMLInputElement>(null); const rangeRef = useRef<HTMLInputElement>(null);
const editorRef = useRef<HTMLInputElement>(null); const editorRef = useRef<HTMLInputElement>(null);
const cancelNextBlurRef = useRef(false); const cancelNextBlurRef = useRef(false);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(() => editableNumber(value)); const [draft, setDraft] = useState(() => editableNumber(value));
const [editorContrast, setEditorContrast] = useState<"base" | "fill">("base");
const safeMax = max === min ? min + 1 : max; const safeMax = max === min ? min + 1 : max;
const progress = Math.max(0, Math.min(100, ((value - min) / (safeMax - min)) * 100)); const progress = Math.max(0, Math.min(100, ((value - min) / (safeMax - min)) * 100));
const displayValue = formatValue(value); const displayValue = formatValue(value);
@@ -87,30 +77,10 @@ export function RangeControl({
if (!editing) setDraft(editableNumber(value)); if (!editing) setDraft(editableNumber(value));
}, [editing, value]); }, [editing, value]);
useLayoutEffect(() => {
const root = rootRef.current;
const editor = editorRef.current;
if (!root || !editor) return undefined;
const updateContrast = () => {
const rootBounds = root.getBoundingClientRect();
const editorBounds = editor.getBoundingClientRect();
const paddingRight = Number.parseFloat(getComputedStyle(editor).paddingRight) || 0;
const fillRight = rootBounds.left + rootBounds.width * progress / 100;
const textRight = editorBounds.right - paddingRight;
setEditorContrast(fillRight >= textRight ? "fill" : "base");
};
updateContrast();
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(updateContrast);
observer?.observe(root);
return () => observer?.disconnect();
}, [editorCharacters, progress]);
const finishEditing = (commit: boolean, restoreRangeFocus: boolean) => { const finishEditing = (commit: boolean, restoreRangeFocus: boolean) => {
if (commit) { if (commit) {
const parsed = Number(draft.trim().replace(",", ".")); const parsed = Number(draft.trim().replace(",", "."));
if (Number.isFinite(parsed)) { if (Number.isFinite(parsed)) onChange(normalizeEditedRangeValue(parsed, min, max, step));
onChange(normalizeEditedRangeValue(parsed, min, max, step, exactValueBounds));
}
} }
setEditing(false); setEditing(false);
if (restoreRangeFocus) requestAnimationFrame(() => rangeRef.current?.focus()); if (restoreRangeFocus) requestAnimationFrame(() => rangeRef.current?.focus());
@@ -133,7 +103,7 @@ export function RangeControl({
}; };
return ( return (
<div ref={rootRef} className={cn("nodedc-range", className)} style={style} data-editing={editing || undefined}> <div className={cn("nodedc-range", className)} style={style} data-editing={editing || undefined}>
<input <input
ref={rangeRef} ref={rangeRef}
{...props} {...props}
@@ -159,14 +129,22 @@ export function RangeControl({
className="nodedc-range__editor" className="nodedc-range__editor"
type="text" type="text"
inputMode="decimal" inputMode="decimal"
value={draft} value={editing ? draft : editableNumber(value)}
aria-label={`${label}: точное значение`} aria-label={`${label}: точное значение`}
disabled={disabled} disabled={disabled}
spellCheck={false} spellCheck={false}
data-active={editing || undefined} data-active={editing || undefined}
data-contrast={editorContrast} onPointerDown={() => {
if (!editing) {
setDraft(editableNumber(value));
setEditing(true);
}
}}
onFocus={() => { onFocus={() => {
if (!editing) setEditing(true); if (!editing) {
setDraft(editableNumber(value));
setEditing(true);
}
}} }}
onChange={(event) => setDraft(event.target.value)} onChange={(event) => setDraft(event.target.value)}
onKeyDown={handleEditorKeyDown} onKeyDown={handleEditorKeyDown}
+2 -10
View File
@@ -8,13 +8,10 @@ export interface SegmentedItem<T extends string> {
disabled?: boolean; disabled?: boolean;
} }
export type SegmentedControlSize = "default" | "dense";
export interface SegmentedControlProps<T extends string> { export interface SegmentedControlProps<T extends string> {
value: T; value: T;
items: Array<SegmentedItem<T>>; items: Array<SegmentedItem<T>>;
label: string; label: string;
size?: SegmentedControlSize;
className?: string; className?: string;
onChange: (value: T) => void; onChange: (value: T) => void;
} }
@@ -23,17 +20,11 @@ export function SegmentedControl<T extends string>({
value, value,
items, items,
label, label,
size = "default",
className, className,
onChange, onChange,
}: SegmentedControlProps<T>) { }: SegmentedControlProps<T>) {
return ( return (
<div <div className={cn("nodedc-segmented", className)} role="tablist" aria-label={label}>
className={cn("nodedc-segmented", className)}
role="tablist"
aria-label={label}
data-size={size === "default" ? undefined : size}
>
{items.map((item) => ( {items.map((item) => (
<button <button
key={item.value} key={item.value}
@@ -52,3 +43,4 @@ export function SegmentedControl<T extends string>({
</div> </div>
); );
} }
+1 -21
View File
@@ -12,7 +12,7 @@ export interface SelectOption<T extends string> {
disabled?: boolean; disabled?: boolean;
} }
export type SelectVariant = "integrated" | "split" | "inline"; export type SelectVariant = "integrated" | "split";
export interface SelectProps<T extends string> { export interface SelectProps<T extends string> {
value: T; value: T;
@@ -102,26 +102,6 @@ export function Select<T extends string>({
); );
} }
if (resolvedVariant === "inline") {
return (
<button
ref={setTriggerRef}
type="button"
className={cn("nodedc-select-inline", triggerClassName)}
aria-label={label}
aria-haspopup="listbox"
aria-controls={surfaceId}
aria-expanded={open}
disabled={disabled}
onClick={toggle}
onKeyDown={handleKeyDown}
>
{selected?.icon ? <span className="nodedc-select-trigger__icon">{selected.icon}</span> : null}
<span>{selected?.label ?? "—"}</span>
</button>
);
}
return ( return (
<button <button
ref={setTriggerRef} ref={setTriggerRef}
-156
View File
@@ -1,156 +0,0 @@
import {
useRef,
useState,
type CSSProperties,
type HTMLAttributes,
type KeyboardEvent,
type PointerEvent,
type ReactNode,
} from "react";
import { cn } from "./cn.js";
export type SplitPaneOrientation = "vertical" | "horizontal";
export interface SplitPaneProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
primary: ReactNode;
secondary: ReactNode;
primarySize: number;
onPrimarySizeChange: (primarySize: number) => void;
orientation?: SplitPaneOrientation;
minPrimarySize?: number;
minSecondarySize?: number;
step?: number;
resizable?: boolean;
separatorLabel: string;
}
const clamp = (value: number, minimum: number, maximum: number) => (
Math.min(Math.max(value, minimum), maximum)
);
const normalizedLimits = (minPrimarySize: number, minSecondarySize: number) => {
const primary = clamp(minPrimarySize, 0, 100);
const secondary = clamp(minSecondarySize, 0, 100);
if (primary + secondary <= 100) return { minimum: primary, maximum: 100 - secondary };
const scale = 100 / (primary + secondary);
return { minimum: primary * scale, maximum: 100 - secondary * scale };
};
export function SplitPane({
primary,
secondary,
primarySize,
onPrimarySizeChange,
orientation = "vertical",
minPrimarySize = 20,
minSecondarySize = 20,
step = 2,
resizable = true,
separatorLabel,
className,
style,
...props
}: SplitPaneProps) {
const rootRef = useRef<HTMLDivElement>(null);
const activePointerIdRef = useRef<number | null>(null);
const [dragging, setDragging] = useState(false);
const limits = normalizedLimits(minPrimarySize, minSecondarySize);
const size = clamp(Number.isFinite(primarySize) ? primarySize : 50, limits.minimum, limits.maximum);
const keyboardIncrement = Number.isFinite(step) && step > 0 ? step : 2;
const emitPointerSize = (event: PointerEvent<HTMLElement>) => {
const root = rootRef.current;
if (!root) return;
const rect = root.getBoundingClientRect();
const available = orientation === "vertical" ? rect.width : rect.height;
if (available <= 0) return;
const offset = orientation === "vertical"
? event.clientX - rect.left
: event.clientY - rect.top;
onPrimarySizeChange(clamp(offset / available * 100, limits.minimum, limits.maximum));
};
const handleKeyDown = (event: KeyboardEvent<HTMLElement>) => {
const multiplier = event.shiftKey ? 5 : 1;
const decrement = orientation === "vertical" ? "ArrowLeft" : "ArrowUp";
const increment = orientation === "vertical" ? "ArrowRight" : "ArrowDown";
let next: number | null = null;
if (event.key === decrement) next = size - keyboardIncrement * multiplier;
if (event.key === increment) next = size + keyboardIncrement * multiplier;
if (event.key === "Home") next = limits.minimum;
if (event.key === "End") next = limits.maximum;
if (next === null) return;
event.preventDefault();
onPrimarySizeChange(clamp(next, limits.minimum, limits.maximum));
};
const splitStyle = {
...style,
"--nodedc-split-pane-primary": `${size}%`,
} as CSSProperties;
return (
<div
ref={rootRef}
className={cn("nodedc-split-pane", className)}
data-orientation={orientation}
data-dragging={dragging ? "true" : undefined}
style={splitStyle}
{...props}
>
<div className="nodedc-split-pane__panel" data-pane="primary">
{primary}
</div>
<div className="nodedc-split-pane__panel" data-pane="secondary">
{secondary}
</div>
{resizable ? (
<div
className="nodedc-split-pane__separator"
role="separator"
tabIndex={0}
aria-label={separatorLabel}
aria-orientation={orientation}
aria-valuemin={Math.round(limits.minimum)}
aria-valuemax={Math.round(limits.maximum)}
aria-valuenow={Math.round(size)}
aria-valuetext={`${Math.round(size)}% / ${Math.round(100 - size)}%`}
onKeyDown={handleKeyDown}
onPointerDown={(event) => {
if (event.button !== 0) return;
event.currentTarget.focus();
activePointerIdRef.current = event.pointerId;
event.currentTarget.setPointerCapture(event.pointerId);
setDragging(true);
emitPointerSize(event);
event.preventDefault();
}}
onPointerMove={(event) => {
if (activePointerIdRef.current !== event.pointerId) return;
emitPointerSize(event);
event.preventDefault();
}}
onPointerUp={(event) => {
if (activePointerIdRef.current !== event.pointerId) return;
activePointerIdRef.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
setDragging(false);
}}
onPointerCancel={(event) => {
if (activePointerIdRef.current !== event.pointerId) return;
activePointerIdRef.current = null;
setDragging(false);
}}
onLostPointerCapture={(event) => {
if (activePointerIdRef.current !== event.pointerId) return;
activePointerIdRef.current = null;
setDragging(false);
}}
/>
) : null}
</div>
);
}
+12 -23
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, type HTMLAttributes } from "react"; import { useEffect, type HTMLAttributes } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { cn } from "./cn.js"; import { cn } from "./cn.js";
import { Icon, type IconName } from "./Icon.js"; import { Icon, type IconName } from "./Icon.js";
@@ -21,8 +21,6 @@ const toastIcons: Record<ToastTone, IconName> = {
loading: "refresh", loading: "refresh",
}; };
const DEFAULT_TOAST_DURATION_MS = 10_000;
export interface ToastCardProps extends HTMLAttributes<HTMLDivElement> { export interface ToastCardProps extends HTMLAttributes<HTMLDivElement> {
item: ToastItem; item: ToastItem;
onDismiss?: (id: string) => void; onDismiss?: (id: string) => void;
@@ -50,30 +48,21 @@ export function ToastCard({ item, onDismiss, className, ...props }: ToastCardPro
); );
} }
function TimedToastCard({ item, onDismiss }: { item: ToastItem; onDismiss: (id: string) => void }) {
const dismissRef = useRef(onDismiss);
useEffect(() => {
dismissRef.current = onDismiss;
}, [onDismiss]);
useEffect(() => {
const duration = item.durationMs === undefined
? item.tone === "loading" ? null : DEFAULT_TOAST_DURATION_MS
: item.durationMs;
if (typeof duration !== "number" || duration <= 0) return undefined;
const timer = window.setTimeout(() => dismissRef.current(item.id), duration);
return () => window.clearTimeout(timer);
}, [item.durationMs, item.id, item.tone]);
return <ToastCard item={item} onDismiss={onDismiss} />;
}
export function ToastStack({ items, onDismiss }: { items: ToastItem[]; onDismiss: (id: string) => void }) { export function ToastStack({ items, onDismiss }: { items: ToastItem[]; onDismiss: (id: string) => void }) {
useEffect(() => {
const timers = items.flatMap((item) => {
const duration = item.durationMs === undefined ? 4200 : item.durationMs;
return typeof duration === "number" && duration > 0
? [window.setTimeout(() => onDismiss(item.id), duration)]
: [];
});
return () => timers.forEach((timer) => window.clearTimeout(timer));
}, [items, onDismiss]);
if (typeof document === "undefined" || items.length === 0) return null; if (typeof document === "undefined" || items.length === 0) return null;
return createPortal( return createPortal(
<div className="nodedc-toast-viewport nodedc-ui-root" aria-live="polite" aria-relevant="additions removals"> <div className="nodedc-toast-viewport nodedc-ui-root" aria-live="polite" aria-relevant="additions removals">
{items.map((item) => <TimedToastCard key={item.id} item={item} onDismiss={onDismiss} />)} {items.map((item) => <ToastCard key={item.id} item={item} onDismiss={onDismiss} />)}
</div>, </div>,
document.body, document.body,
); );
-2
View File
@@ -1,5 +1,4 @@
export * from "./AppHeader.js"; export * from "./AppHeader.js";
export * from "./ActivityIndicator.js";
export * from "./AdminNavigationPanel.js"; export * from "./AdminNavigationPanel.js";
export * from "./ApplicationShell.js"; export * from "./ApplicationShell.js";
export * from "./ApplicationSidePanel.js"; export * from "./ApplicationSidePanel.js";
@@ -21,7 +20,6 @@ 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";
+11 -56
View File
@@ -10,10 +10,8 @@
"summary": "Matte application surfaces, the Engine V4 modal material and the light translucent surface used over Cesium imagery.", "summary": "Matte application surfaces, the Engine V4 modal material and the light translucent surface used over Cesium imagery.",
"anatomy": ["theme-provided surface", "modal tint", "map-only light translucency", "gradient rim", "backdrop blur/saturation/brightness", "content"], "anatomy": ["theme-provided surface", "modal tint", "map-only light translucency", "gradient rim", "backdrop blur/saturation/brightness", "content"],
"variants": ["default", "strong", "soft", "map"], "variants": ["default", "strong", "soft", "map"],
"radiusVariants": ["card", "panel", "modal", "pill"],
"rules": [ "rules": [
"Geometry is invariant across application themes.", "Geometry is invariant across application themes.",
"Compact rails of canonical round actions use the pill radius; consumers do not recreate it with a local border radius.",
"A material rim is a glass highlight, not a hard product-colored border.", "A material rim is a glass highlight, not a hard product-colored border.",
"Nested surfaces must use a softer tone to avoid card-inside-card noise.", "Nested surfaces must use a softer tone to avoid card-inside-card noise.",
"GlassyMaterialSurface is restricted to modal Windows and the draggable Inspector.", "GlassyMaterialSurface is restricted to modal Windows and the draggable Inspector.",
@@ -28,31 +26,13 @@
"domContract": ["nodedc-button", "nodedc-icon-button"], "domContract": ["nodedc-button", "nodedc-icon-button"],
"summary": "Text and icon actions with shared sizing, states and computed accent contrast.", "summary": "Text and icon actions with shared sizing, states and computed accent contrast.",
"variants": ["primary", "secondary", "ghost", "danger", "accent"], "variants": ["primary", "secondary", "ghost", "danger", "accent"],
"sizes": ["default", "compact", "dense"],
"shapes": ["default", "pill", "rounded"], "shapes": ["default", "pill", "rounded"],
"rules": [ "rules": [
"Icon-only actions are circular by default.", "Icon-only actions are circular by default.",
"Controlled toggle actions expose aria-pressed and use the canonical active surface without changing geometry.",
"Filled accent actions derive foreground contrast from the actual accent.", "Filled accent actions derive foreground contrast from the actual accent.",
"Destructive actions remain neutral until confirmation unless danger is the principal message." "Destructive actions remain neutral until confirmation unless danger is the principal message."
] ]
}, },
{
"id": "activity-indicator",
"status": "baseline",
"package": "@nodedc/ui-react",
"exports": ["ActivityIndicator", "ActivityIndicatorProps", "ActivityIndicatorSize"],
"domContract": ["nodedc-activity-indicator"],
"summary": "Theme-independent indeterminate progress indicator for inline operations and pending action controls.",
"variants": ["default", "compact"],
"behavior": ["decorative by default", "optional status semantics", "static reduced-motion presentation"],
"rules": [
"Use compact inside a Button icon slot and default for standalone inline progress.",
"The process owner exposes aria-busy and visible pending copy; provide label only when the indicator itself is the status announcement.",
"The component never owns operation state, timing or completion.",
"Reduced-motion preferences stop rotation without hiding the pending-state affordance."
]
},
{ {
"id": "field", "id": "field",
"status": "baseline", "status": "baseline",
@@ -84,20 +64,17 @@
"package": "@nodedc/ui-react", "package": "@nodedc/ui-react",
"exports": ["RangeControl"], "exports": ["RangeControl"],
"domContract": ["nodedc-range"], "domContract": ["nodedc-range"],
"summary": "Filled pill range control with embedded label, drag interaction and a persistent native inline exact-value editor.", "summary": "Filled pill range control with embedded label, drag interaction and a persistent native inline exact-value editor.",
"behavior": ["native range drag", "native caret and selection replacement", "automatic fill/base contrast", "unbounded finite exact editing by default", "Enter/blur commit", "Escape cancel", "independent optional exact-value bounds", "step normalization"], "behavior": ["native range drag", "value click exact editing", "Enter/blur commit", "Escape cancel", "min/max clamp", "step normalization"],
"rules": [ "rules": [
"The accent fill follows the active application theme.", "The accent fill follows the active application theme.",
"The native range remains the accessible drag input while its chrome is visually replaced.", "The native range remains the accessible drag input while its chrome is visually replaced.",
"The fill travels beneath the visible value; the persistent transparent native editor above the value receives exact-edit clicks without remounting.", "The fill travels beneath the visible value; the persistent transparent native editor above the value receives exact-edit clicks without remounting.",
"Exact editing stays inside the existing value area and never expands the control.", "Exact editing stays inside the existing value area and never expands the control.",
"Slider min/max define drag travel only; finite typed values are independent and may exceed that geometry.", "The browser places the caret at the clicked character and keeps native drag selection; entering exact editing never forces select-all or adds a second focus ring.",
"exactValueBounds adds explicit domain limits when a parameter must reject values beyond them.", "The editor has no separate background box and keeps focus across parent window rerenders and pointer leave.",
"The browser places the caret at the clicked character, preserves native drag or keyboard selection, and replaces the selected substring on typing without pointer-state interception.", "Editor keyboard events do not bubble into enclosing Window shortcuts."
"Editor foreground and caret switch automatically between theme text and on-accent tokens according to the actual fill edge beneath the value area.", ]
"The editor has no separate background box and keeps focus across parent window rerenders and pointer leave.",
"Editor keyboard events do not bubble into enclosing Window shortcuts."
]
}, },
{ {
"id": "color-field", "id": "color-field",
@@ -136,12 +113,11 @@
"domPackage": "@nodedc/ui-dom", "domPackage": "@nodedc/ui-dom",
"domExports": ["createSelectController"], "domExports": ["createSelectController"],
"summary": "Theme-independent selection control using the canonical dropdown layer.", "summary": "Theme-independent selection control using the canonical dropdown layer.",
"variants": ["integrated", "split", "inline"], "variants": ["integrated", "split"],
"behavior": ["controlled value", "optional search", "disabled options", "selected state", "portal menu"], "behavior": ["controlled value", "optional search", "disabled options", "selected state", "portal menu"],
"rules": [ "rules": [
"Integrated is the single-pill Hub/Launcher form.", "Integrated is the single-pill Hub/Launcher form.",
"Split is the Engine form: a separate value surface and a 46 px toggle separated by an 8 px gap.", "Split is the Engine form: a separate value surface and a 46 px toggle separated by an 8 px gap.",
"Inline is a compact label-only toolbar trigger without a persistent surface or chevron; its menu and keyboard behavior remain canonical.",
"Integrated Select is forbidden inside Inspector; use InspectorSelectField so the visible label is above a full-width split control.", "Integrated Select is forbidden inside Inspector; use InspectorSelectField so the visible label is above a full-width split control.",
"The portal menu preserves its source-family row geometry instead of inheriting card radii." "The portal menu preserves its source-family row geometry instead of inheriting card radii."
] ]
@@ -187,22 +163,6 @@
"summary": "Async-safe confirmation window for destructive and consequential operations.", "summary": "Async-safe confirmation window for destructive and consequential operations.",
"behavior": ["pending state", "double-submit protection", "disabled close policy while pending"] "behavior": ["pending state", "double-submit protection", "disabled close policy while pending"]
}, },
{
"id": "split-pane",
"status": "baseline",
"package": "@nodedc/ui-react",
"exports": ["SplitPane", "SplitPaneProps", "SplitPaneOrientation"],
"domContract": ["nodedc-split-pane", "nodedc-split-pane__panel", "nodedc-split-pane__separator"],
"summary": "Controlled two-panel layout with a pointer- and keyboard-resizable accessible separator.",
"anatomy": ["primary panel", "structural separator", "secondary panel"],
"variants": ["vertical", "horizontal"],
"behavior": ["controlled percentage", "optional resizable separator", "pointer capture drag", "Arrow/Home/End keyboard sizing", "ARIA separator range", "ratio preservation across host resize"],
"rules": [
"The application owns the controlled primary percentage and panel content; the component owns separator interaction and accessibility.",
"Minimum primary and secondary percentages clamp both pointer and keyboard changes.",
"The separator is a necessary structural divider, never a decorative perimeter border."
]
},
{ {
"id": "share-access-modal", "id": "share-access-modal",
"status": "baseline", "status": "baseline",
@@ -238,11 +198,7 @@
"package": "@nodedc/ui-react", "package": "@nodedc/ui-react",
"exports": ["SegmentedControl"], "exports": ["SegmentedControl"],
"domContract": ["nodedc-segmented"], "domContract": ["nodedc-segmented"],
"summary": "Pill navigation used in the shared top header and compact mode switches.", "summary": "Pill navigation used in the shared top header and compact mode switches."
"sizes": ["default", "dense"],
"rules": [
"Dense is reserved for high-density in-viewer layer and mode toolbars; application headers retain default geometry."
]
}, },
{ {
"id": "app-header", "id": "app-header",
@@ -413,10 +369,9 @@
"domContract": ["nodedc-toast-viewport", "nodedc-toast", "nodedc-toast__icon", "nodedc-toast__copy"], "domContract": ["nodedc-toast-viewport", "nodedc-toast", "nodedc-toast__icon", "nodedc-toast__copy"],
"summary": "Tasker-derived non-blocking status notification with a bottom-right glass stack.", "summary": "Tasker-derived non-blocking status notification with a bottom-right glass stack.",
"variants": ["success", "error", "warning", "info", "loading"], "variants": ["success", "error", "warning", "info", "loading"],
"behavior": ["controlled items", "independent 10 second default auto-dismiss per terminal item", "manual dismiss", "portal viewport", "polite live region"], "behavior": ["controlled items", "optional auto-dismiss", "manual dismiss", "portal viewport", "polite live region"],
"rules": [ "rules": [
"Applications own operation state and message copy; ToastStack owns viewport geometry and dismissal timing.", "Applications own operation state and message copy; ToastStack owns viewport geometry and dismissal timing.",
"Adding or updating another notification never restarts the lifetime of an existing item; each item owns its timer by stable id.",
"Loading notifications remain until the operation updates or dismisses them.", "Loading notifications remain until the operation updates or dismisses them.",
"Status notifications never replace a blocking confirmation modal." "Status notifications never replace a blocking confirmation modal."
] ]
+3 -5
View File
@@ -6,7 +6,6 @@
"defaultSize": 18, "defaultSize": 18,
"supportedSizes": [16, 18, 20], "supportedSizes": [16, 18, 20],
"strokeWidth": 1.8, "strokeWidth": 1.8,
"filledNames": ["play", "stop"],
"groups": [ "groups": [
{ {
"id": "window-layer", "id": "window-layer",
@@ -30,7 +29,7 @@
"id": "state-access", "id": "state-access",
"label": "Состояние и доступ", "label": "Состояние и доступ",
"referenceSources": ["launcher", "seo", "task-manager"], "referenceSources": ["launcher", "seo", "task-manager"],
"names": ["check", "alert", "activity", "lock", "key", "shield", "circle", "eye", "eye-off"] "names": ["check", "alert", "activity", "lock", "key", "shield", "circle"]
}, },
{ {
"id": "entities", "id": "entities",
@@ -42,14 +41,13 @@
"id": "content", "id": "content",
"label": "Контент", "label": "Контент",
"referenceSources": ["seo", "bim-viewer", "engine"], "referenceSources": ["seo", "bim-viewer", "engine"],
"names": ["camera", "plan", "play", "stop", "image", "video", "file", "folder", "clipboard", "settings"] "names": ["image", "video", "file", "folder", "clipboard", "settings"]
} }
], ],
"rules": [ "rules": [
"Use semantic icon names from this registry instead of importing an application-local icon by shape.", "Use semantic icon names from this registry instead of importing an application-local icon by shape.",
"Icons inherit currentColor; their button surface, active state and hit target are owned by Button, IconButton or the containing pattern.", "Icons inherit currentColor; their button surface, active state and hit target are owned by Button, IconButton or the containing pattern.",
"Do not copy the complete Font Awesome or Lucide vendor catalog into application code.", "Do not copy the complete Font Awesome or Lucide vendor catalog into application code.",
"A new icon requires a confirmed product use, a registry entry and a catalog specimen.", "A new icon requires a confirmed product use, a registry entry and a catalog specimen."
"Playback icons play and stop are filled semantic glyphs; all other canonical glyphs remain outline unless their semantic contract says otherwise."
] ]
} }
@@ -1,41 +0,0 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { ActivityIndicator } from "../packages/ui-react/dist/index.js";
test("ActivityIndicator separates decorative and announced progress", () => {
const decorative = renderToStaticMarkup(createElement(ActivityIndicator, { size: "compact" }));
const announced = renderToStaticMarkup(createElement(ActivityIndicator, { label: "Подключаем устройство" }));
assert.match(decorative, /class="nodedc-activity-indicator"/);
assert.match(decorative, /data-size="compact"/);
assert.match(decorative, /aria-hidden="true"/);
assert.doesNotMatch(decorative, /role="status"/);
assert.match(announced, /role="status"/);
assert.match(announced, /aria-label="Подключаем устройство"/);
assert.doesNotMatch(announced, /aria-hidden/);
assert.doesNotMatch(announced, /data-size=/);
});
test("ActivityIndicator is registered, cataloged and motion-safe", async () => {
const [styles, registrySource, docs, catalog] = await Promise.all([
readFile(new URL("../packages/ui-core/styles.css", import.meta.url), "utf8"),
readFile(new URL("../registry/components.json", import.meta.url), "utf8"),
readFile(new URL("../docs/COMPONENTS.md", import.meta.url), "utf8"),
readFile(new URL("../apps/catalog/src/CatalogApp.tsx", import.meta.url), "utf8"),
]);
const registry = JSON.parse(registrySource);
const entry = registry.components.find((component) => component.id === "activity-indicator");
assert.deepEqual(entry?.variants, ["default", "compact"]);
assert.ok(entry?.exports.includes("ActivityIndicator"));
assert.match(styles, /\.nodedc-activity-indicator\s*\{[\s\S]*?animation: nodedc-activity-indicator-spin/);
assert.match(styles, /\.nodedc-activity-indicator\[data-size="compact"\]/);
assert.match(styles, /@media \(prefers-reduced-motion: reduce\) \{\s*\.nodedc-activity-indicator,[\s\S]*?animation: none/);
assert.match(docs, /## ActivityIndicator/);
assert.match(catalog, /<ActivityIndicator label="Загружаем данные"/);
assert.match(catalog, /icon=\{<ActivityIndicator size="compact" \/>\}/);
});
-37
View File
@@ -1,37 +0,0 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { Button, SegmentedControl } from "../packages/ui-react/dist/index.js";
test("dense viewer controls remain a public Button and SegmentedControl contract", async () => {
const button = renderToStaticMarkup(createElement(Button, { size: "dense" }, "Source points"));
const segmented = renderToStaticMarkup(createElement(SegmentedControl, {
size: "dense",
value: "source",
items: [{ label: "Source points", value: "source" }],
label: "Viewer layer",
onChange: () => {},
}));
const [tokens, styles, registrySource, docs] = await Promise.all([
readFile(new URL("../packages/tokens/tokens.css", import.meta.url), "utf8"),
readFile(new URL("../packages/ui-core/styles.css", import.meta.url), "utf8"),
readFile(new URL("../registry/components.json", import.meta.url), "utf8"),
readFile(new URL("../docs/COMPONENTS.md", import.meta.url), "utf8"),
]);
const registry = JSON.parse(registrySource);
const buttonEntry = registry.components.find((component) => component.id === "button");
const segmentedEntry = registry.components.find((component) => component.id === "segmented-control");
assert.match(button, /data-size="dense"/);
assert.match(segmented, /data-size="dense"/);
assert.ok(buttonEntry?.sizes.includes("dense"));
assert.ok(segmentedEntry?.sizes.includes("dense"));
assert.match(tokens, /--nodedc-control-height-dense: 1\.875rem/);
assert.match(tokens, /--nodedc-font-size-dense: 0\.45rem/);
assert.match(styles, /\.nodedc-button\[data-size="dense"\]/);
assert.match(styles, /\.nodedc-segmented\[data-size="dense"\]/);
assert.match(docs, /size="dense"/);
assert.match(docs, /viewer/);
});
+7 -4
View File
@@ -49,15 +49,18 @@ test("floating surface remains bounded when its anchor is below the viewport", (
}); });
test("dropdown scroll keeps portal geometry stable and contained", async () => { test("dropdown scroll keeps portal geometry stable and contained", async () => {
const styles = await readFile( const [styles, dropdown] = await Promise.all([
new URL("../packages/ui-core/styles.css", import.meta.url), readFile(new URL("../packages/ui-core/styles.css", import.meta.url), "utf8"),
"utf8", readFile(new URL("../packages/ui-react/src/Dropdown.tsx", import.meta.url), "utf8"),
); ]);
const dropdownRule = styles.match(/\.nodedc-dropdown-surface \{(?<body>[\s\S]*?)\n\}/)?.groups?.body ?? ""; const dropdownRule = styles.match(/\.nodedc-dropdown-surface \{(?<body>[\s\S]*?)\n\}/)?.groups?.body ?? "";
assert.match(dropdownRule, /box-sizing:\s*border-box;/); assert.match(dropdownRule, /box-sizing:\s*border-box;/);
assert.match(dropdownRule, /overflow:\s*auto;/); assert.match(dropdownRule, /overflow:\s*auto;/);
assert.match(dropdownRule, /overscroll-behavior:\s*contain;/); assert.match(dropdownRule, /overscroll-behavior:\s*contain;/);
assert.match(dropdown, /new ResizeObserver\(schedulePositionUpdate\)/);
assert.match(dropdown, /resizeObserver\.observe\(surface\)/);
assert.match(dropdown, /window\.requestAnimationFrame\(updatePosition\)/);
}); });
test("workspace windows keep their glass rim without masked compositor overlays", async () => { test("workspace windows keep their glass rim without masked compositor overlays", async () => {
-30
View File
@@ -1,30 +0,0 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { Icon } from "../packages/ui-react/dist/index.js";
test("only registered playback glyphs are filled", async () => {
const registry = JSON.parse(await readFile(
new URL("../registry/icons.json", import.meta.url),
"utf8",
));
const names = registry.groups.flatMap((group) => group.names);
const filledNames = new Set(registry.filledNames);
assert.deepEqual([...filledNames], ["play", "stop"]);
assert.equal(new Set(names).size, names.length);
for (const name of names) {
const markup = renderToStaticMarkup(createElement(Icon, { name }));
const rootTag = markup.slice(0, markup.indexOf(">") + 1);
if (filledNames.has(name)) {
assert.match(rootTag, /fill="currentColor"/, `${name} must be filled`);
assert.match(rootTag, /stroke-width="0"/, `${name} must not retain an outline`);
} else {
assert.match(rootTag, /fill="none"/, `${name} must remain outline-only`);
assert.doesNotMatch(rootTag, /fill="currentColor"/, `${name} must not be filled`);
}
}
});
+4 -4
View File
@@ -48,14 +48,14 @@ test("Inspector hard-forces a raw integrated Select to the split presentation",
}); });
test("Foundry Inspector screens consume the semantic selection field", async () => { test("Foundry Inspector screens consume the semantic selection field", async () => {
const [mapPreview, catalog, coreStyles] = await Promise.all([ const [mapInspector, catalog, coreStyles] = await Promise.all([
readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"), readFile(new URL("../apps/catalog/src/mapInspectorSections.tsx", import.meta.url), "utf8"),
readFile(new URL("../apps/catalog/src/CatalogApp.tsx", import.meta.url), "utf8"), readFile(new URL("../apps/catalog/src/CatalogApp.tsx", import.meta.url), "utf8"),
readFile(new URL("../packages/ui-core/styles.css", import.meta.url), "utf8"), readFile(new URL("../packages/ui-core/styles.css", import.meta.url), "utf8"),
]); ]);
assert.match(mapPreview, /<InspectorSelectField\s+label="Профиль покрытия"/); assert.match(mapInspector, /<InspectorSelectField\s+label="Профиль покрытия"/);
assert.doesNotMatch(mapPreview, /<ControlRow label="Профиль покрытия">/); assert.doesNotMatch(mapInspector, /<ControlRow label="Профиль покрытия">/);
assert.equal((catalog.match(/<InspectorSelectField/g) ?? []).length, 2); assert.equal((catalog.match(/<InspectorSelectField/g) ?? []).length, 2);
assert.match(coreStyles, /\.nodedc-inspector \.nodedc-control-row:has\(\.nodedc-select-anchor\)/); assert.match(coreStyles, /\.nodedc-inspector \.nodedc-control-row:has\(\.nodedc-select-anchor\)/);
}); });
+28 -25
View File
@@ -315,29 +315,32 @@ test("grid center is fixed and camera-independent, including for legacy camera-m
}); });
test("renderer uses fixed ENU sectors, angular graticules and a non-blank double-buffer swap", async () => { test("renderer uses fixed ENU sectors, angular graticules and a non-blank double-buffer swap", async () => {
const source = await readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"); const [renderer, gridLayer] = await Promise.all([
assert.match(source, /class GridLayerController/); readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"),
assert.match(source, /dataSourceDisplay\.ready/); readFile(new URL("../apps/catalog/src/mapCesiumGridLayer.ts", import.meta.url), "utf8"),
assert.match(source, /fixedGridOrigin\(presentation\)/); ]);
assert.match(source, /Transforms\.eastNorthUpToFixedFrame\(anchor\)/); assert.match(gridLayer, /class GridLayerController/);
assert.match(source, /localSectorAt\(/); assert.match(gridLayer, /dataSourceDisplay\.ready/);
assert.match(source, /gridController\?\.pick\(worldPosition\)/); assert.match(gridLayer, /fixedGridOrigin\(presentation\)/);
assert.match(source, /lod\.stepKm \* 1_000/); assert.match(gridLayer, /Transforms\.eastNorthUpToFixedFrame\(anchor\)/);
assert.match(source, /lod\.graticuleStepDegrees/); assert.match(gridLayer, /localSectorAt\(/);
assert.match(source, /lod\.graticuleLineWidthPx/); assert.match(renderer, /gridController\?\.pick\(worldPosition\)/);
assert.match(source, /arcType: ArcType\.RHUMB/); assert.match(gridLayer, /lod\.stepKm \* 1_000/);
assert.match(source, /clampToGround: false/); assert.match(gridLayer, /lod\.graticuleStepDegrees/);
assert.match(source, /majorLineWidthMultiplier/); assert.match(gridLayer, /lod\.graticuleLineWidthPx/);
assert.match(source, /isLocalMajorLineIndex/); assert.match(gridLayer, /arcType: ArcType\.RHUMB/);
assert.match(source, /isGraticuleMajorLineValue/); assert.match(gridLayer, /clampToGround: false/);
assert.match(source, /new LabelCollection\(\)/); assert.match(gridLayer, /majorLineWidthMultiplier/);
assert.match(source, /const maximumLabels = 48/); assert.match(gridLayer, /isLocalMajorLineIndex/);
assert.match(source, /materializeLocalSelection/); assert.match(gridLayer, /isGraticuleMajorLineValue/);
assert.match(source, /localVolumeAt\(/); assert.match(gridLayer, /new LabelCollection\(\)/);
assert.match(source, /focusGridSector/); assert.match(gridLayer, /const maximumLabels = 48/);
assert.match(source, /selectedGridSector/); assert.match(gridLayer, /materializeLocalSelection/);
assert.match(source, /mountResources\(resources\)[\s\S]*?const previous = this\.current;[\s\S]*?removeResources\(previous\.resources\)/); assert.match(gridLayer, /localVolumeAt\(/);
assert.doesNotMatch(source, /function rebuildElevatedGrid[\s\S]*?entities\.removeAll\(\)/); assert.match(renderer, /focusGridSector/);
assert.doesNotMatch(source, /Math\.min\(40,[\s\S]*?safeRadiusKm \/ safeStepKm/); assert.match(renderer, /selectedGridSector/);
assert.doesNotMatch(source, /snapGridCenter\(/); assert.match(gridLayer, /mountResources\(resources\)[\s\S]*?const previous = this\.current;[\s\S]*?removeResources\(previous\.resources\)/);
assert.doesNotMatch(gridLayer, /function rebuildElevatedGrid[\s\S]*?entities\.removeAll\(\)/);
assert.doesNotMatch(gridLayer, /Math\.min\(40,[\s\S]*?safeRadiusKm \/ safeStepKm/);
assert.doesNotMatch(gridLayer, /snapGridCenter\(/);
}); });
+8 -8
View File
@@ -3,17 +3,17 @@ import { readFile } from "node:fs/promises";
import test from "node:test"; import test from "node:test";
test("Cesium elevated targets keep their profile border while labels occlude them inside the scene overlay", async () => { test("Cesium elevated targets keep their profile border while labels occlude them inside the scene overlay", async () => {
const [renderer, cesiumDisplay] = await Promise.all([ const [runtimeLayers, cesiumDisplay] = await Promise.all([
readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"), readFile(new URL("../apps/catalog/src/mapCesiumRuntimeLayers.ts", import.meta.url), "utf8"),
readFile(new URL("../node_modules/@cesium/engine/Source/DataSources/DataSourceDisplay.js", import.meta.url), "utf8"), readFile(new URL("../node_modules/@cesium/engine/Source/DataSources/DataSourceDisplay.js", import.meta.url), "utf8"),
]); ]);
assert.match(renderer, /BillboardGraphics/); assert.match(runtimeLayers, /BillboardGraphics/);
assert.match(renderer, /elevatedTargetImage\(\s*color,\s*Color\.fromCssColorString\(target\.outlineColor\)\.withAlpha\(target\.outlineOpacity\),\s*target\.outlineWidthPx,\s*target\.headSizePx/); assert.match(runtimeLayers, /elevatedTargetImage\(\s*color,\s*Color\.fromCssColorString\(target\.outlineColor\)\.withAlpha\(target\.outlineOpacity\),\s*target\.outlineWidthPx,\s*target\.headSizePx/);
assert.match(renderer, /entity\.point = undefined;\s*entity\.billboard = new BillboardGraphics/); assert.match(runtimeLayers, /entity\.point = undefined;\s*entity\.billboard = new BillboardGraphics/);
assert.match(renderer, /disableDepthTestDistance: Number\.POSITIVE_INFINITY/); assert.match(runtimeLayers, /disableDepthTestDistance: Number\.POSITIVE_INFINITY/);
assert.match(renderer, /outlineWidth: 0,\s*style: LabelStyle\.FILL/); assert.match(runtimeLayers, /outlineWidth: 0,\s*style: LabelStyle\.FILL/);
assert.doesNotMatch(renderer, /style: 2/); assert.doesNotMatch(runtimeLayers, /style: 2/);
assert.ok(cesiumDisplay.indexOf("new BillboardVisualizer") < cesiumDisplay.indexOf("new LabelVisualizer")); assert.ok(cesiumDisplay.indexOf("new BillboardVisualizer") < cesiumDisplay.indexOf("new LabelVisualizer"));
}); });
+107 -80
View File
@@ -2,71 +2,92 @@ import assert from "node:assert/strict";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import test from "node:test"; import test from "node:test";
test("Objects menu opens controllable groups from the row and keeps plain bindings as visibility layers", async () => { const source = (path) => readFile(new URL(path, import.meta.url), "utf8");
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
assert.match(preview, /const toggleSubjectVisibility = \(bindingId: string\)/); test("Objects menu delegates controlled layers to the workspace reducer", async () => {
assert.match(preview, /role=\{hasControls \? "menuitem" : "menuitemcheckbox"\}/); const [preview, toolbar, state] = await Promise.all([
assert.match(preview, /if \(hasControls\) \{\s*openSubjectWindow\(summary\.bindingId\);\s*close\(\)/); source("../apps/catalog/src/MapFixturePreview.tsx"),
assert.match(preview, /toggleSubjectVisibility\(summary\.bindingId\)/); source("../apps/catalog/src/MapWorkspaceToolbar.tsx"),
assert.match(preview, /visible: !state\.visible/); source("../apps/catalog/src/mapWorkspaceState.mjs"),
assert.match(preview, /слой скрыт · \$\{summary\.total\} объектов/); ]);
assert.doesNotMatch(preview, /Фильтры и счётчики: \$\{summary\.displayName\}/);
assert.match(toolbar, /role=\{hasControls \? "menuitem" : "menuitemcheckbox"\}/);
assert.match(toolbar, /onOpenSubjectWindow\(summary\.bindingId\)/);
assert.match(toolbar, /onToggleSubjectVisibility\(summary\.bindingId\)/);
assert.match(toolbar, /слой скрыт · \$\{summary\.total\} объектов/);
assert.match(preview, /type: "toggle-subject-visibility", bindingId/);
assert.match(state, /visible: !subject\.visible/);
}); });
test("a subject window exists only when the presentation profile exposes controls", async () => { test("a subject window exists only when the MCP presentation profile exposes controls", async () => {
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"); const [windows, model, state] = await Promise.all([
source("../apps/catalog/src/MapSubjectWorkspaceWindows.tsx"),
source("../apps/catalog/src/mapWorkspaceModel.mjs"),
source("../apps/catalog/src/mapWorkspaceState.mjs"),
]);
assert.match(preview, /function hasSubjectWindowControls\(profile: MapPresentationProfile\)/); assert.match(model, /export function mapProfileHasSubjectWindowControls/);
assert.match(preview, /if \(!state\?\.window\.open \|\| !hasSubjectWindowControls\(summary\.profile\)\) return null/); assert.match(windows, /!state\?\.window\.open \|\| !mapProfileHasSubjectWindowControls\(summary\.profile\)/);
assert.match(preview, /window: \{ \.\.\.state\.window, open: false \}/); assert.match(windows, /type: "close-subject-window"/);
assert.match(state, /window: \{ \.\.\.subject\.window, open: false \}/);
}); });
test("hidden and visible layers have an explicit visual state", async () => { test("hidden and visible layers have an explicit visual state", async () => {
const styles = await readFile(new URL("../apps/catalog/src/styles.css", import.meta.url), "utf8"); const styles = await source("../apps/catalog/src/styles.css");
assert.match(styles, /\.catalog-map-fixture__objects-menu-item\[data-visible\]/); assert.match(styles, /\.catalog-map-fixture__objects-menu-item\[data-visible\]/);
assert.match(styles, /\.catalog-map-fixture__objects-menu-item:not\(\[data-visible\]\)/); assert.match(styles, /\.catalog-map-fixture__objects-menu-item:not\(\[data-visible\]\)/);
}); });
test("facet controls and renderer consume the same canonical enabled-value state", async () => { test("facet controls and renderer consume one canonical enabled-value state", async () => {
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"); const [preview, windows, state] = await Promise.all([
source("../apps/catalog/src/MapFixturePreview.tsx"),
source("../apps/catalog/src/MapSubjectWorkspaceWindows.tsx"),
source("../apps/catalog/src/mapWorkspaceState.mjs"),
]);
assert.match(preview, /initialSubjectState\(initialLayout\?\.dataProductBindings[\s\S]*?presentationProfiles\)/); assert.match(preview, /normalizeMapPresentationFacetSelections\(state\.filters, profile\)/);
assert.match(preview, /facets: profile \? normalizeMapPresentationFacetSelections\(state\.filters, profile\) : state\.filters/); assert.match(preview, /presentationFilters=\{rendererPresentationFilters\}/);
assert.match(preview, /filters: normalizeMapPresentationFacetSelections\(state\.filters, summary\.profile\)/); assert.match(windows, /mapPresentationFacetValueIsEnabled\(state\.filters, facet\.field, item\.value\)/);
assert.match(preview, /const active = mapPresentationFacetValueIsEnabled\(state\.filters, facet\.field, item\.value\)/); assert.match(windows, /facet\.values\.map\(\(value\) => value\.value\)/);
assert.match(preview, /toggleMapPresentationFacetSelection\(filters, field, value, availableValues\)/); assert.match(state, /toggleMapPresentationFacetSelection\(/);
assert.match(preview, /facet\.values\.map\(\(value\) => value\.value\)/);
}); });
test("joined detail aspects do not become independent map layers", async () => { test("joined detail aspects enrich subjects but never become independent renderer layers", async () => {
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"); const [preview, model] = await Promise.all([
source("../apps/catalog/src/MapFixturePreview.tsx"),
source("../apps/catalog/src/mapWorkspaceModel.mjs"),
]);
assert.match(preview, /dataProductBindings\.filter\(\(binding\) => !binding\.joinToBindingId\)/); assert.match(model, /filter\(\(binding\) => !binding\.joinToBindingId\)/);
assert.match(preview, /runtimeBindings\.filter\(\(binding\) => primaryBindingIds\.has\(binding\.bindingId\)\)/); assert.match(model, /primaryMapBindingConfigs\(bindingConfigs\)/);
assert.match(preview, /runtimeBindings=\{\[\.\.\.sectorScopedPrimaryRuntimeBindings, \.\.\.referenceRuntimeBindings\]\}/); assert.match(preview, /runtimeBindings=\{\[\.\.\.sectorScopedPrimaryRuntimeBindings, \.\.\.referenceRuntimeBindings\]\}/);
}); });
test("bounded map windows keep one stack while layers and settings use their canonical surfaces", async () => { test("bounded map windows share one reducer-owned z-stack while settings and layers keep canonical surfaces", async () => {
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"); const [preview, toolbar, windows, sectorWindow, state, contract] = await Promise.all([
source("../apps/catalog/src/MapFixturePreview.tsx"),
source("../apps/catalog/src/MapWorkspaceToolbar.tsx"),
source("../apps/catalog/src/MapSubjectWorkspaceWindows.tsx"),
source("../apps/catalog/src/MapSectorWorkspaceWindow.tsx"),
source("../apps/catalog/src/mapWorkspaceState.mjs"),
source("../apps/catalog/src/mapPageContract.ts"),
]);
assert.match(preview, /type MapWorkspaceWindowId = "sector" \| "subject-card" \| `binding:\$\{string\}`/); assert.match(contract, /type MapWorkspaceWindowId = "sector" \| "subject-card" \| `binding:\$\{string\}`/);
assert.match(preview, /const activateWorkspaceWindow = useCallback/); assert.match(state, /function activateWindow\(state, windowId\)/);
assert.match(preview, /onActivate=\{\(\) => activateWorkspaceWindow\(`binding:\$\{summary\.bindingId\}`\)\}/); assert.match(state, /Math\.max\([\s\S]*state\.sector\.window\.zIndex[\s\S]*state\.subjectCard\.zIndex/);
assert.doesNotMatch(preview, /onActivate=\{\(\) => openSubjectWindow\(summary\.bindingId\)\}/); assert.match(windows, /type: "activate-window", windowId: `binding:\$\{summary\.bindingId\}`/);
assert.match(preview, /sectorWindowZIndex[\s\S]*subjectCardZIndex/); assert.match(sectorWindow, /onActivate=\{onActivate\}/);
assert.match(preview, /<ApplicationSidePanel[\s\S]*title="Настройки карты"[\s\S]*onClose=\{closeSettingsPanel\}/); assert.match(preview, /<ApplicationSidePanel[\s\S]*title="Настройки карты"[\s\S]*onClose=\{closeSettingsPanel\}/);
assert.match(preview, /createPortal\(headerActions, headerActionsHost\)/); assert.match(preview, /createPortal\(headerActions, headerActionsHost\)/);
assert.doesNotMatch(preview, /settingsPanelPinned|onPinnedChange/); assert.match(toolbar, /surfaceClassName="catalog-map-fixture__objects-menu catalog-map-fixture__layers-menu nodedc-map-glass"/);
assert.match(preview, /surfaceClassName="catalog-map-fixture__objects-menu catalog-map-fixture__layers-menu nodedc-map-glass"/); assert.match(toolbar, /onOpenChange=\{onOpenChange\}/);
assert.match(preview, /onOpenChange=\{setLayersOpen\}/); assert.doesNotMatch(preview, /settingsPanelPinned|settingsWindowZIndex|layersWindowZIndex/);
assert.doesNotMatch(preview, /settingsWindowZIndex|layersWindowZIndex/);
assert.doesNotMatch(preview, /<Window\b/);
}); });
test("selecting a point or HGeoZone preserves the active grid sector", async () => { test("selecting a point or HGeoZone preserves the active grid sector", async () => {
const renderer = await readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"); const renderer = await source("../apps/catalog/src/CesiumMapRenderer.tsx");
const pointBranch = renderer.match(/if \(pickedId instanceof Entity[\s\S]*?return;\s*\}/)?.[0] ?? ""; const pointBranch = renderer.match(/if \(pickedId instanceof Entity[\s\S]*?return;\s*\}/)?.[0] ?? "";
const zoneBranch = renderer.match(/if \(\s*pickedId[\s\S]*?HGeoZonePickId\)\.entityId\);\s*return;\s*\}/)?.[0] ?? ""; const zoneBranch = renderer.match(/if \(\s*pickedId[\s\S]*?HGeoZonePickId\)\.entityId\);\s*return;\s*\}/)?.[0] ?? "";
@@ -77,59 +98,65 @@ test("selecting a point or HGeoZone preserves the active grid sector", async ()
assert.match(renderer, /gridController\?\.setSelection\(gridSelection\);\s*onGridSectorSelectRef\.current\?\.\(gridSelection\)/); assert.match(renderer, /gridController\?\.setSelection\(gridSelection\);\s*onGridSectorSelectRef\.current\?\.\(gridSelection\)/);
}); });
test("an active sector scopes point facts, exposes data facets and deactivates on close", async () => { test("active sector scope is geometric, filterable and reducer-deactivated", async () => {
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"); const [runtime, sectorWindow, state] = await Promise.all([
source("../apps/catalog/src/mapSectorRuntime.mjs"),
assert.match(preview, /function mapFactInsideGridSector/); source("../apps/catalog/src/MapSectorWorkspaceWindow.tsx"),
assert.match(preview, /localSectorAtGeodetic/); source("../apps/catalog/src/mapWorkspaceState.mjs"),
assert.match(preview, /label="Скрыть объекты за сектором"/);
assert.match(preview, />Домены данных</);
assert.match(preview, />Провайдеры</);
assert.match(preview, />Типы объектов</);
assert.match(preview, />Объекты сектора</);
assert.match(preview, /onClose=\{deactivateGridSector\}/);
assert.match(preview, />\s*Деактивировать сектор\s*</);
assert.match(preview, /setSelectedGridSector\(null\)/);
});
test("reference stations are first-class Objects menu layers without provider settings actions", async () => {
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
assert.match(preview, /const referenceObjectSummaries = useMemo/);
assert.match(preview, /const objectLayerCount = presentationSummaries\.length \+ referenceObjectSummaries\.length/);
assert.match(preview, /referenceObjectSummaries\.map\(\(\{ layer, displayName, total \}\)/);
assert.match(preview, /candidate\.id === layer\.id \? \{ \.\.\.candidate, visible: !candidate\.visible \} : candidate/);
assert.match(preview, /role="menuitemcheckbox"/);
});
test("facet window auto-fits expanded and collapsed content inside map workspace", async () => {
const [preview, workspace] = await Promise.all([
readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"),
readFile(new URL("../packages/ui-react/src/WorkspaceWindow.tsx", import.meta.url), "utf8"),
]); ]);
assert.match(preview, /minHeight=\{220\}\s*autoHeight/); assert.match(runtime, /export function mapFactInsideGridSector/);
assert.match(runtime, /localSectorAtGeodetic/);
assert.match(sectorWindow, /label="Скрыть объекты за сектором"/);
assert.match(sectorWindow, /title="Домены данных"/);
assert.match(sectorWindow, /title="Провайдеры"/);
assert.match(sectorWindow, /title="Типы объектов"/);
assert.match(sectorWindow, /id="map-sector-objects-title">Объекты сектора/);
assert.match(sectorWindow, /onClose=\{onDeactivate\}/);
assert.match(sectorWindow, />\s*Деактивировать сектор\s*</);
assert.match(state, /case "deactivate-sector"/);
assert.match(state, /selectedSector: null/);
});
test("reference stations remain first-class visibility layers without provider settings actions", async () => {
const [preview, toolbar] = await Promise.all([
source("../apps/catalog/src/MapFixturePreview.tsx"),
source("../apps/catalog/src/MapWorkspaceToolbar.tsx"),
]);
assert.match(preview, /const referenceObjectSummaries = useMemo/);
assert.match(toolbar, /const objectLayerCount = summaries\.length \+ referenceSummaries\.length/);
assert.match(toolbar, /referenceSummaries\.map\(\(\{ layer, displayName, total \}\)/);
assert.match(toolbar, /onToggleReferenceLayer\(layer\.id\)/);
assert.match(toolbar, /role="menuitemcheckbox"/);
});
test("facet windows auto-fit expanded and collapsed content inside the map workspace", async () => {
const [windows, workspace] = await Promise.all([
source("../apps/catalog/src/MapSubjectWorkspaceWindows.tsx"),
source("../packages/ui-react/src/WorkspaceWindow.tsx"),
]);
assert.match(windows, /minHeight=\{220\}\s*autoHeight/);
assert.match(workspace, /autoHeight\?: boolean/); assert.match(workspace, /autoHeight\?: boolean/);
assert.match(workspace, /content\.scrollHeight/); assert.match(workspace, /content\.scrollHeight/);
assert.match(workspace, /availableHeight = Math\.max\(0, nextBounds\.height - normalized\.y\)/); assert.match(workspace, /availableHeight = Math\.max\(0, nextBounds\.height - normalized\.y\)/);
assert.match(workspace, /new ResizeObserver\(scheduleFit\)/); assert.match(workspace, /new ResizeObserver\(scheduleFit\)/);
}); });
test("facet tree selection focuses the subject without resetting a compatible detail tab", async () => { test("facet and search selection focus the subject while the reducer preserves a valid detail tab", async () => {
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"); const [preview, renderer, state] = await Promise.all([
const renderer = await readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"); source("../apps/catalog/src/MapFixturePreview.tsx"),
source("../apps/catalog/src/CesiumMapRenderer.tsx"),
source("../apps/catalog/src/mapWorkspaceState.mjs"),
]);
assert.match(preview, /const \[expandedFacetRows, setExpandedFacetRows\]/);
assert.match(preview, /const handleSelectAndFocus = useCallback/); assert.match(preview, /const handleSelectAndFocus = useCallback/);
assert.match(preview, /const focusSubject = useCallback/);
assert.match(preview, /if \(renderer\.focusRuntimeEntity\(entityId\)\) return true/); assert.match(preview, /if \(renderer\.focusRuntimeEntity\(entityId\)\) return true/);
assert.match(preview, /mapFactPointCoordinates\(selectable\.find/);
assert.match(preview, /renderer\.focusSubjectCoordinates\(fallbackCoordinates\[0\], fallbackCoordinates\[1\]\)/); assert.match(preview, /renderer\.focusSubjectCoordinates\(fallbackCoordinates\[0\], fallbackCoordinates\[1\]\)/);
assert.match(preview, /focusSubject\(entityId\)/);
assert.match(preview, /focusSubject\(result\.entityId, result\.coordinates\)/); assert.match(preview, /focusSubject\(result\.entityId, result\.coordinates\)/);
assert.match(preview, /setSubjectCardTabId\(\(current\) =>/); assert.match(preview, /validTabIds: profile\?\.tabs\.map\(\(tab\) => tab\.id\) \?\? \["overview"\]/);
assert.match(preview, /profile\?\.tabs\.some\(\(tab\) => tab\.id === current\)/); assert.match(state, /action\.validTabIds\.includes\(state\.subjectCard\.tabId\)/);
assert.match(preview, /\(profile\?\.defaultTabId \?\? "overview"\)/);
assert.match(renderer, /void viewer\.flyTo\(entity, \{/); assert.match(renderer, /void viewer\.flyTo\(entity, \{/);
assert.match(renderer, /const focusSubjectCoordinates = useCallback/); assert.match(renderer, /const focusSubjectCoordinates = useCallback/);
assert.match(renderer, /viewer\.camera\.flyToBoundingSphere\(new BoundingSphere\(target, 1\), \{/); assert.match(renderer, /viewer\.camera\.flyToBoundingSphere\(new BoundingSphere\(target, 1\), \{/);
+1 -9
View File
@@ -1,13 +1,5 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test"; import test from "node:test";
import ts from "typescript";
const source = await readFile(new URL("../apps/catalog/src/mapPresentationProfile.ts", import.meta.url), "utf8");
const transpiled = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
}).outputText;
const moduleUrl = `data:text/javascript;base64,${Buffer.from(transpiled).toString("base64")}`;
const { const {
mapFactMatchesFilters, mapFactMatchesFilters,
mapPresentationBindingIsAll, mapPresentationBindingIsAll,
@@ -15,7 +7,7 @@ const {
mapPresentationFacetValueIsEnabled, mapPresentationFacetValueIsEnabled,
normalizeMapPresentationFacetSelections, normalizeMapPresentationFacetSelections,
toggleMapPresentationFacetSelection, toggleMapPresentationFacetSelection,
} = await import(moduleUrl); } = await import("../apps/catalog/src/mapPresentationProfile.mjs");
const profile = { const profile = {
id: "map.moving-object.operational.default", id: "map.moving-object.operational.default",
+163
View File
@@ -0,0 +1,163 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import {
runStagedMapProviders,
waitForGlobeViewportReady,
} from "../apps/catalog/src/mapProviderStartup.mjs";
function eventHarness() {
const listeners = new Set();
return {
event: {
addEventListener(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
},
raise(...args) {
for (const listener of [...listeners]) listener(...args);
},
get size() {
return listeners.size;
},
};
}
test("providers start in base-map order", async () => {
const calls = [];
const result = await runStagedMapProviders({
loadImagery: () => calls.push("imagery"),
waitAfterImagery: () => calls.push("imagery-ready"),
loadTerrain: () => calls.push("terrain"),
waitAfterTerrain: () => calls.push("terrain-ready"),
loadBuildings: () => calls.push("buildings"),
loadDeferred: () => calls.push("deferred"),
});
assert.equal(result, "complete");
assert.deepEqual(calls, ["imagery", "imagery-ready", "terrain", "terrain-ready", "buildings", "deferred"]);
});
test("imagery failure is reported without blocking terrain or buildings", async () => {
const calls = [];
const errors = [];
await runStagedMapProviders({
loadImagery: () => { calls.push("imagery"); throw new Error("imagery failed"); },
waitAfterImagery: () => calls.push("imagery-ready"),
loadTerrain: () => calls.push("terrain"),
waitAfterTerrain: () => calls.push("terrain-ready"),
loadBuildings: () => calls.push("buildings"),
onProviderError: (provider, error) => errors.push([provider, error.message]),
});
assert.deepEqual(calls, ["imagery", "terrain", "terrain-ready", "buildings"]);
assert.deepEqual(errors, [["imagery", "imagery failed"]]);
});
test("terrain failure is reported without blocking buildings", async () => {
const calls = [];
const errors = [];
await runStagedMapProviders({
loadImagery: () => calls.push("imagery"),
waitAfterImagery: () => calls.push("imagery-ready"),
loadTerrain: () => { calls.push("terrain"); throw new Error("terrain failed"); },
waitAfterTerrain: () => calls.push("terrain-ready"),
loadBuildings: () => calls.push("buildings"),
onProviderError: (provider, error) => errors.push([provider, error.message]),
});
assert.deepEqual(calls, ["imagery", "imagery-ready", "terrain", "buildings"]);
assert.deepEqual(errors, [["terrain", "terrain failed"]]);
});
test("cancellation prevents all later stages", async () => {
const controller = new AbortController();
const calls = [];
const result = await runStagedMapProviders({
signal: controller.signal,
loadImagery: () => { calls.push("imagery"); controller.abort(); },
waitAfterImagery: () => calls.push("imagery-ready"),
loadTerrain: () => calls.push("terrain"),
loadBuildings: () => calls.push("buildings"),
});
assert.equal(result, "cancelled");
assert.deepEqual(calls, ["imagery"]);
});
test("a stalled provider times out without inserting late or blocking later stages", async () => {
const calls = [];
const errors = [];
let releaseImagery;
const stalledImagery = new Promise((resolve) => { releaseImagery = resolve; });
await runStagedMapProviders({
providerInitializationTimeoutMs: 1,
loadImagery: async ({ signal }) => {
calls.push("imagery-start");
await stalledImagery;
if (!signal.aborted) calls.push("imagery-insert");
},
loadTerrain: () => calls.push("terrain"),
loadBuildings: () => calls.push("buildings"),
onProviderError: (provider, error) => errors.push([provider, error.message]),
});
releaseImagery();
await Promise.resolve();
assert.deepEqual(calls, ["imagery-start", "terrain", "buildings"]);
assert.deepEqual(errors, [["imagery", "imagery_startup_timeout"]]);
});
test("viewport readiness waits for discovery frames and cleans listeners", async () => {
const progress = eventHarness();
const postRender = eventHarness();
const globe = { tilesLoaded: true, tileLoadProgressEvent: progress.event };
const scene = { postRender: postRender.event, requestRender() {} };
let settled = false;
const ready = waitForGlobeViewportReady({ globe, scene, timeoutMs: 100, minimumRenderFrames: 2 })
.then((reason) => { settled = true; return reason; });
postRender.raise();
await Promise.resolve();
assert.equal(settled, false);
postRender.raise();
assert.equal(await ready, "loaded");
assert.equal(progress.size, 0);
assert.equal(postRender.size, 0);
});
test("viewport readiness times out and aborts without leaking listeners", async () => {
const timeoutProgress = eventHarness();
const timeoutPostRender = eventHarness();
assert.equal(await waitForGlobeViewportReady({
globe: { tilesLoaded: false, tileLoadProgressEvent: timeoutProgress.event },
scene: { postRender: timeoutPostRender.event, requestRender() {} },
timeoutMs: 1,
}), "timeout");
assert.equal(timeoutProgress.size, 0);
assert.equal(timeoutPostRender.size, 0);
const abortProgress = eventHarness();
const abortPostRender = eventHarness();
const controller = new AbortController();
const pending = waitForGlobeViewportReady({
globe: { tilesLoaded: false, tileLoadProgressEvent: abortProgress.event },
scene: { postRender: abortPostRender.event, requestRender() {} },
signal: controller.signal,
timeoutMs: 100,
});
controller.abort();
assert.equal(await pending, "cancelled");
assert.equal(abortProgress.size, 0);
assert.equal(abortPostRender.size, 0);
});
test("the live renderer uses the staged provider coordinator", async () => {
const renderer = await readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8");
assert.match(renderer, /runStagedMapProviders\(\{/);
assert.match(renderer, /waitForGlobeViewportReady\(\{/);
assert.match(renderer, /providerStartupAbort\.abort\(\)/);
assert.doesNotMatch(renderer, /Do not serialize provider startup/);
});
+4 -3
View File
@@ -28,13 +28,14 @@ test("Map Page registers a provider-neutral reference-points slot and three stat
test("existing layouts upgrade reference layers on read and renderer accepts their slot", async () => { test("existing layouts upgrade reference layers on read and renderer accepts their slot", async () => {
const server = await readFile(new URL("server/catalog-server.mjs", root), "utf8"); const server = await readFile(new URL("server/catalog-server.mjs", root), "utf8");
const renderer = await readFile(new URL("apps/catalog/src/CesiumMapRenderer.tsx", root), "utf8"); const runtimeLayers = await readFile(new URL("apps/catalog/src/mapCesiumRuntimeLayers.ts", root), "utf8");
const runtime = await readFile(new URL("apps/catalog/src/useMapReferenceRuntime.ts", root), "utf8"); const runtime = await readFile(new URL("apps/catalog/src/useMapReferenceRuntime.ts", root), "utf8");
assert.match(server, /pageLayoutMatch\[1\] === "map" \? validateMapPageLayout\(stored\) : stored/); assert.match(server, /pageLayoutMatch\[1\] === "map" \? validateMapPageLayout\(stored\) : stored/);
assert.match(server, /referenceLayers: structuredClone\(canonicalMapReferenceLayers\)/); assert.match(server, /referenceLayers: structuredClone\(canonicalMapReferenceLayers\)/);
assert.match(renderer, /binding\.slotId === "reference-points"/); assert.match(runtimeLayers, /binding\.slotId === "reference-points"/);
assert.match(runtime, /\/api\/map-gateway\/api\/map\/reference-sources\/v1\/profiles\//); assert.match(runtime, /\/api\/map-gateway\/api\/map\/reference-sources\/v1\/profiles\//);
assert.match(runtime, /allowedAttributes = new Set\(\["name", "category", "network", "operator", "official_name", "local_name", "uic_ref", "wheelchair"\]\)/); assert.match(runtime, /allowedAttributes = new Set\(\["name", "category", "network", "operator", "official_name", "local_name", "alternate_names", "uic_ref", "wheelchair"\]\)/);
assert.match(runtime, /alternate_names/);
assert.doesNotMatch(runtime, /credential|accessToken|providerEndpoint|rawPayload/); assert.doesNotMatch(runtime, /credential|accessToken|providerEndpoint|rawPayload/);
}); });
+3 -1
View File
@@ -85,7 +85,7 @@ test("reference subjects remain searchable by profile labels without a provider
id: "map.reference.station.v1", id: "map.reference.station.v1",
title: "Метро", title: "Метро",
semanticTypes: ["map.station"], semanticTypes: ["map.station"],
label: { mode: "attributes", fields: ["name", "official_name"] }, label: { mode: "attributes", fields: ["name", "official_name", "alternate_names"] },
}; };
const index = buildMapSearchIndex({ const index = buildMapSearchIndex({
runtimeBindings: [{ runtimeBindings: [{
@@ -93,6 +93,7 @@ test("reference subjects remain searchable by profile labels without a provider
presentationProfileId: stationProfile.id, presentationProfileId: stationProfile.id,
facts: [point("osm.node.1", "map.station", { facts: [point("osm.node.1", "map.station", {
name: "Петроградская", name: "Петроградская",
alternate_names: ["Petrogradskaya station"],
provider_note: "not searchable", provider_note: "not searchable",
})], })],
}], }],
@@ -100,6 +101,7 @@ test("reference subjects remain searchable by profile labels without a provider
}); });
assert.equal(searchMapSubjects(index, "петрог").at(0)?.title, "Петроградская"); assert.equal(searchMapSubjects(index, "петрог").at(0)?.title, "Петроградская");
assert.equal(searchMapSubjects(index, "Petrogradskaya").at(0)?.sourceId, "osm.node.1");
assert.equal(searchMapSubjects(index, "osm.node.1").at(0)?.groupTitle, "Метро"); assert.equal(searchMapSubjects(index, "osm.node.1").at(0)?.groupTitle, "Метро");
assert.deepEqual(searchMapSubjects(index, "provider_note"), []); assert.deepEqual(searchMapSubjects(index, "provider_note"), []);
}); });
+8 -5
View File
@@ -275,17 +275,20 @@ test("restricted identity aspects render full identifiers and bounded string lis
}); });
test("Cesium pin and label share one entity selection that opens the subject card", async () => { test("Cesium pin and label share one entity selection that opens the subject card", async () => {
const [renderer, preview] = await Promise.all([ const [renderer, preview, windows, state] = await Promise.all([
readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"), readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"),
readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"), readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"),
readFile(new URL("../apps/catalog/src/MapSubjectWorkspaceWindows.tsx", import.meta.url), "utf8"),
readFile(new URL("../apps/catalog/src/mapWorkspaceState.mjs", import.meta.url), "utf8"),
]); ]);
assert.match(renderer, /viewer\?\.scene\.pick/); assert.match(renderer, /viewer\?\.scene\.pick/);
assert.match(renderer, /pickedId instanceof Entity/); assert.match(renderer, /pickedId instanceof Entity/);
assert.match(renderer, /onSelectRef\.current\?\.\(pickedId\.id\)/); assert.match(renderer, /onSelectRef\.current\?\.\(pickedId\.id\)/);
assert.match(preview, /const handleSelect = useCallback/); assert.match(preview, /const handleSelect = useCallback/);
assert.match(preview, /setSubjectCardOpen\(true\)/); assert.match(preview, /type: "select-entity"/);
assert.match(preview, /title=\{selectedSubjectCard\.title\}/); assert.match(state, /subjectCard: \{ \.\.\.state\.subjectCard, open: true, tabId \}/);
assert.match(preview, /Карточка объекта:/); assert.match(windows, /title=\{selectedSubjectCard\.title\}/);
assert.match(preview, /SegmentedControl/); assert.match(windows, /Карточка объекта:/);
assert.match(windows, /SegmentedControl/);
}); });
+235
View File
@@ -0,0 +1,235 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildFilteredMapTargets,
buildMapPresentationFilters,
buildSelectableMapEntities,
planMapSubjectReveal,
primaryMapRuntimeBindings,
} from "../apps/catalog/src/mapWorkspaceModel.mjs";
import { mapRuntimeEntityId, mapRuntimeFactKey } from "../apps/catalog/src/mapRuntimeIdentity.mjs";
import { localSectorAtGeodetic, localSectorSummary } from "../apps/catalog/src/mapSectorGrid.mjs";
const origin = { latitude: 55.7558, longitude: 37.6173 };
const gridProfile = {
mode: "3d",
stepKm: 1,
tileSizeKm: 5,
graticuleStepDegrees: 1,
};
const primaryBinding = {
id: "positions",
order: 1,
dataProductId: "fleet.positions.current.v5",
slotId: "points",
delivery: "snapshot+patch",
semanticTypes: ["map.moving_object"],
fieldProjection: ["display_name", "signal_state", "position_source", "object_kind"],
presentationProfileId: "moving",
};
const joinedBinding = {
...primaryBinding,
id: "identity",
order: 2,
dataProductId: "fleet.units.identity.current.v1",
joinToBindingId: "positions",
};
const profile = {
id: "moving",
title: "Current positions",
semanticTypes: ["map.moving_object"],
label: { mode: "attributes", fields: ["display_name"], maxLength: 80 },
target: { variant: "elevated-spike" },
facets: [{
id: "signal",
field: "signal_state",
filterable: true,
counter: true,
values: [
{ value: "active", label: "Online", order: 1 },
{ value: "inactive", label: "Offline", order: 2 },
],
}],
styles: [{ id: "active", color: "#fff", opacity: 1 }],
classes: [{ id: "active", label: "Online", priority: 1, match: [], styleId: "active", renderable: true }],
defaultClassId: "active",
sort: [{ field: "signal_state", order: ["active", "inactive"] }],
};
function pointFact(sourceId, signalState, coordinates, displayName = sourceId) {
return {
sourceId,
semanticType: "map.moving_object",
observedAt: "2026-08-09T12:00:00.000Z",
receivedAt: "2026-08-09T12:00:01.000Z",
attributes: {
display_name: displayName,
signal_state: signalState,
position_source: "gelios",
object_kind: "tracked_unit",
},
geometry: { type: "Point", coordinates },
presentationStatus: signalState,
};
}
const inactive = pointFact("unit-offline", "inactive", [37.6173, 55.7558], "Альфа");
const active = pointFact("unit-online", "active", [37.618, 55.756], "Янтарь");
const runtimeBindings = [{
bindingId: "positions",
dataProductId: primaryBinding.dataProductId,
slotId: "points",
presentationProfileId: "moving",
facts: [inactive, active],
cursor: "1",
state: "ready",
}, {
bindingId: "identity",
dataProductId: joinedBinding.dataProductId,
slotId: "subject-aspect",
presentationProfileId: "moving",
facts: [active],
cursor: "1",
state: "ready",
}];
test("stable runtime identity uses binding, semantic type and source id only", () => {
const moved = { ...active, geometry: { type: "Point", coordinates: [40, 60] } };
assert.equal(mapRuntimeFactKey(active), "map.moving_object\u0000unit-online");
assert.equal(mapRuntimeEntityId("positions", active), mapRuntimeEntityId("positions", moved));
assert.equal(mapRuntimeEntityId("positions", active), "nodedc-runtime:positions:map.moving_object:unit-online");
});
test("joined aspects enrich a primary subject and never become map entities", () => {
assert.deepEqual(
primaryMapRuntimeBindings(runtimeBindings, [joinedBinding, primaryBinding]).map((binding) => binding.bindingId),
["positions"],
);
assert.deepEqual(
buildSelectableMapEntities(runtimeBindings, [joinedBinding, primaryBinding], [profile]).map((entity) => entity.fact.sourceId),
["unit-online", "unit-offline"],
);
});
test("object overview preserves the same MCP profile sort as facets and renderer", () => {
const filters = { positions: { visible: true, facets: {} } };
const selectable = buildSelectableMapEntities(runtimeBindings, [primaryBinding, joinedBinding], [profile]);
const targets = buildFilteredMapTargets(runtimeBindings, [primaryBinding, joinedBinding], [profile], filters);
// Alphabetical title order would be Альфа, Янтарь. Declared state order is
// active, inactive and must remain authoritative everywhere.
assert.deepEqual(selectable.map((entity) => entity.title), ["Янтарь", "Альфа"]);
assert.deepEqual(targets.map((target) => target.title), ["Янтарь", "Альфа"]);
});
test("explicit empty facets remain match-nothing through workspace projection", () => {
const subjectStates = {
positions: {
bindingId: "positions",
visible: true,
filters: { signal_state: [] },
window: { open: false, rect: { x: 0, y: 0, width: 280, height: 260 }, maximized: false, zIndex: 20 },
},
};
const filters = buildMapPresentationFilters(subjectStates, [primaryBinding], [profile]);
assert.deepEqual(filters.positions.facets, { signal_state: [] });
assert.deepEqual(buildFilteredMapTargets(runtimeBindings, [primaryBinding], [profile], filters), []);
});
test("search reveal removes only constraints hiding the selected subject", () => {
const address = localSectorAtGeodetic(origin, {
lod: 1,
originLatitude: origin.latitude,
originLongitude: origin.longitude,
stepMeters: 1_000,
});
const selectedSector = {
...localSectorSummary(address, {
lod: 1,
originLatitude: origin.latitude,
originLongitude: origin.longitude,
stepMeters: 1_000,
tileSizeMeters: 5_000,
}),
mode: "3d",
units: "meters-enu",
volume: null,
};
const entity = buildSelectableMapEntities(runtimeBindings, [primaryBinding], [profile])
.find((candidate) => candidate.fact.sourceId === "unit-offline");
const subjectState = {
bindingId: "positions",
visible: false,
filters: { signal_state: ["active"] },
window: { open: true, rect: { x: 10, y: 20, width: 280, height: 260 }, maximized: false, zIndex: 42 },
};
const reveal = planMapSubjectReveal({
entity,
subjectState,
profile,
selectedSector,
gridProfiles: [gridProfile],
origin,
hideOutsideSector: true,
scope: {
excludedBindingIds: ["positions", "other"],
excludedProviders: ["gelios", "other"],
excludedObjectKinds: ["tracked_unit", "other"],
},
});
assert.equal(reveal.subjectState.visible, true);
assert.deepEqual(reveal.subjectState.filters, {});
assert.deepEqual(reveal.subjectState.window, subjectState.window);
assert.equal(reveal.hideOutsideSector, true);
assert.deepEqual(reveal.scope, {
excludedBindingIds: ["other"],
excludedProviders: ["other"],
excludedObjectKinds: ["other"],
});
});
test("search keeps the active sector but relaxes outside clipping for an outside subject", () => {
const address = localSectorAtGeodetic(origin, {
lod: 1,
originLatitude: origin.latitude,
originLongitude: origin.longitude,
stepMeters: 1_000,
});
const selectedSector = {
...localSectorSummary(address, {
lod: 1,
originLatitude: origin.latitude,
originLongitude: origin.longitude,
stepMeters: 1_000,
tileSizeMeters: 5_000,
}),
mode: "3d",
units: "meters-enu",
volume: null,
};
const outsideEntity = {
...buildSelectableMapEntities(runtimeBindings, [primaryBinding], [profile])[0],
fact: pointFact("far-away", "active", [50, 60]),
};
const subjectState = {
bindingId: "positions",
visible: true,
filters: {},
window: { open: false, rect: { x: 0, y: 0, width: 280, height: 260 }, maximized: false, zIndex: 20 },
};
const reveal = planMapSubjectReveal({
entity: outsideEntity,
subjectState,
profile,
selectedSector,
gridProfiles: [gridProfile],
origin,
hideOutsideSector: true,
scope: { excludedBindingIds: [], excludedProviders: [], excludedObjectKinds: [] },
});
assert.equal(reveal.hideOutsideSector, false);
assert.equal(selectedSector.id, reveal ? selectedSector.id : null);
});
+102
View File
@@ -0,0 +1,102 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createMapWorkspaceState, mapWorkspaceReducer } from "../apps/catalog/src/mapWorkspaceState.mjs";
const rect = (x, y, width = 280, height = 260) => ({ x, y, width, height });
const subjectStates = {
positions: {
bindingId: "positions",
visible: true,
filters: {},
window: { open: false, rect: rect(24, 56), maximized: false, zIndex: 20 },
},
zones: {
bindingId: "zones",
visible: true,
filters: {},
window: { open: false, rect: rect(52, 84), maximized: false, zIndex: 21 },
},
};
function initialState() {
return createMapWorkspaceState({
subjectStates: structuredClone(subjectStates),
sectorWindowRect: rect(24, 72, 380, 530),
subjectCardRect: rect(940, 72, 390, 520),
});
}
test("one reducer owns the complete floating-window stack", () => {
const opened = mapWorkspaceReducer(initialState(), { type: "open-subject-window", bindingId: "positions" });
assert.equal(opened.subjectStates.positions.window.open, true);
assert.equal(opened.activeWindowId, "binding:positions");
assert.ok(opened.subjectStates.positions.window.zIndex > opened.sector.window.zIndex);
const activatedCard = mapWorkspaceReducer(opened, {
type: "select-entity",
entityId: "nodedc-runtime:positions:map.moving_object:unit-1",
validTabIds: ["overview", "position"],
defaultTabId: "overview",
});
assert.equal(activatedCard.subjectCard.open, true);
assert.equal(activatedCard.activeWindowId, "subject-card");
assert.ok(activatedCard.subjectCard.zIndex > opened.subjectStates.positions.window.zIndex);
// Re-activating the active window is stable and does not inflate z-index.
assert.equal(
mapWorkspaceReducer(activatedCard, { type: "activate-window", windowId: "subject-card" }),
activatedCard,
);
});
test("selecting a domain subject never clears the active grid sector", () => {
const sector = { id: "grid/local/sector", lod: 2, mode: "3d" };
const withSector = mapWorkspaceReducer(initialState(), { type: "select-sector", selection: sector });
const withSubject = mapWorkspaceReducer(withSector, {
type: "select-entity",
entityId: "nodedc-runtime:positions:map.moving_object:unit-1",
validTabIds: ["overview"],
defaultTabId: "overview",
});
assert.equal(withSubject.selectedSector, sector);
assert.equal(withSubject.selectedEntityId, "nodedc-runtime:positions:map.moving_object:unit-1");
});
test("sector deactivation clears only sector scope and keeps subject UI state", () => {
let state = mapWorkspaceReducer(initialState(), { type: "select-sector", selection: { id: "sector", lod: 1, mode: "3d" } });
state = mapWorkspaceReducer(state, { type: "set-sector-scope-enabled", dimension: "binding", value: "positions", enabled: false });
state = mapWorkspaceReducer(state, { type: "open-subject-window", bindingId: "positions" });
state = mapWorkspaceReducer(state, { type: "deactivate-sector" });
assert.equal(state.selectedSector, null);
assert.deepEqual(state.sector.scope, {
excludedBindingIds: [],
excludedProviders: [],
excludedObjectKinds: [],
});
assert.equal(state.subjectStates.positions.window.open, true);
assert.equal(state.subjectStates.positions.visible, true);
});
test("search reveal state is applied atomically without replacing window geometry", () => {
const state = initialState();
const originalWindow = state.subjectStates.positions.window;
const revealedSubject = {
...state.subjectStates.positions,
visible: true,
filters: { signal_state: ["inactive"] },
};
const next = mapWorkspaceReducer(state, {
type: "reveal-subject",
bindingId: "positions",
subjectState: revealedSubject,
hideOutsideSector: false,
scope: { excludedBindingIds: [], excludedProviders: ["other"], excludedObjectKinds: [] },
});
assert.deepEqual(next.subjectStates.positions.window, originalWindow);
assert.deepEqual(next.subjectStates.positions.filters, { signal_state: ["inactive"] });
assert.deepEqual(next.sector.scope.excludedProviders, ["other"]);
});
+1 -12
View File
@@ -30,17 +30,7 @@ test("exact editing is a stable native caret field without remount, forced selec
readFile(new URL("../packages/ui-react/src/Window.tsx", import.meta.url), "utf8"), readFile(new URL("../packages/ui-react/src/Window.tsx", import.meta.url), "utf8"),
]); ]);
assert.match(component, /exactValueBounds\?: \{/); assert.match(component, /normalizeEditedRangeValue\(parsed, min, max, step\)/);
assert.match(component, /exactValueBounds\?\.min \?\? Number\.NEGATIVE_INFINITY/);
assert.match(component, /exactValueBounds\?\.max \?\? Number\.POSITIVE_INFINITY/);
assert.match(component, /normalizeEditedRangeValue\(parsed, min, max, step, exactValueBounds\)/);
assert.match(component, /value=\{draft\}/);
assert.match(component, /data-contrast=\{editorContrast\}/);
assert.match(component, /fillRight >= textRight \? "fill" : "base"/);
assert.match(component, /new ResizeObserver\(updateContrast\)/);
const editorSource = component.match(/className="nodedc-range__editor"[\s\S]*?onBlur=\{\(\) => \{[\s\S]*?\n \}\}\n \/>/)?.[0] ?? "";
assert.notEqual(editorSource, "");
assert.doesNotMatch(editorSource, /onPointerDown=/);
assert.match(component, /event\.key === "Enter"/); assert.match(component, /event\.key === "Enter"/);
assert.match(component, /event\.key === "Escape"/); assert.match(component, /event\.key === "Escape"/);
assert.match(component, /event\.stopPropagation\(\)/); assert.match(component, /event\.stopPropagation\(\)/);
@@ -53,7 +43,6 @@ test("exact editing is a stable native caret field without remount, forced selec
assert.match(styles, /\.nodedc-range input\[type="range"\][\s\S]*?z-index: 4/); assert.match(styles, /\.nodedc-range input\[type="range"\][\s\S]*?z-index: 4/);
assert.match(styles, /\.nodedc-range__editor[\s\S]*?z-index: 5[\s\S]*?background: transparent[\s\S]*?color: transparent[\s\S]*?caret-color: transparent[\s\S]*?box-shadow: none/); assert.match(styles, /\.nodedc-range__editor[\s\S]*?z-index: 5[\s\S]*?background: transparent[\s\S]*?color: transparent[\s\S]*?caret-color: transparent[\s\S]*?box-shadow: none/);
assert.match(styles, /\.nodedc-range__editor\[data-active\][\s\S]*?caret-color: currentColor/); assert.match(styles, /\.nodedc-range__editor\[data-active\][\s\S]*?caret-color: currentColor/);
assert.match(styles, /\.nodedc-range__editor\[data-active\]\[data-contrast="fill"\][\s\S]*?rgb\(var\(--nodedc-on-accent-rgb\)\)/);
assert.match(windowComponent, /const onCloseRef = useRef\(onClose\)/); assert.match(windowComponent, /const onCloseRef = useRef\(onClose\)/);
assert.match(windowComponent, /onCloseRef\.current = onClose/); assert.match(windowComponent, /onCloseRef\.current = onClose/);
assert.match(windowComponent, /onCloseRef\.current\(\)/); assert.match(windowComponent, /onCloseRef\.current\(\)/);
-137
View File
@@ -1,137 +0,0 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
function lineNumber(lines, value) {
return lines.findIndex((line) => line.includes(value));
}
function assertLine(lines, value, message) {
const index = lineNumber(lines, value);
assert.ok(index >= 0, `${message} (expected near line 1+, missing: ${value})`);
return index + 1;
}
function extractBlock(lines, key) {
const start = lines.findIndex((line) => line.trim() === `${key} = """`);
assert.ok(start >= 0, `missing ${key} block start`);
let end = start + 1;
while (end < lines.length && lines[end] !== "\"\"\"") {
end += 1;
}
assert.ok(end < lines.length, `unterminated ${key} block`);
return lines.slice(start + 1, end).join("\n").replace(/\s+/g, " ").trim();
}
test("global spark agent configuration matches governance contract", async () => {
const configLines = (await readFile(new URL("../.codex/config.toml", import.meta.url), "utf8")).split(/\r?\n/);
const agentsLine = assertLine(configLines, "[agents]", "global config should declare [agents]");
const enabledLine = assertLine(configLines, 'enabled = true', "global config should enable agents");
const threadLine = assertLine(configLines, "max_concurrent_threads_per_session = 2", "global config should cap threads at 2");
const defaultModelLine = assertLine(configLines, 'default_subagent_model = "gpt-5.6-terra"', "global config should set default subagent model");
const effortLine = assertLine(configLines, 'default_subagent_reasoning_effort = "low"', "global config should set default low reasoning for unpinned subagents");
const interruptLine = assertLine(configLines, "interrupt_message = true", "global config should keep interruption messages enabled");
assert.equal(configLines[agentsLine - 1].trim(), "[agents]", "[agents] block should be present");
assert.equal(configLines[enabledLine - 1].trim(), 'enabled = true');
assert.equal(configLines[threadLine - 1].trim(), "max_concurrent_threads_per_session = 2");
assert.equal(configLines[defaultModelLine - 1].trim(), 'default_subagent_model = "gpt-5.6-terra"');
assert.equal(configLines[effortLine - 1].trim(), 'default_subagent_reasoning_effort = "low"');
assert.equal(configLines[interruptLine - 1].trim(), "interrupt_message = true");
});
test("spark_explorer contract", async () => {
const explorerLines = (await readFile(new URL("../.codex/agents/spark-explorer.toml", import.meta.url), "utf8")).split(/\r?\n/);
const instructions = extractBlock(explorerLines, "developer_instructions");
const nameLine = assertLine(explorerLines, 'name = "spark_explorer"', "explorer name should be spark_explorer");
const modelLine = assertLine(explorerLines, 'model = "gpt-5.3-codex-spark"', "explorer should pin model gpt-5.3-codex-spark");
const effortLine = assertLine(explorerLines, 'model_reasoning_effort = "medium"', "explorer should use medium reasoning");
const sandboxLine = assertLine(explorerLines, 'sandbox_mode = "read-only"', "explorer should use read-only sandbox mode");
assert.equal(explorerLines[nameLine - 1].trim(), 'name = "spark_explorer"');
assert.equal(explorerLines[modelLine - 1].trim(), 'model = "gpt-5.3-codex-spark"');
assert.equal(explorerLines[effortLine - 1].trim(), 'model_reasoning_effort = "medium"');
assert.equal(explorerLines[sandboxLine - 1].trim(), 'sandbox_mode = "read-only"');
assert.ok(
instructions.includes("Never edit, create, move, or delete files."),
"explorer instructions must prohibit edits",
);
assert.ok(
instructions.includes("Never spawn another agent."),
"explorer instructions must prohibit nested agents",
);
});
test("spark_worker contract", async () => {
const workerLines = (await readFile(new URL("../.codex/agents/spark-worker.toml", import.meta.url), "utf8")).split(/\r?\n/);
const instructions = extractBlock(workerLines, "developer_instructions");
const nameLine = assertLine(workerLines, 'name = "spark_worker"', "worker name should be spark_worker");
const modelLine = assertLine(workerLines, 'model = "gpt-5.3-codex-spark"', "worker should pin model gpt-5.3-codex-spark");
const effortLine = assertLine(workerLines, 'model_reasoning_effort = "medium"', "worker should use medium reasoning");
const sandboxLine = assertLine(workerLines, 'sandbox_mode = "workspace-write"', "worker should use workspace-write sandbox mode");
assert.equal(workerLines[nameLine - 1].trim(), 'name = "spark_worker"');
assert.equal(workerLines[modelLine - 1].trim(), 'model = "gpt-5.3-codex-spark"');
assert.equal(workerLines[effortLine - 1].trim(), 'model_reasoning_effort = "medium"');
assert.equal(workerLines[sandboxLine - 1].trim(), 'sandbox_mode = "workspace-write"');
assert.ok(
instructions.includes("exact allowed file"),
"worker instructions must require exact allowed path allowlist",
);
assert.ok(instructions.includes("Never commit, push, deploy, install dependencies, or change external systems."), "worker must not commit, push, deploy, or install");
assert.ok(instructions.includes("Never spawn another agent."), "worker must prohibit nested agents");
assert.ok(instructions.includes("One corrective retry is allowed after a failed check; then stop and return the failure evidence."), "worker must limit corrective retries");
});
test("AGENTS and governance contract documents", async () => {
const agentsLines = (await readFile(new URL("../AGENTS.md", import.meta.url), "utf8")).split(/\r?\n/);
const governanceLines = (await readFile(new URL("../docs/CODEX_SUBAGENT_GOVERNANCE.md", import.meta.url), "utf8")).split(/\r?\n/);
assert.ok(
agentsLines.some((line) => line.includes("`docs/CODEX_SUBAGENT_GOVERNANCE.md`")),
"AGENTS.md must point to governance document",
);
assert.ok(
agentsLines.some((line) => line.toLowerCase().includes("at most two subagents")),
"AGENTS.md should state max two subagents",
);
assert.ok(
agentsLines.some((line) => line.toLowerCase().includes("one write-capable")),
"AGENTS.md should state max one writer subagent",
);
assert.ok(
governanceLines.some((line) => line.includes("## Mandatory task packet")),
"governance should define mandatory task packet",
);
assert.ok(
governanceLines.some((line) => line.includes("1. Objective: one concrete outcome.")),
"governance should include mandatory objective field",
);
assert.ok(
governanceLines.some((line) => line.includes("2. Allowed scope: exact files, directories, or read-only tools.")),
"governance should include allowed scope field",
);
assert.ok(
governanceLines.some((line) => line.includes("3. Forbidden actions: especially external writes, commits, pushes, and deploys.")),
"governance should include forbidden actions field",
);
assert.ok(
governanceLines.some((line) => line.includes("4. Acceptance criteria: observable evidence of completion.")),
"governance should include acceptance criteria field",
);
assert.ok(
governanceLines.some((line) => line.includes("5. Verification: exact checks the worker may run.")),
"governance should include verification field",
);
assert.ok(
governanceLines.some((line) => line.includes("6. Output: a short structured summary, not raw logs.")),
"governance should include output field",
);
assert.ok(
governanceLines.some((line) => line.includes("Reviews the diff and performs final verification itself.")),
"governance should retain final primary review and verification ownership",
);
});
-63
View File
@@ -1,63 +0,0 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { SplitPane } from "../packages/ui-react/dist/index.js";
test("SplitPane exposes one controlled accessible separator", () => {
const markup = renderToStaticMarkup(createElement(SplitPane, {
primary: createElement("div", null, "Видео"),
secondary: createElement("div", null, "3D"),
primarySize: 64,
onPrimarySizeChange: () => {},
minPrimarySize: 25,
minSecondarySize: 30,
separatorLabel: "Изменить ширину представлений",
}));
assert.match(markup, /class="nodedc-split-pane"/);
assert.match(markup, /role="separator"/);
assert.match(markup, /aria-orientation="vertical"/);
assert.match(markup, /aria-valuemin="25"/);
assert.match(markup, /aria-valuemax="70"/);
assert.match(markup, /aria-valuenow="64"/);
assert.match(markup, /aria-valuetext="64% \/ 36%"/);
});
test("SplitPane can keep panel ownership stable without an inactive separator", () => {
const markup = renderToStaticMarkup(createElement(SplitPane, {
primary: createElement("div", null, "Видео"),
secondary: createElement("div", null, "3D"),
primarySize: 100,
onPrimarySizeChange: () => {},
separatorLabel: "Изменить ширину представлений",
resizable: false,
}));
assert.match(markup, /data-pane="primary"/);
assert.match(markup, /data-pane="secondary"/);
assert.doesNotMatch(markup, /role="separator"/);
});
test("SplitPane is registered, documented, cataloged and keyboard-addressable", async () => {
const [component, styles, registrySource, docs, catalog] = await Promise.all([
readFile(new URL("../packages/ui-react/src/SplitPane.tsx", import.meta.url), "utf8"),
readFile(new URL("../packages/ui-core/styles.css", import.meta.url), "utf8"),
readFile(new URL("../registry/components.json", import.meta.url), "utf8"),
readFile(new URL("../docs/COMPONENTS.md", import.meta.url), "utf8"),
readFile(new URL("../apps/catalog/src/CatalogApp.tsx", import.meta.url), "utf8"),
]);
const registry = JSON.parse(registrySource);
const entry = registry.components.find((item) => item.id === "split-pane");
assert.ok(entry?.exports.includes("SplitPane"));
assert.deepEqual(entry?.variants, ["vertical", "horizontal"]);
assert.match(component, /event\.key === "Home"/);
assert.match(component, /event\.key === "End"/);
assert.match(component, /setPointerCapture/);
assert.match(component, /activePointerIdRef\.current !== event\.pointerId/);
assert.match(component, /role="separator"/);
assert.match(styles, /\.nodedc-split-pane__separator:focus-visible::before/);
assert.match(docs, /## SplitPane/);
assert.match(catalog, /<SplitPane/);
});
-18
View File
@@ -1,18 +0,0 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const source = readFileSync(
new URL("../packages/ui-react/src/Toast.tsx", import.meta.url),
"utf8",
);
test("toast items own independent ten-second terminal lifetimes", () => {
assert.match(source, /const DEFAULT_TOAST_DURATION_MS = 10_000/);
assert.match(source, /function TimedToastCard/);
assert.match(source, /item\.tone === "loading" \? null : DEFAULT_TOAST_DURATION_MS/);
assert.match(source, /window\.setTimeout\(\(\) => dismissRef\.current\(item\.id\), duration\)/);
assert.match(source, /\[item\.durationMs, item\.id, item\.tone\]/);
assert.match(source, /items\.map\(\(item\) => <TimedToastCard key=\{item\.id\}/);
assert.doesNotMatch(source, /items\.flatMap/);
});