Radarchart.html / index.html
Lashtw's picture
Update index.html
43a7e30 verified
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>學生成績雷達圖產生器 (Web版)</title>
<!-- 引入字型 -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;700&display=swap" rel="stylesheet">
<!-- 引入 Tailwind CSS (樣式) -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- 引入必要函式庫 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/PapaParse/5.4.1/papaparse.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js"></script>
<style>
body {
font-family: 'Noto Sans TC', sans-serif;
background-color: #f3f4f6;
}
.step-card {
background: white;
padding: 1.5rem;
border-radius: 0.5rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
margin-bottom: 1.5rem;
}
/* 隱藏用於批次生成的 Canvas,避免畫面閃爍 */
#batchCanvas {
position: absolute;
left: -9999px;
top: -9999px;
}
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 50;
}
</style>
</head>
<body class="p-6">
<div id="loadingOverlay" class="loading-overlay hidden">
<div class="text-xl font-bold text-green-700">處理中,請稍候...</div>
</div>
<div class="max-w-6xl mx-auto">
<header class="text-center mb-8">
<h1 class="text-3xl font-bold text-gray-800 mb-2">📊 學生成績雷達圖產生器</h1>
<p class="text-gray-600">純網頁版 - 資料安全不需上傳伺服器</p>
</header>
<!-- 步驟 1: 上傳檔案 -->
<div class="step-card">
<h2 class="text-xl font-bold text-green-700 mb-4">Step 1: 上傳 CSV 成績檔</h2>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-4">
<input type="file" id="csvInput" accept=".csv"
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">
</div>
<p class="text-xs text-gray-500 mt-2">
* 檔案必須包含「姓名」欄位及數值成績欄位。<br>
* 如果沒反應,請嘗試將 Excel 檔案「另存新檔」為 <b>CSV (UTF-8)</b> 格式。
</p>
</div>
</div>
<!-- 主要控制區 (讀取檔案後顯示) -->
<div id="mainControl" class="hidden grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- 左側:設定區 -->
<div class="lg:col-span-1 space-y-6">
<!-- 科目選擇 -->
<div class="step-card">
<h3 class="font-bold text-gray-700 mb-3 border-b pb-2">選擇分析科目</h3>
<div id="subjectCheckboxes" class="grid grid-cols-2 gap-2 text-sm">
<!-- 動態生成 -->
</div>
</div>
<!-- 比較基準 -->
<div class="step-card">
<h3 class="font-bold text-gray-700 mb-3 border-b pb-2">比較基準 (背景圖形)</h3>
<div class="space-y-3">
<label class="flex items-center space-x-2">
<input type="checkbox" id="showAverage" checked class="form-checkbox text-green-600">
<span>顯示全班平均</span>
</label>
<div>
<label class="block text-sm text-gray-600 mb-1">額外比較對象 (選填)</label>
<select id="compareStudentSelect" class="w-full border rounded p-2 text-sm bg-gray-50">
<option value=""></option>
<!-- 動態生成 -->
</select>
</div>
</div>
</div>
<!-- 批次下載 -->
<div class="step-card bg-green-50 border-green-200 border">
<h3 class="font-bold text-green-800 mb-2">📥 批次輸出</h3>
<p class="text-sm text-green-700 mb-4">將為全班每位學生生成一張圖片並打包下載。</p>
<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">
<span>下載全班圖表 (.ZIP)</span>
</button>
<!-- 進度條 -->
<div id="progressContainer" class="hidden mt-4">
<div class="w-full bg-gray-200 rounded-full h-2.5">
<div id="progressBar" class="bg-green-600 h-2.5 rounded-full" style="width: 0%"></div>
</div>
<p id="progressText" class="text-xs text-center mt-1 text-gray-600">準備中...</p>
</div>
</div>
</div>
<!-- 右側:預覽區 -->
<div class="lg:col-span-2">
<div class="step-card h-full min-h-[500px]">
<div class="flex justify-between items-center mb-4 border-b pb-2">
<h3 class="font-bold text-gray-700">即時預覽</h3>
<div class="flex items-center gap-2">
<label class="text-sm">預覽學生:</label>
<select id="previewStudentSelect" class="border rounded p-1 text-sm">
<!-- 動態生成 -->
</select>
</div>
</div>
<div class="relative w-full aspect-[4/3] flex justify-center items-center bg-white">
<canvas id="radarChart"></canvas>
</div>
</div>
</div>
</div>
</div>
<!-- 隱藏的 Canvas 用於批次生成 -->
<canvas id="batchCanvas" width="1200" height="900"></canvas>
<script>
// 全域變數
let globalData = [];
let numericColumns = [];
let studentNames = [];
let chartInstance = null;
let batchChartInstance = null;
// 顏色設定
const COLORS = {
main: { border: 'rgba(31, 119, 180, 1)', bg: 'rgba(31, 119, 180, 0.2)' }, // 藍色 (主要學生)
avg: { border: 'rgba(255, 127, 14, 1)', bg: 'rgba(255, 127, 14, 0.1)' }, // 橘色 (平均)
compare: { border: 'rgba(44, 160, 44, 1)', bg: 'rgba(44, 160, 44, 0.0)' } // 綠色 (比較對象)
};
// DOM 元素
const els = {
csvInput: document.getElementById('csvInput'),
mainControl: document.getElementById('mainControl'),
subjectCheckboxes: document.getElementById('subjectCheckboxes'),
previewSelect: document.getElementById('previewStudentSelect'),
compareSelect: document.getElementById('compareStudentSelect'),
showAvg: document.getElementById('showAverage'),
canvas: document.getElementById('radarChart'),
batchCanvas: document.getElementById('batchCanvas'),
btnZip: document.getElementById('btnDownloadZip'),
progressContainer: document.getElementById('progressContainer'),
progressBar: document.getElementById('progressBar'),
progressText: document.getElementById('progressText'),
loading: document.getElementById('loadingOverlay')
};
// 檢查函式庫是否載入
window.addEventListener('load', () => {
if (typeof Papa === 'undefined' || typeof Chart === 'undefined') {
alert("錯誤:程式庫未正確載入。\n\n請檢查您的網路連線,或確認此環境未封鎖 cdnjs.cloudflare.com 與 cdn.jsdelivr.net。");
}
});
// 初始化 Chart.js 預設字型
if (typeof Chart !== 'undefined') {
Chart.defaults.font.family = "'Noto Sans TC', sans-serif";
Chart.defaults.font.size = 14;
}
// 監聽檔案上傳
if (els.csvInput) {
els.csvInput.addEventListener('change', handleFileUpload);
}
// 監聽控制項變更以更新圖表
els.previewSelect.addEventListener('change', updateChart);
els.compareSelect.addEventListener('change', updateChart);
els.showAvg.addEventListener('change', updateChart);
// 監聽下載按鈕
els.btnZip.addEventListener('click', generateBatchZip);
function handleFileUpload(event) {
const file = event.target.files[0];
if (!file) return;
// 顯示 Loading
els.loading.classList.remove('hidden');
// 稍微延遲以確保 loading 顯示
setTimeout(() => {
Papa.parse(file, {
header: true,
skipEmptyLines: true,
// 不強制指定 encoding,讓 PapaParse 自動處理,但後續會檢查內容
complete: function(results) {
try {
if (results.errors && results.errors.length > 0) {
console.warn("CSV 解析警告:", results.errors);
}
processData(results.data);
} catch (e) {
console.error(e);
alert("❌ 資料處理錯誤:\n" + e.message);
} finally {
els.loading.classList.add('hidden');
}
},
error: function(err) {
els.loading.classList.add('hidden');
alert("❌ CSV 檔案讀取失敗: " + err.message);
}
});
}, 100);
}
function processData(data) {
if (!data || data.length === 0) {
throw new Error("檔案內容為空,或無法識別任何資料行。");
}
// --- 關鍵修正:處理 BOM 與 欄位名稱 ---
// 取得第一列的所有 key
let rawHeaders = Object.keys(data[0]);
// 建立 key 對應表 (處理 BOM 或前後空白)
const keyMap = {};
rawHeaders.forEach(key => {
// 移除 BOM (\ufeff) 和前後空白
let cleanKey = key.replace(/^\ufeff/, '').trim();
keyMap[key] = cleanKey;
});
// 重新整理 data,將所有 key 換成乾淨的 key
const cleanData = data.map(row => {
const newRow = {};
Object.keys(row).forEach(k => {
const cleanK = keyMap[k] || k.trim();
newRow[cleanK] = row[k];
});
return newRow;
});
// 檢查「姓名」欄位是否存在
const headers = Object.keys(cleanData[0]);
if (!headers.includes('姓名')) {
// 偵測是否是亂碼 (簡易判斷:檢查是否包含大量非 ASCII 且非中文的奇怪符號,或長度過長)
const headerStr = headers.join(",");
if (headerStr.includes("")) {
throw new Error("找不到「姓名」欄位且偵測到亂碼。\n\n原因:這可能是 Excel 存檔的編碼問題。\n解法:請用 Excel 開啟檔案,選擇「另存新檔」,格式選擇「CSV UTF-8 (逗號分隔)」。");
}
throw new Error(`找不到「姓名」欄位。\n\n偵測到的欄位有:${headers.join(", ")}\n\n請確認 CSV 檔案中第一列包含「姓名」二字。`);
}
// 2. 篩選數值欄位
numericColumns = headers.filter(h => {
if (h === '姓名') return false;
// 檢查第一筆資料是否可轉為數字 (過濾掉空值或非數字)
const val = cleanData[0][h];
return val !== "" && !isNaN(parseFloat(val));
});
if (numericColumns.length === 0) {
throw new Error("找不到數值欄位(成績)。請確認檔案中除「姓名」外,還有數字成績欄位。");
}
// 3. 轉型與儲存
globalData = cleanData.map(row => {
let newRow = { '姓名': row['姓名'] };
numericColumns.forEach(col => {
newRow[col] = parseFloat(row[col]) || 0;
});
return newRow;
});
// 過濾掉沒有名字的空行
globalData = globalData.filter(d => d['姓名'] && d['姓名'].trim() !== "");
studentNames = globalData.map(d => d['姓名']);
if (studentNames.length === 0) {
throw new Error("解析後沒有找到任何有效的學生資料。");
}
// 4. 初始化 UI
initUI();
// 5. 顯示控制區
els.mainControl.classList.remove('hidden');
// 6. 繪製初始圖表
updateChart();
}
function initUI() {
// 生成科目 Checkbox
els.subjectCheckboxes.innerHTML = '';
// 預設選取的科目 (常用的)
const defaultSubjects = ['國文', '英文', '數學', '物理', '化學', '生物', '歷史', '地理', '公民', '社會', '自科', '總分', '平均'];
numericColumns.forEach(col => {
const wrapper = document.createElement('div');
wrapper.className = 'flex items-center space-x-2';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.value = col;
cb.id = `sub_${col}`;
cb.className = 'subject-cb form-checkbox text-green-600 rounded';
// 簡單邏輯:如果是預設列表中的,或者數值欄位少於 6 個,則預設勾選
if (defaultSubjects.includes(col) || numericColumns.length <= 6) {
cb.checked = true;
}
// 國文、英文、數學 必勾 (如果有的話)
if (['國文', '英文', '數學'].includes(col)) cb.checked = true;
cb.addEventListener('change', updateChart);
const label = document.createElement('label');
label.htmlFor = `sub_${col}`;
label.textContent = col;
wrapper.appendChild(cb);
wrapper.appendChild(label);
els.subjectCheckboxes.appendChild(wrapper);
});
// 填充下拉選單
const fillSelect = (selectEl, includeEmpty) => {
selectEl.innerHTML = includeEmpty ? '<option value="">無</option>' : '';
studentNames.forEach(name => {
const opt = document.createElement('option');
opt.value = name;
opt.textContent = name;
selectEl.appendChild(opt);
});
};
fillSelect(els.previewSelect, false);
fillSelect(els.compareSelect, true);
}
function getSelectedSubjects() {
const checkboxes = document.querySelectorAll('.subject-cb:checked');
return Array.from(checkboxes).map(cb => cb.value);
}
function getStudentData(name) {
return globalData.find(d => d['姓名'] === name);
}
function calculateAverage(subjects) {
let sums = {};
subjects.forEach(s => sums[s] = 0);
globalData.forEach(student => {
subjects.forEach(s => {
sums[s] += student[s];
});
});
const count = globalData.length;
return subjects.map(s => (sums[s] / count).toFixed(1)); // 保留一位小數
}
function createChartConfig(targetStudentName, compareStudentName, showAvg, subjects) {
const targetData = getStudentData(targetStudentName);
const datasets = [];
// 1. 全班平均 (放在最底層)
if (showAvg) {
const avgData = calculateAverage(subjects);
datasets.push({
label: '全班平均',
data: avgData,
backgroundColor: COLORS.avg.bg,
borderColor: COLORS.avg.border,
borderWidth: 2,
borderDash: [5, 5],
pointRadius: 0
});
}
// 2. 比較對象
if (compareStudentName && compareStudentName !== targetStudentName) {
const compData = getStudentData(compareStudentName);
if (compData) {
const dataValues = subjects.map(s => compData[s]);
datasets.push({
label: compareStudentName,
data: dataValues,
backgroundColor: COLORS.compare.bg,
borderColor: COLORS.compare.border,
borderWidth: 2,
pointStyle: 'rectRot',
pointRadius: 5
});
}
}
// 3. 主要學生 (最上層)
const mainValues = subjects.map(s => targetData[s]);
datasets.push({
label: targetStudentName,
data: mainValues,
fill: true,
backgroundColor: COLORS.main.bg,
borderColor: COLORS.main.border,
pointBackgroundColor: COLORS.main.border,
pointBorderColor: '#fff',
pointHoverBackgroundColor: '#fff',
pointHoverBorderColor: COLORS.main.border,
borderWidth: 3,
pointRadius: 4
});
// 計算最大值以設定刻度
let allValues = [...mainValues];
if (showAvg) allValues = allValues.concat(calculateAverage(subjects).map(Number));
const maxVal = Math.max(...allValues) || 100;
const suggestedMax = Math.ceil(maxVal * 1.1); // 留 10% 空間
return {
type: 'radar',
data: {
labels: subjects,
datasets: datasets
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
r: {
angleLines: { display: true },
suggestedMin: 0,
suggestedMax: suggestedMax,
ticks: {
backdropColor: 'transparent',
font: { size: 10 }
},
pointLabels: {
font: { size: 14, weight: 'bold', family: "'Noto Sans TC', sans-serif" },
color: '#374151'
}
}
},
plugins: {
title: {
display: true,
text: `${targetStudentName} 成績分析圖`,
font: { size: 20, family: "'Noto Sans TC', sans-serif" },
padding: 20
},
legend: {
position: 'bottom',
labels: {
font: { size: 12, family: "'Noto Sans TC', sans-serif" },
padding: 20
}
}
}
}
};
}
function updateChart() {
const targetName = els.previewSelect.value;
const compareName = els.compareSelect.value;
const subjects = getSelectedSubjects();
const showAvg = els.showAvg.checked;
if (!targetName || subjects.length === 0) return;
const config = createChartConfig(targetName, compareName, showAvg, subjects);
if (chartInstance) {
chartInstance.destroy();
}
chartInstance = new Chart(els.canvas, config);
}
// ============================
// 批次生成邏輯
// ============================
async function generateBatchZip() {
const subjects = getSelectedSubjects();
const compareName = els.compareSelect.value;
const showAvg = els.showAvg.checked;
if (subjects.length === 0) {
alert("請至少選擇一個科目!");
return;
}
// UI 狀態更新
els.btnZip.disabled = true;
els.btnZip.textContent = "生成中... 請勿關閉";
els.progressContainer.classList.remove('hidden');
const zip = new JSZip();
const batchCtx = els.batchCanvas.getContext('2d');
// 確保 Canvas 尺寸足夠大以獲得高品質圖片
// Chart.js 依賴父容器或 Canvas 屬性,這裡我們直接畫在隱藏的 1200x900 Canvas 上
let processedCount = 0;
const total = studentNames.length;
// 為了不卡死瀏覽器 UI,我們使用非同步遞迴或 for loop with await
for (let i = 0; i < total; i++) {
const studentName = studentNames[i];
// 更新進度條
const percent = Math.round(((i + 1) / total) * 100);
els.progressBar.style.width = `${percent}%`;
els.progressText.textContent = `正在處理: ${studentName} (${i + 1}/${total})`;
// 產生 Config
const config = createChartConfig(studentName, compareName, showAvg, subjects);
// 強制關閉動畫,這樣畫完馬上就能截圖
config.options.animation = false;
config.options.devicePixelRatio = 2; // 提高清晰度
// 銷毀舊的批次圖表
if (batchChartInstance) {
batchChartInstance.destroy();
}
// 繪製新圖表
// 使用 Promise 等待渲染完成 (雖然 animation: false 通常是同步的,但保險起見)
await new Promise(resolve => {
batchChartInstance = new Chart(batchCtx, {
...config,
plugins: [{
id: 'background-colour',
beforeDraw: (chart) => {
const ctx = chart.ctx;
ctx.save();
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, chart.width, chart.height);
ctx.restore();
}
}]
});
// 給一點點時間讓 Canvas 渲染 buffer
setTimeout(resolve, 50);
});
// 轉成 Blob/Base64
const imgData = els.batchCanvas.toDataURL("image/png").split(',')[1]; // 去除 header
// 加入 ZIP
zip.file(`${studentName}_成績雷達圖.png`, imgData, {base64: true});
}
els.progressText.textContent = "正在壓縮檔案...";
// 生成 ZIP 並下載
zip.generateAsync({type: "blob"}).then(function(content) {
saveAs(content, "學生成績雷達圖_全班.zip");
// 復原 UI
els.btnZip.disabled = false;
els.btnZip.innerHTML = "<span>下載全班圖表 (.ZIP)</span>";
els.progressText.textContent = "完成!";
setTimeout(() => {
els.progressContainer.classList.add('hidden');
}, 3000);
});
}
</script>
</body>
</html>