289 lines
9.6 KiB
JavaScript
289 lines
9.6 KiB
JavaScript
/**
|
||
* EwoooC Chart Theme v3.0 — Production
|
||
* ─────────────────────────────────────────────────────────────
|
||
* 變更重點(相對 v2.x):
|
||
* 1. 動態讀取當前頁面 [data-page-group],圖表自動換群組調色盤
|
||
* 2. 移除 dotMatrixPlugin(chart 底圖點陣)— 干擾資料判讀
|
||
* 3. 移除 alpha fill 預設(散布圖、長條圖背景透明度過低看不清)
|
||
* 4. 字體統一走 var(--momo-font-mono) for tick / value
|
||
* 5. 顏色映射保留 — 第三方 chart config 用冷色時自動轉暖色
|
||
*
|
||
* 群組調色盤:
|
||
* monitor → caramel primary,apricot/honey/olive/clay/greige
|
||
* analytics → honey primary,caramel/olive/apricot/clay/greige
|
||
* ops → clay primary,terra/honey/caramel/apricot/greige
|
||
* ai → saffron primary,honey/caramel/olive/apricot/greige
|
||
* system → terra primary,clay/olive/greige/honey/apricot
|
||
*/
|
||
(function () {
|
||
let chartJsLoader = null;
|
||
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 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);
|
||
}
|
||
|
||
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 || {}),
|
||
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}${formatNumber(v)}`;
|
||
},
|
||
...((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(),
|
||
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;
|
||
|
||
chartJsLoader = new Promise((resolve, reject) => {
|
||
const script = document.createElement('script');
|
||
script.src = 'https://cdn.jsdelivr.net/npm/chart.js@4.4.6/dist/chart.umd.min.js';
|
||
script.async = true;
|
||
script.onload = () => {
|
||
installChartJsTheme();
|
||
resolve(window.Chart);
|
||
};
|
||
script.onerror = () => reject(new Error('Chart.js 載入失敗'));
|
||
document.head.appendChild(script);
|
||
});
|
||
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);
|
||
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 }
|
||
};
|
||
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();
|
||
})();
|