import { ProcessSankey } from "semiotic"
Sankey-style flow with a real time axis. Each edge has astartTime / endTime; nodes may declare an xExtent: [start, end] to bound their lane explicitly. Use it for flow events with timestamps (PR commits over time, campaign-finance contributions, supply-chain shipments) where a static-snapshot SankeyDiagramwould erase the temporal structure.
How it differs from SankeyDiagram
- Edges carry time. Each edge has
startTime (when it leaves the source) andendTime (when it arrives at the target). Multiple edges can connect the same source / target pair at different times. Standard sankey treats edges as instantaneous and collapses parallel edges into one. - Nodes have lifetimes, not ranks. A node’s lane spans
min(xExtent[0], earliestEdge)to max(xExtent[1], latestEdge). There is no node-rank prop; the layout reads timing from the data. Nodes may carry an optional xExtent: [start, end] to extend the lane outward — useful when a candidate exists before the first contribution arrives or stays open after the last spend settles. - Static-graph cycles are valid. If A sends to B and B later sends back to A, the graph has a topological cycle but every edge still moves forward in time.
ProcessSankey accepts this; standard sankey rejects it as a DAG violation. - Lane reuse instead of dedicated rows. When two nodes carry mass during non-overlapping intervals, they can share a lane. Small layouts choose the minimum-row assignment that keeps the most flow traveling straight, then aligns alternate phases with the same predecessor/successor role. Pass
packing="off" for one row per node.
Quick Start
The minimum a ProcessSankey needs isnodes, time-stamped edges, and adomain. Toggle Streaming to see the same chart built up via the push API — omit the edgesprop, push them in via the ref.
Sandbox
Pick a fixture and tune layout knobs to see how each affects the chart. Configurations that are good defaults for most data: Pairing: temporal, Packing: reuse,Lane order: crossing-min, Density: capped + hug,Lifetime: half edge.
Fixture
Packing
Lane order
Pairing
Lane density
Ribbon lane
Feeder runway
Lifetime
Lane rails
Labels
Particles
Color by
Legend
Examples
Each example below isolates one setting against the same fixture so you can see what the knob does on its own. The full playground above lets you compose them.
Categorical coloring + legend
colorBy="category" shares one color across every commit / PR / library node, andshowLegend renders the swatch column to the right. Set colorBy="id" for the per-node rainbow instead.
Particle flow
showParticles renders a continuous stream of dots flowing along every ribbon. Spawn rate is proportional toedge.value; tune visual style viaparticleStyle — the same config shapeSankeyDiagram uses.
xExtent — pre-edge and post-edge lifetime
Each commit lane in the Library fixture opens two days before its OUT edge (xExtent[0] = c.start - 2d) and the Library lane stays open through the end of the domain (xExtent[1] = Apr 1). The synthesized leading mass is visible as the small left-tab on each blue commit; the green tail past the last PR merge shows the post-edge extension.
Packing off — one row per node
Default packing reuses lanes whenever two node bands carry mass during non-overlapping intervals, favoring same-row handoffs for heavier flow and alternate phases with the same process role. On larger histories, it also avoids reusing one physical row for source-only feeders aimed at incompatible destinations when another legal row is available. Settingpacking="off" gives every node its own row — useful when readers need a stable y-position per node, even at the cost of vertical space.
HatchFill bands via styleRules
Band styleRules accept a solid color or aHatchFill descriptor (canvas pattern + SSR<pattern>). The legend below is thestatus channel from colorBy="status"— not a claim that “disputed leads to Ship.” Stages runIntake → Legal review → Ship; only the middle stage is marked disputed, so it is hatched while both clear stages share the blue solid.
JSX
// colorBy = status (legend: clear | disputed) // hatch only the disputed stage — stages are still sequential process steps <ProcessSankey colorBy="status" colorScheme={{ clear: "#3b82f6", disputed: "#f59e0b" }} styleRules={[ { when: { field: "status", eq: "disputed" }, style: { fill: { type: "hatch", background: "#fde68a", stroke: "#92400e", spacing: 5, }, }, }, ]} />
Auto labels + priority
showLabels="auto" keeps a density-budgeted subset. Pass labelPriorityAccessor so important stages survive first; shed labels keep their text deferred and reappear when the band is selected — no layout recompute.
Quality readout + layout metrics
showQualityReadout overlays crossings, pixel length, transit occlusion, and lane utilization (plus non-fatal validation warnings). The same metrics are on the layout snapshot:ref.getCustomLayout()?.layout.layoutQuality. Pure helpersdiagnoseProcessSankeyLayout /explainProcessSankeyLayout turn that snapshot into diagnoses for agents and MCP.
Ribbon routing
ribbonLane shifts where the bezier curves bend."source" hugs the source band (the ribbon body lives mostly under the source lane);"target" mirrors that on the target side. Useful for “these came from X” vs “these go to Y” readings.
When a short event crosses several lanes, no control-point ratio can make a few timeline pixels read as a generous curve. SetribbonMinRun="auto" to let source-only feeder ribbons borrow earlier runway from an already-visible source band. Lockstep bonded feeder groups borrow one shared runway; sequential grouped departures remain exact. The rendered band hands that stock to the ribbon at the same visual point, avoiding a rectangle beneath the curve. A number sets the desired runway in pixels; 0 keeps the authored endpoints exact. This is display geometry only: target arrival, inventory events, tooltip dates, and raw data stay unchanged.
systemInTime / systemOutTime — arrival + departure stubs
An edge’s startTime and endTimedescribe when it leaves the source and arrives at the target. Real systems often have a third and fourth moment that matter for the band layout: when did the unit of mass first appear at the source? and when did it finally leave the target? The optional systemInTime andsystemOutTime fields carry those.
When an edge carries systemInTime <startTime, the source band stops painting its flat fill and instead traces just the perimeter outline. Inside, the renderer drops a 20-px gradient stub at that edge’s source slot — band-color saturated fromsystemInTime through startTime, fading transparent → band-color in the 20 px immediately to the left of systemInTime. If the system-in time predates the visible domain, the cropped band instead fades inward over the first 20 px, signaling that it was already in existence when the chart begins. The same mechanic runs on the target side when an edge carriessystemOutTime > endTime: solid band-color from endTime throughsystemOutTime, then a 20-px fade to transparent on the right. Wire the fields through with the matching accessors:
JSX
<ProcessSankey // ... usual accessors ... systemInTimeAccessor="ticketed" systemOutTimeAccessor="closed" />
The helpdesk-ticket fixture below has six tickets. Each one opens (systemInTime), waits in queue, gets triaged (the visible ribbon spansstartTime → endTime), is worked on by the closing team, then finally closes (systemOutTime). The Queue band shows the wait time as a saturated stub leading into the soft fade-in at each ticket’s open time; the Closed band shows the active work time as a saturated stub that fades out at each ticket’s close. The bands’ outer strokes trace the full lifetime envelope, so the chart still reads as node-shaped even where the interior is empty.
Props reference
| Prop | Type | Required | Default | Description |
|---|
nodes | array | — | — | Optional node records; may carry xExtent: [start, end] for an explicit lifetime. Missing endpoints are inferred. |
edges | array | — | — | Timed edge records; omit when ingesting through the push API. |
domain | [number, number] | Yes | — | [tStart, tEnd] of the time axis. |
axisTicks | array | — | — | Optional [{ date, label }] tick overrides. |
orientation | "horizontal" | "vertical" | — | "horizontal" | Read time left-to-right or top-to-bottom. |
xExtentAccessor | string | function | — | — | Per-node [start, end] lifetime accessor. |
nodeLabel | string | function | — | — | Visible lane-label accessor; defaults to nodeIdAccessor. |
groupBy | string | function | — | — | Optional node accessor. Equal non-empty values bond lanes into one contiguous stream-like block. |
systemInTimeAccessor | string | function | — | — | Optional source inventory-arrival time before an edge departs; pre-domain values fade inward from the opening boundary. |
systemOutTimeAccessor | string | function | — | — | Optional target inventory-departure time after an edge arrives. |
pairing | "value" | "temporal" | — | "temporal" | How incoming/outgoing flows are paired into ribbons. |
packing | "off" | "reuse" | — | "reuse" | Pack disjoint occupied-band windows into shared rows, favoring straight handoffs, matching process roles, and destination-coherent source feeders. |
laneOrder | "crossing-min" | "inside-out" | "crossing-min+inside-out" | "insertion" | — | "crossing-min" | Cross-time lane ordering strategy. |
maxValueScale | number | — | — | Optional pixels-per-value cap; prevents sparse bands from inflating to fill the plot. |
lanePlacement | "stack" | "hug" | — | "stack" | Use capped-scale slack to pull connected lanes together. |
groupPadding | number | — | 0 | Pixel gutter inside a bonded group; zero makes adjacent silhouettes touch. |
lifetimeMode | "full" | "half" | — | "half" | Whether dashed node rails split each transition at its midpoint or span the full edge. |
ribbonLane | "source" | "target" | "both" | — | — | Which side(s) ribbons attach to. |
ribbonMinRun | number | "auto" | — | 0 | Minimum rendered time-axis runway for source-only feeders and lockstep bonded feeder groups; auto adapts to lane distance and moves the visual band handoff with the ribbon. |
colorBy | string | function | — | — | Field/accessor that drives categorical node + ribbon color. |
showLaneRails | boolean | — | — | Draw the rail guides behind lanes. |
showLabels | boolean | "auto" | — | true | Node labels; "auto" density-budgets and defers shed labels for selection reveal. |
labelPriorityAccessor | string | function | — | — | Higher values survive showLabels="auto" first (does not reflow geometry). |
maxLabels | number | — | — | Hard cap on auto-visible labels after the area budget. |
selectionDatum | "raw" | "scene" | — | "raw" | What selection/linkedHover predicates see — author records or full scene payload. |
styleRules | StyleRule[] | — | — | Declarative band styling; fill may be a solid color or HatchFill. |
showQualityReadout | boolean | — | — | Overlay crossings, pixel length, transit occlusion, lane utilization, and validation warnings. |
layoutExecution | "auto" | "worker" | "sync" | — | "auto" | Offload dense packing/order to a module worker when auto cost threshold is met. |
showParticles | boolean | — | — | Animate particles along ribbons (pair with particleStyle). |
timeFormat | function | — | — | Formatter for axis ticks and tooltip time fields. |
valueFormat | string | function | — | — | Formatter for flow values. |
The subsections below are the detailed reference for the data shape, accessors, layout controls, and visual options summarized above.
Data
nodes — array of node records. Nodes may carry an optional xExtent: [start, end] tuple. When present, the node’s lane spansmin(xExtent[0], earliestEdge) tomax(xExtent[1], latestEdge) — set both endpoints to the same value for a pure-source “opens at T” anchor; set the second endpoint past the last edge to keep the lane drawing after the final flow settles.edges — array of edge records with source, target, value, startTime, endTime.domain — [tStart, tEnd] of the chart’s time axis.axisTicks — optional array of { date, label }.orientation — "horizontal" reads time left-to-right; "vertical" reads top-to-bottom with lanes distributed across the x-axis.
Accessors
nodeIdAccessor (default "id")sourceAccessor / targetAccessor / valueAccessorstartTimeAccessor / endTimeAccessor / xExtentAccessoredgeIdAccessor (defaults to a synthesized id)systemInTimeAccessor — optional per-edge stamp on the source attachment. When supplied AND less than startTime, the source band drops its flat fill in favor of an outline and paints a 20-px gradient stub at the edge’s slot fading transparent → band-color in the 20 px before systemInTime; the slot stays saturated through startTime. A time before the visible domain moves that fade inside the chart’s opening boundary. See the arrival + departure stubs example.systemOutTimeAccessor — mirror on the target side. When supplied AND greater thanendTime, the target band shows a saturated stub from endTime throughsystemOutTime, then a 20-px fade to transparent past systemOutTime.- Time accessors return
number, Date, or a parseable date string. Internal computation uses ms since epoch.
Layout
pairing: "temporal"(default) pairs by arrival/departure order;"value" pairs largest-incoming with largest-outgoing when magnitude should dominate ribbon grouping.packing: "reuse" (default) packs nodes with disjoint occupied-band windows into the same row. On small layouts it chooses among minimum-row assignments by keeping the greatest flow weight straight, then matching alternate phases with common predecessors and successors. Its bounded large-layout refinement keeps reusable source-feeder rows coherent by destination without weakening direct handoffs. "off" gives every node its own row.laneOrder: "crossing-min"(default) — pixel-aware brute force for ≤8 lanes / ≤40 edges, guarded barycentric + local-delta adjacent swaps above. The cost preserves crossings first, then reduces rendered distance and long-ribbon transit through dense lanes. "inside-out"places largest-mass slot at the median."crossing-min+inside-out" runs both;"insertion" preserves packing order.maxValueScale caps pixels per value unit instead of letting sparse bands expand until the plot is full. Combine it with lanePlacement="hug" to pull connected attachment centers through the resulting slack using order-preserving minimum-gap placement. Both options are opt-in; without the cap, "hug" degenerates to the legacy stack.groupBy bonds nodes with the same non-empty key into one contiguous lane block. Grouped rows only reuse other rows from that group, move as a block during crossing minimization, and use a zero-pixel internal gutter by default so their silhouettes meet like neighboring streamgraph layers. Set groupPadding when a small internal separation is preferable. Grouping does not merge node identities, values, labels, tooltips, or edge attachments.ribbonLane: "both" (default) routes ribbons via the timeline midpoint;"source" hugs the source lane;"target" hops to the target lane early.ribbonMinRun: 0 (default) preserves exact event endpoints. A positive pixel value gives eligible source-only feeders and lockstep bonded feeder groups a minimum time-axis run;"auto" derives a run from lane distance. Pullback is clamped to the feeder band’s declared runway, and the rendered feeder silhouette ends or shrinks at that same handoff. Mass accounting and reported dates never change.lifetimeMode: "half"(default) assigns the first half of a transition to the source rail and the second half to the target rail; "full" gives both endpoint rails the complete edge extent. Packing uses visible band occupancy independently, so rail guidance does not waste rows.showLaneRails (default false) — toggle dashed lifetime rails behind each band.showLabels (default true) — render each lane’s nodeLabel (falling back to its id) at the band’s opening edge. Turn off for dense layouts where labels would overlap, or when the legend already names every band.showQualityReadout — render the small before/after readout for crossings, pixel-weighted ribbon length, authored-window transit occlusion, and lane utilization. WithribbonMinRun enabled, the dates remain authoritative, so this diagnostic deliberately excludes the borrowed visual runway. Useful while tuning laneOrder, maxValueScale, andlanePlacement.
Coloring
colorBy — node accessor used to drive the color scale. Pass a categorical field (e.g. "category") so every commit, person, or PR shares one color.colorScheme — preset name or array of colors.showLegend (defaults to true whencolorBy is set) — render a swatch + label legend to the right of the chart.legendPosition — "right"(default) or "bottom".
Formatting
timeFormat(d: Date) — applied to axis tick labels (overrides tick.label when set) and to time fields in the default tooltip (startTime,endTime, and node mass-history timestamps). Same convention as xFormat on XY charts.valueFormat(v: number) — applied tovalue in the default edge tooltip and to total mass in the node mass-history table. MirrorsyFormat on XY charts.
Default tooltip layouts
- Edge tooltip shows the source → target pair, the edge value, and the time window. Time fields use
timeFormat when supplied, the value usesvalueFormat. - Node tooltip shows the node id and a mass-history table — one row per distinct mass state across the node’s lifetime, with
{ Time, Mass } columns formatted viatimeFormat / valueFormat. The timestamps come from the layout’s sample series so each row corresponds to an event that changed the band’s width. The default caps display at five rows: when the node has more than five distinct mass states, the table condenses to the min / q25 / median / q75 / max picks (re-sorted by time), with a small footer noting the original sample count. To render the history a different way — sparkline, deltas, full series — pass a customtooltip function that overrides this default body. - A custom
tooltip prop overrides both defaults.
Interaction
tooltip — true/omitted shows a default key/value list of the hovered datum’s public fields;false disables; pass a Tooltip(...)config or custom function for full control. Hover targets are node bands and edge ribbons; the hovered datum is the original record from nodes or edges.enableHover (default true) — set to false to suppress hover detection entirely.onClick(datum, {x, y}) — fired when a band or ribbon is clicked.onObservation — fired with{type, datum, x, y, chartType, chartId, timestamp}for hover/hover-end/click events. Standard semiotic observation contract.
Particles
showParticles (default false) — render a continuous stream of dots along every ribbon. The band geometry encodes when a flow happens, the particles encode how much.particleStyle — visual config object. Same shapeSankeyDiagram uses:{ radius, opacity, spawnRate, maxPerEdge, speedMultiplier, color, colorBy, proportionalSpeed }. Defaults from DEFAULT_PARTICLE_STYLE (radius3, opacity 0.7, spawnRate0.1, maxPerEdge 50).
Particles ride the shared canvas + ParticlePool path the rest of the network family uses — spawn rate scales proportional to edge.value, particles recycle out of a pre- allocated pool, and the rAF loop pauses cleanly whenshowParticles is toggled off.
Push API
Like the rest of the chart catalog, ProcessSankey supports ref-based live ingestion. Omit edges from props to enter push mode — the component then owns the edge list and the ref methods mutate it. nodes can be either controlled or pushed.
TSX
import { useRef } from "react" import { ProcessSankey } from "semiotic" const ref = useRef(null) // Live mode: omit edges, push them as they arrive <ProcessSankey ref={ref} nodes={nodes} domain={[t0, t1]} /> // Add an edge ref.current.push({ id: "e1", source: "Alice", target: "Eng", value: 8, startTime: Date.now(), endTime: Date.now() + 86400e3, }) // Batch ref.current.pushMany([edge1, edge2, edge3]) // Update by id (requires edgeIdAccessor or auto-id) ref.current.update("e1", e => ({ ...e, value: 12 })) // Remove by id ref.current.remove(["e1", "e2"]) // Snapshot the current edge list const all = ref.current.getData() // Wipe everything ref.current.clear()
push auto-detects edges (records withsource + target) vs nodes; non-edges go to the internal node list. pushMany partitions a mixed batch the same way. Edge writes are silently dropped with a console warning if edges is being passed as a prop (controlled mode); node writes always flow through.
remove(id) and update(id, fn) address edges by id first (resolved via edgeIdAccessor, falling back to a synthesizedsource-target-index id) and fall through to nodes by nodeIdAccessor when no edge matches.
Caveats and known limitations
- Backward-in-time edges fail validation. An edge with
endTime ≤ startTime blocks rendering. Normalize your data before passing it in. - Mid-stream nodes assume balanced flow. If a transit node receives more or less than it sends across its lifetime, the synthesis falls back to
createevents at xExtent[0] - 1. SetxExtent on net-source nodes for predictable behavior. - Same-slot edges render as “handoff” ribbons along the bottom of the shared lane (a consequence of lane reuse). The visual reads correctly for hierarchical accreters; it can look unusual for cyclic fixtures with heavy reuse.