125 lines
4.3 KiB
TypeScript
125 lines
4.3 KiB
TypeScript
import type { UnifiedChartSpec } from '@ftb/shared';
|
|
import type { EChartsOption } from 'echarts';
|
|
|
|
const ACCENT = '#0f172a';
|
|
const MUTED = '#94a3b8';
|
|
const RISK = '#f97316';
|
|
|
|
export function toEChartsOption(spec: UnifiedChartSpec): EChartsOption {
|
|
if (spec.kind === 'line_area') return lineAreaOption(spec);
|
|
if (spec.kind === 'horizontal_bar' || spec.kind === 'stacked_horizontal_bar') return horizontalBarOption(spec);
|
|
if (spec.kind === 'donut') return donutOption(spec);
|
|
return numberCardFallbackOption(spec);
|
|
}
|
|
|
|
function lineAreaOption(spec: UnifiedChartSpec): EChartsOption {
|
|
const xField = spec.dataset.x ?? spec.encoding.x?.field ?? 'label';
|
|
const yField = spec.dataset.y ?? spec.encoding.y?.field ?? spec.encoding.value?.field ?? 'value';
|
|
return {
|
|
color: [ACCENT],
|
|
grid: { left: 8, right: 8, top: 18, bottom: 24, containLabel: true },
|
|
tooltip: { trigger: 'axis', borderWidth: 0, backgroundColor: 'rgba(255,255,255,0.92)', textStyle: { color: '#111827' } },
|
|
xAxis: {
|
|
type: 'category',
|
|
show: true,
|
|
boundaryGap: false,
|
|
axisTick: { show: false },
|
|
axisLine: { show: false },
|
|
axisLabel: { color: MUTED, fontSize: 11 },
|
|
data: spec.dataset.source.map((row) => formatCategory(row[xField])),
|
|
},
|
|
yAxis: {
|
|
type: 'value',
|
|
show: true,
|
|
axisTick: { show: false },
|
|
axisLine: { show: false },
|
|
axisLabel: { show: false },
|
|
splitLine: { show: false },
|
|
},
|
|
series: [{
|
|
type: 'line',
|
|
smooth: true,
|
|
symbol: 'circle',
|
|
symbolSize: 7,
|
|
data: spec.dataset.source.map((row) => formatNumeric(row[yField])),
|
|
lineStyle: { width: 3 },
|
|
areaStyle: { opacity: 0.14 },
|
|
markPoint: buildMarkPoints(spec),
|
|
}],
|
|
};
|
|
}
|
|
|
|
function horizontalBarOption(spec: UnifiedChartSpec): EChartsOption {
|
|
const labelField = spec.dataset.label ?? 'label';
|
|
const valueField = spec.dataset.value ?? spec.encoding.value?.field ?? 'value';
|
|
const rows = spec.dataset.source.slice().reverse();
|
|
return {
|
|
color: [spec.encoding.color?.mode === 'risk' ? RISK : ACCENT],
|
|
grid: { left: 8, right: 32, top: 12, bottom: 12, containLabel: true },
|
|
tooltip: { trigger: 'item', borderWidth: 0, backgroundColor: 'rgba(255,255,255,0.92)' },
|
|
xAxis: { type: 'value', show: false },
|
|
yAxis: {
|
|
type: 'category',
|
|
axisTick: { show: false },
|
|
axisLine: { show: false },
|
|
axisLabel: { color: '#334155', fontSize: 12 },
|
|
data: rows.map((row) => formatCategory(row[labelField])),
|
|
},
|
|
series: [{
|
|
type: 'bar',
|
|
data: rows.map((row) => formatNumeric(row[valueField])),
|
|
barWidth: 12,
|
|
itemStyle: { borderRadius: [0, 8, 8, 0] },
|
|
label: { show: true, position: 'right', color: '#64748b', fontSize: 11 },
|
|
animationDuration: 520,
|
|
}],
|
|
};
|
|
}
|
|
|
|
function donutOption(spec: UnifiedChartSpec): EChartsOption {
|
|
const labelField = spec.dataset.label ?? 'label';
|
|
const valueField = spec.dataset.value ?? spec.encoding.value?.field ?? 'value';
|
|
return {
|
|
color: ['#0f172a', '#64748b', '#94a3b8', '#cbd5e1', '#e2e8f0', '#f97316', '#fb923c', '#fed7aa'],
|
|
tooltip: { trigger: 'item', borderWidth: 0, backgroundColor: 'rgba(255,255,255,0.92)' },
|
|
series: [{
|
|
type: 'pie',
|
|
radius: ['62%', '82%'],
|
|
avoidLabelOverlap: true,
|
|
label: { color: '#334155', fontSize: 11 },
|
|
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
|
|
data: spec.dataset.source.map((row) => ({ name: formatCategory(row[labelField]), value: formatNumeric(row[valueField]) })),
|
|
}],
|
|
};
|
|
}
|
|
|
|
function numberCardFallbackOption(spec: UnifiedChartSpec): EChartsOption {
|
|
return horizontalBarOption({ ...spec, kind: 'horizontal_bar' });
|
|
}
|
|
|
|
function buildMarkPoints(spec: UnifiedChartSpec) {
|
|
if (!spec.annotations?.length) return undefined;
|
|
return {
|
|
symbolSize: 42,
|
|
label: { fontSize: 10 },
|
|
data: spec.annotations.map((item) => ({
|
|
type: item.type === 'peak' ? 'max' as const : undefined,
|
|
name: item.label,
|
|
value: item.value,
|
|
})),
|
|
};
|
|
}
|
|
|
|
function formatCategory(value: string | number | null): string {
|
|
return value == null ? '' : String(value);
|
|
}
|
|
|
|
function formatNumeric(value: string | number | null): number {
|
|
if (typeof value === 'number') return value;
|
|
if (typeof value === 'string') {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : 0;
|
|
}
|
|
return 0;
|
|
}
|