실시간 틱
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() },
);
}