Skip to content

Agent guide - data visualization

Read as Markdown

Read data visualization, 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. Preserve data attribution and definitions when adapting a chart; a more dramatic effect must not change what a mark represents.

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.

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.

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.

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.

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.

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 built with @pibbl/core 0.0.2, revision c321a83. ALPHA — NOT FOR PRODUCTION USE.