Skip to content

Compose data visualizations

Read as Markdown

@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.

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.

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.

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.

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.

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.

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.

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.

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.

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 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

Read the Data visualization companion for ownership, adaptation, failure modes, and verification. Agent start provides the version-selection workflow.

Documentation built with @pibbl/core 0.0.2, revision 272a94a. ALPHA — NOT FOR PRODUCTION USE.