| name | ripl-charts |
| description | Build production-ready charts in the Ripl monorepo (@ripl/charts) — a new chart type, or a fix/demo for an existing one — with correct animation, interaction, docs, and demos, first try. Use whenever adding or changing a chart in packages/charts, its VitePress demo/docs under apps/website/src/charts, or the shared chart infrastructure. Covers the factory+class pattern, the enter/update/exit render pipeline, tooltips/hover-highlight, labels, the element toolkit, demo authoring, gallery/docs registration, and how to verify in this sandbox. |
Building Ripl charts
Ripl is a Yarn 4 monorepo. Charts live in @ripl/charts and render on the retained-mode scenegraph
from @ripl/core (context-agnostic: Canvas, SVG, and terminal). A chart is a factory + a class over a
Scene/Renderer; every chart gets animated transitions, pointer events, responsive sizing, and
tooltips for free by following the shared pattern below.
Match the surrounding code's idiom (comment density, naming, arrayJoin reconcile, data-driven
transitions). When in doubt, copy the closest existing chart: scatter.ts/polar-scatter.ts (Chart
base), line.ts/area.ts (CartesianChart base), packed-circle.ts/force-directed.ts (custom
layouts), pie.ts/radial-bar.ts (arcs).
Where things live
packages/charts/src/charts/<name>.ts — the chart (factory create<Name>Chart + class).
packages/charts/src/core/ — shared infra: chart.ts (base Chart), cartesian.ts
(CartesianChart with scales/axes/grid/crosshair), animation.ts (ANIMATION_REFERENCE,
resolveAnimation, stagger, exitElement), interaction.ts (applyHoverHighlight),
labels.ts (createSegmentLabel, createDataLabel), data.ts (resolveAccessor),
options.ts (resolveValueFormat, resolveLineDash), morph.ts (correspondence, keysDiffer),
pack.ts, force.ts, fill.ts.
packages/charts/src/components/ — tooltip.ts, legend.ts, axis.ts, grid.ts, crosshair.ts.
packages/charts/test/ — vitest unit tests; test/visual/gallery.ts + chart-ids.ts drive
Playwright snapshots.
apps/website/src/charts/<name>.md — the VitePress demo + docs page.
apps/website/src/charts/getting-started.md — the "Available Charts" table (register new charts here).
Anatomy of a chart
export interface <Name>ChartOptions<TData = unknown> extends BaseChartOptions {
data: TData[];
key: keyof TData | ((item: TData) => string);
value: keyof TData | ((item: TData) => number);
format?: ValueFormatInput;
}
export interface <Name>ChartEventMap extends EventMap {
cellclick: <Name>CellEvent;
cellenter: <Name>CellEvent;
cellleave: <Name>CellEvent;
}
export class <Name>Chart<TData = unknown> extends Chart<<Name><>, <>> {
: [] = [];
: ;
() {
(target, options);
. = ({ : ., : . });
.();
}
() {
.( () => {
});
}
}
create<>< = >(: | | , : <><>) {
<><>(target, options);
}
Extend Chart for radial/network/custom layouts; extend CartesianChart when you need x/y
scales, axes, grid, and crosshair (it exposes this.xScale, this.yScale, axis/grid rendering).
chart.update(partialOptions) merges options and re-renders; chart.destroy() tears everything down.
The render pipeline
Inside super.render(async () => { ... }):
- Layout:
const layout = this.createLayout(); this.reserveTitle(layout); then optionally
this.reserveLegend(layout, legendItems, this.options.legend); — each carves space out of
layout.area (the drawable rect: { x, y, width, height }).
- Accessors:
const getKey = resolveAccessor<TData, string>(key); (accepts a field name or a
function). resolveValueFormat(this.options.format) for tooltip/label number formatting.
- Colors:
this.resolveSeriesColors(items.map(i => ({ id, color }))) once, then
this.getSeriesColor(id) per series (respects an explicit color and the config palette).
- Reconcile existing elements against new data with
arrayJoin (from @ripl/utilities):
const { left: entries, inner: updates, right: exits } =
arrayJoin(data, this.groups, (item, group) => group.id === getKey(item));
exits.forEach(group => group.destroy());
left = entering (new), inner = [data, element] pairs to update, right = leaving. Persist the
combined [...entryGroups, ...updates.map(([, g]) => g)] back onto this.groups.
- Create entering elements at their start state (e.g.
radius: 0, opacity: 0) and stash the
target state on element.data. Update existing elements by setting element.data to the new
target (and applying non-tweenable props like renderer/// directly —
see pitfalls).
Path & point animation helpers (@ripl/core)
interpolatePath(points) — reveals a path from its start to end (draw-on). Use for lines/arcs
growing out of a point (area entry, arc-diagram ripple). Pass it as a state.points value.
interpolatePoints(from, to, { resolveKeys }) — morphs point arrays with key reconciliation so a
curved series stays curved across add/remove (resolveKeys: () => correspondence(prevKeys, newKeys)
from core/morph.ts, guarded by keysDiffer).
interpolateWaypoint(points) — a point sampler along a polyline.
- Closed curved fills (area/band) must curve only the line-following edge, not the whole loop —
use
areaBandRenderer / anchoredAreaRenderer from core/fill.ts (built on
resolvePolylineRenderer). A plain curve over [top, reversed bottom] gaps away from the line.
Interaction
- Tooltip + hover highlight:
applyHoverHighlight(element, { renderer, duration, ease, tooltip, anchor, content, highlight, restore, onEnter, onLeave, onClick }). anchor() returns the tooltip
point; highlight/restore are partial states toggled on hover. Highlight the property that
actually carries the color — { fill } for filled shapes, { stroke } for stroked shapes (stroked
arcs, lines, links). Emit typed events from onEnter/onLeave/onClick via this.emit('cellenter', payload).
- Legend hover-highlight: call
this.registerHighlightGroups(this.groups) so hovering a legend
item dims the other series (wired through reserveLegend).
- Hit testing is path-based (
Shape2D.intersectsWith → isPointInStroke || isPointInPath), so a
thick stroked arc/line hit-tests precisely on its band — no need for a filled shape.
Labels
Use createSegmentLabel({ id, x, y, content, font }) (fades via text.data = { opacity: 1 }) or
createDataLabel(...) from core/labels.ts for consistent styling. Reconcile labels on update
(reposition + fade) alongside their shapes; never leave stale labels.
Element toolkit (@ripl/core)
createCircle (cx,cy,radius), createArc (cx,cy,radius,innerRadius?,startAngle,endAngle,padAngle?,padWidth?,borderRadius?),
createLine (x1,y1,x2,y2), createPolyline (points, renderer), createText, createGroup,
createRect. Shapes take autoFill/autoStroke toggles.
- Rounded radial/progress bars: prefer a filled annular arc (
innerRadius + radius at the
band edges) with a scalar borderRadius — it rounds all four corners and clamps itself to half the
band thickness, so an over-rounded segment becomes a capsule rather than self-intersecting. The
stroked open arc (no innerRadius, on the band centerline, lineWidth = band thickness,
lineCap: 'round') is still the right tool for a genuinely stroke-only bar — a track/value pair
where you want caps without a fill, or where the band is a stroke you also dash. An open arc's
borderRadius rounds only its two outer corners and keeps a sharp center point. It also flips the
open arc's topology: 0 emits a bare arc (a fill closes it with a chord — a circular segment),
any non-zero value closes it through the centre (a wedge), so filled area, isPointInPath hit
region and the stroke (which gains two radial spokes) all jump at that boundary. Never animate an
open arc's borderRadius up from 0 — use an annular arc if the rounding must animate.
- Arc padding:
padAngle (radians) opens a wedge that widens with radius; padWidth (pixels)
insets each radius by asin(padWidth / 2r) so the gap keeps a constant width — parallel facing
edges on an annular sector, a single outer-radius trim on an open arc. padWidth wins wherever it
is provided (padWidth: 0 means no padding, not "use padAngle", so animating it up from 0 is
continuous), and padding is applied before corner rounding.
- Polyline curves:
renderer is a named type ('linear' | 'spline' | 'cardinal' | 'monotoneX' | …)
or a custom (context, path, points) => void. Resolve a named one with resolvePolylineRenderer.
matches/closest are on every Element (via the Queryable contract), not just Group:
el.matches('rect.active'), el.closest('#chart'). query/queryAll remain group/free-function.
Note: the option does split on whitespace — pass for two classes.
Custom layouts
- Circle packing:
packSiblings(circles) (front-chain, tight, centered on the origin) +
enclosingCircle(circles) (Welzl minimal enclosing circle) from core/pack.ts. Draw a visible
containing circle at the fit radius.
- Force layout:
simulateForce(nodes, links, options) from core/force.ts — deterministic,
seeds only zero positions. Persist settled positions per id and seed the next run from them so
reweights relax from the current layout (glide, not reshuffle). Spring nodes out from a root using
BFS depth for the stagger and easeOutBack.
Authoring the demo (apps/website/src/charts/<name>.md)
VitePress page with a live example. Copy an existing page (e.g. radial-bar.md). Essentials:
<ripl-example @context-changed="contextChanged">
<template #footer>
<RiplControlGroup>
<RiplButton @click="randomize">Randomize</RiplButton>
</RiplControlGroup>
<RiplField label="Points">
<RiplInputRange v-model="points" :min="3" :max="12" :step="1" />
</RiplField>
</template>
<template #config>
<RiplChartConfig :config="config" extra-title="<Name>">
<RiplField label="Rounded" inline><RiplSwitch v-model="rounded" /></RiplField>
</RiplChartConfig>
</template>
</ripl-example>
<script setup lang="ts">
import {
useRiplChart,
} from '../../.vitepress/compositions/example';
import {
buildCommonOptions,
useChartConfig,
} from '../../.vitepress/compositions/use-chart-config';
import {
create<Name>Chart,
} from '@ripl/charts';
import {
ref,
watch,
} from 'vue';
const config = useChartConfig({ features: { title: true, legend: true, animation: true }, title: '…' });
const { contextChanged, chart } = useRiplChart(context =>
create<Name>Chart(context, { data, /* … */, ...buildCommonOptions(config) }));
function apply() { chart.value?.update({ /* … */, ...buildCommonOptions(config) }); }
watch(config, apply, { deep: true });
</script>
Demo data rules (so transitions actually demonstrate):
- Distinct series — don't let multiple series read identical fields (they'd overlap). Give each
series its own accessors / regions.
- Real add/remove — keep a stable array and
push/pop/slice one item; don't regenerate the
whole dataset (that makes add/remove look like a randomize and jumps every element).
- Randomize re-rolls values but keeps counts and identities.
Then document Usage, Data Format, and an Options bullet list (one line per option, with defaults),
and add the chart to the "Available Charts" table in getting-started.md.
Imports follow the repo grouping convention (ripl/import-export-spacing, see AGENTS.md):
each braced import on its own multi-line group with a trailing comma and alphabetised members;
side-effect and default imports group by kind; groups of differing kinds are blank-separated. Doc-page
<script setup> blocks are linted, so keep this format or yarn lint will fail.
Gallery snapshot
Add a create<Name>Chart(mount('<name>'), { animation: false, … }) block to
packages/charts/test/visual/gallery.ts (exercise notable options), and the id to chart-ids.ts.
animation: false renders the final frame for a stable screenshot.
Verifying in this sandbox
yarn install/vitest/the app build can't run here (partial node_modules; only typescript). Use:
- Type-check with the standalone compiler over the source + tests:
node node_modules/typescript/bin/tsc -p <scratchpad>/tc.json (maps @ripl/* → source,
types: [], skipLibCheck). Keep a tc-tests.json / tc-gallery.json including test files plus a
vitest-shim.d.ts. Must be clean after every change.
- Pure logic (packers, force sim, geometry) can be compiled with
tsc <file> --outDir … --module commonjs --ignoreDeprecations 6.0 and exercised with node for a real behavioral check.
- Runtime chart render needs jsdom + a mock canvas (
@ripl/test-utils mockCanvasContext,
polyfillPath2D) — run via vitest locally. Charts that can't init headlessly (svg/webgpu/terminal)
document the limitation and rely on the shared context-free logic.
- Locally (user runs):
yarn test (vitest unit), Playwright visual snapshots, and
yarn workspace @ripl/website prepare-playground + yarn workspace @ripl/website start to eyeball demos.
Common pitfalls
- Non-tweenable props (
renderer, lineCap, lineDash, stroke, text content) must be set
directly on the element, not put in the transition state — the tween would snap them at t=0.5.
- Curved area fills gap against the line unless you use the
core/fill.ts band renderers.
Arc.borderRadius is a scalar, not the Rect family's number | [tl, tr, br, bl] | 'full'; it
clamps to half the band thickness, so passing a huge number gives a capsule, not an error.
- Reweight/reflow should animate from current positions — persist layout state (force positions,
morph keys) between renders.
- Call
this.init() last in the constructor; it triggers the first render.
Commit hygiene
Commit per logical change with the required trailers:
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nbx2eSMiLFPNQfd11euXC7
Never put the internal model identifier in commits, code, or docs.