Chart

Draw any chart that TanStack Charts can describe, with native elements. Describe the chart with TanStack's defineChart and pass it to <ui-chart>, which draws it and handles touch, tooltips, screen readers, pan and zoom, and dark mode.

For one series, the Line, Area, Bar, Scatter and Candlestick presets are quicker. Use <ui-chart> for several series, overlays, or marks the presets don't offer.

Installation

npx @blotch/dolan add chart
import { UiChart, tooltip } from '../components/ui/chart';

dolan add also installs the npm packages the chart imports, @tanstack/charts and d3-shape. If that install fails, it prints the command to run yourself.

Usage

src/app/revenue.ts
import { Component } from '@angular/core';
import { scaleBand } from '@tanstack/charts/scales/band';
import { scaleLinear } from '@tanstack/charts/scales/linear';
import { barY, defineChart } from '@tanstack/charts/universal';
import { UiChart, tooltip } from '../components/ui/chart';

const revenue = [
  { month: 'Jan', value: 42 },
  { month: 'Feb', value: 58 },
  { month: 'Mar', value: 71 },
];

@Component({
  selector: 'app-revenue',
  imports: [UiChart],
  template: `<ui-chart [definition]="definition" label="Monthly revenue" />`,
})
export class Revenue {
  protected readonly definition = defineChart({
    marks: [barY(revenue, { x: 'month', y: 'value', fill: 'var(--primary)' })],
    scales: {
      x: { scale: () => scaleBand().padding(0.2) },
      y: { scale: scaleLinear, nice: true, grid: true },
    },
    tooltip,
  });
}
  • A definition maps fields of your data to a mark's channels (x, y, fill, …) and configures the scales.
  • Import defineChart and the marks from @tanstack/charts/universal, the entry point without browser code. Scales come from @tanstack/charts/scales/*.
  • The TanStack Charts docs cover every mark, scale and option. Don't use @tanstack/charts/angular: it draws into the browser DOM, which Lynx doesn't have.

Data from a signal

Build the definition in a computed() so the chart redraws when the data changes, and bind [definition]="definition()":

readonly sales = input.required<readonly Sale[]>();

protected readonly definition = computed(() =>
  defineChart({
    marks: [barY(this.sales(), { x: 'month', y: 'value' })],
    scales: {
      x: { scale: () => scaleBand().padding(0.2) },
      y: { scale: scaleLinear, nice: true, grid: true },
    },
    tooltip,
  }),
);

Size

Without width or height, the chart fills its container's width and is 200px tall. It redraws when its layout changes, for example on rotation. Set the height with a class, or fix either side in px:

<ui-chart [definition]="definition" class="h-64" />
<ui-chart [definition]="definition" [width]="320" [height]="180" />

Series and marks

Put every series in one array and split them with the z channel. Each series takes the next palette color, var(--chart-1) to var(--chart-5).

const revenue = [
  { month: 1, series: 'This year', value: 12 },
  { month: 1, series: 'Last year', value: 8 },
  // ...
];

protected readonly definition = defineChart({
  marks: [lineY(revenue, { x: 'month', y: 'value', z: 'series' })],
  scales: {
    x: { scale: scaleLinear },
    y: { scale: scaleLinear, nice: true, grid: true },
  },
  focus: 'group-x',
  tooltip,
});
  • focus: 'group-x' focuses every series at the touched x value, so the tooltip lists them together.
  • If each series is its own array, give each a mark with a fixed z: lineY(lastYear, { x: 'month', y: 'value', z: () => 'Last year' }).
  • For a legend, lay out <view> swatches next to the chart. The bg-chart-1 to bg-chart-5 classes match the series colors.

List several marks to layer them. They share the scales, and each draws over the ones before it:

marks: [
  barY(revenue, { x: 'month', y: 'value', fill: 'var(--primary)', radius: 4 }),
  ruleY([60], { stroke: 'var(--destructive)', strokeOpacity: 1, strokeDasharray: '4 4' }),
],
  • Rules don't take focus, so a touch still lands on the bars.
  • For a trend line, use linearRegressionY(points, { x: 'x', y: 'y', ci: 0 }) before a dot mark. ci: 0 leaves out the confidence band.
  • For smooth lines and areas, pass curve: d3Curve(curveMonotoneX), with curveMonotoneX from d3-shape. It never overshoots the data.

To keep a preset's axes and styling, start from its definition function and add marks. Each preset exports one, such as lineChartDefinition:

const base = lineChartDefinition({ data: revenue, smooth: true, yMin: 0 });

protected readonly withTarget = defineChart({
  ...base,
  marks: [...base.marks, ruleY([20], { stroke: 'var(--muted-foreground)' })],
});

Tooltips

Put tooltip in the definition to show a tooltip for the touched point. Without it, a touch still focuses points and emits focusChange.

  • Import tooltip from the chart component, not from @tanstack/charts/tooltip, which is built from browser DOM elements. This one draws native views.
  • Pass TanStack's tooltip options with use, as in tooltip: { use: tooltip, sticky: false }. See tooltip for the options it reads.

To lay out the tooltip yourself, put an <ng-template uiChartTooltip> inside the chart. The chart still places the tooltip and draws its card.

<ui-chart [definition]="definition" label="Monthly revenue">
  <ng-template uiChartTooltip let-tooltip>
    <text class="text-sm font-semibold text-card-foreground">
      {{ tooltip.primaryPoint.yValue }}k
    </text>
  </ng-template>
</ui-chart>

Import UiChartTooltip and LYNX_ELEMENTS in your component. The template gets a UiChartTooltipContext. A point's datum is typed unknown there, so read its fields in a component method that casts it.

Touch, selection and zoom

  • Scrub: touch and drag. Focus follows the nearest point, which the chart highlights and shows in the tooltip.
  • Tap: emits the point from pointSelect and pins the tooltip. While it's pinned, touches don't move focus. The next tap unpins it. With sticky: false, a tap doesn't pin.
  • If a scrolling parent takes over the touch, focus clears. A pinned tooltip stays.
// <ui-chart [definition]="definition" (pointSelect)="onSelect($event)" />
protected onSelect(point: ChartPoint | null): void {
  this.selected.set(point ? (point.datum as Sale) : null);
}

ChartPoint comes from @tanstack/charts/types. Its datum is the row you passed to the mark. Both outputs fire inside the touch, so wrap router navigation in setTimeout (see the setTimeout rule).

By default a touch focuses the nearest point within 48px (maxFocusDistance). Set focus in the definition to change that: 'nearest-x' prefers the closest x value, 'group-x' takes every series at that x, and false turns focus off. A TanStack selection controller in the definition is told about each tap.

Pan and zoom

Set zoomable to let users drag to pan, with +, − and 1:1 (reset) buttons in the top-right corner.

<ui-chart [definition]="definition" [zoomable]="true" zoomAxes="x" />
  • Zoom works on continuous axes in scales.x and scales.y, such as scaleLinear. Band and point scales don't zoom, and an axis missing from scales stays fixed.
  • On a zoomable chart, a drag pans instead of moving focus. A short tap still selects a point.
  • There's no pinch to zoom, because Lynx has no native pinch handler yet.
  • For your own controls, set showZoomControls to false and call zoomIn(), zoomOut() or resetZoom() from a viewChild.required(UiChart).
  • Zooming doesn't touch your data or definition. The chart sets TanStack's viewport option on the zoomed scales.

Accessibility

The chart is one accessibility element. Set label on every chart to say what it shows; the default is 'Chart'.

  • A screen reader announces the label, then the focused point's series, x value and y value.
  • Two custom actions, Next point and Previous point, step focus through the points and show the tooltip.

Theming and dark mode

Give marks theme colors as CSS variables, such as var(--primary) or the palette var(--chart-1) to var(--chart-5). They switch with dark mode. Fixed colors such as #2563eb stay the same in both modes. Marks without a color take the palette in order, and text and gridlines follow var(--foreground).

Why the colors are in TypeScript too

Lynx draws the marks with its native <svg> element, which never sees your CSS, and Lynx can't read a variable's value from JavaScript. So chart-theme.ts keeps a copy of the theme colors, and the chart swaps each var(--token) for its value before drawing. Labels and the tooltip are native views, so they read the CSS variables directly.

If you change a color in default.css or dark.css, change it in chart-theme.ts too, or override it in your app config. Pass only the tokens you changed:

src/app/app.config.ts
providers: [
  provideChartTheme({
    light: { 'chart-1': 'rgba(37, 99, 235, 1)' },
    dark: { 'chart-1': 'rgba(96, 165, 250, 1)' },
  }),
],

The chart has to follow the same source as your app's .dark class, or you get dark bars on a light card. The color scheme says which color table the marks use:

ValueColorsUse it when
'light'Always light (the default)Your app never sets .dark.
'system'Follows LynxTheme, the device appearanceYour app sets .dark from LynxTheme, as in Dark mode.
'dark'Always darkYour app is always dark.
A functionWhatever it returns, 'light' or 'dark'Your app switches themes itself. Read the same signal that sets .dark

Set it for every chart with provideChartTheme({ colorScheme: 'system' }), or for one chart, preset or <ui-chart>, with its colorScheme input.

API

UiChart

InputTypeDefaultDescription
definitionChartDefinitionRequiredThe chart to draw: what defineChart returns.
widthnumberfills containerFixed width in px.
heightnumber200Fixed height in px. Without it, 200px or a height class.
labelstring'Chart'What a screen reader announces for the chart.
colorScheme'light' | 'dark' | 'system'from provideChartTheme ('light')Which color table the marks use. 'system' follows LynxTheme.
zoomablebooleanfalseDrag to pan, with zoom buttons.
zoomAxes'x' | 'y' | 'xy''xy'Which axes pan and zoom.
showZoomControlsbooleantrueShow the +/−/1:1 buttons when zoomable.
minZoomnumber1Smallest zoom. 1 shows all the data.
maxZoomnumber8Largest zoom.
zoomStepnumber1.4Zoom factor per button press or method call.
classstring''Extra Tailwind classes for the chart's root view.
OutputTypeDescription
focusChangeChartPoint | nullThe focused point (first of a focused group) each time focus moves. null when it clears.
pointSelectChartPoint | nullThe point a tap landed on, or null for empty space. Emits on every tap, including unpinning.
MethodDescription
zoomIn()Zooms in by zoomStep around the center of the visible window.
zoomOut()Zooms out by zoomStep around the center of the visible window.
resetZoom()Shows the whole chart again.
dismiss()Clears focus and unpins the tooltip.

A ChartPoint has datum (your row), xValue and yValue, groupLabel (the series name), x and y (its position in px) and color.

UiChartTooltip

<ng-template uiChartTooltip let-tooltip> replaces the tooltip's contents. Its context is a UiChartTooltipContext:

PropertyTypeDescription
pointsreadonly ChartPoint[]The focused points, in the tooltip's row order.
primaryPointChartPointThe touched point, whatever the row order.
contentChartTooltipContent | stringThe default title and rows, already formatted.
pinnedbooleanWhether a tap has pinned the tooltip.
dismiss() => voidClears focus and unpins the tooltip.

tooltip

The tooltip token: tooltip alone, or { use: tooltip, ...options }. <ui-chart> reads these TanStack options. Options that style the browser tooltip's DOM, such as className and portal, have no effect.

OptionDefaultDescription
itemsx and yRows for a single focused point: channels, datum fields, or text.
contentautomaticA function that returns the title and rows.
format—A function that returns text for the primary point, instead of rows.
formatGroup—A function that returns text for the whole focused group.
sort'visual'Row order when several points have focus.
anchor'point'What the tooltip follows: 'point', 'pointer', 'group-center', ….
placement'auto'Side of the anchor, or a list to try in order. Kept inside the chart.
offset10Gap between the anchor and the tooltip, in px.
stickytrueA tap pins the tooltip until the next tap.
visibility'focus''pinned' shows the tooltip only once a tap pins it.

provideChartTheme

provideChartTheme(theme) returns a provider for every chart in its injector. Every field is optional, and light, dark and chart merge over their defaults.

FieldTypeDescription
colorScheme'light' | 'dark' | 'system' | (() => 'light' | 'dark')Which color table the marks use. Defaults to 'light'.
lightRecord<string, string>Light color per token, without -- (e.g. 'chart-1'). Merged over CHART_LIGHT_COLORS.
darkRecord<string, string>Dark color per token. Merged over CHART_DARK_COLORS.
chartPartial<ChartTheme>The TanStack theme, such as its palette. Merged over DEFAULT_CHART_THEME.

Keep paints as var(--token) strings so one definition serves both color schemes. An unknown token falls back to the var()'s fallback, as in var(--brand, #2563eb), or to the foreground color.

Migrating from the old chart components

<ui-cartesian-chart> and its <ui-*-series> children are gone, and so is the ChartPoint type from cartesian-chart. Single-series charts keep their preset, which now fills its container's width (it was 320px). Use XYDatum and CandlestickDatum for the point types, and set a color scheme to match how your app switches .dark. Pinch to zoom is gone. Several series move to <ui-chart>, with one mark per series:

BeforeAfter
<ui-line-series>lineY, plus dot for the dots.
<ui-area-series>areaY with y1 set to the baseline, plus lineY.
<ui-bar-series>barY for category x values, rect for numbers.
<ui-scatter-series>dot.
<ui-candlestick-series>The marks of candlestickChartDefinition (link and rect).
xDomain / yDomainscales.x / scales.y with scale: () => scaleLinear().domain([…]).
xAxisLabel, xTicksaxis: { label: '…', ticks: { values: […] } } on the scale.
(pointTap)(pointSelect) on <ui-chart>. The item is point.datum.