Files
goldenChart/frontend/src/components/DrawingCanvas.tsx
T
2026-06-06 00:56:45 +09:00

2692 lines
112 KiB
TypeScript

import React, { useRef, useEffect, useCallback, useState } from 'react';
import type { ChartManager } from '../utils/ChartManager';
import type { Drawing, DrawingPoint, DrawingToolId, Theme } from '../types';
import { formatUnixDateTime } from '../utils/timezone';
import { formatUpbitKrwPrice } from '../utils/safeFormat';
// ─── Hit-test ──────────────────────────────────────────────────────────────
const HIT_RADIUS = 7; // px (CSS 좌표)
export function distToSegment(
px: number, py: number,
x0: number, y0: number,
x1: number, y1: number,
): number {
const dx = x1 - x0, dy = y1 - y0;
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) return Math.hypot(px - x0, py - y0);
const t = Math.max(0, Math.min(1, ((px - x0) * dx + (py - y0) * dy) / lenSq));
return Math.hypot(px - x0 - t * dx, py - y0 - t * dy);
}
/** 툴 alias 해소 - 저장된 드로잉의 type을 실제 렌더 type으로 변환 */
function resolveAlias(type: string): string {
return TOOL_ALIAS[type] ?? type;
}
/** 캔버스 CSS 좌표 (cx, cy)가 주어진 Drawing 위에 있으면 true */
export function hitTestDrawing(
cx: number, cy: number,
d: Drawing,
m: ChartManager,
cssW: number,
cssH: number,
): boolean {
if (d.visible === false) return false;
const effectiveType = resolveAlias(d.type);
switch (effectiveType) {
case 'hline': {
if (!d.points[0]) return false;
const y = m.priceToY(d.points[0].price);
return y !== null && Math.abs(cy - y) <= HIT_RADIUS;
}
case 'vline': {
if (!d.points[0]) return false;
const x = m.timeToX(d.points[0].time);
return x !== null && Math.abs(cx - x) <= HIT_RADIUS;
}
case 'trendline':
case 'ray': {
if (d.points.length < 2) return false;
const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price);
const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price);
if (x0 === null || y0 === null || x1 === null || y1 === null) return false;
const dx = x1 - x0, dy = y1 - y0;
const extend = d.type === 'trendline' ? 'both' : 'right';
let sx = x0, sy = y0, ex = x1, ey = y1;
if (extend === 'right' || extend === 'both') {
if (Math.abs(dx) > 0.001) {
const t = (cssW - x0) / dx;
if (t > 1) { ex = cssW; ey = y0 + dy * t; }
}
}
if (extend === 'both') {
if (Math.abs(dx) > 0.001) {
const t = (0 - x0) / dx;
if (t < 0) { sx = 0; sy = y0 + dy * t; }
}
}
return distToSegment(cx, cy, sx, sy, ex, ey) <= HIT_RADIUS;
}
case 'fib': {
if (d.points.length < 2) return false;
const [p0, p1] = d.points;
const range = p0.price - p1.price;
for (const level of FIB_LEVELS) {
const price = p0.price - range * level;
const y = m.priceToY(price);
if (y !== null && Math.abs(cy - y) <= HIT_RADIUS) return true;
}
return false;
}
case 'fibtz': {
if (d.points.length < 1) return false;
// 기준선(0) 히트 테스트
const x0tz = m.timeToX(d.points[0].time);
if (x0tz !== null && Math.abs(cx - x0tz) <= HIT_RADIUS) return true;
if (d.points.length < 2) return false;
// 각 피보나치 레벨 수직선 히트 테스트
const dt = d.points[1].time - d.points[0].time;
for (const level of FIB_TZ_LEVELS) {
const xTZ = m.timeToX(d.points[0].time + dt * level);
if (xTZ !== null && Math.abs(cx - xTZ) <= HIT_RADIUS) return true;
}
return false;
}
case 'rect': {
if (d.points.length < 2) return false;
const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price);
const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price);
if (x0 === null || y0 === null || x1 === null || y1 === null) return false;
const lx = Math.min(x0, x1), rx = Math.max(x0, x1);
const ty = Math.min(y0, y1), by = Math.max(y0, y1);
// 내부 + 테두리 모두 hit
return cx >= lx - HIT_RADIUS && cx <= rx + HIT_RADIUS
&& cy >= ty - HIT_RADIUS && cy <= by + HIT_RADIUS;
}
case 'measure': {
if (d.points.length < 2) return false;
const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price);
const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price);
if (x0 === null || y0 === null || x1 === null || y1 === null) return false;
const lx = Math.min(x0, x1) - HIT_RADIUS, rx = Math.max(x0, x1) + HIT_RADIUS;
const ty = Math.min(y0, y1) - HIT_RADIUS, by = Math.max(y0, y1) + HIT_RADIUS;
return cx >= lx && cx <= rx && cy >= ty && cy <= by;
}
case 'channel': {
// 첫 번째 선만 hit-test (main line)
if (d.points.length < 2) return false;
const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price);
const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price);
if (x0 === null || y0 === null || x1 === null || y1 === null) return false;
const dx = x1 - x0, dy = y1 - y0;
let sx = x0, sy = y0, ex = x1, ey = y1;
if (Math.abs(dx) > 0.001) {
const tR = (cssW - x0) / dx; if (tR > 1) { ex = cssW; ey = y0 + dy * tR; }
const tL = -x0 / dx; if (tL < 0) { sx = 0; sy = y0 + dy * tL; }
}
return distToSegment(cx, cy, sx, sy, ex, ey) <= HIT_RADIUS;
}
case 'pencil': {
if (d.points.length < 2) return false;
for (let i = 0; i < d.points.length - 1; i++) {
const x0 = m.timeToX(d.points[i].time), y0 = m.priceToY(d.points[i].price);
const x1 = m.timeToX(d.points[i + 1].time), y1 = m.priceToY(d.points[i + 1].price);
if (x0 !== null && y0 !== null && x1 !== null && y1 !== null) {
if (distToSegment(cx, cy, x0, y0, x1, y1) <= HIT_RADIUS) return true;
}
}
return false;
}
case 'text': {
if (!d.points[0]) return false;
const x = m.timeToX(d.points[0].time), y = m.priceToY(d.points[0].price);
if (x === null || y === null) return false;
return cx >= x - 4 && cx <= x + 120 && cy >= y - 20 && cy <= y + 6;
}
case 'circle': {
if (d.points.length < 2) return false;
const cx0 = m.timeToX(d.points[0].time), cy0 = m.priceToY(d.points[0].price);
const ex = m.timeToX(d.points[1].time), ey = m.priceToY(d.points[1].price);
if (cx0 === null || cy0 === null || ex === null || ey === null) return false;
const r = Math.hypot(ex - cx0, ey - cy0);
const dist = Math.hypot(cx - cx0, cy - cy0);
return Math.abs(dist - r) <= HIT_RADIUS;
}
case 'triangle': {
if (d.points.length < 3) return false;
for (let i = 0; i < 3; i++) {
const a = d.points[i], b = d.points[(i + 1) % 3];
const ax = m.timeToX(a.time), ay = m.priceToY(a.price);
const bx = m.timeToX(b.time), by = m.priceToY(b.price);
if (ax === null || ay === null || bx === null || by === null) continue;
if (distToSegment(cx, cy, ax, ay, bx, by) <= HIT_RADIUS) return true;
}
return false;
}
case 'pitchfork': {
if (d.points.length < 3) return false;
const [p0, p1, p2] = d.points;
const x0 = m.timeToX(p0.time), y0 = m.priceToY(p0.price);
const x1 = m.timeToX(p1.time), y1 = m.priceToY(p1.price);
const x2 = m.timeToX(p2.time), y2 = m.priceToY(p2.price);
if (x0===null||y0===null||x1===null||y1===null||x2===null||y2===null) return false;
const mx = (x1 + x2) / 2, my = (y1 + y2) / 2;
if (distToSegment(cx, cy, x0, y0, mx, my) <= HIT_RADIUS) return true;
if (distToSegment(cx, cy, x0+(x1-mx), y0+(y1-my), x1, y1) <= HIT_RADIUS) return true;
if (distToSegment(cx, cy, x0+(x2-mx), y0+(y2-my), x2, y2) <= HIT_RADIUS) return true;
return false;
}
case 'arrow_mark_up':
case 'arrow_mark_down':
case 'arrow_mark_left':
case 'arrow_mark_right':
case 'arrow_mark': {
if (!d.points[0]) return false;
const ax = m.timeToX(d.points[0].time), ay = m.priceToY(d.points[0].price);
if (ax === null || ay === null) return false;
return Math.hypot(cx - ax, cy - ay) <= 14;
}
case 'long_position':
case 'short_position': {
if (d.points.length < 2) return false;
const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price);
const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price);
if (x0===null||y0===null||x1===null||y1===null) return false;
return cx >= Math.min(x0,x1)-HIT_RADIUS && cx <= Math.max(x0,x1)+HIT_RADIUS
&& cy >= Math.min(y0,y1)-HIT_RADIUS && cy <= Math.max(y0,y1)+HIT_RADIUS;
}
// ── 신규 툴 히트 테스트 ──
case 'cross_line': {
if (!d.points[0]) return false;
const xc = m.timeToX(d.points[0].time), yc = m.priceToY(d.points[0].price);
if (xc === null || yc === null) return false;
return Math.abs(cy - yc) <= HIT_RADIUS || Math.abs(cx - xc) <= HIT_RADIUS;
}
case 'hline_ray':
case 'info_line':
case 'guide_line': {
if (!d.points[0]) return false;
const xr = m.timeToX(d.points[0].time), yr = m.priceToY(d.points[0].price);
if (xr === null || yr === null) return false;
return Math.abs(cy - yr) <= HIT_RADIUS && cx >= xr - HIT_RADIUS;
}
case 'arrow': {
if (d.points.length < 2) return false;
const xa0 = m.timeToX(d.points[0].time), ya0 = m.priceToY(d.points[0].price);
const xa1 = m.timeToX(d.points[1].time), ya1 = m.priceToY(d.points[1].price);
if (xa0===null||ya0===null||xa1===null||ya1===null) return false;
return distToSegment(cx, cy, xa0, ya0, xa1, ya1) <= HIT_RADIUS;
}
case 'trend_angle': {
if (d.points.length < 2) return false;
const xt0 = m.timeToX(d.points[0].time), yt0 = m.priceToY(d.points[0].price);
const xt1 = m.timeToX(d.points[1].time), yt1 = m.priceToY(d.points[1].price);
if (xt0===null||yt0===null||xt1===null||yt1===null) return false;
return distToSegment(cx, cy, xt0, yt0, xt1, yt1) <= HIT_RADIUS;
}
case 'parallel_channel': {
if (d.points.length < 2) return false;
const xp0 = m.timeToX(d.points[0].time), yp0 = m.priceToY(d.points[0].price);
const xp1 = m.timeToX(d.points[1].time), yp1 = m.priceToY(d.points[1].price);
if (xp0===null||yp0===null||xp1===null||yp1===null) return false;
return distToSegment(cx, cy, xp0, yp0, xp1, yp1) <= HIT_RADIUS;
}
case 'fib_ext':
case 'fib_channel':
case 'fib_speed_fan':
case 'fib_circles':
case 'fib_spiral':
case 'fib_speed_arc': {
if (d.points.length < 2) return false;
const xfe0 = m.timeToX(d.points[0].time), yfe0 = m.priceToY(d.points[0].price);
const xfe1 = m.timeToX(d.points[1].time), yfe1 = m.priceToY(d.points[1].price);
if (xfe0===null||yfe0===null) return false;
// 중심점 근처 또는 반지름 선 근처
if (Math.hypot(cx - xfe0, cy - yfe0) <= HIT_RADIUS * 2) return true;
if (xfe1!==null && yfe1!==null) {
const r = Math.hypot(xfe1 - xfe0, yfe1 - yfe0);
const distFromCenter = Math.hypot(cx - xfe0, cy - yfe0);
return Math.abs(distFromCenter - r) <= HIT_RADIUS;
}
return false;
}
case 'gann_fan':
case 'gann_box':
case 'gann_square': {
if (d.points.length < 2) return false;
const xg0 = m.timeToX(d.points[0].time), yg0 = m.priceToY(d.points[0].price);
const xg1 = m.timeToX(d.points[1].time), yg1 = m.priceToY(d.points[1].price);
if (xg0===null||yg0===null||xg1===null||yg1===null) return false;
const lgx = Math.min(xg0,xg1)-HIT_RADIUS, rgx = Math.max(xg0,xg1)+HIT_RADIUS;
const tgy = Math.min(yg0,yg1)-HIT_RADIUS, bgy = Math.max(yg0,yg1)+HIT_RADIUS;
return cx >= lgx && cx <= rgx && cy >= tgy && cy <= bgy;
}
case 'forecast': {
if (d.points.length < 2) return false;
const xf0 = m.timeToX(d.points[0].time), yf0 = m.priceToY(d.points[0].price);
const xf1 = m.timeToX(d.points[1].time), yf1 = m.priceToY(d.points[1].price);
if (xf0===null||yf0===null||xf1===null||yf1===null) return false;
return cx >= Math.min(xf0,xf1)-HIT_RADIUS && cx <= Math.max(xf0,xf1)+HIT_RADIUS
&& cy >= Math.min(yf0,yf1)-HIT_RADIUS && cy <= Math.max(yf0,yf1)+HIT_RADIUS;
}
case 'date_range': {
if (d.points.length < 2) return false;
const xd0 = m.timeToX(d.points[0].time);
const xd1 = m.timeToX(d.points[1].time);
if (xd0===null||xd1===null) return false;
return cx >= Math.min(xd0,xd1)-HIT_RADIUS && cx <= Math.max(xd0,xd1)+HIT_RADIUS;
}
case 'date_price_range': {
if (d.points.length < 2) return false;
const xdp0 = m.timeToX(d.points[0].time), ydp0 = m.priceToY(d.points[0].price);
const xdp1 = m.timeToX(d.points[1].time), ydp1 = m.priceToY(d.points[1].price);
if (xdp0===null||ydp0===null||xdp1===null||ydp1===null) return false;
return cx >= Math.min(xdp0,xdp1)-HIT_RADIUS && cx <= Math.max(xdp0,xdp1)+HIT_RADIUS
&& cy >= Math.min(ydp0,ydp1)-HIT_RADIUS && cy <= Math.max(ydp0,ydp1)+HIT_RADIUS;
}
case 'data_window': {
if (!d.points[0]) return false;
const xdw = m.timeToX(d.points[0].time), ydw = m.priceToY(d.points[0].price);
if (xdw===null||ydw===null) return false;
return cx >= xdw - HIT_RADIUS && cx <= xdw + 180 && Math.abs(cy - ydw) <= 50;
}
case 'magnifier': {
if (!d.points[0]) return false;
const xmg = m.timeToX(d.points[0].time), ymg = m.priceToY(d.points[0].price);
if (xmg===null||ymg===null) return false;
let rmg = 40;
if (d.points.length >= 2) {
const xmg1 = m.timeToX(d.points[1].time), ymg1 = m.priceToY(d.points[1].price);
if (xmg1!==null && ymg1!==null) rmg = Math.max(20, Math.hypot(xmg1-xmg, ymg1-ymg));
}
return Math.hypot(cx - xmg, cy - ymg) <= rmg + HIT_RADIUS;
}
case 'callout': {
if (!d.points[0]) return false;
const xca = m.timeToX(d.points[0].time), yca = m.priceToY(d.points[0].price);
if (xca===null||yca===null) return false;
return cx >= xca - 30 && cx <= xca + 150 && cy >= yca - 60 && cy <= yca + 10;
}
case 'pin_mark':
case 'flag_mark': {
if (!d.points[0]) return false;
const xpm = m.timeToX(d.points[0].time), ypm = m.priceToY(d.points[0].price);
if (xpm===null||ypm===null) return false;
return Math.hypot(cx - xpm, cy - ypm) <= 20;
}
case 'note_text': {
if (!d.points[0]) return false;
const xnt = m.timeToX(d.points[0].time), ynt = m.priceToY(d.points[0].price);
if (xnt===null||ynt===null) return false;
return cx >= xnt && cx <= xnt + 140 && cy >= ynt && cy <= ynt + 60;
}
case 'price_note':
case 'price_label': {
if (!d.points[0]) return false;
const xpn = m.timeToX(d.points[0].time), ypn = m.priceToY(d.points[0].price);
if (xpn===null||ypn===null) return false;
return Math.hypot(cx - xpn, cy - ypn) <= HIT_RADIUS * 3;
}
case 'comment_mark': {
if (!d.points[0]) return false;
const xcm = m.timeToX(d.points[0].time), ycm = m.priceToY(d.points[0].price);
if (xcm===null||ycm===null) return false;
return cx >= xcm - 40 && cx <= xcm + 40 && cy >= ycm - 50 && cy <= ycm + 10;
}
case 'fixed_text': {
if (!d.points[0]) return false;
const xft = m.timeToX(d.points[0].time), yft = m.priceToY(d.points[0].price);
if (xft===null||yft===null) return false;
return cx >= xft && cx <= xft + 140 && Math.abs(cy - yft) <= 12;
}
case 'pitchfork_schiff':
case 'pitchfork_inside': {
if (d.points.length < 3) return false;
const [ps0, ps1, ps2] = d.points;
const xps0 = m.timeToX(ps0.time), yps0 = m.priceToY(ps0.price);
const xps1 = m.timeToX(ps1.time), yps1 = m.priceToY(ps1.price);
const xps2 = m.timeToX(ps2.time), yps2 = m.priceToY(ps2.price);
if (xps0===null||yps0===null||xps1===null||yps1===null||xps2===null||yps2===null) return false;
if (distToSegment(cx, cy, xps0, yps0, (xps1+xps2)/2, (yps1+yps2)/2) <= HIT_RADIUS) return true;
if (distToSegment(cx, cy, xps0, yps0, xps1, yps1) <= HIT_RADIUS) return true;
return distToSegment(cx, cy, xps0, yps0, xps2, yps2) <= HIT_RADIUS;
}
case 'ellipse': {
if (d.points.length < 2) return false;
const xel = m.timeToX(d.points[0].time), yel = m.priceToY(d.points[0].price);
const xel1 = m.timeToX(d.points[1].time), yel1 = m.priceToY(d.points[1].price);
if (xel===null||yel===null||xel1===null||yel1===null) return false;
const rx = Math.abs(xel1-xel), ry = Math.abs(yel1-yel);
if (rx < 1 || ry < 1) return false;
const norm = Math.hypot((cx-xel)/rx, (cy-yel)/ry);
return Math.abs(norm - 1) <= HIT_RADIUS / Math.min(rx, ry);
}
case 'arc': {
if (!d.points[0]) return false;
const xar = m.timeToX(d.points[0].time), yar = m.priceToY(d.points[0].price);
if (xar===null||yar===null) return false;
return Math.hypot(cx - xar, cy - yar) <= HIT_RADIUS * 2;
}
case 'rotated_rect':
case 'parallelogram': {
if (d.points.length < 2) return false;
// 경계 박스로 근사 히트테스트
const pts2d = d.points.map(p => ({x:m.timeToX(p.time), y:m.priceToY(p.price)}));
for (let i = 0; i < pts2d.length - 1; i++) {
const {x:ax,y:ay} = pts2d[i], {x:bx,y:by} = pts2d[i+1];
if (ax!==null&&ay!==null&&bx!==null&&by!==null) {
if (distToSegment(cx,cy,ax,ay,bx,by) <= HIT_RADIUS) return true;
}
}
// 마지막 변 (마지막→첫 번째)
const first = pts2d[0], last = pts2d[pts2d.length-1];
if (first.x!==null&&first.y!==null&&last.x!==null&&last.y!==null) {
if (distToSegment(cx,cy,last.x,last.y,first.x,first.y) <= HIT_RADIUS) return true;
}
return false;
}
case 'polyline': {
for (let i = 0; i < d.points.length - 1; i++) {
const xpl0=m.timeToX(d.points[i].time), ypl0=m.priceToY(d.points[i].price);
const xpl1=m.timeToX(d.points[i+1].time), ypl1=m.priceToY(d.points[i+1].price);
if (xpl0!==null&&ypl0!==null&&xpl1!==null&&ypl1!==null) {
if (distToSegment(cx,cy,xpl0,ypl0,xpl1,ypl1) <= HIT_RADIUS) return true;
}
}
return false;
}
// ── 패턴 (하모닉/엘리엇) ── 임의 선분 클릭
case 'xabcd': case 'cypher': case 'abcd':
case 'head_shoulders': case 'wedge_pattern': case 'three_drives':
case 'elliott_impulse': case 'elliott_correction':
case 'elliott_triangle_wave': case 'elliott_double_combo': {
for (let i = 0; i < d.points.length - 1; i++) {
const xp0 = m.timeToX(d.points[i].time), yp0 = m.priceToY(d.points[i].price);
const xp1 = m.timeToX(d.points[i+1].time), yp1 = m.priceToY(d.points[i+1].price);
if (xp0!==null&&yp0!==null&&xp1!==null&&yp1!==null) {
if (distToSegment(cx, cy, xp0, yp0, xp1, yp1) <= HIT_RADIUS) return true;
}
}
return false;
}
// ── 프로젝션 도구 ──
case 'ghost_feed': case 'bar_pattern': case 'fixed_volume_profile': case 'forecast': {
if (d.points.length < 2) return false;
const xpr0 = m.timeToX(d.points[0].time), ypr0 = m.priceToY(d.points[0].price);
const xpr1 = m.timeToX(d.points[1].time), ypr1 = m.priceToY(d.points[1].price);
if (xpr0===null||ypr0===null||xpr1===null||ypr1===null) return false;
return cx >= Math.min(xpr0,xpr1)-HIT_RADIUS && cx <= Math.max(xpr0,xpr1)+HIT_RADIUS
&& cy >= Math.min(ypr0,ypr1)-HIT_RADIUS && cy <= Math.max(ypr0,ypr1)+HIT_RADIUS;
}
case 'anchored_vwap': {
if (!d.points[0]) return false;
const xav = m.timeToX(d.points[0].time), yav = m.priceToY(d.points[0].price);
if (xav===null||yav===null) return false;
return Math.abs(cy - yav) <= HIT_RADIUS && cx >= xav - HIT_RADIUS;
}
default: return false;
}
}
// ─── Tool config ───────────────────────────────────────────────────────────
// 신규 툴 → 기존 툴 렌더/히트테스트 위임 매핑
// (독립 렌더러가 구현된 툴은 여기에 없음)
const TOOL_ALIAS: Record<string, string> = {
// 선 계열
extended_line: 'trendline', // 양방향 확장선 (trendline과 동일)
fib_trend_time: 'fib', // 추세기반 피보나치 시간 → 기본 fib 레벨
fib_wedge: 'fib', // 피보나치 웻지 → 기본 fib 레벨
// fib_circles, fib_spiral, fib_speed_arc 는 독립 렌더러로 구현됨
disjoint_channel: 'channel',
flat_top_bottom: 'rect',
// 텍스트 기반 (자체 렌더러 없는 것만 alias 유지)
table_tool: 'text', // 테이블은 단순 텍스트 박스로 처리
// note_text / price_note / comment_mark / fixed_text / price_label → 자체 렌더러
// 화살표 마크 (arrow_mark / anchored_arrow → 화살촉 있는 arrow 렌더)
arrow_mark: 'arrow',
anchored_arrow: 'arrow',
arrow_mark_left: 'arrow_mark_left',
arrow_mark_right: 'arrow_mark_right',
signpost_up: 'arrow_mark_up',
signpost_down: 'arrow_mark_down',
// 기타
highlight: 'pencil',
price_range: 'measure',
// date_price_range / data_window → 자체 렌더러
// magnifier → 자체 렌더러
bar_pattern: 'rect',
ghost_feed: 'rect',
projection_tool: 'trendline',
anchored_vwap: 'hline',
fixed_volume_profile: 'rect',
};
// 실제 드로잉을 생성하지 않는 스텁 툴 (UI 선택만)
const STUB_TOOLS = new Set<string>([
// 현재 모든 툴 구현 완료
]);
const TOOL_COLORS: Record<string, string> = {
hline: '#FF6D00',
vline: '#2196F3',
trendline: '#4CAF50',
extended_line: '#4CAF50',
ray: '#9C27B0',
arrow: '#9C27B0',
trend_angle: '#4CAF50',
cross_line: '#2196F3',
hline_ray: '#FF6D00',
info_line: '#FF6D00',
guide_line: '#78909C',
fib: '#FFC107',
fib_ext: '#FF9800',
fib_channel: '#FFC107',
fib_speed_fan: '#FFB300',
measure: '#00BCD4',
date_range: '#00BCD4',
forecast: '#78909C',
text: '#FFEB3B',
callout: '#FFEB3B',
pin_mark: '#F06292',
flag_mark: '#EF5350',
channel: '#E91E63',
parallel_channel: '#E91E63',
rect: '#607D8B',
gann_box: '#8BC34A',
gann_square: '#8BC34A',
gann_fan: '#CDDC39',
pencil: '#FF5722',
circle: '#26C6DA',
ellipse: '#26C6DA',
triangle: '#AB47BC',
arc: '#AB47BC',
pitchfork: '#EF5350',
pitchfork_schiff: '#FF7043',
pitchfork_inside: '#FF8A65',
arrow_mark_up: '#4CAF50',
arrow_mark_down: '#EF5350',
arrow_mark_left: '#FFC107',
arrow_mark_right: '#FFC107',
arrow_mark: '#9C27B0',
long_position: '#4CAF50',
short_position: '#EF5350',
highlight: '#FFEB3B',
rotated_rect: '#AB47BC',
parallelogram: '#CE93D8',
polyline: '#FF9800',
note_text: '#F9A825',
price_note: '#26C6DA',
comment_mark: '#78909C',
fixed_text: '#AED6F1',
price_label: '#EF5350',
date_price_range: '#00BCD4',
data_window: '#42A5F5',
magnifier: '#80DEEA',
};
const POINTS_NEEDED: Record<string, number> = {
// ── 1점 ──
hline: 1, vline: 1, text: 1, callout: 1,
arrow_mark_up: 1, arrow_mark_down: 1, arrow_mark_left: 1, arrow_mark_right: 1,
arrow_mark: 1, flag_mark: 1, pin_mark: 1,
cross_line: 1, hline_ray: 1, info_line: 1, guide_line: 1,
// 텍스트 별칭 (1점)
fixed_text: 1, note_text: 1, price_note: 1, price_label: 1,
comment_mark: 1, table_tool: 1,
// ── 2점 ──
trendline: 2, extended_line: 2, ray: 2, arrow: 2, trend_angle: 2,
anchored_arrow: 2, projection_tool: 2,
fib: 2, fib_ext: 2, fib_channel: 2, fib_speed_fan: 2,
fib_trend_time: 2, fib_circles: 2, fib_spiral: 2,
fib_speed_arc: 2, fib_wedge: 2,
fibtz: 2, measure: 2, price_range: 2,
rect: 2, flat_top_bottom: 2,
channel: 2, disjoint_channel: 2,
circle: 2, ellipse: 2, long_position: 2, short_position: 2,
gann_fan: 2, gann_box: 2, gann_square: 2,
forecast: 2, date_range: 2, date_price_range: 2, data_window: 1, magnifier: 2,
bar_pattern: 2, ghost_feed: 2, fixed_volume_profile: 2,
anchored_vwap: 1,
// ── 3점 ──
triangle: 3, pitchfork: 3, pitchfork_schiff: 3, pitchfork_inside: 3,
parallel_channel: 3, arc: 3,
rotated_rect: 3, parallelogram: 3,
// ── 다각형 (더블클릭으로 완성, 사실상 무한) ──
polyline: 999,
// ── 패턴 스텁 (포인트 수집용) ──
xabcd: 5, cypher: 5, abcd: 4, head_shoulders: 7, wedge_pattern: 5, three_drives: 6,
elliott_impulse: 6, elliott_correction: 4, elliott_triangle_wave: 6, elliott_double_combo: 4,
// pencil, zoom, highlight : drag 기반이므로 별도 처리
};
const FIB_LEVELS = [0, 0.236, 0.382, 0.5, 0.618, 0.786, 1, 1.272, 1.618];
const FIB_COLORS = ['#EF5350','#FF9800','#FFC107','#FFFFFF','#4CAF50','#2196F3','#9C27B0','#607D8B','#607D8B'];
// 피보나치 확장 레벨
const FIB_EXT_LEVELS = [0, 0.236, 0.382, 0.618, 0.786, 1.0, 1.272, 1.618, 2.0, 2.618];
const FIB_EXT_COLORS = ['#EF5350','#FF9800','#FFC107','#4CAF50','#26C6DA','#FFFFFF','#9C27B0','#E91E63','#F44336','#00BCD4'];
// 갠 팬 비율
const GANN_RATIOS = [8, 4, 3, 2, 1, 0.5, 0.333, 0.25, 0.125];
const GANN_LABELS = ['8:1','4:1','3:1','2:1','1:1','1:2','1:3','1:4','1:8'];
const GANN_COLORS_ARR = ['#EF5350','#FF9800','#FFC107','#8BC34A','#FFFFFF','#8BC34A','#FFC107','#FF9800','#EF5350'];
// 피보나치 타임존 수열 (캔들 간격 배수)
const FIB_TZ_LEVELS = [1, 2, 3, 5, 8, 13, 21, 34, 55, 89];
const FIB_TZ_COLORS = [
'#2196F3','#4CAF50','#FF9800','#E91E63','#9C27B0',
'#00BCD4','#8BC34A','#FF5722','#607D8B','#795548',
];
// ─── Renderers ────────────────────────────────────────────────────────────
function setLineStyle(ctx: CanvasRenderingContext2D, style?: string) {
if (style === 'dashed') ctx.setLineDash([6, 4]);
else if (style === 'dotted') ctx.setLineDash([2, 4]);
else ctx.setLineDash([]);
}
function renderHLine(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssW: number) {
if (!d.points[0]) return;
const y = m.priceToY(d.points[0].price);
if (y === null || y < 0) return;
setLineStyle(ctx, d.style);
ctx.strokeStyle = d.color; ctx.lineWidth = d.lineWidth ?? 1;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(cssW, y); ctx.stroke();
const lbl = d.points[0].price.toFixed(2);
ctx.font = '11px monospace';
const tw = ctx.measureText(lbl).width;
ctx.fillStyle = d.color + 'cc';
ctx.fillRect(cssW - tw - 10, y - 10, tw + 8, 14);
ctx.fillStyle = '#fff';
ctx.fillText(lbl, cssW - tw - 6, y + 1);
}
function renderVLine(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssH: number) {
if (!d.points[0]) return;
const x = m.timeToX(d.points[0].time);
if (x === null || x < 0) return;
setLineStyle(ctx, d.style);
ctx.strokeStyle = d.color; ctx.lineWidth = d.lineWidth ?? 1;
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, cssH); ctx.stroke();
}
function renderLineOrRay(
ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager,
extend: 'none' | 'both' | 'right', cssW: number, cssH: number,
) {
if (d.points.length < 1) return;
const p0 = d.points[0];
const x0 = m.timeToX(p0.time); const y0 = m.priceToY(p0.price);
if (x0 === null || y0 === null) return;
if (d.points.length < 2) {
ctx.beginPath(); ctx.arc(x0, y0, 4, 0, Math.PI * 2);
ctx.fillStyle = d.color; ctx.fill(); return;
}
const p1 = d.points[1];
const x1 = m.timeToX(p1.time); const y1 = m.priceToY(p1.price);
if (x1 === null || y1 === null) return;
setLineStyle(ctx, d.style);
ctx.strokeStyle = d.color; ctx.lineWidth = d.lineWidth ?? 1;
let sx = x0, sy = y0, ex = x1, ey = y1;
const dx = x1 - x0, dy = y1 - y0;
if (extend === 'right' || extend === 'both') {
if (Math.abs(dx) > 0.001) {
const t = (cssW - x0) / dx;
if (t > 1) { ex = cssW; ey = y0 + dy * t; }
}
}
if (extend === 'both') {
if (Math.abs(dx) > 0.001) {
const t = (0 - x0) / dx;
if (t < 0) { sx = 0; sy = y0 + dy * t; }
}
}
ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(ex, ey); ctx.stroke();
const endPrice = m.yToPrice(ey);
if (endPrice !== null) {
const lbl = endPrice.toFixed(2);
ctx.font = '10px monospace';
const tw = ctx.measureText(lbl).width;
ctx.fillStyle = d.color + 'cc';
ctx.fillRect(ex - tw - 8, ey - 10, tw + 6, 13);
ctx.fillStyle = '#fff';
ctx.fillText(lbl, ex - tw - 5, ey + 1);
}
}
function renderFib(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssW: number) {
if (d.points.length < 2) {
renderLineOrRay(ctx, d, m, 'none', cssW, 0); return;
}
const [p0, p1] = d.points;
const range = p0.price - p1.price;
for (let i = 0; i < FIB_LEVELS.length; i++) {
const price = p0.price - range * FIB_LEVELS[i];
const y = m.priceToY(price);
if (y === null) continue;
const color = FIB_COLORS[i] ?? '#607D8B';
ctx.strokeStyle = color;
ctx.lineWidth = (i === 0 || i === 6) ? 1.5 : 0.8;
ctx.setLineDash(i === 3 ? [6, 4] : [3, 3]);
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(cssW, y); ctx.stroke();
const pct = (FIB_LEVELS[i] * 100).toFixed(1);
const lbl = `${pct}% ${price.toFixed(2)}`;
ctx.font = '10px monospace';
const tw = ctx.measureText(lbl).width;
ctx.fillStyle = color + 'cc';
ctx.fillRect(2, y - 12, tw + 6, 14);
ctx.fillStyle = '#fff';
ctx.fillText(lbl, 5, y - 1);
}
}
function applyLineStyle(ctx: CanvasRenderingContext2D, style?: string) {
if (style === 'dashed') ctx.setLineDash([6, 4]);
else if (style === 'dotted') ctx.setLineDash([2, 3]);
else ctx.setLineDash([]);
}
function renderFibTZ(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssH: number) {
if (!d.points[0]) return;
const x0 = m.timeToX(d.points[0].time);
if (x0 === null) return;
// settings 가 있으면 적용, 없으면 기본값 사용
const settings = d.fibtzSettings;
const bl = settings?.baseline;
ctx.save();
ctx.textBaseline = 'top';
ctx.font = 'bold 10px monospace';
// 기준선 (0)
ctx.strokeStyle = bl?.color ?? d.color;
ctx.lineWidth = bl?.lineWidth ?? (d.lineWidth ?? 1.5);
applyLineStyle(ctx, bl?.style ?? 'solid');
ctx.beginPath(); ctx.moveTo(x0, 0); ctx.lineTo(x0, cssH); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = bl?.color ?? d.color;
ctx.fillText('0', x0 + 3, 4);
if (d.points.length >= 2) {
// 두 기준점 사이의 시간 간격 = 1단위
const dt = d.points[1].time - d.points[0].time;
const lvList = settings?.levels ?? FIB_TZ_LEVELS.map((v, i) => ({
value: v,
enabled: true,
color: FIB_TZ_COLORS[i % FIB_TZ_COLORS.length],
lineWidth: 1,
style: 'dashed' as const,
}));
for (const lv of lvList) {
if (!lv.enabled) continue;
const zoneTime = d.points[0].time + dt * lv.value;
const xTZ = m.timeToX(zoneTime);
if (xTZ === null) continue;
ctx.strokeStyle = lv.color;
ctx.lineWidth = lv.lineWidth;
ctx.globalAlpha = 0.85;
applyLineStyle(ctx, lv.style);
ctx.beginPath(); ctx.moveTo(xTZ, 0); ctx.lineTo(xTZ, cssH); ctx.stroke();
ctx.globalAlpha = 1;
ctx.setLineDash([]);
ctx.fillStyle = lv.color;
ctx.fillText(String(lv.value), xTZ + 3, 4);
}
ctx.globalAlpha = 1;
}
ctx.restore();
}
function renderMeasure(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, theme: Theme) {
if (d.points.length < 2) {
const p = d.points[0]; if (!p) return;
const x = m.timeToX(p.time), y = m.priceToY(p.price);
if (x !== null && y !== null) { ctx.beginPath(); ctx.arc(x, y, 4, 0, Math.PI*2); ctx.fillStyle = '#00BCD4'; ctx.fill(); }
return;
}
const [p0, p1] = d.points;
const x0 = m.timeToX(p0.time), y0 = m.priceToY(p0.price);
const x1 = m.timeToX(p1.time), y1 = m.priceToY(p1.price);
if (x0 === null || y0 === null || x1 === null || y1 === null) return;
const lx = Math.min(x0, x1), rx = Math.max(x0, x1);
const ty = Math.min(y0, y1), by = Math.max(y0, y1);
ctx.setLineDash([]); ctx.strokeStyle = '#00BCD4'; ctx.lineWidth = 1;
ctx.fillStyle = '#00BCD41a';
ctx.fillRect(lx, ty, rx - lx, by - ty);
ctx.strokeRect(lx, ty, rx - lx, by - ty);
const pct = ((p1.price - p0.price) / p0.price * 100);
const sign = pct >= 0 ? '+' : '';
const cx = (lx + rx) / 2;
const cy = (ty + by) / 2;
ctx.font = 'bold 13px monospace';
ctx.fillStyle = pct >= 0 ? '#4CAF50' : '#EF5350';
ctx.textAlign = 'center';
ctx.fillText(`${sign}${pct.toFixed(2)}%`, cx, cy);
ctx.font = '11px monospace';
ctx.fillStyle = theme === 'dark' ? '#80DEEA' : '#006064';
ctx.fillText(${Math.abs(p1.price - p0.price).toFixed(2)}`, cx, cy + 15);
ctx.textAlign = 'left';
}
function renderRect(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) {
if (d.points.length < 2) { renderLineOrRay(ctx, d, m, 'none', 0, 0); return; }
const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price);
const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price);
if (x0 === null || y0 === null || x1 === null || y1 === null) return;
setLineStyle(ctx, d.style); ctx.strokeStyle = d.color; ctx.lineWidth = d.lineWidth ?? 1;
ctx.fillStyle = d.color + '22';
const lx = Math.min(x0,x1), ty = Math.min(y0,y1), w = Math.abs(x1-x0), h = Math.abs(y1-y0);
ctx.fillRect(lx, ty, w, h);
ctx.strokeRect(lx, ty, w, h);
}
function renderChannel(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssW: number, cssH: number) {
// 첫 번째 선 (추세선)
renderLineOrRay(ctx, d, m, 'both', cssW, cssH);
if (d.points.length >= 2) {
// 3번째 점이 있으면 그 점의 Y 기준으로 평행선, 없으면 기울기 동일한 평행선 자동 생성
let parallelPoints: DrawingPoint[];
if (d.points.length >= 3) {
const [p0, p1, p2] = d.points;
// p2 에서 같은 기울기로 평행선 생성
const priceDiff = p1.price - p0.price;
const timeDiff = p1.time - p0.time;
const endTime = timeDiff !== 0 ? p2.time + timeDiff : p2.time;
const endPrice = timeDiff !== 0
? p2.price + priceDiff
: p2.price;
parallelPoints = [p2, { time: endTime, price: endPrice }];
} else {
// 2점만 있을 때: 가격 차이를 기준으로 자동 평행선
const [p0, p1] = d.points;
const dy = p1.price - p0.price;
const offset = Math.abs(dy) * 0.618 || (p0.price * 0.01);
parallelPoints = [
{ time: p0.time, price: p0.price - offset },
{ time: p1.time, price: p1.price - offset },
];
}
renderLineOrRay(
ctx,
{ ...d, style: 'dashed', points: parallelPoints },
m, 'both', cssW, cssH,
);
// 채널 사이 영역 음영
if (d.points.length >= 2) {
const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price);
const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price);
const px0 = m.timeToX(parallelPoints[0].time), py0 = m.priceToY(parallelPoints[0].price);
const px1 = m.timeToX(parallelPoints[1].time), py1 = m.priceToY(parallelPoints[1].price);
if (x0!==null && y0!==null && x1!==null && y1!==null && px0!==null && py0!==null && px1!==null && py1!==null) {
ctx.beginPath();
ctx.moveTo(x0, y0); ctx.lineTo(x1, y1);
ctx.lineTo(px1, py1); ctx.lineTo(px0, py0);
ctx.closePath();
ctx.fillStyle = d.color + '18';
ctx.fill();
}
}
}
}
function renderPencil(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) {
if (d.points.length < 2) return;
const isHighlight = d.type === 'highlight';
setLineStyle(ctx, d.style);
ctx.strokeStyle = d.color;
ctx.lineWidth = d.lineWidth ?? 1.5;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
if (isHighlight) ctx.globalAlpha = 0.35;
ctx.beginPath();
for (let i = 0; i < d.points.length; i++) {
const x = m.timeToX(d.points[i].time);
const y = m.priceToY(d.points[i].price);
if (x === null || y === null) continue;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
}
function renderText(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) {
if (!d.points[0] || !d.text) return;
const x = m.timeToX(d.points[0].time), y = m.priceToY(d.points[0].price);
if (x === null || y === null) return;
ctx.font = 'bold 13px sans-serif';
ctx.fillStyle = d.color;
ctx.fillText(d.text, x, y);
}
function renderCircle(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) {
if (d.points.length < 2) return;
const cx = m.timeToX(d.points[0].time), cy = m.priceToY(d.points[0].price);
const ex = m.timeToX(d.points[1].time), ey = m.priceToY(d.points[1].price);
if (cx===null||cy===null||ex===null||ey===null) return;
const r = Math.hypot(ex - cx, ey - cy);
applyLineStyle(ctx, d.style);
ctx.strokeStyle = d.color;
ctx.lineWidth = d.lineWidth ?? 1.5;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fillStyle = d.color + '22';
ctx.fill();
ctx.stroke();
}
function renderTriangle(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) {
if (d.points.length < 3) return;
const pts = d.points.slice(0, 3).map(p => ({ x: m.timeToX(p.time), y: m.priceToY(p.price) }));
if (pts.some(p => p.x === null || p.y === null)) return;
applyLineStyle(ctx, d.style);
ctx.strokeStyle = d.color;
ctx.lineWidth = d.lineWidth ?? 1.5;
ctx.beginPath();
ctx.moveTo(pts[0].x!, pts[0].y!);
ctx.lineTo(pts[1].x!, pts[1].y!);
ctx.lineTo(pts[2].x!, pts[2].y!);
ctx.closePath();
ctx.fillStyle = d.color + '22';
ctx.fill();
ctx.stroke();
}
function renderPitchfork(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssW: number, cssH: number) {
if (d.points.length < 3) return;
const [p0, p1, p2] = d.points;
const x0 = m.timeToX(p0.time), y0 = m.priceToY(p0.price);
const x1 = m.timeToX(p1.time), y1 = m.priceToY(p1.price);
const x2 = m.timeToX(p2.time), y2 = m.priceToY(p2.price);
if (x0===null||y0===null||x1===null||y1===null||x2===null||y2===null) return;
const mx = (x1 + x2) / 2, my = (y1 + y2) / 2;
applyLineStyle(ctx, d.style);
ctx.strokeStyle = d.color;
ctx.lineWidth = d.lineWidth ?? 1.5;
// 핸들: p0 → 중간점
const drawExtendedLine = (sx: number, sy: number, ex: number, ey: number) => {
const dx = ex - sx, dy = ey - sy;
const len = Math.hypot(dx, dy);
if (len < 0.001) return;
const t = Math.max(cssW, cssH) * 2 / len;
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(sx + dx * t, sy + dy * t);
ctx.stroke();
};
// 핸들선
ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(mx, my); ctx.stroke();
// 중간 포크
drawExtendedLine(mx, my, mx + (mx - x0), my + (my - y0));
// 좌우 포크
const offX = x1 - mx, offY = y1 - my;
drawExtendedLine(x0 + offX, y0 + offY, x1, y1);
drawExtendedLine(x0 - offX, y0 - offY, x2, y2);
// 포인트 표시
[[x0,y0],[x1,y1],[x2,y2]].forEach(([px,py]) => {
ctx.beginPath(); ctx.arc(px, py, 4, 0, Math.PI*2);
ctx.fillStyle = d.color; ctx.fill();
});
}
function renderArrowMark(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, dir: 'up'|'down'|'left'|'right') {
if (!d.points[0]) return;
const cx = m.timeToX(d.points[0].time), cy = m.priceToY(d.points[0].price);
if (cx===null||cy===null) return;
const s = 10;
ctx.fillStyle = d.color;
ctx.beginPath();
switch (dir) {
case 'up':
ctx.moveTo(cx, cy - 20); ctx.lineTo(cx - s, cy - 10); ctx.lineTo(cx + s, cy - 10); break;
case 'down':
ctx.moveTo(cx, cy + 20); ctx.lineTo(cx - s, cy + 10); ctx.lineTo(cx + s, cy + 10); break;
case 'left':
ctx.moveTo(cx - 20, cy); ctx.lineTo(cx - 10, cy - s); ctx.lineTo(cx - 10, cy + s); break;
case 'right':
ctx.moveTo(cx + 20, cy); ctx.lineTo(cx + 10, cy - s); ctx.lineTo(cx + 10, cy + s); break;
}
ctx.closePath();
ctx.fill();
}
function renderLongShort(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, isLong: boolean) {
if (d.points.length < 2) return;
const x0 = m.timeToX(d.points[0].time), entry = m.priceToY(d.points[0].price);
const x1 = m.timeToX(d.points[1].time), target = m.priceToY(d.points[1].price);
if (x0===null||entry===null||x1===null||target===null) return;
const lx = Math.min(x0, x1), rx = Math.max(x0, x1);
// 손절선: entry에서 target 반대방향으로 같은 거리의 1/3
const diff = target - entry;
const stop = entry - diff * 0.33;
const profitColor = isLong ? 'rgba(76,175,80,0.25)' : 'rgba(239,83,80,0.25)';
const lossColor = isLong ? 'rgba(239,83,80,0.18)' : 'rgba(76,175,80,0.18)';
const lineColor = isLong ? '#4CAF50' : '#EF5350';
// 수익 구간
ctx.fillStyle = profitColor;
ctx.fillRect(lx, Math.min(entry, target), rx - lx, Math.abs(target - entry));
// 손실 구간
ctx.fillStyle = lossColor;
ctx.fillRect(lx, Math.min(entry, stop), rx - lx, Math.abs(stop - entry));
// 진입선
ctx.strokeStyle = lineColor;
ctx.lineWidth = 1.5;
ctx.setLineDash([]);
ctx.beginPath(); ctx.moveTo(lx, entry); ctx.lineTo(rx, entry); ctx.stroke();
// 타겟선
ctx.strokeStyle = isLong ? '#4CAF50' : '#EF5350';
ctx.setLineDash([4, 3]);
ctx.beginPath(); ctx.moveTo(lx, target); ctx.lineTo(rx, target); ctx.stroke();
// 손절선
ctx.strokeStyle = isLong ? '#EF5350' : '#4CAF50';
ctx.beginPath(); ctx.moveTo(lx, stop); ctx.lineTo(rx, stop); ctx.stroke();
ctx.setLineDash([]);
// 레이블
const tag = (label: string, y: number, fg: string) => {
ctx.font = 'bold 11px sans-serif';
const tw = ctx.measureText(label).width + 8;
ctx.fillStyle = fg;
ctx.fillRect(rx - tw - 2, y - 9, tw, 16);
ctx.fillStyle = '#fff';
ctx.fillText(label, rx - tw, y + 4);
};
tag(isLong ? '목표' : '손절', target, isLong ? '#4CAF50' : '#EF5350');
tag('진입', entry, '#607D8B');
tag(isLong ? '손절' : '목표', stop, isLong ? '#EF5350' : '#4CAF50');
}
// ── 신규 렌더 함수들 ────────────────────────────────────────────────────────
function renderCrossLine(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssW: number, cssH: number) {
if (!d.points[0]) return;
const x = m.timeToX(d.points[0].time), y = m.priceToY(d.points[0].price);
if (x === null || y === null) return;
applyLineStyle(ctx, d.style);
ctx.strokeStyle = d.color; ctx.lineWidth = d.lineWidth ?? 1;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(cssW, y); ctx.stroke();
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, cssH); ctx.stroke();
}
function renderHlineRay(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssW: number) {
if (!d.points[0]) return;
const x = m.timeToX(d.points[0].time), y = m.priceToY(d.points[0].price);
if (x === null || y === null) return;
applyLineStyle(ctx, d.style);
ctx.strokeStyle = d.color; ctx.lineWidth = d.lineWidth ?? 1;
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(cssW, y); ctx.stroke();
ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2);
ctx.fillStyle = d.color; ctx.fill();
}
function renderArrowLine(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssW: number) {
if (d.points.length < 2) return;
const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price);
const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price);
if (x0 === null || y0 === null || x1 === null || y1 === null) return;
const dx = x1 - x0, dy = y1 - y0;
let ex = x1, ey = y1;
if (Math.abs(dx) > 0.001) {
const t = (cssW - x0) / dx;
if (t > 1) { ex = cssW; ey = y0 + dy * t; }
}
applyLineStyle(ctx, d.style);
ctx.strokeStyle = d.color; ctx.lineWidth = d.lineWidth ?? 1.5;
ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(ex, ey); ctx.stroke();
const angle = Math.atan2(ey - y0, ex - x0);
const hs = 12;
ctx.fillStyle = d.color; ctx.setLineDash([]);
ctx.beginPath();
ctx.moveTo(ex, ey);
ctx.lineTo(ex - hs * Math.cos(angle - 0.45), ey - hs * Math.sin(angle - 0.45));
ctx.lineTo(ex - hs * Math.cos(angle + 0.45), ey - hs * Math.sin(angle + 0.45));
ctx.closePath(); ctx.fill();
}
function renderTrendAngle(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssW: number, cssH: number) {
if (d.points.length < 2) return;
renderLineOrRay(ctx, d, m, 'both', cssW, cssH);
const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price);
const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price);
if (x0 === null || y0 === null || x1 === null || y1 === null) return;
const lineAngle = Math.atan2(y1 - y0, x1 - x0);
const angleDeg = Math.round(-lineAngle * 180 / Math.PI);
const arcR = 28;
ctx.strokeStyle = d.color; ctx.lineWidth = 1; ctx.setLineDash([]);
ctx.beginPath(); ctx.arc(x0, y0, arcR, 0, lineAngle, lineAngle > 0); ctx.stroke();
ctx.font = '11px sans-serif'; ctx.fillStyle = d.color;
ctx.fillText(`${angleDeg}°`, x0 + arcR + 4, y0 - 4);
}
function renderInfoLine(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssW: number) {
if (!d.points[0]) return;
const y = m.priceToY(d.points[0].price);
if (y === null) return;
applyLineStyle(ctx, d.style);
ctx.strokeStyle = d.color; ctx.lineWidth = d.lineWidth ?? 1;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(cssW, y); ctx.stroke();
const price = d.points[0].price;
const label = formatUpbitKrwPrice(price);
ctx.font = 'bold 10px sans-serif';
const tw = ctx.measureText(label).width + 8;
ctx.fillStyle = d.color;
ctx.fillRect(cssW - tw - 4, y - 9, tw, 18);
ctx.fillStyle = '#000';
ctx.fillText(label, cssW - tw, y + 4);
}
function renderParallelChannel3(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssW: number, cssH: number) {
if (d.points.length < 2) return;
const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price);
const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price);
if (x0 === null || y0 === null || x1 === null || y1 === null) return;
const dx = x1 - x0, dy = y1 - y0;
const len = Math.hypot(dx, dy);
let offX = 0, offY = 0;
if (d.points.length >= 3 && len > 0.001) {
const x2 = m.timeToX(d.points[2].time), y2 = m.priceToY(d.points[2].price);
if (x2 !== null && y2 !== null) {
const nx = -dy / len, ny = dx / len;
const t = (x2 - x0) * nx + (y2 - y0) * ny;
offX = t * nx; offY = t * ny;
}
}
applyLineStyle(ctx, d.style);
ctx.strokeStyle = d.color; ctx.lineWidth = d.lineWidth ?? 1.5;
const T = (cssW + cssH) * 3;
const drawExt = (ox: number, oy: number, ls: string) => {
if (len < 0.001) return;
const t2 = T / len;
ctx.setLineDash(ls === 'dashed' ? [6, 4] : []);
ctx.beginPath(); ctx.moveTo(ox - dx * t2, oy - dy * t2); ctx.lineTo(ox + dx * t2, oy + dy * t2); ctx.stroke();
};
drawExt(x0, y0, d.style ?? 'solid');
if (offX !== 0 || offY !== 0) {
drawExt(x0 + offX, y0 + offY, 'dashed');
const t2 = T / Math.max(len, 1);
ctx.globalAlpha = 0.08; ctx.fillStyle = d.color;
ctx.beginPath();
ctx.moveTo(x0 - dx * t2, y0 - dy * t2); ctx.lineTo(x0 + dx * t2, y0 + dy * t2);
ctx.lineTo(x0 + offX + dx * t2, y0 + offY + dy * t2); ctx.lineTo(x0 + offX - dx * t2, y0 + offY - dy * t2);
ctx.closePath(); ctx.fill(); ctx.globalAlpha = 1;
}
ctx.setLineDash([]);
}
function renderFibExt(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssW: number) {
if (d.points.length < 2) return;
const range = d.points[1].price - d.points[0].price;
for (let i = 0; i < FIB_EXT_LEVELS.length; i++) {
const price = d.points[1].price + range * FIB_EXT_LEVELS[i];
const y = m.priceToY(price);
if (y === null) continue;
ctx.strokeStyle = FIB_EXT_COLORS[i] ?? '#888';
ctx.lineWidth = FIB_EXT_LEVELS[i] === 0 || FIB_EXT_LEVELS[i] === 1.0 ? 1.5 : 0.8;
ctx.setLineDash([3, 3]);
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(cssW, y); ctx.stroke();
ctx.setLineDash([]);
ctx.font = '9px monospace'; ctx.fillStyle = FIB_EXT_COLORS[i] ?? '#888';
ctx.fillText(`${(FIB_EXT_LEVELS[i] * 100).toFixed(1)}%`, 4, y - 2);
}
}
function renderFibChannel(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssW: number, cssH: number) {
if (d.points.length < 2) return;
const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price);
const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price);
if (x0 === null || y0 === null || x1 === null || y1 === null) return;
const dx = x1 - x0, dy = y1 - y0;
const len = Math.hypot(dx, dy);
if (len < 0.001) return;
const nx = -dy / len, ny = dx / len;
const height = dy;
const FIB_CH = [0, 0.236, 0.382, 0.5, 0.618, 0.786, 1.0, 1.618];
const T = (cssW + cssH) * 2 / Math.max(len, 1);
for (let i = 0; i < FIB_CH.length; i++) {
const off = height * FIB_CH[i];
const ox = nx * off, oy = ny * off;
ctx.strokeStyle = FIB_COLORS[Math.min(i, FIB_COLORS.length - 1)];
ctx.lineWidth = i === 0 || i === 6 ? 1.5 : 0.8;
ctx.setLineDash(i === 0 ? [] : [3, 3]);
ctx.beginPath();
ctx.moveTo(x0 + ox - dx * T, y0 + oy - dy * T);
ctx.lineTo(x0 + ox + dx * T, y0 + oy + dy * T);
ctx.stroke(); ctx.setLineDash([]);
ctx.font = '9px monospace'; ctx.fillStyle = FIB_COLORS[Math.min(i, FIB_COLORS.length - 1)];
ctx.fillText(`${(FIB_CH[i] * 100).toFixed(1)}%`, x0 + ox + 4, y0 + oy - 2);
}
}
function renderFibSpeedFan(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssW: number, cssH: number) {
if (d.points.length < 2) return;
const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price);
const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price);
if (x0 === null || y0 === null || x1 === null || y1 === null) return;
const dx = x1 - x0, dy = y1 - y0;
const FIB_FAN = [0.25, 0.333, 0.5, 0.618, 1.0];
const T = Math.max(cssW, cssH) * 3 / Math.max(Math.abs(dx), 1);
ctx.setLineDash([]);
FIB_FAN.forEach((r, i) => {
ctx.strokeStyle = FIB_COLORS[Math.min(i * 2, FIB_COLORS.length - 1)];
ctx.lineWidth = r === 1.0 ? 1.5 : 0.8;
ctx.beginPath(); ctx.moveTo(x0, y0);
ctx.lineTo(x0 + dx * T, y0 + dy * r * T);
ctx.stroke();
ctx.font = '9px monospace'; ctx.fillStyle = FIB_COLORS[Math.min(i * 2, FIB_COLORS.length - 1)];
ctx.fillText(`${Math.round(r * 100)}%`, x0 + dx * T - 24, y0 + dy * r * T);
});
}
function renderGannFan(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssW: number, cssH: number) {
if (d.points.length < 2) return;
const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price);
const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price);
if (x0 === null || y0 === null || x1 === null || y1 === null) return;
const tdx = Math.abs(x1 - x0);
const tdy = Math.abs(y1 - y0);
if (tdx < 1 || tdy < 1) return;
const baseRatio = tdy / tdx;
const dirX = x1 > x0 ? 1 : -1;
const dirY = y1 > y0 ? 1 : -1;
const T = Math.max(cssW, cssH) * 2;
ctx.setLineDash([]);
GANN_RATIOS.forEach((r, i) => {
ctx.strokeStyle = GANN_COLORS_ARR[i]; ctx.lineWidth = r === 1 ? 1.8 : 0.8;
const ex = x0 + dirX * T;
const ey = y0 + dirY * T * r * baseRatio;
ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(ex, ey); ctx.stroke();
ctx.font = '9px monospace'; ctx.fillStyle = GANN_COLORS_ARR[i];
ctx.fillText(GANN_LABELS[i], ex - 26, ey);
});
}
function renderGannBox(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) {
if (d.points.length < 2) return;
const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price);
const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price);
if (x0 === null || y0 === null || x1 === null || y1 === null) return;
const lx = Math.min(x0, x1), rx = Math.max(x0, x1);
const ty = Math.min(y0, y1), by = Math.max(y0, y1);
const w = rx - lx, h = by - ty;
ctx.strokeStyle = d.color; ctx.lineWidth = 1.2;
ctx.fillStyle = d.color + '11'; ctx.fillRect(lx, ty, w, h);
ctx.setLineDash([]); ctx.strokeRect(lx, ty, w, h);
ctx.setLineDash([2, 3]); ctx.globalAlpha = 0.4;
ctx.beginPath(); ctx.moveTo(lx, ty); ctx.lineTo(rx, by); ctx.stroke();
ctx.beginPath(); ctx.moveTo(rx, ty); ctx.lineTo(lx, by); ctx.stroke();
ctx.beginPath(); ctx.moveTo(lx + w / 2, ty); ctx.lineTo(lx + w / 2, by); ctx.stroke();
ctx.beginPath(); ctx.moveTo(lx, ty + h / 2); ctx.lineTo(rx, ty + h / 2); ctx.stroke();
for (const fx of [0.25, 0.75]) {
ctx.beginPath(); ctx.moveTo(lx + w * fx, ty); ctx.lineTo(lx + w * (1 - fx), by); ctx.stroke();
}
ctx.setLineDash([]); ctx.globalAlpha = 1;
}
function renderGannSquare(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) {
if (d.points.length < 2) return;
const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price);
const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price);
if (x0 === null || y0 === null || x1 === null || y1 === null) return;
const s = Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0));
const sx = x1 > x0 ? x0 : x0 - s;
const sy = y1 > y0 ? y0 : y0 - s;
ctx.strokeStyle = d.color; ctx.lineWidth = 1.2;
ctx.fillStyle = d.color + '0A'; ctx.fillRect(sx, sy, s, s); ctx.strokeRect(sx, sy, s, s);
ctx.setLineDash([2, 3]); ctx.globalAlpha = 0.35;
for (let i = 1; i < 8; i++) {
const f = i / 8;
ctx.beginPath(); ctx.moveTo(sx + s * f, sy); ctx.lineTo(sx + s * (1 - f), sy + s); ctx.stroke();
ctx.beginPath(); ctx.moveTo(sx, sy + s * f); ctx.lineTo(sx + s, sy + s * (1 - f)); ctx.stroke();
}
ctx.setLineDash([]); ctx.globalAlpha = 1;
}
function renderForecast(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) {
if (d.points.length < 2) return;
const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price);
const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price);
if (x0 === null || y0 === null || x1 === null || y1 === null) return;
const lx = Math.min(x0, x1), rx = Math.max(x0, x1);
const isUp = y1 < y0;
const ty = Math.min(y0, y1), by = Math.max(y0, y1);
const fillColor = isUp ? 'rgba(76,175,80,0.14)' : 'rgba(239,83,80,0.14)';
const borderColor = isUp ? '#4CAF50' : '#EF5350';
ctx.fillStyle = fillColor; ctx.fillRect(lx, ty, rx - lx, by - ty);
ctx.strokeStyle = borderColor; ctx.lineWidth = 1.2;
ctx.setLineDash([5, 3]); ctx.strokeRect(lx, ty, rx - lx, by - ty); ctx.setLineDash([]);
ctx.strokeStyle = '#9E9E9E'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(lx, y0); ctx.lineTo(rx, y0); ctx.stroke();
const pct = d.points[0].price > 0
? Math.abs((d.points[1].price - d.points[0].price) / d.points[0].price * 100).toFixed(2)
: '0';
ctx.font = 'bold 10px sans-serif'; ctx.fillStyle = borderColor;
ctx.fillText(`${isUp ? '▲' : '▼'} ${pct}%`, lx + 4, ty + 14);
}
function formatDateRangeLabel(m: ChartManager, t0: number, t1: number): string {
const secs = Math.abs(t1 - t0);
const bars = m.countBarsBetweenTime(t0, t1);
const days = Math.floor(secs / 86400);
const hrs = Math.floor((secs % 86400) / 3600);
const mins = Math.floor((secs % 3600) / 60);
const timePart = days > 0
? `${days}${hrs}시간`
: hrs > 0
? `${hrs}시간 ${mins}분`
: mins > 0
? `${mins}분`
: `${secs}초`;
return bars > 0 ? `${bars}봉, ${timePart}` : timePart;
}
function renderDateRange(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssH: number) {
if (d.points.length < 2) return;
const x0 = m.timeToX(d.points[0].time);
const x1 = m.timeToX(d.points[1].time);
if (x0 === null || x1 === null) return;
const lx = Math.min(x0, x1), rx = Math.max(x0, x1);
ctx.fillStyle = d.color + '1A'; ctx.fillRect(lx, 0, rx - lx, cssH);
ctx.strokeStyle = d.color; ctx.lineWidth = 1.2; ctx.setLineDash([]);
ctx.beginPath(); ctx.moveTo(lx, 0); ctx.lineTo(lx, cssH); ctx.stroke();
ctx.beginPath(); ctx.moveTo(rx, 0); ctx.lineTo(rx, cssH); ctx.stroke();
const label = formatDateRangeLabel(m, d.points[0].time, d.points[1].time);
ctx.font = 'bold 11px sans-serif'; ctx.fillStyle = d.color;
const tw = ctx.measureText(label).width;
ctx.fillText(label, lx + (rx - lx) / 2 - tw / 2, 16);
}
/** 날짜&가격 범위 — 날짜 범위(date_range) + 가격 범위(measure) 결합 */
function renderDatePriceRange(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssH: number, theme: Theme) {
if (d.points.length < 2) {
const p = d.points[0]; if (!p) return;
const x = m.timeToX(p.time), y = m.priceToY(p.price);
if (x !== null && y !== null) { ctx.beginPath(); ctx.arc(x, y, 4, 0, Math.PI*2); ctx.fillStyle = d.color; ctx.fill(); }
return;
}
const [p0, p1] = d.points;
const x0 = m.timeToX(p0.time), y0 = m.priceToY(p0.price);
const x1 = m.timeToX(p1.time), y1 = m.priceToY(p1.price);
if (x0===null||y0===null||x1===null||y1===null) return;
const lx = Math.min(x0,x1), rx = Math.max(x0,x1);
const ty = Math.min(y0,y1), by = Math.max(y0,y1);
const pct = (p1.price - p0.price) / p0.price * 100;
const isUp = pct >= 0;
const fillCol = isUp ? 'rgba(76,175,80,0.15)' : 'rgba(239,83,80,0.15)';
const lineCol = isUp ? '#4CAF50' : '#EF5350';
const textCol = isUp ? '#4CAF50' : '#EF5350';
// 배경 사각형
ctx.fillStyle = fillCol;
ctx.fillRect(lx, ty, rx - lx, by - ty);
// 테두리
ctx.strokeStyle = lineCol; ctx.lineWidth = 1; ctx.setLineDash([]);
ctx.strokeRect(lx, ty, rx - lx, by - ty);
// 수직 경계선 (전체 높이)
ctx.setLineDash([4,3]); ctx.lineWidth = 0.8;
ctx.beginPath(); ctx.moveTo(lx, 0); ctx.lineTo(lx, cssH); ctx.stroke();
ctx.beginPath(); ctx.moveTo(rx, 0); ctx.lineTo(rx, cssH); ctx.stroke();
ctx.setLineDash([]);
// ── 중앙 정보 배지 ──
const cx2 = (lx + rx) / 2;
const cy2 = (ty + by) / 2;
const secs = Math.abs(p1.time - p0.time);
const days = Math.floor(secs / 86400);
const hrs = Math.floor((secs % 86400) / 3600);
const dateLabel = days > 0 ? `${days}${hrs}시간` : `${hrs}시간`;
const sign = pct >= 0 ? '+' : '';
const priceLabel = `${sign}${pct.toFixed(2)}% Δ${Math.abs(p1.price - p0.price).toFixed(2)}`;
ctx.font = 'bold 12px sans-serif';
const dw = ctx.measureText(dateLabel).width;
ctx.font = '11px monospace';
const pw = ctx.measureText(priceLabel).width;
const bw2 = Math.max(dw, pw) + 16;
// 배지 배경
ctx.fillStyle = isUp ? 'rgba(76,175,80,0.85)' : 'rgba(239,83,80,0.85)';
if (ctx.roundRect) ctx.roundRect(cx2 - bw2/2, cy2 - 20, bw2, 40, 5);
else ctx.rect(cx2 - bw2/2, cy2 - 20, bw2, 40);
ctx.fill();
// 날짜 텍스트
ctx.fillStyle = '#fff'; ctx.font = 'bold 12px sans-serif'; ctx.textAlign = 'center';
ctx.fillText(dateLabel, cx2, cy2 - 4);
// 가격 텍스트
ctx.font = '11px monospace';
ctx.fillStyle = theme === 'dark' ? '#FFFDE7' : '#fff';
ctx.fillText(priceLabel, cx2, cy2 + 12);
ctx.textAlign = 'left';
}
/** 데이터 창 — 클릭 지점의 가격 정보 창 */
function renderDataWindow(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) {
if (!d.points[0]) return;
const x = m.timeToX(d.points[0].time), y = m.priceToY(d.points[0].price);
if (x===null||y===null) return;
const price = d.points[0].price;
const ts = d.points[0].time;
const rows: [string, string][] = [
['시간', formatUnixDateTime(ts)],
['가격', price.toFixed(2)],
];
const rowH = 20, pad = 10, labelW = 44;
ctx.font = '11px monospace';
const valueW = Math.max(...rows.map(([,v]) => ctx.measureText(v).width));
const bw = labelW + valueW + pad * 2 + 8;
const bh = rowH * rows.length + pad * 2 + 24; // +24 for header
const bx = x + 8, by = y - bh / 2;
// 배경
ctx.fillStyle = '#1E2A35'; ctx.strokeStyle = d.color; ctx.lineWidth = 1; ctx.setLineDash([]);
if (ctx.roundRect) ctx.roundRect(bx, by, bw, bh, 5);
else ctx.rect(bx, by, bw, bh);
ctx.fill(); ctx.stroke();
// 헤더
ctx.fillStyle = d.color;
if (ctx.roundRect) ctx.roundRect(bx, by, bw, 22, [5, 5, 0, 0]);
else ctx.rect(bx, by, bw, 22);
ctx.fill();
ctx.fillStyle = '#fff'; ctx.font = 'bold 11px monospace'; ctx.textAlign = 'center';
ctx.fillText('데이터 창', bx + bw / 2, by + 14);
ctx.textAlign = 'left';
// 행
rows.forEach(([label, value], i) => {
const ry = by + 22 + pad + i * rowH;
// 구분선
if (i > 0) {
ctx.strokeStyle = '#2E3D4D'; ctx.lineWidth = 0.5; ctx.setLineDash([]);
ctx.beginPath(); ctx.moveTo(bx + 4, ry - rowH/2 + 2); ctx.lineTo(bx + bw - 4, ry - rowH/2 + 2); ctx.stroke();
}
ctx.fillStyle = '#78909C'; ctx.font = '10px monospace';
ctx.fillText(label, bx + pad, ry + 4);
ctx.fillStyle = '#ECEFF1'; ctx.font = '11px monospace';
ctx.fillText(value, bx + pad + labelW, ry + 4);
});
// 앵커 점
ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI*2);
ctx.fillStyle = d.color; ctx.fill();
ctx.strokeStyle = d.color; ctx.lineWidth = 0.8; ctx.setLineDash([2,2]);
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(bx, by + bh/2); ctx.stroke();
ctx.setLineDash([]);
}
/** 돋보기 — 2점으로 렌즈 크기 지정, 원형 돋보기 아이콘 표시 */
function renderMagnifier(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) {
if (!d.points[0]) return;
const cx = m.timeToX(d.points[0].time), cy = m.priceToY(d.points[0].price);
if (cx === null || cy === null) return;
let r = 40;
if (d.points.length >= 2) {
const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price);
if (x1 !== null && y1 !== null) r = Math.max(20, Math.hypot(x1 - cx, y1 - cy));
}
// 렌즈 원형 채우기
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fillStyle = d.color + '18'; ctx.fill();
ctx.strokeStyle = d.color; ctx.lineWidth = 2; ctx.setLineDash([]);
ctx.stroke();
// 내부 십자선
ctx.strokeStyle = d.color + '60'; ctx.lineWidth = 0.8;
ctx.beginPath(); ctx.moveTo(cx - r * 0.7, cy); ctx.lineTo(cx + r * 0.7, cy); ctx.stroke();
ctx.beginPath(); ctx.moveTo(cx, cy - r * 0.7); ctx.lineTo(cx, cy + r * 0.7); ctx.stroke();
// 내부 동심원 (3개)
[0.33, 0.55, 0.78].forEach(ratio => {
ctx.beginPath(); ctx.arc(cx, cy, r * ratio, 0, Math.PI * 2);
ctx.strokeStyle = d.color + '30'; ctx.lineWidth = 0.5;
ctx.stroke();
});
// 손잡이 (우하단 45도)
const hx1 = cx + r * 0.72, hy1 = cy + r * 0.72;
const hx2 = cx + r * 1.3, hy2 = cy + r * 1.3;
ctx.strokeStyle = d.color; ctx.lineWidth = 4; ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(hx1, hy1); ctx.lineTo(hx2, hy2); ctx.stroke();
ctx.lineCap = 'butt';
// 중앙 + 표시
ctx.strokeStyle = d.color; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(cx - 5, cy); ctx.lineTo(cx + 5, cy); ctx.stroke();
ctx.beginPath(); ctx.moveTo(cx, cy - 5); ctx.lineTo(cx, cy + 5); ctx.stroke();
}
// ── 텍스트 & 노트 고유 렌더러 ─────────────────────────────────────────────────
/** 노트 (고정위치문자와 달리 노트패드 스타일 박스) */
function renderNoteText(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) {
if (!d.points[0]) return;
const x = m.timeToX(d.points[0].time), y = m.priceToY(d.points[0].price);
if (x === null || y === null) return;
const text = d.text ?? '노트';
ctx.font = '12px sans-serif';
const lines = text.split('\n');
const lh = 16, pad = 8;
const tw = Math.max(...lines.map(l => ctx.measureText(l).width));
const bw = Math.max(tw + pad * 2, 80), bh = lines.length * lh + pad * 2;
// 노트패드 배경 (연노랑)
ctx.fillStyle = '#FFFDE7';
ctx.strokeStyle = d.color; ctx.lineWidth = 1.2; ctx.setLineDash([]);
ctx.beginPath();
if (ctx.roundRect) ctx.roundRect(x, y, bw, bh, 4);
else { ctx.rect(x, y, bw, bh); }
ctx.fill(); ctx.stroke();
// 줄선 (노트패드 효과)
ctx.strokeStyle = '#F9A825'; ctx.lineWidth = 0.5;
for (let i = 1; i <= lines.length; i++) {
const ly = y + pad + i * lh - lh * 0.3;
if (ly < y + bh - 4) {
ctx.beginPath(); ctx.moveTo(x + pad, ly); ctx.lineTo(x + bw - pad, ly); ctx.stroke();
}
}
// 텍스트
ctx.fillStyle = '#37474F'; ctx.font = '12px sans-serif';
lines.forEach((line, i) => ctx.fillText(line, x + pad, y + pad + (i + 1) * lh - 3));
// 핀 앵커
ctx.beginPath(); ctx.arc(x + 4, y + 4, 3, 0, Math.PI * 2);
ctx.fillStyle = d.color; ctx.fill();
}
/** 프라이스 노트 (가격 수준 라인과 연결된 라벨) */
function renderPriceNote(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssW: number) {
if (!d.points[0]) return;
const x = m.timeToX(d.points[0].time), y = m.priceToY(d.points[0].price);
if (x === null || y === null) return;
const text = d.text ?? d.points[0].price.toFixed(2);
ctx.font = '11px monospace';
const tw = ctx.measureText(text).width;
const bw = tw + 16, bh = 22;
// 박스
ctx.fillStyle = d.color + 'DD'; ctx.strokeStyle = d.color; ctx.lineWidth = 1; ctx.setLineDash([]);
ctx.fillRect(x, y - bh / 2, bw, bh);
ctx.strokeRect(x, y - bh / 2, bw, bh);
// 우측으로 가격 수준선
ctx.strokeStyle = d.color; ctx.lineWidth = 0.8; ctx.setLineDash([4, 3]);
ctx.beginPath(); ctx.moveTo(x + bw, y); ctx.lineTo(cssW, y); ctx.stroke();
ctx.setLineDash([]);
// 텍스트
ctx.fillStyle = '#fff'; ctx.fillText(text, x + 8, y + 4);
// 앵커
ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2);
ctx.fillStyle = d.color; ctx.fill();
}
/** 코멘트 (원형 말풍선 — callout의 사각 말풍선과 구별) */
function renderCommentMark(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) {
if (!d.points[0]) return;
const x = m.timeToX(d.points[0].time), y = m.priceToY(d.points[0].price);
if (x === null || y === null) return;
const text = d.text ?? '코멘트';
ctx.font = '12px sans-serif';
const tw = ctx.measureText(text).width;
const pad = 10, r = 8;
const bw = Math.max(tw + pad * 2, 60), bh = 30;
const bx = x - bw / 2, by = y - bh - 10;
// 원형 모서리 말풍선
ctx.fillStyle = '#37474F'; ctx.strokeStyle = d.color; ctx.lineWidth = 1.5; ctx.setLineDash([]);
ctx.beginPath();
if (ctx.roundRect) ctx.roundRect(bx, by, bw, bh, r);
else { ctx.rect(bx, by, bw, bh); }
ctx.fill(); ctx.stroke();
// 꼬리
ctx.fillStyle = '#37474F'; ctx.strokeStyle = d.color; ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(x - 5, by + bh); ctx.lineTo(x, y); ctx.lineTo(x + 5, by + bh);
ctx.fill(); ctx.stroke();
// 작은 원 3개 (채팅 아이콘 느낌)
ctx.fillStyle = '#90A4AE';
[bx + bw * 0.3, bx + bw * 0.5, bx + bw * 0.7].forEach(cx2 => {
ctx.beginPath(); ctx.arc(cx2, by + bh / 2, 3, 0, Math.PI * 2); ctx.fill();
});
// 텍스트
ctx.fillStyle = '#ECEFF1'; ctx.textAlign = 'center';
ctx.fillText(text, x, by + bh / 2 + 4);
ctx.textAlign = 'left';
// 앵커
ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2);
ctx.fillStyle = d.color; ctx.fill();
}
/** 고정위치문자 (화면 고정 위치 텍스트 — 위치 앵커 원 표시) */
function renderFixedText(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) {
if (!d.points[0]) return;
const x = m.timeToX(d.points[0].time), y = m.priceToY(d.points[0].price);
if (x === null || y === null) return;
const text = d.text ?? '고정 텍스트';
ctx.font = '12px sans-serif';
const tw = ctx.measureText(text).width;
// 텍스트 배경
ctx.fillStyle = 'rgba(0,0,0,0.55)';
ctx.fillRect(x + 10, y - 13, tw + 8, 18);
ctx.fillStyle = d.color; ctx.fillText(text, x + 14, y);
// 앵커 원 + 십자
ctx.strokeStyle = d.color; ctx.lineWidth = 1.5; ctx.setLineDash([]);
ctx.beginPath(); ctx.arc(x, y, 6, 0, Math.PI * 2); ctx.stroke();
ctx.beginPath();
ctx.moveTo(x - 9, y); ctx.lineTo(x + 9, y);
ctx.moveTo(x, y - 9); ctx.lineTo(x, y + 9);
ctx.stroke();
}
/** 가격 라벨 (우측 가격축 라벨 스타일) */
function renderPriceLabel(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssW: number) {
if (!d.points[0]) return;
const x = m.timeToX(d.points[0].time), y = m.priceToY(d.points[0].price);
if (x === null || y === null) return;
const text = d.text ?? d.points[0].price.toFixed(2);
ctx.font = '11px monospace';
const tw = ctx.measureText(text).width;
const lbx = cssW - tw - 14;
// 수평 점선 연결
ctx.strokeStyle = d.color; ctx.lineWidth = 0.8; ctx.setLineDash([4, 3]);
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(lbx - 4, y); ctx.stroke();
ctx.setLineDash([]);
// 화살표 뾰족 라벨 박스 (가격축 스타일)
ctx.fillStyle = d.color;
ctx.beginPath();
ctx.moveTo(lbx - 8, y);
ctx.lineTo(lbx, y - 9);
ctx.lineTo(cssW, y - 9);
ctx.lineTo(cssW, y + 9);
ctx.lineTo(lbx, y + 9);
ctx.closePath();
ctx.fill();
// 텍스트
ctx.fillStyle = '#fff'; ctx.fillText(text, lbx + 2, y + 4);
// 앵커
ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2);
ctx.fillStyle = d.color; ctx.fill();
}
function renderCallout(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) {
if (!d.points[0]) return;
const x = m.timeToX(d.points[0].time), y = m.priceToY(d.points[0].price);
if (x === null || y === null) return;
const text = d.text ?? '텍스트';
ctx.font = '12px sans-serif';
const tw = ctx.measureText(text).width;
const bx = x - 8, bw = tw + 16, bh = 28, by = y - 44;
ctx.fillStyle = d.color + 'CC';
ctx.strokeStyle = d.color; ctx.lineWidth = 1.2; ctx.setLineDash([]);
ctx.beginPath();
if (ctx.roundRect) ctx.roundRect(bx, by, bw, bh, 5); else ctx.rect(bx, by, bw, bh);
ctx.fill(); ctx.stroke();
ctx.beginPath(); ctx.moveTo(x - 6, by + bh); ctx.lineTo(x, y); ctx.lineTo(x + 6, by + bh); ctx.closePath();
ctx.fill(); ctx.stroke();
ctx.fillStyle = '#000'; ctx.fillText(text, bx + 8, by + 18);
}
function renderPinMark(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) {
if (!d.points[0]) return;
const x = m.timeToX(d.points[0].time), y = m.priceToY(d.points[0].price);
if (x === null || y === null) return;
ctx.fillStyle = d.color; ctx.strokeStyle = d.color; ctx.lineWidth = 1.5; ctx.setLineDash([]);
ctx.beginPath(); ctx.arc(x, y - 14, 6, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.moveTo(x, y - 8); ctx.lineTo(x, y); ctx.stroke();
ctx.beginPath(); ctx.arc(x, y, 2, 0, Math.PI * 2);
ctx.fillStyle = '#fff'; ctx.fill();
ctx.fillStyle = d.color; ctx.strokeStyle = d.color; ctx.stroke();
}
function renderFlagMark(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) {
if (!d.points[0]) return;
const x = m.timeToX(d.points[0].time), y = m.priceToY(d.points[0].price);
if (x === null || y === null) return;
ctx.strokeStyle = d.color; ctx.fillStyle = d.color; ctx.lineWidth = 1.5; ctx.setLineDash([]);
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x, y - 24); ctx.stroke();
ctx.beginPath();
ctx.moveTo(x, y - 24); ctx.lineTo(x + 14, y - 20); ctx.lineTo(x, y - 16);
ctx.closePath(); ctx.fill();
}
function renderGuideLine(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssW: number) {
if (!d.points[0]) return;
const y = m.priceToY(d.points[0].price);
if (y === null) return;
ctx.strokeStyle = d.color + 'BB'; ctx.lineWidth = d.lineWidth ?? 1;
ctx.setLineDash([6, 4]);
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(cssW, y); ctx.stroke();
ctx.setLineDash([]);
const lbl = d.points[0].price.toFixed(2);
ctx.font = '10px monospace'; ctx.fillStyle = d.color + 'BB';
ctx.fillText(lbl, 4, y - 3);
}
function renderPitchSchiff(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssW: number, cssH: number) {
if (d.points.length < 3) return;
const [p0, p1, p2] = d.points;
const x0 = m.timeToX(p0.time), y0 = m.priceToY(p0.price);
const x1 = m.timeToX(p1.time), y1 = m.priceToY(p1.price);
const x2 = m.timeToX(p2.time), y2 = m.priceToY(p2.price);
if (x0===null||y0===null||x1===null||y1===null||x2===null||y2===null) return;
// Schiff: 핸들 시작점 = P0과 P1의 중간점
const hsx = (x0 + x1) / 2, hsy = (y0 + y1) / 2;
const mx = (x1 + x2) / 2, my = (y1 + y2) / 2;
applyLineStyle(ctx, d.style);
ctx.strokeStyle = d.color; ctx.lineWidth = d.lineWidth ?? 1.5;
const T = (cssW + cssH) * 2;
const drawExt = (sx: number, sy: number, ex: number, ey: number) => {
const ddx = ex - sx, ddy = ey - sy, ll = Math.hypot(ddx, ddy);
if (ll < 0.001) return;
ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(sx + ddx * T / ll, sy + ddy * T / ll); ctx.stroke();
};
ctx.beginPath(); ctx.moveTo(hsx, hsy); ctx.lineTo(mx, my); ctx.stroke();
drawExt(mx, my, mx + (mx - hsx), my + (my - hsy));
const offX = x1 - mx, offY = y1 - my;
drawExt(hsx + offX, hsy + offY, x1, y1);
drawExt(hsx - offX, hsy - offY, x2, y2);
[[hsx, hsy], [x1, y1], [x2, y2]].forEach(([px, py]) => {
ctx.beginPath(); ctx.arc(px, py, 4, 0, Math.PI * 2); ctx.fillStyle = d.color; ctx.fill();
});
}
function renderPitchInside(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssW: number, cssH: number) {
if (d.points.length < 3) return;
const [p0, p1, p2] = d.points;
const x0 = m.timeToX(p0.time), y0 = m.priceToY(p0.price);
const x1 = m.timeToX(p1.time), y1 = m.priceToY(p1.price);
const x2 = m.timeToX(p2.time), y2 = m.priceToY(p2.price);
if (x0===null||y0===null||x1===null||y1===null||x2===null||y2===null) return;
// Inside: 핸들 = 중간점(P0,P1) → 중간점(P1,P2)
const hsx = (x0 + x1) / 2, hsy = (y0 + y1) / 2;
const mx = (x1 + x2) / 2, my = (y1 + y2) / 2;
applyLineStyle(ctx, d.style);
ctx.strokeStyle = d.color; ctx.lineWidth = d.lineWidth ?? 1.5;
const T = (cssW + cssH) * 2;
const drawExt = (sx: number, sy: number, ex: number, ey: number) => {
const ddx = ex - sx, ddy = ey - sy, ll = Math.hypot(ddx, ddy);
if (ll < 0.001) return;
ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(sx + ddx * T / ll, sy + ddy * T / ll); ctx.stroke();
};
ctx.beginPath(); ctx.moveTo(hsx, hsy); ctx.lineTo(mx, my); ctx.stroke();
drawExt(mx, my, mx + (mx - hsx), my + (my - hsy));
const offX = x1 - mx, offY = y1 - my;
drawExt(hsx + offX, hsy + offY, x1, y1);
drawExt(hsx - offX, hsy - offY, x2, y2);
[[hsx, hsy], [x1, y1], [x2, y2]].forEach(([px, py]) => {
ctx.beginPath(); ctx.arc(px, py, 4, 0, Math.PI * 2); ctx.fillStyle = d.color; ctx.fill();
});
}
function renderEllipse(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) {
if (d.points.length < 2) return;
const cx = m.timeToX(d.points[0].time), cy = m.priceToY(d.points[0].price);
const ex = m.timeToX(d.points[1].time), ey = m.priceToY(d.points[1].price);
if (cx===null||cy===null||ex===null||ey===null) return;
const rx = Math.abs(ex - cx), ry = Math.abs(ey - cy);
if (rx < 1 || ry < 1) return;
applyLineStyle(ctx, d.style);
ctx.strokeStyle = d.color; ctx.lineWidth = d.lineWidth ?? 1.5;
ctx.beginPath();
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
ctx.fillStyle = d.color + '22'; ctx.fill(); ctx.stroke();
}
// ── 피보나치 원형 계열 ────────────────────────────────────────────────────────
function renderFibCircles(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) {
if (d.points.length < 2) return;
const cx = m.timeToX(d.points[0].time), cy = m.priceToY(d.points[0].price);
const ex = m.timeToX(d.points[1].time), ey = m.priceToY(d.points[1].price);
if (cx===null||cy===null||ex===null||ey===null) return;
const baseR = Math.hypot(ex - cx, ey - cy);
const levels = [0.236, 0.382, 0.5, 0.618, 0.786, 1.0, 1.618];
levels.forEach((lv, i) => {
const r = baseR * lv;
ctx.strokeStyle = FIB_COLORS[Math.min(i, FIB_COLORS.length - 1)];
ctx.lineWidth = lv === 1.0 ? 1.5 : 0.8;
ctx.setLineDash(lv === 1.0 ? [] : [3, 3]);
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke();
ctx.setLineDash([]);
ctx.font = '9px monospace';
ctx.fillStyle = FIB_COLORS[Math.min(i, FIB_COLORS.length - 1)];
ctx.fillText(`${(lv * 100).toFixed(1)}%`, cx + r + 2, cy - 2);
});
// 중심점
ctx.beginPath(); ctx.arc(cx, cy, 3, 0, Math.PI * 2);
ctx.fillStyle = d.color; ctx.fill();
}
function renderFibSpiral(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) {
if (d.points.length < 2) return;
const cx = m.timeToX(d.points[0].time), cy = m.priceToY(d.points[0].price);
const ex = m.timeToX(d.points[1].time), ey = m.priceToY(d.points[1].price);
if (cx===null||cy===null||ex===null||ey===null) return;
const baseR = Math.hypot(ex - cx, ey - cy);
const startAngle = Math.atan2(ey - cy, ex - cx);
// 황금 비율 나선: r = baseR * e^(b*θ), b = ln(φ)/(π/2)
const PHI = 1.6180339887;
const b = Math.log(PHI) / (Math.PI / 2);
const totalAngle = Math.PI * 3; // 1.5 바퀴
const steps = 300;
ctx.strokeStyle = d.color; ctx.lineWidth = 1.5; ctx.setLineDash([]);
ctx.beginPath();
for (let i = 0; i <= steps; i++) {
const theta = -totalAngle * (1 - i / steps);
const r = baseR * Math.exp(b * theta);
const x = cx + r * Math.cos(startAngle + theta);
const y = cy + r * Math.sin(startAngle + theta);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
// 외곽원 (기준)
ctx.strokeStyle = d.color + '44'; ctx.lineWidth = 0.8;
ctx.beginPath(); ctx.arc(cx, cy, baseR, 0, Math.PI * 2); ctx.stroke();
// 중심점
ctx.beginPath(); ctx.arc(cx, cy, 3, 0, Math.PI * 2);
ctx.fillStyle = d.color; ctx.fill();
}
function renderFibSpeedArc(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) {
if (d.points.length < 2) return;
const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price);
const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price);
if (x0===null||y0===null||x1===null||y1===null) return;
const baseLen = Math.hypot(x1 - x0, y1 - y0);
const baseAngle = Math.atan2(y1 - y0, x1 - x0);
// P0을 중심으로, 각 피보나치 거리에 반원호 그리기
const arcLevels = [0.236, 0.382, 0.5, 0.618, 0.786, 1.0];
arcLevels.forEach((lv, i) => {
const r = baseLen * lv;
ctx.strokeStyle = FIB_COLORS[Math.min(i, FIB_COLORS.length - 1)];
ctx.lineWidth = lv === 1.0 ? 1.5 : 0.8;
ctx.setLineDash(lv === 1.0 ? [] : [3, 2]);
// 기준선 방향 기준으로 ±90° 범위 호
ctx.beginPath();
ctx.arc(x0, y0, r, baseAngle - Math.PI / 2, baseAngle + Math.PI / 2, false);
ctx.stroke();
ctx.setLineDash([]);
ctx.font = '9px monospace';
ctx.fillStyle = FIB_COLORS[Math.min(i, FIB_COLORS.length - 1)];
const lx = x0 + r * Math.cos(baseAngle + Math.PI / 2) + 2;
const ly = y0 + r * Math.sin(baseAngle + Math.PI / 2);
ctx.fillText(`${(lv * 100).toFixed(1)}%`, lx, ly);
});
// 기준선
ctx.strokeStyle = d.color + '66'; ctx.lineWidth = 0.8; ctx.setLineDash([4, 3]);
ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke();
ctx.setLineDash([]);
}
function renderArc3(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) {
if (d.points.length < 3) return;
const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price);
const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price);
const x2 = m.timeToX(d.points[2].time), y2 = m.priceToY(d.points[2].price);
if (x0===null||y0===null||x1===null||y1===null||x2===null||y2===null) return;
// 세 점을 지나는 원 중심 계산
const D = 2 * (x0 * (y1 - y2) + x1 * (y2 - y0) + x2 * (y0 - y1));
if (Math.abs(D) < 0.001) {
applyLineStyle(ctx, d.style); ctx.strokeStyle = d.color; ctx.lineWidth = d.lineWidth ?? 1.5;
ctx.beginPath(); ctx.moveTo(x0,y0); ctx.lineTo(x1,y1); ctx.lineTo(x2,y2); ctx.stroke();
return;
}
const ux = ((x0*x0+y0*y0)*(y1-y2)+(x1*x1+y1*y1)*(y2-y0)+(x2*x2+y2*y2)*(y0-y1))/D;
const uy = ((x0*x0+y0*y0)*(x2-x1)+(x1*x1+y1*y1)*(x0-x2)+(x2*x2+y2*y2)*(x1-x0))/D;
const r = Math.hypot(x0 - ux, y0 - uy);
const ang0 = Math.atan2(y0 - uy, x0 - ux);
const ang2 = Math.atan2(y2 - uy, x2 - ux);
const cross = (x1 - x0) * (y2 - y0) - (y1 - y0) * (x2 - x0);
applyLineStyle(ctx, d.style); ctx.strokeStyle = d.color; ctx.lineWidth = d.lineWidth ?? 1.5;
ctx.beginPath(); ctx.arc(ux, uy, r, ang0, ang2, cross < 0); ctx.stroke();
}
// ── 도형 추가 렌더러 ────────────────────────────────────────────────────────────
function renderRotatedRect(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) {
if (d.points.length < 2) return;
const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price);
const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price);
if (x0===null||y0===null||x1===null||y1===null) return;
const dx = x1 - x0, dy = y1 - y0;
const len = Math.hypot(dx, dy);
if (len < 0.001) return;
// 수직 방향 (단위 법선)
const nx = -dy / len, ny = dx / len;
let offW = len * 0.5; // 기본 폭: 한 변 길이의 절반 미리보기
if (d.points.length >= 3) {
const x2 = m.timeToX(d.points[2].time), y2 = m.priceToY(d.points[2].price);
if (x2 !== null && y2 !== null) {
offW = (x2 - x0) * nx + (y2 - y0) * ny;
}
}
const corners: [number, number][] = [
[x0, y0],
[x1, y1],
[x1 + nx * offW, y1 + ny * offW],
[x0 + nx * offW, y0 + ny * offW],
];
applyLineStyle(ctx, d.style);
ctx.strokeStyle = d.color; ctx.lineWidth = d.lineWidth ?? 1.5;
ctx.fillStyle = d.color + '1A';
ctx.beginPath();
ctx.moveTo(corners[0][0], corners[0][1]);
corners.slice(1).forEach(([cx, cy]) => ctx.lineTo(cx, cy));
ctx.closePath();
ctx.fill(); ctx.stroke();
}
function renderParallelogram(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) {
if (d.points.length < 2) return;
const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price);
const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price);
if (x0===null||y0===null||x1===null||y1===null) return;
// P3 = P0 + (P2 - P1)
let x2 = x1, y2 = y1;
let x3 = x0, y3 = y0;
if (d.points.length >= 3) {
const px2 = m.timeToX(d.points[2].time), py2 = m.priceToY(d.points[2].price);
if (px2 !== null && py2 !== null) {
x2 = px2; y2 = py2;
x3 = x0 + (x2 - x1);
y3 = y0 + (y2 - y1);
}
}
applyLineStyle(ctx, d.style);
ctx.strokeStyle = d.color; ctx.lineWidth = d.lineWidth ?? 1.5;
ctx.fillStyle = d.color + '1A';
ctx.beginPath();
ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.lineTo(x2, y2); ctx.lineTo(x3, y3);
ctx.closePath();
ctx.fill(); ctx.stroke();
}
function renderPolyline(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) {
if (d.points.length < 2) return;
applyLineStyle(ctx, d.style);
ctx.strokeStyle = d.color; ctx.lineWidth = d.lineWidth ?? 1.5;
ctx.beginPath();
for (let i = 0; i < d.points.length; i++) {
const x = m.timeToX(d.points[i].time), y = m.priceToY(d.points[i].price);
if (x === null || y === null) continue;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
// 각 꼭짓점 표시
for (const pt of d.points) {
const x = m.timeToX(pt.time), y = m.priceToY(pt.price);
if (x !== null && y !== null) {
ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2);
ctx.fillStyle = d.color; ctx.fill();
}
}
}
// ── 패턴 & 프로젝션 렌더러 ────────────────────────────────────────────────────
// 각 패턴의 포인트 레이블 정의
const PATTERN_LABELS: Record<string, string[]> = {
xabcd: ['X', 'A', 'B', 'C', 'D'],
cypher: ['X', 'A', 'B', 'C', 'D'],
abcd: ['A', 'B', 'C', 'D'],
head_shoulders: ['L', 'LS', 'H', 'RS', 'R', 'NL1', 'NL2'],
wedge_pattern: ['1', '2', '3', '4', '5'],
three_drives: ['O', 'A', '1', 'B', '2', 'C'],
elliott_impulse: ['0', '1', '2', '3', '4', '5'],
elliott_correction: ['0', 'A', 'B', 'C'],
elliott_triangle_wave: ['0', 'A', 'B', 'C', 'D', 'E'],
elliott_double_combo: ['0', 'W', 'X', 'Y'],
};
// 패턴별 연결 방식 — undefined이면 순서대로 연결
// head_shoulders는 네크라인(마지막 2점)을 별도로 그림
const PATTERN_LINE_ORDER: Record<string, number[][]> = {
head_shoulders: [[0,1,2,3,4], [5,6]], // 패턴 + 넥라인
};
function renderZigzagPattern(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) {
if (!d.points.length) return;
const labels = PATTERN_LABELS[d.type] ?? [];
const lineOrders = PATTERN_LINE_ORDER[d.type];
applyLineStyle(ctx, d.style);
ctx.strokeStyle = d.color;
ctx.lineWidth = d.lineWidth ?? 1.5;
ctx.setLineDash([]);
// 픽셀 좌표 변환
const pts = d.points.map(p => ({
x: m.timeToX(p.time),
y: m.priceToY(p.price),
}));
// 선 그리기 (연결 순서 정의 있으면 사용, 없으면 순서대로)
const drawSegments = (order: number[]) => {
let started = false;
ctx.beginPath();
for (const idx of order) {
const pt = pts[idx];
if (!pt || pt.x === null || pt.y === null) break;
if (!started) { ctx.moveTo(pt.x, pt.y); started = true; }
else ctx.lineTo(pt.x, pt.y);
}
ctx.stroke();
};
if (lineOrders) {
lineOrders.forEach((order, i) => {
ctx.setLineDash(i > 0 ? [4, 3] : []); // 넥라인은 파선
drawSegments(order);
});
ctx.setLineDash([]);
} else {
drawSegments(pts.map((_, i) => i));
}
// 포인트 레이블 & 앵커 원
pts.forEach((pt, i) => {
if (!pt || pt.x === null || pt.y === null) return;
const label = labels[i] ?? `${i}`;
// 앵커 원
ctx.beginPath(); ctx.arc(pt.x, pt.y, 4, 0, Math.PI * 2);
ctx.fillStyle = d.color; ctx.fill();
// 레이블 배경
ctx.font = 'bold 10px sans-serif';
const tw = ctx.measureText(label).width + 6;
const lx = pt.x - tw / 2;
const ly = pt.y - 18;
ctx.fillStyle = d.color + 'DD';
ctx.fillRect(lx, ly, tw, 14);
// 레이블 텍스트
ctx.fillStyle = '#000';
ctx.fillText(label, lx + 3, ly + 11);
});
}
function renderGhostFeed(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) {
if (d.points.length < 2) return;
const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price);
const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price);
if (x0===null||y0===null||x1===null||y1===null) return;
const lx=Math.min(x0,x1), rx=Math.max(x0,x1), ty=Math.min(y0,y1), by=Math.max(y0,y1);
// 반투명 점선 박스
ctx.fillStyle = d.color + '0D';
ctx.fillRect(lx, ty, rx - lx, by - ty);
ctx.strokeStyle = d.color + '88';
ctx.lineWidth = 1.2;
ctx.setLineDash([5, 4]);
ctx.strokeRect(lx, ty, rx - lx, by - ty);
ctx.setLineDash([]);
// 내부에 캔들 실루엣 3개 (ghost 효과)
const cw = (rx - lx) / 5;
for (let i = 1; i <= 3; i++) {
const cx = lx + i * (rx - lx) / 4;
const h = (by - ty) * (0.4 + Math.sin(i) * 0.2);
const cy = (ty + by) / 2;
ctx.fillStyle = d.color + '22';
ctx.fillRect(cx - cw / 2, cy - h / 2, cw, h);
ctx.strokeStyle = d.color + '44';
ctx.lineWidth = 0.8;
ctx.setLineDash([]);
ctx.strokeRect(cx - cw / 2, cy - h / 2, cw, h);
}
}
function renderBarPattern(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) {
if (d.points.length < 2) return;
const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price);
const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price);
if (x0===null||y0===null||x1===null||y1===null) return;
const lx=Math.min(x0,x1), rx=Math.max(x0,x1), ty=Math.min(y0,y1), by=Math.max(y0,y1);
ctx.fillStyle = d.color + '14';
ctx.fillRect(lx, ty, rx - lx, by - ty);
ctx.strokeStyle = d.color;
ctx.lineWidth = 1.2;
ctx.setLineDash([]);
ctx.strokeRect(lx, ty, rx - lx, by - ty);
// 바 패턴 내부 구분선 (4등분)
ctx.strokeStyle = d.color + '44';
ctx.lineWidth = 0.8;
ctx.setLineDash([2, 3]);
for (let i = 1; i <= 3; i++) {
const xx = lx + (rx - lx) * i / 4;
ctx.beginPath(); ctx.moveTo(xx, ty); ctx.lineTo(xx, by); ctx.stroke();
}
ctx.setLineDash([]);
ctx.font = '10px monospace'; ctx.fillStyle = d.color + 'BB';
ctx.fillText('봉패턴', lx + 4, ty + 14);
}
function renderFixedVolumeProfile(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager) {
if (d.points.length < 2) return;
const x0 = m.timeToX(d.points[0].time), y0 = m.priceToY(d.points[0].price);
const x1 = m.timeToX(d.points[1].time), y1 = m.priceToY(d.points[1].price);
if (x0===null||y0===null||x1===null||y1===null) return;
const lx=Math.min(x0,x1), rx=Math.max(x0,x1), ty=Math.min(y0,y1), by=Math.max(y0,y1);
const h = by - ty, w = rx - lx;
ctx.strokeStyle = d.color; ctx.lineWidth = 1.2; ctx.setLineDash([]);
ctx.strokeRect(lx, ty, w, h);
// 수평 볼륨 바 5개
const rowH = h / 6;
const widths = [0.55, 0.8, 1.0, 0.7, 0.45].map(r => w * r * 0.6);
widths.forEach((bw, i) => {
const ry = ty + rowH * (i + 0.5) - rowH * 0.3;
ctx.fillStyle = d.color + '44';
ctx.fillRect(lx + 1, ry, bw, rowH * 0.7);
});
ctx.font = '9px monospace'; ctx.fillStyle = d.color + 'BB';
ctx.fillText('볼륨 프로파일', lx + 2, ty + 12);
}
function renderAnchoredVwap(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssW: number) {
if (!d.points[0]) return;
const x = m.timeToX(d.points[0].time);
const y = m.priceToY(d.points[0].price);
if (x === null || y === null) return;
applyLineStyle(ctx, d.style);
ctx.strokeStyle = d.color; ctx.lineWidth = d.lineWidth ?? 1.5;
// 앵커 시점부터 우측으로
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(cssW, y); ctx.stroke();
// 앵커 원
ctx.beginPath(); ctx.arc(x, y, 5, 0, Math.PI * 2);
ctx.fillStyle = d.color + 'CC'; ctx.fill();
ctx.strokeStyle = d.color; ctx.lineWidth = 1.5; ctx.stroke();
// VWAP 라벨
ctx.font = 'bold 10px monospace'; ctx.fillStyle = d.color;
ctx.fillText('VWAP', x + 8, y - 5);
}
function renderShape(ctx: CanvasRenderingContext2D, d: Drawing, m: ChartManager, cssW: number, cssH: number, theme: Theme) {
ctx.save();
const eff = resolveAlias(d.type);
switch (eff) {
// ── 기본 선 ──
case 'hline': renderHLine(ctx, d, m, cssW); break;
case 'vline': renderVLine(ctx, d, m, cssH); break;
case 'trendline': renderLineOrRay(ctx, d, m, 'both', cssW, cssH); break;
case 'ray': renderLineOrRay(ctx, d, m, 'right', cssW, cssH); break;
// ── 신규 독립 선 ──
case 'cross_line': renderCrossLine(ctx, d, m, cssW, cssH); break;
case 'hline_ray': renderHlineRay(ctx, d, m, cssW); break;
case 'arrow': renderArrowLine(ctx, d, m, cssW); break;
case 'trend_angle': renderTrendAngle(ctx, d, m, cssW, cssH); break;
case 'info_line': renderInfoLine(ctx, d, m, cssW); break;
case 'guide_line': renderGuideLine(ctx, d, m, cssW); break;
// ── 피보나치 ──
case 'fib': renderFib(ctx, d, m, cssW); break;
case 'fib_ext': renderFibExt(ctx, d, m, cssW); break;
case 'fib_channel': renderFibChannel(ctx, d, m, cssW, cssH); break;
case 'fib_speed_fan': renderFibSpeedFan(ctx, d, m, cssW, cssH); break;
case 'fib_circles': renderFibCircles(ctx, d, m); break;
case 'fib_spiral': renderFibSpiral(ctx, d, m); break;
case 'fib_speed_arc': renderFibSpeedArc(ctx, d, m); break;
case 'fibtz': renderFibTZ(ctx, d, m, cssH); break;
// ── 갠 ──
case 'gann_fan': renderGannFan(ctx, d, m, cssW, cssH); break;
case 'gann_box': renderGannBox(ctx, d, m); break;
case 'gann_square': renderGannSquare(ctx, d, m); break;
// ── 채널 / 사각형 ──
case 'measure': renderMeasure(ctx, d, m, theme); break;
case 'date_range': renderDateRange(ctx, d, m, cssH); break;
case 'date_price_range': renderDatePriceRange(ctx, d, m, cssH, theme); break;
case 'data_window': renderDataWindow(ctx, d, m); break;
case 'magnifier': renderMagnifier(ctx, d, m); break;
case 'rect': renderRect(ctx, d, m); break;
case 'channel': renderChannel(ctx, d, m, cssW, cssH); break;
case 'parallel_channel': renderParallelChannel3(ctx, d, m, cssW, cssH); break;
case 'forecast': renderForecast(ctx, d, m); break;
// ── 피치포크 ──
case 'pitchfork': renderPitchfork(ctx, d, m, cssW, cssH); break;
case 'pitchfork_schiff': renderPitchSchiff(ctx, d, m, cssW, cssH); break;
case 'pitchfork_inside': renderPitchInside(ctx, d, m, cssW, cssH); break;
// ── 도형 ──
case 'circle': renderCircle(ctx, d, m); break;
case 'ellipse': renderEllipse(ctx, d, m); break;
case 'triangle': renderTriangle(ctx, d, m); break;
case 'arc': renderArc3(ctx, d, m); break;
case 'rotated_rect': renderRotatedRect(ctx, d, m); break;
case 'parallelogram': renderParallelogram(ctx, d, m); break;
case 'polyline': renderPolyline(ctx, d, m); break;
// ── 텍스트 / 마커 ──
case 'pencil': renderPencil(ctx, d, m); break;
case 'text': renderText(ctx, d, m); break;
case 'callout': renderCallout(ctx, d, m); break;
case 'pin_mark': renderPinMark(ctx, d, m); break;
case 'flag_mark': renderFlagMark(ctx, d, m); break;
// ── 텍스트 & 노트 고유 렌더러 ──
case 'note_text': renderNoteText(ctx, d, m); break;
case 'price_note': renderPriceNote(ctx, d, m, cssW); break;
case 'comment_mark': renderCommentMark(ctx, d, m); break;
case 'fixed_text': renderFixedText(ctx, d, m); break;
case 'price_label': renderPriceLabel(ctx, d, m, cssW); break;
// ── 화살표 마크 ──
case 'arrow_mark_up': renderArrowMark(ctx, d, m, 'up'); break;
case 'arrow_mark_down': renderArrowMark(ctx, d, m, 'down'); break;
case 'arrow_mark_left': renderArrowMark(ctx, d, m, 'left'); break;
case 'arrow_mark_right': renderArrowMark(ctx, d, m, 'right'); break;
// ── 포지션 ──
case 'long_position': renderLongShort(ctx, d, m, true); break;
case 'short_position': renderLongShort(ctx, d, m, false); break;
// ── 패턴 (하모닉/엘리엇) ──
case 'xabcd':
case 'cypher':
case 'abcd':
case 'head_shoulders':
case 'wedge_pattern':
case 'three_drives':
case 'elliott_impulse':
case 'elliott_correction':
case 'elliott_triangle_wave':
case 'elliott_double_combo':
renderZigzagPattern(ctx, d, m); break;
// ── 프로젝션 도구 ──
case 'ghost_feed': renderGhostFeed(ctx, d, m); break;
case 'bar_pattern': renderBarPattern(ctx, d, m); break;
case 'fixed_volume_profile': renderFixedVolumeProfile(ctx, d, m); break;
case 'anchored_vwap': renderAnchoredVwap(ctx, d, m, cssW); break;
}
ctx.restore();
}
// ─── Component ─────────────────────────────────────────────────────────────
interface Props {
manager: ChartManager;
activeTool: string;
drawings: Drawing[];
onAddDrawing: (d: Drawing) => void;
onZoom: (x0: number, x1: number) => void;
theme: Theme;
enabled: boolean; // trading 모드
visible: boolean; // eye 토글
locked: boolean; // lock 토글
/** 현재 선택된 드로잉 ID (핸들 표시) */
selectedDrawingId?: string | null;
}
const DrawingCanvas: React.FC<Props> = ({
manager, activeTool, drawings, onAddDrawing, onZoom,
theme, enabled, visible, locked,
selectedDrawingId,
}) => {
const outerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const cssSize = useRef({ w: 0, h: 0 });
const animRef = useRef(0);
const pendingPts = useRef<DrawingPoint[]>([]);
const mousePoint = useRef<DrawingPoint | null>(null);
// pencil 드래그 상태
const isPencilDragging = useRef(false);
const pencilPts = useRef<DrawingPoint[]>([]);
// zoom 드래그 상태
const isZoomDragging = useRef(false);
const zoomStartX = useRef(0);
const zoomCurrentX = useRef(0);
const [textInput, setTextInput] = useState<{ x: number; y: number; point: DrawingPoint; toolType: string } | null>(null);
const [textVal, setTextVal] = useState('');
// ── Core redraw ──────────────────────────────────────────────────────
const redraw = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const { w, h } = cssSize.current;
if (w === 0 || h === 0) return;
const dpr = window.devicePixelRatio || 1;
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 숨김 모드: drawings 자체를 표시 안 함
if (!visible) return;
ctx.save();
ctx.scale(dpr, dpr);
// 확정된 드로잉 (visible === false 이면 숨김)
for (const d of drawings) {
if (d.visible === false) continue;
renderShape(ctx, d, manager, w, h, theme);
}
// 선택된 드로잉: 앵커 핸들 표시
if (selectedDrawingId) {
const sel = drawings.find(d => d.id === selectedDrawingId && d.visible !== false);
if (sel) {
// 선택 강조: 도형 위에 흰 외곽선 오버레이
ctx.save();
ctx.globalAlpha = 0.25;
ctx.strokeStyle = '#FFFFFF';
ctx.lineWidth = (sel.lineWidth ?? 1) + 4;
ctx.setLineDash([]);
renderShape(ctx, { ...sel, style: 'solid' }, manager, w, h, theme);
ctx.restore();
// 앵커 포인트마다 흰 원형 핸들
for (const pt of sel.points) {
const x = manager.timeToX(pt.time);
const y = manager.priceToY(pt.price);
if (x === null || y === null) continue;
ctx.beginPath();
ctx.arc(x, y, 5, 0, Math.PI * 2);
ctx.fillStyle = '#FFFFFF';
ctx.strokeStyle = sel.color;
ctx.lineWidth = 1.5;
ctx.globalAlpha = 0.9;
ctx.fill();
ctx.stroke();
ctx.globalAlpha = 1;
}
}
}
if (enabled && !locked) {
// pencil 실시간 경로 미리보기
if (activeTool === 'pencil' && isPencilDragging.current && pencilPts.current.length >= 2) {
ctx.save();
ctx.strokeStyle = TOOL_COLORS['pencil'];
ctx.lineWidth = 1.5;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.globalAlpha = 0.7;
ctx.beginPath();
for (let i = 0; i < pencilPts.current.length; i++) {
const x = manager.timeToX(pencilPts.current[i].time);
const y = manager.priceToY(pencilPts.current[i].price);
if (x === null || y === null) continue;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
ctx.restore();
}
// zoom 선택 영역 미리보기
if (activeTool === 'zoom' && isZoomDragging.current) {
const lx = Math.min(zoomStartX.current, zoomCurrentX.current);
const rx = Math.max(zoomStartX.current, zoomCurrentX.current);
ctx.save();
ctx.fillStyle = 'rgba(33,150,243,0.12)';
ctx.fillRect(lx, 0, rx - lx, h);
ctx.strokeStyle = '#2196F3';
ctx.lineWidth = 1;
ctx.setLineDash([4, 3]);
ctx.strokeRect(lx, 0, rx - lx, h);
ctx.restore();
}
// 돋보기: 1점 찍은 뒤 두 번째 점 미리보기
if (activeTool === 'magnifier' && pendingPts.current.length === 1 && mousePoint.current) {
const x0 = manager.timeToX(pendingPts.current[0].time);
const x1 = manager.timeToX(mousePoint.current.time);
if (x0 !== null && x1 !== null) {
const lx = Math.min(x0, x1);
const rx = Math.max(x0, x1);
ctx.save();
ctx.fillStyle = 'rgba(128,222,234,0.12)';
ctx.fillRect(lx, 0, rx - lx, h);
ctx.strokeStyle = TOOL_COLORS['magnifier'] ?? '#80DEEA';
ctx.lineWidth = 1;
ctx.setLineDash([4, 3]);
ctx.strokeRect(lx, 0, rx - lx, h);
ctx.restore();
}
}
// 일반 도구 미리보기 (cursor/pencil/zoom/magnifier 제외)
if (activeTool !== 'cursor' && activeTool !== 'pencil' && activeTool !== 'zoom' && activeTool !== 'magnifier' && mousePoint.current) {
const previewPts = [...pendingPts.current, mousePoint.current];
if (previewPts.length > 0) {
const preview: Drawing = {
id: '__preview',
type: activeTool as DrawingToolId,
points: previewPts,
color: TOOL_COLORS[activeTool] ?? '#FFFFFF',
lineWidth: 1, style: 'dashed',
};
ctx.globalAlpha = 0.65;
renderShape(ctx, preview, manager, w, h, theme);
ctx.globalAlpha = 1;
}
for (const pt of pendingPts.current) {
const x = manager.timeToX(pt.time), y = manager.priceToY(pt.price);
if (x !== null && y !== null) {
ctx.beginPath(); ctx.arc(x, y, 5, 0, Math.PI * 2);
ctx.fillStyle = TOOL_COLORS[activeTool] ?? '#FFFFFF';
ctx.fill();
}
}
}
}
ctx.restore();
}, [drawings, activeTool, manager, theme, enabled, visible, locked, selectedDrawingId]);
const scheduleRedraw = useCallback(() => {
cancelAnimationFrame(animRef.current);
animRef.current = requestAnimationFrame(redraw);
}, [redraw]);
// ── Subscribe to LWC viewport changes ────────────────────────────────
useEffect(() => {
const unsub = manager.subscribeViewport(scheduleRedraw);
return unsub;
}, [manager, scheduleRedraw]);
useEffect(() => { scheduleRedraw(); }, [scheduleRedraw]);
// ── Size canvas to match container ────────────────────────────────────
useEffect(() => {
const outer = outerRef.current;
if (!outer) return;
const applySize = (w: number, h: number) => {
const canvas = canvasRef.current;
if (!canvas || w === 0 || h === 0) return;
cssSize.current = { w, h };
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
scheduleRedraw();
};
const rect = outer.getBoundingClientRect();
applySize(rect.width, rect.height);
const ro = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
applySize(width, height);
});
ro.observe(outer);
return () => ro.disconnect();
}, [scheduleRedraw]);
// Reset pending when tool changes
useEffect(() => {
pendingPts.current = [];
mousePoint.current = null;
isPencilDragging.current = false;
pencilPts.current = [];
isZoomDragging.current = false;
scheduleRedraw();
}, [activeTool, scheduleRedraw]);
// ── Coordinate helpers ───────────────────────────────────────────────
const getPointFromXY = useCallback((clientX: number, clientY: number): DrawingPoint | null => {
const outer = outerRef.current;
if (!outer) return null;
const rect = outer.getBoundingClientRect();
const x = clientX - rect.left;
const y = clientY - rect.top;
const time = manager.xToTime(x);
const price = manager.yToPrice(y);
return (time !== null && price !== null) ? { time, price } : null;
}, [manager]);
const getPoint = useCallback((e: React.PointerEvent | React.MouseEvent): DrawingPoint | null => {
return getPointFromXY(e.clientX, e.clientY);
}, [getPointFromXY]);
const getLocalX = useCallback((e: React.PointerEvent): number => {
const outer = outerRef.current;
if (!outer) return 0;
return e.clientX - outer.getBoundingClientRect().left;
}, []);
// ── Pointer handlers (마우스·터치) ───────────────────────────────────
const handlePointerMove = useCallback((e: React.PointerEvent) => {
if ((activeTool === 'pencil' || activeTool === 'highlight') && isPencilDragging.current) {
const pt = getPoint(e);
if (pt) { pencilPts.current.push(pt); scheduleRedraw(); }
return;
}
if (activeTool === 'zoom' && isZoomDragging.current) {
zoomCurrentX.current = getLocalX(e);
scheduleRedraw();
return;
}
const pt = getPoint(e);
if (!pt) return;
mousePoint.current = pt;
scheduleRedraw();
}, [activeTool, getPoint, getLocalX, scheduleRedraw]);
const handlePointerLeave = useCallback(() => {
mousePoint.current = null;
scheduleRedraw();
}, [scheduleRedraw]);
const handlePointerCancel = useCallback(() => {
mousePoint.current = null;
if (isPencilDragging.current) {
isPencilDragging.current = false;
pencilPts.current = [];
}
if (isZoomDragging.current) {
isZoomDragging.current = false;
}
scheduleRedraw();
}, [scheduleRedraw]);
const handlePointerDown = useCallback((e: React.PointerEvent) => {
if (e.button !== 0) return;
if (!enabled || locked) return;
if (activeTool === 'pencil' || activeTool === 'highlight') {
isPencilDragging.current = true;
pencilPts.current = [];
const pt = getPoint(e);
if (pt) pencilPts.current.push(pt);
}
if (activeTool === 'zoom') {
isZoomDragging.current = true;
const x = getLocalX(e);
zoomStartX.current = x;
zoomCurrentX.current = x;
}
}, [enabled, locked, activeTool, getPoint, getLocalX]);
const handlePointerUp = useCallback((e: React.PointerEvent) => {
if ((activeTool === 'pencil' || activeTool === 'highlight') && isPencilDragging.current) {
isPencilDragging.current = false;
if (pencilPts.current.length >= 3) {
const pts = pencilPts.current;
const step = Math.max(1, Math.floor(pts.length / 200));
const sampled: DrawingPoint[] = [];
for (let i = 0; i < pts.length; i += step) sampled.push(pts[i]);
if (sampled[sampled.length - 1] !== pts[pts.length - 1]) sampled.push(pts[pts.length - 1]);
onAddDrawing({
id: `d_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
type: activeTool as DrawingToolId,
points: sampled,
color: activeTool === 'highlight' ? '#FFEB3B' : TOOL_COLORS['pencil'],
lineWidth: activeTool === 'highlight' ? 12 : 1.5,
style: 'solid',
});
}
pencilPts.current = [];
scheduleRedraw();
return;
}
if (activeTool === 'zoom' && isZoomDragging.current) {
isZoomDragging.current = false;
const x0 = zoomStartX.current;
const x1 = getLocalX(e);
scheduleRedraw();
if (Math.abs(x1 - x0) > 10) {
onZoom(x0, x1);
}
}
}, [activeTool, onAddDrawing, onZoom, getLocalX, scheduleRedraw]);
const handleClick = useCallback((e: React.MouseEvent) => {
if (!enabled || locked) return;
// drag 기반 툴과 커서는 click 처리 안 함
if (['pencil','highlight','zoom','cursor'].includes(activeTool)) return;
// 스텁 툴은 드로잉 생성 안 함 (선택만)
if (STUB_TOOLS.has(activeTool)) return;
const pt = getPoint(e);
if (!pt) return;
// 텍스트 입력이 필요한 모든 툴 처리
const TEXT_INPUT_TOOLS = new Set(['text', 'callout', 'fixed_text', 'note_text', 'comment_mark', 'table_tool', 'price_note', 'price_label']);
if (TEXT_INPUT_TOOLS.has(activeTool)) {
const outer = outerRef.current!.getBoundingClientRect();
setTextInput({ x: e.clientX - outer.left, y: e.clientY - outer.top, point: pt, toolType: activeTool });
setTextVal('');
return;
}
const newPts = [...pendingPts.current, pt];
const needed = POINTS_NEEDED[activeTool] ?? 1;
// 돋보기: 두 점으로 시간 구간 지정 → 메인 차트 확대 (TradingView 동작)
if (activeTool === 'magnifier' && newPts.length >= 2) {
const x0 = manager.timeToX(newPts[0].time);
const x1 = manager.timeToX(newPts[1].time);
pendingPts.current = [];
scheduleRedraw();
if (x0 !== null && x1 !== null && Math.abs(x1 - x0) > 10) {
onZoom(x0, x1);
}
return;
}
if (newPts.length >= needed) {
const d: Drawing = {
id: `d_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
type: activeTool as DrawingToolId,
points: newPts.slice(0, needed),
color: TOOL_COLORS[activeTool] ?? '#FFFFFF',
lineWidth: 1,
style: 'solid',
};
onAddDrawing(d);
pendingPts.current = [];
} else {
pendingPts.current = newPts;
}
scheduleRedraw();
}, [enabled, locked, activeTool, getPoint, onAddDrawing, onZoom, manager, scheduleRedraw]);
const handleDoubleClick = useCallback((e: React.MouseEvent) => {
if (!enabled || locked) return;
if (activeTool !== 'polyline') return;
// dblclick 직전 두 번의 click 이벤트가 점을 2개 추가함
// 그 중 마지막 1개(dblclick 2번째 click)를 제거하고 확정
const pts = pendingPts.current;
if (pts.length >= 2) {
pts.pop(); // 더블클릭의 2번째 click으로 추가된 중복 제거
const finalPts = [...pts];
pendingPts.current = [];
onAddDrawing({
id: `d_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
type: 'polyline' as DrawingToolId,
points: finalPts,
color: TOOL_COLORS['polyline'] ?? '#FF9800',
lineWidth: 1.5,
style: 'solid',
});
scheduleRedraw();
}
}, [enabled, locked, activeTool, onAddDrawing, scheduleRedraw]);
const commitText = useCallback(() => {
if (!textInput) return;
if (textVal.trim()) {
const toolType = textInput.toolType ?? 'text';
onAddDrawing({
id: `d_${Date.now()}`,
type: toolType as DrawingToolId,
points: [textInput.point],
color: TOOL_COLORS[toolType] ?? TOOL_COLORS['text'] ?? '#FFEB3B',
lineWidth: 1, style: 'solid',
text: textVal.trim(),
});
}
setTextInput(null);
}, [textInput, textVal, onAddDrawing]);
const isInteractive = enabled && !locked && activeTool !== 'cursor';
// zoom 드래그 중에는 cursor 변경
const getCursor = () => {
if (!isInteractive) return 'default';
if (activeTool === 'zoom') return isZoomDragging.current ? 'ew-resize' : 'zoom-in';
if (activeTool === 'magnifier') return 'zoom-in';
if (activeTool === 'pencil') return 'crosshair';
return 'crosshair';
};
return (
<div
ref={outerRef}
style={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
zIndex: 10,
}}
>
<canvas
ref={canvasRef}
style={{
display: 'block',
position: 'absolute',
top: 0,
left: 0,
pointerEvents: isInteractive ? 'auto' : 'none',
cursor: getCursor(),
touchAction: isInteractive ? 'none' : 'auto',
}}
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
onPointerCancel={handlePointerCancel}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
/>
{textInput && (
<input
autoFocus
style={{
position: 'absolute',
left: textInput.x,
top: textInput.y - 22,
background: 'rgba(0,0,0,0.8)',
color: '#fff',
border: '1px solid #FFD',
borderRadius: 3,
padding: '2px 6px',
fontSize: 13,
minWidth: 90,
pointerEvents:'auto',
zIndex: 20,
}}
value={textVal}
onChange={e => setTextVal(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') commitText(); if (e.key === 'Escape') setTextInput(null); }}
onBlur={commitText}
placeholder="텍스트 입력…"
/>
)}
</div>
);
};
export default DrawingCanvas;