Files
ewoooc/web/static/js/page-growth.js
OoO c420d48263
Some checks failed
CD Pipeline / deploy (push) Failing after 26s
Fix PPT auto generation and analytics fallbacks
2026-05-18 11:52:31 +08:00

257 lines
8.3 KiB
JavaScript

/* ════════════════════════════════════════════════════════
* page-growth.js
* growth_analysis.html 的 Chart.js 圖表
* Chart.js 由 analysis-chart-theme.js 懶載入,避免首屏阻塞。
* ════════════════════════════════════════════════════════ */
(function () {
'use strict';
let chartsRendered = false;
function readGrowthData() {
const node = document.getElementById('chart-data');
if (!node) return null;
try {
return JSON.parse(node.textContent || '{}');
} 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 renderHtmlBars(canvasId, labels, values, options = {}) {
const canvas = document.getElementById(canvasId);
const wrap = canvas ? canvas.closest('.ga-chart-card__body') : null;
if (!wrap || wrap.querySelector('.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);
wrap.classList.add('has-html-chart');
canvas.setAttribute('aria-hidden', 'true');
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' });
}
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');
if (!revenueEl || !momEl || !aovEl || !marginEl) return;
rememberChart(new Chart(revenueEl, {
type: 'bar',
data: {
labels: data.labels,
datasets: [
{
label: '月營收 ($)',
data: data.revenue,
backgroundColor: chartPalette.caramelSoft,
order: 2
},
{
label: 'YoY 年增率 (%)',
data: data.yoy,
type: 'line',
borderColor: chartPalette.rust,
borderWidth: 2,
yAxisID: 'y1',
order: 1,
tension: 0.3
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: { beginAtZero: true, title: { display: true, text: '金額 ($)' } },
y1: {
position: 'right',
grid: { drawOnChartArea: false },
title: { display: true, text: '成長率 (%)' }
}
}
}
}));
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
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } }
}
}));
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.4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: { y: { beginAtZero: true } }
}
}));
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.4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: { y: { beginAtZero: true } }
}
}));
stabilizeCharts();
renderHtmlChartFallbacks();
}
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);
});
}
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();
}
})();