feat(ai-analysis): 添加图表渲染和分析API客户端
This commit is contained in:
124
apps/web/lib/analysis-chart-renderer.ts
Normal file
124
apps/web/lib/analysis-chart-renderer.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user