Skip to content

Plot 계약: 무엇이 무엇에서 살아남는가

Plot은 무대다. 표현(Series)과 데이터는 갈아끼워지지만, 무대에 올려둔 것은 갈아끼움에도 살아남는다. 여기서 "무대에 올려둔 것"은 두 가지다.

  • 보고 있는 구간 — x 도메인 (pan/zoom 위치)
  • 오버레이에 붙인 것 — annotation, tooltip 같은 DOM 요소

이 문서는 공개 메서드가 각각 무엇을 건드리는지 한 곳에 모은 것이다. 새 메서드를 추가할 때 기본값은 "건드리지 않는다"여야 한다.

메서드별 영향 범위

메서드데이터x 도메인y 도메인레이어렌더 예약
pane.addSeries({ series, data })그 등록이 갖는다첫 데이터면 리핏리핏1회
handle.setData(data)그 등록만 교체리핏리핏1회
handle.prepend(points)앞에 추가1회
handle.append(points)뒤에 추가1회
handle.updateLast(point)마지막 점 교체(같은 x) 또는 추가(큰 x)—¹1회
handle.dispose()그 등록을 뗀다리핏1회
pane.syncSeries(specs)spec의 data로 교체리핏1회
mainPane.setSeries(reg)그 등록 하나만 남는다리핏1회
mainPane.clearSeries()전부 버린다1회
pane.applyOptions(o)—²1회
addPane(o) / removePane(p)재배분1회
addDecoration(d) / 그 해제 함수1회
구분자 드래그재배분이동마다
fitDomains()리핏리핏1회
scrollToRealTime()이동 (폭 보존, 오른끝 = 라이브)1회
applyOptions(patch)1회
setViewport(size)리사이즈1회
pan / panByPixels이동1회
zoom / zoomAtPixel확대/축소³1회
claimCursor(c) / 그 해제 함수커서만⁴
requestRender()1회
render()지금 그린다
destroy()해체이후 전부 무시

는 건드리지 않는다는 뜻이다. 읽기만 하는 것(getOptions·getSeries· handle.xRange·pane.xRange()·on)은 아무것도 건드리지 않으므로 표에 없다.

¹ 예외 하나 — shiftVisibleRangeOnNewBar를 켰고 마지막 봉을 보고 있었으면, 새 봉이 붙는 순간 창이 그만큼 오른쪽으로 이동한다 (기본 꺼짐, 계획 1.3).

² 옵션은 그리는 점을 건드리지 않으므로 값 축을 그 자리에서 다시 맞추지 않는다 (PaneChange.data가 거짓인 갈래). autoScale이 켜진 pane은 어차피 매 렌더가 보이는 구간으로 맞추고(ADR-0016), 꺼진 pane은 직접 정한 구간을 지키는 것이 요청이다. 같은 이유로 x 인덱스도 다시 세우지 않는다 — 봉 번호 좌표계에서 여백 하나를 바꾸는 값이 전체 점의 병합 정렬이면 안 된다.

³ 줌에는 한계가 있다 (min/maxBarSpacing, ADR-0034) — 봉 번호 좌표계는 min 0.5·max 200(px/봉)이 기본이고, 연속 좌표계는 기본이 없으며, 0은 그 방향의 한계를 끈다. 한계에 닿으면 커서 아래 지점을 고정한 채 폭만 잘리고, 폭이 전부 잘리면 도메인이 그대로라 xDomainChange도 나가지 않는다. 한계를 꺼도 무한은 아니다 — 폭이 부동소수의 바닥(도메인 값의 ulp)에 닿으면 줌인은 조용히 멈춘다. fit·복원(fitDomains·setVisibleRange·상태 복원)은 이 한계를 지나지 않는다.

위치에도 경계가 있다 (ADR-0038) — 제스처는 데이터를 화면 밖으로 못 보낸다. pan은 domain.min ≤ 마지막 봉·domain.max ≥ 첫 봉까지만 밀리고 (화면 폭만큼의 여백은 양방향에 남는다), zoom의 중심은 데이터 범위로 잘린다(허공 앵커 연속 줌인이 차트를 잃지 못한다). 경계 밖을 가리키는 창(허공을 저장한 공유 URL의 복원)에서는 돌아오는 방향만 통과한다. 데이터가 없으면 경계도 없고, 프로그램 경로는 여기도 지나지 않는다.

⁴ 커서 모양의 주장이다 — 그리는 것이 없으니 렌더도 없다. 나중 클레임이 이기고 해제하면 아래 것으로 돌아온다. 드래그 중에 모양을 바꾸려면 새것을 먼저 얹고 옛것을 풀어라(사이에 꼭대기가 안 흔들린다). headless 무대에서도 던지지 않는다 — 보일 화면이 없을 뿐이다. 값은 CSS cursor 그대로 ("grabbing"·"crosshair"·"ns-resize" …).

plot.setData는 없다. 데이터는 무대가 아니라 등록이 갖는다 — 시리즈가 셋인 무대에서 그 호출이 어디로 가는지 정의할 방법이 없기 때문이다. 얹을 때 같이 주고(addSeries({ series, data })), 나중에 갈려면 그때 돌려받은 SeriesHandle을 쓴다.

ts
const btc = pane.addSeries({ series: candleSeries(), data: btcCandles });
const eth = pane.addSeries({ series: lineSeries(), data: ethPrices });

btc.append([tick]);        // btc만 자란다
eth.xRange;                // { min, max } | null — 자기 것만 답한다

언제 그려지는가

표의 "렌더 예약"은 그렸다는 뜻이 아니라 그리기로 했다는 뜻이다. 상태는 호출 즉시 바뀌지만 캔버스는 다음 프레임에 한 번 그려진다.

ts
handle.setData(data);
handle.xRange;         // 바뀌어 있다 — 상태는 동기다
canvas.toDataURL();    // 아직 안 그려져 있다

같은 프레임 안의 요청은 모두 하나로 합쳐진다. pan 중 pointermove가 열두 번 와도, 합성 API가 시리즈를 넷 얹어도 그림은 한 번이다 → ADR-0004

지금 그려야 하면 render()를 직접 부른다. 예약된 프레임은 버려지므로 같은 그림을 두 번 그리지 않는다.

ts
handle.setData(data);
plot.render();         // 지금 그린다
canvas.toDataURL();    // 그려져 있다

시점을 통째로 바꾸려면 PlotDeps.createScheduler를 넣는다. 프리셋 기본값은 frameScheduler()이고, requestAnimationFrame이 없는 곳(node·SSR)에서는 즉시 실행으로 물러난다.

스케줄러언제
frameScheduler(view?)다음 프레임에 한 번 (프리셋 기본값)
immediateScheduler요청 즉시 — 바꾼 다음 줄에서 결과를 봐야 할 때
manualScheduler()테스트가 flush()할 때

destroy()는 예약된 프레임을 취소한다. 이미 치운 오버레이에 라벨을 그리는 일이 없다.

왜 이렇게 되어 있나

  • x를 리핏하는 것은 셋뿐이다 — 첫 데이터가 도착할 때 한 번, 명령형 handle.setData, 그리고 fitDomains(). 나머지는 보던 구간을 지킨다.

    • 첫 한 번이 필요한 이유는 그때까지 x 도메인이 스케일 기본값이라 아무것도 제자리에 없어서다.
    • handle.setData새 데이터셋이라 리핏한다. 같은 데이터셋에 페이지를 이어 붙이는 prepend/append가 리핏하면 과거를 불러올수록 화면이 줌아웃된다 → ADR-0001
    • 선언형(syncSeriesdata)은 리핏하지 않는다. 배열을 다시 넘기는 것만으로는 "교체"와 "앞에 붙임"을 구별할 수 없고, 나중에 얹은 시리즈 때문에 보던 창이 합집합으로 튀어서도 안 된다. 그래서 React에서 무한 스크롤은 setState(prev => [...older, ...prev])가 전부다.
  • setSeries는 y만 리핏한다. 시리즈마다 차지하는 값 범위가 다르다 (라인 = close, 캔들 = low~high). x는 보던 구간이므로 유지한다. 데이터는 등록의 것이라 앞의 등록에서 물려받지 않는다 — 새 등록에 같이 준다.

  • applyOptions는 생략한 것을 건드리지 않는다. axis: { x }를 줘도 y는 그대로다 — 예전 setConfig는 얕은 병합이라 여기서 조용히 날아갔다. 갈아 끼우는 단위는 자리마다 다르다: padding·axis.x·axis.y는 필드 병합, style은 통째로 교체(그래야 style: { grid: {} }로 기본값에 돌아갈 수 있다).

  • applyOptions는 레이어를 다시 만들지 않는다. 그리드 토글 같은 것 때문에 Plot을 새로 만들면 캔버스가 새로 생기면서 pan 위치와 오버레이 annotation이 통째로 날아간다. 호스트(React wrapper 포함)가 Plot을 재생성하지 않고 설정만 바꿀 수 있어야 한다.

  • 축 라벨은 캔버스가 아니라 오버레이에 있다. 재렌더에도 DOM 요소가 살아남는다 (원칙: 레이어 분리).

  • setViewport는 상태만 바꾼다. 캔버스 비트맵은 그리기 직전에 따라온다 — canvas.width에 대입하는 순간 그림이 지워지므로, 지우는 시점과 그리는 시점이 벌어지면 그 사이에 빈 화면이 보인다 (리사이즈 중 깜빡임). 크기 자체는 즉시 반영되므로 레이아웃 계산은 다음 줄에서 바로 새 값을 쓴다.

  • 값 축은 보이는 구간을 따라간다 (PaneOptions.autoScale, 기본 true). x를 확대하면 그 구간의 값들이 pane을 채운다 → ADR-0016

  • 켜져 있는 동안 값 도메인은 상태가 아니라 파생값이다. 매 렌더에 다시 계산되므로 pane.yScale.setDomain()으로 직접 정한 값은 다음 프레임에 덮인다. 직접 정하려면 autoScale: false로 끈다 — 그러면 예전처럼 원본 전체 기준으로 한 번만 맞춘다.

  • destroy() 뒤에는 아무 일도 일어나지 않는다. 렌더도 상태 변경도 조용히 무시된다 — 던지지 않는 이유는 언마운트 도중 늦게 도착한 이벤트 핸들러가 부르는 것이 정상 경로이기 때문이다.

  • 예외는 돌려줄 것이 있는 문이다. addPane()은 무시할 수가 없다 — 무시한다는 것이 곧 살아 있는 척하는 pane을 돌려주는 것이라서다. 그 pane은 paneList에 없어 아무도 안 보지만 addSeries는 통하고, 그 위에 설치된 확장의 dispose는 아무도 부르지 않는다. 조용한 좀비 대신 ContractError를 던진다. 가르는 선은 돌려줄 것이 있는가다: 알림 (render·requestRender·상태 변경)은 무시하면 진짜로 아무 일도 안 일어나고, 만드는 문은 그렇지 않다.

    ts
    const config = await fetchConfig();
    if (!disposed) plot.addPane(config.rsi);   // 늦게 착지하는 쪽이 묻는다

Pane

값 축 하나를 공유하는 시리즈들의 묶음이다. 같은 pane에 여러 시리즈를 넣으면 겹쳐 그려지고, 나중에 넣은 것이 위에 온다.

  • x는 Plot이, y는 Pane이 소유한다. pan/zoom은 x에만 걸리므로 pane이 몇 개든 저절로 같이 움직인다.

  • plot.mainPane은 항상 있다. pane을 따로 만들지 않으면 모든 시리즈가 여기 들어가므로, pane이 하나인 것이 지금까지의 동작 그대로다.

  • pane.valueExtent()는 소속 시리즈들의 합집합이다. 시리즈마다 차지하는 폭이 달라서(라인은 close 한 점, 캔들은 low~high) 합집합이라야 아무것도 잘리지 않는다. 시리즈가 없으면 맞출 대상이 없으므로 y를 건드리지 않는다.

  • 잴 것이 하나도 없으면 null이다. 데이터가 아직 안 온 시리즈가 {0,0}을 내면 합집합이 0까지 끌려 내려가 4만~7만짜리 캔들이 화면 끝에 눌린다.

  • 보이는 구간(Viewport)을 넘기면 그 구간만 잰다. 등록마다 자기 매니저로 자르므로 파생 시리즈는 원본이 아니라 자기가 만든 점을 같은 뷰포트로 잘라서 잰다.

  • pane.xRange()는 소속 시리즈들이 그리는 x의 합집합이다. Plot이 x를 맞출 때와 "그릴 것이 있나"를 물을 때 쓴다 — 데이터가 내려간 뒤로 그 질문에 답할 수 있는 것은 등록뿐이다.

  • 등록은 그릴 점이 어디서 오는지로 셋으로 갈린다. 잘못된 조합은 컴파일에서 걸린다 — 런타임 검사가 아니다.

    갈래series데이터
    그대로Series<TSource>data (자기 것)
    파생Series<TPoint>data + derive
    입력Series<TPoint>input (남의 것)
    ts
    pane.addSeries({ series: lineSeries(), data: candles });  // ✗ 컴파일 에러
    pane.addSeries({ series: lineSeries(), input, data });    // ✗ 컴파일 에러
  • 시리즈를 얹는 길은 둘이다. addSeries손잡이를 돌려주는 명령형 등록이고(dispose는 두 번 불러도 안전하다), syncSeries목록 전체를 배열로 받는다. 둘을 섞으면 syncSeries가 이긴다 — 목록을 소유하기 때문이다. 역할이 갈린다: syncSeries는 목록을, 손잡이는 데이터를 소유한다.

  • 떨어져 나간 손잡이의 쓰기 문 다섯(setData·prepend·append· updateLast·swapSeries)은 ContractError를 던진다. 조용히 넘기면 무대가 실제로 움직였다 — setData의 리핏이 남은 시리즈 기준으로 x 창을 다시 맞춰 잡아둔 pan이 튀었다. 빠지는 문은 둘이다: 직접 부른 dispose()와, 목록을 소유한 syncSeries의 축출. 후자는 부른 적 없는 제거handle.attached로 묻는다 — read()·xRange·dispose()는 떨어진 뒤에도 안전하다. 빈 배열(append([]))도 떨어진 손잡이에서는 던진다.

    ts
    socket.on("tick", (t) => { if (handle.attached) handle.updateLast(t); });
  • syncSeries(specs)의 신원은 spec.id다. 같은 id면 등록을 유지하고 시리즈 참조만 갈아 끼우므로 파생 캐시가 살아남고, 다시 계산할지는 deriveKey가 정한다. 그리는 순서는 배열 순서다 — 조건부로 늦게 들어온 시리즈도 제자리에 놓인다. spec은 seriesSpec()으로 만든다 → ADR-0013

  • Pane은 렌더러도 데이터도 갖고 있지 않아 스스로 다시 그릴 수 없다. 시리즈나 옵션이 바뀌면 구독자에게 알리고(pane.subscribe) Plot이 y를 맞춘 뒤 그린다. 듣는 쪽은 여럿일 수 있고 각자 해제 함수를 받는다 → ADR-0007

계산 노드

한 번 돌고 여러 그림을 먹인다. MACD는 선 하나가 아니라 셋인데(macd·signal· histogram), 갈래마다 derive를 달면 같은 EMA를 세 번 돈다.

ts
const price = pane.addSeries({ series: candleSeries(), data: candles });

const macd = computation({
  inputs: [price],                       // 값이다. 이름으로 지목하지 않는다
  calc: (candles) => ({ macd, signal, histogram }),
});

lower.addSeries({ series: lineSeries(), input: macd.out.signal });
lower.addSeries({ series: lineSeries(), input: macd.out.histogram });
  • 참조는 값이다 → ADR-0015. 오타가 컴파일 에러이고, 없는 것을 참조할 수 없어 순환이 구조적으로 불가능하다. 순환 검사가 필요 없는 이유다.
  • plot.addComputation이 아니다. 입력도 출력도 값이라 무대에 등록할 것이 없다 — 자유 함수이고 Plot API가 하나도 늘지 않는다.
  • 당김이다. 갈래를 읽을 때 입력 배열의 신원이 그대로면 지난 결과를 그대로 준다. 그래서 갈래가 몇이든 데이터 한 번 바뀔 때 계산은 한 번이고, 구독을 배선할 것이 없다.
  • 입력은 배열이다 — 가격과 거래량을 같이 보는 지표가 있고, 하나에서 배열로 넓히는 것은 나중에 호출부를 깨기 때문이다.
  • 원소는 손잡이일 수도 다른 계산의 갈래일 수도 있다. 지표 위의 지표가 공짜로 따라온다.
  • 갈래 이름은 첫 계산에서 정해진다. 입력이 비어도 같은 키를 내야 한다.
  • input으로 그리는 등록은 데이터를 소유하지 않는다. 그 손잡이의 setData·prepend·append는 던진다 — 갈아 끼울 것은 입력이다.

측정: MACD 세 갈래를 프레임마다 다시 계산할 때 derive 셋 32.6ms → 계산 노드 16.1ms (−50.8%), 콜드 스타트는 32.1ms → 17.7ms. 같은 세션 안 짝 비교다.

값 없음 (whitespace)

y: null이 "여기는 값이 없다"다. 자리는 남기고 값만 비운다 — 점을 빼면 선이 그 구간을 가로질러 없는 값을 있는 것처럼 그린다.

ts
const ma = source.map((c, i) => ({ x: c.x, y: i < 19 ? null : average(i) }));
  • 선은 값이 이어지는 구간마다 따로 그려진다 (구멍에서 끊긴다)
  • valueExtent는 구멍을 세지 않는다. 전부 구멍이면 null
  • 솎기는 구멍을 삼키지 않는다 — 이어진 구멍은 하나로 접힌다 → ADR-0012
  • x는 비울 수 없다. 파싱 실패는 DataError다 (정렬 위반과 같은 타입)

파생 시리즈

입력 하나·출력 하나짜리 설탕이다. 갈래가 하나뿐이면 노드를 만들 것 없이 이걸 쓴다.

ts
pane.addSeries({ series, data, derive: (source) => points, coordinates });
  • derive는 보이는 구간이 아니라 그 등록의 data 전체를 받는다. 이동평균처럼 앞을 되돌아봐야 하는 지표가 화면 왼쪽 끝에서 끊기지 않으려면 이래야 한다. derive 없는 시리즈는 예전처럼 잘린 구간을 받는다.
  • 데이터가 바뀔 때만 다시 계산한다. 그리는 경로에서 부르면 데이터 크기가 프레임 예산에 들어온다 — 지표 하나당 O(n)이라 pane을 늘릴수록 곱으로 늘어난다.
  • 증분 계산은 하지 않는다. prepend로 과거가 붙으면 전체를 다시 계산하며, 덕분에 경계 근처 값이 저절로 정확해진다 (20일 이동평균은 앞의 20개가 생겨야 맞는 값이 된다).
  • 파생 결과도 같은 뷰포트 기준으로 잘리고 데시메이션을 거친다. 등록은 파생이든 아니든 전용 DataManager를 갖는다 — 자르는 길이 하나다.
  • 매니저는 한 공장에서 나온다(PlotDeps.createDataManager). 상한·층 같은 무대 정책은 같고, 전략과 밀도는 점 타입을 아는 쪽이 말한다: 등록(decimation) > Series.decimation > 공장 기본(M4 @ 4ppp). 인스턴스를 나눠 쓰지 않는 이유는 저마다 다른 데이터를 들고 있어서다 → ADR-0007
  • valueExtent도 파생 값 기준이다 — 원본이 아니라 자기가 만든 값으로 잰다. 오토스케일이 켜져 있으면 그 값들을 다시 보이는 구간으로 잘라서 잰다. derive가 원본 전체를 받는 것과 헷갈리면 안 된다 — 계산은 전체로, 측정은 보이는 구간으로.

다른 "차트 종류"의 상당수는 새 시리즈가 아니라 이 재료의 조합이다:

  • Scatter — 새 시리즈 타입이 필요 없다. 선을 지우고 점만 남긴다: lineSeries({ line: { color: "transparent" }, point: { radius: 3 } }). @finchart/indicators의 Parabolic SAR가 이미 이 수법으로 점만 찍는다.
  • Heikin-Ashiderive가 정확히 이 자리다. heikinAshi(source): OHLC[](@finchart/indicators)는 원본 OHLC를 평활한 OHLC로 바꾸는 순수 함수라 candleSeries()가 그대로 그린다: pane.addSeries({ series: candleSeries(), data, derive: heikinAshi }).
  • Renko·Kagi·Point&Figure는 이 재료로 안 풀린다 — 봉 하나가 입력 점 하나에 대응하지 않고 가격 움직임으로 브릭이 생기고 죽어서, x가 원본의 x를 못 물려받는다(ADR-0021과 정면으로 부딪힌다). 좌표계 설계가 먼저 필요해 미룬다 — 실수요 기록.

높이

flex 비율로 나누고 minHeight를 바닥으로 둔다. 바닥에 닿은 pane은 고정되고 남은 공간을 나머지가 다시 나눈다. 바닥 합이 전체를 넘으면 다 같이 비례 축소한다 (plot/layout.ts). pane 사이 간격은 PlotConfig.paneGap.

setArearange만 바꾸고 도메인은 건드리지 않는다. 높이가 바뀌어도 보고 있던 값 구간이 유지되고, 다시 맞출 것이 없어 싸다.

  • x는 한 벌이고 모든 pane이 공유한다. 눈금 라벨은 맨 아래 pane 밑에만 놓인다.
  • y는 pane마다다. PlotConfig.axis.y가 기본값이고 PaneOptions.axis가 pane별로 덮어쓴다.
  • 눈금 밀도는 각 pane의 픽셀 높이에서 나온다 — 짧은 pane은 저절로 성겨진다. Axis가 스케일의 range를 읽기 때문에 따로 넘겨줄 것이 없다.
  • 그리드도 pane마다 따로 긋는다. 세로선이 pane 사이 간격을 가로지르면 나뉜 영역이 하나로 보인다.

장식 (Decoration)

무대에 그리지만 데이터의 표현은 아닌 것. Series와 가르는 기준은 하나다 — 값 축 리핏에 참여하지 않는다. 목표가 라인을 시리즈로 등록하면 valueExtent가 y를 자기 쪽으로 끌어당겨 가격이 납작해진다 → ADR-0010

ts
const off = pane.addDecoration(priceLine());                        // 시리즈 위
const off = plot.addDecoration(watermark(), { zIndex: BELOW_SERIES }); // 아래

장식인가 플러그인인가 — addDecoration인가 use인가

도그푸딩(마찰 #2)에서 plot.use(watermark())를 시도하다 막혔다. 선은 이렇다:

  • 장식은 그리기 서술 하나다draw(+axisBadges)만 있고 배선이 없다. addDecoration으로 얹고 반환된 함수로 뗀다 (줄 게 해제뿐이면 함수).
  • 플러그인은 배선 묶음이다 — 장식+구독+DOM처럼 여럿을 설치하고 하나로 해제해야 할 때의 포장. use로 설치하고 **api.dispose()**로 뗀다.
  • 이름으로 구분된다: watermark·priceLine·markers처럼 그리는 것은 장식, crosshair·tooltip·legend·drawingTools처럼 행동하는 것은 플러그인이다. 장식을 use에 넣으면 타입이 막는다 — 어휘를 합치지 않는 이유는 X6의 "확장은 포장된다"가 포장(플러그인)과 재료(장식)를 구분하는 데서 시작하기 때문이다.

무대에 설치하나, pane에 설치하나 — plot.use인가 pane.use인가

플러그인에도 같은 종류의 선이 하나 더 있다. 기준은 무엇에 매달리는가다.

확장어디에
crosshair·tooltip·legendplot.use무대 전체를 가로지른다
attachMacdplot.usepane을 만들어야 해서 PaneHost가 필요하다
attachMovingAverage·attachBollingerBandspane.useaddSeries 하나면 된다
drawingToolspane.use그 pane 위에 긋는다
paneMaximizeplot.use모든 pane의 flex를 봐야 한다(PaneHost) → ADR-0032

pane에 설치한 것은 그 pane과 함께 죽는다. plot.removePane(rsi)가 거기 붙은 확장을 정리한다 — 예전에는 pane? 옵션을 손으로 넘기는 관례였고, pane을 떼도 확장은 아무것도 모른 채 떨어져 나간 pane을 계속 붙잡고 있었다.

pane이 줄 수 없는 것(입력·x 좌표·렌더 요청)은 배선으로 받는다drawingTools({ plot })가 그 모양이다 (원칙 11).

소속이 컨텍스트를 정한다. x는 Plot이, y는 Pane이 소유하므로 y가 필요하면 pane 소속이다.

등록처area받는 것
pane.addDecoration그 pane의 슬라이스pane·yScale·ticks.x·ticks.y
plot.addDecoration전체 그림 영역panes·ticks.x

둘 다 data(보이는 구간)와 readStyle을 받는다. 눈금은 이미 계산된 것이 온다 — 각자 계산하면 라벨과 어긋난다.

표기도 이미 해석된 것이 온다 (F1) — 컨텍스트의 formatX(무대의 config.axis.x.format)와 formatY(그 pane의 y 표기, pane.formatValue). 크로스헤어 배지·툴팁·레전드·priceLine의 기본값이 전부 이걸 읽으므로, 축 포맷 한 자리를 정하면 다섯 표면이 같은 자로 찍힌다. 장식 자체 옵션 (crosshair({format}) 등)은 override로 남는다.

순서 = 중첩

plot 장식 중 z < 0
  for each pane:
      pane 장식 중 z < 0      ← 그리드가 여기(내장, BELOW_SERIES)
      시리즈                  ← 자기들끼리 다시 zIndex 순
      pane 장식 중 z ≥ 0
plot 장식 중 z ≥ 0            ← 크로스헤어

"plot 소속은 바깥, pane 소속은 데이터에 가깝게"는 외울 규칙이 아니라 이 중첩의 결과다.

가르는 값은 zIndex 하나다. 시리즈가 그려지는 자리가 SERIES_Z(= 0)이고, 장식의 z는 그 앞뒤로 끼어든다. 이름 붙은 상수 둘이 흔한 자리다.

상수쓰임
BELOW_SERIES−1000데이터 아래 — 그리드·구간 음영·워터마크
SERIES_Z0시리즈가 그려지는 자리 (기준점)
ABOVE_SERIES1000데이터 위 — 장식의 기본값

같은 z끼리는 등록 순서이고, addSeries와 같은 단점을 물려받는다 — 조건부로 뗐다 붙이면 같은 z의 맨 뒤로 간다. 시리즈 등록도 zIndex를 받는데 (기본 0) 그건 시리즈들 사이의 순서다 — 토글로 늦게 켠 밴드 채우기가 캔들 밑에 깔리는 자리다. 그림의 순서일 뿐, probe·툴팁의 행 순서는 등록 순서 그대로다.

상태를 든 장식

크로스헤어의 커서 위치는 렌더가 아니라 입력에서 온다. 장식이 자기 상태로 들고 있다가 화면에 반영할 때 plot.requestRender()를 부른다 — render()와 달리 같은 프레임의 요청은 하나로 합쳐진다.

ts
const cursor = plot.use(crosshair());   // 얹기 + 구독을 한 번에
cursor.dispose();                       // 해제도 하나로 (plot.destroy()도 뗀다)

축 라벨·구분자는 장식이 아니다

DOM이고, 요소가 프레임을 넘어 살아남고, render/clear/destroy 생명주기다. PlotDeps 팩토리로 따로 주입된다.

이벤트

ts
const off = plot.on("xDomainChange", (payload) => { ... });   // 해제 함수
이벤트언제실려 오는 것
render그림이 실제로 나간 뒤없음
xDomainChange보고 있는 x 구간이 바뀌었을 때startX·endX·dataRange
crosshair커서가 지날 때 — pan 드래그 중에도position·x·pane·value
click · dblclick · contextmenu커서 아래를 눌렀을 때같은 CrosshairPayload
stateChange보는 상태의 조각이 바뀌었을 때ChartState 스냅샷 전체

⁵ pan 중에도 십자선은 포인터 아래다(2026-08-14, T1 관례) — tooltip처럼 crosshair를 구독하는 것은 pan 중에도 갱신이 온다. 예외: 터치 pan(손가락 밑 십자선은 정보가 없다), 핀치, 소비자가 먹은 드래그(도구가 잡은 것 위에 십자선이 낄 이유가 없다)는 이전처럼 조용하다.

클릭 셋은 crosshair와 같은 에코다 — 페이로드가 같은 타입(CrosshairPayload) 이라 아래 crosshair 절이 그대로 적용된다. 십자선을 얹지 않아도 온다.

xDomainChange프레임을 기다리지 않는다. 보고 있는 구간은 상태이고 상태는 동기로 바뀐다 → ADR-0009

  • pan · zoom · 리핏에서 돈다. 값이 실제로 바뀔 때만 돈다.
  • handle.prepend/append와 선언형 data 갱신은 도메인을 안 건드리므로 조용하다. 그래서 핸들러 안에서 과거를 이어 붙여도 재귀하지 않는다 — 무한 히스토리 로딩이 여기 걸린다.
  • startX/endX언제나 데이터의 x다. 봉 번호 좌표계여도 인덱스가 아니다 — dataRange와 단위가 같아야 "끝에 가까운가"를 잴 수 있다 → ADR-0021
  • y 도메인은 알리지 않는다. 시리즈에 맞춰 따라오는 것이지 이동이 아니다.
  • dataRange는 가진 데이터의 x 범위다. 끝에 얼마나 가까운지 페이로드만으로 잰다.

구독의 보장은 셋이다.

  • 핸들러 안에서 자기를 해제해도 뒤의 구독자가 다 불린다. 한 회차는 그 시점의 목록 복사본 위에서 돈다 — effect cleanup이 정확히 이 모양이라 안 그러면 조용히 삼켜진다.
  • emit 도중에 새로 구독한 것은 다음 회차부터 참여한다. 아니면 핸들러가 자기 안에서 구독을 무한히 늘릴 수 있다.
  • 같은 함수를 두 번 등록하면 해제 함수도 둘이고 각자 하나씩 뗀다.
ts
plot.on("xDomainChange", ({ startX, dataRange }) => {
  if (!dataRange || startX - dataRange.min > THRESHOLD) return;
  handle.prepend(await fetchBefore(dataRange.min));   // 선언형이면 setState
});

stateChange — 보는 상태의 거울

getState() / stateChange / applyState(partial) 셋이 한 벌이다 → ADR-0022

ts
const state = plot.getState();     // { xDomain, panes: [{flex, autoScale, valueDomain?, invert?}] }
plot.applyState({ xDomain });      // 준 조각만 반영 — 부분 controlled의 재료
serializeChartState(state);        // URL·저장소용 문자열. 버전은 포맷의 것
  • 데이터 변경(append·prepend·선언형 갱신)에는 울리지 않는다 — 데이터는 상태가 아니다.
  • applyState({ xDomain })은 데이터보다 먼저 와도 된다 — 첫 fit 자리에서 fit 대신 반영된다. URL 복원이 그 순서다.
  • 드래그 중에는 pointermove마다 온다. 저장이 비싸면 듣는 쪽이 미룬다.
  • React는 <ChartContainer state onStateChange>로 같은 것을 조립한다.

crosshair

crosshair 이벤트는 좌표가 아니라 의미를 넘긴다.

ts
{ position, x, pane, value }
  • x는 도메인 좌표이고 pane과 무관하게 같다 — x축이 하나뿐이기 때문이다.
  • value커서가 올라간 pane의 스케일에서 읽는다. pane마다 값 축이 다르므로 어느 pane 위인지 모르면 옳은 숫자를 만들 수 없다.
  • 여백이나 pane 사이 간격이면 panevaluenull이다. 판정은 가로·세로 둘 다 본다.
  • position(화면 좌표)은 그대로 남아 있다.

구분자

pane 사이마다 오버레이에 DOM 손잡이가 놓인다 (PlotConfig.resizablePanes로 끈다). 캔버스가 아니라 DOM인 이유는 커서 모양과 히트 영역을 브라우저에 맡기기 위해서고, 캔버스를 다시 그려도 끌던 중인 손잡이가 사라지지 않기 때문이다.

  • 이동량은 양쪽 minHeight에 걸려 잘린다.
  • 결과는 지금 픽셀 높이를 그대로 flex에 써서 굳힌다. flex는 상대값이라 비율이 남고, 창 크기가 바뀌어도 맞춰둔 비율이 비례해서 따라온다.
  • 건드리지 않은 pane의 flex까지 전부 다시 쓴다. 둘만 픽셀로 바꾸면 나머지가 들고 있던 옛 flex와 단위가 어긋난다.
  • 값 도메인은 건드리지 않는다 — pane.flex를 직접 써서 구독자 알림을 우회한다.
  • 구분자는 pointerdown에서 stopPropagation()을 부른다. 이걸 막지 않으면 컨테이너의 pan 핸들러가 히트 테스트 없이 같이 끌려 나간다 (@finchart/dom의 pointer.ts).

인터랙션 — 무엇이 되고, 어떻게 끄고 켜나

기본 인터랙션은 @finchart/dombrowserDeps()가 배선한다. 미세 조정은 pointer 옵션이다 — 요소는 레시피가 묶으므로 팩토리를 직접 부를 일이 없다 (ADR-0037):

ts
const deps = browserDeps({ pointer: { kineticScroll: true, zoomSpeed: 1.2 } });
옵션기본
pantrue드래그 pan (터치 포함, 놓치지 않게 문서 리스닝)
zoomtrue휠 줌 (커서 x 고정) · 두 손가락 핀치는 x 거리 비
crosshairtruehover가 크로스헤어를 움직인다
doubleClickResettrue더블클릭이 전체 보기로 (fitDomains)
kineticScrollfalse놓으면 관성으로 흐른다 — 정밀 조작에 방해라 기본 꺼짐
keyboardtrue바닥 제스처(←→ pan · +/− 줌)만 켜고 끈다 — 아래 절
zoomSpeed1.1휠 한 칸당 배율

키보드 조작 (접근성)

컨테이너 요소(당신이 build()에 준 것)가 언제나 포커스를 받는다 (tabIndex 0). 캔버스가 아니다 — #chart canvas:focus-visible 셀렉터는 영영 안 맞는다. 탭 순서에서 빼려면 그 요소에 tabindex="-1"을 직접 준다. 포커스 링은 지우지 않는다.

그리고 키는 그 요소가 포커스를 가진 동안만 온다 — 포커스는 당신의 것이다.

이것이 실제로 무는 자리는 하나다: 툴바 버튼으로 도구를 켜는 순간 포커스가 그 버튼으로 간다. 그러면 바로 다음에 누른 Esc는 버튼에서 나와 툴바로 올라갈 뿐, 차트의 리스너를 지나가지 않는다 — 아래 표의 키가 전부 죽는다. 도구를 켜고 마음을 바꿔 Esc를 누르는 것이 가장 흔한 취소 동선이라, 이 한 줄이 없으면 "Esc 취소"가 API로만 참이고 손으로는 거짓이다.

ts
const chartEl = document.getElementById("chart")!;
plotBuilder.build(chartEl);           // 키 리스너와 tabIndex가 여기 붙는다

toolbarButton.addEventListener("click", () => {
  tools.begin("trend");
  chartEl.focus();                    // ← 가져간 포커스를 돌려준다
});

우리가 plot.focus()를 주지 않는 이유는 그 요소를 당신이 이미 쥐고 있어서다 — build()에 넘긴 것이 그것이고, React라면 ref가 그 자리에 내려간다. 우리가 못 하는 일이 아니라 당신이 이미 할 수 있는 일이라, 코어 표면을 파는 대신 여기 적는다.

여러 차트를 나란히 둔다면 포커스를 돌려줄 요소는 *"방금 조작한 차트가 build()에 넘긴 그것"*이다 — 셀(카드) 같은 바깥 래퍼가 아니다. 키는 위로만 버블하므로 래퍼에 포커스를 주면 안쪽 리스너에 안 닿는다. apps/showcase가 이 구분을 keyboardHost로 들고 다닌다.

그리고 그 이름은 당신의 것이다. 우리가 당신의 컨테이너를 탭 정지점으로 만들지만(tabIndex를 우리가 쓴다), 이름도 role도 안 준다 — 차트가 무엇을 그리는지는 우리가 모르기 때문이다. 안 달면 스크린 리더 사용자가 도달하는 것은 이름 없는 컨테이너이고, 읽히는 것은 DOM 오버레이(축 라벨·레전드)가 그대로 노출된 숫자 나열이다. axe·Lighthouse는 "focusable element without accessible name"으로 잡는다.

html
<div id="chart" role="img" aria-label="AAPL 일봉, 2024년 1월 ~ 6월"></div>

레전드·툴팁을 aria-hidden으로 감출지는 앱의 결정이다 — 그 숫자들이 유일한 텍스트 대안일 수도 있다.

keyboard: false가 끄는 것은 아래 표의 "코어" 두 줄뿐이다. tabIndex와 keydown 리스너는 옵션과 무관하게 붙으므로, 꺼도 ⑴ 요소는 여전히 포커스를 받고 ⑵ 입력 스택으로 가는 키(드로잉 툴의 Esc·Delete·]·[)는 그대로 산다. 예전에는 이 옵션이 문 자체를 닫아서, "←→가 페이지 스크롤을 뺏는 게 싫다"고 끈 앱에서 드로잉 편집 키가 전부 죽고 차트가 키보드로 도달조차 안 됐다.

하는 일소유
화면 폭의 5%씩 pan코어
+ 중앙 기준 줌코어
Esc작도 취소 → 드래그 취소 → 선택 해제 (그 순서)드로잉 툴
Esc(드로잉 툴이 놓을 게 없으면) 최대화된 pane 복원paneMaximize(기본, ADR-0032)
Delete Backspace선택된 드로잉 삭제드로잉 툴
] [드로잉 선택 순회 (다음/이전, 끝에서 감긴다)드로잉 툴
pane 더블클릭그 pane 최대화 토글paneMaximize({ gestures: true })옵트인, 기본 꺼짐

임자는 커서 아래 겨루는 자다. 드로잉 툴을 pane마다 하나씩 달면 Delete· ]·[커서가 마지막으로 지난 pane의 것이다. 아무도 안 겨루는 자리(축·여백·도구함 없는 지표 pane) 위에서는 마지막 임자가 그대로다 — 가격 라벨을 읽으러 축으로 갔다가 Delete를 누르는 것이 정상 동선이기 때문이다. Esc만 이 판정을 안 묻는다(탈출 키라 어디서 눌러도 지금 하던 것이 끝난다).

우클릭은 고르되 먹지 않는다 — 선 위면 그 선을 선택하고 빈 곳이면 선택을 푼다(왼쪽 버튼과 같은 규칙). 그리고 false를 돌려주므로 contextmenu 이벤트가 그대로 울린다. 앱은 그 안에서 tools.selection()을 읽어 메뉴를 만든다:

ts
plot.on("contextmenu", ({ position }) => {
  const target = tools.selection();   // 커서 아래 드로잉, 없으면 null
  openMenu(position, target);
});

네이티브 메뉴는 우리가 안 막는다. @finchart/dom은 소비자가 이벤트를 먹었을 때만 preventDefault하는데 도구함은 일부러 안 먹으므로, 앱이 자기 컨테이너에 contextmenu 리스너를 달아 막아야 한다. 안 막으면 앱 메뉴 위에 브라우저 메뉴가 같이 뜬다.

키는 입력 스택(ADR-0025)을 한 번 지난다 — 소비자가 안 먹으면(false) 다음으로, 드로잉 툴이 먼저 붙는 게 보통이라 Esc는 거기부터 paneMaximize 순으로 체인된다. 안 먹은 키는 코어의 pan/zoom으로 간다. Tab은 아무도 안 뺏는다 — 포커스 이동은 브라우저의 것이다. 프로그램으로 선택을 만들 때는 tools.select(handle | null)이다.

pane 더블클릭 토글이 기본이 아닌 이유: doubleClickReset(기본 true, 위 표)이 이미 pane 아무 데나 더블클릭을 전체 보기 리셋으로 쓴다. routeInputfitDomains()보다 먼저 불리므로(ADR-0025) 최대화 쪽이 기본으로 dblclick을 먹으면 그 리셋이 모든 pane 클릭에서 조용히 죽는다 — 그래서 명시로 켜야 한다(ADR-0032).

React 합성 API

jsx
<ChartContainer deps={deps} data={candles} plotRef={ref}>
  <XAxis />
  <Crosshair />
  <ChartPane flex={3}>
    <YAxis />
    <ChartCandles />
    {showMa && (
      <ChartLine
        color="#f59e0b"
        pointRadius={0}
        derive={movingAverage(period)}
        deriveKey={[period]}
      />
    )}
  </ChartPane>
  <ChartPane flex={1}>
    <ChartLine derive={rsi(14)} deriveKey={[14]} />
  </ChartPane>
</ChartContainer>

자식은 DOM을 렌더하지 않는다. 렌더 단계에서 자기가 속한 pane의 수집기에 자리를 잡고, commit 뒤에 수집기가 pane.syncSeries()로 목록을 통째로 넘긴다. 코어는 여전히 프레임워크를 모른다 → ADR-0013

시리즈 컴포넌트는 셋이다. <ChartCandles>·<ChartLine>은 코어 시리즈를 대신 만들어 주고, <ChartSeries series={...}>는 직접 만든 Series를 위한 탈출구다.

쓰는 것언제
<ChartCandles up down wickWidth bodyRatio>OHLC를 캔들로
<ChartLine color width pointRadius coordinates derive deriveKey>점을 선으로, 지표 포함
<ChartSeries series={...}>그리는 법을 직접 짰을 때

시리즈가 아닌 것도 컴포넌트로 있다. 얹는 방식이 둘로 갈린다 — 플러그인은 옵션만 갈아 끼우고, 장식은 참조가 바뀌면 떼었다 붙인다.

쓰는 것무엇
<Crosshair vertical horizontal style badges format>십자선 (플러그인)
<Tooltip formatX formatValue> · <Legend formatValue>커서 값 상자 (플러그인)
<PriceLine> · <Markers items> · <Watermark> · <Span>표준 장식
<ChartData value>이 아래 시리즈가 볼 데이터
  • 색은 CSS 변수가 아니라 인자로 준다. --chart-line 같은 변수는 차트 전체의 기본값이라, 한 pane의 라인 둘을 다르게 칠할 수 없다 — 캔버스에는 시리즈를 집을 요소가 없기 때문이다. "이 지표는 주황"은 color로 준다.

  • <Crosshair>는 시리즈가 아니라 장식이다. 값 축에 참여하지 않으므로 pane 안이든 밖이든 같고, 두 pane을 가로지른다. <ChartContainer onCrosshair>와는 별개라 십자선을 끄고도 커서 값만 받을 수 있다.

  • <ChartPane>mainPane을 그대로 쓴다. Plot에는 mainPane이 항상 있으므로, 새로 만들면 비어 있는 pane 하나가 맨 위에서 자리만 차지한다.

  • <ChartPane> 없이 둔 시리즈는 mainPane으로 간다.

  • <YAxis>는 pane 안이면 그 pane의 설정이고, 밖이면 모든 pane의 기본값이다. <XAxis>는 공유라 하나만 둔다.

  • 신원은 컴포넌트 인스턴스다. 코어가 요구하는 id<ChartSeries>useId로 만들어 넘기므로 쓰는 쪽에는 없다. 같은 자리에 남아 있으면 같은 시리즈이고, 목록으로 그릴 때 자리를 옮기는 것은 React의 key다.

  • 참조를 안정적으로 들고 있을 필요가 없다. 렌더마다 series를 새로 만들어도 같은 자리면 파생 캐시가 남는다. useMemo는 이제 선택이다.

  • derive가 있으면 deriveKey도 필수다 (컴파일에서 걸린다). useMemo의 의존성 배열과 같은 규칙이며, 이 값이 그대로면 파생을 다시 돌리지 않는다.

  • 그리는 순서가 JSX 순서다. 조건부로 껐다 켠 지표도 제자리로 돌아온다. 단, 순서를 아는 곳은 렌더 단계뿐이라 pane이 렌더하지 않은 채 자식만 새로 마운트되면(사이에 낀 컴포넌트가 혼자 상태를 바꾼 경우) 그 시리즈는 맨 뒤에 붙는다. 다음 pane 렌더에서 제자리를 찾는다.

  • data prop은 그대로 시리즈로 흘러간다. 컨테이너가 컨텍스트로 내리고 <ChartSeries>가 자기 spec에 담는다 — 무대에는 데이터를 받을 자리가 없다. 그래서 과거를 이어 붙이는 것은 setState(prev => [...older, ...prev])이고, 선언형은 리핏하지 않으므로 보던 구간이 그대로 남는다.

  • 소스가 여럿이면 시리즈가 자기 것을 말한다. 컨테이너의 data는 무대 전체의 기본값이고, 그 위를 둘이 덮는다 — 시리즈 하나면 data prop, 여럿이 같은 것을 보면 <ChartData value>.

    tsx
    <ChartContainer deps={deps} data={btc}>
      <ChartCandles />                 {/* 컨테이너의 data */}
      <ChartLine data={eth} />         {/* 자기 것 */}
      <ChartData value={eth}>
        <ChartLine derive={ma(20)} deriveKey={[20]} />   {/* 이 아래는 eth */}
      </ChartData>
    </ChartContainer>

    첫 리핏은 먼저 도착한 시리즈에 맞춰진다 — 시리즈마다 자기 effect에서 무대에 오르기 때문이다. 전부 보이게 하려면 fitDomains()를 부른다.

  • 신원은 effect가 증언한다. 렌더 단계는 자리(JSX 순서)만 정하고, 목록에 들이는 것은 commit 뒤다 — 렌더는 커밋을 보장하지 않는다. <Activity mode="hidden"> 안의 시리즈는 렌더는 되지만 무대에 오르지 않는다 → ADR-0013

  • 명령형 API(fitDomains·pan)가 필요하면 plotRef로 받는다.

  • 마운트 전체가 프레임 하나로 합쳐진다. 자식마다 effect가 따로 돌아 렌더를 여러 번 요청하지만 그림은 한 번이다. 대신 첫 그림이 한 프레임 늦으므로, 마운트 직후에 캔버스를 읽는 테스트는 plotRef.current.render()를 부른다.

새 메서드를 추가할 때

  1. 도메인을 바꿔야 할 이유가 분명한가? 아니면 건드리지 않는다.
  2. 시야를 다시 맞추는 것은 호출자가 fitDomains()로 명시적으로 한다.
  3. 렌더는 직접 부르지 말고 scheduleRender()로 예약한다. 여러 단계를 밟아도 프레임 하나로 합쳐진다.
  4. 이 표에 행을 추가한다.

관련

  • 원칙: 상태는 동기 그리기는 프레임, 자동 상호작용, 레이어 분리 — PRINCIPLES.md
  • glossary.md — 용어의 뜻
  • architecture.md — 모듈 레이어와 소유 관계
  • ADR-0001
  • ADR-0004 — 언제 그려지는가
  • ADR-0007 — pane 구독과 파생 매니저
  • ADR-0009 — 보고 있는 구간 이벤트
  • rendering.md — 렌더가 실제로 무엇을 하는가