35、自主移动机器人 (AMR) 调度模拟 (电子厂) - /物流与仓储组件/amr-scheduling-electronics
76个工业组件库示例汇总
效果展示
源码
index.html
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>AMR 调度 (电子厂)</title><link rel="stylesheet" href="styles.css">
</head>
<body><div class="amr-container"><header class="amr-header"><h1>自主移动机器人 (AMR) 调度 - 电子厂物料运输</h1></header><div class="amr-body"><!-- Left Panel: Simulation Visualization --><div class="simulation-area-container panel"><h2>工厂布局与 AMR 实时位置</h2><canvas id="simulationCanvas"></canvas></div><!-- Right Panel: Controls and Status --><div class="controls-status-area"><div class="control-panel panel"><h2>控制</h2><div class="controls-grid"><button id="startSimBtn" class="control-button">开始</button><button id="pauseSimBtn" class="control-button" disabled>暂停</button><button id="resetSimBtn" class="control-button" disabled>重置</button><button id="addTaskBtn" class="control-button">添加任务</button><div class="speed-control"><label for="simSpeedSlider">速度:</label><input type="range" id="simSpeedSlider" min="0.5" max="10" step="0.5" value="1"><span id="simSpeedValue">1x</span></div></div></div><div class="amr-status-panel panel scrollable"><h2>AMR 状态</h2><ul id="amrList"><!-- AMR status items will be added here --><li>暂无 AMR 数据</li></ul></div><div class="task-queue-panel panel scrollable"><h2>运输任务队列</h2><ul id="taskList"><!-- Task items will be added here --><li>暂无任务数据</li></ul></div><div class="event-log-panel panel scrollable"><h2>事件日志</h2><ul id="eventLogList"><!-- Log messages will appear here --><li>系统已初始化。</li></ul></div></div></div><footer class="amr-footer"><span id="simulationTime">时间: 00:00:00</span> |<span id="simulationStatus">状态: 空闲</span></footer></div><script src="script.js"></script>
</body>
</html>
styles.css
:root {--background-color: #f5f5f7;--panel-background: #ffffff;--header-background: #e9ecef;--footer-background: #e9ecef;--text-primary: #1d1d1f;--text-secondary: #515154;--text-light: #86868b;--border-color: #d2d2d7;--accent-blue: #007aff;--accent-blue-hover: #005ecf;--accent-green: #34c759;--accent-yellow: #ffcc00;--accent-red: #ff3b30;--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--border-radius: 8px;--panel-shadow: 0 1px 3px rgba(0, 0, 0, 0.05), 0 1px 2px rgba(0,0,0,0.05);--spacing-unit: 16px;--right-panel-width: 350px; /* Width for the right section */
}*,
*::before,
*::after {box-sizing: border-box;margin: 0;padding: 0;
}body {font-family: var(--font-family);background-color: var(--background-color);color: var(--text-primary);line-height: 1.5;margin: 0;padding: var(--spacing-unit);font-size: 14px;/* Prevent body scrolling, handle scrolling within panels */overflow: hidden;height: 100vh;
}.amr-container {display: flex;flex-direction: column;height: calc(100vh - 2 * var(--spacing-unit)); /* Full viewport height minus body padding */max-width: 1800px; /* Optional: Limit max width */margin: 0 auto;background-color: var(--panel-background);border-radius: var(--border-radius);box-shadow: var(--panel-shadow);overflow: hidden; /* Contain header, body, footer */
}.amr-header {background-color: var(--header-background);padding: calc(var(--spacing-unit) * 0.75) var(--spacing-unit);border-bottom: 1px solid var(--border-color);flex-shrink: 0; /* Prevent header from shrinking */
}.amr-header h1 {font-size: 1.2em;font-weight: 600;color: var(--text-primary);margin: 0;
}.amr-body {display: flex;flex-grow: 1; /* Allow body to fill available space */overflow: hidden; /* Important: Prevent body from causing overflow */padding: var(--spacing-unit);gap: var(--spacing-unit);
}.panel {background-color: var(--panel-background);border: 1px solid var(--border-color);border-radius: var(--border-radius);padding: var(--spacing-unit);display: flex;flex-direction: column;overflow: hidden; /* Default overflow hidden, use scrollable */
}.panel h2 {font-size: 1.1em;font-weight: 600;margin-bottom: var(--spacing-unit);padding-bottom: calc(var(--spacing-unit) / 2);border-bottom: 1px solid var(--border-color);color: var(--text-primary);flex-shrink: 0;
}.scrollable {overflow-y: auto;flex-grow: 1;/* Custom scrollbar styling */&::-webkit-scrollbar {width: 6px;}&::-webkit-scrollbar-track {background: #f1f1f1;border-radius: 3px;}&::-webkit-scrollbar-thumb {background: #c1c1c1;border-radius: 3px;}&::-webkit-scrollbar-thumb:hover {background: #a8a8a8;}
}/* Left Panel: Simulation Area */
.simulation-area-container {flex-grow: 1; /* Take remaining horizontal space */min-width: 400px; /* Ensure minimum width */height: 100%; /* Fill vertical space */padding: var(--spacing-unit);
}#simulationCanvas {display: block;width: 100%;height: calc(100% - 50px); /* Adjust based on h2 height and padding */background-color: #eef0f2;border-radius: 6px;border: 1px solid var(--border-color);
}/* Right Panel: Controls and Status */
.controls-status-area {display: flex;flex-direction: column;width: var(--right-panel-width);flex-shrink: 0; /* Prevent shrinking */height: 100%; /* Fill vertical space */gap: var(--spacing-unit);
}.control-panel {flex-shrink: 0; /* Don't let control panel shrink vertically */
}.controls-grid {display: grid;grid-template-columns: repeat(2, 1fr); /* Two columns for buttons */gap: 10px;
}.control-button {padding: 8px 12px;font-size: 0.9em;font-weight: 500;border: none;border-radius: 6px;cursor: pointer;transition: background-color 0.2s ease, opacity 0.2s ease;background-color: var(--accent-blue);color: white;
}.control-button:hover {background-color: var(--accent-blue-hover);
}.control-button:disabled {background-color: #a8a8aa;cursor: not-allowed;opacity: 0.7;
}/* Special case for speed control to span columns if needed or place correctly */
.speed-control {grid-column: 1 / -1; /* Span both columns */display: flex;align-items: center;gap: 8px;font-size: 0.9em;color: var(--text-secondary);margin-top: 10px; /* Add some space above */
}#simSpeedSlider {flex-grow: 1; /* Allow slider to take available space */cursor: pointer;
}#simSpeedValue {font-weight: 500;min-width: 30px; /* Ensure space for value */text-align: right;
}/* AMR Status & Task Queue Panels */
.amr-status-panel,
.task-queue-panel,
.event-log-panel {flex-grow: 1; /* Allow these panels to share remaining vertical space */min-height: 150px; /* Ensure minimum height */
}#amrList,
#taskList,
#eventLogList {list-style: none;padding: 0;margin: 0;
}#amrList li,
#taskList li,
#eventLogList li {padding: 6px 0;border-bottom: 1px dotted var(--border-color);font-size: 0.9em;color: var(--text-secondary);display: flex; /* Use flex for better alignment in lists */justify-content: space-between;flex-wrap: wrap; /* Allow wrapping */gap: 8px;
}#amrList li:last-child,
#taskList li:last-child,
#eventLogList li:last-child {border-bottom: none;
}/* Specific list item styling */
.amr-info,
.task-info {flex-grow: 1;
}.amr-id,
.task-id {font-weight: 600;color: var(--text-primary);margin-right: 8px;
}.amr-battery {display: flex;align-items: center;font-size: 0.85em;color: var(--text-light);
}.battery-icon {width: 16px;height: 8px;border: 1px solid var(--text-light);border-radius: 2px;margin-right: 4px;position: relative;
}.battery-level {height: 100%;background-color: var(--accent-green);position: absolute;left: 0;top: 0;border-radius: 1px;
}.battery-level.low {background-color: var(--accent-yellow);
}
.battery-level.critical {background-color: var(--accent-red);
}.task-details {font-size: 0.85em;color: var(--text-light);
}/* Event Log Specifics */
#eventLogList li {font-size: 0.85em;white-space: normal;display: block; /* Override flex for logs */
}.log-time {color: var(--text-light);margin-right: 5px;
}/* Status indicator spans */
.status-badge {display: inline-block;padding: 2px 6px;border-radius: 4px;font-size: 0.8em;font-weight: 500;color: white;margin-left: 8px;
}.status-idle { background-color: var(--accent-green); }
.status-moving { background-color: var(--accent-blue); }
.status-charging { background-color: var(--accent-yellow); color: var(--text-primary);}
.status-loading { background-color: #ff9500; } /* Orange */
.status-unloading { background-color: #ff9500; }
.status-error { background-color: var(--accent-red); }
.status-pending { background-color: var(--text-light); }
.status-assigned { background-color: var(--accent-blue); }
.status-inprogress { background-color: var(--accent-blue); }
.status-completed { background-color: var(--accent-green); }.amr-footer {background-color: var(--footer-background);padding: calc(var(--spacing-unit) / 2) var(--spacing-unit);border-top: 1px solid var(--border-color);text-align: right;font-size: 0.85em;color: var(--text-secondary);flex-shrink: 0; /* Prevent footer shrinking */
}/* Responsive Adjustments */
@media (max-width: 1200px) {:root {--right-panel-width: 300px;}
}@media (max-width: 900px) {.amr-body {flex-direction: column;overflow-y: auto; /* Allow vertical scrolling of body on small screens */height: auto; /* Let height be determined by content */}.controls-status-area {width: 100%; /* Right panel takes full width */height: auto;}.simulation-area-container {min-height: 300px; /* Ensure canvas has some height */height: 40vh; /* Relative height */}#simulationCanvas {height: calc(100% - 45px); /* Adjust based on h2 */}
}@media (max-width: 600px) {body {padding: calc(var(--spacing-unit) / 2);height: 100%; /* Ensure body takes full height for container calc */}.amr-container {height: calc(100% - var(--spacing-unit));border-radius: 0;}.panel {padding: calc(var(--spacing-unit) * 0.75);}.controls-grid {grid-template-columns: 1fr; /* Stack buttons */}.speed-control {grid-column: auto; /* Reset span */}
}
script.js
document.addEventListener('DOMContentLoaded', () => {// --- DOM Elements ---const canvas = document.getElementById('simulationCanvas');let ctx = null; // Initialize ctx as nullconst startSimBtn = document.getElementById('startSimBtn');const pauseSimBtn = document.getElementById('pauseSimBtn');const resetSimBtn = document.getElementById('resetSimBtn');const addTaskBtn = document.getElementById('addTaskBtn');const simSpeedSlider = document.getElementById('simSpeedSlider');const simSpeedValue = document.getElementById('simSpeedValue');const amrListUl = document.getElementById('amrList');const taskListUl = document.getElementById('taskList');const eventLogUl = document.getElementById('eventLogList');const simulationTimeSpan = document.getElementById('simulationTime');const simulationStatusSpan = document.getElementById('simulationStatus');// --- Simulation State ---let simulationRunning = false;let simulationPaused = false;let simulationSpeed = 1;let simTimeSeconds = 0; // Real seconds elapsed for simulation timinglet lastTimestamp = 0;let animationFrameId = null;let nextTaskId = 1;let amrs = [];let tasks = [];let nodes = {}; // Key: nodeId, Value: { id, x, y, type ('station', 'charger', 'waypoint') }let edges = []; // { from, to, distance } - Represents paths// --- Simulation Configuration ---const config = {numAmrs: 3,amrSpeed: 50, // Pixels per real second at 1x speedamrSize: 15, // Visual size on canvasbatteryCapacity: 100, // Arbitrary unitsdischargeRate: 0.1, // Units per second while moving/workingchargeRate: 2, // Units per second while charginglowBatteryThreshold: 25,criticalBatteryThreshold: 10,taskGenerationInterval: 15, // Avg seconds between new tasks at 1x speedloadingTime: 3, // Real seconds at 1x speedunloadingTime: 3, // Real seconds at 1x speednodeColor: '#86868b',pathColor: '#d2d2d7',stationColor: '#007aff',chargerColor: '#34c759',amrColorIdle: '#34c759',amrColorMoving: '#007aff',amrColorCharging: '#ffcc00',amrColorLoading: '#ff9500',amrColorError: '#ff3b30',pathWidth: 2,nodeSize: 8,stationSize: 12,timeScaleDisplay: 1 // How many sim units pass per real second (for display only)};// --- Utility Functions ---function getRandomInt(min, max) {return Math.floor(Math.random() * (max - min + 1)) + min;}function getRandomElement(arr) {if (!arr || arr.length === 0) return undefined; // Handle empty array casereturn arr[Math.floor(Math.random() * arr.length)];}function formatSimTimeForLog(totalSeconds) {const hours = Math.floor(totalSeconds / 3600);const minutes = Math.floor((totalSeconds % 3600) / 60);const seconds = Math.floor(totalSeconds % 60);return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;}// --- Factory Layout Definition ---function defineLayout() {// Define nodes (stations, chargers, waypoints)nodes = {'SMT_IN': { id: 'SMT_IN', x: 100, y: 100, type: 'station', name: 'SMT上料' },'SMT_OUT': { id: 'SMT_OUT', x: 100, y: 200, type: 'station', name: 'SMT下料' },'ASSY_IN': { id: 'ASSY_IN', x: 400, y: 100, type: 'station', name: '组装上料' },'ASSY_OUT': { id: 'ASSY_OUT', x: 400, y: 200, type: 'station', name: '组装下料' },'WH_IN': { id: 'WH_IN', x: 100, y: 400, type: 'station', name: '仓库入库' },'WH_OUT': { id: 'WH_OUT', x: 100, y: 500, type: 'station', name: '仓库出库' },'CHRG1': { id: 'CHRG1', x: 400, y: 400, type: 'charger', name: '充电站1' },'CHRG2': { id: 'CHRG2', x: 400, y: 500, type: 'charger', name: '充电站2' },'WP1': { id: 'WP1', x: 250, y: 150, type: 'waypoint' }, // Waypoints for pathing'WP2': { id: 'WP2', x: 250, y: 450, type: 'waypoint' },};// Define edges (connections between nodes)// Automatically calculate distance for simplicityedges = [{ from: 'SMT_IN', to: 'SMT_OUT' },{ from: 'SMT_IN', to: 'WP1' },{ from: 'SMT_OUT', to: 'WP1' },{ from: 'SMT_OUT', to: 'WH_IN' }, // Direct path? Maybe via WP2{ from: 'WH_IN', to: 'WH_OUT' },{ from: 'WH_IN', to: 'WP2' },{ from: 'WH_OUT', to: 'WP2' },{ from: 'ASSY_IN', to: 'ASSY_OUT' },{ from: 'ASSY_IN', to: 'WP1' },{ from: 'ASSY_OUT', to: 'WP1' },{ from: 'ASSY_OUT', to: 'WH_IN' }, // Direct path? Maybe via WP2{ from: 'CHRG1', to: 'CHRG2' },{ from: 'CHRG1', to: 'WP2' },{ from: 'CHRG2', to: 'WP2' },{ from: 'WP1', to: 'WP2' }, // Connect waypoints];// Calculate distances for edgesedges.forEach(edge => {const nodeFrom = nodes[edge.from];const nodeTo = nodes[edge.to];if (nodeFrom && nodeTo) {edge.distance = Math.hypot(nodeTo.x - nodeFrom.x, nodeTo.y - nodeFrom.y);} else {console.error(`Invalid node in edge: ${edge.from} to ${edge.to}`);edge.distance = Infinity;}});}// --- AMR Initialization ---function createAmrs() {amrs = [];const startNodes = Object.keys(nodes).filter(id => nodes[id].type === 'charger'); // Start at chargersfor (let i = 0; i < config.numAmrs; i++) {const startNodeId = startNodes[i % startNodes.length] || Object.keys(nodes)[0]; // Fallback if not enough chargersconst startNode = nodes[startNodeId];amrs.push({id: `AMR-${String(i + 1).padStart(2, '0')}`,x: startNode.x,y: startNode.y,angle: 0, // Radiansbattery: config.batteryCapacity,status: '空闲', // Idle, MovingToPickup, Loading, MovingToDropoff, Unloading, MovingToCharge, Charging, ErrorcurrentTask: null, // Assigned task IDpath: [], // Array of node IDs to followcurrentTargetIndex: -1, // Index in the path arrayprocessTimer: 0, // For loading/unloading/charging durationprocessCompleteTime: 0, // Time needed for current process});}}// --- Pathfinding (Simplified BFS) ---function findPath(startNodeId, endNodeId) {if (!nodes[startNodeId] || !nodes[endNodeId]) return null; // Invalid nodesconst queue = [[startNodeId, [startNodeId]]]; // [currentNodeId, pathSoFar]const visited = new Set([startNodeId]);while (queue.length > 0) {const [currentId, path] = queue.shift();if (currentId === endNodeId) {return path; // Found the path}// Find neighborsconst neighbors = [];edges.forEach(edge => {if (edge.from === currentId && !visited.has(edge.to)) {neighbors.push(edge.to);visited.add(edge.to);} else if (edge.to === currentId && !visited.has(edge.from)) {neighbors.push(edge.from);visited.add(edge.from);}});for (const neighborId of neighbors) {queue.push([neighborId, [...path, neighborId]]);}}return null; // No path found}// --- Task Management ---function createTask() {const potentialStarts = Object.keys(nodes).filter(id => nodes[id].type === 'station' && (id.includes('OUT') || id.includes('WH_OUT')));const potentialEnds = Object.keys(nodes).filter(id => nodes[id].type === 'station' && (id.includes('IN') || id.includes('WH_IN')));if (potentialStarts.length === 0 || potentialEnds.length === 0) {addLog("无法创建任务:缺少合适的起点或终点", "warn");return;}let startNodeId = getRandomElement(potentialStarts);let endNodeId = getRandomElement(potentialEnds);// Ensure start and end are different meaningful locationswhile (startNodeId === endNodeId || nodes[startNodeId].name === nodes[endNodeId].name) {startNodeId = getRandomElement(potentialStarts); // Try againendNodeId = getRandomElement(potentialEnds);}const newTask = {id: `T-${String(nextTaskId++).padStart(4, '0')}`,material: `物料批次 ${nextTaskId - 1}`, // Placeholder materialstartNode: startNodeId,endNode: endNodeId,status: '待分配', // Pending, Assigned, InProgressPickup, InProgressDropoff, Completed, FailedassignedAmr: null,creationTime: simTimeSeconds};tasks.push(newTask);addLog(`创建任务 ${newTask.id}: 从 ${nodes[startNodeId].name} 到 ${nodes[endNodeId].name}`);}function assignTasks() {const pendingTasks = tasks.filter(t => t.status === '待分配');let idleAmrs = amrs.filter(a => a.status === '空闲' && a.battery > config.criticalBatteryThreshold);if (pendingTasks.length === 0 || idleAmrs.length === 0) {return; // Nothing to assign or no AMRs available}pendingTasks.forEach(task => {if (task.status !== '待分配') return; // Skip if already assigned concurrentlylet bestAmr = null;let minDistance = Infinity;// Find the closest idle AMRidleAmrs.forEach(amr => {if (amr.status === '空闲') { // Double check statusconst path = findPath(getNodeIdAt(amr.x, amr.y), task.startNode); // Find path from AMR's current node to task startif (path) {const distance = calculatePathDistance(path);if (distance < minDistance) {minDistance = distance;bestAmr = amr;}}}});if (bestAmr) {task.status = '已分配';task.assignedAmr = bestAmr.id;bestAmr.status = '移动至取货点';bestAmr.currentTask = task.id;// Find path for AMR from its current location to task start nodeconst amrCurrentNode = getNodeIdAt(bestAmr.x, bestAmr.y) || findClosestNode(bestAmr.x, bestAmr.y); // Find current or closest nodeconst pathToPickup = findPath(amrCurrentNode, task.startNode);if (pathToPickup) {bestAmr.path = pathToPickup;bestAmr.currentTargetIndex = 0; // Start from the first node in the path (which is current node)addLog(`任务 ${task.id} 分配给 ${bestAmr.id}. 前往 ${nodes[task.startNode].name}`);// Remove assigned AMR from idle list for this iterationidleAmrs = idleAmrs.filter(a => a.id !== bestAmr.id);} else {task.status = '分配失败'; // Cannot find pathbestAmr.status = '空闲';bestAmr.currentTask = null;addLog(`无法为 ${bestAmr.id} 找到前往 ${nodes[task.startNode].name} 的路径`, 'error');}}});}// Helper to get node ID if AMR is exactly at a nodefunction getNodeIdAt(x, y, tolerance = 1) {for (const nodeId in nodes) {if (Math.hypot(nodes[nodeId].x - x, nodes[nodeId].y - y) < tolerance) {return nodeId;}}return null;}// Helper to find the closest node if not exactly at onefunction findClosestNode(x, y) {let closestNodeId = null;let minDist = Infinity;for (const nodeId in nodes) {const dist = Math.hypot(nodes[nodeId].x - x, nodes[nodeId].y - y);if (dist < minDist) {minDist = dist;closestNodeId = nodeId;}}return closestNodeId;}// Helper to calculate total distance of a pathfunction calculatePathDistance(path) {let distance = 0;for (let i = 0; i < path.length - 1; i++) {const edge = edges.find(e => (e.from === path[i] && e.to === path[i+1]) || (e.to === path[i] && e.from === path[i+1]));if (edge) {distance += edge.distance;} else {return Infinity; // Should not happen if path is valid}}return distance;}// --- AMR Update Logic ---function updateAmrs(deltaTime) {amrs.forEach(amr => updateSingleAmr(amr, deltaTime));}function updateSingleAmr(amr, deltaTime) {const stateHandlers = {'空闲': handleIdle,'移动至取货点': handleMoving,'装货中': handleLoading,'移动至卸货点': handleMoving,'卸货中': handleUnloading,'移动至充电点': handleMoving,'充电中': handleCharging,'错误': handleError,};// Battery drain/chargeif (amr.status !== '空闲' && amr.status !== '充电中' && amr.status !== '错误') {amr.battery -= config.dischargeRate * deltaTime;} else if (amr.status === '充电中') {amr.battery += config.chargeRate * deltaTime;amr.battery = Math.min(amr.battery, config.batteryCapacity); // Cap at max}amr.battery = Math.max(0, amr.battery); // Floor at 0// Handle state logicconst handler = stateHandlers[amr.status];if (handler) {handler(amr, deltaTime);} else {console.warn(`Unhandled AMR status: ${amr.status}`);}// Check for critical battery if not already charging/going to chargeif (amr.battery <= config.criticalBatteryThreshold && amr.status !== '充电中' && amr.status !== '移动至充电点' && amr.status !== '错误') {handleLowBattery(amr, true); // Force charging task} else if (amr.battery <= config.lowBatteryThreshold && amr.status === '空闲') {handleLowBattery(amr, false); // Go charge if idle and low}}function handleIdle(amr, deltaTime) {// Stays idle unless a task is assigned or battery is low (handled in updateSingleAmr)}function handleMoving(amr, deltaTime) {if (!amr.path || amr.currentTargetIndex < 0 || amr.currentTargetIndex >= amr.path.length -1) {// If path is complete or invalid, something went wrong or destination reached implicitlyconsole.warn(`${amr.id} in moving state has invalid path/index. Path: ${JSON.stringify(amr.path)}, Index: ${amr.currentTargetIndex}`);// Attempt to recover: find closest node and determine next stateconst currentNodeId = getNodeIdAt(amr.x, amr.y) || findClosestNode(amr.x, amr.y);if (!currentNodeId) {amr.status = '错误';addLog(`${amr.id} 丢失位置,无法恢复!`, 'error');return;}amr.x = nodes[currentNodeId].x; // Snap to nodeamr.y = nodes[currentNodeId].y;// Check intended destination and update statetransitionAfterMove(amr, currentNodeId);return; // Exit after potential state change}const targetNodeId = amr.path[amr.currentTargetIndex + 1];const targetNode = nodes[targetNodeId];if (!targetNode) {amr.status = '错误';addLog(`${amr.id} 路径错误:目标节点 ${targetNodeId} 不存在`, 'error');return;}const dx = targetNode.x - amr.x;const dy = targetNode.y - amr.y;const distanceToTarget = Math.hypot(dx, dy);const moveDistance = config.amrSpeed * simulationSpeed * deltaTime;if (distanceToTarget <= moveDistance) {// Reached the target nodeamr.x = targetNode.x;amr.y = targetNode.y;amr.currentTargetIndex++;// Check if this was the final node in the pathif (amr.currentTargetIndex >= amr.path.length - 1) {transitionAfterMove(amr, targetNodeId);} else {// Continue to the next node, maybe log node arrival// addLog(`${amr.id} reached node ${targetNodeId}`);}} else {// Move towards targetconst angle = Math.atan2(dy, dx);amr.angle = angle;amr.x += Math.cos(angle) * moveDistance;amr.y += Math.sin(angle) * moveDistance;}}function transitionAfterMove(amr, reachedNodeId) {// Determine next state based on the original purpose of the moveswitch (amr.status) {case '移动至取货点':const task = tasks.find(t => t.id === amr.currentTask);if (task && reachedNodeId === task.startNode) {amr.status = '装货中';amr.processCompleteTime = config.loadingTime / simulationSpeed; // Adjust time by speedamr.processTimer = 0;addLog(`${amr.id} 到达 ${nodes[reachedNodeId].name},开始装货 (${task.material})`);} else {amr.status = '错误';addLog(`${amr.id} 到达 ${nodes[reachedNodeId].name} 但任务 ${amr.currentTask} 不匹配或状态错误`, 'error');}break;case '移动至卸货点':const dropOffTask = tasks.find(t => t.id === amr.currentTask);if (dropOffTask && reachedNodeId === dropOffTask.endNode) {amr.status = '卸货中';amr.processCompleteTime = config.unloadingTime / simulationSpeed;amr.processTimer = 0;addLog(`${amr.id} 到达 ${nodes[reachedNodeId].name},开始卸货 (${dropOffTask.material})`);} else {amr.status = '错误';addLog(`${amr.id} 到达 ${nodes[reachedNodeId].name} 但任务 ${amr.currentTask} 卸货点不匹配`, 'error');}break;case '移动至充电点':if (nodes[reachedNodeId]?.type === 'charger') {amr.status = '充电中';amr.processCompleteTime = Infinity; // Charges until full or interruptedamr.processTimer = 0;addLog(`${amr.id} 到达 ${nodes[reachedNodeId].name} 并开始充电`);} else {amr.status = '错误';addLog(`${amr.id} 到达 ${nodes[reachedNodeId].name},但不是充电站!`, 'error');}break;default:// Might happen if path ended unexpectedlyamr.status = '空闲'; // Default to idle if unsureaddLog(`${amr.id} 在 ${nodes[reachedNodeId].name} 完成移动,状态未知,变为空闲`, 'warn');break;}amr.path = []; // Clear path after reaching destinationamr.currentTargetIndex = -1;}function handleLoading(amr, deltaTime) {amr.processTimer += deltaTime;if (amr.processTimer >= amr.processCompleteTime) {const task = tasks.find(t => t.id === amr.currentTask);if (task) {task.status = '前往卸货点'; // Or 'InProgressDropoff'amr.status = '移动至卸货点';addLog(`${amr.id} 装货完成 (${task.material})`);const pathToDropoff = findPath(task.startNode, task.endNode);if (pathToDropoff) {amr.path = pathToDropoff;amr.currentTargetIndex = 0;addLog(`${amr.id} 前往 ${nodes[task.endNode].name}`);} else {amr.status = '错误';task.status = '失败';addLog(`无法找到从 ${nodes[task.startNode].name} 到 ${nodes[task.endNode].name} 的路径! 任务 ${task.id} 失败`, 'error');}} else {amr.status = '错误'; // Task disappeared?addLog(`${amr.id} 装货完成但找不到任务 ${amr.currentTask}!`, 'error');}amr.processTimer = 0;amr.processCompleteTime = 0;}}function handleUnloading(amr, deltaTime) {amr.processTimer += deltaTime;if (amr.processTimer >= amr.processCompleteTime) {const task = tasks.find(t => t.id === amr.currentTask);if (task) {task.status = '已完成';addLog(`任务 ${task.id} (${task.material}) 已完成`);}amr.status = '空闲'; // Becomes idle after completing taskamr.currentTask = null;addLog(`${amr.id} 卸货完成,变为空闲`);amr.processTimer = 0;amr.processCompleteTime = 0;// Check battery immediately after becoming idleif (amr.battery <= config.lowBatteryThreshold) {handleLowBattery(amr, false);}}}function handleCharging(amr, deltaTime) {if (amr.battery >= config.batteryCapacity) {amr.battery = config.batteryCapacity;amr.status = '空闲';addLog(`${amr.id} 充电完成,变为空闲`);}}function handleLowBattery(amr, forceCharge) {if (amr.status === '充电中' || amr.status === '移动至充电点') return; // Already handling batterylet nearestChargerId = null;let minDistance = Infinity;const amrCurrentNode = getNodeIdAt(amr.x, amr.y) || findClosestNode(amr.x, amr.y);if(!amrCurrentNode) {amr.status = '错误';addLog(`${amr.id} 电量低但无法确定当前位置!`, 'error');return;}Object.keys(nodes).filter(id => nodes[id].type === 'charger').forEach(chargerId => {const path = findPath(amrCurrentNode, chargerId);if(path) {const distance = calculatePathDistance(path);if (distance < minDistance) {minDistance = distance;nearestChargerId = chargerId;}}});if (nearestChargerId) {// Interrupt current task if forcedif (forceCharge && amr.currentTask) {const task = tasks.find(t => t.id === amr.currentTask);if (task && (task.status === '已分配' || task.status === '前往取货点' || task.status === '前往卸货点')) {task.status = '待分配'; // Put task back in queuetask.assignedAmr = null;addLog(`AMR ${amr.id} 电量严重不足 (${amr.battery.toFixed(0)}%), 任务 ${task.id} 放回队列`, 'warn');amr.currentTask = null;}// If loading/unloading, maybe let it finish? For simplicity, interrupt.else if (task) {task.status = '失败'; // Mark as failed if interrupted during load/unloadaddLog(`AMR ${amr.id} 在 ${amr.status} 时电量严重不足, 任务 ${task.id} 失败`, 'warn');amr.currentTask = null;}}amr.status = '移动至充电点';const pathToCharger = findPath(amrCurrentNode, nearestChargerId);if(pathToCharger){amr.path = pathToCharger;amr.currentTargetIndex = 0;addLog(`${amr.id} 电量低 (${amr.battery.toFixed(0)}%), 前往 ${nodes[nearestChargerId].name}`);} else {amr.status = '错误';addLog(`${amr.id} 电量低但找不到前往充电站 ${nearestChargerId} 的路径!`, 'error');}} else {amr.status = '错误';addLog(`${amr.id} 电量低 (${amr.battery.toFixed(0)}%) 但找不到可用的充电站!`, 'error');}}function handleError(amr, deltaTime) {// Stays in error state}// --- Drawing Functions ---function drawSimulation() {// Ensure context is availableif (!ctx) {console.error("Canvas context not available for drawing.");return;}// Resize canvas if needed (simple approach)const container = canvas.parentElement;const targetWidth = container.clientWidth - 2 * parseFloat(getComputedStyle(container).paddingLeft);const targetHeight = container.clientHeight - 2 * parseFloat(getComputedStyle(container).paddingTop) - container.querySelector('h2').offsetHeight - 16; // Adjust for title and paddingif (canvas.width !== targetWidth || canvas.height !== targetHeight) {canvas.width = targetWidth;canvas.height = targetHeight;// Need to potentially rescale layout coordinates if canvas size changes significantly// For now, assume layout fits initial size or scales visually ok.}// Clear canvasctx.clearRect(0, 0, canvas.width, canvas.height);ctx.fillStyle = '#eef0f2'; // Backgroundctx.fillRect(0, 0, canvas.width, canvas.height);// Draw LayoutdrawLayout();// Draw AMRsamrs.forEach(drawAmr);// Draw other elements (e.g., task highlights - future)}function drawLayout() {if (!ctx) return; // Check context// Draw Pathsctx.strokeStyle = config.pathColor;ctx.lineWidth = config.pathWidth;edges.forEach(edge => {const fromNode = nodes[edge.from];const toNode = nodes[edge.to];if (fromNode && toNode) {ctx.beginPath();ctx.moveTo(fromNode.x, fromNode.y);ctx.lineTo(toNode.x, toNode.y);ctx.stroke();}});// Draw NodesObject.values(nodes).forEach(node => {let size = config.nodeSize;let color = config.nodeColor;if (node.type === 'station') {size = config.stationSize;color = config.stationColor;} else if (node.type === 'charger') {size = config.stationSize;color = config.chargerColor;}ctx.fillStyle = color;ctx.beginPath();ctx.arc(node.x, node.y, size / 2, 0, Math.PI * 2);ctx.fill();// Draw node names for stations and chargersif (node.type !== 'waypoint' && node.name) {ctx.fillStyle = config.nodeColor;ctx.font = '10px sans-serif';ctx.textAlign = 'center';ctx.fillText(node.name, node.x, node.y + size + 5); // Position text below node}});}function drawAmr(amr) {if (!ctx) return; // Check contextctx.save();ctx.translate(amr.x, amr.y);ctx.rotate(amr.angle); // Rotate based on movement direction// Choose color based on statuslet color = config.amrColorIdle;switch (amr.status) {case '移动至取货点':case '移动至卸货点':case '移动至充电点':color = config.amrColorMoving;break;case '装货中':case '卸货中':color = config.amrColorLoading;break;case '充电中':color = config.amrColorCharging;break;case '错误':color = config.amrColorError;break;}// Draw AMR body (e.g., a rounded rectangle or circle)ctx.fillStyle = color;ctx.beginPath();// Simple triangle shape for directionalityctx.moveTo(config.amrSize / 2, 0);ctx.lineTo(-config.amrSize / 2, -config.amrSize / 3);ctx.lineTo(-config.amrSize / 2, config.amrSize / 3);ctx.closePath();ctx.fill();// Draw ID text abovectx.rotate(-amr.angle); // Counter-rotate for textctx.fillStyle = '#000';ctx.font = '9px sans-serif';ctx.textAlign = 'center';ctx.fillText(amr.id, 0, -config.amrSize / 2 - 2);ctx.restore();// Draw battery bar below AMR (optional)const batteryX = amr.x - config.amrSize / 2;const batteryY = amr.y + config.amrSize / 2 + 3;const batteryWidth = config.amrSize;const batteryHeight = 4;const batteryLevel = (amr.battery / config.batteryCapacity);ctx.fillStyle = '#e0e0e0'; // Background barctx.fillRect(batteryX, batteryY, batteryWidth, batteryHeight);let batteryColor = config.accentGreen;if (amr.battery <= config.criticalBatteryThreshold) batteryColor = config.accentRed;else if (amr.battery <= config.lowBatteryThreshold) batteryColor = config.accentYellow;ctx.fillStyle = batteryColor;ctx.fillRect(batteryX, batteryY, batteryWidth * batteryLevel, batteryHeight);ctx.strokeStyle = '#888';ctx.lineWidth = 0.5;ctx.strokeRect(batteryX, batteryY, batteryWidth, batteryHeight);}// --- UI Update Functions ---function updateAmrList() {amrListUl.innerHTML = ''; // Clearif (amrs.length === 0) {amrListUl.innerHTML = '<li>暂无 AMR 数据</li>';return;}amrs.forEach(amr => {const li = document.createElement('li');const batteryPercentage = ((amr.battery / config.batteryCapacity) * 100).toFixed(0);let batteryClass = '';if (amr.battery <= config.criticalBatteryThreshold) batteryClass = 'critical';else if (amr.battery <= config.lowBatteryThreshold) batteryClass = 'low';li.innerHTML = `<div class="amr-info"><span class="amr-id">${amr.id}</span><span>任务: ${amr.currentTask || '--'}</span></div><div class="amr-status">状态: <span class="status-badge status-${getStatusClass(amr.status)}">${amr.status}</span></div><div class="amr-battery"><div class="battery-icon"><div class="battery-level ${batteryClass}" style="width: ${batteryPercentage}%;"></div></div>${batteryPercentage}%</div>`;amrListUl.appendChild(li);});}function updateTaskList() {taskListUl.innerHTML = ''; // Clearconst tasksToShow = tasks.filter(t => t.status !== '已完成' && t.status !== '失败').slice(-50); // Show active/pending, limit viewconst completedTasks = tasks.filter(t => t.status === '已完成').slice(-10); // Show some recent completedif (tasksToShow.length === 0 && completedTasks.length === 0) {taskListUl.innerHTML = '<li>暂无任务数据</li>';return;}[...tasksToShow, ...completedTasks].forEach(task => {const li = document.createElement('li');const startName = nodes[task.startNode]?.name || task.startNode;const endName = nodes[task.endNode]?.name || task.endNode;li.innerHTML = `<div class="task-info"><span class="task-id">${task.id}</span><span class="task-details">从 ${startName} 到 ${endName} (${task.material})</span></div><div class="task-status"><span class="status-badge status-${getStatusClass(task.status)}">${task.status}</span>${task.assignedAmr ? `(${task.assignedAmr})` : ''}</div>`;taskListUl.appendChild(li);});}function getStatusClass(status) {switch (status) {case '空闲': return 'idle';case '移动至取货点':case '移动至卸货点':case '移动至充电点': return 'moving';case '装货中': return 'loading';case '卸货中': return 'unloading';case '充电中': return 'charging';case '错误': return 'error';case '待分配': return 'pending';case '已分配': return 'assigned';case '前往取货点': return 'inprogress'; // Consider distinct inprogress state?case '前往卸货点': return 'inprogress';case '已完成': return 'completed';case '失败': return 'error';default: return '';}}// --- Simulation Loop ---let timeSinceLastTask = 0;function simulationStep(timestamp) {if (!simulationRunning || simulationPaused) {lastTimestamp = 0; // Reset timestamp when paused/stoppedif (simulationRunning) animationFrameId = requestAnimationFrame(simulationStep);return;}if (!lastTimestamp) lastTimestamp = timestamp;const realDeltaTimeMs = timestamp - lastTimestamp;lastTimestamp = timestamp;const deltaTime = realDeltaTimeMs / 1000; // Delta time in seconds// Update simulation time (real seconds elapsed)simTimeSeconds += deltaTime;// Update AMR states and positionsupdateAmrs(deltaTime);// Assign pending tasksassignTasks();// Generate new tasks periodicallytimeSinceLastTask += deltaTime * simulationSpeed; // Time passes faster at higher speedif (timeSinceLastTask >= config.taskGenerationInterval) {createTask();timeSinceLastTask = 0; // Reset timer}// Draw the simulation statedrawSimulation();// Update UI PanelsupdateAmrList();updateTaskList();const totalSeconds = simTimeSeconds * config.timeScaleDisplay; // Use display scaleconst hours = Math.floor(totalSeconds / 3600);const minutes = Math.floor((totalSeconds % 3600) / 60);const seconds = Math.floor(totalSeconds % 60);simulationTimeSpan.textContent = `模拟时间: ${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;// Request next frameanimationFrameId = requestAnimationFrame(simulationStep);}// --- Event Handlers ---startSimBtn.addEventListener('click', () => {if (simulationRunning && !simulationPaused) return;if (simulationPaused) { // ResumesimulationPaused = false;lastTimestamp = performance.now(); // Get current time immediatelyaddLog("模拟已恢复");pauseSimBtn.disabled = false;startSimBtn.textContent = '开始';startSimBtn.disabled = true;simulationStatusSpan.textContent = '状态: 运行中';} else { // Start freshresetSimulationState();defineLayout();createAmrs();simulationRunning = true;simulationPaused = false;simTimeSeconds = 0;timeSinceLastTask = 0;lastTimestamp = performance.now();addLog("模拟已开始", "success");startSimBtn.disabled = true;pauseSimBtn.disabled = false;resetSimBtn.disabled = false;simulationStatusSpan.textContent = '状态: 运行中';// Add initial task maybe?createTask();}// Clear any previous frame request and start anewcancelAnimationFrame(animationFrameId);animationFrameId = requestAnimationFrame(simulationStep);});pauseSimBtn.addEventListener('click', () => {if (!simulationRunning || simulationPaused) return;simulationPaused = true;// Don't cancel animation frame immediately, let the current step finish if needed?// Or cancel to stop right away:cancelAnimationFrame(animationFrameId);addLog("模拟已暂停", "warn");startSimBtn.disabled = false;startSimBtn.textContent = '恢复';pauseSimBtn.disabled = true;simulationStatusSpan.textContent = `状态: 已暂停`;});resetSimBtn.addEventListener('click', () => {simulationRunning = false;simulationPaused = false;cancelAnimationFrame(animationFrameId);resetSimulationState();defineLayout(); // Redefine layout in case canvas size changedcreateAmrs(); // Recreate AMRsaddLog("模拟已重置", "info");startSimBtn.disabled = false;startSimBtn.textContent = '开始';pauseSimBtn.disabled = true;resetSimBtn.disabled = true;simulationTimeSpan.textContent = `模拟时间: 00:00:00`;simulationStatusSpan.textContent = '状态: 空闲';// Initial draw and UI updatedrawSimulation();updateAmrList();updateTaskList();eventLogUl.innerHTML = '<li>模拟系统已初始化。</li>';});addTaskBtn.addEventListener('click', () => {if (!simulationRunning) {addLog("请先开始模拟才能添加任务", "warn");return;}createTask();updateTaskList(); // Update immediately});simSpeedSlider.addEventListener('input', (e) => {simulationSpeed = parseFloat(e.target.value);simSpeedValue.textContent = `${simulationSpeed}x`;// Adjust task generation interval timing if based on real time intervals});// Utility to add log messagesfunction addLog(message, type = 'info') {const li = document.createElement('li');const timeStr = formatSimTimeForLog(simTimeSeconds * config.timeScaleDisplay); // Use display scaleli.innerHTML = `<span class="log-time">[${timeStr}]</span> ${message}`;li.classList.add(`log-${type}`); // Add class for potential stylingeventLogUl.insertBefore(li, eventLogUl.firstChild);if (eventLogUl.children.length > 150) { // Limit log sizeeventLogUl.removeChild(eventLogUl.lastChild);}}// --- Initialization ---function resetSimulationState() {simTimeSeconds = 0;lastTimestamp = 0;amrs = [];tasks = [];nodes = {};edges = [];nextTaskId = 1;timeSinceLastTask = 0;}function initializeApp() {simSpeedValue.textContent = `${simulationSpeed}x`;simSpeedSlider.value = simulationSpeed;resetSimulationState(); // Initial cleardefineLayout();createAmrs();// --- Get Canvas Context AFTER layout definition ---if (canvas) {ctx = canvas.getContext('2d');if (!ctx) {console.error("Failed to get 2D context from canvas!");addLog("错误:无法初始化绘图区域", "error");// Disable simulation controls if canvas failsstartSimBtn.disabled = true;addTaskBtn.disabled = true;return; // Stop initialization}} else {console.error("Canvas element not found during initialization!");addLog("错误:找不到绘图区域元素", "error");startSimBtn.disabled = true;addTaskBtn.disabled = true;return; // Stop initialization}// --- End getting context ---drawSimulation(); // Initial drawupdateAmrList();updateTaskList();simulationStatusSpan.textContent = '状态: 空闲';simulationTimeSpan.textContent = `模拟时间: 00:00:00`;addLog("应用程序已初始化");// Ensure buttons are correctly enabled/disabled initiallystartSimBtn.disabled = false;pauseSimBtn.disabled = true;resetSimBtn.disabled = true;}initializeApp();
});