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.
When layouts run
On a client mount with bounded data, the frame ingests the data before its first layout. A fixed-size NetworkCustomChart runs its layout once. A responsive container can add a call when its measured plot size replaces the initial size. If measurement arrives before the first layout, there is no fallback-size pass. Stable rerenders and repeated measurements at the same size reuse the scene. Server rendering and client hydration execute separately, and React development StrictMode can replay mounting work.
Network layouts reuse their last successful scene while data, plot dimensions, layout function, layoutConfig, theme/palette, and geometry-driving selection are unchanged. Keep data and configuration immutable; replace their references when values change. Memoize layout functions and configuration objects created inside your component. Pass changing geometry inputs throughlayoutConfig instead of hiding them in mutable closures. An explicitref.current.relayout() forces a new call; a failed call can be retried.
Selection changes use the returned restyle/restyleEdgecallbacks when supplied, so highlighting can repaint without recomputing geometry. Without these callbacks, a changed selection reaches the next layout throughctx.selection. Palette changes update network scenes without rerunning built-in topology solvers. animate={false} also disables network transitions after data or size changes.
Nonempty bounded graphs no longer receive a preliminary empty-data layout. Empty data is still a valid layout input on a lower-level frame or an unseeded push-mode chart: layouts can emit decorations without nodes. An explicitly empty bounded NetworkCustomChart displays its empty state. Edges alone can create endpoint nodes, so an empty authored nodes array does not always mean an empty graph.
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. Categories that receive no cells are disclosed with a shown/total count below the grid, including in SVG exports. Rows and columns must be positive integers, with enough plot space for the gutters; an invalid grid shows a notice instead of cells.
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.
This built-in portable recipe is also a first-class serialized component. Use the same name with MCP or renderChart, while React renders it through the genericChartRecipe host:
JSX
import { ChartRecipe } from "semiotic/ai" <ChartRecipe recipeId="CalendarHeatmapRecipe" data={dailyEvents} layoutConfig={{ dateAccessor: "date", valueAccessor: "count", year: 2025 }} title="Daily activity" description="Daily activity by calendar week and weekday." summary="Activity peaks in the final week of March." accessibleTable />
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.
Flextree and Dagre center and uniformly shrink the node bounds into the plot, including negative coordinates. Labels and tooltips receive your original data objects. Set layoutConfig={{ fit: "none" }} to preserve authored pixel positions. Non-finite positions and nonpositive node sizes are skipped. Dagre includes edge waypoints in the fitted bounds.
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" }} />
To place a minimap, overlay, or viewport on the fitted graph, compute the same fit the layout applies with createDagreFit(nodes, edges, plot, config) (orcreateFlextreeFit(nodes, plot, config) for flextree) instead of copying the recipe's defaults. It returns scale, dx, dy,bounds, project(point), invert(point), andnodeBounds(node), which equals the rect the layout draws. The plot is plot-relative (frame margins excluded); fit each chart size separately. WithZoomableNetworkCustomChart, apply the camera afterwards withprojectNetworkPoint / invertNetworkRect fromsemiotic/network/zoom.
JSX
import { createDagreFit } from "semiotic/recipes" const plot = { x: 0, y: 0, width: width - margin.left - margin.right, height: height - margin.top - margin.bottom } const fit = createDagreFit(nodes, edges, plot) const box = fit.nodeBounds(nodes[0]) // { x, y, width, height, cx, cy } in plot space const authored = fit.invert({ x: pointerX, y: pointerY })
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 cycles as dashed loops with arrowheads inside the plot, and dims to a host-supplied reachable set. Because it only reads pre-computed layer/row coordinates, output is deterministic. Domains fit actual extents, including negative layers and uncentered rows. Backedges are inferred when the source layer is at or beyond the target layer; an explicit isBackEdge boolean overrides that inference. Optional layerCount and maxLayerSize reserve zero-based layers and centered rows, expanding to include data outside those ranges. Set hullGroupAccessor to enclose nodes that share a group value in padded, rounded convex hulls. Those hulls use the layout's fitted pixel geometry and render in the background layer in both the browser and static SVG output. 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, hullGroupAccessor: "subtopologyId", hullColors, hullPadding: 16, hullRadius: 12 }} selection={{ name: "lineage" }} // LinkedCharts → ctx.selection highlights across views />
Use createLineageDagFit(nodes, plot, config) from semiotic/recipesto share the recipe's exact fitting rules with a minimap. It returns resolved glyph dimensions and LOD, nodeBounds(node), project(layer, row), andinvert(x, y). Coordinates are plot-relative; margins and scrolling are separate. Create one fit per view size/LOD and convert the main viewport through logical coordinates into the minimap. An inverse with a single layer returns layer: null because that axis is collapsed; show the full minimap width in that case. For a custom layout's already-emitted positions, the chart ref's getCustomLayout() supplies readback.
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. Rows without positive values are skipped without leaving gaps. When rows cannot fit, a shown/total count below the chart discloses the omission, including in SVG exports.
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.
ParallelCoordinatesRecipe is discoverable through the schema and chart recommender. Its portable configuration requires at least two numeric fields; the lower level layout remains available when a React-only callback such as highlightFnis needed.
JSX
import { ChartRecipe } from "semiotic/ai" <ChartRecipe recipeId="ParallelCoordinatesRecipe" data={cars} layoutConfig={{ fields: ["mpg", "hp", "weight", "accel", "year"], colorBy: "origin", showPoints: true, }} title="Vehicle profiles" description="Vehicle profiles compared across five independently scaled measures." summary="Efficient vehicles generally trade horsepower for lower weight." accessibleTable />
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 decoration. 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. Passcursor: "pointer" when the matching overlay glyph is actionable, and pair it with the frame click/observation API; cursor styling does not create activation or keyboard semantics by itself. If an overlay opts back into pointer events, style that overlay element directly because it, rather than the retained canvas, owns the hit. - 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; use backgrounds for layout-derived regions and enclosures below the scene, and reserve overlays for labels, dividers, arrows, and other detail above it. Both SVG layers are included in static rendering and do not participate in canvas hit-testing.
When the chart has a perspective, the frame projects your scene marks but not those two SVG layers, since they are your own drawing. Wrap ground geometry in NetworkPerspectiveGround and upright chrome inNetworkPerspectiveBillboard, then return perspective: "manual". Both render their children unchanged on a flat chart, so the same layout serves both. The built-in recipes on this page already do this.
HTML marks (rich DOM nodes)
A network layout can return htmlMarks alongside backgrounds /sceneNodes / sceneEdges / overlays: positioned HTML/React nodes that Semiotic renders into one real-DOM layer above the canvas and SVG overlays (stack order: backgrounds → canvas → overlays → htmlMarks). Each mark is{ id, x, y, width, height, content } in the same plot space assceneNodes — the framework owns the margin 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.
HTML wrappers are virtualized against the nearest ancestor with overflow: auto,scroll, or overlay, with 400 CSS pixels of overscan. Scroll and resize updates share one animation-frame measurement and do not rerun the layout. If there is no measurable scroll target, including during SSR or when the target has zero size, all marks mount. This affects the DOM wrappers; scene geometry and hit targets stay retained. For camera zoom, import ZoomableNetworkCustomChart from semiotic/network/zoom. Its frame transform keeps canvas, SVG, HTML, culling and hit testing aligned. External CSS scaling is not supported.
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).
Stable mark IDs and stable descendant component types/keys preserve mounted content across position updates and node-array replacement. A new node array can still rerun the layout. Once an unpinned, unfocused card is culled, its local React state is discarded; keep durable edits outside virtualized cards. A focused card stays mounted until focus leaves. Removing focused data returns lost focus to the frame. Interactive card content must opt into pointer-events: auto.
Through frameProps, set viewport.scrollContainerRef explicitly, or use viewport.scrollContainer with an element stored by a callback ref when the scroll element can be replaced. A null explicit target mounts all marks; it does not fall back to another ancestor. Configure htmlMarkCulling with enabled, overscan (zero is valid), and pinnedIds. Refs resolve after a React commit; mutating ref.current alone does not notify React.
TSX
import { useState } from "react" import type { NetworkViewportSnapshot } from "semiotic/network" const [scrollContainer, setScrollContainer] = useState<HTMLDivElement | null>(null) const [viewport, setViewport] = useState<NetworkViewportSnapshot | null>(null) <div ref={setScrollContainer} style={{ height: 280, overflow: "auto" }}> <NetworkCustomChart nodes={nodes} edges={edges} layout={cardLayout} frameProps={{ viewport: { scrollContainer }, htmlMarkCulling: { overscan: 80, pinnedIds: selectedId ? [selectedId] : [] }, onViewportChange: setViewport, }} /> </div>
onViewportChange reports unpadded visibleRect, overscanned renderRect, visibleMarkIds, and mountedMarkIds in plot space. Null rectangles mean unmeasured. Off-screen pins appear only in the mounted set. Rectangle changes are reported even when IDs do not change, so a minimap tracks scrolling continuously. Reporting also works without HTML marks or with culling disabled. Feed it to subscribed overlay content and a minimap; keep it out of layoutConfigso scrolling does not trigger layout. Edge culling must test complete path bounds, including links whose endpoints are both outside the window. These props are React-only, not JSON configuration.
Scroll the stages. Select a card to retain it, or edit its note to keep it mounted while focused.
Skip to data tableScroll to explore the topology. The minimap outlines the visible region.
30 visible;30 mounted.No card is pinned.
Virtual viewport and semantic zoom
ZoomableNetworkCustomChart keeps the layout fixed while a camera pans and scales the plot. It clips content without native scrollbars. The gesture runtime and LOD hooks are only included when you import semiotic/network/zoom; ordinary charts keep their existing behavior.
- Source
- Validate
- Enrich
- Aggregate
- Publish
Payments validation needs review; Inventory enrichment is delayed. All other steps are healthy. The complete snapshot is also available in the table below.
Source●
- Output rows
- 24,816
- Batch latency
- 42 ms
Validate●
- Output rows
- 24,808
- Batch latency
- 70 ms
Source●
- Output rows
- 6,320
- Batch latency
- 59 ms
Validate●
- Output rows
- 6,309
- Batch latency
- 87 ms
Drag to pan · scroll or pinch to zoom. Focus the chart for + / − and Alt + arrow controls.
0 of 30 steps in viewSilhouettes → shapes → summaries → full detail
Camera settings
Fit, Focus step and Reset respect these bounds and locks.
Read all 30 steps as a table
Illustrative daily snapshot. Each pipeline connects consecutive stages from left to right.| Pipeline | Stage | Status | Output rows | Fields | Batch latency |
|---|
| Orders | Source | Healthy | 24,816 | 12 | 42 ms |
|---|
| Orders | Validate | Healthy | 24,808 | 12 | 70 ms |
|---|
| Orders | Enrich | Healthy | 24,808 | 16 | 98 ms |
|---|
| Orders | Aggregate | Healthy | 15,380 | 6 | 126 ms |
|---|
| Orders | Publish | Healthy | 15,380 | 6 | 154 ms |
|---|
| Customers | Source | Healthy | 6,320 | 12 | 59 ms |
|---|
| Customers | Validate | Healthy | 6,309 | 12 | 87 ms |
|---|
| Customers | Enrich | Healthy | 6,309 | 16 | 115 ms |
|---|
| Customers | Aggregate | Healthy | 3,911 | 6 | 143 ms |
|---|
| Customers | Publish | Healthy | 3,911 | 6 | 171 ms |
|---|
| Payments | Source | Healthy | 18,364 | 12 | 76 ms |
|---|
| Payments | Validate | Review | 18,268 | 12 | 104 ms |
|---|
| Payments | Enrich | Healthy | 18,268 | 16 | 132 ms |
|---|
| Payments | Aggregate | Healthy | 11,326 | 6 | 160 ms |
|---|
| Payments | Publish | Healthy | 11,326 | 6 | 188 ms |
|---|
| Inventory | Source | Healthy | 9,840 | 12 | 93 ms |
|---|
| Inventory | Validate | Healthy | 9,823 | 12 | 121 ms |
|---|
| Inventory | Enrich | Delayed | 9,823 | 16 | 980 ms |
|---|
| Inventory | Aggregate | Healthy | 6,090 | 6 | 177 ms |
|---|
| Inventory | Publish | Healthy | 6,090 | 6 | 205 ms |
|---|
| Shipments | Source | Healthy | 4,386 | 12 | 110 ms |
|---|
| Shipments | Validate | Healthy | 4,366 | 12 | 138 ms |
|---|
| Shipments | Enrich | Healthy | 4,366 | 16 | 166 ms |
|---|
| Shipments | Aggregate | Healthy | 2,706 | 6 | 194 ms |
|---|
| Shipments | Publish | Healthy | 2,706 | 6 | 222 ms |
|---|
| Events | Source | Healthy | 76,820 | 12 | 127 ms |
|---|
| Events | Validate | Healthy | 76,797 | 12 | 155 ms |
|---|
| Events | Enrich | Healthy | 76,797 | 16 | 183 ms |
|---|
| Events | Aggregate | Healthy | 47,614 | 6 | 211 ms |
|---|
| Events | Publish | Healthy | 47,614 | 6 | 239 ms |
|---|
Open the Pipeline Explorer for the complete example, including its inspector, minimap, persistent notes and Full Code view.
TSX
import { ZoomableNetworkCustomChart, useNetworkLOD } from "semiotic/network/zoom" function Card() { const { width, height, level, isInteracting } = useNetworkLOD(260, 174, { breakpoints: [55, 125, 235], hysteresis: 8, // Set keepDetail while an editor has focus. }) // Keep the same outer box and edge ports at every level. // Choose silhouettes, skeletons, summaries or full detail here. // Gate expensive requests on level and !isInteracting; cancel stale work. return <CardContent level={level} displayedSize={{ width, height }} /> } <ZoomableNetworkCustomChart ref={chartRef} nodes={nodes} edges={edges} layout={layout} width={800} height={500} zoomOptions={{ minZoom: 0.15, maxZoom: 4, panBounds: contentBounds }} frameProps={{ htmlMarkCulling: { overscan: 80, pinnedIds: selectedIds } }} /> // chartRef.current.fitToContent(), zoomIn(), zoomOut(), panBy(dx, dy), resetZoom() // Supply zoom={{ x, y, k }} and onZoomChange for a controlled camera.
Wheel zoom requires Ctrl or Meta by default so an embedded chart does not trap page scrolling. Set wheelZoom: true for a dedicated map-like explorer. Mouse drag, touch pinch, double click, plus/minus keys and Alt+arrow panning are supported. Ordinary arrows retain network navigation. Native browser zoom shortcuts and editing controls keep their behavior. Wheel, drag and pinch follow input each animation frame; durationcontrols eased button, keyboard, fit and programmatic zoom transitions. settleDelay defers detail promotion without delaying camera movement.
zoomOptions also exposes locked, zoomEnabled, pan (true, false, "x" or "y"), and separate gesture switches. Scale and pan bounds apply to fit and imperative movement too. Content smaller than the viewport is centered. Fit uses all retained nodes and card boxes; pass explicit bounds to include arbitrary SVG decorations or edge curves.
useNetworkProjectedSize exposes live and settled CSS-pixel sizes; useNetworkZoom exposes the camera and world-coordinate visible rectangle for edge culling or minimaps. The LOD helper delays promotion until motion settles and demotes immediately outside its hysteresis band. It does not fetch or cache data. Store drafts outside virtualized cards if they must survive unmounting, and set keepDetail while an editor is focused. At very small scales, aggregate dense graphs or draw cheap detail on canvas: zooming out can make many more cards visible.
For nested card and row hit rectangles, the smallest containing rectangle wins; equal-area ties use the first scene entry. Nodes take precedence over edges. For visible links drawn in SVG overlays, emit networkEdgeHitTarget into sceneEdges with the same plot geometry. Its default curved path hits the stroke only; use type: "ribbon" or "bezier" for filled bands. Set interactive: false for a table-only edge. Overlay pointer events must reach the frame. Keyboard network traversal remains node-based.
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.