Physics charts explain process — a compound body crosses gates, particles queue under capacity pressure. Traditional charts explain quantity — stacks, positions, ranks. Accessibility and coordinated views both need those two stories to share identity: the same project, the same PR, the same event stream. This page shows how to anchor a StreamPhysicsFrame HOC to an ordinary chart with observation hooks, the push API, and LinkedCharts selection.
The pattern is the same whether the secondary chart is for a screen-reader summary, a static export, or a linked dashboard: treat the physics frame as a live process sensor, then re-encode its state into a mark the rest of the toolkit already understands (stacked area, scatter, table, navigation tree).
1. Gauntlet → stacked area (single trial)
One compound project moves through a regulatory gauntlet (not housing — a drug candidate, NexaVax-7). Positive property particles are benefits; negative particles are risks. Ten timed events (two or three per gate) fire as the core advances; each one snapshots into the stacked area viaonStateChange + the push API, with event index as x.
Property colors are shared end-to-end: each Gauntlet particle usesproperty.color, and the stacked area is wrapped inCategoryColorProvider with the same map so series layers match the nodes. The stack is baseline="diverging": benefit series push positive y (above the axis), risk series pushnegative y (below). Phase II's interim analysis is the reverse gate: popNegative clears Toxicity whileaddPositive attaches Biomarker — watch the red band under the axis shrink and the purple band grow above. The final advisory vote can also popPositive when the package arrives benefit-heavy and risk-light, trimming one softer benefit before the outcome.
Stage waiting · viability 68 · benefits 2 · risks 2
Gauntlet — single compound through regulatory gates
Diverging stack — benefits above, risks below
JSX
import { useCallback, useRef } from "react" import { StackedAreaChart } from "semiotic" import { GauntletChart } from "semiotic/physics" // Property colors are shared: Gauntlet particles + CategoryColorProvider map. const SERIES_COLORS = { Efficacy: "#22c55e", Safety: "#06b6d4", Biomarker: "#a855f7", Toxicity: "#ef4444", Cost: "#f97316", /* … */ } function TrialWithStack() { const areaRef = useRef() const lastStep = useRef(-1) const onStateChange = useCallback((states) => { const project = states[0] if (!project) return const step = project.eventsApplied.length if (step <= lastStep.current) return lastStep.current = step areaRef.current?.pushMany(seriesRowsForState(project, step)) }, []) return ( <> <GauntletChart data={TRIAL_DATA} positiveProperties={/* each { id, label, color, value|load } */} negativeProperties={/* … */} events={(project) => buildTrialEvents(project)} // 10 timed events onStateChange={onStateChange} /> <CategoryColorProvider colors={SERIES_COLORS}> <StackedAreaChart ref={areaRef} xAccessor="gate" yAccessor="value" areaBy="series" colorBy="series" colorScheme={SERIES_COLORS} baseline="diverging" // risks as negative y stack below 0 // omit data — push mode; provider keeps series colors exact /> </CategoryColorProvider> </> ) }
Implementation notes:
- Single item. One row in
data — GauntletChart is for compound project stories, not multi-item factory floors (useProcessFlowChart for those). Keepdata / size referentially stable (module const oruseMemo); a fresh data={[row]} every render used to re-seed the simulation and loop with live onStateChange readouts. - Event step as x.
project.eventsApplied.length is a stable step index after each timed event (several per gate). Seed step 0 from the initial property set so the stack has a start; tick labels name the event, not only the gate. - Shared colors. Define property hexes once, pass them on
positiveProperties/negativeProperties, and feed the same map to CategoryColorProvider colors={…} +colorScheme on the stacked area so particles and layers match. - Push, don't re-pass data. Omit
data on the stacked area and call pushMany so the frame keeps prior gate samples while appending the next. - Equal scale. Both charts share the same measured width so the process and the quantity story read as one figure.
popNegativemirrors popPositive (array, ids, or{ candidates, count }) and pops the matching tethered body.
2. Merge-pressure particles ↔ scatter (1:1)
A compact ProcessFlowChart view of merge pressure — PRs as particles through capacitated review stages (no feature-group sockets; the small panel needs the space). Beside it, a scatterplot with the same rows: one mark per particle, review work on x, stage index on y, author type as color, churn as size.
Cross-highlight is LinkedCharts selection on id. The scatter speaks the usual HOC dialect (linkedHover + selection); the physics frame needs a body predicate, so we bridge withuseSelection and drive it from onObservation on hover. That bridge must live insideLinkedCharts —useSelection outside the provider uses a different store than the scatter. Physics also dims non-matches in bodyStyle (the frame only styles the selected body by default).
Hover either chart: selection is keyed on id, so the particle and the scatter mark are the same record. The physics bridge must calluseSelectioninsideLinkedCharts so it shares the provider store with the scatter; physics dims non-matches inbodyStyle via context.selected.
JSX
import { LinkedCharts, Scatterplot, useSelection } from "semiotic" import { ProcessFlowChart } from "semiotic/physics" import { unwrapDatum } from "semiotic/utils" // useSelection MUST run inside LinkedCharts — outside it hits the module // fallback store and never sees the scatter's linkedHover clauses. function MergeLinkedInner({ prs, stages }) { const selection = useSelection({ name: "merge-pr", fields: ["id"] }) return ( <> <ProcessFlowChart data={prs} stages={stages} idAccessor="id" stageAccessor="stage" colorBy="authorType" title="PROCESS FLOW" // No groupBy — skips Auth/Billing/… feature sockets in a small panel. chromeOptions={{ outlineStages: true, showGroupSockets: false }} selection={{ isActive: selection.isActive, predicate: (body) => selection.predicate(unwrapDatum(body.datum) ?? body.datum), }} onObservation={(obs) => { if (obs.type === "hover-end") return selection.clear() if (obs.type !== "hover") return const id = unwrapDatum(obs.datum)?.id if (id != null) selection.selectPoints({ id: [id] }) }} frameProps={{ bodyStyle: (body, ctx) => ({ fill: authorColor(body), opacity: !selection.isActive ? 0.92 : ctx.selected ? 1 : 0.16, }), }} /> <Scatterplot data={prs} xAccessor="reviewWork" yAccessor="stageIndex" colorBy="authorType" pointIdAccessor="id" linkedHover={{ name: "merge-pr", fields: ["id"] }} selection={{ name: "merge-pr", unselectedOpacity: 0.16 }} /> </> ) } function MergeLinked({ prs, stages }) { return ( <LinkedCharts> <MergeLinkedInner prs={prs} stages={stages} /> </LinkedCharts> ) }
Why this sits under Accessibility
A physics simulation alone is hard to trust without a static projection — and hard to navigate without a second encoding that assistive tech and linked views already handle well. Anchoring means:
- The process chart keeps motion as explanatory context (and still ships its settled projection strip).
- The quantitative chart keeps a table, keyboard focus ring, legend, and LinkedCharts selection that other views can join.
- Shared identity (
id, gate step, feature key) is the contract — not pixel proximity.
Pair these demos withstructured navigation on the secondary chart, or feed the same push buffer intoobservation hooks for agent tooling.