Skip to content

Commit 4388dd4

Browse files
committed
feat(web): add SVG export for graphs (task-14)
1 parent 0487e81 commit 4388dd4

File tree

2 files changed

+233
-2
lines changed

2 files changed

+233
-2
lines changed

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

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ import { type GraphMethods, useFitToView } from '@/hooks/useFitToView';
7070
import { useGraphAnnotations } from '@/hooks/useGraphAnnotations';
7171
import { type GraphLayoutType,useGraphLayout } from '@/hooks/useGraphLayout';
7272
import { useNodeExpansion } from '@/lib/graph-index';
73+
import { downloadGraphSVG } from '@/utils/exportUtils';
7374

7475
/**
7576
* Entity Graph Page Component
@@ -273,6 +274,7 @@ const EntityGraphPage = () => {
273274

274275
// Export state
275276
const [isExporting, setIsExporting] = useState(false);
277+
const [isExportingSVG, setIsExportingSVG] = useState(false);
276278

277279
// Handle graph export as PNG
278280
const handleExportPNG = useCallback(() => {
@@ -330,6 +332,54 @@ const EntityGraphPage = () => {
330332
}
331333
}, []);
332334

335+
// Handle graph export as SVG
336+
const handleExportSVG = useCallback(() => {
337+
if (nodes.length === 0) {
338+
notifications.show({
339+
title: 'Export Failed',
340+
message: 'Graph has no nodes to export.',
341+
color: 'red',
342+
});
343+
return;
344+
}
345+
346+
try {
347+
setIsExportingSVG(true);
348+
349+
const width = graphContainerRef.current?.clientWidth ?? 1200;
350+
const height = typeof window !== 'undefined' ? window.innerHeight * 0.55 : 600;
351+
352+
// Create filename with timestamp
353+
const date = new Date().toISOString().split('T')[0];
354+
const time = new Date().toISOString().split('T')[1].split('.')[0].replaceAll(':', '-');
355+
const filename = `graph-${date}-${time}`;
356+
357+
// Download SVG
358+
downloadGraphSVG(nodes, edges, {
359+
width,
360+
height,
361+
padding: 50,
362+
includeLegend: true,
363+
nodePositions,
364+
}, filename);
365+
366+
setIsExportingSVG(false);
367+
368+
notifications.show({
369+
title: 'Export Successful',
370+
message: `Graph exported as ${filename}.svg`,
371+
color: 'green',
372+
});
373+
} catch (error) {
374+
setIsExportingSVG(false);
375+
notifications.show({
376+
title: 'Export Failed',
377+
message: error instanceof Error ? error.message : 'Failed to export graph',
378+
color: 'red',
379+
});
380+
}
381+
}, [nodes, edges, nodePositions]);
382+
333383
// Loading state
334384
if (loading && sources.length === 0) {
335385
return (
@@ -459,6 +509,16 @@ const EntityGraphPage = () => {
459509
<IconDownload size={ICON_SIZE.MD} />
460510
</ActionIcon>
461511
</Tooltip>
512+
<Tooltip label="Export graph as SVG">
513+
<ActionIcon
514+
variant="light"
515+
onClick={handleExportSVG}
516+
loading={isExportingSVG}
517+
aria-label="Export graph as SVG"
518+
>
519+
<IconDownload size={ICON_SIZE.MD} />
520+
</ActionIcon>
521+
</Tooltip>
462522
<Tooltip label="Refresh data">
463523
<ActionIcon variant="light" onClick={refresh} loading={loading}>
464524
<IconRefresh size={ICON_SIZE.MD} />

apps/web/src/utils/exportUtils.ts

Lines changed: 173 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
/**
22
* Export Utilities
33
*
4-
* Provides functionality to export search results to CSV and BibTeX formats.
4+
* Provides functionality to export search results to CSV and BibTeX formats,
5+
* and graph visualizations to SVG format.
56
*/
67

7-
import type { AutocompleteResult } from '@bibgraph/types';
8+
import { ENTITY_TYPE_COLORS as HASH_BASED_ENTITY_COLORS } from '@/styles/hash-colors';
9+
import type { AutocompleteResult, GraphEdge, GraphNode } from '@bibgraph/types';
810

911
/**
1012
* Convert search results to CSV format
@@ -145,3 +147,172 @@ export const getExportFilename = (query: string, format: 'csv' | 'bib'): string
145147
? `${sanitizedQuery}-results-${timestamp}.${format}`
146148
: `search-results-${timestamp}.${format}`;
147149
};
150+
151+
interface SVGExportOptions {
152+
/** Width of the SVG in pixels */
153+
width: number;
154+
/** Height of the SVG in pixels */
155+
height: number;
156+
/** Padding around the graph content */
157+
padding?: number;
158+
/** Whether to include the legend */
159+
includeLegend?: boolean;
160+
/** Node positions override (optional, uses node.x/y if not provided) */
161+
nodePositions?: Map<string, { x: number; y: number }>;
162+
}
163+
164+
/**
165+
* Generate SVG markup from graph data
166+
* @param nodes - Graph nodes to render
167+
* @param edges - Graph edges to render
168+
* @param options - Export options
169+
* @returns SVG markup as string
170+
*/
171+
export const generateGraphSVG = (
172+
nodes: GraphNode[],
173+
edges: GraphEdge[],
174+
options: SVGExportOptions,
175+
): string => {
176+
const { width, height, padding = 20, includeLegend = false, nodePositions } = options;
177+
178+
// Calculate bounds of the graph
179+
let minX = Infinity;
180+
let maxX = -Infinity;
181+
let minY = Infinity;
182+
let maxY = -Infinity;
183+
184+
nodes.forEach((node) => {
185+
const x = nodePositions?.get(node.id)?.x ?? node.x ?? 0;
186+
const y = nodePositions?.get(node.id)?.y ?? node.y ?? 0;
187+
188+
minX = Math.min(minX, x);
189+
maxX = Math.max(maxX, x);
190+
minY = Math.min(minY, y);
191+
maxY = Math.max(maxY, y);
192+
});
193+
194+
// Add padding
195+
minX -= padding;
196+
maxX += padding;
197+
minY -= padding;
198+
maxY += padding;
199+
200+
const graphWidth = maxX - minX;
201+
const graphHeight = maxY - minY;
202+
203+
// Calculate scale to fit within requested dimensions
204+
const scaleX = width / graphWidth;
205+
const scaleY = height / graphHeight;
206+
const scale = Math.min(scaleX, scaleY);
207+
208+
// Calculate offsets to center the graph
209+
const offsetX = (width - graphWidth * scale) / 2 - minX * scale;
210+
const offsetY = (height - graphHeight * scale) / 2 - minY * scale;
211+
212+
// Generate SVG elements
213+
const svgElements: string[] = [];
214+
215+
// Background
216+
svgElements.push(`<rect width="${width}" height="${height}" fill="white"/>`);
217+
218+
// Edges (draw before nodes so they appear behind)
219+
edges.forEach((edge) => {
220+
const sourceNode = nodes.find((n) => n.id === edge.source);
221+
const targetNode = nodes.find((n) => n.id === edge.target);
222+
223+
if (!sourceNode || !targetNode) return;
224+
225+
const x1 = (nodePositions?.get(sourceNode.id)?.x ?? sourceNode.x ?? 0) * scale + offsetX;
226+
const y1 = (nodePositions?.get(sourceNode.id)?.y ?? sourceNode.y ?? 0) * scale + offsetY;
227+
const x2 = (nodePositions?.get(targetNode.id)?.x ?? targetNode.x ?? 0) * scale + offsetX;
228+
const y2 = (nodePositions?.get(targetNode.id)?.y ?? targetNode.y ?? 0) * scale + offsetY;
229+
230+
const edgeColor = '#94a3b8';
231+
const edgeWidth = 1;
232+
233+
svgElements.push(
234+
`<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${edgeColor}" stroke-width="${edgeWidth}" opacity="0.6"/>`,
235+
);
236+
});
237+
238+
// Nodes
239+
const NODE_RADIUS = 5;
240+
nodes.forEach((node) => {
241+
const x = (nodePositions?.get(node.id)?.x ?? node.x ?? 0) * scale + offsetX;
242+
const y = (nodePositions?.get(node.id)?.y ?? node.y ?? 0) * scale + offsetY;
243+
244+
const nodeColor = HASH_BASED_ENTITY_COLORS[node.entityType];
245+
246+
svgElements.push(
247+
`<circle cx="${x}" cy="${y}" r="${NODE_RADIUS}" fill="${nodeColor}" stroke="#333" stroke-width="0.5"/>`,
248+
);
249+
});
250+
251+
// Legend
252+
if (includeLegend) {
253+
const entityTypes = new Set(nodes.map((n) => n.entityType));
254+
const legendX = width - 150;
255+
const legendY = 20;
256+
const legendItemHeight = 25;
257+
258+
// Legend background
259+
svgElements.push(
260+
`<rect x="${legendX - 10}" y="${legendY - 10}" width="140" height="${entityTypes.size * legendItemHeight + 20}" fill="white" stroke="#ccc" stroke-width="1" opacity="0.9"/>`,
261+
);
262+
263+
// Legend items
264+
let itemIndex = 0;
265+
entityTypes.forEach((entityType) => {
266+
const itemY = legendY + itemIndex * legendItemHeight;
267+
268+
svgElements.push(
269+
`<circle cx="${legendX}" cy="${itemY + 7}" r="5" fill="${HASH_BASED_ENTITY_COLORS[entityType]}"/>`,
270+
);
271+
svgElements.push(
272+
`<text x="${legendX + 15}" y="${itemY + 11}" font-family="sans-serif" font-size="12" fill="#333">${entityType}</text>`,
273+
);
274+
275+
itemIndex++;
276+
});
277+
}
278+
279+
// Wrap in SVG tag
280+
const svgContent = `<?xml version="1.0" encoding="UTF-8"?>
281+
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
282+
${svgElements.join('\n ')}
283+
</svg>`;
284+
285+
return svgContent;
286+
};
287+
288+
/**
289+
* Download graph as SVG file
290+
* @param nodes - Graph nodes to export
291+
* @param edges - Graph edges to export
292+
* @param options - Export options
293+
* @param filename - Name for the downloaded file (without .svg extension)
294+
*/
295+
export const downloadGraphSVG = (
296+
nodes: GraphNode[],
297+
edges: GraphEdge[],
298+
options: SVGExportOptions,
299+
filename: string = `graph-${new Date().toISOString().split('T')[0]}-${new Date().toISOString().split('T')[1].split('.')[0].replaceAll(':', '-')}`,
300+
): void => {
301+
const svgContent = generateGraphSVG(nodes, edges, options);
302+
303+
// Create blob and download
304+
const blob = new Blob([svgContent], { type: 'image/svg+xml;charset=utf-8' });
305+
const url = URL.createObjectURL(blob);
306+
307+
const link = document.createElement('a');
308+
link.href = url;
309+
link.download = `${filename}.svg`;
310+
link.style.display = 'none';
311+
312+
document.body.append(link);
313+
link.click();
314+
315+
// Cleanup
316+
link.remove();
317+
URL.revokeObjectURL(url);
318+
};

0 commit comments

Comments
 (0)