Files
ewoooc/web/static/js/analysis-chart-theme.js
OoO 30a173cf69
All checks were successful
CD Pipeline / deploy (push) Successful in 58s
統一全站暖色視覺與市場情報骨架
2026-05-06 20:24:46 +08:00

419 lines
16 KiB
JavaScript

(function () {
const root = document.documentElement;
const css = (name, fallback) => getComputedStyle(root).getPropertyValue(name).trim() || fallback;
const theme = {
font: css('--momo-font-family', '"Inter", "Noto Sans TC", sans-serif'),
mono: css('--momo-font-family-mono', '"JetBrains Mono", monospace'),
ink: css('--momo-text-primary', '#2a2520'),
muted: css('--momo-text-secondary', '#645c52'),
faint: 'rgba(42, 37, 32, 0.10)',
paper: css('--momo-bg-paper', '#f3eee2'),
elevated: css('--momo-bg-elevated', '#fdfaf3'),
accent: css('--momo-warm-caramel', '#c96442'),
honey: css('--momo-warm-honey', '#b88416'),
rust: css('--momo-warm-rust', '#b5342f'),
mahogany: css('--momo-warm-mahogany', '#8f4530'),
earth: css('--momo-warm-earth', '#8a5a2b'),
success: css('--momo-success', '#2a7a3f'),
info: css('--momo-info', '#2d5d80')
};
const palette = [
theme.accent,
theme.success,
theme.honey,
theme.mahogany,
theme.info,
theme.earth,
theme.rust,
'#6f6256',
'#a66a3f',
'#576a42',
'#9b6f1c',
'#5f4638'
];
const isCompact = () => window.matchMedia && window.matchMedia('(max-width: 768px)').matches;
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
const coldColorMap = {
'#6366f1': theme.accent,
'#3b82f6': theme.info,
'#8b5cf6': theme.mahogany,
'#10b981': theme.success,
'#14b8a6': theme.success,
'#0ea5e9': theme.info,
'#0284c7': theme.info,
'#94a3b8': '#8f857a',
'#d1d5db': '#c7bcae',
'#ec4899': theme.rust,
'#ef4444': theme.rust,
'#f59e0b': theme.honey
};
const alpha = (hex, amount) => {
if (!hex || !hex.startsWith('#') || hex.length < 7) return hex;
const n = parseInt(hex.slice(1, 7), 16);
const r = (n >> 16) & 255;
const g = (n >> 8) & 255;
const b = n & 255;
return `rgba(${r}, ${g}, ${b}, ${amount})`;
};
function mapColor(color, fallback) {
if (typeof color !== 'string') return color || fallback;
const key = color.trim().toLowerCase();
if (coldColorMap[key]) return coldColorMap[key];
return color;
}
function mapPalette(colors) {
if (!Array.isArray(colors)) return palette;
return colors.map((color, index) => mapColor(color, palette[index % palette.length]));
}
function formatNumber(value) {
if (typeof value !== 'number' || !Number.isFinite(value)) return value;
return Math.abs(value) >= 10000 ? `${Math.round(value / 10000).toLocaleString()}` : value.toLocaleString();
}
function tuneDataset(dataset, index, chartType) {
if (!dataset || typeof dataset !== 'object') return;
const color = palette[index % palette.length];
const type = dataset.type || chartType;
if (type === 'doughnut' || type === 'pie' || type === 'polarArea') {
dataset.backgroundColor = palette.map((item) => alpha(item, 0.82));
dataset.borderColor = theme.elevated;
dataset.borderWidth = 2;
return;
}
if (type === 'treemap' && typeof dataset.backgroundColor === 'function') {
return;
}
if (typeof dataset.backgroundColor !== 'function') {
dataset.backgroundColor = type === 'line' ? alpha(color, dataset.fill ? 0.16 : 0.08) : alpha(color, 0.72);
}
if (typeof dataset.borderColor !== 'function') {
dataset.borderColor = color;
}
dataset.borderWidth = dataset.borderWidth || (type === 'line' ? 2 : 1);
dataset.borderRadius = dataset.borderRadius ?? (type === 'bar' ? 5 : undefined);
dataset.borderSkipped = dataset.borderSkipped ?? (type === 'bar' ? false : undefined);
dataset.pointRadius = dataset.pointRadius ?? (type === 'line' ? 2.5 : undefined);
dataset.pointHoverRadius = dataset.pointHoverRadius ?? (type === 'line' ? 4 : undefined);
dataset.pointBackgroundColor = dataset.pointBackgroundColor || color;
dataset.pointBorderColor = dataset.pointBorderColor || theme.elevated;
dataset.tension = dataset.tension ?? (type === 'line' ? 0.32 : undefined);
}
function tuneChartConfig(config) {
if (!config || typeof config !== 'object') return config;
const chartType = config.type || 'bar';
const datasets = config.data && Array.isArray(config.data.datasets) ? config.data.datasets : [];
datasets.forEach((dataset, index) => tuneDataset(dataset, index, chartType));
config.options = config.options || {};
config.options.responsive = config.options.responsive !== false;
config.options.maintainAspectRatio = false;
config.options.resizeDelay = config.options.resizeDelay ?? 90;
config.options.interaction = {
mode: chartType === 'scatter' || chartType === 'bubble' ? 'nearest' : 'index',
intersect: chartType === 'scatter' || chartType === 'bubble',
...(config.options.interaction || {})
};
const plugins = config.options.plugins = config.options.plugins || {};
plugins.legend = {
...(plugins.legend || {}),
labels: {
color: theme.muted,
boxWidth: 10,
boxHeight: 10,
borderRadius: 2,
padding: 14,
font: { family: theme.mono, size: 11, weight: '700' },
...((plugins.legend || {}).labels || {})
}
};
plugins.tooltip = {
...(plugins.tooltip || {}),
backgroundColor: 'rgba(253, 250, 243, 0.97)',
titleColor: theme.ink,
bodyColor: theme.ink,
borderColor: alpha(theme.accent, 0.38),
borderWidth: 1,
cornerRadius: 6,
displayColors: true,
titleFont: { family: theme.mono, weight: '800', size: 12 },
bodyFont: { family: theme.font, weight: '600', size: 12 },
padding: 10,
callbacks: {
label: function (ctx) {
const label = ctx.dataset && ctx.dataset.label ? `${ctx.dataset.label}: ` : '';
const value = typeof ctx.parsed === 'object' ? (ctx.parsed.y ?? ctx.parsed.x ?? ctx.raw) : ctx.parsed;
return `${label}${formatNumber(value)}`;
},
...((plugins.tooltip || {}).callbacks || {})
}
};
const scales = config.options.scales || {};
Object.keys(scales).forEach((key) => {
scales[key] = {
border: { color: theme.faint, ...(scales[key].border || {}) },
grid: { color: theme.faint, tickColor: 'transparent', drawBorder: false, ...(scales[key].grid || {}) },
ticks: {
color: theme.muted,
maxRotation: isCompact() ? 0 : 45,
autoSkip: isCompact(),
font: { family: theme.mono, size: isCompact() ? 10 : 11, weight: '650' },
callback: function (value) {
return formatNumber(value);
},
...(scales[key].ticks || {})
},
title: {
color: theme.muted,
font: { family: theme.font, size: 12, weight: '800' },
...(scales[key].title || {})
},
...scales[key]
};
});
config.options.scales = scales;
return config;
}
function mergeAxis(axis) {
if (!axis) return axis;
const tuneOne = (item) => ({
...item,
axisLine: {
...(item.axisLine || {}),
lineStyle: {
...((item.axisLine || {}).lineStyle || {}),
color: mapColor(((item.axisLine || {}).lineStyle || {}).color, theme.faint)
}
},
axisTick: { alignWithLabel: true, ...(item.axisTick || {}) },
splitLine: {
...(item.splitLine || {}),
lineStyle: {
type: 'dashed',
...((item.splitLine || {}).lineStyle || {}),
color: mapColor(((item.splitLine || {}).lineStyle || {}).color, theme.faint)
}
},
axisLabel: {
...((item.axisLabel || {})),
color: theme.muted,
fontFamily: theme.mono,
fontSize: isCompact() ? 10 : clamp((item.axisLabel || {}).fontSize || 11, 10, 12),
fontWeight: 700,
hideOverlap: true,
overflow: 'truncate',
width: (item.axisLabel || {}).width || (isCompact() ? 72 : 120)
},
nameTextStyle: {
...((item.nameTextStyle || {})),
color: theme.muted,
fontFamily: theme.font,
fontWeight: 800
}
});
return Array.isArray(axis) ? axis.map(tuneOne) : tuneOne(axis);
}
function tuneItemStyle(itemStyle, index) {
const fallback = palette[index % palette.length];
if (typeof itemStyle === 'function') return itemStyle;
const tuned = { ...(itemStyle || {}) };
if (typeof tuned.color === 'string') tuned.color = mapColor(tuned.color, fallback);
if (!tuned.color) tuned.color = fallback;
tuned.opacity = tuned.opacity ?? 0.82;
tuned.borderColor = tuned.borderColor || alpha(theme.elevated, 0.94);
tuned.borderWidth = tuned.borderWidth ?? 1;
return tuned;
}
function tuneSeries(series) {
if (!series) return series;
const list = Array.isArray(series) ? series : [series];
const compact = isCompact();
const tuned = list.map((item, index) => {
const type = item.type || 'line';
const dataLength = Array.isArray(item.data) ? item.data.length : 0;
const label = item.label || {};
const shouldShowLabel = label.show === true && !(compact && (dataLength > 8 || type === 'pie' || type === 'heatmap'));
return {
...item,
itemStyle: tuneItemStyle(item.itemStyle, index),
lineStyle: type === 'line' ? {
...item.lineStyle,
color: mapColor((item.lineStyle || {}).color, palette[index % palette.length]),
width: (item.lineStyle || {}).width || 2.4
} : item.lineStyle,
areaStyle: item.areaStyle ? {
...item.areaStyle,
opacity: compact ? 0.08 : 0.12,
color: alpha(mapColor((item.areaStyle || {}).color, palette[index % palette.length]), compact ? 0.10 : 0.16)
} : item.areaStyle,
label: {
...label,
color: theme.ink,
fontFamily: theme.mono,
fontSize: compact ? 10 : clamp(label.fontSize || 11, 10, 12),
fontWeight: 800,
hideOverlap: true,
overflow: 'truncate',
show: shouldShowLabel
},
labelLine: type === 'pie' ? { length: compact ? 8 : 14, length2: compact ? 6 : 10, ...(item.labelLine || {}) } : item.labelLine,
emphasis: {
focus: type === 'pie' ? 'self' : 'series',
...(item.emphasis || {})
},
barMaxWidth: type === 'bar' ? (compact ? 18 : (item.barMaxWidth || 28)) : item.barMaxWidth,
barCategoryGap: type === 'bar' ? (item.barCategoryGap || '42%') : item.barCategoryGap,
symbolSize: type === 'line' ? (item.symbolSize || (compact ? 4 : 5)) : item.symbolSize
};
});
return Array.isArray(series) ? tuned : tuned[0];
}
function tuneEchartsOption(option) {
const compact = isCompact();
const tuned = {
...option,
color: option.color ? mapPalette(option.color) : palette,
backgroundColor: 'transparent',
textStyle: { color: theme.ink, fontFamily: theme.font, ...(option.textStyle || {}) },
grid: {
containLabel: true,
borderColor: theme.faint,
left: compact ? 10 : 28,
right: compact ? 12 : 24,
top: compact ? 32 : 40,
bottom: compact ? 38 : 44,
...(option.grid || {})
},
tooltip: {
backgroundColor: 'rgba(253, 250, 243, 0.97)',
borderColor: alpha(theme.accent, 0.36),
borderWidth: 1,
textStyle: { color: theme.ink, fontFamily: theme.font, fontWeight: 650 },
extraCssText: 'box-shadow:0 10px 24px rgba(42,37,32,.12);border-radius:6px;',
...(option.tooltip || {})
},
legend: {
...(option.legend || {}),
type: compact ? 'scroll' : ((option.legend || {}).type || 'plain'),
textStyle: {
...((option.legend || {}).textStyle || {}),
color: theme.muted,
fontFamily: theme.mono,
fontWeight: 700,
fontSize: compact ? 10 : clamp(((option.legend || {}).textStyle || {}).fontSize || 11, 10, 12)
},
itemWidth: compact ? 10 : 12,
itemHeight: compact ? 7 : 8,
pageIconColor: theme.accent,
pageTextStyle: { ...((option.legend || {}).pageTextStyle || {}), color: theme.muted, fontFamily: theme.mono }
},
title: Array.isArray(option.title)
? option.title.map((title) => ({ ...title, textStyle: { ...((title || {}).textStyle || {}), color: theme.ink, fontFamily: theme.font, fontWeight: 800 } }))
: option.title ? { ...option.title, textStyle: { ...((option.title || {}).textStyle || {}), color: theme.ink, fontFamily: theme.font, fontWeight: 800 } } : option.title,
xAxis: mergeAxis(option.xAxis),
yAxis: mergeAxis(option.yAxis),
series: tuneSeries(option.series),
visualMap: option.visualMap ? {
...option.visualMap,
inRange: { ...((option.visualMap || {}).inRange || {}), color: ['#fbf1d3', '#eac26b', theme.honey, theme.accent, theme.mahogany] },
textStyle: { ...((option.visualMap || {}).textStyle || {}), color: theme.muted, fontFamily: theme.mono, fontWeight: 700 },
itemWidth: compact ? 12 : 16,
itemHeight: compact ? 84 : 120
} : option.visualMap
};
return tuned;
}
const dotMatrixPlugin = {
id: 'ewooocDotMatrix',
beforeDraw(chart) {
const area = chart.chartArea;
if (!area) return;
const ctx = chart.ctx;
ctx.save();
ctx.fillStyle = 'rgba(253, 250, 243, 0.72)';
ctx.fillRect(area.left, area.top, area.right - area.left, area.bottom - area.top);
ctx.fillStyle = 'rgba(42, 37, 32, 0.055)';
for (let x = Math.floor(area.left) + 8; x < area.right; x += 14) {
for (let y = Math.floor(area.top) + 8; y < area.bottom; y += 14) {
ctx.fillRect(x, y, 1, 1);
}
}
ctx.restore();
}
};
function installChartJsTheme() {
if (!window.Chart || window.Chart.__ewooocThemed) return;
const NativeChart = window.Chart;
if (NativeChart.register) NativeChart.register(dotMatrixPlugin);
NativeChart.defaults.font.family = theme.font;
NativeChart.defaults.font.size = 12;
NativeChart.defaults.color = theme.muted;
function ThemedChart(item, config) {
return new NativeChart(item, tuneChartConfig(config));
}
for (const key of Reflect.ownKeys(NativeChart)) {
if (['length', 'name', 'prototype'].includes(key)) continue;
try {
Object.defineProperty(ThemedChart, key, Object.getOwnPropertyDescriptor(NativeChart, key));
} catch (error) {}
}
ThemedChart.prototype = NativeChart.prototype;
Object.setPrototypeOf(ThemedChart, NativeChart);
ThemedChart.__ewooocThemed = true;
ThemedChart.__native = NativeChart;
window.Chart = ThemedChart;
}
function installEchartsTheme() {
if (!window.echarts || window.echarts.__ewooocThemed) return;
const echarts = window.echarts;
const nativeInit = echarts.init.bind(echarts);
echarts.init = function (dom, themeName, opts) {
const chart = nativeInit(dom, themeName || null, opts);
const nativeSetOption = chart.setOption.bind(chart);
chart.setOption = function (option, notMerge, lazyUpdate) {
return nativeSetOption(tuneEchartsOption(option || {}), notMerge, lazyUpdate);
};
if (window.ResizeObserver && dom) {
const observer = new ResizeObserver(() => window.requestAnimationFrame(() => chart.resize()));
observer.observe(dom);
}
if (window.IntersectionObserver && dom) {
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) window.requestAnimationFrame(() => chart.resize());
});
}, { threshold: 0.1 });
observer.observe(dom);
}
window.requestAnimationFrame(() => chart.resize());
return chart;
};
echarts.__ewooocThemed = true;
}
window.EwoooCChartTheme = { palette, theme, tuneChartConfig, installChartJsTheme, installEchartsTheme };
installChartJsTheme();
installEchartsTheme();
})();