Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions app/e2e/chart-export.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { test, expect, ALICE } from "./fixtures";

test.describe("Chart export actions (issue #872)", () => {
test("ECharts widget exposes Export PNG + Export SVG and PNG downloads", async ({
authPage,
page,
}) => {
test.setTimeout(60_000);
await authPage.login(ALICE.email, ALICE.password);

// Movie Analytics seeded dashboard — first widget is a bar chart (ECharts)
const res = await page.request.get("/api/dashboards");
const dashboards = (await res.json()).data as {
id: string;
name: string;
}[];
const movieAnalytics = dashboards.find((d) => d.name === "Movie Analytics");
expect(movieAnalytics).toBeTruthy();
await page.goto(`/${movieAnalytics!.id}`);

// Find the first ECharts widget — one whose card contains a base-chart element
const echartsCard = page
.locator("[data-testid='widget-card']")
.filter({ has: page.locator("[data-testid='base-chart']") })
.first();
await expect(echartsCard).toBeVisible({ timeout: 15_000 });
await echartsCard.hover();
await echartsCard.getByRole("button", { name: "Widget actions" }).click();

// Both export actions present
await expect(
page.getByRole("menuitem", { name: "Export PNG" }),
).toBeVisible();
await expect(
page.getByRole("menuitem", { name: "Export SVG" }),
).toBeVisible();

// Clicking Export PNG triggers a .png download
const downloadPromise = page.waitForEvent("download");
await page.getByRole("menuitem", { name: "Export PNG" }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/\.png$/);
});
});
28 changes: 24 additions & 4 deletions app/src/components/dashboard-container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import {
buildCsvString,
triggerDownload,
triggerSvgDownload,
triggerPngDownload,
buildExportFilename,
exportChartToSvg,
exportChartToPng,
} from "@neoboard/components";
import { interpolateTitle } from "@/lib/widget/interpolate-title";
import { buildExportData } from "@/lib/widget/card-utils";
Expand Down Expand Up @@ -209,6 +211,18 @@ export function DashboardContainer({
triggerSvgDownload(svg, filename);
}

function exportWidgetPng(widget: DashboardWidget) {
const el = document.querySelector(`[data-widget-id="${widget.id}"]`);
if (!el) return;
const chartEl = el.querySelector<HTMLElement>('[data-testid="base-chart"]');
if (!chartEl) return;
const dataUrl = exportChartToPng(chartEl);
if (!dataUrl) return;
const title = (widget.settings?.title as string) || widget.chartType;
const filename = buildExportFilename(title, "png", page.title);
triggerPngDownload(dataUrl, filename);
}

const buildActions = (widget: DashboardWidget) => {
const actions = [];

Expand All @@ -220,10 +234,16 @@ export function DashboardContainer({
}

if (getChartConfig(widget.chartType)?.capabilities.isECharts) {
actions.push({
label: "Export SVG",
onClick: () => exportWidgetSvg(widget),
});
actions.push(
{
label: "Export PNG",
onClick: () => exportWidgetPng(widget),
},
{
label: "Export SVG",
onClick: () => exportWidgetSvg(widget),
},
);
}

if (onSaveAsTemplate) {
Expand Down
208 changes: 208 additions & 0 deletions component/src/charts/__tests__/chart-export.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";

const h = vi.hoisted(() => {
const mockGetInstanceByDom = vi.fn();
const mockOffscreenSetOption = vi.fn();
const mockOffscreenDispose = vi.fn();
const mockGetOption = vi.fn(() => ({ title: { text: "Test" } }));
const mockGetDataURL = vi.fn(() => "data:image/png;base64,STUB");
const state: { initSideEffect: ((el: HTMLElement) => void) | null } = {
initSideEffect: (offscreen) => {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
offscreen.appendChild(svg);
},
};
return {
mockGetInstanceByDom,
mockOffscreenSetOption,
mockOffscreenDispose,
mockGetOption,
mockGetDataURL,
state,
};
});

vi.mock("echarts/core", () => {
const use = vi.fn();
const registerTheme = vi.fn();
const init = vi.fn((offscreen: HTMLElement) => {
if (h.state.initSideEffect) h.state.initSideEffect(offscreen);
return {
setOption: h.mockOffscreenSetOption,
dispose: h.mockOffscreenDispose,
};
});
return {
use,
init,
registerTheme,
getInstanceByDom: h.mockGetInstanceByDom,
default: {
use,
init,
registerTheme,
getInstanceByDom: h.mockGetInstanceByDom,
},
};
});

vi.mock("echarts/components", () => ({
TitleComponent: vi.fn(),
TooltipComponent: vi.fn(),
LegendComponent: vi.fn(),
GridComponent: vi.fn(),
DataZoomComponent: vi.fn(),
AriaComponent: vi.fn(),
RadarComponent: vi.fn(),
MarkLineComponent: vi.fn(),
GraphicComponent: vi.fn(),
}));

import { exportChartToSvg, exportChartToPng } from "../base-chart";

function makeVisibleChartStub(width = 400, height = 300) {
const dom = document.createElement("div");
vi.spyOn(dom, "getBoundingClientRect").mockReturnValue({
width,
height,
top: 0,
left: 0,
right: width,
bottom: height,
x: 0,
y: 0,
toJSON: () => ({}),
} as DOMRect);
return {
getOption: h.mockGetOption,
getDom: () => dom,
getDataURL: h.mockGetDataURL,
};
}

describe("exportChartToSvg", () => {
beforeEach(() => {
vi.clearAllMocks();
h.mockOffscreenSetOption.mockReset();
h.mockOffscreenDispose.mockReset();
h.state.initSideEffect = (offscreen) => {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
offscreen.appendChild(svg);
};
h.mockGetOption.mockReturnValue({ title: { text: "Test" } });
});

afterEach(() => {
document
.querySelectorAll('div[style*="-9999px"]')
.forEach((el) => el.remove());
});

it("returns SVG string and disposes offscreen instance on success", () => {
const container = document.createElement("div");
h.mockGetInstanceByDom.mockReturnValue(makeVisibleChartStub());

const result = exportChartToSvg(container);

expect(typeof result).toBe("string");
expect(result).toContain("<svg");
expect(h.mockOffscreenDispose).toHaveBeenCalledTimes(1);
expect(document.querySelector('div[style*="-9999px"]')).toBeNull();
});

it("returns null when no chart instance is found", () => {
const container = document.createElement("div");
h.mockGetInstanceByDom.mockReturnValue(null);

const result = exportChartToSvg(container);

expect(result).toBeNull();
expect(h.mockOffscreenDispose).not.toHaveBeenCalled();
});

it("disposes offscreen instance and removes DOM when setOption throws", () => {
const container = document.createElement("div");
h.mockGetInstanceByDom.mockReturnValue(makeVisibleChartStub());
h.mockOffscreenSetOption.mockImplementation(() => {
throw new Error("setOption boom");
});

expect(() => exportChartToSvg(container)).toThrow("setOption boom");
expect(h.mockOffscreenDispose).toHaveBeenCalledTimes(1);
expect(document.querySelector('div[style*="-9999px"]')).toBeNull();
});

it("returns null and disposes when no svg element is rendered", () => {
const container = document.createElement("div");
h.mockGetInstanceByDom.mockReturnValue(makeVisibleChartStub());
h.state.initSideEffect = null;

const result = exportChartToSvg(container);

expect(result).toBeNull();
expect(h.mockOffscreenDispose).toHaveBeenCalledTimes(1);
expect(document.querySelector('div[style*="-9999px"]')).toBeNull();
});

it("rounds fractional dimensions before passing to echarts.init", async () => {
const container = document.createElement("div");
h.mockGetInstanceByDom.mockReturnValue(makeVisibleChartStub(403.7, 299.4));

exportChartToSvg(container);

const core = await import("echarts/core");
const initCall = (core.init as ReturnType<typeof vi.fn>).mock.calls[0];
const initOpts = initCall[2] as { width: number; height: number };
expect(initOpts.width).toBe(404);
expect(initOpts.height).toBe(299);
expect(Number.isInteger(initOpts.width)).toBe(true);
expect(Number.isInteger(initOpts.height)).toBe(true);
});
});

describe("exportChartToPng", () => {
beforeEach(() => {
vi.clearAllMocks();
h.mockGetDataURL.mockReset();
h.mockGetDataURL.mockReturnValue("data:image/png;base64,STUB");
document.documentElement.classList.remove("dark");
});

it("returns a PNG data URL with light background in light mode", () => {
const container = document.createElement("div");
h.mockGetInstanceByDom.mockReturnValue(makeVisibleChartStub());

const result = exportChartToPng(container);

expect(result).toBe("data:image/png;base64,STUB");
expect(h.mockGetDataURL).toHaveBeenCalledWith(
expect.objectContaining({
type: "png",
pixelRatio: 2,
backgroundColor: "#ffffff",
}),
);
});

it("uses dark background when dark mode is active", () => {
document.documentElement.classList.add("dark");
const container = document.createElement("div");
h.mockGetInstanceByDom.mockReturnValue(makeVisibleChartStub());

exportChartToPng(container);

expect(h.mockGetDataURL).toHaveBeenCalledWith(
expect.objectContaining({ backgroundColor: "#0a0f1e" }),
);
});

it("returns null when no chart instance is found", () => {
const container = document.createElement("div");
h.mockGetInstanceByDom.mockReturnValue(null);

const result = exportChartToPng(container);

expect(result).toBeNull();
expect(h.mockGetDataURL).not.toHaveBeenCalled();
});
});
40 changes: 29 additions & 11 deletions component/src/charts/base-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -292,44 +292,62 @@ function exportChartToSvg(container: HTMLElement): string | null {
if (!instance) return null;

const options = instance.getOption();
const { width, height } = instance.getDom().getBoundingClientRect();
const rect = instance.getDom().getBoundingClientRect();
// Round to integers — some SVG viewers handle fractional dimensions inconsistently
const width = Math.round(rect.width);
const height = Math.round(rect.height);

// Create an offscreen container for the SVG renderer
const offscreen = document.createElement("div");
offscreen.style.width = `${width}px`;
offscreen.style.height = `${height}px`;
offscreen.style.position = "absolute";
offscreen.style.left = "-9999px";
document.body.appendChild(offscreen);

let svgInstance: ReturnType<typeof echarts.init> | null = null;
try {
const themeName = isDarkMode() ? THEME_DARK : THEME_LIGHT;
const svgInstance = echarts.init(offscreen, themeName, {
svgInstance = echarts.init(offscreen, themeName, {
renderer: "svg",
width,
height,
});
svgInstance.setOption(options);

// Extract the rendered SVG from the offscreen container
const svgEl = offscreen.querySelector("svg");
if (!svgEl) {
svgInstance.dispose();
return null;
}
if (!svgEl) return null;

const svgString = new XMLSerializer().serializeToString(svgEl);
svgInstance.dispose();
return svgString;
return new XMLSerializer().serializeToString(svgEl);
} finally {
// dispose-in-finally guarantees no leak on any throw path
if (svgInstance) svgInstance.dispose();
document.body.removeChild(offscreen);
}
}

/**
* Export an ECharts chart to a PNG data URL by reading from the existing
* canvas-rendered instance. Background matches the current theme so the
* exported image looks like what's on screen.
*
* @returns PNG data URL (image/png) or null if the chart instance can't be found
*/
function exportChartToPng(container: HTMLElement): string | null {
const instance = echarts.getInstanceByDom(container);
if (!instance) return null;
const backgroundColor = isDarkMode() ? "#0a0f1e" : "#ffffff";
return instance.getDataURL({
type: "png",
pixelRatio: 2,
backgroundColor,
});
}

export {
BaseChart,
CHART_COLORS_FALLBACK as CHART_COLORS,
resolveChartColors,
useDarkMode,
exportChartToSvg,
exportChartToPng,
};
1 change: 1 addition & 0 deletions component/src/charts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export {
resolveChartColors,
useDarkMode,
exportChartToSvg,
exportChartToPng,
} from "./base-chart";
export {
THEME_LIGHT,
Expand Down
Loading
Loading