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 (
)
}
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 (
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() : '—'}
)
}