/** 캔들 차트 매수·매도 시그널 라벨 (화살표와 분리 렌더) */ export interface TradeSignalLabelItem { time: number; text: string; color: string; isBuy: boolean; price: number; } export interface TradeSignalMarkerWithPrice { time: number; position: 'aboveBar' | 'belowBar'; shape: 'arrowUp' | 'arrowDown' | 'circle' | 'square'; color: string; text: string; price: number; } export interface SignalLabelAnchors { /** 매수 라벨 baseline (bottom) — 시간축 바로 위 */ buyBaseBottomY: number; /** 매도 라벨 baseline (top) — 캔들 pane 상단 */ sellBaseTopY: number; } export interface DrawableSignalLabel { item: TradeSignalLabelItem; x: number; arrowY: number; } export interface PlacedSignalLabel { item: TradeSignalLabelItem; x: number; arrowY: number; textY: number; baseline: 'top' | 'bottom'; } const CHART_BOTTOM_TIME_SCALE_H = 26; const GAP_ABOVE_TIME_AXIS = 4; const TOP_INSET = 10; export const SIGNAL_LABEL_LINE_HEIGHT = 15; const X_CLUSTER_PX = 12; const LABEL_COLLISION_PAD = 4; interface BarTimeResolver { getBarAtTime(time: number): { time: number } | undefined; getRawBars(): Array<{ time: number }>; timeToX(time: number): number | null; } export function toTradeSignalLabelItem(m: TradeSignalMarkerWithPrice): TradeSignalLabelItem { return { time: m.time, text: m.text, color: m.color, isBuy: m.position === 'belowBar', price: m.price, }; } /** 시그널 time → 차트 bar time (정확 일치 또는 최근접 봉) */ export function resolveSignalBarTime(mgr: BarTimeResolver, timeSec: number): number { const exact = mgr.getBarAtTime(timeSec); if (exact) return exact.time; const bars = mgr.getRawBars(); if (bars.length === 0) return timeSec; let best = bars[0].time; let bestDist = Math.abs(best - timeSec); for (let i = 1; i < bars.length; i++) { const t = bars[i].time; const dist = Math.abs(t - timeSec); if (dist < bestDist) { bestDist = dist; best = t; } } const interval = bars.length >= 2 ? Math.abs(bars[bars.length - 1].time - bars[bars.length - 2].time) : 60; const maxSnap = Math.max(interval * 2, 60); return bestDist <= maxSnap ? best : timeSec; } /** 같은 캔들·같은 X 클러스터로 묶기 */ export function clusterSignalLabels( items: DrawableSignalLabel[], mgr: BarTimeResolver, clusterPx = X_CLUSTER_PX, ): DrawableSignalLabel[][] { if (items.length === 0) return []; const enriched = items.map(d => ({ d, barTime: resolveSignalBarTime(mgr, d.item.time), })); const byBar = new Map(); for (const { d, barTime } of enriched) { const list = byBar.get(barTime) ?? []; list.push(d); byBar.set(barTime, list); } const groups = [...byBar.values()].map(group => { const barTime = resolveSignalBarTime(mgr, group[0].item.time); const snappedX = mgr.timeToX(barTime) ?? group[0].x; return group.map(d => ({ ...d, x: snappedX })); }); groups.sort((a, b) => a[0].x - b[0].x); const merged: DrawableSignalLabel[][] = []; for (const g of groups) { const last = merged[merged.length - 1]; if (last && Math.abs(g[0].x - last[0].x) <= clusterPx) { last.push(...g); } else { merged.push([...g]); } } return merged; } interface LabelRect { x: number; textY: number; width: number; height: number; baseline: 'top' | 'bottom'; } function labelRect( x: number, textY: number, width: number, height: number, baseline: 'top' | 'bottom', ): { left: number; right: number; top: number; bottom: number } { const halfW = width / 2; const top = baseline === 'top' ? textY : textY - height; const bottom = baseline === 'top' ? textY + height : textY; return { left: x - halfW, right: x + halfW, top, bottom }; } function rectsOverlap(a: LabelRect, b: LabelRect): boolean { const ra = labelRect(a.x, a.textY, a.width, a.height, a.baseline); const rb = labelRect(b.x, b.textY, b.width, b.height, b.baseline); const pad = LABEL_COLLISION_PAD; return ra.left < rb.right + pad && ra.right + pad > rb.left && ra.top < rb.bottom + pad && ra.bottom + pad > rb.top; } function compareSignalOrder(a: TradeSignalLabelItem, b: TradeSignalLabelItem): number { if (a.time !== b.time) return a.time - b.time; if (a.price !== b.price) return a.price - b.price; return a.text.localeCompare(b.text); } /** * 같은 캔들·겹치는 X 에 여러 시그널 — 세로 줄(lane) 분리 + 가로 겹침 시 다음 줄. */ export function layoutStackedSignalLabels( ctx: CanvasRenderingContext2D, items: DrawableSignalLabel[], mgr: BarTimeResolver, baseY: number, baseline: 'top' | 'bottom', font: string, ): PlacedSignalLabel[] { if (items.length === 0) return []; ctx.font = font; const placed: PlacedSignalLabel[] = []; const occupied: LabelRect[] = []; const lineHeight = SIGNAL_LABEL_LINE_HEIGHT; const clusters = clusterSignalLabels(items, mgr); for (const cluster of clusters) { const anchorX = cluster[0].x; const sorted = [...cluster].sort((a, b) => compareSignalOrder(a.item, b.item)); for (const d of sorted) { const width = ctx.measureText(d.item.text).width; let lane = 0; while (true) { const textY = baseline === 'top' ? baseY + lane * lineHeight : baseY - lane * lineHeight; const candidate: LabelRect = { x: anchorX, textY, width, height: lineHeight, baseline, }; if (!occupied.some(o => rectsOverlap(o, candidate))) { occupied.push(candidate); placed.push({ item: d.item, x: anchorX, arrowY: d.arrowY, textY, baseline, }); break; } lane += 1; if (lane > 24) { placed.push({ item: d.item, x: anchorX, arrowY: d.arrowY, textY, baseline, }); break; } } } } return placed; } /** 매수(하단·시간축 위) / 매도(상단) 라벨 기준 Y */ export function resolveSignalLabelAnchors( mgr: { getCandlePaneTimeAxisBand(): { topY: number; height: number; plotWidth: number } | null }, main: { topY: number; height: number }, ): SignalLabelAnchors { const band = mgr.getCandlePaneTimeAxisBand(); const buyBaseBottomY = band ? band.topY - GAP_ABOVE_TIME_AXIS : main.topY + main.height - CHART_BOTTOM_TIME_SCALE_H - GAP_ABOVE_TIME_AXIS; return { buyBaseBottomY, sellBaseTopY: main.topY + TOP_INSET, }; }