Line Chart
Trends
"How did this move over time, and where can't I trust the data?" One series or fifty, with missing periods rendered as dashes so a reporting gap never reads as a real dip - plus an opt-in canvas renderer (LTTB-decimated for big data) when the points run into the thousands.
The chart above is the same engine in every framework - only the integration code below differs.
When to reach for it
- Tracking a KPI's trajectory. Revenue by month, users by week, latency by hour - anywhere "how did it move?" is the question, the line answers it faster than any table.
- Comparing a handful of series on one scale, with reporting gaps kept honest: a missing period renders as a dash, never a fake dip an exec might act on.
- Big data included. Thousands of points stay smooth with the opt-in canvas renderer (LTTB decimation). But if the story is a prediction rather than history, the Fan chart shows the range, not just the line.
Heavy data on WebGPU Experimental
The line chart's opt-in renderer="webgpu" paints its line/marker geometry on the GPU while axes, labels and tooltips stay on the SVG layer; it is capability-gated with automatic canvas fallback when WebGPU is unavailable.
Gap detection
A missing period renders as a dashed segment - set it per point with certainty: false, or let detectGaps derive it. Here one series skips a reporting period:
Continuous timeline & no-data ticks
By default the x-axis is honest about time in two ways: the first and last period are never dropped (even when they land on an "unround" month d3 would otherwise skip), and crowded labels tilt to -45° then thin to ~5 - always keeping both ends.
Opt into fillPeriodTicks and the axis draws a tick for every period in range, not just the ones with data. Months with no value render faded; hover one to explain the gap. Flip the toggle:
Toggle it on: the empty months appear as faded ticks. Hover one to see its "no data" tooltip. First and last periods are always kept, either way.
Customize it: noDataTickTooltip(epochMs) returns the tooltip text (plain string or sanitized HTML), and noDataTickColor (or the --michi-vz-tick-nodata CSS var) sets the faded colour.
<LineChart
{...props}
xAxisDataType="date_monthly"
fillPeriodTicks
noDataTickTooltip={() => "No data reported for this month"}
noDataTickColor="#c0392b"
/><LineChart :options="{ ...props, fillPeriodTicks: true, noDataTickTooltip: () => 'No data' }" /><div use:lineChart={{ ...props, fillPeriodTicks: true, noDataTickTooltip: () => 'No data' }}></div>applyLineChartProps(this.c.nativeElement, {
...props,
fillPeriodTicks: true,
noDataTickTooltip: () => "No data",
});<michi-vz-line-chart id="c" fill-period-ticks no-data-tick-color="#c0392b"></michi-vz-line-chart>
<script>
document.getElementById("c").noDataTickTooltip = () => "No data reported";
</script>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 line draws only up to the active year, and playing forward smoothly extends it further as the sweep advances. Scrub backward and the line retracts to match. Hover only ever inspects what has actually been drawn. This is the interactive, year-by-year version: scrub or step through the real years in the data. For a one-shot cinematic sweep instead, see Progressive draw and tip labels below. 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.
const ref = useRef<LineChartHandle>(null);
<LineChart ref={ref} {...props} timeline={{ speedMs: 1000, loop: true }} />;
// ref.current?.timeline() -> play() / pause() / seek(year) / stepForward()<LineChart :options="{ ...props, timeline: { speedMs: 1000, loop: true } }" /><div use:lineChart={{ ...props, timeline: { speedMs: 1000, loop: true } }}></div>applyLineChartProps(this.c.nativeElement, { ...props, timeline: { speedMs: 1000, loop: true } });<michi-vz-line-chart id="c"></michi-vz-line-chart>
<script>
const el = document.getElementById("c");
el.timeline = { speedMs: 1000, loop: true };
// el.getTimeline() -> play() / pause() / seek(year)
</script>speedMssets the pace,loopwraps around,autoplay: truestarts on mount,showControl: falsehides the built-in bar.- The headless controller is always available:
chart.timeline()exposesplay() / pause() / toggle() / seek(period) / stepForward() / stepBack(), plusonStepandformatPeriodin the config for custom UI. - Values glide between years by default (
interpolate); setinterpolate: falsefor hard jump-cuts. Reduced motion always jump-cuts. timelinewins overprogressiveDrawwhen both are set on the same chart.tipLabel: truein thetimelineconfig keeps a label riding the line's growing tip while it plays - the same tip label asprogressiveDraw, just driven by the sweep instead of the one-shot reveal.
Progressive draw and tip labels
Let the chart tell its story in order: with progressiveDraw, every line draws itself from the first year to the last, and an optional tip label rides each line's end showing the series name and its current value, settling next to the finished line. This is a one-shot cinematic sweep on mount; for interactive year-by-year stepping with a scrubber, see Play through the years above. Off by default - nothing changes until a chart opts in.
Each line grows from the first year to the last; the label rides the tip and settles at the line's end. With reduced motion enabled, the chart renders fully drawn instantly.
progressiveDraw: true enables the defaults (1200 ms, easeInOutCubic). A config object tunes it:
const ref = useRef<LineChartHandle>(null);
<LineChart
ref={ref}
{...props}
progressiveDraw={{ durationMs: 2000, tipLabel: true }}
/>;
// ref.current?.replay() re-runs the reveal on demand<LineChart :options="{ ...props, progressiveDraw: { durationMs: 2000, tipLabel: true } }" /><div use:lineChart={{ ...props, progressiveDraw: { durationMs: 2000, tipLabel: true } }}></div>applyLineChartProps(this.c.nativeElement, {
...props,
progressiveDraw: { durationMs: 2000, tipLabel: true },
});<michi-vz-line-chart id="c"></michi-vz-line-chart>
<script>
const el = document.getElementById("c");
el.progressiveDraw = { durationMs: 2000, tipLabel: true };
// el.replay() re-runs the reveal
</script>durationMsandeasing("linear", "easeOutQuad", "easeInOutCubic", or a custom(t) => tfunction) shape the sweep.tipLabel: truedraws the moving label;{ content: "name" | "value" | "both", format }narrows or rewrites its text. The value shown is always a real data point, never an interpolated number.autoplay: falserenders the chart fully drawn; callreplay()(React ref handle, web-component method, or the core instance) to run the reveal on demand.replayOnUpdate: truere-runs it on every data change.- Respects
prefers-reduced-motion: the chart renders fully drawn instantly. - While the reveal runs, the crosshair and tooltips stop at the reveal edge, so hovering never inspects data that is not drawn yet. The webgpu renderer skips the animation.
Usage
import { LineChart } from "@michi-vz/react";
export default () => <LineChart {...props} />; // props = the chart options<script setup>
import { LineChart } from "@michi-vz/vue";
</script>
<template>
<LineChart :options="props" />
</template><script>
import { lineChart } from "@michi-vz/svelte";
</script>
<div use:lineChart={props}></div>// main.ts - register the elements once
import "@michi-vz/angular";
import { applyLineChartProps } from "@michi-vz/angular";
// component (uses CUSTOM_ELEMENTS_SCHEMA)
// template: <michi-vz-line-chart #c></michi-vz-line-chart>
applyLineChartProps(this.c.nativeElement, props);<script type="module" src="https://cdn.jsdelivr.net/npm/@michi-vz/wc/dist/michi-vz-wc.bundle.js"></script>
<michi-vz-line-chart id="c"></michi-vz-line-chart>
<script>
Object.assign(document.getElementById("c"), props); // dataSet/series, title, …
</script>import { mountLineChart } from "@michi-vz/core";
const chart = mountLineChart(el, props);
chart.update(next);
chart.getContext(); // renderer-agnostic, LLM-ready
chart.destroy();Loading and no-data states
Pass isLoading while your data fetch is in-flight; the engine shows a .mv-loading overlay and sets data-mv-state="loading" on the host.
When the fetch resolves to nothing, isNodata takes over. The default predicate treats an empty dataSet (or every series being empty) as no-data - you can override it with a boolean or a function:
// boolean shortcut
<LineChart isLoading={query.isPending} isNodata={query.data?.length === 0} noDataLabel="No data available" />
// function predicate
<LineChart isNodata={(ds) => ds.every(s => s.data.length === 0)} />In React the isNodataComponent and isLoadingComponent props accept any ReactNode. The engine stays mounted (so onChartDataProcessed still fires); your node is rendered as an overlay on top of the chart host, and suppressDefaultOverlay is set automatically so the built-in .mv-nodata message is hidden:
<LineChart
isLoading={isPending}
isLoadingComponent={<Spinner />}
isNodata={isEmpty}
isNodataComponent={<p className="no-data">No results for this selection.</p>}
/>For vanilla JS / other frameworks the built-in overlay is shown by default. Suppress it and render your own node alongside the chart host:
const chart = mountLineChart(el, { ...props, suppressDefaultOverlay: true });
// render your overlay next to el when data-mv-state === "nodata"Axis configuration
| Prop | Default | Effect |
|---|---|---|
yTicks | 10 | Number of y-axis tick intervals |
showGridLines | true | Horizontal (y) dashed gridlines |
showVerticalGridLines | false | Vertical (x) dashed gridlines |
highlightZeroLine | true | Draws y = 0 as a solid line |
The zero line colour defaults to the grid colour (--michi-vz-grid). Override it independently:
.my-chart-host {
--michi-vz-zero-line: #e53935; /* solid red zero line */
--michi-vz-grid: #e0e0e0; /* dashed gridlines stay grey */
}<LineChart
yTicks={5}
showGridLines={true}
showVerticalGridLines={false}
highlightZeroLine={true}
/>Font family
Pass fontFamily to keep SVG labels and canvas text in sync. The engine writes --michi-vz-font-family on the chart host; both the SVG <text> elements and the canvas ctx.font path read that computed style, so no font embedding is required - the family just needs to be loaded by the page already.
<LineChart fontFamily="Inter, sans-serif" />mountLineChart(el, { ...props, fontFamily: "Inter, sans-serif" });Colours and legend data
Line colours follow the data-label-safe CSS contract. Each series element carries a data-label-safe attribute (the sanitized series label); you target it in CSS to set the stroke colour. The canvas renderer probes those computed styles at render time, so the same CSS rules drive both renderers.
onChartDataProcessed (and getContext()) emit a legendData array on the ChartContext. Each entry has { label, color, order, disabled?, dataLabelSafe }. A colour authority (e.g. a provider component) can read these entries and emit the matching CSS:
<LineChart
onChartDataProcessed={(ctx) => {
ctx.legendData?.forEach(({ dataLabelSafe, color }) => {
// write `.line[data-label-safe="${dataLabelSafe}"] { stroke: ${color} }` into a <style> tag
});
}}
/>API
Props are typed as LineChartProps 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.
