javascript
复制代码
document.addEventListener('DOMContentLoaded', () => {
// --- DOM Elements ---
const canvas = document.getElementById('simulationCanvas');
let ctx = null; // Initialize ctx as null
const 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 timing
let 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 speed
amrSize: 15, // Visual size on canvas
batteryCapacity: 100, // Arbitrary units
dischargeRate: 0.1, // Units per second while moving/working
chargeRate: 2, // Units per second while charging
lowBatteryThreshold: 25,
criticalBatteryThreshold: 10,
taskGenerationInterval: 15, // Avg seconds between new tasks at 1x speed
loadingTime: 3, // Real seconds at 1x speed
unloadingTime: 3, // Real seconds at 1x speed
nodeColor: '#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 case
return 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 simplicity
edges = [
{ 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 edges
edges.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 chargers
for (let i = 0; i < config.numAmrs; i++) {
const startNodeId = startNodes[i % startNodes.length] || Object.keys(nodes)[0]; // Fallback if not enough chargers
const startNode = nodes[startNodeId];
amrs.push({
id: `AMR-${String(i + 1).padStart(2, '0')}`,
x: startNode.x,
y: startNode.y,
angle: 0, // Radians
battery: config.batteryCapacity,
status: '空闲', // Idle, MovingToPickup, Loading, MovingToDropoff, Unloading, MovingToCharge, Charging, Error
currentTask: null, // Assigned task ID
path: [], // Array of node IDs to follow
currentTargetIndex: -1, // Index in the path array
processTimer: 0, // For loading/unloading/charging duration
processCompleteTime: 0, // Time needed for current process
});
}
}
// --- Pathfinding (Simplified BFS) ---
function findPath(startNodeId, endNodeId) {
if (!nodes[startNodeId] || !nodes[endNodeId]) return null; // Invalid nodes
const 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 neighbors
const 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 locations
while (startNodeId === endNodeId || nodes[startNodeId].name === nodes[endNodeId].name) {
startNodeId = getRandomElement(potentialStarts); // Try again
endNodeId = getRandomElement(potentialEnds);
}
const newTask = {
id: `T-${String(nextTaskId++).padStart(4, '0')}`,
material: `物料批次 ${nextTaskId - 1}`, // Placeholder material
startNode: startNodeId,
endNode: endNodeId,
status: '待分配', // Pending, Assigned, InProgressPickup, InProgressDropoff, Completed, Failed
assignedAmr: 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 concurrently
let bestAmr = null;
let minDistance = Infinity;
// Find the closest idle AMR
idleAmrs.forEach(amr => {
if (amr.status === '空闲') { // Double check status
const path = findPath(getNodeIdAt(amr.x, amr.y), task.startNode); // Find path from AMR's current node to task start
if (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 node
const amrCurrentNode = getNodeIdAt(bestAmr.x, bestAmr.y) || findClosestNode(bestAmr.x, bestAmr.y); // Find current or closest node
const 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 iteration
idleAmrs = idleAmrs.filter(a => a.id !== bestAmr.id);
} else {
task.status = '分配失败'; // Cannot find path
bestAmr.status = '空闲';
bestAmr.currentTask = null;
addLog(`无法为 ${bestAmr.id} 找到前往 ${nodes[task.startNode].name} 的路径`, 'error');
}
}
});
}
// Helper to get node ID if AMR is exactly at a node
function 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 one
function 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 path
function 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/charge
if (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 logic
const 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 charge
if (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 implicitly
console.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 state
const 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 node
amr.y = nodes[currentNodeId].y;
// Check intended destination and update state
transitionAfterMove(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 node
amr.x = targetNode.x;
amr.y = targetNode.y;
amr.currentTargetIndex++;
// Check if this was the final node in the path
if (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 target
const 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 move
switch (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 speed
amr.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 interrupted
amr.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 unexpectedly
amr.status = '空闲'; // Default to idle if unsure
addLog(`${amr.id} 在 ${nodes[reachedNodeId].name} 完成移动,状态未知,变为空闲`, 'warn');
break;
}
amr.path = []; // Clear path after reaching destination
amr.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 task
amr.currentTask = null;
addLog(`${amr.id} 卸货完成,变为空闲`);
amr.processTimer = 0;
amr.processCompleteTime = 0;
// Check battery immediately after becoming idle
if (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 battery
let 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 forced
if (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 queue
task.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/unload
addLog(`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 available
if (!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 padding
if (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 canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#eef0f2'; // Background
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw Layout
drawLayout();
// Draw AMRs
amrs.forEach(drawAmr);
// Draw other elements (e.g., task highlights - future)
}
function drawLayout() {
if (!ctx) return; // Check context
// Draw Paths
ctx.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 Nodes
Object.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 chargers
if (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 context
ctx.save();
ctx.translate(amr.x, amr.y);
ctx.rotate(amr.angle); // Rotate based on movement direction
// Choose color based on status
let 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 directionality
ctx.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 above
ctx.rotate(-amr.angle); // Counter-rotate for text
ctx.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 bar
ctx.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 = ''; // Clear
if (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 = ''; // Clear
const tasksToShow = tasks.filter(t => t.status !== '已完成' && t.status !== '失败').slice(-50); // Show active/pending, limit view
const completedTasks = tasks.filter(t => t.status === '已完成').slice(-10); // Show some recent completed
if (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/stopped
if (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 positions
updateAmrs(deltaTime);
// Assign pending tasks
assignTasks();
// Generate new tasks periodically
timeSinceLastTask += deltaTime * simulationSpeed; // Time passes faster at higher speed
if (timeSinceLastTask >= config.taskGenerationInterval) {
createTask();
timeSinceLastTask = 0; // Reset timer
}
// Draw the simulation state
drawSimulation();
// Update UI Panels
updateAmrList();
updateTaskList();
const totalSeconds = simTimeSeconds * config.timeScaleDisplay; // Use display scale
const 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 frame
animationFrameId = requestAnimationFrame(simulationStep);
}
// --- Event Handlers ---
startSimBtn.addEventListener('click', () => {
if (simulationRunning && !simulationPaused) return;
if (simulationPaused) { // Resume
simulationPaused = false;
lastTimestamp = performance.now(); // Get current time immediately
addLog("模拟已恢复");
pauseSimBtn.disabled = false;
startSimBtn.textContent = '开始';
startSimBtn.disabled = true;
simulationStatusSpan.textContent = '状态: 运行中';
} else { // Start fresh
resetSimulationState();
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 anew
cancelAnimationFrame(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 changed
createAmrs(); // Recreate AMRs
addLog("模拟已重置", "info");
startSimBtn.disabled = false;
startSimBtn.textContent = '开始';
pauseSimBtn.disabled = true;
resetSimBtn.disabled = true;
simulationTimeSpan.textContent = `模拟时间: 00:00:00`;
simulationStatusSpan.textContent = '状态: 空闲';
// Initial draw and UI update
drawSimulation();
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 messages
function addLog(message, type = 'info') {
const li = document.createElement('li');
const timeStr = formatSimTimeForLog(simTimeSeconds * config.timeScaleDisplay); // Use display scale
li.innerHTML = `<span class="log-time">[${timeStr}]</span> ${message}`;
li.classList.add(`log-${type}`); // Add class for potential styling
eventLogUl.insertBefore(li, eventLogUl.firstChild);
if (eventLogUl.children.length > 150) { // Limit log size
eventLogUl.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 clear
defineLayout();
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 fails
startSimBtn.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 draw
updateAmrList();
updateTaskList();
simulationStatusSpan.textContent = '状态: 空闲';
simulationTimeSpan.textContent = `模拟时间: 00:00:00`;
addLog("应用程序已初始化");
// Ensure buttons are correctly enabled/disabled initially
startSimBtn.disabled = false;
pauseSimBtn.disabled = true;
resetSimBtn.disabled = true;
}
initializeApp();
});