Files
chvt-bak/module4/src/App.tsx
T

398 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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',
fieldSize: 5,
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={` 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 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 fieldSize = state.fieldSize || 5
const markers = Array.from({ length: fieldSize ** 2 }, (_, id) => ({
id,
x: -Math.floor(id / fieldSize),
y: id % fieldSize,
}))
return (
<div className="relative aspect-square min-h-105 overflow-hidden border border-slate-700 bg-gray-500">
<svg viewBox={`0 0 ${SVG_SIZE} ${SVG_SIZE}`} className="h-full w-full" aria-label="Карта поля и телеметрия роверов">
<rect width={SVG_SIZE} height={SVG_SIZE} />
{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: fieldSize }, (_, row) => Array.from({ length: fieldSize }, (_, col) => {
const here = worldToSvg({ x: -row, y: col })
const right = col < fieldSize - 1 ? worldToSvg({ x: -row, y: col + 1 }) : null
const down = row < fieldSize - 1 ? 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 px-3 py-2 text-xs text-slate-300 backdrop-blur">
ArUco: поле {fieldSize}×{fieldSize} - шаг 1 м - X вниз - Y вправо
</div>
<div className="absolute bottom-3 right-3 flex gap-3 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 border border-slate-600 bg-slate-800 px-3 py-3 font-semibold text-slate-100 hover:bg-slate-700 active:bg-slate-600 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-indigo-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">Веб-Панель - модуль Г</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={`px-3 py-1.5 ${socketOnline && fresh ? 'bg-emerald-950 text-emerald-300' : 'bg-red-950'}`}>WebSocket: {socketOnline && fresh ? 'online' : 'offline'}</span>
<span className={`px-3 py-1.5 ${state.bridgeOnline ? 'bg-emerald-950 text-emerald-300' : 'bg-amber-950'}`}>Bridge: {state.bridgeOnline ? state.mode : 'offline'}</span>
<span className="max-w-sm truncate 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={`cursor-pointer flex-1 border px-4 py-3 text-left ${selected === id ? 'border-violet-500' : '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=" 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=" 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=" 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={`border px-5 py-5 text-lg font-black tracking-wide ${rover.emergency ? 'border-emerald-500 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 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=" 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=" 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=" bg-cyan-800 px-3 py-2 disabled:opacity-40">Открыть схват</button><button disabled={rover.emergency} onClick={() => send({ type: 'gripper', robot: 'RMC1', state: 'closed' })} className=" 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=" bg-amber-800 px-3 py-2 disabled:opacity-40">Поднять лифт</button><button disabled={rover.emergency} onClick={() => send({ type: 'lift', robot: 'RMC2', height: 0 })} className=" bg-amber-800 px-3 py-2 disabled:opacity-40">Опустить лифт</button></div>
)}
</Card>
</aside>
</div>
</main>
)
}