150 lines
4.6 KiB
TypeScript
150 lines
4.6 KiB
TypeScript
/**
|
|
* 볼린저밴드 상·하단 사이 영역 채움 (업비트 "백그라운드 그리기")
|
|
*/
|
|
import type {
|
|
ISeriesPrimitive,
|
|
IPrimitivePaneView,
|
|
SeriesAttachedParameter,
|
|
Time,
|
|
SeriesType,
|
|
ISeriesApi,
|
|
} from 'lightweight-charts';
|
|
import type { CanvasRenderingTarget2D, MediaCoordinatesRenderingScope } from 'fancy-canvas';
|
|
|
|
interface ScreenPt { x: number; y: number }
|
|
|
|
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);
|
|
const a = h.length >= 8 ? parseInt(h.slice(6, 8), 16) / 255 : alpha;
|
|
return `rgba(${r},${g},${b},${a})`;
|
|
}
|
|
|
|
function resolveFillColor(color: string): string {
|
|
if (color.length >= 9) {
|
|
return hexToRgba(color.slice(0, 7), parseInt(color.slice(7, 9), 16) / 255);
|
|
}
|
|
return hexToRgba(color.slice(0, 7), 0.15);
|
|
}
|
|
|
|
class BandFillRenderer {
|
|
constructor(
|
|
private readonly upper: ScreenPt[],
|
|
private readonly lower: ScreenPt[],
|
|
private readonly fillColor: string,
|
|
) {}
|
|
|
|
draw(target: CanvasRenderingTarget2D): void {
|
|
if (this.upper.length < 2 || this.lower.length < 2) return;
|
|
target.useMediaCoordinateSpace((scope: MediaCoordinatesRenderingScope) => {
|
|
const ctx = scope.context;
|
|
const n = this.upper.length;
|
|
ctx.save();
|
|
ctx.fillStyle = this.fillColor;
|
|
ctx.beginPath();
|
|
ctx.moveTo(this.upper[0].x, this.upper[0].y);
|
|
for (let i = 1; i < n; i++) ctx.lineTo(this.upper[i].x, this.upper[i].y);
|
|
for (let i = n - 1; i >= 0; i--) ctx.lineTo(this.lower[i].x, this.lower[i].y);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.restore();
|
|
});
|
|
}
|
|
}
|
|
|
|
class BandFillPaneView implements IPrimitivePaneView {
|
|
constructor(
|
|
private readonly upper: ScreenPt[],
|
|
private readonly lower: ScreenPt[],
|
|
private readonly fillColor: string,
|
|
) {}
|
|
|
|
renderer() { return new BandFillRenderer(this.upper, this.lower, this.fillColor); }
|
|
zOrder(): 'bottom' { return 'bottom'; }
|
|
}
|
|
|
|
export interface BandBackgroundOptions {
|
|
visible: boolean;
|
|
color: string;
|
|
}
|
|
|
|
/** basis 시리즈에 부착, upper/lower 데이터로 채움 */
|
|
export class BollingerBandFillPrimitive implements ISeriesPrimitive<Time> {
|
|
private _ap: SeriesAttachedParameter<Time> | null = null;
|
|
private _upper: ISeriesApi<SeriesType, Time>;
|
|
private _lower: ISeriesApi<SeriesType, Time>;
|
|
private _bg: BandBackgroundOptions;
|
|
private _upperPts: ScreenPt[] = [];
|
|
private _lowerPts: ScreenPt[] = [];
|
|
|
|
constructor(
|
|
upperSeries: ISeriesApi<SeriesType, Time>,
|
|
lowerSeries: ISeriesApi<SeriesType, Time>,
|
|
background: BandBackgroundOptions,
|
|
) {
|
|
this._upper = upperSeries;
|
|
this._lower = lowerSeries;
|
|
this._bg = background;
|
|
}
|
|
|
|
attached(ap: SeriesAttachedParameter<Time>): void {
|
|
this._ap = ap;
|
|
this._recompute();
|
|
}
|
|
detached(): void { this._ap = null; }
|
|
|
|
updateAllViews(): void { this._recompute(); }
|
|
paneViews(): readonly IPrimitivePaneView[] {
|
|
if (this._bg.visible === false || this._upperPts.length < 2) return [];
|
|
return [new BandFillPaneView(this._upperPts, this._lowerPts, resolveFillColor(this._bg.color))];
|
|
}
|
|
|
|
updateBackground(bg: BandBackgroundOptions): void {
|
|
this._bg = bg;
|
|
this._ap?.requestUpdate();
|
|
}
|
|
|
|
requestRefresh(): void {
|
|
this._recompute();
|
|
this._ap?.requestUpdate();
|
|
}
|
|
|
|
private _recompute(): void {
|
|
if (!this._ap || this._bg.visible === false) {
|
|
this._upperPts = [];
|
|
this._lowerPts = [];
|
|
return;
|
|
}
|
|
|
|
const ts = this._ap.chart.timeScale();
|
|
const upperRaw = this._upper.data() as readonly { time: Time; value?: number }[];
|
|
const lowerRaw = this._lower.data() as readonly { time: Time; value?: number }[];
|
|
const lowerByTime = new Map<Time, number>();
|
|
for (const d of lowerRaw) {
|
|
if (d.value !== undefined && d.value !== null && !Number.isNaN(d.value)) {
|
|
lowerByTime.set(d.time, d.value);
|
|
}
|
|
}
|
|
|
|
const upperPts: ScreenPt[] = [];
|
|
const lowerPts: ScreenPt[] = [];
|
|
|
|
for (const u of upperRaw) {
|
|
const lv = lowerByTime.get(u.time);
|
|
if (u.value === undefined || u.value === null || Number.isNaN(u.value)) continue;
|
|
if (lv === undefined || Number.isNaN(lv)) continue;
|
|
const x = ts.timeToCoordinate(u.time);
|
|
const uy = this._upper.priceToCoordinate(u.value);
|
|
const ly = this._lower.priceToCoordinate(lv);
|
|
if (x === null || uy === null || ly === null) continue;
|
|
upperPts.push({ x: x as number, y: uy as number });
|
|
lowerPts.push({ x: x as number, y: ly as number });
|
|
}
|
|
|
|
this._upperPts = upperPts;
|
|
this._lowerPts = lowerPts;
|
|
}
|
|
}
|