3800 lines
146 KiB
TypeScript
3800 lines
146 KiB
TypeScript
import {
|
|
createChart,
|
|
CandlestickSeries,
|
|
BarSeries,
|
|
LineSeries,
|
|
AreaSeries,
|
|
BaselineSeries,
|
|
HistogramSeries,
|
|
createSeriesMarkers,
|
|
type IChartApi,
|
|
type ISeriesApi,
|
|
type SeriesType,
|
|
type MouseEventParams,
|
|
type Time,
|
|
type TickMarkFormatter,
|
|
type LogicalRange,
|
|
type IPriceLine,
|
|
type ISeriesMarkersPluginApi,
|
|
ColorType,
|
|
LineStyle,
|
|
CrosshairMode,
|
|
} from 'lightweight-charts';
|
|
import { IndicatorFillPrimitive } from './IndicatorFillPrimitive';
|
|
import { BollingerBandFillPrimitive } from './BollingerBandFillPrimitive';
|
|
import { resolveBbBandBackground } from './bollingerConfig';
|
|
import type { OHLCVBar, ChartType, Theme, IndicatorConfig, Timeframe } from '../types';
|
|
import { formatChartAxisPrice, formatIndicatorAxisPrice } from './dataGenerator';
|
|
import { TRADE_BUY_COLOR, TRADE_SELL_COLOR } from './tradeSignalColors';
|
|
import {
|
|
DEFAULT_CHART_TIME_FORMAT,
|
|
formatUnixWithChartPattern,
|
|
formatLwcTimeWithPattern,
|
|
formatLwcTickMarkWithPattern,
|
|
normalizeChartTimeFormat,
|
|
} from './chartTimeFormat';
|
|
import { DEFAULT_DISPLAY_TIMEZONE } from './timezone';
|
|
import { sortIndicatorsForPaneLoad } from './indicatorPaneMerge';
|
|
import { calculateIndicator, enrichIndicatorConfig, getIndicatorDef, getHLineLabel, type PlotData, type MarkerData } from './indicatorRegistry';
|
|
import { IchimokuCloudPlugin, type IchimokuCloudPoint } from './IchimokuCloudPlugin';
|
|
|
|
const MAIN_PRICE_FORMAT = {
|
|
type: 'custom' as const,
|
|
minMove: 1,
|
|
formatter: formatChartAxisPrice,
|
|
};
|
|
|
|
const INDICATOR_PRICE_FORMAT = {
|
|
type: 'custom' as const,
|
|
minMove: 0.01,
|
|
formatter: formatIndicatorAxisPrice,
|
|
};
|
|
|
|
function priceFormatForIndicatorPane(paneIndex: number) {
|
|
return paneIndex >= 2 ? INDICATOR_PRICE_FORMAT : undefined;
|
|
}
|
|
import {
|
|
getIchimokuPlotTitle,
|
|
isIchimokuCloudVisible,
|
|
resolveIchimokuCloudColors,
|
|
resolveIchimokuDisplacements,
|
|
} from './ichimokuConfig';
|
|
import type { PlotDef, HLineStyle } from './indicatorRegistry';
|
|
import { mapHistogramSeriesData, histogramBarColor } from './plotColorUtils';
|
|
import {
|
|
applyPaneSeparatorToChartOptions,
|
|
resolveChartPaneSeparatorOptions,
|
|
type ChartPaneSeparatorOptions,
|
|
} from '../types/chartPaneSeparator';
|
|
|
|
function plotSeriesLineStyle(style?: HLineStyle): number {
|
|
if (style === 'dashed') return LineStyle.Dashed;
|
|
if (style === 'dotted') return LineStyle.Dotted;
|
|
return LineStyle.Solid;
|
|
}
|
|
import { calcAutoFib } from './calculations';
|
|
|
|
interface ThemeTokens {
|
|
bgColor: string;
|
|
textColor: string;
|
|
gridColor: string;
|
|
borderColor: string;
|
|
crosshairColor: string;
|
|
upColor: string;
|
|
downColor: string;
|
|
}
|
|
|
|
// 다크: 도쿄 나이트 스타일 (THEME_SETTINGS.md §2.2 / §3 darkChartColors)
|
|
const DARK: ThemeTokens = {
|
|
bgColor: '#1a1b26',
|
|
textColor: '#c0caf5',
|
|
gridColor: 'rgba(122, 162, 247, 0.10)',
|
|
borderColor: 'rgba(122, 162, 247, 0.15)',
|
|
crosshairColor: 'rgba(122, 162, 247, 0.60)',
|
|
upColor: '#ff6b6b', // 한국 관례: 상승=빨간색
|
|
downColor: '#4dabf7', // 한국 관례: 하락=파란색
|
|
};
|
|
// 라이트 (THEME_SETTINGS.md §2.1 / §3 lightChartColors)
|
|
const LIGHT: ThemeTokens = {
|
|
bgColor: '#ffffff',
|
|
textColor: '#212121',
|
|
gridColor: '#f0f0f0',
|
|
borderColor: 'rgba(0, 0, 0, 0.08)',
|
|
crosshairColor: 'rgba(33, 150, 243, 0.50)',
|
|
upColor: '#c62828', // 한국 관례: 상승=빨간색
|
|
downColor: '#1565c0', // 한국 관례: 하락=파란색
|
|
};
|
|
// 블루: 딥 네이비 (THEME_SETTINGS.md §2.3 / §3 blueChartColors)
|
|
const BLUE: ThemeTokens = {
|
|
bgColor: '#0c1929',
|
|
textColor: '#e8f4ff',
|
|
gridColor: 'rgba(94, 181, 255, 0.10)',
|
|
borderColor: 'rgba(94, 181, 255, 0.15)',
|
|
crosshairColor: 'rgba(94, 181, 255, 0.60)',
|
|
upColor: '#ff5555', // 한국 관례: 상승=빨간색
|
|
downColor: '#55aaff', // 한국 관례: 하락=파란색
|
|
};
|
|
|
|
interface IndicatorEntry {
|
|
id: string;
|
|
type: string;
|
|
seriesList: ISeriesApi<SeriesType>[];
|
|
/**
|
|
* seriesList 순서와 1:1 대응
|
|
* - plotId: 해당 시리즈가 매핑된 plots 객체의 키 (예: 'plot0', 'plot1')
|
|
* → _applyLastPointsToSeries 에서 인덱스 대신 plotId 로 정확히 조회
|
|
* - isHistogram: histogram 시리즈 여부 (색상 재계산용)
|
|
* - color: 기본 색상
|
|
*/
|
|
seriesMeta: Array<{ plotId: string; isHistogram: boolean; color: string }>;
|
|
paneIndex?: number;
|
|
alertLines: IPriceLine[];
|
|
/** 수평 기준선(과매수/과매도/0선)의 IPriceLine 참조 — 첫 번째 시리즈에만 생성 */
|
|
hlineRefs: IPriceLine[];
|
|
/** 과매수/과매도 음영 채움 프리미티브 */
|
|
fillPrimitive?: import('./IndicatorFillPrimitive').IndicatorFillPrimitive;
|
|
/** 볼린저밴드 어퍼~로우어 영역 채움 */
|
|
bbFillPrimitive?: BollingerBandFillPrimitive;
|
|
config: IndicatorConfig;
|
|
cloudPlugin?: IchimokuCloudPlugin; // 일목균형표 구름 플러그인
|
|
}
|
|
|
|
interface AlertEntry { price: number; label: string; line: IPriceLine; }
|
|
interface FibLevel { price: number; label: string; line: IPriceLine; }
|
|
|
|
function getTheme(theme: Theme): ThemeTokens {
|
|
if (theme === 'blue') return BLUE;
|
|
if (theme === 'light') return LIGHT;
|
|
return DARK;
|
|
}
|
|
|
|
function seriesByPlotId(
|
|
entry: IndicatorEntry,
|
|
plotId: string,
|
|
): ISeriesApi<SeriesType> | undefined {
|
|
const i = entry.seriesMeta.findIndex(m => m.plotId === plotId);
|
|
return i >= 0 ? entry.seriesList[i] : undefined;
|
|
}
|
|
|
|
function shouldShowBbBandFill(config: IndicatorConfig): boolean {
|
|
const bg = resolveBbBandBackground(config.bandBackground);
|
|
if (!bg.visible) return false;
|
|
if (config.hidden === true) return false;
|
|
if (config.plotVisibility?.plot1 === false || config.plotVisibility?.plot2 === false) return false;
|
|
return true;
|
|
}
|
|
|
|
function attachBbBandFill(entry: IndicatorEntry, config: IndicatorConfig): void {
|
|
if (!shouldShowBbBandFill(config)) {
|
|
detachBbBandFill(entry);
|
|
return;
|
|
}
|
|
const basis = seriesByPlotId(entry, 'plot0');
|
|
const upper = seriesByPlotId(entry, 'plot1');
|
|
const lower = seriesByPlotId(entry, 'plot2');
|
|
if (!basis || !upper || !lower) return;
|
|
const bg = resolveBbBandBackground(config.bandBackground);
|
|
if (entry.bbFillPrimitive) {
|
|
entry.bbFillPrimitive.updateBackground(bg);
|
|
entry.bbFillPrimitive.requestRefresh();
|
|
return;
|
|
}
|
|
entry.bbFillPrimitive = new BollingerBandFillPrimitive(upper, lower, bg);
|
|
basis.attachPrimitive(entry.bbFillPrimitive);
|
|
}
|
|
|
|
function detachBbBandFill(entry: IndicatorEntry): void {
|
|
if (!entry.bbFillPrimitive) return;
|
|
const basis = seriesByPlotId(entry, 'plot0');
|
|
if (basis) {
|
|
try { basis.detachPrimitive(entry.bbFillPrimitive); } catch { /* ok */ }
|
|
}
|
|
entry.bbFillPrimitive = undefined;
|
|
}
|
|
|
|
export class ChartManager {
|
|
private chart: IChartApi;
|
|
private container: HTMLElement;
|
|
private mainSeries: ISeriesApi<SeriesType> | null = null;
|
|
private volumeSeries: ISeriesApi<'Histogram'> | null = null;
|
|
private indicators = new Map<string, IndicatorEntry>();
|
|
private alertEntries: AlertEntry[] = [];
|
|
private fibLines: FibLevel[] = [];
|
|
private drawingTool = 'cursor';
|
|
private drawingPoints: { time: Time; price: number }[] = [];
|
|
private drawingLines: IPriceLine[] = [];
|
|
private _clickHandler: ((p: MouseEventParams<Time>) => void) | null = null;
|
|
private rawBars: OHLCVBar[] = [];
|
|
/** setData 호출 세대 — addIndicator 중 stale rawBars 적용 방지 */
|
|
private _dataGeneration = 0;
|
|
|
|
/** 진행 중인 보조지표 스로틀 갱신 취소 (종목 전환·재로드 시) */
|
|
cancelPendingIndicatorUpdates(): void {
|
|
if (this._indUpdateTimer) {
|
|
clearTimeout(this._indUpdateTimer);
|
|
this._indUpdateTimer = null;
|
|
}
|
|
this._pendingIndUpdate = false;
|
|
this._indRunning = false;
|
|
}
|
|
private currentTheme: Theme = 'dark';
|
|
private currentChartType: ChartType = 'candlestick';
|
|
private displayTimezone = DEFAULT_DISPLAY_TIMEZONE;
|
|
private displayTimeframe: Timeframe = '1D';
|
|
private chartTimeFormat = DEFAULT_CHART_TIME_FORMAT;
|
|
private mainMarkersPlugin: ISeriesMarkersPluginApi<Time> | null = null;
|
|
private patternMarkers: Array<{ id: string; markers: MarkerData[] }> = [];
|
|
/** 백테스팅 매수/매도 시그널 마커 */
|
|
private backtestMarkers: MarkerData[] = [];
|
|
/** 실시간 전략 체크 마커 */
|
|
private liveStrategyMarkers: MarkerData[] = [];
|
|
private compareSeriesMap = new Map<string, ISeriesApi<SeriesType>>();
|
|
|
|
// ── 인디케이터 실시간 갱신 스로틀 ─────────────────────────────────────────
|
|
private _indUpdateTimer: ReturnType<typeof setTimeout> | null = null;
|
|
private _pendingIndUpdate = false;
|
|
/** true 이면 _updateIndicatorLastPoints 가 이미 실행 중 → 중복 실행 방지 */
|
|
private _indRunning = false;
|
|
private static readonly IND_THROTTLE_MS = 300;
|
|
|
|
// ── 시리즈 클릭 감지 (단일/더블) ─────────────────────────────────────────
|
|
private _lastClickTime = 0;
|
|
private _lastClickEntryId = '';
|
|
private _clickSingleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
// ── 크로스헤어 hover 추적 (오버레이 지표 클릭 감지용) ─────────────────────
|
|
/** 현재 크로스헤어가 위에 있는 시리즈의 entryId ('__main__' | indicatorId | null) */
|
|
private _crosshairEntryId: string | null = null;
|
|
|
|
/** 캔들 pane(0~1) 오버레이 지표 — 우측 가격축 라벨·설명 */
|
|
private _candleAreaPriceLabelsEnabled = true;
|
|
/** 하단 보조지표 pane(2+) — 우측 가격축 라벨·설명 */
|
|
private _indicatorAreaPriceLabelsEnabled = true;
|
|
|
|
/** 차트 하단 거래량 pane 표시 */
|
|
private _volumeVisible = true;
|
|
|
|
/** pane 간 구분선 (설정 화면) */
|
|
private _paneSeparatorOptions: ChartPaneSeparatorOptions =
|
|
resolveChartPaneSeparatorOptions(null, 'dark');
|
|
|
|
/** applyPaneLayout / resetPaneHeights 에서 측정한 마지막 가용 높이(px) */
|
|
private _lastLayoutAvailableHeight?: number;
|
|
|
|
/** 알림 목록 미니 차트 등 고정 높이 — pane 최소 px·비율 완화 */
|
|
private _compactPaneLayout = false;
|
|
|
|
/** 캔들+거래량 그룹 vs 보조 pane 그룹 stretch 비율 (투자관리·백테스트 분석 차트) */
|
|
private _paneAreaRatio: { candle: number; aux: number } | null = null;
|
|
|
|
constructor(
|
|
container: HTMLElement,
|
|
theme: Theme,
|
|
options?: { autoSize?: boolean; compactPaneLayout?: boolean },
|
|
) {
|
|
this.container = container;
|
|
this._compactPaneLayout = options?.compactPaneLayout ?? false;
|
|
const autoSize = options?.autoSize ?? !this._compactPaneLayout;
|
|
const t = getTheme(theme);
|
|
this.chart = createChart(container, {
|
|
/* autoSize: true → LWC가 내부적으로 ResizeObserver를 사용해 컨테이너 크기 변경을 자동으로 처리.
|
|
멀티 레이아웃에서 초기 렌더 시 컨테이너가 0 크기일 때도 차트가 정상 표시됨.
|
|
compact(미니) 차트는 autoSize:false + syncLayout() 으로 피드백 루프 방지. */
|
|
autoSize,
|
|
layout: {
|
|
background: { type: ColorType.Solid, color: t.bgColor },
|
|
textColor: t.textColor,
|
|
fontSize: 12,
|
|
panes: { enableResize: false, separatorColor: 'transparent', separatorHoverColor: 'transparent' },
|
|
},
|
|
grid: {
|
|
vertLines: { color: t.gridColor },
|
|
horzLines: { color: t.gridColor },
|
|
},
|
|
crosshair: {
|
|
mode: CrosshairMode.Normal, /* 기본: 마우스 위치 자유 이동 (자석모드 OFF) */
|
|
vertLine: {
|
|
color: t.crosshairColor,
|
|
labelVisible: true,
|
|
labelBackgroundColor: '#50535E',
|
|
},
|
|
horzLine: { color: t.crosshairColor, labelBackgroundColor: '#50535E' },
|
|
},
|
|
timeScale: {
|
|
borderColor: t.borderColor,
|
|
timeVisible: true,
|
|
secondsVisible: false,
|
|
rightOffset: 12,
|
|
fixLeftEdge: false,
|
|
fixRightEdge: false,
|
|
lockVisibleTimeRangeOnResize: false,
|
|
},
|
|
rightPriceScale: { borderColor: t.borderColor },
|
|
// pressedMouseMove: TradingChart 에서 커스텀 패닝 처리 (드로잉·클릭과 충돌 방지)
|
|
handleScroll: { mouseWheel: true, pressedMouseMove: false, horzTouchDrag: true, vertTouchDrag: false },
|
|
handleScale: { axisPressedMouseMove: true, axisDoubleClickReset: true, mouseWheel: true, pinch: true },
|
|
});
|
|
|
|
this._applyDisplayTimezoneOptions();
|
|
this._paneSeparatorOptions = resolveChartPaneSeparatorOptions(null, theme);
|
|
this.chart.applyOptions(applyPaneSeparatorToChartOptions(this._paneSeparatorOptions));
|
|
|
|
// 크로스헤어 이동 시 hover 중인 시리즈 추적 (오버레이 클릭 감지에 사용)
|
|
this.chart.subscribeCrosshairMove((params: MouseEventParams<Time>) => {
|
|
if (params.hoveredSeries) {
|
|
const hovered = params.hoveredSeries as ISeriesApi<SeriesType>;
|
|
for (const [id, entry] of this.indicators) {
|
|
if (entry.seriesList.some(s => s === hovered)) {
|
|
this._crosshairEntryId = id;
|
|
return;
|
|
}
|
|
}
|
|
// mainSeries 또는 volumeSeries 위라면 '__main__'
|
|
this._crosshairEntryId = '__main__';
|
|
} else {
|
|
this._crosshairEntryId = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
// ─── Data ───────────────────────────────────────────────────────────────
|
|
|
|
/** 종목·타임프레임 전환 직후 — 이전 시리즈·rawBars 제거 (실시간 틱 혼입·캔들 폭 깨짐 방지) */
|
|
clearForSymbolChange(): void {
|
|
this.cancelPendingIndicatorUpdates();
|
|
this._dataGeneration += 1;
|
|
|
|
for (const entry of this.indicators.values()) {
|
|
if (entry.cloudPlugin) {
|
|
for (const s of entry.seriesList) { try { s.detachPrimitive(entry.cloudPlugin); } catch { /* ok */ } }
|
|
}
|
|
detachBbBandFill(entry);
|
|
for (const s of entry.seriesList) { try { this.chart.removeSeries(s); } catch { /* ok */ } }
|
|
}
|
|
this.indicators.clear();
|
|
this.patternMarkers = [];
|
|
this._removeExtraSubPanes();
|
|
|
|
this._disposeMainMarkersPlugin();
|
|
if (this.mainSeries) { try { this.chart.removeSeries(this.mainSeries); } catch { /* ok */ } this.mainSeries = null; }
|
|
if (this.volumeSeries) { try { this.chart.removeSeries(this.volumeSeries); } catch { /* ok */ } this.volumeSeries = null; }
|
|
this.rawBars = [];
|
|
}
|
|
|
|
setData(bars: OHLCVBar[], chartType: ChartType, theme: Theme): void {
|
|
// 전체 재로드 전: 진행 중인 지표 갱신 타이머·플래그를 초기화해
|
|
// 이전 rawBars 기반의 stale 계산이 새 지표에 적용되는 것을 방지한다.
|
|
this.cancelPendingIndicatorUpdates();
|
|
this._dataGeneration += 1;
|
|
|
|
this.rawBars = bars;
|
|
this.currentTheme = theme;
|
|
this.currentChartType = chartType;
|
|
const t = getTheme(theme);
|
|
|
|
// 기존 보조지표 시리즈를 모두 제거 (새 데이터로 재계산하기 위해)
|
|
for (const entry of this.indicators.values()) {
|
|
if (entry.cloudPlugin) {
|
|
for (const s of entry.seriesList) { try { s.detachPrimitive(entry.cloudPlugin); } catch { /* ok */ } }
|
|
}
|
|
detachBbBandFill(entry);
|
|
for (const s of entry.seriesList) { try { this.chart.removeSeries(s); } catch { /* ok */ } }
|
|
}
|
|
this.indicators.clear();
|
|
this.patternMarkers = [];
|
|
this._removeExtraSubPanes();
|
|
|
|
this._disposeMainMarkersPlugin();
|
|
if (this.mainSeries) { try { this.chart.removeSeries(this.mainSeries); } catch { /* ok */ } this.mainSeries = null; }
|
|
if (this.volumeSeries) { try { this.chart.removeSeries(this.volumeSeries); } catch { /* ok */ } this.volumeSeries = null; }
|
|
|
|
this.mainSeries = this._createMainSeries(chartType, t);
|
|
const mainData = bars.map(b => {
|
|
if (chartType === 'line' || chartType === 'area' || chartType === 'baseline')
|
|
return { time: b.time as Time, value: b.close };
|
|
return { time: b.time as Time, open: b.open, high: b.high, low: b.low, close: b.close };
|
|
});
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(this.mainSeries as ISeriesApi<any>).setData(mainData);
|
|
|
|
// Volume sub-pane (pane index 1)
|
|
this.volumeSeries = this.chart.addSeries(HistogramSeries, {
|
|
priceFormat: { type: 'volume' },
|
|
priceScaleId: 'volume',
|
|
}, 1);
|
|
|
|
// 초기 volume pane 크기: resetPaneHeights() 호출 전 임시 설정
|
|
// (setHeight 대신 setStretchFactor - LWC 내부 기준 높이와 무관하게 동작)
|
|
const panesInit = this.chart.panes();
|
|
if (panesInit[0]) panesInit[0].setStretchFactor(0.88);
|
|
if (panesInit[1]) panesInit[1].setStretchFactor(0.12);
|
|
|
|
this.volumeSeries.setData(bars.map(b => ({
|
|
time: b.time as Time,
|
|
value: b.volume,
|
|
color: b.close >= b.open ? `${t.upColor}88` : `${t.downColor}88`,
|
|
})));
|
|
|
|
if (!this._volumeVisible) {
|
|
try {
|
|
this.volumeSeries.applyOptions({ visible: false } as Parameters<ISeriesApi<SeriesType>['applyOptions']>[0]);
|
|
} catch { /* ok */ }
|
|
}
|
|
|
|
this._reapplyAllPatternMarkers();
|
|
|
|
// 종목 전환·초기 로드 — bar 수가 적을 때 음수 logical from 은 캔들이 우측으로 몰림
|
|
this._applyDefaultVisibleRange(200, 0);
|
|
}
|
|
|
|
/**
|
|
* 표시용 visible range 설정.
|
|
* bar < displayCount 이면 fitContent (음수 logical index 사용 금지).
|
|
*/
|
|
private _applyDefaultVisibleRange(displayCount: number, extendFutureBars = 0): void {
|
|
const bars = this.rawBars;
|
|
if (!bars || bars.length < 2) return;
|
|
|
|
const ts = this.chart.timeScale();
|
|
|
|
if (bars.length <= displayCount) {
|
|
if (extendFutureBars > 0) {
|
|
let to: number = bars[bars.length - 1].time as number;
|
|
to += extendFutureBars * this._barIntervalSecs();
|
|
ts.setVisibleRange({
|
|
from: bars[0].time as Time,
|
|
to: to as Time,
|
|
});
|
|
} else {
|
|
ts.fitContent();
|
|
}
|
|
this._notifyViewport();
|
|
return;
|
|
}
|
|
|
|
const startIdx = Math.max(0, bars.length - displayCount);
|
|
let to: number = bars[bars.length - 1].time as number;
|
|
if (extendFutureBars > 0) {
|
|
to += extendFutureBars * this._barIntervalSecs();
|
|
}
|
|
ts.setVisibleRange({
|
|
from: bars[startIdx].time as Time,
|
|
to: to as Time,
|
|
});
|
|
this._notifyViewport();
|
|
}
|
|
|
|
private _createMainSeries(chartType: ChartType, t: ThemeTokens): ISeriesApi<SeriesType> {
|
|
switch (chartType) {
|
|
case 'candlestick':
|
|
return this.chart.addSeries(CandlestickSeries, {
|
|
upColor: t.upColor, downColor: t.downColor,
|
|
borderUpColor: t.upColor, borderDownColor: t.downColor,
|
|
wickUpColor: t.upColor, wickDownColor: t.downColor,
|
|
priceFormat: MAIN_PRICE_FORMAT,
|
|
});
|
|
case 'bar':
|
|
return this.chart.addSeries(BarSeries, { upColor: t.upColor, downColor: t.downColor, priceFormat: MAIN_PRICE_FORMAT });
|
|
case 'line':
|
|
return this.chart.addSeries(LineSeries, { color: '#2962FF', lineWidth: 2, crosshairMarkerVisible: true, priceFormat: MAIN_PRICE_FORMAT });
|
|
case 'area':
|
|
return this.chart.addSeries(AreaSeries, {
|
|
lineColor: '#2962FF', topColor: '#2962FF44', bottomColor: '#2962FF00', lineWidth: 2,
|
|
priceFormat: MAIN_PRICE_FORMAT,
|
|
});
|
|
case 'baseline':
|
|
return this.chart.addSeries(BaselineSeries, {
|
|
baseValue: { type: 'price', price: 0 },
|
|
topLineColor: '#26a69a', topFillColor1: '#26a69a44', topFillColor2: '#26a69a00',
|
|
bottomLineColor: '#ef5350', bottomFillColor1: '#ef535000', bottomFillColor2: '#ef535044',
|
|
priceFormat: MAIN_PRICE_FORMAT,
|
|
});
|
|
default:
|
|
return this.chart.addSeries(CandlestickSeries, {
|
|
upColor: t.upColor, downColor: t.downColor,
|
|
borderUpColor: t.upColor, borderDownColor: t.downColor,
|
|
wickUpColor: t.upColor, wickDownColor: t.downColor,
|
|
priceFormat: MAIN_PRICE_FORMAT,
|
|
});
|
|
}
|
|
}
|
|
|
|
// ─── Display timezone ───────────────────────────────────────────────────
|
|
private _tickMarkFormatter(): TickMarkFormatter {
|
|
const tf = this.displayTimeframe;
|
|
const tz = this.displayTimezone;
|
|
const fmt = this.chartTimeFormat;
|
|
return (time, tickMarkType) =>
|
|
formatLwcTickMarkWithPattern(time as Time, tickMarkType, tf, tz, fmt);
|
|
}
|
|
|
|
private _applyDisplayTimezoneOptions(): void {
|
|
const tf = this.displayTimeframe;
|
|
const tz = this.displayTimezone;
|
|
const fmt = this.chartTimeFormat;
|
|
const timeFormatter = (time: Time) =>
|
|
formatLwcTimeWithPattern(
|
|
time as number | { year: number; month: number; day: number },
|
|
tf,
|
|
tz,
|
|
fmt,
|
|
);
|
|
this.chart.applyOptions({
|
|
localization: { timeFormatter },
|
|
timeScale: { tickMarkFormatter: this._tickMarkFormatter() },
|
|
});
|
|
this._applyAllSeriesPriceFormats();
|
|
this._notifyPaneLayout();
|
|
}
|
|
|
|
/** pane별 priceFormat — 전역 priceFormatter 는 보조지표 라벨 포맷을 덮어씀 */
|
|
private _applyAllSeriesPriceFormats(): void {
|
|
if (this.mainSeries) {
|
|
try {
|
|
this.mainSeries.applyOptions({ priceFormat: MAIN_PRICE_FORMAT } as Parameters<ISeriesApi<SeriesType>['applyOptions']>[0]);
|
|
} catch { /* ok */ }
|
|
}
|
|
for (const entry of this.indicators.values()) {
|
|
const fmt = priceFormatForIndicatorPane(entry.paneIndex ?? 0);
|
|
if (!fmt) continue;
|
|
for (const series of entry.seriesList) {
|
|
try {
|
|
series.applyOptions({ priceFormat: fmt } as Parameters<ISeriesApi<SeriesType>['applyOptions']>[0]);
|
|
} catch { /* ok */ }
|
|
}
|
|
}
|
|
}
|
|
|
|
setDisplayTimezone(tz: string, timeframe?: Timeframe, chartTimeFormat?: string): void {
|
|
this.displayTimezone = tz || DEFAULT_DISPLAY_TIMEZONE;
|
|
if (timeframe) this.displayTimeframe = timeframe;
|
|
if (chartTimeFormat != null) {
|
|
this.chartTimeFormat = normalizeChartTimeFormat(chartTimeFormat);
|
|
}
|
|
this._applyDisplayTimezoneOptions();
|
|
}
|
|
|
|
/** 시간축·크로스헤어·캔들 pane 시간 라벨 포맷만 갱신 */
|
|
setChartTimeFormat(chartTimeFormat: string): void {
|
|
this.chartTimeFormat = normalizeChartTimeFormat(chartTimeFormat);
|
|
this._applyDisplayTimezoneOptions();
|
|
}
|
|
|
|
// ─── Theme ──────────────────────────────────────────────────────────────
|
|
setTheme(theme: Theme): void {
|
|
this.currentTheme = theme;
|
|
const t = getTheme(theme);
|
|
this.chart.applyOptions({
|
|
layout: {
|
|
background: { type: ColorType.Solid, color: t.bgColor },
|
|
textColor: t.textColor,
|
|
panes: applyPaneSeparatorToChartOptions(this._paneSeparatorOptions).layout.panes,
|
|
},
|
|
grid: { vertLines: { color: t.gridColor }, horzLines: { color: t.gridColor } },
|
|
crosshair: {
|
|
vertLine: {
|
|
color: t.crosshairColor,
|
|
labelVisible: true,
|
|
labelBackgroundColor: '#50535E',
|
|
},
|
|
horzLine: { color: t.crosshairColor, labelBackgroundColor: '#50535E' },
|
|
},
|
|
timeScale: { borderColor: t.borderColor },
|
|
rightPriceScale: { borderColor: t.borderColor },
|
|
});
|
|
this._applyMainSeriesTheme(t);
|
|
this._applyVolumeSeriesTheme(t);
|
|
}
|
|
|
|
getContainer(): HTMLElement {
|
|
return this.container;
|
|
}
|
|
|
|
getPaneSeparatorOptions(): ChartPaneSeparatorOptions {
|
|
return this._paneSeparatorOptions;
|
|
}
|
|
|
|
setPaneSeparatorOptions(opts: ChartPaneSeparatorOptions): void {
|
|
this._paneSeparatorOptions = opts;
|
|
this.chart.applyOptions(applyPaneSeparatorToChartOptions(opts));
|
|
this._notifyPaneLayout();
|
|
}
|
|
|
|
private _applyMainSeriesTheme(t: ThemeTokens): void {
|
|
if (!this.mainSeries) return;
|
|
switch (this.currentChartType) {
|
|
case 'candlestick':
|
|
this.mainSeries.applyOptions({
|
|
upColor: t.upColor,
|
|
downColor: t.downColor,
|
|
borderUpColor: t.upColor,
|
|
borderDownColor: t.downColor,
|
|
wickUpColor: t.upColor,
|
|
wickDownColor: t.downColor,
|
|
});
|
|
break;
|
|
case 'bar':
|
|
this.mainSeries.applyOptions({ upColor: t.upColor, downColor: t.downColor });
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
private _applyVolumeSeriesTheme(t: ThemeTokens): void {
|
|
if (!this.volumeSeries || this.rawBars.length === 0) return;
|
|
this.volumeSeries.setData(this.rawBars.map(b => ({
|
|
time: b.time as Time,
|
|
value: b.volume,
|
|
color: b.close >= b.open ? `${t.upColor}88` : `${t.downColor}88`,
|
|
})));
|
|
}
|
|
|
|
private _discardIndicatorSeries(
|
|
seriesList: ISeriesApi<SeriesType>[],
|
|
options?: { cloudPlugin?: IchimokuCloudPlugin; fillPrimitive?: IndicatorFillPrimitive },
|
|
): void {
|
|
if (options?.cloudPlugin) {
|
|
for (const s of seriesList) {
|
|
try { s.detachPrimitive(options.cloudPlugin); } catch { /* ok */ }
|
|
}
|
|
}
|
|
if (options?.fillPrimitive && seriesList[0]) {
|
|
try { seriesList[0].detachPrimitive(options.fillPrimitive); } catch { /* ok */ }
|
|
}
|
|
for (const s of seriesList) {
|
|
try { this.chart.removeSeries(s); } catch { /* ok */ }
|
|
}
|
|
}
|
|
|
|
private _indicatorLoadStale(loadGen: number): boolean {
|
|
return loadGen !== this._dataGeneration;
|
|
}
|
|
|
|
// ─── Indicators ─────────────────────────────────────────────────────────
|
|
async addIndicator(
|
|
config: IndicatorConfig,
|
|
options?: { skipLayout?: boolean },
|
|
): Promise<void> {
|
|
config = enrichIndicatorConfig(config);
|
|
if (this.indicators.has(config.id) || this.rawBars.length === 0) return;
|
|
const dataGenAtStart = this._dataGeneration;
|
|
const indicatorHidden = config.hidden === true;
|
|
const def = getIndicatorDef(config.type);
|
|
const seriesList: ISeriesApi<SeriesType>[] = [];
|
|
const seriesMeta: IndicatorEntry['seriesMeta'] = [];
|
|
|
|
try {
|
|
// 스냅샷 전달: await 중 rawBars 가 변경돼도 이 지표는 일관된 초기 데이터로 setData
|
|
const result = await calculateIndicator(config.type, this.rawBars.slice(), config.params);
|
|
|
|
if (dataGenAtStart !== this._dataGeneration) return;
|
|
|
|
if (def?.returnsMarkers && result.markers && result.markers.length > 0) {
|
|
this.patternMarkers.push({ id: config.id, markers: result.markers });
|
|
this._reapplyAllPatternMarkers();
|
|
this.indicators.set(config.id, { id: config.id, type: config.type, seriesList: [], seriesMeta: [], alertLines: [], hlineRefs: [], config });
|
|
return;
|
|
}
|
|
|
|
const pane = def?.overlay ? 0 : this._resolveSubPane(config);
|
|
|
|
// ─── 일목균형표 특별 처리 ────────────────────────────────────────────
|
|
if (config.type === 'IchimokuCloud') {
|
|
const { cloudPlugin, seriesMeta: ichiMeta } = await this._addIchimokuSeries(result, pane, config, seriesList);
|
|
if (this._indicatorLoadStale(dataGenAtStart) || this.indicators.has(config.id)) {
|
|
this._discardIndicatorSeries(seriesList, { cloudPlugin });
|
|
return;
|
|
}
|
|
this.indicators.set(config.id, { id: config.id, type: config.type, seriesList, seriesMeta: ichiMeta, paneIndex: pane, alertLines: [], hlineRefs: [], config, cloudPlugin });
|
|
const ichiPane = this._readSeriesPaneIndex(seriesList[0]);
|
|
const ichiEntry = this.indicators.get(config.id);
|
|
if (ichiEntry && ichiPane >= 2) ichiEntry.paneIndex = ichiPane;
|
|
this.applyIndicatorStyle(config);
|
|
return;
|
|
}
|
|
|
|
const plotDefs = config.plots ?? def?.plots ?? [];
|
|
|
|
for (const plotDef of plotDefs) {
|
|
const plotData = result.plots[plotDef.id] as PlotData | undefined;
|
|
if (!plotData || plotData.length === 0) continue;
|
|
const filtered = plotData.filter(p => p.value !== null && !isNaN(p.value));
|
|
if (filtered.length === 0) continue;
|
|
|
|
let series: ISeriesApi<SeriesType>;
|
|
const indicatorPriceFormat = priceFormatForIndicatorPane(pane);
|
|
if (plotDef.type === 'histogram') {
|
|
series = this.chart.addSeries(HistogramSeries, {
|
|
color: plotDef.color,
|
|
...(indicatorPriceFormat ? { priceFormat: indicatorPriceFormat } : {}),
|
|
}, pane);
|
|
series.setData(mapHistogramSeriesData(filtered, plotDef));
|
|
seriesMeta.push({ plotId: plotDef.id, isHistogram: true, color: plotDef.color });
|
|
} else {
|
|
const isPlotVisible = !indicatorHidden && config.plotVisibility?.[plotDef.id] !== false;
|
|
const showPriceLabel = this.shouldShowSeriesPriceLabel(config, true, pane);
|
|
const showSeriesTitle = pane < 2 && showPriceLabel;
|
|
series = this.chart.addSeries(LineSeries, {
|
|
color: plotDef.color,
|
|
lineWidth: (plotDef.lineWidth ?? 1) as 1 | 2 | 3 | 4,
|
|
lineStyle: plotSeriesLineStyle(plotDef.lineStyle),
|
|
priceLineVisible: false,
|
|
lastValueVisible: showPriceLabel,
|
|
title: showSeriesTitle ? this.seriesTitleForPlot(config, plotDef) : '',
|
|
visible: isPlotVisible,
|
|
...(indicatorPriceFormat ? { priceFormat: indicatorPriceFormat } : {}),
|
|
}, pane);
|
|
series.setData(filtered.map(p => ({ time: p.time as Time, value: p.value })));
|
|
seriesMeta.push({ plotId: plotDef.id, isHistogram: false, color: plotDef.color });
|
|
}
|
|
|
|
seriesList.push(series);
|
|
}
|
|
|
|
// hlines(과열선/중앙선/침체선)는 첫 번째 시리즈에만 한 번 생성 (중복 방지)
|
|
const hlineRefs: IPriceLine[] = [];
|
|
let fillPrimitive: IndicatorFillPrimitive | undefined;
|
|
|
|
if (!indicatorHidden && seriesList.length > 0) {
|
|
const firstSeries = seriesList[0];
|
|
const hlines = config.hlines ?? def?.hlines ?? [];
|
|
|
|
// HLineStyle → lightweight-charts LineStyle 변환
|
|
const toLineStyle = (s?: string): number => {
|
|
if (s === 'solid') return LineStyle.Solid;
|
|
if (s === 'dotted') return LineStyle.Dotted;
|
|
return LineStyle.Dashed; // 기본값
|
|
};
|
|
|
|
for (const hl of hlines) {
|
|
if (hl.visible === false) continue;
|
|
const pl = firstSeries.createPriceLine({
|
|
price: hl.price,
|
|
color: hl.color,
|
|
lineWidth: Math.min(4, hl.lineWidth ?? 1) as 1|2|3|4,
|
|
lineStyle: toLineStyle(hl.lineStyle),
|
|
axisLabelVisible: false,
|
|
});
|
|
hlineRefs.push(pl);
|
|
}
|
|
|
|
// 과열선/침체선 레이블이 있는 hlines 가 있으면 음영 프리미티브 부착
|
|
const prices = hlines.filter(h => h.visible !== false).map(h => h.price);
|
|
const hasZone = hlines.some(h => {
|
|
const lbl = h.label ?? getHLineLabel(h.price, prices);
|
|
return (lbl === '과열선' || lbl === '침체선') && h.visible !== false;
|
|
});
|
|
const hasBg = config.hlinesBackground?.visible !== false;
|
|
if (hasZone || hasBg) {
|
|
fillPrimitive = new IndicatorFillPrimitive(hlines, config.hlinesBackground);
|
|
firstSeries.attachPrimitive(fillPrimitive);
|
|
}
|
|
}
|
|
|
|
const entry: IndicatorEntry = {
|
|
id: config.id, type: config.type, seriesList, seriesMeta,
|
|
paneIndex: pane, alertLines: [], hlineRefs, fillPrimitive, config,
|
|
};
|
|
const actualPane = this._readSeriesPaneIndex(seriesList[0]);
|
|
if (actualPane >= 2) entry.paneIndex = actualPane;
|
|
if (this._indicatorLoadStale(dataGenAtStart) || this.indicators.has(config.id)) {
|
|
this._discardIndicatorSeries(seriesList, { fillPrimitive });
|
|
return;
|
|
}
|
|
this.indicators.set(config.id, entry);
|
|
this.applyIndicatorStyle(config);
|
|
if (config.type === 'BollingerBands' && !indicatorHidden) {
|
|
attachBbBandFill(entry, config);
|
|
entry.bbFillPrimitive?.requestRefresh();
|
|
}
|
|
} catch (e) {
|
|
console.error(`[Indicator] ${config.type} error:`, e);
|
|
} finally {
|
|
if (!options?.skipLayout) {
|
|
this._finalizeIndicatorPaneLayout();
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 지표 추가·제거 후 pane index 동기화 + stretch 재배분 */
|
|
private _finalizeIndicatorPaneLayout(): void {
|
|
this._removeOrphanSubPanes();
|
|
if (this._activeIndicatorPaneIndices().size > 0) {
|
|
this._resyncIndicatorPaneIndices();
|
|
this._applyAllSeriesPriceFormats();
|
|
} else {
|
|
this._trimTrailingEmptySubPanes();
|
|
}
|
|
const H = this._lastLayoutAvailableHeight ?? this.container.clientHeight;
|
|
this.resetPaneHeights(H > 0 ? H : undefined);
|
|
if (this._compactPaneLayout && H > 0) {
|
|
try {
|
|
const w = this.container.clientWidth;
|
|
if (w > 0) this.chart.resize(w, H);
|
|
} catch { /* ok */ }
|
|
}
|
|
this._notifyPaneLayout();
|
|
}
|
|
|
|
/**
|
|
* 고정 높이 미니 차트 — pane 비율 재계산 + LWC resize 를 한 번에 수행.
|
|
* (autoSize:false 일 때 외부 ResizeObserver 가 호출)
|
|
*/
|
|
syncLayout(width: number, height: number): void {
|
|
if (height > 0) this._lastLayoutAvailableHeight = height;
|
|
this._removeOrphanSubPanes();
|
|
if (this._activeIndicatorPaneIndices().size > 0) {
|
|
this._resyncIndicatorPaneIndices();
|
|
} else {
|
|
this._trimTrailingEmptySubPanes();
|
|
}
|
|
this.resetPaneHeights(height);
|
|
try {
|
|
if (width > 0 && height > 0) this.chart.resize(width, height);
|
|
} catch { /* ok */ }
|
|
this._notifyPaneLayout();
|
|
}
|
|
|
|
/** 여러 지표 추가 후 pane 레이아웃을 한 번만 갱신 */
|
|
async addIndicatorsBatch(configs: IndicatorConfig[]): Promise<void> {
|
|
for (const config of configs) {
|
|
await this.addIndicator(config, { skipLayout: true });
|
|
}
|
|
if (configs.length > 0) {
|
|
this._finalizeIndicatorPaneLayout();
|
|
}
|
|
}
|
|
|
|
private _detachIndicatorEntry(id: string): boolean {
|
|
const entry = this.indicators.get(id);
|
|
if (!entry) return false;
|
|
if (entry.seriesList.length > 0) {
|
|
for (const pl of entry.hlineRefs) {
|
|
try { entry.seriesList[0].removePriceLine(pl); } catch { /* ok */ }
|
|
}
|
|
if (entry.fillPrimitive) {
|
|
try { entry.seriesList[0].detachPrimitive(entry.fillPrimitive); } catch { /* ok */ }
|
|
}
|
|
}
|
|
if (entry.cloudPlugin) {
|
|
for (const s of entry.seriesList) {
|
|
try { s.detachPrimitive(entry.cloudPlugin); } catch { /* ok */ }
|
|
}
|
|
}
|
|
detachBbBandFill(entry);
|
|
for (const s of entry.seriesList) { try { this.chart.removeSeries(s); } catch { /* ok */ } }
|
|
this.patternMarkers = this.patternMarkers.filter(p => p.id !== id);
|
|
this._reapplyAllPatternMarkers();
|
|
this.indicators.delete(id);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 순서 변경 전용 재로드 — 메인 차트·볼륨 시리즈는 유지하고 보조지표만 제거 후 재추가.
|
|
* reloadAll 과 달리 setData()를 호출하지 않으므로 메인 캔들 차트가 깜빡이지 않는다.
|
|
*/
|
|
async reloadIndicatorsOnly(inds: import('../types').IndicatorConfig[]): Promise<void> {
|
|
this.cancelPendingIndicatorUpdates();
|
|
this._dataGeneration += 1;
|
|
const loadGen = this._dataGeneration;
|
|
|
|
// ① 기존 보조지표 시리즈 모두 제거 (메인·볼륨 제외)
|
|
for (const entry of this.indicators.values()) {
|
|
if (entry.cloudPlugin) {
|
|
for (const s of entry.seriesList) { try { s.detachPrimitive(entry.cloudPlugin); } catch { /* ok */ } }
|
|
}
|
|
if (entry.fillPrimitive && entry.seriesList[0]) {
|
|
try { entry.seriesList[0].detachPrimitive(entry.fillPrimitive); } catch { /* ok */ }
|
|
}
|
|
detachBbBandFill(entry);
|
|
for (const s of entry.seriesList) { try { this.chart.removeSeries(s); } catch { /* ok */ } }
|
|
}
|
|
this.indicators.clear();
|
|
this.patternMarkers = this.patternMarkers.filter(() => false);
|
|
this._reapplyAllPatternMarkers();
|
|
this._removeExtraSubPanes();
|
|
|
|
// ② 새 순서로 재추가 (병합 호스트 → 멤버 순) — 레이아웃은 마지막에 한 번만
|
|
this._indRunning = false;
|
|
const sorted = sortIndicatorsForPaneLoad(inds);
|
|
for (const ind of sorted) {
|
|
if (this._indicatorLoadStale(loadGen)) break;
|
|
await this.addIndicator(ind, { skipLayout: true });
|
|
}
|
|
|
|
if (this._indicatorLoadStale(loadGen)) return;
|
|
|
|
this._finalizeIndicatorPaneLayout();
|
|
}
|
|
|
|
removeIndicator(id: string): void {
|
|
this.removeIndicators([id]);
|
|
}
|
|
|
|
/** 여러 지표 제거 후 pane 레이아웃을 한 번만 갱신 (줌·스크롤 유지에 유리) */
|
|
removeIndicators(ids: string[]): void {
|
|
let any = false;
|
|
for (const id of ids) {
|
|
if (this._detachIndicatorEntry(id)) any = true;
|
|
}
|
|
if (!any) return;
|
|
this._removeOrphanSubPanes();
|
|
this._trimTrailingEmptySubPanes();
|
|
this._resyncIndicatorPaneIndices();
|
|
this.resetPaneHeights(this._lastLayoutAvailableHeight);
|
|
this._notifyPaneLayout();
|
|
}
|
|
|
|
/** 파라미터 변경된 지표만 제거 후 재추가 (전체 보조지표 재로드 회피) */
|
|
async refreshIndicators(configs: IndicatorConfig[]): Promise<void> {
|
|
if (configs.length === 0) return;
|
|
this.cancelPendingIndicatorUpdates();
|
|
const loadGen = ++this._dataGeneration;
|
|
|
|
let any = false;
|
|
for (const c of configs) {
|
|
if (this._detachIndicatorEntry(c.id)) any = true;
|
|
}
|
|
if (any) {
|
|
this._removeOrphanSubPanes();
|
|
this._trimTrailingEmptySubPanes();
|
|
}
|
|
if (this._indicatorLoadStale(loadGen)) return;
|
|
|
|
for (const config of configs) {
|
|
if (this._indicatorLoadStale(loadGen)) break;
|
|
await this.addIndicator(config, { skipLayout: true });
|
|
}
|
|
if (this._indicatorLoadStale(loadGen)) return;
|
|
|
|
this._finalizeIndicatorPaneLayout();
|
|
}
|
|
|
|
/**
|
|
* 파라미터만 변경된 지표 — pane·시리즈를 유지하고 setData 로 갱신.
|
|
* (detach/re-add 시 orphan pane·레이블 좌표 어긋남·이중 표시 방지)
|
|
*/
|
|
async refreshIndicatorParamsInPlace(configs: IndicatorConfig[]): Promise<void> {
|
|
if (configs.length === 0) return;
|
|
this.cancelPendingIndicatorUpdates();
|
|
const loadGen = ++this._dataGeneration;
|
|
const fallback: IndicatorConfig[] = [];
|
|
|
|
for (const raw of configs) {
|
|
const config = enrichIndicatorConfig(raw);
|
|
const def = getIndicatorDef(config.type);
|
|
if (def?.returnsMarkers) {
|
|
fallback.push(config);
|
|
continue;
|
|
}
|
|
|
|
const entry = this.indicators.get(config.id);
|
|
if (!entry || entry.seriesList.length === 0 || entry.type !== config.type) {
|
|
fallback.push(config);
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const result = await calculateIndicator(
|
|
config.type, this.rawBars.slice(), config.params ?? {},
|
|
);
|
|
if (this._indicatorLoadStale(loadGen)) return;
|
|
|
|
if (!this._canApplyPlotsInPlace(entry, result.plots)) {
|
|
fallback.push(config);
|
|
continue;
|
|
}
|
|
|
|
entry.config = config;
|
|
if (config.type === 'IchimokuCloud') {
|
|
this._setIchimokuPlotsFullData(entry, result.plots, config);
|
|
if (entry.cloudPlugin) {
|
|
this._applyIchimokuCloudPlugin(entry.cloudPlugin, result.plots, config);
|
|
}
|
|
} else {
|
|
this._setIndicatorPlotsFullData(entry, result.plots, config);
|
|
}
|
|
this.applyIndicatorStyle(config);
|
|
} catch (e) {
|
|
console.error(`[Indicator in-place] ${config.type}:`, e);
|
|
fallback.push(config);
|
|
}
|
|
}
|
|
|
|
if (this._indicatorLoadStale(loadGen)) return;
|
|
if (fallback.length > 0) {
|
|
await this.refreshIndicators(fallback);
|
|
return;
|
|
}
|
|
|
|
this._resyncIndicatorPaneIndices();
|
|
this._notifyPaneLayout();
|
|
}
|
|
|
|
private _canApplyPlotsInPlace(
|
|
entry: IndicatorEntry,
|
|
plots: Record<string, PlotData>,
|
|
): boolean {
|
|
if (entry.seriesMeta.length === 0) return false;
|
|
for (const meta of entry.seriesMeta) {
|
|
const data = plots[meta.plotId] as PlotData | undefined;
|
|
if (!data?.length) return false;
|
|
const filtered = data.filter(p => p.value !== null && !isNaN(p.value));
|
|
if (filtered.length === 0) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/** 기존 시리즈에 전체 plot 데이터 재적용 (pane 유지) */
|
|
private _setIndicatorPlotsFullData(
|
|
entry: IndicatorEntry,
|
|
plots: Record<string, PlotData>,
|
|
config: IndicatorConfig,
|
|
): void {
|
|
const plotDefs = config.plots ?? getIndicatorDef(config.type)?.plots ?? [];
|
|
const plotById = Object.fromEntries(plotDefs.map((p: PlotDef) => [p.id, p]));
|
|
|
|
for (let i = 0; i < entry.seriesList.length; i++) {
|
|
const meta = entry.seriesMeta[i];
|
|
if (!meta) continue;
|
|
const plot = plotById[meta.plotId];
|
|
const plotData = plots[meta.plotId] as PlotData | undefined;
|
|
if (!plot || !plotData?.length) continue;
|
|
const filtered = plotData.filter(p => p.value !== null && !isNaN(p.value));
|
|
if (filtered.length === 0) continue;
|
|
|
|
const series = entry.seriesList[i];
|
|
if (meta.isHistogram) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(series as ISeriesApi<any>).setData(mapHistogramSeriesData(filtered, plot));
|
|
} else {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(series as ISeriesApi<any>).setData(
|
|
filtered.map(p => ({ time: p.time as Time, value: p.value })),
|
|
);
|
|
}
|
|
}
|
|
|
|
if (config.type === 'BollingerBands') {
|
|
detachBbBandFill(entry);
|
|
attachBbBandFill(entry, config);
|
|
entry.bbFillPrimitive?.requestRefresh();
|
|
}
|
|
}
|
|
|
|
private _setIchimokuPlotsFullData(
|
|
entry: IndicatorEntry,
|
|
plots: Record<string, PlotData>,
|
|
config: IndicatorConfig,
|
|
): void {
|
|
const { senkou: displacement } = resolveIchimokuDisplacements(config.params);
|
|
const laggingPeriod = (config.params?.laggingSpan2Periods as number) ?? 52;
|
|
const convPlot = (plots['plot0'] as PlotData) ?? [];
|
|
const basePlot = (plots['plot1'] as PlotData) ?? [];
|
|
|
|
for (let i = 0; i < entry.seriesList.length; i++) {
|
|
const meta = entry.seriesMeta[i];
|
|
if (!meta) continue;
|
|
const plotData = plots[meta.plotId] as PlotData | undefined;
|
|
if (!plotData?.length) continue;
|
|
const filtered = plotData.filter(p => p.value !== null && !isNaN(p.value));
|
|
if (filtered.length === 0) continue;
|
|
|
|
let seriesData: Array<{ time: Time; value: number }> =
|
|
filtered.map(p => ({ time: p.time as Time, value: p.value }));
|
|
|
|
if (meta.plotId === 'plot3') {
|
|
seriesData = seriesData.concat(
|
|
this._ichimokuFutureSpanA(convPlot, basePlot, displacement),
|
|
);
|
|
} else if (meta.plotId === 'plot4') {
|
|
seriesData = seriesData.concat(
|
|
this._ichimokuFutureSpanB(displacement, laggingPeriod),
|
|
);
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(entry.seriesList[i] as ISeriesApi<any>).setData(seriesData);
|
|
}
|
|
}
|
|
|
|
/** LWC pane index ↔ entry.paneIndex 동기화 (removePane 후 어긋남 보정) */
|
|
private _resyncIndicatorPaneIndices(): void {
|
|
for (const entry of this.indicators.values()) {
|
|
const first = entry.seriesList[0];
|
|
if (!first) continue;
|
|
try {
|
|
const pi = first.getPane().paneIndex();
|
|
if (pi >= 0) entry.paneIndex = pi;
|
|
} catch { /* ok */ }
|
|
}
|
|
}
|
|
|
|
/** 메인 시리즈 마커 플러그인 해제 (setData 등 시리즈 교체 전 호출) */
|
|
private _disposeMainMarkersPlugin(): void {
|
|
if (!this.mainMarkersPlugin) return;
|
|
try { this.mainMarkersPlugin.detach(); } catch { /* ok */ }
|
|
this.mainMarkersPlugin = null;
|
|
}
|
|
|
|
private _reapplyAllPatternMarkers(): void {
|
|
if (!this.mainSeries) return;
|
|
const patternAll = this.patternMarkers.flatMap(({ markers }) =>
|
|
markers.map(m => ({
|
|
time: m.time as Time,
|
|
position: m.position,
|
|
shape: m.shape,
|
|
color: m.color,
|
|
text: m.text,
|
|
}))
|
|
);
|
|
const backtestAll = this.backtestMarkers.map(m => ({
|
|
time: m.time as Time,
|
|
position: m.position,
|
|
shape: m.shape,
|
|
color: m.color,
|
|
text: m.text,
|
|
}));
|
|
const liveAll = this.liveStrategyMarkers.map(m => ({
|
|
time: m.time as Time,
|
|
position: m.position,
|
|
shape: m.shape,
|
|
color: m.color,
|
|
text: m.text,
|
|
}));
|
|
const all = [...patternAll, ...backtestAll, ...liveAll];
|
|
if (this.mainMarkersPlugin) {
|
|
try {
|
|
if (this.mainMarkersPlugin.getSeries() !== this.mainSeries) {
|
|
this._disposeMainMarkersPlugin();
|
|
}
|
|
} catch {
|
|
this._disposeMainMarkersPlugin();
|
|
}
|
|
}
|
|
if (!this.mainMarkersPlugin) {
|
|
this.mainMarkersPlugin = createSeriesMarkers(this.mainSeries, all);
|
|
} else {
|
|
this.mainMarkersPlugin.setMarkers(all);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 백테스팅 결과 매수/매도 시그널을 캔들 차트에 마커로 표시.
|
|
* signals 가 빈 배열이면 기존 백테스팅 마커를 제거.
|
|
*/
|
|
setBacktestMarkers(
|
|
signals: Array<{ time: number; type: string; price: number }>,
|
|
showPrice = true,
|
|
): void {
|
|
const isBuy = (t: string) => t === 'BUY' || t === 'SHORT_EXIT';
|
|
const isSell = (t: string) => t === 'SELL' || t === 'SHORT_ENTRY' || t === 'PARTIAL_SELL';
|
|
this.backtestMarkers = signals
|
|
.filter(s => isBuy(s.type) || isSell(s.type))
|
|
.map(s => {
|
|
const buy = isBuy(s.type);
|
|
const label =
|
|
s.type === 'PARTIAL_SELL' ? '부분매도'
|
|
: buy ? '매수'
|
|
: '매도';
|
|
const text = showPrice
|
|
? `${label} : ${s.price.toLocaleString()}`
|
|
: label;
|
|
return {
|
|
time: s.time,
|
|
position: buy ? ('belowBar' as const) : ('aboveBar' as const),
|
|
shape: buy ? ('arrowUp' as const) : ('arrowDown' as const),
|
|
color: s.type === 'PARTIAL_SELL' ? '#FF9800' : (buy ? TRADE_BUY_COLOR : TRADE_SELL_COLOR),
|
|
text,
|
|
};
|
|
});
|
|
// 시리즈 교체·detach 이후 stale 플러그인 방지 — 백테스트 마커는 항상 재생성
|
|
this._disposeMainMarkersPlugin();
|
|
this._reapplyAllPatternMarkers();
|
|
}
|
|
|
|
/** 백테스팅 마커 제거 */
|
|
clearBacktestMarkers(): void {
|
|
this.backtestMarkers = [];
|
|
this._reapplyAllPatternMarkers();
|
|
}
|
|
|
|
/**
|
|
* 실시간 전략 체크 마커를 업데이트한다.
|
|
* 호출될 때마다 기존 목록을 완전히 교체하여 LWC 에 반영한다.
|
|
*/
|
|
setLiveStrategyMarkers(
|
|
markers: Array<{ time: number; signal: 'BUY' | 'SELL'; price: number }>,
|
|
showPrice = true,
|
|
): void {
|
|
this.liveStrategyMarkers = markers.map(m => {
|
|
const buy = m.signal === 'BUY';
|
|
const text = showPrice
|
|
? `실시간 ${buy ? '매수' : '매도'} : ${m.price.toLocaleString()}`
|
|
: `실시간 ${buy ? '매수' : '매도'}`;
|
|
return {
|
|
time: m.time,
|
|
position: buy ? ('belowBar' as const) : ('aboveBar' as const),
|
|
shape: buy ? ('arrowUp' as const) : ('arrowDown' as const),
|
|
color: buy ? TRADE_BUY_COLOR : TRADE_SELL_COLOR,
|
|
text,
|
|
};
|
|
});
|
|
this._reapplyAllPatternMarkers();
|
|
}
|
|
|
|
/** 실시간 전략 마커 전체 제거 */
|
|
clearLiveStrategyMarkers(): void {
|
|
this.liveStrategyMarkers = [];
|
|
this._reapplyAllPatternMarkers();
|
|
}
|
|
|
|
/**
|
|
* 일목균형표 전용: 전환선·기준선·후행스팬·선행스팬A·B 라인 + 구름 플러그인을 추가합니다.
|
|
*
|
|
* ichimoku.ts 실제 plot 매핑:
|
|
* plot0 = 전환선(Tenkan) plot1 = 기준선(Kijun)
|
|
* plot2 = 후행스팬(Chikou) plot3 = 선행스팬A plot4 = 선행스팬B
|
|
*/
|
|
private async _addIchimokuSeries(
|
|
result: Awaited<ReturnType<typeof calculateIndicator>>,
|
|
pane: number,
|
|
_config: IndicatorConfig,
|
|
seriesList: ISeriesApi<SeriesType>[],
|
|
): Promise<{ cloudPlugin: IchimokuCloudPlugin; seriesMeta: IndicatorEntry['seriesMeta'] }> {
|
|
const { senkou: displacement } = resolveIchimokuDisplacements(_config.params);
|
|
const laggingPeriod = (_config.params?.laggingSpan2Periods as number) ?? 52;
|
|
const seriesMeta: IndicatorEntry['seriesMeta'] = [];
|
|
|
|
const plotInfo = [
|
|
{ id: 'plot0', title: getIchimokuPlotTitle('plot0'), color: '#26c6da', lastVal: true },
|
|
{ id: 'plot1', title: getIchimokuPlotTitle('plot1'), color: '#ef5350', lastVal: true },
|
|
{ id: 'plot2', title: getIchimokuPlotTitle('plot2'), color: '#ab47bc', lastVal: false },
|
|
{ id: 'plot3', title: getIchimokuPlotTitle('plot3'), color: '#66bb6a', lastVal: true },
|
|
{ id: 'plot4', title: getIchimokuPlotTitle('plot4'), color: '#ef9a9a', lastVal: true },
|
|
];
|
|
|
|
const convPlot = result.plots['plot0'] as PlotData ?? [];
|
|
const basePlot = result.plots['plot1'] as PlotData ?? [];
|
|
let spanASeriesRef: ISeriesApi<SeriesType> | null = null;
|
|
|
|
for (const info of plotInfo) {
|
|
const plotData = result.plots[info.id] as PlotData | undefined;
|
|
if (!plotData || plotData.length === 0) continue;
|
|
const filtered = plotData.filter(p => p.value !== null && !isNaN(p.value));
|
|
if (filtered.length === 0) continue;
|
|
|
|
let seriesData: Array<{ time: Time; value: number }> =
|
|
filtered.map(p => ({ time: p.time as Time, value: p.value }));
|
|
|
|
// 선행스팬A/B: 미래 26봉 프로젝션 추가
|
|
if (info.id === 'plot3') {
|
|
seriesData = seriesData.concat(
|
|
this._ichimokuFutureSpanA(convPlot, basePlot, displacement)
|
|
);
|
|
} else if (info.id === 'plot4') {
|
|
seriesData = seriesData.concat(
|
|
this._ichimokuFutureSpanB(displacement, laggingPeriod)
|
|
);
|
|
}
|
|
|
|
const showPriceLabel = this.shouldShowSeriesPriceLabel(_config, info.lastVal, pane);
|
|
const series = this.chart.addSeries(LineSeries, {
|
|
color: info.color,
|
|
lineWidth: 1,
|
|
priceLineVisible: false,
|
|
lastValueVisible: showPriceLabel,
|
|
title: showPriceLabel ? info.title : '',
|
|
}, pane);
|
|
series.setData(seriesData);
|
|
seriesList.push(series);
|
|
seriesMeta.push({ plotId: info.id, isHistogram: false, color: info.color });
|
|
if (info.id === 'plot3') spanASeriesRef = series;
|
|
}
|
|
|
|
// 구름 플러그인 (과거 + 미래 포함)
|
|
const cloudPlugin = new IchimokuCloudPlugin();
|
|
const colors = resolveIchimokuCloudColors(_config.cloudColors);
|
|
cloudPlugin.setColors(colors.bullishColor, colors.bearishColor);
|
|
const host = spanASeriesRef ?? seriesList[0];
|
|
if (host) {
|
|
host.attachPrimitive(cloudPlugin);
|
|
this._applyIchimokuCloudPlugin(cloudPlugin, result.plots, _config);
|
|
}
|
|
return { cloudPlugin, seriesMeta };
|
|
}
|
|
|
|
/** 선행스팬A의 미래 26봉: (전환선 + 기준선) / 2, 미래 타임스탬프로 매핑 */
|
|
private _ichimokuFutureSpanA(
|
|
convPlot: PlotData,
|
|
basePlot: PlotData,
|
|
displacement: number,
|
|
): Array<{ time: Time; value: number }> {
|
|
const N = this.rawBars.length;
|
|
if (N < 2) return [];
|
|
const interval = this.rawBars[N - 1].time - this.rawBars[N - 2].time;
|
|
const lastTime = this.rawBars[N - 1].time;
|
|
const out: Array<{ time: Time; value: number }> = [];
|
|
|
|
for (let k = 1; k <= displacement; k++) {
|
|
const srcIdx = N - displacement - 1 + k; // N-26 +k-1 → 0-based
|
|
if (srcIdx < 0 || srcIdx >= N) continue;
|
|
const conv = convPlot[srcIdx]?.value;
|
|
const base = basePlot[srcIdx]?.value;
|
|
if (conv == null || isNaN(conv) || base == null || isNaN(base)) continue;
|
|
out.push({ time: (lastTime + k * interval) as Time, value: (conv + base) / 2 });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** 선행스팬B의 미래 26봉: donchian(laggingPeriod), 미래 타임스탬프로 매핑 */
|
|
private _ichimokuFutureSpanB(
|
|
displacement: number,
|
|
laggingPeriod: number,
|
|
): Array<{ time: Time; value: number }> {
|
|
const N = this.rawBars.length;
|
|
if (N < 2) return [];
|
|
const interval = this.rawBars[N - 1].time - this.rawBars[N - 2].time;
|
|
const lastTime = this.rawBars[N - 1].time;
|
|
const out: Array<{ time: Time; value: number }> = [];
|
|
|
|
for (let k = 1; k <= displacement; k++) {
|
|
const srcIdx = N - displacement - 1 + k;
|
|
if (srcIdx < 0 || srcIdx >= N) continue;
|
|
const val = this._donchianAt(srcIdx, laggingPeriod);
|
|
if (val === null) continue;
|
|
out.push({ time: (lastTime + k * interval) as Time, value: val });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** 특정 바 인덱스에서 Donchian 중선을 계산 */
|
|
private _donchianAt(idx: number, period: number): number | null {
|
|
const start = idx - period + 1;
|
|
if (start < 0) return null;
|
|
let hi = -Infinity, lo = Infinity;
|
|
for (let i = start; i <= idx; i++) {
|
|
if (this.rawBars[i].high > hi) hi = this.rawBars[i].high;
|
|
if (this.rawBars[i].low < lo) lo = this.rawBars[i].low;
|
|
}
|
|
return (hi + lo) / 2;
|
|
}
|
|
|
|
/** 구름 데이터 빌드: 과거(역사 데이터) + 미래 26봉 포함 */
|
|
private _buildIchimokuCloudData(
|
|
spanAPlot: PlotData,
|
|
spanBPlot: PlotData,
|
|
convPlot: PlotData,
|
|
basePlot: PlotData,
|
|
displacement: number,
|
|
laggingPeriod: number,
|
|
): IchimokuCloudPoint[] {
|
|
// 과거 구름
|
|
const bMap = new Map(
|
|
(spanBPlot ?? []).filter(p => p.value != null && !isNaN(p.value)).map(p => [p.time, p.value])
|
|
);
|
|
const historical: IchimokuCloudPoint[] = (spanAPlot ?? [])
|
|
.filter(p => p.value != null && !isNaN(p.value) && bMap.has(p.time))
|
|
.map(p => ({ time: p.time as number, spanA: p.value, spanB: bMap.get(p.time)! }));
|
|
|
|
// 미래 구름
|
|
const N = this.rawBars.length;
|
|
if (N < 2) return historical;
|
|
const interval = this.rawBars[N - 1].time - this.rawBars[N - 2].time;
|
|
const lastTime = this.rawBars[N - 1].time;
|
|
const future: IchimokuCloudPoint[] = [];
|
|
|
|
for (let k = 1; k <= displacement; k++) {
|
|
const srcIdx = N - displacement - 1 + k;
|
|
if (srcIdx < 0 || srcIdx >= N) continue;
|
|
const conv = convPlot[srcIdx]?.value;
|
|
const base = basePlot[srcIdx]?.value;
|
|
if (conv == null || isNaN(conv) || base == null || isNaN(base)) continue;
|
|
const spanA = (conv + base) / 2;
|
|
const spanB = this._donchianAt(srcIdx, laggingPeriod);
|
|
if (spanB === null) continue;
|
|
future.push({ time: lastTime + k * interval, spanA, spanB });
|
|
}
|
|
return [...historical, ...future];
|
|
}
|
|
|
|
private _readSeriesPaneIndex(series: ISeriesApi<SeriesType> | undefined): number {
|
|
if (!series) return -1;
|
|
try {
|
|
return series.getPane().paneIndex();
|
|
} catch {
|
|
return -1;
|
|
}
|
|
}
|
|
|
|
/** LWC 시리즈가 실제로 붙어 있는 sub-pane index (2+) */
|
|
private _collectUsedSubPaneIndices(): Set<number> {
|
|
const indices = new Set<number>();
|
|
for (const entry of this.indicators.values()) {
|
|
let found = false;
|
|
for (const series of entry.seriesList) {
|
|
const pi = this._readSeriesPaneIndex(series);
|
|
if (pi >= 2) {
|
|
indices.add(pi);
|
|
found = true;
|
|
}
|
|
}
|
|
if (!found && entry.paneIndex != null && entry.paneIndex >= 2) {
|
|
indices.add(entry.paneIndex);
|
|
}
|
|
}
|
|
return indices;
|
|
}
|
|
|
|
private _getNextSubPane(): number {
|
|
const used = this._collectUsedSubPaneIndices();
|
|
let pane = 2;
|
|
while (used.has(pane)) pane++;
|
|
return pane;
|
|
}
|
|
|
|
/** mergedWith 가 있으면 호스트 pane, 없으면 새 sub-pane */
|
|
private _resolveSubPane(config: IndicatorConfig): number {
|
|
if (config.mergedWith) {
|
|
const host = this.indicators.get(config.mergedWith);
|
|
if (host?.paneIndex != null && host.paneIndex >= 2) {
|
|
return host.paneIndex;
|
|
}
|
|
}
|
|
return this._getNextSubPane();
|
|
}
|
|
|
|
// ─── 실시간 바 업데이트 (WebSocket 틱) ────────────────────────────────────
|
|
/**
|
|
* 현재 캔들을 틱 데이터로 갱신.
|
|
* - 메인 캔들 + 볼륨: 즉시 반영 (매 틱)
|
|
* - 보조지표 마지막 포인트: 300ms 스로틀 후 갱신 (과도한 재계산 방지)
|
|
*/
|
|
updateBar(bar: OHLCVBar): void {
|
|
if (!this.mainSeries) return;
|
|
const t = getTheme(this.currentTheme);
|
|
|
|
// 1) rawBars 업데이트
|
|
let chartTime = bar.time;
|
|
if (this.rawBars.length > 0) {
|
|
const last = this.rawBars[this.rawBars.length - 1];
|
|
if (last.time === bar.time) {
|
|
this.rawBars[this.rawBars.length - 1] = bar;
|
|
} else if (bar.time > last.time) {
|
|
this.rawBars.push(bar);
|
|
} else {
|
|
// STOMP·히스토리 시계 차이 등: 마지막 봉 OHLC만 갱신 (시간축은 유지)
|
|
chartTime = last.time;
|
|
this.rawBars[this.rawBars.length - 1] = { ...bar, time: last.time };
|
|
}
|
|
}
|
|
|
|
// 2) 메인 시리즈 즉시 갱신 (캔들 또는 라인/에리어 형식)
|
|
try {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(this.mainSeries as ISeriesApi<any>).update({
|
|
time: chartTime as Time,
|
|
open: bar.open, high: bar.high, low: bar.low, close: bar.close,
|
|
value: bar.close, // line/area/baseline 용 (candlestick은 무시)
|
|
});
|
|
} catch (err) {
|
|
console.error('[ChartManager] updateBar mainSeries.update 오류:', err, bar);
|
|
return;
|
|
}
|
|
|
|
// 3) 볼륨 즉시 갱신
|
|
try {
|
|
this.volumeSeries?.update({
|
|
time: chartTime as Time,
|
|
value: bar.volume,
|
|
color: bar.close >= bar.open ? `${t.upColor}88` : `${t.downColor}88`,
|
|
});
|
|
} catch { /* 볼륨 갱신 실패 무시 */ }
|
|
|
|
// 4) 보조지표 마지막 포인트 스로틀 갱신
|
|
this._scheduleIndicatorLastUpdate();
|
|
}
|
|
|
|
/**
|
|
* 보조지표 마지막 포인트 갱신 스케줄 (스로틀: 300ms)
|
|
*
|
|
* 동작:
|
|
* - 계산 실행 중(_indRunning)이거나 타이머 대기 중이면 pending 마킹만 하고 반환
|
|
* - 타이머 종료 후 _indRunning 가드 아래 실행 → 중복 실행 방지
|
|
* - 실행 후 pending 이 남아 있으면 즉시 재스케줄 (최신 틱 보장)
|
|
*/
|
|
private _scheduleIndicatorLastUpdate(): void {
|
|
// 이미 계산 중이면 pending 마킹 → 완료 후 자동 재실행
|
|
if (this._indRunning) {
|
|
this._pendingIndUpdate = true;
|
|
return;
|
|
}
|
|
// 타이머 대기 중이면 pending 마킹만
|
|
if (this._indUpdateTimer) {
|
|
this._pendingIndUpdate = true;
|
|
return;
|
|
}
|
|
this._pendingIndUpdate = false;
|
|
this._indUpdateTimer = setTimeout(async () => {
|
|
this._indUpdateTimer = null;
|
|
// 계산 중 플래그로 동시 실행 완전 차단
|
|
if (this._indRunning) {
|
|
this._pendingIndUpdate = true;
|
|
return;
|
|
}
|
|
this._indRunning = true;
|
|
try {
|
|
await this._updateIndicatorLastPoints();
|
|
// 실행 중 도착한 틱이 있으면 한 번 더 실행
|
|
if (this._pendingIndUpdate) {
|
|
this._pendingIndUpdate = false;
|
|
await this._updateIndicatorLastPoints();
|
|
}
|
|
} finally {
|
|
this._indRunning = false;
|
|
}
|
|
// 두 번째 실행 중 또 틱이 왔으면 다시 스케줄
|
|
if (this._pendingIndUpdate) {
|
|
this._scheduleIndicatorLastUpdate();
|
|
}
|
|
}, ChartManager.IND_THROTTLE_MS);
|
|
}
|
|
|
|
/** 모든 보조지표 시리즈의 마지막 데이터 포인트를 현재 rawBars 기준으로 재계산·갱신 */
|
|
private async _updateIndicatorLastPoints(): Promise<void> {
|
|
if (this.rawBars.length === 0) return;
|
|
const dataGenAtStart = this._dataGeneration;
|
|
|
|
// 루프 시작 전 스냅샷 고정: 루프 실행 중 rawBars 가 변경돼도 모든 지표가
|
|
// 동일한 데이터 기준으로 계산되어 지표 간 시각적 불일치를 방지한다.
|
|
const barsSnapshot = this.rawBars.slice();
|
|
|
|
for (const [, entry] of this.indicators.entries()) {
|
|
if (dataGenAtStart !== this._dataGeneration) return;
|
|
if (!entry.config || entry.seriesList.length === 0) continue;
|
|
try {
|
|
const { plots } = await calculateIndicator(
|
|
entry.config.type, barsSnapshot, entry.config.params ?? {}
|
|
);
|
|
if (dataGenAtStart !== this._dataGeneration) return;
|
|
// 일목균형표: 선행스팬 미래 구간 포함 전체 setData (틱 갱신 시에도 유지)
|
|
if (entry.type === 'IchimokuCloud') {
|
|
this._refreshIchimokuSeriesData(entry, plots);
|
|
continue;
|
|
}
|
|
this._applyLastPointsToSeries(entry, plots, /* updateFutureSpans */ false);
|
|
} catch { /* 계산 실패 무시 */ }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* plots 의 마지막 데이터 포인트를 각 시리즈에 update() 합니다.
|
|
*
|
|
* ★ seriesMeta[i].plotId 를 사용해 plots 에서 정확한 데이터를 조회합니다.
|
|
* (Object.values(plots) 인덱스 의존 제거 → plot 스킵 시 인덱스 어긋남 방지)
|
|
*
|
|
* - histogram: 색상(상승/하락/양수/음수) 재계산
|
|
* - IchimokuCloud SpanA/SpanB: updateFutureSpans=true 일 때 미래 프로젝션 포함 setData()
|
|
* - null/NaN 값 방어 처리
|
|
*/
|
|
private _applyLastPointsToSeries(
|
|
entry: IndicatorEntry,
|
|
plots: Record<string, PlotData>,
|
|
updateFutureSpans: boolean,
|
|
): void {
|
|
for (let i = 0; i < entry.seriesList.length; i++) {
|
|
const meta = entry.seriesMeta[i];
|
|
if (!meta) continue;
|
|
|
|
// ★ plotId 로 정확한 플롯 데이터 조회 (인덱스 매핑 오류 방지)
|
|
const plotData = plots[meta.plotId] as PlotData | undefined;
|
|
if (!plotData?.length) continue;
|
|
|
|
const lastPoint = plotData[plotData.length - 1];
|
|
if (!lastPoint) continue;
|
|
|
|
// ── IchimokuCloud: SpanA/SpanB 미래 프로젝션 갱신 ──────────────────
|
|
if (updateFutureSpans && entry.type === 'IchimokuCloud') {
|
|
const config = entry.config;
|
|
const { senkou: disp } = resolveIchimokuDisplacements(config.params);
|
|
const lagPeriod = (config.params?.laggingSpan2Periods as number) ?? 52;
|
|
const convPlot = (plots['plot0'] as PlotData) ?? [];
|
|
const basePlot = (plots['plot1'] as PlotData) ?? [];
|
|
|
|
if (meta.plotId === 'plot3') {
|
|
const spanAData: Array<{ time: Time; value: number }> = [
|
|
...((plots['plot3'] as PlotData) ?? [])
|
|
.filter(p => p.value !== null && !isNaN(p.value))
|
|
.map(p => ({ time: p.time as Time, value: p.value })),
|
|
...this._ichimokuFutureSpanA(convPlot, basePlot, disp),
|
|
];
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(entry.seriesList[i] as ISeriesApi<any>).setData(spanAData);
|
|
continue;
|
|
}
|
|
if (meta.plotId === 'plot4') {
|
|
const spanBData: Array<{ time: Time; value: number }> = [
|
|
...((plots['plot4'] as PlotData) ?? [])
|
|
.filter(p => p.value !== null && !isNaN(p.value))
|
|
.map(p => ({ time: p.time as Time, value: p.value })),
|
|
...this._ichimokuFutureSpanB(disp, lagPeriod),
|
|
];
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(entry.seriesList[i] as ISeriesApi<any>).setData(spanBData);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
const curVal = lastPoint.value;
|
|
// null / NaN 방어: 유효하지 않은 값은 update 생략
|
|
if (curVal === null || curVal === undefined || isNaN(curVal as number)) continue;
|
|
|
|
// ── Histogram: 색상 재계산 ──────────────────────────────────────────
|
|
if (meta.isHistogram) {
|
|
const plotDef = (entry.config?.plots ?? getIndicatorDef(entry.type)?.plots ?? [])
|
|
.find(p => p.id === meta.plotId);
|
|
const prevVal = plotData.length >= 2 ? plotData[plotData.length - 2]?.value : null;
|
|
const barColor = plotDef
|
|
? histogramBarColor(curVal as number, prevVal, plotDef)
|
|
: (lastPoint.color ?? '#26A69A88');
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(entry.seriesList[i] as ISeriesApi<any>).update({
|
|
time: lastPoint.time as Time,
|
|
value: curVal as number,
|
|
color: barColor,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// ── Line / 기타 ─────────────────────────────────────────────────────
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(entry.seriesList[i] as ISeriesApi<any>).update({
|
|
time: lastPoint.time as Time,
|
|
value: curVal as number,
|
|
});
|
|
}
|
|
if (entry.type === 'BollingerBands') {
|
|
entry.bbFillPrimitive?.requestRefresh();
|
|
}
|
|
}
|
|
|
|
/** histogram 막대 색상(상승/하락) 변경 후 전체 재색칠 */
|
|
private async _repaintHistogramSeries(indicatorId: string, config: IndicatorConfig): Promise<void> {
|
|
const entry = this.indicators.get(indicatorId);
|
|
if (!entry || this.rawBars.length === 0) return;
|
|
const plotDefs = config.plots ?? getIndicatorDef(config.type)?.plots ?? [];
|
|
const plotById = Object.fromEntries(plotDefs.map((p: PlotDef) => [p.id, p]));
|
|
try {
|
|
const { plots } = await calculateIndicator(config.type, this.rawBars.slice(), config.params ?? {});
|
|
for (let i = 0; i < entry.seriesList.length; i++) {
|
|
const meta = entry.seriesMeta[i];
|
|
if (!meta?.isHistogram) continue;
|
|
const plot = plotById[meta.plotId];
|
|
if (!plot) continue;
|
|
const plotData = plots[meta.plotId] as PlotData | undefined;
|
|
if (!plotData?.length) continue;
|
|
const filtered = plotData.filter(p => p.value !== null && !isNaN(p.value));
|
|
if (filtered.length === 0) continue;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(entry.seriesList[i] as ISeriesApi<any>).setData(mapHistogramSeriesData(filtered, plot));
|
|
}
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
/** 일목균형표 구름 플러그인 — 색상·가시성·데이터 동기화 */
|
|
private _applyIchimokuCloudPlugin(
|
|
plugin: IchimokuCloudPlugin,
|
|
plots: Record<string, PlotData>,
|
|
config: IndicatorConfig,
|
|
): void {
|
|
const colors = resolveIchimokuCloudColors(config.cloudColors);
|
|
plugin.setColors(colors.bullishColor, colors.bearishColor);
|
|
plugin.setVisibility(colors.bullishVisible !== false, colors.bearishVisible !== false);
|
|
|
|
if (!isIchimokuCloudVisible(config)) {
|
|
plugin.setCloudData([]);
|
|
return;
|
|
}
|
|
|
|
const spanAPlot = plots['plot3'] as PlotData | undefined;
|
|
const spanBPlot = plots['plot4'] as PlotData | undefined;
|
|
const convPlot = plots['plot0'] as PlotData ?? [];
|
|
const basePlot = plots['plot1'] as PlotData ?? [];
|
|
if (!spanAPlot || !spanBPlot) return;
|
|
|
|
const { senkou: displacement } = resolveIchimokuDisplacements(config.params);
|
|
const laggingPeriod = (config.params?.laggingSpan2Periods as number) ?? 52;
|
|
|
|
const cloud = this._buildIchimokuCloudData(
|
|
spanAPlot, spanBPlot, convPlot, basePlot, displacement, laggingPeriod,
|
|
);
|
|
plugin.setCloudData(cloud.length >= 2 ? cloud : []);
|
|
}
|
|
|
|
/**
|
|
* 새 캔들을 기존 차트 우측에만 추가.
|
|
* 전체 차트를 재그리지 않고 각 시리즈에 update(lastPoint)만 호출.
|
|
*/
|
|
async appendBar(bar: OHLCVBar): Promise<void> {
|
|
if (!this.mainSeries) return;
|
|
const dataGenAtStart = this._dataGeneration;
|
|
const t = getTheme(this.currentTheme);
|
|
|
|
// 새 캔들 추가 시 이 함수 내부에서 모든 지표를 직접 재계산하므로,
|
|
// updateBar 가 예약한 스로틀 타이머를 취소해 중복 계산을 방지합니다.
|
|
this.cancelPendingIndicatorUpdates();
|
|
|
|
// rawBars에 신규 바 추가 (중복 방지)
|
|
const last = this.rawBars[this.rawBars.length - 1];
|
|
if (last && last.time === bar.time) {
|
|
this.rawBars[this.rawBars.length - 1] = bar;
|
|
} else {
|
|
this.rawBars.push(bar);
|
|
}
|
|
|
|
// 메인 시리즈에 신규 바 추가 (time > last → LWC가 우측에 append)
|
|
try {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(this.mainSeries as ISeriesApi<any>).update({
|
|
time: bar.time as Time,
|
|
open: bar.open, high: bar.high, low: bar.low, close: bar.close,
|
|
value: bar.close, // line/area/baseline 용 (candlestick은 무시)
|
|
});
|
|
} catch (err) {
|
|
console.error('[ChartManager] appendBar mainSeries.update 오류:', err, bar);
|
|
return;
|
|
}
|
|
|
|
// 볼륨 시리즈 추가
|
|
try {
|
|
this.volumeSeries?.update({
|
|
time: bar.time as Time,
|
|
value: bar.volume,
|
|
color: bar.close >= bar.open ? `${t.upColor}88` : `${t.downColor}88`,
|
|
});
|
|
} catch { /* 볼륨 갱신 실패 무시 */ }
|
|
|
|
// 각 보조지표의 마지막 포인트 업데이트 + 일목균형표 미래 프로젝션 갱신
|
|
// _indRunning 가드: 이 루프 실행 중 도착한 tick 은 pending 으로 대기
|
|
this._indRunning = true;
|
|
try {
|
|
// rawBars 스냅샷을 찍어 루프 도중 rawBars 가 변경돼도 일관된 계산 보장
|
|
const barsSnapshot = this.rawBars.slice();
|
|
for (const [, entry] of this.indicators.entries()) {
|
|
if (dataGenAtStart !== this._dataGeneration) return;
|
|
if (!entry.config || entry.seriesList.length === 0) continue;
|
|
try {
|
|
const { plots } = await calculateIndicator(
|
|
entry.config.type, barsSnapshot, entry.config.params ?? {}
|
|
);
|
|
if (dataGenAtStart !== this._dataGeneration) return;
|
|
// 일목균형표: 구름 플러그인 + SpanA/B 미래 라인 갱신
|
|
if (entry.type === 'IchimokuCloud' && entry.cloudPlugin) {
|
|
this._applyIchimokuCloudPlugin(entry.cloudPlugin, plots, entry.config);
|
|
}
|
|
// updateFutureSpans=true → 새 캔들 추가 시 SpanA/B setData() 전체 재설정
|
|
this._applyLastPointsToSeries(entry, plots, /* updateFutureSpans */ true);
|
|
} catch { /* 인디케이터 계산 실패 무시 */ }
|
|
}
|
|
} finally {
|
|
this._indRunning = false;
|
|
}
|
|
// appendBar 완료 후에도 tick 이 도착해 pending 이 남아있으면 즉시 갱신 스케줄
|
|
if (this._pendingIndUpdate) {
|
|
this._scheduleIndicatorLastUpdate();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 새 캔들 완성 후 모든 보조지표를 새 데이터로 재계산 (전체 reload).
|
|
* 심볼/타임프레임 변경 시 사용. 실시간 수신 시에는 appendBar()를 사용할 것.
|
|
*/
|
|
async recalculateIndicators(newBars: OHLCVBar[]): Promise<void> {
|
|
this.cancelPendingIndicatorUpdates();
|
|
this._dataGeneration += 1;
|
|
const loadGen = this._dataGeneration;
|
|
this.rawBars = newBars;
|
|
|
|
const configs = Array.from(this.indicators.values()).map(e => e.config);
|
|
|
|
for (const entry of this.indicators.values()) {
|
|
detachBbBandFill(entry);
|
|
for (const s of entry.seriesList) { try { this.chart.removeSeries(s); } catch { /* ok */ } }
|
|
}
|
|
this.indicators.clear();
|
|
this.patternMarkers = [];
|
|
this._reapplyAllPatternMarkers();
|
|
this._removeExtraSubPanes();
|
|
|
|
for (const cfg of configs) {
|
|
if (this._indicatorLoadStale(loadGen)) break;
|
|
await this.addIndicator(cfg);
|
|
}
|
|
}
|
|
|
|
// ─── Alerts ──────────────────────────────────────────────────────────────
|
|
addAlert(price: number, label = ''): void {
|
|
if (!this.mainSeries) return;
|
|
const line = this.mainSeries.createPriceLine({
|
|
price, color: '#FFB300', lineWidth: 1, lineStyle: LineStyle.Dashed,
|
|
axisLabelVisible: true, title: label || `Alert ${price.toFixed(2)}`,
|
|
});
|
|
this.alertEntries.push({ price, label, line });
|
|
}
|
|
|
|
removeAlert(price: number): void {
|
|
const idx = this.alertEntries.findIndex(a => Math.abs(a.price - price) < 0.0001);
|
|
if (idx < 0) return;
|
|
try { this.mainSeries?.removePriceLine(this.alertEntries[idx].line); } catch { /* ok */ }
|
|
this.alertEntries.splice(idx, 1);
|
|
}
|
|
|
|
getAlerts(): Array<{ price: number; label: string }> {
|
|
return this.alertEntries.map(({ price, label }) => ({ price, label }));
|
|
}
|
|
|
|
// ─── Auto Fibonacci ──────────────────────────────────────────────────────
|
|
drawAutoFib(lookback = 50): void {
|
|
if (!this.mainSeries || this.rawBars.length === 0) return;
|
|
this._clearFibLines();
|
|
const { levels } = calcAutoFib(this.rawBars, lookback);
|
|
const COLORS: Record<string, string> = {
|
|
'0%': '#EF5350', '23.6%': '#FF9800', '38.2%': '#FFC107',
|
|
'50%': '#FFFFFF', '61.8%': '#4CAF50', '78.6%': '#2196F3',
|
|
'100%': '#9C27B0', '127.2%': '#607D8B', '161.8%': '#607D8B',
|
|
};
|
|
for (const l of levels) {
|
|
const line = this.mainSeries.createPriceLine({
|
|
price: l.price, color: COLORS[l.label] ?? '#607D8B',
|
|
lineWidth: l.label === '0%' || l.label === '100%' ? 2 : 1,
|
|
lineStyle: l.label === '50%' ? LineStyle.Dashed : LineStyle.Dotted,
|
|
axisLabelVisible: true, title: `Fib ${l.label}`,
|
|
});
|
|
this.fibLines.push({ price: l.price, label: l.label, line });
|
|
}
|
|
}
|
|
|
|
clearFib(): void { this._clearFibLines(); }
|
|
private _clearFibLines(): void {
|
|
for (const f of this.fibLines) { try { this.mainSeries?.removePriceLine(f.line); } catch { /* ok */ } }
|
|
this.fibLines = [];
|
|
}
|
|
|
|
// ─── Drawing ─────────────────────────────────────────────────────────────
|
|
setDrawingTool(tool: string): void {
|
|
this.drawingTool = tool;
|
|
this.drawingPoints = [];
|
|
if (this._clickHandler) { this.chart.unsubscribeClick(this._clickHandler); this._clickHandler = null; }
|
|
this.container.style.cursor = tool === 'cursor' ? 'default' : 'crosshair';
|
|
if (tool === 'cursor') return;
|
|
|
|
this._clickHandler = (p: MouseEventParams<Time>) => {
|
|
if (!p.time || !p.point || !this.mainSeries) return;
|
|
const price = this.mainSeries.coordinateToPrice(p.point.y);
|
|
if (price === null) return;
|
|
this.drawingPoints.push({ time: p.time, price });
|
|
|
|
if (tool === 'hline') {
|
|
const l = this.mainSeries.createPriceLine({ price, color: '#FF6D00', lineWidth: 1, lineStyle: LineStyle.Solid, axisLabelVisible: true, title: '' });
|
|
this.drawingLines.push(l);
|
|
this.drawingPoints = [];
|
|
} else if (tool === 'trendline' && this.drawingPoints.length === 2) {
|
|
const [p1, p2] = this.drawingPoints;
|
|
const l = this.mainSeries.createPriceLine({ price: (p1.price + p2.price) / 2, color: '#2962FF', lineWidth: 1, lineStyle: LineStyle.Solid, axisLabelVisible: false, title: `${p1.price.toFixed(2)}→${p2.price.toFixed(2)}` });
|
|
this.drawingLines.push(l);
|
|
this.drawingPoints = [];
|
|
} else if (tool === 'measure' && this.drawingPoints.length === 2) {
|
|
const [p1, p2] = this.drawingPoints;
|
|
const pct = ((p2.price - p1.price) / p1.price * 100).toFixed(2);
|
|
this.mainSeries.createPriceLine({ price: p1.price, color: '#FFC10788', lineWidth: 1, lineStyle: LineStyle.Dashed, axisLabelVisible: true, title: '' });
|
|
this.mainSeries.createPriceLine({ price: p2.price, color: '#FFC10788', lineWidth: 1, lineStyle: LineStyle.Dashed, axisLabelVisible: true, title: `${pct}%` });
|
|
this.drawingPoints = [];
|
|
}
|
|
};
|
|
this.chart.subscribeClick(this._clickHandler);
|
|
}
|
|
|
|
clearDrawings(): void {
|
|
for (const l of this.drawingLines) { try { this.mainSeries?.removePriceLine(l); } catch { /* ok */ } }
|
|
this.drawingLines = [];
|
|
this.drawingPoints = [];
|
|
this._clearFibLines();
|
|
}
|
|
|
|
// ─── Crosshair ────────────────────────────────────────────────────────────
|
|
/**
|
|
* 차트 클릭 감지: 시리즈 근처(40px 이내)를 단일/더블 클릭 시 콜백 호출.
|
|
* - entryId: 클릭한 인디케이터 ID ('__main__' = 메인캔들, null = 빈 공간)
|
|
* - point: 차트 컨테이너 기준 픽셀 좌표
|
|
*/
|
|
/**
|
|
* 클릭/더블클릭 구독 — 네이티브 DOM 이벤트 기반.
|
|
*
|
|
* LWC click 이벤트의 params.point.y 는 pane 내부 좌표계이므로
|
|
* getPaneLayouts() 의 chart-relative topY 와 혼용하면 패인 위치를 잘못 계산함.
|
|
* 네이티브 MouseEvent.clientY 는 항상 스크린 절대 좌표이므로
|
|
* containerRect 를 빼면 chart-relative Y 를 정확히 얻을 수 있음.
|
|
*
|
|
* 패인 탐색 결과:
|
|
* pane 0 → '__main__'
|
|
* pane 1 → null (volume, 무시)
|
|
* pane 2+ → 해당 indicator ID
|
|
*/
|
|
subscribeSeriesClick(
|
|
onSingle: (entryId: string | null, point: { x: number; y: number }) => void,
|
|
onDouble: (entryId: string | null, point: { x: number; y: number }) => void,
|
|
): () => void {
|
|
let lastClickTime = 0;
|
|
let lastEntryId = '';
|
|
let singleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
/** e.clientY → chart-container 상대 Y → pane 탐색 → entryId + paneTopY */
|
|
const resolveEntry = (e: MouseEvent): { entryId: string | null; paneTopY: number } => {
|
|
const rect = this.container.getBoundingClientRect();
|
|
const clickY = e.clientY - rect.top;
|
|
const layouts = this.getPaneLayouts();
|
|
const pane = layouts.find(l => clickY >= l.topY && clickY < l.topY + l.height);
|
|
if (!pane) return { entryId: null, paneTopY: 0 };
|
|
|
|
if (pane.paneIndex === 0) {
|
|
// 크로스헤어가 오버레이 지표 위에 있으면 해당 지표 반환
|
|
if (this._crosshairEntryId && this._crosshairEntryId !== '__main__') {
|
|
const entry = this.indicators.get(this._crosshairEntryId);
|
|
if (entry && entry.paneIndex === 0) {
|
|
return { entryId: this._crosshairEntryId, paneTopY: 0 };
|
|
}
|
|
}
|
|
return { entryId: '__main__', paneTopY: 0 };
|
|
}
|
|
if (pane.paneIndex === 1) return { entryId: null, paneTopY: pane.topY }; // volume
|
|
|
|
// pane 2+ → 해당 패인의 indicator 탐색
|
|
const mapping = this.getIndicatorPaneMapping();
|
|
for (const [id, pIdx] of mapping) {
|
|
if (pIdx === pane.paneIndex) return { entryId: id, paneTopY: pane.topY };
|
|
}
|
|
return { entryId: null, paneTopY: pane.topY };
|
|
};
|
|
|
|
const clickHandler = (e: MouseEvent) => {
|
|
const now = Date.now();
|
|
const { entryId, paneTopY } = resolveEntry(e);
|
|
const rect = this.container.getBoundingClientRect();
|
|
const point = { x: rect.left, y: rect.top + paneTopY };
|
|
|
|
const isDouble = now - lastClickTime < 400 && lastEntryId === (entryId ?? '');
|
|
lastClickTime = now;
|
|
lastEntryId = entryId ?? '';
|
|
|
|
if (isDouble) {
|
|
if (singleTimer) { clearTimeout(singleTimer); singleTimer = null; }
|
|
onDouble(entryId, point);
|
|
} else {
|
|
if (singleTimer) clearTimeout(singleTimer);
|
|
singleTimer = setTimeout(() => {
|
|
onSingle(entryId, point);
|
|
singleTimer = null;
|
|
}, 220);
|
|
}
|
|
};
|
|
|
|
this.container.addEventListener('click', clickHandler);
|
|
return () => {
|
|
this.container.removeEventListener('click', clickHandler);
|
|
if (singleTimer) { clearTimeout(singleTimer); singleTimer = null; }
|
|
};
|
|
}
|
|
|
|
/** @deprecated 내부용: 더 이상 subscribeSeriesClick 에서 호출하지 않음 */
|
|
private _findClickedEntryId(_params: MouseEventParams<Time>): string | null {
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* sub-pane 의 시작 pane index 반환.
|
|
* pane 0 = 메인, pane 1 = 볼륨 (고정), pane 2+ = 보조지표
|
|
*/
|
|
getSubPaneStart(): number { return 2; }
|
|
|
|
/**
|
|
* 현재 indicators Map 에서 (indicatorId → 실제 paneIndex) 매핑 반환.
|
|
* 타이밍 문제 없이 ChartManager 에 등록된 실제 값 사용.
|
|
*/
|
|
getIndicatorPaneMapping(): Map<string, number> {
|
|
const map = new Map<string, number>();
|
|
for (const [id, entry] of this.indicators) {
|
|
if (entry.paneIndex !== undefined && entry.paneIndex !== null) {
|
|
map.set(id, entry.paneIndex);
|
|
}
|
|
}
|
|
return map;
|
|
}
|
|
|
|
/** 인디케이터 메타 반환 (컨텍스트 툴바용) */
|
|
getIndicatorMeta(id: string): { type: string; config: IndicatorConfig } | null {
|
|
if (id === '__main__') return null;
|
|
const entry = this.indicators.get(id);
|
|
return entry ? { type: entry.type, config: entry.config } : null;
|
|
}
|
|
|
|
/**
|
|
* 지정 pane index 의 차트 컨테이너 기준 상단 Y 좌표 반환.
|
|
* 각 pane height 합산으로 계산. 컨테이너 기준이므로 screen 좌표로
|
|
* 변환하려면 container.getBoundingClientRect().top 을 더해야 함.
|
|
*/
|
|
getPaneTopY(paneIndex: number): number {
|
|
const layouts = this.getPaneLayouts();
|
|
return layouts.find(l => l.paneIndex === paneIndex)?.topY ?? 0;
|
|
}
|
|
|
|
/** 인디케이터가 속한 pane index 반환 */
|
|
getIndicatorPaneIndex(id: string): number {
|
|
if (id === '__main__') return 0;
|
|
return this.indicators.get(id)?.paneIndex ?? 0;
|
|
}
|
|
|
|
/**
|
|
* 차트 컨테이너 기준 각 pane 의 topY 와 height 배열 반환.
|
|
* 크로스헤어 이동 시 호출하여 최신 레이아웃 유지.
|
|
*/
|
|
/** pane 레이아웃 변경 알림 (보조지표 추가·제거·높이 재배분 후) */
|
|
private readonly _paneLayoutListeners = new Set<() => void>();
|
|
|
|
subscribePaneLayout(cb: () => void): () => void {
|
|
this._paneLayoutListeners.add(cb);
|
|
return () => { this._paneLayoutListeners.delete(cb); };
|
|
}
|
|
|
|
private _notifyPaneLayout(): void {
|
|
for (const cb of this._paneLayoutListeners) {
|
|
try { cb(); } catch { /* ok */ }
|
|
}
|
|
}
|
|
|
|
/** 가시 시간 범위 변경 알림 (커스텀 패닝·줌 등 LWC 이벤트 미발생 경로 포함) */
|
|
private readonly _viewportListeners = new Set<() => void>();
|
|
private _viewportLwcHooked = false;
|
|
private readonly _lwcViewportHandler = (): void => { this._notifyViewport(); };
|
|
|
|
private _notifyViewport(): void {
|
|
for (const cb of this._viewportListeners) {
|
|
try { cb(); } catch { /* ok */ }
|
|
}
|
|
}
|
|
|
|
private _hookViewportLwc(): void {
|
|
if (this._viewportLwcHooked) return;
|
|
this._viewportLwcHooked = true;
|
|
this.chart.timeScale().subscribeVisibleTimeRangeChange(this._lwcViewportHandler);
|
|
this.chart.timeScale().subscribeVisibleLogicalRangeChange(this._lwcViewportHandler);
|
|
}
|
|
|
|
private _unhookViewportLwc(): void {
|
|
if (!this._viewportLwcHooked) return;
|
|
this._viewportLwcHooked = false;
|
|
try { this.chart.timeScale().unsubscribeVisibleTimeRangeChange(this._lwcViewportHandler); } catch { /* ok */ }
|
|
try { this.chart.timeScale().unsubscribeVisibleLogicalRangeChange(this._lwcViewportHandler); } catch { /* ok */ }
|
|
}
|
|
|
|
/** 논리 범위 변경 + 오버레이(시간축·드로잉) 동기화 */
|
|
private _setVisibleLogicalRange(range: { from: number; to: number }): void {
|
|
try {
|
|
this.chart.timeScale().setVisibleLogicalRange(range);
|
|
this._notifyViewport();
|
|
} catch { /* 데이터 로드 전 */ }
|
|
}
|
|
|
|
/** PaneLegend 등 — 보조지표 pane DOM 배치가 끝난 뒤 레이블 위치 재동기화 */
|
|
notifyPaneLayoutChanged(): void {
|
|
this._notifyPaneLayout();
|
|
}
|
|
|
|
/** 시간축 오버레이 등 — 뷰포트 동기화 강제 (패닝 종료 시 등) */
|
|
notifyViewportChanged(): void {
|
|
this._notifyViewport();
|
|
}
|
|
|
|
/** visible logical range → 플롯 X (LWC 좌표 API와 동일한 선형 매핑) */
|
|
private _logicalToAxisX(logical: number, lr: { from: number; to: number }, plotWidth: number): number {
|
|
const span = lr.to - lr.from;
|
|
if (!Number.isFinite(span) || span <= 0) return 0;
|
|
return ((logical - lr.from) / span) * plotWidth;
|
|
}
|
|
|
|
/**
|
|
* 차트 컨테이너 기준 각 pane 의 topY 와 height 배열 반환.
|
|
* IPaneApi.getHTMLElement() 로 pane index ↔ 화면 위치를 직접 매칭한다.
|
|
* (tr 행 순서는 orphan 빈 pane 때문에 pane index 와 어긋날 수 있음)
|
|
*/
|
|
getPaneLayouts(): Array<{ paneIndex: number; topY: number; height: number }> {
|
|
const panes = this.chart.panes();
|
|
const containerRect = this.container.getBoundingClientRect();
|
|
const result: Array<{ paneIndex: number; topY: number; height: number }> = [];
|
|
let missing = 0;
|
|
|
|
for (const pane of panes) {
|
|
const paneIndex = pane.paneIndex();
|
|
const el = pane.getHTMLElement();
|
|
if (el) {
|
|
const rect = el.getBoundingClientRect();
|
|
result.push({
|
|
paneIndex,
|
|
topY: Math.round(rect.top - containerRect.top),
|
|
height: Math.round(rect.height),
|
|
});
|
|
} else {
|
|
missing++;
|
|
result.push({ paneIndex, topY: 0, height: 0 });
|
|
}
|
|
}
|
|
|
|
if (missing > 0) {
|
|
const fallback = this._getPaneLayoutsStretchEstimate();
|
|
for (let i = 0; i < result.length; i++) {
|
|
if (result[i].height <= 0) {
|
|
const fb = fallback.find(f => f.paneIndex === result[i].paneIndex);
|
|
if (fb) result[i] = fb;
|
|
}
|
|
}
|
|
}
|
|
|
|
const active = this._activeIndicatorPaneIndices();
|
|
if (active.size > 0) {
|
|
return result.filter(l => l.paneIndex < 2 || active.has(l.paneIndex));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/** stretchFactor 비율로 pane 위치 추정 (getHTMLElement 미준비 시) */
|
|
private _getPaneLayoutsStretchEstimate(): Array<{ paneIndex: number; topY: number; height: number }> {
|
|
const panes = this.chart.panes();
|
|
const paneCount = panes.length;
|
|
const SEP_H = 4;
|
|
const TIME_AXIS_H = 26;
|
|
const sepCount = Math.max(0, paneCount - 1);
|
|
const chartAreaH = Math.max(1, this.container.clientHeight - TIME_AXIS_H - sepCount * SEP_H);
|
|
const totalFactor = panes.reduce((s, p) => s + p.getStretchFactor(), 0) || 1;
|
|
const result: Array<{ paneIndex: number; topY: number; height: number }> = [];
|
|
let y = 0;
|
|
for (let i = 0; i < paneCount; i++) {
|
|
const h = Math.round((panes[i].getStretchFactor() / totalFactor) * chartAreaH);
|
|
result.push({ paneIndex: panes[i].paneIndex(), topY: y, height: h });
|
|
y += h + (i < paneCount - 1 ? SEP_H : 0);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* 보조지표가 실제로 붙어 있는 sub-pane 레이아웃만 (pane 2+).
|
|
* orphan pane·빈 pane 은 제외해 PaneLegend 위치 오차를 방지한다.
|
|
*/
|
|
getIndicatorSubPaneLayouts(): Array<{ paneIndex: number; topY: number; height: number }> {
|
|
const MIN_H = 12;
|
|
const active = this._activeIndicatorPaneIndices();
|
|
return this.getPaneLayouts()
|
|
.filter(l => active.has(l.paneIndex) && l.height > MIN_H)
|
|
.sort((a, b) => a.topY - b.topY);
|
|
}
|
|
|
|
/**
|
|
* 보조지표 id 기준 화면 pane 위치 (시리즈가 붙은 LWC pane DOM).
|
|
* PaneLegend 레이블 위치 — pane index·tr 순서와 무관.
|
|
*/
|
|
getIndicatorPaneScreenLayout(id: string): { topY: number; height: number } | null {
|
|
const entry = this.indicators.get(id);
|
|
if (!entry || entry.paneIndex == null || entry.paneIndex < 2) return null;
|
|
const containerRect = this.container.getBoundingClientRect();
|
|
const MIN_H = 4;
|
|
|
|
for (const series of entry.seriesList) {
|
|
try {
|
|
const el = series.getPane().getHTMLElement();
|
|
if (!el) continue;
|
|
const rect = el.getBoundingClientRect();
|
|
const height = Math.round(rect.height);
|
|
if (height < MIN_H) continue;
|
|
return {
|
|
topY: Math.round(rect.top - containerRect.top),
|
|
height,
|
|
};
|
|
} catch {
|
|
/* try next series */
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 서브 pane(비오버레이) 인디케이터 정보 목록 반환.
|
|
* PaneLegend 컴포넌트에서 각 pane 레이블 렌더링에 사용.
|
|
*/
|
|
getIndicatorPaneInfo(): Array<{
|
|
id: string;
|
|
type: string;
|
|
config: IndicatorConfig;
|
|
paneIndex: number;
|
|
plotColors: string[];
|
|
}> {
|
|
const result = [];
|
|
for (const [id, entry] of this.indicators) {
|
|
result.push({
|
|
id,
|
|
type: entry.type,
|
|
config: entry.config,
|
|
paneIndex: entry.paneIndex ?? 0,
|
|
plotColors: entry.seriesMeta.map(m => m.color),
|
|
});
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* 크로스헤어 위치의 각 인디케이터 시리즈 값을
|
|
* indicatorId → number[] (plot 순서) 형태로 반환.
|
|
*/
|
|
getIndicatorValuesByIdFromParams(params: MouseEventParams<Time>): Record<string, number[]> {
|
|
const result: Record<string, number[]> = {};
|
|
for (const [id, entry] of this.indicators) {
|
|
const vals: number[] = [];
|
|
for (const series of entry.seriesList) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const d = params.seriesData.get(series) as any;
|
|
if (!d) { vals.push(NaN); continue; }
|
|
const v = typeof d.value === 'number' ? d.value : NaN;
|
|
vals.push(v);
|
|
}
|
|
if (vals.some(v => !isNaN(v))) result[id] = vals;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/** 인디케이터 전체 가시성 토글 (시리즈 visible 옵션 변경) */
|
|
toggleIndicatorVisible(id: string, visible: boolean): void {
|
|
const entry = this.indicators.get(id);
|
|
if (!entry) return;
|
|
for (const series of entry.seriesList) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
series.applyOptions({ visible } as any);
|
|
}
|
|
if (entry.cloudPlugin) {
|
|
// 일목균형표 구름 플러그인: 가시성은 series 통해 처리됨
|
|
}
|
|
}
|
|
|
|
/** 특정 plot 의 색상/선폭 즉시 변경 */
|
|
updateIndicatorPlotStyle(id: string, plotIndex: number, color: string, lineWidth?: number): void {
|
|
const entry = this.indicators.get(id);
|
|
if (!entry) return;
|
|
const series = entry.seriesList[plotIndex];
|
|
if (!series) return;
|
|
const opts: Record<string, unknown> = { color };
|
|
if (lineWidth != null) opts['lineWidth'] = lineWidth;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
series.applyOptions(opts as any);
|
|
}
|
|
|
|
/** 차트 설정: 캔들·보조지표 영역 가격축 라벨·설명 (동일 값이면 기존 API 호환) */
|
|
setSeriesPriceLabelsEnabled(enabled: boolean): void {
|
|
this.setCandleAreaPriceLabelsEnabled(enabled);
|
|
this.setIndicatorAreaPriceLabelsEnabled(enabled);
|
|
}
|
|
|
|
setCandleAreaPriceLabelsEnabled(enabled: boolean): void {
|
|
this._candleAreaPriceLabelsEnabled = enabled;
|
|
this._refreshPriceLabelStyles();
|
|
}
|
|
|
|
setIndicatorAreaPriceLabelsEnabled(enabled: boolean): void {
|
|
this._indicatorAreaPriceLabelsEnabled = enabled;
|
|
this._refreshPriceLabelStyles();
|
|
}
|
|
|
|
getCandleAreaPriceLabelsEnabled(): boolean {
|
|
return this._candleAreaPriceLabelsEnabled;
|
|
}
|
|
|
|
getIndicatorAreaPriceLabelsEnabled(): boolean {
|
|
return this._indicatorAreaPriceLabelsEnabled;
|
|
}
|
|
|
|
private _refreshPriceLabelStyles(): void {
|
|
for (const entry of this.indicators.values()) {
|
|
this.applyIndicatorStyle(entry.config);
|
|
}
|
|
this._applyAllSeriesPriceFormats();
|
|
}
|
|
|
|
/** 차트 설정: 거래량 pane 표시 on/off */
|
|
setVolumeVisible(visible: boolean, availableHeight?: number): void {
|
|
this._volumeVisible = visible;
|
|
if (this._candleOnlyLayout) return;
|
|
this.applyVolumeVisibility(visible, availableHeight);
|
|
}
|
|
|
|
getVolumeVisible(): boolean {
|
|
return this._volumeVisible;
|
|
}
|
|
|
|
private applyVolumeVisibility(visible: boolean, availableHeight?: number): void {
|
|
try {
|
|
this.volumeSeries?.applyOptions({ visible } as Parameters<ISeriesApi<SeriesType>['applyOptions']>[0]);
|
|
} catch { /* ok */ }
|
|
this.resetPaneHeights(availableHeight);
|
|
}
|
|
|
|
private shouldShowSeriesPriceLabel(
|
|
config: IndicatorConfig,
|
|
plotAllowsLastValue = true,
|
|
paneIndex = 0,
|
|
): boolean {
|
|
const areaEnabled = paneIndex < 2
|
|
? this._candleAreaPriceLabelsEnabled
|
|
: this._indicatorAreaPriceLabelsEnabled;
|
|
return areaEnabled
|
|
&& plotAllowsLastValue
|
|
&& config.lastValueVisible !== false;
|
|
}
|
|
|
|
private seriesTitleForPlot(
|
|
config: IndicatorConfig,
|
|
plot: PlotDef | undefined,
|
|
plotAllowsLastValue = true,
|
|
paneIndex = 0,
|
|
): string {
|
|
if (!this.shouldShowSeriesPriceLabel(config, plotAllowsLastValue, paneIndex)) return '';
|
|
if (config.type === 'IchimokuCloud' && plot?.id) {
|
|
return getIchimokuPlotTitle(plot.id);
|
|
}
|
|
return plot?.title ?? '';
|
|
}
|
|
|
|
/**
|
|
* 인디케이터의 스타일(색상·선폭·plotVisibility)을 in-place 로 일괄 적용.
|
|
* 시리즈를 제거·재생성하지 않으므로 pane 재번호 문제가 없음.
|
|
*/
|
|
applyIndicatorStyle(config: IndicatorConfig): void {
|
|
config = enrichIndicatorConfig(config);
|
|
const entry = this.indicators.get(config.id);
|
|
if (!entry) return;
|
|
entry.config = config;
|
|
const def = getIndicatorDef(config.type);
|
|
const plotDefs = config.plots ?? def?.plots ?? [];
|
|
|
|
const plotById = Object.fromEntries(plotDefs.map((p: PlotDef) => [p.id, p]));
|
|
|
|
entry.seriesList.forEach((series, i) => {
|
|
let plot: PlotDef | undefined;
|
|
const pid = entry.seriesMeta[i]?.plotId;
|
|
plot = (pid ? plotById[pid] : undefined) ?? plotDefs[i];
|
|
if (!plot) return;
|
|
const indicatorVisible = config.hidden !== true;
|
|
const visible = indicatorVisible && config.plotVisibility?.[plot.id] !== false;
|
|
const paneIdx = entry.paneIndex ?? 0;
|
|
const indicatorPriceFormat = priceFormatForIndicatorPane(paneIdx);
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const opts: Record<string, unknown> = { visible };
|
|
if (indicatorPriceFormat) {
|
|
opts['priceFormat'] = indicatorPriceFormat;
|
|
}
|
|
if (!entry.seriesMeta[i]?.isHistogram) {
|
|
opts['color'] = plot.color ?? '#aaa';
|
|
if (plot.lineWidth != null) opts['lineWidth'] = plot.lineWidth;
|
|
opts['lineStyle'] = plotSeriesLineStyle(plot.lineStyle);
|
|
let plotAllows = true;
|
|
if (entry.type === 'IchimokuCloud') {
|
|
plotAllows = entry.seriesMeta[i]?.plotId !== 'plot2';
|
|
}
|
|
const showPriceLabel = this.shouldShowSeriesPriceLabel(config, plotAllows, paneIdx);
|
|
opts['lastValueVisible'] = showPriceLabel;
|
|
opts['title'] = paneIdx < 2 && showPriceLabel
|
|
? this.seriesTitleForPlot(config, plot, plotAllows, paneIdx)
|
|
: '';
|
|
} else {
|
|
opts['color'] = plot.color ?? '#aaa';
|
|
}
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
series.applyOptions(opts as any);
|
|
});
|
|
|
|
// ── hlines(과열선/중앙선/침체선) 동기화 ─────────────────────────────────
|
|
if (entry.seriesList.length > 0) {
|
|
const firstSeries = entry.seriesList[0];
|
|
const hlines = config.hlines ?? def?.hlines ?? [];
|
|
|
|
const toLineStyle2 = (s?: string): number => {
|
|
if (s === 'solid') return LineStyle.Solid;
|
|
if (s === 'dotted') return LineStyle.Dotted;
|
|
return LineStyle.Dashed;
|
|
};
|
|
|
|
// 기존 price line 제거
|
|
for (const pl of entry.hlineRefs) {
|
|
try { firstSeries.removePriceLine(pl); } catch { /* 이미 제거됨 */ }
|
|
}
|
|
entry.hlineRefs = [];
|
|
|
|
if (entry.fillPrimitive) {
|
|
try { firstSeries.detachPrimitive(entry.fillPrimitive); } catch { /* ok */ }
|
|
entry.fillPrimitive = undefined;
|
|
}
|
|
|
|
if (config.hidden !== true) {
|
|
for (const hl of hlines) {
|
|
if (hl.visible === false) continue;
|
|
const pl = firstSeries.createPriceLine({
|
|
price: hl.price,
|
|
color: hl.color,
|
|
lineWidth: Math.min(4, hl.lineWidth ?? 1) as 1|2|3|4,
|
|
lineStyle: toLineStyle2(hl.lineStyle),
|
|
axisLabelVisible: false,
|
|
});
|
|
entry.hlineRefs.push(pl);
|
|
}
|
|
|
|
const prices = hlines.filter(h => h.visible !== false).map(h => h.price);
|
|
const hasZone = hlines.some(h => {
|
|
const lbl = h.label ?? getHLineLabel(h.price, prices);
|
|
return (lbl === '과열선' || lbl === '침체선') && h.visible !== false;
|
|
});
|
|
const hasBg = config.hlinesBackground?.visible !== false;
|
|
if (hasZone || hasBg) {
|
|
entry.fillPrimitive = new IndicatorFillPrimitive(hlines, config.hlinesBackground);
|
|
firstSeries.attachPrimitive(entry.fillPrimitive);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (entry.type === 'BollingerBands') {
|
|
detachBbBandFill(entry);
|
|
if (config.hidden !== true) {
|
|
attachBbBandFill(entry, config);
|
|
entry.bbFillPrimitive?.requestRefresh();
|
|
}
|
|
}
|
|
|
|
if (entry.type === 'IchimokuCloud' && entry.cloudPlugin) {
|
|
void calculateIndicator(
|
|
'IchimokuCloud',
|
|
this.rawBars,
|
|
config.params ?? {},
|
|
).then(({ plots }) => {
|
|
this._applyIchimokuCloudPlugin(entry.cloudPlugin!, plots, config);
|
|
}).catch(() => { /* ignore */ });
|
|
}
|
|
|
|
entry.config = config;
|
|
if (entry.seriesMeta.some(m => m.isHistogram)) {
|
|
void this._repaintHistogramSeries(config.id, config);
|
|
}
|
|
|
|
if (this._candleOnlyLayout && this._isSubPaneIndicator(entry)) {
|
|
this._hideSubPaneIndicator(entry);
|
|
}
|
|
}
|
|
|
|
/** 메인 차트(캔들) 색상 스타일 즉시 업데이트 */
|
|
updateMainSeriesStyle(style: import('../types').MainChartStyle): void {
|
|
if (!this.mainSeries) return;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(this.mainSeries as ISeriesApi<any>).applyOptions({
|
|
upColor: style.upColor,
|
|
downColor: style.downColor,
|
|
borderUpColor: style.borderUpColor,
|
|
borderDownColor: style.borderDownColor,
|
|
wickUpColor: style.wickUpColor,
|
|
wickDownColor: style.wickDownColor,
|
|
});
|
|
// 볼륨 색상도 동기화
|
|
if (this.volumeSeries) {
|
|
// 볼륨은 개별 bar 색상이어서 전체 재적용이 필요
|
|
// rawBars 가 있을 때만 재색칠
|
|
if (this.rawBars.length > 0) {
|
|
this.volumeSeries.setData(
|
|
this.rawBars.map(b => ({
|
|
time: b.time as Time,
|
|
value: b.volume,
|
|
color: b.close >= b.open ? `${style.upColor}88` : `${style.downColor}88`,
|
|
}))
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
subscribeCrosshair(cb: (p: MouseEventParams<Time>) => void): () => void {
|
|
this.chart.subscribeCrosshairMove(cb);
|
|
return () => this.chart.unsubscribeCrosshairMove(cb);
|
|
}
|
|
|
|
/**
|
|
* 크로스헤어 자석모드 전환.
|
|
* - enabled=true → CrosshairMode.Magnet (캔들 OHLCV에 스냅)
|
|
* - enabled=false → CrosshairMode.Normal (마우스 위치 자유 이동)
|
|
*/
|
|
setCrosshairMagnet(enabled: boolean): void {
|
|
this.chart.applyOptions({
|
|
crosshair: { mode: enabled ? CrosshairMode.Magnet : CrosshairMode.Normal },
|
|
});
|
|
}
|
|
|
|
// ─── Screenshot ───────────────────────────────────────────────────────────
|
|
screenshot(): string | null {
|
|
try {
|
|
const canvas = this.chart.takeScreenshot();
|
|
return canvas.toDataURL('image/png');
|
|
} catch { return null; }
|
|
}
|
|
|
|
// ─── Layout ───────────────────────────────────────────────────────────────
|
|
fitContent(): void { this.chart.timeScale().fitContent(); }
|
|
|
|
/** 시간축 확대 (visible logical range 20% 축소) */
|
|
zoomIn(): void {
|
|
const ts = this.chart.timeScale();
|
|
const lr = ts.getVisibleLogicalRange();
|
|
if (!lr) return;
|
|
const span = lr.to - lr.from;
|
|
const center = (lr.from + lr.to) / 2;
|
|
const newSpan = Math.max(span * 0.8, 5); // 최소 5바
|
|
this._setVisibleLogicalRange({ from: center - newSpan / 2, to: center + newSpan / 2 });
|
|
}
|
|
|
|
/** 시간축 축소 (visible logical range 20% 확대) */
|
|
zoomOut(): void {
|
|
const ts = this.chart.timeScale();
|
|
const lr = ts.getVisibleLogicalRange();
|
|
if (!lr) return;
|
|
const span = lr.to - lr.from;
|
|
const center = (lr.from + lr.to) / 2;
|
|
const newSpan = span * 1.25;
|
|
this._setVisibleLogicalRange({ from: center - newSpan / 2, to: center + newSpan / 2 });
|
|
}
|
|
|
|
/** 시간축 왼쪽(과거)으로 스크롤 — visible 범위의 20%씩 이동 */
|
|
scrollLeft(): void {
|
|
const ts = this.chart.timeScale();
|
|
const lr = ts.getVisibleLogicalRange();
|
|
if (!lr) return;
|
|
const shift = (lr.to - lr.from) * 0.2;
|
|
this._setVisibleLogicalRange({ from: lr.from - shift, to: lr.to - shift });
|
|
}
|
|
|
|
/** 시간축 오른쪽(미래)으로 스크롤 — visible 범위의 20%씩 이동 */
|
|
scrollRight(): void {
|
|
const ts = this.chart.timeScale();
|
|
const lr = ts.getVisibleLogicalRange();
|
|
if (!lr) return;
|
|
const shift = (lr.to - lr.from) * 0.2;
|
|
this._setVisibleLogicalRange({ from: lr.from + shift, to: lr.to + shift });
|
|
}
|
|
|
|
/** 캔들 전용(보조지표·거래량 숨김) 레이아웃 적용 중 여부 */
|
|
private _candleOnlyLayout = false;
|
|
|
|
isCandleOnlyLayout(): boolean {
|
|
return this._candleOnlyLayout;
|
|
}
|
|
|
|
/**
|
|
* 캔들 pane(0) 플롯 영역(가격·시간축 제외) 여부.
|
|
*/
|
|
isInCandlePlotArea(chartX: number, chartY: number): boolean {
|
|
if (this.getPaneIndexAtChartY(chartY) !== 0) return false;
|
|
if (this.isOnPriceAxis(chartX, chartY) || this.isOnTimeAxis(chartY)) return false;
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* chart-container 좌표 기준 클릭 대상 (pane 0 오버레이 지표 / 메인 / 보조지표 pane).
|
|
*/
|
|
resolveEntryAtChartPoint(chartX: number, chartY: number): string | null {
|
|
const layouts = this.getPaneLayouts();
|
|
const pane = layouts.find(l => chartY >= l.topY && chartY < l.topY + l.height);
|
|
if (!pane) return null;
|
|
|
|
if (pane.paneIndex === 0) {
|
|
if (this._crosshairEntryId && this._crosshairEntryId !== '__main__') {
|
|
const entry = this.indicators.get(this._crosshairEntryId);
|
|
if (entry && entry.paneIndex === 0) return this._crosshairEntryId;
|
|
}
|
|
return '__main__';
|
|
}
|
|
if (pane.paneIndex === 1) return null;
|
|
|
|
const mapping = this.getIndicatorPaneMapping();
|
|
for (const [id, pIdx] of mapping) {
|
|
if (pIdx === pane.paneIndex) return id;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** chart X 좌표에 가장 가까운 캔들 bar (미래 빈 구간은 null) */
|
|
private _barAtChartX(chartX: number): OHLCVBar | undefined {
|
|
const time = this.xToTime(chartX);
|
|
if (time == null || this.rawBars.length === 0) return undefined;
|
|
|
|
const exact = this.getBarAtTime(time);
|
|
if (exact) return exact;
|
|
|
|
let best = this.rawBars[0];
|
|
let bestDt = Math.abs((best.time as number) - time);
|
|
for (const b of this.rawBars) {
|
|
const dt = Math.abs((b.time as number) - time);
|
|
if (dt < bestDt) {
|
|
bestDt = dt;
|
|
best = b;
|
|
}
|
|
}
|
|
const interval = this._barIntervalSecs();
|
|
return bestDt <= interval * 0.55 ? best : undefined;
|
|
}
|
|
|
|
/**
|
|
* 캔들 pane에서 실제 메인 시리즈(캔들·라인 등) 위 더블클릭인지 판별.
|
|
* 빈 플롯 영역이면 false → 전체보기 토글 대상.
|
|
*/
|
|
isClickOnMainSeries(chartX: number, chartY: number): boolean {
|
|
if (!this.isInCandlePlotArea(chartX, chartY) || !this.mainSeries) return false;
|
|
|
|
const bar = this._barAtChartX(chartX);
|
|
if (!bar) return false;
|
|
|
|
const price = this.yToPrice(chartY);
|
|
if (price == null || !Number.isFinite(price)) return false;
|
|
|
|
const ct = this.currentChartType;
|
|
if (ct === 'line' || ct === 'area' || ct === 'baseline') {
|
|
const tol = Math.max(Math.abs(bar.close) * 0.008, (bar.high - bar.low) * 0.15, 1e-8);
|
|
return Math.abs(price - bar.close) <= tol;
|
|
}
|
|
|
|
const bodyPad = Math.max((bar.high - bar.low) * 0.12, Math.abs(bar.close) * 0.0008, 1e-8);
|
|
return price >= bar.low - bodyPad && price <= bar.high + bodyPad;
|
|
}
|
|
|
|
/**
|
|
* 캔들 pane 전체보기 — pane 0(캔들+오버레이 지표)만 전체 높이,
|
|
* 거래량·하단 보조지표 pane 은 접힘. 지표 시리즈는 제거하지 않음(복귀 시 즉시 복원).
|
|
*/
|
|
applyCandleOnlyLayout(enabled: boolean, availableHeight?: number): void {
|
|
this._candleOnlyLayout = enabled;
|
|
const panes = this.chart.panes();
|
|
if (panes.length === 0) return;
|
|
|
|
if (enabled) {
|
|
try {
|
|
this.volumeSeries?.applyOptions({
|
|
visible: false,
|
|
} as Parameters<ISeriesApi<SeriesType>['applyOptions']>[0]);
|
|
} catch { /* ok */ }
|
|
this._resetPaneHeightsCandleFullscreen(availableHeight);
|
|
this._hideAllSubPaneIndicators();
|
|
const H = availableHeight && availableHeight > 0 ? availableHeight : 0;
|
|
const W = this.container.clientWidth;
|
|
if (H > 0 && W > 0) {
|
|
try { this.chart.resize(W, H); } catch { /* ok */ }
|
|
}
|
|
} else {
|
|
this.restoreFromCandleFullscreen(availableHeight);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 캔들 전체보기 해제 — 플래그·거래량·pane 비율·지표 데이터를 정상 모드로 복원.
|
|
*/
|
|
restoreFromCandleFullscreen(availableHeight?: number): void {
|
|
this._candleOnlyLayout = false;
|
|
this.container.style.height = '';
|
|
|
|
try {
|
|
this.volumeSeries?.applyOptions({
|
|
visible: this._volumeVisible,
|
|
} as Parameters<ISeriesApi<SeriesType>['applyOptions']>[0]);
|
|
} catch { /* ok */ }
|
|
|
|
this._resetMainPricePan();
|
|
|
|
this._restoreSubPaneIndicatorVisibility();
|
|
|
|
const H = (availableHeight && availableHeight > 0)
|
|
? availableHeight
|
|
: this.container.clientHeight;
|
|
|
|
this.resetPaneHeights(H);
|
|
|
|
try {
|
|
const w = this.container.clientWidth;
|
|
if (w > 0 && H > 0) this.chart.resize(w, H);
|
|
} catch { /* ok */ }
|
|
|
|
this._scheduleIndicatorLastUpdate();
|
|
this.autoScale();
|
|
}
|
|
|
|
/** pane 2+ 하단 보조지표 여부 (pane 0 오버레이 제외) */
|
|
private _isSubPaneIndicator(entry: IndicatorEntry): boolean {
|
|
return (entry.paneIndex ?? 0) >= 2;
|
|
}
|
|
|
|
/** 캔들 전체보기 — 하단 보조지표 pane 시리즈·기준선 숨김 */
|
|
private _hideSubPaneIndicator(entry: IndicatorEntry): void {
|
|
for (const series of entry.seriesList) {
|
|
try {
|
|
series.applyOptions({
|
|
visible: false,
|
|
lastValueVisible: false,
|
|
title: '',
|
|
} as Parameters<ISeriesApi<SeriesType>['applyOptions']>[0]);
|
|
} catch { /* ok */ }
|
|
}
|
|
const first = entry.seriesList[0];
|
|
if (first) {
|
|
for (const pl of entry.hlineRefs) {
|
|
try { first.removePriceLine(pl); } catch { /* ok */ }
|
|
}
|
|
entry.hlineRefs = [];
|
|
if (entry.fillPrimitive) {
|
|
try { first.detachPrimitive(entry.fillPrimitive); } catch { /* ok */ }
|
|
}
|
|
}
|
|
if (entry.cloudPlugin) {
|
|
for (const s of entry.seriesList) {
|
|
try { s.detachPrimitive(entry.cloudPlugin); } catch { /* ok */ }
|
|
}
|
|
}
|
|
detachBbBandFill(entry);
|
|
}
|
|
|
|
private _hideAllSubPaneIndicators(): void {
|
|
for (const entry of this.indicators.values()) {
|
|
if (this._isSubPaneIndicator(entry)) {
|
|
this._hideSubPaneIndicator(entry);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 캔들 전체보기 해제 — 하단 보조지표 표시·스타일 복원 */
|
|
private _restoreSubPaneIndicatorVisibility(): void {
|
|
for (const entry of this.indicators.values()) {
|
|
if (!this._isSubPaneIndicator(entry)) continue;
|
|
if (entry.cloudPlugin) {
|
|
const host = seriesByPlotId(entry, 'plot3') ?? entry.seriesList[0];
|
|
if (host) {
|
|
try { host.attachPrimitive(entry.cloudPlugin); } catch { /* ok */ }
|
|
}
|
|
}
|
|
this.applyIndicatorStyle(entry.config);
|
|
}
|
|
}
|
|
|
|
/** 실제 보조지표가 붙어 있는 pane index 집합 (2+) */
|
|
private _activeIndicatorPaneIndices(): Set<number> {
|
|
return this._collectUsedSubPaneIndices();
|
|
}
|
|
|
|
/** pane 2+ 빈 sub-pane 제거 — reloadIndicatorsOnly 전용 (모든 지표 시리즈 제거 후) */
|
|
private _removeExtraSubPanes(): void {
|
|
for (;;) {
|
|
const panes = this.chart.panes();
|
|
if (panes.length <= 2) return;
|
|
const last = panes.length - 1;
|
|
try {
|
|
this.chart.removePane(last);
|
|
} catch {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 활성 지표가 없는 sub-pane 제거 (중간·상단 orphan pane 포함) */
|
|
private _removeOrphanSubPanes(): void {
|
|
const active = this._activeIndicatorPaneIndices();
|
|
for (let i = this.chart.panes().length - 1; i >= 2; i--) {
|
|
const panes = this.chart.panes();
|
|
if (i >= panes.length) continue;
|
|
const pi = panes[i].paneIndex();
|
|
if (active.has(pi)) continue;
|
|
try {
|
|
this.chart.removePane(i);
|
|
} catch {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 활성 지표가 없는 맨 끝 sub-pane 만 제거 — 단일 지표 제거 시 나머지 pane 유지 */
|
|
private _trimTrailingEmptySubPanes(): void {
|
|
const active = this._activeIndicatorPaneIndices();
|
|
for (;;) {
|
|
const panes = this.chart.panes();
|
|
if (panes.length <= 2) return;
|
|
const last = panes.length - 1;
|
|
const paneIndex = panes[last].paneIndex();
|
|
if (active.has(paneIndex)) return;
|
|
try {
|
|
this.chart.removePane(last);
|
|
} catch {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** chart-container 기준 Y(px) → pane index (0=캔들, 1=거래량, 2+=보조지표) */
|
|
getPaneIndexAtChartY(chartY: number): number {
|
|
const layouts = this.getPaneLayouts();
|
|
const pane = layouts.find(l => chartY >= l.topY && chartY < l.topY + l.height);
|
|
return pane?.paneIndex ?? 0;
|
|
}
|
|
|
|
/** 우측 가격축 라벨 영역 클릭 여부 (LWC 축 스케일 드래그에 위임) */
|
|
isOnPriceAxis(chartX: number, chartY: number): boolean {
|
|
const paneIndex = this.getPaneIndexAtChartY(chartY);
|
|
const scaleW = this.getPriceScaleWidth(paneIndex);
|
|
if (scaleW <= 0) return false;
|
|
return chartX >= this.container.clientWidth - scaleW;
|
|
}
|
|
|
|
/** pane 우측 가격축(값 라벨) 너비 — PaneLegend 버튼 배치용 */
|
|
getPriceScaleWidth(paneIndex: number): number {
|
|
try {
|
|
const ps = paneIndex === 1
|
|
? this.chart.priceScale('volume', 1)
|
|
: this.chart.priceScale('right', paneIndex);
|
|
const scaleW = ps.width();
|
|
return scaleW > 0 ? scaleW : 56;
|
|
} catch {
|
|
return 56;
|
|
}
|
|
}
|
|
|
|
/** 캔들 pane(0) 플롯 영역 너비 (우측 가격축 제외) */
|
|
private _candlePlotWidth(): number {
|
|
const scaleW = this.getPriceScaleWidth(0);
|
|
return Math.max(40, this.container.clientWidth - scaleW);
|
|
}
|
|
|
|
/**
|
|
* 캔들 pane 하단 시간축 밴드 위치 (보조지표·거래량이 아래에 있을 때).
|
|
*/
|
|
getCandlePaneTimeAxisBand(): { topY: number; height: number; plotWidth: number } | null {
|
|
return this._getCandlePaneTimeAxisBand();
|
|
}
|
|
|
|
/** 크로스헤어 세로선 시간 — 설정된 차트 시간 포맷·시간대 적용 */
|
|
formatCrosshairAxisTime(time: Time): string {
|
|
return formatLwcTimeWithPattern(
|
|
time as number | { year: number; month: number; day: number },
|
|
this.displayTimeframe,
|
|
this.displayTimezone,
|
|
this.chartTimeFormat,
|
|
);
|
|
}
|
|
|
|
private _getCandlePaneTimeAxisBand(): { topY: number; height: number; plotWidth: number } | null {
|
|
if (!this.mainSeries || this.rawBars.length < 2) return null;
|
|
|
|
const layouts = this.getPaneLayouts();
|
|
const hasLowerPane = layouts.some(l => l.paneIndex > 0 && l.height > 8);
|
|
if (!hasLowerPane) return null;
|
|
|
|
const main = layouts.find(l => l.paneIndex === 0);
|
|
if (!main || main.height < 48) return null;
|
|
|
|
const AXIS_H = 22;
|
|
const plotWidth = Math.max(40, this.chart.timeScale().width());
|
|
return {
|
|
topY: main.topY + main.height - AXIS_H,
|
|
height: AXIS_H,
|
|
plotWidth,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 캔들 pane 하단 시간축 오버레이 — 거래량·보조지표가 있을 때 캔들 영역 바로 아래에도 시각 표시.
|
|
*/
|
|
getCandlePaneTimeAxisOverlay(): {
|
|
topY: number;
|
|
height: number;
|
|
plotWidth: number;
|
|
labels: Array<{ x: number; text: string }>;
|
|
} | null {
|
|
const band = this._getCandlePaneTimeAxisBand();
|
|
if (!band) return null;
|
|
|
|
const ts = this.chart.timeScale();
|
|
const lr = ts.getVisibleLogicalRange();
|
|
if (!lr) return null;
|
|
|
|
const { topY, height, plotWidth } = band;
|
|
const lrSpan = lr.to - lr.from;
|
|
if (!Number.isFinite(lrSpan) || lrSpan <= 0) return null;
|
|
|
|
const targetCount = Math.max(4, Math.min(12, Math.floor(plotWidth / 76)));
|
|
const step = Math.max(1, Math.ceil((lr.to - Math.max(0, lr.from)) / targetCount));
|
|
|
|
const labels: Array<{ x: number; text: string }> = [];
|
|
const fmt = this.chartTimeFormat;
|
|
const tz = this.displayTimezone;
|
|
const startLogical = Math.max(0, Math.ceil(lr.from));
|
|
const endLogical = Math.min(this.rawBars.length - 1, Math.floor(lr.to));
|
|
|
|
for (let logical = startLogical; logical <= endLogical; logical += step) {
|
|
const bar = this.rawBars[logical];
|
|
if (!bar?.time) continue;
|
|
const x = this._logicalToAxisX(logical, lr, plotWidth);
|
|
if (!Number.isFinite(x) || x < 12 || x > plotWidth - 12) continue;
|
|
labels.push({
|
|
x,
|
|
text: formatUnixWithChartPattern(bar.time, fmt, tz),
|
|
});
|
|
}
|
|
|
|
if (labels.length === 0) return null;
|
|
|
|
return {
|
|
topY,
|
|
height,
|
|
plotWidth,
|
|
labels,
|
|
};
|
|
}
|
|
|
|
/** 하단 시간축 영역 클릭 여부 */
|
|
isOnTimeAxis(chartY: number): boolean {
|
|
try {
|
|
const h = this.chart.timeScale().height();
|
|
return chartY >= this.container.clientHeight - h;
|
|
} catch {
|
|
return chartY >= this.container.clientHeight - 28;
|
|
}
|
|
}
|
|
|
|
/** 캔들 pane(0) 우측 가격축 라벨 영역 */
|
|
isCandlePanePriceAxis(chartX: number, chartY: number): boolean {
|
|
return this.getPaneIndexAtChartY(chartY) === 0 && this.isOnPriceAxis(chartX, chartY);
|
|
}
|
|
|
|
/**
|
|
* 캔들 pane(0) 우측 가격축 휠 — pane 높이는 유지, 플롯 내 가격 범위만 확대/축소.
|
|
* wheel up → 줌 인(캔들·그래프가 세로로 커 보임), wheel down → 줌 아웃.
|
|
*/
|
|
zoomMainPriceByWheel(deltaY: number, anchorChartY?: number): boolean {
|
|
if (!this.mainSeries || deltaY === 0) return false;
|
|
|
|
const main = this.getPaneLayouts().find(l => l.paneIndex === 0);
|
|
if (!main || main.height <= 0) return false;
|
|
|
|
let range = this._mainPriceVisibleRange ?? this._readMainPriceRange();
|
|
if (!range) return false;
|
|
|
|
const span = range.to - range.from;
|
|
if (!Number.isFinite(span) || span <= 0) return false;
|
|
|
|
const ps = this.mainSeries.priceScale();
|
|
if (!this._mainPriceVisibleRange) {
|
|
ps.setAutoScale(false);
|
|
this.mainSeries.applyOptions({ autoscaleInfoProvider: undefined });
|
|
this._mainPriceVisibleRange = { ...range };
|
|
}
|
|
|
|
const paneLocalY = anchorChartY != null
|
|
? Math.max(0, Math.min(main.height, anchorChartY - main.topY))
|
|
: main.height / 2;
|
|
const rawAnchor = this.mainSeries.coordinateToPrice(paneLocalY);
|
|
const anchorPrice = (rawAnchor != null && Number.isFinite(rawAnchor))
|
|
? rawAnchor
|
|
: (range.from + range.to) / 2;
|
|
|
|
const factor = Math.exp(deltaY * 0.0012);
|
|
const newSpan = Math.max(span * 1e-6, span * factor);
|
|
const minSpan = Math.max(Math.abs(anchorPrice) * 1e-6, 1e-8);
|
|
const clampedSpan = Math.max(minSpan, newSpan);
|
|
|
|
const ratio = Math.max(0, Math.min(1, (anchorPrice - range.from) / span));
|
|
const next = {
|
|
from: anchorPrice - ratio * clampedSpan,
|
|
to: anchorPrice + (1 - ratio) * clampedSpan,
|
|
};
|
|
|
|
this._mainPriceVisibleRange = next;
|
|
try {
|
|
ps.setVisibleRange(next);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 마우스 드래그(px)만큼 차트를 패닝합니다.
|
|
* - deltaX: 시간축 이동 — 모든 pane(캔들·보조지표) 동시 이동
|
|
* - deltaY: 캔들 플롯 영역 — 가격 범위 이동(그래프 상하 패닝, 축 확대/축소 아님)
|
|
*/
|
|
panByPixelDelta(
|
|
deltaX: number,
|
|
deltaY: number,
|
|
options?: { allowVerticalPan?: boolean },
|
|
): void {
|
|
if (deltaX !== 0) {
|
|
const ts = this.chart.timeScale();
|
|
const lr = ts.getVisibleLogicalRange();
|
|
if (lr) {
|
|
const w = Math.max(1, ts.width());
|
|
const span = lr.to - lr.from;
|
|
const dLog = (deltaX / w) * span;
|
|
this._setVisibleLogicalRange({ from: lr.from - dLog, to: lr.to - dLog });
|
|
}
|
|
}
|
|
|
|
const allowVertical = options?.allowVerticalPan !== false;
|
|
if (deltaY !== 0 && allowVertical) {
|
|
this._panMainPriceVertical(deltaY);
|
|
}
|
|
}
|
|
|
|
/** 캔들 pane 수동 가격 범위 (세로 패닝용) */
|
|
private _mainPriceVisibleRange: { from: number; to: number } | null = null;
|
|
|
|
private _readMainPriceRange(): { from: number; to: number } | null {
|
|
if (!this.mainSeries) return null;
|
|
const ps = this.mainSeries.priceScale();
|
|
const existing = ps.getVisibleRange();
|
|
if (existing) return { from: existing.from, to: existing.to };
|
|
const main = this.getPaneLayouts().find(l => l.paneIndex === 0);
|
|
if (!main || main.height <= 0) return null;
|
|
const pTop = this.mainSeries.coordinateToPrice(main.topY);
|
|
const pBot = this.mainSeries.coordinateToPrice(main.topY + main.height);
|
|
if (pTop === null || pBot === null) return null;
|
|
return { from: Math.min(pTop, pBot), to: Math.max(pTop, pBot) };
|
|
}
|
|
|
|
private _panMainPriceVertical(deltaY: number): void {
|
|
if (!this.mainSeries) return;
|
|
const main = this.getPaneLayouts().find(l => l.paneIndex === 0);
|
|
if (!main || main.height <= 0) return;
|
|
|
|
let range = this._mainPriceVisibleRange ?? this._readMainPriceRange();
|
|
if (!range) return;
|
|
|
|
const ps = this.mainSeries.priceScale();
|
|
if (!this._mainPriceVisibleRange) {
|
|
ps.setAutoScale(false);
|
|
this.mainSeries.applyOptions({ autoscaleInfoProvider: undefined });
|
|
this._mainPriceVisibleRange = { ...range };
|
|
}
|
|
|
|
const span = range.to - range.from;
|
|
const shift = (deltaY / main.height) * span;
|
|
const next = { from: range.from + shift, to: range.to + shift };
|
|
this._mainPriceVisibleRange = next;
|
|
try {
|
|
ps.setVisibleRange(next);
|
|
} catch { /* 범위 미설정 등 */ }
|
|
}
|
|
|
|
private _resetMainPricePan(): void {
|
|
this._mainPriceVisibleRange = null;
|
|
if (!this.mainSeries) return;
|
|
this.mainSeries.applyOptions({ autoscaleInfoProvider: undefined });
|
|
try {
|
|
this.mainSeries.priceScale().setAutoScale(true);
|
|
} catch { /* ok */ }
|
|
}
|
|
|
|
// ── 멀티차트 동기화 헬퍼 ────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Crosshair sync: 특정 time 위치에 크로스헤어를 표시한다.
|
|
* - rawBars 에서 해당 time 의 bar 를 찾아 mid 가격에 수평선을 맞춤
|
|
* - 데이터가 없으면 no-op
|
|
*/
|
|
setCrosshairAtTime(time: number): void {
|
|
if (!this.mainSeries) return;
|
|
const bar = this.rawBars?.find(b => (b.time as number) === time);
|
|
if (!bar) return;
|
|
const mid = (bar.high + bar.low) / 2;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
this.chart.setCrosshairPosition(mid, time as Time, this.mainSeries as ISeriesApi<any>);
|
|
}
|
|
|
|
/** Crosshair sync: 크로스헤어 제거 */
|
|
clearCrosshairPosition(): void {
|
|
this.chart.clearCrosshairPosition();
|
|
}
|
|
|
|
/** Time sync: 현재 가시 시간 범위(unix sec) 반환 */
|
|
getVisibleTimeRange(): { from: number; to: number } | null {
|
|
const r = this.chart.timeScale().getVisibleRange();
|
|
if (!r) return null;
|
|
return { from: r.from as number, to: r.to as number };
|
|
}
|
|
|
|
/** Date range sync: 현재 가시 논리 범위(bar index) 반환 */
|
|
getVisibleLogicalRange(): { from: number; to: number } | null {
|
|
return this.chart.timeScale().getVisibleLogicalRange();
|
|
}
|
|
|
|
/**
|
|
* Time sync: 가시 시간 범위 외부 적용.
|
|
* rawBars 가 비어 있거나 LWC 내부 시간 맵이 아직 구축되지 않은 경우
|
|
* (멀티차트 전환 직후 데이터 로드 전 등) setVisibleRange 가 "Value is null" 을
|
|
* throw 하므로 안전하게 무시한다.
|
|
*/
|
|
applyVisibleTimeRange(from: number, to: number): void {
|
|
if (this.rawBars.length === 0) return;
|
|
try {
|
|
this.chart.timeScale().setVisibleRange({ from: from as Time, to: to as Time });
|
|
this._notifyViewport();
|
|
} catch { /* 데이터 로딩 전 호출 무시 */ }
|
|
}
|
|
|
|
/**
|
|
* Date range sync: 가시 논리 범위 외부 적용.
|
|
* 데이터 로드 전 호출 시 LWC 가 throw 하므로 안전하게 무시한다.
|
|
*/
|
|
applyVisibleLogicalRange(from: number, to: number): void {
|
|
if (this.rawBars.length === 0) return;
|
|
this._setVisibleLogicalRange({ from, to });
|
|
}
|
|
|
|
/** Time sync: 가시 시간 범위 변화 구독. unsubscribe fn 반환 */
|
|
subscribeVisibleTimeRange(
|
|
cb: (range: { from: number; to: number } | null) => void,
|
|
): () => void {
|
|
const handler = () => cb(this.getVisibleTimeRange());
|
|
this.chart.timeScale().subscribeVisibleTimeRangeChange(handler);
|
|
return () => {
|
|
try { this.chart.timeScale().unsubscribeVisibleTimeRangeChange(handler); } catch { /* ok */ }
|
|
};
|
|
}
|
|
|
|
/** Date range sync: 가시 논리 범위 변화 구독. unsubscribe fn 반환 */
|
|
subscribeVisibleLogicalRange(
|
|
cb: (range: { from: number; to: number } | null) => void,
|
|
): () => void {
|
|
const handler = (lr: LogicalRange | null) => cb(lr ?? null);
|
|
this.chart.timeScale().subscribeVisibleLogicalRangeChange(handler);
|
|
return () => {
|
|
try { this.chart.timeScale().unsubscribeVisibleLogicalRangeChange(handler); } catch { /* ok */ }
|
|
};
|
|
}
|
|
|
|
/**
|
|
* LWC 공식 데모의 "Go to realtime" 기능
|
|
* 사용자가 과거 데이터를 탐색하다가 최신 캔들로 즉시 이동할 때 호출
|
|
* (ref: https://tradingview.github.io/lightweight-charts/tutorials/demos/realtime-updates)
|
|
*/
|
|
scrollToRealTime(): void { this.chart.timeScale().scrollToRealTime(); }
|
|
|
|
/** 줌 툴: 두 X 픽셀 위치(canvas 내부)를 시간 범위로 변환하여 확대 */
|
|
zoomToXRange(x0: number, x1: number): void {
|
|
const t0 = this.xToTime(Math.min(x0, x1));
|
|
const t1 = this.xToTime(Math.max(x0, x1));
|
|
if (t0 === null || t1 === null || t0 >= t1) return;
|
|
try {
|
|
this.chart.timeScale().setVisibleRange({ from: t0 as import('lightweight-charts').Time, to: t1 as import('lightweight-charts').Time });
|
|
this._notifyViewport();
|
|
} catch { /* ok */ }
|
|
}
|
|
|
|
/**
|
|
* 연속된 바의 중앙값 간격(초)을 계산합니다.
|
|
* 일목균형표 미래 확장 시 사용.
|
|
*/
|
|
private _barIntervalSecs(): number {
|
|
const bars = this.rawBars;
|
|
if (!bars || bars.length < 2) return 60;
|
|
const intervals: number[] = [];
|
|
for (let i = Math.max(0, bars.length - 20); i < bars.length - 1; i++) {
|
|
intervals.push((bars[i + 1].time as number) - (bars[i].time as number));
|
|
}
|
|
intervals.sort((a, b) => a - b);
|
|
return intervals[Math.floor(intervals.length / 2)] ?? 60;
|
|
}
|
|
|
|
/**
|
|
* 워밍업 바를 제외한 "표시용 마지막 displayCount 바" 가 화면에 꽉 차도록
|
|
* visibleRange를 설정합니다.
|
|
*
|
|
* - rawBars 전체는 차트에 등록되어 있어 왼쪽으로 스크롤 가능
|
|
* - 초기 뷰에서는 워밍업 구간을 숨기고, 지표가 완전히 그려진 영역만 표시
|
|
* - extendFutureBars > 0 이면 우측을 그만큼 미래로 확장 (일목균형표 선행스팬용)
|
|
*
|
|
* @param displayCount 표시할 최신 바 개수 (예: 200)
|
|
* @param extendFutureBars 마지막 바 이후 추가로 보여줄 바 수 (기본 0)
|
|
*/
|
|
setInitialVisibleRange(displayCount: number, extendFutureBars = 0): void {
|
|
this._applyDefaultVisibleRange(displayCount, extendFutureBars);
|
|
}
|
|
|
|
/** 일목균형표가 활성화되어 있는지 여부 */
|
|
hasIchimoku(): boolean {
|
|
for (const [, entry] of this.indicators) {
|
|
if (entry.type === 'IchimokuCloud') return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** 메인 시리즈가 데이터와 함께 초기화되었는지 확인 */
|
|
hasMainSeries(): boolean {
|
|
return this.mainSeries !== null && this.rawBars.length > 0;
|
|
}
|
|
|
|
/** 로드된 보조지표 수 (종목 전환 후 캔들만 그려진 상태 감지용) */
|
|
getIndicatorCount(): number {
|
|
return this.indicators.size;
|
|
}
|
|
|
|
/** 실제 시리즈·구름이 붙은 보조지표 수 (부분 로드·중단 감지용) */
|
|
getLoadedIndicatorCount(): number {
|
|
return this.getLoadedIndicatorIds().size;
|
|
}
|
|
|
|
/** 차트에 실제로 그려진 보조지표 id (부분 로드·증분 복구용) */
|
|
getLoadedIndicatorIds(): Set<string> {
|
|
const ids = new Set<string>();
|
|
for (const entry of this.indicators.values()) {
|
|
if (entry.config?.hidden === true) continue;
|
|
if (entry.cloudPlugin || entry.seriesList.length > 0) ids.add(entry.id);
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
/** 현재 로드된 rawBars 개수 반환 (데이터 로드 여부 확인용) */
|
|
getRawBarsLength(): number {
|
|
return this.rawBars.length;
|
|
}
|
|
|
|
/** 현재 차트에 로드된 캔들 스냅샷 (과거 추가 로드·백테스트 재계산용) */
|
|
getRawBars(): OHLCVBar[] {
|
|
return this.rawBars.slice();
|
|
}
|
|
|
|
/** 두 시각(초) 사이에 포함된 봉 개수 — 기간(날짜 범위) 측정 라벨용 */
|
|
countBarsBetweenTime(t0: number, t1: number): number {
|
|
const lo = Math.min(t0, t1);
|
|
const hi = Math.max(t0, t1);
|
|
let n = 0;
|
|
for (const b of this.rawBars) {
|
|
if (b.time >= lo && b.time <= hi) n++;
|
|
}
|
|
return n;
|
|
}
|
|
|
|
/** 가장 오래된 bar 의 Unix 타임스탬프(초) 반환. 데이터 없으면 null. */
|
|
getOldestBarTime(): number | null {
|
|
return this.rawBars.length > 0 ? this.rawBars[0].time as number : null;
|
|
}
|
|
|
|
/**
|
|
* 더 오래된 bar 를 앞에 추가하고 시리즈·보조지표를 갱신합니다.
|
|
*
|
|
* - 이미 존재하는 시각보다 오래된 bar 만 실제로 추가합니다.
|
|
* - 현재 가시 범위(logical range)를 추가된 bar 수만큼 우측으로 이동시켜
|
|
* 사용자가 보고 있던 구간이 유지되도록 합니다.
|
|
* - 모든 보조지표를 전체 데이터 기준으로 재계산합니다.
|
|
*/
|
|
async prependBars(olderBars: OHLCVBar[]): Promise<number> {
|
|
if (!this.mainSeries || olderBars.length === 0) return 0;
|
|
|
|
const firstExistingTime = this.rawBars[0]?.time as number | undefined;
|
|
const newBars = firstExistingTime !== undefined
|
|
? olderBars.filter(b => (b.time as number) < firstExistingTime)
|
|
: olderBars;
|
|
|
|
if (newBars.length === 0) return 0;
|
|
|
|
// 현재 가시 논리 범위 저장 (prepend 후 복원용)
|
|
const logicalRange = this.chart.timeScale().getVisibleLogicalRange();
|
|
|
|
this.rawBars = [...newBars, ...this.rawBars];
|
|
|
|
const t = getTheme(this.currentTheme);
|
|
const ct = this.currentChartType;
|
|
|
|
// 메인 시리즈 전체 데이터 갱신
|
|
if (ct === 'line' || ct === 'area' || ct === 'baseline') {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(this.mainSeries as ISeriesApi<any>).setData(
|
|
this.rawBars.map(b => ({ time: b.time as Time, value: b.close }))
|
|
);
|
|
} else {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(this.mainSeries as ISeriesApi<any>).setData(
|
|
this.rawBars.map(b => ({
|
|
time: b.time as Time,
|
|
open: b.open, high: b.high, low: b.low, close: b.close,
|
|
}))
|
|
);
|
|
}
|
|
|
|
// 볼륨 시리즈 전체 데이터 갱신
|
|
this.volumeSeries?.setData(
|
|
this.rawBars.map(b => ({
|
|
time: b.time as Time,
|
|
value: b.volume,
|
|
color: b.close >= b.open ? `${t.upColor}88` : `${t.downColor}88`,
|
|
}))
|
|
);
|
|
|
|
// 가시 논리 범위 복원: 앞에 추가된 bar 수만큼 오른쪽으로 이동
|
|
if (logicalRange) {
|
|
this._setVisibleLogicalRange({
|
|
from: logicalRange.from + newBars.length,
|
|
to: logicalRange.to + newBars.length,
|
|
});
|
|
}
|
|
|
|
// 보조지표 전체 재계산 (새 데이터가 왼쪽에 추가됐으므로 전체 갱신 필요)
|
|
await this._refreshAllIndicatorsData();
|
|
return newBars.length;
|
|
}
|
|
|
|
/** 모든 보조지표를 현재 rawBars 기준으로 전체 재계산하고 시리즈 데이터를 교체합니다. */
|
|
private async _refreshAllIndicatorsData(): Promise<void> {
|
|
if (this.rawBars.length === 0) return;
|
|
const bars = this.rawBars.slice();
|
|
|
|
// 패턴 마커 초기화 후 재구성
|
|
this.patternMarkers = [];
|
|
|
|
for (const [, entry] of this.indicators.entries()) {
|
|
if (!entry.config) continue;
|
|
try {
|
|
const result = await calculateIndicator(entry.config.type, bars, entry.config.params ?? {});
|
|
const { plots } = result;
|
|
|
|
// 패턴 마커 지표 (seriesList 가 비어 있음)
|
|
if (entry.seriesList.length === 0) {
|
|
if (result.markers?.length) {
|
|
this.patternMarkers.push({ id: entry.id, markers: result.markers });
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// 일목균형표: 미래 프로젝션 포함 특별 처리
|
|
if (entry.type === 'IchimokuCloud') {
|
|
this._refreshIchimokuSeriesData(entry, plots);
|
|
continue;
|
|
}
|
|
|
|
// 일반 지표: 각 시리즈에 전체 데이터 setData
|
|
for (let i = 0; i < entry.seriesList.length; i++) {
|
|
const series = entry.seriesList[i];
|
|
const meta = entry.seriesMeta[i];
|
|
if (!meta) continue;
|
|
const plotData = plots[meta.plotId] as PlotData | undefined;
|
|
if (!plotData?.length) continue;
|
|
const filtered = plotData.filter(p => p.value !== null && !isNaN(p.value));
|
|
if (filtered.length === 0) continue;
|
|
|
|
if (meta.isHistogram) {
|
|
const plotDef = (entry.config?.plots ?? getIndicatorDef(entry.type)?.plots ?? [])
|
|
.find(p => p.id === meta.plotId) ?? { id: meta.plotId, title: '', color: meta.color, type: 'histogram' as const };
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(series as ISeriesApi<any>).setData(mapHistogramSeriesData(filtered, plotDef));
|
|
} else {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(series as ISeriesApi<any>).setData(
|
|
filtered.map(p => ({ time: p.time as Time, value: p.value }))
|
|
);
|
|
}
|
|
}
|
|
if (entry.type === 'BollingerBands') {
|
|
entry.bbFillPrimitive?.requestRefresh();
|
|
}
|
|
} catch { /* 계산 실패 무시 */ }
|
|
}
|
|
|
|
this._reapplyAllPatternMarkers();
|
|
}
|
|
|
|
/**
|
|
* 일목균형표 시리즈 데이터를 미래 프로젝션 포함하여 전체 갱신합니다.
|
|
* (prependBars / _refreshAllIndicatorsData 전용)
|
|
*/
|
|
private _refreshIchimokuSeriesData(
|
|
entry: IndicatorEntry,
|
|
plots: Record<string, PlotData>,
|
|
): void {
|
|
const { senkou: displacement } = resolveIchimokuDisplacements(entry.config?.params);
|
|
const laggingPeriod = (entry.config?.params?.laggingSpan2Periods as number) ?? 52;
|
|
const convPlot = (plots['plot0'] as PlotData) ?? [];
|
|
const basePlot = (plots['plot1'] as PlotData) ?? [];
|
|
|
|
for (let i = 0; i < entry.seriesList.length; i++) {
|
|
const series = entry.seriesList[i];
|
|
const meta = entry.seriesMeta[i];
|
|
if (!meta) continue;
|
|
const plotData = (plots[meta.plotId] as PlotData) ?? [];
|
|
const filtered = plotData.filter(p => p.value !== null && !isNaN(p.value));
|
|
|
|
let seriesData: Array<{ time: Time; value: number }> =
|
|
filtered.map(p => ({ time: p.time as Time, value: p.value }));
|
|
|
|
// 선행스팬A·B: 미래 26봉 프로젝션 추가
|
|
if (meta.plotId === 'plot3') {
|
|
seriesData = seriesData.concat(this._ichimokuFutureSpanA(convPlot, basePlot, displacement));
|
|
} else if (meta.plotId === 'plot4') {
|
|
seriesData = seriesData.concat(this._ichimokuFutureSpanB(displacement, laggingPeriod));
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(series as ISeriesApi<any>).setData(seriesData);
|
|
}
|
|
|
|
if (entry.cloudPlugin && entry.config) {
|
|
this._applyIchimokuCloudPlugin(entry.cloudPlugin, plots, entry.config);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 특정 time(Unix 초)에 해당하는 bar를 rawBars에서 반환합니다.
|
|
* 실시간 tick/appendBar 로 갱신된 최신 OHLCV 값을 포함합니다.
|
|
* 크로스헤어 레전드 표시 등 외부에서 최신 bar 데이터가 필요할 때 사용합니다.
|
|
*/
|
|
getBarAtTime(time: number): OHLCVBar | undefined {
|
|
return this.rawBars.find(b => (b.time as number) === time);
|
|
}
|
|
setLogScale(enabled: boolean): void {
|
|
this.mainSeries?.applyOptions({ priceScale: { mode: enabled ? 1 : 0 } } as Parameters<ISeriesApi<SeriesType>['applyOptions']>[0]);
|
|
}
|
|
|
|
/** 퍼센트(%) 스케일 토글 — 가격 대신 % 변동률로 Y축 표시 */
|
|
setPercentScale(enabled: boolean): void {
|
|
// PriceScaleMode: 0=Normal, 1=Logarithmic, 2=Percentage, 3=IndexedTo100
|
|
this.mainSeries?.priceScale().applyOptions({ mode: enabled ? 2 : 0 });
|
|
}
|
|
|
|
/** 차트 그리드 라인 표시/숨김 */
|
|
setGrid(enabled: boolean): void {
|
|
this.chart.applyOptions({
|
|
grid: {
|
|
vertLines: { color: enabled ? getTheme(this.currentTheme).gridColor : 'transparent' },
|
|
horzLines: { color: enabled ? getTheme(this.currentTheme).gridColor : 'transparent' },
|
|
},
|
|
});
|
|
}
|
|
|
|
/** 가격 스케일 자동(auto) 맞춤 — fitContent와 달리 시간축은 변경하지 않음 */
|
|
autoScale(): void {
|
|
this._resetMainPricePan();
|
|
this.mainSeries?.priceScale().setAutoScale(true);
|
|
}
|
|
|
|
/**
|
|
* 느린 네트워크·레이아웃 지연 후 가격축이 0~수억처럼 비정상인지 감지.
|
|
* (마지막 종가가 가시 범위 밖이거나, 범위 하한이 0 이하인 경우)
|
|
*/
|
|
isMainPriceScaleLikelyCorrupt(): boolean {
|
|
if (!this.mainSeries || this.rawBars.length < 2) return false;
|
|
const lastClose = this.rawBars[this.rawBars.length - 1].close;
|
|
if (!Number.isFinite(lastClose) || lastClose <= 0) return false;
|
|
|
|
const main = this.getPaneLayouts().find(l => l.paneIndex === 0);
|
|
if (!main || main.height < 24) return true;
|
|
|
|
try {
|
|
const topPrice = this.mainSeries.coordinateToPrice(4);
|
|
const bottomPrice = this.mainSeries.coordinateToPrice(main.height - 4);
|
|
if (topPrice == null || bottomPrice == null) return false;
|
|
const lo = Math.min(topPrice, bottomPrice);
|
|
const hi = Math.max(topPrice, bottomPrice);
|
|
if (lo <= 0) return true;
|
|
if (lastClose < lo * 0.5 || lastClose > hi * 2) return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* 크로스헤어 파라미터에서 보조지표 시리즈 값 추출
|
|
* { indicatorType → [plot0 값, plot1 값, ...] }
|
|
*/
|
|
getIndicatorValuesFromParams(params: MouseEventParams<Time>): Record<string, number[]> {
|
|
const result: Record<string, number[]> = {};
|
|
for (const [, entry] of this.indicators) {
|
|
const values: number[] = [];
|
|
for (const series of entry.seriesList) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const d = params.seriesData.get(series) as any;
|
|
const v = d != null
|
|
? (typeof d.value === 'number' ? d.value : d.close ?? NaN)
|
|
: NaN;
|
|
values.push(v);
|
|
}
|
|
if (values.some(v => !isNaN(v))) {
|
|
result[entry.type] = values;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
resize(w: number, h: number): void { this.chart.resize(w, h); }
|
|
|
|
// ─── Compare overlay ──────────────────────────────────────────────────────
|
|
addCompareSeries(symbol: string, bars: OHLCVBar[], color: string): void {
|
|
if (this.compareSeriesMap.has(symbol)) return;
|
|
const s = this.chart.addSeries(LineSeries, { color, lineWidth: 2, priceLineVisible: false });
|
|
const base = bars[0]?.close ?? 1;
|
|
s.setData(bars.map(b => ({ time: b.time as Time, value: (b.close / base - 1) * 100 })));
|
|
this.compareSeriesMap.set(symbol, s);
|
|
}
|
|
|
|
removeCompareSeries(symbol: string): void {
|
|
const s = this.compareSeriesMap.get(symbol);
|
|
if (s) { try { this.chart.removeSeries(s); } catch { /* ok */ } this.compareSeriesMap.delete(symbol); }
|
|
}
|
|
|
|
// ─── Coordinate conversion (for drawing canvas) ───────────────────────────
|
|
timeToX(time: number): number | null {
|
|
const coord = this.chart.timeScale().timeToCoordinate(time as Time);
|
|
return coord === null ? null : coord;
|
|
}
|
|
|
|
priceToY(price: number): number | null {
|
|
const coord = this.mainSeries?.priceToCoordinate(price);
|
|
return coord === null || coord === undefined ? null : coord;
|
|
}
|
|
|
|
xToTime(x: number): number | null {
|
|
const t = this.chart.timeScale().coordinateToTime(x);
|
|
return t === null ? null : (t as number);
|
|
}
|
|
|
|
yToPrice(y: number): number | null {
|
|
const p = this.mainSeries?.coordinateToPrice(y);
|
|
return p === null || p === undefined ? null : p;
|
|
}
|
|
|
|
/**
|
|
* 우클릭 매수·매도: 캔들 pane(0) 클릭 좌표 → 해당 Y축 가격.
|
|
* 가격축·시간축·거래량·보조지표 pane 클릭은 null.
|
|
*/
|
|
getTradePriceAtChartPoint(chartX: number, chartY: number): number | null {
|
|
if (!this.mainSeries) return null;
|
|
if (this.isOnPriceAxis(chartX, chartY) || this.isOnTimeAxis(chartY)) return null;
|
|
if (this.getPaneIndexAtChartY(chartY) !== 0) return null;
|
|
const layout = this.getPaneLayouts().find(l => l.paneIndex === 0);
|
|
if (!layout) return null;
|
|
const paneLocalY = chartY - layout.topY;
|
|
const p = this.mainSeries.coordinateToPrice(paneLocalY);
|
|
if (p == null || !Number.isFinite(p) || p <= 0) return null;
|
|
return p;
|
|
}
|
|
|
|
/** 가시 시간 범위 변경 구독 (LWC 네이티브 스크롤 + 커스텀 패닝). unsubscribe fn 반환 */
|
|
subscribeViewport(cb: () => void): () => void {
|
|
this._viewportListeners.add(cb);
|
|
this._hookViewportLwc();
|
|
return () => {
|
|
this._viewportListeners.delete(cb);
|
|
if (this._viewportListeners.size === 0) this._unhookViewportLwc();
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Pane 높이를 아래 규칙에 따라 재배분하고 **필요한 총 높이**를 반환합니다.
|
|
*
|
|
* ┌────────────────────────────────────────────────────────────┐
|
|
* │ 보조지표 N개 │ 캔들 차트 │ 보조지표 영역 │
|
|
* ├────────────────┼───────────────┼────────────────────────────┤
|
|
* │ 0 │ H - 60 │ - │
|
|
* │ 1 ~ 3 │ H/2 │ H/2 를 N 등분 │
|
|
* │ 4+ │ max(H/3, min) │ 나머지를 N 등분 │
|
|
* │ (화면 초과 시) │ ↑ 고정 │ 각 80px 최소 → 스크롤 │
|
|
* └────────────────────────────────────────────────────────────┘
|
|
*
|
|
* - 거래량 pane (index 1): 항상 60px 고정
|
|
* - 캔들 차트 최소 높이: H / 3 (절대 그 이하로 줄지 않음)
|
|
* - 반환값 > 현재 컨테이너 높이 → 호출자가 스크롤 컨테이너 확장 필요
|
|
*/
|
|
/**
|
|
* Pane 높이를 **setStretchFactor** 기반으로 재배분합니다.
|
|
*
|
|
* setHeight() 는 내부적으로 현재 LWC 렌더링 높이 기준의 stretch factor 로 변환되어
|
|
* 시간봉 변경 등 리렌더링 직후에는 비율이 틀어지는 문제가 있습니다.
|
|
* setStretchFactor(0~1) 는 차트 전체 높이를 1 로 놓은 절대 비율이므로 항상 정확합니다.
|
|
*
|
|
* ┌────────────────┬──────────────────┬─────────────────────────────┐
|
|
* │ 보조지표 N 개 │ 캔들 비율 │ 보조지표 비율 │
|
|
* ├────────────────┼──────────────────┼─────────────────────────────┤
|
|
* │ 0 │ 1 - vol_frac │ - │
|
|
* │ 1 ~ 3 │ 0.5 │ (0.5 - vol_frac) / N 각각 │
|
|
* │ 4 + │ 최소 1/3 │ 나머지 균등 │
|
|
* └────────────────┴──────────────────┴─────────────────────────────┘
|
|
*
|
|
* @param availableHeight 외부에서 측정한 실제 가용 높이(px). 스크롤 필요 여부 판단에만 사용.
|
|
*/
|
|
/**
|
|
* 캔들 영역 vs 보조지표 영역 높이 비율 (예: 보조 1개 → 2:1, 2~5개 → 1:1, 6+ → 1:2)
|
|
* auxWeight 가 0 이면 비율 해제.
|
|
*/
|
|
setPaneAreaRatio(candleWeight: number, auxWeight: number): void {
|
|
if (auxWeight <= 0) {
|
|
this._paneAreaRatio = null;
|
|
} else {
|
|
this._paneAreaRatio = {
|
|
candle: Math.max(0.1, candleWeight),
|
|
aux: Math.max(0.1, auxWeight),
|
|
};
|
|
}
|
|
}
|
|
|
|
private _volumeFrac(H: number): number {
|
|
if (!this._volumeVisible || this._candleOnlyLayout) return 0;
|
|
return Math.min(Math.max(60 / H, 0.04), 0.12);
|
|
}
|
|
|
|
private _applyPaneStretchFactors(mainFrac: number, availableHeight?: number): number {
|
|
const panes = this.chart.panes();
|
|
if (panes.length === 0) return availableHeight ?? this.container.clientHeight;
|
|
|
|
const activeIndPanes = this._activeIndicatorPaneIndices();
|
|
const N = activeIndPanes.size;
|
|
const H = (availableHeight && availableHeight > 0)
|
|
? availableHeight
|
|
: (this._lastLayoutAvailableHeight && this._lastLayoutAvailableHeight > 0
|
|
? this._lastLayoutAvailableHeight
|
|
: this.container.clientHeight);
|
|
|
|
const volumeShown = this._volumeVisible && !this._candleOnlyLayout;
|
|
const VOL_FRAC = this._volumeFrac(H);
|
|
const MIN_IND_PX = this._minIndPx(H);
|
|
const ORPHAN_STRETCH = 0.0001;
|
|
/** 보조지표 pane 은 동일 stretch weight → 항상 같은 높이 */
|
|
const IND_EQUAL_WEIGHT = 1;
|
|
|
|
const remaining = Math.max(0, 1 - mainFrac - (volumeShown ? VOL_FRAC : 0));
|
|
const eachIndFrac = N > 0 ? remaining / N : 0;
|
|
const naturalIndPx = eachIndFrac * H;
|
|
const actualIndFrac = naturalIndPx >= MIN_IND_PX
|
|
? eachIndFrac
|
|
: MIN_IND_PX / H;
|
|
|
|
const indWeight = IND_EQUAL_WEIGHT;
|
|
const indUnit = Math.max(actualIndFrac, 1e-9);
|
|
const mainWeight = N > 0 ? mainFrac / indUnit : mainFrac;
|
|
const volWeight = volumeShown
|
|
? (N > 0 ? VOL_FRAC / indUnit : VOL_FRAC)
|
|
: ORPHAN_STRETCH;
|
|
|
|
for (let i = 0; i < panes.length; i++) {
|
|
const paneIndex = panes[i].paneIndex();
|
|
if (paneIndex === 0) {
|
|
panes[i].setStretchFactor(mainWeight);
|
|
} else if (paneIndex === 1) {
|
|
panes[i].setStretchFactor(volumeShown ? volWeight : ORPHAN_STRETCH);
|
|
} else if (activeIndPanes.has(paneIndex)) {
|
|
panes[i].setStretchFactor(indWeight);
|
|
} else {
|
|
panes[i].setStretchFactor(ORPHAN_STRETCH);
|
|
}
|
|
}
|
|
|
|
const totalRequired = Math.round(
|
|
mainFrac * H + (volumeShown ? VOL_FRAC * H : 0) + N * Math.max(naturalIndPx, MIN_IND_PX),
|
|
);
|
|
this._notifyPaneLayout();
|
|
return totalRequired;
|
|
}
|
|
|
|
private _minIndPx(H: number): number {
|
|
if (this._compactPaneLayout) {
|
|
return Math.min(56, Math.max(28, Math.round(H * 0.2)));
|
|
}
|
|
return 80;
|
|
}
|
|
|
|
private _paneHeightFractions(
|
|
indicatorPaneCount: number,
|
|
volFrac: number,
|
|
availableHeight?: number,
|
|
): { mainFrac: number; eachIndFrac: number } {
|
|
const N = indicatorPaneCount;
|
|
if (N === 0) {
|
|
return { mainFrac: 1 - volFrac, eachIndFrac: 0 };
|
|
}
|
|
if (this._compactPaneLayout && N === 1 && availableHeight != null && availableHeight < 400) {
|
|
const mainFrac = 0.52;
|
|
return { mainFrac, eachIndFrac: Math.max(0.28, 1 - mainFrac - volFrac) };
|
|
}
|
|
if (N <= 3) {
|
|
return { mainFrac: 0.5, eachIndFrac: (0.5 - volFrac) / N };
|
|
}
|
|
const mainFrac = Math.max(1 / 3, volFrac + 0.05);
|
|
return { mainFrac, eachIndFrac: (1 - mainFrac - volFrac) / N };
|
|
}
|
|
|
|
/** 캔들 pane 전체보기 — pane 0 만 사용, 하단 pane 접음 */
|
|
private _resetPaneHeightsCandleFullscreen(availableHeight?: number): number {
|
|
const panes = this.chart.panes();
|
|
if (panes.length === 0) return availableHeight ?? this.container.clientHeight;
|
|
|
|
const ORPHAN_STRETCH = 0.0001;
|
|
const H = (availableHeight && availableHeight > 0)
|
|
? availableHeight
|
|
: this.container.clientHeight;
|
|
|
|
for (let i = 0; i < panes.length; i++) {
|
|
panes[i].setStretchFactor(i === 0 ? 1 : ORPHAN_STRETCH);
|
|
}
|
|
|
|
this._hideAllSubPaneIndicators();
|
|
|
|
return H;
|
|
}
|
|
|
|
resetPaneHeights(availableHeight?: number): number {
|
|
const panes = this.chart.panes();
|
|
if (panes.length === 0) return availableHeight ?? this.container.clientHeight;
|
|
|
|
if (availableHeight != null && availableHeight > 0) {
|
|
this._lastLayoutAvailableHeight = availableHeight;
|
|
}
|
|
|
|
if (this._candleOnlyLayout) {
|
|
return this._resetPaneHeightsCandleFullscreen(availableHeight);
|
|
}
|
|
|
|
const H = (availableHeight && availableHeight > 0)
|
|
? availableHeight
|
|
: (this._lastLayoutAvailableHeight && this._lastLayoutAvailableHeight > 0
|
|
? this._lastLayoutAvailableHeight
|
|
: this.container.clientHeight);
|
|
const N = this._activeIndicatorPaneIndices().size;
|
|
const volFrac = this._volumeFrac(H);
|
|
let mainFrac: number;
|
|
if (this._paneAreaRatio && N > 0) {
|
|
const total = this._paneAreaRatio.candle + this._paneAreaRatio.aux;
|
|
const candleGroupFrac = this._paneAreaRatio.candle / total;
|
|
mainFrac = Math.max(0.12, candleGroupFrac - volFrac);
|
|
} else {
|
|
mainFrac = this._paneHeightFractions(N, volFrac, H).mainFrac;
|
|
}
|
|
|
|
return this._applyPaneStretchFactors(mainFrac, availableHeight);
|
|
}
|
|
|
|
// ─── Diagnostic ───────────────────────────────────────────────────────────
|
|
/**
|
|
* 현재 rawBars 기준으로 모든 보조지표의 마지막 계산값을 콘솔에 출력합니다.
|
|
* 브라우저 콘솔에서 `chartManager.logIndicatorLastValues()` 로 호출하여
|
|
* 갱신 여부와 실제 값 변화를 수동으로 확인할 수 있습니다.
|
|
*
|
|
* 예: ADX 값이 두 번 호출 사이에 다른지 확인 → 값이 매우 조금씩 변하면
|
|
* 갱신 로직은 정상이며 Wilder's smoothing 의 수학적 특성으로 인해
|
|
* 변화가 작을 뿐임을 확인할 수 있습니다.
|
|
*/
|
|
async logIndicatorLastValues(): Promise<void> {
|
|
if (this.rawBars.length === 0) { console.warn('[ChartManager] rawBars empty'); return; }
|
|
const snapshot = this.rawBars.slice();
|
|
const lastBar = snapshot[snapshot.length - 1];
|
|
console.group(`[ChartManager] Indicator last values @ bar time=${new Date(lastBar.time * 1000).toISOString()}`);
|
|
console.log('Main bar:', lastBar);
|
|
for (const [, entry] of this.indicators.entries()) {
|
|
if (!entry.config || entry.seriesList.length === 0) continue;
|
|
try {
|
|
const { plots } = await calculateIndicator(entry.config.type, snapshot, entry.config.params ?? {});
|
|
const vals: Record<string, number | null> = {};
|
|
for (const meta of entry.seriesMeta) {
|
|
const pd = plots[meta.plotId] as PlotData | undefined;
|
|
vals[meta.plotId] = pd?.length ? (pd[pd.length - 1]?.value ?? null) : null;
|
|
}
|
|
console.log(` [${entry.config.type}]`, vals);
|
|
} catch (e) {
|
|
console.warn(` [${entry.config.type}] calc error:`, e);
|
|
}
|
|
}
|
|
console.groupEnd();
|
|
}
|
|
|
|
// ─── Destroy ─────────────────────────────────────────────────────────────
|
|
destroy(): void {
|
|
if (this._indUpdateTimer) { clearTimeout(this._indUpdateTimer); this._indUpdateTimer = null; }
|
|
if (this._clickHandler) this.chart.unsubscribeClick(this._clickHandler);
|
|
this.chart.remove();
|
|
}
|
|
}
|