From 173b47d485f4d16051eab62e43cafc39e6cb6ce0 Mon Sep 17 00:00:00 2001 From: Macbook Date: Tue, 26 May 2026 01:16:36 +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=EB=A9=80=ED=8B=B0=EB=AA=A8=EB=93=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= 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 + .../V30__chart_live_receive_highlight.sql | 9 + frontend/src/App.css | 55 +++++- frontend/src/App.tsx | 5 + frontend/src/components/ChartSlot.tsx | 70 +++++--- frontend/src/components/MarketSearchPanel.tsx | 169 +++++++++++------- frontend/src/components/SettingsPage.tsx | 23 +++ .../src/components/VirtualTradingPage.tsx | 2 + .../components/virtual/VirtualTargetCard.tsx | 7 +- .../virtual/VirtualTargetFocusView.tsx | 7 +- .../components/virtual/VirtualTargetGrid.tsx | 4 + frontend/src/hooks/useAppSettings.ts | 1 + frontend/src/utils/ChartManager.ts | 61 +++---- frontend/src/utils/backendApi.ts | 2 + 15 files changed, 302 insertions(+), 121 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V30__chart_live_receive_highlight.sql diff --git a/backend/src/main/java/com/goldenchart/entity/GcAppSettings.java b/backend/src/main/java/com/goldenchart/entity/GcAppSettings.java index 88e44d9..b98a2d8 100644 --- a/backend/src/main/java/com/goldenchart/entity/GcAppSettings.java +++ b/backend/src/main/java/com/goldenchart/entity/GcAppSettings.java @@ -97,6 +97,11 @@ public class GcAppSettings { @Builder.Default private Boolean chartVolumeVisible = true; + /** 실시간 수신 시 카드 아웃라인 형광 연두 음영 (기본 true) */ + @Column(name = "chart_live_receive_highlight", nullable = false) + @Builder.Default + private Boolean chartLiveReceiveHighlight = 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 6f7096e..0fdc53f 100644 --- a/backend/src/main/java/com/goldenchart/service/AppSettingsService.java +++ b/backend/src/main/java/com/goldenchart/service/AppSettingsService.java @@ -92,6 +92,8 @@ public class AppSettingsService { Boolean.parseBoolean(d.get("chartSeriesPriceLabels").toString())); if (d.containsKey("chartVolumeVisible")) s.setChartVolumeVisible( Boolean.parseBoolean(d.get("chartVolumeVisible").toString())); + if (d.containsKey("chartLiveReceiveHighlight")) s.setChartLiveReceiveHighlight( + Boolean.parseBoolean(d.get("chartLiveReceiveHighlight").toString())); if (d.containsKey("chartLegendOptions")) s.setChartLegendOptionsJson(toJson(d.get("chartLegendOptions"))); if (d.containsKey("tradeAlertPopup")) s.setTradeAlertPopup( Boolean.parseBoolean(d.get("tradeAlertPopup").toString())); @@ -179,6 +181,7 @@ public class AppSettingsService { m.put("btShowPrice", s.getBtShowPrice() != null ? s.getBtShowPrice() : true); 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("chartLegendOptions", parseJson(s.getChartLegendOptionsJson())); m.put("tradeAlertPopup", s.getTradeAlertPopup() != null ? s.getTradeAlertPopup() : true); m.put("tradeAlertSoundEnabled", s.getTradeAlertSoundEnabled() != null ? s.getTradeAlertSoundEnabled() : true); diff --git a/backend/src/main/resources/db/migration/V30__chart_live_receive_highlight.sql b/backend/src/main/resources/db/migration/V30__chart_live_receive_highlight.sql new file mode 100644 index 0000000..fe59a97 --- /dev/null +++ b/backend/src/main/resources/db/migration/V30__chart_live_receive_highlight.sql @@ -0,0 +1,9 @@ +-- ============================================================ +-- V30: gc_app_settings — 실시간 수신 카드 음영(형광 연두 아웃라인) +-- ON(1, 기본값): 수신 시 카드박스 아웃라인 glow +-- OFF(0) : 음영 효과 비표시 +-- ============================================================ + +ALTER TABLE gc_app_settings + ADD COLUMN chart_live_receive_highlight TINYINT(1) NOT NULL DEFAULT 1 + COMMENT '실시간 수신 시 카드 아웃라인 형광 연두 음영 (1=ON, 0=OFF)'; diff --git a/frontend/src/App.css b/frontend/src/App.css index 847f571..2cb4ab6 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1519,6 +1519,37 @@ html.theme-blue { /* top/left/width/height 는 JS 인라인 스타일로 지정됨 */ } +.msp-panel--dropdown { + border-radius: 8px; + box-shadow: 0 12px 36px rgba(0, 0, 0, 0.48); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.msp-header--compact, +.msp-item--compact { + grid-template-columns: minmax(160px, 1fr) 92px 68px; +} + +.msp-header--compact .msp-col-name { + min-width: 0; +} + +.msp-item--compact .msp-col-name { + flex-direction: row; + align-items: center; + min-width: 0; +} + +.msp-item--compact .msp-kor { + font-size: 13px; + font-weight: 600; + overflow: visible; + text-overflow: clip; + white-space: nowrap; +} + /* 검색 입력 행 */ .msp-search-row { display: flex; @@ -4256,22 +4287,38 @@ html.theme-blue { } .slot-compact-name { + display: inline-flex; + align-items: center; + gap: 5px; flex: 0 1 auto; - max-width: 38%; + max-width: 42%; background: none; - border: none; - padding: 0; + border: 1px solid transparent; + border-radius: 6px; + padding: 2px 6px; + margin: -2px -6px; cursor: pointer; color: var(--text, #d1d4dc); font-size: 13px; font-weight: 700; white-space: nowrap; overflow: hidden; - text-overflow: ellipsis; text-align: left; + transition: background 0.12s, border-color 0.12s, color 0.12s; +} +.slot-compact-name-text { + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} +.slot-compact-name:hover { + background: color-mix(in srgb, var(--accent, #2962ff) 8%, transparent); + border-color: color-mix(in srgb, var(--accent, #2962ff) 22%, transparent); } .slot-compact-name.open { color: var(--accent, #2962ff); + background: color-mix(in srgb, var(--accent, #2962ff) 12%, transparent); + border-color: color-mix(in srgb, var(--accent, #2962ff) 35%, transparent); } .slot-compact-quote { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9d5c89f..1d02d50 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -581,6 +581,7 @@ function App() { const chartSeriesPriceLabels = appDefaults.chartSeriesPriceLabels ?? true; const chartVolumeVisible = appDefaults.chartVolumeVisible ?? true; + const chartLiveReceiveHighlight = appDefaults.chartLiveReceiveHighlight ?? true; const chartLegendOptions = appDefaults.chartLegendOptions; const paperTradingEnabled = appDefaults.paperTradingEnabled ?? true; const paperAutoTradeEnabled = appDefaults.paperAutoTradeEnabled ?? false; @@ -1700,6 +1701,8 @@ function App() { managerRef.current?.setVolumeVisible(v, wrapper?.clientHeight); slotRefs.current.forEach(slot => slot?.setVolumeVisible(v)); }} + chartLiveReceiveHighlight={chartLiveReceiveHighlight} + onChartLiveReceiveHighlight={v => saveAppDef({ chartLiveReceiveHighlight: v })} chartLegendOptions={chartLegendOptions} onChartLegendOptionsChange={patch => { saveAppDef({ @@ -2002,6 +2005,7 @@ function App() { onTradeOrderRequest={(req, idx) => applyTradeFill(req, idx)} chartSeriesPriceLabels={chartSeriesPriceLabels} chartVolumeVisible={chartVolumeVisible} + chartLiveReceiveHighlight={chartLiveReceiveHighlight} chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'} displayTimezone={displayTimezone} compactMode @@ -2038,6 +2042,7 @@ function App() { onTradeOrderRequest={(req, idx) => applyTradeFill(req, idx)} chartSeriesPriceLabels={chartSeriesPriceLabels} chartVolumeVisible={chartVolumeVisible} + chartLiveReceiveHighlight={chartLiveReceiveHighlight} chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'} displayTimezone={displayTimezone} compactMode diff --git a/frontend/src/components/ChartSlot.tsx b/frontend/src/components/ChartSlot.tsx index c77c046..966bfcf 100644 --- a/frontend/src/components/ChartSlot.tsx +++ b/frontend/src/components/ChartSlot.tsx @@ -24,6 +24,7 @@ import { useLiveReceiveFlash } from '../hooks/useLiveReceiveFlash'; import VirtualLiveBadge from './virtual/VirtualLiveBadge'; import type { VirtualLiveStatus } from '../hooks/useVirtualTargetLiveStatus'; import { invalidateMarketCache } from '../utils/requestCache'; +import { DISPLAY_COUNT } from '../hooks/useUpbitData'; import { useHistoryLoader, LOAD_MORE_TRIGGER } from '../hooks/useHistoryLoader'; import { useIndicatorSettings } from '../hooks/useIndicatorSettings'; import { @@ -162,6 +163,8 @@ export interface ChartSlotProps { displayTimezone?: string; /** 멀티차트 — 가상투자 카드 차트처럼 컴팩트 카드 UI */ compactMode?: boolean; + /** 실시간 수신 시 카드 아웃라인 형광 연두 음영 (설정 연동) */ + chartLiveReceiveHighlight?: boolean; } const ChartSlot = forwardRef(function ChartSlot({ @@ -183,6 +186,7 @@ const ChartSlot = forwardRef(function ChartSlot chartRealtimeSource = 'BACKEND_STOMP', displayTimezone, compactMode = false, + chartLiveReceiveHighlight = true, }, ref) { // ── 전역 지표 파라미터 + 시각 설정 (DB 동기화) ──────────────────────── const { getParams, saveParams, getVisualConfig, saveVisual } = useIndicatorSettings(); @@ -200,10 +204,11 @@ const ChartSlot = forwardRef(function ChartSlot const [showMainChartModal, setShowMainChartModal] = useState(false); const [mainChartStyle, setMainChartStyle] = useState(DEFAULT_MAIN_CHART_STYLE); const [showMarket, setShowMarket] = useState(false); - // 슬롯 컨테이너 rect: MarketSearchPanel 위치 계산에 사용 + // MarketSearchPanel 위치: compact=종목명 버튼, 일반=슬롯 전체 const [slotAnchorRect, setSlotAnchorRect] = useState(null); const managerRef = useRef(null); + const compactNameRef = useRef(null); // initialSymbol 도 초기값으로 포함: requestAnimationFrame 이전에 올바른 값 보장 const symbolRef = useRef(initialSymbol ?? forcedSymbol ?? 'KRW-BTC'); const timeframeRef = useRef(forcedTf ?? '1D'); @@ -362,6 +367,8 @@ const ChartSlot = forwardRef(function ChartSlot // → onDataLoaded 콜백에서 재적용 (멀티차트 첫 마운트 시 타이밍 문제 해결) const pendingSyncVisibleRef = useRef<{ from: number; to: number } | null>(null); const pendingSyncLogicalRef = useRef<{ from: number; to: number } | null>(null); + /** 종목·TF 전환 직후 sync range 재적용 스킵 (이전 종목 뷰포트가 새 차트에 적용되는 것 방지) */ + const skipSyncApplyRef = useRef(false); // ── Crosshair sync: syncTime → setCrosshairAtTime ──────────────────── useEffect(() => { @@ -371,7 +378,7 @@ const ChartSlot = forwardRef(function ChartSlot // ── Time sync: syncVisibleRange 외부 적용 ──────────────────────────── useEffect(() => { - if (!syncVisibleRange) return; + if (!syncVisibleRange || skipSyncApplyRef.current) return; const mgr = managerRef.current; if (!mgr || mgr.getRawBarsLength() === 0) { // 데이터 로드 전 → pending 저장 후 onDataLoaded 에서 재적용 @@ -385,7 +392,7 @@ const ChartSlot = forwardRef(function ChartSlot // ── Date range sync: syncLogicalRange 외부 적용 ────────────────────── useEffect(() => { - if (!syncLogicalRange) return; + if (!syncLogicalRange || skipSyncApplyRef.current) return; const mgr = managerRef.current; if (!mgr || mgr.getRawBarsLength() === 0) { pendingSyncLogicalRef.current = syncLogicalRange; @@ -409,6 +416,9 @@ const ChartSlot = forwardRef(function ChartSlot reloadTriggeredRef.current = false; chartLiveReadyRef.current = false; pendingRealtimeBarRef.current = null; + pendingSyncVisibleRef.current = null; + pendingSyncLogicalRef.current = null; + skipSyncApplyRef.current = true; }, [symbol, timeframe]); useEffect(() => { const checks = [200, 500, 1000, 2000].map(delay => @@ -519,6 +529,7 @@ const ChartSlot = forwardRef(function ChartSlot }, [useUpbit, wsStatus, latestBar]); const receiving = useLiveReceiveFlash(receiveSignal, compactMode && useUpbit && wsStatus === 'connected'); + const highlightReceiving = chartLiveReceiveHighlight && receiving; // ── 인디케이터 핸들러 ───────────────────────────────────────────────── const handleIndicatorSave = useCallback((updated: IndicatorConfig) => { @@ -590,11 +601,29 @@ const ChartSlot = forwardRef(function ChartSlot // ── 심볼 선택 ───────────────────────────────────────────────────────── const handleSymbolSelect = useCallback((s: string) => { if (isUpbitMarket(s)) invalidateMarketCache(s); + pendingSyncVisibleRef.current = null; + pendingSyncLogicalRef.current = null; + skipSyncApplyRef.current = true; setSymbol(s); setLegend(null); setShowMarket(false); + setSlotAnchorRect(null); }, []); + const openMarketSearch = useCallback((e?: React.MouseEvent) => { + e?.stopPropagation(); + onActivate(); + if (!showMarket) { + const anchorEl = compactMode + ? compactNameRef.current + : (slotContainerRef.current?.closest('.chart-slot') as HTMLElement | null); + setSlotAnchorRect(anchorEl?.getBoundingClientRect() ?? null); + } else { + setSlotAnchorRect(null); + } + setShowMarket(v => !v); + }, [compactMode, onActivate, showMarket]); + const displayKoName = compactKoName(symbol); const trendUp = compactMode && currentBar != null && dailyChangePct >= 0; const trendDown = compactMode && currentBar != null && dailyChangePct < 0; @@ -605,7 +634,7 @@ const ChartSlot = forwardRef(function ChartSlot 'chart-slot', active ? 'active' : '', compactMode ? 'chart-slot--compact' : '', - receiving ? 'chart-slot--receiving' : '', + highlightReceiving ? 'chart-slot--receiving' : '', trendUp ? 'chart-slot--trend-up' : '', trendDown ? 'chart-slot--trend-down' : '', ].filter(Boolean).join(' ')} @@ -620,17 +649,17 @@ const ChartSlot = forwardRef(function ChartSlot
{currentBar && ( @@ -676,13 +705,7 @@ const ChartSlot = forwardRef(function ChartSlot - ) : ( - toggleFav(m.market, e)} - title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'} - > - {isFav ? '★' : '☆'} - + {!isCompactDropdown && ( + actionMode === 'add' ? ( + + ) : ( + toggleFav(m.market, e)} + title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'} + > + {isFav ? '★' : '☆'} + + ) )} {m.korean_name} - {m.symbol} + {!isCompactDropdown && {m.symbol}} {!embedded && ( @@ -396,9 +437,11 @@ export const MarketSearchPanel: React.FC = ({ ? '-' : `${isUp ? '+' : ''}${safeToFixed(rate, 2)}%`} - - {tk?.acc_trade_price_24h ? formatVol(tk.acc_trade_price_24h) : '-'} - + {!isCompactDropdown && ( + + {tk?.acc_trade_price_24h ? formatVol(tk.acc_trade_price_24h) : '-'} + + )} )}
diff --git a/frontend/src/components/SettingsPage.tsx b/frontend/src/components/SettingsPage.tsx index 4033fa6..d184c86 100644 --- a/frontend/src/components/SettingsPage.tsx +++ b/frontend/src/components/SettingsPage.tsx @@ -96,6 +96,8 @@ interface SettingsPageProps { onChartSeriesPriceLabels?: (v: boolean) => void; chartVolumeVisible?: boolean; onChartVolumeVisible?: (v: boolean) => void; + chartLiveReceiveHighlight?: boolean; + onChartLiveReceiveHighlight?: (v: boolean) => void; chartLegendOptions?: ChartLegendVisibility; onChartLegendOptionsChange?: (patch: Partial) => void; paperTradingEnabled?: boolean; @@ -565,6 +567,8 @@ interface ChartPanelProps { onChartSeriesPriceLabels?: (v: boolean) => void; chartVolumeVisible?: boolean; onChartVolumeVisible?: (v: boolean) => void; + chartLiveReceiveHighlight?: boolean; + onChartLiveReceiveHighlight?: (v: boolean) => void; chartLegendOptions?: ChartLegendVisibility; onChartLegendOptionsChange?: (patch: Partial) => void; } @@ -577,6 +581,8 @@ const ChartPanel: React.FC = ({ onChartSeriesPriceLabels, chartVolumeVisible = true, onChartVolumeVisible, + chartLiveReceiveHighlight = true, + onChartLiveReceiveHighlight, chartLegendOptions, onChartLegendOptionsChange, }) => { @@ -598,6 +604,19 @@ const ChartPanel: React.FC = ({ + + + @@ -1351,6 +1370,8 @@ const SettingsPage: React.FC = ({ onChartSeriesPriceLabels, chartVolumeVisible = true, onChartVolumeVisible, + chartLiveReceiveHighlight = true, + onChartLiveReceiveHighlight, chartLegendOptions, onChartLegendOptionsChange, tradeAlertSoundEnabled = true, @@ -1428,6 +1449,8 @@ const SettingsPage: React.FC = ({ onChartSeriesPriceLabels={onChartSeriesPriceLabels} chartVolumeVisible={chartVolumeVisible} onChartVolumeVisible={onChartVolumeVisible} + chartLiveReceiveHighlight={chartLiveReceiveHighlight} + onChartLiveReceiveHighlight={onChartLiveReceiveHighlight} chartLegendOptions={chartLegendOptions} onChartLegendOptionsChange={onChartLegendOptionsChange} /> diff --git a/frontend/src/components/VirtualTradingPage.tsx b/frontend/src/components/VirtualTradingPage.tsx index efce38e..1245fb6 100644 --- a/frontend/src/components/VirtualTradingPage.tsx +++ b/frontend/src/components/VirtualTradingPage.tsx @@ -90,6 +90,7 @@ const VirtualTradingPage: React.FC = ({ const chartRealtimeSource = (appChartDefaults.chartRealtimeSource ?? 'BACKEND_STOMP') as ChartRealtimeSource; const chartSeriesPriceLabels = appChartDefaults.chartSeriesPriceLabels; + const chartLiveReceiveHighlight = appChartDefaults.chartLiveReceiveHighlight; useEffect(() => { void Promise.all([ @@ -340,6 +341,7 @@ const VirtualTradingPage: React.FC = ({ theme={theme} chartRealtimeSource={chartRealtimeSource} chartSeriesPriceLabels={chartSeriesPriceLabels} + chartLiveReceiveHighlight={chartLiveReceiveHighlight} tickers={tickers} viewMode={viewMode} globalDisplayMode={globalDisplayMode} diff --git a/frontend/src/components/virtual/VirtualTargetCard.tsx b/frontend/src/components/virtual/VirtualTargetCard.tsx index e0e6deb..1104ce4 100644 --- a/frontend/src/components/virtual/VirtualTargetCard.tsx +++ b/frontend/src/components/virtual/VirtualTargetCard.tsx @@ -36,6 +36,7 @@ interface Props { theme?: Theme; chartRealtimeSource?: ChartRealtimeSource; chartSeriesPriceLabels?: boolean; + chartLiveReceiveHighlight?: boolean; ticker?: TickerData; } @@ -59,6 +60,7 @@ const VirtualTargetCard: React.FC = ({ theme = 'dark', chartRealtimeSource = 'BACKEND_STOMP', chartSeriesPriceLabels = true, + chartLiveReceiveHighlight = true, ticker, }) => { const ko = getKoreanName(market); @@ -79,6 +81,7 @@ const VirtualTargetCard: React.FC = ({ return `${lastTickAt ?? 0}|${snapshot?.updatedAt ?? 0}`; }, [running, lastTickAt, snapshot?.updatedAt]); const receiving = useLiveReceiveFlash(receiveSignal, running); + const highlightReceiving = chartLiveReceiveHighlight && receiving; return (
= ({ 'vtd-card', 'vtd-card--signal', running ? 'vtd-card--live' : '', - receiving ? 'vtd-card--receiving' : '', + highlightReceiving ? 'vtd-card--receiving' : '', isDetail ? 'vtd-card--detail' : 'vtd-card--summary', isChart ? 'vtd-card--chart-mode' : '', selected ? 'vtd-card--selected' : '', @@ -187,7 +190,7 @@ const VirtualTargetCard: React.FC = ({ )}
diff --git a/frontend/src/components/virtual/VirtualTargetFocusView.tsx b/frontend/src/components/virtual/VirtualTargetFocusView.tsx index 6c86e10..b64e460 100644 --- a/frontend/src/components/virtual/VirtualTargetFocusView.tsx +++ b/frontend/src/components/virtual/VirtualTargetFocusView.tsx @@ -29,6 +29,7 @@ interface Props { theme?: Theme; chartRealtimeSource?: ChartRealtimeSource; chartSeriesPriceLabels?: boolean; + chartLiveReceiveHighlight?: boolean; ticker?: TickerData; } @@ -49,6 +50,7 @@ const VirtualTargetFocusView: React.FC = ({ theme = 'dark', chartRealtimeSource = 'BACKEND_STOMP', chartSeriesPriceLabels = true, + chartLiveReceiveHighlight = true, ticker, }) => { const ko = getKoreanName(market); @@ -60,9 +62,10 @@ const VirtualTargetFocusView: React.FC = ({ return `${lastTickAt ?? 0}|${snapshot?.updatedAt ?? 0}`; }, [running, lastTickAt, snapshot?.updatedAt]); const receiving = useLiveReceiveFlash(receiveSignal, running); + const highlightReceiving = chartLiveReceiveHighlight && receiving; return ( -
+
@@ -129,7 +132,7 @@ const VirtualTargetFocusView: React.FC = ({
diff --git a/frontend/src/components/virtual/VirtualTargetGrid.tsx b/frontend/src/components/virtual/VirtualTargetGrid.tsx index 1a0f62c..fd3fafc 100644 --- a/frontend/src/components/virtual/VirtualTargetGrid.tsx +++ b/frontend/src/components/virtual/VirtualTargetGrid.tsx @@ -22,6 +22,7 @@ interface Props { theme?: Theme; chartRealtimeSource?: ChartRealtimeSource; chartSeriesPriceLabels?: boolean; + chartLiveReceiveHighlight?: boolean; tickers?: Map; viewMode: VirtualCardViewMode; globalDisplayMode: VirtualCardDisplayMode; @@ -36,6 +37,7 @@ const VirtualTargetGrid: React.FC = ({ theme = 'dark', chartRealtimeSource = 'BACKEND_STOMP', chartSeriesPriceLabels = true, + chartLiveReceiveHighlight = true, tickers, viewMode, globalDisplayMode, @@ -116,6 +118,7 @@ const VirtualTargetGrid: React.FC = ({ theme={theme} chartRealtimeSource={chartRealtimeSource} chartSeriesPriceLabels={chartSeriesPriceLabels} + chartLiveReceiveHighlight={chartLiveReceiveHighlight} ticker={tickers?.get(focusTarget.market)} /> ) : ( @@ -144,6 +147,7 @@ const VirtualTargetGrid: React.FC = ({ theme={theme} chartRealtimeSource={chartRealtimeSource} chartSeriesPriceLabels={chartSeriesPriceLabels} + chartLiveReceiveHighlight={chartLiveReceiveHighlight} ticker={tickers?.get(t.market)} /> ); diff --git a/frontend/src/hooks/useAppSettings.ts b/frontend/src/hooks/useAppSettings.ts index cf53ee9..7800ff3 100644 --- a/frontend/src/hooks/useAppSettings.ts +++ b/frontend/src/hooks/useAppSettings.ts @@ -75,6 +75,7 @@ export function resolveAppDefaults(s: AppSettingsDto) { btShowPrice: s.btShowPrice ?? true, chartSeriesPriceLabels: s.chartSeriesPriceLabels ?? true, chartVolumeVisible: s.chartVolumeVisible ?? true, + chartLiveReceiveHighlight: s.chartLiveReceiveHighlight ?? true, chartLegendOptions: resolveChartLegendOptions( s.chartLegendOptions as Partial | null | undefined, ), diff --git a/frontend/src/utils/ChartManager.ts b/frontend/src/utils/ChartManager.ts index 0fa1de0..bf76ee9 100644 --- a/frontend/src/utils/ChartManager.ts +++ b/frontend/src/utils/ChartManager.ts @@ -363,19 +363,43 @@ export class ChartManager { this._reapplyAllPatternMarkers(); - // 이전 timeScale visible range를 완전히 리셋 + // 종목 전환·초기 로드 — bar 수가 적을 때 음수 logical from 은 캔들이 우측으로 몰림 + this._applyDefaultVisibleRange(200, 0); + } + + /** + * 표시용 visible range 설정. + * bar < displayCount 이면 fitContent (음수 logical index 사용 금지). + */ + private _applyDefaultVisibleRange(displayCount: number, extendFutureBars = 0): void { + const bars = this.rawBars; + if (!bars || bars.length < 2) return; + const ts = this.chart.timeScale(); - if (bars.length >= 2) { - if (bars.length < 20) { - ts.setVisibleLogicalRange({ from: bars.length - 200, to: bars.length + 5 }); - } else { + + if (bars.length <= displayCount) { + if (extendFutureBars > 0) { + let to: number = bars[bars.length - 1].time as number; + to += extendFutureBars * this._barIntervalSecs(); ts.setVisibleRange({ from: bars[0].time as Time, - to: bars[bars.length - 1].time as Time, + to: to as Time, }); + } else { ts.fitContent(); } + return; } + + const startIdx = Math.max(0, bars.length - displayCount); + let to: number = bars[bars.length - 1].time as number; + if (extendFutureBars > 0) { + to += extendFutureBars * this._barIntervalSecs(); + } + ts.setVisibleRange({ + from: bars[startIdx].time as Time, + to: to as Time, + }); } private _createMainSeries(chartType: ChartType, t: ThemeTokens): ISeriesApi { @@ -2397,30 +2421,7 @@ export class ChartManager { * @param extendFutureBars 마지막 바 이후 추가로 보여줄 바 수 (기본 0) */ setInitialVisibleRange(displayCount: number, extendFutureBars = 0): void { - const bars = this.rawBars; - if (!bars || bars.length === 0) return; - - const ts = this.chart.timeScale(); - - if (bars.length <= displayCount && extendFutureBars === 0) { - if (bars.length < 20) { - ts.setVisibleLogicalRange({ from: bars.length - displayCount, to: bars.length + 5 }); - } else { - ts.fitContent(); - } - return; - } - - const startIdx = Math.max(0, bars.length - displayCount); - const from = bars[startIdx].time as number; - let to: number = bars[bars.length - 1].time as number; - - if (extendFutureBars > 0) { - const interval = this._barIntervalSecs(); - to = to + extendFutureBars * interval; - } - - ts.setVisibleRange({ from: from as Time, to: to as Time }); + this._applyDefaultVisibleRange(displayCount, extendFutureBars); } /** 일목균형표가 활성화되어 있는지 여부 */ diff --git a/frontend/src/utils/backendApi.ts b/frontend/src/utils/backendApi.ts index 6e4cb5f..fd21bc4 100644 --- a/frontend/src/utils/backendApi.ts +++ b/frontend/src/utils/backendApi.ts @@ -388,6 +388,8 @@ export interface AppSettingsDto { chartSeriesPriceLabels?: boolean; /** 차트 하단 거래량 바 표시 (기본 true) */ chartVolumeVisible?: boolean; + /** 실시간 수신 시 카드 아웃라인 형광 연두 음영 (기본 true) */ + chartLiveReceiveHighlight?: boolean; /** 차트 상단 범례(tv-legend) 항목별 표시 옵션 */ chartLegendOptions?: Record | null; /** 매매 시그널 발생 시 알림 팝업 표시 여부 (기본 true) */