From ab5c11fa14777495ad724b3ff08c451baecbe963 Mon Sep 17 00:00:00 2001 From: Macbook Date: Fri, 29 May 2026 21:30:34 +0900 Subject: [PATCH] =?UTF-8?q?=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EC=B0=A8?= =?UTF-8?q?=ED=8A=B8=20=EC=8A=A4=ED=81=AC=EB=A1=A4=EC=8B=9C=20=EB=82=A0?= =?UTF-8?q?=EC=A7=9C=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/goldenchart/entity/GcAppSettings.java | 5 + .../service/AppSettingsService.java | 3 + .../V48__chart_crosshair_info_visible.sql | 3 + frontend/src/App.css | 1 + frontend/src/App.tsx | 7 + .../src/components/CandlePaneTimeAxis.tsx | 107 +++++++++---- frontend/src/components/ChartLegendBar.tsx | 14 +- frontend/src/components/ChartSlot.tsx | 4 + frontend/src/components/PaneLegend.tsx | 11 +- frontend/src/components/SettingsPage.tsx | 28 ++++ frontend/src/components/TradingChart.tsx | 9 +- frontend/src/hooks/useAppSettings.ts | 1 + frontend/src/utils/ChartManager.ts | 141 +++++++++++++----- frontend/src/utils/backendApi.ts | 2 + 14 files changed, 263 insertions(+), 73 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V48__chart_crosshair_info_visible.sql diff --git a/backend/src/main/java/com/goldenchart/entity/GcAppSettings.java b/backend/src/main/java/com/goldenchart/entity/GcAppSettings.java index db998af..6610106 100644 --- a/backend/src/main/java/com/goldenchart/entity/GcAppSettings.java +++ b/backend/src/main/java/com/goldenchart/entity/GcAppSettings.java @@ -112,6 +112,11 @@ public class GcAppSettings { @Builder.Default private Boolean chartLiveReceiveHighlight = true; + /** 크로스헤어 이동 시 OHLC·보조지표 수치 표시 (기본 true) */ + @Column(name = "chart_crosshair_info_visible", nullable = false) + @Builder.Default + private Boolean chartCrosshairInfoVisible = true; + /** 차트 상단 범례(tv-legend) 항목별 표시 옵션 JSON */ @Column(name = "chart_legend_options_json", columnDefinition = "JSON") @JdbcTypeCode(SqlTypes.JSON) diff --git a/backend/src/main/java/com/goldenchart/service/AppSettingsService.java b/backend/src/main/java/com/goldenchart/service/AppSettingsService.java index c876ec3..d022c91 100644 --- a/backend/src/main/java/com/goldenchart/service/AppSettingsService.java +++ b/backend/src/main/java/com/goldenchart/service/AppSettingsService.java @@ -96,6 +96,8 @@ public class AppSettingsService { Boolean.parseBoolean(d.get("chartVolumeVisible").toString())); if (d.containsKey("chartLiveReceiveHighlight")) s.setChartLiveReceiveHighlight( Boolean.parseBoolean(d.get("chartLiveReceiveHighlight").toString())); + if (d.containsKey("chartCrosshairInfoVisible")) s.setChartCrosshairInfoVisible( + Boolean.parseBoolean(d.get("chartCrosshairInfoVisible").toString())); if (d.containsKey("chartLegendOptions")) s.setChartLegendOptionsJson(toJson(d.get("chartLegendOptions"))); if (d.containsKey("chartPaneSeparator")) s.setChartPaneSeparatorJson(toJson(d.get("chartPaneSeparator"))); if (d.containsKey("tradeAlertPopup")) s.setTradeAlertPopup( @@ -199,6 +201,7 @@ public class AppSettingsService { m.put("chartSeriesPriceLabels", s.getChartSeriesPriceLabels() != null ? s.getChartSeriesPriceLabels() : true); m.put("chartVolumeVisible", s.getChartVolumeVisible() != null ? s.getChartVolumeVisible() : true); m.put("chartLiveReceiveHighlight", s.getChartLiveReceiveHighlight() != null ? s.getChartLiveReceiveHighlight() : true); + m.put("chartCrosshairInfoVisible", s.getChartCrosshairInfoVisible() != null ? s.getChartCrosshairInfoVisible() : true); m.put("chartLegendOptions", parseJson(s.getChartLegendOptionsJson())); m.put("chartPaneSeparator", parseJson(s.getChartPaneSeparatorJson())); m.put("tradeAlertPopup", s.getTradeAlertPopup() != null ? s.getTradeAlertPopup() : true); diff --git a/backend/src/main/resources/db/migration/V48__chart_crosshair_info_visible.sql b/backend/src/main/resources/db/migration/V48__chart_crosshair_info_visible.sql new file mode 100644 index 0000000..faf6be6 --- /dev/null +++ b/backend/src/main/resources/db/migration/V48__chart_crosshair_info_visible.sql @@ -0,0 +1,3 @@ +ALTER TABLE gc_app_settings + ADD COLUMN chart_crosshair_info_visible BOOLEAN NOT NULL DEFAULT TRUE + COMMENT '크로스헤어 이동 시 OHLC·보조지표 수치 표시'; diff --git a/frontend/src/App.css b/frontend/src/App.css index 6e95e60..83517f3 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -988,6 +988,7 @@ html.theme-blue { border-top: 1px solid var(--border, rgba(42, 46, 58, 0.85)); background: color-mix(in srgb, var(--bg, #131722) 88%, transparent); overflow: hidden; + will-change: transform; } .candle-pane-time-axis__label { position: absolute; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ff23fda..e85973e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -638,6 +638,7 @@ function App() { const chartVolumeVisible = appDefaults.chartVolumeVisible ?? true; const chartLiveReceiveHighlight = appDefaults.chartLiveReceiveHighlight ?? true; const chartLegendOptions = appDefaults.chartLegendOptions; + const chartCrosshairInfoVisible = appDefaults.chartCrosshairInfoVisible ?? true; const chartPaneSeparator = appDefaults.chartPaneSeparator; const paperTradingEnabled = appDefaults.paperTradingEnabled ?? true; const paperAutoTradeEnabled = appDefaults.paperAutoTradeEnabled ?? false; @@ -1878,6 +1879,8 @@ function App() { chartLegendOptions: { ...chartLegendOptions, ...patch } as unknown as Record, }); }} + chartCrosshairInfoVisible={chartCrosshairInfoVisible} + onChartCrosshairInfoVisible={v => saveAppDef({ chartCrosshairInfoVisible: v })} chartPaneSeparator={chartPaneSeparator} onChartPaneSeparatorChange={opts => { saveAppDef({ chartPaneSeparator: opts }); @@ -2201,6 +2204,7 @@ function App() { chartSeriesPriceLabels={chartSeriesPriceLabels} chartVolumeVisible={chartVolumeVisible} chartPaneSeparator={chartPaneSeparator} + chartCrosshairInfoVisible={chartCrosshairInfoVisible} chartLiveReceiveHighlight={chartLiveReceiveHighlight} chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'} displayTimezone={displayTimezone} @@ -2241,6 +2245,7 @@ function App() { chartSeriesPriceLabels={chartSeriesPriceLabels} chartVolumeVisible={chartVolumeVisible} chartPaneSeparator={chartPaneSeparator} + chartCrosshairInfoVisible={chartCrosshairInfoVisible} chartLiveReceiveHighlight={chartLiveReceiveHighlight} chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'} displayTimezone={displayTimezone} @@ -2257,6 +2262,7 @@ function App() {
1 ? 'none' : undefined }}> syncBacktestMarkersRef.current()} diff --git a/frontend/src/components/CandlePaneTimeAxis.tsx b/frontend/src/components/CandlePaneTimeAxis.tsx index 80ccec6..be1525b 100644 --- a/frontend/src/components/CandlePaneTimeAxis.tsx +++ b/frontend/src/components/CandlePaneTimeAxis.tsx @@ -1,7 +1,10 @@ /** * 캔들 pane 하단 시간축 — 거래량·보조지표가 아래에 있을 때 캔들 영역 바로 아래에도 시간 표시. + * + * 드래그 패닝 중에는 라벨 좌표를 재계산하지 않고 translate3d 로 차트와 동일 픽셀만큼 이동한다. + * (setVisibleLogicalRange 직후 LWC 좌표 API가 한 프레임 늦게 갱신되는 문제 회피) */ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useEffect, useCallback, useRef } from 'react'; import type { ChartManager } from '../utils/ChartManager'; import { subscribeChartTimeFormat } from '../utils/chartTimeFormat'; @@ -12,24 +15,81 @@ interface CandlePaneTimeAxisProps { containerEl: HTMLElement; } -const CandlePaneTimeAxis: React.FC = ({ manager, containerEl }) => { - const [state, setState] = useState>(null); +function applyOverlayLayout( + root: HTMLDivElement, + pool: HTMLSpanElement[], + state: NonNullable>, +): void { + root.style.display = 'flex'; + root.style.top = `${state.topY}px`; + root.style.height = `${state.height ?? AXIS_HEIGHT}px`; + root.style.width = `${state.plotWidth}px`; - const sync = useCallback(() => { - if (!containerEl.isConnected) return; - setState(manager.getCandlePaneTimeAxisOverlay()); + state.labels.forEach((label, i) => { + let span = pool[i]; + if (!span) { + span = document.createElement('span'); + span.className = 'candle-pane-time-axis__label'; + root.appendChild(span); + pool[i] = span; + } + span.style.display = ''; + span.style.left = `${label.x}px`; + span.textContent = label.text; + }); + for (let i = state.labels.length; i < pool.length; i++) { + if (pool[i]) pool[i].style.display = 'none'; + } +} + +const CandlePaneTimeAxis: React.FC = ({ manager, containerEl }) => { + const axisRef = useRef(null); + const labelPoolRef = useRef([]); + + const renderOverlay = useCallback(() => { + const root = axisRef.current; + if (!root || !containerEl.isConnected) return; + + const panActive = manager.isCandleAxisPanActive(); + const panOffset = manager.getCandleAxisPanOffsetPx(); + + if (panActive) { + const state = manager.getCandlePaneTimeAxisOverlay(); + if (!state || state.labels.length === 0) { + root.style.display = 'none'; + return; + } + if (panOffset === 0) { + applyOverlayLayout(root, labelPoolRef.current, state); + } + root.style.transform = `translate3d(${panOffset}px, 0, 0)`; + return; + } + + root.style.transform = ''; + + const state = manager.getCandlePaneTimeAxisOverlay(); + if (!state || state.labels.length === 0) { + root.style.display = 'none'; + for (const span of labelPoolRef.current) { + span.style.display = 'none'; + } + return; + } + + applyOverlayLayout(root, labelPoolRef.current, state); }, [manager, containerEl]); useEffect(() => { - sync(); - const ro = new ResizeObserver(sync); + renderOverlay(); + const ro = new ResizeObserver(renderOverlay); ro.observe(containerEl); - const unsubLayout = manager.subscribePaneLayout(sync); - const unsubRange = manager.subscribeViewport(sync); - const unsubFormat = subscribeChartTimeFormat(sync); + const unsubLayout = manager.subscribePaneLayout(renderOverlay); + const unsubRange = manager.subscribeViewport(renderOverlay); + const unsubFormat = subscribeChartTimeFormat(renderOverlay); - const timers = [0, 50, 150, 400].map(ms => window.setTimeout(sync, ms)); + const timers = [0, 50, 150, 400].map(ms => window.setTimeout(renderOverlay, ms)); return () => { ro.disconnect(); unsubLayout(); @@ -37,30 +97,15 @@ const CandlePaneTimeAxis: React.FC = ({ manager, contai unsubFormat(); timers.forEach(clearTimeout); }; - }, [manager, containerEl, sync]); - - if (!state || state.labels.length === 0) return null; + }, [manager, containerEl, renderOverlay]); return (
- {state.labels.map((label, idx) => ( - - {label.text} - - ))} -
+ /> ); }; diff --git a/frontend/src/components/ChartLegendBar.tsx b/frontend/src/components/ChartLegendBar.tsx index 56e6f62..f3e367d 100644 --- a/frontend/src/components/ChartLegendBar.tsx +++ b/frontend/src/components/ChartLegendBar.tsx @@ -28,6 +28,8 @@ const WsBadge: React.FC<{ status: WsStatus; isUpbit: boolean }> = ({ status, isU export interface ChartLegendBarProps { visibility: ChartLegendVisibility; + /** 크로스헤어 이동 시 OHLC·지표 수치 표시 */ + crosshairInfoVisible?: boolean; symbol: string; timeframe: string; useUpbit: boolean; @@ -43,6 +45,7 @@ export interface ChartLegendBarProps { export const ChartLegendBar: React.FC = ({ visibility: v, + crosshairInfoVisible = true, symbol, timeframe, useUpbit, @@ -54,11 +57,12 @@ export const ChartLegendBar: React.FC = ({ legend, }) => { const ohlcUp = ohlcBar ? ohlcBar.close >= ohlcBar.open : true; - const showOhlc = ohlcBar && (v.open || v.high || v.low || v.close); + const showOhlc = crosshairInfoVisible && ohlcBar && (v.open || v.high || v.low || v.close); const showLiveQuote = (v.close || v.change) && liveQuote.price != null; + const showCrosshairIndicators = crosshairInfoVisible && v.indicators && indicators.length > 0; const showAny = v.symbol || v.timeframe || (useUpbit && v.liveStatus) || (mode === 'trading' && v.tradingBadge) - || showLiveQuote || showOhlc || (v.indicators && indicators.length > 0); + || showLiveQuote || showOhlc || showCrosshairIndicators; if (!showAny) return null; @@ -96,7 +100,7 @@ export const ChartLegendBar: React.FC = ({ )}
- {(showOhlc || (v.indicators && indicators.length > 0)) && ( + {(showOhlc || showCrosshairIndicators) && (
{showOhlc && ( @@ -114,7 +118,7 @@ export const ChartLegendBar: React.FC = ({ )} )} - {v.indicators && indicators.map(ind => { + {showCrosshairIndicators && indicators.map(ind => { const def = getIndicatorDef(ind.type); const vals = legend?.indicatorValues?.[ind.type]; const plots = ind.plots ?? def?.plots ?? []; @@ -128,7 +132,7 @@ export const ChartLegendBar: React.FC = ({ return def ? ( {label} - {v.indicatorValues && vals && vals.length > 0 && ( + {crosshairInfoVisible && v.indicatorValues && vals && vals.length > 0 && ( {vals.map((n, i) => Number.isFinite(n) ? ( diff --git a/frontend/src/components/ChartSlot.tsx b/frontend/src/components/ChartSlot.tsx index 6638c69..110359d 100644 --- a/frontend/src/components/ChartSlot.tsx +++ b/frontend/src/components/ChartSlot.tsx @@ -186,6 +186,8 @@ export interface ChartSlotProps { chartVisible?: boolean; /** pane 구분선 */ chartPaneSeparator?: ChartPaneSeparatorOptions; + /** 크로스헤어 이동 시 OHLC·보조지표 수치 표시 */ + chartCrosshairInfoVisible?: boolean; /** 업비트 실시간 티커 맵 (슬롯 심볼별 현재가·등락) */ marketTickers?: Map; } @@ -212,6 +214,7 @@ const ChartSlot = forwardRef(function ChartSlot chartLiveReceiveHighlight = true, chartVisible = true, chartPaneSeparator, + chartCrosshairInfoVisible = true, marketTickers, }, ref) { // ── 전역 지표 파라미터 + 시각 설정 (DB 동기화) ──────────────────────── @@ -853,6 +856,7 @@ const ChartSlot = forwardRef(function ChartSlot showHoverToolbar={!compactMode} volumeVisible={chartVolumeVisible} paneSeparatorOptions={chartPaneSeparator} + crosshairInfoVisible={chartCrosshairInfoVisible} onCrosshair={data => { setLegend(data); if (data && onCrosshairTime) onCrosshairTime(data.time ?? 0); diff --git a/frontend/src/components/PaneLegend.tsx b/frontend/src/components/PaneLegend.tsx index a72f278..b8d54cc 100644 --- a/frontend/src/components/PaneLegend.tsx +++ b/frontend/src/components/PaneLegend.tsx @@ -78,6 +78,8 @@ export interface PaneLegendProps { focusedId?: string | null; /** 전체화면 → 전체 보기 복원 */ onRestore?: () => void; + /** 크로스헤어 이동 시 보조지표 수치 표시 */ + crosshairInfoVisible?: boolean; } // ── 지그재그 배경색 — 홀짝 2단계로만 번갈아 적용 ─────────────────────────── @@ -328,6 +330,7 @@ const PaneLegend: React.FC = ({ manager, indicators, containerEl, paused = false, onHoverName, onLeaveName, onDoubleClick, onReorder, onMerge, onSplit, onExpand, onRemove, onDuplicate, focusedId, onRestore, + crosshairInfoVisible = true, }) => { const [paneItems, setPaneItems] = useState([]); const [paneLayouts, setPaneLayouts] = useState>([]); @@ -426,12 +429,16 @@ const PaneLegend: React.FC = ({ }, [containerEl, refreshLayouts, syncPaneItems]); useEffect(() => { + if (!crosshairInfoVisible) { + setValues({}); + return; + } const unsub = manager.subscribeCrosshair((p: MouseEventParams - {itemVals.map((v, i) => isNaN(v) ? null : ( + {crosshairInfoVisible && itemVals.map((v, i) => isNaN(v) ? null : ( {formatVal(v)} diff --git a/frontend/src/components/SettingsPage.tsx b/frontend/src/components/SettingsPage.tsx index 5dc8aeb..c118fab 100644 --- a/frontend/src/components/SettingsPage.tsx +++ b/frontend/src/components/SettingsPage.tsx @@ -118,6 +118,8 @@ interface SettingsPageProps { onChartLiveReceiveHighlight?: (v: boolean) => void; chartLegendOptions?: ChartLegendVisibility; onChartLegendOptionsChange?: (patch: Partial) => void; + chartCrosshairInfoVisible?: boolean; + onChartCrosshairInfoVisible?: (v: boolean) => void; chartPaneSeparator?: ChartPaneSeparatorOptions; onChartPaneSeparatorChange?: (opts: ChartPaneSeparatorOptions) => void; paperTradingEnabled?: boolean; @@ -672,6 +674,8 @@ interface ChartPanelProps { onChartLiveReceiveHighlight?: (v: boolean) => void; chartLegendOptions?: ChartLegendVisibility; onChartLegendOptionsChange?: (patch: Partial) => void; + chartCrosshairInfoVisible?: boolean; + onChartCrosshairInfoVisible?: (v: boolean) => void; chartPaneSeparator?: ChartPaneSeparatorOptions; onChartPaneSeparatorChange?: (opts: ChartPaneSeparatorOptions) => void; chartTimeFormat?: string; @@ -690,6 +694,8 @@ const ChartPanel: React.FC = ({ onChartLiveReceiveHighlight, chartLegendOptions, onChartLegendOptionsChange, + chartCrosshairInfoVisible = true, + onChartCrosshairInfoVisible, chartPaneSeparator, onChartPaneSeparatorChange, chartTimeFormat = 'MM-dd HH:mm', @@ -881,6 +887,24 @@ const ChartPanel: React.FC = ({ )} + {chartLegendOptions && onChartLegendOptionsChange && ( + + + + + + )} + {chartLegendOptions && onChartLegendOptionsChange && ( {CHART_LEGEND_SETTING_ITEMS.map(({ key, label, desc }) => ( @@ -1571,6 +1595,8 @@ const SettingsPage: React.FC = ({ onChartLiveReceiveHighlight, chartLegendOptions, onChartLegendOptionsChange, + chartCrosshairInfoVisible = true, + onChartCrosshairInfoVisible, chartPaneSeparator, onChartPaneSeparatorChange, tradeAlertSoundEnabled = true, @@ -1667,6 +1693,8 @@ const SettingsPage: React.FC = ({ onChartLiveReceiveHighlight={onChartLiveReceiveHighlight} chartLegendOptions={chartLegendOptions} onChartLegendOptionsChange={onChartLegendOptionsChange} + chartCrosshairInfoVisible={chartCrosshairInfoVisible} + onChartCrosshairInfoVisible={onChartCrosshairInfoVisible} chartPaneSeparator={chartPaneSeparator} onChartPaneSeparatorChange={onChartPaneSeparatorChange} chartTimeFormat={chartTimeFormat} diff --git a/frontend/src/components/TradingChart.tsx b/frontend/src/components/TradingChart.tsx index a1db2a7..f0c2458 100644 --- a/frontend/src/components/TradingChart.tsx +++ b/frontend/src/components/TradingChart.tsx @@ -137,6 +137,8 @@ interface TradingChartProps { paneLayoutClamp?: boolean; /** 보조지표 pane 레전드(이름·값 오버레이) 표시 (기본 true, 알림 미니차트는 false) */ showPaneLegend?: boolean; + /** 크로스헤어 이동 시 OHLC·보조지표 수치 표시 (기본 true) */ + crosshairInfoVisible?: boolean; } const TradingChart: React.FC = ({ @@ -170,6 +172,7 @@ const TradingChart: React.FC = ({ paneSeparatorOptions, paneLayoutClamp = false, showPaneLegend = true, + crosshairInfoVisible = true, }) => { const containerRef = useRef(null); const wrapperRef = useRef(null); // 스크롤 래퍼 @@ -859,7 +862,10 @@ const TradingChart: React.FC = ({ const onPanPointerUp = () => { if (!panStateRef.current.active) return; - if (panStateRef.current.moved) suppressClickRef.current = true; + if (panStateRef.current.moved) { + suppressClickRef.current = true; + } + managerRef.current?.endCandleAxisPan(); panStateRef.current.active = false; container.style.cursor = canPanRef.current ? 'grab' : ''; }; @@ -1423,6 +1429,7 @@ const TradingChart: React.FC = ({ onDuplicate={onDuplicateIndicator} focusedId={focusedIndicatorId} onRestore={onRestoreIndicators} + crosshairInfoVisible={crosshairInfoVisible} /> )} diff --git a/frontend/src/hooks/useAppSettings.ts b/frontend/src/hooks/useAppSettings.ts index de22ba9..2e9db40 100644 --- a/frontend/src/hooks/useAppSettings.ts +++ b/frontend/src/hooks/useAppSettings.ts @@ -145,6 +145,7 @@ export function resolveAppDefaults(s: AppSettingsDto) { chartSeriesPriceLabels: s.chartSeriesPriceLabels ?? true, chartVolumeVisible: s.chartVolumeVisible ?? true, chartLiveReceiveHighlight: s.chartLiveReceiveHighlight ?? true, + chartCrosshairInfoVisible: s.chartCrosshairInfoVisible ?? true, chartLegendOptions: resolveChartLegendOptions( s.chartLegendOptions as Partial | null | undefined, ), diff --git a/frontend/src/utils/ChartManager.ts b/frontend/src/utils/ChartManager.ts index bdc3596..e2af1ae 100644 --- a/frontend/src/utils/ChartManager.ts +++ b/frontend/src/utils/ChartManager.ts @@ -418,6 +418,7 @@ export class ChartManager { } else { ts.fitContent(); } + this._notifyViewport(); return; } @@ -430,6 +431,7 @@ export class ChartManager { from: bars[startIdx].time as Time, to: to as Time, }); + this._notifyViewport(); } private _createMainSeries(chartType: ChartType, t: ThemeTokens): ISeriesApi { @@ -1929,11 +1931,83 @@ export class ChartManager { } } + /** 가시 시간 범위 변경 알림 (커스텀 패닝·줌 등 LWC 이벤트 미발생 경로 포함) */ + private readonly _viewportListeners = new Set<() => void>(); + private _viewportLwcHooked = false; + private readonly _lwcViewportHandler = (): void => { this._notifyViewport(); }; + + /** 캔들 pane 시간축 — 드래그 패닝 중 transform 동기화용 */ + private _candleAxisPanActive = false; + private _candleAxisPanOffsetPx = 0; + + private _notifyViewport(): void { + for (const cb of this._viewportListeners) { + try { cb(); } catch { /* ok */ } + } + } + + private _hookViewportLwc(): void { + if (this._viewportLwcHooked) return; + this._viewportLwcHooked = true; + this.chart.timeScale().subscribeVisibleTimeRangeChange(this._lwcViewportHandler); + this.chart.timeScale().subscribeVisibleLogicalRangeChange(this._lwcViewportHandler); + } + + private _unhookViewportLwc(): void { + if (!this._viewportLwcHooked) return; + this._viewportLwcHooked = false; + try { this.chart.timeScale().unsubscribeVisibleTimeRangeChange(this._lwcViewportHandler); } catch { /* ok */ } + try { this.chart.timeScale().unsubscribeVisibleLogicalRangeChange(this._lwcViewportHandler); } catch { /* ok */ } + } + + /** 논리 범위 변경 + 오버레이(시간축·드로잉) 동기화 */ + private _setVisibleLogicalRange(range: { from: number; to: number }): void { + try { + this.chart.timeScale().setVisibleLogicalRange(range); + this._notifyViewport(); + } catch { /* 데이터 로드 전 */ } + } + /** PaneLegend 등 — 보조지표 pane DOM 배치가 끝난 뒤 레이블 위치 재동기화 */ notifyPaneLayoutChanged(): void { this._notifyPaneLayout(); } + /** 시간축 오버레이 등 — 뷰포트 동기화 강제 (패닝 종료 시 등) */ + notifyViewportChanged(): void { + this._notifyViewport(); + } + + /** 캔들 pane 시간축 드래그 패닝 종료 — 라벨 위치를 최종 논리 범위로 재정렬 */ + endCandleAxisPan(): void { + if (!this._candleAxisPanActive) return; + this._candleAxisPanActive = false; + this._candleAxisPanOffsetPx = 0; + this._notifyViewport(); + } + + isCandleAxisPanActive(): boolean { + return this._candleAxisPanActive; + } + + getCandleAxisPanOffsetPx(): number { + return this._candleAxisPanOffsetPx; + } + + private _ensureCandleAxisPan(): void { + if (this._candleAxisPanActive) return; + this._candleAxisPanActive = true; + this._candleAxisPanOffsetPx = 0; + this._notifyViewport(); + } + + /** visible logical range → 플롯 X (LWC 좌표 API와 동일한 선형 매핑) */ + private _logicalToAxisX(logical: number, lr: { from: number; to: number }, plotWidth: number): number { + const span = lr.to - lr.from; + if (!Number.isFinite(span) || span <= 0) return 0; + return ((logical - lr.from) / span) * plotWidth; + } + /** * 차트 컨테이너 기준 각 pane 의 topY 와 height 배열 반환. * IPaneApi.getHTMLElement() 로 pane index ↔ 화면 위치를 직접 매칭한다. @@ -2340,7 +2414,7 @@ export class ChartManager { const span = lr.to - lr.from; const center = (lr.from + lr.to) / 2; const newSpan = Math.max(span * 0.8, 5); // 최소 5바 - ts.setVisibleLogicalRange({ from: center - newSpan / 2, to: center + newSpan / 2 }); + this._setVisibleLogicalRange({ from: center - newSpan / 2, to: center + newSpan / 2 }); } /** 시간축 축소 (visible logical range 20% 확대) */ @@ -2351,7 +2425,7 @@ export class ChartManager { const span = lr.to - lr.from; const center = (lr.from + lr.to) / 2; const newSpan = span * 1.25; - ts.setVisibleLogicalRange({ from: center - newSpan / 2, to: center + newSpan / 2 }); + this._setVisibleLogicalRange({ from: center - newSpan / 2, to: center + newSpan / 2 }); } /** 시간축 왼쪽(과거)으로 스크롤 — visible 범위의 20%씩 이동 */ @@ -2360,7 +2434,7 @@ export class ChartManager { const lr = ts.getVisibleLogicalRange(); if (!lr) return; const shift = (lr.to - lr.from) * 0.2; - ts.setVisibleLogicalRange({ from: lr.from - shift, to: lr.to - shift }); + this._setVisibleLogicalRange({ from: lr.from - shift, to: lr.to - shift }); } /** 시간축 오른쪽(미래)으로 스크롤 — visible 범위의 20%씩 이동 */ @@ -2369,7 +2443,7 @@ export class ChartManager { const lr = ts.getVisibleLogicalRange(); if (!lr) return; const shift = (lr.to - lr.from) * 0.2; - ts.setVisibleLogicalRange({ from: lr.from + shift, to: lr.to + shift }); + this._setVisibleLogicalRange({ from: lr.from + shift, to: lr.to + shift }); } /** 캔들 전용(보조지표·거래량 숨김) 레이아웃 적용 중 여부 */ @@ -2678,25 +2752,23 @@ export class ChartManager { const lr = ts.getVisibleLogicalRange(); if (!lr) return null; - const plotWidth = this._candlePlotWidth(); - const from = Math.max(0, Math.ceil(lr.from)); - const to = Math.min(this.rawBars.length - 1, Math.floor(lr.to)); - if (to <= from) return null; + const plotWidth = Math.max(40, ts.width()); + const lrSpan = lr.to - lr.from; + if (!Number.isFinite(lrSpan) || lrSpan <= 0) return null; - const span = to - from; const targetCount = Math.max(4, Math.min(12, Math.floor(plotWidth / 76))); - const step = Math.max(1, Math.ceil(span / targetCount)); + 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 startLogical = Math.max(0, Math.ceil(lr.from)); + const endLogical = Math.min(this.rawBars.length - 1, Math.floor(lr.to)); - for (let i = from; i <= to; i += step) { - const bar = this.rawBars[i]; + for (let logical = startLogical; logical <= endLogical; logical += step) { + const bar = this.rawBars[logical]; if (!bar?.time) continue; - const coord = ts.timeToCoordinate(bar.time as Time); - if (coord == null) continue; - const x = Number(coord); + const x = this._logicalToAxisX(logical, lr, plotWidth); if (!Number.isFinite(x) || x < 12 || x > plotWidth - 12) continue; labels.push({ x, @@ -2791,15 +2863,16 @@ export class ChartManager { options?: { allowVerticalPan?: boolean }, ): void { if (deltaX !== 0) { + this._ensureCandleAxisPan(); + this._candleAxisPanOffsetPx += deltaX; + const ts = this.chart.timeScale(); const lr = ts.getVisibleLogicalRange(); if (lr) { - const w = Math.max(1, this.container.clientWidth); + const w = Math.max(1, ts.width()); const span = lr.to - lr.from; const dLog = (deltaX / w) * span; - try { - ts.setVisibleLogicalRange({ from: lr.from - dLog, to: lr.to - dLog }); - } catch { /* 데이터 로드 전 */ } + this._setVisibleLogicalRange({ from: lr.from - dLog, to: lr.to - dLog }); } } @@ -2901,6 +2974,7 @@ export class ChartManager { if (this.rawBars.length === 0) return; try { this.chart.timeScale().setVisibleRange({ from: from as Time, to: to as Time }); + this._notifyViewport(); } catch { /* 데이터 로딩 전 호출 무시 */ } } @@ -2910,9 +2984,7 @@ export class ChartManager { */ applyVisibleLogicalRange(from: number, to: number): void { if (this.rawBars.length === 0) return; - try { - this.chart.timeScale().setVisibleLogicalRange({ from, to }); - } catch { /* 데이터 로딩 전 호출 무시 */ } + this._setVisibleLogicalRange({ from, to }); } /** Time sync: 가시 시간 범위 변화 구독. unsubscribe fn 반환 */ @@ -2949,7 +3021,10 @@ export class ChartManager { const t0 = this.xToTime(Math.min(x0, x1)); const t1 = this.xToTime(Math.max(x0, x1)); if (t0 === null || t1 === null || t0 >= t1) return; - this.chart.timeScale().setVisibleRange({ from: t0 as import('lightweight-charts').Time, to: t1 as import('lightweight-charts').Time }); + try { + this.chart.timeScale().setVisibleRange({ from: t0 as import('lightweight-charts').Time, to: t1 as import('lightweight-charts').Time }); + this._notifyViewport(); + } catch { /* ok */ } } /** @@ -3089,12 +3164,10 @@ export class ChartManager { // 가시 논리 범위 복원: 앞에 추가된 bar 수만큼 오른쪽으로 이동 if (logicalRange) { - try { - this.chart.timeScale().setVisibleLogicalRange({ - from: logicalRange.from + newBars.length, - to: logicalRange.to + newBars.length, - }); - } catch { /* 무시 */ } + this._setVisibleLogicalRange({ + from: logicalRange.from + newBars.length, + to: logicalRange.to + newBars.length, + }); } // 보조지표 전체 재계산 (새 데이터가 왼쪽에 추가됐으므로 전체 갱신 필요) @@ -3309,13 +3382,13 @@ export class ChartManager { return p; } - /** Subscribe to visible range changes (time scale + price scale). Returns unsubscribe fn. */ + /** 가시 시간 범위 변경 구독 (LWC 네이티브 스크롤 + 커스텀 패닝). unsubscribe fn 반환 */ subscribeViewport(cb: () => void): () => void { - this.chart.timeScale().subscribeVisibleTimeRangeChange(cb); - this.chart.timeScale().subscribeVisibleLogicalRangeChange(cb); + this._viewportListeners.add(cb); + this._hookViewportLwc(); return () => { - try { this.chart.timeScale().unsubscribeVisibleTimeRangeChange(cb); } catch { /* ok */ } - try { this.chart.timeScale().unsubscribeVisibleLogicalRangeChange(cb); } catch { /* ok */ } + this._viewportListeners.delete(cb); + if (this._viewportListeners.size === 0) this._unhookViewportLwc(); }; } diff --git a/frontend/src/utils/backendApi.ts b/frontend/src/utils/backendApi.ts index 9af5dd7..8a06da6 100644 --- a/frontend/src/utils/backendApi.ts +++ b/frontend/src/utils/backendApi.ts @@ -392,6 +392,8 @@ export interface AppSettingsDto { chartVolumeVisible?: boolean; /** 실시간 수신 시 카드 아웃라인 형광 연두 음영 (기본 true) */ chartLiveReceiveHighlight?: boolean; + /** 크로스헤어 이동 시 OHLC·보조지표 수치 표시 (기본 true) */ + chartCrosshairInfoVisible?: boolean; /** 차트 상단 범례(tv-legend) 항목별 표시 옵션 */ chartLegendOptions?: Record | null; /** 차트 pane(캔들·거래량·보조지표) 구분선 */