Files
ewoooc/web/static/js/analysis-chart-theme.js
OoO ef9c2272b9
All checks were successful
CD Pipeline / deploy (push) Successful in 1m6s
V10.437 harden sales chart rendering
2026-05-24 17:15:21 +08:00

395 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* EwoooC Chart Theme v3.0 — Production
* ─────────────────────────────────────────────────────────────
* 變更重點(相對 v2.x
* 1. 動態讀取當前頁面 [data-page-group],圖表自動換群組調色盤
* 2. 移除 dotMatrixPluginchart 底圖點陣)— 干擾資料判讀
* 3. 移除 alpha fill 預設(散布圖、長條圖背景透明度過低看不清)
* 4. 字體統一走 var(--momo-font-mono) for tick / value
* 5. 顏色映射保留 — 第三方 chart config 用冷色時自動轉暖色
*
* 群組調色盤:
* monitor → caramel primaryapricot/honey/olive/clay/greige
* analytics → honey primarycaramel/olive/apricot/clay/greige
* ops → clay primaryterra/honey/caramel/apricot/greige
* ai → saffron primaryhoney/caramel/olive/apricot/greige
* system → terra primaryclay/olive/greige/honey/apricot
*/
(function () {
let chartJsLoader = null;
const CHART_LOAD_TIMEOUT_MS = 4500;
const CHART_CDN_URLS = [
'https://cdn.jsdelivr.net/npm/chart.js@4.4.6/dist/chart.umd.min.js',
'https://unpkg.com/chart.js@4.4.6/dist/chart.umd.js',
'https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.6/chart.umd.min.js'
];
const root = document.documentElement;
const css = (name, fallback) =>
getComputedStyle(root).getPropertyValue(name).trim() || fallback;
function readPalette() {
// 優先讀群組變數,沒有就 fallback 到 momo-warm-*
const shell = document.querySelector('.momo-app[data-page-group]');
const scope = shell || document.documentElement;
const get = (n, fb) =>
getComputedStyle(scope).getPropertyValue(n).trim() || fb;
return [
get('--momo-page-chart-1', '#c96442'),
get('--momo-page-chart-2', '#e7b98f'),
get('--momo-page-chart-3', '#c89043'),
get('--momo-page-chart-4', '#8a7340'),
get('--momo-page-chart-5', '#b86f52'),
get('--momo-page-chart-6', '#b8aea2'),
'#a85d3d',
'#6f6256',
'#d8c1aa',
'#edd6cc'
];
}
const theme = {
font: css('--momo-font-family', '"Inter", "Noto Sans TC", sans-serif'),
mono: css('--momo-font-mono', '"JetBrains Mono", monospace'),
ink: css('--momo-text-primary', '#2a2520'),
muted: css('--momo-text-secondary', '#6b6155'),
faint: 'rgba(42, 37, 32, 0.10)',
paper: css('--momo-bg-paper', '#f5efe2'),
elevated: css('--momo-bg-elevated', '#fdfaf2')
};
const isCompact = () =>
window.matchMedia && window.matchMedia('(max-width: 768px)').matches;
const clamp = (v, mn, mx) => Math.max(mn, Math.min(mx, v));
// 冷色 → 暖色映射(保留 v2 行為)
const coldColorMap = {
'#6366f1': '#c96442',
'#3b82f6': '#4a6b85',
'#8b5cf6': '#8f4530',
'#10b981': '#5a7a3f',
'#14b8a6': '#5a7a3f',
'#0ea5e9': '#4a6b85',
'#0284c7': '#4a6b85',
'#94a3b8': '#8f857a',
'#d1d5db': '#c7bcae',
'#ec4899': '#b5342f',
'#ef4444': '#b5342f',
'#f59e0b': '#c89043'
};
const alpha = (hex, amount) => {
if (!hex || !hex.startsWith('#') || hex.length < 7) return hex;
const n = parseInt(hex.slice(1, 7), 16);
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${amount})`;
};
function mapColor(color, fb) {
if (typeof color !== 'string') return color || fb;
const k = color.trim().toLowerCase();
return coldColorMap[k] || color;
}
function formatNumber(v) {
if (typeof v !== 'number' || !Number.isFinite(v)) return v;
return Math.abs(v) >= 10000
? `${Math.round(v / 10000).toLocaleString()}`
: 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();
const color = palette[idx % palette.length];
const t = ds.type || type;
if (t === 'doughnut' || t === 'pie' || t === 'polarArea') {
ds.backgroundColor = palette.map(c => alpha(c, 0.85));
ds.borderColor = theme.elevated;
ds.borderWidth = 2;
return;
}
if (typeof ds.backgroundColor !== 'function') {
ds.backgroundColor =
t === 'line' ? alpha(color, ds.fill ? 0.18 : 0.0) : alpha(color, 0.78);
}
if (typeof ds.borderColor !== 'function') ds.borderColor = color;
ds.borderWidth = ds.borderWidth || (t === 'line' ? 2 : 1);
ds.borderRadius = ds.borderRadius ?? (t === 'bar' ? 3 : undefined);
ds.borderSkipped = ds.borderSkipped ?? (t === 'bar' ? false : undefined);
ds.pointRadius = ds.pointRadius ?? (t === 'line' ? 2 : undefined);
ds.pointHoverRadius = ds.pointHoverRadius ?? (t === 'line' ? 4 : undefined);
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) {
if (!config || typeof config !== 'object') return config;
const type = config.type || 'bar';
const datasets =
config.data && Array.isArray(config.data.datasets)
? config.data.datasets
: [];
datasets.forEach((d, i) => tuneDataset(d, i, type));
config.options = config.options || {};
config.options.responsive = config.options.responsive !== false;
config.options.maintainAspectRatio = false;
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,
boxHeight: 10,
borderRadius: 2,
padding: 12,
font: { family: theme.mono, size: 11, weight: '600' },
...((plugins.legend || {}).labels || {})
}
};
plugins.tooltip = {
...(plugins.tooltip || {}),
backgroundColor: theme.elevated,
titleColor: theme.ink,
bodyColor: theme.ink,
borderColor: theme.faint,
borderWidth: 1,
cornerRadius: 4,
titleFont: { family: theme.mono, weight: '700', size: 12 },
bodyFont: { family: theme.font, weight: '500', size: 12 },
padding: 10,
callbacks: {
label: ctx => {
const lbl =
ctx.dataset && ctx.dataset.label ? `${ctx.dataset.label}: ` : '';
const v =
typeof ctx.parsed === 'object'
? ctx.parsed.y ?? ctx.parsed.x ?? ctx.raw
: ctx.parsed;
return `${lbl}${formatMetric(v, ctx.dataset && ctx.dataset.label)}`;
},
...((plugins.tooltip || {}).callbacks || {})
}
};
const scales = config.options.scales || {};
Object.keys(scales).forEach(k => {
scales[k] = {
border: { color: theme.faint, ...(scales[k].border || {}) },
grid: {
color: theme.faint,
tickColor: 'transparent',
drawBorder: false,
...(scales[k].grid || {})
},
ticks: {
color: theme.muted,
maxRotation: isCompact() ? 0 : 30,
autoSkip: isCompact(),
maxTicksLimit: isCompact() ? 5 : 8,
font: {
family: theme.mono,
size: isCompact() ? 10 : 11,
weight: '500'
},
callback: v => formatNumber(v),
...(scales[k].ticks || {})
},
...scales[k]
};
});
config.options.scales = scales;
return config;
}
function installChartJsTheme() {
if (!window.Chart || window.Chart.__ewooocThemed) return;
const Native = window.Chart;
Native.defaults.font.family = theme.font;
Native.defaults.font.size = 12;
Native.defaults.color = theme.muted;
function ThemedChart(item, config) {
return new Native(item, tuneChartConfig(config));
}
for (const k of Reflect.ownKeys(Native)) {
if (['length', 'name', 'prototype'].includes(k)) continue;
try {
Object.defineProperty(
ThemedChart,
k,
Object.getOwnPropertyDescriptor(Native, k)
);
} catch (e) {}
}
ThemedChart.prototype = Native.prototype;
Object.setPrototypeOf(ThemedChart, Native);
ThemedChart.__ewooocThemed = true;
window.Chart = ThemedChart;
}
function loadChartJs() {
if (window.Chart) {
installChartJsTheme();
return Promise.resolve(window.Chart);
}
if (chartJsLoader) return chartJsLoader;
const loadFromCdn = index => new Promise((resolve, reject) => {
const url = CHART_CDN_URLS[index];
if (!url) {
reject(new Error('Chart.js 載入失敗'));
return;
}
if (window.Chart) {
installChartJsTheme();
resolve(window.Chart);
return;
}
let settled = false;
const finish = callback => value => {
if (settled) return;
settled = true;
window.clearTimeout(timer);
callback(value);
};
const fail = finish(error => {
script.remove();
if (index + 1 < CHART_CDN_URLS.length) {
loadFromCdn(index + 1).then(resolve).catch(reject);
} else {
reject(error);
}
});
const succeed = finish(() => {
installChartJsTheme();
resolve(window.Chart);
});
const timer = window.setTimeout(
() => fail(new Error(`Chart.js 載入逾時: ${url}`)),
CHART_LOAD_TIMEOUT_MS
);
const script = document.createElement('script');
script.src = url;
script.async = true;
script.dataset.ewooocChartjs = String(index + 1);
script.onload = succeed;
script.onerror = () => fail(new Error(`Chart.js 載入失敗: ${url}`));
document.head.appendChild(script);
});
chartJsLoader = loadFromCdn(0);
return chartJsLoader;
}
// ECharts 走精簡版v2 完整 mergeAxis/tuneSeries 保留邏輯但用群組色)
function installEchartsTheme() {
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);
chart.setOption = function (opt, notMerge, lazy) {
const palette = readPalette();
const tuned = {
...opt,
color: palette,
backgroundColor: 'transparent',
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);
};
if (window.ResizeObserver && dom) {
new ResizeObserver(() =>
requestAnimationFrame(() => chart.resize())
).observe(dom);
}
return chart;
};
ec.__ewooocThemed = true;
}
window.EwoooCChartTheme = {
readPalette,
theme,
tuneChartConfig,
loadChartJs,
installChartJsTheme,
installEchartsTheme
};
installChartJsTheme();
installEchartsTheme();
})();