스파크라인 — 프리셋을 안 쓰는 배선
종목 리스트 한 행 옆의 작은 라인 차트. 축도, 십자선도, pan/zoom도 없다.
이 문서가 따로 있는 이유는 browserDeps()가 이 자리의 답이 아니기 때문이다. 프리셋은 흔한 배선 전부를 싣는다 — 축 라벨, 구분자, 포인터 입력, 프레임 스케줄러, getComputedStyle 리더. 스파크라인은 그중 무엇도 안 쓰는데, 프리셋을 부르면 전부 들어온다. 끄는 방법은 안 넣는 것이다 (ADR-0019).
이 문서의 코드는
packages/dom/src/__tests__/sparkline.types.ts에서 컴파일된다. 문서가 낡으면 빌드가 깨진다.
레시피
import type { LineDataPoint } from "@finchart/core";
import {
createCanvasRenderer,
createPlotDeps,
lineSeries,
noStyle,
Plot,
} from "@finchart/core";
import { createDomLayers, observeDevicePixelRatio } from "@finchart/dom";
declare const row: HTMLElement;
declare const closes: LineDataPoint[];
const deps = createPlotDeps({
createLayers: (width, height) => createDomLayers(row, width, height),
createRenderer: createCanvasRenderer,
createStyleReader: () => noStyle,
observeResolution: observeDevicePixelRatio(row),
});
const sparkline = new Plot({
deps,
config: {
showGrid: false,
padding: { top: 2, right: 2, bottom: 2, left: 2 },
},
size: { width: 120, height: 32 },
});
sparkline.mainPane.addSeries({
series: lineSeries({ point: { radius: 0 }, line: { width: 1.5 } }),
data: closes,
});
sparkline.render();끝났을 때 sparkline.destroy()를 부른다 — 캔버스와 관찰자를 거둔다. 리스트가 사는 곳이 정한다: SPA면 프레임워크의 언마운트 훅에서 (React useEffect의 cleanup, Vue onUnmounted), 순수 페이지면 행을 DOM에서 떼는 그 자리에서다. 행 하나에 무대 하나이므로 거두는 것도 행 단위다 — 리스트를 다시 그릴 때 살아남은 행의 무대는 그대로 둔다.
무엇을 넣었고 무엇을 안 넣었나
createPlotDeps가 요구하는 필수는 셋뿐이다.
| 자리 | 무엇 | 왜 필수인가 |
|---|---|---|
createLayers | 어디에 그리는가 | 표면이 없으면 무대가 아니다 |
createRenderer | 무엇으로 그리는가 | 폴백을 두면 안 쓰는 사람의 번들에도 캔버스가 들어온다 |
createStyleReader | 어떤 스타일로 | 위와 같다 — 폴백이 getComputedStyle을 끌고 왔었다 |
나머지는 createPlotDeps가 브라우저를 모르는 기본값으로 채운다: x·y 스케일(LinearScale)과 데이터 매니저(M4 솎기). 손으로 적을 것이 없다.
그리고 안 넣은 것이 곧 없는 것이다:
| 안 넣은 것 | 결과 |
|---|---|
createAxisLabels | 눈금 라벨이 없고 축이 자리를 안 뗀다 — 슬라이스가 0이다 (ADR-0023) |
createDividers | pane 손잡이가 없다 (pane이 하나니 놓을 자리도 없다) |
interactions | pan·zoom·십자선이 없다. 행 안에서 휠이 페이지 스크롤과 싸울 자리가 구조적으로 없다 |
createScheduler | 즉시 그린다 — render()가 그 줄에서 끝난다 (→ 실시간이라면) |
createTextMeasurer | 잴 글자가 없다 |
뒤집어야 하는 기본값 둘
기본값은 "축이 있는 큰 차트"를 기준으로 골라져 있다. 스파크라인에서는 둘이 틀린다.
point.radius는 기본 3이다. 폭 120px에 30점이면 4px 간격에 지름 6px 원이라 원끼리 겹쳐 선이 굵은 소시지가 된다. 0이면 원을 아예 발행하지 않는다(line-series.ts의 radius <= 0 분기) — 명령 수가 점 수만큼 준다.
lineSeries({ point: { radius: 0 } })showGrid는 기본 true다. 32px 높이에서 그리드선은 그림을 덮는다.
config: { showGrid: false, ... }캔버스로 괜찮은가 — 메모리
괜찮다. 비트맵은 면적에 비례하고, 스파크라인은 면적이 작다.
백킹 스토어는 round(w×dpr) × round(h×dpr) × 4B다 (ADR-0003). 120×32 행 기준:
| DPR 1 | DPR 2 | DPR 3 | |
|---|---|---|---|
| 1개 | 15.0 KB | 60.0 KB | 135 KB |
| 50행 | 0.73 MB | 2.93 MB | 6.59 MB |
| 200행 | 2.93 MB | 11.72 MB | 26.4 MB |
같은 DPR 2에서 메인 차트 한 장(1200×600)이 10.99 MB다. 즉 50행 리스트 전체가 큰 차트 하나의 **27%**고, 200행을 다 띄워야 겨우 같아진다. 비율이 직관과 거꾸로다 — 걱정할 것은 작은 차트 여럿이 아니라 큰 차트 하나다.
시간은 쟀다
위는 비트맵 산수다. 프레임과 콜드 스타트는 실측했다 — 무대는 이 문서의 레시피 그대로이고, 한 프레임에 모든 행이 틱을 받고 모든 행이 그려진다 (DPR 1, 1200×600, 같은 세션 3회 런).
| 콜드 스타트 | 프레임 (전 행 동시 틱) | |
|---|---|---|
| 50행 × 30점 | 1.10~1.20 ms | 0.20~0.30 ms |
| 50행 × 250점 | — | 1.00 ms |
| 200행 × 30점 | 4.80~4.90 ms | 0.90 ms |
| (대조) 100k 캔들 + 지표 4개 · 하나 | 14.90 ms | 3.30 ms |
200행 리스트 전체가 지표 넷 얹은 큰 차트 하나보다 싸다. 인스턴스 하나의 한계 비용은 콜드 ~25 µs, 프레임 ~4 µs로 선형이다. 200행이 한 프레임에 전부 갱신돼도 60Hz 예산의 **5%**다.
1년치 일봉(250점)도 안 솎인다 — 폭 120px의 임계는 width × pointsPerPixel = 480점이라 그 아래다. 250점 × 50행 = 12,500점이 그대로 발행된 것이 실측값이다.
아직 안 잰 것 둘. rAF 팬아웃 자체(하네스가 즉시 스케줄러라 렌더 작업만 잡는다)와 JS 힙(
performance.memory가 Chrome 캐시로 안 움직여 CDP가 필요하다). 비트맵과 시간은 위에서 닫혔다. 원본: performance.md의 2026-08-17 행.
SVG 렌더러는 없고, 이 유스케이스 때문에 만들 계획도 없다. 측정이 그 이유를 하나 더 준다 — 리스트에서는 commit(캔버스 재생)이 프레임의 **20~37%**라, 표면을 갈아 끼워도 손댈 수 있는 몫이 3분의 1 남짓이다. 나머지는 서술과 인스턴스 오버헤드다.
이 숫자는 2026-08-21에 정정됐다 — 앞서 여기 "12~19%"라고 적혀 있었다. 벤치의 계측 래퍼가
Renderer.clip을 빠뜨려서, 재생 단계 자체가 프로덕션과 다른 렌더러로 측정한 값이었다. clip이 무는 자리는 대형 캔버스가 아니라 작은 캔버스다 — pane당 네댓 번은 수천~수만 명령짜리 프레임에서는 묻히지만 120×32px 스파크라인에서는 재생 시간의 상당 부분이 된다. 실측으로 큰 차트 17종은 commitShare가 하나도 안 바뀌었고(그래서performance.md의 값은 그대로다) 스파크라인 3종만 22→36 · 17→20 · 23→37%로 같이 뛰었다.결론은 안 뒤집힌다. 3분의 1이어도 표면 교체로 손댈 수 있는 몫이 절반을 못 넘는다는 판단은 그대로다 — 틀린 것은 근거 숫자이지 결정이 아니다.
SSR 첫 페인트가 필요하면 길은 렌더러가 아니라 명령 목록이다 — DrawCommand[]가 순수 데이터라 문자열로 직렬화할 수 있다. 아직 안 만들었다.
해상도 관찰자를 빼먹지 않는다
observeResolution은 스파크라인에서 유일하게 꼭 필요한 관찰자다.
백킹 스토어를 다시 잡는 것은 Plot.render() 안의 layers.resize()이고, render()는 뭔가 바뀌어야 예약된다. 인터랙티브 차트는 호버 한 번으로 자가 치유되지만 다시 그릴 일이 없는 차트에는 그 계기가 영영 안 온다 — 모니터를 옮기거나 페이지를 확대한 순간부터 리스트 전체가 뭉갠 채 굳는다.
autoSize는 이것을 못 고친다. ResizeObserver는 contentRect, 즉 CSS 픽셀을 보는데 배율만 바뀔 때 그 값은 안 변한다.
browserDeps()는 이 자리를 기본으로 채우지만 createPlotDeps는 안 채운다 — 손으로 배선하는 쪽이 명시하는 것이 이 함수의 계약이다.
실시간으로 갱신한다면
기본은 즉시 렌더라 setData 한 번이 곧 한 프레임이다. 시세가 흐르면 프레임당 한 번으로 합친다:
import { frameScheduler } from "@finchart/core";
const deps = createPlotDeps({
// …위와 같고,
createScheduler: frameScheduler(),
});데이터는 등록이 돌려주는 손잡이로 간다 — 무대에는 그 메서드가 없다.
const handle = sparkline.mainPane.addSeries({ series: lineSeries(...), data: closes });
handle.append([tick]); // 새 점
handle.updateLast(tick); // 진행 중인 점의 갱신
handle.setData(next); // 통째로 (두 축을 다시 맞춘다)언제 browserDeps로 돌아가나
행을 클릭하면 뜨는 큰 차트는 프리셋의 자리다. 축·십자선·툴팁·pan/zoom이 전부 필요하고, 그때는 협력자를 손으로 세는 것이 손해다.
두 차트가 같은 페이지에 있어도 배선은 서로 무관하다 — 배선은 인스턴스마다 고르는 것이고 전역 레지스트리가 없다(ADR-0015).
Related
- ADR-0019 — 협력자는 선택이다. 축도 입력도 없는 차트가 정상 상태인 근거
- ADR-0023 — 라벨 협력자가 없으면 축 슬라이스가 0
- ADR-0003 — 백킹 스토어와 해상도 관찰
- ADR-0012 — 좁은 폭에서 점이 솎여도 극값은 남는다
packages/core/src/plot/__tests__/minimal-deps.test.ts— 이 배선을 지키는 테스트