드로잉 툴
버튼을 누르고 차트를 클릭해 긋는다 — 수평선은 한 번, 추세선·피보나치는 두 번. Esc가 취소, 선택 후 Delete가 삭제, 끝점을 끌면 편집이다. `]`·`[`로 포인터 없이 선택을 순회한다(키가 오려면 먼저 차트를 한 번 클릭해 포커스를 줘야 한다). 모바일 등 키보드가 없는 환경을 위해 아래 버튼 셋(선택 해제·삭제·전체 지우기)도 같은 일을 한다. 그은 것은 localStorage에 저장된다 — serialize()/load() 왕복이라 새로고침·테마 전환(재마운트)에도 남는다. 드래그 이동은 reason "move"라 저장만 미룬다. 자석을 켜면 작도·끝점 드래그가 봉의 종가·저가·고가(와 봉 x)에 붙는다 — 고점을 픽셀 운에 안 맡긴다.
소스
apps/examples/src/cases/drawing.ts — CI가 타입체크하는 실물이다.
ts
import { PlotBuilder, browserDeps } from "@finchart/dom";
import type { OHLC } from "@finchart/core";
import { candleSeries, crosshair, priceFormat, timeTicks } from "@finchart/core";
import { drawingTools } from "@finchart/tools";
import { fixtureCandles } from "./fixture";
import { chartHost } from "./stage";
export const title = "드로잉 툴";
export const description =
"버튼을 누르고 차트를 클릭해 긋는다 — 수평선은 한 번, 추세선·피보나치는 두 번. Esc가 취소, 선택 후 Delete가 삭제, 끝점을 끌면 편집이다. " +
"`]`·`[`로 포인터 없이 선택을 순회한다(키가 오려면 먼저 차트를 한 번 클릭해 포커스를 줘야 한다). " +
"모바일 등 키보드가 없는 환경을 위해 아래 버튼 셋(선택 해제·삭제·전체 지우기)도 같은 일을 한다. " +
"그은 것은 localStorage에 저장된다 — serialize()/load() 왕복이라 새로고침·테마 전환(재마운트)에도 남는다. 드래그 이동은 reason \"move\"라 저장만 미룬다. " +
"자석을 켜면 작도·끝점 드래그가 봉의 종가·저가·고가(와 봉 x)에 붙는다 — 고점을 픽셀 운에 안 맡긴다.";
/** 눌린 버튼의 배경 — 셸 CSS 없이도 케이스 혼자 보인다(senior-review-2026-08-13 D3). */
function paintPressed(el: HTMLButtonElement, pressed: boolean): void {
el.style.background = pressed ? "#3b82f6" : "";
el.style.color = pressed ? "#fff" : "";
}
export function mount(container: HTMLElement): () => void {
const toolbar = document.createElement("div");
toolbar.setAttribute("role", "group");
toolbar.setAttribute("aria-label", "드로잉 도구");
toolbar.style.cssText = "display: flex; gap: 8px; margin-bottom: 8px; flex-wrap: wrap";
container.append(toolbar);
const host = chartHost(container, 480);
const plot = PlotBuilder.create<OHLC>(browserDeps({ autoSize: true }))
.setSize(container.clientWidth || 900, 480)
.setAxis({
x: { ticks: timeTicks({ timeZone: "UTC", locale: "ko" }) },
y: { position: "right", format: priceFormat({ compact: true, locale: "ko" }) },
})
.build(host);
plot.mainPane.addSeries({
series: candleSeries(),
data: fixtureCandles(),
name: "가격",
});
plot.use(crosshair({ magnet: true }));
const tools = plot.mainPane.use(drawingTools({ plot }));
/**
* 직렬화 왕복 — 앱은 이 위층(`tools.serialize()`/`load()`)만 쓰면 된다.
* 저수준 `serializeDrawings`/`parseDrawings`(드로잉 배열 ↔ 문자열)는
* 자기 저장 형식을 가진 앱의 것이다. `load`는 못 읽으면 false를
* 돌려주고 기존 목록을 지킨다 — 낡은 저장본이 무대를 못 깨뜨린다.
*/
const STORAGE_KEY = "charts-case-drawings";
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) tools.load(saved);
// 드래그 이동은 pointermove마다 울리므로(reason "move") 그것만 미룬다.
let saveTimer: number | undefined;
tools.changes.subscribe(({ reason }) => {
if (reason !== "move") {
localStorage.setItem(STORAGE_KEY, tools.serialize());
return;
}
clearTimeout(saveTimer);
saveTimer = window.setTimeout(
() => localStorage.setItem(STORAGE_KEY, tools.serialize()),
200,
);
});
const buttons = new Map<string, HTMLButtonElement>();
for (const [label, kind] of [
["수평선", "horizontal"],
["추세선", "trend"],
["피보나치", "fib"],
] as const) {
const el = document.createElement("button");
el.textContent = label;
el.addEventListener("click", () => {
if (tools.mode() === kind) tools.cancel();
else tools.begin(kind);
});
toolbar.appendChild(el);
buttons.set(kind, el);
}
tools.modeChanges.subscribe(({ mode }) => {
for (const [kind, el] of buttons) {
const pressed = mode === kind;
el.setAttribute("aria-pressed", String(pressed));
paintPressed(el, pressed);
}
host.style.cursor = mode ? "crosshair" : "";
});
// 키보드 없는 환경(터치)에서도 선택·삭제에 닿는 문 — 드로잉 시니어
// 리뷰(2026-08-13)가 "폰에서 그은 선을 지울 방법이 없다"고 잡은 자리.
// Delete는 새 API가 아니라 실제 키가 지나는 것과 같은 문(routeInput)을
// 쓴다 — 아무것도 선택 안 됐으면 그냥 아무 일도 안 한다.
const deselect = document.createElement("button");
deselect.textContent = "선택 해제";
deselect.addEventListener("click", () => tools.select(null));
const remove = document.createElement("button");
remove.textContent = "삭제";
remove.title = "선택된 드로잉 삭제 (Delete와 같다)";
remove.addEventListener("click", () => {
plot.routeInput({ type: "keydown", key: "Delete" });
});
const clearAll = document.createElement("button");
clearAll.textContent = "전체 지우기";
clearAll.addEventListener("click", () => tools.clear());
// 자석 — 작도·앵커 드래그가 봉의 값(종가·저가·고가)과 봉 x에 붙는다.
// 기본 꺼짐이라 켜는 버튼이 있어야 실물이 보인다 (16차).
const magnet = document.createElement("button");
magnet.textContent = "자석";
magnet.title = "봉의 값에 스냅 — 고점·저점을 정확히 잡는다";
magnet.addEventListener("click", () => {
tools.setSnap(!tools.snapping());
magnet.setAttribute("aria-pressed", String(tools.snapping()));
paintPressed(magnet, tools.snapping());
});
toolbar.append(deselect, remove, clearAll, magnet);
return Object.assign(
() => {
clearTimeout(saveTimer); // 미룬 저장이 죽은 tools를 부르지 않게
plot.destroy(); // 플러그인(tools 포함)은 destroy가 걷는다
toolbar.remove();
host.remove();
},
{ requestRender: () => plot.requestRender() },
);
}