Skip to content

보는 상태는 값이다

pan·zoom·드래그로 사용자가 만든 "지금 보고 있는 구간"은 흩어진 내부 필드가 아니라 하나의 값이다.

ts
interface ChartState {
  xDomain: Range | null; // 데이터의 x. 첫 fit 전이면 null
  panes: PaneState[]; // { flex, autoScale, valueDomain? } — 인덱스가 신원
}

데이터도, 시리즈 구성도, 스타일도 여기 안 든다. 새로고침 뒤에도 사용자가 돌아오길 기대하는 것만 — 보는 구간과 pane 배치다.

셋이 전부다 — getState · stateChange · applyState

ts
const state = plot.getState(); // 지금 스냅샷
plot.on("stateChange", (state) => save(state)); // 바뀔 때마다(동기)
plot.applyState({ xDomain: saved.xDomain }); // 조각만 줘도 된다

되먹임 고리는 코어가 끊는다 — 같은 값을 다시 applyState해도 stateChange가 또 울리지 않는다. applyState가 받은 조각만 반영하므로 "줌만 밖에서 걸고 싶다"는 { xDomain }만 넘기면 된다.

이 셋을 옵션(TanStack류)이 아니라 거울+되먹임(TradingView류)으로 둔 이유는 프레임 예산이다 — pan이 60fps로 도는 캔버스에서 상태를 옵션으로 왕복시키면 한 프레임 늦은 드래그가 구조에 박힌다. getState/stateChange/applyState 조합은 ADR-0022의 결론이다.

URL에 저장하기

serializeChartState/parseChartState가 문자열 왕복을 맡는다 — 파서는 못 읽으면 던지지 않고 null을 낸다. URL은 사용자가 손으로도 건드리는 입력이라, 깨진 문자열은 오류가 아니라 "복원할 게 없다"로 다룬다.

ts
const saved = new URLSearchParams(location.search).get("view");
const restored = saved ? parseChartState(saved) : null;
if (restored) plot.applyState(restored);

plot.on("stateChange", (state) => {
  const url = new URL(location.href);
  url.searchParams.set("view", serializeChartState(state));
  history.replaceState(null, "", url);
});

실행되는 전체 예제는 URL에 저장되는 상태에 있다. 상태가 데이터보다 먼저 도착해도(마운트 직후 URL 복원 → 데이터 로드) 첫 fit이 복원한 창을 덮지 않는다 — applyState가 데이터 x를 pending으로 들고 있다가 첫 fit이 돌 자리에서 fit 대신 반영한다.

두 차트 동기화 — syncX

xDomainChange(데이터 x로 알린다)와 setVisibleRange(데이터 x로 잡는다) — 이미 있는 계약 둘을 30줄로 묶은 것이 syncX다. 코어 계약이 아니라 조립이다.

ts
import { syncX } from "@finchart/core";

const release = syncX(btcPlot, ethPlot);
// 나중에
release();

되울림은 플래그 하나로 끊는다 — 반영이 동기라 그 플래그가 프레임을 넘지 않는다. 실행되는 전체 예제는 동기화된 두 차트 — syncX에 있다.

crosshair는 상태가 아니다

십자선은 픽셀로 살고, URL 저장도 undo도 원하지 않는다. 두 차트의 십자선을 맞추고 싶다면 crosshair 이벤트(데이터 x를 준다)와 crosshairLine.follow() 로 이미 가능하다 — ChartState에 넣지 않은 이유는 직렬화가 처음부터 "이 조각은 빼고"를 알아야 하는 부담을 지지 않기 위해서다.