|
| 1 | +#!/usr/bin/env node |
| 2 | +/** |
| 3 | + * Generate SVG from Arc diagram definition. |
| 4 | + * Usage: node diagrams/generate-svg.mjs |
| 5 | + */ |
| 6 | +import { readFileSync, writeFileSync } from 'fs' |
| 7 | +import { dirname, join } from 'path' |
| 8 | +import { fileURLToPath } from 'url' |
| 9 | + |
| 10 | +const __dirname = dirname(fileURLToPath(import.meta.url)) |
| 11 | + |
| 12 | +// Inline the needed constants and functions from @arach/arc |
| 13 | +const NODE_SIZES = { |
| 14 | + xs: { width: 80, height: 36 }, |
| 15 | + s: { width: 95, height: 42 }, |
| 16 | + m: { width: 145, height: 68 }, |
| 17 | + l: { width: 210, height: 85 }, |
| 18 | +} |
| 19 | + |
| 20 | +const nodeColors = { |
| 21 | + violet: { bg: '#8b5cf6', text: '#ffffff' }, |
| 22 | + emerald: { bg: '#34d399', text: '#ffffff' }, |
| 23 | + blue: { bg: '#60a5fa', text: '#ffffff' }, |
| 24 | + amber: { bg: '#fbbf24', text: '#1f2937' }, |
| 25 | + zinc: { bg: '#71717a', text: '#ffffff' }, |
| 26 | + sky: { bg: '#38bdf8', text: '#ffffff' }, |
| 27 | + rose: { bg: '#f43f5e', text: '#ffffff' }, |
| 28 | + orange: { bg: '#fb923c', text: '#ffffff' }, |
| 29 | +} |
| 30 | + |
| 31 | +function escapeXml(str) { |
| 32 | + if (!str) return '' |
| 33 | + return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') |
| 34 | +} |
| 35 | + |
| 36 | +function getAnchorPosition(x, y, width, height, position) { |
| 37 | + switch (position) { |
| 38 | + case 'left': return { x: x, y: y + height / 2 } |
| 39 | + case 'right': return { x: x + width, y: y + height / 2 } |
| 40 | + case 'top': return { x: x + width / 2, y: y } |
| 41 | + case 'bottom': return { x: x + width / 2, y: y + height } |
| 42 | + case 'bottomRight': return { x: x + width, y: y + height - 15 } |
| 43 | + case 'bottomLeft': return { x: x, y: y + height - 15 } |
| 44 | + case 'topRight': return { x: x + width, y: y + 15 } |
| 45 | + case 'topLeft': return { x: x, y: y + 15 } |
| 46 | + default: return { x: x + width / 2, y: y + height / 2 } |
| 47 | + } |
| 48 | +} |
| 49 | + |
| 50 | +function getConnectorPath(from, to, fromAnchor, toAnchor) { |
| 51 | + const dx = to.x - from.x |
| 52 | + const dy = to.y - from.y |
| 53 | + let cp1x = from.x, cp1y = from.y, cp2x = to.x, cp2y = to.y |
| 54 | + const offset = Math.min(Math.abs(dx), Math.abs(dy), 50) |
| 55 | + |
| 56 | + if (['right', 'bottomRight', 'topRight'].includes(fromAnchor)) cp1x = from.x + offset |
| 57 | + else if (['left', 'bottomLeft', 'topLeft'].includes(fromAnchor)) cp1x = from.x - offset |
| 58 | + else if (fromAnchor === 'bottom') cp1y = from.y + offset |
| 59 | + else if (fromAnchor === 'top') cp1y = from.y - offset |
| 60 | + |
| 61 | + if (['left', 'bottomLeft', 'topLeft'].includes(toAnchor)) cp2x = to.x - offset |
| 62 | + else if (['right', 'bottomRight', 'topRight'].includes(toAnchor)) cp2x = to.x + offset |
| 63 | + else if (toAnchor === 'top') cp2y = to.y - offset |
| 64 | + else if (toAnchor === 'bottom') cp2y = to.y + offset |
| 65 | + |
| 66 | + return `M ${from.x} ${from.y} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${to.x} ${to.y}` |
| 67 | +} |
| 68 | + |
| 69 | +function generateSVG(diagram, options = {}) { |
| 70 | + const { backgroundColor = 'transparent', padding = 20 } = options |
| 71 | + const bounds = { x: 0, y: 0, width: diagram.layout.width, height: diagram.layout.height } |
| 72 | + const viewBox = `${bounds.x - padding} ${bounds.y - padding} ${bounds.width + padding * 2} ${bounds.height + padding * 2}` |
| 73 | + const svgWidth = bounds.width + padding * 2 |
| 74 | + const svgHeight = bounds.height + padding * 2 |
| 75 | + |
| 76 | + let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${svgWidth}" height="${svgHeight}" viewBox="${viewBox}">\n` |
| 77 | + |
| 78 | + if (backgroundColor !== 'transparent') { |
| 79 | + svg += ` <rect x="${bounds.x - padding}" y="${bounds.y - padding}" width="${svgWidth}" height="${svgHeight}" fill="${backgroundColor}"/>\n` |
| 80 | + } |
| 81 | + |
| 82 | + // Connectors |
| 83 | + for (const connector of diagram.connectors) { |
| 84 | + const fromNode = diagram.nodes[connector.from] |
| 85 | + const toNode = diagram.nodes[connector.to] |
| 86 | + if (!fromNode || !toNode) continue |
| 87 | + |
| 88 | + const fromSize = NODE_SIZES[fromNode.size] || NODE_SIZES.m |
| 89 | + const toSize = NODE_SIZES[toNode.size] || NODE_SIZES.m |
| 90 | + const style = diagram.connectorStyles?.[connector.style] || { color: 'zinc', strokeWidth: 2 } |
| 91 | + const strokeColor = nodeColors[style.color]?.bg || '#71717a' |
| 92 | + |
| 93 | + const fromPos = getAnchorPosition(fromNode.x, fromNode.y, fromSize.width, fromSize.height, connector.fromAnchor) |
| 94 | + const toPos = getAnchorPosition(toNode.x, toNode.y, toSize.width, toSize.height, connector.toAnchor) |
| 95 | + const path = getConnectorPath(fromPos, toPos, connector.fromAnchor, connector.toAnchor) |
| 96 | + |
| 97 | + svg += ` <path d="${path}" fill="none" stroke="${strokeColor}" stroke-width="${style.strokeWidth || 2}"${style.dashed ? ' stroke-dasharray="6 3"' : ''} opacity="0.7"/>\n` |
| 98 | + svg += ` <circle cx="${toPos.x}" cy="${toPos.y}" r="3.5" fill="${strokeColor}" opacity="0.7"/>\n` |
| 99 | + |
| 100 | + if (style.label) { |
| 101 | + const midX = (fromPos.x + toPos.x) / 2 |
| 102 | + const midY = (fromPos.y + toPos.y) / 2 |
| 103 | + svg += ` <text x="${midX}" y="${midY - 8}" text-anchor="middle" fill="${strokeColor}" font-size="10" font-family="ui-sans-serif, system-ui, sans-serif" opacity="0.8">${escapeXml(style.label)}</text>\n` |
| 104 | + } |
| 105 | + } |
| 106 | + |
| 107 | + // Nodes |
| 108 | + for (const [nodeId, node] of Object.entries(diagram.nodes)) { |
| 109 | + const data = diagram.nodeData[nodeId] |
| 110 | + if (!data) continue |
| 111 | + |
| 112 | + const size = NODE_SIZES[node.size] || NODE_SIZES.m |
| 113 | + const width = size.width |
| 114 | + const height = size.height |
| 115 | + const colors = nodeColors[data.color] || nodeColors.zinc |
| 116 | + |
| 117 | + svg += ` <rect x="${node.x}" y="${node.y}" width="${width}" height="${height}" rx="12" fill="${colors.bg}"/>\n` |
| 118 | + |
| 119 | + const fontSize = node.size === 'xs' ? 10 : node.size === 's' ? 11 : 13 |
| 120 | + const nameY = data.subtitle ? node.y + height / 2 : node.y + height / 2 + fontSize / 3 |
| 121 | + svg += ` <text x="${node.x + width / 2}" y="${nameY}" text-anchor="middle" fill="${colors.text}" font-size="${fontSize}" font-weight="600" font-family="ui-sans-serif, system-ui, sans-serif">${escapeXml(data.name)}</text>\n` |
| 122 | + |
| 123 | + if (data.subtitle && height >= 60) { |
| 124 | + svg += ` <text x="${node.x + width / 2}" y="${nameY + fontSize + 4}" text-anchor="middle" fill="${colors.text}" opacity="0.75" font-size="${fontSize - 2}" font-family="ui-sans-serif, system-ui, sans-serif">${escapeXml(data.subtitle)}</text>\n` |
| 125 | + } |
| 126 | + } |
| 127 | + |
| 128 | + svg += `</svg>` |
| 129 | + return svg |
| 130 | +} |
| 131 | + |
| 132 | +// Load and generate |
| 133 | +const diagram = (await import('./architecture.diagram.js')).default |
| 134 | +const svg = generateSVG(diagram) |
| 135 | +const outPath = join(__dirname, '..', 'public', 'architecture.svg') |
| 136 | +writeFileSync(outPath, svg) |
| 137 | +console.log(`Generated ${outPath}`) |
0 commit comments