Skip to content

Customization

The shell and layouts

The complete chart application: symbol search, panes, layouts and stored drawings.

One Call for the Whole Application

createChartShell wraps a financial chart in the chrome a user expects: a toolbar, a drawing rail, symbol search, an indicator menu, a data picker, scale controls, screenshot, theme and full screen. It is plain DOM with no framework dependency, styled through CSS custom properties derived from the chart theme.

import { createChartShell } from "@ortex-charts/ui";

const shell = createChartShell(container, {
  symbol: "AAPL",
  datafeed,                                    // any Datafeed; search comes from searchSymbols
  overlays: myOverlays,                        // host-defined data series
  layout: savedLayout,                         // what the user chose last time, or null
  onLayoutChange: (layout) => api.saveChartLayout("stock-page", layout),
  drawingsStore: myDrawingsStore,              // drawings that follow the symbol
});

In React it is <ChartShell symbol="AAPL" layout={saved} options={{ datafeed, overlays, onLayoutChange }} /> from @ortex-charts/react.

The full shell@ortex-charts/ui
Loading the full shell
The whole terminal chart: symbol box, resolutions, series types, indicator and drawing menus, screenshot and full screen.

Open this example with its source

ChartShellOptions extends FinancialChartOptions, so every chart option — theme, time zone, resolution, session, watermark, crosshair, branding, data — is accepted in the same object.

The Shell Handle

interface ChartShell {
  readonly root: HTMLElement;
  readonly chart: FinancialChart;             // .chart.chart is the core Chart
  readonly drawings: DrawingsPrimitive | null;
  readonly binding: DatafeedBinding | null;

  symbol(): string;
  setSymbol(symbol: string): Promise&#x3C;void>;
  resolution(): string;
  setResolution(resolution: string): Promise&#x3C;void>;
  setSeriesType(type: MainSeriesType): void;
  setScaleMode(mode: ScaleMode): void;

  addIndicator(id: string, inputs?: Record&#x3C;string, number | string | boolean>): IndicatorInstance;
  indicators(): readonly IndicatorInstance[];

  overlays(): Array&#x3C;{ id: string; symbol: string; key: string; label: string; on: boolean }>;
  setOverlay(id: string, on: boolean, symbol?: string): Promise&#x3C;void>;
  openDataPicker(): void;

  placeSeries(seriesId: string, placement: Partial&#x3C;SeriesPlacement>): void;
  placementOf(seriesId: string): SeriesPlacement | null;

  addAction(action: ToolbarAction): () => void;
  exportCsv(): string;

  getLayout(): ChartLayout;
  setLayout(layout: ChartLayout | null): Promise&#x3C;void>;

  setTheme(theme: Theme | "dark" | "light"): void;
  toggleFullscreen(): Promise&#x3C;void>;
  isFullscreen(): boolean;

  replay(): ReplayController;
  startReplay(index?: number): void;
  stopReplay(): void;

  remove(): void;
}

Nothing is hidden behind the toolbar. shell.chart is the FinancialChart, shell.chart.chart is the core Chart, and shell.drawings is the drawing primitive, so anything on Series types, Indicators, Drawing tools or Order flow applies unchanged.

Layouts: The Chart Never Persists, the Host Does

A layout is the user's choice of how the chart looks, independent of which symbol is shown. It is plain JSON with a version.

interface ChartLayout {
  version: 1;
  seriesType: "candlestick" | "bar" | "line" | "area" | "baseline";
  scaleMode: "normal" | "logarithmic" | "percentage" | "indexedTo100";
  resolution?: string;
  indicators: Array&#x3C;{ id: string; inputs?: Record&#x3C;string, number | string | boolean>; visible?: boolean }>;
  overlays: string[];                                   // ids that are on; `id@SYMBOL` for a compared symbol
  scales?: Record&#x3C;string, { scale: "right" | "left" | "own"; pane: "main" | "separate"; visible?: boolean }>;
  drawings?: Drawing[];                                 // only with layoutIncludesDrawings
}
const layout = shell.getLayout();
await shell.setLayout(layout);        // null resets to the shell defaults, including defaultOn overlays

onLayoutChange fires at most once per frame after any user action that changes the layout: chart type, scale mode, resolution, indicator added, removed, reconfigured or hidden, overlay toggled, series moved to another scale or pane, and drawings when they are included.

The shell never touches storage. Where the layout lives — per user, per page kind, per symbol — is a host decision. The usual arrangement is one row per user and page kind:

const saved = await api.get(`/chart-layout/stock/`);

const shell = createChartShell(el, {
  symbol,
  datafeed,
  overlays,
  layout: saved,
  onLayoutChange: (l) => api.put(`/chart-layout/stock/`, l),
});

Unknown overlay and indicator ids in a stored layout are ignored rather than throwing, so renaming or retiring an overlay does not break every saved layout in your database.

Overlays Are Host-Defined

An overlay is a named thing your application knows how to put on the chart. The shell shows the label and group in the Data menu, keeps the check marks in sync, stores only the id in the layout, and calls your disposer when it is switched off.

import type { OverlayDef } from "@ortex-charts/ui";

const overlays: OverlayDef[] = [
  {
    id: "si",
    label: "Short interest",
    group: "Securities lending",
    description: "Estimated short interest as a percentage of free float",
    defaultOn: true,
    add: async ({ chart, symbol, resolution }) => {
      const rows = await api.shortInterest(symbol, resolution);
      const s = chart.addSeries("line", {
        title: "Short interest",
        priceScaleId: "left",
        align: "forwardFill",
        priceFormat: { type: "percent", precision: 2, minMove: 0.01 },
      });
      s.setData(rows);
      return () => chart.removeSeries(s.id);
    },
  },
];

add receives { chart, fc, shell, symbol, isCurrentSymbol, resolution }, may be asynchronous, and returns the function that removes the overlay again. It is called again after every symbol or resolution change, with the previous disposer run first, so the host fetches for ctx.symbol inside add and never has to watch the chart itself.

otherSymbolOnly: true marks an overlay that only makes sense for a compared symbol and hides it for the chart's own.

Comparing Symbols

When a datafeed is bound, the shell adds a built-in Price overlay for other symbols: the closes of another instrument as a line, with the scale switched to percent so the two are comparable. Comparing AAPL with MSFT therefore needs no host code beyond searchSymbols.

Overlays for another symbol are stored as id@SYMBOL in the layout, and overlayKey and parseOverlayKey are exported for hosts that need to read or build those keys.

await shell.setOverlay("price", true, "MSFT");
shell.overlays();   // [{ id, symbol, key, label, on }, …] including compared symbols

Set compare: false to remove the feature.

The "Add Data to Chart" Modal

With more than eight overlays, or whenever symbol search is available, the Data button opens a modal instead of a dropdown. dataPicker: "menu" | "modal" | "auto" overrides the decision.

The modal has a series search, a category list with counts on the left, grouped rows that toggle on click, and a symbol search that points the picker at another instrument so its series can be compared. shell.openDataPicker() opens it from your own control, and openDataPicker is exported from @ortex-charts/ui for hosts that want the same component somewhere else.

Moving a Series to Another Scale or Pane

Every legend row carries a button. For an overlay or an indicator output it offers Right scale, Left scale, Own scale (a hidden scale, so the series autoscales alone), Main pane, New pane, Hide and Remove, plus Collapse pane, Expand pane, Maximise pane and Restore panes for the pane the series sits on.

The choice is stored in layout.scales, keyed by series title, so it survives an overlay being re-added after a symbol change and comes back with the user's saved layout.

shell.placeSeries(series.id, { scale: "own", pane: "separate" });
shell.placementOf(series.id);   // { scale, pane, visible } or null

The underlying core methods are chart.setPaneCollapsed, chart.isPaneCollapsed, chart.maximizePane, chart.restorePanes, chart.maximizedPane and chart.subscribePaneChange, and chart.subscribeLegendMenu is the hook a host uses to build its own menu on that button.

A collapsed pane is a 24-pixel legend strip. Its series stay attached and keep updating, so collapsing is a display state rather than a teardown.

Drawings That Follow the Symbol

The most-requested behavior in this category — a trend line drawn on AAPL being there on every chart of AAPL — is a storage question, and the host owns storage. Give the shell a drawingsStore and it does the rest.

import { localStorageDrawingsStore } from "@ortex-charts/ui";

createChartShell(el, { symbol: "AAPL", datafeed, drawingsStore: localStorageDrawingsStore() });

// or your own, server-backed:
createChartShell(el, {
  symbol: "AAPL",
  datafeed,
  drawingsStore: {
    load: (symbol) => api.get(`/drawings/${symbol}`),
    save: (symbol, drawings) => api.put(`/drawings/${symbol}`, drawings),
  },
});

Both methods may be asynchronous. Drawings are saved whenever they change and on every symbol switch, and loaded for the new symbol.

With a store in use, leave layoutIncludesDrawings off: a layout then describes the chart and drawings describe the symbol, which is the separation users expect. Turn layoutIncludesDrawings on instead only when the page is inherently single-symbol and you want one blob rather than two.

Symbol Logos

SymbolInfo.logo — a URL or a data URI — is shown in the symbol button and before the main series in the legend. A missing or failing image falls back to the color dot silently. The datafeed supplies it from resolveSymbol or searchSymbols, and an adapter spec maps it with symbol.logo, which accepts a template such as https://logos.example.com/{symbol}.png or {info.logo_url}.

CSV Export

The download toolbar item is off by default. Turning it on exports every visible series on the main timeline as CSV.

createChartShell(el, {
  symbol: "AAPL",
  datafeed,
  toolbar: { download: true },
  download: {
    fileName: "aapl-daily.csv",
    onDownload: (csv, shell) => api.saveExport(shell.symbol(), csv),   // intercept it
  },
});

const csv = shell.exportCsv();   // the same text, on demand

With onDownload set, the browser does not save a file; the text goes to you instead.

Screenshots

createChartShell(el, {
  screenshotFileName: "chart.png",
  onScreenshot: (canvas) => canvas.toBlob((blob) => uploadToSlack(blob!)),
});

const canvas = shell.chart.chart.takeScreenshot();   // from code, at any time

takeScreenshot composes every pane canvas into one and is on the core chart, so it works without the shell.

Full Screen

toggleFullscreen() uses the Fullscreen API on the shell root and falls back to a fixed-position layer where the API is unavailable, which is the case on iOS Safari. Escape leaves either. isFullscreen() reports the current state, and the toolbar button reflects it.

Events

createChartShell(el, {
  onLayoutChange: (layout) => save(layout),
  onSymbolChange: (symbol, info) => setPageTitle(symbol, info?.name),
  onResolutionChange: (resolution) => track("resolution", resolution),
  onThemeChange: (theme) => persistThemePreference(theme),
  onScreenshot: (canvas) => upload(canvas),
  onError: (error) => report(error),
});

onError catches datafeed and overlay failures that would otherwise be silent; wiring it to your error reporter is worth the two lines.

What the Host Has to Provide

The chart side is finished; the persistence side is yours. For a complete integration you need four things, none of which the library can decide for you:

  • A per-user layout store keyed by page kind, one JSON body per user and page, read on mount and written from onLayoutChange.
  • An overlay registry, one OverlayDef[] per page kind, wrapping your own series endpoints.
  • A drawings store per symbol, if you want drawings to follow the symbol.
  • Defaults for anonymous users, which is simply a layout object in your bundle.

Nothing in the chart depends on those existing. Without any of them the shell still works; it just forgets everything on reload.