알림목록 obv 그래프 문제 최종수정
This commit is contained in:
@@ -19,6 +19,7 @@ import {
|
||||
ColorType,
|
||||
LineStyle,
|
||||
CrosshairMode,
|
||||
TickMarkType,
|
||||
} from 'lightweight-charts';
|
||||
import { IndicatorFillPrimitive } from './IndicatorFillPrimitive';
|
||||
import { BollingerBandFillPrimitive } from './BollingerBandFillPrimitive';
|
||||
@@ -29,14 +30,14 @@ import { formatUpbitKrwPrice } from './safeFormat';
|
||||
import { TRADE_BUY_COLOR, TRADE_SELL_COLOR } from './tradeSignalColors';
|
||||
import {
|
||||
DEFAULT_CHART_TIME_FORMAT,
|
||||
formatUnixWithChartPattern,
|
||||
formatLwcTimeWithPattern,
|
||||
formatLwcTickMarkWithPattern,
|
||||
normalizeChartTimeFormat,
|
||||
} from './chartTimeFormat';
|
||||
import { DEFAULT_DISPLAY_TIMEZONE } from './timezone';
|
||||
import { DEFAULT_DISPLAY_TIMEZONE, getTimezoneOffsetSec } from './timezone';
|
||||
import { getPaneHostId, sortIndicatorsForPaneLoad } from './indicatorPaneMerge';
|
||||
import { calculateIndicator, enrichIndicatorConfig, getIndicatorDef, getHLineLabel, type PlotData, type MarkerData } from './indicatorRegistry';
|
||||
import { filterObvPlotDataForChart } from './customIndicators';
|
||||
import { IchimokuCloudPlugin, type IchimokuCloudPoint } from './IchimokuCloudPlugin';
|
||||
|
||||
const MAIN_PRICE_FORMAT = {
|
||||
@@ -226,18 +227,10 @@ function sortDualLinePlotDefs(plotDefs: PlotDef[]): PlotDef[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* OBV 등 이중 plot — 설정 lineWidth 기준 굵은 선 먼저(하단), 가는 선 나중(상단).
|
||||
* 값이 겹쳐도 점선·실선·색상(설정값)이 함께 보이도록 z-order만 조정.
|
||||
* OBV 등 이중 plot — 본선(plot0) 먼저·신호(plot1) 나중 (신호 점선이 본선 위).
|
||||
*/
|
||||
function sortPlotDefsForSeriesAdd(type: string, plotDefs: PlotDef[]): PlotDef[] {
|
||||
const sorted = sortDualLinePlotDefs(plotDefs);
|
||||
if (type !== 'OBV') return sorted;
|
||||
return [...sorted].sort((a, b) => {
|
||||
const wA = a.lineWidth ?? 1;
|
||||
const wB = b.lineWidth ?? 1;
|
||||
if (wA !== wB) return wB - wA;
|
||||
return DUAL_LINE_PLOT_ORDER.indexOf(a.id) - DUAL_LINE_PLOT_ORDER.indexOf(b.id);
|
||||
});
|
||||
return sortDualLinePlotDefs(plotDefs);
|
||||
}
|
||||
|
||||
export class ChartManager {
|
||||
@@ -783,6 +776,40 @@ export class ChartManager {
|
||||
.filter(p => p.type !== 'histogram' && enriched.plotVisibility?.[p.id] !== false);
|
||||
}
|
||||
|
||||
/**
|
||||
* OBV 시리즈 추가 순서 — plot0(본선) 먼저·plot1(신호) 나중.
|
||||
* 신호선은 기본 점선 → 겹쳐도 파란 본선+주황 신호가 동시에 보임.
|
||||
*/
|
||||
private _obvSeriesAddOrder(): readonly ['plot0', 'plot1'] {
|
||||
return ['plot0', 'plot1'];
|
||||
}
|
||||
|
||||
/** 레전드·크로스헤어 — plot0→plot1 순서 (seriesList z-order 와 무관) */
|
||||
private _plotOrderForEntry(entry: IndicatorEntry): string[] {
|
||||
const config = entry.config;
|
||||
const def = getIndicatorDef(entry.type);
|
||||
const enriched = config ? enrichIndicatorConfig(config) : null;
|
||||
const plots = enriched?.plots ?? def?.plots ?? [];
|
||||
return plots
|
||||
.filter(p => enriched?.plotVisibility?.[p.id] !== false)
|
||||
.map(p => p.id);
|
||||
}
|
||||
|
||||
private _indicatorValuesInPlotOrder(
|
||||
entry: IndicatorEntry,
|
||||
params: MouseEventParams<Time>,
|
||||
): number[] {
|
||||
return this._plotOrderForEntry(entry).map(plotId => {
|
||||
const idx = entry.seriesMeta.findIndex(m => m.plotId === plotId);
|
||||
if (idx < 0) return NaN;
|
||||
const series = entry.seriesList[idx];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const d = params.seriesData.get(series) as any;
|
||||
if (!d) return NaN;
|
||||
return typeof d.value === 'number' ? d.value : NaN;
|
||||
});
|
||||
}
|
||||
|
||||
/** OBV — plot0·plot1 항상 쌍으로 생성 (메인 차트와 동일, DUAL_LINE repair 경로 회피) */
|
||||
private _addObvIndicatorSeries(
|
||||
seriesList: ISeriesApi<SeriesType>[],
|
||||
@@ -799,40 +826,104 @@ export class ChartManager {
|
||||
);
|
||||
const indicatorPriceFormat = priceFormatForIndicatorPane(pane);
|
||||
|
||||
for (const plotId of ['plot0', 'plot1'] as const) {
|
||||
if (enriched.plotVisibility?.[plotId] === false) continue;
|
||||
// 본선(plot0)·신호선(plot1) 을 같은 가격 범위로 고정.
|
||||
// 재렌더·pane 재배치 과정에서 스케일이 오염되면 본선이 납작하게 눌릴 수 있어,
|
||||
// OBV 데이터(plot0+plot1) 의 실제 min/max 로 autoscale 을 강제한다.
|
||||
let obvMin = Infinity, obvMax = -Infinity;
|
||||
for (const id of ['plot0', 'plot1'] as const) {
|
||||
for (const p of (result.plots[id] ?? []) as PlotData) {
|
||||
if (Number.isFinite(p.value)) {
|
||||
obvMin = Math.min(obvMin, p.value);
|
||||
obvMax = Math.max(obvMax, p.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 본선·신호선을 같은 전용 가격축에 격리 → 같은 pane 의 다른(오염된) 'right' 축이나
|
||||
// 고아 시리즈의 거대값에 휩쓸려 본선이 납작하게 눌리는 것을 차단한다.
|
||||
const obvScaleId = `obv-${config.id}`;
|
||||
const obvAutoscaleProvider = (Number.isFinite(obvMin) && Number.isFinite(obvMax) && obvMax > obvMin)
|
||||
? (baseImpl: () => { priceRange: { minValue: number; maxValue: number } } | null) => {
|
||||
// plot0·plot1 둘 다 동일 범위(union)를 쓰도록 해 실제 값 차이가 세로로 분리돼 보인다.
|
||||
// 실시간 봉 추가로 값이 커지면 base(현재 시리즈 범위)와 union 해서 추적.
|
||||
let lo = obvMin, hi = obvMax;
|
||||
const base = baseImpl?.();
|
||||
if (base?.priceRange) {
|
||||
lo = Math.min(lo, base.priceRange.minValue);
|
||||
hi = Math.max(hi, base.priceRange.maxValue);
|
||||
}
|
||||
const pad = (hi - lo) * 0.08 || Math.abs(hi) * 0.08 || 1;
|
||||
return { priceRange: { minValue: lo - pad, maxValue: hi + pad } };
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// lightweight-charts 는 존재하지 않는 pane 을 한 번에 하나씩만 만들기 때문에,
|
||||
// 볼륨 숨김 차트(pane 0 만 존재)에서 paneIndex=2 로 두 시리즈를 추가하면
|
||||
// 첫 시리즈는 pane 1, 두 번째는 pane 2 로 갈라진다. 첫 시리즈가 실제로 배치된
|
||||
// pane 을 읽어 이후 시리즈를 같은 pane 에 강제 → plot0/plot1 분리(축 분리) 차단.
|
||||
let targetPane = pane;
|
||||
|
||||
for (const plotId of this._obvSeriesAddOrder()) {
|
||||
const plotDef = plotById[plotId] ?? def?.plots?.find(p => p.id === plotId);
|
||||
if (!plotDef || plotDef.type === 'histogram') continue;
|
||||
|
||||
const plotData = result.plots[plotId] as PlotData | undefined;
|
||||
if (!plotData?.length) continue;
|
||||
const filtered = plotData.filter(p => p.value !== null && !isNaN(p.value));
|
||||
const filtered = filterObvPlotDataForChart(
|
||||
plotData,
|
||||
result.plots as Record<string, PlotData>,
|
||||
);
|
||||
if (filtered.length === 0) continue;
|
||||
|
||||
const isPlotVisible = !indicatorHidden;
|
||||
const showPriceLabel = this.shouldShowSeriesPriceLabel(enriched, true, pane);
|
||||
const showSeriesTitle = pane < 2 && showPriceLabel;
|
||||
const regPlot = def?.plots?.find(p => p.id === plotId);
|
||||
const fallbackColor = plotId === 'plot0'
|
||||
? (regPlot?.color ?? '#2196F3')
|
||||
: (regPlot?.color ?? plotDef.color ?? '#2962FF');
|
||||
|
||||
const resolvedColor = resolvePlotLineColor(
|
||||
plotDef.color,
|
||||
regPlot?.color ?? plotDef.color ?? (plotId === 'plot0' ? '#2196F3' : '#FF9800'),
|
||||
);
|
||||
const resolvedWidth = (plotId === 'plot0'
|
||||
? Math.max(2, plotDef.lineWidth ?? regPlot?.lineWidth ?? 2)
|
||||
: Math.max(1, plotDef.lineWidth ?? regPlot?.lineWidth ?? 1)) as 1 | 2 | 3 | 4;
|
||||
|
||||
const series = this.chart.addSeries(LineSeries, {
|
||||
color: resolvePlotLineColor(plotDef.color, fallbackColor),
|
||||
lineWidth: (plotId === 'plot0'
|
||||
? Math.max(2, plotDef.lineWidth ?? regPlot?.lineWidth ?? 2)
|
||||
: Math.max(1, plotDef.lineWidth ?? regPlot?.lineWidth ?? 1)) as 1 | 2 | 3 | 4,
|
||||
color: resolvedColor,
|
||||
lineWidth: resolvedWidth,
|
||||
lineStyle: plotSeriesLineStyle(plotDef.lineStyle),
|
||||
priceLineVisible: false,
|
||||
lastValueVisible: showPriceLabel,
|
||||
crosshairMarkerVisible: true,
|
||||
crosshairMarkerRadius: plotId === 'plot0' ? 4 : 3,
|
||||
title: showSeriesTitle ? this.seriesTitleForPlot(enriched, plotDef) : '',
|
||||
visible: isPlotVisible,
|
||||
priceScaleId: obvScaleId,
|
||||
...(obvAutoscaleProvider ? { autoscaleInfoProvider: obvAutoscaleProvider } : {}),
|
||||
...(indicatorPriceFormat ? { priceFormat: indicatorPriceFormat } : {}),
|
||||
}, pane);
|
||||
}, targetPane);
|
||||
// 첫 시리즈가 실제로 배치된 pane 으로 이후 시리즈를 고정
|
||||
if (seriesList.length === 0) {
|
||||
const actual = this._readSeriesPaneIndex(series);
|
||||
if (actual >= 0) targetPane = actual;
|
||||
}
|
||||
series.setData(filtered.map(p => ({ time: p.time as Time, value: p.value })));
|
||||
seriesList.push(series);
|
||||
seriesMeta.push({ plotId, isHistogram: false, color: plotDef.color });
|
||||
}
|
||||
|
||||
// 전용 축을 우측에 표시 (눈금 라벨 유지) + 여백
|
||||
if (seriesList.length > 0) {
|
||||
try {
|
||||
this.chart.priceScale(obvScaleId, pane).applyOptions({
|
||||
visible: true,
|
||||
scaleMargins: { top: 0.12, bottom: 0.12 },
|
||||
});
|
||||
} catch { /* ok */ }
|
||||
}
|
||||
|
||||
if (!indicatorHidden && seriesMeta.length < 2) {
|
||||
console.warn('[OBV] incomplete series — expected plot0+plot1, got', seriesMeta.map(m => m.plotId));
|
||||
}
|
||||
}
|
||||
|
||||
/** RSI·CCI·TRIX — plot0→plot1 순서로 시리즈를 한 번에 생성 */
|
||||
@@ -1157,6 +1248,9 @@ export class ChartManager {
|
||||
}
|
||||
this.indicators.set(config.id, entry);
|
||||
if (config.type === 'OBV') {
|
||||
if (entry.seriesMeta.length < 2 && !indicatorHidden && def) {
|
||||
await this._ensureObvSeriesForEntry(entry);
|
||||
}
|
||||
if (entry.seriesList.length > 0 && !indicatorHidden) {
|
||||
this._setIndicatorPlotsFullData(entry, result.plots as Record<string, PlotData>, config);
|
||||
}
|
||||
@@ -1186,6 +1280,7 @@ export class ChartManager {
|
||||
if (this._activeIndicatorPaneIndices().size > 0) {
|
||||
this._resyncIndicatorPaneIndices();
|
||||
this._splitCollidingIndicatorPanes();
|
||||
this._consolidateMultiSeriesPanes();
|
||||
this._resyncIndicatorPaneIndices();
|
||||
this._applyAllSeriesPriceFormats();
|
||||
} else {
|
||||
@@ -1211,6 +1306,7 @@ export class ChartManager {
|
||||
this._removeOrphanSubPanes();
|
||||
if (this._activeIndicatorPaneIndices().size > 0) {
|
||||
this._resyncIndicatorPaneIndices();
|
||||
this._consolidateMultiSeriesPanes();
|
||||
} else {
|
||||
this._trimTrailingEmptySubPanes();
|
||||
}
|
||||
@@ -1221,6 +1317,87 @@ export class ChartManager {
|
||||
this._notifyPaneLayout();
|
||||
}
|
||||
|
||||
/** 누락된 OBV plot 시리즈만 추가 (기존 plot1 유지 등) */
|
||||
private _appendMissingObvPlotSeries(
|
||||
entry: IndicatorEntry,
|
||||
config: IndicatorConfig,
|
||||
result: { plots: Record<string, PlotData> },
|
||||
pane: number,
|
||||
indicatorHidden: boolean,
|
||||
def: NonNullable<ReturnType<typeof getIndicatorDef>>,
|
||||
): void {
|
||||
const existing = new Set(entry.seriesMeta.map(m => m.plotId));
|
||||
if (existing.has('plot0') && existing.has('plot1')) return;
|
||||
|
||||
const scratchList: ISeriesApi<SeriesType>[] = [];
|
||||
const scratchMeta: IndicatorEntry['seriesMeta'] = [];
|
||||
this._addObvIndicatorSeries(
|
||||
scratchList, scratchMeta, config, result, pane, indicatorHidden, def,
|
||||
);
|
||||
for (let i = 0; i < scratchList.length; i++) {
|
||||
const meta = scratchMeta[i];
|
||||
if (!meta || existing.has(meta.plotId)) {
|
||||
try { this.chart.removeSeries(scratchList[i]); } catch { /* ok */ }
|
||||
continue;
|
||||
}
|
||||
entry.seriesList.push(scratchList[i]);
|
||||
entry.seriesMeta.push(meta);
|
||||
}
|
||||
}
|
||||
|
||||
/** OBV plot0/plot1 누락·데이터 길이 불일치 시 재생성 또는 전체 setData */
|
||||
private async _ensureObvSeriesForEntry(entry: IndicatorEntry): Promise<void> {
|
||||
if (entry.type !== 'OBV' || !entry.config || this.rawBars.length === 0) return;
|
||||
const config = enrichIndicatorConfig(entry.config);
|
||||
const def = getIndicatorDef('OBV');
|
||||
if (!def) return;
|
||||
|
||||
const missingPlot = !entry.seriesMeta.some(m => m.plotId === 'plot0')
|
||||
|| !entry.seriesMeta.some(m => m.plotId === 'plot1');
|
||||
|
||||
if (missingPlot) {
|
||||
const pane = entry.paneIndex ?? this._readSeriesPaneIndex(entry.seriesList[0]) ?? 2;
|
||||
const hidden = !this._isIndicatorEffectivelyVisible(config);
|
||||
if (entry.seriesList.length > 0) {
|
||||
try {
|
||||
const result = await calculateIndicator('OBV', this.rawBars.slice(), config.params ?? {});
|
||||
this._appendMissingObvPlotSeries(entry, config, result, pane, hidden, def);
|
||||
if (entry.seriesList.length > 0) {
|
||||
this._setIndicatorPlotsFullData(entry, result.plots as Record<string, PlotData>, config);
|
||||
}
|
||||
entry.config = config;
|
||||
} catch (e) {
|
||||
console.error('[Indicator] OBV append missing error:', e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const s of entry.seriesList) {
|
||||
try { this.chart.removeSeries(s); } catch { /* ok */ }
|
||||
}
|
||||
entry.seriesList = [];
|
||||
entry.seriesMeta = [];
|
||||
try {
|
||||
const result = await calculateIndicator('OBV', this.rawBars.slice(), config.params ?? {});
|
||||
this._addObvIndicatorSeries(
|
||||
entry.seriesList, entry.seriesMeta, config, result, pane, hidden, def,
|
||||
);
|
||||
if (entry.seriesList.length > 0) {
|
||||
this._setIndicatorPlotsFullData(entry, result.plots as Record<string, PlotData>, config);
|
||||
}
|
||||
entry.config = config;
|
||||
} catch (e) {
|
||||
console.error('[Indicator] OBV ensure error:', e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.seriesList.length === 0) return;
|
||||
try {
|
||||
const result = await calculateIndicator('OBV', this.rawBars.slice(), config.params ?? {});
|
||||
this._setIndicatorPlotsFullData(entry, result.plots as Record<string, PlotData>, config);
|
||||
} catch { /* ok */ }
|
||||
}
|
||||
|
||||
/** OBV·RSI 등 본선(plot0) 시리즈 누락 시 재생성 + 전체 plot setData + 스타일(DB) 반영 */
|
||||
async repairDualLineSeries(): Promise<void> {
|
||||
for (const entry of this.indicators.values()) {
|
||||
@@ -1228,35 +1405,7 @@ export class ChartManager {
|
||||
|
||||
if (entry.type === 'OBV') {
|
||||
const config = enrichIndicatorConfig(entry.config);
|
||||
const def = getIndicatorDef('OBV');
|
||||
const missingPlot = !entry.seriesMeta.some(m => m.plotId === 'plot0')
|
||||
|| !entry.seriesMeta.some(m => m.plotId === 'plot1');
|
||||
if (missingPlot && def && this.rawBars.length > 0) {
|
||||
const pane = entry.paneIndex ?? this._readSeriesPaneIndex(entry.seriesList[0]) ?? 2;
|
||||
const hidden = !this._isIndicatorEffectivelyVisible(config);
|
||||
for (const s of entry.seriesList) {
|
||||
try { this.chart.removeSeries(s); } catch { /* ok */ }
|
||||
}
|
||||
entry.seriesList = [];
|
||||
entry.seriesMeta = [];
|
||||
try {
|
||||
const result = await calculateIndicator('OBV', this.rawBars.slice(), config.params ?? {});
|
||||
this._addObvIndicatorSeries(
|
||||
entry.seriesList, entry.seriesMeta, config, result, pane, hidden, def,
|
||||
);
|
||||
if (entry.seriesList.length > 0) {
|
||||
this._setIndicatorPlotsFullData(entry, result.plots as Record<string, PlotData>, config);
|
||||
}
|
||||
entry.config = config;
|
||||
} catch (e) {
|
||||
console.error('[Indicator] OBV repair error:', e);
|
||||
}
|
||||
} else if (entry.seriesList.length > 0 && this.rawBars.length > 0) {
|
||||
try {
|
||||
const result = await calculateIndicator('OBV', this.rawBars.slice(), config.params ?? {});
|
||||
this._setIndicatorPlotsFullData(entry, result.plots as Record<string, PlotData>, config);
|
||||
} catch { /* ok */ }
|
||||
}
|
||||
await this._ensureObvSeriesForEntry(entry);
|
||||
this.applyIndicatorStyle(config, { skipDualLineRefresh: true });
|
||||
continue;
|
||||
}
|
||||
@@ -1497,16 +1646,19 @@ export class ChartManager {
|
||||
plots: Record<string, PlotData>,
|
||||
config: IndicatorConfig,
|
||||
): void {
|
||||
const plotDefs = config.plots ?? getIndicatorDef(config.type)?.plots ?? [];
|
||||
const def = getIndicatorDef(config.type);
|
||||
const plotDefs = config.plots ?? def?.plots ?? [];
|
||||
const plotById = Object.fromEntries(plotDefs.map((p: PlotDef) => [p.id, p]));
|
||||
|
||||
for (let i = 0; i < entry.seriesList.length; i++) {
|
||||
const meta = entry.seriesMeta[i];
|
||||
if (!meta) continue;
|
||||
const plot = plotById[meta.plotId];
|
||||
const plot = plotById[meta.plotId] ?? def?.plots?.find(p => p.id === meta.plotId);
|
||||
const plotData = plots[meta.plotId] as PlotData | undefined;
|
||||
if (!plot || !plotData?.length) continue;
|
||||
const filtered = plotData.filter(p => p.value !== null && !isNaN(p.value));
|
||||
if (!plotData?.length) continue;
|
||||
const filtered = config.type === 'OBV'
|
||||
? filterObvPlotDataForChart(plotData, plots)
|
||||
: plotData.filter(p => p.value !== null && !isNaN(p.value));
|
||||
if (filtered.length === 0) continue;
|
||||
|
||||
const series = entry.seriesList[i];
|
||||
@@ -1885,19 +2037,34 @@ export class ChartManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** LWC 시리즈가 실제로 붙어 있는 sub-pane index (2+) */
|
||||
/** 볼륨 pane(1)이 실제로 표시되는지 여부 */
|
||||
private _volumePaneShown(): boolean {
|
||||
return this._volumePaneEnabled && this._volumeVisible && !this._candleOnlyLayout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 보조지표가 들어갈 수 있는 첫 sub-pane index.
|
||||
* - 볼륨 표시: 2 (pane 1 = 볼륨)
|
||||
* - 볼륨 숨김: 1 (볼륨이 없으므로 pane 1 이 곧 첫 지표 pane)
|
||||
*/
|
||||
private _minIndicatorSubPane(): number {
|
||||
return this._volumePaneShown() ? 2 : 1;
|
||||
}
|
||||
|
||||
/** LWC 시리즈가 실제로 붙어 있는 sub-pane index (볼륨 숨김 시 1+, 표시 시 2+) */
|
||||
private _collectUsedSubPaneIndices(): Set<number> {
|
||||
const minSub = this._minIndicatorSubPane();
|
||||
const indices = new Set<number>();
|
||||
for (const entry of this.indicators.values()) {
|
||||
let pane = -1;
|
||||
for (const series of entry.seriesList) {
|
||||
const pi = this._readSeriesPaneIndex(series);
|
||||
if (pi >= 2) pane = Math.max(pane, pi);
|
||||
if (pi >= minSub) pane = Math.max(pane, pi);
|
||||
}
|
||||
if (pane < 2 && entry.paneIndex != null && entry.paneIndex >= 2) {
|
||||
if (pane < minSub && entry.paneIndex != null && entry.paneIndex >= minSub) {
|
||||
pane = entry.paneIndex;
|
||||
}
|
||||
if (pane >= 2) indices.add(pane);
|
||||
if (pane >= minSub) indices.add(pane);
|
||||
}
|
||||
return indices;
|
||||
}
|
||||
@@ -1954,9 +2121,61 @@ export class ChartManager {
|
||||
if (changed) this._removeOrphanSubPanes();
|
||||
}
|
||||
|
||||
/**
|
||||
* 같은 지표(entry)의 모든 plot 시리즈를 반드시 한 pane 으로 통일한다.
|
||||
*
|
||||
* lightweight-charts 의 가격축(price scale)은 pane 단위라서, 같은 priceScaleId
|
||||
* 문자열이라도 plot0/plot1 이 서로 다른 pane 에 흩어지면 서로 다른 축 객체가 되어
|
||||
* 본선이 신호선과 다른 스케일로 눌리는 문제가 발생한다. 레이아웃/리사이즈 직후
|
||||
* 호출해 분리를 복구한다.
|
||||
*/
|
||||
private _consolidateMultiSeriesPanes(): void {
|
||||
for (const entry of this.indicators.values()) {
|
||||
if (entry.seriesList.length < 2) continue;
|
||||
|
||||
const def = getIndicatorDef(entry.type);
|
||||
// 오버레이(BB 등)·마커 지표는 캔들 pane(0)을 공유하므로 통일 대상에서 제외
|
||||
if (def?.overlay || def?.returnsMarkers) continue;
|
||||
|
||||
// 서브패널 지표: 모든 plot 을 동일한 실제 지표 pane 으로 통일.
|
||||
// 시리즈가 흩어졌을 때 가장 깊은 pane(=정상 배치된 plot 의 pane)을 타깃으로 삼고,
|
||||
// 모두 0/1(캔들·볼륨 슬롯)에 갇혀 있으면 첫 실제 sub-pane 으로 끌어올린다.
|
||||
// (볼륨 숨김 시 minSub=1 이므로 pane 1 이 정상 지표 pane 이 된다.)
|
||||
const minSub = this._minIndicatorSubPane();
|
||||
let target = -1;
|
||||
for (const s of entry.seriesList) {
|
||||
const pi = this._readSeriesPaneIndex(s);
|
||||
if (pi > target) target = pi;
|
||||
}
|
||||
if (target < minSub) target = minSub;
|
||||
|
||||
let moved = false;
|
||||
for (const s of entry.seriesList) {
|
||||
try {
|
||||
if (this._readSeriesPaneIndex(s) !== target) {
|
||||
s.moveToPane(target);
|
||||
moved = true;
|
||||
}
|
||||
} catch { /* ok */ }
|
||||
}
|
||||
if (!moved && entry.paneIndex === target) continue;
|
||||
entry.paneIndex = target;
|
||||
|
||||
// 통일된 pane 에서 OBV 전용 축 옵션 재확정 (이동 중 손실 방지)
|
||||
if (entry.type === 'OBV' && entry.config) {
|
||||
try {
|
||||
this.chart.priceScale(`obv-${entry.config.id}`, target).applyOptions({
|
||||
visible: true,
|
||||
scaleMargins: { top: 0.12, bottom: 0.12 },
|
||||
});
|
||||
} catch { /* ok */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _getNextSubPane(): number {
|
||||
const used = this._collectUsedSubPaneIndices();
|
||||
let pane = 2;
|
||||
let pane = this._minIndicatorSubPane();
|
||||
while (used.has(pane)) pane++;
|
||||
return pane;
|
||||
}
|
||||
@@ -1965,7 +2184,8 @@ export class ChartManager {
|
||||
private _resolveSubPane(config: IndicatorConfig): number {
|
||||
if (config.mergedWith) {
|
||||
const host = this.indicators.get(config.mergedWith);
|
||||
if (host?.paneIndex != null && host.paneIndex >= 2) {
|
||||
const minSub = this._minIndicatorSubPane();
|
||||
if (host?.paneIndex != null && host.paneIndex >= minSub) {
|
||||
return host.paneIndex;
|
||||
}
|
||||
}
|
||||
@@ -2081,7 +2301,11 @@ export class ChartManager {
|
||||
if (dataGenAtStart !== this._dataGeneration) return;
|
||||
if (!entry.config) continue;
|
||||
try {
|
||||
if (DUAL_LINE_INDICATOR_TYPES.has(entry.type)) {
|
||||
if (entry.type === 'OBV') {
|
||||
const missing = !entry.seriesMeta.some(m => m.plotId === 'plot0')
|
||||
|| !entry.seriesMeta.some(m => m.plotId === 'plot1');
|
||||
if (missing) await this._ensureObvSeriesForEntry(entry);
|
||||
} else if (DUAL_LINE_INDICATOR_TYPES.has(entry.type)) {
|
||||
const config = enrichIndicatorConfig(entry.config);
|
||||
const def = getIndicatorDef(entry.type);
|
||||
if (this._dualLineSeriesOutOfSync(entry, config, def)) {
|
||||
@@ -2099,6 +2323,12 @@ export class ChartManager {
|
||||
this._refreshIchimokuSeriesData(entry, plots);
|
||||
continue;
|
||||
}
|
||||
if (entry.type === 'OBV' && entry.config) {
|
||||
this._setIndicatorPlotsFullData(
|
||||
entry, plots, enrichIndicatorConfig(entry.config),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
this._applyLastPointsToSeries(entry, plots, /* updateFutureSpans */ false);
|
||||
} catch { /* 계산 실패 무시 */ }
|
||||
}
|
||||
@@ -2313,7 +2543,11 @@ export class ChartManager {
|
||||
if (dataGenAtStart !== this._dataGeneration) return;
|
||||
if (!entry.config) continue;
|
||||
try {
|
||||
if (DUAL_LINE_INDICATOR_TYPES.has(entry.type)) {
|
||||
if (entry.type === 'OBV') {
|
||||
const missing = !entry.seriesMeta.some(m => m.plotId === 'plot0')
|
||||
|| !entry.seriesMeta.some(m => m.plotId === 'plot1');
|
||||
if (missing) await this._ensureObvSeriesForEntry(entry);
|
||||
} else if (DUAL_LINE_INDICATOR_TYPES.has(entry.type)) {
|
||||
const config = enrichIndicatorConfig(entry.config);
|
||||
const def = getIndicatorDef(entry.type);
|
||||
if (this._dualLineSeriesOutOfSync(entry, config, def)) {
|
||||
@@ -2330,6 +2564,12 @@ export class ChartManager {
|
||||
if (entry.type === 'IchimokuCloud' && entry.cloudPlugin) {
|
||||
this._applyIchimokuCloudPlugin(entry.cloudPlugin, plots, entry.config);
|
||||
}
|
||||
if (entry.type === 'OBV' && entry.config) {
|
||||
this._setIndicatorPlotsFullData(
|
||||
entry, plots, enrichIndicatorConfig(entry.config),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// updateFutureSpans=true → 새 캔들 추가 시 SpanA/B setData() 전체 재설정
|
||||
this._applyLastPointsToSeries(entry, plots, /* updateFutureSpans */ true);
|
||||
} catch { /* 인디케이터 계산 실패 무시 */ }
|
||||
@@ -2799,7 +3039,10 @@ export class ChartManager {
|
||||
type: entry.type,
|
||||
config: entry.config,
|
||||
paneIndex: entry.paneIndex ?? 0,
|
||||
plotColors: entry.seriesMeta.map(m => m.color),
|
||||
plotColors: this._plotOrderForEntry(entry).map(plotId => {
|
||||
const meta = entry.seriesMeta.find(m => m.plotId === plotId);
|
||||
return (meta?.color ?? '#888888').slice(0, 7);
|
||||
}),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
@@ -2812,14 +3055,7 @@ export class ChartManager {
|
||||
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);
|
||||
}
|
||||
const vals = this._indicatorValuesInPlotOrder(entry, params);
|
||||
if (vals.some(v => !isNaN(v))) result[id] = vals;
|
||||
}
|
||||
return result;
|
||||
@@ -3120,7 +3356,9 @@ export class ChartManager {
|
||||
let plot: PlotDef | undefined;
|
||||
const pid = entry.seriesMeta[i]?.plotId;
|
||||
plot = (pid ? plotById[pid] : undefined) ?? plotDefs[i];
|
||||
const visible = this._resolveSeriesVisible(config, pid);
|
||||
const visible = entry.type === 'OBV'
|
||||
? indicatorVisible
|
||||
: this._resolveSeriesVisible(config, pid);
|
||||
if (!plot) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
series.applyOptions({ visible } as any);
|
||||
@@ -3138,8 +3376,16 @@ export class ChartManager {
|
||||
}
|
||||
if (!entry.seriesMeta[i]?.isHistogram) {
|
||||
const regPlot = def?.plots?.find(p => p.id === pid);
|
||||
opts['color'] = resolvePlotLineColor(plot.color, regPlot?.color ?? plot.color ?? '#aaa');
|
||||
opts['lineWidth'] = Math.max(1, plot.lineWidth ?? regPlot?.lineWidth ?? 1);
|
||||
const defaultObvColor = pid === 'plot0' ? '#2196F3' : pid === 'plot1' ? '#FF9800' : '#2962FF';
|
||||
opts['color'] = resolvePlotLineColor(
|
||||
plot.color,
|
||||
regPlot?.color ?? plot.color ?? (entry.type === 'OBV' ? defaultObvColor : '#aaa'),
|
||||
);
|
||||
opts['lineWidth'] = entry.type === 'OBV'
|
||||
? ((pid === 'plot0'
|
||||
? Math.max(2, plot.lineWidth ?? regPlot?.lineWidth ?? 2)
|
||||
: Math.max(1, plot.lineWidth ?? regPlot?.lineWidth ?? 1)) as 1 | 2 | 3 | 4)
|
||||
: Math.max(1, plot.lineWidth ?? regPlot?.lineWidth ?? 1);
|
||||
opts['lineStyle'] = plotSeriesLineStyle(plot.lineStyle);
|
||||
let plotAllows = true;
|
||||
if (entry.type === 'IchimokuCloud') {
|
||||
@@ -3489,9 +3735,9 @@ export class ChartManager {
|
||||
this.autoScale();
|
||||
}
|
||||
|
||||
/** pane 2+ 하단 보조지표 여부 (pane 0 오버레이 제외) */
|
||||
/** 하단 보조지표 pane 여부 (pane 0 오버레이 제외, 볼륨 숨김 시 pane 1 포함) */
|
||||
private _isSubPaneIndicator(entry: IndicatorEntry): boolean {
|
||||
return (entry.paneIndex ?? 0) >= 2;
|
||||
return (entry.paneIndex ?? 0) >= this._minIndicatorSubPane();
|
||||
}
|
||||
|
||||
/** 캔들 전체보기 — 하단 보조지표 pane 시리즈·기준선 숨김 */
|
||||
@@ -3687,23 +3933,57 @@ export class ChartManager {
|
||||
const lrSpan = lr.to - lr.from;
|
||||
if (!Number.isFinite(lrSpan) || lrSpan <= 0) return null;
|
||||
|
||||
const targetCount = Math.max(4, Math.min(12, Math.floor(plotWidth / 76)));
|
||||
const step = Math.max(1, Math.ceil((lr.to - Math.max(0, lr.from)) / targetCount));
|
||||
|
||||
const labels: Array<{ x: number; text: string }> = [];
|
||||
const fmt = this.chartTimeFormat;
|
||||
const tz = this.displayTimezone;
|
||||
const tf = this.displayTimeframe;
|
||||
const startLogical = Math.max(0, Math.ceil(lr.from));
|
||||
const endLogical = Math.min(this.rawBars.length - 1, Math.floor(lr.to));
|
||||
if (endLogical <= startLogical) return null;
|
||||
|
||||
for (let logical = startLogical; logical <= endLogical; logical += step) {
|
||||
const firstT = this.rawBars[startLogical]?.time;
|
||||
const lastT = this.rawBars[endLogical]?.time;
|
||||
if (!firstT || !lastT || lastT <= firstT) return null;
|
||||
|
||||
// 하단 네이티브 시간축과 동일하게 "정시 경계"에 눈금 배치 → x·텍스트 정렬.
|
||||
// (네이티브 LWC 도 실제 바 위치에 round time 눈금을 찍는다)
|
||||
const NICE_STEPS_SEC = [
|
||||
60, 120, 300, 600, 900, 1800, // 분 단위
|
||||
3600, 7200, 10800, 21600, 43200, // 시간 단위
|
||||
86400, 172800, 604800, // 일·주 단위
|
||||
];
|
||||
const targetCount = Math.max(3, Math.min(10, Math.floor(plotWidth / 92)));
|
||||
const visSpanSec = lastT - firstT;
|
||||
let stepSec = NICE_STEPS_SEC[NICE_STEPS_SEC.length - 1];
|
||||
for (const s of NICE_STEPS_SEC) {
|
||||
if (visSpanSec / s <= targetCount) { stepSec = s; break; }
|
||||
}
|
||||
|
||||
// 표시 시간대 기준 벽시계 정렬용 오프셋
|
||||
const offsetSec = getTimezoneOffsetSec(firstT, tz);
|
||||
const labels: Array<{ x: number; text: string }> = [];
|
||||
let prevBucket: number | null = null;
|
||||
let prevDayKey: number | null = null;
|
||||
|
||||
for (let logical = startLogical; logical <= endLogical; logical++) {
|
||||
const bar = this.rawBars[logical];
|
||||
if (!bar?.time) continue;
|
||||
const bucket = Math.floor((bar.time + offsetSec) / stepSec);
|
||||
if (bucket === prevBucket) continue; // 같은 경계 버킷 → 첫 바만 눈금
|
||||
prevBucket = bucket;
|
||||
|
||||
const x = this._logicalToAxisX(logical, lr, plotWidth);
|
||||
if (!Number.isFinite(x) || x < 12 || x > plotWidth - 12) continue;
|
||||
|
||||
const dayKey = Math.floor((bar.time + offsetSec) / 86400);
|
||||
const isDayChange = prevDayKey !== null && dayKey !== prevDayKey;
|
||||
prevDayKey = dayKey;
|
||||
const tickType = stepSec >= 86400 || isDayChange
|
||||
? TickMarkType.DayOfMonth
|
||||
: TickMarkType.Time;
|
||||
|
||||
labels.push({
|
||||
x,
|
||||
text: formatUnixWithChartPattern(bar.time, fmt, tz),
|
||||
text: formatLwcTickMarkWithPattern(bar.time as Time, tickType, tf, tz, fmt),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4141,6 +4421,13 @@ export class ChartManager {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.type === 'OBV' && entry.config) {
|
||||
this._setIndicatorPlotsFullData(
|
||||
entry, plots, enrichIndicatorConfig(entry.config),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 일반 지표: 각 시리즈에 전체 데이터 setData
|
||||
for (let i = 0; i < entry.seriesList.length; i++) {
|
||||
const series = entry.seriesList[i];
|
||||
@@ -4448,7 +4735,14 @@ export class ChartManager {
|
||||
if (paneIndex === 0) {
|
||||
panes[i].setStretchFactor(mainWeight);
|
||||
} else if (paneIndex === 1) {
|
||||
panes[i].setStretchFactor(volumeShown ? volWeight : ORPHAN_STRETCH);
|
||||
if (volumeShown) {
|
||||
panes[i].setStretchFactor(volWeight);
|
||||
} else if (activeIndPanes.has(1)) {
|
||||
// 볼륨 숨김 + pane 1 에 보조지표가 배치된 경우 → 지표 pane 으로 취급해 높이 부여
|
||||
panes[i].setStretchFactor(indWeight);
|
||||
} else {
|
||||
panes[i].setStretchFactor(ORPHAN_STRETCH);
|
||||
}
|
||||
} else if (activeIndPanes.has(paneIndex)) {
|
||||
panes[i].setStretchFactor(indWeight);
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user