feat(chart): support displaying all data labels on bar and line charts

This commit is contained in:
lxk955
2026-10-05 02:40:00 +08:00
committed by GitHub
parent a919333418
commit 2729d83eb8
16 changed files with 345 additions and 52 deletions
@@ -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<ChartType>("bar");
const xColumnIndex = ref(0);
const yColumnIndexes = ref<number[]>([]);
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
</DropdownMenuContent>
</DropdownMenu>
</div>
<span class="h-4 w-px bg-border" />
<Button size="sm" :variant="showLabels ? 'secondary' : 'ghost'" class="h-6 px-2 text-xs" data-testid="query-chart-show-labels-btn" @click="showLabels = !showLabels">
{{ t("chart.showLabels") }}
</Button>
</div>
<div class="flex-1 min-h-0 p-2">
<VChart v-if="chartOption" :option="chartOption" autoresize class="h-full w-full" />
@@ -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);
});
});
+1
View File
@@ -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",
+1
View File
@@ -2084,6 +2084,7 @@ export default {
bar: "Bar",
pie: "Pie",
noNumericData: "No numeric data available for charting",
showLabels: "Show Values",
},
grid: {
rows: "{count} rows",
+1
View File
@@ -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}",
+1
View File
@@ -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",
+1
View File
@@ -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}",
+1
View File
@@ -1961,6 +1961,7 @@ export default withEnglishFallback({
bar: "棒",
pie: "円",
noNumericData: "グラフ化できる数値データがありません",
showLabels: "値を表示",
},
grid: {
agentExecuteTime: "実行時間:{duration}",
+1
View File
@@ -1899,6 +1899,7 @@ export default withEnglishFallback({
bar: "막대",
pie: "파이",
noNumericData: "차트에 사용할 숫자 데이터가 없습니다",
showLabels: "값 표시",
},
grid: {
agentExecuteTime: "실행 시간: {duration}",
+1
View File
@@ -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}",
+1
View File
@@ -1992,6 +1992,7 @@ export default withEnglishFallback({
bar: "Столбчатая",
pie: "Круговая",
noNumericData: "Нет числовых данных для построения диаграммы",
showLabels: "Показывать значения",
},
grid: {
rows: "Строк: {count}",
+1
View File
@@ -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",
+1
View File
@@ -2020,6 +2020,7 @@ export default withEnglishFallback({
bar: "柱状图",
pie: "饼图",
noNumericData: "没有可用于图表的数值数据",
showLabels: "显示数值",
},
grid: {
rows: "{count} 行",
+1
View File
@@ -1944,6 +1944,7 @@ export default withEnglishFallback({
bar: "柱狀圖",
pie: "餅圖",
noNumericData: "沒有可用於圖表的數值資料",
showLabels: "顯示數值",
},
grid: {
rows: "{count} 列",
+120 -1
View File
@@ -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,
});
});
});
});
@@ -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 } } : {}),
})),
};
}