From d841e576703f4dfe894beea00643dbaedd67dcd1 Mon Sep 17 00:00:00 2001 From: Macbook Date: Tue, 9 Jun 2026 22:56:30 +0900 Subject: [PATCH] =?UTF-8?q?=EC=88=98=EC=A0=952?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/utils/ChartManager.ts | 92 +++++++++++++++++++++++++----- 1 file changed, 78 insertions(+), 14 deletions(-) diff --git a/frontend/src/utils/ChartManager.ts b/frontend/src/utils/ChartManager.ts index a7eb079..cacb52a 100644 --- a/frontend/src/utils/ChartManager.ts +++ b/frontend/src/utils/ChartManager.ts @@ -240,6 +240,11 @@ function sortPlotDefsForSeriesAdd(type: string, plotDefs: PlotDef[]): PlotDef[] }); } +/** OBV 신호선 — pane 내 독립 price scale (본선과 값이 가까워도 겹치지 않게) */ +function obvSignalPriceScaleId(indicatorId: string): string { + return `obv-sig-${indicatorId}`; +} + export class ChartManager { private chart: IChartApi; private container: HTMLElement; @@ -753,9 +758,10 @@ export class ChartManager { config: IndicatorConfig, def: ReturnType | undefined, ): string[] { - const plotDefs = def?.plots ?? config.plots ?? []; + const enriched = enrichIndicatorConfig(config); + const plotDefs = enriched.plots ?? def?.plots ?? []; return plotDefs - .filter(p => p.type !== 'histogram' && config.plotVisibility?.[p.id] !== false) + .filter(p => p.type !== 'histogram' && enriched.plotVisibility?.[p.id] !== false) .map(p => p.id); } @@ -773,6 +779,37 @@ export class ChartManager { return expected.some(id => !existing.has(id)); } + /** OBV — 본선(하단)·신호선(상단) 독립 price scale margin */ + private _configureObvDualLinePriceScales( + seriesList: ISeriesApi[], + seriesMeta: IndicatorEntry['seriesMeta'], + ): void { + for (let i = 0; i < seriesList.length; i++) { + const meta = seriesMeta[i]; + if (!meta) continue; + try { + const ps = seriesList[i].priceScale(); + if (meta.plotId === 'plot0') { + ps.applyOptions({ scaleMargins: { top: 0.52, bottom: 0.05 } }); + } else if (meta.plotId === 'plot1') { + ps.applyOptions({ + visible: false, + scaleMargins: { top: 0.05, bottom: 0.52 }, + }); + } + } catch { /* ok */ } + } + } + + private _dualLinePlotDefsForRender( + config: IndicatorConfig, + def: ReturnType, + ): PlotDef[] { + const enriched = enrichIndicatorConfig(config); + return sortPlotDefsForSeriesAdd(config.type, enriched.plots ?? def?.plots ?? []) + .filter(p => p.type !== 'histogram' && enriched.plotVisibility?.[p.id] !== false); + } + /** OBV·RSI 등 — plot0→plot1 순서로 시리즈를 한 번에 생성 (append 누락·순서 어긋남 방지) */ private _addDualLinePlotSeries( seriesList: ISeriesApi[], @@ -783,11 +820,7 @@ export class ChartManager { indicatorHidden: boolean, def: ReturnType, ): void { - const regPlots = def?.plots ?? []; - const styleById = Object.fromEntries((config.plots ?? regPlots).map(p => [p.id, p])); - const plotDefs = sortDualLinePlotDefs(regPlots) - .filter(p => p.type !== 'histogram' && config.plotVisibility?.[p.id] !== false) - .map(p => ({ ...p, ...styleById[p.id], id: p.id, title: p.title })); + const plotDefs = this._dualLinePlotDefsForRender(config, def); const indicatorPriceFormat = priceFormatForIndicatorPane(pane); for (const plotDef of plotDefs) { @@ -803,20 +836,33 @@ export class ChartManager { const showPriceLabel = this.shouldShowSeriesPriceLabel(config, true, pane); const showSeriesTitle = pane < 2 && showPriceLabel; const regPlot = def?.plots?.find(p => p.id === plotDef.id); + const fallbackColor = config.type === 'OBV' && plotDef.id === 'plot0' + ? (regPlot?.color ?? '#2196F3') + : (regPlot?.color ?? plotDef.color ?? '#2962FF'); + const obvSignalScale = config.type === 'OBV' && plotDef.id === 'plot1' + ? obvSignalPriceScaleId(config.id) + : undefined; const series = this.chart.addSeries(LineSeries, { - color: resolvePlotLineColor(plotDef.color, regPlot?.color ?? plotDef.color ?? '#2962FF'), - lineWidth: Math.max(1, plotDef.lineWidth ?? regPlot?.lineWidth ?? 1) as 1 | 2 | 3 | 4, + color: resolvePlotLineColor(plotDef.color, fallbackColor), + lineWidth: (config.type === 'OBV' && plotDef.id === 'plot0' + ? Math.max(2, plotDef.lineWidth ?? regPlot?.lineWidth ?? 2) + : Math.max(1, plotDef.lineWidth ?? regPlot?.lineWidth ?? 1)) as 1 | 2 | 3 | 4, lineStyle: plotSeriesLineStyle(plotDef.lineStyle), priceLineVisible: false, lastValueVisible: showPriceLabel, title: showSeriesTitle ? this.seriesTitleForPlot(config, plotDef) : '', visible: isPlotVisible, ...(indicatorPriceFormat ? { priceFormat: indicatorPriceFormat } : {}), + ...(obvSignalScale ? { priceScaleId: obvSignalScale } : {}), }, pane); series.setData(filtered.map(p => ({ time: p.time as Time, value: p.value }))); seriesList.push(series); seriesMeta.push({ plotId: plotDef.id, isHistogram: false, color: plotDef.color }); } + + if (config.type === 'OBV' && seriesList.length > 0) { + this._configureObvDualLinePriceScales(seriesList, seriesMeta); + } } private _appendMissingPlotSeriesToLists( @@ -828,13 +874,12 @@ export class ChartManager { indicatorHidden: boolean, def: ReturnType, ): void { - const plotDefs = sortPlotDefsForSeriesAdd(config.type, config.plots ?? def?.plots ?? []); + const plotDefs = this._dualLinePlotDefsForRender(config, def); const existingIds = new Set(seriesMeta.map(m => m.plotId)); const indicatorPriceFormat = priceFormatForIndicatorPane(pane); for (const plotDef of plotDefs) { if (existingIds.has(plotDef.id)) continue; - if (config.plotVisibility?.[plotDef.id] === false) continue; if (plotDef.type === 'histogram') continue; const plotData = result.plots[plotDef.id] as PlotData | undefined; @@ -849,20 +894,33 @@ export class ChartManager { const showPriceLabel = this.shouldShowSeriesPriceLabel(config, true, pane); const showSeriesTitle = pane < 2 && showPriceLabel; const regPlot = def?.plots?.find(p => p.id === plotDef.id); + const fallbackColor = config.type === 'OBV' && plotDef.id === 'plot0' + ? (regPlot?.color ?? '#2196F3') + : (regPlot?.color ?? plotDef.color ?? '#2962FF'); + const obvSignalScale = config.type === 'OBV' && plotDef.id === 'plot1' + ? obvSignalPriceScaleId(config.id) + : undefined; const series = this.chart.addSeries(LineSeries, { - color: resolvePlotLineColor(plotDef.color, regPlot?.color ?? plotDef.color ?? '#2962FF'), - lineWidth: Math.max(1, plotDef.lineWidth ?? regPlot?.lineWidth ?? 1) as 1 | 2 | 3 | 4, + color: resolvePlotLineColor(plotDef.color, fallbackColor), + lineWidth: (config.type === 'OBV' && plotDef.id === 'plot0' + ? Math.max(2, plotDef.lineWidth ?? regPlot?.lineWidth ?? 2) + : Math.max(1, plotDef.lineWidth ?? regPlot?.lineWidth ?? 1)) as 1 | 2 | 3 | 4, lineStyle: plotSeriesLineStyle(plotDef.lineStyle), priceLineVisible: false, lastValueVisible: showPriceLabel, title: showSeriesTitle ? this.seriesTitleForPlot(config, plotDef) : '', visible: isPlotVisible, ...(indicatorPriceFormat ? { priceFormat: indicatorPriceFormat } : {}), + ...(obvSignalScale ? { priceScaleId: obvSignalScale } : {}), }, pane); series.setData(filtered.map(p => ({ time: p.time as Time, value: p.value }))); seriesList.push(series); seriesMeta.push({ plotId: plotDef.id, isHistogram: false, color: plotDef.color }); } + + if (config.type === 'OBV' && seriesList.length > 0) { + this._configureObvDualLinePriceScales(seriesList, seriesMeta); + } } /** plot0·plot1 시리즈 전부 제거 후 DB 설정 스타일로 재생성 */ @@ -3031,7 +3089,9 @@ 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); + opts['lineWidth'] = config.type === 'OBV' && pid === 'plot0' + ? Math.max(2, plot.lineWidth ?? regPlot?.lineWidth ?? 2) + : Math.max(1, plot.lineWidth ?? regPlot?.lineWidth ?? 1); opts['lineStyle'] = plotSeriesLineStyle(plot.lineStyle); let plotAllows = true; if (entry.type === 'IchimokuCloud') { @@ -3049,6 +3109,10 @@ export class ChartManager { series.applyOptions(opts as any); }); + if (config.type === 'OBV' && entry.seriesList.length > 0) { + this._configureObvDualLinePriceScales(entry.seriesList, entry.seriesMeta); + } + // ── hlines(과열선/중앙선/침체선) 동기화 ───────────────────────────────── if (entry.seriesList.length > 0) { const firstSeries = entry.seriesList[0];