feat(fleet): add board observation center with live sources and maps
Add adaptive per-vehicle layouts, full-panel dragging, source controls and automatic preview recovery for RealSense, Insta360 X4 and XGRIDS K1 observation views. Integrate cached Cesium map layers through the private NAS gateway link, retain bounded sensor command receipts, and document validation and operational handoff.
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import type {MapVisualSettings} from '../../core/map/mapView';
|
||||
import type {CesiumMapRendererProps} from '@nodedc/map-cesium-react';
|
||||
|
||||
export function mapPresentation(settings:MapVisualSettings):CesiumMapRendererProps['settings'] {
|
||||
return {
|
||||
atmosphere_enabled: settings.atmosphereEnabled,
|
||||
atmosphere_hue: settings.atmosphereHue,
|
||||
atmosphere_saturation:
|
||||
settings.atmosphereSaturation,
|
||||
atmosphere_brightness:
|
||||
settings.atmosphereBrightness,
|
||||
fog_enabled: settings.fogEnabled,
|
||||
fog_density: settings.fogDensity,
|
||||
sun_enabled: settings.sunEnabled,
|
||||
sun_hour: settings.sunHour,
|
||||
sun_intensity: settings.sunIntensity,
|
||||
shadows_enabled: settings.shadowsEnabled,
|
||||
monochrome_enabled: settings.monochromeEnabled,
|
||||
monochrome_color: settings.monochromeColor,
|
||||
imagery_brightness: settings.imageryBrightness,
|
||||
imagery_contrast: settings.imageryContrast,
|
||||
imagery_saturation: settings.imagerySaturation,
|
||||
imagery_gamma: settings.imageryGamma,
|
||||
imagery_hue: settings.imageryHue,
|
||||
imagery_alpha: settings.imageryAlpha,
|
||||
globe_color: settings.globeColor,
|
||||
background_color: settings.backgroundColor,
|
||||
terrain_exaggeration:
|
||||
settings.terrainExaggeration,
|
||||
buildings_color: settings.buildingsColor,
|
||||
buildings_opacity: settings.buildingsOpacity,
|
||||
buildings_maximum_screen_space_error:
|
||||
settings.buildingsMaximumScreenSpaceError,
|
||||
grid_lod_enabled: settings.gridLodEnabled,
|
||||
grid_height_meters: settings.gridHeightMeters,
|
||||
grid_lod_1_max_height_km:
|
||||
settings.gridLod1MaxHeightKm,
|
||||
grid_lod_1_step_km:
|
||||
settings.gridLod1StepKm,
|
||||
grid_lod_2_max_height_km:
|
||||
settings.gridLod2MaxHeightKm,
|
||||
grid_lod_2_step_km:
|
||||
settings.gridLod2StepKm,
|
||||
grid_lod_3_step_km:
|
||||
settings.gridLod3StepKm,
|
||||
grid_radius_km: settings.gridRadiusKm,
|
||||
grid_line_width: settings.gridLineWidth,
|
||||
grid_color: settings.gridColor,
|
||||
grid_opacity: settings.gridOpacity,
|
||||
grid_dots_enabled:
|
||||
settings.gridDotsEnabled,
|
||||
grid_dots_size: settings.gridDotsSize,
|
||||
grid_dots_color: settings.gridDotsColor,
|
||||
grid_dots_opacity:
|
||||
settings.gridDotsOpacity,
|
||||
camera_animation_enabled:
|
||||
settings.cameraAnimationEnabled,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export interface ObservationLayout {
|
||||
version:1; order:string[]; hidden:string[]; layers:Record<string,string>;
|
||||
splits:Record<string,number>; arrangement:'auto'|'columns'|'rows';
|
||||
}
|
||||
export const emptyObservationLayout=():ObservationLayout=>({version:1,order:[],hidden:[],layers:{},splits:{},arrangement:'auto'});
|
||||
const record=(value:unknown):Record<string,unknown>=>value!==null&&typeof value==='object'&&!Array.isArray(value)?value as Record<string,unknown>:{};
|
||||
export function decodeObservationLayout(value:unknown):ObservationLayout {
|
||||
const source=record(value),result=emptyObservationLayout();if(source.version!==1)return result;
|
||||
const ids=(value:unknown)=>Array.isArray(value)?[...new Set(value.filter((v):v is string=>typeof v==='string'&&v.length<512))].slice(0,128):[];
|
||||
result.order=ids(source.order);result.hidden=ids(source.hidden);
|
||||
result.layers=Object.fromEntries(Object.entries(record(source.layers)).filter(([k,v])=>k.length<512&&typeof v==='string'&&v.length<128).slice(0,128)) as Record<string,string>;
|
||||
result.splits=Object.fromEntries(Object.entries(record(source.splits)).filter(([k,v])=>k.length<4096&&typeof v==='number'&&Number.isFinite(v)).slice(0,256).map(([k,v])=>[k,Math.min(80,Math.max(20,v as number))]));
|
||||
if(source.arrangement==='rows'||source.arrangement==='columns')result.arrangement=source.arrangement;
|
||||
return result;
|
||||
}
|
||||
export function orderedObservationIDs(layout:ObservationLayout,available:string[]):string[]{
|
||||
const present=new Set(available);return [...layout.order.filter(id=>present.has(id)),...available.filter(id=>!layout.order.includes(id))];
|
||||
}
|
||||
export function moveObservation(layout:ObservationLayout,available:string[],from:string,to:string):ObservationLayout {
|
||||
const order=orderedObservationIDs(layout,available);const source=order.indexOf(from),target=order.indexOf(to);
|
||||
if(source<0||target<0||source===target)return layout;
|
||||
order.splice(source,1);order.splice(target,0,from);return {...layout,order};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type {SensorInventory, SensorTransport} from '../../../../../packages/sensor-ui/src/contracts';
|
||||
import {fleetRequest} from './useFleet';
|
||||
|
||||
export function createFleetSensorTransport(vehicleID:string):SensorTransport {
|
||||
const path=`/${encodeURIComponent(vehicleID)}/devices`;
|
||||
const inventory=(fleet:{items:{id:string;connectivity:string;enrollment:string;sensor_state:SensorInventory}[]})=>{
|
||||
const value=fleet.items.find(v=>v.id===vehicleID);
|
||||
if(!value)throw new Error('Аппарат не найден.');
|
||||
return {...value.sensor_state,fresh:value.connectivity==='online'&&value.enrollment==='paired'};
|
||||
};
|
||||
return {
|
||||
enrollment:{state:()=>fleetRequest(`${path}/enrollment`),submit:value=>fleetRequest(`${path}/enrollment/operations`,'POST',value),operation:id=>fleetRequest(`${path}/enrollment/operations/${encodeURIComponent(id)}`)},
|
||||
inventory:async()=>inventory(await fleetRequest()),
|
||||
subscribe:(receive,unavailable)=>{const events=new EventSource('/api/v1/fleet/events');events.onmessage=e=>{try{receive(inventory(JSON.parse(e.data)));}catch{unavailable();}};events.onerror=unavailable;return()=>events.close();},
|
||||
submit:value=>fleetRequest(`${path}/operations`,'POST',value),
|
||||
operation:id=>fleetRequest(`${path}/operations/${encodeURIComponent(id)}`),
|
||||
};
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export function BoardMonitorView({vehicle,name,back}:{vehicle:string;name:string
|
||||
{latest&&<p>Измерено на БК: {time(latest.at)}. {value?.storage==='ready'?'Локальная история записывается.':'Запись локальной истории недоступна.'} {value?.backlog?'История догружается в Mission Core.':''}</p>}
|
||||
{value?.database_bytes!=null&&<p>Архив на БК: {monitorValue(value.database_bytes,'bytes')} из {monitorValue(value.database_budget_bytes,'bytes')}. {value.archive_since?`Копия в Mission Core с ${time(value.archive_since)}.`:''}</p>}
|
||||
</SettingsCard>
|
||||
{!value?<ActivityIndicator label="Получаем мониторинг БК"/>:!value.available?<SettingsCard title="История БК пока не получена" description="Данные появятся после подключения бортового сборщика телеметрии."/>:<>
|
||||
{!value?<ActivityIndicator label="Получение мониторинга БК"/>:!value.available?<SettingsCard title="История БК пока не получена" description="Данные появятся после подключения бортового сборщика телеметрии."/>:<>
|
||||
<SettingsCard title="История нагрузки">
|
||||
<div className="board-monitor-controls">
|
||||
<Select label="Ресурс" value={group} options={groups.map(label=>({value:label,label}))} onChange={next=>{setGroup(next);const first=Object.entries(definitions).find(([,item])=>item.group===next);if(first)setMetric(first[0]);}}/>
|
||||
@@ -33,7 +33,7 @@ export function BoardMonitorView({vehicle,name,back}:{vehicle:string;name:string
|
||||
<p>{time(value.start)} — {time(value.end)} · интервал {value.bucket_seconds} с. Разрывы означают отсутствие измерений.</p>
|
||||
{!value.series.length&&<p>За выбранный период измерений нет.</p>}
|
||||
</SettingsCard>
|
||||
<SettingsCard title={`Последние измерения · ${group}`} description={latest?time(latest.at):'Ожидаем измерения'}>
|
||||
<SettingsCard title={`Последние измерения · ${group}`} description={latest?time(latest.at):'Ожидание измерений'}>
|
||||
<ResourceList aria-label="Показатели системы">{metrics.map(([key,item])=><li key={key}><ResourceRow title={`${item.resource} · ${item.label}`} description={latest?.values[key]==null?item.reason??'Счётчик недоступен':undefined} metadata={monitorValue(latest?.values[key],item.unit)}/></li>)}</ResourceList>
|
||||
</SettingsCard>
|
||||
<SettingsCard title="События системы и приложения" description="За выбранный период">
|
||||
|
||||
@@ -2,21 +2,10 @@ import {useMemo} from 'react';
|
||||
import {useDevicePluginHost} from '../../core/device-plugins/DevicePluginHost';
|
||||
import {createIsolatedRerunHost} from '../../components/rerun/isolatedRerunHost';
|
||||
import {SensorWorkspace} from '../../../../../packages/sensor-ui/src/SensorWorkspace';
|
||||
import type {SensorInventory,SensorTransport} from '../../../../../packages/sensor-ui/src/contracts';
|
||||
import {fleetRequest} from '../../core/fleet/useFleet';
|
||||
import {createFleetSensorTransport} from '../../core/fleet/sensorTransport';
|
||||
export function VehicleSensors({vehicleID,enabled,onDetailChange}:{vehicleID:string;enabled:boolean;onDetailChange:(open:boolean)=>void}){
|
||||
const {registry}=useDevicePluginHost();
|
||||
const sensorContributions=registry.sensorContributions;
|
||||
const transport=useMemo<SensorTransport>(()=>({
|
||||
enrollment:{
|
||||
state:()=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/enrollment`),
|
||||
submit:value=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/enrollment/operations`,'POST',value),
|
||||
operation:id=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/enrollment/operations/${encodeURIComponent(id)}`),
|
||||
},
|
||||
inventory:async()=>{const fleet=await fleetRequest<{items:{id:string;connectivity:string;sensor_state:SensorInventory}[]}>();const value=fleet.items.find(v=>v.id===vehicleID);if(!value)throw new Error('Аппарат не найден.');return {...value.sensor_state,fresh:value.connectivity==='online'};},
|
||||
subscribe:(receive,unavailable)=>{const events=new EventSource('/api/v1/fleet/events');events.onmessage=e=>{try{const value=JSON.parse(e.data).items.find((v:{id:string})=>v.id===vehicleID);if(value)receive({...value.sensor_state,fresh:value.connectivity==='online'});else unavailable();}catch{unavailable();}};events.onerror=unavailable;return()=>events.close();},
|
||||
submit:value=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/operations`,'POST',value),
|
||||
operation:id=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/operations/${encodeURIComponent(id)}`),
|
||||
}),[vehicleID]);
|
||||
const transport=useMemo(()=>createFleetSensorTransport(vehicleID),[vehicleID]);
|
||||
return <SensorWorkspace contributions={sensorContributions} createRerunHost={createIsolatedRerunHost} key={vehicleID} transport={transport} enabled={enabled} onDetailChange={onDetailChange}/>;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {BoardObservationCenter} from './observation/BoardObservationCenter';
|
||||
import {BoardMonitorView} from './BoardMonitorView';
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { LoadingRegion, Button, ConfirmationModal, Icon, IconButton, ResourceList, ResourceRow, Select, SettingsCard, StatusBadge, TextAreaField, TextField, Window, WindowFooterActions } from "@nodedc/ui-react";
|
||||
@@ -21,6 +22,7 @@ export function VehiclesWorkspace({ createRequest = 0 }: { createRequest?: numbe
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [sensorOpen, setSensorOpen] = useState(false);
|
||||
const [monitorOpen,setMonitorOpen]=useState(false);
|
||||
const [observationOpen,setObservationOpen]=useState(false);
|
||||
const [revoking, setRevoking] = useState<Vehicle | null>(null);
|
||||
const lastCreateRequest = useRef(createRequest);
|
||||
useEffect(() => { if (createRequest !== lastCreateRequest.current) { lastCreateRequest.current = createRequest; setAdding(true); setError(""); } }, [createRequest]);
|
||||
@@ -42,6 +44,7 @@ export function VehiclesWorkspace({ createRequest = 0 }: { createRequest?: numbe
|
||||
finally { setPending(false); }
|
||||
}
|
||||
const detail = fleet.items?.find(item => item.id === selected);
|
||||
if(detail&&observationOpen)return <BoardObservationCenter key={detail.id} vehicleID={detail.id} name={detail.name} enabled={!fleet.error&&detail.enrollment==="paired"&&detail.connectivity==="online"} back={()=>{setObservationOpen(false);setSensorOpen(false);}}/>;
|
||||
if(detail&&monitorOpen)return <BoardMonitorView vehicle={detail.id} name={detail.name} back={()=>setMonitorOpen(false)}/>;
|
||||
return <div className="fleet-workspace">
|
||||
{fleet.error && <p role="alert">{fleet.error}</p>}
|
||||
@@ -54,12 +57,13 @@ export function VehiclesWorkspace({ createRequest = 0 }: { createRequest?: numbe
|
||||
<div><dt>Последняя связь</dt><dd>{detail.last_seen ? new Date(detail.last_seen * 1000).toLocaleString("ru-RU") : "Соединение ещё не получено"}</dd></div>
|
||||
{detail.host && <><div><dt>Имя БК в системе</dt><dd>{detail.host.hostname}</dd></div><div><dt>Операционная система</dt><dd>{detail.host.os}</dd></div><div><dt>Архитектура</dt><dd>{detail.host.architecture}</dd></div><div><dt>Процессоры</dt><dd>{detail.host.cpus}</dd></div><div><dt>Память</dt><dd>{detail.host.memory_kib ? `${(detail.host.memory_kib / 1024 / 1024).toFixed(1)} ГиБ` : "Нет сведений"}</dd></div></>}
|
||||
</dl>
|
||||
<Button onClick={()=>setMonitorOpen(true)}><Icon name="activity"/>Мониторинг системы БК</Button>
|
||||
{detail.enrollment !== "revoked" && <Button onClick={() => setRevoking(detail)}>Отозвать привязку БК</Button>}
|
||||
<div className="fleet-board-actions"><Button onClick={()=>setMonitorOpen(true)}><Icon name="activity"/>Мониторинг системы БК</Button>
|
||||
<Button onClick={()=>setObservationOpen(true)}><Icon name="eye"/>Центр наблюдения</Button>
|
||||
{detail.enrollment !== "revoked" && <Button onClick={() => setRevoking(detail)}>Отозвать привязку БК</Button>}</div>
|
||||
</SettingsCard>}
|
||||
<SettingsCard title="Устройства аппарата"><VehicleSensors onDetailChange={setSensorOpen} vehicleID={detail.id} enabled={!fleet.error && detail.enrollment === "paired" && detail.connectivity === "online"} /></SettingsCard>
|
||||
</> : !fleet.items ? <LoadingRegion loading label="Получаем аппараты" /> : fleet.items.length === 0 ? <SettingsCard title="Аппаратов пока нет" description="Добавьте аппарат по приглашению из Mission Core Node на его бортовом компьютере."><Button onClick={() => setAdding(true)}>Добавить аппарат</Button></SettingsCard> : <ResourceList aria-label="Аппараты">{fleet.items.map(item => <li key={item.id}><ResourceRow icon={<Icon name="apps" />} title={item.name} description={`${platformLabel(item.platform)} · бортовой компьютер`} status={<StatusBadge tone={!fleet.error && item.enrollment === "paired" && item.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(item)}</StatusBadge>} actions={<IconButton label={`Конфигурация: ${item.name}`} onClick={() => setSelected(item.id)}><Icon name="eye" /></IconButton>} /></li>)}</ResourceList>}
|
||||
<Window open={adding} title="Добавить аппарат" subtitle="Подключить бортовой компьютер по приглашению Node" size="md" closeOnBackdrop={false} closeOnEscape={!pending} onClose={close} footer={<WindowFooterActions><Button disabled={pending} onClick={close}>Отмена</Button>{preview ? <Button disabled={pending || !name.trim()} onClick={() => void add()}>{pending ? "Добавляем…" : "Добавить аппарат"}</Button> : <Button type="submit" form="fleet-invitation" disabled={pending || !code.trim()}>{pending ? "Проверяем БК…" : "Проверить БК"}</Button>}</WindowFooterActions>}>
|
||||
</> : !fleet.items ? <LoadingRegion loading label="Получение аппаратов" /> : fleet.items.length === 0 ? <SettingsCard title="Аппаратов пока нет" description="Добавьте аппарат по приглашению из Mission Core Node на его бортовом компьютере."><Button onClick={() => setAdding(true)}>Добавить аппарат</Button></SettingsCard> : <ResourceList aria-label="Аппараты">{fleet.items.map(item => <li key={item.id}><ResourceRow icon={<Icon name="apps" />} title={item.name} description={`${platformLabel(item.platform)} · бортовой компьютер`} status={<StatusBadge tone={!fleet.error && item.enrollment === "paired" && item.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(item)}</StatusBadge>} actions={<><IconButton label={`Конфигурация: ${item.name}`} onClick={() => setSelected(item.id)}><Icon name="sliders" /></IconButton><IconButton label={`Центр наблюдения: ${item.name}`} onClick={() => {setSelected(item.id);setObservationOpen(true);}}><Icon name="eye" /></IconButton></>} /></li>)}</ResourceList>}
|
||||
<Window open={adding} title="Добавить аппарат" subtitle="Подключить бортовой компьютер по приглашению Node" size="md" closeOnBackdrop={false} closeOnEscape={!pending} onClose={close} footer={<WindowFooterActions><Button disabled={pending} onClick={close}>Отмена</Button>{preview ? <Button disabled={pending || !name.trim()} onClick={() => void add()}>{pending ? "Добавление…" : "Добавить аппарат"}</Button> : <Button type="submit" form="fleet-invitation" disabled={pending || !code.trim()}>{pending ? "Проверка БК…" : "Проверить БК"}</Button>}</WindowFooterActions>}>
|
||||
<form id="fleet-invitation" className="fleet-form" onSubmit={inspect} aria-busy={pending}>
|
||||
<Select label="Способ подключения" value="node" options={[{ value: "node", label: "С бортовым компьютером" }]} onChange={() => undefined} disabled={pending} />
|
||||
<Select label="Класс аппарата" value={platform} options={platforms} onChange={setPlatform} disabled={pending} />
|
||||
|
||||
@@ -7,3 +7,7 @@
|
||||
.fleet-facts { font-size: var(--nodedc-font-size-sm); line-height: 1.5; }
|
||||
.fleet-facts dt { color: var(--nodedc-text-secondary); }
|
||||
.fleet-facts dd { color: var(--nodedc-text-muted); }
|
||||
|
||||
.fleet-board-actions { display:flex; gap:var(--nodedc-space-3); }
|
||||
.fleet-board-actions > * { flex:1; }
|
||||
@media(max-width:600px) { .fleet-board-actions { flex-wrap:wrap; } }
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import {useEffect,useLayoutEffect,useMemo,useRef,useState} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import {Button,DragDropRoot,DraggableItem,DropZone,GlassSurface,Icon,IconButton,LoadingRegion,Select,StatusBadge,Switch,Window} from '@nodedc/ui-react';
|
||||
import {useDevicePluginHost} from '../../../core/device-plugins/DevicePluginHost';
|
||||
import {createFleetSensorTransport} from '../../../core/fleet/sensorTransport';
|
||||
import {decodeObservationLayout,emptyObservationLayout,moveObservation,orderedObservationIDs} from '../../../core/fleet/observationLayout';
|
||||
import {createIsolatedRerunHost} from '../../../components/rerun/isolatedRerunHost';
|
||||
import {CameraObservation} from '../../../../../../packages/sensor-ui/src/CameraObservation';
|
||||
import {cameraObservationViews,observationKey,type SensorObservationView,type ObservationHeaderTargets} from '../../../../../../packages/sensor-ui/src/observation';
|
||||
import {ObservationDeck,ObservationMount} from './ObservationDeck';
|
||||
import {ObservationMap} from './ObservationMap';
|
||||
import {useObservationInventory} from './useObservationInventory';
|
||||
import './observation.css';
|
||||
|
||||
export function BoardObservationCenter({vehicleID,name,enabled,back}:{vehicleID:string;name:string;enabled:boolean;back:()=>void}){
|
||||
const {registry}=useDevicePluginHost(),transport=useMemo(()=>createFleetSensorTransport(vehicleID),[vehicleID]);
|
||||
const inventory=useObservationInventory(transport),storageKey=`missioncore.observation.v1:${vehicleID}`;
|
||||
const [layout,setLayout]=useState(()=>{try{return decodeObservationLayout(JSON.parse(localStorage.getItem(storageKey)??'null'));}catch{return emptyObservationLayout();}});
|
||||
const [layersOpen,setLayersOpen]=useState(false),[focused,setFocused]=useState<string|null>(null);
|
||||
const [full,setFull]=useState<string|null>(null),[expanded,setExpanded]=useState(false);
|
||||
const frame=useRef<HTMLDivElement>(null),[portal]=useState(()=>document.createElement('div'));
|
||||
useLayoutEffect(()=>{(expanded?document.body:frame.current!).append(portal);return()=>portal.remove();},[expanded,portal]);
|
||||
const mounts=useRef(new Map<string,HTMLElement>()),media=useRef(new Map<string,HTMLElement>()),headers=useRef(new Map<string,ObservationHeaderTargets>());
|
||||
const groups=(inventory?.items??[]).flatMap(device=>{
|
||||
const contribution=registry.sensorContributions.find(item=>item.kind===device.kind)?.observation;
|
||||
const views=contribution?.views(device)??cameraObservationViews(device);
|
||||
return views.length?[{device,views,Session:contribution?.Session??CameraObservation}]:[];
|
||||
});
|
||||
const sources:{key:string;deviceID?:string;view:SensorObservationView}[]=groups.flatMap(group=>group.views.map(view=>({key:observationKey(group.device.id,view.id),deviceID:group.device.id,view})));
|
||||
sources.push({key:'map',view:{id:'map',label:'Карта'}});
|
||||
for(const source of sources){if(!mounts.current.has(source.key)){mounts.current.set(source.key,document.createElement('div'));media.current.set(source.key,document.createElement('div'));headers.current.set(source.key,{statusTarget:document.createElement('div'),actionsTarget:document.createElement('div')});mounts.current.get(source.key)!.className='observation-panel-mount';media.current.get(source.key)!.className='observation-media-mount';}}
|
||||
const ids=orderedObservationIDs(layout,sources.map(source=>source.key)),visible=ids.filter(id=>!layout.hidden.includes(id));
|
||||
const effectiveFull=full&&visible.includes(full)?full:null;
|
||||
const active=sources.find(source=>source.key===focused);
|
||||
useEffect(()=>{try{localStorage.setItem(storageKey,JSON.stringify(layout));}catch{/* The workspace remains usable without browser storage. */}},[storageKey,layout]);
|
||||
useEffect(()=>{const escape=(event:KeyboardEvent)=>{if(event.key!=='Escape'||layersOpen||document.querySelector('[role=dialog], [role=listbox]'))return;if(effectiveFull){setFull(null);event.stopImmediatePropagation();}else if(expanded){setExpanded(false);event.stopImmediatePropagation();}};window.addEventListener('keydown',escape,true);return()=>window.removeEventListener('keydown',escape,true);},[effectiveFull,expanded,layersOpen]);
|
||||
const setVisible=(id:string,show:boolean)=>setLayout(value=>({...value,hidden:show?value.hidden.filter(key=>key!==id):[...new Set([...value.hidden,id])]}));
|
||||
const layerFor=(key:string,view:SensorObservationView)=>view.layers?.find(layer=>layer.value===layout.layers[key])?.value??view.layers?.[0]?.value??view.id;
|
||||
return <><div ref={frame}/>{createPortal(<section className={`observation-center${expanded?' observation-center--expanded':''}`} aria-label={`Центр наблюдения · ${name}`}>
|
||||
<header className="observation-toolbar"><div><h2>Центр наблюдения</h2><span>{name}</span></div><div className="observation-actions"><Button onClick={back}><Icon name="sliders"/>Конфигуратор</Button><Button onClick={()=>{setFocused(null);setLayersOpen(true);}}><Icon name="grid"/>Доступные слои</Button><IconButton label={expanded?'Восстановить размер центра':'Развернуть центр наблюдения'} onClick={()=>setExpanded(value=>!value)}><Icon name={expanded?'minimize':'expand'}/></IconButton></div></header>
|
||||
<div className="observation-summary"><StatusBadge tone={enabled&&inventory&&inventory.fresh!==false?'success':'neutral'}>{!inventory?'Получение источников':!enabled||inventory.fresh===false?'Нет свежих данных с БК':`Устройства: ${groups.length} · Окна: ${visible.length}`}</StatusBadge>{effectiveFull&&<Button onClick={()=>setFull(null)}>Все окна</Button>}</div>
|
||||
<DragDropRoot onDragEnd={({activeId,overId})=>{if(overId)setLayout(value=>moveObservation(value,ids,activeId,overId));}}>
|
||||
<div className="observation-deck">{!inventory?<LoadingRegion loading label="Получение визуальных источников"/>:visible.length?<ObservationDeck ids={effectiveFull?[effectiveFull]:visible} mounts={mounts.current} layout={layout} onSplit={(key,value)=>setLayout(current=>({...current,splits:{...current.splits,[key]:value}}))}/>:<div className="observation-empty"><p>Все окна скрыты.</p><Button onClick={()=>{setFocused(null);setLayersOpen(true);}}>Доступные слои</Button></div>}</div>
|
||||
{sources.filter(source=>visible.includes(source.key)).map(source=>createPortal(
|
||||
<DropZone id={source.key} className="observation-panel">
|
||||
<DraggableItem id={source.key} className="observation-panel-drag">{({handle})=>
|
||||
<GlassSurface tone="soft" radius="panel" padding="none" materialRim={false} className="observation-panel__surface">
|
||||
<header className="observation-panel__header">
|
||||
{handle}
|
||||
<div className="observation-panel__title"><h3 title={source.view.label}>{source.view.label}</h3><ObservationMount className="observation-header-slot" element={headers.current.get(source.key)!.statusTarget}/></div>
|
||||
<div className="observation-actions">
|
||||
{source.view.layers&&<Select className="observation-layer-select" label={`Слой · ${source.view.label}`} value={layerFor(source.key,source.view)} options={source.view.layers} onChange={value=>setLayout(current=>({...current,layers:{...current.layers,[source.key]:value}}))} variant="inline"/>}
|
||||
<ObservationMount className="observation-header-slot" element={headers.current.get(source.key)!.actionsTarget}/>
|
||||
{source.key!=='map'&&<IconButton label={`Настройки · ${source.view.label}`} onClick={()=>{setFocused(source.key);setLayersOpen(true);}}><Icon name="settings"/></IconButton>}
|
||||
<IconButton label={`${effectiveFull===source.key?'Восстановить':'Развернуть'} · ${source.view.label}`} onClick={()=>setFull(value=>value===source.key?null:source.key)}><Icon name={effectiveFull===source.key?'minimize':'expand'}/></IconButton>
|
||||
<IconButton label={`Скрыть · ${source.view.label}`} onClick={()=>setVisible(source.key,false)}><Icon name="close"/></IconButton>
|
||||
</div>
|
||||
</header>
|
||||
<ObservationMount element={media.current.get(source.key)!}/>
|
||||
</GlassSurface>
|
||||
}</DraggableItem>
|
||||
</DropZone>,mounts.current.get(source.key)!,source.key))}
|
||||
</DragDropRoot>
|
||||
{groups.map(({device,views,Session})=>visible.some(key=>views.some(view=>key===observationKey(device.id,view.id)))&&<Session key={device.id} device={device} transport={transport} enabled={enabled&&inventory?.fresh!==false&&device.online} createRerunHost={createIsolatedRerunHost} views={views.map(view=>({id:view.id,layer:layerFor(observationKey(device.id,view.id),view),target:media.current.get(observationKey(device.id,view.id))!,...headers.current.get(observationKey(device.id,view.id))!}))}/>)}
|
||||
{visible.includes('map')&&createPortal(<ObservationMap vehicleID={vehicleID} header={headers.current.get('map')!}/>,media.current.get('map')!,'map-session')}
|
||||
<Window open={layersOpen} onClose={()=>setLayersOpen(false)} title={active?active.view.label:'Доступные слои'} size="md"><div className="observation-settings">
|
||||
{!active&&<Select label="Расположение окон" value={layout.arrangement} options={[{value:'auto',label:'Автоматически'},{value:'columns',label:'В ряд'},{value:'rows',label:'Друг под другом'}]} onChange={value=>setLayout(current=>({...current,arrangement:value as typeof layout.arrangement}))}/>}
|
||||
{ids.filter(id=>!active||id===active.key).map(id=>{const source=sources.find(item=>item.key===id)!;return <div className="observation-layer" key={id}><Switch label={source.view.label} checked={visible.includes(id)} onChange={value=>setVisible(id,value)}/><Select label={`Позиция · ${source.view.label}`} value={String(ids.indexOf(id))} options={ids.map((_,i)=>({value:String(i),label:`Окно ${i+1}`}))} onChange={value=>setLayout(current=>moveObservation(current,ids,id,ids[Number(value)]))}/></div>;})}
|
||||
{active?.view.layers&&<Select label="Слой источника" value={layerFor(active.key,active.view)} options={active.view.layers} onChange={value=>setLayout(current=>({...current,layers:{...current.layers,[active.key]:value}}))}/>}
|
||||
<Button onClick={()=>{setLayout(emptyObservationLayout());setFull(null);}}>Восстановить расположение</Button>
|
||||
</div></Window>
|
||||
</section>,portal)}</>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import {useLayoutEffect,useRef} from 'react';
|
||||
import {SplitPane} from '@nodedc/ui-react';
|
||||
import type {ObservationLayout} from '../../../core/fleet/observationLayout';
|
||||
|
||||
/** Moving a mount preserves media elements, peer lifetimes and native renderer state. */
|
||||
export function ObservationMount({element,className='observation-mount'}:{element:HTMLElement;className?:string}){
|
||||
const ref=useRef<HTMLDivElement>(null);
|
||||
useLayoutEffect(()=>{const host=ref.current!;host.append(element);return()=>{if(element.parentElement===host)element.remove();};},[element]);
|
||||
return <div ref={ref} className={className}/>;
|
||||
}
|
||||
export function ObservationDeck({ids,mounts,layout,onSplit,depth=0}:{ids:string[];mounts:Map<string,HTMLElement>;layout:ObservationLayout;onSplit:(key:string,value:number)=>void;depth?:number}){
|
||||
if(!ids.length)return null;
|
||||
if(ids.length===1)return <ObservationMount element={mounts.get(ids[0])!}/>;
|
||||
const mid=Math.ceil(ids.length/2),key=JSON.stringify(ids),orientation=layout.arrangement==='rows'?'horizontal':layout.arrangement==='columns'?'vertical':depth%2?'horizontal':'vertical';
|
||||
return <SplitPane className="observation-split" orientation={orientation} primarySize={layout.splits[key]??50} onPrimarySizeChange={value=>onSplit(key,value)} separatorLabel="Изменить размеры окон" primary={<ObservationDeck ids={ids.slice(0,mid)} mounts={mounts} layout={layout} onSplit={onSplit} depth={depth+1}/>} secondary={<ObservationDeck ids={ids.slice(mid)} mounts={mounts} layout={layout} onSplit={onSplit} depth={depth+1}/>}/>;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import {useMemo,useRef,useState} from 'react';
|
||||
import {CesiumMapRenderer,initialMapRuntimeState,type CesiumMapRendererHandle,type MapCamera} from '@nodedc/map-cesium-react';
|
||||
import {Button,Icon,IconButton,Switch,Window} from '@nodedc/ui-react';
|
||||
import {ObservationHeader} from '../../../../../../packages/sensor-ui/src/ObservationHeader';
|
||||
import type {ObservationHeaderTargets} from '../../../../../../packages/sensor-ui/src/observation';
|
||||
import {defaultMapViewDocument} from '../../../core/map/mapView';
|
||||
import {mapPresentation} from '../../../components/map/mapPresentation';
|
||||
|
||||
export function ObservationMap({vehicleID,header}:{vehicleID:string;header:ObservationHeaderTargets}){
|
||||
const defaults=useMemo(()=>defaultMapViewDocument().view,[]),renderer=useRef<CesiumMapRendererHandle>(null);
|
||||
const [settings,setSettings]=useState(false),[state,setState]=useState(initialMapRuntimeState),[generation,setGeneration]=useState(0);
|
||||
const storageKey=`missioncore.observation-map.v1:${vehicleID}`;
|
||||
const [camera]=useState(()=>{try{const value=JSON.parse(localStorage.getItem(storageKey)??'null') as MapCamera|null;return value&&['longitude','latitude','height','heading','pitch','roll'].every(key=>Number.isFinite(value[key as keyof MapCamera]))?value:defaults.camera;}catch{return defaults.camera;}});
|
||||
const [layers,setLayers]=useState({...defaults.layerVisibility,grid:false,targets:false});
|
||||
const ready=state.phase==='ready';
|
||||
const available=Object.values(state.providers).some(provider=>provider.phase==='ready');
|
||||
return <div className="observation-source">
|
||||
<ObservationHeader targets={header} live={ready} label={ready?'Карта доступна':state.phase==='loading'?'Загрузка карты':state.phase==='degraded'?'Часть слоёв недоступна':'Карта недоступна'}><IconButton label="Слои карты" onClick={()=>setSettings(true)}><Icon name="layers"/></IconButton></ObservationHeader>
|
||||
<div className="observation-source__media observation-map" data-available={available}><CesiumMapRenderer key={generation} ref={renderer} runtimeConfigUrl="/api/v1/map/runtime-config" camera={camera} layers={layers} settings={mapPresentation(defaults.visualSettings)} cacheIntent={{enabled:true,no_overwrite:true}} onRuntimeStateChange={setState} onCameraChange={value=>{try{localStorage.setItem(storageKey,JSON.stringify(value));}catch{/* Browser storage may be unavailable. */}}}/></div>
|
||||
{!available&&state.phase!=='loading'&&<div className="observation-source__notice"><p>Не удалось загрузить картографические слои.</p><Button onClick={()=>setGeneration(v=>v+1)}>Повторить загрузку карты</Button></div>}
|
||||
<Window open={settings} onClose={()=>setSettings(false)} title="Слои карты" size="sm">
|
||||
<div className="observation-settings">{([{id:'imagery',name:'Спутниковые снимки'},{id:'terrain',name:'Рельеф'},{id:'buildings',name:'Здания OSM'}] as const).map(item=><Switch key={item.id} label={item.name} checked={layers[item.id]} onChange={value=>setLayers(current=>({...current,[item.id]:value}))}/>)}
|
||||
</div>
|
||||
</Window>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
.observation-center { display:flex; flex-direction:column; gap:var(--nodedc-space-3); padding:var(--nodedc-space-4); min-width:0; height:max(600px, calc(100dvh - 260px)); }
|
||||
.observation-center--expanded { position:fixed; inset:0; z-index:var(--nodedc-layer-overlay); background:var(--nodedc-canvas); height:100dvh; box-sizing:border-box; }
|
||||
.observation-toolbar, .observation-summary, .observation-actions { display:flex; align-items:center; gap:var(--nodedc-space-2); }
|
||||
.observation-toolbar { justify-content:space-between; flex-wrap:wrap; }
|
||||
.observation-toolbar h2, .observation-panel h3 { margin:0; }
|
||||
.observation-toolbar span { color:var(--nodedc-text-muted); font-size:var(--nodedc-font-size-sm); }
|
||||
.observation-actions { flex-shrink:0; }
|
||||
.observation-deck { flex:1; min-height:0; min-width:0; }
|
||||
.observation-mount, .observation-panel-mount, .observation-media-mount, .observation-panel, .observation-split { height:100%; min-height:0; min-width:0; }
|
||||
.observation-panel__surface { display:flex; flex-direction:column; height:100%; overflow:hidden; container-type:inline-size; }
|
||||
.observation-panel__surface > .observation-mount { flex:1; }
|
||||
.observation-panel__header { display:flex; align-items:center; gap:var(--nodedc-space-2); padding:var(--nodedc-space-2); flex-shrink:0; }
|
||||
.observation-panel__header h3 { min-width:0; font-size:var(--nodedc-font-size-sm); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.observation-source { position:relative; display:flex; flex-direction:column; height:100%; min-height:0; }
|
||||
.observation-panel__title { display:flex; align-items:center; gap:var(--nodedc-space-2); flex:1; min-width:0; }
|
||||
.observation-header-slot, .observation-header-slot > div { display:flex; align-items:center; gap:var(--nodedc-space-2); flex-shrink:0; }
|
||||
.observation-header-slot:has(> div:empty) { display:none; }
|
||||
.observation-layer-select { min-width:0; max-width:120px; }
|
||||
.observation-layer-select button { max-width:100%; }
|
||||
.observation-layer-select button > span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.observation-source__media { position:relative; flex:1; min-height:0; overflow:hidden; display:flex; align-items:center; justify-content:center; }
|
||||
.observation-source__media video, .observation-source__media canvas { width:100%; height:100%; object-fit:contain; }
|
||||
.observation-source__media[data-live="false"] > *, .observation-source__media [data-live="false"] { visibility:hidden; }
|
||||
.observation-source__notice { position:absolute; inset:30% var(--nodedc-space-4) auto; text-align:center; color:var(--nodedc-text-secondary); font-size:calc(var(--nodedc-font-size-lg) * .7); }
|
||||
.observation-rerun, .observation-rerun > div, .observation-map > div { width:100%; height:100%; }
|
||||
.observation-settings { display:flex; flex-direction:column; gap:var(--nodedc-space-4); }
|
||||
.observation-layer { display:grid; grid-template-columns:minmax(0, 1fr) 50%; align-items:center; gap:var(--nodedc-space-3); }
|
||||
.observation-layer > * { min-width:0; width:100%; }
|
||||
.observation-layer > .nodedc-switch { text-align:left; }
|
||||
.observation-empty { display:grid; place-content:center; height:100%; text-align:center; }
|
||||
.observation-decoder { position:absolute; width:1px; height:1px; opacity:0; pointer-events:none; }
|
||||
@media(max-width:700px) { .observation-toolbar .observation-actions { flex-wrap:wrap; } .observation-center { height:max(650px, calc(100dvh - 200px)); } .observation-center--expanded { height:100dvh; } }
|
||||
|
||||
.observation-map[data-available="false"] > [data-nodedc-map-renderer] { visibility:hidden; }
|
||||
|
||||
/* Transform the live panel as one item; reveal it across nested split boundaries. */
|
||||
.observation-panel-drag { position:relative; height:100%; min-height:0; }
|
||||
.observation-panel-drag.is-dragging { z-index:var(--nodedc-layer-panel); }
|
||||
.observation-split:has(.observation-panel-drag.is-dragging),
|
||||
.observation-split > .nodedc-split-pane__panel:has(.observation-panel-drag.is-dragging) { position:relative; overflow:visible; z-index:var(--nodedc-layer-panel); }
|
||||
@container (max-width:340px) {
|
||||
.observation-panel__header { flex-wrap:wrap; }
|
||||
.observation-panel__title { min-width:80px; }
|
||||
.observation-panel__header > .observation-actions { max-width:100%; flex-wrap:wrap; justify-content:flex-end; margin-left:auto; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {useEffect,useState} from 'react';
|
||||
import type {SensorInventory,SensorTransport} from '../../../../../../packages/sensor-ui/src/contracts';
|
||||
|
||||
export function useObservationInventory(transport:SensorTransport){
|
||||
const [inventory,setInventory]=useState<SensorInventory|null>(null);
|
||||
useEffect(()=>{
|
||||
let active=true,running=false,revision=0,last=0;
|
||||
const receive=(value:SensorInventory)=>{if(active){revision++;last=Date.now();setInventory(value);}};
|
||||
// A lost SSE connection is not evidence that a working camera stopped.
|
||||
// Retain the last inventory for one bounded heartbeat window and verify via HTTP.
|
||||
const unavailable=()=>{if(active&&Date.now()-last>15000)setInventory(value=>value?{...value,fresh:false}:value);};
|
||||
const poll=async()=>{if(running||Date.now()-last<4000)return;running=true;const before=revision;try{const value=await transport.inventory();if(before===revision)receive(value);}catch{if(before===revision)unavailable();}finally{running=false;}};
|
||||
void poll();const unsubscribe=transport.subscribe?.(receive,()=>{unavailable();void poll();});const timer=setInterval(()=>{unavailable();void poll();},5000);
|
||||
return()=>{active=false;clearInterval(timer);unsubscribe?.();};
|
||||
},[transport]);
|
||||
return inventory;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import {mapPresentation} from '../../components/map/mapPresentation';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -207,60 +208,7 @@ export function WorldMapWorkspace({
|
||||
runtimeConfigUrl="/api/v1/map/runtime-config"
|
||||
camera={draft.view.camera}
|
||||
layers={draft.view.layerVisibility}
|
||||
settings={{
|
||||
atmosphere_enabled: draft.view.visualSettings.atmosphereEnabled,
|
||||
atmosphere_hue: draft.view.visualSettings.atmosphereHue,
|
||||
atmosphere_saturation:
|
||||
draft.view.visualSettings.atmosphereSaturation,
|
||||
atmosphere_brightness:
|
||||
draft.view.visualSettings.atmosphereBrightness,
|
||||
fog_enabled: draft.view.visualSettings.fogEnabled,
|
||||
fog_density: draft.view.visualSettings.fogDensity,
|
||||
sun_enabled: draft.view.visualSettings.sunEnabled,
|
||||
sun_hour: draft.view.visualSettings.sunHour,
|
||||
sun_intensity: draft.view.visualSettings.sunIntensity,
|
||||
shadows_enabled: draft.view.visualSettings.shadowsEnabled,
|
||||
monochrome_enabled: draft.view.visualSettings.monochromeEnabled,
|
||||
monochrome_color: draft.view.visualSettings.monochromeColor,
|
||||
imagery_brightness: draft.view.visualSettings.imageryBrightness,
|
||||
imagery_contrast: draft.view.visualSettings.imageryContrast,
|
||||
imagery_saturation: draft.view.visualSettings.imagerySaturation,
|
||||
imagery_gamma: draft.view.visualSettings.imageryGamma,
|
||||
imagery_hue: draft.view.visualSettings.imageryHue,
|
||||
imagery_alpha: draft.view.visualSettings.imageryAlpha,
|
||||
globe_color: draft.view.visualSettings.globeColor,
|
||||
background_color: draft.view.visualSettings.backgroundColor,
|
||||
terrain_exaggeration:
|
||||
draft.view.visualSettings.terrainExaggeration,
|
||||
buildings_color: draft.view.visualSettings.buildingsColor,
|
||||
buildings_opacity: draft.view.visualSettings.buildingsOpacity,
|
||||
buildings_maximum_screen_space_error:
|
||||
draft.view.visualSettings.buildingsMaximumScreenSpaceError,
|
||||
grid_lod_enabled: draft.view.visualSettings.gridLodEnabled,
|
||||
grid_height_meters: draft.view.visualSettings.gridHeightMeters,
|
||||
grid_lod_1_max_height_km:
|
||||
draft.view.visualSettings.gridLod1MaxHeightKm,
|
||||
grid_lod_1_step_km:
|
||||
draft.view.visualSettings.gridLod1StepKm,
|
||||
grid_lod_2_max_height_km:
|
||||
draft.view.visualSettings.gridLod2MaxHeightKm,
|
||||
grid_lod_2_step_km:
|
||||
draft.view.visualSettings.gridLod2StepKm,
|
||||
grid_lod_3_step_km:
|
||||
draft.view.visualSettings.gridLod3StepKm,
|
||||
grid_radius_km: draft.view.visualSettings.gridRadiusKm,
|
||||
grid_line_width: draft.view.visualSettings.gridLineWidth,
|
||||
grid_color: draft.view.visualSettings.gridColor,
|
||||
grid_opacity: draft.view.visualSettings.gridOpacity,
|
||||
grid_dots_enabled:
|
||||
draft.view.visualSettings.gridDotsEnabled,
|
||||
grid_dots_size: draft.view.visualSettings.gridDotsSize,
|
||||
grid_dots_color: draft.view.visualSettings.gridDotsColor,
|
||||
grid_dots_opacity:
|
||||
draft.view.visualSettings.gridDotsOpacity,
|
||||
camera_animation_enabled:
|
||||
draft.view.visualSettings.cameraAnimationEnabled,
|
||||
}}
|
||||
settings={mapPresentation(draft.view.visualSettings)}
|
||||
cacheIntent={{
|
||||
enabled: draft.view.cacheIntent.enabled,
|
||||
no_overwrite: draft.view.cacheIntent.noOverwrite,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {after,before,test} from 'node:test';
|
||||
import {createServer} from 'vite';
|
||||
let server,layout,source,fanout;
|
||||
before(async()=>{
|
||||
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
|
||||
layout=await server.ssrLoadModule('/src/core/fleet/observationLayout.ts');
|
||||
source=await server.ssrLoadModule('../../packages/sensor-ui/src/observation.ts');
|
||||
({observationRerun:fanout}=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/observationRerun.ts'));
|
||||
});
|
||||
after(async()=>{await server?.close();});
|
||||
test('layout keeps independent identities and restores absent devices without confusing equal model names',()=>{
|
||||
const a=source.observationKey('camera-1','left'),b=source.observationKey('camera-2','left'),c=source.observationKey('camera-1','right');
|
||||
assert.notEqual(a,b);assert.notEqual(a,c);
|
||||
const value={...layout.emptyObservationLayout(),order:[b,a],hidden:[a],layers:{[a]:'depth'}};
|
||||
assert.deepEqual(layout.orderedObservationIDs(value,[a,c]),[a,c]);
|
||||
assert.deepEqual(layout.orderedObservationIDs(value,[a,b,c]),[b,a,c]);
|
||||
assert.deepEqual(layout.moveObservation(value,[a,b,c],c,b).order,[c,b,a]);
|
||||
assert.deepEqual(value.hidden,[a]);
|
||||
});
|
||||
test('untrusted browser layout is bounded and validates revisions and split numbers',()=>{
|
||||
const decoded=layout.decodeObservationLayout({version:1,order:['a','a',null,8],hidden:'all',layers:{a:'depth',b:[]},splits:{a:Infinity,b:-8,c:98},arrangement:'bad'});
|
||||
assert.deepEqual(decoded.order,['a']);assert.deepEqual(decoded.hidden,[]);assert.deepEqual(decoded.splits,{b:20,c:80});assert.deepEqual(decoded.layers,{a:'depth'});assert.equal(decoded.arrangement,'auto');
|
||||
assert.deepEqual(layout.decodeObservationLayout({version:2}),layout.emptyObservationLayout());
|
||||
});
|
||||
test('camera observation exposes only advertised layers with one viewport and RGB default',()=>{
|
||||
const views=source.cameraObservationViews({name:'RealSense',layers:['depth','private-channel','color','points']});
|
||||
assert.equal(views.length,1);assert.deepEqual(views[0].layers.map(v=>v.value),['color','depth','points']);
|
||||
assert.deepEqual(source.cameraObservationViews({name:'unknown',layers:[]}),[]);
|
||||
});
|
||||
test('two Rerun views share bytes, apply independent blueprints once and dispose both hosts',async()=>{
|
||||
const fetchBefore=globalThis.fetch;globalThis.fetch=async url=>new Response(Uint8Array.of(String(url).includes('plan')?2:1));
|
||||
const sent=[[],[]],closed=[],started=[];let count=0;
|
||||
const factory=mount=>{const index=count++;return {ready:Promise.resolve({mount,viewer:{start:async()=>started.push(index),open_channel:name=>({ready:true,send_rrd:bytes=>sent[index].push([name,[...bytes]]),close:()=>{}}),get_active_recording_id:()=> 'recording-1',override_panel_state:()=>{},set_active_timeline:()=>{}}}),dispose:()=>closed.push(index)};};
|
||||
try{
|
||||
const host=fanout(factory,{current:{id:'top'}})({id:'3d'});const {viewer,mount}=await host.ready;
|
||||
await viewer.start(null,mount,{});const channel=viewer.open_channel('rrd');channel.send_rrd(Uint8Array.of(9,8));
|
||||
assert.deepEqual(sent[0],[['rrd',[9,8]]]);assert.deepEqual(sent[1],[['rrd',[9,8]]]);
|
||||
viewer.get_active_recording_id();viewer.get_active_recording_id();
|
||||
assert.deepEqual(sent[0].at(-1),['observation-perspective',[1]]);assert.deepEqual(sent[1].at(-1),['observation-plan',[2]]);
|
||||
assert.equal(sent[0].length,2);assert.deepEqual(started,[0,1]);host.dispose();assert.deepEqual(closed,[0,1]);
|
||||
}finally{globalThis.fetch=fetchBefore;}
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {after,before,test} from 'node:test';
|
||||
import {createServer} from 'vite';
|
||||
let server,activation,x4;
|
||||
before(async()=>{
|
||||
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
|
||||
activation=await server.ssrLoadModule('../../packages/sensor-ui/src/observationStart.ts');
|
||||
x4=await server.ssrLoadModule('../../plugins/insta360-x4/frontend/src/observationStart.ts');
|
||||
});
|
||||
after(async()=>{await server?.close();});
|
||||
const device=(id='camera-1')=>({id,online:true,prepared:true,layers:['color'],snapshot:{acquisition:'idle',context:{session_id:'session-1',execution:{node_id:'board-1'}}},camera_status:{connected:true,preview:0,recording:0}});
|
||||
function transport(items,submit=()=>({state:'complete'})){
|
||||
const commands=[];return {commands,inventory:async()=>({fresh:true,items}),submit:async c=>{commands.push(c);return submit(c);},operation:async()=>({state:'complete'})};
|
||||
}
|
||||
test('opening two views shares one start; another camera keeps its own lifecycle',async()=>{
|
||||
const a=device(),b=device('camera-2'),t=transport([a,b]);
|
||||
await Promise.all([activation.startCameraObservation(a,t),activation.startCameraObservation(a,t),activation.startCameraObservation(b,t)]);
|
||||
assert.equal(t.commands.length,2);
|
||||
assert.deepEqual(t.commands.map(c=>c.session.device_id).sort(),[a.id,b.id]);
|
||||
for(const c of t.commands){assert.equal(c.action_id,'start');assert.deepEqual(c.parameters,{record:false});}
|
||||
});
|
||||
test('opening observer cannot replace active capture, recording or playback',async()=>{
|
||||
for(const value of [{snapshot:{acquisition:'streaming'}},{snapshot:{acquisition:'starting'}},{snapshot:{acquisition:'stopping'}},{playback_id:'recorded-source'}]){
|
||||
const a=device();Object.assign(a,{...value,snapshot:{...a.snapshot,...value.snapshot}});const t=transport([a]);
|
||||
await activation.startCameraObservation(a,t);assert.equal(t.commands.length,0);
|
||||
}
|
||||
});
|
||||
test('stale inventory and replaced sessions never receive starts',async()=>{
|
||||
const original=device(),replacement=device();replacement.snapshot.context.session_id='new-session';
|
||||
for(const t of [transport([replacement]),{...transport([original]),inventory:async()=>({fresh:false,items:[original]})}]){
|
||||
await assert.rejects(activation.startCameraObservation(original,t));assert.equal(t.commands.length,0);
|
||||
}
|
||||
});
|
||||
test('X4 auto start uses preview only and attaches without restarting an active camera',async()=>{
|
||||
const a=device(),t=transport([a]);await x4.startX4Observation(a,t);assert.deepEqual(t.commands.map(c=>c.action_id),['preview.start']);
|
||||
a.camera_status.preview=1;t.commands.length=0;await x4.startX4Observation(a,t);assert.equal(t.commands.length,0);
|
||||
});
|
||||
test('stalled X4 preview recovers once for both lenses without any recording action',async()=>{
|
||||
const a=device();a.camera_status.preview=1;const t=transport([a]);
|
||||
await Promise.all([x4.startX4Observation(a,t,true),x4.startX4Observation(a,t,true)]);
|
||||
assert.deepEqual(t.commands.map(c=>c.action_id),['preview.stop','preview.start']);
|
||||
});
|
||||
test('X4 recovery requires confirmed stopped recording and successful preview stop',async()=>{
|
||||
for(const recording of [1,null,-1]){const a=device();a.camera_status={connected:true,preview:1,recording};const t=transport([a]);await assert.rejects(x4.startX4Observation(a,t,true));assert.equal(t.commands.length,0);}
|
||||
const a=device();a.camera_status.preview=1;const t=transport([a],()=>({state:'unknown'}));
|
||||
await assert.rejects(x4.startX4Observation(a,t,true));assert.deepEqual(t.commands.map(c=>c.action_id),['preview.stop']);
|
||||
});
|
||||
test('recovery cannot start a replacement camera session after its stop',async()=>{
|
||||
const a=device();a.camera_status.preview=1;const b=device();b.snapshot.context.session_id='replacement';
|
||||
const items=[a],t=transport(items,()=>{items[0]=b;return {state:'complete'};});
|
||||
await assert.rejects(x4.startX4Observation(a,t,true));assert.deepEqual(t.commands.map(c=>c.action_id),['preview.stop']);
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {after,before,test} from 'node:test';
|
||||
import {createServer} from 'vite';
|
||||
let server,PreviewLiveness,previewPlayback;
|
||||
before(async()=>{
|
||||
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
|
||||
({PreviewLiveness}=await server.ssrLoadModule('../../packages/sensor-ui/src/previewLiveness.ts'));
|
||||
({previewPlayback}=await server.ssrLoadModule('../../packages/sensor-ui/src/previewPlayback.ts'));
|
||||
});
|
||||
after(async()=>{await server?.close();});
|
||||
test('offscreen RTP decode progress keeps a viewer live without DOM frame callbacks',()=>{
|
||||
const h=new PreviewLiveness(0);
|
||||
for(let now=2000;now<180000;now+=2000){h.frame(now);assert.equal(h.state(now),'live');}
|
||||
});
|
||||
test('one stale telemetry sample signals delay, never a camera restart',()=>{
|
||||
const h=new PreviewLiveness(0);h.frame(1000);h.source(false,1100);
|
||||
assert.equal(h.state(1100),'delayed');h.source(true,1500);h.frame(1500);assert.equal(h.state(1500),'live');
|
||||
});
|
||||
test('capture recovery requires sustained fresh reports that the source stopped',()=>{
|
||||
const h=new PreviewLiveness(0);h.frame(1000);
|
||||
for(let now=2000;now<=12000;now+=1000){h.source(false,now);assert.equal(h.state(now),'delayed');}
|
||||
h.source(false,14000);assert.equal(h.state(14000),'source-stalled');
|
||||
h.source(true,14500);h.frame(14500);assert.equal(h.state(14500),'live');
|
||||
});
|
||||
test('missing telemetry and a stalled peer cannot be treated as proof to restart capture',()=>{
|
||||
const h=new PreviewLiveness(0);h.frame(100);h.source(false,200);
|
||||
assert.equal(h.state(13000),'peer-stalled');
|
||||
});
|
||||
test('negotiation time does not consume the first-frame grace period',()=>{
|
||||
const h=new PreviewLiveness(0);h.connected(45000);
|
||||
assert.equal(h.state(45001),'waiting');assert.equal(h.state(53001),'peer-stalled');
|
||||
});
|
||||
test('a browser-paused panel resumes the same stream and coalesces pending playback',async()=>{
|
||||
const stream={},attempts=[];
|
||||
const video={srcObject:stream,muted:false,paused:true,play(){return new Promise(resolve=>attempts.push(resolve));}};
|
||||
const playback=previewPlayback(video);
|
||||
playback.resume();playback.resume();assert.equal(attempts.length,1);assert.equal(video.muted,true);
|
||||
video.paused=false;attempts[0]();await new Promise(resolve=>setImmediate(resolve));
|
||||
playback.resume();assert.equal(attempts.length,1);
|
||||
video.paused=true;playback.resume();assert.equal(attempts.length,2);assert.equal(video.srcObject,stream);
|
||||
playback.dispose();attempts[1]();await new Promise(resolve=>setImmediate(resolve));
|
||||
playback.resume();assert.equal(attempts.length,2);
|
||||
});
|
||||
test('hidden-panel play rejection is retried on visibility without replacing the stream',async()=>{
|
||||
const stream={};let attempts=0;
|
||||
const video={srcObject:stream,muted:true,paused:true,play(){attempts++;return Promise.reject(new Error('offscreen'));}};
|
||||
const playback=previewPlayback(video);
|
||||
playback.resume();await new Promise(resolve=>setImmediate(resolve));
|
||||
playback.resume();await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(attempts,2);assert.equal(video.srcObject,stream);
|
||||
video.srcObject=null;playback.resume();assert.equal(attempts,2);
|
||||
});
|
||||
@@ -35,6 +35,9 @@
|
||||
],
|
||||
"@mission-core/sensor-sdk": [
|
||||
"../../packages/sensor-ui/src/pluginSdk.ts"
|
||||
],
|
||||
"react-dom": [
|
||||
"node_modules/@types/react-dom/index.d.ts"
|
||||
]
|
||||
},
|
||||
"resolveJsonModule": true,
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
],
|
||||
"@mission-core/sensor-sdk": [
|
||||
"../../../packages/sensor-ui/src/pluginSdk.ts"
|
||||
],
|
||||
"react-dom": [
|
||||
"node_modules/@types/react-dom/index.d.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user