When the catalog doesn't fit, the four custom-chart HOCs — XYCustomChart,OrdinalCustomChart, NetworkCustomChart, andGeoCustomChart — let you supply a layout function that emits scene nodes directly. The frame still owns scales, theme, hit testing, transitions, decay, accessibility, and SSR — your layout owns the geometry. Most novel chart types (waffle, calendar, streamgraph, flextree, dagre) decompose into the same primitives the built-in HOCs use.
Layouts ship in semiotic/recipes. You can use them as-is or copy one and customize. Writing your own is ~30 lines.
Isometric geographic board
GeoCustomChart receives the frame’s fitted projection helpers plus raw areas, points, and lines, then emits interactive geographic scene nodes. TheParis, Isometric City of Lightsexample quantizes DBpedia landmarks into a five-by-five isometric board while GeoFrame retains polygon hit-testing, accessibility, tooltips, selection, and SSR.
JSX
import { GeoCustomChart } from "semiotic/geo" import { isometricLandmarkLayout } from "semiotic/recipes" <GeoCustomChart points={landmarks} layout={isometricLandmarkLayout} layoutConfig={{ center: { lon: 2.3522, lat: 48.8566 }, centerId: "http://dbpedia.org/resource/Paris", gridSize: 5 }} />
Waffle chart
A grid of cells where each cell represents one share of the total. Categories fill row-major, allocated proportionally with the largest-remainder method. The whole layout is ~40 lines and emits RectSceneNodes — every theme, hover, and selection feature works without extra wiring.
Recipe contract: semiotic.recipe.waffle.v0 · intentpart-to-whole · semantic navigation by category, not by individual cell.
Intent Mark · part-to-whole
XYCustomChart: part-to-whole for general-technical. A unit grid makes composition concrete through repeated, inspectable units.
{ "ididVersion": "0.1", "chartId": "custom-layouts-waffle", "title": "Waffle chart", "intent": { "primary": "part-to-whole", "secondary": [ "compare-categories", "explanation" ] }, "audience": { "primary": "general-technical", "familiarityAssumptions": { "waffleChart": "medium", "partToWhole": "high" }, "literacyTargets": [ { "feature": "unit-based composition", "rationale": "Readers can reason about proportions through repeated visible units." } ] }, "reception": { "channels": [ "visual", "interactive", "screen-reader", "agent" ], "strengths": [ "memorable", "glanceable", "explainable", "teaching-friendly" ], "risks": [ "precise comparison may be harder than a bar chart", "individual cells can become navigation noise" ], "scaffolds": [ "legend", "category summary", "accessible table", "description" ], "memorableForm": true }, "designContract": { "chartFamily": "XYCustomChart", "whyThisForm": "A unit grid makes composition concrete through repeated, inspectable units.", "whyNotDefault": "A pie chart would show share but provide less unit-level evidence and less interaction surface.", "risks": [ "precise comparison may be harder than a bar chart", "individual cells can become navigation noise" ], "misuse": [ "too many categories", "too many units", "false precision", "treating each cell as a separate semantic datum" ] }, "accessibility": { "description": "A 100-cell waffle chart showing share composition by region. 4 categories total 100; each cell represents approximately 1% of the whole. AMER is the largest category at 42%, represented by 42 of 100 cells. This is an apportioning and explanatory chart: repeated units make the composition tangible while category summaries preserve the meaningful reading unit.", "navigation": true, "dataFallback": true, "manualChecks": [ "real screen-reader behavior", "keyboard order quality", "custom metaphor comprehension", "animation distraction" ] }, "provenance": { "reviewStatus": "docs example", "generatedBy": "Semiotic recipe intelligence" } }JSX
import { XYCustomChart } from "semiotic/xy" import { waffleLayout } from "semiotic/recipes" <XYCustomChart data={[ { region: "AMER", share: 42 }, { region: "EMEA", share: 28 }, { region: "APAC", share: 18 }, { region: "LATAM", share: 12 }, ]} layout={waffleLayout} layoutConfig={{ rows: 10, columns: 10, gutter: 3, categoryAccessor: "region", valueAccessor: "share", }} width={420} height={420} />
Calendar heatmap
GitHub-style day-by-day grid. 53 ISO weeks × 7 days, color-encoded by daily value. The recipe handles the day-of-week / week-of-year math; the active theme provides the defaultsurface → primary color ramp. Pass a custom colorRamp for non-default ramps.
JSX
import { calendarLayout } from "semiotic/recipes" <XYCustomChart data={dailyEvents} layout={calendarLayout} layoutConfig={{ dateAccessor: "date", valueAccessor: "count", year: 2025, colorRamp: ["#ebedf0", "#216e39"], // optional }} width={780} height={130} />
Flextree (network)
Hierarchical trees where node sizes vary along the major axis — rendered throughNetworkCustomChart. The user runs d3-flextree (a BYO dep) to compute positions, then passes the laid-out nodes and parent → child edges into the chart. The recipe handles scene emission: rect nodes, smooth bezier edges from parent-bottom to child-top, optional labels.
The demo below is what flextree exists for. Four kinds of variation that uniform-heightd3-tree can't pack tightly are all present at once:
- Per-node height. Row 1's Tutorials (tall card) sits next toAPI Reference (short pill); Row 2's First Chart is taller than every other rect in the row.
- Per-node width. Each rect hugs its label — from the 46px Dataleaf to the 92px Tutorials card.
- Asymmetric subtree breadth.Tutorials's subtree is huge (3 leaves, one of which itself has 3 sub-leaves), so it claims most of the horizontal room.API Reference only needs a narrow slot.
- Asymmetric depth. Two branches drop a 3rd row of children; the others stop at row 2. Real documentation / project trees look like this; flextree packs them without forcing every leaf to the deepest level.
JSX
import flextree from "d3-flextree" import { NetworkCustomChart } from "semiotic/network" import { flextreeLayout } from "semiotic/recipes" const layout = flextree({ nodeSize: (n) => [n.data.size, 40] }) const tree = layout.hierarchy(rootData) layout(tree) const nodes = tree.descendants().map(n => ({ id: n.data.id, x: n.x, y: n.y, width: n.size[0], height: n.size[1], })) const edges = tree.links().map(l => ({ source: l.source.data.id, target: l.target.data.id, })) <NetworkCustomChart nodes={nodes} edges={edges} layout={flextreeLayout} layoutConfig={{ orientation: "vertical" }} width={800} height={260} />
Dagre (network)
Layered DAG layout from dagre (BYO dep). Same shape as flextree: rundagre.layout(g), flatten nodes/edges, pass to NetworkCustomChart. Edges with a points waypoint array render as polylines through those points; without waypoints, fall back to a straight line.
JSX
import dagre from "dagre" import { NetworkCustomChart } from "semiotic/network" import { dagreLayout } from "semiotic/recipes" const g = new dagre.graphlib.Graph() g.setGraph({ rankdir: "TB" }) g.setDefaultEdgeLabel(() => ({})) for (const n of myNodes) g.setNode(n.id, { width: 120, height: 40, label: n.label }) for (const e of myEdges) g.setEdge(e.source, e.target) dagre.layout(g) const nodes = g.nodes().map(id => { const n = g.node(id) return { id, x: n.x, y: n.y, width: n.width, height: n.height, label: n.label } }) const edges = g.edges().map(e => { const ed = g.edge(e) return { source: e.v, target: e.w, points: ed.points } }) <NetworkCustomChart nodes={nodes} edges={edges} layout={dagreLayout} layoutConfig={{ edgeStyle: "smooth" }} />
Lineage DAG with composite glyphs (network)
lineageDagLayout renders a pre-positioned layered lineage/DAG where each node is a composite glyph — a partition-colored container, a semantic icon, a truncated label, and a chip per attached item — that stays a single hit-testable unit: one canvas rect owns the hit area while the icon, label, and chips ride the layout's overlays (which is pointer-events: none, so it never steals a hover). It collapses through full → compact → icon → dot as the graph gets denser, renders isBackEdge cycles as distinct dashed loops, and dims to a host-supplied reachable set. Because it only reads pre-computed layer/row coordinates, output is deterministic. See the full interactive build — main view, synced minimap, and a snapshot morph — in Kafka Streams.
JSX
import { NetworkCustomChart } from "semiotic/network" import { lineageDagLayout } from "semiotic/recipes" <NetworkCustomChart nodes={nodes} // each { id, x: layer, y: row, partition, semantic, stores, label } edges={edges} // each { source, target, edgeType, isBackEdge } layout={lineageDagLayout} layoutConfig={{ layerCount, maxLayerSize, reachableIds, selectedId, renderIcon, partitionColors }} selection={{ name: "lineage" }} // LinkedCharts → ctx.selection highlights across views />
Streamgraph
Streamgraphs aren't a recipe — they're StackedAreaChart withbaseline="wiggle" (Byron–Wattenberg offset, post-centered on y=0) orbaseline="silhouette" (symmetric centering). The same layout pipeline that drives stacked areas handles the offset; no escape hatch needed.
For the canonical streamgraph aesthetic — a "central anchor" series with smaller series wrapping outward — pair the baseline with stackOrder="insideOut". The series with the largest total ends up in the middle straddling y=0; smaller series alternate above and below it. Without an explicit order, series stack alphabetically (which is stable under streaming but visually arbitrary).
JSX
<StackedAreaChart data={timeSeries} xAccessor="t" yAccessor="v" areaBy="group" baseline="wiggle" // or "silhouette" for symmetric centering stackOrder="insideOut" // central anchor series, others wrap outward colorBy="group" />
Marimekko (ordinal)
Variable-width stacked bars where each bar's width encodes its category's contribution to the grand total, and the inner stacked segments encode the within-category breakdown bystackBy. Both dimensions are proportional, making it the natural pick for cohort revenue analysis, market share by segment × product, or any "what's the mix within the mix" question.
JSX
import { OrdinalCustomChart } from "semiotic/ordinal" import { marimekkoLayout } from "semiotic/recipes" <OrdinalCustomChart data={salesByRegionAndProduct} layout={marimekkoLayout} layoutConfig={{ categoryAccessor: "region", stackBy: "product", valueAccessor: "revenue", gutter: 4, }} width={760} height={320} />
Bullet chart (ordinal)
Stephen Few's compact KPI replacement for half-circle gauges. Each row stacks three layers: qualitative range bands (poor → satisfactory → good) as backgrounds, a thinner dark bar for the actual measured value, and a perpendicular tick at the target. Each row is independently scaled so metrics in different units (dollars, percentages, counts) sit side-by-side without any shared axis.
JSX
import { bulletLayout } from "semiotic/recipes" <OrdinalCustomChart data={[ { metric: "Revenue ($M)", actual: 270, target: 250, ranges: [150, 225, 300] }, { metric: "Profit Margin (%)", actual: 23, target: 27, ranges: [ 20, 25, 30] }, { metric: "Order Size ($)", actual: 102, target: 120, ranges: [ 80, 110, 140] }, { metric: "New Customers", actual: 540, target: 600, ranges: [400, 550, 700] }, ]} layout={bulletLayout} layoutConfig={{ categoryAccessor: "metric", valueAccessor: "actual", targetAccessor: "target", rangesAccessor: "ranges", }} width={600} height={210} />
Parallel coordinates (ordinal)
One polyline per row, traced across N parallel vertical axes. Each axis represents a numeric field with its own independent linear scale, so columns in different units (mpg, horsepower, weight) can sit side-by-side without normalizing. Useful for high-dimensional pattern hunting: clusters of similar rows, outliers that swing wildly between axes, and inverse correlations (lines crossing). Set colorBy to color groups.
Interaction: hover any line to dim its neighbors. The recipe accepts ahighlightFn predicate; the demo wraps the chart in a small component that tracks the hovered row's name and feeds it to the recipe. The same hook is the intended integration point for a future <ParallelCoordinatesBrushes> overlay (drag ranges on each axis to filter rows) and for useBrushSelection-style linked brushing across coordinated charts.
Hover any line to highlight.
JSX
import { parallelCoordinatesLayout } from "semiotic/recipes" function ParallelCoords({ cars }) { const [hovered, setHovered] = useState(null) return ( <OrdinalCustomChart data={cars} layout={parallelCoordinatesLayout} layoutConfig={{ fields: ["mpg", "hp", "weight", "accel", "year"], colorBy: "name", showPoints: true, // Recipes are pure, so hover state lives here in the parent. highlightFn: hovered ? (d) => d.name === hovered : undefined, }} onObservation={(obs) => { if (obs.type === "hover") setHovered(obs.datum?.data?.name ?? obs.datum?.name) else if (obs.type === "hover-end") setHovered(null) }} width={760} height={320} /> ) }
Writing your own layout
A layout is a pure function: (ctx) => { nodes, overlays? }. The context exposes the frame's scales, plot-rect dimensions, theme, and a resolveColorhelper that honors the same CategoryColorProvider /ThemeProvider cascade built-in charts use. Emit standard scene nodes (rect, line, area, point,heatcell) and the frame handles the rest.
TSX
import type { CustomLayout } from "semiotic/xy" interface MyConfig { rows: number columns: number valueAccessor: string } export const myLayout: CustomLayout<MyConfig> = (ctx) => { const { rows, columns, valueAccessor } = ctx.config const { plot } = ctx.dimensions const cellW = plot.width / columns const cellH = plot.height / rows const nodes = ctx.data.map((d, i) => { const r = Math.floor(i / columns) const c = i % columns return { type: "rect", x: c * cellW, y: r * cellH, w: cellW - 1, h: cellH - 1, style: { fill: ctx.resolveColor(String(d[valueAccessor])) }, datum: d, } }) return { nodes } }
What you get for free
- Hit testing. Hover, tooltip, click — quadtree built from your scene-node geometry.
- Transitions. Enter/exit/move animations run through the same pipeline as built-in charts.
- Theme cascade. Use
ctx.resolveColor instead of hard-coded literals and the chart honors ThemeProvider +CategoryColorProvider. - SSR. Scene nodes serialize to SVG via the same path the built-in charts use.
- Streaming. Layouts re-run on each push/pushMany — your custom waffle becomes a streaming waffle without extra plumbing.
Recipe authoring contracts
Custom layouts work best when they split semantic marks from decorative chrome. Emit data-bearing scene nodes for anything the reader can hover, click, select, animate, or describe. Render labels, petals, ribbons, arrows, silhouettes, and other visual detail as keyed SVG overlays. If a layout returns overlays without scene nodes, or every scene node has datum: null, Semiotic warns in development because hover callbacks and tooltips have nothing useful to read.
- Stable ids. Give each emitted node a stable identity such as
_transitionKey, pointId, or a recipe-specific id so transitions can interpolate between solved states. - Transparent hit targets. Pictorial glyphs often need a simple scene node underneath the visible overlay. Use
hitTargetPoint,hitTargetRect, networkHitTarget,geoHitTarget, or geoAreaHitTarget with a useful datum. - Tooltip payloads. Shape datums with user-facing keys, then use
buildTooltipEntries to unwrap hover payloads consistently across custom chart families.
TSX
import { buildTooltipEntries } from "semiotic/recipes" const hit = { type: "rect", x, y, w: width, h: height, style: { fill: "rgba(0,0,0,0)", stroke: "none" }, datum: { category, amount, kind: "bottle" }, group: category, _transitionKey: `bottle-hit-${category}`, } function Tooltip(hover) { const rows = buildTooltipEntries(hover) return rows.length ? ( <div className="semiotic-tooltip"> {rows.map((row) => <div key={row.key}>{row.label}: {row.formatted}</div>)} </div> ) : null }
Push semantics
The push API supports two different mental models. Append streams add observations over time: a new event, trade, request, or passenger enters the layout. State updates replace an existing object's current value: a bottle's fill level changes, a node moves, or a KPI updates. Use ref.current.push(datum) for append streams andref.current.update(id, updater) when identity should stay fixed.
JSX
const ref = useRef(null) // Append semantics: add another observation. ref.current.push({ id: "event-42", category: "A", value: 12 }) // State semantics: update an existing object in place. ref.current.update("planning", (d) => ({ ...d, amount: nextAmount, }))
A pictorial bottle-fill chart is the canonical state-update case: the same bottles stay in the scene graph, keyed by id, while their fill values animate between solved states — no insertions or removals, just an in-place value change per frame.
Network custom diagrams
NetworkCustomChart is not limited to network science charts. It is the right escape hatch for semantic diagrams with nodes, edges, and stable identity: state machines, dependency diagrams, lineage diagrams, Python Tutor memory diagrams, process maps, and other object-reference systems. Use scene rects/circles for the semantic objects and network edges for relationships; reserve overlays for labels, dividers, arrows, and other detail that should not intercept interaction.
HTML marks (rich DOM nodes)
A network layout can return htmlMarks alongside sceneNodes /sceneEdges / overlays: positioned HTML/React nodes that Semiotic renders into one real-DOM layer above the canvas and SVG overlays (stack order: canvas → overlays → htmlMarks). Each mark is{ id, x, y, width, height, content } in the same plot space assceneNodes — the framework owns the margin (and any future zoom/pan) transform, so a mark at (x, y) lands exactly where a scene node at(x, y) does.
Reach for it over an SVG <foreignObject> when a node istext-heavy or a rich component and dims/animates on hover. HTML laid out inside SVG gets no compositor layer, so an opacity change — the everyday hover-dim — forces the browser to re-rasterize the node's text; across a large graph that stalls the interaction. A real <div> composites opacity /transform / visibility changes without repainting its contents. Marks are pointer-events: none by default, so keep a transparent hit-rect scene node per card and let the canvas stay authoritative for hover, tooltip, and click.
Hover a card. Cards are real DOM (htmlMarks); edges paint on the canvas; hit-testing rides transparent canvas rects.
TSX
import { NetworkCustomChart } from "semiotic/network" import type { NetworkCustomLayout } from "semiotic/network" const cardLayout: NetworkCustomLayout = (ctx) => { const place = (node) => /* your positions, in plot space */ ({ x, y }) return { // Transparent canvas rect per node → owns hit-testing (onObservation/onClick). sceneNodes: ctx.nodes.map((node) => { const { x, y } = place(node) return { type: "rect", x, y, w: 150, h: 58, style: { fill: "rgba(0,0,0,0)" }, datum: node.data, id: node.id } }), // SVG edges paint beneath the cards. sceneEdges: edges, // Rich HTML cards, positioned in the SAME plot space, rendered as real DOM. htmlMarks: ctx.nodes.map((node) => { const { x, y } = place(node) return { id: node.id, x, y, width: 150, height: 58, content: <NodeCard {...node.data} /> } // dims via opacity → composite-only }), } } <NetworkCustomChart nodes={nodes} edges={edges} layout={cardLayout} />
The card's content can call useCustomLayoutSelection() to read the shared selection and dim itself — that updates the DOM layer without re-running the layout, so hover stays cheap even on big graphs (the demo above drives the dim through layoutConfig for brevity, which re-runs the layout each hover — fine at four nodes, but prefer the selection path at scale).
Sub-path import
Each frame's escape-hatch HOC ships from its own sub-path: XYCustomChart fromsemiotic/xy, NetworkCustomChart fromsemiotic/network, OrdinalCustomChart fromsemiotic/ordinal, and GeoCustomChart fromsemiotic/geo. Layout recipes live on semiotic/recipes as a separate sub-path so they only land in the bundle if you actually use them. BYO deps (d3-flextree, dagre) are imported by your code, not Semiotic — keeps the library small.
JSX
import { XYCustomChart } from "semiotic/xy" import { NetworkCustomChart } from "semiotic/network" import { OrdinalCustomChart } from "semiotic/ordinal" import { GeoCustomChart } from "semiotic/geo" import { waffleLayout, calendarLayout, flextreeLayout, dagreLayout, marimekkoLayout, bulletLayout, parallelCoordinatesLayout, isometricLandmarkLayout, buildTooltipEntries, } from "semiotic/recipes"
Notes
- Plot-relative coordinates. Scene-node positions are in plot space — the frame already translates by the resolved
margin. Usectx.dimensions.plot for the drawing rect. - Renderer dispatch. When
customLayout is provided, the frame uses a renderer set that handles every node type, so your layout can emit any mix of rects, areas, lines, etc. regardless of chartType. - Extents. If your layout uses scales, pass
xExtent /yExtent on XYCustomChart to lock the domain. Layouts that don't use scales (waffle, calendar) ignore them. - For richer composition examples, seerecipes pages. Streamgraph baseline lives on StackedAreaChart.
- See it end-to-end in the Cookbook. Several Cookbook entries build the same chart concepts as worked examples —Marimekko,Slope,Radar,Isotype, andTimeline. Some use these layout functions; others reach the same result through a Stream Frame directly — useful for comparing the two routes.