mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-22 02:06:26 +00:00
fix(chart): improve query chart axis selection
This commit is contained in:
@@ -6,11 +6,13 @@ import { CanvasRenderer } from "echarts/renderers";
|
||||
import { LineChart, BarChart, PieChart } from "echarts/charts";
|
||||
import { GridComponent, TooltipComponent, LegendComponent } from "echarts/components";
|
||||
import VChart from "vue-echarts";
|
||||
import { BarChart3 } from "@lucide/vue";
|
||||
import { BarChart3, ChevronDown } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
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/chartData";
|
||||
|
||||
use([CanvasRenderer, LineChart, BarChart, PieChart, GridComponent, TooltipComponent, LegendComponent]);
|
||||
|
||||
@@ -23,41 +25,58 @@ const { isDark } = useTheme();
|
||||
|
||||
type ChartType = "line" | "bar" | "pie";
|
||||
const chartType = ref<ChartType>("bar");
|
||||
const xColumn = ref("");
|
||||
const yColumns = ref<string[]>([]);
|
||||
const xColumnIndex = ref(0);
|
||||
const yColumnIndexes = ref<number[]>([]);
|
||||
|
||||
const numericColumns = computed(() => props.result.columns.filter((_, idx) => props.result.rows.some((row) => typeof row[idx] === "number")));
|
||||
const numericColumnIndexes = computed(() => chartableColumnIndexes(props.result));
|
||||
|
||||
const allColumns = computed(() => props.result.columns);
|
||||
const allColumnOptions = computed(() => props.result.columns.map((_, index) => ({ index, label: axisColumnLabel(props.result.columns, index) })));
|
||||
const numericColumnOptions = computed(() => numericColumnIndexes.value.map((index) => ({ index, label: axisColumnLabel(props.result.columns, index) })));
|
||||
const xColumnValue = computed({
|
||||
get: () => String(xColumnIndex.value),
|
||||
set: (value: string) => {
|
||||
const index = Number(value);
|
||||
if (Number.isInteger(index) && index >= 0 && index < props.result.columns.length) {
|
||||
xColumnIndex.value = index;
|
||||
}
|
||||
},
|
||||
});
|
||||
const yColumnLabel = computed(() => {
|
||||
if (yColumnIndexes.value.length === 0) return "0";
|
||||
const [first, ...rest] = yColumnIndexes.value;
|
||||
const label = axisColumnLabel(props.result.columns, first);
|
||||
return rest.length > 0 ? `${label} +${rest.length}` : label;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.result,
|
||||
() => {
|
||||
const cols = props.result.columns;
|
||||
const numCols = numericColumns.value;
|
||||
xColumn.value = cols.find((c) => !numCols.includes(c)) || cols[0] || "";
|
||||
yColumns.value = numCols.length > 0 ? [numCols[0]] : [];
|
||||
const numCols = numericColumnIndexes.value;
|
||||
xColumnIndex.value = cols.findIndex((_, index) => !numCols.includes(index));
|
||||
if (xColumnIndex.value < 0) xColumnIndex.value = cols.length > 0 ? 0 : -1;
|
||||
yColumnIndexes.value = numCols.length > 0 ? [numCols[0]] : [];
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function toggleYColumn(col: string) {
|
||||
const idx = yColumns.value.indexOf(col);
|
||||
function toggleYColumn(index: number) {
|
||||
const idx = yColumnIndexes.value.indexOf(index);
|
||||
if (idx >= 0) {
|
||||
yColumns.value = yColumns.value.filter((c) => c !== col);
|
||||
yColumnIndexes.value = yColumnIndexes.value.filter((selected) => selected !== index);
|
||||
} else {
|
||||
yColumns.value = [...yColumns.value, col];
|
||||
yColumnIndexes.value = [...yColumnIndexes.value, index];
|
||||
}
|
||||
}
|
||||
|
||||
const chartOption = computed(() => {
|
||||
const xIdx = props.result.columns.indexOf(xColumn.value);
|
||||
if (xIdx < 0 || yColumns.value.length === 0) return null;
|
||||
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 = props.result.columns.indexOf(yColumns.value[0]);
|
||||
const yIdx = yColumnIndexes.value[0];
|
||||
if (yIdx < 0) return null;
|
||||
return {
|
||||
tooltip: { trigger: "item" },
|
||||
@@ -68,14 +87,14 @@ const chartOption = computed(() => {
|
||||
radius: ["30%", "60%"],
|
||||
data: xData.map((name, i) => ({
|
||||
name,
|
||||
value: Number(props.result.rows[i][yIdx]) || 0,
|
||||
value: toChartNumber(props.result.rows[i][yIdx]) ?? 0,
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const yIndices = yColumns.value.map((c) => props.result.columns.indexOf(c)).filter((i) => i >= 0);
|
||||
const yIndices = yColumnIndexes.value.filter((index) => index >= 0 && index < props.result.columns.length);
|
||||
|
||||
return {
|
||||
tooltip: { trigger: "axis" },
|
||||
@@ -94,15 +113,15 @@ const chartOption = computed(() => {
|
||||
axisLabel: { color: isDark.value ? "#aaa" : "#666" },
|
||||
},
|
||||
series: yIndices.map((yIdx) => ({
|
||||
name: props.result.columns[yIdx],
|
||||
name: axisColumnLabel(props.result.columns, yIdx),
|
||||
type: chartType.value,
|
||||
data: props.result.rows.map((row) => Number(row[yIdx]) || 0),
|
||||
data: props.result.rows.map((row) => toChartNumber(row[yIdx]) ?? 0),
|
||||
smooth: chartType.value === "line",
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const hasData = computed(() => props.result.rows.length > 0 && numericColumns.value.length > 0);
|
||||
const hasData = computed(() => props.result.rows.length > 0 && numericColumnIndexes.value.length > 0);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -124,23 +143,31 @@ const hasData = computed(() => props.result.rows.length > 0 && numericColumns.va
|
||||
<span class="h-4 w-px bg-border" />
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-muted-foreground">X</span>
|
||||
<Select :model-value="xColumn" @update:model-value="(v: any) => (xColumn = v)">
|
||||
<Select v-model="xColumnValue">
|
||||
<SelectTrigger class="h-6 w-auto max-w-40 border-0 bg-transparent px-1 text-xs shadow-none focus:ring-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="col in allColumns" :key="col" :value="col">{{ col }}</SelectItem>
|
||||
<SelectItem v-for="col in allColumnOptions" :key="col.index" :value="String(col.index)">{{ col.label }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<span class="h-4 w-px bg-border" />
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-muted-foreground">Y</span>
|
||||
<div class="flex gap-0.5">
|
||||
<Button v-for="col in numericColumns" :key="col" size="sm" :variant="yColumns.includes(col) ? 'secondary' : 'ghost'" class="h-6 px-2 text-xs" @click="toggleYColumn(col)">
|
||||
{{ col }}
|
||||
</Button>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-6 max-w-48 gap-1 px-2 text-xs">
|
||||
<span class="truncate">{{ yColumnLabel }}</span>
|
||||
<ChevronDown class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="w-56" align="start" @close-auto-focus.prevent>
|
||||
<DropdownMenuCheckboxItem v-for="col in numericColumnOptions" :key="col.index" :checked="yColumnIndexes.includes(col.index)" class="text-xs" @select.prevent @click="toggleYColumn(col.index)">
|
||||
<span class="truncate">{{ col.label }}</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0 p-2">
|
||||
|
||||
@@ -46,6 +46,7 @@ import { isTableDataEditable } from "@/lib/tableEditing";
|
||||
import { tableMetaForDataTab } from "@/lib/tableDataTabMeta";
|
||||
import { formatShortcut } from "@/lib/shortcutRegistry";
|
||||
import { effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect";
|
||||
import { chartableColumnIndexes } from "@/lib/chartData";
|
||||
import { useTabScroll } from "@/composables/useTabScroll";
|
||||
import type { QueryTab, ConnectionConfig } from "@/types/database";
|
||||
import type { SqlFormatDialect } from "@/lib/sqlFormatter";
|
||||
@@ -203,7 +204,7 @@ const resultTabsScrollbarThumbStyle = computed<CSSProperties>(() => ({
|
||||
const hasNumericData = computed(() => {
|
||||
const r = props.activeTab.result;
|
||||
if (!r || r.rows.length === 0) return false;
|
||||
return r.columns.some((_, idx) => r.rows.some((row) => typeof row[idx] === "number"));
|
||||
return chartableColumnIndexes(r).length > 0;
|
||||
});
|
||||
|
||||
const activeQueryError = computed(() => {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { axisColumnLabel, chartableColumnIndexes, toChartNumber } from "@/lib/chartData";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
|
||||
function result(columns: string[], rows: QueryResult["rows"]): QueryResult {
|
||||
return {
|
||||
columns,
|
||||
rows,
|
||||
affected_rows: rows.length,
|
||||
execution_time_ms: 1,
|
||||
};
|
||||
}
|
||||
|
||||
describe("chartData", () => {
|
||||
it("accepts finite numbers and numeric strings", () => {
|
||||
expect(toChartNumber(42)).toBe(42);
|
||||
expect(toChartNumber("42.5")).toBe(42.5);
|
||||
expect(toChartNumber(" 1e3 ")).toBe(1000);
|
||||
});
|
||||
|
||||
it("rejects non-finite and non-numeric values", () => {
|
||||
expect(toChartNumber("")).toBeNull();
|
||||
expect(toChartNumber("abc")).toBeNull();
|
||||
expect(toChartNumber(null)).toBeNull();
|
||||
expect(toChartNumber(true)).toBeNull();
|
||||
});
|
||||
|
||||
it("finds numeric columns returned as strings", () => {
|
||||
expect(
|
||||
chartableColumnIndexes(
|
||||
result(
|
||||
["name", "decimal_total", "status"],
|
||||
[
|
||||
["a", "12.34", "ok"],
|
||||
["b", "56.78", "ok"],
|
||||
],
|
||||
),
|
||||
),
|
||||
).toEqual([1]);
|
||||
});
|
||||
|
||||
it("disambiguates duplicate axis labels by index", () => {
|
||||
expect(axisColumnLabel(["amount", "amount"], 0)).toBe("amount #1");
|
||||
expect(axisColumnLabel(["amount", "amount"], 1)).toBe("amount #2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { QueryResult } from "@/types/database";
|
||||
|
||||
export function toChartNumber(value: QueryResult["rows"][number][number]): number | null {
|
||||
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
||||
if (typeof value !== "string") return null;
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "") return null;
|
||||
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
export function isChartableValue(value: QueryResult["rows"][number][number]): boolean {
|
||||
return toChartNumber(value) !== null;
|
||||
}
|
||||
|
||||
export function chartableColumnIndexes(result: QueryResult): number[] {
|
||||
return result.columns.map((_, index) => index).filter((index) => result.rows.some((row) => isChartableValue(row[index])));
|
||||
}
|
||||
|
||||
export function axisColumnLabel(columns: string[], index: number): string {
|
||||
const name = columns[index] ?? `#${index + 1}`;
|
||||
if (columns.filter((column) => column === name).length <= 1) return name;
|
||||
return `${name} #${index + 1}`;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
import type { ConnectionConfig, DatabaseType } from "@/types/database";
|
||||
|
||||
export const CONNECTION_ATTEMPT_TIMEOUT_BUFFER_MS = 2_000;
|
||||
export const MONGO_LEGACY_FALLBACK_TIMEOUT_BUFFER_MS = 30_000;
|
||||
|
||||
Reference in New Issue
Block a user