Agent guide - data visualization
Reading path
Section titled “Reading path”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.
Allocation, scale, and ownership
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “viz-band-bars.example.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.htmlconst 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
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision 2dccb19. ALPHA — NOT FOR PRODUCTION USE.