테마 — 색·선폭의 주인은 소비자다
라이브러리는 프리셋을 배포하지 않는다. 브라우저에서는 컨테이너에 CSS 변수를 얹으면 되고, CSS가 없는 곳(서버 PNG·worker·테스트)에서는 같은 키를 StyleReader 주입으로 준다.
.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-down | OHLC 바의 색 |
--chart-bar-line-width | OHLC 바의 획 굵기(px) — 세로선과 틱 |
--chart-bar-tick-ratio | OHLC 바의 가로 폭 — 슬롯 폭 대비 틱 길이(0~1) |
--chart-area, --chart-area-bottom, --chart-area-line, --chart-area-line-width, --chart-area-line-dash | area (--chart-area-bottom을 주면 위→아래 세로 그라디언트) |
--chart-baseline-top, --chart-baseline-bottom | baseline의 위/아래 선 색 (기준값보다 위/아래) |
--chart-baseline-top-fill, --chart-baseline-bottom-fill | baseline의 위/아래 면 색 |
--chart-baseline-line-width | baseline의 위/아래 데이터 선의 획 굵기(px) — 기준선이 아니다. baseline 옵션이 정하는 그 선은 그리지 않는다 |
--chart-histogram, --chart-histogram-bar-ratio | 히스토그램 |
--chart-grid, --chart-grid-width, --chart-grid-dash | 그리드 |
--chart-pane-divider, --chart-pane-divider-width | pane 경계선 (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-poc | Volume Profile 막대·POC (@finchart/indicators) |
--chart-drawing, --chart-drawing-width, --chart-drawing-dash | 드로잉 툴 (@finchart/tools) |
값의 종류 — 이름이 말한다
| 접미 | 뜻 | 예 |
|---|---|---|
-width · -radius | px 스칼라. 맨 숫자나 px | --chart-line-width: 2 · 2px |
-ratio | 무단위 0..1. 벗어나면 잘린다(clamp) | --chart-candle-body-ratio: 0.6 |
-dash | CSS dash 리스트. 음수는 무시된다 | --chart-grid-dash: 4,4 |
-font-size · -font-family | CSS 값 그대로. 단위가 필수다 | --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/indicators의 VOLUME_PROFILE_STYLE_SPEC· BAND_STYLE_SPEC, @finchart/tools의 DRAWING_STYLE_SPEC. 값을 발명하면 선폭이 미묘하게 달라진 것을 코드리뷰에서 아무도 못 잡는다. 남는 공백은 장식 쪽(크로스헤어·배지·가격선·마커·워터마크·구간·툴팁·레전드)이고, 그 스펙들을 공개할지는 배포 후 큐에 있다.
DOM 오버레이에 사는 것만 예외다 — 축 라벨·툴팁·레전드는 var()를 그대로 넘기고 해석은 브라우저가 한다. 그래서 그것들의 색은 CSS 변수를 바꾸면 재렌더 없이 따라오지만, Canvas에 그려지는 것들(캔들·라인· 그리드…)은 다음 렌더에서 반영된다 — 아래 다크 전환 레시피의 두 번째 줄(requestRender())이 필요한 이유가 이것이다.
폰트는 크기와 패밀리를 따로 받는다. 패밀리의 기본값은 inherit이라 아무것도 설정하지 않으면 차트를 얹은 페이지의 폰트를 따라간다 — DOM에 사는 글자(레전드·툴팁·DOM 축 라벨)도, 캔버스에 그려지는 글자(캔버스 축 라벨·마커 캡션·피보나치 레벨)도 마찬가지다.
캔버스의
ctx.font은inherit을 모른다. 그래서 캔버스 쪽은 코어의labelFontFamily(readStyle)가 변수 → 컨테이너의 계산된font-family→sans-serif순으로 풀어inherit을 실현한다. 커스텀 시리즈·장식이 글자를 그릴 때도 이것을 쓴다 — 안 쓰면 그 글자만 페이지 폰트를 안 따라온다.
DOM 훅 — pane 구분자 손잡이
pane 사이의 경계선은 캔버스가 그린다(--chart-pane-divider, 위 표). 끌 수 있다는 표시는 별개다 — 오버레이의 투명한 손잡이가 [data-chart-divider] 속성을 달고 있고, 드래그 중에는 [data-dragging]이 붙는다. 상태 표시를 :hover에만 얹으면 드래그 중 포인터가 손잡이를 벗어날 때마다 깜빡이므로 둘을 같이 얹는 것이 레시피다:
.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-* 그대로다 — 이름 하나가 양 세계에서 통한다.
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 객체다.
브라우저에서는 이 길을 쓰지 말 것. browserDeps도 createStyleReader를 받긴 하지만, DOM 오버레이(축 라벨·툴팁·레전드)는 var()를 브라우저가 해석하므로 주입한 reader를 타지 않는다 — canvas와 오버레이의 색이 갈라진다. 브라우저의 테마는 CSS 변수가 정답이고, 주입은 CSS가 없는 곳의 것이다.
다크 팔레트 레퍼런스
다크는 변수를 갈아 끼우는 것으로 끝난다 — 코어가 프레임마다 읽으므로 클래스를 토글하고 requestRender() 한 번이면 그리드·라벨·배지·툴팁· 워터마크·드로잉까지 전부 갈아입는다.
document.body.classList.toggle("dark");
plot.requestRender();prefers-color-scheme으로 할 때는 배선이 하나 더 필요하다
미디어 쿼리만 쓰면(클래스 토글 없이) 차트가 반쪽만 갈아입는다 — 축 라벨·툴팁·레전드는 즉시 따라오고 캔들·그리드·크로스헤어는 옛 색 그대로 남는다. 변수 값은 이미 도착해 있는데 캔버스를 다시 그릴 사람이 없기 때문이다(위 표의 셋째 열). 소비자 코드가 한 줄도 안 도는 경로라 걸 자리가 없으니, 구독을 직접 놓는다:
const dark = window.matchMedia("(prefers-color-scheme: dark)");
dark.addEventListener("change", () => plot.requestRender());React라면 useEffect에서 같은 구독을 놓고 plot.requestRender()를 부른다. DPR 변화에 @finchart/dom의 observeDevicePixelRatio가 하는 일과 같은 모양이다.
색각
아래 팔레트는 #0b1220 배경에서 대비를 검증한 출발점이다 (도그푸딩 화면이 쓰는 값 — 배경은 우리 토큰이 아니라 앱의 것이라 여기 적어만 둔다). 흰 카드 위에 얹으면 --chart-label이 2.56으로 AA에 못 미치니 그때는 색을 다시 골라야 한다. 색만 갈면 된다 — 너비·비율·dash는 테마의 것이 아니다.
색각은 검증하지 않았다. 상승/하락은 이 라이브러리의 주 인코딩인데 그 인코딩이 색 하나뿐이고, 기본값과 아래 팔레트 둘 다 적록이다. 2형 색각 시뮬레이션(Viénot 1999) 후 WCAG 대비:
| 팔레트 | 상승 / 하락 | 정상 | 2형 색각 |
|---|---|---|---|
| 코어 기본 | #16a34a / #dc2626 | 1.47 | 1.16 |
| 아래 다크 | #22c55e / #f87171 | 1.21 | 1.01 |
1.01은 같은 색이라는 뜻이다. 청/주황으로 갈면 갈라진다:
.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;
}기본값을 이걸로 바꾸지 않는 이유는 하나다 — 값의 주인은 소비자이고, 기본값을 옮기면 이미 우리 색에 맞춰 둔 앱이 깨진다.
.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 — 레이어·명령의 뜻