goldenChat base source add

This commit is contained in:
aidev
2026-05-23 15:11:48 +09:00
commit a4ea7762b5
2081 changed files with 1155760 additions and 0 deletions
@@ -0,0 +1,272 @@
/**
* 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 === '과열선',
});
}
}
}