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