mirror of
https://github.com/mayswind/ezbookkeeping.git
synced 2026-05-14 06:57:35 +08:00
add radar chart for categorical analysis on desktop version
This commit is contained in:
@@ -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, '<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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1512,6 +1512,7 @@
|
||||
"Not between": "間ではない",
|
||||
"Pie Chart": "円グラフ",
|
||||
"Bar Chart": "棒グラフ",
|
||||
"Radar Chart": "Radar Chart",
|
||||
"Area Chart": "エリアチャート",
|
||||
"Column Chart": "列チャート",
|
||||
"Candlestick Chart": "Candlestick Chart",
|
||||
|
||||
@@ -1512,6 +1512,7 @@
|
||||
"Not between": "사이 아님",
|
||||
"Pie Chart": "원형 차트",
|
||||
"Bar Chart": "막대 차트",
|
||||
"Radar Chart": "Radar Chart",
|
||||
"Area Chart": "영역 차트",
|
||||
"Column Chart": "세로 막대 차트",
|
||||
"Candlestick Chart": "캠들스틱 차트",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1512,6 +1512,7 @@
|
||||
"Not between": "Не между",
|
||||
"Pie Chart": "Круговая диаграмма",
|
||||
"Bar Chart": "Гистограмма",
|
||||
"Radar Chart": "Radar Chart",
|
||||
"Area Chart": "Диаграмма с областями",
|
||||
"Column Chart": "Столбчатая диаграмма",
|
||||
"Candlestick Chart": "Candlestick Chart",
|
||||
|
||||
@@ -1512,6 +1512,7 @@
|
||||
"Not between": "ไม่อยู่ระหว่าง",
|
||||
"Pie Chart": "กราฟวงกลม",
|
||||
"Bar Chart": "กราฟแท่ง",
|
||||
"Radar Chart": "Radar Chart",
|
||||
"Area Chart": "กราฟพื้นที่",
|
||||
"Column Chart": "กราฟคอลัมน์",
|
||||
"Candlestick Chart": "กราฟแท่งเทียน",
|
||||
|
||||
@@ -1512,6 +1512,7 @@
|
||||
"Not between": "Не між",
|
||||
"Pie Chart": "Кругова діаграма",
|
||||
"Bar Chart": "Гістограма",
|
||||
"Radar Chart": "Radar Chart",
|
||||
"Area Chart": "Діаграма з областями",
|
||||
"Column Chart": "Стовпчикова діаграма",
|
||||
"Candlestick Chart": "Candlestick Chart",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1512,6 +1512,7 @@
|
||||
"Not between": "不介于",
|
||||
"Pie Chart": "饼图",
|
||||
"Bar Chart": "条形图",
|
||||
"Radar Chart": "雷达图",
|
||||
"Area Chart": "面积图",
|
||||
"Column Chart": "柱状图",
|
||||
"Candlestick Chart": "K线图",
|
||||
|
||||
@@ -1512,6 +1512,7 @@
|
||||
"Not between": "不介於",
|
||||
"Pie Chart": "圓餅圖",
|
||||
"Bar Chart": "長條圖",
|
||||
"Radar Chart": "雷達圖",
|
||||
"Area Chart": "面積圖",
|
||||
"Column Chart": "柱狀圖",
|
||||
"Candlestick Chart": "K線圖",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user