feat(analysis): 趋势图增加本周选项、线性图简化为总金额曲线
- 添加本周时间范围选项 - 线性图模式只显示总支出曲线,不再显示分类曲线 - 图例根据图表类型动态切换(堆叠图显示分类,线性图显示总支出) - 时间范围选项:7天、本周、30天、本月、3个月、本年
This commit is contained in:
@@ -6,6 +6,8 @@
|
||||
import TrendingUp from '@lucide/svelte/icons/trending-up';
|
||||
import TrendingDown from '@lucide/svelte/icons/trending-down';
|
||||
import Calendar from '@lucide/svelte/icons/calendar';
|
||||
import AreaChart from '@lucide/svelte/icons/area-chart';
|
||||
import LineChart from '@lucide/svelte/icons/line-chart';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import type { BillRecord } from '$lib/api';
|
||||
import { pieColors } from '$lib/constants/chart';
|
||||
@@ -24,8 +26,12 @@
|
||||
let selectedDateRecords = $state<BillRecord[]>([]);
|
||||
|
||||
// 时间范围选项
|
||||
type TimeRange = '7d' | '30d' | '3m';
|
||||
let timeRange = $state<TimeRange>('3m');
|
||||
type TimeRange = '7d' | 'week' | '30d' | 'month' | '3m' | 'year';
|
||||
let timeRange = $state<TimeRange>('month');
|
||||
|
||||
// 图表类型
|
||||
type ChartType = 'area' | 'line';
|
||||
let chartType = $state<ChartType>('area');
|
||||
|
||||
// 隐藏的类别
|
||||
let hiddenCategories = $state<Set<string>>(new Set());
|
||||
@@ -42,8 +48,11 @@
|
||||
|
||||
const timeRangeOptions = [
|
||||
{ value: '7d', label: '最近 7 天' },
|
||||
{ value: 'week', label: '本周' },
|
||||
{ value: '30d', label: '最近 30 天' },
|
||||
{ value: '3m', label: '最近 3 个月' }
|
||||
{ value: 'month', label: '本月' },
|
||||
{ value: '3m', label: '最近 3 个月' },
|
||||
{ value: 'year', label: '本年' }
|
||||
];
|
||||
|
||||
// 获取截止日期
|
||||
@@ -52,15 +61,58 @@
|
||||
switch (range) {
|
||||
case '7d':
|
||||
return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
case 'week':
|
||||
// 本周一
|
||||
const day = now.getDay();
|
||||
const diff = now.getDate() - day + (day === 0 ? -6 : 1);
|
||||
return new Date(now.getFullYear(), now.getMonth(), diff);
|
||||
case '30d':
|
||||
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
case 'month':
|
||||
// 本月第一天
|
||||
return new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
case '3m':
|
||||
default:
|
||||
return new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
|
||||
case 'year':
|
||||
// 本年第一天
|
||||
return new Date(now.getFullYear(), 0, 1);
|
||||
default:
|
||||
return new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
// 按日期和分类分组数据
|
||||
// 聚合粒度类型
|
||||
type AggregationType = 'day' | 'week' | 'month';
|
||||
|
||||
// 获取周的起始日期(周一)
|
||||
function getWeekStart(date: Date): string {
|
||||
const d = new Date(date);
|
||||
const day = d.getDay();
|
||||
const diff = d.getDate() - day + (day === 0 ? -6 : 1); // 调整到周一
|
||||
d.setDate(diff);
|
||||
return d.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
// 获取月份标识
|
||||
function getMonthKey(date: Date): string {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// 格式化聚合后的标签
|
||||
function formatAggregationLabel(key: string, type: AggregationType): string {
|
||||
if (type === 'day') {
|
||||
const d = new Date(key);
|
||||
return `${d.getMonth() + 1}/${d.getDate()}`;
|
||||
} else if (type === 'week') {
|
||||
const d = new Date(key);
|
||||
return `${d.getMonth() + 1}/${d.getDate()}周`;
|
||||
} else {
|
||||
const [year, month] = key.split('-');
|
||||
return `${month}月`;
|
||||
}
|
||||
}
|
||||
|
||||
// 按日期和分类分组数据(支持智能聚合)
|
||||
let processedData = $derived(() => {
|
||||
const cutoffDate = getCutoffDate(timeRange);
|
||||
|
||||
@@ -71,11 +123,10 @@
|
||||
return recordDate >= cutoffDate;
|
||||
});
|
||||
|
||||
if (expenseRecords.length === 0) return { data: [], categories: [], maxValue: 0 };
|
||||
if (expenseRecords.length === 0) return { data: [], categories: [], maxValue: 0, aggregationType: 'day' as AggregationType };
|
||||
|
||||
// 按日期分组
|
||||
const dateMap = new Map<string, Map<string, number>>();
|
||||
const categorySet = new Set<string>();
|
||||
// 先按天分组,计算天数
|
||||
const dailyMap = new Map<string, Map<string, number>>();
|
||||
const categoryTotals: Record<string, number> = {};
|
||||
|
||||
expenseRecords.forEach(record => {
|
||||
@@ -83,16 +134,49 @@
|
||||
const category = record.category || '其他';
|
||||
const amount = parseFloat(record.amount) || 0;
|
||||
|
||||
categorySet.add(category);
|
||||
categoryTotals[category] = (categoryTotals[category] || 0) + amount;
|
||||
|
||||
if (!dateMap.has(dateStr)) {
|
||||
dateMap.set(dateStr, new Map());
|
||||
if (!dailyMap.has(dateStr)) {
|
||||
dailyMap.set(dateStr, new Map());
|
||||
}
|
||||
const dayData = dateMap.get(dateStr)!;
|
||||
const dayData = dailyMap.get(dateStr)!;
|
||||
dayData.set(category, (dayData.get(category) || 0) + amount);
|
||||
});
|
||||
|
||||
const dayCount = dailyMap.size;
|
||||
|
||||
// 根据天数决定聚合粒度
|
||||
let aggregationType: AggregationType = 'day';
|
||||
if (dayCount > 90) {
|
||||
aggregationType = 'month';
|
||||
} else if (dayCount > 30) {
|
||||
aggregationType = 'week';
|
||||
}
|
||||
|
||||
// 按聚合粒度重新分组
|
||||
const aggregatedMap = new Map<string, Map<string, number>>();
|
||||
|
||||
dailyMap.forEach((dayData, dateStr) => {
|
||||
const date = new Date(dateStr);
|
||||
let key: string;
|
||||
|
||||
if (aggregationType === 'day') {
|
||||
key = dateStr;
|
||||
} else if (aggregationType === 'week') {
|
||||
key = getWeekStart(date);
|
||||
} else {
|
||||
key = getMonthKey(date);
|
||||
}
|
||||
|
||||
if (!aggregatedMap.has(key)) {
|
||||
aggregatedMap.set(key, new Map());
|
||||
}
|
||||
const aggData = aggregatedMap.get(key)!;
|
||||
dayData.forEach((amount, cat) => {
|
||||
aggData.set(cat, (aggData.get(cat) || 0) + amount);
|
||||
});
|
||||
});
|
||||
|
||||
// 获取前5大分类
|
||||
const sortedCategories = Object.entries(categoryTotals)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
@@ -100,16 +184,26 @@
|
||||
.map(([cat]) => cat);
|
||||
|
||||
// 转换为数组格式并计算堆叠值
|
||||
const data = Array.from(dateMap.entries())
|
||||
.map(([dateStr, dayData]) => {
|
||||
const data = Array.from(aggregatedMap.entries())
|
||||
.map(([key, aggData]) => {
|
||||
// 为不同聚合类型创建正确的日期对象
|
||||
let date: Date;
|
||||
if (aggregationType === 'month') {
|
||||
const [year, month] = key.split('-');
|
||||
date = new Date(parseInt(year), parseInt(month) - 1, 15); // 月中
|
||||
} else {
|
||||
date = new Date(key);
|
||||
}
|
||||
|
||||
const result: Record<string, any> = {
|
||||
date: new Date(dateStr),
|
||||
dateStr: dateStr
|
||||
date,
|
||||
dateStr: key,
|
||||
label: formatAggregationLabel(key, aggregationType)
|
||||
};
|
||||
|
||||
let cumulative = 0;
|
||||
sortedCategories.forEach(cat => {
|
||||
const value = dayData.get(cat) || 0;
|
||||
const value = aggData.get(cat) || 0;
|
||||
result[cat] = value;
|
||||
// 只有未隐藏的类别参与堆叠
|
||||
if (!hiddenCategories.has(cat)) {
|
||||
@@ -124,7 +218,7 @@
|
||||
|
||||
// 其他分类汇总
|
||||
let otherSum = 0;
|
||||
dayData.forEach((amount, cat) => {
|
||||
aggData.forEach((amount, cat) => {
|
||||
if (!sortedCategories.includes(cat)) {
|
||||
otherSum += amount;
|
||||
}
|
||||
@@ -153,7 +247,7 @@
|
||||
|
||||
const maxValue = Math.max(...data.map(d => d.total || 0), 1);
|
||||
|
||||
return { data, categories: finalCategories, maxValue };
|
||||
return { data, categories: finalCategories, maxValue, aggregationType, dayCount };
|
||||
});
|
||||
|
||||
// 获取颜色
|
||||
@@ -176,13 +270,23 @@
|
||||
|
||||
// 获取描述文本
|
||||
let descriptionText = $derived(() => {
|
||||
const { data } = processedData();
|
||||
const { data, aggregationType, dayCount } = processedData();
|
||||
const label = timeRangeOptions.find(o => o.value === timeRange)?.label || '最近 3 个月';
|
||||
return `${label}各分类支出趋势 (${data.length} 天)`;
|
||||
|
||||
let aggregationHint = '';
|
||||
if (aggregationType === 'week') {
|
||||
aggregationHint = `,按周聚合 (${data.length} 周)`;
|
||||
} else if (aggregationType === 'month') {
|
||||
aggregationHint = `,按月聚合 (${data.length} 月)`;
|
||||
} else {
|
||||
aggregationHint = ` (${dayCount || data.length} 天)`;
|
||||
}
|
||||
|
||||
return `${label}各分类支出趋势${aggregationHint}`;
|
||||
});
|
||||
|
||||
function handleTimeRangeChange(value: string | undefined) {
|
||||
if (value && ['7d', '30d', '3m'].includes(value)) {
|
||||
if (value && ['7d', 'week', '30d', 'month', '3m', 'year'].includes(value)) {
|
||||
timeRange = value as TimeRange;
|
||||
}
|
||||
}
|
||||
@@ -295,13 +399,46 @@
|
||||
return path;
|
||||
}
|
||||
|
||||
// 生成线性图路径(总金额曲线)
|
||||
function generateTotalLinePath(data: any[], maxValue: number): string {
|
||||
if (data.length === 0) return '';
|
||||
|
||||
// 计算每天的总支出(所有可见分类的总和)
|
||||
const points: { x: number; y: number }[] = data.map((d) => {
|
||||
// 使用 total 字段,这是所有可见分类的累计值
|
||||
const total = d.total || 0;
|
||||
return {
|
||||
x: xScale(d.date, data),
|
||||
y: yScale(total, maxValue)
|
||||
};
|
||||
});
|
||||
|
||||
// 起始点
|
||||
const startPoint = points[0];
|
||||
let path = `M ${startPoint.x},${startPoint.y}`;
|
||||
|
||||
// 平滑曲线
|
||||
path += generateSmoothPath(points);
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
// 生成线性图的数据点坐标(总金额)
|
||||
function getTotalLinePoints(data: any[], maxValue: number): { x: number; y: number; value: number }[] {
|
||||
return data.map((d) => ({
|
||||
x: xScale(d.date, data),
|
||||
y: yScale(d.total || 0, maxValue),
|
||||
value: d.total || 0
|
||||
}));
|
||||
}
|
||||
|
||||
// 生成 X 轴刻度
|
||||
function getXTicks(data: any[]): { x: number; label: string }[] {
|
||||
if (data.length === 0) return [];
|
||||
const step = Math.max(1, Math.floor(data.length / 6));
|
||||
return data.filter((_, i) => i % step === 0 || i === data.length - 1).map(d => ({
|
||||
x: xScale(d.date, data),
|
||||
label: `${d.date.getMonth() + 1}/${d.date.getDate()}`
|
||||
label: d.label || `${d.date.getMonth() + 1}/${d.date.getDate()}`
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -437,39 +574,72 @@
|
||||
{descriptionText()}
|
||||
</Card.Description>
|
||||
</div>
|
||||
<Select.Root type="single" value={timeRange} onValueChange={handleTimeRangeChange}>
|
||||
<Select.Trigger class="w-[140px] h-8 text-xs">
|
||||
{timeRangeOptions.find(o => o.value === timeRange)?.label}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each timeRangeOptions as option}
|
||||
<Select.Item value={option.value}>{option.label}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 图表类型切换 -->
|
||||
<div class="flex items-center rounded-md border bg-muted/50 p-0.5">
|
||||
<button
|
||||
class="flex items-center justify-center h-7 w-7 rounded transition-colors {chartType === 'area' ? 'bg-background shadow-sm' : 'hover:bg-background/50'}"
|
||||
onclick={() => chartType = 'area'}
|
||||
title="堆叠面积图"
|
||||
>
|
||||
<AreaChart class="h-4 w-4 {chartType === 'area' ? 'text-primary' : 'text-muted-foreground'}" />
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center justify-center h-7 w-7 rounded transition-colors {chartType === 'line' ? 'bg-background shadow-sm' : 'hover:bg-background/50'}"
|
||||
onclick={() => chartType = 'line'}
|
||||
title="线性图"
|
||||
>
|
||||
<LineChart class="h-4 w-4 {chartType === 'line' ? 'text-primary' : 'text-muted-foreground'}" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 时间范围选择 -->
|
||||
<Select.Root type="single" value={timeRange} onValueChange={handleTimeRangeChange}>
|
||||
<Select.Trigger class="w-[140px] h-8 text-xs">
|
||||
{timeRangeOptions.find(o => o.value === timeRange)?.label}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each timeRangeOptions as option}
|
||||
<Select.Item value={option.value}>{option.label}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<!-- 分类图例 (点击可切换显示) -->
|
||||
<!-- 图例 -->
|
||||
<div class="flex flex-wrap gap-4 mb-4">
|
||||
{#each categories as category, i}
|
||||
{@const isHidden = hiddenCategories.has(category)}
|
||||
<button
|
||||
class="flex items-center gap-2 cursor-pointer hover:opacity-80 transition-opacity outline-none"
|
||||
onclick={() => toggleCategory(category)}
|
||||
title={isHidden ? '点击显示' : '点击隐藏'}
|
||||
>
|
||||
{#if chartType === 'area'}
|
||||
<!-- 堆叠面积图:分类图例 (点击可切换显示) -->
|
||||
{#each categories as category, i}
|
||||
{@const isHidden = hiddenCategories.has(category)}
|
||||
<button
|
||||
class="flex items-center gap-2 cursor-pointer hover:opacity-80 transition-opacity outline-none"
|
||||
onclick={() => toggleCategory(category)}
|
||||
title={isHidden ? '点击显示' : '点击隐藏'}
|
||||
>
|
||||
<div
|
||||
class="w-3 h-3 rounded-sm transition-opacity {isHidden ? 'opacity-30' : ''}"
|
||||
style="background-color: {getColor(i)}"
|
||||
></div>
|
||||
<span
|
||||
class="text-xs transition-colors {isHidden ? 'text-muted-foreground/40 line-through' : 'text-muted-foreground'}"
|
||||
>{category}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{:else}
|
||||
<!-- 线性图:总支出图例 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-3 h-3 rounded-sm transition-opacity {isHidden ? 'opacity-30' : ''}"
|
||||
style="background-color: {getColor(i)}"
|
||||
class="w-3 h-3 rounded-full"
|
||||
style="background-color: oklch(0.65 0.2 25)"
|
||||
></div>
|
||||
<span
|
||||
class="text-xs transition-colors {isHidden ? 'text-muted-foreground/40 line-through' : 'text-muted-foreground'}"
|
||||
>{category}</span>
|
||||
</button>
|
||||
{/each}
|
||||
<span class="text-xs text-muted-foreground">总支出</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 堆叠面积图 (自定义 SVG) -->
|
||||
<!-- 趋势图 (自定义 SVG) -->
|
||||
<div class="relative w-full" style="aspect-ratio: {chartWidth}/{chartHeight};">
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions -->
|
||||
<svg
|
||||
@@ -535,16 +705,42 @@
|
||||
</text>
|
||||
{/each}
|
||||
|
||||
<!-- 堆叠面积 (从后向前渲染) -->
|
||||
{#each [...categories].reverse() as category, i}
|
||||
{@const colorIdx = categories.length - 1 - i}
|
||||
{#if chartType === 'area'}
|
||||
<!-- 堆叠面积图 (从后向前渲染) -->
|
||||
{#each [...categories].reverse() as category, i}
|
||||
{@const colorIdx = categories.length - 1 - i}
|
||||
<path
|
||||
d={generateAreaPath(category, data, maxValue)}
|
||||
fill={getColor(colorIdx)}
|
||||
fill-opacity="0.7"
|
||||
class="transition-opacity hover:opacity-90"
|
||||
/>
|
||||
{/each}
|
||||
{:else}
|
||||
<!-- 线性图(总金额) -->
|
||||
<!-- 曲线 -->
|
||||
<path
|
||||
d={generateAreaPath(category, data, maxValue)}
|
||||
fill={getColor(colorIdx)}
|
||||
fill-opacity="0.7"
|
||||
class="transition-opacity hover:opacity-90"
|
||||
d={generateTotalLinePath(data, maxValue)}
|
||||
fill="none"
|
||||
stroke="oklch(0.65 0.2 25)"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="transition-opacity"
|
||||
/>
|
||||
{/each}
|
||||
<!-- 数据点 -->
|
||||
{#each getTotalLinePoints(data, maxValue) as point}
|
||||
<circle
|
||||
cx={point.x}
|
||||
cy={point.y}
|
||||
r="4"
|
||||
fill="oklch(0.65 0.2 25)"
|
||||
stroke="white"
|
||||
stroke-width="2"
|
||||
class="transition-all"
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<!-- Tooltip 辅助线 -->
|
||||
{#if tooltipData}
|
||||
@@ -582,7 +778,7 @@
|
||||
style="left: {adjustedLeft}%; top: 15%;"
|
||||
>
|
||||
<div class="font-medium text-foreground mb-2">
|
||||
{tooltipData.date.toLocaleDateString('zh-CN')}
|
||||
{tooltipData.label || tooltipData.date.toLocaleDateString('zh-CN')}
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
{#each categories as category, i}
|
||||
|
||||
Reference in New Issue
Block a user