Lashtw commited on
Commit
43a7e30
·
verified ·
1 Parent(s): a3bcdcd

Update index.html

Browse files
Files changed (1) hide show
  1. index.html +608 -19
index.html CHANGED
@@ -1,19 +1,608 @@
1
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
19
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-TW">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>學生成績雷達圖產生器 (Web版)</title>
7
+
8
+ <!-- 引入字型 -->
9
+ <link rel="preconnect" href="https://fonts.googleapis.com">
10
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
+ <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;700&display=swap" rel="stylesheet">
12
+
13
+ <!-- 引入 Tailwind CSS (樣式) -->
14
+ <script src="https://cdn.tailwindcss.com"></script>
15
+
16
+ <!-- 引入必要函式庫 -->
17
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/PapaParse/5.4.1/papaparse.min.js"></script>
18
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
19
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
20
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js"></script>
21
+
22
+ <style>
23
+ body {
24
+ font-family: 'Noto Sans TC', sans-serif;
25
+ background-color: #f3f4f6;
26
+ }
27
+ .step-card {
28
+ background: white;
29
+ padding: 1.5rem;
30
+ border-radius: 0.5rem;
31
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
32
+ margin-bottom: 1.5rem;
33
+ }
34
+ /* 隱藏用於批次生成的 Canvas,避免畫面閃爍 */
35
+ #batchCanvas {
36
+ position: absolute;
37
+ left: -9999px;
38
+ top: -9999px;
39
+ }
40
+ .loading-overlay {
41
+ position: fixed;
42
+ top: 0;
43
+ left: 0;
44
+ width: 100%;
45
+ height: 100%;
46
+ background: rgba(255, 255, 255, 0.8);
47
+ display: flex;
48
+ justify-content: center;
49
+ align-items: center;
50
+ z-index: 50;
51
+ }
52
+ </style>
53
+ </head>
54
+ <body class="p-6">
55
+
56
+ <div id="loadingOverlay" class="loading-overlay hidden">
57
+ <div class="text-xl font-bold text-green-700">處理中,請稍候...</div>
58
+ </div>
59
+
60
+ <div class="max-w-6xl mx-auto">
61
+ <header class="text-center mb-8">
62
+ <h1 class="text-3xl font-bold text-gray-800 mb-2">📊 學生成績雷達圖產生器</h1>
63
+ <p class="text-gray-600">純網頁版 - 資料安全不需上傳伺服器</p>
64
+ </header>
65
+
66
+ <!-- 步驟 1: 上傳檔案 -->
67
+ <div class="step-card">
68
+ <h2 class="text-xl font-bold text-green-700 mb-4">Step 1: 上傳 CSV 成績檔</h2>
69
+ <div class="flex flex-col gap-2">
70
+ <div class="flex items-center gap-4">
71
+ <input type="file" id="csvInput" accept=".csv"
72
+ class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-green-50 file:text-green-700 hover:file:bg-green-100 cursor-pointer">
73
+ </div>
74
+ <p class="text-xs text-gray-500 mt-2">
75
+ * 檔案必須包含「姓名」欄位及數值成績欄位。<br>
76
+ * 如果沒反應,請嘗試將 Excel 檔案「另存新檔」為 <b>CSV (UTF-8)</b> 格式。
77
+ </p>
78
+ </div>
79
+ </div>
80
+
81
+ <!-- 主要控制區 (讀取檔案後顯示) -->
82
+ <div id="mainControl" class="hidden grid grid-cols-1 lg:grid-cols-3 gap-6">
83
+
84
+ <!-- 左側:設定區 -->
85
+ <div class="lg:col-span-1 space-y-6">
86
+
87
+ <!-- 科目選擇 -->
88
+ <div class="step-card">
89
+ <h3 class="font-bold text-gray-700 mb-3 border-b pb-2">選擇分析科目</h3>
90
+ <div id="subjectCheckboxes" class="grid grid-cols-2 gap-2 text-sm">
91
+ <!-- 動態生成 -->
92
+ </div>
93
+ </div>
94
+
95
+ <!-- 比較基準 -->
96
+ <div class="step-card">
97
+ <h3 class="font-bold text-gray-700 mb-3 border-b pb-2">比較基準 (背景圖形)</h3>
98
+ <div class="space-y-3">
99
+ <label class="flex items-center space-x-2">
100
+ <input type="checkbox" id="showAverage" checked class="form-checkbox text-green-600">
101
+ <span>顯示全班平均</span>
102
+ </label>
103
+
104
+ <div>
105
+ <label class="block text-sm text-gray-600 mb-1">額外比較對象 (選填)</label>
106
+ <select id="compareStudentSelect" class="w-full border rounded p-2 text-sm bg-gray-50">
107
+ <option value="">無</option>
108
+ <!-- 動態生成 -->
109
+ </select>
110
+ </div>
111
+ </div>
112
+ </div>
113
+
114
+ <!-- 批次下載 -->
115
+ <div class="step-card bg-green-50 border-green-200 border">
116
+ <h3 class="font-bold text-green-800 mb-2">📥 批次輸出</h3>
117
+ <p class="text-sm text-green-700 mb-4">將為全班每位學生生成一張圖片並打包下載。</p>
118
+
119
+ <button id="btnDownloadZip" class="w-full bg-green-600 hover:bg-green-700 text-white font-bold py-2 px-4 rounded shadow transition flex items-center justify-center gap-2">
120
+ <span>下載全班圖表 (.ZIP)</span>
121
+ </button>
122
+
123
+ <!-- 進度條 -->
124
+ <div id="progressContainer" class="hidden mt-4">
125
+ <div class="w-full bg-gray-200 rounded-full h-2.5">
126
+ <div id="progressBar" class="bg-green-600 h-2.5 rounded-full" style="width: 0%"></div>
127
+ </div>
128
+ <p id="progressText" class="text-xs text-center mt-1 text-gray-600">準備中...</p>
129
+ </div>
130
+ </div>
131
+ </div>
132
+
133
+ <!-- 右側:預覽區 -->
134
+ <div class="lg:col-span-2">
135
+ <div class="step-card h-full min-h-[500px]">
136
+ <div class="flex justify-between items-center mb-4 border-b pb-2">
137
+ <h3 class="font-bold text-gray-700">即時預覽</h3>
138
+ <div class="flex items-center gap-2">
139
+ <label class="text-sm">預覽學生:</label>
140
+ <select id="previewStudentSelect" class="border rounded p-1 text-sm">
141
+ <!-- 動態生成 -->
142
+ </select>
143
+ </div>
144
+ </div>
145
+
146
+ <div class="relative w-full aspect-[4/3] flex justify-center items-center bg-white">
147
+ <canvas id="radarChart"></canvas>
148
+ </div>
149
+ </div>
150
+ </div>
151
+ </div>
152
+ </div>
153
+
154
+ <!-- 隱藏的 Canvas 用於批次生成 -->
155
+ <canvas id="batchCanvas" width="1200" height="900"></canvas>
156
+
157
+ <script>
158
+ // 全域變數
159
+ let globalData = [];
160
+ let numericColumns = [];
161
+ let studentNames = [];
162
+ let chartInstance = null;
163
+ let batchChartInstance = null;
164
+
165
+ // 顏色設定
166
+ const COLORS = {
167
+ main: { border: 'rgba(31, 119, 180, 1)', bg: 'rgba(31, 119, 180, 0.2)' }, // 藍色 (主要學生)
168
+ avg: { border: 'rgba(255, 127, 14, 1)', bg: 'rgba(255, 127, 14, 0.1)' }, // 橘色 (平均)
169
+ compare: { border: 'rgba(44, 160, 44, 1)', bg: 'rgba(44, 160, 44, 0.0)' } // 綠色 (比較對象)
170
+ };
171
+
172
+ // DOM 元素
173
+ const els = {
174
+ csvInput: document.getElementById('csvInput'),
175
+ mainControl: document.getElementById('mainControl'),
176
+ subjectCheckboxes: document.getElementById('subjectCheckboxes'),
177
+ previewSelect: document.getElementById('previewStudentSelect'),
178
+ compareSelect: document.getElementById('compareStudentSelect'),
179
+ showAvg: document.getElementById('showAverage'),
180
+ canvas: document.getElementById('radarChart'),
181
+ batchCanvas: document.getElementById('batchCanvas'),
182
+ btnZip: document.getElementById('btnDownloadZip'),
183
+ progressContainer: document.getElementById('progressContainer'),
184
+ progressBar: document.getElementById('progressBar'),
185
+ progressText: document.getElementById('progressText'),
186
+ loading: document.getElementById('loadingOverlay')
187
+ };
188
+
189
+ // 檢查函式庫是否載入
190
+ window.addEventListener('load', () => {
191
+ if (typeof Papa === 'undefined' || typeof Chart === 'undefined') {
192
+ alert("錯誤:程式庫未正確載入。\n\n請檢查您的網路連線,或確認此環境未封鎖 cdnjs.cloudflare.com 與 cdn.jsdelivr.net。");
193
+ }
194
+ });
195
+
196
+ // 初始化 Chart.js 預設字型
197
+ if (typeof Chart !== 'undefined') {
198
+ Chart.defaults.font.family = "'Noto Sans TC', sans-serif";
199
+ Chart.defaults.font.size = 14;
200
+ }
201
+
202
+ // 監聽檔案上傳
203
+ if (els.csvInput) {
204
+ els.csvInput.addEventListener('change', handleFileUpload);
205
+ }
206
+
207
+ // 監聽控制項變更以更新圖表
208
+ els.previewSelect.addEventListener('change', updateChart);
209
+ els.compareSelect.addEventListener('change', updateChart);
210
+ els.showAvg.addEventListener('change', updateChart);
211
+
212
+ // 監聽下載按鈕
213
+ els.btnZip.addEventListener('click', generateBatchZip);
214
+
215
+ function handleFileUpload(event) {
216
+ const file = event.target.files[0];
217
+ if (!file) return;
218
+
219
+ // 顯示 Loading
220
+ els.loading.classList.remove('hidden');
221
+
222
+ // 稍微延遲以確保 loading 顯示
223
+ setTimeout(() => {
224
+ Papa.parse(file, {
225
+ header: true,
226
+ skipEmptyLines: true,
227
+ // 不強制指定 encoding,讓 PapaParse 自動處理,但後續會檢查內容
228
+ complete: function(results) {
229
+ try {
230
+ if (results.errors && results.errors.length > 0) {
231
+ console.warn("CSV 解析警告:", results.errors);
232
+ }
233
+ processData(results.data);
234
+ } catch (e) {
235
+ console.error(e);
236
+ alert("❌ 資料處理錯誤:\n" + e.message);
237
+ } finally {
238
+ els.loading.classList.add('hidden');
239
+ }
240
+ },
241
+ error: function(err) {
242
+ els.loading.classList.add('hidden');
243
+ alert("❌ CSV 檔案讀取失敗: " + err.message);
244
+ }
245
+ });
246
+ }, 100);
247
+ }
248
+
249
+ function processData(data) {
250
+ if (!data || data.length === 0) {
251
+ throw new Error("檔案內容為空,或無法識別任何資料行。");
252
+ }
253
+
254
+ // --- 關鍵修正:處理 BOM 與 欄位名稱 ---
255
+ // 取得第一列的所有 key
256
+ let rawHeaders = Object.keys(data[0]);
257
+
258
+ // 建立 key 對應表 (處理 BOM 或前後空白)
259
+ const keyMap = {};
260
+ rawHeaders.forEach(key => {
261
+ // 移除 BOM (\ufeff) 和前後空白
262
+ let cleanKey = key.replace(/^\ufeff/, '').trim();
263
+ keyMap[key] = cleanKey;
264
+ });
265
+
266
+ // 重新整理 data,將所有 key 換成乾淨的 key
267
+ const cleanData = data.map(row => {
268
+ const newRow = {};
269
+ Object.keys(row).forEach(k => {
270
+ const cleanK = keyMap[k] || k.trim();
271
+ newRow[cleanK] = row[k];
272
+ });
273
+ return newRow;
274
+ });
275
+
276
+ // 檢查「姓名」欄位是否存在
277
+ const headers = Object.keys(cleanData[0]);
278
+ if (!headers.includes('姓名')) {
279
+ // 偵測是否是亂碼 (簡易判斷:檢查是否包含大量非 ASCII 且非中文的奇怪符號,或長度過長)
280
+ const headerStr = headers.join(",");
281
+ if (headerStr.includes("")) {
282
+ throw new Error("找不到「姓名」欄位且偵測到亂碼。\n\n原因:這可能是 Excel 存檔的編碼問題。\n解法:請用 Excel 開啟檔案,選擇「另存新檔」,格式選擇「CSV UTF-8 (逗號分隔)」。");
283
+ }
284
+ throw new Error(`找不到「姓名」欄位。\n\n偵測到的欄位有:${headers.join(", ")}\n\n請確認 CSV 檔案中第一列包含「姓名」二字。`);
285
+ }
286
+
287
+ // 2. 篩選數值欄位
288
+ numericColumns = headers.filter(h => {
289
+ if (h === '姓名') return false;
290
+ // 檢查第一筆資料是否可轉為數字 (過濾掉空值或非數字)
291
+ const val = cleanData[0][h];
292
+ return val !== "" && !isNaN(parseFloat(val));
293
+ });
294
+
295
+ if (numericColumns.length === 0) {
296
+ throw new Error("找不到數值欄位(成績)。請確認檔案中除「姓名」外,還有數字成績欄位。");
297
+ }
298
+
299
+ // 3. 轉型與儲存
300
+ globalData = cleanData.map(row => {
301
+ let newRow = { '姓名': row['姓名'] };
302
+ numericColumns.forEach(col => {
303
+ newRow[col] = parseFloat(row[col]) || 0;
304
+ });
305
+ return newRow;
306
+ });
307
+
308
+ // 過濾掉沒有名字的空行
309
+ globalData = globalData.filter(d => d['姓名'] && d['姓名'].trim() !== "");
310
+ studentNames = globalData.map(d => d['姓名']);
311
+
312
+ if (studentNames.length === 0) {
313
+ throw new Error("解析後沒有找到任何有效的學生資料。");
314
+ }
315
+
316
+ // 4. 初始化 UI
317
+ initUI();
318
+
319
+ // 5. 顯示控制區
320
+ els.mainControl.classList.remove('hidden');
321
+
322
+ // 6. 繪製初始圖表
323
+ updateChart();
324
+ }
325
+
326
+ function initUI() {
327
+ // 生成科目 Checkbox
328
+ els.subjectCheckboxes.innerHTML = '';
329
+ // 預設選取的科目 (常用的)
330
+ const defaultSubjects = ['國文', '英文', '數學', '物理', '化學', '生物', '歷史', '地理', '公民', '社會', '自科', '總分', '平均'];
331
+
332
+ numericColumns.forEach(col => {
333
+ const wrapper = document.createElement('div');
334
+ wrapper.className = 'flex items-center space-x-2';
335
+
336
+ const cb = document.createElement('input');
337
+ cb.type = 'checkbox';
338
+ cb.value = col;
339
+ cb.id = `sub_${col}`;
340
+ cb.className = 'subject-cb form-checkbox text-green-600 rounded';
341
+
342
+ // 簡單邏輯:如果是預設列表中的,或者數值欄位少於 6 個,則預設勾選
343
+ if (defaultSubjects.includes(col) || numericColumns.length <= 6) {
344
+ cb.checked = true;
345
+ }
346
+
347
+ // 國文、英文、數學 必勾 (如果有的話)
348
+ if (['國文', '英文', '數學'].includes(col)) cb.checked = true;
349
+
350
+ cb.addEventListener('change', updateChart);
351
+
352
+ const label = document.createElement('label');
353
+ label.htmlFor = `sub_${col}`;
354
+ label.textContent = col;
355
+
356
+ wrapper.appendChild(cb);
357
+ wrapper.appendChild(label);
358
+ els.subjectCheckboxes.appendChild(wrapper);
359
+ });
360
+
361
+ // 填充下拉選單
362
+ const fillSelect = (selectEl, includeEmpty) => {
363
+ selectEl.innerHTML = includeEmpty ? '<option value="">無</option>' : '';
364
+ studentNames.forEach(name => {
365
+ const opt = document.createElement('option');
366
+ opt.value = name;
367
+ opt.textContent = name;
368
+ selectEl.appendChild(opt);
369
+ });
370
+ };
371
+
372
+ fillSelect(els.previewSelect, false);
373
+ fillSelect(els.compareSelect, true);
374
+ }
375
+
376
+ function getSelectedSubjects() {
377
+ const checkboxes = document.querySelectorAll('.subject-cb:checked');
378
+ return Array.from(checkboxes).map(cb => cb.value);
379
+ }
380
+
381
+ function getStudentData(name) {
382
+ return globalData.find(d => d['姓名'] === name);
383
+ }
384
+
385
+ function calculateAverage(subjects) {
386
+ let sums = {};
387
+ subjects.forEach(s => sums[s] = 0);
388
+
389
+ globalData.forEach(student => {
390
+ subjects.forEach(s => {
391
+ sums[s] += student[s];
392
+ });
393
+ });
394
+
395
+ const count = globalData.length;
396
+ return subjects.map(s => (sums[s] / count).toFixed(1)); // 保留一位小數
397
+ }
398
+
399
+ function createChartConfig(targetStudentName, compareStudentName, showAvg, subjects) {
400
+ const targetData = getStudentData(targetStudentName);
401
+ const datasets = [];
402
+
403
+ // 1. 全班平均 (放在最底層)
404
+ if (showAvg) {
405
+ const avgData = calculateAverage(subjects);
406
+ datasets.push({
407
+ label: '全班平均',
408
+ data: avgData,
409
+ backgroundColor: COLORS.avg.bg,
410
+ borderColor: COLORS.avg.border,
411
+ borderWidth: 2,
412
+ borderDash: [5, 5],
413
+ pointRadius: 0
414
+ });
415
+ }
416
+
417
+ // 2. 比較對象
418
+ if (compareStudentName && compareStudentName !== targetStudentName) {
419
+ const compData = getStudentData(compareStudentName);
420
+ if (compData) {
421
+ const dataValues = subjects.map(s => compData[s]);
422
+ datasets.push({
423
+ label: compareStudentName,
424
+ data: dataValues,
425
+ backgroundColor: COLORS.compare.bg,
426
+ borderColor: COLORS.compare.border,
427
+ borderWidth: 2,
428
+ pointStyle: 'rectRot',
429
+ pointRadius: 5
430
+ });
431
+ }
432
+ }
433
+
434
+ // 3. 主要學生 (最上層)
435
+ const mainValues = subjects.map(s => targetData[s]);
436
+ datasets.push({
437
+ label: targetStudentName,
438
+ data: mainValues,
439
+ fill: true,
440
+ backgroundColor: COLORS.main.bg,
441
+ borderColor: COLORS.main.border,
442
+ pointBackgroundColor: COLORS.main.border,
443
+ pointBorderColor: '#fff',
444
+ pointHoverBackgroundColor: '#fff',
445
+ pointHoverBorderColor: COLORS.main.border,
446
+ borderWidth: 3,
447
+ pointRadius: 4
448
+ });
449
+
450
+ // 計算最大值以設定刻度
451
+ let allValues = [...mainValues];
452
+ if (showAvg) allValues = allValues.concat(calculateAverage(subjects).map(Number));
453
+ const maxVal = Math.max(...allValues) || 100;
454
+ const suggestedMax = Math.ceil(maxVal * 1.1); // 留 10% 空間
455
+
456
+ return {
457
+ type: 'radar',
458
+ data: {
459
+ labels: subjects,
460
+ datasets: datasets
461
+ },
462
+ options: {
463
+ responsive: true,
464
+ maintainAspectRatio: false,
465
+ scales: {
466
+ r: {
467
+ angleLines: { display: true },
468
+ suggestedMin: 0,
469
+ suggestedMax: suggestedMax,
470
+ ticks: {
471
+ backdropColor: 'transparent',
472
+ font: { size: 10 }
473
+ },
474
+ pointLabels: {
475
+ font: { size: 14, weight: 'bold', family: "'Noto Sans TC', sans-serif" },
476
+ color: '#374151'
477
+ }
478
+ }
479
+ },
480
+ plugins: {
481
+ title: {
482
+ display: true,
483
+ text: `${targetStudentName} 成績分析圖`,
484
+ font: { size: 20, family: "'Noto Sans TC', sans-serif" },
485
+ padding: 20
486
+ },
487
+ legend: {
488
+ position: 'bottom',
489
+ labels: {
490
+ font: { size: 12, family: "'Noto Sans TC', sans-serif" },
491
+ padding: 20
492
+ }
493
+ }
494
+ }
495
+ }
496
+ };
497
+ }
498
+
499
+ function updateChart() {
500
+ const targetName = els.previewSelect.value;
501
+ const compareName = els.compareSelect.value;
502
+ const subjects = getSelectedSubjects();
503
+ const showAvg = els.showAvg.checked;
504
+
505
+ if (!targetName || subjects.length === 0) return;
506
+
507
+ const config = createChartConfig(targetName, compareName, showAvg, subjects);
508
+
509
+ if (chartInstance) {
510
+ chartInstance.destroy();
511
+ }
512
+
513
+ chartInstance = new Chart(els.canvas, config);
514
+ }
515
+
516
+ // ============================
517
+ // 批次生成邏輯
518
+ // ============================
519
+ async function generateBatchZip() {
520
+ const subjects = getSelectedSubjects();
521
+ const compareName = els.compareSelect.value;
522
+ const showAvg = els.showAvg.checked;
523
+
524
+ if (subjects.length === 0) {
525
+ alert("請至少選擇一個科目!");
526
+ return;
527
+ }
528
+
529
+ // UI 狀態更新
530
+ els.btnZip.disabled = true;
531
+ els.btnZip.textContent = "生成中... 請勿關閉";
532
+ els.progressContainer.classList.remove('hidden');
533
+
534
+ const zip = new JSZip();
535
+ const batchCtx = els.batchCanvas.getContext('2d');
536
+
537
+ // 確保 Canvas 尺寸足夠大以獲得高品質圖片
538
+ // Chart.js 依賴父容器或 Canvas 屬性,這裡我們直接畫在隱藏的 1200x900 Canvas 上
539
+
540
+ let processedCount = 0;
541
+ const total = studentNames.length;
542
+
543
+ // 為了不卡死瀏覽器 UI,我們使用非同步遞迴或 for loop with await
544
+ for (let i = 0; i < total; i++) {
545
+ const studentName = studentNames[i];
546
+
547
+ // 更新進度條
548
+ const percent = Math.round(((i + 1) / total) * 100);
549
+ els.progressBar.style.width = `${percent}%`;
550
+ els.progressText.textContent = `正在處理: ${studentName} (${i + 1}/${total})`;
551
+
552
+ // 產生 Config
553
+ const config = createChartConfig(studentName, compareName, showAvg, subjects);
554
+
555
+ // 強制關閉動畫,這樣畫完馬上就能截圖
556
+ config.options.animation = false;
557
+ config.options.devicePixelRatio = 2; // 提高清晰度
558
+
559
+ // 銷毀舊的批次圖表
560
+ if (batchChartInstance) {
561
+ batchChartInstance.destroy();
562
+ }
563
+
564
+ // 繪製新圖表
565
+ // 使用 Promise 等待渲染完成 (雖然 animation: false 通常是同步的,但保險起見)
566
+ await new Promise(resolve => {
567
+ batchChartInstance = new Chart(batchCtx, {
568
+ ...config,
569
+ plugins: [{
570
+ id: 'background-colour',
571
+ beforeDraw: (chart) => {
572
+ const ctx = chart.ctx;
573
+ ctx.save();
574
+ ctx.fillStyle = 'white';
575
+ ctx.fillRect(0, 0, chart.width, chart.height);
576
+ ctx.restore();
577
+ }
578
+ }]
579
+ });
580
+ // 給一點點時間讓 Canvas 渲染 buffer
581
+ setTimeout(resolve, 50);
582
+ });
583
+
584
+ // 轉成 Blob/Base64
585
+ const imgData = els.batchCanvas.toDataURL("image/png").split(',')[1]; // 去除 header
586
+
587
+ // 加入 ZIP
588
+ zip.file(`${studentName}_成績雷達圖.png`, imgData, {base64: true});
589
+ }
590
+
591
+ els.progressText.textContent = "正在壓縮檔案...";
592
+
593
+ // 生成 ZIP 並下載
594
+ zip.generateAsync({type: "blob"}).then(function(content) {
595
+ saveAs(content, "學生成績雷達圖_全班.zip");
596
+
597
+ // 復原 UI
598
+ els.btnZip.disabled = false;
599
+ els.btnZip.innerHTML = "<span>下載全班圖表 (.ZIP)</span>";
600
+ els.progressText.textContent = "完成!";
601
+ setTimeout(() => {
602
+ els.progressContainer.classList.add('hidden');
603
+ }, 3000);
604
+ });
605
+ }
606
+ </script>
607
+ </body>
608
+ </html>