315 lines
8.4 KiB
TypeScript
315 lines
8.4 KiB
TypeScript
import type { EChartsOption } from 'echarts';
|
||
import type { AdminLocale } from '../i18n/admin-messages';
|
||
import { formatAmount, formatAmountFull } from './format-amount';
|
||
|
||
export type ChartI18n = {
|
||
locale?: AdminLocale;
|
||
betCountSeries: string;
|
||
axisAmount: string;
|
||
axisCount: string;
|
||
countSuffix: string;
|
||
pieTooltip: string;
|
||
noData: string;
|
||
pieEmpty: string;
|
||
};
|
||
|
||
const tooltipBase = {
|
||
backgroundColor: '#ffffff',
|
||
borderColor: '#e7e2d8',
|
||
textStyle: { color: '#1f2320', fontSize: 12 },
|
||
};
|
||
|
||
const axisLabel = { color: '#78746b', fontSize: 11 };
|
||
const splitLine = { lineStyle: { color: '#eee9df' } };
|
||
|
||
export type ChartSeries = { name: string; color: string; values: number[] };
|
||
export type PieSegment = { label: string; value: number; color: string };
|
||
|
||
export function buildBarChartOption(
|
||
labels: string[],
|
||
series: ChartSeries[],
|
||
opts?: { amountAxis?: boolean },
|
||
): EChartsOption {
|
||
const amountAxis = opts?.amountAxis !== false;
|
||
return {
|
||
backgroundColor: 'transparent',
|
||
color: series.map((s) => s.color),
|
||
tooltip: {
|
||
...tooltipBase,
|
||
trigger: 'axis',
|
||
axisPointer: { type: 'shadow' },
|
||
valueFormatter: (v) =>
|
||
amountAxis ? formatAmountFull(Number(v)) : fmtCount(Number(v)),
|
||
},
|
||
legend: {
|
||
bottom: 0,
|
||
itemWidth: 10,
|
||
itemHeight: 10,
|
||
textStyle: { color: '#78746b', fontSize: 11 },
|
||
},
|
||
grid: { left: 52, right: 12, top: 20, bottom: 48 },
|
||
xAxis: {
|
||
type: 'category',
|
||
data: labels,
|
||
axisLabel,
|
||
axisLine: { lineStyle: { color: '#e7e2d8' } },
|
||
axisTick: { show: false },
|
||
},
|
||
yAxis: {
|
||
type: 'value',
|
||
axisLabel: {
|
||
...axisLabel,
|
||
formatter: (v: number) => (amountAxis ? formatAmount(v) : fmtCount(v)),
|
||
},
|
||
splitLine,
|
||
},
|
||
series: series.map((s) => ({
|
||
name: s.name,
|
||
type: 'bar',
|
||
data: s.values,
|
||
itemStyle: { color: s.color, borderRadius: [4, 4, 0, 0] },
|
||
barMaxWidth: 22,
|
||
emphasis: { focus: 'series' },
|
||
})),
|
||
};
|
||
}
|
||
|
||
export function buildMultiLineChartOption(
|
||
labels: string[],
|
||
series: ChartSeries[],
|
||
): EChartsOption {
|
||
return {
|
||
backgroundColor: 'transparent',
|
||
color: series.map((s) => s.color),
|
||
tooltip: {
|
||
...tooltipBase,
|
||
trigger: 'axis',
|
||
valueFormatter: (v) => formatAmountFull(Number(v)),
|
||
},
|
||
legend: {
|
||
bottom: 0,
|
||
itemWidth: 10,
|
||
itemHeight: 10,
|
||
textStyle: { color: '#78746b', fontSize: 11 },
|
||
},
|
||
grid: { left: 52, right: 12, top: 20, bottom: 48 },
|
||
xAxis: {
|
||
type: 'category',
|
||
data: labels,
|
||
boundaryGap: false,
|
||
axisLabel,
|
||
axisLine: { lineStyle: { color: '#e7e2d8' } },
|
||
},
|
||
yAxis: {
|
||
type: 'value',
|
||
axisLabel: { ...axisLabel, formatter: (v: number) => formatAmount(v) },
|
||
splitLine,
|
||
},
|
||
series: series.map((s) => ({
|
||
name: s.name,
|
||
type: 'line',
|
||
smooth: true,
|
||
symbol: 'circle',
|
||
symbolSize: 6,
|
||
data: s.values,
|
||
itemStyle: { color: s.color },
|
||
lineStyle: { color: s.color, width: 2 },
|
||
areaStyle: { color: s.color, opacity: 0.12 },
|
||
})),
|
||
};
|
||
}
|
||
|
||
export function buildPieChartOption(
|
||
title: string,
|
||
segments: PieSegment[],
|
||
i18n?: Partial<ChartI18n>,
|
||
): EChartsOption {
|
||
const loc = i18n?.locale;
|
||
const noData = i18n?.noData ?? '暂无数据';
|
||
const data = segments.map((s) => ({
|
||
name: s.label,
|
||
value: s.value,
|
||
itemStyle: { color: s.color },
|
||
}));
|
||
|
||
return {
|
||
backgroundColor: 'transparent',
|
||
tooltip: {
|
||
...tooltipBase,
|
||
trigger: 'item',
|
||
formatter: i18n?.pieTooltip ?? '{b}:{c}({d}%)',
|
||
},
|
||
legend: {
|
||
orient: 'vertical',
|
||
right: 4,
|
||
top: 'middle',
|
||
itemWidth: 8,
|
||
itemHeight: 8,
|
||
textStyle: { color: '#78746b', fontSize: 11 },
|
||
},
|
||
series: [
|
||
{
|
||
name: title,
|
||
type: 'pie',
|
||
radius: ['42%', '68%'],
|
||
center: ['36%', '50%'],
|
||
avoidLabelOverlap: true,
|
||
itemStyle: { borderRadius: 4, borderColor: '#ffffff', borderWidth: 2 },
|
||
label: {
|
||
show: segments.length > 0,
|
||
color: '#5f5b53',
|
||
fontSize: 11,
|
||
formatter: '{b}\n{d}%',
|
||
},
|
||
labelLine: { lineStyle: { color: '#d5cfc3' } },
|
||
emphasis: {
|
||
label: { fontSize: 12, fontWeight: 'bold' },
|
||
scaleSize: 6,
|
||
},
|
||
data: data.length ? data : [{ name: noData, value: 1, itemStyle: { color: '#e6e1d8' } }],
|
||
},
|
||
],
|
||
};
|
||
}
|
||
|
||
function fmtCount(v: number, locale?: AdminLocale) {
|
||
const tag = locale ?? 'zh-CN';
|
||
return v.toLocaleString(tag, { maximumFractionDigits: 0 });
|
||
}
|
||
|
||
/** 7 日金额折线 + 注单柱(双 Y 轴),一图看清趋势 */
|
||
export function buildCombinedTrendOption(
|
||
labels: string[],
|
||
amountSeries: ChartSeries[],
|
||
betCounts: number[],
|
||
i18n: ChartI18n,
|
||
): EChartsOption {
|
||
const loc = i18n.locale;
|
||
const betCountName = i18n.betCountSeries;
|
||
const countSuffix = i18n.countSuffix;
|
||
return {
|
||
backgroundColor: 'transparent',
|
||
color: [...amountSeries.map((s) => s.color), '#956400'],
|
||
tooltip: {
|
||
...tooltipBase,
|
||
trigger: 'axis',
|
||
formatter(params) {
|
||
const items = Array.isArray(params) ? params : [params];
|
||
return items
|
||
.map((p) => {
|
||
const v = Number(p.value ?? 0);
|
||
const isCount = p.seriesName === betCountName;
|
||
const val = isCount ? `${fmtCount(v, loc)} ${countSuffix}` : formatAmountFull(v, loc);
|
||
const sep = loc?.startsWith('zh') ? ':' : ': ';
|
||
return `${p.marker ?? ''}${p.seriesName}${sep}${val}`;
|
||
})
|
||
.join('<br/>');
|
||
},
|
||
},
|
||
legend: {
|
||
top: 0,
|
||
itemWidth: 10,
|
||
itemHeight: 10,
|
||
textStyle: { color: '#78746b', fontSize: 11 },
|
||
},
|
||
grid: { left: 56, right: 48, top: 36, bottom: 28 },
|
||
xAxis: {
|
||
type: 'category',
|
||
data: labels,
|
||
boundaryGap: true,
|
||
axisLabel,
|
||
axisLine: { lineStyle: { color: '#e7e2d8' } },
|
||
},
|
||
yAxis: [
|
||
{
|
||
type: 'value',
|
||
name: i18n.axisAmount,
|
||
nameTextStyle: { color: '#8c867c', fontSize: 10 },
|
||
axisLabel: { ...axisLabel, formatter: (v: number) => formatAmount(v, 2, loc) },
|
||
splitLine,
|
||
},
|
||
{
|
||
type: 'value',
|
||
name: i18n.axisCount,
|
||
nameTextStyle: { color: '#8c867c', fontSize: 10 },
|
||
axisLabel: { ...axisLabel, formatter: (v: number) => fmtCount(v, loc) },
|
||
splitLine: { show: false },
|
||
},
|
||
],
|
||
series: [
|
||
...amountSeries.map((s) => ({
|
||
name: s.name,
|
||
type: 'line' as const,
|
||
yAxisIndex: 0,
|
||
smooth: true,
|
||
symbol: 'circle',
|
||
symbolSize: 5,
|
||
data: s.values,
|
||
itemStyle: { color: s.color },
|
||
lineStyle: { color: s.color, width: 2 },
|
||
})),
|
||
{
|
||
name: betCountName,
|
||
type: 'bar',
|
||
yAxisIndex: 1,
|
||
data: betCounts,
|
||
barMaxWidth: 14,
|
||
itemStyle: { color: 'rgba(149, 100, 0, 0.34)', borderRadius: [3, 3, 0, 0] },
|
||
},
|
||
],
|
||
};
|
||
}
|
||
|
||
/** 三个饼图并排,占一张图 */
|
||
export function buildTriplePieOption(
|
||
blocks: { title: string; segments: PieSegment[] }[],
|
||
i18n?: Partial<ChartI18n>,
|
||
): EChartsOption {
|
||
const pieEmpty = i18n?.pieEmpty ?? '暂无';
|
||
const pieTooltip = i18n?.pieTooltip ?? '{b}:{c}({d}%)';
|
||
const slots = [
|
||
{ center: ['18%', '58%'] as [string, string], titleLeft: '14%' },
|
||
{ center: ['50%', '58%'] as [string, string], titleLeft: '46%' },
|
||
{ center: ['82%', '58%'] as [string, string], titleLeft: '78%' },
|
||
];
|
||
|
||
return {
|
||
backgroundColor: 'transparent',
|
||
tooltip: {
|
||
...tooltipBase,
|
||
trigger: 'item',
|
||
formatter: pieTooltip,
|
||
},
|
||
graphic: blocks.map((b, i) => ({
|
||
type: 'text' as const,
|
||
left: slots[i]?.titleLeft ?? '50%',
|
||
top: '6%',
|
||
style: {
|
||
text: b.title,
|
||
fill: '#5f5b53',
|
||
fontSize: 12,
|
||
fontWeight: 600,
|
||
textAlign: 'center',
|
||
},
|
||
})),
|
||
series: blocks.map((b, i) => {
|
||
const data = b.segments.map((s) => ({
|
||
name: s.label,
|
||
value: s.value,
|
||
itemStyle: { color: s.color },
|
||
}));
|
||
return {
|
||
name: b.title,
|
||
type: 'pie' as const,
|
||
radius: ['32%', '48%'],
|
||
center: slots[i]?.center ?? ['50%', '55%'],
|
||
label: { show: false },
|
||
labelLine: { show: false },
|
||
data: data.length
|
||
? data
|
||
: [{ name: pieEmpty, value: 1, itemStyle: { color: '#e6e1d8' } }],
|
||
};
|
||
}),
|
||
};
|
||
}
|