128 lines
5.2 KiB
TypeScript
128 lines
5.2 KiB
TypeScript
import {Dropdown,Icon,type IconName} from '@nodedc/ui-react';
|
|
export type ObservationSourceModality='point-cloud'|'video'|'image'|'depth';
|
|
export type ObservationSourceAvailability='unverified'|'declared'|'available'|'connecting'|'streaming'|'degraded'|'unavailable'|'error';
|
|
export interface SpatialSourceDescriptor {id:string;label:string;modality:ObservationSourceModality;availability:ObservationSourceAvailability;endpointLabel?:string|null;transport:string;previewUrl?:string|null;delivery?:unknown;activation?:{controllable:boolean}|null}
|
|
export const sourceIcon: Record<ObservationSourceModality, IconName> = {
|
|
"point-cloud": "globe",
|
|
video: "video",
|
|
image: "image",
|
|
depth: "image",
|
|
};
|
|
|
|
const availabilityCopy: Record<ObservationSourceAvailability, string> = {
|
|
unverified: "Не подтверждён",
|
|
declared: "Канал объявлен",
|
|
available: "Доступен",
|
|
connecting: "Подключение",
|
|
streaming: "Эфир",
|
|
degraded: "Нестабильно",
|
|
unavailable: "Недоступен",
|
|
error: "Ошибка",
|
|
};
|
|
|
|
export function observationSourceStatusLabel(source: SpatialSourceDescriptor): string {
|
|
return availabilityCopy[source.availability];
|
|
}
|
|
|
|
export function ObservationSourcePicker({
|
|
sources,
|
|
visibleSourceIds,
|
|
pendingSourceIds,
|
|
onToggle,
|
|
}: {
|
|
sources: readonly SpatialSourceDescriptor[];
|
|
visibleSourceIds: ReadonlySet<string>;
|
|
pendingSourceIds?: ReadonlySet<string>;
|
|
onToggle: (sourceId: string) => void | Promise<boolean>;
|
|
}) {
|
|
const visibleCount = sources.filter((source) => visibleSourceIds.has(source.id)).length;
|
|
|
|
return (
|
|
<Dropdown
|
|
className="scene-source-picker"
|
|
placement="bottom-start"
|
|
width={340}
|
|
offset={10}
|
|
surfaceRole="dialog"
|
|
surfaceClassName="observation-source-menu"
|
|
trigger={({ open, toggle, setAnchorRef, setTriggerRef, surfaceId }) => (
|
|
<button
|
|
ref={(node) => {
|
|
setAnchorRef(node);
|
|
setTriggerRef(node);
|
|
}}
|
|
type="button"
|
|
className="scene-source-picker__trigger"
|
|
data-active={open || visibleCount > 0 ? "true" : undefined}
|
|
aria-label="Источники данных сцены"
|
|
aria-haspopup="dialog"
|
|
aria-expanded={open}
|
|
aria-controls={surfaceId}
|
|
onClick={toggle}
|
|
>
|
|
<Icon name="database" size={17} />
|
|
{sources.length > 0 ? <span>{visibleCount}</span> : null}
|
|
</button>
|
|
)}
|
|
>
|
|
<header className="observation-source-menu__head">
|
|
<div>
|
|
<span className="section-eyebrow">НАБЛЮДЕНИЕ</span>
|
|
<strong>Источники данных</strong>
|
|
</div>
|
|
<small>{sources.length ? `${visibleCount} из ${sources.length}` : "нет источников"}</small>
|
|
</header>
|
|
{sources.length ? (
|
|
<div className="observation-source-menu__list">
|
|
{sources.map((source) => {
|
|
const selected = visibleSourceIds.has(source.id);
|
|
const pending = pendingSourceIds?.has(source.id) ?? false;
|
|
const canOpen = Boolean(
|
|
selected ||
|
|
source.modality === "point-cloud" ||
|
|
source.previewUrl ||
|
|
source.delivery ||
|
|
source.activation?.controllable,
|
|
);
|
|
return (
|
|
<button
|
|
key={source.id}
|
|
type="button"
|
|
className="nodedc-dropdown-option observation-source-option"
|
|
data-selected={selected ? "true" : undefined}
|
|
aria-pressed={selected}
|
|
disabled={pending || !canOpen}
|
|
onClick={() => void onToggle(source.id)}
|
|
>
|
|
<span className="nodedc-dropdown-option__icon">
|
|
<Icon name={sourceIcon[source.modality]} size={16} />
|
|
</span>
|
|
<span className="nodedc-dropdown-option__body">
|
|
<span className="nodedc-dropdown-option__label">{source.label}</span>
|
|
<span className="nodedc-dropdown-option__description">
|
|
<i data-availability={source.availability} aria-hidden="true" />
|
|
{pending ? "Переключение" : observationSourceStatusLabel(source)} · {source.endpointLabel || source.transport}
|
|
</span>
|
|
</span>
|
|
<span className="nodedc-dropdown-option__check">
|
|
{selected ? <Icon name="check" size={15} /> : null}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<div className="observation-source-menu__empty">
|
|
<Icon name="database" size={18} />
|
|
<strong>Каталог пока пуст</strong>
|
|
<span>Выберите профиль устройства — его плагин опубликует доступные каналы.</span>
|
|
</div>
|
|
)}
|
|
<footer className="observation-source-menu__foot">
|
|
Каналы приходят из активного контура или выбранной сохранённой сессии; сцена не знает
|
|
модель оборудования.
|
|
</footer>
|
|
</Dropdown>
|
|
);
|
|
}
|