Skip to content

스파크라인 — 프리셋을 안 쓰는 배선

종목 리스트 한 행 옆의 작은 라인 차트. 축도, 십자선도, pan/zoom도 없다.

이 문서가 따로 있는 이유는 browserDeps()가 이 자리의 답이 아니기 때문이다. 프리셋은 흔한 배선 전부를 싣는다 — 축 라벨, 구분자, 포인터 입력, 프레임 스케줄러, getComputedStyle 리더. 스파크라인은 그중 무엇도 안 쓰는데, 프리셋을 부르면 전부 들어온다. 끄는 방법은 안 넣는 것이다 (ADR-0019).

이 문서의 코드는 packages/dom/src/__tests__/sparkline.types.ts에서 컴파일된다. 문서가 낡으면 빌드가 깨진다.

레시피

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)
createDividerspane 손잡이가 없다 (pane이 하나니 놓을 자리도 없다)
interactionspan·zoom·십자선이 없다. 행 안에서 휠이 페이지 스크롤과 싸울 자리가 구조적으로 없다
createScheduler즉시 그린다 — render()가 그 줄에서 끝난다 (→ 실시간이라면)
createTextMeasurer잴 글자가 없다

뒤집어야 하는 기본값 둘

기본값은 "축이 있는 큰 차트"를 기준으로 골라져 있다. 스파크라인에서는 둘이 틀린다.

point.radius는 기본 3이다. 폭 120px에 30점이면 4px 간격에 지름 6px 원이라 원끼리 겹쳐 선이 굵은 소시지가 된다. 0이면 원을 아예 발행하지 않는다(line-series.tsradius <= 0 분기) — 명령 수가 점 수만큼 준다.

ts
lineSeries({ point: { radius: 0 } })

showGrid는 기본 true다. 32px 높이에서 그리드선은 그림을 덮는다.

ts
config: { showGrid: false, ... }

캔버스로 괜찮은가 — 메모리

괜찮다. 비트맵은 면적에 비례하고, 스파크라인은 면적이 작다.

백킹 스토어는 round(w×dpr) × round(h×dpr) × 4B다 (ADR-0003). 120×32 행 기준:

DPR 1DPR 2DPR 3
1개15.0 KB60.0 KB135 KB
50행0.73 MB2.93 MB6.59 MB
200행2.93 MB11.72 MB26.4 MB

같은 DPR 2에서 메인 차트 한 장(1200×600)이 10.99 MB다. 즉 50행 리스트 전체가 큰 차트 하나의 **27%**고, 200행을 다 띄워야 겨우 같아진다. 비율이 직관과 거꾸로다 — 걱정할 것은 작은 차트 여럿이 아니라 큰 차트 하나다.

시간은 쟀다

위는 비트맵 산수다. 프레임과 콜드 스타트는 실측했다 — 무대는 이 문서의 레시피 그대로이고, 한 프레임에 모든 행이 틱을 받고 모든 행이 그려진다 (DPR 1, 1200×600, 같은 세션 3회 런).

콜드 스타트프레임 (전 행 동시 틱)
50행 × 30점1.10~1.20 ms0.20~0.30 ms
50행 × 250점1.00 ms
200행 × 30점4.80~4.90 ms0.90 ms
(대조) 100k 캔들 + 지표 4개 · 하나14.90 ms3.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는 이것을 못 고친다. ResizeObservercontentRect, 즉 CSS 픽셀을 보는데 배율만 바뀔 때 그 값은 안 변한다.

browserDeps()는 이 자리를 기본으로 채우지만 createPlotDeps는 안 채운다 — 손으로 배선하는 쪽이 명시하는 것이 이 함수의 계약이다.

실시간으로 갱신한다면

기본은 즉시 렌더라 setData 한 번이 곧 한 프레임이다. 시세가 흐르면 프레임당 한 번으로 합친다:

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

const deps = createPlotDeps({
  // …위와 같고,
  createScheduler: frameScheduler(),
});

데이터는 등록이 돌려주는 손잡이로 간다 — 무대에는 그 메서드가 없다.

ts
const handle = sparkline.mainPane.addSeries({ series: lineSeries(...), data: closes });

handle.append([tick]);      // 새 점
handle.updateLast(tick);    // 진행 중인 점의 갱신
handle.setData(next);       // 통째로 (두 축을 다시 맞춘다)

언제 browserDeps로 돌아가나

행을 클릭하면 뜨는 큰 차트는 프리셋의 자리다. 축·십자선·툴팁·pan/zoom이 전부 필요하고, 그때는 협력자를 손으로 세는 것이 손해다.

두 차트가 같은 페이지에 있어도 배선은 서로 무관하다 — 배선은 인스턴스마다 고르는 것이고 전역 레지스트리가 없다(ADR-0015).

  • ADR-0019 — 협력자는 선택이다. 축도 입력도 없는 차트가 정상 상태인 근거
  • ADR-0023 — 라벨 협력자가 없으면 축 슬라이스가 0
  • ADR-0003 — 백킹 스토어와 해상도 관찰
  • ADR-0012 — 좁은 폭에서 점이 솎여도 극값은 남는다
  • packages/core/src/plot/__tests__/minimal-deps.test.ts — 이 배선을 지키는 테스트