@@ -0,0 +1,395 @@
import { useCallback , useEffect , useMemo , useRef , useState } from 'react'
import type { Command , FmsState , Point , Pose , RobotId , RobotState } from './types'
const WORLD = { minX : - 5.5 , maxX : 0.5 , minY : - 0.5 , maxY : 5.5 }
const SVG_SIZE = 600
const emptyRobot : RobotState = {
online : false ,
lastMessageMs : null ,
pose : { x : 0 , y : 0 , yaw : 0 } ,
velocity : { x : 0 , y : 0 , yaw : 0 } ,
battery : null ,
lidar : [ ] ,
trail : [ ] ,
plan : [ ] ,
goal : null ,
emergency : false ,
arucoId : null ,
liftStatus : null ,
gripper : 'unknown' ,
}
const initialState : FmsState = {
type : 'state' ,
timestamp : 0 ,
mode : 'ros' ,
bridgeOnline : false ,
bridgeError : null ,
map : null ,
robots : { RMC1 : emptyRobot , RMC2 : emptyRobot } ,
}
function worldToSvg ( point : Point ) : Point {
return {
x : ( ( point . y - WORLD . minY ) / ( WORLD . maxY - WORLD . minY ) ) * SVG_SIZE ,
y : ( ( point . x - WORLD . minX ) / ( WORLD . maxX - WORLD . minX ) ) * SVG_SIZE ,
}
}
function points ( pointsValue : Point [ ] ) : string {
return pointsValue . map ( worldToSvg ) . map ( ( point ) = > ` ${ point . x } , ${ point . y } ` ) . join ( ' ' )
}
function format ( value : number | null , digits = 2 ) : string {
return value == null || ! Number . isFinite ( value ) ? '—' : value . toFixed ( digits )
}
function Card ( { title , children , className = '' } : { title : string ; children : React.ReactNode ; className? : string } ) {
return (
< section className = { ` rounded-xl border border-slate-700 bg-slate-900/80 p-4 ${ className } ` } >
< h2 className = "mb-3 text-sm font-semibold uppercase tracking-wide text-slate-400" > { title } < / h2 >
{ children }
< / section >
)
}
function NumberField ( { label , value , onChange } : { label : string ; value : number ; onChange : ( value : number ) = > void } ) {
return (
< label className = "grid gap-1 text-xs text-slate-400" >
{ label }
< input
className = "w-full rounded-lg border border-slate-700 bg-slate-950 px-2 py-2 text-sm text-slate-100 outline-none focus:border-cyan-500"
type = "number"
step = "0.1"
value = { value }
onChange = { ( event ) = > onChange ( Number ( event . target . value ) ) }
/ >
< / label >
)
}
function FieldMap ( { state , selected } : { state : FmsState ; selected : RobotId } ) {
const occupied = useMemo ( ( ) = > {
if ( ! state . map ) return [ ]
const result : { x : number ; y : number ; size : number ; value : number } [ ] = [ ]
for ( let row = 0 ; row < state . map . height ; row += 1 ) {
for ( let col = 0 ; col < state . map . width ; col += 1 ) {
const value = state . map . data [ row * state . map . width + col ]
if ( value > 25 ) {
result . push ( {
x : state.map.origin.x + col * state . map . resolution ,
y : state.map.origin.y + row * state . map . resolution ,
size : state.map.resolution ,
value ,
} )
}
}
}
return result . slice ( 0 , 5000 )
} , [ state . map ] )
const markers = Array . from ( { length : 36 } , ( _ , id ) = > ( {
id ,
x : - Math . floor ( id / 6 ) ,
y : id % 6 ,
} ) )
return (
< div className = "relative aspect-square min-h-[420px] overflow-hidden rounded-xl border border-slate-700 bg-slate-950" >
< svg viewBox = { ` 0 0 ${ SVG_SIZE } ${ SVG_SIZE } ` } className = "h-full w-full" aria-label = "Карта поля и телеметрия роверов" >
< rect width = { SVG_SIZE } height = { SVG_SIZE } fill = "#020617" / >
{ occupied . map ( ( cell , index ) = > {
const topLeft = worldToSvg ( { x : cell.x , y : cell.y } )
const bottomRight = worldToSvg ( { x : cell.x + cell . size , y : cell.y + cell . size } )
return (
< rect
key = { ` cell- ${ index } ` }
x = { Math . min ( topLeft . x , bottomRight . x ) }
y = { Math . min ( topLeft . y , bottomRight . y ) }
width = { Math . max ( 1 , Math . abs ( bottomRight . x - topLeft . x ) ) }
height = { Math . max ( 1 , Math . abs ( bottomRight . y - topLeft . y ) ) }
fill = { cell . value > 65 ? '#334155' : '#1e293b' }
opacity = "0.7"
/ >
)
} ) }
{ Array . from ( { length : 6 } , ( _ , row ) = > Array . from ( { length : 6 } , ( _ , col ) = > {
const here = worldToSvg ( { x : - row , y : col } )
const right = col < 5 ? worldToSvg ( { x : - row , y : col + 1 } ) : null
const down = row < 5 ? worldToSvg ( { x : - ( row + 1 ) , y : col } ) : null
return (
< g key = { ` edges- ${ row } - ${ col } ` } stroke = "#1e3a5f" strokeWidth = "2" >
{ right && < line x1 = { here . x } y1 = { here . y } x2 = { right . x } y2 = { right . y } / > }
{ down && < line x1 = { here . x } y1 = { here . y } x2 = { down . x } y2 = { down . y } / > }
< / g >
)
} ) ) }
{ ( [ 'RMC1' , 'RMC2' ] as RobotId [ ] ) . map ( ( id ) = > {
const rover = state . robots [ id ]
const color = id === 'RMC1' ? '#22d3ee' : '#f59e0b'
const center = worldToSvg ( rover . pose )
const direction = rover . pose . yaw - Math . PI / 2
return (
< g key = { id } opacity = { id === selected ? 1 : 0.55 } >
{ rover . trail . length > 1 && < polyline points = { points ( rover . trail ) } fill = "none" stroke = { color } strokeWidth = "3" opacity = "0.45" / > }
{ rover . plan . length > 1 && < polyline points = { points ( rover . plan ) } fill = "none" stroke = "#a78bfa" strokeWidth = "4" strokeDasharray = "10 7" / > }
{ rover . lidar . map ( ( point , index ) = > {
const p = worldToSvg ( point )
return < circle key = { ` ${ id } -lidar- ${ index } ` } cx = { p . x } cy = { p . y } r = "2.2" fill = { color } opacity = "0.55" / >
} ) }
{ rover . goal && ( ( ) = > {
const goal = worldToSvg ( rover . goal )
return < g > < circle cx = { goal . x } cy = { goal . y } r = "12" fill = "none" stroke = "#a78bfa" strokeWidth = "3" / > < line x1 = { goal . x - 16 } y1 = { goal . y } x2 = { goal . x + 16 } y2 = { goal . y } stroke = "#a78bfa" / > < line x1 = { goal . x } y1 = { goal . y - 16 } x2 = { goal . x } y2 = { goal . y + 16 } stroke = "#a78bfa" / > < / g >
} ) ( ) }
< g transform = { ` translate( ${ center . x } ${ center . y } ) rotate( ${ direction * 180 / Math . PI } ) ` } >
< circle r = { id === selected ? 18 : 14 } fill = { color } stroke = "#f8fafc" strokeWidth = { id === selected ? 3 : 1 } / >
< path d = "M 0 -25 L -7 -10 L 7 -10 Z" fill = "#f8fafc" / >
< / g >
< text x = { center . x + 21 } y = { center . y - 18 } fill = { color } fontSize = "14" fontWeight = "700" > { id } < / text >
< / g >
)
} ) }
{ markers . map ( ( marker ) = > {
const point = worldToSvg ( marker )
const current = state . robots . RMC2 . arucoId === String ( marker . id )
return (
< g key = { marker . id } >
< rect x = { point . x - 11 } y = { point . y - 11 } width = "22" height = "22" rx = "4" fill = { current ? '#f59e0b' : '#0f172a' } stroke = { current ? '#fbbf24' : '#475569' } strokeWidth = "2" / >
< text x = { point . x } y = { point . y + 4 } textAnchor = "middle" fill = "#f8fafc" fontSize = "10" > { marker . id } < / text >
< / g >
)
} ) }
< / svg >
< div className = "absolute left-3 top-3 rounded-lg bg-slate-950/80 px-3 py-2 text-xs text-slate-300 backdrop-blur" >
ArUco : шаг 1 м · X в н и з · Y в п р а в о
< / div >
< div className = "absolute bottom-3 right-3 flex gap-3 rounded-lg bg-slate-950/80 px-3 py-2 text-xs backdrop-blur" >
< span className = "text-cyan-300" > ● RMC1 < / span > < span className = "text-amber-300" > ● RMC2 < / span > < span className = "text-violet-300" > -- м а р ш р у т < / span >
< / div >
< / div >
)
}
function ManualControls ( { robot , emergency , send } : { robot : RobotId ; emergency : boolean ; send : ( command : Command ) = > void } ) {
const timer = useRef < ReturnType < typeof setInterval > | null > ( null )
const stop = useCallback ( ( ) = > {
if ( timer . current ) clearInterval ( timer . current )
timer . current = null
send ( { type : 'stop' , robot } )
} , [ robot , send ] )
const start = ( x : number , y : number , yaw : number ) = > {
if ( emergency ) return
if ( timer . current ) clearInterval ( timer . current )
const command : Command = { type : 'manual' , robot , x , y , yaw }
send ( command )
timer . current = setInterval ( ( ) = > send ( command ) , 120 )
}
useEffect ( ( ) = > stop , [ stop ] )
const button = 'select-none rounded-lg border border-slate-600 bg-slate-800 px-3 py-3 font-semibold text-slate-100 hover:bg-slate-700 active:bg-cyan-700 disabled:cursor-not-allowed disabled:opacity-40'
const handlers = ( x : number , y : number , yaw : number ) = > ( {
onPointerDown : ( event : React.PointerEvent < HTMLButtonElement > ) = > {
event . currentTarget . setPointerCapture ( event . pointerId )
start ( x , y , yaw )
} ,
onPointerUp : stop ,
onPointerCancel : stop ,
} )
return (
< div className = "grid grid-cols-3 gap-2" >
< button className = { button } disabled = { robot === 'RMC2' || emergency } { ...handlers ( 0 , 0.35 , 0 ) } > ↖ б о к < / button >
< button className = { button } disabled = { emergency } { ...handlers ( 0.45 , 0 , 0 ) } > ↑ W < / button >
< button className = { button } disabled = { robot === 'RMC2' || emergency } { ...handlers ( 0 , - 0.35 , 0 ) } > б о к ↗ < / button >
< button className = { button } disabled = { emergency } { ...handlers ( 0 , 0 , 0.75 ) } > ↶ A < / button >
< button className = { ` ${ button } border-red-700 bg-red-950 text-red-200 ` } onClick = { stop } > STOP < / button >
< button className = { button } disabled = { emergency } { ...handlers ( 0 , 0 , - 0.75 ) } > D ↷ < / button >
< button className = { button } disabled = { robot === 'RMC2' || emergency } { ...handlers ( 0 , - 0.35 , 0 ) } > ↙ б о к < / button >
< button className = { button } disabled = { emergency } { ...handlers ( - 0.45 , 0 , 0 ) } > ↓ S < / button >
< button className = { button } disabled = { robot === 'RMC2' || emergency } { ...handlers ( 0 , 0.35 , 0 ) } > б о к ↘ < / button >
< / div >
)
}
export default function App() {
const [ state , setState ] = useState < FmsState > ( initialState )
const [ selected , setSelected ] = useState < RobotId > ( 'RMC1' )
const [ socketOnline , setSocketOnline ] = useState ( false )
const [ message , setMessage ] = useState ( 'Подключение…' )
const [ poseForm , setPoseForm ] = useState < Pose > ( { x : 0 , y : 0 , yaw : 0 } )
const [ goalForm , setGoalForm ] = useState < Pose > ( { x : - 2 , y : 3 , yaw : 0 } )
const send = useCallback ( async ( command : Command ) = > {
try {
const response = await fetch ( '/api/command' , {
method : 'POST' ,
headers : { 'content-type' : 'application/json' } ,
body : JSON.stringify ( command ) ,
} )
const result = await response . json ( ) as { ok : boolean ; error? : string }
setMessage ( result . ok ? ` Команда: ${ command . type } ` : ` Ошибка: ${ result . error } ` )
} catch ( error ) {
setMessage ( ` API недоступен: ${ String ( error ) } ` )
}
} , [ ] )
useEffect ( ( ) = > {
let socket : WebSocket | null = null
let retry : ReturnType < typeof setTimeout >
let disposed = false
const connect = ( ) = > {
const protocol = location . protocol === 'https:' ? 'wss:' : 'ws:'
socket = new WebSocket ( ` ${ protocol } // ${ location . host } /ws ` )
socket . onopen = ( ) = > { setSocketOnline ( true ) ; setMessage ( 'Телеметрия подключена' ) }
socket . onmessage = ( event ) = > {
try {
const data = JSON . parse ( event . data ) as FmsState
if ( data . type === 'state' ) setState ( data )
} catch { /* ignore malformed packet */ }
}
socket . onclose = ( ) = > {
setSocketOnline ( false )
if ( ! disposed ) retry = setTimeout ( connect , 1200 )
}
}
connect ( )
return ( ) = > { disposed = true ; clearTimeout ( retry ) ; socket ? . close ( ) }
} , [ ] )
useEffect ( ( ) = > {
const pressed = new Set < string > ( )
const movement = ( ) = > {
let x = 0 , y = 0 , yaw = 0
if ( pressed . has ( 'w' ) ) x += 0.45
if ( pressed . has ( 's' ) ) x -= 0.45
if ( pressed . has ( 'a' ) ) yaw += 0.75
if ( pressed . has ( 'd' ) ) yaw -= 0.75
if ( selected === 'RMC1' && pressed . has ( 'q' ) ) y += 0.35
if ( selected === 'RMC1' && pressed . has ( 'e' ) ) y -= 0.35
if ( x || y || yaw ) send ( { type : 'manual' , robot : selected , x , y , yaw } )
else send ( { type : 'stop' , robot : selected } )
}
const down = ( event : KeyboardEvent ) = > {
if ( ( event . target as HTMLElement ) . tagName === 'INPUT' ) return
const key = event . key . toLowerCase ( )
if ( ! [ 'w' , 'a' , 's' , 'd' , 'q' , 'e' ] . includes ( key ) ) return
event . preventDefault ( )
pressed . add ( key )
movement ( )
}
const up = ( event : KeyboardEvent ) = > {
const key = event . key . toLowerCase ( )
if ( ! pressed . delete ( key ) ) return
movement ( )
}
const blur = ( ) = > { pressed . clear ( ) ; movement ( ) }
const repeat = setInterval ( ( ) = > { if ( pressed . size ) movement ( ) } , 120 )
window . addEventListener ( 'keydown' , down )
window . addEventListener ( 'keyup' , up )
window . addEventListener ( 'blur' , blur )
return ( ) = > {
clearInterval ( repeat )
window . removeEventListener ( 'keydown' , down )
window . removeEventListener ( 'keyup' , up )
window . removeEventListener ( 'blur' , blur )
}
} , [ selected , send ] )
const rover = state . robots [ selected ]
const fresh = Date . now ( ) - state . timestamp < 1500
const setForm = ( setter : React.Dispatch < React.SetStateAction < Pose > > , key : keyof Pose , value : number ) = > setter ( ( current ) = > ( { . . . current , [ key ] : value } ) )
return (
< main className = "min-h-screen bg-slate-950 p-3 text-slate-100 lg:p-5" >
< header className = "mx-auto mb-4 flex max-w-[1600px] flex-wrap items-center justify-between gap-3" >
< div > < h1 className = "text-xl font-bold" > MVCH FMS · м о д у л ь Г < / h1 > < p className = "text-sm text-slate-400" > У п р а в л е н и е , н а в и г а ц и я и т е л е м е т р и я RMC1 / RMC2 < / p > < / div >
< div className = "flex flex-wrap items-center gap-2 text-xs" >
< span className = { ` rounded-full px-3 py-1.5 ${ socketOnline && fresh ? 'bg-emerald-950 text-emerald-300' : 'bg-red-950 text-red-300' } ` } > WebSocket : { socketOnline && fresh ? 'online' : 'offline' } < / span >
< span className = { ` rounded-full px-3 py-1.5 ${ state . bridgeOnline ? 'bg-emerald-950 text-emerald-300' : 'bg-amber-950 text-amber-300' } ` } > Bridge : { state . bridgeOnline ? state . mode : 'offline' } < / span >
< span className = "max-w-sm truncate rounded-full bg-slate-800 px-3 py-1.5 text-slate-300" > { state . bridgeError ? ? message } < / span >
< / div >
< / header >
< div className = "mx-auto grid max-w-[1600px] gap-4 xl:grid-cols-[minmax(600px,1fr)_420px]" >
< div className = "grid gap-4" >
< div className = "flex gap-2" >
{ ( [ 'RMC1' , 'RMC2' ] as RobotId [ ] ) . map ( ( id ) = > (
< button key = { id } onClick = { ( ) = > { setSelected ( id ) ; setPoseForm ( state . robots [ id ] . pose ) } } className = { ` flex-1 rounded-xl border px-4 py-3 text-left ${ selected === id ? 'border-cyan-500 bg-cyan-950/60' : 'border-slate-700 bg-slate-900' } ` } >
< span className = "font-bold" > { id } < / span >
< span className = { ` ml-3 text-xs ${ state . robots [ id ] . online ? 'text-emerald-400' : 'text-red-400' } ` } > { state . robots [ id ] . online ? 'ROS online' : 'нет данных' } < / span >
< / button >
) ) }
< / div >
< FieldMap state = { state } selected = { selected } / >
< Card title = "Состояние двух роверов" >
< div className = "grid grid-cols-2 gap-3 text-sm lg:grid-cols-4" >
{ ( [ 'RMC1' , 'RMC2' ] as RobotId [ ] ) . map ( ( id ) = > < div key = { id } className = "rounded-lg bg-slate-950 p-3" > < strong > { id } < / strong > < div className = "mt-1 text-slate-400" > x { format ( state . robots [ id ] . pose . x ) } · y { format ( state . robots [ id ] . pose . y ) } < / div > < div className = "text-slate-400" > yaw { format ( state . robots [ id ] . pose . yaw ) } rad < / div > < / div > ) }
< div className = "rounded-lg bg-slate-950 p-3" > < strong > К а р т а < / strong > < div className = "mt-1 text-slate-400" > { state . map ? ` ${ state . map . width } × ${ state . map . height } ` : 'нет /map' } < / div > < / div >
< div className = "rounded-lg bg-slate-950 p-3" > < strong > О б н о в л е н и е < / strong > < div className = "mt-1 text-slate-400" > { state . timestamp ? new Date ( state . timestamp ) . toLocaleTimeString ( ) : '—' } < / div > < / div >
< / div >
< / Card >
< / div >
< aside className = "grid content-start gap-4" >
< Card title = { ` Ручное управление ${ selected } ` } >
< ManualControls robot = { selected } emergency = { rover . emergency } send = { send } / >
< p className = "mt-2 text-xs text-slate-500" > Клавиши : W / S — х о д , A / D — п о в о р о т , Q / E — б о к о в о й х о д RMC1 . П р и о т п у с к а н и и о т п р а в л я е т с я STOP . < / p >
< / Card >
< button onClick = { ( ) = > send ( { type : 'emergency' , robot : selected , active : ! rover . emergency } ) } className = { ` rounded-xl border-2 px-5 py-5 text-lg font-black tracking-wide ${ rover . emergency ? 'border-emerald-500 bg-emerald-950 text-emerald-300' : 'border-red-500 bg-red-950 text-red-200' } ` } >
{ rover . emergency ? 'СНЯТЬ АВАРИЙНЫЙ СТОП' : 'АВАРИЙНЫЙ СТОП' }
< / button >
< Card title = "Телеметрия" >
< dl className = "grid grid-cols-2 gap-x-4 gap-y-2 text-sm" >
< dt className = "text-slate-400" > П о з и ц и я < / dt > < dd > x { format ( rover . pose . x ) } · y { format ( rover . pose . y ) } < / dd >
< dt className = "text-slate-400" > О р и е н т а ц и я < / dt > < dd > { format ( rover . pose . yaw ) } rad < / dd >
< dt className = "text-slate-400" > С к о р о с т ь < / dt > < dd > { format ( rover . velocity . x ) } м / с < / dd >
< dt className = "text-slate-400" > Л и д а р < / dt > < dd > { rover . lidar . length } т о ч е к < / dd >
< dt className = "text-slate-400" > А к к у м у л я т о р < / dt > < dd > { rover . battery == null ? 'нет топика' : ` ${ format ( rover . battery ) } В ` } < / dd >
< dt className = "text-slate-400" > ArUco < / dt > < dd > { rover . arucoId ? ? '—' } < / dd >
< dt className = "text-slate-400" > И н с т р у м е н т < / dt > < dd > { selected === 'RMC1' ? rover . gripper : ( rover . liftStatus ? ? '—' ) } < / dd >
< dt className = "text-slate-400" > В о з р а с т д а н н ы х < / dt > < dd > { rover . lastMessageMs == null ? '—' : ` ${ rover . lastMessageMs } мс ` } < / dd >
< / dl >
< / Card >
< Card title = "Установить текущее положение" >
< div className = "grid grid-cols-3 gap-2" >
< NumberField label = "X, м" value = { poseForm . x } onChange = { ( value ) = > setForm ( setPoseForm , 'x' , value ) } / >
< NumberField label = "Y, м" value = { poseForm . y } onChange = { ( value ) = > setForm ( setPoseForm , 'y' , value ) } / >
< NumberField label = "Yaw, рад" value = { poseForm . yaw } onChange = { ( value ) = > setForm ( setPoseForm , 'yaw' , value ) } / >
< / div >
< button onClick = { ( ) = > send ( { type : 'set_pose' , robot : selected , pose : poseForm } ) } className = "mt-3 w-full rounded-lg bg-slate-700 px-4 py-2 font-semibold hover:bg-slate-600" > П р и м е н и т ь б е з д в и ж е н и я < / button >
< / Card >
< Card title = "Автономная цель Nav2" >
< div className = "grid grid-cols-3 gap-2" >
< NumberField label = "X, м" value = { goalForm . x } onChange = { ( value ) = > setForm ( setGoalForm , 'x' , value ) } / >
< NumberField label = "Y, м" value = { goalForm . y } onChange = { ( value ) = > setForm ( setGoalForm , 'y' , value ) } / >
< NumberField label = "Yaw, рад" value = { goalForm . yaw } onChange = { ( value ) = > setForm ( setGoalForm , 'yaw' , value ) } / >
< / div >
< div className = "mt-3 grid grid-cols-2 gap-2" >
< button disabled = { rover . emergency } onClick = { ( ) = > send ( { type : 'set_goal' , robot : selected , pose : goalForm } ) } className = "rounded-lg bg-violet-700 px-3 py-2 font-semibold hover:bg-violet-600 disabled:opacity-40" > Е х а т ь к т о ч к е < / button >
< button onClick = { ( ) = > send ( { type : 'cancel_goal' , robot : selected } ) } className = "rounded-lg bg-slate-700 px-3 py-2 font-semibold hover:bg-slate-600" > О т м е н и т ь < / button >
< / div >
< / Card >
< Card title = "Рабочий инструмент" >
{ selected === 'RMC1' ? (
< div className = "grid grid-cols-2 gap-2" > < button disabled = { rover . emergency } onClick = { ( ) = > send ( { type : 'gripper' , robot : 'RMC1' , state : 'open' } ) } className = "rounded-lg bg-cyan-800 px-3 py-2 disabled:opacity-40" > О т к р ы т ь с х в а т < / button > < button disabled = { rover . emergency } onClick = { ( ) = > send ( { type : 'gripper' , robot : 'RMC1' , state : 'closed' } ) } className = "rounded-lg bg-cyan-800 px-3 py-2 disabled:opacity-40" > З а к р ы т ь с х в а т < / button > < / div >
) : (
< div className = "grid grid-cols-2 gap-2" > < button disabled = { rover . emergency } onClick = { ( ) = > send ( { type : 'lift' , robot : 'RMC2' , height : 0.1 } ) } className = "rounded-lg bg-amber-800 px-3 py-2 disabled:opacity-40" > П о д н я т ь л и ф т < / button > < button disabled = { rover . emergency } onClick = { ( ) = > send ( { type : 'lift' , robot : 'RMC2' , height : 0 } ) } className = "rounded-lg bg-amber-800 px-3 py-2 disabled:opacity-40" > О п у с т и т ь л и ф т < / button > < / div >
) }
< / Card >
< / aside >
< / div >
< / main >
)
}