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:
DCCONSTRUCTIONS
2026-09-10 22:12:05 +03:00
parent 5f75afb720
commit be58d589e2
48 changed files with 1575 additions and 83 deletions
@@ -0,0 +1,23 @@
import {observationRerun} from './observationRerun';
import {useCallback,useEffect,useMemo,useRef,useState} from 'react';
import {createPortal} from 'react-dom';
import {Icon,IconButton} from '@nodedc/ui-react';
import {ObservationHeader,type SensorObservationProps} from '@mission-core/sensor-sdk';
import {useK1Preview,pendingPreview,type PreviewStatus} from './useK1Preview';
function ActiveK1Observation({device,transport,enabled,views,createRerunHost}:SensorObservationProps){
const spatial=useRef<HTMLDivElement>(null),plan=useRef<HTMLDivElement>(null),video=useRef<HTMLVideoElement>(null);
const [status,setStatus]=useState(pendingPreview),[generation,setGeneration]=useState(0);const attempts=useRef(0);
const report=useCallback((value:PreviewStatus)=>setStatus(value),[]);
const host=useMemo(()=>observationRerun(createRerunHost,plan),[createRerunHost]);
useK1Preview(device,transport,host,spatial,video,report,generation,enabled);
useEffect(()=>{if(!status.retry)return;const timer=setTimeout(()=>setGeneration(v=>v+1),Math.min(30000,2000*2**Math.min(attempts.current++,4)));return()=>clearTimeout(timer);},[status.retry]);
return <>{views.map(view=>{const points=view.id!=='camera',live=points?status.presented:status.cameraPresented;return createPortal(<div className="observation-source">
<ObservationHeader targets={view} live={live} label={live?'Прямой эфир':points?status.lidar:status.camera}>{status.retry&&<IconButton label="Повторить подключение K1" onClick={()=>setGeneration(v=>v+1)}><Icon name="refresh"/></IconButton>}</ObservationHeader>
<div className="observation-source__media">{points?<div className="observation-rerun" ref={view.id==='plan'?plan:spatial}/>:<video ref={video} autoPlay muted playsInline aria-label={`${device.name} Cam Right`} data-live={live}/>}</div>
</div>,view.target,view.id);})}</>;
}
export function K1Observation(props:SensorObservationProps){
if(!props.enabled||props.device.snapshot.acquisition!=='streaming')return <>{props.views.map(view=>createPortal(<div className="observation-source"><ObservationHeader targets={view} live={false} label={props.enabled?'Просмотр остановлен':'Нет связи с источником'}/><p className="observation-source__notice">Начните приём данных в конфигураторе K1.</p></div>,view.target,view.id))}</>;
return <ActiveK1Observation {...props}/>;
}
@@ -0,0 +1,24 @@
import type {RefObject} from 'react';
import type {RerunHostFactory,LiveRerunViewer} from '@mission-core/sensor-sdk';
/** One incoming RRD stream, two independently navigable native views. */
export function observationRerun(factory:RerunHostFactory,plan:RefObject<HTMLDivElement|null>):RerunHostFactory {
return element=>{
const spatial=factory(element),overhead=factory(plan.current!);let disposed=false;
const controller=new AbortController();
const load=async(url:URL)=>{const response=await fetch(url,{signal:controller.signal});if(!response.ok)throw new Error('Не удалось загрузить вид облака точек.');return new Uint8Array(await response.arrayBuffer());};
const ready=Promise.all([spatial.ready,overhead.ready,load(new URL('./observation-3d.rbl',import.meta.url)),load(new URL('./observation-plan.rbl',import.meta.url))]).then(([a,b,perspective,top])=>{
let primaryBlueprint:ReturnType<LiveRerunViewer['open_channel']>|null=null,secondaryBlueprint:ReturnType<LiveRerunViewer['open_channel']>|null=null,recording:string|null=null;
const viewer:LiveRerunViewer={
...a.viewer,
start:async(source,mount,options)=>{await a.viewer.start(source,mount,options);if(disposed)return;await b.viewer.start(source,b.mount,options);primaryBlueprint=a.viewer.open_channel('observation-perspective');secondaryBlueprint=b.viewer.open_channel('observation-plan');},
open_channel:name=>{const first=a.viewer.open_channel(name),second=b.viewer.open_channel(name);return {get ready(){return first.ready&&second.ready;},send_rrd:bytes=>{second.send_rrd(bytes.slice());first.send_rrd(bytes);},close:()=>{first.close();second.close();}};},
get_active_recording_id:()=>{const id=a.viewer.get_active_recording_id();if(id&&id!==recording&&primaryBlueprint?.ready&&secondaryBlueprint?.ready&&b.viewer.get_active_recording_id()===id){primaryBlueprint.send_rrd(perspective.slice());secondaryBlueprint.send_rrd(top.slice());recording=id;}return id;},
override_panel_state:(panel,state)=>{a.viewer.override_panel_state(panel,state);b.viewer.override_panel_state(panel,state);},
set_active_timeline:(id,timeline)=>{a.viewer.set_active_timeline(id,timeline);b.viewer.set_active_timeline(id,timeline);},
};
return {viewer,mount:a.mount};
});
return {ready,dispose:()=>{disposed=true;controller.abort();spatial.dispose();overhead.dispose();}};
};
}
@@ -2,9 +2,11 @@ import type {SensorUiContribution} from '@mission-core/sensor-sdk';
import {K1Detail} from './K1Detail';
import {DeviceEnrollmentWindow} from './DeviceEnrollmentWindow';
import {k1Status} from './presentation';
import {K1Observation} from './K1Observation';
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`}]},
};