diff --git a/src/components/Figure.test.tsx b/src/components/Figure.test.tsx
index 45dbf65c..3941f30a 100644
--- a/src/components/Figure.test.tsx
+++ b/src/components/Figure.test.tsx
@@ -1,7 +1,9 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, act } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
import Figure from "./Figure";
import { Compute, Fix, Variable, PlotData, LMPModifier, ModifierType } from "../types";
+import { exportPlotDataToCsv } from "../utils/exportCsv";
// Mock useStoreState hook
vi.mock("../hooks", () => ({
@@ -19,12 +21,32 @@ vi.mock("dygraphs", () => {
};
});
-// Mock antd Modal and Empty components
+// Mock antd Modal, Empty, and Button components
vi.mock("antd", () => ({
Modal: ({ children, open, onCancel }: { children: React.ReactNode; open: boolean; onCancel: () => void }) => (
- open ?
{children}
: null
+ open ? (
+
+ {children}
+
+ ) : null
),
Empty: () => Empty
,
+ Button: ({ children, onClick, icon, type, style }: { children: React.ReactNode; onClick: () => void; icon?: React.ReactNode; type?: string; style?: React.CSSProperties }) => (
+
+ ),
+}));
+
+// Mock @ant-design/icons
+vi.mock("@ant-design/icons", () => ({
+ DownloadOutlined: () => Download,
+}));
+
+// Mock exportCsv utility
+vi.mock("../utils/exportCsv", () => ({
+ exportPlotDataToCsv: vi.fn(),
}));
describe("Figure", () => {
@@ -324,5 +346,101 @@ describe("Figure", () => {
expect(screen.getByTestId("empty")).toBeInTheDocument();
});
});
+
+ describe("CSV export", () => {
+ it("should render export button when data1D is available", () => {
+ // Arrange
+ const plotData = createMockPlotData();
+
+ // Act
+ render();
+
+ // Assert
+ expect(screen.getByTestId("button")).toBeInTheDocument();
+ expect(screen.getByText("Export CSV")).toBeInTheDocument();
+ });
+
+ it("should not render export button when data1D is not available", () => {
+ // Arrange
+ const plotData = createMockPlotData({
+ data1D: undefined,
+ });
+
+ // Act
+ render();
+
+ // Assert
+ expect(screen.queryByTestId("button")).not.toBeInTheDocument();
+ });
+
+ it("should call exportPlotDataToCsv when export button is clicked", async () => {
+ // Arrange
+ const plotData = createMockPlotData({
+ name: "test plot",
+ data1D: {
+ data: [[0, 1], [1, 2]],
+ labels: ["x", "y"],
+ },
+ });
+ const user = userEvent.setup();
+
+ // Act
+ render();
+ const exportButton = screen.getByTestId("button");
+ await user.click(exportButton);
+
+ // Assert
+ expect(exportPlotDataToCsv).toHaveBeenCalledWith(
+ plotData.data1D,
+ "test-plot.csv",
+ );
+ });
+
+ it("should sanitize filename by replacing spaces with hyphens", async () => {
+ // Arrange
+ const plotData = createMockPlotData({
+ name: "my test plot with spaces",
+ data1D: {
+ data: [[0, 1]],
+ labels: ["x", "y"],
+ },
+ });
+ const user = userEvent.setup();
+
+ // Act
+ render();
+ const exportButton = screen.getByTestId("button");
+ await user.click(exportButton);
+
+ // Assert
+ expect(exportPlotDataToCsv).toHaveBeenCalledWith(
+ plotData.data1D,
+ "my-test-plot-with-spaces.csv",
+ );
+ });
+
+ it("should sanitize filename by replacing invalid characters", async () => {
+ // Arrange
+ const plotData = createMockPlotData({
+ name: 'plot/with\\invalid:characters*?"|<>',
+ data1D: {
+ data: [[0, 1]],
+ labels: ["x", "y"],
+ },
+ });
+ const user = userEvent.setup();
+
+ // Act
+ render();
+ const exportButton = screen.getByTestId("button");
+ await user.click(exportButton);
+
+ // Assert
+ expect(exportPlotDataToCsv).toHaveBeenCalledWith(
+ plotData.data1D,
+ "plot-with-invalid-characters------.csv",
+ );
+ });
+ });
});
diff --git a/src/components/Figure.tsx b/src/components/Figure.tsx
index 01e140f3..2fc74c0d 100644
--- a/src/components/Figure.tsx
+++ b/src/components/Figure.tsx
@@ -1,8 +1,10 @@
-import { Modal, Empty } from "antd";
+import { Modal, Empty, Button } from "antd";
+import { DownloadOutlined } from "@ant-design/icons";
import { Compute, Fix, Variable, PlotData } from "../types";
import { useEffect, useState, useId, useMemo, useRef } from "react";
import { useStoreState } from "../hooks";
import Dygraph from "dygraphs";
+import { exportPlotDataToCsv } from "../utils/exportCsv";
type FigureProps = {
onClose: () => void;
@@ -132,8 +134,30 @@ const Figure = ({
}
}, [graph, plotConfig, timesteps]);
+ const handleExportCsv = () => {
+ if (plotConfig?.data1D) {
+ const filename = `${plotConfig.name.replace(/[\s/\\?%*:"|<>]/g, "-")}.csv`;
+ exportPlotDataToCsv(plotConfig.data1D, filename);
+ }
+ };
+
return (
-
+
+ {plotConfig?.data1D && (
+ }
+ onClick={handleExportCsv}
+ style={{ marginBottom: 16 }}
+ >
+ Export CSV
+
+ )}
{!graph && }
diff --git a/src/utils/exportCsv.test.ts b/src/utils/exportCsv.test.ts
new file mode 100644
index 00000000..bd633092
--- /dev/null
+++ b/src/utils/exportCsv.test.ts
@@ -0,0 +1,253 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { plotDataToCsv, exportPlotDataToCsv } from "./exportCsv";
+import { Data1D } from "../types";
+
+describe("plotDataToCsv", () => {
+ it("should convert simple plot data to CSV format", () => {
+ // Arrange
+ const data1D: Data1D = {
+ data: [
+ [0, 1],
+ [1, 2],
+ [2, 3],
+ ],
+ labels: ["x", "y"],
+ };
+
+ // Act
+ const csv = plotDataToCsv(data1D);
+
+ // Assert
+ const expected = "x,y\n0,1\n1,2\n2,3";
+ expect(csv).toBe(expected);
+ });
+
+ it("should handle multiple columns", () => {
+ // Arrange
+ const data1D: Data1D = {
+ data: [
+ [0, 1, 2, 3],
+ [1, 2, 3, 4],
+ ],
+ labels: ["time", "temperature", "pressure", "energy"],
+ };
+
+ // Act
+ const csv = plotDataToCsv(data1D);
+
+ // Assert
+ const expected = "time,temperature,pressure,energy\n0,1,2,3\n1,2,3,4";
+ expect(csv).toBe(expected);
+ });
+
+ it("should handle empty data array", () => {
+ // Arrange
+ const data1D: Data1D = {
+ data: [],
+ labels: ["x", "y"],
+ };
+
+ // Act
+ const csv = plotDataToCsv(data1D);
+
+ // Assert
+ expect(csv).toBe("x,y\n");
+ });
+
+ it("should handle single data row", () => {
+ // Arrange
+ const data1D: Data1D = {
+ data: [[42, 3.14]],
+ labels: ["answer", "pi"],
+ };
+
+ // Act
+ const csv = plotDataToCsv(data1D);
+
+ // Assert
+ expect(csv).toBe("answer,pi\n42,3.14");
+ });
+
+ it("should handle decimal numbers", () => {
+ // Arrange
+ const data1D: Data1D = {
+ data: [
+ [0.0, 1.234],
+ [0.5, 2.345],
+ [1.0, 3.456],
+ ],
+ labels: ["time", "value"],
+ };
+
+ // Act
+ const csv = plotDataToCsv(data1D);
+
+ // Assert
+ const expected = "time,value\n0,1.234\n0.5,2.345\n1,3.456";
+ expect(csv).toBe(expected);
+ });
+
+ it("should handle negative numbers", () => {
+ // Arrange
+ const data1D: Data1D = {
+ data: [
+ [-1, -2],
+ [0, 0],
+ [1, 2],
+ ],
+ labels: ["x", "y"],
+ };
+
+ // Act
+ const csv = plotDataToCsv(data1D);
+
+ // Assert
+ const expected = "x,y\n-1,-2\n0,0\n1,2";
+ expect(csv).toBe(expected);
+ });
+
+ it("should handle scientific notation", () => {
+ // Arrange
+ const data1D: Data1D = {
+ data: [
+ [0, 1e-10],
+ [1, 2e10],
+ ],
+ labels: ["x", "y"],
+ };
+
+ // Act
+ const csv = plotDataToCsv(data1D);
+
+ // Assert
+ const expected = "x,y\n0,1e-10\n1,20000000000";
+ expect(csv).toBe(expected);
+ });
+});
+
+describe("exportPlotDataToCsv", () => {
+ let createElementSpy: ReturnType;
+ let createObjectURLSpy: ReturnType;
+ let revokeObjectURLSpy: ReturnType;
+ let mockLink: {
+ setAttribute: ReturnType;
+ click: ReturnType;
+ style: { visibility: string };
+ };
+
+ beforeEach(() => {
+ // Create mock link element
+ mockLink = {
+ setAttribute: vi.fn(),
+ click: vi.fn(),
+ style: { visibility: "" },
+ };
+
+ // Mock document.createElement
+ createElementSpy = vi.spyOn(document, "createElement").mockReturnValue(
+ mockLink as unknown as HTMLElement,
+ );
+
+ // Mock URL.createObjectURL and URL.revokeObjectURL
+ createObjectURLSpy = vi.spyOn(URL, "createObjectURL").mockReturnValue("mock-url");
+ revokeObjectURLSpy = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
+
+ // Mock document.body methods
+ vi.spyOn(document.body, "appendChild").mockImplementation(() => mockLink as unknown as Node);
+ vi.spyOn(document.body, "removeChild").mockImplementation(() => mockLink as unknown as Node);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("should create a downloadable CSV file", () => {
+ // Arrange
+ const data1D: Data1D = {
+ data: [
+ [0, 1],
+ [1, 2],
+ ],
+ labels: ["x", "y"],
+ };
+
+ // Act
+ exportPlotDataToCsv(data1D);
+
+ // Assert
+ expect(createElementSpy).toHaveBeenCalledWith("a");
+ expect(createObjectURLSpy).toHaveBeenCalled();
+ expect(mockLink.setAttribute).toHaveBeenCalledWith("href", "mock-url");
+ expect(mockLink.setAttribute).toHaveBeenCalledWith("download", "plot-data.csv");
+ expect(mockLink.click).toHaveBeenCalled();
+ expect(revokeObjectURLSpy).toHaveBeenCalledWith("mock-url");
+ });
+
+ it("should use custom filename when provided", () => {
+ // Arrange
+ const data1D: Data1D = {
+ data: [[0, 1]],
+ labels: ["x", "y"],
+ };
+ const customFilename = "my-custom-plot.csv";
+
+ // Act
+ exportPlotDataToCsv(data1D, customFilename);
+
+ // Assert
+ expect(mockLink.setAttribute).toHaveBeenCalledWith("download", customFilename);
+ });
+
+ it("should create blob with correct CSV content", () => {
+ // Arrange
+ const data1D: Data1D = {
+ data: [
+ [0, 1],
+ [1, 2],
+ ],
+ labels: ["time", "value"],
+ };
+
+ const blobSpy = vi.spyOn(global, "Blob");
+
+ // Act
+ exportPlotDataToCsv(data1D);
+
+ // Assert
+ expect(blobSpy).toHaveBeenCalledWith(
+ ["time,value\n0,1\n1,2"],
+ { type: "text/csv;charset=utf-8;" },
+ );
+ });
+
+ it("should set link visibility to hidden", () => {
+ // Arrange
+ const data1D: Data1D = {
+ data: [[0, 1]],
+ labels: ["x", "y"],
+ };
+
+ // Act
+ exportPlotDataToCsv(data1D);
+
+ // Assert
+ expect(mockLink.style.visibility).toBe("hidden");
+ });
+
+ it("should append and remove link from document body", () => {
+ // Arrange
+ const data1D: Data1D = {
+ data: [[0, 1]],
+ labels: ["x", "y"],
+ };
+ const appendChildSpy = vi.spyOn(document.body, "appendChild");
+ const removeChildSpy = vi.spyOn(document.body, "removeChild");
+
+ // Act
+ exportPlotDataToCsv(data1D);
+
+ // Assert
+ expect(appendChildSpy).toHaveBeenCalledWith(mockLink);
+ expect(removeChildSpy).toHaveBeenCalledWith(mockLink);
+ });
+});
diff --git a/src/utils/exportCsv.ts b/src/utils/exportCsv.ts
new file mode 100644
index 00000000..671b217d
--- /dev/null
+++ b/src/utils/exportCsv.ts
@@ -0,0 +1,53 @@
+import { Data1D } from "../types";
+
+/**
+ * Converts plot data to CSV format
+ * @param data1D - The 1D plot data containing data rows and column labels
+ * @returns CSV string
+ */
+export function plotDataToCsv(data1D: Data1D): string {
+ const { data, labels } = data1D;
+
+ // Handle empty data
+ if (!data || data.length === 0) {
+ return labels.join(",") + "\n";
+ }
+
+ // Create CSV header from labels
+ const header = labels.join(",");
+
+ // Create CSV rows from data
+ const rows = data.map((row) => row.join(","));
+
+ // Combine header and rows
+ return [header, ...rows].join("\n");
+}
+
+/**
+ * Exports plot data to a CSV file and triggers download
+ * @param data1D - The 1D plot data containing data rows and column labels
+ * @param filename - The filename for the downloaded CSV file (default: "plot-data.csv")
+ */
+export function exportPlotDataToCsv(data1D: Data1D, filename = "plot-data.csv"): void {
+ const csv = plotDataToCsv(data1D);
+
+ // Create a blob from the CSV string
+ const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
+
+ // Create a temporary link element to trigger download
+ const link = document.createElement("a");
+ const url = URL.createObjectURL(blob);
+
+ link.setAttribute("href", url);
+ link.setAttribute("download", filename);
+ link.style.visibility = "hidden";
+
+ document.body.appendChild(link);
+ try {
+ link.click();
+ } finally {
+ document.body.removeChild(link);
+ // Clean up the URL object
+ URL.revokeObjectURL(url);
+ }
+}