diff --git a/apps/tauri/package.json b/apps/tauri/package.json index 4975a827..130f935b 100644 --- a/apps/tauri/package.json +++ b/apps/tauri/package.json @@ -27,6 +27,7 @@ "@vue-flow/controls": "^1.1.3", "@vue-flow/core": "^1.48.2", "codemirror": "^6.0.2", + "echarts": "^6.1.0", "lodash": "^4.18.1", "marked": "^4.3.0", "material-symbols": "^0.49.0", diff --git a/apps/tauri/pnpm-lock.yaml b/apps/tauri/pnpm-lock.yaml index 1b12402d..3612763c 100644 --- a/apps/tauri/pnpm-lock.yaml +++ b/apps/tauri/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: codemirror: specifier: ^6.0.2 version: 6.0.2 + echarts: + specifier: ^6.1.0 + version: 6.1.0 lodash: specifier: ^4.18.1 version: 4.18.1 @@ -961,6 +964,9 @@ packages: resolution: {integrity: sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==} engines: {node: '>=0.10.0'} + echarts@6.1.0: + resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==} + electron-to-chromium@1.5.485: resolution: {integrity: sha512-m3uadzR0Z+sR19HGWgfxoZcKv9hE/edVM9OZ+FJ4wNRyfuFtkwZJIDoehEty5sisYVwmge8lKV1tenWOyClVgw==} @@ -1252,6 +1258,9 @@ packages: resolution: {integrity: sha512-uivwYcQaxAucv1CzRp2n/QdYPo4ILf9VXgH19zEIjFx2EJufV16P0JtJVpYHy89DItG6Kwj2oIUjrcK5au+4tQ==} engines: {node: '>=8'} + tslib@2.3.0: + resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + tslib@2.9.1: resolution: {integrity: sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==} @@ -1382,6 +1391,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zrender@6.1.0: + resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==} + snapshots: '@babel/code-frame@7.29.7': @@ -2218,6 +2230,11 @@ snapshots: deepmerge@2.2.1: {} + echarts@6.1.0: + dependencies: + tslib: 2.3.0 + zrender: 6.1.0 + electron-to-chromium@1.5.485: {} entities@7.0.1: {} @@ -2496,6 +2513,8 @@ snapshots: dependencies: tslib: 2.9.1 + tslib@2.3.0: {} + tslib@2.9.1: {} typescript@5.8.3: {} @@ -2609,3 +2628,7 @@ snapshots: yallist@3.1.1: {} yocto-queue@0.1.0: {} + + zrender@6.1.0: + dependencies: + tslib: 2.3.0 diff --git a/apps/tauri/src/charts/chartData.test.ts b/apps/tauri/src/charts/chartData.test.ts new file mode 100644 index 00000000..25cc0e4c --- /dev/null +++ b/apps/tauri/src/charts/chartData.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { + buildChartSeries, + defaultAggregation, + numericColumns, + parseNumber, +} from "./chartData"; + +describe("chartData", () => { + const columns = ["city", "sales", "qty"]; + const rows = [ + ["Paris", "10", "2"], + ["Paris", "5", "1"], + ["Berlin", "7", "3"], + ]; + + it("detects numeric columns", () => { + expect(numericColumns(columns, rows)).toEqual([1, 2]); + expect(parseNumber(" 3.5 ")).toBe(3.5); + expect(parseNumber("x")).toBeNull(); + }); + + it("defaults aggregation to sum when labels repeat", () => { + expect( + defaultAggregation({ rows, labelColumn: 0, valueColumn: 1 }), + ).toBe("sum"); + expect( + defaultAggregation({ rows, labelColumn: null, valueColumn: 1 }), + ).toBe("none"); + }); + + it("builds bar series with topN Other bucket", () => { + const series = buildChartSeries({ + rows, + labelColumn: 0, + valueColumn: 1, + type: "bar", + aggregation: "sum", + topN: 1, + }); + expect(series.points).toHaveLength(2); + expect(series.points[0]?.label).toBe("Paris"); + expect(series.points[0]?.value).toBe(15); + expect(series.points[1]?.label).toBe("Other"); + expect(series.points[1]?.value).toBe(7); + }); + + it("caps line series", () => { + const many = Array.from({ length: 60 }, (_, i) => [ + String(i), + String(i), + ]); + const series = buildChartSeries({ + rows: many, + labelColumn: 0, + valueColumn: 1, + type: "line", + }); + expect(series.points).toHaveLength(50); + expect(series.droppedRows).toBe(10); + }); +}); diff --git a/apps/tauri/src/charts/chartData.ts b/apps/tauri/src/charts/chartData.ts new file mode 100644 index 00000000..2f793354 --- /dev/null +++ b/apps/tauri/src/charts/chartData.ts @@ -0,0 +1,248 @@ +/** Pure helpers — Flutter `ChartData` parity for Quick Charts (#1899). */ + +export type QuickChartType = "bar" | "line" | "pie"; + +export type ChartAggregation = "none" | "sum" | "count" | "avg" | "min" | "max"; + +export type ChartPoint = { + label: string; + value: number; + time?: Date | null; +}; + +export type ChartSeries = { + points: ChartPoint[]; + droppedRows: number; +}; + +export const MAX_LINE_POINTS = 50; +export const DEFAULT_TOP_N = 20; +export const PIE_TOP_N = 8; +export const OTHER_LABEL = "Other"; +export const TOP_N_CHOICES: (number | null)[] = [8, 10, 20, 50, null]; + +export function parseNumber(raw: string): number | null { + const text = raw.trim(); + if (!text) return null; + const value = Number(text); + if (!Number.isFinite(value)) return null; + return value; +} + +/** ISO-ish date / timestamp; null when it does not fit. */ +export function parseTime(raw: string): Date | null { + const s = raw.trim(); + if (s.length < 10) return null; + if (!/^\d{4}-\d{2}-\d{2}/.test(s)) return null; + const d = new Date(s); + return Number.isNaN(d.getTime()) ? null : d; +} + +export function numericColumns( + columns: string[], + rows: string[][], +): number[] { + const result: number[] = []; + for (let c = 0; c < columns.length; c++) { + let seen = 0; + let ok = true; + for (const row of rows) { + if (c >= row.length) continue; + const cell = (row[c] ?? "").trim(); + if (!cell || cell === "NULL") continue; + if (parseNumber(cell) == null) { + ok = false; + break; + } + seen++; + } + if (ok && seen > 0) result.push(c); + } + return result; +} + +export function isTimeColumn(rows: string[][], labelColumn: number): boolean { + let seen = 0; + for (const row of rows) { + if (labelColumn >= row.length) continue; + const cell = (row[labelColumn] ?? "").trim(); + if (!cell || cell === "NULL") continue; + if (!parseTime(cell)) return false; + seen++; + } + return seen > 0; +} + +export function defaultAggregation(opts: { + rows: string[][]; + labelColumn: number | null; + valueColumn: number; +}): ChartAggregation { + const { rows, labelColumn, valueColumn } = opts; + if (labelColumn == null) return "none"; + const seen = new Set(); + for (const row of rows) { + if (labelColumn >= row.length || valueColumn >= row.length) continue; + if (parseNumber(row[valueColumn] ?? "") == null) continue; + const label = row[labelColumn] ?? ""; + if (seen.has(label)) return "sum"; + seen.add(label); + } + return "none"; +} + +function parsePoints( + rows: string[][], + labelColumn: number | null, + valueColumn: number, +): ChartPoint[] { + const points: ChartPoint[] = []; + for (let i = 0; i < rows.length; i++) { + const row = rows[i] ?? []; + if (valueColumn >= row.length) continue; + const v = parseNumber(row[valueColumn] ?? ""); + if (v == null) continue; + const label = + labelColumn != null && labelColumn < row.length + ? (row[labelColumn] ?? "") + : String(i + 1); + points.push({ label, value: v, time: parseTime(label) }); + } + return points; +} + +function aggregate( + points: ChartPoint[], + aggregation: ChartAggregation, +): ChartPoint[] { + const groups = new Map< + string, + { sum: number; count: number; min: number; max: number; time?: Date | null } + >(); + for (const p of points) { + let g = groups.get(p.label); + if (!g) { + g = { + sum: 0, + count: 0, + min: Number.POSITIVE_INFINITY, + max: Number.NEGATIVE_INFINITY, + time: p.time, + }; + groups.set(p.label, g); + } + g.sum += p.value; + g.count += 1; + g.min = Math.min(g.min, p.value); + g.max = Math.max(g.max, p.value); + } + const out: ChartPoint[] = []; + for (const [label, g] of groups) { + let value = g.sum; + switch (aggregation) { + case "count": + value = g.count; + break; + case "avg": + value = g.count ? g.sum / g.count : 0; + break; + case "min": + value = g.min; + break; + case "max": + value = g.max; + break; + case "sum": + case "none": + value = g.sum; + break; + } + out.push({ label, value, time: g.time }); + } + return out; +} + +export function buildChartSeries(opts: { + rows: string[][]; + labelColumn: number | null; + valueColumn: number; + type: QuickChartType; + aggregation?: ChartAggregation; + topN?: number | null; +}): ChartSeries { + const { + rows, + labelColumn, + valueColumn, + type, + aggregation = "none", + topN, + } = opts; + let points = parsePoints(rows, labelColumn, valueColumn); + if (aggregation !== "none") { + points = aggregate(points, aggregation); + } + + if (type === "line") { + if ( + labelColumn != null && + isTimeColumn(rows, labelColumn) && + points.every((p) => p.time) + ) { + points = [...points].sort( + (a, b) => (a.time?.getTime() ?? 0) - (b.time?.getTime() ?? 0), + ); + } + if (points.length <= MAX_LINE_POINTS) { + return { points, droppedRows: 0 }; + } + return { + points: points.slice(0, MAX_LINE_POINTS), + droppedRows: points.length - MAX_LINE_POINTS, + }; + } + + if (type === "pie") { + points = points.filter((p) => p.value > 0); + points.sort((a, b) => b.value - a.value); + } + + if (topN == null || points.length <= topN) { + return { points, droppedRows: 0 }; + } + + const ranked = [...points].sort((a, b) => b.value - a.value); + const head = ranked.slice(0, topN); + const rest = ranked.slice(topN).reduce((s, p) => s + p.value, 0); + return { + points: [...head, { label: OTHER_LABEL, value: rest }], + droppedRows: 0, + }; +} + +export function defaultLabelColumn( + columns: string[], + valueCol: number, + numeric: number[], +): number | null { + for (let i = 0; i < columns.length; i++) { + if (i !== valueCol && !numeric.includes(i)) return i; + } + for (let i = 0; i < columns.length; i++) { + if (i !== valueCol) return i; + } + return null; +} + +export function rowsToChartStrings(rows: unknown[][]): string[][] { + return rows.map((row) => + row.map((cell) => { + if (cell === null || cell === undefined) return ""; + if (typeof cell === "string") return cell === "NULL" ? "" : cell; + if (typeof cell === "number" || typeof cell === "boolean") { + return String(cell); + } + return JSON.stringify(cell); + }), + ); +} diff --git a/apps/tauri/src/charts/toEchartsOption.ts b/apps/tauri/src/charts/toEchartsOption.ts new file mode 100644 index 00000000..5d8df40f --- /dev/null +++ b/apps/tauri/src/charts/toEchartsOption.ts @@ -0,0 +1,80 @@ +import type { EChartsOption } from "echarts"; +import type { ChartSeries, QuickChartType } from "./chartData"; + +export function toEchartsOption(opts: { + type: QuickChartType; + series: ChartSeries; + title: string; + dark: boolean; +}): EChartsOption { + const { type, series, title, dark } = opts; + const text = dark ? "#e2e8f0" : "#1e293b"; + const muted = dark ? "#94a3b8" : "#64748b"; + const axis = dark ? "#334155" : "#cbd5e1"; + const labels = series.points.map((p) => p.label); + const values = series.points.map((p) => p.value); + + const base: EChartsOption = { + backgroundColor: "transparent", + textStyle: { color: text }, + title: { + text: title, + left: "center", + top: 8, + textStyle: { color: text, fontSize: 13, fontWeight: 600 }, + }, + tooltip: { trigger: type === "pie" ? "item" : "axis" }, + grid: { + left: 48, + right: 24, + top: 48, + bottom: type === "pie" ? 24 : 40, + }, + }; + + if (type === "pie") { + return { + ...base, + legend: { + type: "scroll", + orient: "vertical", + right: 8, + top: 48, + textStyle: { color: muted }, + }, + series: [ + { + type: "pie", + radius: ["35%", "65%"], + center: ["40%", "55%"], + data: series.points.map((p) => ({ name: p.label, value: p.value })), + label: { color: muted }, + }, + ], + }; + } + + return { + ...base, + xAxis: { + type: "category", + data: labels, + axisLabel: { color: muted, hideOverlap: true }, + axisLine: { lineStyle: { color: axis } }, + }, + yAxis: { + type: "value", + axisLabel: { color: muted }, + splitLine: { lineStyle: { color: axis, opacity: 0.45 } }, + }, + series: [ + { + type, + data: values, + smooth: type === "line", + itemStyle: { color: "#22d3ee" }, + areaStyle: type === "line" ? { opacity: 0.08 } : undefined, + }, + ], + }; +} diff --git a/apps/tauri/src/ui/QuickChartView.vue b/apps/tauri/src/ui/QuickChartView.vue new file mode 100644 index 00000000..22eed5fd --- /dev/null +++ b/apps/tauri/src/ui/QuickChartView.vue @@ -0,0 +1,362 @@ + + + + + diff --git a/apps/tauri/src/ui/ResultViewPane.vue b/apps/tauri/src/ui/ResultViewPane.vue new file mode 100644 index 00000000..b9f52cd2 --- /dev/null +++ b/apps/tauri/src/ui/ResultViewPane.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/apps/tauri/src/ui/SqlWorkspace.vue b/apps/tauri/src/ui/SqlWorkspace.vue index 8140110b..bd3c8071 100644 --- a/apps/tauri/src/ui/SqlWorkspace.vue +++ b/apps/tauri/src/ui/SqlWorkspace.vue @@ -6,7 +6,7 @@ import { useQueryResultStore } from "../stores/queryResult"; import { useShellNavStore } from "../stores/shellNav"; import { useConnectionsStore } from "../stores/connections"; import SqlEditor from "./SqlEditor.vue"; -import ResultGrid from "./ResultGrid.vue"; +import ResultViewPane from "./ResultViewPane.vue"; const workspace = useWorkspaceStore(); const query = useQueryResultStore(); @@ -94,7 +94,7 @@ const hasGrid = computed(() => columns.value.length > 0);

{{ error }}

- +

Results appear here after Run.

diff --git a/apps/tauri/src/ui/TableObjectView.vue b/apps/tauri/src/ui/TableObjectView.vue index b7e1a691..04de9185 100644 --- a/apps/tauri/src/ui/TableObjectView.vue +++ b/apps/tauri/src/ui/TableObjectView.vue @@ -13,7 +13,7 @@ import { dbListColumns, formatConnectionError, } from "../api/connection"; -import ResultGrid from "./ResultGrid.vue"; +import ResultViewPane from "./ResultViewPane.vue"; import { QueryaIcon } from "./icons"; import ErdView from "./erd/ErdView.vue"; @@ -182,7 +182,7 @@ watch(
- +

No rows