|
1 | 1 | /** |
2 | 2 | * Export Utilities |
3 | 3 | * |
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. |
5 | 6 | */ |
6 | 7 |
|
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'; |
8 | 10 |
|
9 | 11 | /** |
10 | 12 | * Convert search results to CSV format |
@@ -145,3 +147,172 @@ export const getExportFilename = (query: string, format: 'csv' | 'bib'): string |
145 | 147 | ? `${sanitizedQuery}-results-${timestamp}.${format}` |
146 | 148 | : `search-results-${timestamp}.${format}`; |
147 | 149 | }; |
| 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