Skip to content

Infinite history — infiniteHistory ​

Drag toward the left edge — older bars arrive page by page from a simulated 250ms feed. infiniteHistory(plot, sink, fetch, { from }) owns the cursor and the bookkeeping; the consumer owns fetch and where a page lands. The status line reads the loader's status()/statusChanges pair.

In this imperative demo, the consumer's whole share is two functions: a fetch that produces the page of bars strictly before a given x (empty array = the end of history), and a sink that says where a landed page goes — here one fetch fans out to the candle and volume handles. Everything the hand-rolled version had to carry — the cursor, the "how close to the edge" test, in-flight dedup, trimming the inclusive boundary bar exchanges like to send back, and re-checking after a landing (a prepend never moves the domain, so no event announces it) — is the loader's.

One sizing rule worth stealing: against a capped API (Toss and Upbit take count up to 200, Binance limit up to 1000), request the cap every time. One bigger page beats several small ones on every axis at once — round trips while the user is looking at a gap, request quota, and landings (each landing recomputes, wholesale, every derivation fed by the source that changed and declaring no head door — deriveFirst, calcFirst or headLookback — and a price-axis transform never declares one).

The status line reads the loader's status() / statusChanges pair — the same snapshot-plus-subscription shape usePluginState consumes in React.

In React, useInfiniteHistory holds the data and the loader's place, and <InfiniteHistory history> inside the chart pages into it:

tsx
const CANDLES = new OHLCAccessor(); // module level
const history = useInfiniteHistory<OHLC, string>({ coordinates: CANDLES });
const { reset } = history; // stable — `history` itself changes with every page
useEffect(() => {
  let current = true;
  api.candles(symbol).then((page) => {
    if (!current) return;
    reset(page.bars, {
      next: page.next,
      fetchPage: (cursor) => api.candles(symbol, cursor),
    });
    // Paged by time instead: reset(page.bars, { fetch: (before) => api.candlesBefore(symbol, before) })
  });
  return () => {
    current = false;
  };
}, [symbol, reset]);

<ChartContainer deps={deps} data={history.data}>
  <ChartCandles />
  <InfiniteHistory history={history} />
</ChartContainer>

history.data is React state — pass it as the series' data and edit a live bar with history.setData; history.status is the loader's state as React state. The fetch comes with the load — reset takes the bars and how to page back from them, from the scope that knows the symbol — so no later render can pair one load's token with another symbol's API. The place outlives the chart: a chart remounted under a key resumes from the first bar held and the token last taken, an end stays an end, and a page landing for a load reset replaced is dropped. What reset is handed is the caller's to vouch for: a first page that answers for a symbol already left (the effect above, cleaned up) must not reach it. An edit through setData is for one load's bars and is dropped if React applies it once another load holds them: by default the load on screen when it's made, so a tick for a symbol being left never lands on the next one's bars; a feed that already knows its new load names it — setData(update, load) with the handle reset returned (history.load reads the one on screen). One <InfiniteHistory> pages a history at a time; a second one mounted alongside is refused, since two loaders on one token would fetch and prepend the same page twice. The wrapper demo (apps/examples/src/App.tsx) pages by time this way.

A page counts once it reaches the chart. The loader asks for the next page only when the chart holds the one before it (plot.getDataRange() reaches the page's first x) — a setState prepend lands a frame later, and the loader looks again on that frame. A page the chart refuses never lands, so the loader stops asking instead of piling up pages nobody draws. Outside React, a consumer that starts a new loader from the same place reads the token from the cursor-mode loader's cursor() — an empty page moves it without reaching the sink.

For a live feed on the same chart, the imperative example above wraps its handle with conflated — the two doors compose on one handle and are torn down together when the symbol changes. In React, a live edit goes through history.setData.

Source ​

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

ts
/**
 * Infinite history through the `infiniteHistory` door.
 *
 * The consumer's whole job is two functions: a fetch that produces the page
 * of bars before a given x (here: generated, behind a simulated 250ms round
 * trip), and a sink that says where a landed page goes (here: fanned out to
 * the candle and volume handles — one fetch, two deliveries). The cursor,
 * the threshold test, in-flight dedup, boundary trimming, and the chaining
 * that keeps filling while the view sits past the data are the loader's.
 *
 * The status line under the chart is wired to the loader's snapshot +
 * subscription pair — `loading` flashes during a round trip, and once the
 * generator's backstop is reached the loader reports `done` and stops
 * asking.
 */
import { PlotBuilder, browserDeps } from "@finchart/dom";
import type { HistogramPoint, HistoryStatus, OHLC } from "@finchart/core";
import { OHLCAccessor, candleSeries, crosshair, histogramSeries, infiniteHistory, priceFormat, timeTicks } from "@finchart/core";
import { chartHost } from "./stage";

const timeFormat = new Intl.DateTimeFormat("en-US", {
  timeZone: "UTC",
  month: "short",
  day: "2-digit",
  hour: "2-digit",
  minute: "2-digit",
});

export const title = "Infinite history — infiniteHistory";
export const description =
  "Drag toward the left edge — older bars arrive page by page from a simulated 250ms feed. infiniteHistory(plot, sink, fetch, { from }) owns the cursor and the bookkeeping; the consumer owns fetch and where a page lands. The status line reads the loader's status()/statusChanges pair.";

const MINUTE = 60_000;
const BASE = Date.UTC(2026, 7, 10, 9, 0);
const CHUNK = 80; // bars shown at first, and bars per fetched page
const MAX_HISTORY = 1200; // the generator's backstop — reaching it shows `done`

/** Deterministic pseudo-random in [0,1) — one index, always the same value. */
function noise(seed: number): number {
  const x = Math.sin(seed * 12.9898) * 43758.5453123;
  return x - Math.floor(x);
}

/** A pure function of the index, so the past extends deterministically. */
function priceAt(index: number): number {
  const drift = Math.sin(index / 19) * 700 + Math.sin(index / 67) * 1200 + index * 0.5;
  return Math.max(1_000, 42_000 + drift + (noise(index) - 0.5) * 2200);
}

function ohlcAt(index: number): OHLC {
  const open = priceAt(index - 1);
  const close = priceAt(index);
  const spread = noise(index * 7 + 3) * 700 + 90;
  const volume = Math.round(100 + noise(index * 13 + 1) * 400);
  return {
    x: BASE + index * MINUTE,
    open,
    close,
    high: Math.max(open, close) + spread,
    low: Math.min(open, close) - spread,
    volume,
  };
}

function toVolumePoint(candle: OHLC): HistogramPoint {
  return {
    x: candle.x,
    y: candle.volume ?? null,
    tone: candle.close >= candle.open ? "up" : "down",
  };
}

function bars(from: number, to: number): OHLC[] {
  const out: OHLC[] = [];
  for (let i = from; i < to; i++) out.push(ohlcAt(i));
  return out;
}

/** The exchange stand-in: the page of bars before `before`, after a round trip. */
function fetchOlder(before: number): Promise<OHLC[]> {
  const end = Math.round((before - BASE) / MINUTE);
  const from = Math.max(end - CHUNK, -MAX_HISTORY);
  const page = from >= end ? [] : bars(from, end);
  return new Promise((resolve) => setTimeout(() => resolve(page), 250));
}

const STATUS_LINE: Record<HistoryStatus, string> = {
  idle: "idle — pan left for more",
  loading: "loading older bars…",
  done: "done — the beginning of history",
  terminated: "terminated — the fetch broke its contract",
  stopped: "stopped — the loader was disposed",
};

export function mount(container: HTMLElement): () => void {
  const host = chartHost(container, 360);

  const status = document.createElement("div");
  status.style.cssText =
    "font-size: 13px; color: #64748b; margin-top: 6px; font-variant-numeric: tabular-nums";
  container.append(status);

  const plot = PlotBuilder.create<OHLC>(browserDeps({ autoSize: true }))
    .setSize(container.clientWidth || 900, 360)
    .setAxis({
      // timeTicks covers the grid labels only — the crosshair badge reads
      // axis.x.format, and without it the raw epoch ms shows through.
      x: {
        ticks: timeTicks({ timeZone: "UTC", locale: "en-US" }),
        format: (x) => timeFormat.format(x),
      },
      y: { position: "right", format: priceFormat({ compact: true, locale: "en-US" }) },
    })
    .build(host);

  const initial = bars(-CHUNK, 0);
  const priceHandle = plot.mainPane.addSeries({
    series: candleSeries(),
    data: initial,
    name: "Price",
  });
  const volumeHandle = plot
    .addPane({ flex: 0.3, minHeight: 50 })
    .addSeries({
      // The volume pane recedes under price: a green/red pair at 45%, the same in
      // both modes, on the series — a CSS variable is chart-wide and would wash
      // an indicator's bars too.
      series: histogramSeries({ style: { up: "rgba(22, 163, 74, 0.45)", down: "rgba(220, 38, 38, 0.45)" } }),
      data: initial.map(toVolumePoint),
      name: "Volume",
    });
  plot.use(crosshair({ magnet: true }));

  let held = initial.length;
  const paint = () => {
    status.textContent = `${STATUS_LINE[loader.status()]} · ${held.toLocaleString("en-US")} bars held`;
  };

  const loader = infiniteHistory(
    plot,
    (older) => {
      // One fetch, two deliveries — the volume pane rides the same page.
      priceHandle.prepend(older);
      volumeHandle.prepend(older.map(toVolumePoint));
      held += older.length;
    },
    fetchOlder,
    { from: initial[0].x, coordinates: new OHLCAccessor() },
  );
  const offStatus = loader.statusChanges.subscribe(paint);
  paint();

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