Skip to content

State, synchronization, export

Chart state

Zoom and series hidden via the legend are serializable state:

ts
const state = chart.getState();
// { zoom: { x: [0.25, 0.75] }, hiddenSeries: ['line-1', 'histogram-0#1'] }
localStorage.setItem('chart-state', JSON.stringify(state));

// restore — at creation time or on a live instance
Charts.create({ ...options, initialState: JSON.parse(saved) });
await chart.setState(JSON.parse(saved));

hiddenSeries also holds the items of a series that puts several in the legend — pie/donut sectors and histogram groups, as 'seriesId#index'. They are restored with everything else, and because series are rebuilt on every update(), this is what keeps a legend filter from evaporating the next time the options change.

Chart synchronization

Charts with the same sync.groupId share node highlighting and the zoom window:

ts
Charts.create({ ...top, sync: { groupId: 'dashboard' } });
Charts.create({ ...bottom, sync: { groupId: 'dashboard' } });
OptionDefaultDescription
groupId'default'group name
nodeInteractiontruehighlight synchronization (by data index)
zoomtruezoom window synchronization

Modular build

When building with grafit-charts/core, synchronization is a separate module: register(syncModule).

Context menu

contextMenu: { enabled: true } — right click opens a menu: "Download PNG", "Reset zoom" (when zoom is present), and extraItems: [{ label, action }]. When building with grafit-charts/core, it is a separate module: register(contextMenuModule).

Export

ts
chart.download({ fileName: 'report.png' });
const dataUrl = chart.getImageDataURL();

Animation

Series entrance is animated by default (600 ms, ease-out). On update/updateDelta the new data flows into place instead of replacing what is drawn: rows are matched, their numeric fields walk to the new values, and the axes travel along with them. A tooltip open while this happens keeps its node and its numbers keep up.

Rows are matched by position, so a change in how many there are is drawn at once. Name a key — the field a row is the same row by — and the rows that stayed keep flowing however many arrived or left: an entering row grows out of the base of its value fields and opens a band of its own, a leaving one sinks back and closes its band behind it, so the categories beside it spread rather than snap into the room.

The value axis walks to its new bounds along with the data instead of being read off rows still in motion — its ticks stay the round numbers of the settled scale, and nothing on the chart jumps when the scale changes gear.

ts
chart.update({ ...options, data: next, animation: { key: 'month', updateDuration: 300 } });

Press for a new reading — the bars walk to their new heights, and a service that drops out sinks away while the one taking its place grows in:

ts
import { getData, newServices, newValues, type Reading } from './data';
import type { ChartInstance, ChartOptions } from 'grafit-charts';

// Data replaced on demand: the bars walk to their new heights and the axis
// travels with them. `key` says what makes a bar the same bar between readings,
// so a service that drops out sinks away while the one taking its place grows in.
export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Requests per minute' },
    series: [{ type: 'bar', xField: 'service', yField: 'requests', name: 'Requests', cornerRadius: 4 }],
    legend: { enabled: false },
    tooltip: {},
    animation: { key: 'service', updateDuration: 900 },
  };
}

// Buttons under the demo. The data of the chart is the state of the demo:
// each reading is built from the one on screen, so the bars drift rather than jump.
export const actions = [
  {
    label: 'New values',
    run: (chart: ChartInstance) => update(chart, newValues),
  },
  {
    label: 'New services',
    run: (chart: ChartInstance) => update(chart, newServices),
  },
];

function update(chart: ChartInstance, next: (previous: Reading[]) => Reading[]): void {
  const options = chart.getOptions();
  void chart.update({ ...options, data: next((options.data as Reading[]) ?? getData()) });
}
ts
/** Services the dashboard watches; a reading shows a few of them at a time. */
const SERVICES = ['auth', 'search', 'checkout', 'catalog', 'billing', 'media', 'notify'];

/** A type rather than an interface: a chart takes rows as `Datum`, and only a type alias reads as one. */
export type Reading = {
  service: string;
  requests: number;
};

/** The starting frame, fixed — a demo that opens differently every time is hard to read. */
export function getData(): Reading[] {
  return [
    { service: 'auth', requests: 820 },
    { service: 'search', requests: 1340 },
    { service: 'checkout', requests: 460 },
    { service: 'catalog', requests: 1180 },
    { service: 'billing', requests: 240 },
  ];
}

/** The same services, read again: every bar walks to a new height. */
export function newValues(previous: Reading[]): Reading[] {
  return previous.map((row) => ({ service: row.service, requests: drift(row.requests) }));
}

/**
 * A different handful of services. The ones that stayed keep the load they had
 * and carry on from it; the rest arrive and leave — which is what `key` is for.
 */
export function newServices(previous: Reading[]): Reading[] {
  return pickServices(previous.length).map((service) => ({
    service,
    requests: drift(previous.find((row) => row.service === service)?.requests ?? 600),
  }));
}

/** A random handful, in a stable order — the axis should not reshuffle on its own. */
function pickServices(count: number): string[] {
  const pool = [...SERVICES];
  const picked: string[] = [];
  while (picked.length < count && pool.length > 0) {
    picked.push(...pool.splice(Math.floor(Math.random() * pool.length), 1));
  }
  return picked.sort((a, b) => SERVICES.indexOf(a) - SERVICES.indexOf(b));
}

/** Within ±35% of the previous value, clamped to a plausible range. */
function drift(value: number): number {
  return Math.round(Math.min(1600, Math.max(120, value * (0.65 + Math.random() * 0.7))));
}

animation: { enabled: false } switches both animations off. updateEnabled speaks for the update alone and wins wherever it is set, so a chart can appear at once and move afterwards. update() resolves when the transition has arrived, and so does waitForUpdate().

Options

OptionTypeDefaultDescription
animation.enabledbooleantrueentrance and update animation
animation.durationnumber600entrance duration, ms
animation.updateEnabledbooleananimation.enabledthe update transition on its own
animation.updateDurationnumberduration, else 450update transition duration, ms
animation.keystring | (datum, index) => unknownwhat a row is the same row by
contextMenu.enabledbooleantrueright-click menu
contextMenu.extraItems{ label, action }[]custom items after the standard ones
download(options){ fileName?, fileFormat? }chart.pngPNG/JPEG export
initialStateChartStateinitial zoom and hidden series