From 67a6aa0dee456de28ca4ed0d0ed8905912f05273 Mon Sep 17 00:00:00 2001 From: Macbook Date: Mon, 25 May 2026 03:49:53 +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=EA=B0=B1=EC=8B=A0=EC=98=A4=EB=A5=98=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 --- frontend/src/App.tsx | 7 +- frontend/src/components/ChartSlot.tsx | 2 +- frontend/src/components/TradingChart.tsx | 243 ++++++++++++-------- frontend/src/styles/strategyEditor.css | 22 ++ frontend/src/styles/strategyEditorTheme.css | 6 + 5 files changed, 183 insertions(+), 97 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7b78075..8d14d59 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1092,11 +1092,8 @@ function App() { noAutoSave: !dbLoaded, }); - /** 로그인·로그아웃·종목·타임프레임 변경 시 차트 재마운트 (종목 전환 직후 stale 데이터·중단된 reload 방지) */ - const chartMountKey = useMemo( - () => `${sessionKey}:${symbol}:${timeframe}`, - [sessionKey, symbol, timeframe], - ); + /** 로그인·로그아웃 시에만 차트 재마운트 (종목·타임프레임은 TradingChart 내부 reloadAll로 처리) */ + const chartMountKey = useMemo(() => sessionKey, [sessionKey]); // DB에 워크스페이스 없음(204) → appDefaults 레이아웃 적용 후 저장 허용 useEffect(() => { diff --git a/frontend/src/components/ChartSlot.tsx b/frontend/src/components/ChartSlot.tsx index 20c864f..6417b68 100644 --- a/frontend/src/components/ChartSlot.tsx +++ b/frontend/src/components/ChartSlot.tsx @@ -631,7 +631,7 @@ const ChartSlot = forwardRef(function ChartSlot )} = ({ // ── 전체 재로드 (데이터 + 인디케이터) ───────────────────────────────────── const reloadSafetyTimers = useRef[]>([]); const reloadGenRef = useRef(0); + const reloadInFlightRef = useRef(false); + const reloadCoalesceRef = useRef(false); const reloadRetryRef = useRef(0); const reloadAllRef = useRef<( mgr: ChartManager, @@ -350,6 +352,17 @@ const TradingChart: React.FC = ({ }, 120); }, [market, barsMarket, applyPaneLayout]); + /** setData 직후 캔들·볼륨 pane 레이아웃 확정 (지표 로드 중단 시에도 빈 화면 방지) */ + const commitCandleLayout = useCallback((mgr: ChartManager) => { + requestAnimationFrame(() => { + applyPaneLayout(mgr); + requestAnimationFrame(() => { + const futureBars = mgr.hasIchimoku() ? 28 : 0; + mgr.setInitialVisibleRange(DISPLAY_COUNT, futureBars); + }); + }); + }, [applyPaneLayout]); + const reloadAll = useCallback(async ( mgr: ChartManager, newBars: OHLCVBar[], @@ -360,104 +373,123 @@ const TradingChart: React.FC = ({ ) => { if (newBars.length === 0) return; - const gen = ++reloadGenRef.current; - - reloadSafetyTimers.current.forEach(clearTimeout); - reloadSafetyTimers.current = []; - - barsRef.current = newBars; - - mgr.setData(newBars, ct, th); - if (gen !== reloadGenRef.current) { - prevBarsKey.current = ''; - queueFullReload(); - return; - } - mgr.setTheme(th); - mgr.setLogScale(ls); - mgr.setDisplayTimezone(displayTimezone, timeframe); - - for (const ind of inds) { - if (gen !== reloadGenRef.current) { - prevBarsKey.current = ''; - queueFullReload(); - return; - } - await mgr.addIndicator(ind); - } - - if (gen !== reloadGenRef.current) { - prevBarsKey.current = ''; - queueFullReload(); + if (reloadInFlightRef.current) { + reloadCoalesceRef.current = true; + barsRef.current = newBars; return; } - const expected = countExpectedVisibleIndicators(inds); - const loaded = mgr.getLoadedIndicatorCount(); + reloadInFlightRef.current = true; - if (expected > 0 && loaded < expected) { - prevBarsKey.current = ''; - if (reloadRetryRef.current < 4 && mgr.hasMainSeries()) { - reloadRetryRef.current += 1; - await mgr.reloadIndicatorsOnly(inds); + try { + do { + reloadCoalesceRef.current = false; + const gen = reloadGenRef.current; + const barData = barsRef.current.length > 0 ? barsRef.current : newBars; + if (barData.length === 0) break; + + reloadSafetyTimers.current.forEach(clearTimeout); + reloadSafetyTimers.current = []; + + barsRef.current = barData; + + mgr.setData(barData, ct, th); if (gen !== reloadGenRef.current) { - queueFullReload(); - return; - } - } - if (mgr.getLoadedIndicatorCount() < expected) { - if (reloadRetryRef.current < 6) { - reloadRetryRef.current += 1; - queueIndicatorRecovery(); - } - return; - } - } - - reloadRetryRef.current = 0; - prevChartType.current = ct; - prevTheme.current = th; - prevLogScale.current = ls; - prevBarsKey.current = barsKey(newBars, market); - prevIndKey.current = indKey(inds); - prevSortedPKRef.current = sortedParamKey(inds); - - await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => { - applyPaneLayout(mgr); - requestAnimationFrame(() => { - const futureBars = mgr.hasIchimoku() ? 28 : 0; - mgr.setInitialVisibleRange(DISPLAY_COUNT, futureBars); - resolve(); - }); - }))); - - if (gen !== reloadGenRef.current) { - prevBarsKey.current = ''; - queueFullReload(); - return; - } - - onDataLoaded?.(); - - reloadSafetyTimers.current = [300, 700, 1400].map(delay => - setTimeout(() => { - const m = managerRef.current; - if (!m || !m.hasMainSeries()) return; - const expectedLater = countExpectedVisibleIndicators(indicatorsRef.current); - if (expectedLater > 0 && m.getLoadedIndicatorCount() < expectedLater) { prevBarsKey.current = ''; - queueIndicatorRecovery(); - return; + break; } - const w = wrapperRef.current; - if (!w || w.clientHeight <= 0) return; - m.resetPaneHeights(w.clientHeight); - const fb = m.hasIchimoku() ? 28 : 0; - m.setInitialVisibleRange(DISPLAY_COUNT, fb); - if (delay === 1400) onDataLoaded?.(); - }, delay) - ); - }, [applyPaneLayout, onDataLoaded, displayTimezone, timeframe, market, queueFullReload]); + mgr.setTheme(th); + mgr.setLogScale(ls); + mgr.setDisplayTimezone(displayTimezone, timeframe); + commitCandleLayout(mgr); + + let stale = false; + for (const ind of inds) { + if (gen !== reloadGenRef.current) { + prevBarsKey.current = ''; + stale = true; + break; + } + await mgr.addIndicator(ind); + } + if (stale) break; + + if (gen !== reloadGenRef.current) { + prevBarsKey.current = ''; + break; + } + + const expected = countExpectedVisibleIndicators(inds); + const loaded = mgr.getLoadedIndicatorCount(); + + if (expected > 0 && loaded < expected) { + prevBarsKey.current = ''; + if (reloadRetryRef.current < 4 && mgr.hasMainSeries()) { + reloadRetryRef.current += 1; + await mgr.reloadIndicatorsOnly(inds); + if (gen !== reloadGenRef.current) break; + } + if (mgr.getLoadedIndicatorCount() < expected) { + commitCandleLayout(mgr); + if (reloadRetryRef.current < 6) { + reloadRetryRef.current += 1; + queueIndicatorRecovery(); + } + break; + } + } + + reloadRetryRef.current = 0; + prevChartType.current = ct; + prevTheme.current = th; + prevLogScale.current = ls; + prevBarsKey.current = barsKey(barData, market); + prevIndKey.current = indKey(inds); + prevSortedPKRef.current = sortedParamKey(inds); + + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => { + applyPaneLayout(mgr); + requestAnimationFrame(() => { + const futureBars = mgr.hasIchimoku() ? 28 : 0; + mgr.setInitialVisibleRange(DISPLAY_COUNT, futureBars); + resolve(); + }); + }))); + + if (gen !== reloadGenRef.current) { + prevBarsKey.current = ''; + break; + } + + onDataLoaded?.(); + + reloadSafetyTimers.current = [300, 700, 1400].map(delay => + setTimeout(() => { + const m = managerRef.current; + if (!m || !m.hasMainSeries()) return; + const expectedLater = countExpectedVisibleIndicators(indicatorsRef.current); + if (expectedLater > 0 && m.getLoadedIndicatorCount() < expectedLater) { + prevBarsKey.current = ''; + queueIndicatorRecovery(); + return; + } + const w = wrapperRef.current; + if (!w || w.clientHeight <= 0) return; + m.resetPaneHeights(w.clientHeight); + const fb = m.hasIchimoku() ? 28 : 0; + m.setInitialVisibleRange(DISPLAY_COUNT, fb); + if (delay === 1400) onDataLoaded?.(); + }, delay) + ); + } while (reloadCoalesceRef.current); + } finally { + reloadInFlightRef.current = false; + if (reloadCoalesceRef.current) { + reloadCoalesceRef.current = false; + queueFullReload(); + } + } + }, [applyPaneLayout, commitCandleLayout, onDataLoaded, displayTimezone, timeframe, market, queueFullReload, queueIndicatorRecovery]); useEffect(() => { reloadAllRef.current = reloadAll; @@ -786,6 +818,8 @@ const TradingChart: React.FC = ({ barsRef.current = []; reloadRetryRef.current = 0; reloadGenRef.current += 1; + reloadInFlightRef.current = false; + reloadCoalesceRef.current = false; reloadSafetyTimers.current.forEach(clearTimeout); reloadSafetyTimers.current = []; managerRef.current?.cancelPendingIndicatorUpdates(); @@ -901,6 +935,33 @@ const TradingChart: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [chartMgr, bars, barsMarket, market, chartType, theme, logScale, indicators]); + // 데이터는 준비됐으나 메인 시리즈가 없는 빈 차트 자동 복구 (종목 전환·레이아웃 전환 직후) + useEffect(() => { + const mgr = managerRef.current; + if (!mgr || !chartMgr || bars.length === 0) return; + if (!canApplyChartBars(bars, barsMarket, market)) return; + if (mgr.hasMainSeries()) return; + + const timers = [0, 80, 200, 500].map(delay => + window.setTimeout(() => { + const m = managerRef.current; + if (!m || m.hasMainSeries()) return; + const data = barsRef.current; + if (!canApplyChartBars(data, barsMarketRef.current, marketRef.current)) return; + void reloadAllRef.current( + m, + data, + prevChartType.current, + prevTheme.current, + prevLogScale.current, + indicatorsRef.current, + ); + }, delay), + ); + + return () => { timers.forEach(clearTimeout); }; + }, [chartMgr, bars, barsMarket, market, chartType, theme, logScale, indicators]); + // 종목 전환 직후 reloadAll 이 중단되어 캔들만 남은 경우 자동 복구 useEffect(() => { const mgr = managerRef.current; diff --git a/frontend/src/styles/strategyEditor.css b/frontend/src/styles/strategyEditor.css index 5d32609..973eeb8 100644 --- a/frontend/src/styles/strategyEditor.css +++ b/frontend/src/styles/strategyEditor.css @@ -1423,6 +1423,28 @@ box-shadow: inset 3px 0 0 var(--se-palette-logic-not-accent), 0 4px 14px color-mix(in srgb, var(--se-palette-logic-not-accent) 22%, transparent); } +/* ── START (시간봉 시작점) ── */ +.se-palette-card--logic-start { + border-color: color-mix(in srgb, var(--se-palette-logic-start-accent) 52%, transparent); + background: var(--se-palette-logic-start-bg); + box-shadow: inset 3px 0 0 var(--se-palette-logic-start-accent); +} +.se-palette-card--selected.se-palette-card--logic-start { + outline-color: var(--se-palette-logic-start-accent); +} +.se-palette-card--logic-start .se-palette-card-icon { + background: color-mix(in srgb, var(--se-palette-logic-start-accent) 28%, transparent); + color: var(--se-palette-logic-start-accent); + font-weight: 900; +} +.se-palette-card--logic-start .se-palette-card-name { + color: var(--se-palette-logic-start-accent); +} +.se-palette-card--logic-start:hover { + border-color: color-mix(in srgb, var(--se-palette-logic-start-accent) 72%, transparent); + box-shadow: inset 3px 0 0 var(--se-palette-logic-start-accent), 0 4px 14px color-mix(in srgb, var(--se-palette-logic-start-accent) 28%, transparent); +} + /* ── 밴드 · 추세 ── */ .se-palette-card--band { border-color: color-mix(in srgb, var(--se-palette-band-accent) 42%, transparent); diff --git a/frontend/src/styles/strategyEditorTheme.css b/frontend/src/styles/strategyEditorTheme.css index b406fc9..cb29559 100644 --- a/frontend/src/styles/strategyEditorTheme.css +++ b/frontend/src/styles/strategyEditorTheme.css @@ -63,6 +63,8 @@ --se-palette-logic-or-bg: color-mix(in srgb, #22d3ee 14%, var(--bg2)); --se-palette-logic-not-accent: #fbbf24; --se-palette-logic-not-bg: color-mix(in srgb, #fbbf24 14%, var(--bg2)); + --se-palette-logic-start-accent: #ffb020; + --se-palette-logic-start-bg: color-mix(in srgb, #ffb020 22%, var(--bg2)); --se-palette-band-accent: #a78bfa; --se-palette-band-bg: color-mix(in srgb, #a78bfa 14%, var(--bg2)); --se-palette-ind-accent: #34d399; @@ -136,6 +138,8 @@ --se-palette-logic-or-bg: rgba(2, 136, 209, 0.08); --se-palette-logic-not-accent: #f57f17; --se-palette-logic-not-bg: rgba(245, 127, 23, 0.08); + --se-palette-logic-start-accent: #e65100; + --se-palette-logic-start-bg: rgba(255, 152, 0, 0.14); --se-palette-band-accent: #7b1fa2; --se-palette-band-bg: rgba(123, 31, 162, 0.07); --se-palette-ind-accent: #2e7d32; @@ -176,6 +180,8 @@ --se-palette-logic-or-bg: rgba(77, 208, 225, 0.12); --se-palette-logic-not-accent: #ffd54f; --se-palette-logic-not-bg: rgba(255, 213, 79, 0.12); + --se-palette-logic-start-accent: #ffb300; + --se-palette-logic-start-bg: rgba(255, 179, 0, 0.18); --se-palette-band-accent: #ce93d8; --se-palette-band-bg: rgba(206, 147, 216, 0.12); --se-palette-ind-accent: #69f0ae;