From a6059b53772ed6522799bab38a853fe146e6e9fa Mon Sep 17 00:00:00 2001 From: OoO Date: Mon, 18 May 2026 14:24:28 +0800 Subject: [PATCH] fix: restore analysis chart rendering --- tests/test_chart_fallback_contract.py | 30 +++++++ web/static/css/page-daily-sales.css | 19 ++++- web/static/css/page-growth-bem.css | 16 +++- web/static/js/analysis-chart-theme.js | 69 +++++++++++++++- web/static/js/page-daily-sales.js | 109 +++++++++++++++++++------- web/static/js/page-growth.js | 105 ++++++++++++++++++++++--- 6 files changed, 302 insertions(+), 46 deletions(-) create mode 100644 tests/test_chart_fallback_contract.py diff --git a/tests/test_chart_fallback_contract.py b/tests/test_chart_fallback_contract.py new file mode 100644 index 0000000..a809ef6 --- /dev/null +++ b/tests/test_chart_fallback_contract.py @@ -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] diff --git a/web/static/css/page-daily-sales.css b/web/static/css/page-daily-sales.css index f35938e..f70b502 100644 --- a/web/static/css/page-daily-sales.css +++ b/web/static/css/page-daily-sales.css @@ -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; diff --git a/web/static/css/page-growth-bem.css b/web/static/css/page-growth-bem.css index 94775a2..9623eb0 100644 --- a/web/static/css/page-growth-bem.css +++ b/web/static/css/page-growth-bem.css @@ -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; diff --git a/web/static/js/analysis-chart-theme.js b/web/static/js/analysis-chart-theme.js index b8c3be8..7e74eaa 100644 --- a/web/static/js/analysis-chart-theme.js +++ b/web/static/js/analysis-chart-theme.js @@ -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); }; diff --git a/web/static/js/page-daily-sales.js b/web/static/js/page-daily-sales.js index 6fa663f..5ced585 100644 --- a/web/static/js/page-daily-sales.js +++ b/web/static/js/page-daily-sales.js @@ -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(); }); } diff --git a/web/static/js/page-growth.js b/web/static/js/page-growth.js index 98e8959..ab8bfba 100644 --- a/web/static/js/page-growth.js +++ b/web/static/js/page-growth.js @@ -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(); }); }