add radar chart for categorical analysis on desktop version

This commit is contained in:
MaysWind
2025-10-26 02:01:51 +08:00
parent e1dcf56ca9
commit 745efe1222
21 changed files with 248 additions and 11 deletions
+179
View File
@@ -0,0 +1,179 @@
<template>
<v-chart autoresize class="radar-chart-container" :class="{ 'transition-in': skeleton }" :option="chartOptions" />
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useTheme } from 'vuetify';
import { useI18n } from '@/locales/helpers.ts';
import type { ColorValue } from '@/core/color.ts';
import { ThemeType } from '@/core/theme.ts';
import { DEFAULT_CHART_COLORS } from '@/consts/color.ts';
import { isNumber } from '@/lib/common.ts';
import { getDisplayColor } from '@/lib/color.ts';
interface RadarChartData {
totalValidValue: number;
maxValue: number;
indicators: RadarChartDataItem[];
values: number[];
tooltip: string;
}
interface RadarChartDataItem {
name: string,
max: number,
color: string
}
const props = defineProps<{
skeleton?: boolean;
items: Record<string, unknown>[];
nameField: string;
valueField: string;
percentField?: string;
colorField?: string;
hiddenField?: string;
minValidPercent?: number;
defaultCurrency?: string;
showValue?: boolean;
}>();
const theme = useTheme();
const { formatAmountToLocalizedNumeralsWithCurrency, formatPercentToLocalizedNumerals } = useI18n();
const isDarkMode = computed<boolean>(() => theme.global.name.value === ThemeType.Dark);
const radarData = computed<RadarChartData>(() => {
let totalValidValue = 0;
let maxValue = 0;
const indicators: RadarChartDataItem[] = [];
const values: number[] = [];
let tooltip = '';
for (const item of props.items) {
const value = item[props.valueField];
if (isNumber(value) && value > 0 && (!props.hiddenField || !item[props.hiddenField])) {
totalValidValue += value;
if (value > maxValue) {
maxValue = value;
}
}
}
for (const item of props.items) {
const value = item[props.valueField];
const percent = props.percentField ? item[props.percentField] : -1;
if (isNumber(value) && value > 0 &&
(!props.hiddenField || !item[props.hiddenField]) &&
(!props.minValidPercent || value / totalValidValue > props.minValidPercent)) {
const name = item[props.nameField] as string;
const color = getDisplayColor((props.colorField && item[props.colorField]) ? item[props.colorField] as ColorValue : DEFAULT_CHART_COLORS[indicators.length % DEFAULT_CHART_COLORS.length]);
const finalPercent = (isNumber(percent) && percent >= 0) ? percent : (value / totalValidValue * 100);
const displayPercent = formatPercentToLocalizedNumerals(finalPercent, 2, '&lt;0.01');
const displayValue = formatAmountToLocalizedNumeralsWithCurrency(value, props.defaultCurrency);
indicators.push({
name: name,
max: maxValue,
color: isDarkMode.value ? '#ccc' : '#333'
});
values.push(value);
tooltip += '<div><span class="chart-pointer" style="background-color: ' + color + '"></span>';
tooltip += `<span>${name}</span>`;
if (props.showValue) {
tooltip += `<span class="ms-1" style="float: inline-end">(${displayPercent})</span><span class="ms-5" style="float: inline-end">${displayValue}</span>`;
} else {
tooltip += `<span class="ms-5" style="float: inline-end">${displayPercent}</span>`;
}
tooltip += '</div>';
}
}
const ret: RadarChartData = {
totalValidValue: totalValidValue,
maxValue: maxValue,
indicators: indicators,
values: values,
tooltip: tooltip
};
return ret;
});
const chartOptions = computed<object>(() => {
return {
tooltip: {
trigger: 'item',
backgroundColor: isDarkMode.value ? '#333' : '#fff',
borderColor: isDarkMode.value ? '#333' : '#fff',
textStyle: {
color: isDarkMode.value ? '#eee' : '#333'
},
formatter: () => radarData.value.tooltip
},
radar: {
radius: '75%',
indicator: radarData.value.indicators
},
series: [
{
type: 'radar',
data: [
{
value: radarData.value.values
}
],
top: 0,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)',
}
},
animation: !props.skeleton
}
]
};
});
</script>
<style scoped>
.radar-chart-container {
width: 100%;
height: 460px;
}
@media (min-width: 600px) {
.radar-chart-container {
height: 560px;
}
}
.radar-chart-container.transition-in {
animation: radar-chart-skeleton-fade-in 2s 1;
}
@keyframes radar-chart-skeleton-fade-in {
0% {
opacity: 0;
}
20% {
opacity: 0;
}
100% {
opacity: 1;
}
}
</style>
+19 -7
View File
@@ -7,25 +7,37 @@ export enum StatisticsAnalysisType {
}
export class CategoricalChartType implements TypeAndName {
private static readonly allInstances: CategoricalChartType[] = [];
private static readonly allInstancesForAll: CategoricalChartType[] = [];
private static readonly allInstancesForDesktop: CategoricalChartType[] = [];
public static readonly Pie = new CategoricalChartType(0, 'Pie Chart');
public static readonly Bar = new CategoricalChartType(1, 'Bar Chart');
public static readonly Pie = new CategoricalChartType(0, 'Pie Chart', false);
public static readonly Bar = new CategoricalChartType(1, 'Bar Chart', false);
public static readonly Radar = new CategoricalChartType(2, 'Radar Chart', true);
public static readonly Default = CategoricalChartType.Pie;
public readonly type: number;
public readonly name: string;
public readonly desktopOnly: boolean = false;
private constructor(type: number, name: string) {
private constructor(type: number, name: string, desktopOnly: boolean) {
this.type = type;
this.name = name;
this.desktopOnly = desktopOnly;
CategoricalChartType.allInstances.push(this);
if (!desktopOnly) {
CategoricalChartType.allInstancesForAll.push(this);
}
CategoricalChartType.allInstancesForDesktop.push(this);
}
public static values(): CategoricalChartType[] {
return CategoricalChartType.allInstances;
public static values(withDesktopOnlyChart: boolean): CategoricalChartType[] {
if (withDesktopOnlyChart) {
return CategoricalChartType.allInstancesForDesktop;
} else {
return CategoricalChartType.allInstancesForAll;
}
}
}
+4 -1
View File
@@ -52,7 +52,7 @@ import 'vuetify/styles';
import * as echarts from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { LineChart, BarChart, PieChart, CandlestickChart } from 'echarts/charts';
import { LineChart, BarChart, PieChart, CandlestickChart, RadarChart } from 'echarts/charts';
import {
GridComponent,
TooltipComponent,
@@ -99,6 +99,7 @@ import StepsBar from '@/components/desktop/StepsBar.vue';
import ConfirmDialog from '@/components/desktop/ConfirmDialog.vue';
import SnackBar from '@/components/desktop/SnackBar.vue';
import PieChartComponent from '@/components/desktop/PieChart.vue';
import RadarChartComponent from '@/components/desktop/RadarChart.vue';
import MonthlyTrendsChart from '@/components/desktop/MonthlyTrendsChart.vue';
import DateRangeSelectionDialog from '@/components/desktop/DateRangeSelectionDialog.vue';
import MonthSelectionDialog from '@/components/desktop/MonthSelectionDialog.vue';
@@ -496,6 +497,7 @@ echarts.use([
BarChart,
PieChart,
CandlestickChart,
RadarChart,
GridComponent,
TooltipComponent,
LegendComponent
@@ -536,6 +538,7 @@ app.component('StepsBar', StepsBar);
app.component('ConfirmDialog', ConfirmDialog);
app.component('SnackBar', SnackBar);
app.component('PieChart', PieChartComponent);
app.component('RadarChart', RadarChartComponent);
app.component('MonthlyTrendsChart', MonthlyTrendsChart);
app.component('DateRangeSelectionDialog', DateRangeSelectionDialog);
app.component('MonthSelectionDialog', MonthSelectionDialog);
+1
View File
@@ -1512,6 +1512,7 @@
"Not between": "Nicht zwischen",
"Pie Chart": "Tortendiagramm",
"Bar Chart": "Balkendiagramm",
"Radar Chart": "Radar Chart",
"Area Chart": "Flächendiagramm",
"Column Chart": "Säulendiagramm",
"Candlestick Chart": "Candlestick Chart",
+1
View File
@@ -1512,6 +1512,7 @@
"Not between": "Not between",
"Pie Chart": "Pie Chart",
"Bar Chart": "Bar Chart",
"Radar Chart": "Radar Chart",
"Area Chart": "Area Chart",
"Column Chart": "Column Chart",
"Candlestick Chart": "Candlestick Chart",
+1
View File
@@ -1512,6 +1512,7 @@
"Not between": "No entre",
"Pie Chart": "Gráfico circular",
"Bar Chart": "Gráfico de barras",
"Radar Chart": "Radar Chart",
"Area Chart": "Gráfico de área",
"Column Chart": "Gráfico de columnas",
"Candlestick Chart": "Candlestick Chart",
+1
View File
@@ -1512,6 +1512,7 @@
"Not between": "Pas entre",
"Pie Chart": "Graphique en secteurs",
"Bar Chart": "Graphique en barres",
"Radar Chart": "Radar Chart",
"Area Chart": "Graphique en aires",
"Column Chart": "Graphique en colonnes",
"Candlestick Chart": "Graphique en chandelier",
+1 -1
View File
@@ -2349,7 +2349,7 @@ export function useI18n() {
getAllIncomeAmountColors: () => getAllExpenseIncomeAmountColors(CategoryType.Income),
getAllAccountCategories,
getAllAccountTypes: () => getLocalizedDisplayNameAndType(AccountType.values()),
getAllCategoricalChartTypes: () => getLocalizedDisplayNameAndType(CategoricalChartType.values()),
getAllCategoricalChartTypes: (withDesktopOnlyChart?: boolean) => getLocalizedDisplayNameAndType(CategoricalChartType.values(!!withDesktopOnlyChart)),
getAllTrendChartTypes: () => getLocalizedDisplayNameAndType(TrendChartType.values()),
getAllAccountBalanceTrendChartTypes: () => getLocalizedDisplayNameAndType(AccountBalanceTrendChartType.values()),
getAllStatisticsChartDataTypes: (analysisType: StatisticsAnalysisType) => getLocalizedDisplayNameAndType(ChartDataType.values(analysisType)),
+1
View File
@@ -1512,6 +1512,7 @@
"Not between": "Non tra",
"Pie Chart": "Grafico a torta",
"Bar Chart": "Grafico a barre",
"Radar Chart": "Radar Chart",
"Area Chart": "Grafico ad area",
"Column Chart": "Grafico a colonne",
"Candlestick Chart": "Candlestick Chart",
+1
View File
@@ -1512,6 +1512,7 @@
"Not between": "間ではない",
"Pie Chart": "円グラフ",
"Bar Chart": "棒グラフ",
"Radar Chart": "Radar Chart",
"Area Chart": "エリアチャート",
"Column Chart": "列チャート",
"Candlestick Chart": "Candlestick Chart",
+1
View File
@@ -1512,6 +1512,7 @@
"Not between": "사이 아님",
"Pie Chart": "원형 차트",
"Bar Chart": "막대 차트",
"Radar Chart": "Radar Chart",
"Area Chart": "영역 차트",
"Column Chart": "세로 막대 차트",
"Candlestick Chart": "캠들스틱 차트",
+1
View File
@@ -1512,6 +1512,7 @@
"Not between": "Niet tussen",
"Pie Chart": "Cirkeldiagram",
"Bar Chart": "Balkdiagram",
"Radar Chart": "Radar Chart",
"Area Chart": "Vlakdiagram",
"Column Chart": "Kolomdiagram",
"Candlestick Chart": "Candlestickdiagram",
+1
View File
@@ -1512,6 +1512,7 @@
"Not between": "Não entre",
"Pie Chart": "Gráfico de Pizza",
"Bar Chart": "Gráfico de Barras",
"Radar Chart": "Radar Chart",
"Area Chart": "Gráfico de Área",
"Column Chart": "Gráfico de Colunas",
"Candlestick Chart": "Candlestick Chart",
+1
View File
@@ -1512,6 +1512,7 @@
"Not between": "Не между",
"Pie Chart": "Круговая диаграмма",
"Bar Chart": "Гистограмма",
"Radar Chart": "Radar Chart",
"Area Chart": "Диаграмма с областями",
"Column Chart": "Столбчатая диаграмма",
"Candlestick Chart": "Candlestick Chart",
+1
View File
@@ -1512,6 +1512,7 @@
"Not between": "ไม่อยู่ระหว่าง",
"Pie Chart": "กราฟวงกลม",
"Bar Chart": "กราฟแท่ง",
"Radar Chart": "Radar Chart",
"Area Chart": "กราฟพื้นที่",
"Column Chart": "กราฟคอลัมน์",
"Candlestick Chart": "กราฟแท่งเทียน",
+1
View File
@@ -1512,6 +1512,7 @@
"Not between": "Не між",
"Pie Chart": "Кругова діаграма",
"Bar Chart": "Гістограма",
"Radar Chart": "Radar Chart",
"Area Chart": "Діаграма з областями",
"Column Chart": "Стовпчикова діаграма",
"Candlestick Chart": "Candlestick Chart",
+1
View File
@@ -1512,6 +1512,7 @@
"Not between": "Không giữa",
"Pie Chart": "Biểu đồ tròn",
"Bar Chart": "Biểu đồ cột",
"Radar Chart": "Radar Chart",
"Area Chart": "Biểu đồ diện tích",
"Column Chart": "Biểu đồ cột",
"Candlestick Chart": "Candlestick Chart",
+1
View File
@@ -1512,6 +1512,7 @@
"Not between": "不介于",
"Pie Chart": "饼图",
"Bar Chart": "条形图",
"Radar Chart": "雷达图",
"Area Chart": "面积图",
"Column Chart": "柱状图",
"Candlestick Chart": "K线图",
+1
View File
@@ -1512,6 +1512,7 @@
"Not between": "不介於",
"Pie Chart": "圓餅圖",
"Bar Chart": "長條圖",
"Radar Chart": "雷達圖",
"Area Chart": "面積圖",
"Column Chart": "柱狀圖",
"Candlestick Chart": "K線圖",
+3 -1
View File
@@ -700,7 +700,9 @@ export const useStatisticsStore = defineStore('statistics', () => {
transactionStatisticsFilter.value.categoricalChartType = settingsStore.appSettings.statistics.defaultCategoricalChartType;
}
if (transactionStatisticsFilter.value.categoricalChartType !== CategoricalChartType.Pie.type && transactionStatisticsFilter.value.categoricalChartType !== CategoricalChartType.Bar.type) {
if (transactionStatisticsFilter.value.categoricalChartType !== CategoricalChartType.Pie.type &&
transactionStatisticsFilter.value.categoricalChartType !== CategoricalChartType.Bar.type &&
transactionStatisticsFilter.value.categoricalChartType !== CategoricalChartType.Radar.type) {
transactionStatisticsFilter.value.categoricalChartType = CategoricalChartType.Default.type;
}
@@ -263,6 +263,32 @@
</v-list>
</v-card-text>
<v-card-text :class="{ 'readonly': loading }" v-if="queryAnalysisType === StatisticsAnalysisType.CategoricalAnalysis && query.categoricalChartType === CategoricalChartType.Radar.type">
<radar-chart
:items="[
{id: '1', name: '---', value: 60, color: '7c7c7f'},
{id: '2', name: '---', value: 20, color: 'a5a5aa'},
{id: '3', name: '---', value: 20, color: 'c5c5c9'}
]"
:skeleton="true"
name-field="name"
value-field="value"
color-field="color"
v-if="initing"
></radar-chart>
<radar-chart
:items="categoricalAnalysisData && categoricalAnalysisData.items && categoricalAnalysisData.items.length ? categoricalAnalysisData.items : []"
:min-valid-percent="0.0001"
:show-value="showAmountInChart"
:default-currency="defaultCurrency"
name-field="name"
value-field="totalAmount"
percent-field="percent"
hidden-field="hidden"
v-else-if="!initing"
/>
</v-card-text>
<v-card-text :class="{ 'readonly': loading }" v-if="queryAnalysisType === StatisticsAnalysisType.TrendAnalysis">
<monthly-trends-chart
:type="queryChartType"
@@ -504,7 +530,7 @@ const statisticsDataHasData = computed<boolean>(() => {
const allChartTypes = computed<TypeAndDisplayName[]>(() => {
if (analysisType.value === StatisticsAnalysisType.CategoricalAnalysis) {
return getAllCategoricalChartTypes();
return getAllCategoricalChartTypes(true);
} else if (analysisType.value === StatisticsAnalysisType.TrendAnalysis) {
return getAllTrendChartTypes();
} else {