diff --git a/frontend/src/utils/ChartManager.ts b/frontend/src/utils/ChartManager.ts index e55f5b8..648531c 100644 --- a/frontend/src/utils/ChartManager.ts +++ b/frontend/src/utils/ChartManager.ts @@ -740,50 +740,61 @@ export class ChartManager { /** 이중 plot 지표 — addIndicator 루프에서 누락된 plot 시리즈 추가 */ /** - * OBV pane: plot0(본선)=하반부, plot1(신호선)=상반부로 scale 분리. - * 두 선이 언제나 pane의 서로 다른 절반에 표시되어 항상 시각적으로 구분됨. + * OBV pane: plot0(본선)=하반부, plot1(신호선)=상반부로 분리. + * 각 series의 autoscaleInfoProvider에서 priceRange를 오프셋 처리하여 + * 독립 scale에서도 항상 pane의 서로 다른 절반에 표시되도록 보장. + * + * 원리: plot0는 [pMin, pMax + pSpan] 범위를 사용 → 실제 데이터가 하반부에 위치 + * plot1는 [pMin - pSpan, pMax] 범위를 사용 → 실제 데이터가 상반부에 위치 */ private _applyObvTightScale( seriesList: ISeriesApi[], plots: Record, seriesMeta?: IndicatorEntry['seriesMeta'], ): void { - seriesList.forEach((s, i) => { - const plotId = seriesMeta?.[i]?.plotId; - try { - const ps = s.priceScale(); - if (plotId === 'plot1') { - // 신호선: 상반부 (하단 55% 여백) - ps.applyOptions({ scaleMargins: { top: 0.03, bottom: 0.55 }, visible: false }); - } else { - // 본선(plot0): 하반부 (상단 55% 여백) - ps.applyOptions({ scaleMargins: { top: 0.55, bottom: 0.03 } }); - } - } catch { /* ok */ } - }); - // plot0에 전체 값 범위 기준 autoscaleInfoProvider (과거 극값에 의한 압축 방지) const allPts: number[] = []; for (const key of ['plot0', 'plot1']) { const pts = (plots[key] ?? []).filter(p => p.value != null && isFinite(p.value as number)); allPts.push(...pts.slice(-60).map(p => p.value as number)); } - if (allPts.length > 0) { - const minV = Math.min(...allPts); - const maxV = Math.max(...allPts); - const span = Math.max(1, maxV - minV); - const pad = span * 0.15; - seriesList.forEach(s => { - try { + if (allPts.length === 0) return; + + const minV = Math.min(...allPts); + const maxV = Math.max(...allPts); + const span = Math.max(1, maxV - minV); + const pad = span * 0.12; + const pMin = minV - pad; + const pMax = maxV + pad; + const pSpan = pMax - pMin; + + seriesList.forEach((s, i) => { + const plotId = seriesMeta?.[i]?.plotId; + try { + if (plotId === 'plot1') { + // 신호선: 독립 scale, 상반부 — priceRange 를 하방으로 pSpan 확장 + // [pMin - pSpan, pMax] 에서 실제 데이터 [pMin, pMax] 가 상위 50% 차지 // eslint-disable-next-line @typescript-eslint/no-explicit-any (s as ISeriesApi).applyOptions({ autoscaleInfoProvider: () => ({ - priceRange: { minValue: minV - pad, maxValue: maxV + pad }, - margins: { above: 0, below: 0 }, + priceRange: { minValue: pMin - pSpan, maxValue: pMax }, + margins: { above: 0.04, below: 0 }, }), }); - } catch { /* ok */ } - }); - } + // 신호선 scale axis 숨김 + try { s.priceScale().applyOptions({ visible: false }); } catch { /* ok */ } + } else { + // OBV 본선: 하반부 — priceRange 를 상방으로 pSpan 확장 + // [pMin, pMax + pSpan] 에서 실제 데이터 [pMin, pMax] 가 하위 50% 차지 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (s as ISeriesApi).applyOptions({ + autoscaleInfoProvider: () => ({ + priceRange: { minValue: pMin, maxValue: pMax + pSpan }, + margins: { above: 0, below: 0.04 }, + }), + }); + } + } catch { /* ok */ } + }); } private _appendMissingPlotSeriesToLists( @@ -3023,19 +3034,14 @@ export class ChartManager { } } - // OBV: 스타일 갱신 후 pane 분리 scaleMargins 재적용 - if (entry.type === 'OBV' && entry.seriesList.length > 0) { - entry.seriesList.forEach((s, i) => { - const pid = entry.seriesMeta[i]?.plotId; + // OBV: 스타일 갱신 후 autoscaleInfoProvider 기반 상하 분리 재적용 + if (entry.type === 'OBV' && entry.seriesList.length > 0 && this.rawBars.length > 0) { + (async () => { try { - const ps = s.priceScale(); - if (pid === 'plot1') { - ps.applyOptions({ scaleMargins: { top: 0.03, bottom: 0.55 }, visible: false }); - } else { - ps.applyOptions({ scaleMargins: { top: 0.55, bottom: 0.03 } }); - } + const result = await calculateIndicator('OBV', this.rawBars.slice(), entry.config?.params ?? {}); + this._applyObvTightScale(entry.seriesList, result.plots as Record, entry.seriesMeta); } catch { /* ok */ } - }); + })(); } if (entry.type === 'BollingerBands') {