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 = { // 선 계열 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([ // 현재 모든 툴 구현 완료 ]); const TOOL_COLORS: Record = { 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 = { // ── 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 = { 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 = { 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 = ({ manager, activeTool, drawings, onAddDrawing, onZoom, theme, enabled, visible, locked, selectedDrawingId, }) => { const outerRef = useRef(null); const canvasRef = useRef(null); const cssSize = useRef({ w: 0, h: 0 }); const animRef = useRef(0); const pendingPts = useRef([]); const mousePoint = useRef(null); // pencil 드래그 상태 const isPencilDragging = useRef(false); const pencilPts = useRef([]); // 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 (
{textInput && ( setTextVal(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') commitText(); if (e.key === 'Escape') setTextInput(null); }} onBlur={commitText} placeholder="텍스트 입력…" /> )}
); }; export default DrawingCanvas;