Files
ParkingRobot/Simulation/wwwroot/viewer.js
T

843 lines
24 KiB
JavaScript
Raw Normal View History

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);