feat(observation): expose offline sources with transparent viewport headers
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
export interface ObservationLayout {
|
||||
version:1; order:string[]; hidden:string[]; layers:Record<string,string>;
|
||||
splits:Record<string,number>; arrangement:'auto'|'columns'|'rows';
|
||||
catalogVisible?: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):[];
|
||||
if(source.catalogVisible!==undefined)result.catalogVisible=ids(source.catalogVisible);
|
||||
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))]));
|
||||
|
||||
@@ -7,6 +7,10 @@ import {decodeObservationLayout,emptyObservationLayout,moveObservation,orderedOb
|
||||
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 {unboundObservationViews} from '../../../../../../packages/sensor-ui/src/observationCatalog';
|
||||
import {sensorContribution} from '../../../../../../packages/sensor-ui/src/extensions';
|
||||
import {ObservationDeviceSettings} from './ObservationDeviceSettings';
|
||||
import {ObservationPanelHeader} from './ObservationPanelHeader';
|
||||
import {ObservationDeck,ObservationMount} from './ObservationDeck';
|
||||
import {ObservationMap} from './ObservationMap';
|
||||
import {useObservationInventory} from './useObservationInventory';
|
||||
@@ -19,62 +23,65 @@ export function BoardObservationCenter({vehicleID,name,enabled,back,configure,he
|
||||
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 [settingsDevice,setSettingsDevice]=useState<string|null>(null);
|
||||
const [layersOpen,setLayersOpen]=useState(false);
|
||||
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 contribution=sensorContribution(registry.sensorContributions,device)?.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})));
|
||||
const sources:{key:string;deviceID?:string;catalog?:string;view:SensorObservationView}[]=groups.flatMap(group=>group.views.map(view=>({key:observationKey(group.device.id,view.id),deviceID:group.device.id,view})));
|
||||
sources.push(...unboundObservationViews(inventory?.items??[],registry.sensorContributions));
|
||||
sources.push({key:'map',view:{id:'map',label:'Карта'}},{key:'rover',view:{id:'rover',label:'3D View аппарата'}},{key:'telemetry',view:{id:'telemetry',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 ids=orderedObservationIDs(layout,sources.map(source=>source.key)),visible=ids.filter(id=>!layout.hidden.includes(id)&&(!sources.find(source=>source.key===id)?.catalog||layout.catalogVisible?.includes(id)));
|
||||
const effectiveFull=full&&visible.includes(full)?full:null;
|
||||
const rover=useRoverControl(vehicleID,enabled&&(visible.includes('rover')||visible.includes('telemetry')));
|
||||
const drivingVisible=visible.includes('rover')&&(!effectiveFull||effectiveFull==='rover')&&!layersOpen;
|
||||
const drivingVisible=visible.includes('rover')&&(!effectiveFull||effectiveFull==='rover')&&!layersOpen&&!settingsDevice;
|
||||
useEffect(()=>{if(!drivingVisible)rover.stop();},[drivingVisible,rover.stop]);
|
||||
const active=sources.find(source=>source.key===focused);
|
||||
const configuredDevice=inventory?.items.find(device=>device.id===settingsDevice);
|
||||
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 setVisible=(id:string,show:boolean)=>setLayout(value=>({...value,catalogVisible:show?[...new Set([...(value.catalogVisible??[]),...(sources.find(source=>source.key===id)?.catalog?[id]:[])])]:(value.catalogVisible??[]).filter(key=>key!==id),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 <>{headerToolsHost&&createPortal(<IconButton label="К аппаратам" onClick={back}><Icon name="chevron-left"/></IconButton>,headerToolsHost)}<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">{(!headerToolsHost||expanded)&&<IconButton label="К аппаратам" onClick={back}><Icon name="chevron-left"/></IconButton>}<Button onClick={configure}><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>
|
||||
<header className="observation-toolbar"><div><h2>Центр наблюдения и управления</h2><span>{name}</span></div><div className="observation-actions">{(!headerToolsHost||expanded)&&<IconButton label="К аппаратам" onClick={back}><Icon name="chevron-left"/></IconButton>}<Button onClick={configure}><Icon name="sliders"/>Конфигуратор</Button><Button onClick={()=>{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>
|
||||
<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={()=>{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">
|
||||
<GlassSurface tone="soft" radius="panel" padding="none" materialRim={false} className="observation-panel__surface observation-panel__surface--overlay">
|
||||
<ObservationPanelHeader>
|
||||
{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.deviceID&&<IconButton label={`Настройки · ${source.view.label}`} onClick={()=>{setFocused(source.key);setLayersOpen(true);}}><Icon name="settings"/></IconButton>}
|
||||
{source.deviceID&&<IconButton label={`Настройки · ${source.view.label}`} onClick={()=>setSettingsDevice(source.deviceID!)}><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>
|
||||
</ObservationPanelHeader>
|
||||
<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))!}))}/>)}
|
||||
{sources.filter(source=>source.catalog&&visible.includes(source.key)).map(source=>createPortal(<div className="observation-source"><div className="observation-source__notice"><p>{source.catalog} не подключён к аппарату.</p><Button onClick={configure}>Подключить в конфигураторе</Button></div></div>,media.current.get(source.key)!,source.key))}
|
||||
{configuredDevice&&<ObservationDeviceSettings key={configuredDevice.id} device={configuredDevice} transport={transport} contributions={registry.sensorContributions} enabled={enabled&&inventory?.fresh!==false&&configuredDevice.online} close={()=>setSettingsDevice(null)} configure={configure}/>}
|
||||
{visible.includes('map')&&createPortal(<ObservationMap vehicleID={vehicleID} header={headers.current.get('map')!}/>,media.current.get('map')!,'map-session')}
|
||||
{visible.includes('rover')&&createPortal(<RoverView vehicleID={vehicleID} controller={rover} header={headers.current.get('rover')!} active={drivingVisible}/>,media.current.get('rover')!,'rover-session')}
|
||||
{visible.includes('telemetry')&&createPortal(<RoverTelemetry state={rover.state}/>,media.current.get('telemetry')!,'telemetry-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}}))}/>}
|
||||
<Window open={layersOpen} onClose={()=>setLayersOpen(false)} title="Доступные слои" size="md"><div className="observation-settings">
|
||||
<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.map(id=>{const source=sources.find(item=>item.key===id)!;return <div className="observation-layer" key={id}><Switch label={source.catalog?`${source.view.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>;})}
|
||||
<Button onClick={()=>{setLayout(emptyObservationLayout());setFull(null);}}>Восстановить расположение</Button>
|
||||
</div></Window>
|
||||
</section>,portal)}</>;
|
||||
|
||||
@@ -14,5 +14,5 @@ export function ObservationDeck({ids,mounts,layout,onSplit,depth=0}:{ids:string[
|
||||
const defaultColumns=depth===0&&layout.arrangement==='auto'&&!layout.order.some(id=>id==='rover'||id==='telemetry')&&ids.some(id=>!['map','rover','telemetry'].includes(id));
|
||||
const cameraCount=ids.filter(id=>!['map','rover','telemetry'].includes(id)).length;
|
||||
const mid=defaultColumns&&cameraCount<ids.length?cameraCount:layout.arrangement==='auto'&&ids[0]==='map'&&ids.includes('rover')?1: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}/>}/>;
|
||||
return <SplitPane separatorAppearance="invisible" 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,17 @@
|
||||
import {useState} from 'react';
|
||||
import {Button,Window} from '@nodedc/ui-react';
|
||||
import {SensorDetail} from '../../../../../../packages/sensor-ui/src/SensorDetail';
|
||||
import {sensorContribution,type SensorUiContribution} from '../../../../../../packages/sensor-ui/src/extensions';
|
||||
import type {Sensor,SensorTransport} from '../../../../../../packages/sensor-ui/src/contracts';
|
||||
import {createIsolatedRerunHost} from '../../../components/rerun/isolatedRerunHost';
|
||||
|
||||
export function ObservationDeviceSettings({device,transport,contributions,enabled,close,configure}:{device:Sensor;transport:SensorTransport;contributions:readonly SensorUiContribution[];enabled:boolean;close:()=>void;configure:()=>void}) {
|
||||
const [error,setError]=useState('');
|
||||
const Detail=sensorContribution(contributions,device)?.Detail??SensorDetail;
|
||||
return <Window open title={`Настройки · ${device.name}`} size="lg" placement="end" draggable closeOnBackdrop={false} lockBodyScroll={false} trapFocus={false} onClose={close}>
|
||||
{!enabled&&<p>Нет связи с устройством. Настройки оборудования будут доступны после подключения.</p>}
|
||||
{!device.prepared&&<Button onClick={configure}>Подготовить устройство в конфигураторе</Button>}
|
||||
{error&&<p role="alert">{error}</p>}
|
||||
<Detail device={device} transport={transport} enabled={enabled} back={close} refresh={async()=>{await transport.inventory();}} failure={reason=>setError(reason instanceof Error?reason.message:reason?String(reason):'')} createRerunHost={createIsolatedRerunHost}/>
|
||||
</Window>;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import {useLayoutEffect,useRef,type ReactNode} from 'react';
|
||||
|
||||
/** Reserve the actual overlay height for text/status content, including wrapped controls. */
|
||||
export function ObservationPanelHeader({children}:{children:ReactNode}) {
|
||||
const ref=useRef<HTMLElement>(null);
|
||||
useLayoutEffect(()=>{
|
||||
const header=ref.current!;
|
||||
const measure=()=>header.parentElement?.style.setProperty('--observation-overlay-header-space',`${header.getBoundingClientRect().height}px`);
|
||||
measure();const observer=new ResizeObserver(measure);observer.observe(header);
|
||||
return()=>observer.disconnect();
|
||||
},[]);
|
||||
return <header ref={ref} className="observation-panel__header">{children}</header>;
|
||||
}
|
||||
@@ -9,6 +9,9 @@
|
||||
.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__surface--overlay { position:relative; --observation-overlay-header-space:62px; }
|
||||
.observation-panel__surface--overlay > .observation-panel__header { position:absolute; inset:0 0 auto; z-index:1; background:transparent; pointer-events:none; }
|
||||
.observation-panel__surface--overlay > .observation-panel__header > * { pointer-events:auto; }
|
||||
.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; }
|
||||
@@ -21,7 +24,7 @@
|
||||
.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-source__notice { position:absolute; inset:max(30%, calc(var(--observation-overlay-header-space, 0px) + var(--nodedc-space-3))) 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); }
|
||||
@@ -43,3 +46,5 @@
|
||||
.observation-panel__title { min-width:80px; }
|
||||
.observation-panel__header > .observation-actions { max-width:100%; flex-wrap:wrap; justify-content:flex-end; margin-left:auto; }
|
||||
}
|
||||
|
||||
.observation-panel__surface--overlay .rover-telemetry { padding-top:calc(var(--observation-overlay-header-space) + var(--nodedc-space-3)); }
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {after,before,test} from 'node:test';
|
||||
import {createServer} from 'vite';
|
||||
let server,layout,source,fanout;
|
||||
let server,layout,source,fanout,catalog;
|
||||
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');
|
||||
catalog=await server.ssrLoadModule('../../packages/sensor-ui/src/observationCatalog.ts');
|
||||
({observationRerun:fanout}=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/observationRerun.ts'));
|
||||
});
|
||||
after(async()=>{await server?.close();});
|
||||
@@ -41,3 +42,21 @@ test('two Rerun views share bytes, apply independent blueprints once and dispose
|
||||
assert.equal(sent[0].length,2);assert.deepEqual(started,[0,1]);host.dispose();assert.deepEqual(closed,[0,1]);
|
||||
}finally{globalThis.fetch=fetchBefore;}
|
||||
});
|
||||
|
||||
|
||||
test('known offline D455 remains selectable; connected capabilities override the catalog',()=>{
|
||||
const offline={name:'RealSense D455',model:'RealSense D455',online:false,layers:[]};
|
||||
assert.deepEqual(source.cameraObservationViews(offline)[0].layers.map(v=>v.value),['color','depth','infrared1','infrared2','points','motion']);
|
||||
assert.deepEqual(source.cameraObservationViews({...offline,online:true,layers:['depth']})[0].layers.map(v=>v.value),['depth']);
|
||||
assert.deepEqual(source.cameraObservationViews({...offline,model:'unknown'}),[]);
|
||||
});
|
||||
test('unbound catalog views have no device authority; connected instances replace templates without aliasing',()=>{
|
||||
const plugin={kind:'k1',observation:{catalog:{label:'K1',views:[{id:'points',label:'Points'},{id:'camera',label:'Camera'}]}}};
|
||||
const entries=catalog.unboundObservationViews([],[plugin]);
|
||||
assert.equal(entries.length,2);assert.notEqual(entries[0].key,entries[1].key);
|
||||
assert.equal(entries[0].deviceID,undefined);assert.equal(entries[0].device,undefined);
|
||||
assert.deepEqual(catalog.unboundObservationViews([{id:'one',kind:'k1'}],[plugin]),[]);
|
||||
assert.deepEqual(catalog.unboundObservationViews([],[plugin,plugin]),[]);
|
||||
const persisted=layout.decodeObservationLayout({...layout.emptyObservationLayout(),catalogVisible:[entries[0].key,entries[0].key,null]});
|
||||
assert.deepEqual(persisted.catalogVisible,[entries[0].key]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# 0049 — Observation overlays, offline source catalog and section navigation
|
||||
|
||||
## Decision
|
||||
|
||||
All apparatus observation panels place titles/actions over their viewport, without a separate header strip. A ResizeObserver reserves the real header height for status and telemetry content. DG SplitPane owns the invisible separator variant: pointer hit area, keyboard resize and keyboard focus remain available. Ratios still use the existing per-vehicle JSON layout.
|
||||
|
||||
The apparatus detail and board monitor return through a shared circular header action. Explicit left-menu navigation remounts the workspace entry view, including repeated selection; persisted configuration and window arrangements are retained. Scene tool windows and recorded-session overview close. System contour selection returns to its default modules view. Leaving rover control invokes its existing cleanup/stop.
|
||||
|
||||
## Sources and authority
|
||||
|
||||
A known D455 with an empty offline capability list remains available as a camera viewport with its supported layer choices. A published nonempty capability list takes precedence. Detailed camera settings reuse SensorDetail; its capabilities and preview are not requested from an offline board.
|
||||
|
||||
Plugins may declare a view catalog independent of physical inventory. K1 declares point cloud (3D), point cloud (top), and camera. These optional windows are listed even before enrollment, start hidden, and display an explicit disconnected-source message when selected. They never fabricate a Sensor, session, device identifier or command authority. The catalog choice is persisted in bounded catalogVisible layout data. Actual devices retain distinct device-ID-based views and replace unbound catalog entries; no ambiguous model-to-instance assignment is inferred. K1 acquisition remains an explicit operator action in its existing configurator.
|
||||
|
||||
Source gear actions open the existing device settings in a draggable window. The layers dialog continues to control visibility and order.
|
||||
|
||||
## Validation scope
|
||||
|
||||
Build and offline UI acceptance cover section reset/back navigation, overlay headers, invisible pointer/keyboard resizing, layer visibility/persistence, disconnected notices and settings access. Unit cases cover D455 capability precedence, ambiguous plugin rejection, template/physical-device separation and bounded catalog persistence. The board was offline during this change: live RealSense/XGRIDS delivery and hardware settings are not claimed as accepted.
|
||||
@@ -3,11 +3,11 @@ import {LoadingRegion,Icon,IconButton,SettingsCard,StatusBadge} from '@nodedc/ui
|
||||
import {perform,type Sensor,type SensorTransport} from './contracts';
|
||||
import {PointViewport} from './PointViewport';
|
||||
interface Telemetry {points?:number[];motion?:Record<string,{x:number;y:number;z:number}>;acquisition?:string;frame?:{observed_at:string}}
|
||||
export function LiveViewport({device,layer,transport,failure,title,note,inactiveMessage}:{device:Sensor;layer:string;transport:SensorTransport;failure:(e:unknown)=>void;title?:string;note?:string;inactiveMessage?:string}){
|
||||
export function LiveViewport({device,layer,transport,failure,title,note,inactiveMessage,enabled=true}:{enabled?:boolean;device:Sensor;layer:string;transport:SensorTransport;failure:(e:unknown)=>void;title?:string;note?:string;inactiveMessage?:string}){
|
||||
const video=useRef<HTMLVideoElement>(null);const frame=useRef<HTMLDivElement>(null);const [expanded,setExpanded]=useState(false);
|
||||
const [state,setState]=useState('Подключение просмотра');const [telemetry,setTelemetry]=useState<Telemetry>({});
|
||||
useEffect(()=>{
|
||||
if(device.snapshot.acquisition!=='streaming'){setState('Захват остановлен');return;}
|
||||
if(!enabled||device.snapshot.acquisition!=='streaming'){setState('Захват остановлен');return;}
|
||||
let cancelled=false;let terminal=false;let peerID='';let timer:ReturnType<typeof setInterval>|undefined;let last=Date.now();let frameHandle:number|undefined;let decoded=0;
|
||||
const live=()=>{if(cancelled||terminal)return;last=Date.now();setState(device.playback_id?'Исходная запись':'Прямой эфир');};
|
||||
const frameSeen=()=>{live();if(!cancelled)frameHandle=video.current?.requestVideoFrameCallback(frameSeen);};
|
||||
@@ -28,9 +28,9 @@ export function LiveViewport({device,layer,transport,failure,title,note,inactive
|
||||
await pc.setRemoteDescription({type:'answer',sdp:answer.sdp});
|
||||
}catch(e){if(!cancelled){terminal=true;setState('Просмотр недоступен');failure(e);}}}
|
||||
void connect();return()=>{cancelled=true;if(timer)clearInterval(timer);if(frameHandle!==undefined)video.current?.cancelVideoFrameCallback(frameHandle);pc.close();if(peerID)void perform(transport,device,'close-peer',{peer_id:peerID}).catch(()=>undefined);};
|
||||
},[device.id,device.snapshot.context.session_id,device.snapshot.acquisition,device.playback_id,layer,transport]);
|
||||
},[enabled,device.id,device.snapshot.context.session_id,device.snapshot.acquisition,device.playback_id,layer,transport]);
|
||||
useEffect(()=>{const escape=(e:KeyboardEvent)=>{if(e.key==='Escape')setExpanded(false);};document.addEventListener('keydown',escape);return()=>document.removeEventListener('keydown',escape);},[]);
|
||||
const active=device.snapshot.acquisition==='streaming';
|
||||
const active=enabled&&device.snapshot.acquisition==='streaming';
|
||||
return <div ref={frame} className={expanded?'sensor-viewer sensor-viewer-expanded':'sensor-viewer'}><SettingsCard title={title??(layer==='points'?'Облако точек':layer==='motion'?'Движение':({color:'RGB',depth:'Глубина',infrared1:'ИК · 1',infrared2:'ИК · 2'}[layer]??layer))} actions={<><StatusBadge tone={active&&(state==='Прямой эфир'||state==='Исходная запись')?'success':'neutral'}>{state}</StatusBadge><IconButton label={expanded?'Восстановить размер просмотра':'Развернуть просмотр'} onClick={()=>setExpanded(!expanded)}><Icon name={expanded?'minimize':'expand'}/></IconButton></>}>
|
||||
{!active?<p>{inactiveMessage??'Нажмите «Начать просмотр» или «Начать запись» для запуска камеры.'}</p>:<>
|
||||
<LoadingRegion loading={state==='Подключение просмотра'||state==='Ожидание изображения'} label={state}>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {LiveViewport} from './LiveViewport';
|
||||
export function SensorDetail({device,transport,back,refresh,failure,enabled=true}:{enabled?:boolean;device:Sensor;transport:SensorTransport;back:()=>void;refresh:()=>Promise<void>;failure:(e:unknown)=>void}){
|
||||
const [detail,setDetail]=useState<Sensor|null>(null);const [selected,setSelected]=useState<Record<string,string>>({});const [layer,setLayer]=useState('color');const [pendingAction,setPendingAction]=useState<string|null>(null);const pending=pendingAction!==null;const [option,setOption]=useState('');const [optionValue,setOptionValue]=useState('');
|
||||
async function load(){const value=await perform<Sensor>(transport,device,'details');setDetail(value);return value;}
|
||||
useEffect(()=>{let live=true;if(device.prepared)void load().then(value=>{if(live){const selection:Record<string,string>={};for(const p of value.profiles??[])if(value.defaults?.includes(p.id))selection[p.stream+':'+p.index]=p.id;setSelected(selection);}}).catch(failure);return()=>{live=false;};},[device.id,device.prepared,device.snapshot.context.session_id]);
|
||||
useEffect(()=>{let live=true;if(enabled&&device.online&&device.prepared)void load().then(value=>{if(live){const selection:Record<string,string>={};for(const p of value.profiles??[])if(value.defaults?.includes(p.id))selection[p.stream+':'+p.index]=p.id;setSelected(selection);}}).catch(failure);return()=>{live=false;};},[enabled,device.online,device.id,device.prepared,device.snapshot.context.session_id]);
|
||||
async function act(action:string,parameters:Record<string,unknown>={}){if(pending||!enabled)return;failure(null);setPendingAction(action==='start'?(parameters.record?'record.start':'preview.start'):action==='replay'?'replay:'+String(parameters.recording_id):action);try{await perform(transport,device,action,parameters);await refresh();await load();}catch(e){failure(e);}finally{setPendingAction(null);}}
|
||||
const supported=(detail?.profiles??[]).filter(p=>['rgb8','bgr8','z16','y8','motion_xyz32f'].includes(p.format)&&(p.stream!=='infrared'||[1,2].includes(p.index)));
|
||||
const groups=[...new Set(supported.map(p=>p.stream+':'+p.index))];
|
||||
@@ -21,10 +21,10 @@ export function SensorDetail({device,transport,back,refresh,failure,enabled=true
|
||||
{device.snapshot.acquisition==='stopping'&&<p>Сохранение исходной записи и проверка целостности.</p>}
|
||||
{device.recording&&device.snapshot.acquisition!=='stopping'&&<p>Идёт исходная запись на БК. Она продолжится после закрытия окна.</p>}
|
||||
</SettingsCard>
|
||||
{detail&&<><Select label="Слой камеры" value={layer} options={available.map(value=>({value,label:videoNames[value]??value}))} onChange={setLayer}/><LiveViewport device={device} layer={layer} transport={transport} failure={failure}/>
|
||||
{detail&&<><Select label="Слой камеры" value={layer} options={available.map(value=>({value,label:videoNames[value]??value}))} onChange={setLayer}/><LiveViewport enabled={enabled&&device.online} device={device} layer={layer} transport={transport} failure={failure}/>
|
||||
<SettingsCard title="Профили потоков" description="Применяются при следующем запуске захвата."><div className="sensor-fields">{groups.map(group=><Select key={group} label={group} value={selected[group]??''} options={[{value:'',label:'Выключен'},...(detail.profiles??[]).filter(p=>p.stream+':'+p.index===group&&['rgb8','bgr8','z16','y8','motion_xyz32f'].includes(p.format)).map(p=>({value:p.id,label:`${p.width?p.width+' × '+p.height+' · ':''}${p.fps} Гц · ${p.format}`}))]} onChange={value=>setSelected(v=>({...v,[group]:value}))} disabled={!enabled||pending||active}/>)}</div></SettingsCard>
|
||||
<SettingsCard title="Параметры камеры"><div className="sensor-fields"><Select searchable label="Параметр" value={option} options={(detail.options??[]).map(v=>({value:v.id,label:v.label,description:v.sensor}))} onChange={id=>{setOption(id);setOptionValue(String(detail.options?.find(v=>v.id===id)?.value??''));}}/>{currentOption&&<><TextField type="number" label={`${currentOption.min} … ${currentOption.max}${currentOption.read_only?' · только чтение':''}`} min={currentOption.min} max={currentOption.max} step={currentOption.step||'any'} value={optionValue} onChange={e=>setOptionValue(e.target.value)} disabled={!enabled||pending||currentOption.read_only||!!device.playback_id}/><Button disabled={!enabled||pending||currentOption.read_only||!!device.playback_id||optionValue===''} loading={pendingAction==='option'} onClick={()=>void act('option',{id:option,value:Number(optionValue)})}>Применить параметр</Button></>}</div></SettingsCard>
|
||||
<SettingsCard title="Исходные записи">{detail.recordings?.length?detail.recordings.map(item=><div className="sensor-record" key={item.id}><span>{new Date(item.started_at).toLocaleString('ru-RU')}</span><span>{item.state==='complete'?'Запись завершена':item.state==='recording'?'Идёт запись':item.state==='finalizing'?'Сохранение записи':item.state==='interrupted'?'Запись прервана':'Запись не завершена'}</span><span>{item.bytes?`${(item.bytes/1048576).toFixed(1)} МиБ`:''}</span><IconButton label={`Открыть запись: ${new Date(item.started_at).toLocaleString('ru-RU')}`} disabled={!enabled||pending||active||item.state!=='complete'} loading={pendingAction==='replay:'+item.id} onClick={()=>void act('replay',{recording_id:item.id})}><Icon name="eye"/></IconButton></div>):<p>Записей пока нет.</p>}</SettingsCard></>}
|
||||
{!detail&&<p>{device.prepared?'Получение возможностей камеры…':'Подготовьте устройство в списке.'}</p>}
|
||||
{!detail&&<p>{!enabled||!device.online?'Возможности камеры будут прочитаны после подключения.':device.prepared?'Получение возможностей камеры…':'Подготовьте устройство в списке.'}</p>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -12,10 +12,12 @@ export interface SensorObservationProps {
|
||||
export interface SensorObservationContribution {
|
||||
views:(device:Sensor)=>SensorObservationView[];
|
||||
Session:ComponentType<SensorObservationProps>;
|
||||
catalog?:{label:string;views:SensorObservationView[]};
|
||||
}
|
||||
export const sensorLayerLabels:Record<string,string>={color:'RGB',depth:'Глубина',infrared1:'ИК · 1',infrared2:'ИК · 2',points:'Облако точек',motion:'Движение'};
|
||||
export function cameraObservationViews(device:Sensor):SensorObservationView[] {
|
||||
const layers=device.layers.filter(value=>Object.hasOwn(sensorLayerLabels,value)).map(value=>({value,label:sensorLayerLabels[value]}));
|
||||
const published=device.layers.length?device.layers:/^RealSense D455$/i.test(device.model??'')?['color','depth','infrared1','infrared2','points','motion']:[];
|
||||
const layers=published.filter(value=>Object.hasOwn(sensorLayerLabels,value)).map(value=>({value,label:sensorLayerLabels[value]}));
|
||||
layers.sort((a,b)=>Number(b.value==='color')-Number(a.value==='color'));
|
||||
return layers.length?[{id:'camera',label:device.name,layers}]:[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type {Sensor} from './contracts';
|
||||
import type {SensorUiContribution} from './extensions';
|
||||
|
||||
/** Supported view slots are not physical devices and carry no command authority. */
|
||||
export function unboundObservationViews(devices:Sensor[],contributions:readonly SensorUiContribution[]) {
|
||||
return contributions.flatMap(contribution=>{
|
||||
const catalog=contribution.observation?.catalog;
|
||||
if(!catalog||devices.some(device=>device.kind===contribution.kind)||contributions.filter(other=>other.kind===contribution.kind).length!==1)return [];
|
||||
return catalog.views.map(view=>({key:JSON.stringify(['catalog',contribution.kind,view.id]),catalog:catalog.label,view}));
|
||||
});
|
||||
}
|
||||
@@ -8,5 +8,5 @@ export const xgridsK1SensorUi:SensorUiContribution={
|
||||
kind:'k1', Detail:K1Detail, icon:'network', retainOffline:true, supportsPreparation:false,
|
||||
supportsRenaming:false,status:k1Status,
|
||||
wirelessEnrollment:{label:'XGRIDS LixelKity K1',View:DeviceEnrollmentWindow},
|
||||
observation:{Session:K1Observation,views:device=>[{id:'points',label:`${device.name} Point Cloud · 3D`},{id:'plan',label:`${device.name} Point Cloud · Сверху`},{id:'camera',label:`${device.name} Cam Right`}]},
|
||||
observation:{Session:K1Observation,catalog:{label:'XGRIDS LixelKity K1',views:[{id:'points',label:'XGRIDS K1 · Облако точек · 3D'},{id:'plan',label:'XGRIDS K1 · Облако точек · Сверху'},{id:'camera',label:'XGRIDS K1 · Камера'}]},views:device=>[{id:'points',label:`${device.name} Point Cloud · 3D`},{id:'plan',label:`${device.name} Point Cloud · Сверху`},{id:'camera',label:`${device.name} Cam Right`}]},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user