fix: restore analysis chart rendering

This commit is contained in:
OoO
2026-05-18 14:24:28 +08:00
parent 49f6b3ebd9
commit a6059b5377
6 changed files with 302 additions and 46 deletions

View 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]

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);
};

View File

@@ -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();
});
}

View File

@@ -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();
});
}