Skip to content

Fan Chart

Trends Forecast

"What will revenue be next quarter?" The honest answer is never a single number - it is a range, and the range is the whole point. Hand an exec one number and you are guessing; hand them this fan and you are telling the truth about the risk. The solid line is what already happened, the dashed line is the single most-likely path, and the shaded bands show how sure the forecast is - widening as they reach into the future, because the further ahead you look, the less anyone can know.

Example
canvas · responsive
Go deeper: Insights guide·DevTools guide

Heavy data on WebGPU Experimental

FanChart's opt-in renderer="webgpu" paints its line and band marks on the GPU while axes, labels and tooltips 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 · ~1,500 points… detecting

How to read it

  • Solid line - history. The actuals you already have.
  • Dashed line - the most-likely path (the forecast median): one best guess, never the whole story.
  • Nested bands - confidence. Inner to outer = 50% / 80% / 95%. The real value should land inside the 95% band about 19 times out of 20. You plan against the band, not the line.
  • Why it fans out. Next month is fairly knowable; a year out is not. Uncertainty compounds with distance, so the bands widen.

Read your worst case off the bottom of the outer band and your best case off the top. The fan is your base / upside / downside in a single picture - no separate scenario tab needed.

The maths, in plain terms

You do not need the equations to use the chart, but here is what is under the hood - and why you can trust it:

  • The median comes from Holt-Winters exponential smoothing. It tracks two moving quantities, the current level and the trend (slope), and rolls them forward; if the series has a repeating season, it tracks that too. (Prefer a straight line? method: "linear" fits an ordinary least-squares regression instead.)

    ℓₜ = α·yₜ + (1−α)(ℓₜ₋₁ + bₜ₋₁) · bₜ = β(ℓₜ − ℓₜ₋₁) + (1−β)bₜ₋₁ · ŷₜ₊ₕ = ℓₜ + h·bₜ

  • The bands come from the model's own past errors. It measures how far its fitted values missed (the residual spread σ) and widens the interval as ŷ ± z·σ·√h - z = 1.96 for 95%, and the √h is exactly why the fan opens with the horizon h.
  • Should you trust it? A backtest hides the last few real points, re-forecasts them, and reports the error (MAPE, RMSE). You get an honesty score before you bet on the number, not after.

All of it runs in the browser - no data-science backend, no server round-trip. (Power BI, by contrast, only forecasts on a line chart and stops where real modelling begins.)

Build the data in one call with forecastFan() from @michi-vz/insights/forecast, or hand it series (history + certainty:false median) and nested bands.

Reveal animation

The chart wipes in from left to right on mount, revealing its marks in sequence before settling into place. Off by default - a chart opts in with the progressiveDraw prop.

The marks wipe in from left to right; axes and titles stay put. With reduced motion enabled, the chart renders fully drawn instantly.

progressiveDraw: true enables the defaults (1200 ms, easeInOutCubic). A config object tunes it:

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

<FanChart
  ref={ref}
  {...props}
  progressiveDraw={{ durationMs: 2000 }}
/>;
// ref.current?.replay() re-runs the reveal on demand
vue
<FanChart :options="{ ...props, progressiveDraw: { durationMs: 2000 } }" />
svelte
<div use:fanChart={{ ...props, progressiveDraw: { durationMs: 2000 } }}></div>
ts
applyFanChartProps(this.c.nativeElement, {
  ...props,
  progressiveDraw: { durationMs: 2000 },
});
html
<michi-vz-fan-chart id="c"></michi-vz-fan-chart>
<script>
  const el = document.getElementById("c");
  el.progressiveDraw = { durationMs: 2000 };
  // el.replay() re-runs the reveal
</script>
  • durationMs and easing ("linear", "easeOutQuad", "easeInOutCubic", or a custom (t) => t function) shape the sweep.
  • autoplay: false renders the chart fully drawn; call replay() (React ref handle, web-component method, or the core instance) to run the reveal on demand. replayOnUpdate: true re-runs it on every data change.
  • Respects prefers-reduced-motion: the chart renders fully drawn instantly.

Play through the years

The data already spans years, so there is nothing to tag. Flip on timeline and the chart's own play button and scrubber step through those years: at each step the history line, forecast median, and confidence bands draw only up to the active year, and playing forward smoothly extends them further as the sweep advances. Scrub backward and they retract to match. Hover only ever inspects what has actually been drawn. 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<FanChartHandle>(null);

<FanChart ref={ref} {...props} timeline={{ speedMs: 1000, loop: true }} />;
// ref.current?.timeline() -> play() / pause() / seek(year) / stepForward()
vue
<FanChart :options="{ ...props, timeline: { speedMs: 1000, loop: true } }" />
svelte
<div use:fanChart={{ ...props, timeline: { speedMs: 1000, loop: true } }}></div>
ts
applyFanChartProps(this.c.nativeElement, { ...props, timeline: { speedMs: 1000, loop: true } });
html
<michi-vz-fan-chart id="c"></michi-vz-fan-chart>
<script>
  const el = document.getElementById("c");
  el.timeline = { speedMs: 1000, loop: true };
  // el.getTimeline() -> play() / pause() / seek(year)
</script>
  • speedMs sets the pace, loop wraps around, autoplay: true starts on mount, showControl: false hides the built-in bar.
  • The headless controller is always available: chart.timeline() exposes play() / pause() / toggle() / seek(period) / stepForward() / stepBack(), plus onStep and formatPeriod in the config for custom UI.
  • Values glide between years by default (interpolate); set interpolate: false for hard jump-cuts. Reduced motion always jump-cuts.
  • timeline wins over progressiveDraw when both are set on the same chart.

Usage

ts
import { mountFanChart } from "@michi-vz/core";
import { forecastFan } from "@michi-vz/insights/forecast";

// history = DataPoint[] of actuals; build the fan (median + 50/80/95% bands)
const item = forecastFan(history, { method: "holt-winters", horizon: 4, levels: [0.5, 0.8], level: 0.95 }, "Revenue");
const chart = mountFanChart(el, { dataSet: [item], xAxisDataType: "date_annual" });
ts
import { mountFanChart } from "@michi-vz/core";

const chart = mountFanChart(el, props); // props.dataSet = FanDataItem[]
chart.update(next);
chart.getContext(); // renderer-agnostic, LLM-ready
chart.destroy();
html
<script type="module" src="https://cdn.jsdelivr.net/npm/@michi-vz/wc/dist/michi-vz-wc.bundle.js"></script>

<michi-vz-fan-chart id="c"></michi-vz-fan-chart>
<script>
  Object.assign(document.getElementById("c"), props); // dataSet (series + bands), title, …
</script>

Data shape

A FanDataItem is a familiar line series plus nested bands:

ts
interface FanDataItem {
  label: string;
  color?: string;
  series: DataPoint[];   // history (certainty:true) then forecast median (certainty:false → dashed)
  bands: { level: number; series: RangeDataPoint[] }[]; // drawn widest-first, graduated opacity
}

API

Props are typed as FanChartProps in @michi-vz/core and mirror LineChartProps (width, height, margin, colors / colorsMapping, renderer, highlightItems, disabledItems, fillOpacity, and the on* callbacks). onChartDataProcessed / getContext() return the renderer-agnostic ChartContext.