/** * 趋势折线图(plan.md「5.2 图表与可视化」)。 * 采用 react-native-svg 绘制三次贝塞尔曲线及渐变填充区域。 */ import React, { useMemo } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import Svg, { Path, Circle, Defs, LinearGradient, Stop } from 'react-native-svg'; import { useTheme } from '../../theme'; import { useT } from '../../i18n'; import type { Transaction } from '../../domain/types'; import { groupByMonth } from '../../domain/chartStats'; export function TrendLine({ transactions }: { transactions: Transaction[] }) { const { theme } = useTheme(); const t = useT(); const data = useMemo(() => groupByMonth(transactions).slice(-6), [transactions]); // 1. 数据配置 const width = 300; const height = 120; const paddingX = 20; const paddingY = 15; const maxVal = Math.max(...data.flatMap(d => [d.income, d.expense]), 1); // 2. 坐标转换计算 const pointsIncome = data.map((d, i) => { const x = data.length > 1 ? paddingX + (i * (width - 2 * paddingX)) / (data.length - 1) : width / 2; const y = height - paddingY - (d.income / maxVal) * (height - 2 * paddingY); return { x, y }; }); const pointsExpense = data.map((d, i) => { const x = data.length > 1 ? paddingX + (i * (width - 2 * paddingX)) / (data.length - 1) : width / 2; const y = height - paddingY - (d.expense / maxVal) * (height - 2 * paddingY); return { x, y }; }); // 3. 贝塞尔曲线生成算法 const getBezierPath = (points: { x: number; y: number }[]) => { if (points.length === 0) return ''; if (points.length === 1) return `M ${points[0].x} ${points[0].y}`; let path = `M ${points[0].x} ${points[0].y}`; for (let i = 0; i < points.length - 1; i++) { const p0 = points[i]; const p1 = points[i + 1]; const cp1x = p0.x + (p1.x - p0.x) / 2; const cp1y = p0.y; const cp2x = p0.x + (p1.x - p0.x) / 2; const cp2y = p1.y; path += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p1.x} ${p1.y}`; } return path; }; const getClosedBezierPath = (points: { x: number; y: number }[]) => { if (points.length === 0) return ''; const linePath = getBezierPath(points); const first = points[0]; const last = points[points.length - 1]; return `${linePath} L ${last.x} ${height - paddingY} L ${first.x} ${height - paddingY} Z`; }; const incomePath = getBezierPath(pointsIncome); const incomeClosedPath = getClosedBezierPath(pointsIncome); const expensePath = getBezierPath(pointsExpense); const expenseClosedPath = getClosedBezierPath(pointsExpense); return ( {t('report.trendTitle')} {data.length > 0 ? ( {/* 网格线(只绘制一条底线和中线) */} {/* 收入填充与折线 */} {incomeClosedPath ? : null} {incomePath ? ( ) : null} {/* 支出填充与折线 */} {expenseClosedPath ? : null} {expensePath ? ( ) : null} {/* 数据点标记 */} {pointsIncome.map((p, i) => ( ))} {pointsExpense.map((p, i) => ( ))} {/* 月度文本 X 轴 */} {data.map((d, i) => ( {d.month.slice(5)} ))} ) : ( {t('common.noData')} )} {t('home.income')} {t('home.expense')} ); } const styles = StyleSheet.create({ container: { gap: 10 }, chartWrapper: { marginTop: 6 }, xAxis: { flexDirection: 'row', justifyContent: 'space-between', paddingHorizontal: 12, marginTop: 4 }, legend: { flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 4 }, legendDot: { width: 8, height: 8, borderRadius: 4, marginLeft: 8 }, });