273 lines
11 KiB
TypeScript
273 lines
11 KiB
TypeScript
/**
|
|
* 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<Time> {
|
|
private _ap: SeriesAttachedParameter<Time> | null = null;
|
|
private _hlines: HLineDef[] = [];
|
|
private _background: HlinesBackgroundOptions | undefined;
|
|
private _pts: ScreenPt[] = [];
|
|
private _zones: FillZone[] = [];
|
|
|
|
constructor(hlines: HLineDef[], background?: HlinesBackgroundOptions) {
|
|
this._hlines = hlines;
|
|
this._background = background;
|
|
}
|
|
|
|
// ISeriesPrimitive 콜백
|
|
attached(ap: SeriesAttachedParameter<Time>): void { this._ap = ap; }
|
|
detached(): void { this._ap = null; }
|
|
|
|
updateAllViews(): void { this._recompute(); }
|
|
paneViews(): readonly IPrimitivePaneView[] {
|
|
return [new FillPaneView(this._pts, this._zones)];
|
|
}
|
|
|
|
/** 외부에서 hlines 가 변경되면 호출 */
|
|
updateHLines(hlines: HLineDef[], background?: HlinesBackgroundOptions): void {
|
|
this._hlines = hlines;
|
|
this._background = background;
|
|
this._ap?.requestUpdate();
|
|
}
|
|
|
|
// ─── 좌표 재계산 ───────────────────────────────────────────────────────────
|
|
private _recompute(): void {
|
|
if (!this._ap) { this._pts = []; this._zones = []; return; }
|
|
|
|
const series = this._ap.series as ISeriesApi<SeriesType, Time>;
|
|
const ts = this._ap.chart.timeScale();
|
|
|
|
// 시리즈 데이터 → 화면 좌표
|
|
const raw = series.data() as readonly { time: Time; value?: number }[];
|
|
this._pts = raw
|
|
.map(d => {
|
|
if (d.value === undefined || d.value === null || isNaN(d.value)) return null;
|
|
const x = ts.timeToCoordinate(d.time);
|
|
const y = series.priceToCoordinate(d.value);
|
|
if (x === null || y === null) return null;
|
|
return { x: x as number, y: y as number };
|
|
})
|
|
.filter((p): p is ScreenPt => p !== null);
|
|
|
|
// 음영 구간 결정 (과매수/과매도 레이블이 있는 hline 만 사용)
|
|
const visible = this._hlines.filter(h => h.visible !== false);
|
|
const prices = visible.map(h => h.price);
|
|
|
|
this._zones = [];
|
|
|
|
// ── Hlines Background: 과열선~침체선 사이 배경 밴드 ────────────────────
|
|
const bg = this._background;
|
|
if (bg?.visible !== false) {
|
|
const upperHl = visible.find(h => (h.label ?? getHLineLabel(h.price, prices)) === '과열선');
|
|
const lowerHl = visible.find(h => (h.label ?? getHLineLabel(h.price, prices)) === '침체선');
|
|
if (upperHl && lowerHl) {
|
|
const upperY = series.priceToCoordinate(upperHl.price);
|
|
const lowerY = series.priceToCoordinate(lowerHl.price);
|
|
if (upperY !== null && lowerY !== null) {
|
|
// 배경을 채우기 위해 항상 존재하는 "모든 구간" 영역으로 처리
|
|
// 과열선 아래(침체선까지)를 채우는 방식: 두 개의 존 대신 캔버스 rect 로 직접 그림
|
|
const bgColor = bg?.color
|
|
? hexToRgba(bg.color.slice(0, 7), parseInt(bg.color.slice(7, 9) || 'ff', 16) / 255 * 0.4)
|
|
: 'rgba(120,123,134,0.08)';
|
|
// 배경 존은 limitY=upperY, isAbove=false 로 표현하면 침체선 아래만 채움.
|
|
// 대신 "배경 전용 존"은 전체 visible 구간을 채우도록 두 경계를 모두 넣는다.
|
|
this._zones.push({
|
|
limitY: upperY as number,
|
|
color: bgColor,
|
|
isAbove: false,
|
|
isBand: true,
|
|
bandLimitY: lowerY as number,
|
|
} as FillZone & { isBand: true; bandLimitY: number });
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 과매수/과매도 초과 구간 음영 ────────────────────────────────────────
|
|
for (const hl of visible) {
|
|
const label = hl.label ?? getHLineLabel(hl.price, prices);
|
|
if (label !== '과열선' && label !== '침체선') continue;
|
|
|
|
const limitY = series.priceToCoordinate(hl.price);
|
|
if (limitY === null) continue;
|
|
|
|
this._zones.push({
|
|
limitY: limitY as number,
|
|
color: hexToRgba(hl.color.slice(0, 7), 0.25),
|
|
isAbove: label === '과열선',
|
|
});
|
|
}
|
|
}
|
|
}
|