Skip to content

Examples

Complete scenes assembled from the components in this section.

Bar chart

This chart is built entirely from the built-in components, with no @ripl/charts and no imperative createScene or createRenderer. Scales come from @ripl/core, and the rest is template. For a bar chart that already has axes, legends and tooltips, reach for <ripl-bar-chart> instead.

Click a bar to select it, hover to highlight, and use the controls to drive the enter, update and leave transitions.

Layout and scales

The chart needs its own size, and <ripl-context> has none of its own: it fills whatever element you give it. Capture the context from @ready and re-read its dimensions on @resize. The resize event carries no payload, so you need the context itself:

ts
function syncSize() {
    size.width = context.value?.width ?? 0;
    size.height = context.value?.height ?? 0;
}

Ripl's scales do the rest. A band scale spaces the categories and reports a bandwidth for the bar width, and a continuous scale maps values to pixels:

ts
const categoryScale = scaleBand(months, [plot.x, plot.x + plot.width], {
    innerPadding: 0.28,
    outerPadding: 0.14,
});

const valueScale = scaleContinuous([0, max], [plot.y + plot.height, plot.y], {
    padToTicks: 5,
});

The value scale's range runs bottom to top, because pixel y grows downward. That also puts valueScale(0) on the axis, which gives you the baseline with no special-casing. padToTicks expands the domain to a round tick boundary, so the gridlines land on sensible numbers.

Each bar is then four numbers:

ts
const bar = {
    x: categoryScale(month),
    width: categoryScale.bandwidth,
    y: valueScale(value),
    height: baseline - valueScale(value),
};

Deriving the whole layout in a computed and iterating it with one v-for per visual layer keeps the geometry out of the template. Note bandwidth is a property, not a method.

Guard the plot on plot.width > 0. The surface genuinely has no size until its host element lands in the document, and the first real measurement arrives with the first resize.

Transitions

Bars grow out of the baseline, which is the enter phase's state: the state an element animates from.

ts
const barEnter = computed(() => (element, index, length) => ({
    duration: 700,
    delay: (index / length) * 400,
    ease: easeOutCubic,
    state: {
        y: baseline.value,
        height: 0,
    },
}));

Expressing the phase as a factory produces the staggered sweep. Each element gets its index and the total, so the delay fans out across the set. Leaving reverses it, collapsing the bars back to the baseline and fading them before they are destroyed.

The phases are computed so baseline stays current after a resize. They are also plain reactive props, so the Animate toggle switches them off by binding undefined, after which unanimated changes apply instantly.

The value and category labels sit in their own <ripl-transition> fading on { opacity: 0 }. A scope applies its phases to every descendant, and height means nothing to a text component, so the labels need a phase of their own. The gridlines get a third scope with only an update phase, so a tick that survives a domain change slides rather than jumping.

See Transitions for the full phase API.

Interaction

Selection and hover are ordinary Vue listeners on the rect:

vue
<template>
    <ripl-rect
        v-for="bar in bars"
        :key="bar.key"
        :fill="bar.fill"
        @click="toggle(bar.key)"
        @mouseenter="hovered = bar.key"
        @mouseleave="hovered = undefined"
    />
</template>

Both feed back into bar.fill, so the highlight is the same reactive prop the rest of the chart uses rather than a separate code path, and it tweens through the update phase without any extra work.

Only the events you bind are subscribed, which matters here: binding a pointer listener is what makes an element a hit-test target. The text labels bind nothing, so they never steal a click from the bar behind them. See Events for the full list and their payloads.