/** * IndicatorFillPrimitive * * 보조지표 그래프에서 과매수(상한선 이상) / 과매도(하한선 이하) 구간에 * 반투명 음영을 그려주는 lightweight-charts ISeriesPrimitive 구현. * * - 상한선(과매수) 색상의 25% 투명도로 상단 영역을 채운다. * - 하한선(과매도) 색상의 25% 투명도로 하단 영역을 채운다. * - 지표 선과 한계선 사이의 정확한 교차점을 보간하여 부드러운 채움을 표현한다. */ import type { ISeriesPrimitive, IPrimitivePaneView, SeriesAttachedParameter, Time, SeriesType, ISeriesApi, } from 'lightweight-charts'; import type { CanvasRenderingTarget2D, MediaCoordinatesRenderingScope } from 'fancy-canvas'; import { getHLineLabel } from './indicatorRegistry'; import type { HLineDef } from './indicatorRegistry'; // ───────────────────────────────────────────────────────────────────────────── // 내부 유틸 // ───────────────────────────────────────────────────────────────────────────── /** HEX(6~9자리) + alpha 값 → CSS rgba() 문자열 */ function hexToRgba(hex: string, alpha: number): string { const h = hex.startsWith('#') ? hex.slice(1) : hex; const r = parseInt(h.slice(0, 2), 16); const g = parseInt(h.slice(2, 4), 16); const b = parseInt(h.slice(4, 6), 16); return `rgba(${r},${g},${b},${alpha})`; } interface ScreenPt { x: number; y: number; } interface FillZone { limitY: number; color: string; isAbove: boolean; // true=상한 초과, false=하한 미만 isBand?: boolean; // true=배경 직사각형 밴드 (과열선~침체선 사이) bandLimitY?: number; // isBand=true 일 때 반대쪽 경계 Y } // ───────────────────────────────────────────────────────────────────────────── // Canvas 그리기 유틸 (교차점 보간 포함) // ───────────────────────────────────────────────────────────────────────────── /** * 하나의 연속 구간에 대해 채움 폴리곤을 그린다. * pts[0]·pts[last]는 반드시 limitY 위에 있어야 한다 (교차점 또는 첫/마지막 점). */ function drawSegment( ctx: CanvasRenderingContext2D, pts: ScreenPt[], limitY: number, ): void { if (pts.length < 2) return; ctx.beginPath(); ctx.moveTo(pts[0].x, limitY); for (const p of pts) ctx.lineTo(p.x, p.y); ctx.lineTo(pts[pts.length - 1].x, limitY); ctx.closePath(); ctx.fill(); } /** * isAbove=true: indicator > limitPrice(화면상 y < limitY) → 빨간 음영 * isAbove=false: indicator < limitPrice(화면상 y > limitY) → 초록 음영 */ function drawFillZone( ctx: CanvasRenderingContext2D, pts: ScreenPt[], limitY: number, color: string, isAbove: boolean, ): void { if (pts.length < 2) return; ctx.save(); ctx.fillStyle = color; const inZone = (y: number) => isAbove ? y < limitY : y > limitY; let seg: ScreenPt[] = []; const flushSeg = () => { if (seg.length >= 1) { drawSegment(ctx, seg, limitY); seg = []; } }; for (let i = 0; i < pts.length; i++) { const cur = pts[i]; const curIn = inZone(cur.y); if (curIn) { if (seg.length === 0 && i > 0) { // 이전 점과의 교차점 보간 const prev = pts[i - 1]; const t = (limitY - prev.y) / (cur.y - prev.y); seg.push({ x: prev.x + t * (cur.x - prev.x), y: limitY }); } seg.push({ x: cur.x, y: cur.y }); } else if (seg.length > 0) { // 구간 종료 → 종료 교차점 보간 const prev = pts[i - 1]; const t = (limitY - prev.y) / (cur.y - prev.y); seg.push({ x: prev.x + t * (cur.x - prev.x), y: limitY }); flushSeg(); } } flushSeg(); // 마지막 구간 ctx.restore(); } // ───────────────────────────────────────────────────────────────────────────── // Renderer // ───────────────────────────────────────────────────────────────────────────── class FillRenderer { constructor( private readonly pts: ScreenPt[], private readonly zones: FillZone[], ) {} draw(target: CanvasRenderingTarget2D): void { target.useMediaCoordinateSpace((scope: MediaCoordinatesRenderingScope) => { const ctx = scope.context; for (const z of this.zones) { if (z.isBand && z.bandLimitY !== undefined && this.pts.length >= 2) { // 과열선~침체선 사이 배경 직사각형 const minX = this.pts[0].x; const maxX = this.pts[this.pts.length - 1].x; const topY = Math.min(z.limitY, z.bandLimitY); const bottomY = Math.max(z.limitY, z.bandLimitY); ctx.save(); ctx.fillStyle = z.color; ctx.fillRect(minX, topY, maxX - minX, bottomY - topY); ctx.restore(); } else { drawFillZone(ctx, this.pts, z.limitY, z.color, z.isAbove); } } }); } } // ───────────────────────────────────────────────────────────────────────────── // PaneView // ───────────────────────────────────────────────────────────────────────────── class FillPaneView implements IPrimitivePaneView { constructor( private readonly pts: ScreenPt[], private readonly zones: FillZone[], ) {} renderer() { return new FillRenderer(this.pts, this.zones); } zOrder(): 'bottom' { return 'bottom'; } } // ───────────────────────────────────────────────────────────────────────────── // Public Primitive // ───────────────────────────────────────────────────────────────────────────── export interface HlinesBackgroundOptions { visible: boolean; color: string; } export class IndicatorFillPrimitive implements ISeriesPrimitive