/* ════════════════════════════════════════════════════════ * page-growth.js * growth_analysis.html 的 Chart.js 圖表 * Chart.js 由 analysis-chart-theme.js 懶載入,避免首屏阻塞。 * ════════════════════════════════════════════════════════ */ (function () { 'use strict'; let chartsRendered = false; const isCompact = () => window.matchMedia && window.matchMedia('(max-width: 768px)').matches; function readGrowthData() { const node = document.getElementById('chart-data'); if (!node) return null; const rawPayload = [ node.content && node.content.textContent, node.textContent, node.innerHTML ].find(value => value && value.trim()); if (!rawPayload) { console.error('[growth_analysis] chart data is empty'); return null; } try { return JSON.parse(rawPayload); } catch (error) { console.error('[growth_analysis] chart data parse failed:', error); return null; } } const data = readGrowthData(); if (!data) return; const rootStyle = getComputedStyle(document.documentElement); const token = (name, fallback) => rootStyle.getPropertyValue(name).trim() || fallback; const chartPalette = { caramel: token('--momo-page-chart-2', '#c96442'), caramelSoft: token('--momo-warm-caramel-soft', 'rgba(201, 100, 66, 0.58)'), honey: token('--momo-page-accent', '#c89043'), honeySoft: token('--momo-page-accent-soft', 'rgba(200, 144, 67, 0.14)'), rust: token('--momo-danger-text', '#7a3210'), rustSoft: token('--momo-danger-bg', '#efd3c4') }; const chartInstances = []; function rememberChart(chart) { if (chart) chartInstances.push(chart); return chart; } function stabilizeCharts() { window.requestAnimationFrame(() => { chartInstances.forEach(chart => { if (!chart) return; if (typeof chart.resize === 'function') chart.resize(); if (typeof chart.update === 'function') chart.update('none'); }); window.dispatchEvent(new Event('resize')); }); } function formatShort(value, mode) { const n = Number(value || 0); if (mode === 'pct') return `${n.toFixed(1)}%`; if (mode === 'currency') return `$${Math.round(n).toLocaleString()}`; return Math.round(n).toLocaleString(); } function formatMetric(value, mode) { const n = Number(value || 0); if (mode === 'pct') return `${n >= 0 ? '+' : ''}${n.toFixed(1)}%`; if (mode === 'currency') return `$${Math.round(n).toLocaleString()}`; return Math.round(n).toLocaleString(); } function moneyAxis(title) { return { beginAtZero: true, grace: '8%', title: { display: !isCompact(), text: title }, ticks: { callback: value => formatMetric(value, 'currency') } }; } function pctAxis(title) { return { beginAtZero: false, grace: '12%', title: { display: !isCompact(), text: title }, ticks: { callback: value => formatMetric(value, 'pct') } }; } function renderHtmlBars(canvasId, labels, values, options = {}) { const canvas = document.getElementById(canvasId); const wrap = canvas ? canvas.closest('.ga-chart-card__body') : null; if (!wrap) return; wrap.classList.add('chart-fallback-active'); canvas.setAttribute('aria-hidden', 'true'); if (wrap.querySelector('.ga-chart-snapshot, .ga-chart-fallback')) return; const pairs = (labels || []).map((label, index) => ({ label: String(label || ''), value: Number((values || [])[index] || 0) })).filter(item => Number.isFinite(item.value)); if (!pairs.length) return; const max = Math.max(...pairs.map(item => Math.abs(item.value)), 1); const chart = document.createElement('div'); chart.className = 'ga-chart-fallback'; pairs.forEach(item => { const bar = document.createElement('div'); bar.className = `ga-chart-fallback__bar ${item.value < 0 ? 'is-negative' : ''}`; bar.style.setProperty('--bar-h', `${Math.max(4, Math.round(Math.abs(item.value) / max * 100))}%`); const label = document.createElement('span'); label.textContent = item.label; const value = document.createElement('strong'); value.textContent = formatShort(item.value, options.mode); bar.append(label, value); chart.appendChild(bar); }); wrap.appendChild(chart); } function renderHtmlChartFallbacks() { renderHtmlBars('revenueChart', data.labels, data.revenue, { mode: 'currency' }); renderHtmlBars('momChart', data.labels, data.mom, { mode: 'pct' }); renderHtmlBars('aovChart', data.labels, data.aov, { mode: 'currency' }); renderHtmlBars('marginChart', data.labels, data.margin_rate, { mode: 'pct' }); renderHtmlBars('competitorPressureChart', data.labels, data.competitor_gap_pct, { mode: 'pct' }); } function hasSeriesData(labels, ...seriesList) { return Array.isArray(labels) && labels.length > 0 && seriesList.some(series => Array.isArray(series) && series.some(value => Number.isFinite(Number(value)) && Math.abs(Number(value)) > 1e-9) ); } function renderChartEmpty(canvasId, message) { const canvas = document.getElementById(canvasId); const wrap = canvas ? canvas.closest('.ga-chart-card__body') : null; if (!wrap || wrap.querySelector('.ga-chart-empty')) return; wrap.classList.add('chart-empty-active'); canvas.setAttribute('aria-hidden', 'true'); const empty = document.createElement('div'); empty.className = 'ga-chart-empty'; empty.innerHTML = `尚無可繪製資料${message}`; wrap.appendChild(empty); } function loadChartJs() { if (window.EwoooCChartTheme && window.EwoooCChartTheme.loadChartJs) { return window.EwoooCChartTheme.loadChartJs(); } if (window.Chart) return Promise.resolve(window.Chart); return Promise.reject(new Error('Chart.js loader unavailable')); } function renderCharts() { if (chartsRendered) return; chartsRendered = true; Chart.defaults.color = token('--momo-text-secondary', '#6b6155'); Chart.defaults.borderColor = token('--momo-border-light', 'rgba(42, 37, 32, 0.10)'); Chart.defaults.font.family = token('--momo-font-family', "'Inter', system-ui, sans-serif"); const revenueEl = document.getElementById('revenueChart'); const momEl = document.getElementById('momChart'); const aovEl = document.getElementById('aovChart'); const marginEl = document.getElementById('marginChart'); const competitorEl = document.getElementById('competitorPressureChart'); if (!revenueEl || !momEl || !aovEl || !marginEl) return; if (!hasSeriesData(data.labels, data.revenue, data.yoy)) { renderChartEmpty('revenueChart', '目前圖表 payload 沒有月營收或年增率序列。'); } else { rememberChart(new Chart(revenueEl, { type: 'bar', data: { labels: data.labels, datasets: [ { label: '月營收 ($)', data: data.revenue, backgroundColor: chartPalette.caramelSoft, borderColor: chartPalette.caramel, borderWidth: 1, maxBarThickness: 34, order: 2 }, { label: 'YoY 年增率 (%)', data: data.yoy, type: 'line', borderColor: chartPalette.rust, borderWidth: 2, yAxisID: 'y1', order: 1, tension: 0.34, cubicInterpolationMode: 'monotone', pointRadius: 2, pointHoverRadius: 5 } ] }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, plugins: { legend: { position: isCompact() ? 'bottom' : 'top' }, tooltip: { callbacks: { label: ctx => { const mode = ctx.dataset.yAxisID === 'y1' ? 'pct' : 'currency'; return `${ctx.dataset.label}: ${formatMetric(ctx.parsed.y, mode)}`; } } } }, scales: { x: { grid: { display: false }, ticks: { maxTicksLimit: isCompact() ? 5 : 8 } }, y: moneyAxis('月營收'), y1: { position: 'right', grid: { drawOnChartArea: false }, ...pctAxis('YoY 年增率') } } } })); } if (!hasSeriesData(data.labels, data.mom)) { renderChartEmpty('momChart', '目前圖表 payload 沒有月增率序列。'); } else { rememberChart(new Chart(momEl, { type: 'bar', data: { labels: data.labels, datasets: [{ label: 'MoM 月增率 (%)', data: data.mom, backgroundColor: ctx => ctx.raw >= 0 ? chartPalette.honeySoft : chartPalette.rustSoft, borderColor: ctx => ctx.raw >= 0 ? chartPalette.honey : chartPalette.rust, borderWidth: 1, maxBarThickness: 34 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { callbacks: { label: ctx => formatMetric(ctx.parsed.y, 'pct') } } }, scales: { x: { grid: { display: false }, ticks: { maxTicksLimit: isCompact() ? 5 : 8 } }, y: pctAxis('MoM 月增率') } } })); } if (!hasSeriesData(data.labels, data.aov)) { renderChartEmpty('aovChart', '目前圖表 payload 沒有平均單價序列。'); } else { rememberChart(new Chart(aovEl, { type: 'line', data: { labels: data.labels, datasets: [{ label: '平均單價 ($)', data: data.aov, borderColor: chartPalette.caramel, backgroundColor: chartPalette.caramelSoft, fill: true, tension: 0.36, cubicInterpolationMode: 'monotone', pointRadius: 2, pointHoverRadius: 5 }] }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, plugins: { legend: { display: false }, tooltip: { callbacks: { label: ctx => formatMetric(ctx.parsed.y, 'currency') } } }, scales: { x: { grid: { display: false }, ticks: { maxTicksLimit: isCompact() ? 5 : 8 } }, y: moneyAxis('平均單價') } } })); } if (!hasSeriesData(data.labels, data.margin_rate)) { renderChartEmpty('marginChart', '目前圖表 payload 沒有毛利率序列。'); } else { rememberChart(new Chart(marginEl, { type: 'line', data: { labels: data.labels, datasets: [{ label: '毛利率 (%)', data: data.margin_rate, borderColor: chartPalette.honey, backgroundColor: chartPalette.honeySoft, fill: true, tension: 0.36, cubicInterpolationMode: 'monotone', pointRadius: 2, pointHoverRadius: 5 }] }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, plugins: { legend: { display: false }, tooltip: { callbacks: { label: ctx => formatMetric(ctx.parsed.y, 'pct') } } }, scales: { x: { grid: { display: false }, ticks: { maxTicksLimit: isCompact() ? 5 : 8 } }, y: pctAxis('毛利率') } } })); } if (competitorEl) { if (!hasSeriesData(data.labels, data.competitor_gap_pct, data.competitor_risk_count)) { renderChartEmpty('competitorPressureChart', '目前沒有可用的 PChome 月度價差序列。'); } else { rememberChart(new Chart(competitorEl, { type: 'bar', data: { labels: data.labels, datasets: [ { label: '價格壓力 SKU 數', data: data.competitor_risk_count || [], backgroundColor: chartPalette.rustSoft, borderColor: chartPalette.rust, borderWidth: 1, maxBarThickness: 34, yAxisID: 'y' }, { label: '平均價差 (%)', data: data.competitor_gap_pct || [], type: 'line', borderColor: chartPalette.honey, borderWidth: 2, yAxisID: 'y1', tension: 0.34, cubicInterpolationMode: 'monotone', pointRadius: 2, pointHoverRadius: 5 } ] }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, plugins: { legend: { position: isCompact() ? 'bottom' : 'top' }, tooltip: { callbacks: { label: ctx => { const mode = ctx.dataset.yAxisID === 'y1' ? 'pct' : 'number'; return `${ctx.dataset.label}: ${formatMetric(ctx.parsed.y, mode)}`; } } } }, scales: { x: { grid: { display: false }, ticks: { maxTicksLimit: isCompact() ? 5 : 8 } }, y: { beginAtZero: true, title: { display: !isCompact(), text: '風險 SKU 數' } }, y1: { position: 'right', grid: { drawOnChartArea: false }, ...pctAxis('平均價差') } } } })); } } stabilizeCharts(); } function bootCharts() { document.documentElement.dataset.growthCharts = 'loading'; loadChartJs() .then(() => { renderCharts(); document.documentElement.dataset.growthCharts = 'ready'; }) .catch(error => { document.documentElement.dataset.growthCharts = 'error'; document.documentElement.dataset.growthChartsError = error && error.message ? error.message : String(error); console.error('[growth_analysis] Chart.js 載入失敗:', error); renderHtmlChartFallbacks(); }); } function scheduleChartBoot() { window.setTimeout(bootCharts, 0); } function observeCharts() { const targets = Array.from(document.querySelectorAll('.ga-chart-card')); if (!targets.length) return; if (!('IntersectionObserver' in window)) { bootCharts(); return; } const observer = new IntersectionObserver(entries => { if (!entries.some(entry => entry.isIntersecting)) return; observer.disconnect(); bootCharts(); }, { rootMargin: '220px 0px' }); targets.forEach(target => observer.observe(target)); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', scheduleChartBoot, { once: true }); } else { scheduleChartBoot(); } })();