mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 05:01:53 +00:00
feat(chart): support displaying all data labels on bar and line charts
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -2084,6 +2084,7 @@ export default {
|
||||
bar: "Bar",
|
||||
pie: "Pie",
|
||||
noNumericData: "No numeric data available for charting",
|
||||
showLabels: "Show Values",
|
||||
},
|
||||
grid: {
|
||||
rows: "{count} rows",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -1961,6 +1961,7 @@ export default withEnglishFallback({
|
||||
bar: "棒",
|
||||
pie: "円",
|
||||
noNumericData: "グラフ化できる数値データがありません",
|
||||
showLabels: "値を表示",
|
||||
},
|
||||
grid: {
|
||||
agentExecuteTime: "実行時間:{duration}",
|
||||
|
||||
@@ -1899,6 +1899,7 @@ export default withEnglishFallback({
|
||||
bar: "막대",
|
||||
pie: "파이",
|
||||
noNumericData: "차트에 사용할 숫자 데이터가 없습니다",
|
||||
showLabels: "값 표시",
|
||||
},
|
||||
grid: {
|
||||
agentExecuteTime: "실행 시간: {duration}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -1992,6 +1992,7 @@ export default withEnglishFallback({
|
||||
bar: "Столбчатая",
|
||||
pie: "Круговая",
|
||||
noNumericData: "Нет числовых данных для построения диаграммы",
|
||||
showLabels: "Показывать значения",
|
||||
},
|
||||
grid: {
|
||||
rows: "Строк: {count}",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2020,6 +2020,7 @@ export default withEnglishFallback({
|
||||
bar: "柱状图",
|
||||
pie: "饼图",
|
||||
noNumericData: "没有可用于图表的数值数据",
|
||||
showLabels: "显示数值",
|
||||
},
|
||||
grid: {
|
||||
rows: "{count} 行",
|
||||
|
||||
@@ -1944,6 +1944,7 @@ export default withEnglishFallback({
|
||||
bar: "柱狀圖",
|
||||
pie: "餅圖",
|
||||
noNumericData: "沒有可用於圖表的數值資料",
|
||||
showLabels: "顯示數值",
|
||||
},
|
||||
grid: {
|
||||
rows: "{count} 列",
|
||||
|
||||
+120
-1
@@ -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 } } : {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user