알림목록 obv 그래프 문제 최종수정

This commit is contained in:
Macbook
2026-06-10 09:57:38 +09:00
parent 490db298b6
commit 1ec22d6ea2
12 changed files with 745 additions and 162 deletions
+379 -85
View File
@@ -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 {