Examples
Every example below is a file in 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 stand in for your data and your server.
Open in playground under a chart opens its code in the 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.
seriesimport { 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.
series panesimport { 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.
seriesimport { 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.
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.
panesimport { 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.
seriesimport { 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.
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.
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.
data source realtimeimport { 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.
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.
volume profileimport { 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.
volume profileimport { 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.
annotationsimport { 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.
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.
shell drawingsimport { 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.
shellimport { 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.
shell seriesimport 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.
extendingimport { 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.
extending seriesimport { 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.
extending drawingsimport { 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.
shellimport 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;
}