From 2729d83eb8ecadec19767404d81920ab49a5982e Mon Sep 17 00:00:00 2001 From: lxk955 <164191365+lxk955@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:40:00 +0800 Subject: [PATCH] feat(chart): support displaying all data labels on bar and line charts --- .../src/components/chart/QueryChart.vue | 82 +++++------- .../chart/__tests__/QueryChart.spec.ts | 106 +++++++++++++++ apps/desktop/src/i18n/locales/az.ts | 1 + apps/desktop/src/i18n/locales/en.ts | 1 + apps/desktop/src/i18n/locales/es.ts | 1 + apps/desktop/src/i18n/locales/id.ts | 1 + apps/desktop/src/i18n/locales/it.ts | 1 + apps/desktop/src/i18n/locales/ja.ts | 1 + apps/desktop/src/i18n/locales/ko.ts | 1 + apps/desktop/src/i18n/locales/pt-BR.ts | 1 + apps/desktop/src/i18n/locales/ru.ts | 1 + apps/desktop/src/i18n/locales/tr.ts | 1 + apps/desktop/src/i18n/locales/zh-CN.ts | 1 + apps/desktop/src/i18n/locales/zh-TW.ts | 1 + .../lib/__tests__/dataGrid/chartData.spec.ts | 121 +++++++++++++++++- apps/desktop/src/lib/dataGrid/chartData.ts | 76 +++++++++++ 16 files changed, 345 insertions(+), 52 deletions(-) create mode 100644 apps/desktop/src/components/chart/__tests__/QueryChart.spec.ts diff --git a/apps/desktop/src/components/chart/QueryChart.vue b/apps/desktop/src/components/chart/QueryChart.vue index 0f0478130f..53c14a6f80 100644 --- a/apps/desktop/src/components/chart/QueryChart.vue +++ b/apps/desktop/src/components/chart/QueryChart.vue @@ -12,7 +12,7 @@ import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMe import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import type { QueryResult } from "@/types/database"; import { useTheme } from "@/composables/useTheme"; -import { axisColumnLabel, chartableColumnIndexes, toChartNumber } from "@/lib/dataGrid/chartData"; +import { axisColumnLabel, chartableColumnIndexes, buildQueryChartOption, type ChartType } from "@/lib/dataGrid/chartData"; use([CanvasRenderer, LineChart, BarChart, PieChart, GridComponent, TooltipComponent, LegendComponent]); @@ -23,10 +23,28 @@ const props = defineProps<{ const { t } = useI18n(); const { isDark } = useTheme(); -type ChartType = "line" | "bar" | "pie"; +const QUERY_CHART_SHOW_LABELS_KEY = "dbx-query-chart-show-labels"; + +function readShowLabelsPreference(): boolean { + try { + return typeof localStorage !== "undefined" && localStorage.getItem(QUERY_CHART_SHOW_LABELS_KEY) === "true"; + } catch { + return false; + } +} + const chartType = ref("bar"); const xColumnIndex = ref(0); const yColumnIndexes = ref([]); +const showLabels = ref(readShowLabelsPreference()); + +watch(showLabels, (val) => { + try { + localStorage.setItem(QUERY_CHART_SHOW_LABELS_KEY, String(val)); + } catch { + // ignore + } +}); const numericColumnIndexes = computed(() => chartableColumnIndexes(props.result)); @@ -70,55 +88,13 @@ function setYColumn(index: number, selected: boolean | "indeterminate") { } const chartOption = computed(() => { - const xIdx = xColumnIndex.value; - if (xIdx < 0 || yColumnIndexes.value.length === 0) return null; - - const xData = props.result.rows.map((row) => String(row[xIdx] ?? "")); - - if (chartType.value === "pie") { - const yIdx = yColumnIndexes.value[0]; - if (yIdx < 0) return null; - return { - tooltip: { trigger: "item" }, - legend: { bottom: 0, textStyle: { color: isDark.value ? "#ccc" : "#333" } }, - series: [ - { - type: "pie", - radius: ["30%", "60%"], - data: xData.map((name, i) => ({ - name, - value: toChartNumber(props.result.rows[i][yIdx]) ?? 0, - })), - }, - ], - }; - } - - const yIndices = yColumnIndexes.value.filter((index) => index >= 0 && index < props.result.columns.length); - - return { - tooltip: { trigger: "axis" }, - legend: { - bottom: 0, - textStyle: { color: isDark.value ? "#ccc" : "#333" }, - }, - grid: { left: 60, right: 20, top: 20, bottom: 40 }, - xAxis: { - type: "category" as const, - data: xData, - axisLabel: { color: isDark.value ? "#aaa" : "#666" }, - }, - yAxis: { - type: "value" as const, - axisLabel: { color: isDark.value ? "#aaa" : "#666" }, - }, - series: yIndices.map((yIdx) => ({ - name: axisColumnLabel(props.result.columns, yIdx), - type: chartType.value, - data: props.result.rows.map((row) => toChartNumber(row[yIdx]) ?? 0), - smooth: chartType.value === "line", - })), - }; + return buildQueryChartOption(props.result, { + chartType: chartType.value, + xColumnIndex: xColumnIndex.value, + yColumnIndexes: yColumnIndexes.value, + showLabels: showLabels.value, + isDark: isDark.value, + }); }); const hasData = computed(() => props.result.rows.length > 0 && numericColumnIndexes.value.length > 0); @@ -169,6 +145,10 @@ const hasData = computed(() => props.result.rows.length > 0 && numericColumnInde + +
diff --git a/apps/desktop/src/components/chart/__tests__/QueryChart.spec.ts b/apps/desktop/src/components/chart/__tests__/QueryChart.spec.ts new file mode 100644 index 0000000000..7288f6c58f --- /dev/null +++ b/apps/desktop/src/components/chart/__tests__/QueryChart.spec.ts @@ -0,0 +1,106 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createApp, nextTick, type App } from "vue"; +import QueryChart from "../QueryChart.vue"; +import type { QueryResult } from "@/types/database"; + +vi.mock("vue-i18n", () => ({ + useI18n: () => ({ t: (key: string) => key }), +})); + +vi.mock("@/composables/useTheme", () => ({ + useTheme: () => ({ isDark: { value: false } }), +})); + +vi.mock("echarts/core", () => ({ use: vi.fn() })); +vi.mock("echarts/renderers", () => ({ CanvasRenderer: {} })); +vi.mock("echarts/charts", () => ({ LineChart: {}, BarChart: {}, PieChart: {} })); +vi.mock("echarts/components", () => ({ GridComponent: {}, LegendComponent: {}, TooltipComponent: {} })); + +vi.mock("vue-echarts", async () => { + const { defineComponent, h } = await import("vue"); + return { + default: defineComponent({ + name: "VChart", + props: { option: { type: Object, default: () => ({}) } }, + setup: (props) => () => h("div", { "data-chart-option": JSON.stringify(props.option) }), + }), + }; +}); + +function createQueryResult(): QueryResult { + return { + columns: ["item", "amount"], + rows: [ + ["A", 100], + ["B", 200], + ], + affected_rows: 2, + execution_time_ms: 5, + }; +} + +describe("QueryChart", () => { + let app: App | null = null; + let container: HTMLDivElement | null = null; + + beforeEach(() => { + localStorage.clear(); + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + if (app) { + app.unmount(); + app = null; + } + if (container) { + container.remove(); + container = null; + } + localStorage.clear(); + }); + + it("renders showLabels toggle button and updates chart option on click", async () => { + app = createApp(QueryChart, { result: createQueryResult() }); + app.mount(container!); + await nextTick(); + + const btn = container!.querySelector('[data-testid="query-chart-show-labels-btn"]') as HTMLButtonElement; + expect(btn).not.toBeNull(); + expect(btn.textContent).toContain("chart.showLabels"); + + const vchart = container!.querySelector("[data-chart-option]") as HTMLDivElement; + let option = JSON.parse(vchart.getAttribute("data-chart-option") || "{}"); + expect(option.series[0].label.show).toBe(false); + + btn.click(); + await nextTick(); + + expect(localStorage.getItem("dbx-query-chart-show-labels")).toBe("true"); + option = JSON.parse(vchart.getAttribute("data-chart-option") || "{}"); + expect(option.series[0].label.show).toBe(true); + expect(option.series[0].label.position).toBe("top"); + + btn.click(); + await nextTick(); + + expect(localStorage.getItem("dbx-query-chart-show-labels")).toBe("false"); + option = JSON.parse(vchart.getAttribute("data-chart-option") || "{}"); + expect(option.series[0].label.show).toBe(false); + }); + + it("initializes showLabels from localStorage if previously enabled", async () => { + localStorage.setItem("dbx-query-chart-show-labels", "true"); + + app = createApp(QueryChart, { result: createQueryResult() }); + app.mount(container!); + await nextTick(); + + const vchart = container!.querySelector("[data-chart-option]") as HTMLDivElement; + const option = JSON.parse(vchart.getAttribute("data-chart-option") || "{}"); + expect(option.series[0].label.show).toBe(true); + }); +}); diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index 1104bf15b2..1352913973 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -1751,6 +1751,7 @@ export default withEnglishFallback({ bar: "Sütunlu", pie: "Dairəvi", noNumericData: "Diaqram qurmaq üçün ədədi verilənlər yoxdur", + showLabels: "Dəyərləri göstər", }, grid: { rows: "{count} sətir", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 647ea1bda4..a666efd7ff 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2084,6 +2084,7 @@ export default { bar: "Bar", pie: "Pie", noNumericData: "No numeric data available for charting", + showLabels: "Show Values", }, grid: { rows: "{count} rows", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 10665ca0f8..e4fdd75a59 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -2040,6 +2040,7 @@ export default withEnglishFallback({ bar: "Barras", pie: "Circular", noNumericData: "No hay datos numéricos disponibles para graficar", + showLabels: "Mostrar valores", }, grid: { agentExecuteTime: "Ejecución: {duration}", diff --git a/apps/desktop/src/i18n/locales/id.ts b/apps/desktop/src/i18n/locales/id.ts index 39e27966db..7d8d216e90 100644 --- a/apps/desktop/src/i18n/locales/id.ts +++ b/apps/desktop/src/i18n/locales/id.ts @@ -1991,6 +1991,7 @@ export default withEnglishFallback({ bar: "Batang", pie: "Pai", noNumericData: "Tidak ada data numerik yang tersedia untuk membuat chart", + showLabels: "Tampilkan Nilai", }, grid: { rows: "{count} baris", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index dda51e088f..1eb3f45041 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1939,6 +1939,7 @@ export default withEnglishFallback({ bar: "Barre", pie: "Torta", noNumericData: "Nessun dato numerico disponibile per il grafico", + showLabels: "Mostra valori", }, grid: { agentExecuteTime: "Esecuzione: {duration}", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index f9b2878054..c81d6b40c1 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1961,6 +1961,7 @@ export default withEnglishFallback({ bar: "棒", pie: "円", noNumericData: "グラフ化できる数値データがありません", + showLabels: "値を表示", }, grid: { agentExecuteTime: "実行時間:{duration}", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index de882560a6..f00ecd2451 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -1899,6 +1899,7 @@ export default withEnglishFallback({ bar: "막대", pie: "파이", noNumericData: "차트에 사용할 숫자 데이터가 없습니다", + showLabels: "값 표시", }, grid: { agentExecuteTime: "실행 시간: {duration}", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index f0ac1d2141..56cee1ce1a 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1941,6 +1941,7 @@ export default withEnglishFallback({ bar: "Barra", pie: "Pizza", noNumericData: "Nenhum dado numérico disponível para gerar o gráfico", + showLabels: "Mostrar valores", }, grid: { agentExecuteTime: "Execução: {duration}", diff --git a/apps/desktop/src/i18n/locales/ru.ts b/apps/desktop/src/i18n/locales/ru.ts index 1871f1cce4..80f2b903b5 100644 --- a/apps/desktop/src/i18n/locales/ru.ts +++ b/apps/desktop/src/i18n/locales/ru.ts @@ -1992,6 +1992,7 @@ export default withEnglishFallback({ bar: "Столбчатая", pie: "Круговая", noNumericData: "Нет числовых данных для построения диаграммы", + showLabels: "Показывать значения", }, grid: { rows: "Строк: {count}", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index dd1f9b8aa6..c331a80fdc 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -1750,6 +1750,7 @@ export default withEnglishFallback({ bar: "Sütun", pie: "Pasta", noNumericData: "Grafik için sayısal veri yok", + showLabels: "Değerleri Göster", }, grid: { rows: "{count} satır", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 16f0b4a7a9..34e9c875cc 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2020,6 +2020,7 @@ export default withEnglishFallback({ bar: "柱状图", pie: "饼图", noNumericData: "没有可用于图表的数值数据", + showLabels: "显示数值", }, grid: { rows: "{count} 行", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 920249f81d..1d25ae01a8 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1944,6 +1944,7 @@ export default withEnglishFallback({ bar: "柱狀圖", pie: "餅圖", noNumericData: "沒有可用於圖表的數值資料", + showLabels: "顯示數值", }, grid: { rows: "{count} 列", diff --git a/apps/desktop/src/lib/__tests__/dataGrid/chartData.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/chartData.spec.ts index 825e462746..38b04dfb37 100644 --- a/apps/desktop/src/lib/__tests__/dataGrid/chartData.spec.ts +++ b/apps/desktop/src/lib/__tests__/dataGrid/chartData.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { axisColumnLabel, chartableColumnIndexes, toChartNumber } from "@/lib/dataGrid/chartData"; +import { axisColumnLabel, buildQueryChartOption, chartableColumnIndexes, toChartNumber } from "@/lib/dataGrid/chartData"; import type { QueryResult } from "@/types/database"; function result(columns: string[], rows: QueryResult["rows"]): QueryResult { @@ -43,4 +43,123 @@ describe("chartData", () => { expect(axisColumnLabel(["amount", "amount"], 0)).toBe("amount #1"); expect(axisColumnLabel(["amount", "amount"], 1)).toBe("amount #2"); }); + + describe("buildQueryChartOption", () => { + const testResult = result( + ["category", "sales", "cost"], + [ + ["Product A", 150, 90], + ["Product B", 280, 140], + ], + ); + + it("returns null when xColumnIndex is negative or yColumnIndexes is empty", () => { + expect(buildQueryChartOption(testResult, { chartType: "bar", xColumnIndex: -1, yColumnIndexes: [1] })).toBeNull(); + expect(buildQueryChartOption(testResult, { chartType: "bar", xColumnIndex: 0, yColumnIndexes: [] })).toBeNull(); + expect(buildQueryChartOption(testResult, { chartType: "bar", xColumnIndex: 0, yColumnIndexes: [99] })).toBeNull(); + }); + + it("builds bar chart with showLabels disabled by default", () => { + const option = buildQueryChartOption(testResult, { + chartType: "bar", + xColumnIndex: 0, + yColumnIndexes: [1], + }); + + expect(option).not.toBeNull(); + expect(option?.grid).toEqual({ left: 60, right: 20, top: 20, bottom: 40 }); + expect(option?.series).toHaveLength(1); + expect(option?.series[0]).toMatchObject({ + name: "sales", + type: "bar", + data: [150, 280], + label: { + show: false, + position: "top", + color: "#333", + fontSize: 11, + }, + }); + expect(option?.series[0].labelLayout).toBeUndefined(); + }); + + it("builds bar chart with showLabels enabled and dark theme", () => { + const option = buildQueryChartOption(testResult, { + chartType: "bar", + xColumnIndex: 0, + yColumnIndexes: [1, 2], + showLabels: true, + isDark: true, + }); + + expect(option).not.toBeNull(); + expect(option?.grid).toEqual({ left: 60, right: 20, top: 28, bottom: 40 }); + expect(option?.series).toHaveLength(2); + expect(option?.series[0].label).toEqual({ + show: true, + position: "top", + color: "#ccc", + fontSize: 11, + }); + expect(option?.series[1].label).toEqual({ + show: true, + position: "top", + color: "#ccc", + fontSize: 11, + }); + expect(option?.series[0].labelLayout).toEqual({ hideOverlap: true }); + expect(option?.series[1].labelLayout).toEqual({ hideOverlap: true }); + }); + + it("builds line chart with showLabels enabled", () => { + const option = buildQueryChartOption(testResult, { + chartType: "line", + xColumnIndex: 0, + yColumnIndexes: [1], + showLabels: true, + isDark: false, + }); + + expect(option).not.toBeNull(); + expect(option?.series[0]).toMatchObject({ + name: "sales", + type: "line", + smooth: true, + data: [150, 280], + label: { + show: true, + position: "top", + color: "#333", + }, + }); + }); + + it("builds pie chart formatting values when showLabels is enabled", () => { + const withoutLabels = buildQueryChartOption(testResult, { + chartType: "pie", + xColumnIndex: 0, + yColumnIndexes: [1], + showLabels: false, + }); + expect(withoutLabels?.series[0].label).toEqual({ + show: true, + formatter: "{b}", + color: "#333", + fontSize: 11, + }); + + const withLabels = buildQueryChartOption(testResult, { + chartType: "pie", + xColumnIndex: 0, + yColumnIndexes: [1], + showLabels: true, + }); + expect(withLabels?.series[0].label).toEqual({ + show: true, + formatter: "{b}: {c}", + color: "#333", + fontSize: 11, + }); + }); + }); }); diff --git a/apps/desktop/src/lib/dataGrid/chartData.ts b/apps/desktop/src/lib/dataGrid/chartData.ts index c70bb37fda..07d5cc56c6 100644 --- a/apps/desktop/src/lib/dataGrid/chartData.ts +++ b/apps/desktop/src/lib/dataGrid/chartData.ts @@ -24,3 +24,79 @@ export function axisColumnLabel(columns: string[], index: number): string { if (columns.filter((column) => column === name).length <= 1) return name; return `${name} #${index + 1}`; } + +export type ChartType = "line" | "bar" | "pie"; + +export interface QueryChartOptionConfig { + chartType: ChartType; + xColumnIndex: number; + yColumnIndexes: number[]; + showLabels?: boolean; + isDark?: boolean; +} + +export function buildQueryChartOption(result: QueryResult, config: QueryChartOptionConfig) { + const { chartType, xColumnIndex, yColumnIndexes, showLabels = false, isDark = false } = config; + if (xColumnIndex < 0 || yColumnIndexes.length === 0) return null; + + const xData = result.rows.map((row) => String(row[xColumnIndex] ?? "")); + + if (chartType === "pie") { + const yIdx = yColumnIndexes[0]; + if (yIdx < 0 || yIdx >= result.columns.length) return null; + return { + tooltip: { trigger: "item" }, + legend: { bottom: 0, textStyle: { color: isDark ? "#ccc" : "#333" } }, + series: [ + { + type: "pie", + radius: ["30%", "60%"], + data: xData.map((name, i) => ({ + name, + value: toChartNumber(result.rows[i]?.[yIdx]) ?? 0, + })), + label: { + show: true, + formatter: showLabels ? "{b}: {c}" : "{b}", + color: isDark ? "#ccc" : "#333", + fontSize: 11, + }, + }, + ], + }; + } + + const yIndices = yColumnIndexes.filter((index) => index >= 0 && index < result.columns.length); + if (yIndices.length === 0) return null; + + return { + tooltip: { trigger: "axis" }, + legend: { + bottom: 0, + textStyle: { color: isDark ? "#ccc" : "#333" }, + }, + grid: { left: 60, right: 20, top: showLabels ? 28 : 20, bottom: 40 }, + xAxis: { + type: "category" as const, + data: xData, + axisLabel: { color: isDark ? "#aaa" : "#666" }, + }, + yAxis: { + type: "value" as const, + axisLabel: { color: isDark ? "#aaa" : "#666" }, + }, + series: yIndices.map((yIdx) => ({ + name: axisColumnLabel(result.columns, yIdx), + type: chartType, + data: result.rows.map((row) => toChartNumber(row[yIdx]) ?? 0), + smooth: chartType === "line", + label: { + show: showLabels, + position: "top" as const, + color: isDark ? "#ccc" : "#333", + fontSize: 11, + }, + ...(showLabels ? { labelLayout: { hideOverlap: true } } : {}), + })), + }; +}