```json
{
"summary": "矿车运营数据统计仪表盘(全分组对比版),白底企业级设计,8项核心指标+三图全量分组对比(折线趋势按组别多线/堆叠柱状按组别分列/环形占比按组别多图),支持矿区→车辆类型→车辆三级联动筛选,年/月/日粒度,日期区间选取,CSV导出与打印。",
"widget": {
"type": "full-html",
"html": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>矿车生产运营数据统计</title>\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n <link href=\"https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9…\" rel=\"stylesheet\">\n <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.c…\">\n <style>\n :root {\n --bg-page: #f5f6f8;\n --bg-panel: #fdfdfd;\n --bg-card: #ffffff;\n --bg-elevated: #fafbfc;\n --bg-stripe: #f8f9fb;\n --border: #e5e8ed;\n --border-light: #eff1f5;\n --border-active: #c9ced7;\n --text-primary: #0f172a;\n --text-secondary: #475569;\n --text-muted: #94a3b8;\n --accent-amber: #d4891a;\n --accent-cyan: #0d82b8;\n --accent-teal: #0f766e;\n --accent-orange: #ea570c;\n --accent-purple: #7c3aed;\n --accent-blue: #2563eb;\n --accent-green: #16a34a;\n --accent-red: #dc2626;\n --radius-sm: 4px;\n --radius: 8px;\n --radius-md: 10px;\n --radius-lg: 14px;\n --radius-xl: 18px;\n --shadow-xs: 0 1px 2px rgba(0,0,0,0.03);\n --shadow-sm: 0 1px 3px rgba(0,0,0,0.04), 0 1px 2px rgba(0,0,0,0.03);\n --shadow-card: 0 2px 8px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.02);\n --shadow-elevated: 0 8px 28px rgba(0,0,0,0.07), 0 2px 6px rgba(0,0,0,0.03);\n --transition-base: 200ms cubic-bezier(0.4, 0, 0.2, 1);\n --font-display: 'DM Sans', 'Noto Sans SC', sans-serif;\n --font-body: 'Noto Sans SC', 'DM Sans', sans-serif;\n }\n\n * { box-sizing: border-box; margin: 0; padding: 0; }\n\n body {\n background: var(--bg-page);\n color: var(--text-primary);\n font-family: var(--font-body);\n min-height: 100vh;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 10px;\n background-image:\n radial-gradient(ellipse at 15% 10%, rgba(212,137,26,0.03) 0%, transparent 55%),\n radial-gradient(ellipse at 85% 90%, rgba(13,130,184,0.02) 0%, transparent 55%);\n }\n\n .dashboard {\n width: 100%;\n max-width: 1600px;\n aspect-ratio: 16 / 9;\n max-height: calc(100vh - 20px);\n display: flex;\n flex-direction: column;\n gap: 5px;\n background: var(--bg-panel);\n border-radius: var(--radius-xl);\n padding: 10px 14px;\n box-shadow: var(--shadow-elevated);\n border: 1px solid var(--border);\n position: relative;\n overflow: hidden;\n }\n\n .dashboard::before {\n content: '';\n position: absolute;\n inset: 0;\n pointer-events: none;\n background:\n linear-gradient(180deg, rgba(212,137,26,0.012) 0%, transparent 25%),\n linear-gradient(0deg, rgba(13,130,184,0.008) 0%, transparent 35%);\n border-radius: var(--radius-xl);\n z-index: 0;\n }\n\n .dashboard > * { position: relative; z-index: 1; }\n\n /* ============ HEADER ============ */\n .header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 8px;\n flex-shrink: 0;\n padding-bottom: 4px;\n border-bottom: 1px solid var(--border-light);\n }\n\n .header-left { display: flex; align-items: center; gap: 8px; }\n\n .logo-mark {\n width: 30px;\n height: 30px;\n background: linear-gradient(135deg, #d4891a, #b07214);\n border-radius: var(--radius);\n display: flex;\n align-items: center;\n justify-content: center;\n font-size: 14px;\n color: #ffffff;\n font-weight: 700;\n flex-shrink: 0;\n box-shadow: 0 2px 8px rgba(212,137,26,0.22);\n }\n\n .header-title h1 {\n font-family: var(--font-display);\n font-size: clamp(12px, 1vw, 17px);\n font-weight: 700;\n letter-spacing: -0.01em;\n color: var(--text-primary);\n line-height: 1.1;\n }\n\n .header-title span {\n font-size: 8px;\n color: var(--text-muted);\n font-weight: 500;\n letter-spacing: 0.06em;\n text-transform: uppercase;\n }\n\n .header-right {\n display: flex;\n align-items: center;\n gap: 4px;\n flex-wrap: wrap;\n justify-content: flex-end;\n }\n\n .btn-group {\n display: flex;\n background: var(--bg-card);\n border-radius: var(--radius);\n border: 1px solid var(--border);\n overflow: hidden;\n box-shadow: var(--shadow-xs);\n }\n\n .btn-group button {\n background: transparent;\n border: none;\n color: var(--text-secondary);\n padding: 4px 9px;\n cursor: pointer;\n font-family: var(--font-display);\n font-size: 10px;\n font-weight: 600;\n letter-spacing: 0.01em;\n transition: all var(--transition-base);\n white-space: nowrap;\n position: relative;\n }\n\n .btn-group button:not(:last-child)::after {\n content: '';\n position: absolute;\n right: 0;\n top: 14%;\n height: 72%;\n width: 1px;\n background: var(--border);\n }\n\n .btn-group button:hover { color: var(--text-primary); background: var(--bg-elevated); }\n\n .btn-group button.active {\n color: #ffffff;\n background: #d4891a;\n box-shadow: 0 0 8px rgba(212,137,26,0.2);\n border-radius: 0;\n }\n .btn-group button.active::after { display: none; }\n .btn-group button:first-child.active { border-radius: var(--radius) 0 0 var(--radius); }\n .btn-group button:last-child.active { border-radius: 0 var(--radius) var(--radius) 0; }\n\n select, .date-input {\n background: var(--bg-card);\n border: 1px solid var(--border);\n color: var(--text-primary);\n padding: 4px 6px;\n border-radius: var(--radius);\n font-family: var(--font-display);\n font-size: 10px;\n outline: none;\n transition: all var(--transition-base);\n cursor: pointer;\n box-shadow: var(--shadow-xs);\n }\n select:focus, .date-input:focus { border-color: #d4891a; box-shadow: 0 0 0 2px rgba(212,137,26,0.1); }\n .date-input { width: 100px; }\n select { min-width: 68px; }\n\n .date-separator { color: var(--text-muted); font-size: 10px; font-weight: 500; }\n\n .filter-label {\n font-size: 7px;\n color: var(--text-muted);\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.05em;\n white-space: nowrap;\n }\n\n .btn-action {\n background: var(--bg-card);\n border: 1px solid var(--border);\n color: var(--text-secondary);\n width: 26px;\n height: 26px;\n border-radius: var(--radius);\n cursor: pointer;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: all var(--transition-base);\n font-size: 10px;\n box-shadow: var(--shadow-xs);\n }\n\n .btn-action:hover { color: var(--text-primary); border-color: var(--border-active); background: var(--bg-elevated); }\n\n .btn-primary {\n background: linear-gradient(135deg, #d4891a, #b07214);\n border: none;\n color: #ffffff;\n padding: 4px 12px;\n border-radius: var(--radius);\n cursor: pointer;\n font-family: var(--font-display);\n font-size: 10px;\n font-weight: 600;\n letter-spacing: 0.01em;\n transition: all var(--transition-base);\n white-space: nowrap;\n box-shadow: 0 2px 6px rgba(212,137,26,0.2);\n }\n\n .btn-primary:hover { filter: brightness(1.07); transform: translateY(-1px); box-shadow: 0 4px 12px rgba(212,137,26,0.26); }\n .btn-primary:active { transform: scale(0.97); }\n\n /* ============ FILTER BAR ============ */\n .filter-bar {\n display: flex;\n align-items: center;\n gap: 5px;\n flex-shrink: 0;\n flex-wrap: wrap;\n padding: 3px 0;\n }\n\n .filter-bar .sep {\n width: 1px;\n height: 18px;\n background: var(--border);\n margin: 0 1px;\n }\n\n /* ============ METRICS ROW ============ */\n .metrics-row {\n display: grid;\n grid-template-columns: repeat(8, 1fr);\n gap: 4px;\n flex-shrink: 0;\n }\n\n .metric-card {\n background: var(--bg-card);\n border-radius: var(--radius-md);\n padding: 6px 8px;\n border: 1px solid var(--border-light);\n position: relative;\n overflow: hidden;\n transition: all var(--transition-base);\n cursor: default;\n display: flex;\n flex-direction: column;\n gap: 1px;\n box-shadow: var(--shadow-sm);\n }\n\n .metric-card:hover {\n border-color: var(--border-active);\n box-shadow: var(--shadow-elevated);\n transform: translateY(-1px);\n }\n\n .metric-card .card-accent {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 2px;\n }\n\n .metric-card:nth-child(1) .card-accent { background: #d4891a; }\n .metric-card:nth-child(2) .card-accent { background: #0d82b8; }\n .metric-card:nth-child(3) .card-accent { background: #0f766e; }\n .metric-card:nth-child(4) .card-accent { background: #ea570c; }\n .metric-card:nth-child(5) .card-accent { background: #7c3aed; }\n .metric-card:nth-child(6) .card-accent { background: #2563eb; }\n .metric-card:nth-child(7) .card-accent { background: #16a34a; }\n .metric-card:nth-child(8) .card-accent { background: #dc2626; }\n\n .metric-card .card-icon { font-size: 7px; color: var(--text-muted); }\n\n .metric-card .card-label {\n font-size: 7px;\n color: var(--text-muted);\n text-transform: uppercase;\n letter-spacing: 0.05em;\n font-weight: 600;\n }\n\n .metric-card .card-value {\n font-family: var(--font-display);\n font-size: clamp(11px, 1.2vw, 17px);\n font-weight: 700;\n color: var(--text-primary);\n letter-spacing: -0.02em;\n line-height: 1;\n }\n\n .metric-card .card-sub { font-size: 6px; color: var(--text-muted); font-weight: 500; }\n\n /* ============ CHARTS AREA ============ */\n .charts-area {\n display: grid;\n grid-template-columns: 1.25fr 0.85fr 0.9fr;\n gap: 5px;\n flex: 1;\n min-height: 0;\n }\n\n .chart-box {\n background: var(--bg-card);\n border-radius: var(--radius-md);\n border: 1px solid var(--border-light);\n padding: 8px 10px;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n box-shadow: var(--shadow-sm);\n }\n\n .chart-box-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n flex-shrink: 0;\n margin-bottom: 3px;\n gap: 4px;\n }\n\n .chart-box-title {\n font-family: var(--font-display);\n font-size: 9px;\n font-weight: 600;\n letter-spacing: 0.02em;\n color: var(--text-secondary);\n text-transform: uppercase;\n white-space: nowrap;\n }\n\n .chart-legend {\n display: flex;\n gap: 4px;\n font-size: 7px;\n color: var(--text-muted);\n flex-wrap: wrap;\n justify-content: flex-end;\n }\n\n .chart-legend span { display: flex; align-items: center; gap: 2px; white-space: nowrap; }\n .chart-legend .dot { width: 4px; height: 4px; border-radius: 50%; display: inline-block; flex-shrink: 0; }\n\n .chart-canvas-wrap {\n flex: 1;\n min-height: 0;\n position: relative;\n width: 100%;\n }\n\n .chart-canvas-wrap canvas { width: 100%; height: 100%; display: block; }\n\n /* ============ RATIO PANEL (多分组环形图) ============ */\n .ratio-panel {\n background: var(--bg-card);\n border-radius: var(--radius-md);\n border: 1px solid var(--border-light);\n padding: 8px 10px;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n box-shadow: var(--shadow-sm);\n }\n\n .ratio-header { flex-shrink: 0; margin-bottom: 2px; }\n\n .ratio-body {\n flex: 1;\n display: flex;\n flex-direction: column;\n gap: 3px;\n min-height: 0;\n overflow-y: auto;\n }\n\n .ratio-body::-webkit-scrollbar { width: 3px; }\n .ratio-body::-webkit-scrollbar-track { background: transparent; }\n .ratio-body::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }\n\n .ratio-group-row {\n display: flex;\n align-items: center;\n gap: 6px;\n flex-shrink: 0;\n }\n\n .ratio-group-name {\n font-family: var(--font-display);\n font-size: 8px;\n font-weight: 600;\n color: var(--text-secondary);\n width: 52px;\n text-align: right;\n flex-shrink: 0;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n .ratio-hbar-wrap {\n flex: 1;\n height: 10px;\n background: #eef0f4;\n border-radius: 3px;\n overflow: hidden;\n display: flex;\n }\n\n .ratio-hbar-wrap .seg-load { background: #ea570c; height: 100%; }\n .ratio-hbar-wrap .seg-haul { background: #2563eb; height: 100%; }\n .ratio-hbar-wrap .seg-unload { background: #7c3aed; height: 100%; }\n\n .ratio-group-pct {\n font-family: var(--font-display);\n font-size: 8px;\n font-weight: 700;\n color: var(--text-primary);\n width: 30px;\n text-align: right;\n flex-shrink: 0;\n }\n\n .ratio-legend-row {\n display: flex;\n gap: 8px;\n font-size: 7px;\n color: var(--text-muted);\n flex-shrink: 0;\n padding-bottom: 2px;\n border-bottom: 1px solid var(--border-light);\n }\n\n .ratio-legend-row span { display: flex; align-items: center; gap: 2px; }\n .ratio-legend-row .dot { width: 5px; height: 5px; border-radius: 2px; display: inline-block; flex-shrink: 0; }\n\n /* ============ TABLE ============ */\n .table-section {\n flex-shrink: 0;\n background: var(--bg-card);\n border-radius: var(--radius-md);\n border: 1px solid var(--border-light);\n overflow: hidden;\n display: flex;\n flex-direction: column;\n max-height: 95px;\n box-shadow: var(--shadow-sm);\n }\n\n .table-section-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 3px 10px;\n border-bottom: 1px solid var(--border-light);\n flex-shrink: 0;\n }\n\n .table-section-title {\n font-family: var(--font-display);\n font-size: 9px;\n font-weight: 600;\n letter-spacing: 0.03em;\n color: var(--text-secondary);\n text-transform: uppercase;\n }\n\n .table-wrap { overflow-y: auto; flex: 1; }\n .table-wrap::-webkit-scrollbar { width: 3px; }\n .table-wrap::-webkit-scrollbar-track { background: transparent; }\n .table-wrap::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }\n\n table { width: 100%; border-collapse: collapse; font-size: 8px; font-family: var(--font-display); }\n\n thead th {\n text-align: left;\n padding: 3px 5px;\n color: var(--text-muted);\n font-weight: 500;\n letter-spacing: 0.04em;\n text-transform: uppercase;\n font-size: 7px;\n position: sticky;\n top: 0;\n background: var(--bg-card);\n z-index: 2;\n border-bottom: 1px solid var(--border-light);\n }\n\n tbody td {\n padding: 3px 5px;\n border-bottom: 1px solid rgba(0,0,0,0.03);\n color: var(--text-primary);\n font-weight: 500;\n letter-spacing: 0.01em;\n }\n\n tbody tr:nth-child(even) { background: var(--bg-stripe); }\n tbody tr:hover { background: rgba(212,137,26,0.03); }\n tbody tr { transition: background var(--transition-base); }\n\n .cell-highlight { color: #d4891a; font-weight: 700; }\n .cell-cycles { color: #0f766e; font-weight: 700; }\n\n /* ============ TOAST ============ */\n .toast-container {\n position: fixed;\n top: 14px;\n right: 14px;\n z-index: 9999;\n display: flex;\n flex-direction: column;\n gap: 5px;\n pointer-events: none;\n }\n\n .toast {\n background: #ffffff;\n border: 1px solid var(--border);\n border-left: 3px solid #d4891a;\n color: var(--text-primary);\n padding: 7px 12px;\n border-radius: var(--radius);\n font-size: 11px;\n font-family: var(--font-body);\n box-shadow: 0 4px 20px rgba(0,0,0,0.1);\n opacity: 0;\n transform: translateX(30px);\n transition: all 280ms cubic-bezier(0.4, 0, 0.2, 1);\n pointer-events: auto;\n display: flex;\n align-items: center;\n gap: 5px;\n }\n\n .toast.show { opacity: 1; transform: translateX(0); }\n .toast i { color: #d4891a; font-size: 12px; }\n\n /* ============ RESPONSIVE ============ */\n @media (max-width: 1400px) {\n .metrics-row { grid-template-columns: repeat(4, 1fr); }\n .charts-area { grid-template-columns: 1fr 1fr; }\n .ratio-panel { grid-column: span 2; }\n .dashboard { aspect-ratio: auto; max-height: none; }\n .chart-box { min-height: 170px; }\n .ratio-panel { min-height: 150px; }\n }\n\n @media (max-width: 900px) {\n .metrics-row { grid-template-columns: repeat(3, 1fr); }\n .charts-area { grid-template-columns: 1fr; }\n .ratio-panel { grid-column: span 1; }\n .header { flex-direction: column; align-items: stretch; }\n .header-right { justify-content: flex-start; }\n select, .date-input { font-size: 9px; padding: 3px 5px; }\n .date-input { width: 80px; }\n .dashboard { padding: 6px; }\n .chart-box { min-height: 150px; }\n }\n\n @media (max-width: 600px) {\n .metrics-row { grid-template-columns: repeat(2, 1fr); }\n .btn-group button { padding: 3px 6px; font-size: 9px; }\n .header-right { gap: 2px; }\n table { font-size: 7px; }\n thead th, tbody td { padding: 2px 3px; }\n .filter-bar { gap: 2px; }\n .filter-bar .sep { display: none; }\n }\n\n @media print {\n body { background: #fff; padding: 0; }\n .dashboard { box-shadow: none; border: none; aspect-ratio: auto; max-height: none; }\n .btn-primary, .btn-action, .btn-group, .filter-bar select, .filter-bar .date-input { display: none; }\n .toast-container { display: none; }\n }\n </style>\n</head>\n<body>\n\n<div class=\"dashboard\" id=\"dashboard\">\n\n <!-- HEADER -->\n <div class=\"header\">\n <div class=\"header-left\">\n <div class=\"logo-mark\"><i class=\"fa-solid fa-truck\"></i></div>\n <div class=\"header-title\">\n <h1>矿车运营数据统计</h1>\n <span>Mining Truck Operations</span>\n </div>\n </div>\n <div class=\"header-right\">\n <div class=\"btn-group\" id=\"granularityGroup\">\n <button data-gran=\"day\" class=\"active\">按日</button>\n <button data-gran=\"month\">按月</button>\n <button data-gran=\"year\">按年</button>\n </div>\n <input type=\"date\" class=\"date-input\" id=\"startDate\" value=\"2026-04-01\">\n <span class=\"date-separator\">至</span>\n <input type=\"date\" class=\"date-input\" id=\"endDate\" value=\"2026-04-27\">\n <button class=\"btn-primary\" onclick=\"refreshData()\"><i class=\"fa-solid fa-arrows-rotate\"></i> 刷新</button>\n <button class=\"btn-action\" onclick=\"exportCSV()\" title=\"导出CSV\"><i class=\"fa-solid fa-file-csv\"></i></button>\n <button class=\"btn-action\" onclick=\"printDashboard()\" title=\"打印\"><i class=\"fa-solid fa-print\"></i></button>\n </div>\n </div>\n\n <!-- FILTER BAR -->\n <div class=\"filter-bar\">\n <span class=\"filter-label\">分组</span>\n <div class=\"btn-group\" id=\"groupByGroup\">\n <button data-group=\"mine\" class=\"active\">按矿区</button>\n <button data-group=\"type\">按车辆类型</button>\n <button data-group=\"vehicle\">按车辆</button>\n </div>\n <span class=\"sep\"></span>\n <span class=\"filter-label\">矿区</span>\n <select id=\"filterMine\"><option value=\"\">全部矿区</option></select>\n <span class=\"filter-label\">车辆类型</span>\n <select id=\"filterType\"><option value=\"\">全部类型</option></select>\n <span class=\"filter-label\">车辆</span>\n <select id=\"filterVehicle\"><option value=\"\">全部车辆</option></select>\n </div>\n\n <!-- METRICS ROW -->\n <div class=\"metrics-row\" id=\"metricsRow\"></div>\n\n <!-- CHARTS AREA -->\n <div class=\"charts-area\">\n <!-- LINE CHART: 运行趋势(按分组多线) -->\n <div class=\"chart-box\" id=\"lineChartBox\">\n <div class=\"chart-box-header\">\n <span class=\"chart-box-title\"><i class=\"fa-solid fa-chart-line\"></i> 运行趋势</span>\n <div class=\"chart-legend\" id=\"lineLegend\"></div>\n </div>\n <div class=\"chart-canvas-wrap\"><canvas id=\"lineChart\"></canvas></div>\n </div>\n\n <!-- STACKED BAR: 时长构成(按分组各一列) -->\n <div class=\"chart-box\" id=\"barChartBox\">\n <div class=\"chart-box-header\">\n <span class=\"chart-box-title\"><i class=\"fa-solid fa-chart-bar\"></i> 时长构成对比</span>\n <div class=\"chart-legend\">\n <span><span class=\"dot\" style=\"background:#ea570c\"></span>装载</span>\n <span><span class=\"dot\" style=\"background:#2563eb\"></span>重车</span>\n <span><span class=\"dot\" style=\"background:#7c3aed\"></span>卸载</span>\n <span><span class=\"dot\" style=\"background:#16a34a\"></span>空返</span>\n <span><span class=\"dot\" style=\"background:#dc2626\"></span>怠速</span>\n </div>\n </div>\n <div class=\"chart-canvas-wrap\"><canvas id=\"barChart\"></canvas></div>\n </div>\n\n <!-- RATIO PANEL: 装载/行驶/卸载占比(按分组横向堆叠条) -->\n <div class=\"ratio-panel\" id=\"ratioPanel\">\n <div class=\"ratio-header\">\n <span class=\"chart-box-title\"><i class=\"fa-solid fa-chart-pie\"></i> 装载 / 行驶 / 卸载占比</span>\n </div>\n <div class=\"ratio-legend-row\">\n <span><span class=\"dot\" style=\"background:#ea570c\"></span>装载</span>\n <span><span class=\"dot\" style=\"background:#2563eb\"></span>行驶</span>\n <span><span class=\"dot\" style=\"background:#7c3aed\"></span>卸载</span>\n </div>\n <div class=\"ratio-body\" id=\"ratioBody\"></div>\n </div>\n </div>\n\n <!-- TABLE -->\n <div class=\"table-section\">\n <div class=\"table-section-header\">\n <span class=\"table-section-title\"><i class=\"fa-solid fa-table-list\"></i> 数据明细</span>\n <span style=\"font-size:7px;color:var(--text-muted)\" id=\"tableInfo\">共 0 条记录</span>\n </div>\n <div class=\"table-wrap\">\n <table>\n <thead id=\"tableHead\"></thead>\n <tbody id=\"tableBody\"></tbody>\n </table>\n </div>\n </div>\n</div>\n\n<div class=\"toast-container\" id=\"toastContainer\"></div>\n\n<script>\n(function() {\n 'use strict';\n\n // ==================== VEHICLES ====================\n const VEHICLES = [\n { id: 'A-E-001', mine: '矿区A', type: '电动矿卡', capacity: 60 },\n { id: 'A-E-002', mine: '矿区A', type: '电动矿卡', capacity: 60 },\n { id: 'A-E-003', mine: '矿区A', type: '电动矿卡', capacity: 55 },\n { id: 'A-F-001', mine: '矿区A', type: '燃油矿卡', capacity: 70 },\n { id: 'A-F-002', mine: '矿区A', type: '燃油矿卡', capacity: 70 },\n { id: 'B-E-001', mine: '矿区B', type: '电动矿卡', capacity: 60 },\n { id: 'B-E-002', mine: '矿区B', type: '电动矿卡', capacity: 55 },\n { id: 'B-H-001', mine: '矿区B', type: '混合动力矿卡', capacity: 65 },\n { id: 'B-H-002', mine: '矿区B', type: '混合动力矿卡', capacity: 65 },\n { id: 'B-H-003', mine: '矿区B', type: '混合动力矿卡', capacity: 60 },\n { id: 'C-F-001', mine: '矿区C', type: '燃油矿卡', capacity: 70 },\n { id: 'C-F-002', mine: '矿区C', type: '燃油矿卡', capacity: 75 },\n { id: 'C-F-003', mine: '矿区C', type: '燃油矿卡', capacity: 70 },\n { id: 'C-H-001', mine: '矿区C', type: '混合动力矿卡', capacity: 65 },\n { id: 'C-H-002', mine: '矿区C', type: '混合动力矿卡', capacity: 60 },\n ];\n\n const MINES = [...new Set(VEHICLES.map(v => v.mine))];\n const TYPES = [...new Set(VEHICLES.map(v => v.type))];\n const GROUP_COLORS = ['#d4891a', '#0d82b8', '#0f766e', '#ea570c', '#7c3aed', '#2563eb', '#16a34a', '#dc2626', '#059669', '#db2777', '#6366f1', '#0891b2', '#ca8a04', '#9333ea', '#e11d48'];\n\n // ==================== STATE ====================\n const state = {\n granularity: 'day',\n startDate: '2026-04-01',\n endDate: '2026-04-27',\n groupBy: 'mine',\n filterMine: '',\n filterType: '',\n filterVehicle: '',\n };\n\n const $ = s => document.querySelector(s);\n\n const dom = {\n granularityGroup: $('#granularityGroup'),\n groupByGroup: $('#groupByGroup'),\n startDate: $('#startDate'),\n endDate: $('#endDate'),\n filterMine: $('#filterMine'),\n filterType: $('#filterType'),\n filterVehicle: $('#filterVehicle'),\n metricsRow: $('#metricsRow'),\n lineChart: $('#lineChart'),\n barChart: $('#barChart'),\n lineLegend: $('#lineLegend'),\n ratioBody: $('#ratioBody'),\n tableHead: $('#tableHead'),\n tableBody: $('#tableBody'),\n tableInfo: $('#tableInfo'),\n toastContainer: $('#toastContainer'),\n };\n\n // ==================== TOAST ====================\n function showToast(msg, icon) {\n const el = document.createElement('div');\n el.className = 'toast';\n el.innerHTML = `<i class=\"fa-solid ${icon || 'fa-circle-check'}\"></i>${msg}`;\n dom.toastContainer.appendChild(el);\n requestAnimationFrame(() => el.classList.add('show'));\n setTimeout(() => { el.classList.remove('show'); setTimeout(() => el.remove(), 350); }, 2000);\n }\n\n // ==================== FILTERS ====================\n function getFilteredVehicles() {\n return VEHICLES.filter(v => {\n if (state.filterMine && v.mine !== state.filterMine) return false;\n if (state.filterType && v.type !== state.filterType) return false;\n if (state.filterVehicle && v.id !== state.filterVehicle) return false;\n return true;\n });\n }\n\n function populateFilterOptions() {\n const prevMine = dom.filterMine.value;\n dom.filterMine.innerHTML = '<option value=\"\">全部矿区</option>' + MINES.map(m => `<option value=\"${m}\" ${prevMine===m?'selected':''}>${m}</option>`).join('');\n if (prevMine && MINES.includes(prevMine)) dom.filterMine.value = prevMine;\n\n const prevType = dom.filterType.value;\n const mf = dom.filterMine.value;\n const availTypes = mf ? [...new Set(VEHICLES.filter(v=>v.mine===mf).map(v=>v.type))] : TYPES;\n dom.filterType.innerHTML = '<option value=\"\">全部类型</option>' + availTypes.map(t => `<option value=\"${t}\" ${prevType===t?'selected':''}>${t}</option>`).join('');\n if (prevType && availTypes.includes(prevType)) dom.filterType.value = prevType; else dom.filterType.value = '';\n\n const prevVeh = dom.filterVehicle.value;\n const tf = dom.filterType.value;\n let availVeh = VEHICLES;\n if (mf) availVeh = availVeh.filter(v=>v.mine===mf);\n if (tf) availVeh = availVeh.filter(v=>v.type===tf);\n dom.filterVehicle.innerHTML = '<option value=\"\">全部车辆</option>' + availVeh.map(v => `<option value=\"${v.id}\" ${prevVeh===v.id?'selected':''}>${v.id}(${v.capacity}t)</option>`).join('');\n if (prevVeh && availVeh.some(v=>v.id===prevVeh)) dom.filterVehicle.value = prevVeh; else dom.filterVehicle.value = '';\n }\n\n function getGroupKey(v) {\n if (state.groupBy === 'mine') return v.mine;\n if (state.groupBy === 'type') return v.type;\n return v.id;\n }\n\n function getGroupLabel(k) {\n if (state.groupBy === 'vehicle') { const v = VEHICLES.find(x=>x.id===k); return v ? `${v.id}(${v.capacity}t)` : k; }\n return k;\n }\n\n // ==================== DATA GENERATION ====================\n function seedRandom(seed) { let s = seed; return () => { s = (s * 16807 + 0) % 2147483647; return (s - 1) / 2147483646; }; }\n\n function generateTimeLabels() {\n const labels = [];\n const sd = new Date(state.startDate);\n const ed = new Date(state.endDate);\n if (isNaN(sd.getTime()) || isNaN(ed.getTime()) || sd > ed) return labels;\n const cur = new Date(sd);\n if (state.granularity === 'day') {\n while (cur <= ed) { labels.push(new Date(cur)); cur.setDate(cur.getDate() + 1); }\n } else if (state.granularity === 'month') {\n cur.setDate(1); const em = new Date(ed.getFullYear(), ed.getMonth(), 1);\n while (cur <= em) { labels.push(new Date(cur)); cur.setMonth(cur.getMonth() + 1); }\n } else {\n cur.setMonth(0, 1); const ey = new Date(ed.getFullYear(), 0, 1);\n while (cur <= ey) { labels.push(new Date(cur)); cur.setFullYear(cur.getFullYear() + 1); }\n }\n if (labels.length > 120) { const step = Math.ceil(labels.length / 100); return labels.filter((_, i) => i % step === 0); }\n return labels;\n }\n\n function generateRecords() {\n const vehicles = getFilteredVehicles();\n const timeLabels = generateTimeLabels();\n if (vehicles.length === 0 || timeLabels.length === 0) return { records: [], groupKeys: [], timeLabels: [] };\n\n const gkSet = new Set();\n vehicles.forEach(v => gkSet.add(getGroupKey(v)));\n const groupKeys = [...gkSet].sort();\n\n const dm = {};\n groupKeys.forEach(gk => {\n dm[gk] = timeLabels.map(() => ({\n totalTime:0, workTime:0, cycles:0, idleTime:0, loadTime:0, unloadTime:0, emptyReturnTime:0, loadedRunTime:0\n }));\n });\n\n vehicles.forEach(v => {\n const gk = getGroupKey(v);\n timeLabels.forEach((d, ti) => {\n const rng = seedRandom(d.getTime() % 99997 + v.id.charCodeAt(0) * 73 + ti * 41 + v.capacity * 7);\n const cm = state.granularity === 'year' ? 4200 + rng() * 2800 : state.granularity === 'month' ? 350 + rng() * 240 : 13 + rng() * 11;\n const cyc = Math.round(cm);\n const lpc = 0.09 + rng() * 0.07;\n const lrpc = 0.16 + rng() * 0.12;\n const upc = 0.03 + rng() * 0.03;\n const epc = 0.10 + rng() * 0.08;\n const ipc = 0.06 + rng() * 0.10;\n const lt = Math.round(cyc * lpc * 10) / 10;\n const lrt = Math.round(cyc * lrpc * 10) / 10;\n const ut = Math.round(cyc * upc * 10) / 10;\n const ert = Math.round(cyc * epc * 10) / 10;\n const it = Math.round(cyc * ipc * 10) / 10;\n const wt = Math.round((lt + lrt + ut + ert) * 10) / 10;\n const tt = Math.round((wt + it) * 10) / 10;\n const c = dm[gk][ti];\n c.cycles += cyc; c.loadTime += lt; c.loadedRunTime += lrt; c.unloadTime += ut;\n c.emptyReturnTime += ert; c.idleTime += it; c.workTime += wt; c.totalTime += tt;\n });\n });\n\n const records = [];\n groupKeys.forEach(gk => {\n timeLabels.forEach((d, ti) => {\n const c = dm[gk][ti];\n records.push({\n timeLabel: d, groupKey: gk, groupLabel: getGroupLabel(gk),\n totalTime: Math.round(c.totalTime*10)/10, workTime: Math.round(c.workTime*10)/10,\n cycles: c.cycles, idleTime: Math.round(c.idleTime*10)/10,\n loadTime: Math.round(c.loadTime*10)/10, unloadTime: Math.round(c.unloadTime*10)/10,\n emptyReturnTime: Math.round(c.emptyReturnTime*10)/10, loadedRunTime: Math.round(c.loadedRunTime*10)/10,\n });\n });\n });\n\n return { records, groupKeys, timeLabels };\n }\n\n function fmtTL(d) {\n if (state.granularity === 'day') return `${d.getMonth()+1}/${d.getDate()}`;\n if (state.granularity === 'month') return `${d.getFullYear()}/${String(d.getMonth()+1).padStart(2,'0')}`;\n return `${d.getFullYear()}`;\n }\n\n function fmtTF(d) {\n if (state.granularity === 'day') return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;\n if (state.granularity === 'month') return `${d.getFullYear()}年${d.getMonth()+1}月`;\n return `${d.getFullYear()}年`;\n }\n\n // ==================== METRICS ====================\n function renderMetrics(records) {\n if (!records.length) {\n dom.metricsRow.innerHTML = Array.from({length:8}, () => `<div class=\"metric-card\"><div class=\"card-accent\"></div><div class=\"card-label\">--</div><div class=\"card-value\">0</div></div>`).join('');\n return;\n }\n const sm = records.reduce((a,r) => { Object.keys(a).forEach(k=>{a[k]+=r[k]||0;}); return a; }, {totalTime:0,workTime:0,cycles:0,idleTime:0,loadTime:0,unloadTime:0,emptyReturnTime:0,loadedRunTime:0});\n const cfg = [\n {icon:'fa-clock',label:'运行时长',value:sm.totalTime.toFixed(1),unit:'h',color:'#d4891a'},\n {icon:'fa-gears',label:'作业时长',value:sm.workTime.toFixed(1),unit:'h',color:'#0d82b8'},\n {icon:'fa-rotate',label:'循环次数',value:sm.cycles.toString(),unit:'次',color:'#0f766e'},\n {icon:'fa-pause',label:'怠速时长',value:sm.idleTime.toFixed(1),unit:'h',color:'#ea570c'},\n {icon:'fa-arrow-down',label:'装载时长',value:sm.loadTime.toFixed(1),unit:'h',color:'#7c3aed'},\n {icon:'fa-arrow-up',label:'卸载时长',value:sm.unloadTime.toFixed(1),unit:'h',color:'#2563eb'},\n {icon:'fa-truck-fast',label:'空车返回',value:sm.emptyReturnTime.toFixed(1),unit:'h',color:'#16a34a'},\n {icon:'fa-weight-hanging',label:'重车运行',value:sm.loadedRunTime.toFixed(1),unit:'h',color:'#dc2626'},\n ];\n dom.metricsRow.innerHTML = cfg.map(c => `\n <div class=\"metric-card\">\n <div class=\"card-accent\"></div>\n <div class=\"card-icon\"><i class=\"fa-solid ${c.icon}\"></i></div>\n <div class=\"card-label\">${c.label}</div>\n <div class=\"card-value\" style=\"color:${c.color}\">${c.value}</div>\n <div class=\"card-sub\">${c.unit}</div>\n </div>`).join('');\n }\n\n // ==================== LINE CHART (分组多线趋势) ====================\n function renderLineChart(canvas, records, groupKeys, timeLabels) {\n if (!canvas || !records.length) return;\n const dpr = window.devicePixelRatio || 1;\n const rect = canvas.parentElement.getBoundingClientRect();\n const w = rect.width, h = rect.height;\n canvas.width = w * dpr; canvas.height = h * dpr;\n canvas.style.width = w + 'px'; canvas.style.height = h + 'px';\n const ctx = canvas.getContext('2d');\n ctx.scale(dpr, dpr); ctx.clearRect(0, 0, w, h);\n\n const pad = { top: 8, right: 12, bottom: 24, left: 38 };\n const cw = w - pad.left - pad.right, ch = h - pad.top - pad.bottom;\n\n dom.lineLegend.innerHTML = groupKeys.map((gk,i) => `<span><span class=\"dot\" style=\"background:${GROUP_COLORS[i%GROUP_COLORS.length]}\"></span>${gk}</span>`).join('');\n\n let mx = 0;\n records.forEach(r => { if (r.totalTime > mx) mx = r.totalTime; });\n mx = Math.ceil(mx * 1.2) || 10;\n\n const gl = 5;\n ctx.strokeStyle = '#eef0f4'; ctx.lineWidth = 1;\n for (let i = 0; i <= gl; i++) {\n const y = pad.top + (ch / gl) * i;\n ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(w - pad.right, y); ctx.stroke();\n ctx.fillStyle = '#94a3b8'; ctx.font = '7px DM Sans, sans-serif'; ctx.textAlign = 'right';\n ctx.fillText(Math.round(mx - (mx / gl) * i), pad.left - 4, y + 3);\n }\n\n const xs = cw / Math.max(timeLabels.length - 1, 1);\n const ml = Math.min(timeLabels.length, 14);\n const ls = Math.max(1, Math.floor(timeLabels.length / ml));\n ctx.fillStyle = '#94a3b8'; ctx.font = '7px DM Sans, sans-serif'; ctx.textAlign = 'center';\n timeLabels.forEach((d, i) => {\n if (i % ls === 0 || i === timeLabels.length - 1) ctx.fillText(fmtTL(d), pad.left + xs * i, pad.top + ch + 13);\n });\n\n groupKeys.forEach((gk, gi) => {\n const color = GROUP_COLORS[gi % GROUP_COLORS.length];\n const gr = records.filter(r => r.groupKey === gk);\n ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.lineJoin = 'round'; ctx.lineCap = 'round';\n ctx.beginPath();\n gr.forEach((r, i) => {\n const x = pad.left + xs * i;\n const y = pad.top + ch - (r.totalTime / mx) * ch;\n if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);\n });\n ctx.stroke();\n gr.forEach((r, i) => {\n const x = pad.left + xs * i;\n const y = pad.top + ch - (r.totalTime / mx) * ch;\n ctx.fillStyle = '#ffffff'; ctx.beginPath(); ctx.arc(x, y, 2.5, 0, Math.PI*2); ctx.fill();\n ctx.fillStyle = color; ctx.beginPath(); ctx.arc(x, y, 1.5, 0, Math.PI*2); ctx.fill();\n });\n });\n }\n\n // ==================== BAR CHART (分组时长构成对比) ====================\n function renderBarChart(canvas, records, groupKeys) {\n if (!canvas || !records.length || !groupKeys.length) return;\n const dpr = window.devicePixelRatio || 1;\n const rect = canvas.parentElement.getBoundingClientRect();\n const w = rect.width, h = rect.height;\n canvas.width = w * dpr; canvas.height = h * dpr;\n canvas.style.width = w + 'px'; canvas.style.height = h + 'px';\n const ctx = canvas.getContext('2d');\n ctx.scale(dpr, dpr); ctx.clearRect(0, 0, w, h);\n\n // Aggregate per group across all time\n const groupAgg = groupKeys.map(gk => {\n const gr = records.filter(r => r.groupKey === gk);\n return {\n groupKey: gk,\n loadTime: gr.reduce((s,r)=>s+r.loadTime,0),\n loadedRunTime: gr.reduce((s,r)=>s+r.loadedRunTime,0),\n unloadTime: gr.reduce((s,r)=>s+r.unloadTime,0),\n emptyReturnTime: gr.reduce((s,r)=>s+r.emptyReturnTime,0),\n idleTime: gr.reduce((s,r)=>s+r.idleTime,0),\n };\n });\n\n const datasets = [\n { key: 'loadTime', color: '#ea570c' },\n { key: 'loadedRunTime', color: '#2563eb' },\n { key: 'unloadTime', color: '#7c3aed' },\n { key: 'emptyReturnTime', color: '#16a34a' },\n { key: 'idleTime', color: '#dc2626' },\n ];\n\n let mx = 0;\n groupAgg.forEach(g => { const s = datasets.reduce((a,d)=>a+g[d.key],0); if (s > mx) mx = s; });\n mx = Math.ceil(mx * 1.15) || 10;\n\n const pad = { top: 8, right: 12, bottom: 32, left: 42 };\n const cw = w - pad.left - pad.right, ch = h - pad.top - pad.bottom;\n\n const gl = 5;\n ctx.strokeStyle = '#eef0f4'; ctx.lineWidth = 1;\n for (let i = 0; i <= gl; i++) {\n const y = pad.top + (ch / gl) * i;\n ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(w - pad.right, y); ctx.stroke();\n ctx.fillStyle = '#94a3b8'; ctx.font = '7px DM Sans, sans-serif'; ctx.textAlign = 'right';\n ctx.fillText(Math.round(mx - (mx / gl) * i), pad.left - 4, y + 3);\n }\n\n const barGap = 4;\n const barW = (cw / groupKeys.length) - barGap;\n if (barW <= 0) return;\n\n groupAgg.forEach((g, gi) => {\n const x = pad.left + (cw / groupKeys.length) * gi + barGap / 2;\n let sy = pad.top + ch;\n datasets.forEach(ds => {\n const val = g[ds.key];\n const bh = (val / mx) * ch;\n const y = sy - bh;\n ctx.fillStyle = ds.color;\n ctx.fillRect(x, y, barW, bh);\n if (bh > 2) {\n ctx.fillStyle = 'rgba(255,255,255,0.2)';\n ctx.fillRect(x, y, barW, 0.4);\n }\n sy = y;\n });\n\n ctx.fillStyle = '#475569';\n ctx.font = '7px DM Sans, sans-serif';\n ctx.textAlign = 'center';\n const label = g.groupKey.length > 8 ? g.groupKey.slice(0,7)+'…' : g.groupKey;\n ctx.fillText(label, x + barW / 2, pad.top + ch + 13);\n\n // total on top\n const total = datasets.reduce((a,d)=>a+g[d.key],0);\n ctx.fillStyle = '#0f172a';\n ctx.font = 'bold 7px DM Sans, sans-serif';\n ctx.fillText(total.toFixed(0)+'h', x + barW / 2, pad.top + ch - (total/mx)*ch - 3);\n });\n }\n\n // ==================== RATIO PANEL (分组横向堆叠占比条) ====================\n function renderRatioPanel(records, groupKeys) {\n if (!records.length || !groupKeys.length) {\n dom.ratioBody.innerHTML = '<div style=\"text-align:center;color:var(--text-muted);padding:8px;font-size:9px;\">暂无数据</div>';\n return;\n }\n\n const groupAgg = groupKeys.map(gk => {\n const gr = records.filter(r => r.groupKey === gk);\n const load = gr.reduce((s,r)=>s+r.loadTime,0);\n const haul = gr.reduce((s,r)=>s+r.loadedRunTime+r.emptyReturnTime,0);\n const unload = gr.reduce((s,r)=>s+r.unloadTime,0);\n const total = load + haul + unload;\n return {\n groupKey: gk,\n loadPct: total > 0 ? load / total * 100 : 0,\n haulPct: total > 0 ? haul / total * 100 : 0,\n unloadPct: total > 0 ? unload / total * 100 : 0,\n total,\n };\n });\n\n dom.ratioBody.innerHTML = groupAgg.map(g => `\n <div class=\"ratio-group-row\">\n <div class=\"ratio-group-name\" title=\"${g.groupKey}\">${g.groupKey.length > 6 ? g.groupKey.slice(0,5)+'…' : g.groupKey}</div>\n <div class=\"ratio-hbar-wrap\">\n <div class=\"seg-load\" style=\"width:${g.loadPct.toFixed(1)}%\"></div>\n <div class=\"seg-haul\" style=\"width:${g.haulPct.toFixed(1)}%\"></div>\n <div class=\"seg-unload\" style=\"width:${g.unloadPct.toFixed(1)}%\"></div>\n </div>\n <div class=\"ratio-group-pct\">${g.total.toFixed(1)}h</div>\n </div>\n `).join('');\n }\n\n // ==================== TABLE ====================\n function renderTable(records) {\n dom.tableInfo.textContent = `共 ${records.length} 条记录`;\n if (!records.length) {\n dom.tableHead.innerHTML = `<tr><th>周期</th><th>分组</th><th>运行(h)</th><th>作业(h)</th><th>循环</th><th>怠速(h)</th><th>装载(h)</th><th>卸载(h)</th><th>空返(h)</th><th>重车(h)</th></tr>`;\n dom.tableBody.innerHTML = '<tr><td colspan=\"10\" style=\"text-align:center;color:var(--text-muted);padding:10px;\">暂无数据</td></tr>';\n return;\n }\n const gl = state.groupBy === 'mine' ? '矿区' : state.groupBy === 'type' ? '车辆类型' : '车辆';\n dom.tableHead.innerHTML = `<tr><th>周期</th><th>${gl}</th><th>运行(h)</th><th>作业(h)</th><th>循环</th><th>怠速(h)</th><th>装载(h)</th><th>卸载(h)</th><th>空返(h)</th><th>重车(h)</th></tr>`;\n const disp = records.slice(-28);\n dom.tableBody.innerHTML = disp.map(r => `\n <tr>\n <td>${fmtTF(r.timeLabel)}</td><td>${r.groupLabel}</td>\n <td class=\"cell-highlight\">${r.totalTime.toFixed(1)}</td><td>${r.workTime.toFixed(1)}</td>\n <td class=\"cell-cycles\">${r.cycles}</td><td>${r.idleTime.toFixed(1)}</td>\n <td>${r.loadTime.toFixed(1)}</td><td>${r.unloadTime.toFixed(1)}</td>\n <td>${r.emptyReturnTime.toFixed(1)}</td><td>${r.loadedRunTime.toFixed(1)}</td>\n </tr>`).join('');\n }\n\n // ==================== MAIN ====================\n let currentData = { records: [], groupKeys: [], timeLabels: [] };\n\n function updateDashboard(silent) {\n currentData = generateRecords();\n const { records, groupKeys, timeLabels } = currentData;\n\n renderMetrics(records);\n renderLineChart(dom.lineChart, records, groupKeys, timeLabels);\n renderBarChart(dom.barChart, records, groupKeys);\n renderRatioPanel(records, groupKeys);\n renderTable(records);\n\n if (!silent) {\n const gL = state.granularity === 'day' ? '日' : state.granularity === 'month' ? '月' : '年';\n const gbL = state.groupBy === 'mine' ? '矿区' : state.groupBy === 'type' ? '车辆类型' : '车辆';\n showToast(`已按${gbL}加载 ${records.length} 条${gL}度运营数据`, 'fa-circle-check');\n }\n }\n\n // ==================== EVENTS ====================\n dom.granularityGroup.addEventListener('click', e => {\n const btn = e.target.closest('button');\n if (!btn || btn.classList.contains('active')) return;\n dom.granularityGroup.querySelectorAll('button').forEach(b => b.classList.remove('active'));\n btn.classList.add('active');\n state.granularity = btn.dataset.gran;\n const now = new Date();\n if (state.granularity === 'day') {\n state.startDate = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0,10);\n state.endDate = now.toISOString().slice(0,10);\n } else if (state.granularity === 'month') {\n state.startDate = new Date(now.getFullYear()-1, now.getMonth(), 1).toISOString().slice(0,10);\n state.endDate = now.toISOString().slice(0,10);\n } else {\n state.startDate = new Date(now.getFullYear()-4, 0, 1).toISOString().slice(0,10);\n state.endDate = now.toISOString().slice(0,10);\n }\n dom.startDate.value = state.startDate;\n dom.endDate.value = state.endDate;\n updateDashboard();\n });\n\n dom.groupByGroup.addEventListener('click', e => {\n const btn = e.target.closest('button');\n if (!btn || btn.classList.contains('active')) return;\n dom.groupByGroup.querySelectorAll('button').forEach(b => b.classList.remove('active'));\n btn.classList.add('active');\n state.groupBy = btn.dataset.group;\n populateFilterOptions();\n updateDashboard();\n });\n\n dom.filterMine.addEventListener('change', () => { state.filterMine = dom.filterMine.value; populateFilterOptions(); updateDashboard(); });\n dom.filterType.addEventListener('change', () => { state.filterType = dom.filterType.value; populateFilterOptions(); updateDashboard(); });\n dom.filterVehicle.addEventListener('change', () => { state.filterVehicle = dom.filterVehicle.value; updateDashboard(); });\n dom.startDate.addEventListener('change', () => { state.startDate = dom.startDate.value; updateDashboard(); });\n dom.endDate.addEventListener('change', () => { state.endDate = dom.endDate.value; updateDashboard(); });\n\n window.refreshData = function() {\n state.startDate = dom.startDate.value;\n state.endDate = dom.endDate.value;\n state.filterMine = dom.filterMine.value;\n state.filterType = dom.filterType.value;\n state.filterVehicle = dom.filterVehicle.value;\n updateDashboard();\n };\n\n window.exportCSV = function() {\n if (!currentData.records.length) { showToast('无数据可导出', 'fa-triangle-exclamation'); return; }\n const gc = state.groupBy === 'mine' ? '矿区' : state.groupBy === 'type' ? '车辆类型' : '车辆';\n let csv = `周期,${gc},运行时长(h),作业时长(h),循环次数,怠速时长(h),装载时长(h),卸载时长(h),空车返回(h),重车运行(h)\\n`;\n currentData.records.forEach(r => {\n csv += [fmtTF(r.timeLabel), r.groupLabel, r.totalTime.toFixed(1), r.workTime.toFixed(1), r.cycles,\n r.idleTime.toFixed(1), r.loadTime.toFixed(1), r.unloadTime.toFixed(1), r.emptyReturnTime.toFixed(1), r.loadedRunTime.toFixed(1)].join(',') + '\\n';\n });\n const blob = new Blob(['\\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });\n const url = URL.createObjectURL(blob);\n const a = document.createElement('a');\n a.href = url;\n a.download = `矿车运营数据_${state.groupBy}_${state.startDate}_${state.endDate}.csv`;\n a.click();\n URL.revokeObjectURL(url);\n showToast('CSV 已下载', 'fa-file-csv');\n };\n\n window.printDashboard = function() { showToast('正在准备打印...', 'fa-print'); setTimeout(() => window.print(), 400); };\n\n let resizeTid;\n window.addEventListener('resize', () => {\n clearTimeout(resizeTid);\n resizeTid = setTimeout(() => {\n const { records, groupKeys, timeLabels } = currentData;\n if (records.length > 0) {\n renderLineChart(dom.lineChart, records, groupKeys, timeLabels);\n renderBarChart(dom.barChart, records, groupKeys);\n renderRatioPanel(records, groupKeys);\n }\n }, 250);\n });\n\n function init() {\n const now = new Date();\n state.startDate = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0,10);\n state.endDate = now.toISOString().slice(0,10);\n dom.startDate.value = state.startDate;\n dom.endDate.value = state.endDate;\n populateFilterOptions();\n updateDashboard(true);\n }\n\n init();\n})();\n</script>\n\n</body>\n</html>"
},
"next": ["按班次拆分统计", "添加设备稼动率", "设置异常预警线", "导出PDF运营报告", "接入实时运营数据"],
"breakpoint": "none"
}
```
推理
The user wants me to modify the mining truck operations dashboard so that the "时长构成" (duration composition) stacked bar chart and the "装载/行驶/卸载占比" (load/haul/unload ratio) donut chart also incorporate group comparison, not just the line chart. Currently, the line chart shows data by group (mine/vehicle type/vehicle), but the bar chart and donut chart aggregate all groups together. The user wants all three charts to support group comparison.
Let me think about how to implement this:
1. **Stacked Bar Chart (时长构成)**: Currently it aggregates across all groups. I need to show it by group - perhaps grouped stacked bars or a side-by-side approach. Since it's a stacked bar showing load/haul/unload/idle/empty-return, and we have multiple groups, I could show one stacked bar per group per time period - but that gets complex with many groups and time periods. A better approach might be to show the top N groups as separate stacked bars per time period, or to show the average per group as a standalone set of stacked bars.
Actually, re-reading the user's request: "时长构成和装载/行驶/卸载占比 也加入分组对比处理" - they want these charts to also show group comparison.
For the bar chart (时长构成), a good approach would be to show one stacked bar per group (aggregated over the time period), comparing different groups side by side. This way each group gets one stacked bar showing its duration composition.
For the donut chart (装载/行驶/卸载占比), I could show multiple donut charts side by side (one per group), or show a grouped legend. But with limited space, perhaps I'll show one donut per group in a compact layout, or use a different visualization. Actually, since the space is limited in the donut panel, I could keep one donut but update it based on which group is selected, or show small donuts for each group.
Let me think more practically:
For the **stacked bar chart**: Change from time-series stacked bars to group-comparison stacked bars. Each group gets one stacked bar showing the aggregate duration composition (装载/重车/卸载/空返/怠速) over the entire selected time period. This is cleaner and more useful for group comparison.
For the **donut chart**: Show one donut per group, or since space is limited, I could show the donut with a group selector, or multiple small donuts. Given the 3-column layout and the donut panel being the 3rd column, I think showing a single donut that can be cycled through groups, or showing compact donuts for top 3-4 groups, would work. Let me go with a compact layout showing multiple mini donuts for each group.
Wait, actually let me reconsider. The ratio panel is quite small. Let me instead:
- For the bar chart: Show grouped stacked bars, one per group (aggregated)
- For the donut: Keep one donut but with a dropdown to switch between groups, or show the overall aggregate with group breakdown in the legend
Actually, I think the cleanest approach is:
- **Bar chart**: One stacked bar per group showing the full-period aggregate duration composition
- **Donut chart**: Show a donut for each group (compact, maybe 2-3 columns of small donuts)
Let me implement this. The bar chart becomes straightforward - one bar per group. For the donut, I'll render multiple small donuts side by side.
Let me also handle the legend properly for the line chart to show which group each line color corresponds to.
Now let me code this up. I'll make significant changes to the bar chart and donut chart rendering functions, and the data generation stays the same.
For the bar chart (group comparison mode):
- Each group gets one stacked bar
- The bar is composed of 5 segments: 装载, 重车, 卸载, 空返, 怠速
- Data is aggregated across all time periods for each group
For the donut chart (group comparison mode):
- Show small donuts for each group (up to maybe 6-8 groups)
- Each donut shows load/haul/unload ratio for that group
Let me write the code.
Let me think about how to implement this:
1. **Stacked Bar Chart (时长构成)**: Currently it aggregates across all groups. I need to show it by group - perhaps grouped stacked bars or a side-by-side approach. Since it's a stacked bar showing load/haul/unload/idle/empty-return, and we have multiple groups, I could show one stacked bar per group per time period - but that gets complex with many groups and time periods. A better approach might be to show the top N groups as separate stacked bars per time period, or to show the average per group as a standalone set of stacked bars.
Actually, re-reading the user's request: "时长构成和装载/行驶/卸载占比 也加入分组对比处理" - they want these charts to also show group comparison.
For the bar chart (时长构成), a good approach would be to show one stacked bar per group (aggregated over the time period), comparing different groups side by side. This way each group gets one stacked bar showing its duration composition.
For the donut chart (装载/行驶/卸载占比), I could show multiple donut charts side by side (one per group), or show a grouped legend. But with limited space, perhaps I'll show one donut per group in a compact layout, or use a different visualization. Actually, since the space is limited in the donut panel, I could keep one donut but update it based on which group is selected, or show small donuts for each group.
Let me think more practically:
For the **stacked bar chart**: Change from time-series stacked bars to group-comparison stacked bars. Each group gets one stacked bar showing the aggregate duration composition (装载/重车/卸载/空返/怠速) over the entire selected time period. This is cleaner and more useful for group comparison.
For the **donut chart**: Show one donut per group, or since space is limited, I could show the donut with a group selector, or multiple small donuts. Given the 3-column layout and the donut panel being the 3rd column, I think showing a single donut that can be cycled through groups, or showing compact donuts for top 3-4 groups, would work. Let me go with a compact layout showing multiple mini donuts for each group.
Wait, actually let me reconsider. The ratio panel is quite small. Let me instead:
- For the bar chart: Show grouped stacked bars, one per group (aggregated)
- For the donut: Keep one donut but with a dropdown to switch between groups, or show the overall aggregate with group breakdown in the legend
Actually, I think the cleanest approach is:
- **Bar chart**: One stacked bar per group showing the full-period aggregate duration composition
- **Donut chart**: Show a donut for each group (compact, maybe 2-3 columns of small donuts)
Let me implement this. The bar chart becomes straightforward - one bar per group. For the donut, I'll render multiple small donuts side by side.
Let me also handle the legend properly for the line chart to show which group each line color corresponds to.
Now let me code this up. I'll make significant changes to the bar chart and donut chart rendering functions, and the data generation stays the same.
For the bar chart (group comparison mode):
- Each group gets one stacked bar
- The bar is composed of 5 segments: 装载, 重车, 卸载, 空返, 怠速
- Data is aggregated across all time periods for each group
For the donut chart (group comparison mode):
- Show small donuts for each group (up to maybe 6-8 groups)
- Each donut shows load/haul/unload ratio for that group
Let me write the code.
角色
assistant
content
total_tokens
152175
summary
矿车运营数据统计仪表盘(全分组对比版),白底企业级设计,8项核心指标+三图全量分组对比(折线趋势按组别多线/堆叠柱状按组别分列/环形占比按组别多图),支持矿区→车辆类型→车辆三级联动筛选,年/月/日粒度,日期区间选取,CSV导出与打印。
uiParsing
关闭