244 lines
7.1 KiB
TypeScript
244 lines
7.1 KiB
TypeScript
import { type ChartInstance } from '@/domain/entities/ChartInstance';
|
|
import { type StyleConfig } from '@/domain/entities/StyleConfig';
|
|
import { type FieldBinding } from '@/domain/entities/FieldBinding';
|
|
|
|
function getBinding(bindings: FieldBinding[], axis: string): FieldBinding | undefined {
|
|
return bindings.find((b) => b.axis === axis);
|
|
}
|
|
|
|
function applyCommonStyle(option: Record<string, any>, style: StyleConfig): void {
|
|
if (style.title.visible) {
|
|
option.title = {
|
|
text: style.title.text,
|
|
left: style.title.align,
|
|
textStyle: {
|
|
fontSize: style.title.fontSize,
|
|
fontWeight: style.title.fontWeight,
|
|
color: style.title.color,
|
|
},
|
|
};
|
|
}
|
|
|
|
if (style.legend.visible) {
|
|
// When title is visible and legend is at top, push legend below the title
|
|
const hasTitle = style.title.visible && style.title.text;
|
|
let legendTop: string | number = 'top';
|
|
if (style.legend.position === 'top') {
|
|
legendTop = hasTitle ? 35 : 'top';
|
|
} else if (style.legend.position === 'bottom') {
|
|
legendTop = 'bottom';
|
|
} else {
|
|
legendTop = 'middle';
|
|
}
|
|
|
|
option.legend = {
|
|
show: true,
|
|
orient: style.legend.orient,
|
|
top: legendTop,
|
|
left: style.legend.position === 'left' ? 'left' : style.legend.position === 'right' ? 'right' : 'center',
|
|
textStyle: {
|
|
fontSize: style.legend.fontSize,
|
|
color: style.legend.color,
|
|
},
|
|
};
|
|
} else {
|
|
option.legend = { show: false };
|
|
}
|
|
|
|
option.color = style.colors;
|
|
option.backgroundColor = style.background.color !== '' ? style.background.color : 'transparent';
|
|
|
|
if (style.animation.enabled) {
|
|
option.animation = true;
|
|
option.animationDuration = style.animation.duration;
|
|
option.animationEasing = style.animation.easing === 'ease-in'
|
|
? 'cubicIn'
|
|
: style.animation.easing === 'ease-out'
|
|
? 'cubicOut'
|
|
: style.animation.easing === 'ease-in-out'
|
|
? 'cubicInOut'
|
|
: 'linear';
|
|
} else {
|
|
option.animation = false;
|
|
}
|
|
|
|
// Dynamic grid top: title(30) + legend(25) + padding
|
|
const hasTitle = style.title.visible && style.title.text;
|
|
const hasLegend = style.legend.visible && style.legend.position === 'top';
|
|
let gridTop = 20;
|
|
if (hasTitle) gridTop += 30;
|
|
if (hasLegend) gridTop += 25;
|
|
|
|
option.grid = {
|
|
containLabel: true,
|
|
top: gridTop,
|
|
right: 30,
|
|
bottom: 30,
|
|
left: 30,
|
|
};
|
|
}
|
|
|
|
function buildAxisConfig(style: StyleConfig, isHorizontal: boolean) {
|
|
const xAxisStyle = isHorizontal ? style.yAxis : style.xAxis;
|
|
const yAxisStyle = isHorizontal ? style.xAxis : style.yAxis;
|
|
|
|
const xAxis: Record<string, any> = {
|
|
show: xAxisStyle.visible,
|
|
axisLabel: {
|
|
show: xAxisStyle.labelVisible,
|
|
fontSize: xAxisStyle.labelFontSize,
|
|
color: xAxisStyle.labelColor,
|
|
rotate: xAxisStyle.labelRotation,
|
|
},
|
|
splitLine: {
|
|
show: xAxisStyle.gridVisible,
|
|
lineStyle: { color: xAxisStyle.gridColor },
|
|
},
|
|
};
|
|
|
|
if (xAxisStyle.titleVisible && xAxisStyle.titleText) {
|
|
xAxis.name = xAxisStyle.titleText;
|
|
}
|
|
|
|
const yAxis: Record<string, any> = {
|
|
show: yAxisStyle.visible,
|
|
axisLabel: {
|
|
show: yAxisStyle.labelVisible,
|
|
fontSize: yAxisStyle.labelFontSize,
|
|
color: yAxisStyle.labelColor,
|
|
rotate: yAxisStyle.labelRotation,
|
|
},
|
|
splitLine: {
|
|
show: yAxisStyle.gridVisible,
|
|
lineStyle: { color: yAxisStyle.gridColor },
|
|
},
|
|
};
|
|
|
|
if (yAxisStyle.titleVisible && yAxisStyle.titleText) {
|
|
yAxis.name = yAxisStyle.titleText;
|
|
}
|
|
|
|
return { xAxis, yAxis };
|
|
}
|
|
|
|
function buildLabelConfig(style: StyleConfig) {
|
|
if (!style.dataLabel.visible) return { show: false };
|
|
let formatter: string | undefined;
|
|
switch (style.dataLabel.format) {
|
|
case 'percent':
|
|
formatter = '{b}: {d}%';
|
|
break;
|
|
case 'custom':
|
|
formatter = style.dataLabel.template;
|
|
break;
|
|
case 'value':
|
|
default:
|
|
formatter = '{c}';
|
|
break;
|
|
}
|
|
return {
|
|
show: true,
|
|
fontSize: style.dataLabel.fontSize,
|
|
color: style.dataLabel.color,
|
|
position: style.dataLabel.position,
|
|
formatter,
|
|
};
|
|
}
|
|
|
|
export function buildBarOption(
|
|
chart: ChartInstance,
|
|
data: Record<string, any>[],
|
|
): Record<string, any> {
|
|
const { bindings, style, type } = chart;
|
|
const xBinding = getBinding(bindings, 'x');
|
|
const yBinding = getBinding(bindings, 'y');
|
|
const seriesBinding = getBinding(bindings, 'series');
|
|
const isHorizontal = type === 'horizontal-bar';
|
|
const isStacked = type === 'stacked-bar';
|
|
const isGrouped = type === 'grouped-bar';
|
|
|
|
const xField = xBinding?.columnName ?? '';
|
|
const yField = yBinding?.columnName ?? '';
|
|
|
|
const labelBinding = getBinding(bindings, 'label');
|
|
|
|
const option: Record<string, any> = {};
|
|
applyCommonStyle(option, style);
|
|
|
|
const categories = [...new Set(data.map((row) => String(row[xField])))];
|
|
const { xAxis, yAxis } = buildAxisConfig(style, isHorizontal);
|
|
|
|
// If a label binding exists, force show data labels
|
|
const label = labelBinding
|
|
? { show: true, position: 'top' as const, fontSize: 12, formatter: '{c}' }
|
|
: buildLabelConfig(style);
|
|
|
|
const colorBinding = getBinding(bindings, 'color');
|
|
const splitField = seriesBinding?.columnName ?? colorBinding?.columnName;
|
|
const hasSplit = !!splitField;
|
|
|
|
if (hasSplit) {
|
|
// Multi-series: split by series or color field
|
|
const splitNames = [...new Set(data.map((row) => String(row[splitField])))];
|
|
|
|
const seriesList = splitNames.map((name, idx) => {
|
|
const seriesData = categories.map((cat) => {
|
|
// Sum all matching rows (there may be multiple)
|
|
const matchingRows = data.filter(
|
|
(r) => String(r[xField]) === cat && String(r[splitField]) === name,
|
|
);
|
|
return matchingRows.reduce((sum, r) => sum + (Number(r[yField]) || 0), 0);
|
|
});
|
|
|
|
return {
|
|
name,
|
|
type: 'bar',
|
|
stack: isStacked ? 'total' : undefined,
|
|
data: seriesData,
|
|
itemStyle: {
|
|
color: style.colors[idx % style.colors.length],
|
|
},
|
|
label,
|
|
};
|
|
});
|
|
|
|
if (isHorizontal) {
|
|
option.yAxis = { ...xAxis, type: 'category', data: categories };
|
|
option.xAxis = { ...yAxis, type: 'value' };
|
|
} else {
|
|
option.xAxis = { ...xAxis, type: 'category', data: categories };
|
|
option.yAxis = { ...yAxis, type: 'value' };
|
|
}
|
|
option.series = seriesList;
|
|
option.tooltip = { trigger: 'axis' };
|
|
} else {
|
|
// Single series: aggregate by x category
|
|
const seriesData = categories.map((cat) => {
|
|
const matchingRows = data.filter((r) => String(r[xField]) === cat);
|
|
return matchingRows.reduce((sum, r) => sum + (Number(r[yField]) || 0), 0);
|
|
});
|
|
|
|
if (isHorizontal) {
|
|
option.yAxis = { ...xAxis, type: 'category', data: categories };
|
|
option.xAxis = { ...yAxis, type: 'value' };
|
|
} else {
|
|
option.xAxis = { ...xAxis, type: 'category', data: categories };
|
|
option.yAxis = { ...yAxis, type: 'value' };
|
|
}
|
|
|
|
option.series = [
|
|
{
|
|
type: 'bar',
|
|
data: seriesData.map((val, idx) => ({
|
|
value: val,
|
|
itemStyle: { color: style.colors[idx % style.colors.length] },
|
|
})),
|
|
label,
|
|
},
|
|
];
|
|
option.tooltip = { trigger: 'axis' };
|
|
}
|
|
|
|
return option;
|
|
}
|