add heat map chart in insights explorer

This commit is contained in:
MaysWind
2026-04-14 00:36:57 +08:00
parent 4af0797051
commit c2d7bcc5f1
23 changed files with 333 additions and 3 deletions
+272
View File
@@ -0,0 +1,272 @@
<template>
<v-chart autoresize :class="finalClass" :style="finalStyle" :option="chartOptions" />
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useTheme } from 'vuetify';
import type { CallbackDataParams } from 'echarts/types/dist/shared';
import { useI18n } from '@/locales/helpers.ts';
import { itemAndIndex } from '@/core/base.ts';
import { TextDirection } from '@/core/text.ts';
import { ThemeType } from '@/core/theme.ts';
import { isArray, isNumber } from '@/lib/common.ts';
interface HeapMapData {
allSeriesNames: string[];
data: [number, number, number][];
minValue: number;
maxValue: number;
}
const props = defineProps<{
class?: string;
skeleton?: boolean;
showValue?: boolean;
allCategoryNames: string[];
items: Record<string, unknown>[];
nameField: string;
valuesField: string;
hiddenField?: string;
translateName?: boolean;
valueTypeName: string;
amountValue?: boolean;
defaultCurrency?: string;
}>();
const theme = useTheme();
const {
tt,
getCurrentLanguageTextDirection,
formatAmountToLocalizedNumeralsWithCurrency,
formatNumberToLocalizedNumerals
} = useI18n();
const textDirection = computed<TextDirection>(() => getCurrentLanguageTextDirection());
const isDarkMode = computed<boolean>(() => theme.global.name.value === ThemeType.Dark);
const finalClass = computed<string>(() => {
let finalClass = '';
if (props.skeleton) {
finalClass += 'transition-in';
}
if (props.class) {
finalClass += ` ${props.class}`;
} else {
finalClass += ' heapmap-chart-container';
}
return finalClass;
});
const finalStyle = computed<Record<string, string>>(() => {
const style: Record<string, string> = {};
if (heatMapData.value.allSeriesNames && heatMapData.value.allSeriesNames.length > 15) {
style['height'] = `${heatMapData.value.allSeriesNames.length * 40}px`;
}
return style;
});
const heatMapData = computed<HeapMapData>(() => {
const allData: [number, number, number][] = [];
const allSeriesNames: string[] = [];
let minValue: number = Number.POSITIVE_INFINITY;
let maxValue: number = 0;
for (const [item, seriesIndex] of itemAndIndex(props.items)) {
if (props.hiddenField && item[props.hiddenField]) {
continue;
}
if (!isArray(item[props.valuesField])) {
continue;
}
allSeriesNames.push(getItemName(item[props.nameField] as string));
const allAmounts: number[] = item[props.valuesField] as number[];
for (const [amount, categoryIndex] of itemAndIndex(allAmounts)) {
if (amount > maxValue) {
maxValue = amount;
}
if (amount < minValue) {
minValue = amount;
}
allData.push([categoryIndex, seriesIndex, amount]);
}
}
const ret: HeapMapData = {
allSeriesNames: allSeriesNames,
data: allData,
minValue: minValue === Number.POSITIVE_INFINITY ? 0 : minValue,
maxValue: maxValue
};
return ret;
});
const yAxisWidth = computed<number>(() => {
let width: number = 90;
if (!heatMapData.value || !heatMapData.value.allSeriesNames) {
return width;
}
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (context) {
context.font = '12px Arial';
for (const seriesName of heatMapData.value.allSeriesNames) {
const textMetrics = context.measureText(seriesName);
const actualWidth = Math.round(textMetrics.width) + 20;
if (actualWidth > width) {
width = actualWidth;
}
}
}
if (width >= 200) {
width = 200;
}
return width;
});
const chartOptions = computed<object>(() => {
return {
tooltip: {
backgroundColor: isDarkMode.value ? '#333' : '#fff',
borderColor: isDarkMode.value ? '#333' : '#fff',
textStyle: {
color: isDarkMode.value ? '#eee' : '#333'
},
formatter: (params: CallbackDataParams) => {
if (!props.showValue) {
return '';
}
const dataItem = params.data as [number, number, number];
const name = props.valueTypeName;
const value = dataItem && isNumber(dataItem[2]) ? getDisplayValue(dataItem[2]) : '';
return `<div><span class="chart-pointer" style="background-color: ${params.color}"></span>`
+ `<span>${name}</span>`
+ `<span class="ms-5">${value}</span>`
+ '</div>';
}
},
visualMap: [
{
type: 'continuous',
orient: 'horizontal',
top: 0,
left: 'center',
itemHeight: 320,
min: heatMapData.value.minValue,
max: heatMapData.value.maxValue,
calculable: true,
inRange: {
color: isDarkMode.value ? [ '#060504', '#c67e48' ] : [ '#faf8f4', '#c67e48' ]
},
formatter: (value: string) => {
if (!props.showValue) {
return '';
}
return getDisplayValue(parseInt(value));
}
}
],
grid: {
left: yAxisWidth.value,
right: 20,
bottom: 40
},
xAxis: [
{
type: 'category',
data: props.allCategoryNames,
inverse: textDirection.value === TextDirection.RTL,
axisLabel: {
color: isDarkMode.value ? '#888' : '#666'
}
}
],
yAxis: [
{
type: 'category',
data: heatMapData.value.allSeriesNames,
inverse: true,
axisLabel: {
color: isDarkMode.value ? '#888' : '#666'
}
}
],
series: [
{
type: 'heatmap',
animation: !props.skeleton,
data: heatMapData.value.data,
label: {
show: props.showValue ?? false,
color: isDarkMode.value ? '#eee' : '#333',
formatter: (params: CallbackDataParams) => {
if (!props.showValue) {
return '';
}
const data: [number, number, number] = params.data as [number, number, number];
const value: number = data && isNumber(data[2]) ? data[2] : 0;
return getDisplayValue(value);
}
},
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
}
}
]
};
});
function getItemName(name: string): string {
return props.translateName ? tt(name) : name;
}
function getDisplayValue(value: number): string {
if (props.amountValue) {
return formatAmountToLocalizedNumeralsWithCurrency(value, props.defaultCurrency);
}
return formatNumberToLocalizedNumerals(value, 2);
}
</script>
<style scoped>
.heapmap-chart-container {
width: 100%;
height: 560px;
margin-top: 10px;
}
@media (min-width: 600px) {
.heapmap-chart-container {
height: 630px;
}
}
</style>
+3 -1
View File
@@ -175,7 +175,8 @@ export enum TransactionExplorerChartTypeValue {
AreaStacked = 'areaStacked',
Area100PercentStacked = 'area100%Stacked',
BubbleGrouped = 'bubbleGrouped',
Radar = 'radar'
Radar = 'radar',
Heatmap = 'heatmap'
}
export class TransactionExplorerChartType implements NameValue {
@@ -191,6 +192,7 @@ export class TransactionExplorerChartType implements NameValue {
public static readonly AreaStacked = new TransactionExplorerChartType('Area Chart (Stacked)', TransactionExplorerChartTypeValue.AreaStacked, true);
public static readonly Area100PercentStacked = new TransactionExplorerChartType('Area Chart (100% Stacked)', TransactionExplorerChartTypeValue.Area100PercentStacked, true);
public static readonly BubbleGrouped = new TransactionExplorerChartType('Bubble Chart (Grouped)', TransactionExplorerChartTypeValue.BubbleGrouped, true);
public static readonly Heatmap = new TransactionExplorerChartType('Heatmap Chart', TransactionExplorerChartTypeValue.Heatmap, true);
public static readonly Default = TransactionExplorerChartType.Pie;
+17 -2
View File
@@ -52,11 +52,22 @@ import 'vuetify/styles';
import * as echarts from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { LineChart, BarChart, PieChart, ScatterChart, BoxplotChart, CandlestickChart, RadarChart, SankeyChart } from 'echarts/charts';
import {
LineChart,
BarChart,
PieChart,
ScatterChart,
BoxplotChart,
CandlestickChart,
RadarChart,
HeatmapChart,
SankeyChart
} from 'echarts/charts';
import {
GridComponent,
TooltipComponent,
LegendComponent,
VisualMapComponent
} from 'echarts/components';
import VChart from 'vue-echarts';
@@ -103,6 +114,7 @@ import PieChartComponent from '@/components/desktop/PieChart.vue';
import RadarChartComponent from '@/components/desktop/RadarChart.vue';
import AxisChart from '@/components/desktop/AxisChart.vue';
import TrendsChart from '@/components/desktop/TrendsChart.vue';
import HeatMapChart from '@/components/desktop/HeatMapChart.vue';
import RenameDialog from '@/components/desktop/RenameDialog.vue';
import DateRangeSelectionDialog from '@/components/desktop/DateRangeSelectionDialog.vue';
import MonthSelectionDialog from '@/components/desktop/MonthSelectionDialog.vue';
@@ -506,10 +518,12 @@ echarts.use([
BoxplotChart,
CandlestickChart,
RadarChart,
HeatmapChart,
SankeyChart,
GridComponent,
TooltipComponent,
LegendComponent
LegendComponent,
VisualMapComponent
]);
app.use(pinia);
@@ -551,6 +565,7 @@ app.component('PieChart', PieChartComponent);
app.component('RadarChart', RadarChartComponent);
app.component('AxisChart', AxisChart);
app.component('TrendsChart', TrendsChart);
app.component('HeatMapChart', HeatMapChart);
app.component('RenameDialog', RenameDialog);
app.component('DateRangeSelectionDialog', DateRangeSelectionDialog);
app.component('MonthSelectionDialog', MonthSelectionDialog);
+1
View File
@@ -1603,6 +1603,7 @@
"Area Chart (Stacked)": "Flächendiagramm (Gestapelt)",
"Area Chart (100% Stacked)": "Flächendiagramm (100% Gestapelt)",
"Bubble Chart (Grouped)": "Blasendiagramm (Gruppiert)",
"Heatmap Chart": "Heatmap Chart",
"Sort by": "Sortieren nach",
"Map": "Karte",
"Provider": "Anbieter",
+1
View File
@@ -1603,6 +1603,7 @@
"Area Chart (Stacked)": "Area Chart (Stacked)",
"Area Chart (100% Stacked)": "Area Chart (100% Stacked)",
"Bubble Chart (Grouped)": "Bubble Chart (Grouped)",
"Heatmap Chart": "Heatmap Chart",
"Sort by": "Sort by",
"Map": "Map",
"Provider": "Provider",
+1
View File
@@ -1603,6 +1603,7 @@
"Area Chart (Stacked)": "Gráfico de área (apilado)",
"Area Chart (100% Stacked)": "Gráfico de área (100 % apilado)",
"Bubble Chart (Grouped)": "Gráfico de burbujas (agrupado)",
"Heatmap Chart": "Heatmap Chart",
"Sort by": "Ordenar por",
"Map": "Mapa",
"Provider": "Proveedor",
+1
View File
@@ -1603,6 +1603,7 @@
"Area Chart (Stacked)": "Area Chart (Stacked)",
"Area Chart (100% Stacked)": "Area Chart (100% Stacked)",
"Bubble Chart (Grouped)": "Bubble Chart (Grouped)",
"Heatmap Chart": "Heatmap Chart",
"Sort by": "Trier par",
"Map": "Carte",
"Provider": "Fournisseur",
+1
View File
@@ -1603,6 +1603,7 @@
"Area Chart (Stacked)": "Area Chart (Stacked)",
"Area Chart (100% Stacked)": "Area Chart (100% Stacked)",
"Bubble Chart (Grouped)": "Bubble Chart (Grouped)",
"Heatmap Chart": "Heatmap Chart",
"Sort by": "Ordina per",
"Map": "Mappa",
"Provider": "Fornitore",
+1
View File
@@ -1603,6 +1603,7 @@
"Area Chart (Stacked)": "Area Chart (Stacked)",
"Area Chart (100% Stacked)": "Area Chart (100% Stacked)",
"Bubble Chart (Grouped)": "Bubble Chart (Grouped)",
"Heatmap Chart": "Heatmap Chart",
"Sort by": "ソート順",
"Map": "地図",
"Provider": "プロバイダー",
+1
View File
@@ -1603,6 +1603,7 @@
"Area Chart (Stacked)": "Area Chart (Stacked)",
"Area Chart (100% Stacked)": "Area Chart (100% Stacked)",
"Bubble Chart (Grouped)": "Bubble Chart (Grouped)",
"Heatmap Chart": "Heatmap Chart",
"Sort by": "ಇದರ ಪ್ರಕಾರ ವಿಂಗಡಿಸಿ",
"Map": "ನಕ್ಷೆ",
"Provider": "ಪ್ರದಾತ",
+1
View File
@@ -1603,6 +1603,7 @@
"Area Chart (Stacked)": "누적 영역 차트",
"Area Chart (100% Stacked)": "100% 누적 영역 차트",
"Bubble Chart (Grouped)": "그룹화된 버블 차트",
"Heatmap Chart": "Heatmap Chart",
"Sort by": "정렬 기준",
"Map": "지도",
"Provider": "제공자",
+1
View File
@@ -1603,6 +1603,7 @@
"Area Chart (Stacked)": "Area Chart (Stacked)",
"Area Chart (100% Stacked)": "Area Chart (100% Stacked)",
"Bubble Chart (Grouped)": "Bubble Chart (Grouped)",
"Heatmap Chart": "Heatmap Chart",
"Sort by": "Sorteren op",
"Map": "Kaart",
"Provider": "Provider",
+1
View File
@@ -1603,6 +1603,7 @@
"Area Chart (Stacked)": "Gráfico de Área (Empilhado)",
"Area Chart (100% Stacked)": "Gráfico de Área (100% Empilhado)",
"Bubble Chart (Grouped)": "Gráfico de Bolhas (Agrupado)",
"Heatmap Chart": "Heatmap Chart",
"Sort by": "Ordenar por",
"Map": "Mapa",
"Provider": "Provedor",
+1
View File
@@ -1603,6 +1603,7 @@
"Area Chart (Stacked)": "Диаграмма с областями (с накоплением)",
"Area Chart (100% Stacked)": "Диаграмма с областями (с 100% накоплением)",
"Bubble Chart (Grouped)": "Пузырьковая диаграмма (сгрупированная)",
"Heatmap Chart": "Heatmap Chart",
"Sort by": "Сортировать по",
"Map": "Карта",
"Provider": "Провайдер",
+1
View File
@@ -1603,6 +1603,7 @@
"Area Chart (Stacked)": "Naložen ploskovni grafikon",
"Area Chart (100% Stacked)": "100-odstotno naložen ploskovni grafikon",
"Bubble Chart (Grouped)": "Grupiran mehurčni grafikon",
"Heatmap Chart": "Heatmap Chart",
"Sort by": "Razvrsti po",
"Map": "Zemljevid",
"Provider": "Ponudnik",
+1
View File
@@ -1603,6 +1603,7 @@
"Area Chart (Stacked)": "பரப்பு வரைபடம் (அடுக்கப்பட்ட)",
"Area Chart (100% Stacked)": "பரப்பு வரைபடம் (100% அடுக்கப்பட்ட)",
"Bubble Chart (Grouped)": "குமிழி வரைபடம் (குழுவாக்கப்பட்ட)",
"Heatmap Chart": "Heatmap Chart",
"Sort by": "இதன் வகை வரிசைப்படுத்து",
"Map": "வரைபடம்",
"Provider": "வழங்குநர்",
+1
View File
@@ -1603,6 +1603,7 @@
"Area Chart (Stacked)": "Area Chart (Stacked)",
"Area Chart (100% Stacked)": "Area Chart (100% Stacked)",
"Bubble Chart (Grouped)": "Bubble Chart (Grouped)",
"Heatmap Chart": "Heatmap Chart",
"Sort by": "จัดเรียงตาม",
"Map": "แผนที่",
"Provider": "ผู้ให้บริการ",
+1
View File
@@ -1603,6 +1603,7 @@
"Area Chart (Stacked)": "Area Chart (Stacked)",
"Area Chart (100% Stacked)": "Area Chart (100% Stacked)",
"Bubble Chart (Grouped)": "Bubble Chart (Grouped)",
"Heatmap Chart": "Heatmap Chart",
"Sort by": "Sıralama ölçütü",
"Map": "Harita",
"Provider": "Sağlayıcı",
+1
View File
@@ -1603,6 +1603,7 @@
"Area Chart (Stacked)": "Area Chart (Stacked)",
"Area Chart (100% Stacked)": "Area Chart (100% Stacked)",
"Bubble Chart (Grouped)": "Bubble Chart (Grouped)",
"Heatmap Chart": "Heatmap Chart",
"Sort by": "Сортувати за",
"Map": "Карта",
"Provider": "Провайдер",
+1
View File
@@ -1603,6 +1603,7 @@
"Area Chart (Stacked)": "Area Chart (Stacked)",
"Area Chart (100% Stacked)": "Area Chart (100% Stacked)",
"Bubble Chart (Grouped)": "Bubble Chart (Grouped)",
"Heatmap Chart": "Heatmap Chart",
"Sort by": "Sắp xếp theo",
"Map": "Bản đồ",
"Provider": "Nhà cung cấp",
+1
View File
@@ -1603,6 +1603,7 @@
"Area Chart (Stacked)": "面积图(堆叠)",
"Area Chart (100% Stacked)": "面积图(100%堆叠)",
"Bubble Chart (Grouped)": "气泡图(分组)",
"Heatmap Chart": "热力图",
"Sort by": "排序方式",
"Map": "地图",
"Provider": "提供者",
+1
View File
@@ -1603,6 +1603,7 @@
"Area Chart (Stacked)": "面積圖(堆疊)",
"Area Chart (100% Stacked)": "面積圖(100%堆疊)",
"Bubble Chart (Grouped)": "氣泡圖(分組)",
"Heatmap Chart": "熱力圖",
"Sort by": "排序方式",
"Map": "地圖",
"Provider": "提供者",
@@ -162,6 +162,28 @@
v-else-if="!loading"
/>
</v-card-text>
<v-card-text :class="{ 'readonly': loading }" v-else-if="currentExplorer.chartType === TransactionExplorerChartType.Heatmap.value">
<heat-map-chart
:skeleton="true"
:all-category-names="[]"
:items="[]"
:value-type-name="tt(TransactionExplorerValueMetric.valueOf(currentExplorer.valueMetric)?.name ?? 'Value')"
name-field="name"
values-field="values"
v-if="loading"
/>
<heat-map-chart
:show-value="true"
:all-category-names="categoriedNamesSortedByDisplayOrder"
:items="seriesDimensionTransactionExplorerData"
:value-type-name="tt(TransactionExplorerValueMetric.valueOf(currentExplorer.valueMetric)?.name ?? 'Value')"
:amount-value="TransactionExplorerValueMetric.valueOf(currentExplorer.valueMetric)?.isAmount"
:default-currency="defaultCurrency"
name-field="name"
values-field="categoryValues"
v-else-if="!loading"
/>
</v-card-text>
</template>
<script setup lang="ts">