Skip to content

실시간 틱

400ms마다 틱이 오고 세 틱마다 봉이 마감된다 — 진행 봉은 updateLast(같은 x는 교체)로 꿈틀거리고, 마감 뒤 첫 틱이 새 봉을 연다(큰 x는 추가). 뷰포트는 따라간다(shiftVisibleRangeOnNewBar). 재생/정지로 멈춰 놓고 코드와 대조할 수 있다 — 데이터는 결정적이라 같은 자리에서 멈추면 같은 그림이다.

소스

apps/examples/src/cases/realtime.ts — CI가 타입체크하는 실물이다.

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

export const title = "실시간 틱";
export const description =
  "400ms마다 틱이 오고 세 틱마다 봉이 마감된다 — 진행 봉은 updateLast(같은 x는 교체)로 꿈틀거리고, 마감 뒤 첫 틱이 새 봉을 연다(큰 x는 추가). 뷰포트는 따라간다(shiftVisibleRangeOnNewBar). 재생/정지로 멈춰 놓고 코드와 대조할 수 있다 — 데이터는 결정적이라 같은 자리에서 멈추면 같은 그림이다.";

export function mount(container: HTMLElement): () => void {
  const toolbar = document.createElement("div");
  toolbar.style.cssText = "margin-bottom: 8px";
  container.append(toolbar);
  const host = chartHost(container, 480);

  const plot = PlotBuilder.create<OHLC>(browserDeps({ autoSize: true }))
    .setSize(container.clientWidth || 900, 480)
    .setAxis({
      x: { ticks: timeTicks({ timeZone: "UTC", locale: "ko" }) },
      y: { position: "right", format: priceFormat({ compact: true, locale: "ko" }) },
    })
    .build(host);
  plot.applyOptions({ shiftVisibleRangeOnNewBar: true, rightOffset: 4 });

  const all = fixtureCandles(600);
  let revealed = 250;
  const price = plot.mainPane.addSeries({
    series: candleSeries(),
    data: all.slice(0, revealed),
    name: "가격",
  });

  let timer: number | undefined;
  const playing = () => timer !== undefined;

  /**
   * 진행 봉 하나를 세 틱으로 쪼갠다 — `updateLast`의 계약이 그대로
   * 문법이다: **같은 x는 교체**(진행 봉이 꿈틀거림), **큰 x는 추가**
   * (마감 뒤 첫 틱이 새 봉을 연다). 값은 대본에서 결정적으로 보간한다.
   */
  const TICKS_PER_BAR = 3;
  let tick = 0;

  const inProgress = (bar: OHLC, progress: number): OHLC => {
    const close = bar.open + (bar.close - bar.open) * progress;
    return {
      x: bar.x,
      open: bar.open,
      close,
      high: Math.max(bar.open, close, bar.open + (bar.high - bar.open) * progress),
      low: Math.min(bar.open, close, bar.open + (bar.low - bar.open) * progress),
      volume: Math.round((bar.volume ?? 0) * progress),
    };
  };

  const button = document.createElement("button");
  const pause = () => {
    clearInterval(timer);
    timer = undefined;
    button.textContent = "재생";
  };
  const play = () => {
    button.textContent = "정지";
    timer = window.setInterval(() => {
      const next = all[revealed];
      if (!next) return pause(); // 대본이 끝났다

      tick += 1;
      if (tick < TICKS_PER_BAR) {
        price.updateLast(inProgress(next, tick / TICKS_PER_BAR));
        return;
      }
      price.updateLast(next); // 확정치로 굳힌다
      revealed += 1;
      tick = 0;
    }, 400);
  };
  button.addEventListener("click", () => (playing() ? pause() : play()));
  toolbar.appendChild(button);
  play();

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