448 lines
15 KiB
JavaScript
448 lines
15 KiB
JavaScript
/* ════════════════════════════════════════════════════════
|
|
* 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 = `<strong>尚無可繪製資料</strong><span>${message}</span>`;
|
|
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();
|
|
}
|
|
})();
|