Compose data visualizations
@pibbl/core/viz provides scale and drawing primitives. Core owns layout, signals,
events, clipping, and the JSX runtime. No chart container or second rendering
loop is required.
import { Absolute, Group } from "@pibbl/core";import { Axis, LineSeries, LinearScale } from "@pibbl/core/viz";
const readings = [ { time: 0, value: 10 }, { time: 1, value: 25 }, { time: 2, value: null }, { time: 3, value: 20 }, { time: 4, value: 40 },];
<Group style={{ translateX: 48, translateY: 16 }}> <Absolute style={{ width: 400, height: 200 }}> <LinearScale id="x" domain={[0, 4]} range="width"> <LinearScale id="y" domain={[0, 50]} range="height" reverse> <Axis scale="x" position="bottom" /> <Axis scale="y" position="left" /> <LineSeries data={readings} x="time" y="value" xScale="x" yScale="y" /> </LinearScale> </LinearScale> </Absolute></Group>;The Group supplies gutters; Absolute supplies a finite local allocation. A Grid or Flex parent can allocate that plot instead. Width and height ranges use the plot’s allocation, not a shared global chart rectangle. Null creates a real gap.
Pass core signals directly to data, domain, range, reverse, or declared style
inputs. The receiving component owns those reads. Calling .get() yourself
instead makes the enclosing component the consumer. Scale uses a stable computed
signal as layout and props change, without render-time writes.
Use useLinearScale(id).get().map(value) for custom marks and invert(pixel) for
inspection. Event coordinates are root-logical; convert them to the plot’s local
space before inversion. ScaleAdjust positions ordinary Pibbl children using that
mapping. Scales neither clip nor invent event targets.
Explore an actual record
Section titled “Explore an actual record”The CO₂ explorer embeds 504 NOAA GML/Scripps monthly observations from 1980–2021. Hover or tap to inspect, click to pin, drag the overview selection to pan, and drag an edge/date callout to resize. The example’s brush uses ordinary pointer capture and signals; it is not a new public chart or brush component. Data attribution and the source snapshot are included in its editable source.
Known gap: the example currently has a desktop-oriented layout. Phone-sized presentation and a fully evaluated casual-touch experience remain deferred. Core supports responsive style queries on returned Pibbl elements; conditional styles for viz-owned marks remain a separate follow-up.
Linear, UTC, band, log, symlog, and point scales are available. Lines default to linear connections; named curve definitions control step geometry. Local time and automatic legends remain future work. AreaSeries fills to an explicit data-space baseline; IntervalBandSeries fills matching lower/upper observations. Both preserve missing runs and use caller-owned clipping and layout. Nearest-data inspection is available through the functions below.
Dots and custom marks
Section titled “Dots and custom marks”Use PointSeries for circles with bounded component/event overhead; radius uses
local pixels. A shared style accepts shallow signals, while a per-datum style
callback returns plain paint/radius values. Circles paint fill then stroke in
source order, preserving transparent overlap. The series has one union target;
nearest-data lookup is separate from topmost painted hit testing.
Use PlotSeries for ordinary Pibbl elements at each data coordinate. Its required
render(datum, index, data) callback returns nodes; hooks belong in returned
components. Supply keyBy for mutable data to preserve per-row state on reorder.
Missing coordinates skip rows. A callback that returns null retains its row
wrapper; removing/invalidation of a row unmounts it. Custom trees cost ordinary
per-row nodes and targets. The penguin example
compares both approaches with a static credited dataset.
Function-based hover queries
Section titled “Function-based hover queries”HoverData calls an ordinary synchronous query during rendering, then passes its
inferred result to a function child. Custom queries need no registration:
<HoverData query={(context) => { const selected = selection.get(); if (!selected) return undefined; return { datum: selected, anchor: { x: context.scale("x").map(selected.time), y: context.scale("y").map(selected.value), }, }; }}> {(hit) => hit && <Reading datum={hit.datum} anchor={hit.anchor} />}</HoverData>Query/render-callback signal reads belong to the receiving HoverData. Queries cannot write signals, call hooks, return promises, or retain the scale context. Return components for hooks. Changing the query function does not remount same returned component types/keys. Undefined is passed to the callback so it controls empty behavior. Compose query functions by calling each with the same context.
defineSeries({data,x,y,defined?}) is an optional shallow-frozen descriptor helper;
plain compatible descriptors also work. It observes nothing until queried.
| Query factory | Selection | Result |
|---|---|---|
closestPoint |
One mapped point across named datasets, Euclidean local pixels | Series key, original datum/index, domain x/y, local anchor, distance |
closestX |
One recorded point nearest a domain x; ignores pointer y | Original datum/index, actual x/y, local anchor, delta |
interpolateX |
One exact or estimated value at the requested domain x | Discriminated exact datum or interpolated endpoints, fraction, and anchor |
Factories return ordinary functions for HoverData. Inputs accept data/position signals; null inspection yields undefined. Nearest ties preserve series-key and source order. maxDistance uses local pixels; maxDelta and maxGap use domain units. Use separate closestX queries for two recorded values, or separate interpolateX queries at the same time for two aligned estimates. They may also be combined in one custom function returning a comparison object.
Interpolation requires nondecreasing source x and an explicit interpolation
function, such as linearInterpolation(). Duplicate x values error by default;
choose duplicateX: "first" or "last" deliberately. Missing/undefined rows break
segments. No extrapolation or crossing missing-value gaps occurs. maxGap limits
bracketing intervals; exact records still work. Estimates are computed in data
space before mapping and retain both original endpoints, never a fabricated datum.
The two-line comparison contains complete wiring for one nearest point, nearest points per line, and shared-time interpolation, using real NOAA monthly/deseasonalized values. Its pointer/drag handlers convert root-logical x to plot-local x, then to domain time. The guide uses an ordinary Line and pointer capture. No extra pointer tracker, Plot wrapper, Chart container, or scheduler is required.
Cards that protect the inspected point
Section titled “Cards that protect the inspected point”Render HoverCard after the series, at plot origin, using a local anchor:
<HoverCard anchor={hit.anchor} anchorRadius={6} gap={8} style={{ width: 156, height: 32, padding: 10 }}> <Text pointerEvents="none" style={{ font: "12px sans-serif" }}> {hit.datum.label} </Text></HoverCard>Width/height are declared content sizes; padding adds to the outer box. The rounded translucent background passes pointer input through. Set decorative children to pointerEvents none too; explicitly interactive children retain normal targets. No recursive pointer suppression is invented.
Auto placement tries above, below, right, left, sliding only along a side. It keeps the outer border clear of radius plus gap. If no placement fits, it overflows rather than covering the protected anchor; ancestor clips still apply. Explicit bounds use the same local frame. This protects the declared card box, not arbitrary overflowing or filtered child paint, and does not prevent multiple cards from colliding with each other. Preferred sides can help separate two readouts.
Viz-owned shared styles support ordinary signals and layout-driven reflow. Returned Pibbl marks support core responsive style queries against their containing allocation. Direct conditional-style support for viz-owned styles remains deferred.
For multiple readouts, use AnchoredLayout instead of positioning each HoverCard independently. Give it keyed items with local anchors and declared outer sizes; its child callback renders ordinary Pibbl content at each placed origin. It arranges the boxes together and keeps them clear of every supplied anchor. The Hover comparisons lesson demonstrates this with connector lines and a shared column that flips at the plot edge.
Categorical comparisons
Section titled “Categorical comparisons”The band scales and bars example uses an ordered category domain and a numeric value scale to compare population changes in the six most populous U.S. states (Census Bureau, Vintage 2023). Positive and negative bars share a zero baseline. Hover or tap reveals exact counts without changing the bar geometry. Missing-value behavior is covered in the tests.
<BarScale id="channels" domain={["Search", "Email"]} range="width" paddingInner={0.3} paddingOuter={0.2}> <LinearScale id="change" domain={[-30, 30]} range="height" reverse> <BarSeries data={[ { channel: "Search", change: 20 }, { channel: "Email", change: -10 }, ]} category="channel" value="change" categoryScale="channels" valueScale="change" /> </LinearScale></BarScale>Import BarSeries from @pibbl/core/viz. useBarScale("channels").get() exposes
band starts and bandwidth for composed labels and inspection. Categories are
strings or finite numbers; order is explicit, duplicates reject, and unknowns
map to undefined. Padding and domain can be signals. Value and range bars support both orientations. With orientation="horizontal",
use category range "height" and value range "width"; callers choose Axis
positions. The semantic props stay unchanged when orientation changes. Axis
supports categorical labels, all four edges and explicit bounded tick lists.
For grouped bars, nest another band scale with
range={{ bandwidthOf: "categories" }} and pass paired group and
groupScale props to the same BarSeries. Its inner domain sets group order;
missing rows leave their slots empty. A style callback supplies per-row colors
without splitting the series. See Grouped bars
for a complete editable example with two or three groups, responsive sizing,
and inspection.
To color different value regions within the same bar, pass a threshold fill in
style: { fill: { type: "threshold", thresholds: [0, 300_000], colors: ["orange", "green", "red"] } }. One BarSeries paints all intervals; a bar
crossing a threshold changes color there while retaining one border and target.
Thresholds use the numeric value scale’s domain, not pixels. The population
example demonstrates this with green gains up to 300,000 and red above it.
For stacked bars, derive each segment’s start and end in a pure data transform,
then pass valueStart and valueEnd in place of value and baseline.
The same styles and grouped positioning apply. See
Stacked bars for positive and negative
cash flows with segment inspection. The example defines stack order and separate
positive/negative accumulators; the renderer does not choose a stacking policy.
Compose a pie with coordinated labels
Section titled “Compose a pie with coordinated labels”pieSlices(data, { value: "amount", keyBy: "id" }) remains a pure geometry helper.
For coordinated label layout, use PieLayout with ordinary PieSlice and
PieLabel children:
const label = (slice) => `${slice.datum.name} · ${slice.value}`;
<PieLayout data={data} value="amount" keyBy="id" label={label} placement="auto" style={{ cx: 300, cy: 200, radius: 110, labelFont: "14px sans-serif" }}> {(slice) => ( <PieSlice slice={slice} style={{ fill: slice.datum.color }}> <PieLabel slice={slice} /> </PieSlice> )}</PieLayout>;Label text is declared before children render, so the chart measures and places
it once without evaluating arbitrary component trees. By default, callbacks reevaluate when PieLayout evaluates. Supply revision
to opt into cached angles and placements, and change it for in-place data or
nonreactive callback dependency changes. With that explicit contract, paint-only
selection updates reuse label measurements and layout. Tracked signal reads
remain dependencies even when callback identities stay stable. Core still
repaints the Canvas normally.
Choose inside, outside, or auto. Auto moves non-fitting labels outside;
strict inside omits them. Outside labels pack into separate left/right lanes,
with leaders entirely outside the circle. If the available bounds cannot hold
all labels, slice.label exposes a hidden result and reason. No small wedge is
inflated or silently aggregated. Compose an interactive key as an alternative
to hitting tiny slices. PieLabel is decorative unless explicitly given
pointerEvents="auto"; its target does not enlarge the wedge.
The pie composition includes all three modes, tiny categories on both sides, long names, reordering, and limited label space. It keeps the hover/pinned readout in a reserved area outside the whole pie. A full chart or automatic legend is not required.
Values must be nonnegative and finite; missing values have zero area. See
pieSlices and
PieLayout for identity, empty-data, and placement
contracts. The initial label renderer supports single-line text. Custom label
content remains a later addition. Set style.innerRadius for a donut. Inside
labels must fit entirely in the ring, including exclusion of the hole; auto
placement moves non-fitting labels outside. The editable example includes a
donut toggle.
Calendar time
Section titled “Calendar time”Use UTCScale with numeric epoch milliseconds for real calendar ticks.
Try UTC basics for the minimal Scale,
Axis, useLinearScale, extent and niceUTCDomain APIs, then
A rising baseline for 504 real NOAA
observations, decade zoom, seasonal adjustment and inspection.
The UTC domain reference lists complete options,
validation and calendar behavior.
Explicit segments and stacks
Section titled “Explicit segments and stacks”Threshold-colored CO₂ splits a linear path in application code and renders each run with LineSeries. Adjacent colors share the same computed crossing vertex. A value exactly at the threshold belongs to the upper category; zero-length runs are not measurements. Inspection continues to select original NOAA records, never the interpolated crossing. This recipe assumes finite ordered observations; split missing runs before applying it. It is not a smoothed-curve intersection algorithm.
Three states, explicit totals computes BarSeries valueStart/valueEnd before rendering. The authored state order is California, Texas, Florida. A second view divides by the sum of those three states for each date; it never implies a share of the United States. Counts remain available in every readout. Its local transform rejects missing/nonfinite/negative counts and represents an all-zero total as zero-width segments with zero shares. It does not silently omit a missing state and reweight the rest.
The Census Vintage 2023 source contains April 1, 2020 estimates base values and July 1, 2022/2023 estimates; those dates are intentionally different. The example labels the historical snapshot rather than presenting it as current population. The existing signed cash-flow recipe remains an illustration: it accumulates positive and negative amounts separately. No renderer owns aggregation, stacking, normalization, or legend semantics.
Controlled inspection and domain windows
Section titled “Controlled inspection and domain windows”Shared inspection keeps selected month timestamps in application-owned signals. Pointer movement is transient; a tap or keyboard action commits an observed key. Arrow keys follow authored chronological order, Home/End select endpoints, and Escape clears. ReferenceLine and ordinary shapes provide crosshair geometry; there is no separate selection manager or crosshair container. The Canvas focus outline is visual feedback, not an accessibility tree; applications still own semantic alternatives.
Controlled domain brush supplies an explicit UTC domain to a detail plot and keeps the full domain in an overview. Its private gesture draft does not change the committed detail domain until pointer-up. Escape, pointer cancellation, native capture loss, resize, or an external domain change discards the draft. Handles use a 22px hit radius; nearest wins if they overlap, with the start handle winning a tie. Dragging beyond the other endpoint clamps rather than exchanging handle roles. Outside taps recenter the span, and continuing that drag starts from the recentered position.
The brush clamps gestures to the archive and requires at least 30 UTC days.
UTC endpoints are rounded to integer milliseconds. Invalid externally controlled
domains reject rather than rendering a different overview and detail domain.
Left/Right pans by 30 days; Shift+Left/Right moves the end; Home resets. Touch
scrolling is disabled only on the owned Canvas and its prior touch-action value
is restored on disposal. Native capture and listeners remain core-owned. There
are no global selected-chart variables, document listeners, or extra frame loops.
These are editable application recipes, not exported brush/selection APIs. The private optional mount configuration exists for the same headless/public-surface checks that exercise the default Studio lesson. Ordinary applications compose signals, the dedicated scale hooks, and core events directly.
Linked keys and small multiples
Section titled “Linked keys and small multiples”Linked monthly inspection keeps
one NOAA month timestamp as its application-owned key. A pin persists separately
from transient hover and committed inspection, so both the observed-ppm and
ppm − 330 panels draw the same selected record. If replacement data removes a
key, the lesson clears it and does not revive it when that timestamp returns.
Quarterly small multiples aligns
four three-month panels by the explicit ordinal month position (1–3).
Each panel retains its actual month labels and reports no observation when a
selected position is absent. The shared-domain control makes the comparison
choice explicit; the independent option uses each panel’s own finite values.
Proportional signed marks and color providers
Section titled “Proportional signed marks and color providers”State population change maps signed Census changes to a symlog y position. Mark area is proportional to absolute change: positive values use circles and negative values use equal-area squares. The lesson labels this encoding directly and offers a circles-only comparison.
SequentialColorScale, DivergingColorScale, and ThresholdColorScale are
dedicated ancestor providers. Read each with its matching hook and stable ID.
Threshold colors use strictly ascending finite boundaries, one #RRGGBB color
per bucket, and map a value equal to a boundary into the following bucket.
Provider IDs are scoped by the visualization tree; a nested matching provider
shadows an outer one, while a different color-scale family with the same ID is
an error.
The detail plot in the domain example uses panDomain and zoomDomain,
pure operations on the current resolved continuous mapping. They return a
bounded domain pair for the application’s signal. Wheel units, zoom factors,
minimum span, reset and gesture cancellation are explicit application policies;
the helpers own no input listeners or state.
Try Monthly CO₂ in color to compare
all three color families. The sequential and diverging providers expose frozen
stops; the threshold provider exposes frozen bands. The example builds its
ramp or bucket key from those exact resolved values and paints cells using the
same map. Widening a domain or changing thresholds therefore updates both marks
and key. All twelve observed values remain readable on the cells, with a separate
keyboard/touch inspection target. See the complete color contract
for encoded-sRGB interpolation, clamping, explicit center, signal lifetimes and
validation. No palette is inferred from the data and no generic legend is needed.
Open the interactive workbench
Implementation guidance for agents
Section titled “Implementation guidance for agents”Read the Data visualization companion for ownership, adaptation, failure modes, and verification. Agent start provides the version-selection workflow.
Documentation version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision 272a94a. ALPHA — NOT FOR PRODUCTION USE.