Skip to content

Custom series — volume dots ​

A Series is two methods: valueExtent (how much of the y axis it takes) and draw (what it draws). This amber dot series is a presentation the core knows nothing about, and it puts a volume-sized circle on the close — built without touching the library. The bigger the volume, the bigger the circle.

Source ​

apps/examples/src/cases/custom-series.ts — the real thing, type-checked in CI.

ts
import { PlotBuilder, browserDeps } from "@finchart/dom";
import type { DrawTarget, OHLC, Series } from "@finchart/core";
import { OHLCAccessor, candleSeries, isGap, priceFormat, timeTicks } from "@finchart/core";
import { fixtureCandles } from "./fixture";
import { chartHost } from "./stage";

export const title = "Custom series — volume dots";
export const description =
  "A Series is two methods: valueExtent (how much of the y axis it takes) and draw (what it draws). This amber dot series is a presentation the core knows nothing about, and it puts a volume-sized circle on the close — built without touching the library. The bigger the volume, the bigger the circle.";

/** Rough upper bound on fixtureCandles' volume — used only to normalize the radius. */
const MAX_VOLUME = 500;
const DOT_COLOR = "rgba(217, 119, 6, 0.55)"; // amber-600 — a color that doesn't collide with the candles' green/red

/**
 * The real thing behind the contract the README sells ("a Series is two
 * methods") — the core has no idea this series exists. `valueExtent` says how
 * much of the y axis it needs, and `draw` puts that out as commands.
 */
const volumeDots: Series<OHLC> = {
  // Bars, not {x, y} — the data gate reads the point through this.
  coordinates: new OHLCAccessor(),
  valueExtent(data) {
    if (data.length === 0) return null;
    let min = Number.POSITIVE_INFINITY;
    let max = Number.NEGATIVE_INFINITY;
    for (const point of data) {
      if (point.close < min) min = point.close;
      if (point.close > max) max = point.close;
    }
    return { min, max };
  },
  draw(target: DrawTarget, { data, x, yScale }) {
    for (const point of data) {
      // A bar without volume is a gap — no dot, not a minimum-size one.
      if (isGap(point.volume)) continue;
      const radius = 3 + (point.volume / MAX_VOLUME) * 13;
      target.drawShape({
        shape: "circle",
        cx: x.toPixel(point.x),
        cy: yScale.scale(point.close),
        r: radius,
        fill: DOT_COLOR,
      });
    }
  },
};

export function mount(container: HTMLElement): () => void {
  const host = chartHost(container, 420);
  const plot = PlotBuilder.create<OHLC>(browserDeps({ autoSize: true }))
    .setSize(container.clientWidth || 900, 420)
    .setAxis({
      x: { ticks: timeTicks({ timeZone: "UTC", locale: "en-US" }) },
      y: { position: "right", format: priceFormat({ compact: true, locale: "en-US" }) },
    })
    .build(host);

  // Fewer bars — the dots are wider than a bar, so packed tight they cover each other.
  const data = fixtureCandles(80);
  plot.mainPane.addSeries({ series: candleSeries(), data, name: "Price" });
  plot.mainPane.addSeries({ series: volumeDots, data, name: "Volume (dots)" });

  return Object.assign(
    () => {
      plot.destroy();
      host.remove();
    },
    { requestRender: () => plot.requestRender() },
  );
}