摘要
延續 Day 20 的長條圖統計,我們今天要繼續延伸 Chart.js 的應用:
- 圓餅圖:用來顯示各精神狀態的「比例分布」。
- 折線圖:改成計算「每日有效造訪次數」——同一天不論提交幾次,只算 1 次,觀察使用者最近的回訪頻率。
同一份 MOOD_LOG_KEY 資料,可以從「比例」和「趨勢」兩個角度來看,幫助使用者更清楚認識自己的精神狀態與使用習慣。
假設已具備:
Day 12
Day 13
Day 14
Day 15
Day 20
<!-- [Day21-NEW] 精神狀態占比(圓餅圖) -->
<div id="moodPieWrap" style="position:relative; height:240px; max-width:560px; margin:.5rem 0;">
<canvas id="moodPie" hidden aria-label="精神狀態圓餅圖" role="img"></canvas>
</div>
<!-- [Day21-NEW] 每日有效造訪(折線圖) -->
<div id="moodTrendWrap" style="position:relative; height:260px; max-width:680px; margin:.5rem 0;">
<canvas id="moodTrend" hidden aria-label="每日有效造訪折線圖" role="img"></canvas>
</div>
<ul id="moodTrendList" class="muted" aria-live="polite"></ul>
// [Day21-NEW] 載入 Chart.js 與時間軸 adapter(可選)
async function ensureChartJS() {
// 1) Chart.js
if (!window.Chart) {
await new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = 'https://cdn.jsdelivr.net/npm/chart.js';
s.onload = resolve; s.onerror = reject;
document.head.appendChild(s);
});
}
// 2) date-fns adapter(time scale 需要;失敗就走降級)
if (!window._ChartDateAdapterLoaded) {
try {
await new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = 'https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns';
s.onload = resolve; s.onerror = reject;
document.head.appendChild(s);
});
window._ChartDateAdapterLoaded = true;
} catch (e) {
console.warn('adapter 載入失敗,折線圖將使用「分類軸」降級:', e);
window._ChartDateAdapterLoaded = false;
}
}
}
// [Day21-NEW] 取得 2D context
function getCtx(id) {
const el = document.getElementById(id);
if (!el) return null;
el.hidden = false;
return el.getContext('2d');
}
// [Day21-NEW] 銷毀舊圖,避免重疊 / memory leak
function destroyChart(canvasId) {
const el = document.getElementById(canvasId);
if (el && el._chartInstance) {
el._chartInstance.destroy();
el._chartInstance = null;
}
}
// 產生本地 YYYY-MM-DD key(避免時區影響)
function dateKeyLocal(d) {
const y = d.getFullYear();
const m = String(d.getMonth()+1).padStart(2,'0');
const day = String(d.getDate()).padStart(2,'0');
return `${y}-${m}-${day}`;
}
// [Day21-CHANGED] 每日有效造訪(補零):預設最近 30 天
function aggregateDailyVisits(days = 30) {
const log = readMoodLog(); // [{ ts, ... }]
const visitSet = new Set(
log.map(s => dateKeyLocal(new Date(s.ts || Date.now())))
);
// 從今天往回推 N 天,逐日填入 0/1
const today = new Date();
today.setHours(0,0,0,0);
const rows = [];
for (let i = days - 1; i >= 0; i--) {
const d = new Date(today);
d.setDate(today.getDate() - i);
const key = dateKeyLocal(d);
rows.push({ x: new Date(d), dateStr: key, y: visitSet.has(key) ? 1 : 0 });
}
return rows; // 長度固定 = days
}
// [Day21-NEW] 繪製圓餅圖(占比)
function renderMoodPie(countMap) {
const canvasId = 'moodPie';
destroyChart(canvasId);
const labels = [...countMap.keys()];
const data = [...countMap.values()];
if (data.length === 0) {
document.getElementById(canvasId).hidden = true;
return;
}
const ctx = getCtx(canvasId);
if (!ctx) return;
const chart = new Chart(ctx, {
type: 'pie',
data: { labels, datasets: [{ data }] },
options: {
responsive: true,
plugins: { legend: { position: 'bottom' }, tooltip: { callbacks: { label: ctx => `${ctx.label}: ${ctx.parsed}` } } }
}
});
document.getElementById(canvasId)._chartInstance = chart;
}
// [Day21-NEW] 繪製折線圖(趨勢)— 可用 time scale;失敗則用分類軸
function renderMoodTrend(dailyPoints) {
const canvasId = 'moodTrend';
const listEl = document.getElementById('moodTrendList');
destroyChart(canvasId);
if (!dailyPoints.length) {
document.getElementById(canvasId).hidden = true;
listEl.innerHTML = '<li>尚無有效造訪資料</li>';
return;
}
// 清單回退:只列出有造訪(=1)的日期
const nonZero = dailyPoints.filter(p => p.y > 0);
listEl.innerHTML = nonZero.length
? nonZero.map(p => `<li>${p.dateStr}</li>`).join('')
: '<li>尚無有效造訪資料</li>';
// 嘗試用 time scale
const usingTimeScale = !!window._ChartDateAdapterLoaded; // 若 adapter 成功載入,啟用 time scale
const labels = dailyPoints.map(p => p.dateStr);
const data = dailyPoints.map(p => (usingTimeScale ? { x: p.x, y: p.y } : p.y));
const ctx = getCtx(canvasId);
if (!ctx) return;
const chart = new Chart(ctx, {
type: 'line',
data: {
labels: usingTimeScale ? undefined : labels,
datasets: [{
label: '每日有效造訪數',
data,
tension: 0.25,
pointRadius: 3
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: usingTimeScale
? { x: { type: 'time', time: { unit: 'day' } }, y: { beginAtZero: true, ticks: { precision: 0, stepSize: 1 } } }
: { x: { ticks: { autoSkip: true } }, y: { beginAtZero: true, ticks: { precision: 0, stepSize: 1 } } },
plugins: { legend: { display: false } }
}
});
document.getElementById(canvasId)._chartInstance = chart;
}
// [Day21-NEW] 在歷史頁渲染所有圖(bar + pie + line)
async function renderMoodChartsAll() {
// 先確保圖表核心與 adapter(可選)就緒
try { await ensureChartJS(); } catch (e) { console.warn('Chart.js 載入失敗:', e); }
// bar(既有):沿用 Day 20 的 renderMoodStats()
await renderMoodStats();
// pie:占比
const counts = aggregateMoodCounts(); // Map<label, count>
renderMoodPie(counts);
// line:趨勢
const daily = aggregateDailyVisits(30); // [{x:Date, dateStr:'YYYY-MM-DD', y:number}]
renderMoodTrend(daily);
}
// === showPage('history') ===
if (page === 'history') {
historySection.hidden = false;
renderMoodChartsAll().finally(() => {
if (typeof renderHistory === 'function') renderHistory();
});
focusOrFallback(historySection, 'history-title');
}