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 (

{title}

{children}
) } function NumberField({ label, value, onChange }: { label: string; value: number; onChange: (value: number) => void }) { return ( ) } 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 (
{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 ( 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 ( {right && } {down && } ) }))} {(['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 ( {rover.trail.length > 1 && } {rover.plan.length > 1 && } {rover.lidar.map((point, index) => { const p = worldToSvg(point) return })} {rover.goal && (() => { const goal = worldToSvg(rover.goal) return })()} {id} ) })} {markers.map((marker) => { const point = worldToSvg(marker) const current = state.robots.RMC2.arucoId === String(marker.id) return ( {marker.id} ) })}
ArUco: поле {fieldSize}×{fieldSize} - шаг 1 м - X вниз - Y вправо
RMC1RMC2 - - - маршрут
) } function ManualControls({ robot, emergency, send }: { robot: RobotId; emergency: boolean; send: (command: Command) => void }) { const timer = useRef | 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) => { event.currentTarget.setPointerCapture(event.pointerId) start(x, y, yaw) }, onPointerUp: stop, onPointerCancel: stop, }) return (
) } export default function App() { const [state, setState] = useState(initialState) const [selected, setSelected] = useState('RMC1') const [socketOnline, setSocketOnline] = useState(false) const [message, setMessage] = useState('Подключение…') const [poseForm, setPoseForm] = useState({ x: 0, y: 0, yaw: 0 }) const [goalForm, setGoalForm] = useState({ 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 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() 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>, key: keyof Pose, value: number) => setter((current) => ({ ...current, [key]: value })) return (

Веб-Панель - модуль Г

Управление, навигация и телеметрия RMC1 / RMC2

WebSocket: {socketOnline && fresh ? 'online' : 'offline'} Bridge: {state.bridgeOnline ? state.mode : 'offline'} {state.bridgeError ?? message}
{(['RMC1', 'RMC2'] as RobotId[]).map((id) => ( ))}
{(['RMC1', 'RMC2'] as RobotId[]).map((id) =>
{id}
x {format(state.robots[id].pose.x)} · y {format(state.robots[id].pose.y)}
yaw {format(state.robots[id].pose.yaw)} rad
)}
Карта
{state.map ? `${state.map.width} × ${state.map.height}` : 'нет /map'}
Обновление
{state.timestamp ? new Date(state.timestamp).toLocaleTimeString() : '—'}
) }