| 1 | --- |
| 2 | /** |
| 3 | * `<DAG />` — server-rendered SVG of a small directed acyclic graph. |
| 4 | * |
| 5 | * Used in concept docs to illustrate resource dependency graphs and |
| 6 | * lifecycle flows. Auto-layouts nodes left-to-right using longest-path |
| 7 | * layering, then positions each node within its layer to minimize |
| 8 | * crossings (centered, tie-broken by input order). |
| 9 | * |
| 10 | * Styled to match the brand (parchment panel, moss accents, walnut |
| 11 | * text, JetBrains Mono labels). |
| 12 | * |
| 13 | * Usage in MDX: |
| 14 | * import DAG from "../../../components/DAG.astro"; |
| 15 | * <DAG |
| 16 | * nodes={[ |
| 17 | * { id: "Bucket", label: "Bucket", type: "Cloudflare.R2Bucket" }, |
| 18 | * { id: "Sessions", label: "Sessions", type: "Cloudflare.KVNamespace" }, |
| 19 | * { id: "Queue", label: "Queue", type: "AWS.SQS.Queue" }, |
| 20 | * { id: "Worker", label: "Worker", type: "Cloudflare.Worker" }, |
| 21 | * ]} |
| 22 | * edges={[ |
| 23 | * { from: "Bucket", to: "Queue" }, |
| 24 | * { from: "Bucket", to: "Worker" }, |
| 25 | * { from: "Sessions", to: "Worker" }, |
| 26 | * { from: "Queue", to: "Worker" }, |
| 27 | * ]} |
| 28 | * /> |
| 29 | */ |
| 30 | |
| 31 | export interface NodeIn { |
| 32 | id: string; |
| 33 | /** Visible label inside the node (defaults to `id`). */ |
| 34 | label?: string; |
| 35 | /** Smaller subtitle below the label (e.g. resource type). */ |
| 36 | type?: string; |
| 37 | /** Optional layer override (0-indexed left-to-right). Otherwise computed. */ |
| 38 | layer?: number; |
| 39 | /** Visual tone for the node. Defaults to "default". */ |
| 40 | tone?: "default" | "accent" | "warn" | "danger"; |
| 41 | } |
| 42 | |
| 43 | export interface EdgeIn { |
| 44 | from: string; |
| 45 | to: string; |
| 46 | /** Optional small label drawn near the edge midpoint. */ |
| 47 | label?: string; |
| 48 | /** Render as a dashed line (e.g. for "later" / "deferred" edges). */ |
| 49 | dashed?: boolean; |
| 50 | } |
| 51 | |
| 52 | interface Props { |
| 53 | nodes: NodeIn[]; |
| 54 | edges: EdgeIn[]; |
| 55 | /** Layout direction. Defaults to "LR" (left-to-right). */ |
| 56 | direction?: "LR" | "TB"; |
| 57 | /** Optional caption rendered below the graph. */ |
| 58 | caption?: string; |
| 59 | } |
| 60 | |
| 61 | const { nodes, edges, direction = "LR", caption } = Astro.props as Props; |
| 62 | |
| 63 | // ---------- Layout ---------- |
| 64 | |
| 65 | const NODE_W = 140; |
| 66 | const NODE_H = 56; |
| 67 | const NODE_H_TYPED = 64; // slightly taller when subtitle present |
| 68 | const COL_GAP = 80; |
| 69 | const ROW_GAP = 28; |
| 70 | const PAD = 24; |
| 71 | |
| 72 | const idIndex = new Map(nodes.map((n, i) => [n.id, i])); |
| 73 | |
| 74 | // Longest-path layering: layer(v) = 1 + max(layer(u)) over predecessors. |
| 75 | const incoming = new Map<string, string[]>(); |
| 76 | const outgoing = new Map<string, string[]>(); |
| 77 | for (const n of nodes) { |
| 78 | incoming.set(n.id, []); |
| 79 | outgoing.set(n.id, []); |
| 80 | } |
| 81 | for (const e of edges) { |
| 82 | outgoing.get(e.from)?.push(e.to); |
| 83 | incoming.get(e.to)?.push(e.from); |
| 84 | } |
| 85 | |
| 86 | // Longest-path layering, cycle-safe: any predecessor edge that would |
| 87 | // re-enter a node currently on the recursion stack is treated as a |
| 88 | // back-edge and ignored for the purposes of layer assignment. This |
| 89 | // keeps cyclic graphs (A binds B, B binds A) layoutable side-by-side. |
| 90 | const layerOf = new Map<string, number>(); |
| 91 | const visiting = new Set<string>(); |
| 92 | const visit = (id: string): number => { |
| 93 | const cached = layerOf.get(id); |
| 94 | if (cached !== undefined) return cached; |
| 95 | const override = nodes[idIndex.get(id)!]?.layer; |
| 96 | if (override !== undefined) { |
| 97 | layerOf.set(id, override); |
| 98 | return override; |
| 99 | } |
| 100 | if (visiting.has(id)) { |
| 101 | // Back-edge: caller will Math.max with our placeholder; returning |
| 102 | // -1 means "don't push my dependent down a layer for this edge". |
| 103 | return -1; |
| 104 | } |
| 105 | visiting.add(id); |
| 106 | const preds = incoming.get(id) ?? []; |
| 107 | const predLayers = preds.map(visit).filter((l) => l >= 0); |
| 108 | const l = predLayers.length === 0 ? 0 : Math.max(...predLayers) + 1; |
| 109 | visiting.delete(id); |
| 110 | layerOf.set(id, l); |
| 111 | return l; |
| 112 | }; |
| 113 | nodes.forEach((n) => visit(n.id)); |
| 114 | |
| 115 | const layers: string[][] = []; |
| 116 | for (const n of nodes) { |
| 117 | const l = layerOf.get(n.id)!; |
| 118 | if (!layers[l]) layers[l] = []; |
| 119 | layers[l].push(n.id); |
| 120 | } |
| 121 | |
| 122 | // Heights — bump rows that have a subtitle. |
| 123 | const heightOf = (id: string): number => |
| 124 | nodes[idIndex.get(id)!]?.type ? NODE_H_TYPED : NODE_H; |
| 125 | |
| 126 | // Position nodes within each layer; center on the tallest layer. |
| 127 | const colMaxHeight = layers.map((col) => |
| 128 | col.reduce((acc, id) => acc + heightOf(id) + ROW_GAP, -ROW_GAP), |
| 129 | ); |
| 130 | const tallest = Math.max(...colMaxHeight); |
| 131 | |
| 132 | const positions = new Map<string, { x: number; y: number; h: number }>(); |
| 133 | |
| 134 | if (direction === "LR") { |
| 135 | layers.forEach((col, ci) => { |
| 136 | const colH = colMaxHeight[ci]!; |
| 137 | let y = PAD + (tallest - colH) / 2; |
| 138 | for (const id of col) { |
| 139 | const h = heightOf(id); |
| 140 | positions.set(id, { |
| 141 | x: PAD + ci * (NODE_W + COL_GAP), |
| 142 | y, |
| 143 | h, |
| 144 | }); |
| 145 | y += h + ROW_GAP; |
| 146 | } |
| 147 | }); |
| 148 | } else { |
| 149 | // TB — swap roles: each "layer" becomes a row. |
| 150 | layers.forEach((row, ri) => { |
| 151 | const rowW = row.length * (NODE_W + COL_GAP) - COL_GAP; |
| 152 | const totalW = Math.max( |
| 153 | ...layers.map((r) => r.length * (NODE_W + COL_GAP) - COL_GAP), |
| 154 | ); |
| 155 | let x = PAD + (totalW - rowW) / 2; |
| 156 | for (const id of row) { |
| 157 | const h = heightOf(id); |
| 158 | positions.set(id, { |
| 159 | x, |
| 160 | y: PAD + ri * (NODE_H_TYPED + 60), |
| 161 | h, |
| 162 | }); |
| 163 | x += NODE_W + COL_GAP; |
| 164 | } |
| 165 | }); |
| 166 | } |
| 167 | |
| 168 | const hasBackEdge = edges.some( |
| 169 | (e) => (layerOf.get(e.to) ?? 0) <= (layerOf.get(e.from) ?? 0), |
| 170 | ); |
| 171 | const BACK_EDGE_GUTTER = 56; |
| 172 | |
| 173 | const W = |
| 174 | direction === "LR" |
| 175 | ? PAD * 2 + |
| 176 | layers.length * NODE_W + |
| 177 | (layers.length - 1) * COL_GAP + |
| 178 | (hasBackEdge ? BACK_EDGE_GUTTER : 0) |
| 179 | : PAD * 2 + |
| 180 | Math.max(...layers.map((r) => r.length * (NODE_W + COL_GAP) - COL_GAP)); |
| 181 | const H = |
| 182 | direction === "LR" |
| 183 | ? PAD * 2 + tallest + (hasBackEdge ? BACK_EDGE_GUTTER : 0) |
| 184 | : PAD * 2 + |
| 185 | layers.length * (NODE_H_TYPED + 60) - |
| 186 | 60 + |
| 187 | (hasBackEdge ? BACK_EDGE_GUTTER : 0); |
| 188 | |
| 189 | // ---------- Edge geometry ---------- |
| 190 | |
| 191 | interface Edge { |
| 192 | d: string; |
| 193 | midX: number; |
| 194 | midY: number; |
| 195 | label?: string; |
| 196 | dashed?: boolean; |
| 197 | } |
| 198 | |
| 199 | const TIP = 6; |
| 200 | |
| 201 | const buildEdges = (): Edge[] => |
| 202 | edges.map((e) => { |
| 203 | const a = positions.get(e.from)!; |
| 204 | const b = positions.get(e.to)!; |
| 205 | const aLayer = layerOf.get(e.from)!; |
| 206 | const bLayer = layerOf.get(e.to)!; |
| 207 | const isBack = |
| 208 | direction === "LR" ? bLayer <= aLayer : bLayer <= aLayer; |
| 209 | |
| 210 | if (direction === "LR") { |
| 211 | if (!isBack) { |
| 212 | // Forward edge — straight cubic from source.right to target.left. |
| 213 | const x1 = a.x + NODE_W; |
| 214 | const y1 = a.y + a.h / 2; |
| 215 | const x2 = b.x - TIP; |
| 216 | const y2 = b.y + b.h / 2; |
| 217 | const cx = x1 + (x2 - x1) * 0.5; |
| 218 | const d = `M ${x1} ${y1} C ${cx} ${y1}, ${cx} ${y2}, ${x2} ${y2}`; |
| 219 | return { |
| 220 | d, |
| 221 | midX: (x1 + x2) / 2, |
| 222 | midY: (y1 + y2) / 2, |
| 223 | label: e.label, |
| 224 | dashed: e.dashed, |
| 225 | }; |
| 226 | } |
| 227 | // Back-edge — arc beneath both nodes from source.bottom to target.bottom |
| 228 | // so arrow remains visibly between/under the nodes (not behind them). |
| 229 | const x1 = a.x + NODE_W * 0.5; |
| 230 | const y1 = a.y + a.h; |
| 231 | const x2 = b.x + NODE_W * 0.5; |
| 232 | const y2 = b.y + b.h + TIP; |
| 233 | const dropMax = 36; |
| 234 | const cy = Math.max(y1, y2) + dropMax; |
| 235 | const d = `M ${x1} ${y1} C ${x1} ${cy}, ${x2} ${cy}, ${x2} ${y2}`; |
| 236 | return { |
| 237 | d, |
| 238 | midX: (x1 + x2) / 2, |
| 239 | midY: cy, |
| 240 | label: e.label, |
| 241 | dashed: e.dashed, |
| 242 | }; |
| 243 | } |
| 244 | |
| 245 | // TB direction. |
| 246 | if (!isBack) { |
| 247 | const x1 = a.x + NODE_W / 2; |
| 248 | const y1 = a.y + a.h; |
| 249 | const x2 = b.x + NODE_W / 2; |
| 250 | const y2 = b.y - TIP; |
| 251 | const cy = y1 + (y2 - y1) * 0.5; |
| 252 | const d = `M ${x1} ${y1} C ${x1} ${cy}, ${x2} ${cy}, ${x2} ${y2}`; |
| 253 | return { |
| 254 | d, |
| 255 | midX: (x1 + x2) / 2, |
| 256 | midY: (y1 + y2) / 2, |
| 257 | label: e.label, |
| 258 | dashed: e.dashed, |
| 259 | }; |
| 260 | } |
| 261 | // TB back-edge — arc to the right. |
| 262 | const x1 = a.x + NODE_W; |
| 263 | const y1 = a.y + a.h / 2; |
| 264 | const x2 = b.x + NODE_W + TIP; |
| 265 | const y2 = b.y + b.h / 2; |
| 266 | const dx = 36; |
| 267 | const cx = Math.max(x1, x2) + dx; |
| 268 | const d = `M ${x1} ${y1} C ${cx} ${y1}, ${cx} ${y2}, ${x2} ${y2}`; |
| 269 | return { |
| 270 | d, |
| 271 | midX: cx, |
| 272 | midY: (y1 + y2) / 2, |
| 273 | label: e.label, |
| 274 | dashed: e.dashed, |
| 275 | }; |
| 276 | }); |
| 277 | |
| 278 | const edgeShapes = buildEdges(); |
| 279 | --- |
| 280 | |
| 281 | <figure class="alc-dag not-content" data-nosnippet> |
| 282 | <svg |
| 283 | class="alc-dag__svg" |
| 284 | viewBox={`0 0 ${W} ${H}`} |
| 285 | role="img" |
| 286 | aria-label={caption ?? "Dependency graph"} |
| 287 | > |
| 288 | <defs> |
| 289 | <marker |
| 290 | id="alc-dag-arrow" |
| 291 | viewBox="0 0 10 10" |
| 292 | refX="8" |
| 293 | refY="5" |
| 294 | markerWidth="6" |
| 295 | markerHeight="6" |
| 296 | orient="auto-start-reverse" |
| 297 | > |
| 298 | <path d="M 0 0 L 10 5 L 0 10 z" fill="var(--alc-accent)" /> |
| 299 | </marker> |
| 300 | </defs> |
| 301 | |
| 302 | {/* Edges first so they sit beneath nodes. */} |
| 303 | {edgeShapes.map((e) => ( |
| 304 | <g class="alc-dag__edge"> |
| 305 | <path |
| 306 | d={e.d} |
| 307 | fill="none" |
| 308 | stroke="var(--alc-accent-60)" |
| 309 | stroke-width="1.5" |
| 310 | stroke-dasharray={e.dashed ? "4 4" : undefined} |
| 311 | marker-end="url(#alc-dag-arrow)" |
| 312 | /> |
| 313 | {e.label && ( |
| 314 | <text |
| 315 | x={e.midX} |
| 316 | y={e.midY - 6} |
| 317 | class="alc-dag__edge-label" |
| 318 | text-anchor="middle" |
| 319 | > |
| 320 | {e.label} |
| 321 | </text> |
| 322 | )} |
| 323 | </g> |
| 324 | ))} |
| 325 | |
| 326 | {/* Nodes. */} |
| 327 | {nodes.map((n) => { |
| 328 | const p = positions.get(n.id)!; |
| 329 | const tone = n.tone ?? "default"; |
| 330 | return ( |
| 331 | <g |
| 332 | class={`alc-dag__node alc-dag__node--${tone}`} |
| 333 | transform={`translate(${p.x}, ${p.y})`} |
| 334 | > |
| 335 | <rect |
| 336 | width={NODE_W} |
| 337 | height={p.h} |
| 338 | rx="8" |
| 339 | ry="8" |
| 340 | class="alc-dag__node-bg" |
| 341 | /> |
| 342 | <text |
| 343 | x={NODE_W / 2} |
| 344 | y={n.type ? p.h / 2 - 4 : p.h / 2 + 1} |
| 345 | class="alc-dag__node-label" |
| 346 | text-anchor="middle" |
| 347 | dominant-baseline="middle" |
| 348 | > |
| 349 | {n.label ?? n.id} |
| 350 | </text> |
| 351 | {n.type && ( |
| 352 | <text |
| 353 | x={NODE_W / 2} |
| 354 | y={p.h / 2 + 14} |
| 355 | class="alc-dag__node-type" |
| 356 | text-anchor="middle" |
| 357 | dominant-baseline="middle" |
| 358 | > |
| 359 | {n.type} |
| 360 | </text> |
| 361 | )} |
| 362 | </g> |
| 363 | ); |
| 364 | })} |
| 365 | </svg> |
| 366 | |
| 367 | {caption && <figcaption class="alc-dag__caption">{caption}</figcaption>} |
| 368 | </figure> |
| 369 | |
| 370 | <style> |
| 371 | .alc-dag { |
| 372 | margin: 1.5rem 0; |
| 373 | background: var(--alc-bg-elev-1); |
| 374 | border: 1px solid var(--alc-hairline-2); |
| 375 | border-radius: var(--alc-radius); |
| 376 | padding: 0.75rem; |
| 377 | overflow-x: auto; |
| 378 | } |
| 379 | |
| 380 | .alc-dag__svg { |
| 381 | display: block; |
| 382 | width: 100%; |
| 383 | height: auto; |
| 384 | max-width: 100%; |
| 385 | } |
| 386 | |
| 387 | .alc-dag__node-bg { |
| 388 | fill: var(--alc-bg-elev-2); |
| 389 | stroke: var(--alc-accent-40); |
| 390 | stroke-width: 1.5; |
| 391 | transition: stroke 200ms ease, fill 200ms ease; |
| 392 | } |
| 393 | |
| 394 | .alc-dag__node--accent .alc-dag__node-bg { |
| 395 | stroke: var(--alc-accent); |
| 396 | fill: color-mix(in srgb, var(--alc-accent-soft) 60%, var(--alc-bg-elev-2)); |
| 397 | } |
| 398 | |
| 399 | .alc-dag__node--warn .alc-dag__node-bg { |
| 400 | stroke: var(--alc-warn); |
| 401 | fill: color-mix(in srgb, var(--alc-warn) 12%, var(--alc-bg-elev-2)); |
| 402 | } |
| 403 | |
| 404 | .alc-dag__node--danger .alc-dag__node-bg { |
| 405 | stroke: var(--alc-danger); |
| 406 | fill: color-mix(in srgb, var(--alc-danger) 10%, var(--alc-bg-elev-2)); |
| 407 | } |
| 408 | |
| 409 | .alc-dag__node-label { |
| 410 | font-family: var(--alc-font-mono); |
| 411 | font-size: 13px; |
| 412 | font-weight: 600; |
| 413 | fill: var(--alc-fg-1); |
| 414 | } |
| 415 | |
| 416 | .alc-dag__node-type { |
| 417 | font-family: var(--alc-font-mono); |
| 418 | font-size: 10.5px; |
| 419 | fill: var(--alc-fg-3); |
| 420 | } |
| 421 | |
| 422 | .alc-dag__edge-label { |
| 423 | font-family: var(--alc-font-mono); |
| 424 | font-size: 10.5px; |
| 425 | fill: var(--alc-fg-3); |
| 426 | } |
| 427 | |
| 428 | .alc-dag__caption { |
| 429 | margin-top: 0.5rem; |
| 430 | font-family: var(--alc-font-sans); |
| 431 | font-size: 13px; |
| 432 | color: var(--alc-fg-3); |
| 433 | text-align: center; |
| 434 | } |
| 435 | </style> |