fix: restore analysis chart rendering
This commit is contained in:
30
tests/test_chart_fallback_contract.py
Normal file
30
tests/test_chart_fallback_contract.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_daily_sales_canvas_is_primary_and_fallback_is_opt_in():
|
||||
css = (ROOT / "web/static/css/page-daily-sales.css").read_text(encoding="utf-8")
|
||||
script = (ROOT / "web/static/js/page-daily-sales.js").read_text(encoding="utf-8")
|
||||
|
||||
assert ".chart-container.has-html-chart canvas" not in css
|
||||
assert ".chart-container.chart-fallback-active canvas" in css
|
||||
assert ".chart-container:not(.chart-fallback-active) .chart-fallback-list" in css
|
||||
render_body = script.split("function renderAllCharts()", 1)[1].split("function bootCharts()", 1)[0]
|
||||
assert "renderHtmlChartFallbacks();" not in render_body
|
||||
assert "catch(error =>" in script
|
||||
assert "renderHtmlChartFallbacks();" in script.split("catch(error =>", 1)[1]
|
||||
|
||||
|
||||
def test_growth_analysis_canvas_is_primary_and_fallback_is_opt_in():
|
||||
css = (ROOT / "web/static/css/page-growth-bem.css").read_text(encoding="utf-8")
|
||||
script = (ROOT / "web/static/js/page-growth.js").read_text(encoding="utf-8")
|
||||
|
||||
assert ".ga-chart-card__body.has-html-chart canvas" not in css
|
||||
assert ".ga-chart-card__body.chart-fallback-active canvas" in css
|
||||
assert ".ga-chart-card__body:not(.chart-fallback-active) .ga-chart-snapshot" in css
|
||||
render_body = script.split("function renderCharts()", 1)[1].split("function bootCharts()", 1)[0]
|
||||
assert "renderHtmlChartFallbacks();" not in render_body
|
||||
assert "catch(error =>" in script
|
||||
assert "renderHtmlChartFallbacks();" in script.split("catch(error =>", 1)[1]
|
||||
@@ -881,7 +881,7 @@
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.chart-container.has-html-chart canvas {
|
||||
.chart-container.chart-fallback-active canvas {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -1001,6 +1001,23 @@
|
||||
|
||||
.chart-container--md { height: 350px; }
|
||||
|
||||
.chart-container:not(.chart-fallback-active) .chart-fallback-bars,
|
||||
.chart-container:not(.chart-fallback-active) .chart-fallback-list {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.chart-container.chart-fallback-active .chart-fallback-list {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.chart-container.chart-fallback-active .chart-fallback-bars.is-vertical {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.chart-container.chart-fallback-active .chart-fallback-bars.is-horizontal {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.chart-responsive {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -233,10 +233,11 @@
|
||||
height: 300px;
|
||||
}
|
||||
.growth-analysis-page .ga-chart-card__body canvas {
|
||||
display: block;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
.growth-analysis-page .ga-chart-card__body.has-html-chart canvas {
|
||||
.growth-analysis-page .ga-chart-card__body.chart-fallback-active canvas {
|
||||
display: none !important;
|
||||
}
|
||||
.growth-analysis-page .ga-chart-fallback {
|
||||
@@ -306,6 +307,19 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.growth-analysis-page .ga-chart-card__body:not(.chart-fallback-active) .ga-chart-fallback,
|
||||
.growth-analysis-page .ga-chart-card__body:not(.chart-fallback-active) .ga-chart-snapshot {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.growth-analysis-page .ga-chart-card__body.chart-fallback-active .ga-chart-fallback {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.growth-analysis-page .ga-chart-card__body.chart-fallback-active .ga-chart-snapshot {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.growth-analysis-page .ga-page-head {
|
||||
align-items: flex-start;
|
||||
|
||||
@@ -91,6 +91,18 @@
|
||||
: v.toLocaleString();
|
||||
}
|
||||
|
||||
function formatMetric(v, label = '') {
|
||||
if (typeof v !== 'number' || !Number.isFinite(v)) return v;
|
||||
const name = String(label || '').toLowerCase();
|
||||
if (name.includes('%') || name.includes('率') || name.includes('yoy') || name.includes('mom')) {
|
||||
return `${v >= 0 ? '+' : ''}${v.toFixed(1)}%`;
|
||||
}
|
||||
if (name.includes('$') || name.includes('金額') || name.includes('營收') || name.includes('業績') || name.includes('毛利') || name.includes('客單') || name.includes('單價')) {
|
||||
return `$${Math.round(v).toLocaleString()}`;
|
||||
}
|
||||
return formatNumber(v);
|
||||
}
|
||||
|
||||
function tuneDataset(ds, idx, type) {
|
||||
if (!ds || typeof ds !== 'object') return;
|
||||
const palette = readPalette();
|
||||
@@ -118,6 +130,8 @@
|
||||
ds.pointBackgroundColor = ds.pointBackgroundColor || color;
|
||||
ds.pointBorderColor = ds.pointBorderColor || theme.elevated;
|
||||
ds.tension = ds.tension ?? (t === 'line' ? 0.32 : undefined);
|
||||
ds.hoverBorderWidth = ds.hoverBorderWidth || (t === 'line' ? 3 : 1);
|
||||
ds.maxBarThickness = ds.maxBarThickness || (t === 'bar' ? 34 : undefined);
|
||||
}
|
||||
|
||||
function tuneChartConfig(config) {
|
||||
@@ -136,6 +150,8 @@
|
||||
const plugins = (config.options.plugins = config.options.plugins || {});
|
||||
plugins.legend = {
|
||||
...(plugins.legend || {}),
|
||||
position: (plugins.legend || {}).position || (isCompact() ? 'bottom' : 'top'),
|
||||
align: (plugins.legend || {}).align || 'start',
|
||||
labels: {
|
||||
color: theme.muted,
|
||||
boxWidth: 10,
|
||||
@@ -165,7 +181,7 @@
|
||||
typeof ctx.parsed === 'object'
|
||||
? ctx.parsed.y ?? ctx.parsed.x ?? ctx.raw
|
||||
: ctx.parsed;
|
||||
return `${lbl}${formatNumber(v)}`;
|
||||
return `${lbl}${formatMetric(v, ctx.dataset && ctx.dataset.label)}`;
|
||||
},
|
||||
...((plugins.tooltip || {}).callbacks || {})
|
||||
}
|
||||
@@ -185,6 +201,7 @@
|
||||
color: theme.muted,
|
||||
maxRotation: isCompact() ? 0 : 30,
|
||||
autoSkip: isCompact(),
|
||||
maxTicksLimit: isCompact() ? 5 : 8,
|
||||
font: {
|
||||
family: theme.mono,
|
||||
size: isCompact() ? 10 : 11,
|
||||
@@ -252,6 +269,28 @@
|
||||
if (!window.echarts || window.echarts.__ewooocThemed) return;
|
||||
const ec = window.echarts;
|
||||
const nativeInit = ec.init.bind(ec);
|
||||
const tuneAxis = axis => {
|
||||
if (!axis || typeof axis !== 'object') return axis;
|
||||
return {
|
||||
axisLine: { lineStyle: { color: theme.faint }, ...(axis.axisLine || {}) },
|
||||
axisTick: { lineStyle: { color: theme.faint }, ...(axis.axisTick || {}) },
|
||||
axisLabel: {
|
||||
color: theme.muted,
|
||||
fontFamily: theme.mono,
|
||||
fontSize: isCompact() ? 10 : 11,
|
||||
...(axis.axisLabel || {})
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: { color: theme.faint, type: 'dashed' },
|
||||
...(axis.splitLine || {})
|
||||
},
|
||||
...axis
|
||||
};
|
||||
};
|
||||
const tuneAxisList = axes => {
|
||||
if (Array.isArray(axes)) return axes.map(tuneAxis);
|
||||
return tuneAxis(axes);
|
||||
};
|
||||
ec.init = function (dom, themeName, opts) {
|
||||
const chart = nativeInit(dom, themeName || null, opts);
|
||||
const setOpt = chart.setOption.bind(chart);
|
||||
@@ -261,7 +300,33 @@
|
||||
...opt,
|
||||
color: palette,
|
||||
backgroundColor: 'transparent',
|
||||
textStyle: { color: theme.ink, fontFamily: theme.font }
|
||||
textStyle: { color: theme.ink, fontFamily: theme.font, ...(opt.textStyle || {}) },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
confine: true,
|
||||
backgroundColor: theme.elevated,
|
||||
borderColor: theme.faint,
|
||||
borderWidth: 1,
|
||||
textStyle: { color: theme.ink, fontFamily: theme.font },
|
||||
axisPointer: { type: 'cross', label: { backgroundColor: '#6f6256' } },
|
||||
...(opt.tooltip || {})
|
||||
},
|
||||
legend: {
|
||||
bottom: 0,
|
||||
type: 'scroll',
|
||||
textStyle: { color: theme.muted, fontFamily: theme.mono, fontSize: 11 },
|
||||
...(opt.legend || {})
|
||||
},
|
||||
grid: {
|
||||
containLabel: true,
|
||||
left: isCompact() ? 12 : 28,
|
||||
right: isCompact() ? 12 : 24,
|
||||
top: isCompact() ? 24 : 36,
|
||||
bottom: isCompact() ? 34 : 40,
|
||||
...(opt.grid || {})
|
||||
},
|
||||
xAxis: tuneAxisList(opt.xAxis),
|
||||
yAxis: tuneAxisList(opt.yAxis)
|
||||
};
|
||||
return setOpt(tuned, notMerge, lazy);
|
||||
};
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
if (!dailySalesData) return;
|
||||
window.__DAILY_SALES__ = dailySalesData;
|
||||
let chartsRendered = false;
|
||||
const isCompact = () =>
|
||||
window.matchMedia && window.matchMedia('(max-width: 768px)').matches;
|
||||
|
||||
// -- Palette 讀自 CSS variable (analysis-chart-theme.js 公開) ----------
|
||||
function cssVar(name, fallback) {
|
||||
@@ -107,10 +109,44 @@
|
||||
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 axisMoney(title) {
|
||||
return {
|
||||
beginAtZero: true,
|
||||
grace: '8%',
|
||||
title: { display: !isCompact(), text: title },
|
||||
ticks: { callback: value => formatMetric(value, 'currency') }
|
||||
};
|
||||
}
|
||||
|
||||
function axisPercent(title) {
|
||||
return {
|
||||
beginAtZero: false,
|
||||
grace: '12%',
|
||||
title: { display: !isCompact(), text: title },
|
||||
ticks: { callback: value => formatMetric(value, 'pct') },
|
||||
grid: {
|
||||
color: context => Number(context.tick.value) === 0
|
||||
? rgba(palette.muted, 0.38)
|
||||
: rgba(palette.muted, 0.12),
|
||||
lineWidth: context => Number(context.tick.value) === 0 ? 1.2 : 1
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function renderHtmlBars(canvasId, labels, values, options = {}) {
|
||||
const canvas = document.getElementById(canvasId);
|
||||
const wrap = canvas ? canvas.closest('.chart-container') : null;
|
||||
if (!wrap || wrap.querySelector('.chart-fallback-bars')) return;
|
||||
if (!wrap) return;
|
||||
wrap.classList.add('chart-fallback-active');
|
||||
canvas.setAttribute('aria-hidden', 'true');
|
||||
if (wrap.querySelector('.chart-fallback-list, .chart-fallback-bars')) return;
|
||||
const pairs = (labels || []).map((label, index) => ({
|
||||
label: String(label || ''),
|
||||
value: Number((values || [])[index] || 0)
|
||||
@@ -118,8 +154,6 @@
|
||||
const data = options.limit ? pairs.slice(-options.limit) : pairs;
|
||||
if (!data.length) return;
|
||||
|
||||
wrap.classList.add('has-html-chart');
|
||||
canvas.setAttribute('aria-hidden', 'true');
|
||||
const max = Math.max(...data.map(item => Math.abs(item.value)), 1);
|
||||
const chart = document.createElement('div');
|
||||
chart.className = `chart-fallback-bars ${options.horizontal ? 'is-horizontal' : 'is-vertical'}`;
|
||||
@@ -171,11 +205,13 @@
|
||||
label, data,
|
||||
borderColor: color,
|
||||
backgroundColor: rgba(color, 0.14),
|
||||
borderWidth: 2,
|
||||
tension: 0.3,
|
||||
borderWidth: 2.4,
|
||||
tension: 0.34,
|
||||
cubicInterpolationMode: 'monotone',
|
||||
fill: false,
|
||||
pointRadius: 2,
|
||||
pointRadius: context => context.dataIndex === context.dataset.data.length - 1 ? 3 : 1.6,
|
||||
pointHoverRadius: 5,
|
||||
pointHitRadius: 12,
|
||||
yAxisID
|
||||
};
|
||||
}
|
||||
@@ -210,16 +246,23 @@
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: { legend: { position: 'top' } },
|
||||
plugins: {
|
||||
legend: { position: isCompact() ? 'bottom' : 'top' },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: ctx => {
|
||||
const metric = ctx.dataset.yAxisID === 'y2' ? 'number' : 'currency';
|
||||
return `${ctx.dataset.label}: ${formatMetric(ctx.parsed.y, metric)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
type: 'linear', position: 'left', beginAtZero: true,
|
||||
title: { display: true, text: '業績/毛利 ($)', color: palette.caramel }
|
||||
},
|
||||
x: { grid: { display: false }, ticks: { maxTicksLimit: isCompact() ? 5 : 10 } },
|
||||
y: { type: 'linear', position: 'left', ...axisMoney('業績 / 毛利') },
|
||||
y1: {
|
||||
type: 'linear', position: 'right', beginAtZero: true,
|
||||
type: 'linear', position: 'right', ...axisMoney('客單價'),
|
||||
grid: { drawOnChartArea: false },
|
||||
title: { display: true, text: '客單價 ($)', color: palette.mahogany }
|
||||
},
|
||||
y2: {
|
||||
type: 'linear', position: 'right', display: false, beginAtZero: true,
|
||||
@@ -251,15 +294,16 @@
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: { position: 'top' },
|
||||
legend: { position: isCompact() ? 'bottom' : 'top' },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: ctx => `${ctx.dataset.label}: ${ctx.parsed.y.toFixed(1)}%`
|
||||
label: ctx => `${ctx.dataset.label}: ${formatMetric(ctx.parsed.y, 'pct')}`
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: { beginAtZero: false, title: { display: true, text: 'DoD 成長率 (%)' } }
|
||||
x: { grid: { display: false }, ticks: { maxTicksLimit: isCompact() ? 5 : 10 } },
|
||||
y: axisPercent('DoD 成長率')
|
||||
}
|
||||
}
|
||||
}));
|
||||
@@ -286,7 +330,7 @@
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: { position: 'top' },
|
||||
legend: { position: isCompact() ? 'bottom' : 'top' },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: ctx => {
|
||||
@@ -295,13 +339,14 @@
|
||||
if (i < 7 || v === 0) {
|
||||
return `${ctx.dataset.label}: 無對比資料(需上週同日數據)`;
|
||||
}
|
||||
return `${ctx.dataset.label}: ${v.toFixed(1)}%`;
|
||||
return `${ctx.dataset.label}: ${formatMetric(v, 'pct')}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: { beginAtZero: false, title: { display: true, text: 'WoW 成長率 (%)' } }
|
||||
x: { grid: { display: false }, ticks: { maxTicksLimit: isCompact() ? 5 : 10 } },
|
||||
y: axisPercent('WoW 成長率')
|
||||
}
|
||||
}
|
||||
}));
|
||||
@@ -321,15 +366,22 @@
|
||||
data: safe.top10_values,
|
||||
backgroundColor: rgba(palette.caramel, 0.62),
|
||||
borderColor: palette.caramel,
|
||||
borderWidth: 1
|
||||
borderWidth: 1,
|
||||
maxBarThickness: 22
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
indexAxis: 'y',
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: { x: { beginAtZero: true } }
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: { callbacks: { label: ctx => formatMetric(ctx.parsed.x, 'currency') } }
|
||||
},
|
||||
scales: {
|
||||
x: axisMoney('銷售金額'),
|
||||
y: { grid: { display: false }, ticks: { autoSkip: false } }
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
@@ -352,7 +404,8 @@
|
||||
data: marketing.values,
|
||||
backgroundColor: shades,
|
||||
borderColor: color,
|
||||
borderWidth: 1
|
||||
borderWidth: 1,
|
||||
maxBarThickness: 22
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
@@ -362,15 +415,13 @@
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: { label: ctx => '$' + ctx.raw.toLocaleString() }
|
||||
callbacks: { label: ctx => formatMetric(ctx.parsed.x, 'currency') }
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
beginAtZero: true,
|
||||
ticks: { callback: v => '$' + v.toLocaleString() }
|
||||
},
|
||||
x: axisMoney('業績'),
|
||||
y: {
|
||||
grid: { display: false },
|
||||
ticks: {
|
||||
autoSkip: false,
|
||||
font: { size: 11 },
|
||||
@@ -513,7 +564,6 @@
|
||||
if (mk.discount) renderMarketingBar('discountChart', mk.discount, palette.caramel);
|
||||
if (mk.coupon) renderMarketingBar('couponChart', mk.coupon, palette.olive);
|
||||
stabilizeCharts();
|
||||
renderHtmlChartFallbacks();
|
||||
}
|
||||
|
||||
function bootCharts() {
|
||||
@@ -527,6 +577,7 @@
|
||||
document.documentElement.dataset.dailyCharts = 'error';
|
||||
document.documentElement.dataset.dailyChartsError = error && error.message ? error.message : String(error);
|
||||
console.error('[daily_sales] Chart.js 載入失敗:', error);
|
||||
renderHtmlChartFallbacks();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
'use strict';
|
||||
|
||||
let chartsRendered = false;
|
||||
const isCompact = () =>
|
||||
window.matchMedia && window.matchMedia('(max-width: 768px)').matches;
|
||||
|
||||
function readGrowthData() {
|
||||
const node = document.getElementById('chart-data');
|
||||
@@ -58,18 +60,44 @@
|
||||
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 || wrap.querySelector('.ga-chart-fallback')) return;
|
||||
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);
|
||||
wrap.classList.add('has-html-chart');
|
||||
canvas.setAttribute('aria-hidden', 'true');
|
||||
|
||||
const chart = document.createElement('div');
|
||||
chart.className = 'ga-chart-fallback';
|
||||
@@ -125,6 +153,9 @@
|
||||
label: '月營收 ($)',
|
||||
data: data.revenue,
|
||||
backgroundColor: chartPalette.caramelSoft,
|
||||
borderColor: chartPalette.caramel,
|
||||
borderWidth: 1,
|
||||
maxBarThickness: 34,
|
||||
order: 2
|
||||
},
|
||||
{
|
||||
@@ -135,19 +166,35 @@
|
||||
borderWidth: 2,
|
||||
yAxisID: 'y1',
|
||||
order: 1,
|
||||
tension: 0.3
|
||||
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: {
|
||||
y: { beginAtZero: true, title: { display: true, text: '金額 ($)' } },
|
||||
x: { grid: { display: false }, ticks: { maxTicksLimit: isCompact() ? 5 : 8 } },
|
||||
y: moneyAxis('月營收'),
|
||||
y1: {
|
||||
position: 'right',
|
||||
grid: { drawOnChartArea: false },
|
||||
title: { display: true, text: '成長率 (%)' }
|
||||
...pctAxis('YoY 年增率')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -160,13 +207,23 @@
|
||||
datasets: [{
|
||||
label: 'MoM 月增率 (%)',
|
||||
data: data.mom,
|
||||
backgroundColor: ctx => ctx.raw >= 0 ? chartPalette.honeySoft : chartPalette.rustSoft
|
||||
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 } }
|
||||
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 月增率')
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -180,13 +237,24 @@
|
||||
borderColor: chartPalette.caramel,
|
||||
backgroundColor: chartPalette.caramelSoft,
|
||||
fill: true,
|
||||
tension: 0.4
|
||||
tension: 0.36,
|
||||
cubicInterpolationMode: 'monotone',
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 5
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: { y: { beginAtZero: true } }
|
||||
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('平均單價')
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -200,17 +268,27 @@
|
||||
borderColor: chartPalette.honey,
|
||||
backgroundColor: chartPalette.honeySoft,
|
||||
fill: true,
|
||||
tension: 0.4
|
||||
tension: 0.36,
|
||||
cubicInterpolationMode: 'monotone',
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 5
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: { y: { beginAtZero: true } }
|
||||
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('毛利率')
|
||||
}
|
||||
}
|
||||
}));
|
||||
stabilizeCharts();
|
||||
renderHtmlChartFallbacks();
|
||||
}
|
||||
|
||||
function bootCharts() {
|
||||
@@ -224,6 +302,7 @@
|
||||
document.documentElement.dataset.growthCharts = 'error';
|
||||
document.documentElement.dataset.growthChartsError = error && error.message ? error.message : String(error);
|
||||
console.error('[growth_analysis] Chart.js 載入失敗:', error);
|
||||
renderHtmlChartFallbacks();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user