# Agent guide - data visualization

## Reading path

Read [data visualization](/guides/data-visualization/), [Scale](/reference/components/scale/), and the specific series reference. `@pibbl/core/viz` supplies scale and drawing primitives; core supplies allocation, state, events, clipping, and scheduling. There is no required chart container or separate frame loop.

The complete Band Bars source below uses public imports and embeds its data. [Open the editable comparison](/playground/#/examples/composition/viz-band-bars). Preserve data attribution and definitions when adapting a chart; a more dramatic effect must not change what a mark represents.

## Allocation, scale, and ownership

Give the plot a finite local allocation with layout, and reserve gutters for axes. Width/height scale ranges use that allocation, not a global chart rectangle. A surrounding Group can supply placement. Scale does not automatically clip or create an input target.

Linear and band scales are supported. Do not invent time/UTC scales, nonlinear curves, or automatic legends. Signals can flow into declared data/domain/range/reverse inputs. The receiver owns those reads; `.get()` in an enclosing component moves that dependency to the enclosing component.

Use `useLinearScale(id).get().map(value)` for custom marks and `invert(pixel)` for inspection where supported. Pointer coordinates arrive in root-logical space; convert to the plot's local coordinates before inversion. Do not invert CSS pixels directly when the Canvas uses a fixed viewport or transform.

## Choose mark and inspection semantics

Use `PointSeries` for many circular marks with bounded component/event overhead. It has one union target; nearest-data inspection is distinct from topmost painted hit testing. Use `PlotSeries` for ordinary Pibbl subtrees per datum. Supply `keyBy` for mutable rows so reordering preserves per-row state. Hooks belong in returned components, not the row callback.

`HoverData` invokes a synchronous query during rendering and passes its result to a function child. Queries cannot write signals, invoke hooks, return promises, or retain the scale context. Return components for hook ownership. Explicitly handle undefined results. `HoverCard` and similar presentation are not permission to assume automatic pointer selection.

Missing values matter: null values in a line represent gaps. Do not silently interpolate missing observations, convert null to zero, or reorder data to make a line look smoother. Decorations should opt out of pointer targeting and preserve labels, true baselines, and units.

## Complete source and adaptation

Compile the source using the Pibbl JSX runtime and call its default mount with an attached canvas. Retain/dispose the returned controller. Its native data/control model is illustrative; a host application should supply a semantic table or textual summary for meaningful Canvas information. Existing desktop-oriented examples are not claims of complete phone accessibility.

When changing data, update domains intentionally and decide how empty, singleton, duplicate, and missing values behave. When adding effects, apply them to a decorative boundary rather than obscuring the marks needed for comparison.

## Verification

Assert scale endpoints and representative mapped values before pixels. Check that null produces a gap, zero stays zero, and axis labels agree with units. Test empty/singleton data and reordered keyed rows. For inspection, compare the selected datum with the intended query, especially under transforms and overlapping marks.

Resize the allocated plot and confirm scales follow the new local box. Test keyboard/native-control alternatives and a static semantic summary. Measure dense datasets before choosing thousands of component-based marks. Do not infer rendering or hit-test efficiency from a small screenshot.

## Complete source

Host setup for this source: use a canvas with width 940 and height 660, compile with `jsx: "react-jsx"` and `jsxImportSource: "@pibbl/core"`, import its default `mount`, and call `const controller = mount(canvas)` after attaching the canvas. Call `controller.dispose()` before removing it.

### viz-band-bars.example.tsx

```tsx
import {
  Absolute,
  Group,
  Line,
  Path,
  Rectangle,
  Text,
  pibbl,
  useLayoutBox,
  useSignal,
  type PibblController,
  type PibblPointerEvent,
  type WritableSignal,
} from "@pibbl/core";
import { Axis, BarSeries, useBarScale, useLinearScale, BarScale, LinearScale } from "@pibbl/core/viz";

interface Reading {
  category: string;
  value: number;
}
// Census Bureau, Vintage 2023 estimates (published December 19, 2023).
// Six most populous states in that release; July 1, 2022 to July 1, 2023 change.
// Historical snapshot: later estimate vintages may revise these figures.
// https://www.census.gov/newsroom/press-releases/2023/population-trends-return-to-pre-pandemic-norms.html
const initial: readonly Reading[] = [
  { category: "California", value: -75423 },
  { category: "Texas", value: 473453 },
  { category: "Florida", value: 365205 },
  { category: "New York", value: -101984 },
  { category: "Pennsylvania", value: -10408 },
  { category: "Illinois", value: -32826 },
];
const abbreviations: Readonly<Record<string, string>> = {
  California: "CA",
  Texas: "TX",
  Florida: "FL",
  "New York": "NY",
  Pennsylvania: "PA",
  Illinois: "IL",
};
const ink = "#172b3a",
  muted = "#637785",
  green = "#167b70",
  orange = "#c36638";
const signed = (value: number) =>
  value === 0
    ? "0"
    : `${value > 0 ? "+" : "−"}${Math.abs(value / 1000).toFixed(1)}k`;
const exact = (value: number) =>
  `${value > 0 ? "+" : "−"}${Math.abs(value).toLocaleString("en-US")}`;

function Button({
  x,
  y,
  label,
  action,
}: {
  x: number;
  y: number;
  label: string;
  action: () => void;
}) {
  return (
    <Group style={{ translateX: x, translateY: y }}>
      <Rectangle
        style={{
          width: 138,
          height: 34,
          cornerRadius: 6,
          fill: "#e4eae9",
          cursor: "pointer",
        }}
        onClick={action}
      />
      <Text
        pointerEvents="none"
        style={{
          left: 69,
          top: 17,
          textAlign: "center",
          textBaseline: "middle",
          font: "12px sans-serif",
          fill: ink,
        }}
      >
        {label}
      </Text>
    </Group>
  );
}

function Bars({
  rows,
  selected,
  origin,
  orientation,
}: {
  rows: readonly Reading[];
  selected: WritableSignal<string | null>;
  origin: { x: number; y: number };
  orientation: "vertical" | "horizontal";
}) {
  const band = useBarScale("states").get();
  const values = useLinearScale("change").get();
  const baseline = values.map(0);
  const thresholdY = values.map(300_000);
  const thresholdDots = new Path2D();
  if (orientation === "horizontal") {
    for (let y = 0; y <= band.frame.height; y += 6) {
      thresholdDots.moveTo(thresholdY + 1, y);
      thresholdDots.arc(thresholdY, y, 1, 0, Math.PI * 2);
    }
  } else
    for (let x = 0; x <= band.frame.width; x += 6) {
      thresholdDots.moveTo(x + 1, thresholdY);
      thresholdDots.arc(x, thresholdY, 1, 0, Math.PI * 2);
    }
  const active = selected.get();
  const horizontal = orientation === "horizontal";
  function inspect(event: PibblPointerEvent) {
    const x = event.x - origin.x,
      y = event.y - origin.y;
    const hit = [...rows].reverse().find((row) => {
      const start = band.map(row.category);
      if (start === undefined || row.value === 0) return false;
      const end = values.map(row.value);
      return (
        horizontal
          ? x >= Math.min(end, baseline) &&
            x <= Math.max(end, baseline) &&
            y >= start &&
            y <= start + band.bandwidth
          : x >= start &&
            x <= start + band.bandwidth &&
            y >= Math.min(end, baseline) &&
            y <= Math.max(end, baseline)
      );
    });
    selected.set(hit?.category ?? null);
  }
  const hit = rows.find((row) => row.category === active);
  return (
    <Group
      onPointerMove={inspect}
      onPointerDown={inspect}
      onPointerLeave={() => selected.set(null)}
    >
      <Rectangle
        style={{
          width: band.frame.width,
          height: band.frame.height,
          fill: "#fffdf8",
        }}
      />
      {[-200000, -100000, 100000, 200000, 300000, 400000, 500000].map(
        (tick) => (
          <Line
            key={tick}
            pointerEvents="none"
            style={{
              coords: [
                horizontal
                  ? [values.map(tick), 0]
                  : [0, values.map(tick)],
                horizontal
                  ? [values.map(tick), band.frame.height]
                  : [band.frame.width, values.map(tick)],
              ],
              stroke: "#e8ebe6",
              strokeWidth: 1,
            }}
          />
        ),
      )}
      <Axis
        scale="states"
        position={horizontal ? "left" : "bottom"}
        tickValues={band.domain}
        tickFormat={(value) =>
          typeof value === "string" && band.step < 105
            ? abbreviations[value]
            : String(value)}
        style={{ fill: muted, stroke: "#b5c0bf" }}
      />
      <Axis
        scale="change"
        position={horizontal ? "top" : "left"}
        tickCount={8}
        tickFormat={(value) =>
          typeof value === "number" && value === 0 ? "0" : `${Number(value) / 1000}k`}
        style={{ fill: muted, stroke: "#b5c0bf" }}
      />
      <BarSeries
        orientation={orientation}
        data={rows}
        category="category"
        value="value"
        categoryScale="states"
        valueScale="change"
        style={{
          fill: {
            type: "threshold",
            thresholds: [0, 300_000],
            colors: [orange, green, "#c43c39"],
          },
        }}
      />
      <Line
        pointerEvents="none"
        style={{
          coords: [
            horizontal ? [baseline, 0] : [0, baseline],
            horizontal ? [baseline, band.frame.height] : [band.frame.width, baseline],
          ],
          stroke: ink,
          strokeWidth: 1,
        }}
      />
      <Path
        pointerEvents="none"
        style={{ d: thresholdDots, fill: "#8d3432" }}
      />
      <Text
        pointerEvents="none"
        style={{
          left: horizontal ? thresholdY + 5 : band.frame.width - 5,
          top: horizontal ? 5 : thresholdY - 10,
          textAlign: horizontal ? "left" : "right",
          textBaseline: horizontal ? "top" : "bottom",
          font: "11px sans-serif",
          fill: "#8d3432",
        }}
      >
        300k · color threshold
      </Text>
      {rows.map((row) => {
        const start = band.map(row.category);
        if (start === undefined) return null;
        const center = start + band.bandwidth / 2;
        const end = values.map(row.value);
        return (
          <Group key={row.category}>
            <Text
              pointerEvents="none"
              style={{
                left: horizontal ? end + (row.value < 0 ? -8 : 8) : center,
                top: horizontal ? center : end + (row.value < 0 ? 12 : -20),
                textAlign: horizontal ? row.value < 0 ? "right" : "left" : "center",
                textBaseline: horizontal ? "middle" : "alphabetic",
                font: "bold 13px sans-serif",
                fill: ink,
              }}
            >
              {signed(row.value)}
            </Text>
          </Group>
        );
      })}
      <Text
        pointerEvents="none"
        style={{
          left: 0,
          top: band.frame.height + 61,
          font: "bold 15px sans-serif",
          fill: ink,
        }}
      >
        {hit
          ? `${hit.category} · ${exact(hit.value)} residents`
          : "Hover or tap a bar for the exact population change"}
      </Text>
      <Text
        pointerEvents="none"
        style={{
          left: 0,
          top: band.frame.height + 88,
          font: "11px sans-serif",
          fill: muted,
        }}
      >
        Source: U.S. Census Bureau · Vintage 2023 estimates
      </Text>
    </Group>
  );
}

function Scene() {
  const box = useLayoutBox();
  const order = useSignal<readonly string[]>(
    initial.map((row) => row.category),
  );
  const padding = useSignal(0.3);
  const compact = useSignal(false);
  const orientation = useSignal<"vertical" | "horizontal">("vertical");
  const selected = useSignal<string | null>(null);
  const mode = orientation.get();
  const narrow = box.width < 480;
  const origin = { x: mode === "horizontal" ? 128 : 65, y: narrow ? 240 : 190 };
  const width = Math.max(220, (box.width - origin.x - 35) * (compact.get() ? 0.75 : 1));
  const height = Math.max(60, box.height - origin.y - 110);
  return (
    <Group>
      <Rectangle
        pointerEvents="none"
        style={{ width: box.width, height: box.height, fill: "#fffdf8" }}
      />
      <Text
        pointerEvents="none"
        style={{ left: 28, top: 25, font: "bold 27px sans-serif", fill: ink }}
      >
        Annual population change
      </Text>
      <Text
        pointerEvents="none"
        style={{ left: 28, top: 64, font: "13px sans-serif", fill: muted }}
      >
        Six most populous U.S. states · July 2022–July 2023
      </Text>
      <Button
        x={28}
        y={96}
        label="Reverse order"
        action={() => order.set([...order.get()].reverse())}
      />
      <Button
        x={178}
        y={96}
        label="Change spacing"
        action={() => padding.set(padding.get() === 0.3 ? 0.65 : 0.3)}
      />
      <Button
        x={narrow ? 28 : 328}
        y={narrow ? 140 : 96}
        label="Resize plot"
        action={() => compact.set(!compact.get())}
      />
      <Button
        x={narrow ? 178 : 478}
        y={narrow ? 140 : 96}
        label={mode === "vertical" ? "Horizontal bars" : "Vertical bars"}
        action={() =>
          orientation.set(
            orientation.get() === "vertical" ? "horizontal" : "vertical",
          )}
      />
      <Text
        pointerEvents="none"
        style={{
          left: 28,
          top: origin.y - 42,
          font: "11px sans-serif",
          fill: muted,
        }}
      >
        Orange: below 0 · Green: 0–300k · Red: above 300k
      </Text>
      <Group style={{ translateX: origin.x, translateY: origin.y }}>
        <Absolute style={{ width, height }}>
          <BarScale
            id="states"

            domain={order}
            range={mode === "horizontal" ? "height" : "width"}
            style={{ width, height }}
            paddingInner={padding}
            paddingOuter={0.2}
          >
            <LinearScale
              id="change"

              domain={[-200000, 500000]}
              range={mode === "horizontal" ? "width" : "height"}
              reverse={mode === "vertical"}
            >
              <Bars
                rows={initial}
                selected={selected}
                origin={origin}
                orientation={mode}
              />
            </LinearScale>
          </BarScale>
        </Absolute>
      </Group>
    </Group>
  );
}

export default function mount(canvas: HTMLCanvasElement): PibblController {
  return pibbl(canvas, <Scene />, { viewport: "responsive" });
}

```

## Documentation version

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