添加单车底盘仿真平台并完善运动控制与夹臂功能
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>停车机器人离线测试台</title>
|
||||
<link rel="stylesheet" href="viewer.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="app-shell">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">MY PARKING · OFFLINE LAB</p>
|
||||
<h1>停车机器人离线测试台</h1>
|
||||
</div>
|
||||
<div class="connection">
|
||||
<span id="connection-dot" class="dot"></span>
|
||||
<span id="connection-text">正在连接仿真后端</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="workspace">
|
||||
<aside class="panel controls-panel">
|
||||
<div class="panel-heading">
|
||||
<span>控制台</span>
|
||||
<button id="reset-all" class="text-button">全部复位</button>
|
||||
</div>
|
||||
|
||||
<label class="field-label" for="vehicle-select">当前车辆</label>
|
||||
<select id="vehicle-select">
|
||||
<option value="1">车辆 1</option>
|
||||
</select>
|
||||
|
||||
<section class="virtual-remote">
|
||||
<div class="remote-heading">
|
||||
<p class="group-label">虚拟遥控器</p>
|
||||
<span id="remote-state">已松开</span>
|
||||
</div>
|
||||
<div class="remote-pad" aria-label="虚拟遥控方向面板">
|
||||
<button data-throttle="1" data-steering="1"
|
||||
aria-label="前进并左转">↖</button>
|
||||
<button data-throttle="1" data-steering="0"
|
||||
aria-label="前进">↑</button>
|
||||
<button data-throttle="1" data-steering="-1"
|
||||
aria-label="前进并右转">↗</button>
|
||||
<button data-throttle="0" data-steering="1"
|
||||
aria-label="原地向左转">←</button>
|
||||
<button id="remote-stop" class="remote-stop"
|
||||
aria-label="停止">STOP</button>
|
||||
<button data-throttle="0" data-steering="-1"
|
||||
aria-label="原地向右转">→</button>
|
||||
<button data-throttle="-1" data-steering="1"
|
||||
aria-label="后退并左转">↙</button>
|
||||
<button data-throttle="-1" data-steering="0"
|
||||
aria-label="后退">↓</button>
|
||||
<button data-throttle="-1" data-steering="-1"
|
||||
aria-label="后退并右转">↘</button>
|
||||
</div>
|
||||
<p class="remote-help">
|
||||
按住操作;键盘 W/S 前后,A/D 转向,可组合按键。
|
||||
</p>
|
||||
<label class="remote-slider">
|
||||
<span>线速度 <output id="speed-scale-value">60%</output></span>
|
||||
<input id="speed-scale" type="range"
|
||||
min="0" max="100" value="60">
|
||||
</label>
|
||||
<label class="remote-slider">
|
||||
<span>转向强度 <output id="steering-scale-value">60%</output></span>
|
||||
<input id="steering-scale" type="range"
|
||||
min="0" max="100" value="60">
|
||||
</label>
|
||||
<p class="remote-mode-note">
|
||||
遥控方向遵循当前舵轮模式;正常模式下可边走边转。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div id="action-groups"></div>
|
||||
|
||||
<div id="command-message" class="message">
|
||||
请选择车辆和模式。
|
||||
</div>
|
||||
|
||||
<details class="configuration">
|
||||
<summary>仿真布局配置</summary>
|
||||
|
||||
<label class="field-label" for="vehicle-count">车辆数量</label>
|
||||
<select id="vehicle-count">
|
||||
<option value="1">1 辆</option>
|
||||
<option value="2">2 辆</option>
|
||||
<option value="3">3 辆</option>
|
||||
<option value="4">4 辆</option>
|
||||
<option value="5">5 辆</option>
|
||||
<option value="6">6 辆</option>
|
||||
<option value="7">7 辆</option>
|
||||
<option value="8">8 辆</option>
|
||||
</select>
|
||||
|
||||
<p class="group-label">车队中心(世界系)</p>
|
||||
<div class="numeric-grid">
|
||||
<label>X / m<input id="fleet-x" type="number" step="0.1"></label>
|
||||
<label>Y / m<input id="fleet-y" type="number" step="0.1"></label>
|
||||
<label>Yaw / °<input id="fleet-yaw" type="number" step="1"></label>
|
||||
</div>
|
||||
|
||||
<p class="group-label">单车相对车队中心位姿</p>
|
||||
<div id="layout-rows" class="layout-rows"></div>
|
||||
<button id="apply-configuration" class="primary-button">
|
||||
应用布局并复位
|
||||
</button>
|
||||
</details>
|
||||
|
||||
<div class="legend">
|
||||
<div><span class="axis-swatch x-axis"></span>车体 +X(车头)</div>
|
||||
<div><span class="axis-swatch y-axis"></span>车体 +Y(左侧)</div>
|
||||
<div><span class="legend-line zero"></span>舵轮机械 0°</div>
|
||||
<div><span class="legend-line actual"></span>实际舵角</div>
|
||||
<div><span class="legend-line target"></span>目标舵角</div>
|
||||
<div><span class="legend-dot ready"></span>舵轮已到位</div>
|
||||
<div><span class="legend-dot aligning"></span>舵轮转向中</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="panel viewport-panel">
|
||||
<div class="panel-heading">
|
||||
<span>世界视图</span>
|
||||
<span class="coordinate-note">X 向右 · Y 向上 · 逆时针为正</span>
|
||||
</div>
|
||||
<canvas id="world-canvas"></canvas>
|
||||
</section>
|
||||
|
||||
<aside class="panel status-panel">
|
||||
<div class="panel-heading">
|
||||
<span>车辆状态</span>
|
||||
<span id="update-rate">-- Hz</span>
|
||||
</div>
|
||||
<div id="selected-vehicle-detail"></div>
|
||||
<div id="vehicle-cards" class="vehicle-cards"></div>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="viewer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,603 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: Inter, "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||
background: #050a0e;
|
||||
color: #dbe9ef;
|
||||
--panel: rgba(13, 24, 32, 0.92);
|
||||
--border: rgba(116, 151, 168, 0.19);
|
||||
--muted: #78909d;
|
||||
--accent: #42dfb7;
|
||||
--warning: #ffb257;
|
||||
--danger: #ff6577;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 1100px;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(circle at 25% 0%,
|
||||
rgba(32, 117, 106, 0.16), transparent 34%),
|
||||
linear-gradient(145deg, #071018, #030609 72%);
|
||||
}
|
||||
|
||||
button,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
max-width: 1800px;
|
||||
margin: 0 auto 18px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 7px;
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 26px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.connection {
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
align-items: center;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--danger);
|
||||
box-shadow: 0 0 12px rgba(255, 101, 119, 0.45);
|
||||
}
|
||||
|
||||
.dot.online {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 12px rgba(66, 223, 183, 0.5);
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: 320px minmax(600px, 1fr) 340px;
|
||||
gap: 14px;
|
||||
max-width: 1800px;
|
||||
min-height: calc(100vh - 112px);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.panel {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
background: var(--panel);
|
||||
box-shadow: 0 20px 55px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 48px;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: #bcd0d9;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.controls-panel {
|
||||
max-height: calc(100vh - 112px);
|
||||
overflow-y: auto;
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
|
||||
.controls-panel > :not(.panel-heading) {
|
||||
margin-right: 16px;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.text-button {
|
||||
width: auto;
|
||||
padding: 4px 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.field-label,
|
||||
.group-label {
|
||||
display: block;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
select,
|
||||
.button-grid button,
|
||||
.drive-pad button {
|
||||
border: 1px solid rgba(126, 164, 182, 0.23);
|
||||
border-radius: 8px;
|
||||
background: #101e27;
|
||||
color: #dbe9ef;
|
||||
}
|
||||
|
||||
select {
|
||||
width: calc(100% - 32px);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.virtual-remote {
|
||||
margin-top: 18px !important;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: rgba(5, 12, 17, 0.58);
|
||||
}
|
||||
|
||||
.remote-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.remote-heading .group-label {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#remote-state {
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
#remote-state.active {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.remote-pad {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 54px);
|
||||
gap: 7px;
|
||||
justify-content: center;
|
||||
margin: 12px 0 8px;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.remote-pad button {
|
||||
width: 54px;
|
||||
height: 45px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(126, 164, 182, 0.3);
|
||||
border-radius: 9px;
|
||||
background: #101e27;
|
||||
color: #dbe9ef;
|
||||
cursor: pointer;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.remote-pad button:hover,
|
||||
.remote-pad button.pressed {
|
||||
border-color: var(--accent);
|
||||
background: rgba(66, 223, 183, 0.18);
|
||||
color: #78f0d5;
|
||||
}
|
||||
|
||||
.remote-pad .remote-stop {
|
||||
border-color: rgba(255, 101, 119, 0.45);
|
||||
color: #ff98a5;
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.remote-help,
|
||||
.remote-mode-note {
|
||||
margin: 7px 0 0;
|
||||
color: #718a96;
|
||||
font-size: 9px;
|
||||
line-height: 1.45;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.remote-slider {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin-top: 10px;
|
||||
color: #8fa8b4;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.remote-slider span {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.remote-slider output {
|
||||
color: var(--accent);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.remote-slider input {
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.action-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.action-grid button {
|
||||
min-height: 38px;
|
||||
border: 1px solid rgba(126, 164, 182, 0.23);
|
||||
border-radius: 8px;
|
||||
background: #101e27;
|
||||
color: #dbe9ef;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 120ms,
|
||||
background 120ms,
|
||||
transform 120ms;
|
||||
}
|
||||
|
||||
.action-grid button:hover {
|
||||
border-color: var(--accent);
|
||||
background: #142c31;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.action-grid .danger-button {
|
||||
border-color: rgba(255, 101, 119, 0.45);
|
||||
color: #ff98a5;
|
||||
}
|
||||
|
||||
.configuration {
|
||||
margin-top: 18px !important;
|
||||
border-top: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 13px 0 16px;
|
||||
}
|
||||
|
||||
.configuration summary {
|
||||
color: #bcd0d9;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.configuration select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.numeric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.numeric-grid label,
|
||||
.layout-row label {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 7px 5px;
|
||||
border: 1px solid rgba(126, 164, 182, 0.23);
|
||||
border-radius: 6px;
|
||||
outline: 0;
|
||||
background: #0b171f;
|
||||
color: #dbe9ef;
|
||||
font: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.layout-rows {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
max-height: 275px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.layout-row {
|
||||
display: grid;
|
||||
grid-template-columns: 48px repeat(3, 1fr);
|
||||
gap: 5px;
|
||||
align-items: end;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
background: rgba(5, 12, 17, 0.6);
|
||||
}
|
||||
|
||||
.layout-row strong {
|
||||
align-self: center;
|
||||
color: #9cb2bd;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
padding: 9px;
|
||||
border: 1px solid rgba(66, 223, 183, 0.5);
|
||||
border-radius: 7px;
|
||||
background: rgba(66, 223, 183, 0.12);
|
||||
color: #78f0d5;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.message {
|
||||
min-height: 56px;
|
||||
margin-top: 18px !important;
|
||||
padding: 11px;
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 4px;
|
||||
background: rgba(66, 223, 183, 0.07);
|
||||
color: #a8c4cf;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
border-left-color: var(--danger);
|
||||
background: rgba(255, 101, 119, 0.08);
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 22px !important;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.legend > div {
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.legend-line {
|
||||
width: 24px;
|
||||
height: 0;
|
||||
border-top: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
.legend-line.target {
|
||||
border-top-style: dashed;
|
||||
border-top-color: var(--warning);
|
||||
}
|
||||
|
||||
.legend-line.zero {
|
||||
border-top-width: 1px;
|
||||
border-top-style: dashed;
|
||||
border-top-color: #56a7ff;
|
||||
}
|
||||
|
||||
.axis-swatch {
|
||||
width: 24px;
|
||||
height: 3px;
|
||||
background: #ff6477;
|
||||
}
|
||||
|
||||
.axis-swatch.y-axis {
|
||||
background: #54d991;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.legend-dot.aligning {
|
||||
background: var(--warning);
|
||||
}
|
||||
|
||||
.viewport-panel {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.coordinate-note,
|
||||
#update-rate {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#world-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: calc(100% - 48px);
|
||||
min-height: 620px;
|
||||
}
|
||||
|
||||
.vehicle-cards {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.status-panel {
|
||||
max-height: calc(100vh - 112px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wheel-detail {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.geometry-note {
|
||||
margin-bottom: 11px;
|
||||
color: #8fa8b4;
|
||||
font-size: 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.wheel-row {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.wheel-row-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
color: #9eb4be;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.wheel-row-title span {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.angle-scale {
|
||||
position: relative;
|
||||
height: 8px;
|
||||
margin: 6px 0 3px;
|
||||
border-radius: 4px;
|
||||
background:
|
||||
linear-gradient(90deg,
|
||||
rgba(181, 140, 255, 0.42),
|
||||
rgba(66, 223, 183, 0.15) 50%,
|
||||
rgba(181, 140, 255, 0.42));
|
||||
}
|
||||
|
||||
.zero-mark,
|
||||
.target-mark,
|
||||
.actual-mark {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
width: 2px;
|
||||
height: 14px;
|
||||
transform: translateX(-1px);
|
||||
}
|
||||
|
||||
.zero-mark {
|
||||
left: 50%;
|
||||
background: #56a7ff;
|
||||
}
|
||||
|
||||
.target-mark {
|
||||
background: var(--warning);
|
||||
}
|
||||
|
||||
.actual-mark {
|
||||
width: 3px;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.wheel-speed {
|
||||
color: #617b88;
|
||||
font-size: 9px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.vehicle-card {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 9px;
|
||||
background: rgba(9, 18, 24, 0.78);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.vehicle-card.selected {
|
||||
border-color: rgba(66, 223, 183, 0.7);
|
||||
background: rgba(36, 120, 105, 0.11);
|
||||
}
|
||||
|
||||
.card-topline {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-ready {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.status-aligning {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.mode-name {
|
||||
margin: 7px 0 9px;
|
||||
color: #91a9b5;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
dl {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
dl div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
dt {
|
||||
color: #607985;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
color: #bcd0d9;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@media (max-width: 1300px) {
|
||||
body {
|
||||
min-width: 1100px;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
grid-template-columns: 285px minmax(520px, 1fr) 305px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,842 @@
|
||||
const canvas = document.getElementById("world-canvas");
|
||||
const context = canvas.getContext("2d");
|
||||
const vehicleSelect = document.getElementById("vehicle-select");
|
||||
const commandMessage = document.getElementById("command-message");
|
||||
const connectionDot = document.getElementById("connection-dot");
|
||||
const connectionText = document.getElementById("connection-text");
|
||||
const vehicleCards = document.getElementById("vehicle-cards");
|
||||
const selectedVehicleDetail =
|
||||
document.getElementById("selected-vehicle-detail");
|
||||
const actionGroups = document.getElementById("action-groups");
|
||||
const updateRate = document.getElementById("update-rate");
|
||||
const vehicleCountInput = document.getElementById("vehicle-count");
|
||||
const layoutRows = document.getElementById("layout-rows");
|
||||
const remoteState = document.getElementById("remote-state");
|
||||
const speedScaleInput = document.getElementById("speed-scale");
|
||||
const steeringScaleInput =
|
||||
document.getElementById("steering-scale");
|
||||
const speedScaleValue =
|
||||
document.getElementById("speed-scale-value");
|
||||
const steeringScaleValue =
|
||||
document.getElementById("steering-scale-value");
|
||||
|
||||
let vehicles = [];
|
||||
let actions = [];
|
||||
let configuration = {
|
||||
vehicleCount: 1,
|
||||
fleetCenter: { xMeters: 0, yMeters: 0, yawRadians: 0 },
|
||||
vehicles: [
|
||||
{ vehicleId: 1, xMeters: 0, yMeters: 0, yawRadians: 0 }
|
||||
]
|
||||
};
|
||||
let lastUpdateTime = performance.now();
|
||||
let successfulUpdates = 0;
|
||||
let remoteThrottle = 0;
|
||||
let remoteSteering = 0;
|
||||
let remotePointerId = null;
|
||||
let remoteVehicleId = null;
|
||||
const pressedDriveKeys = new Set();
|
||||
|
||||
function resizeCanvas() {
|
||||
const bounds = canvas.getBoundingClientRect();
|
||||
const ratio = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.max(1, Math.floor(bounds.width * ratio));
|
||||
canvas.height = Math.max(1, Math.floor(bounds.height * ratio));
|
||||
context.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||||
}
|
||||
|
||||
function getView() {
|
||||
const centerX = configuration.fleetCenter.xMeters;
|
||||
const centerY = configuration.fleetCenter.yMeters;
|
||||
let radiusX = 2.1;
|
||||
let radiusY = 1.7;
|
||||
|
||||
for (const vehicle of vehicles) {
|
||||
radiusX = Math.max(
|
||||
radiusX,
|
||||
Math.abs(vehicle.xMeters - centerX) + 1.2);
|
||||
radiusY = Math.max(
|
||||
radiusY,
|
||||
Math.abs(vehicle.yMeters - centerY) + 1.0);
|
||||
}
|
||||
|
||||
const width = canvas.clientWidth;
|
||||
const height = canvas.clientHeight;
|
||||
const scale = Math.min(
|
||||
width / (radiusX * 2),
|
||||
height / (radiusY * 2),
|
||||
155);
|
||||
|
||||
return { centerX, centerY, scale, width, height };
|
||||
}
|
||||
|
||||
function worldToScreen(xMeters, yMeters, view = getView()) {
|
||||
return {
|
||||
x: view.width / 2 +
|
||||
(xMeters - view.centerX) * view.scale,
|
||||
y: view.height / 2 -
|
||||
(yMeters - view.centerY) * view.scale,
|
||||
scale: view.scale
|
||||
};
|
||||
}
|
||||
|
||||
function drawGrid(view) {
|
||||
const width = view.width;
|
||||
const height = view.height;
|
||||
const worldOrigin = worldToScreen(0, 0, view);
|
||||
const spacing = view.scale;
|
||||
|
||||
context.clearRect(0, 0, width, height);
|
||||
context.fillStyle = "#071018";
|
||||
context.fillRect(0, 0, width, height);
|
||||
context.lineWidth = 1;
|
||||
context.strokeStyle = "rgba(109, 143, 160, 0.12)";
|
||||
|
||||
for (let x = worldOrigin.x % spacing; x < width; x += spacing) {
|
||||
context.beginPath();
|
||||
context.moveTo(x, 0);
|
||||
context.lineTo(x, height);
|
||||
context.stroke();
|
||||
}
|
||||
|
||||
for (let y = worldOrigin.y % spacing; y < height; y += spacing) {
|
||||
context.beginPath();
|
||||
context.moveTo(0, y);
|
||||
context.lineTo(width, y);
|
||||
context.stroke();
|
||||
}
|
||||
|
||||
context.strokeStyle = "rgba(90, 125, 142, 0.35)";
|
||||
context.lineWidth = 1.2;
|
||||
context.beginPath();
|
||||
context.moveTo(0, worldOrigin.y);
|
||||
context.lineTo(width, worldOrigin.y);
|
||||
context.moveTo(worldOrigin.x, 0);
|
||||
context.lineTo(worldOrigin.x, height);
|
||||
context.stroke();
|
||||
}
|
||||
|
||||
function drawFleetCenter(view) {
|
||||
const fleet = configuration.fleetCenter;
|
||||
const point = worldToScreen(
|
||||
fleet.xMeters,
|
||||
fleet.yMeters,
|
||||
view);
|
||||
const yaw = fleet.yawRadians;
|
||||
const axisLength = 55;
|
||||
|
||||
context.save();
|
||||
context.translate(point.x, point.y);
|
||||
context.rotate(-yaw);
|
||||
|
||||
context.fillStyle = "#b58cff";
|
||||
context.beginPath();
|
||||
context.moveTo(0, -8);
|
||||
context.lineTo(8, 0);
|
||||
context.lineTo(0, 8);
|
||||
context.lineTo(-8, 0);
|
||||
context.closePath();
|
||||
context.fill();
|
||||
|
||||
drawArrow(0, 0, axisLength, 0, "#ff7285", 2);
|
||||
drawArrow(0, 0, 0, -axisLength, "#66d99a", 2);
|
||||
context.restore();
|
||||
|
||||
context.fillStyle = "#c7aaff";
|
||||
context.font = "12px Inter, sans-serif";
|
||||
context.fillText("FLEET CENTER", point.x + 12, point.y - 11);
|
||||
}
|
||||
|
||||
function drawVehicle(vehicle, view) {
|
||||
const point = worldToScreen(
|
||||
vehicle.xMeters,
|
||||
vehicle.yMeters,
|
||||
view);
|
||||
const selected = Number(vehicleSelect.value) === vehicle.vehicleId;
|
||||
const bodyLength = vehicle.bodyLengthMeters * point.scale;
|
||||
const bodyWidth = vehicle.bodyWidthMeters * point.scale;
|
||||
|
||||
context.save();
|
||||
context.translate(point.x, point.y);
|
||||
context.rotate(-vehicle.yawRadians);
|
||||
|
||||
context.fillStyle = selected
|
||||
? "rgba(26, 180, 155, 0.2)"
|
||||
: "rgba(50, 73, 88, 0.42)";
|
||||
context.strokeStyle = selected ? "#39d8b7" : "#668392";
|
||||
context.lineWidth = selected ? 2.5 : 1.5;
|
||||
context.beginPath();
|
||||
context.roundRect(
|
||||
-bodyLength / 2,
|
||||
-bodyWidth / 2,
|
||||
bodyLength,
|
||||
bodyWidth,
|
||||
12);
|
||||
context.fill();
|
||||
context.stroke();
|
||||
|
||||
drawBodyAxes(bodyLength, bodyWidth);
|
||||
|
||||
for (const wheel of vehicle.wheels) {
|
||||
drawWheel(
|
||||
wheel.xMeters * point.scale,
|
||||
-wheel.yMeters * point.scale,
|
||||
wheel);
|
||||
}
|
||||
|
||||
context.fillStyle = "rgba(202, 222, 230, 0.8)";
|
||||
context.font = "10px Inter, sans-serif";
|
||||
context.textAlign = "center";
|
||||
context.fillText(
|
||||
"1472 × 948 mm",
|
||||
0,
|
||||
bodyWidth / 2 - 8);
|
||||
context.restore();
|
||||
|
||||
context.textAlign = "left";
|
||||
context.fillStyle = "#dbe9ef";
|
||||
context.font = "600 13px Inter, sans-serif";
|
||||
context.fillText(
|
||||
`CAR ${vehicle.vehicleId}`,
|
||||
point.x - 24,
|
||||
point.y - bodyWidth / 2 - 18);
|
||||
|
||||
context.fillStyle = vehicle.modeReady ? "#42dfb7" : "#ffb257";
|
||||
context.font = "12px Inter, sans-serif";
|
||||
context.fillText(
|
||||
vehicle.modeReady ? "READY" : "ALIGNING",
|
||||
point.x - 23,
|
||||
point.y + bodyWidth / 2 + 25);
|
||||
}
|
||||
|
||||
function drawBodyAxes(bodyLength, bodyWidth) {
|
||||
const xLength = bodyLength / 2 + 30;
|
||||
const yLength = bodyWidth / 2 + 27;
|
||||
|
||||
drawArrow(0, 0, xLength, 0, "#ff6477", 2.4);
|
||||
drawArrow(0, 0, 0, -yLength, "#54d991", 2.4);
|
||||
|
||||
context.font = "700 11px Inter, sans-serif";
|
||||
context.fillStyle = "#ff8b99";
|
||||
context.fillText("+X", xLength - 2, -7);
|
||||
context.fillStyle = "#7debab";
|
||||
context.fillText("+Y", 6, -yLength + 2);
|
||||
}
|
||||
|
||||
function drawWheel(x, y, wheel) {
|
||||
const wheelLength = 33;
|
||||
const limitRadius = 20;
|
||||
context.save();
|
||||
context.translate(x, y);
|
||||
|
||||
context.strokeStyle = "rgba(86, 167, 255, 0.72)";
|
||||
context.lineWidth = 1.2;
|
||||
context.setLineDash([3, 3]);
|
||||
context.beginPath();
|
||||
context.moveTo(-22, 0);
|
||||
context.lineTo(24, 0);
|
||||
context.stroke();
|
||||
|
||||
drawWheelLimitArc(limitRadius);
|
||||
|
||||
context.save();
|
||||
context.rotate(-wheel.targetAngleDegrees * Math.PI / 180);
|
||||
context.strokeStyle = "rgba(255, 178, 87, 0.9)";
|
||||
context.lineWidth = 3;
|
||||
context.setLineDash([5, 4]);
|
||||
context.beginPath();
|
||||
context.moveTo(-wheelLength / 2, 0);
|
||||
context.lineTo(wheelLength / 2, 0);
|
||||
context.stroke();
|
||||
context.restore();
|
||||
|
||||
context.save();
|
||||
context.rotate(-wheel.actualAngleDegrees * Math.PI / 180);
|
||||
context.strokeStyle = wheel.isAligned ? "#42dfb7" : "#f2f6f8";
|
||||
context.lineWidth = 7;
|
||||
context.setLineDash([]);
|
||||
context.lineCap = "round";
|
||||
context.beginPath();
|
||||
context.moveTo(-wheelLength / 2, 0);
|
||||
context.lineTo(wheelLength / 2, 0);
|
||||
context.stroke();
|
||||
context.restore();
|
||||
|
||||
context.setLineDash([]);
|
||||
context.fillStyle = wheel.isAligned ? "#7ef4dc" : "#ffca83";
|
||||
context.font = "700 9px Inter, sans-serif";
|
||||
context.textAlign = "center";
|
||||
const sign = wheel.actualAngleDegrees >= 0 ? "+" : "";
|
||||
context.fillText(
|
||||
`${sign}${wheel.actualAngleDegrees.toFixed(0)}°`,
|
||||
0,
|
||||
-25);
|
||||
context.restore();
|
||||
}
|
||||
|
||||
function drawWheelLimitArc(radius) {
|
||||
context.strokeStyle = "rgba(181, 140, 255, 0.5)";
|
||||
context.lineWidth = 1;
|
||||
context.setLineDash([]);
|
||||
context.beginPath();
|
||||
|
||||
for (let angle = -120; angle <= 120; angle += 5) {
|
||||
const radians = -angle * Math.PI / 180;
|
||||
const x = Math.cos(radians) * radius;
|
||||
const y = Math.sin(radians) * radius;
|
||||
if (angle === -120) context.moveTo(x, y);
|
||||
else context.lineTo(x, y);
|
||||
}
|
||||
context.stroke();
|
||||
|
||||
for (const angle of [-120, 0, 120]) {
|
||||
const radians = -angle * Math.PI / 180;
|
||||
context.beginPath();
|
||||
context.moveTo(
|
||||
Math.cos(radians) * (radius - 3),
|
||||
Math.sin(radians) * (radius - 3));
|
||||
context.lineTo(
|
||||
Math.cos(radians) * (radius + 3),
|
||||
Math.sin(radians) * (radius + 3));
|
||||
context.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
function drawArrow(x1, y1, x2, y2, color, width) {
|
||||
const angle = Math.atan2(y2 - y1, x2 - x1);
|
||||
context.strokeStyle = color;
|
||||
context.fillStyle = color;
|
||||
context.lineWidth = width;
|
||||
context.setLineDash([]);
|
||||
context.beginPath();
|
||||
context.moveTo(x1, y1);
|
||||
context.lineTo(x2, y2);
|
||||
context.stroke();
|
||||
context.beginPath();
|
||||
context.moveTo(x2, y2);
|
||||
context.lineTo(
|
||||
x2 - 9 * Math.cos(angle - Math.PI / 6),
|
||||
y2 - 9 * Math.sin(angle - Math.PI / 6));
|
||||
context.lineTo(
|
||||
x2 - 9 * Math.cos(angle + Math.PI / 6),
|
||||
y2 - 9 * Math.sin(angle + Math.PI / 6));
|
||||
context.closePath();
|
||||
context.fill();
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const view = getView();
|
||||
drawGrid(view);
|
||||
drawFleetCenter(view);
|
||||
vehicles.forEach(vehicle => drawVehicle(vehicle, view));
|
||||
requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
function renderActions() {
|
||||
const preferredGroups = ["舵轮模式", "运动测试", "维护"];
|
||||
const groups = [...new Set(actions.map(action => action.group))]
|
||||
.sort((left, right) => {
|
||||
const leftIndex = preferredGroups.indexOf(left);
|
||||
const rightIndex = preferredGroups.indexOf(right);
|
||||
if (leftIndex < 0 && rightIndex < 0)
|
||||
return left.localeCompare(right, "zh-CN");
|
||||
if (leftIndex < 0) return 1;
|
||||
if (rightIndex < 0) return -1;
|
||||
return leftIndex - rightIndex;
|
||||
});
|
||||
|
||||
actionGroups.innerHTML = groups.map(group => {
|
||||
const buttons = actions
|
||||
.filter(action => action.group === group)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map(action => `
|
||||
<button data-command="${action.key}"
|
||||
class="${action.key === "stop" ? "danger-button" : ""}">
|
||||
${action.displayName}
|
||||
</button>`)
|
||||
.join("");
|
||||
|
||||
return `
|
||||
<section class="action-group">
|
||||
<p class="group-label">${group}</p>
|
||||
<div class="action-grid">${buttons}</div>
|
||||
</section>`;
|
||||
}).join("");
|
||||
|
||||
actionGroups.querySelectorAll("[data-command]")
|
||||
.forEach(button => {
|
||||
button.addEventListener("click", () =>
|
||||
sendCommand(button.dataset.command));
|
||||
});
|
||||
}
|
||||
|
||||
function renderVehicleSelector() {
|
||||
const previous = Number(vehicleSelect.value);
|
||||
vehicleSelect.innerHTML = vehicles
|
||||
.map(vehicle =>
|
||||
`<option value="${vehicle.vehicleId}">车辆 ${vehicle.vehicleId}</option>`)
|
||||
.join("");
|
||||
|
||||
if (vehicles.some(vehicle => vehicle.vehicleId === previous))
|
||||
vehicleSelect.value = String(previous);
|
||||
}
|
||||
|
||||
function renderCards() {
|
||||
vehicleCards.innerHTML = vehicles.map(vehicle => {
|
||||
const actual = vehicle.actualBodyTwist;
|
||||
const selected =
|
||||
Number(vehicleSelect.value) === vehicle.vehicleId;
|
||||
return `
|
||||
<button class="vehicle-card ${selected ? "selected" : ""}"
|
||||
data-vehicle="${vehicle.vehicleId}">
|
||||
<div class="card-topline">
|
||||
<strong>CAR ${vehicle.vehicleId}</strong>
|
||||
<span class="${vehicle.modeReady ? "status-ready" : "status-aligning"}">
|
||||
${vehicle.modeReady ? "READY" : "ALIGNING"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mode-name">${formatMode(vehicle.mode)}</div>
|
||||
<dl>
|
||||
<div><dt>X</dt><dd>${vehicle.xMeters.toFixed(2)} m</dd></div>
|
||||
<div><dt>Y</dt><dd>${vehicle.yMeters.toFixed(2)} m</dd></div>
|
||||
<div><dt>Yaw</dt><dd>${radToDeg(vehicle.yawRadians).toFixed(1)}°</dd></div>
|
||||
<div><dt>Vx / Vy</dt><dd>${actual.vxMetersPerSecond.toFixed(2)} / ${actual.vyMetersPerSecond.toFixed(2)}</dd></div>
|
||||
</dl>
|
||||
</button>`;
|
||||
}).join("");
|
||||
|
||||
vehicleCards.querySelectorAll("[data-vehicle]").forEach(card => {
|
||||
card.addEventListener("click", () => {
|
||||
vehicleSelect.value = card.dataset.vehicle;
|
||||
renderCards();
|
||||
renderSelectedVehicleDetail();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderSelectedVehicleDetail() {
|
||||
const selectedId = Number(vehicleSelect.value);
|
||||
const vehicle = vehicles.find(item => item.vehicleId === selectedId);
|
||||
if (!vehicle) {
|
||||
selectedVehicleDetail.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
selectedVehicleDetail.innerHTML = `
|
||||
<section class="wheel-detail">
|
||||
<div class="geometry-note">
|
||||
车体 1472 × 948 mm · 实际DLL轮位 X±750 / Y±500 mm
|
||||
</div>
|
||||
${vehicle.wheels.map(wheel => {
|
||||
const actualPercent =
|
||||
(wheel.actualAngleDegrees + 120) / 240 * 100;
|
||||
const targetPercent =
|
||||
(wheel.targetAngleDegrees + 120) / 240 * 100;
|
||||
return `
|
||||
<div class="wheel-row">
|
||||
<div class="wheel-row-title">
|
||||
<strong>${wheel.name}</strong>
|
||||
<span>实际 ${signed(wheel.actualAngleDegrees)}° · 目标 ${signed(wheel.targetAngleDegrees)}°</span>
|
||||
</div>
|
||||
<div class="angle-scale">
|
||||
<span class="zero-mark"></span>
|
||||
<span class="target-mark" style="left:${targetPercent}%"></span>
|
||||
<span class="actual-mark" style="left:${actualPercent}%"></span>
|
||||
</div>
|
||||
<div class="wheel-speed">
|
||||
轮速 ${wheel.actualSpeedMetersPerSecond.toFixed(2)} m/s
|
||||
</div>
|
||||
</div>`;
|
||||
}).join("")}
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function formatMode(mode) {
|
||||
return {
|
||||
Normal: "正常模式",
|
||||
CrabLeft: "左蟹行",
|
||||
CrabRight: "右蟹行",
|
||||
Spin: "自转模式"
|
||||
}[mode] || mode;
|
||||
}
|
||||
|
||||
async function loadActions() {
|
||||
const response = await fetch("/api/actions", { cache: "no-store" });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
actions = await response.json();
|
||||
renderActions();
|
||||
}
|
||||
|
||||
async function loadConfiguration() {
|
||||
const response = await fetch("/api/configuration", {
|
||||
cache: "no-store"
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
configuration = await response.json();
|
||||
populateConfigurationForm();
|
||||
}
|
||||
|
||||
function populateConfigurationForm() {
|
||||
vehicleCountInput.value = String(configuration.vehicleCount);
|
||||
document.getElementById("fleet-x").value =
|
||||
configuration.fleetCenter.xMeters;
|
||||
document.getElementById("fleet-y").value =
|
||||
configuration.fleetCenter.yMeters;
|
||||
document.getElementById("fleet-yaw").value =
|
||||
radToDeg(configuration.fleetCenter.yawRadians).toFixed(1);
|
||||
renderLayoutRows(configuration.vehicleCount, configuration.vehicles);
|
||||
}
|
||||
|
||||
function readExistingLayouts() {
|
||||
return [...layoutRows.querySelectorAll(".layout-row")].map(row => ({
|
||||
vehicleId: Number(row.dataset.vehicleId),
|
||||
xMeters: Number(row.querySelector("[data-field='x']").value),
|
||||
yMeters: Number(row.querySelector("[data-field='y']").value),
|
||||
yawRadians: degToRad(
|
||||
Number(row.querySelector("[data-field='yaw']").value))
|
||||
}));
|
||||
}
|
||||
|
||||
function renderLayoutRows(count, sourceLayouts = readExistingLayouts()) {
|
||||
const byId = new Map(
|
||||
sourceLayouts.map(layout => [layout.vehicleId, layout]));
|
||||
|
||||
const fallbackSpacing = 2.0;
|
||||
layoutRows.innerHTML = Array.from({ length: count }, (_, index) => {
|
||||
const id = index + 1;
|
||||
const fallbackX =
|
||||
(index - (count - 1) / 2) * fallbackSpacing;
|
||||
const layout = byId.get(id) || {
|
||||
vehicleId: id,
|
||||
xMeters: count === 1 ? 0 : fallbackX,
|
||||
yMeters: 0,
|
||||
yawRadians: 0
|
||||
};
|
||||
|
||||
return `
|
||||
<div class="layout-row" data-vehicle-id="${id}">
|
||||
<strong>CAR ${id}</strong>
|
||||
<label>X<input data-field="x" type="number" step="0.1"
|
||||
value="${layout.xMeters}"></label>
|
||||
<label>Y<input data-field="y" type="number" step="0.1"
|
||||
value="${layout.yMeters}"></label>
|
||||
<label>Yaw°<input data-field="yaw" type="number" step="1"
|
||||
value="${radToDeg(layout.yawRadians).toFixed(1)}"></label>
|
||||
</div>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
async function applyConfiguration() {
|
||||
const count = Number(vehicleCountInput.value);
|
||||
const layouts = readExistingLayouts().slice(0, count);
|
||||
const payload = {
|
||||
vehicleCount: count,
|
||||
fleetCenter: {
|
||||
xMeters: Number(document.getElementById("fleet-x").value),
|
||||
yMeters: Number(document.getElementById("fleet-y").value),
|
||||
yawRadians: degToRad(
|
||||
Number(document.getElementById("fleet-yaw").value))
|
||||
},
|
||||
vehicles: layouts
|
||||
};
|
||||
|
||||
const response = await fetch("/api/configuration", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
commandMessage.textContent =
|
||||
result.message || "布局配置失败。";
|
||||
commandMessage.classList.add("error");
|
||||
return;
|
||||
}
|
||||
|
||||
configuration = result;
|
||||
commandMessage.textContent =
|
||||
`已应用 ${configuration.vehicleCount} 辆车的车队布局。`;
|
||||
commandMessage.classList.remove("error");
|
||||
await updateVehicles();
|
||||
}
|
||||
|
||||
async function updateVehicles() {
|
||||
try {
|
||||
const response = await fetch("/api/vehicles", {
|
||||
cache: "no-store"
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
vehicles = await response.json();
|
||||
renderVehicleSelector();
|
||||
renderCards();
|
||||
renderSelectedVehicleDetail();
|
||||
connectionDot.classList.add("online");
|
||||
connectionText.textContent = "离线仿真运行中";
|
||||
successfulUpdates += 1;
|
||||
} catch {
|
||||
connectionDot.classList.remove("online");
|
||||
connectionText.textContent = "仿真后端未连接";
|
||||
}
|
||||
}
|
||||
|
||||
async function sendCommand(command) {
|
||||
const vehicleId = vehicleSelect.value;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/vehicles/${vehicleId}/commands/${command}`,
|
||||
{ method: "POST" });
|
||||
const result = await response.json();
|
||||
commandMessage.textContent = result.message;
|
||||
commandMessage.classList.toggle("error", !result.success);
|
||||
await updateVehicles();
|
||||
} catch {
|
||||
commandMessage.textContent =
|
||||
"命令发送失败,请检查仿真后端。";
|
||||
commandMessage.classList.add("error");
|
||||
}
|
||||
}
|
||||
|
||||
function getRemoteScale(input) {
|
||||
return Number(input.value) / 100;
|
||||
}
|
||||
|
||||
function updateRemoteLabels() {
|
||||
speedScaleValue.textContent = `${speedScaleInput.value}%`;
|
||||
steeringScaleValue.textContent =
|
||||
`${steeringScaleInput.value}%`;
|
||||
}
|
||||
|
||||
async function sendManualControl(
|
||||
throttle,
|
||||
steering,
|
||||
showMessage = false,
|
||||
vehicleIdOverride = null) {
|
||||
remoteThrottle = throttle;
|
||||
remoteSteering = steering;
|
||||
|
||||
const active =
|
||||
Math.abs(throttle) > 0.001 ||
|
||||
Math.abs(steering) > 0.001;
|
||||
remoteState.textContent = active
|
||||
? `油门 ${signed(throttle)} · 转向 ${signed(steering)}`
|
||||
: "已松开";
|
||||
remoteState.classList.toggle("active", active);
|
||||
|
||||
try {
|
||||
const vehicleId = vehicleIdOverride ??
|
||||
(active
|
||||
? vehicleSelect.value
|
||||
: remoteVehicleId ?? vehicleSelect.value);
|
||||
|
||||
if (active)
|
||||
remoteVehicleId = String(vehicleId);
|
||||
|
||||
const response = await fetch(
|
||||
`/api/vehicles/${vehicleId}/manual-control`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
throttle,
|
||||
steering,
|
||||
speedScale: getRemoteScale(speedScaleInput),
|
||||
steeringScale: getRemoteScale(steeringScaleInput)
|
||||
})
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || showMessage) {
|
||||
commandMessage.textContent = result.message;
|
||||
commandMessage.classList.toggle(
|
||||
"error",
|
||||
!result.success);
|
||||
}
|
||||
|
||||
if (!active &&
|
||||
String(vehicleId) === remoteVehicleId) {
|
||||
remoteVehicleId = null;
|
||||
}
|
||||
} catch {
|
||||
commandMessage.textContent =
|
||||
"虚拟遥控命令发送失败,请检查仿真后端。";
|
||||
commandMessage.classList.add("error");
|
||||
}
|
||||
}
|
||||
|
||||
function stopRemote(showMessage = false) {
|
||||
const vehicleId = remoteVehicleId;
|
||||
pressedDriveKeys.clear();
|
||||
document.querySelectorAll(".remote-pad .pressed")
|
||||
.forEach(button => button.classList.remove("pressed"));
|
||||
return sendManualControl(
|
||||
0,
|
||||
0,
|
||||
showMessage,
|
||||
vehicleId);
|
||||
}
|
||||
|
||||
function driveFromKeyboard() {
|
||||
const forward =
|
||||
pressedDriveKeys.has("KeyW") ||
|
||||
pressedDriveKeys.has("ArrowUp");
|
||||
const backward =
|
||||
pressedDriveKeys.has("KeyS") ||
|
||||
pressedDriveKeys.has("ArrowDown");
|
||||
const left =
|
||||
pressedDriveKeys.has("KeyA") ||
|
||||
pressedDriveKeys.has("ArrowLeft");
|
||||
const right =
|
||||
pressedDriveKeys.has("KeyD") ||
|
||||
pressedDriveKeys.has("ArrowRight");
|
||||
|
||||
const throttle = Number(forward) - Number(backward);
|
||||
const steering = Number(left) - Number(right);
|
||||
sendManualControl(throttle, steering);
|
||||
}
|
||||
|
||||
function isTypingTarget(target) {
|
||||
return target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLSelectElement ||
|
||||
target instanceof HTMLTextAreaElement;
|
||||
}
|
||||
|
||||
document.querySelectorAll(
|
||||
".remote-pad [data-throttle][data-steering]")
|
||||
.forEach(button => {
|
||||
button.addEventListener("pointerdown", event => {
|
||||
event.preventDefault();
|
||||
remotePointerId = event.pointerId;
|
||||
button.setPointerCapture(event.pointerId);
|
||||
button.classList.add("pressed");
|
||||
sendManualControl(
|
||||
Number(button.dataset.throttle),
|
||||
Number(button.dataset.steering));
|
||||
});
|
||||
|
||||
const release = event => {
|
||||
if (remotePointerId !== event.pointerId)
|
||||
return;
|
||||
remotePointerId = null;
|
||||
button.classList.remove("pressed");
|
||||
stopRemote();
|
||||
};
|
||||
|
||||
button.addEventListener("pointerup", release);
|
||||
button.addEventListener("pointercancel", release);
|
||||
button.addEventListener("lostpointercapture", release);
|
||||
});
|
||||
|
||||
document.getElementById("remote-stop").addEventListener(
|
||||
"click",
|
||||
() => stopRemote(true));
|
||||
|
||||
window.addEventListener("keydown", event => {
|
||||
if (isTypingTarget(event.target))
|
||||
return;
|
||||
|
||||
const driveKeys = [
|
||||
"KeyW", "KeyS", "KeyA", "KeyD",
|
||||
"ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"
|
||||
];
|
||||
if (!driveKeys.includes(event.code))
|
||||
return;
|
||||
|
||||
event.preventDefault();
|
||||
if (event.repeat)
|
||||
return;
|
||||
|
||||
pressedDriveKeys.add(event.code);
|
||||
driveFromKeyboard();
|
||||
});
|
||||
|
||||
window.addEventListener("keyup", event => {
|
||||
if (!pressedDriveKeys.has(event.code))
|
||||
return;
|
||||
|
||||
event.preventDefault();
|
||||
pressedDriveKeys.delete(event.code);
|
||||
driveFromKeyboard();
|
||||
});
|
||||
|
||||
window.addEventListener("blur", () => stopRemote());
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden)
|
||||
stopRemote();
|
||||
});
|
||||
|
||||
[speedScaleInput, steeringScaleInput].forEach(input => {
|
||||
input.addEventListener("input", () => {
|
||||
updateRemoteLabels();
|
||||
if (remoteThrottle !== 0 || remoteSteering !== 0) {
|
||||
sendManualControl(
|
||||
remoteThrottle,
|
||||
remoteSteering);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("reset-all").addEventListener(
|
||||
"click",
|
||||
async () => {
|
||||
await fetch("/api/reset", { method: "POST" });
|
||||
commandMessage.textContent = "全部仿真车已复位。";
|
||||
commandMessage.classList.remove("error");
|
||||
await updateVehicles();
|
||||
});
|
||||
|
||||
document.getElementById("apply-configuration")
|
||||
.addEventListener("click", applyConfiguration);
|
||||
|
||||
vehicleCountInput.addEventListener("change", () =>
|
||||
renderLayoutRows(Number(vehicleCountInput.value)));
|
||||
|
||||
vehicleSelect.addEventListener("change", () => {
|
||||
stopRemote();
|
||||
renderCards();
|
||||
renderSelectedVehicleDetail();
|
||||
});
|
||||
|
||||
window.addEventListener("resize", resizeCanvas);
|
||||
|
||||
setInterval(updateVehicles, 100);
|
||||
setInterval(() => {
|
||||
const now = performance.now();
|
||||
const seconds = (now - lastUpdateTime) / 1000;
|
||||
updateRate.textContent =
|
||||
`${(successfulUpdates / seconds).toFixed(0)} Hz`;
|
||||
successfulUpdates = 0;
|
||||
lastUpdateTime = now;
|
||||
}, 1000);
|
||||
|
||||
function signed(value) {
|
||||
return `${value >= 0 ? "+" : ""}${value.toFixed(1)}`;
|
||||
}
|
||||
|
||||
function degToRad(value) {
|
||||
return value * Math.PI / 180;
|
||||
}
|
||||
|
||||
function radToDeg(value) {
|
||||
return value * 180 / Math.PI;
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
try {
|
||||
await Promise.all([
|
||||
loadActions(),
|
||||
loadConfiguration(),
|
||||
updateVehicles()
|
||||
]);
|
||||
} catch {
|
||||
connectionText.textContent = "仿真初始化失败";
|
||||
commandMessage.textContent =
|
||||
"无法读取命令或布局配置,请检查后端。";
|
||||
commandMessage.classList.add("error");
|
||||
}
|
||||
}
|
||||
|
||||
resizeCanvas();
|
||||
updateRemoteLabels();
|
||||
initialize();
|
||||
requestAnimationFrame(draw);
|
||||
Reference in New Issue
Block a user