Skip to content

Comparable Vertical Bar ​

Comparison

Did it get better or worse, category by category? Each column overlays two full-width bars - the reference value behind, the current value in front - with a change arrow + label reading the shift at a glance.

Example
  • 2019 (based, pale tint, behind)
  • 2024 (compared, solid, in front)
canvas · responsive
Go deeper: Insights guide·DevTools guide

The chart above is the same engine in every framework - only the integration code below differs.

Heavy data on WebGPU Experimental ​

ComparableVerticalBarChart has an opt-in renderer="webgpu" that paints the two sub-bars per column as GPU-instanced rectangles while axes, labels, and the delta indicator stay on the SVG layer. It is capability-gated: on a browser without WebGPU it downgrades to canvas automatically, and getContext().renderer reports whichever actually painted.

⚗️ Experimental - not yet stable. WebGPU rendering is an opt-in preview. It needs a WebGPU-capable browser (Chrome / Edge, or Safari 26+); everywhere else it falls back to canvas automatically. Axes, labels and tooltips stay on the SVG layer - only the data marks are painted on the GPU.
Heavy-data demo · ~60 columns… detecting

Play through the years ​

Give every category a date and flip on timeline: the column chart becomes a year-by-year story with its own play button and scrubber, snapshotting one period's based/compared pair at a time. Off by default - nothing changes until a chart opts in.

Press the play button under the chart: it steps through the years, one snapshot at a time. Drag the scrubber to jump to any year.

tsx
const ref = useRef<ComparableVerticalBarChartHandle>(null);

<ComparableVerticalBarChart ref={ref} {...props} timeline={{ speedMs: 1000, loop: true }} />;
// ref.current?.timeline() -> play() / pause() / seek(year) / seekIndex(i) / stepForward()
vue
<ComparableVerticalBarChart :options="{ ...props, timeline: { speedMs: 1000, loop: true } }" />
svelte
<div use:comparableVerticalBarChart={{ ...props, timeline: { speedMs: 1000, loop: true } }}></div>
ts
applyComparableVerticalBarChartProps(this.c.nativeElement, { ...props, timeline: { speedMs: 1000, loop: true } });
html
<michi-vz-comparable-vertical-bar-chart id="c"></michi-vz-comparable-vertical-bar-chart>
<script>
  const el = document.getElementById("c");
  el.timeline = { speedMs: 1000, loop: true };
  // el.getTimeline() -> play() / pause() / seek(year) / seekIndex(i)
</script>
  • speedMs sets the pace, loop wraps around, autoplay: true starts on mount, showControl: false hides the built-in bar.
  • Values glide between periods by default (interpolate); tune the motion with tweenMs and easing, or set interpolate: false for hard cuts. Reduced motion always gets the hard cut.
  • The headless controller is always available: chart.timeline() exposes play() / pause() / toggle() / seek(period) / seekIndex(i) / stepForward() / stepBack(), plus onStep and formatPeriod in the config for custom UI.
  • seek(period) looks for the period first, comparing as text, so seek(2021) and seek("2021") both land on 2021 whether your dates are numbers or strings. A number counts as a position (0 = first) only when no period matches, and a string that matches none does nothing. seekIndex(i) always goes by position, as the built-in scrubber does.
  • A filter (top-N, sorting) still applies inside each period, so a top 5 per year race works out of the box.
  • Columns without a date stay visible in every period.

Corner radius ​

Every column is drawn with 5px corners by default. On a small screen with many categories the columns get narrow - a few px each - and a 5px radius turns them into pills. barRadius sets the radius in px: 0 for square corners, 2-4 for square-ish ones.

barRadius: 5 (default)
barRadius: 2
barRadius: 0

Same chart, same data; only barRadius changes. Switch the renderer: svg and canvas round the corners identically.

tsx
<ComparableVerticalBarChart {...props} barRadius={0} />
vue
<ComparableVerticalBarChart :options="{ ...props, barRadius: 0 }" />
svelte
<div use:comparableVerticalBarChart={{ ...props, barRadius: 0 }}></div>
ts
applyComparableVerticalBarChartProps(this.c.nativeElement, { ...props, barRadius: 0 });
html
<michi-vz-comparable-vertical-bar-chart bar-radius="0"></michi-vz-comparable-vertical-bar-chart>
  • The drawn radius never exceeds half the bar's width or height, in svg and canvas alike.
  • Leaving barRadius out keeps the historical 5px look. A negative value draws square corners.
  • The experimental webgpu renderer draws square corners whatever the value.

Usage ​

tsx
import { ComparableVerticalBarChart } from "@michi-vz/react";

export default () => <ComparableVerticalBarChart {...props} />; // props = the chart options
vue
<script setup>
import { ComparableVerticalBarChart } from "@michi-vz/vue";
</script>

<template>
  <ComparableVerticalBarChart :options="props" />
</template>
svelte
<script>
  import { comparableVerticalBarChart } from "@michi-vz/svelte";
</script>

<div use:comparableVerticalBarChart={props}></div>
ts
// main.ts - register the elements once
import "@michi-vz/angular";
import { applyComparableVerticalBarChartProps } from "@michi-vz/angular";

// component (uses CUSTOM_ELEMENTS_SCHEMA)
// template: <michi-vz-comparable-vertical-bar-chart #c></michi-vz-comparable-vertical-bar-chart>
applyComparableVerticalBarChartProps(this.c.nativeElement, props);
html
<script type="module" src="https://cdn.jsdelivr.net/npm/@michi-vz/wc/dist/michi-vz-wc.bundle.js"></script>

<michi-vz-comparable-vertical-bar-chart id="c"></michi-vz-comparable-vertical-bar-chart>
<script>
  Object.assign(document.getElementById("c"), props); // dataSet, title, …
</script>
ts
import { mountComparableVerticalBarChart } from "@michi-vz/core";

const chart = mountComparableVerticalBarChart(el, props);
chart.update(next);
chart.getContext(); // renderer-agnostic, LLM-ready
chart.destroy();

API ​

Props are typed as ComparableVerticalBarChartProps in @michi-vz/core. Shared across all charts: width, height, margin, colors / colorsMapping, renderer ("svg", "canvas", or experimental "webgpu"), highlightItems, disabledItems, and the on* callbacks. onChartDataProcessed / getContext() return the renderer-agnostic ChartContext.

Behaviour notes ​

Two full-bandwidth sub-bars per column, FIXED z-order ​

Each category draws valueBased (the reference/prior value, rear, hatch-eligible) and valueCompared (the current/latest value, front, solid), both at the same x and the full column bandwidth - unlike ComparableHorizontalBarChart's optional "grouped" half-band layout, this chart is overlay-only. The z-order is fixed (not width-dependent): valueBased is always painted behind, valueCompared always in front - ported from legacy sdg-trade BarchartVertical's BarCompare/Bar paint order. colorsBasedMapping gives the value-based sub-bar its own colour per label: pair an opaque light tint with valueBasedOpacity: 1 for the crispest before/after contrast in both themes. valueBasedOpacity / valueComparedOpacity set their fill opacity (historical look: 0.45 / 0.9). A sub-bar whose resolved fill is transparent is skipped (consumers hide one half via CSS). minBarHeight (default 5) floors a non-zero bar so near-zero values stay visible.

Delta indicator - and this chart's context reflects it ​

deltaIndicator: { show: true } draws a change arrow + formatted label ABOVE the taller of the two sub-bars (legacy translate(bandwidth/3, -32) placement). Unlike ComparableHorizontalBarChart (whose indicator is presentational-only), this chart's getContext() reflects the indicator: each series[] row carries deltaDirection / deltaColor / deltaLabel when active, stats.improved / stats.worsened counts the good/bad movers, and the a11y table gains a fifth "Change" column. positiveIsGood / positiveIsUp pick the color/direction mapping (see the DeltaIndicatorConfig JSDoc for the full decision table); formatter(diff, datum) takes full control of the label text. Omitted, or { show: false }, is a provable no-op - zero geometry, zero .mv-delta DOM, zero extra context fields.

patternsMapping - hatch / image fills ​

patternsMapping: Record<label, imageSrc> fills the value-based sub-bar with a tiled image instead of a flat colour. createHatchPattern({ color, angle?, spacing?, strokeWidth? }) (exported from @michi-vz/core and @michi-vz/react) returns a diagonal-hatch SVG data-URI for the common case. The SVG renderer references it from a real <defs><pattern> def; the canvas renderer tiles it via ctx.createPattern and re-renders once the image loads.

Value axis (y) ​

yAxisDomain: [min, max] fixes the value-axis range. symmetricYDomain forces [-M, M] (M = the largest absolute value) so zero sits centred. showZeroLineForYAxis draws a solid line at y=0 (diverging charts); showGrid toggles horizontal gridlines (default off). yAxisFormat formats the tick labels; ticks sets the approximate tick count.

Category axis (x) ​

Column labels fit horizontally when there's room; otherwise they tilt -45° (or thin to a readable subset with xAxisMode: "horizontal"), the same chooseAxisMode layout VerticalStackBarChart uses. xAxisLabelPadding raises the bar before a label rotates; xAxisFormat formats each label; hideTickLabels hides them entirely; maxBarWidth caps each column's thickness (and centres the plot) so a handful of categories don't balloon into giant blocks.

Tooltip ​

tooltipFormatter(datum, dataSet, type) receives the hovered column, all columns, and the hovered sub-bar type ("based" | "compared"). It returns an HTML string; the React wrapper additionally accepts a React node (converted to static HTML). The built-in tooltip is edge-aware (flips near the right/bottom edges).

Loading / no-data + interaction ​

isLoading and isNodata drive the overlay (React: isLoadingComponent / isNodataComponent). Hovering highlights a column (others dim) and mouseleave clears it; the bars are rounded (radius 5 by default, set by barRadius) with a 1px border.

Consumer colour authorities: the context carries legendData ({ label, color, dataLabelSafe }) so a CSS-injection colour system can key per-label rules; onChartDataProcessed is only emitted when the context changes (re-emitting an unchanged context every render can loop a consumer that dispatches on each call).