Creating Interactive AI-Generated Diagrams on the Fly
A model can describe a system as structured nodes and edges (see generating diagrams with the Gemini SDK), but a static SVG rendered from that description is still just a picture — the reader can't click a box to see what's inside it, zoom into a crowded corner, or ask the model to expand one node without regenerating the whole diagram. An interactive AI-generated diagram keeps the graph as live data in the browser: nodes carry their own state, clicking one can trigger a follow-up model call that expands it in place, and the layout re-flows around new nodes instead of the user waiting for a brand-new image.
This guide builds one running example: a system-architecture explainer that starts with three high-level nodes (Client, API, Database) streamed from an LLM, lets a reader click Database to expand it into Primary + Replica + Cache without losing their pan position, and recovers cleanly when the model streams a malformed edge reference mid-response. You'll see the three-layer model (static image vs. structured description vs. interactive graph), the incremental layout problem streaming creates, and the click-to-expand interaction pattern — with the failure case that breaks naive implementations.
Static image vs. structured graph vs. interactive graph
These are three different deliverables that get confused for one another. A static image (PNG/SVG rendered once) is the cheapest to produce and the least useful — no click targets, no re-layout, and regenerating it for a small change produces a visually unrelated result. A structured graph (a JSON array of {id, label, edges} or Mermaid text) is data a program can act on — it's what the Gemini SDK diagram guide produces — but rendering it once with a layout library still yields a flat picture if nothing in the browser keeps that graph as live state.
An interactive graph is the structured graph kept as React state, rendered through a layout engine (Dagre, ELK, or a force-directed layout) that recomputes positions when the graph changes, with event handlers wired to individual nodes. The distinction matters because each layer has a different failure mode: a static image fails by going stale, a structured-but-unrendered graph fails by not being visual at all, and an interactive graph fails by re-laying out so aggressively that the user's mental map (where's the box I was just looking at) resets on every small update.
Quick reference
- Store the graph as
{ nodes: Node[], edges: Edge[] }state, not as rendered markup — the render is a pure function of that state. - Pick a layout library that supports incremental layout (Dagre and ELK both do) so adding one node doesn't reshuffle every existing coordinate.
- Give every node a stable
idfrom the model's output — regenerating an id on each stream chunk breaks React's reconciliation and causes visible flicker. - A force-directed layout looks organic but is non-deterministic between renders unless you seed and cache positions; prefer a DAG layout (Dagre) for system diagrams where hierarchy matters.
Remember this
An interactive diagram is graph state plus a layout engine plus event handlers — treat the visual as a render of state, not the state itself.
Parsing a streamed graph without jank
When the model streams its structured output token by token, you receive syntactically incomplete JSON for most of the response — you cannot JSON.parse a half-finished object. The reliable pattern is an incremental JSON parser (or a simple state machine if you constrain the model to emit one complete node object per line) that emits onNode events as each node closes, rather than waiting for the full response and parsing once. Each onNode event pushes into the graph state, which triggers layout — but naively re-running full layout on every single token-level update causes visible jank as boxes jump around dozens of times per second.
Debounce the layout recomputation, not the state update: append every node to state as it arrives (so the node list is always current), but only trigger the layout engine on a trailing debounce (150–250ms of no new nodes) or when the stream closes. This keeps the underlying data correct in real time while limiting the expensive part — recomputing every box's x/y — to a rate a human can actually perceive as smooth rather than flickering.
Quick reference
- Constrain the model's output format (one JSON object per line, or a schema with a clear closing delimiter) so incremental parsing is tractable — see structured LLM outputs.
- Always call
.flush()on the debounced layout when the stream ends — otherwise the last node can sit unlaid-out if the trailing debounce window never fires. - Keep a
nodesarray update separate from apositionsmap update; the graph must be correct even a layout frame behind. - Test with an artificially slow/chunked stream locally — jank that's invisible on localhost's instant response often only appears on production network latency.
Remember this
Update graph state on every stream chunk, but debounce the layout recomputation — data correctness and visual smoothness have different acceptable latencies.
Click-to-expand without losing the viewport
The interactive payoff is letting a reader click a node and have the model expand it in place — clicking Database in the example above triggers a follow-up call ("expand this node into its internal components") whose response is spliced into the existing graph as child nodes, rather than the whole diagram being regenerated. The key implementation detail is that the parent node's id and position must stay stable through the expansion: new nodes get positioned relative to the parent, and the layout engine re-runs only the affected subgraph region, not the whole canvas — otherwise every click resets the user's pan and zoom, which trains people to stop exploring the diagram.
Track expansion state per node (collapsed | expanded | loading) so a click while a request is in flight shows a spinner instead of firing a duplicate model call, and so collapsing a node removes its children from state cleanly (delete by parent id, not by re-fetching the whole graph). This is the same request-lifecycle discipline as any async UI — loading, success, error — applied to a single graph node instead of a whole page.
Quick reference
- Debounce duplicate clicks with a per-node loading flag; a fast double-click without one fires two model calls and inserts duplicate children.
- Preserve viewport pan/zoom across an expansion — recentering on every click is the single most common complaint in graph-exploration UIs.
- Cache expansion results by node id for the session so re-collapsing and re-expanding doesn't re-call the model (and re-bill tokens) for content already seen.
- Show a distinct visual state (dashed border, muted icon) for
loadingnodes so a slow model response doesn't look like the click did nothing.
Remember this
Expansion is a per-node request lifecycle (idle/loading/done) layered onto existing graph state — not a full-page regeneration triggered by a click.
When the model streams a broken edge
The realistic failure: the model streams a node whose edges array references a node id that hasn't arrived yet (or never will, if generation was truncated or the response was cut off by a token limit). A renderer that assumes every edge resolves to two existing nodes will throw or silently drop the whole graph the instant this happens — which is a bad failure mode because one malformed edge shouldn't take down 40 correctly-parsed nodes.
The recovery is to validate edges lazily: keep a set of known node ids, and hold any edge whose target isn't yet present in a pending edges list instead of rendering it immediately. Re-check pending edges every time a new node arrives, and drop (with a small warning badge, not a crash) any edge still unresolved after the stream closes — that's a genuine model error, not a timing issue, and the reader should see it flagged rather than the diagram failing outright.
Quick reference
- Never let one bad edge reference throw inside the render loop — validate before you render, not inside the SVG mapping function.
- A
pendingEdgesqueue resolved on every new-node event handles the ordinary case: edges usually arrive slightly out of order relative to their target node. - After stream close, edges still unresolved are a real model output error — surface them (a small warning icon on the source node) instead of hiding them.
- This is the same defensive pattern as prompt injection and tool-using agents: never let untrusted structured output crash the renderer that consumes it.
Remember this
Treat every model-generated edge as unresolved until its target node exists; a queue-and-recheck pattern survives out-of-order streaming, a crash-on-first-bad-edge does not.
Key takeaway
Build a small React page: an LLM streams three nodes (Client → API → Database) as one JSON object per line, parsed incrementally into graph state, laid out with Dagre on a 200ms debounce, and rendered with a click handler on Database that calls a mock "expand" endpoint returning Primary + Replica + Cache as children. Expected result: the three top-level boxes appear progressively as they stream, without visible jank, and clicking Database adds three child boxes without resetting your pan position. Then break it on purpose — have the mock stream emit an edge from db to cache-replica before the cache-replica node itself arrives. Recovery: your pending-edges queue should hold that edge, render it once cache-replica streams in a moment later, and if you simulate truncating the stream before that node ever arrives, the edge should be dropped with a visible warning rather than crashing the render. Pass criterion: all three top-level nodes render with no console errors, the expand click preserves viewport position, and the truncated-stream case shows a warning badge instead of a blank page.
Related Articles
Explore this topic