Every charting library has a map, and every map has an edge. Beyond that edge there be dragons or, in our case, the chart your data actually needs and the library doesn't have: a Kafka Streams topology, a memory diagram, a boba tea, a stack of data-driven wine bottles... Semiotic's answer to the edge is the Custom Chartssurface. This provides a layout function that emits scene primitives and gets the frame's runtime for free. While custom charts were always provided in Semiotic, recently three different improvements made that experience better: a clear contract for what a custom layout owns, a real-world lineage view that hands layout back to the domain, and an interpreter that runs an entirely different library's serialized grammar.
Why a custom-chart surface is the interesting part
Most "extensibility" in charting libraries is a theming API and a slot for a tooltip. The hard version of the problem is: let someone draw a shape you've never heard of, and still give them hit-testing, transitions, decay, staleness, SSR, and accessibility. These are the real value-add that, especially in the age of AI, empowers users to focus on what sets them apart from any average dashboard you might get from an AI otherwise. These are also the features take months to build and that nobody wants to reimplement per chart. If the escape hatch makes you give those up, it isn't an escape hatch; it's a second, worse library bolted to the side of the first.
Semiotic's bet is a scene-node / overlay split. A custom layout returns two things: scene nodes (rects, points, areas) that flow through the same canvas pipeline the built-in charts use. They get hit-testing, transitions, decay, and SSR evidence. They also get overlays (arbitrary SVG) for the chrome that doesn't need to be a first-class datum: labels, connectors, decorative glyph detail. The rule that emerged is worth stating plainly: never let chrome replace data. A pretty glyph is fine, but the thing the user hovers, the thing a screen reader announces, the thing SSR can prove rendered needs to be a scene node.
Letting the domain own layout: Kafka Streams lineage
The first real test was a Kafka Streams topology viewer (/recipes/kstreams). A streaming topology is a layered DAG with source/sink topics, processors, state stores, repartition bridges, and the occasional reconciliation cycle. The layout for that is a well-known domain problem and Semiotic can't anticipate which of the many DAG layouts and settings you might use. So the recipe doesn't compute layout at all. It reads pre-computed logical coordinates (x = layer, y = row) and maps them into the plot. Semiotic positions nothing; it renders what the pipeline already decided but, critically, with the accessibility and theming and other functionality that you shouldn't have to worry about.
That inversion is the point. lineageDagLayout stays domain-agnostic. The app supplies the glyph vocabulary (a topic gets a log/store glyph, a processor gets a colored chip) through a renderIconcallback. Each node is a composite glyph: one transparent hit-rect in the scene graph carrying the node's datum, with the icon, label, and so on as an overlay on top. Level-of-detail collapses the glyph (full → compact → icon → dot) as the graph scales, and back-edges render as distinct dashed curves.
Because the hit target is a real scene node, the interaction story is the normal one:LinkedCharts drives a shared selection across a detail viewand a level-of-detail minimap, hover computes a downstream-reachability set and dims everything else in both views, and click locks a selection. It's the same selection + observation plumbing the catalog charts use, reaching a custom layout because the custom layout plays by the scene-node contract.
// Your pipeline owns layout; the recipe only READS logical x/y. <NetworkCustomChart nodes={view.nodes} edges={view.edges} layout={lineageDagLayout} layoutConfig={{ reachableIds, selectedId, renderIcon, partitionColors }} selection={{ name: "kstreams" }} // shared store → selection ring onObservation={onHover} // hover → downstream reach preview onClick={onPick} />Here's the smallest version that still shows the idea: six pre-positioned nodes with two source topics merging in a join, fanning out to two processors, converging on a sink. The zoom buttons just scale the canvas; because the recipe fits each glyph toplot.width / layerCount and derives level-of-detail from the fitted size, zooming out collapses the full glyphs to icons on its own. No separate "compact mode" to wire up.
100% · zoom out far enough and the glyphs collapse to icons
The full recipe demo takes the samelineageDagLayout further: a linked detail panel and minimap, a downstream-reachability hover preview, and snapshot morphing between topology versions.
Rendering a foreign grammar: GoFish
Update. An earlier cut of this section described an
interpreter that re-executed GoFish's grammar — walking
data → operators → markourselves. That has been superseded. GoFish now exposes
toDisplayList({ w, h }), its post-layout
render IR, so the adapter consumes GoFish's own baked geometry instead of re-deriving it: GoFish owns the layout solve, Semiotic renders. The live gallery and the updated adapter now live at
Interoperability → GoFish DisplayList.
The harder test came from the other direction. GoFishis a research visualization grammar (MIT) that formalizes Gestalt relations — spacing, containment, connection — as composable operators, reclaiming charts that fall outside the classic Grammar of Graphics: mosaics, waffles, ribbons, nested glyphs. The question: can you export anything from GoFish and render it in Semiotic?
The answer is yes, and the seam is GoFish'stoDisplayList({ w, h }) — a flat, viewport-baked list of positioned primitives in absolute pixels, the coordinate transforms already folded in, so a polar petal arrives as a baked path. Each item carries arole (node for data-bearing marks, overlay for chrome) and an optionaldatum. The adapter maps the list onto a custom layout by that contract: data-bearing marks become scene nodes with a transparent hit-rect carrying theirdatum; everything else renders verbatim as overlay. Nothing is recognized or special-cased per chart type — a polar flower, a packed-circle treemap, and a pictorial bottle all flow through one role-driven mapping and pick up Semiotic's hit-testing, tooltips, selection, accessibility, and SSR for free.
The division of labor is the whole point: GoFish owns layout, scales, coordinate transforms, and constraints; Semiotic owns the runtime around the baked shapes. Because the DisplayList is produced by GoFish itself, the result is its real geometry — not a re-implementation that has to stay bug-for-bug compatible. And because the render IR is just data, a host can also emit it by hand: the bubble-tea menu in the gallery is a DisplayList written directly, with no GoFish dependency at all — the same adapter renders it identically.
Six charts ride this adapter atInteroperability → GoFish DisplayList: a flower meadow, a polar ribbon, a fare circle treemap, a bottle-fill, a hand-emitted boba, and a Python Tutor memory diagram — each rendered from a baked DisplayList by the same unchanged adapter.
When to reach for a custom chart
- Reach for it when the catalog genuinely doesn't have the shape: a domain-specific diagram (lineage, memory, state machine), a pictorial glyph, a nested or relational layout, or hosting another tool's output.
- Reach for it when an external system already computes positions and you only need a renderer with interaction. This is how the kstreams recipe works.
- Don't reach for it when a catalog chart fits with props. A custom layout is more surface area to own; the built-ins carry their encodings, legends, and a11y defaults for free.
- Don't reach for it to escape one inconvenient default. That's usually a
frameProps or a style function, not a new layout.
Where this same pattern shows up
The "domain owns layout, the library renders + interacts" split isn't a streaming-topology quirk. It's the right shape any time positions come from somewhere authoritative: build/CI dependency graphs, supply-chain and logistics routes, org and reporting hierarchies, model and data lineage in ML pipelines, and call graphs in observability tooling. And the "render a serialized IR" half generalizes to any design tool or grammar that can bake one. But, to be clear, the reason this is labeled as anunstable API is because this will change by the time it is released to ensure that it is the most effective compatibility layer with GoFish.
Related
- Custom Charts — the XY / ordinal / network escape-hatch surface and the scene-node/overlay contract.
- Kafka Streams Topology — the lineage DAG recipe and the linked detail + minimap views.
- Experimental GoFish Adapter — a live gallery of GoFish charts rendered from their baked DisplayList, plus a hand-emitted one.