From 4d5bf243b580a132c4fc3a2eb7010e11b998fe2e Mon Sep 17 00:00:00 2001 From: Macbook Date: Sun, 21 Jun 2026 23:34:53 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B3=B4=EC=A1=B0=EC=A7=80=ED=91=9C=20?= =?UTF-8?q?=EC=B0=A8=ED=8A=B8=20=EC=9A=B0=EC=B8=A1=20=EB=9D=BC=EB=B2=A8=20?= =?UTF-8?q?=EB=A7=88=EC=9A=B0=EC=8A=A4=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20?= =?UTF-8?q?=ED=99=95=EB=8C=80=EC=B6=95=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/TradingChart.tsx | 4 +- frontend/src/utils/ChartManager.ts | 143 ++++++++++++++++++++--- 2 files changed, 130 insertions(+), 17 deletions(-) diff --git a/frontend/src/components/TradingChart.tsx b/frontend/src/components/TradingChart.tsx index a10cf0e..5ac640c 100644 --- a/frontend/src/components/TradingChart.tsx +++ b/frontend/src/components/TradingChart.tsx @@ -1215,11 +1215,11 @@ const TradingChart: React.FC = ({ const rect = container.getBoundingClientRect(); const chartX = e.clientX - rect.left; const chartY = e.clientY - rect.top; - if (!m.isCandlePanePriceAxis(chartX, chartY)) return; + if (!m.isOnPriceAxis(chartX, chartY)) return; e.preventDefault(); e.stopPropagation(); - m.zoomMainPriceByWheel(e.deltaY, chartY); + m.zoomPriceAxisByWheel(chartX, chartY, e.deltaY); }; container.addEventListener('wheel', onWheelCapture, { capture: true, passive: false }); diff --git a/frontend/src/utils/ChartManager.ts b/frontend/src/utils/ChartManager.ts index 7daa333..6172830 100644 --- a/frontend/src/utils/ChartManager.ts +++ b/frontend/src/utils/ChartManager.ts @@ -4277,36 +4277,139 @@ export class ChartManager { return this.getPaneIndexAtChartY(chartY) === 0 && this.isOnPriceAxis(chartX, chartY); } + /** 우측 가격축 라벨 영역 휠 — 해당 pane(캔들·거래량·보조지표) 세로 줌 */ + zoomPriceAxisByWheel(chartX: number, chartY: number, deltaY: number): boolean { + if (deltaY === 0 || !this.isOnPriceAxis(chartX, chartY)) return false; + const paneIndex = this.getPaneIndexAtChartY(chartY); + return this._zoomPanePriceScaleByWheel(paneIndex, deltaY, chartY); + } + /** * 캔들 pane(0) 우측 가격축 휠 — pane 높이는 유지, 플롯 내 가격 범위만 확대/축소. * wheel up → 줌 인(캔들·그래프가 세로로 커 보임), wheel down → 줌 아웃. */ zoomMainPriceByWheel(deltaY: number, anchorChartY?: number): boolean { if (!this.mainSeries || deltaY === 0) return false; + return this._zoomPanePriceScaleByWheel(0, deltaY, anchorChartY); + } - const main = this.getPaneLayouts().find(l => l.paneIndex === 0); - if (!main || main.height <= 0) return false; + /** 거래량·보조지표 pane 우측 가격축 휠 줌 */ + zoomPanePriceByWheel(paneIndex: number, deltaY: number, anchorChartY?: number): boolean { + if (paneIndex === 0) return this.zoomMainPriceByWheel(deltaY, anchorChartY); + return this._zoomPanePriceScaleByWheel(paneIndex, deltaY, anchorChartY); + } - let range = this._mainPriceVisibleRange ?? this._readMainPriceRange(); + private _getPriceScaleForPane(paneIndex: number) { + return paneIndex === 1 + ? this.chart.priceScale('volume', 1) + : this.chart.priceScale('right', paneIndex); + } + + private _getRepresentativeSeriesForPane(paneIndex: number): ISeriesApi | null { + if (paneIndex === 0) return this.mainSeries; + if (paneIndex === 1 && this.volumeSeries) { + try { + const vis = (this.volumeSeries.options() as { visible?: boolean }).visible; + if (vis !== false) return this.volumeSeries; + } catch { + return this.volumeSeries; + } + } + for (const entry of this.indicators.values()) { + if (entry.paneIndex === paneIndex && entry.seriesList.length > 0) { + return entry.seriesList[0]; + } + } + if (paneIndex === 1) return this.volumeSeries; + return null; + } + + private _readPanePriceRange(paneIndex: number): { from: number; to: number } | null { + if (paneIndex === 0) return this._readMainPriceRange(); + + try { + const existing = this._getPriceScaleForPane(paneIndex).getVisibleRange(); + if (existing) return { from: existing.from, to: existing.to }; + } catch { /* fall through */ } + + const series = this._getRepresentativeSeriesForPane(paneIndex); + const layout = this.getPaneLayouts().find(l => l.paneIndex === paneIndex); + if (!series || !layout || layout.height <= 0) return null; + + const pTop = series.coordinateToPrice(0); + const pBot = series.coordinateToPrice(layout.height); + if (pTop === null || pBot === null) return null; + return { from: Math.min(pTop, pBot), to: Math.max(pTop, pBot) }; + } + + private _preparePaneManualPriceScale(paneIndex: number): void { + if (paneIndex === 0) { + if (!this.mainSeries) return; + this.mainSeries.priceScale().setAutoScale(false); + this.mainSeries.applyOptions({ autoscaleInfoProvider: undefined }); + return; + } + + try { + this._getPriceScaleForPane(paneIndex).setAutoScale(false); + } catch { /* ok */ } + + if (paneIndex === 1 && this.volumeSeries) { + try { + this.volumeSeries.applyOptions({ autoscaleInfoProvider: undefined }); + } catch { /* ok */ } + } + + for (const entry of this.indicators.values()) { + if (entry.paneIndex !== paneIndex) continue; + for (const series of entry.seriesList) { + try { + series.applyOptions({ autoscaleInfoProvider: undefined }); + } catch { /* ok */ } + } + } + } + + private _zoomPanePriceScaleByWheel( + paneIndex: number, + deltaY: number, + anchorChartY?: number, + ): boolean { + if (deltaY === 0) return false; + if (paneIndex === 0 && !this.mainSeries) return false; + + const layout = this.getPaneLayouts().find(l => l.paneIndex === paneIndex); + if (!layout || layout.height <= 0) return false; + + const stored = paneIndex === 0 + ? this._mainPriceVisibleRange + : this._panePriceVisibleRanges.get(paneIndex); + let range = stored ?? this._readPanePriceRange(paneIndex); 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 }; + if (!stored) { + this._preparePaneManualPriceScale(paneIndex); + if (paneIndex === 0) { + this._mainPriceVisibleRange = { ...range }; + } else { + this._panePriceVisibleRanges.set(paneIndex, { ...range }); + } } + const series = this._getRepresentativeSeriesForPane(paneIndex); 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; + ? Math.max(0, Math.min(layout.height, anchorChartY - layout.topY)) + : layout.height / 2; + let anchorPrice = (range.from + range.to) / 2; + if (series) { + const rawAnchor = series.coordinateToPrice(paneLocalY); + if (rawAnchor != null && Number.isFinite(rawAnchor)) { + anchorPrice = rawAnchor; + } + } const factor = Math.exp(deltaY * 0.0012); const newSpan = Math.max(span * 1e-6, span * factor); @@ -4319,8 +4422,16 @@ export class ChartManager { to: anchorPrice + (1 - ratio) * clampedSpan, }; - this._mainPriceVisibleRange = next; + if (paneIndex === 0) { + this._mainPriceVisibleRange = next; + } else { + this._panePriceVisibleRanges.set(paneIndex, next); + } + try { + const ps = paneIndex === 0 && this.mainSeries + ? this.mainSeries.priceScale() + : this._getPriceScaleForPane(paneIndex); ps.setVisibleRange(next); return true; } catch { @@ -4357,6 +4468,8 @@ export class ChartManager { /** 캔들 pane 수동 가격 범위 (세로 패닝용) */ private _mainPriceVisibleRange: { from: number; to: number } | null = null; + /** 보조지표·거래량 pane 수동 가격 범위 (휠 줌용) */ + private _panePriceVisibleRanges = new Map(); private _readMainPriceRange(): { from: number; to: number } | null { if (!this.mainSeries) return null;