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; }