Files
goldenChart/frontend/src/utils/ChartManager.ts
T
2026-05-28 00:36:49 +09:00

3205 lines
126 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 } from './dataGenerator';
import { formatLwcTime, formatLwcTickMark, 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: 0.001,
formatter: formatChartAxisPrice,
};
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 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;
/** 우측 가격축 라벨·plot 설명(전환선 등)·금액 색상 하이라이트 */
private _seriesPriceLabelsEnabled = true;
/** 차트 하단 거래량 pane 표시 */
private _volumeVisible = true;
/** pane 간 구분선 (설정 화면) */
private _paneSeparatorOptions: ChartPaneSeparatorOptions =
resolveChartPaneSeparatorOptions(null, 'dark');
/** applyPaneLayout / resetPaneHeights 에서 측정한 마지막 가용 높이(px) */
private _lastLayoutAvailableHeight?: number;
constructor(container: HTMLElement, theme: Theme) {
this.container = container;
const t = getTheme(theme);
this.chart = createChart(container, {
/* autoSize: true → LWC가 내부적으로 ResizeObserver를 사용해 컨테이너 크기 변경을 자동으로 처리.
멀티 레이아웃에서 초기 렌더 시 컨테이너가 0 크기일 때도 차트가 정상 표시됨. */
autoSize: true,
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, labelBackgroundColor: '#9598A1' },
horzLine: { color: t.crosshairColor, labelBackgroundColor: '#9598A1' },
},
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();
}
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,
});
}
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 {
return (time, tickMarkType) =>
formatLwcTickMark(
time as Time,
tickMarkType,
this.displayTimeframe,
this.displayTimezone,
);
}
private _applyDisplayTimezoneOptions(): void {
const tf = this.displayTimeframe;
const tz = this.displayTimezone;
const timeFormatter = (time: Time) =>
formatLwcTime(time as number | { year: number; month: number; day: number }, tf, tz);
this.chart.applyOptions({
localization: { timeFormatter, priceFormatter: formatChartAxisPrice },
timeScale: { tickMarkFormatter: this._tickMarkFormatter() },
});
}
setDisplayTimezone(tz: string, timeframe?: Timeframe): void {
this.displayTimezone = tz || DEFAULT_DISPLAY_TIMEZONE;
if (timeframe) this.displayTimeframe = timeframe;
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, labelBackgroundColor: '#9598A1' }, horzLine: { color: t.crosshairColor, labelBackgroundColor: '#9598A1' } },
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 });
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>;
if (plotDef.type === 'histogram') {
series = this.chart.addSeries(HistogramSeries, { color: plotDef.color }, 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);
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: this.seriesTitleForPlot(config, plotDef),
visible: isPlotVisible,
}, 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,
};
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._activeIndicatorPaneIndices().size > 0
) {
this.resetPaneHeights(this._lastLayoutAvailableHeight);
}
}
}
/** 여러 지표 추가 후 pane 레이아웃을 한 번만 갱신 */
async addIndicatorsBatch(configs: IndicatorConfig[]): Promise<void> {
for (const config of configs) {
await this.addIndicator(config, { skipLayout: true });
}
if (configs.length > 0 && this._activeIndicatorPaneIndices().size > 0) {
this.resetPaneHeights(this._lastLayoutAvailableHeight);
this._notifyPaneLayout();
}
}
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;
for (const ind of sortIndicatorsForPaneLoad(inds)) {
if (this._indicatorLoadStale(loadGen)) break;
await this.addIndicator(ind);
}
if (this._indicatorLoadStale(loadGen)) return;
// 빈 pane stretch 정리 + 캔들 pane 비율 복구
this.resetPaneHeights();
this._notifyPaneLayout();
}
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._trimTrailingEmptySubPanes();
this.resetPaneHeights(this._lastLayoutAvailableHeight);
this._notifyPaneLayout();
}
/** 파라미터 변경된 지표만 제거 후 재추가 (전체 보조지표 재로드 회피) */
async refreshIndicators(configs: IndicatorConfig[]): Promise<void> {
if (configs.length === 0) return;
let any = false;
for (const c of configs) {
if (this._detachIndicatorEntry(c.id)) any = true;
}
if (any) this._trimTrailingEmptySubPanes();
await this.addIndicatorsBatch(configs);
}
/** 메인 시리즈 마커 플러그인 해제 (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 ? '#26A69A' : '#EF5350'),
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 ? '#00BCD4' : '#FF9800', // 백테스팅과 구별: 청록/주황
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);
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 _getNextSubPane(): number {
const used = new Set(Array.from(this.indicators.values()).map(e => e.paneIndex).filter(Boolean));
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 */ }
}
}
/**
* 차트 컨테이너 기준 각 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: i, 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 series = entry.seriesList[0];
if (!series) return null;
try {
const el = series.getPane().getHTMLElement();
if (!el) return null;
const rect = el.getBoundingClientRect();
const containerRect = this.container.getBoundingClientRect();
const height = Math.round(rect.height);
if (height < 12) return null;
return {
topY: Math.round(rect.top - containerRect.top),
height,
};
} catch {
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);
}
/** 차트 설정: 보조지표 우측 가격축 라벨·설명 on/off */
setSeriesPriceLabelsEnabled(enabled: boolean): void {
this._seriesPriceLabelsEnabled = enabled;
for (const entry of this.indicators.values()) {
this.applyIndicatorStyle(entry.config);
}
}
getSeriesPriceLabelsEnabled(): boolean {
return this._seriesPriceLabelsEnabled;
}
/** 차트 설정: 거래량 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,
): boolean {
return this._seriesPriceLabelsEnabled
&& plotAllowsLastValue
&& config.lastValueVisible !== false;
}
private seriesTitleForPlot(
config: IndicatorConfig,
plot: PlotDef | undefined,
plotAllowsLastValue = true,
): string {
if (!this.shouldShowSeriesPriceLabel(config, plotAllowsLastValue)) 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;
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;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const opts: Record<string, unknown> = { visible };
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);
opts['lastValueVisible'] = showPriceLabel;
opts['title'] = this.seriesTitleForPlot(config, plot, plotAllows);
} 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바
ts.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;
ts.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;
ts.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;
ts.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.container.style.height = '';
this._resetPaneHeightsCandleFullscreen(availableHeight);
this._hideAllSubPaneIndicators();
} 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> {
const indices = new Set<number>();
for (const entry of this.indicators.values()) {
const p = entry.paneIndex;
if (p != null && p >= 2) indices.add(p);
}
return indices;
}
/** 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 만 제거 — 단일 지표 제거 시 나머지 pane 유지 */
private _trimTrailingEmptySubPanes(): void {
for (;;) {
const panes = this.chart.panes();
if (panes.length <= 2) return;
const last = panes.length - 1;
if (this._activeIndicatorPaneIndices().has(last)) 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);
try {
const ps = paneIndex === 1
? this.chart.priceScale('volume', 1)
: this.chart.priceScale('right', paneIndex);
const scaleW = ps.width();
if (scaleW <= 0) return false;
return chartX >= this.container.clientWidth - scaleW;
} catch {
return chartX >= this.container.clientWidth - 56;
}
}
/** 하단 시간축 영역 클릭 여부 */
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, this.container.clientWidth);
const span = lr.to - lr.from;
const dLog = (deltaX / w) * span;
try {
ts.setVisibleLogicalRange({ from: lr.from - dLog, to: lr.to - dLog });
} catch { /* 데이터 로드 전 */ }
}
}
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 });
} catch { /* 데이터 로딩 전 호출 무시 */ }
}
/**
* Date range sync: 가시 논리 범위 외부 적용.
* 데이터 로드 전 호출 시 LWC 가 throw 하므로 안전하게 무시한다.
*/
applyVisibleLogicalRange(from: number, to: number): void {
if (this.rawBars.length === 0) return;
try {
this.chart.timeScale().setVisibleLogicalRange({ from, to });
} catch { /* 데이터 로딩 전 호출 무시 */ }
}
/** 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;
this.chart.timeScale().setVisibleRange({ from: t0 as import('lightweight-charts').Time, to: t1 as import('lightweight-charts').Time });
}
/**
* 연속된 바의 중앙값 간격(초)을 계산합니다.
* 일목균형표 미래 확장 시 사용.
*/
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;
}
/** 두 시각(초) 사이에 포함된 봉 개수 — 기간(날짜 범위) 측정 라벨용 */
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<void> {
if (!this.mainSeries || olderBars.length === 0) return;
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;
// 현재 가시 논리 범위 저장 (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) {
try {
this.chart.timeScale().setVisibleLogicalRange({
from: logicalRange.from + newBars.length,
to: logicalRange.to + newBars.length,
});
} catch { /* 무시 */ }
}
// 보조지표 전체 재계산 (새 데이터가 왼쪽에 추가됐으므로 전체 갱신 필요)
await this._refreshAllIndicatorsData();
}
/** 모든 보조지표를 현재 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);
}
/**
* 크로스헤어 파라미터에서 보조지표 시리즈 값 추출
* { 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;
}
/** Subscribe to visible range changes (time scale + price scale). Returns unsubscribe fn. */
subscribeViewport(cb: () => void): () => void {
this.chart.timeScale().subscribeVisibleTimeRangeChange(cb);
this.chart.timeScale().subscribeVisibleLogicalRangeChange(cb);
return () => {
try { this.chart.timeScale().unsubscribeVisibleTimeRangeChange(cb); } catch { /* ok */ }
try { this.chart.timeScale().unsubscribeVisibleLogicalRangeChange(cb); } catch { /* ok */ }
};
}
/**
* 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). 스크롤 필요 여부 판단에만 사용.
*/
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 = 80;
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++) {
if (i === 0) {
panes[i].setStretchFactor(mainWeight);
} else if (i === 1) {
panes[i].setStretchFactor(volumeShown ? volWeight : ORPHAN_STRETCH);
} else if (activeIndPanes.has(i)) {
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 _paneHeightFractions(
indicatorPaneCount: number,
volFrac: number,
): { mainFrac: number; eachIndFrac: number } {
const N = indicatorPaneCount;
if (N === 0) {
return { mainFrac: 1 - volFrac, eachIndFrac: 0 };
}
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 { mainFrac } = this._paneHeightFractions(N, this._volumeFrac(H));
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();
}
}