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 => ` `) .join(""); return `

${group}

${buttons}
`; }).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 => ``) .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 ` `; }).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 = `
车体 1472 × 948 mm · 实际DLL轮位 X±750 / Y±500 mm
${vehicle.wheels.map(wheel => { const actualPercent = (wheel.actualAngleDegrees + 120) / 240 * 100; const targetPercent = (wheel.targetAngleDegrees + 120) / 240 * 100; return `
${wheel.name} 实际 ${signed(wheel.actualAngleDegrees)}° · 目标 ${signed(wheel.targetAngleDegrees)}°
轮速 ${wheel.actualSpeedMetersPerSecond.toFixed(2)} m/s
`; }).join("")}
`; } 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 `
CAR ${id}
`; }).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);