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
31export 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
43export 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
52interface 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
61const { nodes, edges, direction = "LR", caption } = Astro.props as Props;
62
63// ---------- Layout ----------
64
65const NODE_W = 140;
66const NODE_H = 56;
67const NODE_H_TYPED = 64; // slightly taller when subtitle present
68const COL_GAP = 80;
69const ROW_GAP = 28;
70const PAD = 24;
71
72const idIndex = new Map(nodes.map((n, i) => [n.id, i]));
73
74// Longest-path layering: layer(v) = 1 + max(layer(u)) over predecessors.
75const incoming = new Map<string, string[]>();
76const outgoing = new Map<string, string[]>();
77for (const n of nodes) {
78 incoming.set(n.id, []);
79 outgoing.set(n.id, []);
80}
81for (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.
90const layerOf = new Map<string, number>();
91const visiting = new Set<string>();
92const 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};
113nodes.forEach((n) => visit(n.id));
114
115const layers: string[][] = [];
116for (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.
123const 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.
127const colMaxHeight = layers.map((col) =>
128 col.reduce((acc, id) => acc + heightOf(id) + ROW_GAP, -ROW_GAP),
129);
130const tallest = Math.max(...colMaxHeight);
131
132const positions = new Map<string, { x: number; y: number; h: number }>();
133
134if (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
168const hasBackEdge = edges.some(
169 (e) => (layerOf.get(e.to) ?? 0) <= (layerOf.get(e.from) ?? 0),
170);
171const BACK_EDGE_GUTTER = 56;
172
173const 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));
181const 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
191interface Edge {
192 d: string;
193 midX: number;
194 midY: number;
195 label?: string;
196 dashed?: boolean;
197}
198
199const TIP = 6;
200
201const 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
278const 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>