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

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

The [CO₂ explorer](/playground/#/examples/composition/viz-responsive-line) 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](/reference/functions/series-curves/)
control step geometry. Local time and automatic legends remain future work.
[AreaSeries](/reference/components/area-series/) fills to an explicit data-space baseline;
[IntervalBandSeries](/reference/components/interval-band-series/) 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

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](/playground/#/examples/composition/viz-points-and-inspection)
compares both approaches with a static credited dataset.

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

```tsx
<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](/playground/#/examples/composition/viz-hover-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

Render `HoverCard` after the series, at plot origin, using a local anchor:

```tsx
<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](/reference/components/anchored-layout/)
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

The [band scales and bars example](/playground/#/examples/composition/viz-band-bars) 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.

```tsx
<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](/playground/#/examples/composition/viz-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](/playground/#/examples/composition/viz-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

`pieSlices(data, { value: "amount", keyBy: "id" })` remains a pure geometry helper.
For coordinated label layout, use `PieLayout` with ordinary `PieSlice` and
`PieLabel` children:

```tsx
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](/playground/#/examples/composition/viz-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](/reference/functions/pie-slices/) and
[PieLayout](/reference/components/pie-layout/) 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

Use `UTCScale` with numeric epoch milliseconds for real calendar ticks.
Try [UTC basics](/playground/#/examples/composition/viz-utc-basics) for the minimal Scale,
Axis, useLinearScale, extent and niceUTCDomain APIs, then
[A rising baseline](/playground/#/examples/composition/viz-utc-climate) for 504 real NOAA
observations, decade zoom, seasonal adjustment and inspection.
The [UTC domain reference](/reference/functions/utc-domains/) lists complete options,
validation and calendar behavior.

## Explicit segments and stacks

[Threshold-colored CO₂](/playground/#/examples/composition/viz-threshold-segments) 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](/playground/#/examples/composition/viz-population-stack)
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](/playground/#/examples/composition/viz-stacked-bars)
remains an illustration: it accumulates positive and negative amounts separately.
No renderer owns aggregation, stacking, normalization, or legend semantics.

## Controlled inspection and domain windows

[Shared inspection](/playground/#/examples/composition/viz-linked-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](/playground/#/examples/composition/viz-domain-exploration) 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

[Linked monthly inspection](/playground/#/examples/composition/viz-linked-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](/playground/#/examples/composition/viz-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

[State population change](/playground/#/examples/composition/viz-size-symbols) 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`](/reference/functions/domain-navigation/),
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](/playground/#/examples/composition/viz-color-encodings) 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](/reference/components/color-scales/)
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](/playground/#/workbench/viz-responsive-line)

## Implementation guidance for agents

Read the [Data visualization companion](/agents/topics/visualization/) for ownership, adaptation, failure modes, and verification. [Agent start](/agents/) provides the version-selection workflow.

## Documentation version

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