Skip to content

Commit f0971fa

Browse files
authored
Merge pull request #2173 from mito-ds/feat/chart-wizard-export
Chart Wizard Export
2 parents fd833a7 + 9012f19 commit f0971fa

File tree

10 files changed

+320
-16
lines changed

10 files changed

+320
-16
lines changed

mito-ai/src/Extensions/ChartWizard/ChartWizardPlugin.tsx

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { Widget } from '@lumino/widgets';
1515
import { ChartWizardWidget } from './ChartWizardWidget';
1616
import { COMMAND_MITO_AI_OPEN_CHART_WIZARD } from '../../commands';
1717
import TextAndIconButton from '../../components/TextAndIconButton';
18-
import MagicWand from '../../icons/MagicWand'
18+
import MagicWand from '../../icons/MagicWand';
1919
import { logEvent } from '../../restAPI/RestAPI';
2020
import { setActiveCellByIDInNotebookPanel } from '../../utils/notebook';
2121
import '../../../style/ChartWizardPlugin.css'
@@ -34,18 +34,16 @@ interface ChartWizardButtonProps {
3434

3535
const ChartWizardButton: React.FC<ChartWizardButtonProps> = ({ onButtonClick }) => {
3636
return (
37-
<>
38-
<TextAndIconButton
39-
icon={MagicWand}
40-
text="Chart Wizard"
41-
title="Chart Wizard"
42-
onClick={onButtonClick}
43-
variant='purple'
44-
width='fit-contents'
45-
iconPosition='left'
46-
/>
47-
</>
48-
)
37+
<TextAndIconButton
38+
icon={MagicWand}
39+
text="Chart Wizard"
40+
title="Chart Wizard"
41+
onClick={onButtonClick}
42+
variant="purple"
43+
width="fit-contents"
44+
iconPosition="left"
45+
/>
46+
);
4947
};
5048

5149
const ChartWizardPlugin: JupyterFrontEndPlugin<void> = {
@@ -202,7 +200,7 @@ class AugmentedImageRenderer extends Widget implements IRenderMime.IRenderer {
202200
super.dispose();
203201
}
204202

205-
/*
203+
/*
206204
Handle the Chart Wizard button click.
207205
Extracts chart data and source code, then opens the Chart Wizard panel.
208206
*/

mito-ai/src/Extensions/ChartWizard/ChartWizardWidget.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
77
import { ReactWidget, Notification } from '@jupyterlab/apputils';
88
import { ChartWizardData } from './ChartWizardPlugin';
99
import { updateChartConfig, ChartConfigVariable } from './utils/parser';
10+
import { exportChartImage, type ExportImageFormat } from './utils/chartExport';
1011
import { convertChartCode, logEvent } from '../../restAPI/RestAPI';
1112
import { removeMarkdownCodeFormatting } from '../../utils/strings';
1213
import LoadingDots from '../../components/LoadingDots';
14+
import ToggleButton from '../../components/ToggleButton';
1315
import AddFieldButton from './AddFieldButton';
1416
import {
1517
BooleanInputRow,
@@ -45,6 +47,7 @@ const ChartWizardContent: React.FC<ChartWizardContentProps> = ({ chartData }) =>
4547
const widgetRef = useRef<HTMLDivElement>(null);
4648
const [overlayHeight, setOverlayHeight] = useState<number>(0);
4749
const [isActiveCellMismatch, setIsActiveCellMismatch] = useState(false);
50+
const [exportFormat, setExportFormat] = useState<ExportImageFormat>('png');
4851

4952
// Reset currentSourceCode when switching to a different chart
5053
useEffect(() => {
@@ -214,6 +217,19 @@ const ChartWizardContent: React.FC<ChartWizardContentProps> = ({ chartData }) =>
214217
updateNotebookCell(updatedCode);
215218
}, [updateNotebookCell]);
216219

220+
/**
221+
* Exports the chart image to disk via the chartExport utility; shows a notification on error.
222+
*/
223+
const handleExportChart = useCallback(async (): Promise<void> => {
224+
void logEvent('chart_wizard_export_clicked');
225+
if (!chartData) return;
226+
227+
const result = await exportChartImage(chartData, exportFormat);
228+
if (!result.success) {
229+
Notification.emit(result.error, 'error', { autoClose: 5000 });
230+
}
231+
}, [chartData, exportFormat]);
232+
217233
/**
218234
* Renders the appropriate input field component based on variable type.
219235
*/
@@ -319,6 +335,25 @@ const ChartWizardContent: React.FC<ChartWizardContentProps> = ({ chartData }) =>
319335
clearPendingUpdate={clearPendingUpdate}
320336
onLoadingStateChange={setIsAddingField}
321337
/>
338+
<div className="chart-wizard-export-section">
339+
<h3 className="chart-wizard-section-heading">Export</h3>
340+
<div className="chart-wizard-export-format-row">
341+
<ToggleButton
342+
leftText="PNG"
343+
rightText="JPG"
344+
isLeftSelected={exportFormat === 'png'}
345+
onChange={(isPng) => setExportFormat(isPng ? 'png' : 'jpeg')}
346+
/>
347+
</div>
348+
<button
349+
className="button-base button-purple add-field-button"
350+
type="button"
351+
title="Save chart image to file"
352+
onClick={handleExportChart}
353+
>
354+
Export image
355+
</button>
356+
</div>
322357
</div>
323358
) : (
324359
<div className="chart-wizard-no-config">
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/*
2+
* Copyright (c) Saga Inc.
3+
* Distributed under the terms of the GNU Affero General Public License v3.0 License.
4+
*/
5+
6+
import { ChartWizardData } from '../ChartWizardPlugin';
7+
import { findChartImageDataUrl } from './imageFinder';
8+
import { saveWithFilePicker, isFileSystemAccessAvailable } from './fileSaver';
9+
import { downloadImage } from './download';
10+
import { ExportImageFormat } from './types';
11+
12+
export type ExportChartResult = { success: true } | { success: false; error: string };
13+
14+
export type { ExportImageFormat };
15+
16+
/**
17+
* Exports the chart image to the user's disk. Uses File System Access API when available
18+
* so the user can choose the save location; otherwise triggers a download.
19+
*
20+
* @param chartData - Chart wizard data identifying the notebook panel and cell
21+
* @param format - Export format: 'png' or 'jpeg'
22+
* @returns Result indicating success or an error message for the UI to display
23+
*/
24+
export async function exportChartImage(
25+
chartData: ChartWizardData,
26+
format: ExportImageFormat = 'png'
27+
): Promise<ExportChartResult> {
28+
const found = findChartImageDataUrl(chartData);
29+
if (!found.ok) return { success: false, error: found.error };
30+
31+
if (isFileSystemAccessAvailable()) {
32+
try {
33+
await saveWithFilePicker(found.dataUrl, format);
34+
} catch (err) {
35+
if ((err as { name?: string }).name === 'AbortError') {
36+
return { success: true };
37+
}
38+
await downloadImage(found.dataUrl, format);
39+
}
40+
} else {
41+
await downloadImage(found.dataUrl, format);
42+
}
43+
44+
return { success: true };
45+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*
2+
* Copyright (c) Saga Inc.
3+
* Distributed under the terms of the GNU Affero General Public License v3.0 License.
4+
*/
5+
6+
import { ExportImageFormat } from './types';
7+
import { dataUrlToBlob } from './imageConverter';
8+
9+
const SUGGESTED_NAMES: Record<ExportImageFormat, string> = {
10+
png: 'chart.png',
11+
jpeg: 'chart.jpg'
12+
};
13+
14+
/**
15+
* Triggers a browser download of a URL.
16+
*/
17+
function triggerDownload(url: string, filename: string): void {
18+
const a = document.createElement('a');
19+
a.href = url;
20+
a.download = filename;
21+
a.click();
22+
}
23+
24+
/**
25+
* Downloads an image using the browser's fallback download mechanism.
26+
*/
27+
export async function downloadImage(
28+
dataUrl: string,
29+
format: ExportImageFormat
30+
): Promise<void> {
31+
if (format === 'jpeg') {
32+
const blob = await dataUrlToBlob(dataUrl, format);
33+
const url = URL.createObjectURL(blob);
34+
triggerDownload(url, SUGGESTED_NAMES.jpeg);
35+
URL.revokeObjectURL(url);
36+
} else {
37+
triggerDownload(dataUrl, SUGGESTED_NAMES.png);
38+
}
39+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/*
2+
* Copyright (c) Saga Inc.
3+
* Distributed under the terms of the GNU Affero General Public License v3.0 License.
4+
*/
5+
6+
import { ExportImageFormat } from './types';
7+
import { dataUrlToBlob } from './imageConverter';
8+
9+
const SUGGESTED_NAMES: Record<ExportImageFormat, string> = {
10+
png: 'chart.png',
11+
jpeg: 'chart.jpg'
12+
};
13+
14+
const FILE_PICKER_TYPES: Record<
15+
ExportImageFormat,
16+
Array<{ description: string; accept: Record<string, string[]> }>
17+
> = {
18+
png: [{ description: 'PNG Image', accept: { 'image/png': ['.png'] } }],
19+
jpeg: [{ description: 'JPEG Image', accept: { 'image/jpeg': ['.jpg', '.jpeg'] } }]
20+
};
21+
22+
/**
23+
* Checks if the File System Access API is available.
24+
*/
25+
export function isFileSystemAccessAvailable(): boolean {
26+
return (
27+
'showSaveFilePicker' in window &&
28+
typeof (window as Window & { showSaveFilePicker?: unknown }).showSaveFilePicker ===
29+
'function'
30+
);
31+
}
32+
33+
/**
34+
* Saves a blob to disk using the File System Access API.
35+
*/
36+
async function writeBlobToFile(blob: Blob, handle: FileSystemFileHandle): Promise<void> {
37+
const writable = await (handle as FileSystemFileHandle & {
38+
createWritable(): Promise<{ write(data: Blob): Promise<void>; close(): Promise<void> }>;
39+
}).createWritable();
40+
try {
41+
await writable.write(blob);
42+
} finally {
43+
await writable.close();
44+
}
45+
}
46+
47+
/**
48+
* Saves an image to disk using the File System Access API.
49+
*/
50+
export async function saveWithFilePicker(
51+
dataUrl: string,
52+
format: ExportImageFormat
53+
): Promise<void> {
54+
const handle = await (window as Window & {
55+
showSaveFilePicker?: (options: {
56+
suggestedName?: string;
57+
types?: Array<{
58+
description: string;
59+
accept: Record<string, string[]>;
60+
}>;
61+
}) => Promise<FileSystemFileHandle>;
62+
}).showSaveFilePicker?.({
63+
suggestedName: SUGGESTED_NAMES[format],
64+
types: FILE_PICKER_TYPES[format]
65+
});
66+
if (!handle) return;
67+
68+
const blob = await dataUrlToBlob(dataUrl, format);
69+
await writeBlobToFile(blob, handle);
70+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/*
2+
* Copyright (c) Saga Inc.
3+
* Distributed under the terms of the GNU Affero General Public License v3.0 License.
4+
*/
5+
6+
import { ExportImageFormat } from './types';
7+
8+
const JPEG_QUALITY = 1.0;
9+
10+
/**
11+
* Converts a data URL image to a JPEG blob.
12+
*/
13+
export function dataUrlToJpegBlob(dataUrl: string): Promise<Blob> {
14+
return new Promise((resolve, reject) => {
15+
const img = new Image();
16+
img.crossOrigin = 'anonymous';
17+
img.onload = (): void => {
18+
const canvas = document.createElement('canvas');
19+
canvas.width = img.naturalWidth;
20+
canvas.height = img.naturalHeight;
21+
const ctx = canvas.getContext('2d');
22+
if (!ctx) {
23+
reject(new Error('Could not get canvas context'));
24+
return;
25+
}
26+
ctx.drawImage(img, 0, 0);
27+
canvas.toBlob(
28+
(blob) => (blob ? resolve(blob) : reject(new Error('toBlob failed'))),
29+
'image/jpeg',
30+
JPEG_QUALITY
31+
);
32+
};
33+
img.onerror = (): void => reject(new Error('Failed to load image'));
34+
img.src = dataUrl;
35+
});
36+
}
37+
38+
/**
39+
* Converts a data URL to a blob in the specified format.
40+
*/
41+
export async function dataUrlToBlob(
42+
dataUrl: string,
43+
format: ExportImageFormat
44+
): Promise<Blob> {
45+
if (format === 'jpeg') {
46+
return dataUrlToJpegBlob(dataUrl);
47+
}
48+
return fetch(dataUrl).then((r) => r.blob());
49+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/*
2+
* Copyright (c) Saga Inc.
3+
* Distributed under the terms of the GNU Affero General Public License v3.0 License.
4+
*/
5+
6+
import { CodeCell } from '@jupyterlab/cells';
7+
import { ChartWizardData } from '../ChartWizardPlugin';
8+
9+
export type FindImageResult =
10+
| { ok: true; dataUrl: string }
11+
| { ok: false; error: string };
12+
13+
/**
14+
* Finds the chart image data URL from the notebook cell output.
15+
*/
16+
export function findChartImageDataUrl(chartData: ChartWizardData): FindImageResult {
17+
const notebookPanel = chartData.notebookTracker.find(
18+
(panel) => panel.id === chartData.notebookPanelId
19+
);
20+
if (!notebookPanel) {
21+
return { ok: false, error: 'Could not find the notebook.' };
22+
}
23+
24+
const cellWidget = notebookPanel.content.widgets.find(
25+
(cell) => cell.model.id === chartData.cellId
26+
);
27+
if (!(cellWidget instanceof CodeCell)) {
28+
return { ok: false, error: 'Could not find the chart cell.' };
29+
}
30+
31+
const outputNode = cellWidget.outputArea.node;
32+
const img = outputNode.querySelector(
33+
'.jp-RenderedImage img[src^="data:image"]'
34+
) as HTMLImageElement | null;
35+
36+
if (!img || !img.src || !img.src.startsWith('data:image')) {
37+
return {
38+
ok: false,
39+
error: 'No chart image found. Re-run the chart cell and try again.'
40+
};
41+
}
42+
return { ok: true, dataUrl: img.src };
43+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/*
2+
* Copyright (c) Saga Inc.
3+
* Distributed under the terms of the GNU Affero General Public License v3.0 License.
4+
*/
5+
6+
export type ExportImageFormat = 'png' | 'jpeg';

mito-ai/style/AddFieldButton.css

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@
55

66
.add-field-container {
77
margin-top: 16px;
8-
padding-top: 16px;
9-
border-top: 1px solid #e0e0e0;
108
}
119

1210
.add-field-button {

0 commit comments

Comments
 (0)