Skip to content

테마 — 색·선폭의 주인은 소비자다

라이브러리는 프리셋을 배포하지 않는다. 브라우저에서는 컨테이너에 CSS 변수를 얹으면 되고, CSS가 없는 곳(서버 PNG·worker·테스트)에서는 같은 키를 StyleReader 주입으로 준다.

css
.chart {
  --chart-candle-up: #16a34a;
  --chart-candle-down: #dc2626;
  --chart-grid: #e2e8f0;
  --chart-crosshair: #94a3b8;
}

우선순위는 config 덮어쓰기 > CSS 변수 > 기본값이다. 값이 없으면 기본값으로 떨어진다. 읽을 수 없는 값은 어디에 그리느냐에 따라 다르다:

캔버스 (캔들·라인·그리드·크로스헤어…)DOM (축 라벨·툴팁·레전드)
숫자 자리맨 숫자와 px만 받는다 — 0.5rem·60%기본값
색 자리브라우저가 거절하면 그 요소를 안 그린다var()를 브라우저가 풀므로 CSS 규칙 그대로
light-dark(…)·color-mix(…)캔버스가 못 읽는다 — 안 그린다동작한다
오타(nope)안 그린다속성 종류마다 다르다 — 아래

경로는 사실 셋이다. 크로스헤어 배지·가격선 라벨은 DOM에 살지만 var()가 아니라 확정된 값으로 넘어간다 — 유효성은 DOM 규칙을 따르는데 (light-dark()동작한다) 갱신은 캔버스처럼 requestRender()를 기다린다. 두 열이 서로 반대로 말하는 성질을 하나씩 나눠 가진 셈이라, 위 표만 보고 배지를 캔버스 편으로 분류하면 반대로 진단한다.

유효성갱신
캔버스 (캔들·라인·그리드…)캔버스가 거절하면 안 그린다requestRender()
DOM + var() (축 라벨·툴팁·레전드)CSS 규칙 그대로즉시
DOM + 확정된 값 (배지·가격선 라벨)CSS 규칙 그대로requestRender()

DOM 쪽의 오타는 한 줄로 못 적는다. DOM 잎 여섯을 전수로 재니 문장이 넷에만 참이었다:

DOM 잎이 선언하는 속성오타를 넣으면
색·글자 크기(상속 속성)페이지에서 상속받은 값이 보인다
배경상속이 아니라 초기값 — 투명해진다. 툴팁 배경이 통째로 사라져 밝은 글자가 차트 위에 뜬다
패밀리nope는 유효한 이름이라 무효화되지 않는다 — 그런 폰트가 없어서 브라우저 기본 폰트로 그려진다

그래서 light-dark()를 쓰면 툴팁·레전드는 멀쩡한데 그리드·캔들만 사라진다. 두 경로에 같은 값을 쓰려면 #rrggbb·rgb()처럼 캔버스도 읽는 문법을 고른다 (→ ADR-0031).

캔버스 쪽이 *"안 그린다"*인 것은 직전에 그린 것의 색을 물려받아 엉뚱한 색으로 칠해지던 것을 고친 결과다 — 실제 크로미움 픽셀로 e2e/specs/canvas.spec.ts가 지킨다 — 어떻게 그 우선순위가 지켜지는지는 rendering.md(기여자 문서)에 있다.

읽는 변수

아래 표는 style-vars.test.ts가 소스와 기계로 대조한다 — 표에 있는데 코드에 없거나, 코드에 있는데 표에 없으면 테스트가 잡는다. 오타를 내려면 두 파일에 똑같이 틀리게 적어야 한다.

변수대상
--chart-line, --chart-line-width, --chart-line-dash라인
--chart-point, --chart-point-radius데이터 포인트
--chart-candle-up, --chart-candle-down, --chart-candle-wick-width, --chart-candle-body-ratio캔들
--chart-bar-up, --chart-bar-downOHLC 바의 색
--chart-bar-line-widthOHLC 바의 획 굵기(px) — 세로선과 틱
--chart-bar-tick-ratioOHLC 바의 가로 폭 — 슬롯 폭 대비 틱 길이(0~1)
--chart-area, --chart-area-bottom, --chart-area-line, --chart-area-line-width, --chart-area-line-dasharea (--chart-area-bottom을 주면 위→아래 세로 그라디언트)
--chart-baseline-top, --chart-baseline-bottombaseline의 위/아래 선 색 (기준값보다 위/아래)
--chart-baseline-top-fill, --chart-baseline-bottom-fillbaseline의 위/아래 면 색
--chart-baseline-line-widthbaseline의 위/아래 데이터 선의 획 굵기(px) — 기준선이 아니다. baseline 옵션이 정하는 그 선은 그리지 않는다
--chart-histogram, --chart-histogram-bar-ratio히스토그램
--chart-grid, --chart-grid-width, --chart-grid-dash그리드
--chart-pane-divider, --chart-pane-divider-widthpane 경계선 (pane이 둘 이상일 때)
--chart-crosshair, --chart-crosshair-width, --chart-crosshair-dash크로스헤어 선
--chart-crosshair-badge, --chart-crosshair-badge-back크로스헤어 축 배지
--chart-price-line, --chart-price-line-width, --chart-price-line-dash, --chart-marker, --chart-watermark, --chart-span표준 장식
--chart-label, --chart-label-font-size, --chart-label-font-family축 라벨
--chart-tooltip, --chart-tooltip-back툴팁 (DOM)
--chart-legend레전드 (DOM)
--chart-band밴드·채널 채우기 (@finchart/indicators)
--chart-profile, --chart-profile-pocVolume Profile 막대·POC (@finchart/indicators)
--chart-drawing, --chart-drawing-width, --chart-drawing-dash드로잉 툴 (@finchart/tools)

값의 종류 — 이름이 말한다

접미
-width · -radiuspx 스칼라. 맨 숫자나 px--chart-line-width: 2 · 2px
-ratio무단위 0..1. 벗어나면 잘린다(clamp)--chart-candle-body-ratio: 0.6
-dashCSS dash 리스트. 음수는 무시된다--chart-grid-dash: 4,4
-font-size · -font-familyCSS 값 그대로. 단위가 필수--chart-label-font-size: 12px
나머지--chart-candle-up: #16a34a

-font-size만 단위가 필수인 것은 그 값이 ctx.font 단축 문법으로 그대로 가기 때문이다 — 12는 CSS 폰트로 유효하지 않아 기본값으로 떨어진다. 폭·반지름은 캔버스 좌표가 이미 px라 맨 숫자를 받는다.

기본값은 여기 안 적혀 있다. 47개를 손으로 옮기면 그 표가 곧 낡고, 이 문서의 다른 표들과 달리 기계가 지켜 주지 못한다(스펙 대부분이 공개 표면에 없다). 시리즈 여섯과 무대, 축 라벨의 기본값은 런타임에 찍어 볼 수 있다DEFAULT_LINE_STYLE·DEFAULT_AREA_STYLE·DEFAULT_BAR_STYLE· DEFAULT_BASELINE_STYLE·DEFAULT_CANDLE_STYLE·DEFAULT_HISTOGRAM_STYLE· DEFAULT_PLOT_STYLE·AXIS_LABEL_SPEC이 전부 공개다. 소스를 열 필요가 없다.

확장 패키지도 같다 — @finchart/indicatorsVOLUME_PROFILE_STYLE_SPEC· BAND_STYLE_SPEC, @finchart/toolsDRAWING_STYLE_SPEC. 값을 발명하면 선폭이 미묘하게 달라진 것을 코드리뷰에서 아무도 못 잡는다. 남는 공백은 장식 쪽(크로스헤어·배지·가격선·마커·워터마크·구간·툴팁·레전드)이고, 그 스펙들을 공개할지는 배포 후 큐에 있다.

DOM 오버레이에 사는 것만 예외다 — 축 라벨·툴팁·레전드는 var()를 그대로 넘기고 해석은 브라우저가 한다. 그래서 그것들의 색은 CSS 변수를 바꾸면 재렌더 없이 따라오지만, Canvas에 그려지는 것들(캔들·라인· 그리드…)은 다음 렌더에서 반영된다 — 아래 다크 전환 레시피의 두 번째 줄(requestRender())이 필요한 이유가 이것이다.

폰트는 크기와 패밀리를 따로 받는다. 패밀리의 기본값은 inherit이라 아무것도 설정하지 않으면 차트를 얹은 페이지의 폰트를 따라간다 — DOM에 사는 글자(레전드·툴팁·DOM 축 라벨)도, 캔버스에 그려지는 글자(캔버스 축 라벨·마커 캡션·피보나치 레벨)도 마찬가지다.

캔버스의 ctx.fontinherit을 모른다. 그래서 캔버스 쪽은 코어의 labelFontFamily(readStyle)변수 → 컨테이너의 계산된 font-familysans-serif 순으로 풀어 inherit을 실현한다. 커스텀 시리즈·장식이 글자를 그릴 때도 이것을 쓴다 — 안 쓰면 그 글자만 페이지 폰트를 안 따라온다.

DOM 훅 — pane 구분자 손잡이

pane 사이의 경계선은 캔버스가 그린다(--chart-pane-divider, 위 표). 끌 수 있다는 표시는 별개다 — 오버레이의 투명한 손잡이가 [data-chart-divider] 속성을 달고 있고, 드래그 중에는 [data-dragging]이 붙는다. 상태 표시를 :hover에만 얹으면 드래그 중 포인터가 손잡이를 벗어날 때마다 깜빡이므로 둘을 같이 얹는 것이 레시피다:

css
.my-chart [data-chart-divider]:hover,
.my-chart [data-chart-divider][data-dragging] {
  background: rgba(41, 98, 255, 0.22);
}

실증: 쇼케이스(apps/showcase/src/style.css)가 정확히 이 레시피다.

CSS 없이 테마하기 — headless

코어에 CSS는 없다. 스타일은 StyleReader(name) => string — 로 읽히고, 브라우저 배선(browserDeps)이 그 자리에 계산된 스타일을 읽는 구현(cssReader)을 꽂을 뿐이다. CSS가 없는 환경은 같은 자리에 아무 것이나 꽂으면 된다. 키는 위 표의 --chart-* 그대로다 — 이름 하나가 양 세계에서 통한다.

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

const dark: Record<string, string> = {
  "--chart-candle-up": "#22c55e",
  "--chart-candle-down": "#f87171",
  "--chart-grid": "#1e293b",
  // px 스칼라(-width·-radius)는 맨 숫자도 된다 — 캔버스 좌표가 이미 px다.
  "--chart-line-width": "2",
  // **`-font-size`는 단위가 필수다** — 이 값은 `ctx.font` 단축 문법으로 그대로
  // 간다. `"12"`는 CSS 폰트로 유효하지 않아 캔버스가 조용히 거절한다.
  "--chart-label-font-size": "12px",
};

const model = createPlotModel({
  size: { width: 800, height: 400 },
  series: { series: candleSeries(), data },
  deps: { createStyleReader: () => (name) => dark[name] ?? "" },
});

**여기 오래 "숫자도 문자열로 — 파싱은 코어가 한다"고만 적혀 있었다. 이 절은 모든 값이 필연적으로 문자열이라 그 일반화가 가장 유혹적인 자리인데, -font-size에는 거짓이다. 코어의 applyFont가 자기 머리주석에서 이 문장을 폰트 버그의 출처로 지목하고 있었다: 소비자가 "12"를 쓰면 "12 sans-serif"가 되고 캔버스가 그것을 거절한다. 종류별 규칙은 위 값의 종류 표가 전부이고, 이 절도 그 표를 따른다 — 문자열로 적는다는 것이 아무 문자열이나 된다는 뜻은 아니다.

빈 문자열은 "값 없음"이다 — 그 잎은 기본값으로 떨어진다. 개별 무대 하나만 다르게 입히려면 reader 대신 등록의 options override가 먼저다 (우선순위 표의 1순위).

실증: 예제 갤러리의 worker 렌더 케이스(cases.html#worker-render)가 정확히 이 레시피로 다크를 입는다 — 차트 전체가 worker에 살아 CSS가 없고, 테마는 apps/examples/src/cases/worker-render.worker.ts의 JS 객체다.

브라우저에서는 이 길을 쓰지 말 것. browserDepscreateStyleReader를 받긴 하지만, DOM 오버레이(축 라벨·툴팁·레전드)는 var()를 브라우저가 해석하므로 주입한 reader를 타지 않는다 — canvas와 오버레이의 색이 갈라진다. 브라우저의 테마는 CSS 변수가 정답이고, 주입은 CSS가 없는 곳의 것이다.

다크 팔레트 레퍼런스

다크는 변수를 갈아 끼우는 것으로 끝난다 — 코어가 프레임마다 읽으므로 클래스를 토글하고 requestRender() 한 번이면 그리드·라벨·배지·툴팁· 워터마크·드로잉까지 전부 갈아입는다.

ts
document.body.classList.toggle("dark");
plot.requestRender();

prefers-color-scheme으로 할 때는 배선이 하나 더 필요하다

미디어 쿼리만 쓰면(클래스 토글 없이) 차트가 반쪽만 갈아입는다 — 축 라벨·툴팁·레전드는 즉시 따라오고 캔들·그리드·크로스헤어는 옛 색 그대로 남는다. 변수 값은 이미 도착해 있는데 캔버스를 다시 그릴 사람이 없기 때문이다(위 표의 셋째 열). 소비자 코드가 한 줄도 안 도는 경로라 걸 자리가 없으니, 구독을 직접 놓는다:

ts
const dark = window.matchMedia("(prefers-color-scheme: dark)");
dark.addEventListener("change", () => plot.requestRender());

React라면 useEffect에서 같은 구독을 놓고 plot.requestRender()를 부른다. DPR 변화에 @finchart/domobserveDevicePixelRatio가 하는 일과 같은 모양이다.

색각

아래 팔레트는 #0b1220 배경에서 대비를 검증한 출발점이다 (도그푸딩 화면이 쓰는 값 — 배경은 우리 토큰이 아니라 앱의 것이라 여기 적어만 둔다). 흰 카드 위에 얹으면 --chart-label이 2.56으로 AA에 못 미치니 그때는 색을 다시 골라야 한다. 색만 갈면 된다 — 너비·비율·dash는 테마의 것이 아니다.

색각은 검증하지 않았다. 상승/하락은 이 라이브러리의 주 인코딩인데 그 인코딩이 색 하나뿐이고, 기본값과 아래 팔레트 둘 다 적록이다. 2형 색각 시뮬레이션(Viénot 1999) 후 WCAG 대비:

팔레트상승 / 하락정상2형 색각
코어 기본#16a34a / #dc26261.471.16
아래 다크#22c55e / #f871711.211.01

1.01은 같은 색이라는 뜻이다. 청/주황으로 갈면 갈라진다:

css
.dark .my-chart {
  --chart-candle-up: #2563eb;   --chart-candle-down: #ea580c;
  --chart-bar-up: #2563eb;      --chart-bar-down: #ea580c;
  --chart-baseline-top: #2563eb; --chart-baseline-bottom: #ea580c;
}

기본값을 이걸로 바꾸지 않는 이유는 하나다 — 값의 주인은 소비자이고, 기본값을 옮기면 이미 우리 색에 맞춰 둔 앱이 깨진다.

css
.dark .my-chart {
  /* 배경 요소 */
  --chart-grid: #1e293b;
  --chart-pane-divider: #334155;
  --chart-crosshair: #475569;
  --chart-label: #94a3b8;
  --chart-watermark: rgba(148, 163, 184, 0.1);
  --chart-span: rgba(148, 163, 184, 0.12);

  /* 시리즈 */
  --chart-line: #60a5fa;
  --chart-point: #60a5fa;
  --chart-area: rgba(96, 165, 250, 0.18);
  --chart-area-line: #60a5fa;
  --chart-candle-up: #22c55e;
  --chart-candle-down: #f87171;
  --chart-bar-up: #22c55e;
  --chart-bar-down: #f87171;
  --chart-baseline-top: #34d399;
  --chart-baseline-bottom: #f87171;
  --chart-baseline-top-fill: rgba(52, 211, 153, 0.15);
  --chart-baseline-bottom-fill: rgba(248, 113, 113, 0.15);
  --chart-histogram: rgba(100, 116, 139, 0.5);
  --chart-area-bottom: rgba(96, 165, 250, 0.02);

  /* 장식 — 배지·툴팁은 배경과 글자가 반전 쌍이다 */
  --chart-crosshair-badge: #0f172a;
  --chart-crosshair-badge-back: #94a3b8;
  --chart-tooltip: #0f172a;
  --chart-tooltip-back: rgba(226, 232, 240, 0.92);
  --chart-legend: #cbd5e1;
  --chart-price-line: #f59e0b;
  --chart-marker: #e2e8f0;

  /* 확장 패키지 */
  --chart-band: rgba(96, 165, 250, 0.12);       /* @finchart/indicators */
  --chart-profile: rgba(148, 163, 184, 0.22);  /* @finchart/indicators */
  --chart-profile-poc: rgba(251, 191, 36, 0.5);/* @finchart/indicators */
  --chart-drawing: #818cf8;                 /* @finchart/tools */
}

이 팔레트의 11개가 실사용을 지났다 — 도그푸딩 화면(apps/examples/src/theme.css)이 그만큼을 싣는다. 나머지(베이스라인·에어리어·프로파일 등)는 아직 화면을 안 거쳤다. 그 화면(apps/examples/src/trading.ts)이 이 길로 다크를 전환한다.

관련

  • rendering.md — 이 변수들이 어떻게 확정된 값이 되고 Canvas 명령으로 재생되는지(기여자 문서)
  • glossary.md — 레이어·명령의 뜻