diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index f426a99..e4d70be 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,22 +1,27 @@
{
"name": "frontend",
- "version": "0.1.5",
+ "version": "0.1.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
- "version": "0.1.5",
+ "version": "0.1.6",
"license": "AGPL-3.0-only",
"dependencies": {
"@duckdb/duckdb-wasm": "^1.29.1-dev132.0",
"@monaco-editor/react": "^4.7.0",
"@motherduck/wasm-client": "^0.6.6",
+ "@observablehq/plot": "^0.6.17",
"@radix-ui/react-slot": "^1.2.2",
"@shadcn/ui": "^0.0.4",
"@tailwindcss/vite": "^4.1.5",
"@types/d3": "^7.4.3",
"@types/papaparse": "^5.3.15",
+ "@uwdata/mosaic-core": "^0.17.0",
+ "@uwdata/mosaic-plot": "^0.17.0",
+ "@uwdata/mosaic-sql": "^0.17.0",
+ "@uwdata/vgplot": "^0.17.0",
"apache-arrow": "^20.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -1238,6 +1243,20 @@
"node": ">= 8"
}
},
+ "node_modules/@observablehq/plot": {
+ "version": "0.6.17",
+ "resolved": "https://registry.npmjs.org/@observablehq/plot/-/plot-0.6.17.tgz",
+ "integrity": "sha512-/qaXP/7mc4MUS0s4cPPFASDRjtsWp85/TbfsciqDgU1HwYixbSbbytNuInD8AcTYC3xaxACgVX06agdfQy9W+g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3": "^7.9.0",
+ "interval-tree-1d": "^1.0.0",
+ "isoformat": "^0.2.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
@@ -2429,6 +2448,152 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
+ "node_modules/@uwdata/flechette": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/@uwdata/flechette/-/flechette-2.0.1.tgz",
+ "integrity": "sha512-eKrNd1mNZDqf0iUbaDs3/Ld3qmVGb+ADckNjXTdUwfb2Q4o9cdGYIIYfzFSA5LybXx2RK5PXHPZS1c+PmyW0sA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@uwdata/mosaic-core": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/@uwdata/mosaic-core/-/mosaic-core-0.17.0.tgz",
+ "integrity": "sha512-bMlCdLaNRa0K3ci5HutqHKqJXUawhLfyCVAqw2hdJfOUcZqGk58Cwc/pGV1BSrFW/ou70yatH8CtYcXLn6sYnw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@duckdb/duckdb-wasm": "^1.29.0",
+ "@uwdata/flechette": "^2.0.0",
+ "@uwdata/mosaic-sql": "^0.17.0"
+ }
+ },
+ "node_modules/@uwdata/mosaic-core/node_modules/@duckdb/duckdb-wasm": {
+ "version": "1.29.0",
+ "resolved": "https://registry.npmjs.org/@duckdb/duckdb-wasm/-/duckdb-wasm-1.29.0.tgz",
+ "integrity": "sha512-8Zq7vafQuIz9gklC/9375KE38UlkaS2n8+yvG+/JK7irm3DjwYNJHL4xfplIj0bSHFIg6we5XhWYFqtE/vO3+Q==",
+ "license": "MIT",
+ "dependencies": {
+ "apache-arrow": "^17.0.0"
+ }
+ },
+ "node_modules/@uwdata/mosaic-core/node_modules/@types/node": {
+ "version": "20.19.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.1.tgz",
+ "integrity": "sha512-jJD50LtlD2dodAEO653i3YF04NWak6jN3ky+Ri3Em3mGR39/glWiboM/IePaRbgwSfqM1TpGXfAg8ohn/4dTgA==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@uwdata/mosaic-core/node_modules/apache-arrow": {
+ "version": "17.0.0",
+ "resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-17.0.0.tgz",
+ "integrity": "sha512-X0p7auzdnGuhYMVKYINdQssS4EcKec9TCXyez/qtJt32DrIMGbzqiaMiQ0X6fQlQpw8Fl0Qygcv4dfRAr5Gu9Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@swc/helpers": "^0.5.11",
+ "@types/command-line-args": "^5.2.3",
+ "@types/command-line-usage": "^5.0.4",
+ "@types/node": "^20.13.0",
+ "command-line-args": "^5.2.1",
+ "command-line-usage": "^7.0.1",
+ "flatbuffers": "^24.3.25",
+ "json-bignum": "^0.0.3",
+ "tslib": "^2.6.2"
+ },
+ "bin": {
+ "arrow2csv": "bin/arrow2csv.cjs"
+ }
+ },
+ "node_modules/@uwdata/mosaic-core/node_modules/array-back": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/array-back/-/array-back-3.1.0.tgz",
+ "integrity": "sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@uwdata/mosaic-core/node_modules/command-line-args": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-5.2.1.tgz",
+ "integrity": "sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==",
+ "license": "MIT",
+ "dependencies": {
+ "array-back": "^3.1.0",
+ "find-replace": "^3.0.0",
+ "lodash.camelcase": "^4.3.0",
+ "typical": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/@uwdata/mosaic-core/node_modules/find-replace": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/find-replace/-/find-replace-3.0.0.tgz",
+ "integrity": "sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "array-back": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/@uwdata/mosaic-core/node_modules/flatbuffers": {
+ "version": "24.12.23",
+ "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-24.12.23.tgz",
+ "integrity": "sha512-dLVCAISd5mhls514keQzmEG6QHmUUsNuWsb4tFafIUwvvgDjXhtfAYSKOzt5SWOy+qByV5pbsDZ+Vb7HUOBEdA==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@uwdata/mosaic-core/node_modules/typical": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/typical/-/typical-4.0.0.tgz",
+ "integrity": "sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@uwdata/mosaic-inputs": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/@uwdata/mosaic-inputs/-/mosaic-inputs-0.17.0.tgz",
+ "integrity": "sha512-sb66PfG8FyY8VFdVLkVo4DpCny14PkArCo7f3g+4bo9R4iS9L5WjWrrz5SonB0QLJigA4qCRF8GImb7r92b0JQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@uwdata/mosaic-core": "^0.17.0",
+ "@uwdata/mosaic-sql": "^0.17.0"
+ }
+ },
+ "node_modules/@uwdata/mosaic-plot": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/@uwdata/mosaic-plot/-/mosaic-plot-0.17.0.tgz",
+ "integrity": "sha512-SxXxX+DXjdGKo2jb712qd0IdmJ5LufbN52R8Qak84cM3ile07HieMgTa822S1ERbVv8qBevSTtu1Cu1jH03kaA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@observablehq/plot": "^0.6.17",
+ "@uwdata/mosaic-core": "^0.17.0",
+ "@uwdata/mosaic-sql": "^0.17.0",
+ "d3": "^7.9.0"
+ }
+ },
+ "node_modules/@uwdata/mosaic-sql": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/@uwdata/mosaic-sql/-/mosaic-sql-0.17.0.tgz",
+ "integrity": "sha512-Et7kzXEMo1txsXFJuX+jEujvj6EcIa6xuhlGYPyJi7z3HuFS/s8JNyDSpGjQsJP+JwL4pDeHdkK81w9/anY60Q==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@uwdata/vgplot": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/@uwdata/vgplot/-/vgplot-0.17.0.tgz",
+ "integrity": "sha512-t1VCiMncU2AFungkdR5xI8/rWbz4PkD88A62kUWjBXdecaWxm3hgeWGxdCmEhbjXHFs3119e7JM4AES1Nfap7g==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@uwdata/mosaic-core": "^0.17.0",
+ "@uwdata/mosaic-inputs": "^0.17.0",
+ "@uwdata/mosaic-plot": "^0.17.0",
+ "@uwdata/mosaic-sql": "^0.17.0"
+ }
+ },
"node_modules/@vitejs/plugin-react": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.4.1.tgz",
@@ -2654,6 +2819,12 @@
],
"license": "MIT"
},
+ "node_modules/binary-search-bounds": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/binary-search-bounds/-/binary-search-bounds-2.0.5.tgz",
+ "integrity": "sha512-H0ea4Fd3lS1+sTEB2TgcLoK21lLhwEJzlQv3IN47pJS976Gx4zoWe0ak3q+uYh60ppQxg9F16Ri4tS1sfD4+jA==",
+ "license": "MIT"
+ },
"node_modules/bl": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz",
@@ -4746,6 +4917,15 @@
"node": ">=12"
}
},
+ "node_modules/interval-tree-1d": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/interval-tree-1d/-/interval-tree-1d-1.0.4.tgz",
+ "integrity": "sha512-wY8QJH+6wNI0uh4pDQzMvl+478Qh7Rl4qLmqiluxALlNvl+I+o5x38Pw3/z7mDPTPS1dQalZJXsmbvxx5gclhQ==",
+ "license": "MIT",
+ "dependencies": {
+ "binary-search-bounds": "^2.0.0"
+ }
+ },
"node_modules/invariant": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
@@ -4847,6 +5027,12 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
+ "node_modules/isoformat": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/isoformat/-/isoformat-0.2.1.tgz",
+ "integrity": "sha512-tFLRAygk9NqrRPhJSnNGh7g7oaVWDwR0wKh/GM2LgmPa50Eg4UfyaCO4I8k6EqJHl1/uh2RAD6g06n5ygEnrjQ==",
+ "license": "ISC"
+ },
"node_modules/jiti": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index e919885..4703a3f 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -13,11 +13,16 @@
"@duckdb/duckdb-wasm": "^1.29.1-dev132.0",
"@monaco-editor/react": "^4.7.0",
"@motherduck/wasm-client": "^0.6.6",
+ "@observablehq/plot": "^0.6.17",
"@radix-ui/react-slot": "^1.2.2",
"@shadcn/ui": "^0.0.4",
"@tailwindcss/vite": "^4.1.5",
"@types/d3": "^7.4.3",
"@types/papaparse": "^5.3.15",
+ "@uwdata/mosaic-core": "^0.17.0",
+ "@uwdata/mosaic-plot": "^0.17.0",
+ "@uwdata/mosaic-sql": "^0.17.0",
+ "@uwdata/vgplot": "^0.17.0",
"apache-arrow": "^20.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
diff --git a/frontend/src/components/tabs/VisualizationTab.tsx b/frontend/src/components/tabs/VisualizationTab.tsx
index 4b7f765..f0b2797 100644
--- a/frontend/src/components/tabs/VisualizationTab.tsx
+++ b/frontend/src/components/tabs/VisualizationTab.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect, useMemo } from "react";
+import React, { useState, useEffect } from "react";
import {
BarChart4,
LineChart,
@@ -7,99 +7,34 @@ import {
TrendingUp,
ChevronLeft,
ChevronRight,
- ChevronDown,
- Database,
- FileText,
Settings,
Copy,
ArrowRight,
+ Clock,
+ Rows,
} from "lucide-react";
import { useAppStore } from "@/store/appStore";
+import { useDuckDBStore } from "@/store/duckDBStore";
import { selectHasFiles } from "@/store/selectors/appSelectors";
import { useChartsStore, ChartType } from "@/store/chartsStore";
import { Button } from "@/components/ui/Button";
+import DuckDBTableSelector from "./visualization/DuckDBTableSelector";
import ChartCanvas from "./visualization/ChartCanvas";
import ChartConfigPanel from "./visualization/ChartConfigPanel";
import ExportModal from "./visualization/ExportModal";
-import ChartGallery from "./visualization/ChartGallery";
-
-interface DataSource {
- type: "file";
- fileId: string;
- fileName: string;
- data: any[][];
- columns: string[];
- rowCount: number;
+import { useMosaicQuery } from "@/hooks/chart/useMosaicQuery";
+
+interface DuckDBTable {
+ name: string;
+ rowCount?: number;
+ isView: boolean;
+ source: "local" | "motherduck";
+ database?: string;
+ schema?: { name: string; type: string }[];
}
-/**
- * Minimal compact dropdown for data source selection
- */
-const DataSourceDropdown: React.FC<{
- selectedSource: DataSource | null;
- onSourceChange: (source: DataSource) => void;
-}> = ({ selectedSource, onSourceChange }) => {
- const { files } = useAppStore();
- const [isOpen, setIsOpen] = useState(false);
-
- const dataSources = useMemo(() => {
- return files
- .filter((file) => file.data && file.data.length > 1)
- .map((file) => ({
- type: "file" as const,
- fileId: file.id,
- fileName: file.fileName,
- data: file.data,
- columns: file.data[0],
- rowCount: file.data.length - 1,
- }));
- }, [files]);
-
- return (
-
-
-
- {isOpen && (
-
- {dataSources.map((source) => (
-
- ))}
-
- )}
-
- );
-};
-
/**
* Compact chart type selector
*/
@@ -141,86 +76,99 @@ const ChartTypeRow: React.FC = () => {
};
/**
- * Main visualization component - clean and minimal
+ * Performance indicators for query execution
+ */
+const PerformanceIndicators: React.FC<{
+ executionTime?: number;
+ rowCount?: number;
+ isExecuting?: boolean;
+}> = ({ executionTime, rowCount, isExecuting }) => {
+ if (!executionTime && !rowCount && !isExecuting) return null;
+
+ return (
+
+ {isExecuting && (
+
+ )}
+ {executionTime && !isExecuting && (
+
+
+ {executionTime}ms
+
+ )}
+ {rowCount && !isExecuting && (
+
+
+ {rowCount.toLocaleString()} rows
+
+ )}
+
+ );
+};
+
+/**
+ * Main visualization component - updated to use DuckDB directly
*/
const VisualizationTab: React.FC = () => {
const hasFiles = useAppStore(selectHasFiles);
- const { setActiveTab, activeFileId, setActiveFile, files } = useAppStore();
+ const { setActiveTab } = useAppStore();
+ const { registeredTables } = useDuckDBStore();
const {
currentChart,
createNewChart,
loadChartsFromStorage,
toggleExportModal,
+ updateCurrentChart,
} = useChartsStore();
- const [selectedDataSource, setSelectedDataSource] =
- useState(null);
- const [selectedColumns, setSelectedColumns] = useState([]);
- const [filteredData, setFilteredData] = useState(null);
+ const [selectedTable, setSelectedTable] = useState(null);
const [showLeftPanel, setShowLeftPanel] = useState(true);
+ const [queryResult, setQueryResult] = useState(null);
+
+ const { isExecuting, progress, lastResult } = useMosaicQuery();
// Load saved charts on mount
useEffect(() => {
loadChartsFromStorage();
}, [loadChartsFromStorage]);
- // Auto-select first file
+ // Auto-select first table if available
useEffect(() => {
- if (!selectedDataSource && files.length > 0) {
- const firstFileWithData = files.find(
- (file) => file.id === activeFileId
- );
- if (firstFileWithData) {
- const source: DataSource = {
- type: "file",
- fileId: firstFileWithData.id,
- fileName: firstFileWithData.fileName,
- data: firstFileWithData.data,
- columns: firstFileWithData.data[0],
- rowCount: firstFileWithData.data.length - 1,
- };
- setSelectedDataSource(source);
- setSelectedColumns(source.columns.slice(0, 2));
- }
+ if (!selectedTable && registeredTables.size > 0) {
+ const firstTable = Array.from(registeredTables.keys())[0];
+ setSelectedTable({
+ name: firstTable,
+ isView: false,
+ source: "local",
+ });
}
- }, [files, selectedDataSource]);
+ }, [registeredTables, selectedTable]);
- // Process data when source/columns change
+ // Update chart data when query result changes
useEffect(() => {
- if (selectedDataSource?.data && selectedColumns.length > 0) {
- const headers = selectedDataSource.data[0];
- const rows = selectedDataSource.data.slice(1);
-
- const columnIndices = selectedColumns
- .map((col) => headers.indexOf(col))
- .filter((idx) => idx !== -1);
-
- const formattedData = rows.map((row) => {
- const obj: Record = {};
- selectedColumns.forEach((column, index) => {
- const colIndex = columnIndices[index];
- if (colIndex !== -1) {
- const value = row[colIndex];
- obj[column] = isNaN(Number(value)) ? value : Number(value);
- }
+ if (lastResult && lastResult.data.length > 0) {
+ if (!currentChart) {
+ createNewChart("bar", lastResult.data, lastResult.query);
+ } else {
+ updateCurrentChart({
+ data: lastResult.data,
+ originalData: [...lastResult.data],
});
- return obj;
- });
-
- setFilteredData(formattedData);
-
- if (formattedData.length > 0 && !currentChart) {
- createNewChart("bar", formattedData);
}
- } else {
- setFilteredData(null);
+ setQueryResult(lastResult);
}
- }, [selectedDataSource, selectedColumns, createNewChart, currentChart]);
+ }, [lastResult, currentChart, createNewChart, updateCurrentChart]);
+
+ const hasVisualizationData = currentChart?.data && currentChart.data.length > 0;
- const hasVisualizationData = filteredData && filteredData.length > 0;
+ // Show progress bar for long queries
+ const showProgress = isExecuting && progress > 0;
- // No files state
- if (!hasFiles) {
+ // No tables state
+ if (registeredTables.size === 0) {
return (
@@ -242,37 +190,15 @@ const VisualizationTab: React.FC = () => {
return (
- {/* CSS for chart styling */}
-
+ {/* Progress bar */}
+ {showProgress && (
+
+ )}
{/* Minimal Header */}
@@ -290,13 +216,10 @@ const VisualizationTab: React.FC = () => {
)}
- {/* Data source selection */}
- {
- setSelectedDataSource(file);
- setActiveFile(file.fileId);
- }}
+ {/* Table selection */}
+
{/* Chart type selection */}
@@ -304,21 +227,23 @@ const VisualizationTab: React.FC = () => {
{/* Right side actions */}
-
+
+ {/* Performance indicators */}
+
+
{hasVisualizationData && (
- <>
-
- {filteredData?.length} points
-
-
- >
+
)}
@@ -338,27 +263,29 @@ const VisualizationTab: React.FC = () => {
- {/* Panel content */}
+ {/* Panel content - now gets selected table */}
-
+
)}
{/* Chart Canvas - Maximum Space */}
- {!selectedDataSource ? (
+ {!selectedTable ? (
-
-
Select a data source to begin
+
+
Select a table to begin
- ) : !hasVisualizationData ? (
+ ) : !hasVisualizationData && !isExecuting ? (
-
Select columns to visualize
+
+ Configure your chart in the panel
+
) : (
@@ -375,4 +302,4 @@ const VisualizationTab: React.FC = () => {
);
};
-export default VisualizationTab;
+export default VisualizationTab;
\ No newline at end of file
diff --git a/frontend/src/components/tabs/visualization/ChartCanvas.tsx b/frontend/src/components/tabs/visualization/ChartCanvas.tsx
index 3652176..9b3e96a 100644
--- a/frontend/src/components/tabs/visualization/ChartCanvas.tsx
+++ b/frontend/src/components/tabs/visualization/ChartCanvas.tsx
@@ -1,6 +1,16 @@
-import React from "react";
+import React, { useState, useCallback } from "react";
import { useChartsStore, ChartType } from "@/store/chartsStore";
+import { analyzeDataPerformance } from "@/utils/chartPerformance";
+import { useChartDimensions } from "@/hooks/useResizeObserver";
+import CanvasChart from "./canvas/CanvasChart";
+import ObservablePlotChart from "./observable/ObservablePlotChart";
+import MosaicChart from "./mosaic/MosaicChart";
+import MosaicChartSQL from "./mosaic/MosaicChartSQL";
+import MosaicChartVgplot from "./mosaic/MosaicChartVgplot";
+import MosaicChartSimple from "./mosaic/MosaicChartSimple";
+import MosaicChartCanvas from "./mosaic/MosaicChartCanvas";
+import MosaicChartCanvasSimple from "./mosaic/MosaicChartCanvasSimple";
import {
ResponsiveContainer,
@@ -22,6 +32,8 @@ import {
Legend,
Label,
ReferenceLine,
+ Brush,
+ ReferenceArea,
} from "recharts";
/**
@@ -29,10 +41,110 @@ import {
*/
const ChartCanvas: React.FC = () => {
const { currentChart, colorPalettes } = useChartsStore();
+
+ // Get responsive chart dimensions (must be called before any conditional returns)
+ const { ref: chartRef, width: chartWidth, height: chartHeight, isReady } = useChartDimensions(600, 400);
+
+ // Zoom state management
+ const [zoomState, setZoomState] = useState<{
+ left?: string | number;
+ right?: string | number;
+ refAreaLeft?: string | number;
+ refAreaRight?: string | number;
+ top?: string | number;
+ bottom?: string | number;
+ animation?: boolean;
+ }>({
+ left: 'dataMin',
+ right: 'dataMax',
+ refAreaLeft: '',
+ refAreaRight: '',
+ top: 'dataMax+1',
+ bottom: 'dataMin-1',
+ animation: true,
+ });
+
+ // Zoom handler functions
+ const getAxisYDomain = useCallback((from: number, to: number, ref: string, offset: number) => {
+ const refData = currentChart?.data?.slice(from - 1, to);
+ if (!refData || refData.length === 0) return [0, 0];
+
+ let [bottom, top] = [refData[0][ref], refData[0][ref]];
+ refData.forEach((d) => {
+ if (d[ref] > top) top = d[ref];
+ if (d[ref] < bottom) bottom = d[ref];
+ });
+
+ return [(bottom | 0) - offset, (top | 0) + offset];
+ }, [currentChart?.data]);
+
+ const zoom = useCallback(() => {
+ let { refAreaLeft, refAreaRight } = zoomState;
+ const { data } = currentChart || {};
+
+ if (!data || refAreaLeft === refAreaRight || refAreaRight === '') {
+ setZoomState(prev => ({
+ ...prev,
+ refAreaLeft: '',
+ refAreaRight: '',
+ }));
+ return;
+ }
+
+ // xAxis domain
+ if (refAreaLeft && refAreaRight && refAreaLeft > refAreaRight)
+ [refAreaLeft, refAreaRight] = [refAreaRight, refAreaLeft];
+
+ // yAxis domain
+ const yAxisField = currentChart?.yAxis?.field || currentChart?.yAxis?.dataKey;
+ if (!yAxisField) return;
+
+ const from = data.findIndex(d => d[currentChart.xAxis.field] === refAreaLeft);
+ const to = data.findIndex(d => d[currentChart.xAxis.field] === refAreaRight);
+ const [bottom, top] = getAxisYDomain(from, to, yAxisField, 1);
+
+ setZoomState(prev => ({
+ ...prev,
+ refAreaLeft: '',
+ refAreaRight: '',
+ left: refAreaLeft,
+ right: refAreaRight,
+ bottom,
+ top,
+ }));
+ }, [zoomState, currentChart, getAxisYDomain]);
+
+ const zoomOut = useCallback(() => {
+ setZoomState({
+ left: 'dataMin',
+ right: 'dataMax',
+ refAreaLeft: '',
+ refAreaRight: '',
+ top: 'dataMax+1',
+ bottom: 'dataMin-1',
+ animation: true,
+ });
+ }, []);
+
+ // Analyze performance to determine rendering strategy (must be done with hooks)
+ const performanceAnalysis = currentChart?.data ? analyzeDataPerformance(currentChart.data.length, 2) : null;
+ const shouldUseMosaic = performanceAnalysis?.renderingStrategy === 'mosaic_plot';
+ const shouldUseCanvas = performanceAnalysis?.renderingStrategy === 'canvas';
+ const shouldUseObservablePlot = performanceAnalysis?.renderingStrategy === 'observable_plot';
+
+ // Debug logging (MUST be called every render to maintain hooks order)
+ React.useEffect(() => {
+ if (currentChart?.data && performanceAnalysis) {
+ console.log(`[ChartCanvas] Data size: ${currentChart.data.length} rows`);
+ console.log(`[ChartCanvas] Strategy: ${performanceAnalysis.renderingStrategy}`);
+ console.log(`[ChartCanvas] Using: ${shouldUseCanvas ? 'Canvas' : shouldUseMosaic ? 'Mosaic' : shouldUseObservablePlot ? 'Observable Plot' : 'Recharts'}`);
+ }
+ }, [currentChart?.data?.length, performanceAnalysis?.renderingStrategy, shouldUseCanvas, shouldUseMosaic, shouldUseObservablePlot]);
+ // Early returns after ALL hooks are called
if (!currentChart || !currentChart.data || currentChart.data.length === 0) {
return (
-
+
No Chart Data
@@ -45,6 +157,15 @@ const ChartCanvas: React.FC = () => {
);
}
+ // Don't render chart until dimensions are ready
+ if (!isReady) {
+ return (
+
+ );
+ }
+
// Get color palette
const palette = colorPalettes[currentChart.palette] || colorPalettes.primary;
@@ -56,20 +177,125 @@ const ChartCanvas: React.FC = () => {
// Render the appropriate chart based on type
return (
-
-
- {currentChart.title}
-
- {currentChart.description && (
-
- {currentChart.description}
-
+
+ {/* Header with title and zoom controls */}
+
+
+
+ {currentChart.title}
+
+ {currentChart.description && (
+
+ {currentChart.description}
+
+ )}
+
+
+ {/* Zoom Controls - only show for charts that support zoom */}
+ {currentChart.type !== 'pie' && (
+
+
+
+ Drag to zoom
+
+
+ )}
+
+
+ {/* Sampling Info */}
+ {currentChart.samplingInfo && (
+
+
+ 📊 {currentChart.samplingInfo.mode === 'fixed' ? 'Fixed' : 'Custom'} sampling:
+ {' '}{currentChart.samplingInfo.sampleSize?.toLocaleString()} of {currentChart.samplingInfo.totalRows?.toLocaleString()} rows
+ ({((currentChart.samplingInfo.samplingRatio || 0) * 100).toFixed(1)}%)
+
+
)}
-
-
- {renderChart(currentChart.type, palette)}
-
+
+ {shouldUseCanvas && currentChart.type !== 'pie' ? (
+ /* High-performance Canvas renderer for massive datasets (1M+) */
+
+ {
+ // Auto-detect actual field names from data structure
+ const sampleRow = currentChart.data[0] || {};
+ const aggregation = currentChart.query?.includes('SUM') ? 'sum' :
+ currentChart.query?.includes('AVG') ? 'avg' :
+ currentChart.query?.includes('COUNT') ? 'count' : 'sum';
+ return sampleRow.hasOwnProperty('dimension') ? 'dimension' : currentChart.xAxis.field;
+ })()}
+ yField={(() => {
+ const sampleRow = currentChart.data[0] || {};
+ const aggregation = currentChart.query?.includes('SUM') ? 'sum' :
+ currentChart.query?.includes('AVG') ? 'avg' :
+ currentChart.query?.includes('COUNT') ? 'count' : 'sum';
+ return sampleRow.hasOwnProperty(`${aggregation}_value`) ? `${aggregation}_value` : currentChart.yAxis.field;
+ })()}
+ xLabel={currentChart.xAxis.label}
+ yLabel={currentChart.yAxis.label}
+ width={chartWidth - 32}
+ height={chartHeight - 120}
+ colors={palette}
+ showGrid={currentChart.showGrid}
+ onZoom={(zoomBounds) => {
+ console.log('Canvas zoom:', zoomBounds);
+ // Could trigger new query for zoomed data in the future
+ }}
+ />
+
+ ) : shouldUseMosaic && currentChart.type !== 'pie' ? (
+ /* Canvas-based vgplot implementation optimized for large datasets */
+
+
+
+ ) : shouldUseObservablePlot && currentChart.type !== 'pie' ? (
+ /* Observable Plot for medium datasets (5K-50K) */
+
+
+
+ ) : (
+ /* Traditional Recharts renderer for smaller datasets (<5K) */
+
+ {renderChart(currentChart.type, palette)}
+
+ )}
);
@@ -86,8 +312,8 @@ const ChartCanvas: React.FC = () => {
name={xAxis.label}
stroke="#ffffff60"
scale={xAxis.scale || "auto"}
- domain={xAxis.domain || ["auto", "auto"]}
- allowDataOverflow={!!xAxis.domain}
+ domain={xAxis.domain || [zoomState.left, zoomState.right]}
+ allowDataOverflow={true}
>