Skip to content

드로잉 툴

버튼을 누르고 차트를 클릭해 긋는다 — 수평선은 한 번, 추세선·피보나치는 두 번. 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() },
  );
}