# Examples

Every example below is a file in [`docs/examples`](https://github.com/chart-on/chart-on/tree/main/docs/examples): the chart runs that file, and the code under it is that file, so the two always match. Each exports a `mount(el, { dark, theme })` that draws into `el` and returns a clean-up function. `dark` is the page's colour scheme and `theme` the chart theme that matches the site; in your app, pass `darkTheme`, `lightTheme` or your own theme instead. `sampleBars` and the other helpers in [`sample-data.ts`](https://github.com/chart-on/chart-on/blob/main/docs/examples/sample-data.ts) stand in for your data and your server.

**Open in playground** under a chart opens its code in the [playground](/playground). Press **Run** there (code from a link never runs by itself), edit it, then copy a link that reproduces your version.

The full demo app lives in `apps/demo` (`pnpm dev`): synthetic data, liquidation heatmap, CVD pane and live ticks.

## A first chart

Candles from an array of bars, fitted to the plot.

Tags: series

Example `first-chart` (runs live on the docs page):

```ts
import { createChart } from '@chart-on/core';
import type { ExampleContext } from './types';
import { sampleBars } from './sample-data';

export default function mount(el: HTMLElement, { theme }: ExampleContext) {
  const chart = createChart(el, { theme });
  const candles = chart.addCandles({ title: 'SAMPLE' });

  // Bars are { time (unix seconds), open, high, low, close, volume? }.
  candles.setData(sampleBars());
  chart.fitContent();

  return () => chart.destroy();
}
```

## Candles with a volume pane

A histogram that follows the candles, on a pane of its own.

Tags: series, panes

Example `candles-volume` (runs live on the docs page):

```ts
import { createChart, volumeOf } from '@chart-on/core';
import type { ExampleContext } from './types';
import { sampleBars } from './sample-data';

export default function mount(el: HTMLElement, { theme }: ExampleContext) {
  const chart = createChart(el, { theme });
  const candles = chart.addCandles({ title: 'SAMPLE' });

  // `follow` keeps the volume histogram in step with the candles.
  chart.addHistogram({ title: 'Volume', follow: volumeOf }, chart.addPane({ stretch: 0.25 }));

  candles.setData(sampleBars());
  chart.fitContent();

  return () => chart.destroy();
}
```

## Volume overlay in the price pane

The same histogram on its own scale at the bottom of the price pane.

Tags: series

Example `volume-overlay` (runs live on the docs page):

```ts
import { createChart, volumeOf } from '@chart-on/core';
import type { ExampleContext } from './types';
import { sampleBars } from './sample-data';

export default function mount(el: HTMLElement, { theme }: ExampleContext) {
  const chart = createChart(el, { theme });
  const candles = chart.addCandles({ title: 'SAMPLE' });

  // Its own scale in the bottom fifth of the price pane.
  chart.addHistogram({ title: 'Volume', follow: volumeOf, overlay: { top: 0.8, bottom: 0 } });

  candles.setData(sampleBars());
  chart.fitContent();

  return () => chart.destroy();
}
```

## Series derived from the bars

A line computed from each bar with a `follow` mapper.

Tags: series, panes

Example `derived-series` (runs live on the docs page):

```ts
import { createChart } from '@chart-on/core';
import type { ExampleContext } from './types';
import { sampleBars } from './sample-data';

export default function mount(el: HTMLElement, { theme }: ExampleContext) {
  const chart = createChart(el, { theme });
  const candles = chart.addCandles({ title: 'SAMPLE' });

  // Each bar's range as a line on its own pane; return null to leave a bar out.
  chart.addLine({ title: 'Range', follow: (bar) => ({ time: bar.time, value: bar.high - bar.low }) }, chart.addPane());

  candles.setData(sampleBars());
  chart.fitContent();

  return () => chart.destroy();
}
```

## Several panes

Volume and a cumulative delta line under the price pane.

Tags: panes

Example `panes` (runs live on the docs page):

```ts
import { createChart, volumeOf, type Bar } from '@chart-on/core';
import type { ExampleContext } from './types';
import { sampleBars } from './sample-data';

export default function mount(el: HTMLElement, { theme }: ExampleContext) {
  const chart = createChart(el, { theme });
  const bars = sampleBars();
  chart.addCandles({ title: 'SAMPLE' }).setData(bars);

  const volumePane = chart.addPane({ stretch: 0.25 });
  chart.addHistogram({ title: 'Volume', follow: volumeOf }, volumePane);

  const deltaPane = chart.addPane({ stretch: 0.25 });
  chart.addLine({ title: 'Cumulative delta', baseline: 0 }, deltaPane).setData(cumulativeDelta(bars));

  chart.fitContent();
  return () => chart.destroy();
}

/** Running total of buy volume minus sell volume. */
function cumulativeDelta(bars: Bar[]) {
  let total = 0;
  return bars.map((bar) => {
    const buy = bar.buyVolume ?? 0;
    total += buy - ((bar.volume ?? 0) - buy);
    return { time: bar.time, value: total };
  });
}
```

## Moving average overlay

A line series computed from the closes, over the candles.

Tags: series

Example `moving-average` (runs live on the docs page):

```ts
import { createChart, type Bar } from '@chart-on/core';
import type { ExampleContext } from './types';
import { sampleBars } from './sample-data';

export default function mount(el: HTMLElement, { theme }: ExampleContext) {
  const chart = createChart(el, { theme });
  const bars = sampleBars();
  chart.addCandles({ title: 'SAMPLE' }).setData(bars);
  chart.addLine({ title: 'SMA 20', color: '#f59e0b' }).setData(sma(bars, 20));
  chart.fitContent();
  return () => chart.destroy();
}

/** Simple moving average of the closes over `length` bars. */
function sma(bars: Bar[], length: number) {
  const out = [];
  let sum = 0;
  for (let i = 0; i < bars.length; i++) {
    sum += bars[i]!.close;
    if (i >= length) sum -= bars[i - length]!.close;
    if (i >= length - 1) out.push({ time: bars[i]!.time, value: sum / length });
  }
  return out;
}
```

## Realtime updates

Live ticks replace the forming bar and append new ones with `update`.

Tags: realtime

Example `realtime` (runs live on the docs page):

```ts
import { createChart } from '@chart-on/core';
import type { ExampleContext } from './types';
import { sampleBars, sampleTicks } from './sample-data';

export default function mount(el: HTMLElement, { theme }: ExampleContext) {
  const chart = createChart(el, { theme });
  const candles = chart.addCandles({ title: 'SAMPLE' });
  const history = sampleBars(200);
  candles.setData(history);
  chart.fitContent();

  // `sampleTicks` stands in for a WebSocket; in an app, call `update` from its
  // message handler. The same time replaces the forming bar, a newer one appends.
  const stop = sampleTicks(history.at(-1)!, (bar) => candles.update(bar));

  return () => {
    stop();
    chart.destroy();
  };
}
```

## Infinite scroll back

Older bars load as you pan towards the start, through `history:request`.

Tags: realtime

Example `history-scroll` (runs live on the docs page):

```ts
import { createChart } from '@chart-on/core';
import type { ExampleContext } from './types';
import { fetchOlderBars, sampleBars } from './sample-data';

export default function mount(el: HTMLElement, { theme }: ExampleContext) {
  const chart = createChart(el, { theme });
  const candles = chart.addCandles({ title: 'SAMPLE' });
  candles.setData(sampleBars(150));

  // Pan towards the oldest bar: older pages load and are prepended.
  let loading = false;
  chart.on('history:request', async ({ before }) => {
    if (loading) return;
    loading = true;
    const older = await fetchOlderBars(before);
    if (older.length) candles.setData([...older, ...candles.data]);
    loading = false;
  });

  return () => chart.destroy();
}
```

## Data source

History paging, live bars and caching handled by the chart, here with the mock source.

Tags: data source, realtime

Example `data-source` (runs live on the docs page):

```ts
import { createChart, createMockDataSource, dataSource } from '@chart-on/core';
import type { ExampleContext } from './types';

export default function mount(el: HTMLElement, { theme }: ExampleContext) {
  // `dataSource()` lets the chart load through a source; it is opt-in so charts fed by `setData` stay small.
  const chart = createChart(el, { theme, plugins: [dataSource()] });
  chart.addCandles();

  // The chart loads the first bars, pages in history as you scroll back and
  // merges live bars. The mock source makes up a market; use your own adapter.
  chart.setDataSource(createMockDataSource({ seed: 3 }));
  void chart.setSymbol('DEMO', '1m');

  return () => chart.destroy();
}
```

## Writing a data source

A `DataSource` by hand: symbol lookup, paged history in `[from, to)` and live bars.

Tags: data source, realtime

Example `custom-data-source` (runs live on the docs page):

```ts
import { createChart, dataSource, type Bar, type DataSource, type SymbolInfo } from '@chart-on/core';
import type { ExampleContext } from './types';
import { sampleBars, sampleTicks } from './sample-data';

export default function mount(el: HTMLElement, { theme }: ExampleContext) {
  const chart = createChart(el, { theme, plugins: [dataSource()] });
  chart.addCandles();
  chart.setDataSource(createSampleSource());
  void chart.setSymbol('SAMPLE', '1h');

  // `destroy` cancels requests in flight and calls the unsubscribe function.
  return () => chart.destroy();
}

/**
 * A data source written by hand. An array of bars stands in for your
 * server and `sampleTicks` for its live stream; swap in your API calls.
 */
function createSampleSource(): DataSource {
  const info: SymbolInfo = { symbol: 'SAMPLE', name: 'Sample market', precision: 2, intervals: ['1h'] };
  const stored = sampleBars(2000);
  /** Index of the first stored bar at or after `time`. */
  const indexOf = (time: number) => {
    const i = stored.findIndex((bar) => bar.time >= time);
    return i < 0 ? stored.length : i;
  };

  return {
    async symbol(symbol) {
      if (symbol !== info.symbol) throw new Error(`Unknown symbol ${symbol}`);
      return info;
    },

    async search(query) {
      return info.symbol.includes(query.toUpperCase()) ? [info] : [];
    },

    // At most `limit` bars, the newest ones in [from, to).
    async history(_symbol, _interval, req) {
      await latency(200, req.signal);
      const end = req.to === undefined ? stored.length : indexOf(req.to);
      const start = Math.max(end - req.limit, req.from === undefined ? 0 : indexOf(req.from));
      // Nothing older than the first stored bar: paging back stops there.
      return { bars: stored.slice(start, end), atStart: start === 0 };
    },

    // Keep live bars too, so later requests (a refresh, a reset) include them.
    stream(_symbol, _interval, onBar) {
      return sampleTicks(stored.at(-1)!, (bar: Bar) => {
        if (bar.time === stored.at(-1)!.time) stored[stored.length - 1] = bar;
        else stored.push(bar);
        onBar(bar);
      });
    },
  };
}

/** Waits like a network round trip, and gives up when the chart aborts the request. */
function latency(ms: number, signal: AbortSignal): Promise<void> {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(resolve, ms);
    signal.addEventListener('abort', () => {
      clearTimeout(timer);
      reject(signal.reason);
    });
  });
}
```

## Volume profile of the visible bars

Volume by price for the bars on screen, recomputed as you pan and zoom.

Tags: volume profile

Example `volume-profile` (runs live on the docs page):

```ts
import { createChart, volumeProfile } from '@chart-on/core';
import type { ExampleContext } from './types';
import { sampleBars } from './sample-data';

export default function mount(el: HTMLElement, { theme }: ExampleContext) {
  // Volume profiles are opt-in: the chart gets them through its plugins.
  const chart = createChart(el, { theme, plugins: [volumeProfile()] });
  chart.addCandles({ title: 'SAMPLE' }).setData(sampleBars()); // bars need `volume`

  // The bars on screen, at the right edge; it follows pan and zoom.
  chart.addVolumeProfile();

  chart.fitContent();
  return () => chart.destroy();
}
```

## Volume profile per session

One profile per day, each growing from its first bar.

Tags: volume profile

Example `volume-profile-sessions` (runs live on the docs page):

```ts
import { createChart, volumeProfile } from '@chart-on/core';
import type { ExampleContext } from './types';
import { sampleBars } from './sample-data';

export default function mount(el: HTMLElement, { theme }: ExampleContext) {
  // Volume profiles are opt-in: the chart gets them through its plugins.
  const chart = createChart(el, { theme, plugins: [volumeProfile()] });
  chart.addCandles({ title: 'SAMPLE' }).setData(sampleBars()); // bars need `volume`

  // One profile per UTC day, growing from the day's first bar.
  chart.addVolumeProfile({ range: 'session' });

  chart.fitContent();
  return () => chart.destroy();
}
```

## Time bands, price zones and text labels

Highlights added from code: an event window, a price zone and a note on a bar.

Tags: annotations

Example `annotation-zones` (runs live on the docs page):

```ts
import { annotations, createChart } from '@chart-on/core';
import type { ExampleContext } from './types';
import { HOUR, sampleBars } from './sample-data';

export default function mount(el: HTMLElement, { dark, theme }: ExampleContext) {
  const chart = createChart(el, { theme, plugins: [annotations()] });
  const bars = sampleBars();
  chart.addCandles({ title: 'SAMPLE' }).setData(bars);

  // An event window: shades every bar from `from` to `to`, below the candles.
  const event = bars[200]!.time;
  chart.annotations.addTimeBand({ from: event, to: event + 6 * HOUR, label: 'Earnings' });

  // A zone found by an algorithm: the range of a quiet stretch, carried to the right edge.
  const base = bars.slice(140, 170);
  const high = Math.max(...base.map((b) => b.high));
  const low = Math.min(...base.map((b) => b.low));
  chart.annotations.addPriceZone({
    from: low,
    to: high,
    fromTime: base[0]!.time,
    color: dark ? 'rgba(60, 196, 160, 0.14)' : 'rgba(15, 130, 104, 0.12)',
    label: 'Base',
    axisLabel: true,
  });

  // A note pinned above a bar's high, with a pointer down to it.
  const peak = bars.slice(220).reduce((a, b) => (b.high > a.high ? b : a));
  chart.annotations.addTextLabel({ time: peak.time, price: peak.high, text: 'Local high', pointerLength: 24 });

  chart.fitContent();
  return () => chart.destroy();
}
```

## Custom chart colors

A partial theme over the built-in one, changed later with `applyOptions`.

Tags: theming, series

Example `custom-theme` (runs live on the docs page):

```ts
import { createChart, type Bar } from '@chart-on/core';
import type { ExampleContext } from './types';
import { sampleBars } from './sample-data';

export default function mount(el: HTMLElement, { theme }: ExampleContext) {
  // Keys left out fall back to `darkTheme`, so spread a full base theme first:
  // here the page's `theme`, in an app `darkTheme` or `lightTheme`. Blue and
  // orange stay apart for red-green color blindness.
  const chart = createChart(el, {
    theme: { ...theme, up: '#3d8bd9', down: '#e07b39' },
  });
  const bars = sampleBars();
  chart.addCandles({ title: 'SAMPLE' }).setData(bars);
  // No `color`: the line takes the theme's `line` color.
  chart.addLine({ title: 'SMA 20' }).setData(sma(bars, 20));
  chart.fitContent();

  // Change colors later: only the keys you pass change, and every series
  // that uses them redraws.
  chart.applyOptions({ theme: { line: '#a35fd0' } });

  return () => chart.destroy();
}

/** Simple moving average of the closes over `length` bars. */
function sma(bars: Bar[], length: number) {
  const out = [];
  let sum = 0;
  for (let i = 0; i < bars.length; i++) {
    sum += bars[i]!.close;
    if (i >= length) sum -= bars[i - length]!.close;
    if (i >= length - 1) out.push({ time: bars[i]!.time, value: sum / length });
  }
  return out;
}
```

## Full shell with drawing tools

The UI package: toolbar, drawing-tool rail and bottom bar.

Tags: shell, drawings

Example `shell` (runs live on the docs page):

```ts
import { createChartShell } from '@chart-on/ui';
import '@chart-on/ui/style.css';
import type { ExampleContext } from './types';
import { sampleBars } from './sample-data';

export default function mount(el: HTMLElement, { dark }: ExampleContext) {
  // Toolbar, drawing-tool rail and bottom bar around the chart.
  const shell = createChartShell(el, { theme: dark ? 'dark' : 'light' });
  shell.chart.addCandles({ title: 'SAMPLE' }).setData(sampleBars());
  shell.chart.fitContent();

  return () => shell.destroy();
}
```

## Linked chart grid

Four charts in resizable cells that pan, zoom and move the crosshair together.

Tags: shell

Example `chart-grid` (runs live on the docs page):

```ts
import { createChartGrid } from '@chart-on/ui';
import '@chart-on/ui/style.css';
import type { ExampleContext } from './types';
import { sampleBars } from './sample-data';

export default function mount(el: HTMLElement, { dark }: ExampleContext) {
  // Four linked charts: pan, zoom or move the crosshair on one and the others follow.
  // Drag the bars between the cells, or focus one and use the arrow keys, to resize.
  const grid = createChartGrid(el, {
    layout: '2x2',
    theme: dark ? 'dark' : 'light',
    cell: { rail: false, bottomBar: false },
    setup(shell, index) {
      shell.chart.addCandles({ title: `SAMPLE ${index + 1}` }).setData(sampleBars(300, { seed: index + 1 }));
      shell.chart.fitContent();
    },
  });

  return () => grid.destroy();
}
```

## Chart type switcher

A toolbar menu that changes the candle style.

Tags: shell, series

Example `chart-type-switcher` (runs live on the docs page):

```ts
import type { CandleStyle } from '@chart-on/core';
import { createChartShell } from '@chart-on/ui';
import '@chart-on/ui/style.css';
import type { ExampleContext } from './types';
import { sampleBars } from './sample-data';

const styles: { value: CandleStyle; label: string }[] = [
  { value: 'candles', label: 'Candles' },
  { value: 'hollow', label: 'Hollow candles' },
  { value: 'bars', label: 'Bars' },
  { value: 'heikinAshi', label: 'Heikin-Ashi' },
  { value: 'line', label: 'Line' },
  { value: 'area', label: 'Area' },
  { value: 'baseline', label: 'Baseline' },
];

export default function mount(el: HTMLElement, { dark }: ExampleContext) {
  const shell = createChartShell(el, {
    theme: dark ? 'dark' : 'light',
    rail: false,
    toolbar: [
      {
        type: 'menu',
        id: 'style',
        label: 'Chart type',
        text: 'Candles',
        value: 'candles',
        options: styles,
        onSelect: (value) => candles.applyOptions({ style: value as CandleStyle }),
      },
    ],
  });
  const candles = shell.chart.addCandles({ title: 'SAMPLE' });
  candles.setData(sampleBars());
  shell.chart.fitContent();

  return () => shell.destroy();
}
```

## Writing an indicator

A moving average defined with inputs, a plot and incremental updates, added twice.

Tags: extending

Example `custom-indicator` (runs live on the docs page):

```ts
import { createChart, indicators, sourceAt, sourceOf, type IndicatorDef, type SourceKey } from '@chart-on/core';
import { sma, smaAt, smaLookback } from '@chart-on/ta';
import type { ExampleContext } from './types';
import { sampleBars } from './sample-data';

type Inputs = { length: number; source: SourceKey; color: string };

export const smaDef: IndicatorDef<Inputs> = {
  id: 'sma',
  name: 'SMA',
  placement: 'overlay', // or 'pane' for a pane of its own
  inputs: [
    { id: 'length', type: 'number', default: 20, min: 1 },
    { id: 'source', type: 'source', default: 'close' },
    { id: 'color', type: 'color', default: '#f59e0b' },
  ],
  plots: [{ id: 'sma', type: 'line', colorInput: 'color' }],
  compute(src, { length, source }) {
    const values = sourceOf(src, source);
    return { sma: sma(values, length), source: values }; // NaN = no value
  },
  update(src, { length, source }, i, out) {
    out.source![i] = sourceAt(src, source, i);
    out.sma![i] = smaAt(out.source!, i, length);
  },
  lookback: ({ length }) => smaLookback(length), // bars before the first value
};

export default function mount(el: HTMLElement, { theme }: ExampleContext) {
  const chart = createChart(el, { theme, plugins: [indicators()] });
  chart.addCandles({ title: 'SAMPLE' }).setData(sampleBars());
  chart.indicators.register(smaDef);
  chart.indicators.add('sma', { length: 20 });
  chart.indicators.add(smaDef, { length: 50, color: '#38bdf8' });
  chart.fitContent();
  return () => chart.destroy();
}
```

## Custom series

A price channel drawn by your own series type, with autoscale, legend and axis label.

Tags: extending, series

Example `custom-series` (runs live on the docs page):

```ts
import { createChart, withAlpha, type Bar, type CustomSeriesDef } from '@chart-on/core';
import type { ExampleContext } from './types';
import { sampleBars } from './sample-data';

interface BandPoint { time: number; upper: number; lower: number }

const band: CustomSeriesDef<BandPoint, { color?: string }> = {
  type: 'band',
  // Price span of one item, for autoscale.
  priceRange: (p) => [p.lower, p.upper],
  draw(ctx, view) {
    const { items } = view;
    if (items.length < 2) return;
    const color = view.options.color ?? view.theme.line;
    ctx.beginPath();
    items.forEach(({ item, x }) => ctx.lineTo(x, view.priceToY(item.upper)));
    for (let i = items.length - 1; i >= 0; i--) ctx.lineTo(items[i]!.x, view.priceToY(items[i]!.item.lower));
    ctx.closePath();
    ctx.fillStyle = withAlpha(color, 0.15);
    ctx.fill();
  },
  legend: (p, o) => (p ? [{ label: 'U', text: o.format(p.upper) }, { label: 'L', text: o.format(p.lower) }] : []),
  lastValue: (p) => ({ value: p.upper }),
};

export default function mount(el: HTMLElement, { theme }: ExampleContext) {
  const chart = createChart(el, { theme });
  const bars = sampleBars();
  chart.addCandles({ title: 'SAMPLE' }).setData(bars);
  const channel = chart.addCustomSeries(band, { title: 'Channel', color: '#38bdf8' });
  channel.setData(bars.map((_, i) => channelAt(bars, i, 20))); // ascending by time, unix seconds
  chart.fitContent();
  return () => chart.destroy();
}

/** Highest high and lowest low of the `length` bars up to bar `i`. */
function channelAt(bars: Bar[], i: number, length: number): BandPoint {
  const span = bars.slice(Math.max(0, i - length + 1), i + 1);
  return {
    time: bars[i]!.time,
    upper: Math.max(...span.map((b) => b.high)),
    lower: Math.min(...span.map((b) => b.low)),
  };
}
```

## Custom drawing tool

A price band tool registered on the chart; click twice to place another.

Tags: extending, drawings

Example `custom-drawing-tool` (runs live on the docs page):

```ts
import { createChart, drawings, type DrawingToolDef } from '@chart-on/core';
import type { ExampleContext } from './types';
import { sampleBars } from './sample-data';

const priceBand: DrawingToolDef = {
  type: 'price-band',
  label: 'Price band',
  points: 2,
  render(ctx, d, env) {
    const [a, b] = d.points; // pane-local css px
    if (!a || !b) return;
    ctx.fillStyle = d.drawing.style.fill ?? env.theme.drawingFill;
    ctx.fillRect(0, Math.min(a.y, b.y), env.width, Math.abs(b.y - a.y));
  },
  hitTest(d, p) {
    const [a, b] = d.points;
    return !!a && !!b && p.y >= Math.min(a.y, b.y) && p.y <= Math.max(a.y, b.y);
  },
};

export default function mount(el: HTMLElement, { theme }: ExampleContext) {
  // `drawings()` gives the chart drawings with only your own tools.
  const chart = createChart(el, { theme, plugins: [drawings()] });
  const bars = sampleBars();
  chart.addCandles({ title: 'SAMPLE' }).setData(bars);

  chart.drawings.register(priceBand);
  // One band from code; click twice on the chart to place another.
  chart.drawings.add({
    type: 'price-band',
    pane: 0,
    points: [
      { time: bars[120]!.time, price: bars[120]!.low },
      { time: bars[160]!.time, price: bars[160]!.high },
    ],
    style: {},
  });
  chart.drawings.setTool('price-band');
  chart.fitContent();
  return () => chart.destroy();
}
```

## Settings dialog with a custom input

A schema-driven form that previews changes live, with a stepper registered as a new input kind.

Tags: shell

Example `form-dialog` (runs live on the docs page):

```ts
import type { Bar, LineStyle } from '@chart-on/core';
import {
  createChartShell,
  createFormDialog,
  registerInput,
  type FormInputBase,
  type FormDialog,
  type FormSchema,
  type FormValues,
  type InputRenderer,
} from '@chart-on/ui';
import '@chart-on/ui/style.css';
import type { ExampleContext } from './types';
import { sampleBars } from './sample-data';

// A custom input kind: a minus / value / plus stepper for small whole numbers.
interface StepperInputDef extends FormInputBase<number> {
  kind: 'stepper';
  min?: number;
  max?: number;
}

// Tell the schema types about the new kind.
declare module '@chart-on/ui' {
  interface FormInputKinds {
    stepper: StepperInputDef;
  }
}

const stepper: InputRenderer<StepperInputDef, number> = (input, ctx) => {
  let value = ctx.value;
  const min = input.min ?? 1;
  const max = input.max ?? 10;
  const el = document.createElement('div');
  el.className = 'stepper';
  el.setAttribute('role', 'group');
  el.setAttribute('aria-labelledby', ctx.labelId);
  const minus = document.createElement('button');
  const plus = document.createElement('button');
  const shown = document.createElement('output');
  minus.type = plus.type = 'button';
  minus.textContent = '−';
  plus.textContent = '+';
  minus.setAttribute('aria-label', `Decrease ${input.label}`);
  plus.setAttribute('aria-label', `Increase ${input.label}`);
  minus.setAttribute('id', ctx.controlId);
  const show = (next: number) => {
    value = next;
    shown.textContent = String(next);
    minus.disabled = next <= min;
    plus.disabled = next >= max;
  };
  const step = (by: number) => {
    show(Math.min(max, Math.max(min, value + by)));
    ctx.set(value);
  };
  minus.addEventListener('click', () => step(-1));
  plus.addEventListener('click', () => step(1));
  el.append(minus, shown, plus);
  show(value);
  return {
    el,
    focusTarget: minus,
    setValue: show,
    setDisabled: (disabled) => {
      minus.disabled = plus.disabled = disabled;
    },
  };
};

const schema: FormSchema = {
  id: 'moving-average',
  title: 'Moving average',
  sections: [
    {
      id: 'inputs',
      title: 'Inputs',
      groups: [
        {
          id: 'calc',
          inputs: [
            {
              kind: 'number',
              id: 'length',
              label: 'Length',
              default: 20,
              min: 2,
              max: 200,
              integer: true,
            },
            { kind: 'source', id: 'source', label: 'Source', default: 'close' },
          ],
        },
      ],
    },
    {
      id: 'style',
      title: 'Style',
      groups: [
        {
          id: 'line',
          label: 'Line',
          inputs: [
            { kind: 'color', id: 'color', label: 'Color', default: '#f59e0b' },
            {
              kind: 'stepper',
              id: 'width',
              label: 'Width',
              default: 2,
              min: 1,
              max: 6,
            },
            {
              kind: 'select',
              id: 'lineStyle',
              label: 'Style',
              default: 'solid',
              options: [
                { value: 'solid', label: 'Solid' },
                { value: 'dashed', label: 'Dashed' },
                { value: 'dotted', label: 'Dotted' },
              ],
            },
          ],
        },
      ],
    },
  ],
};

export default function mount(el: HTMLElement, { dark }: ExampleContext) {
  const unregister = registerInput('stepper', stepper);
  let dialog: FormDialog | null = null;
  const shell = createChartShell(el, {
    theme: dark ? 'dark' : 'light',
    rail: false,
    toolbar: [
      {
        type: 'button',
        id: 'settings',
        label: 'Moving average settings',
        text: 'Settings',
        // Every change previews live; Cancel or Escape puts the opening values back.
        onClick: () =>
          (dialog = createFormDialog(shell.el, schema, {
            values: settings,
            onChange: apply,
            onAction: keep,
          })),
      },
    ],
  });
  const bars = sampleBars();
  shell.chart.addCandles({ title: 'SAMPLE' }).setData(bars);
  const line = shell.chart.addLine({ title: 'MA' });
  let settings: FormValues = {};
  const apply = (values: FormValues) => {
    line.applyOptions({
      color: values.color as string,
      lineWidth: values.width as number,
      lineStyle: values.lineStyle as LineStyle,
    });
    line.setData(movingAverage(bars, values.length as number, values.source as keyof Bar));
  };
  const keep = (action: string, values: FormValues) => {
    if (action === 'ok') settings = values;
  };
  settings = {
    length: 20,
    source: 'close',
    color: '#f59e0b',
    width: 2,
    lineStyle: 'solid',
  };
  apply(settings);
  shell.chart.fitContent();

  return () => {
    dialog?.close();
    unregister();
    shell.destroy();
  };
}

/** Simple moving average of one bar field over `length` bars; averages such as `hl2` are computed. */
function movingAverage(bars: Bar[], length: number, source: string) {
  const pick = (b: Bar) =>
    source === 'hl2'
      ? (b.high + b.low) / 2
      : source === 'hlc3'
        ? (b.high + b.low + b.close) / 3
        : source === 'ohlc4'
          ? (b.open + b.high + b.low + b.close) / 4
          : (b[source as 'open' | 'high' | 'low' | 'close'] ?? b.close);
  const out = [];
  let sum = 0;
  for (let i = 0; i < bars.length; i++) {
    sum += pick(bars[i]!);
    if (i >= length) sum -= pick(bars[i - length]!);
    if (i >= length - 1) out.push({ time: bars[i]!.time, value: sum / length });
  }
  return out;
}
```
