Skip to content

Commit 4709f09

Browse files
committed
feat(web): add PNG export for graph visualization
- Added export button in graph page header with download icon - Export handler finds canvas element via DOM query - Generates timestamped PNG file (graph-YYYY-MM-DD-HHMMSS.png) - Shows loading state and success/error notifications - Uses graphContainerRef to access the rendered canvas
1 parent 59ac39c commit 4709f09

File tree

1 file changed

+76
-1
lines changed

1 file changed

+76
-1
lines changed

apps/web/src/routes/graph.lazy.tsx

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
} from '@mantine/core';
3333
import {
3434
IconAlertTriangle,
35+
IconDownload,
3536
IconEye,
3637
IconFocus2,
3738
IconFocusCentered,
@@ -41,6 +42,7 @@ import {
4142
IconRefresh,
4243
} from '@tabler/icons-react';
4344
import { createLazyFileRoute } from '@tanstack/react-router';
45+
import { notifications } from '@mantine/notifications';
4446
import React, { useCallback, useMemo, useRef, useState } from 'react';
4547
import { type ForceGraphMethods } from 'react-force-graph-2d';
4648

@@ -124,6 +126,9 @@ const EntityGraphPage = () => {
124126
// Graph methods ref for external control (zoomToFit, etc.)
125127
const graphMethodsRef = useRef<GraphMethods | null>(null);
126128

129+
// Ref for the graph container to access canvas for export
130+
const graphContainerRef = useRef<HTMLDivElement>(null);
131+
127132
// Context menu state
128133
const [contextMenu, setContextMenu] = useState<ContextMenuState>(INITIAL_CONTEXT_MENU_STATE);
129134

@@ -194,6 +199,65 @@ const EntityGraphPage = () => {
194199
// Count enabled sources with entities
195200
const enabledSourceCount = sources.filter(s => enabledSourceIds.has(s.source.id)).length;
196201

202+
// Export state
203+
const [isExporting, setIsExporting] = useState(false);
204+
205+
// Handle graph export as PNG
206+
const handleExportPNG = useCallback(() => {
207+
if (!graphContainerRef.current) {
208+
notifications.show({
209+
title: 'Export Failed',
210+
message: 'Graph container is not ready for export.',
211+
color: 'red',
212+
});
213+
return;
214+
}
215+
216+
try {
217+
setIsExporting(true);
218+
219+
// Find the canvas element within the graph container
220+
const canvas = graphContainerRef.current.querySelector('canvas');
221+
if (!canvas) {
222+
throw new Error('Could not find graph canvas element');
223+
}
224+
225+
// Convert canvas to data URL
226+
const dataUrl = canvas.toDataURL('image/png');
227+
228+
// Create filename with timestamp
229+
const date = new Date().toISOString().split('T')[0];
230+
const time = new Date().toISOString().split('T')[1].split('.')[0].replaceAll(':', '-');
231+
const filename = `graph-${date}-${time}.png`;
232+
233+
// Create download link
234+
const link = document.createElement('a');
235+
link.href = dataUrl;
236+
link.download = filename;
237+
link.style.display = 'none';
238+
239+
document.body.append(link);
240+
link.click();
241+
242+
// Cleanup
243+
link.remove();
244+
setIsExporting(false);
245+
246+
notifications.show({
247+
title: 'Export Successful',
248+
message: `Graph exported as ${filename}`,
249+
color: 'green',
250+
});
251+
} catch (error) {
252+
setIsExporting(false);
253+
notifications.show({
254+
title: 'Export Failed',
255+
message: error instanceof Error ? error.message : 'Failed to export graph',
256+
color: 'red',
257+
});
258+
}
259+
}, []);
260+
197261
// Loading state
198262
if (loading && sources.length === 0) {
199263
return (
@@ -301,7 +365,17 @@ const EntityGraphPage = () => {
301365
</Text>
302366
</Stack>
303367
</Group>
304-
<Group>
368+
<Group gap="xs">
369+
<Tooltip label="Export graph as PNG">
370+
<ActionIcon
371+
variant="light"
372+
onClick={handleExportPNG}
373+
loading={isExporting}
374+
aria-label="Export graph as PNG"
375+
>
376+
<IconDownload size={ICON_SIZE.MD} />
377+
</ActionIcon>
378+
</Tooltip>
305379
<Tooltip label="Refresh data">
306380
<ActionIcon variant="light" onClick={refresh} loading={loading}>
307381
<IconRefresh size={ICON_SIZE.MD} />
@@ -386,6 +460,7 @@ const EntityGraphPage = () => {
386460

387461
{/* Graph Container */}
388462
<Box
463+
ref={graphContainerRef}
389464
h={LAYOUT.GRAPH_VIEWPORT_HEIGHT}
390465
mih={350}
391466
style={{

0 commit comments

Comments
 (0)