@@ -32,6 +32,7 @@ import {
3232} from '@mantine/core' ;
3333import {
3434 IconAlertTriangle ,
35+ IconDownload ,
3536 IconEye ,
3637 IconFocus2 ,
3738 IconFocusCentered ,
@@ -41,6 +42,7 @@ import {
4142 IconRefresh ,
4243} from '@tabler/icons-react' ;
4344import { createLazyFileRoute } from '@tanstack/react-router' ;
45+ import { notifications } from '@mantine/notifications' ;
4446import React , { useCallback , useMemo , useRef , useState } from 'react' ;
4547import { 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