35、自主移动机器人 (AMR) 调度模拟 (电子厂) - /物流与仓储组件/amr-scheduling-electronics

76个工业组件库示例汇总

效果展示

源码

index.html

javascript 复制代码
<!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

javascript 复制代码
: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

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();
}); 
相关推荐
Rubin9320 分钟前
TS 相关
javascript
该用户已不存在25 分钟前
这几款Rust工具,开发体验直线上升
前端·后端·rust
前端雾辰36 分钟前
Uniapp APP 端实现 TCP Socket 通信(ZPL 打印实战)
前端
无羡仙43 分钟前
虚拟列表:怎么显示大量数据不卡
前端·react.js
云水边1 小时前
前端网络性能优化
前端
用户51681661458411 小时前
[微前端 qiankun] 加载报错:Target container with #child-container not existed while devi
前端
东北南西1 小时前
设计模式-工厂模式
前端·设计模式
HANK1 小时前
ECharts高效实现复杂图表指南
前端·vue.js
入秋1 小时前
Linux服务器安装部署 Nginx、Redis、PostgreSQL、Docker
linux·前端
acocosum1 小时前
毫米波雷达基础知识学习报告
前端