diff --git a/app/src/screens/notifications/NotificationsScreen.tsx b/app/src/screens/notifications/NotificationsScreen.tsx index 8f2752b..01913a7 100644 --- a/app/src/screens/notifications/NotificationsScreen.tsx +++ b/app/src/screens/notifications/NotificationsScreen.tsx @@ -5,6 +5,7 @@ import { useAuth } from '../../contexts/AuthContext'; import { useAppSettings, reloadAppSettingsCache } from '../../hooks/useAppSettings'; import { useVirtualTradingCore } from '@frontend/hooks/useVirtualTradingCore'; import NotificationListRow from './NotificationListRow'; +import { VirtualScroll } from '@frontend/components/common/VirtualScroll'; import NotificationDetailScreen from './NotificationDetailScreen'; import VirtualTradeScreen from '../virtual/VirtualTradeScreen'; @@ -133,12 +134,11 @@ export default function NotificationsScreen() { )}
{(pullY > 0 || refreshing) && (
0 ? `${pullY / 4}px` : 8, fontSize: 12, color: 'var(--gc-text-muted)' }}> @@ -155,23 +155,34 @@ export default function NotificationsScreen() {
) : ( -
- {allNotifications.map(item => ( - { - markAsRead(item.id); - goNotifyDetail(item.id, item.market); - }} - onTrade={side => { - markAsRead(item.id); - goNotifyTrade(item.market, side); - }} - onDelete={() => void deleteNotification(item.id)} - /> - ))} -
+ item.id} + innerClassName="vl-scroll-inner" + aria-label="알림 목록" + > + {item => ( +
+ { + markAsRead(item.id); + goNotifyDetail(item.id, item.market); + }} + onTrade={side => { + markAsRead(item.id); + goNotifyTrade(item.market, side); + }} + onDelete={() => void deleteNotification(item.id)} + /> +
+ )} +
)}
diff --git a/app/src/screens/virtual/VirtualTradingScreen.tsx b/app/src/screens/virtual/VirtualTradingScreen.tsx index c881eee..42c8941 100644 --- a/app/src/screens/virtual/VirtualTradingScreen.tsx +++ b/app/src/screens/virtual/VirtualTradingScreen.tsx @@ -11,6 +11,7 @@ import VirtualFocusScreen from './VirtualFocusScreen'; import VirtualTradeScreen from './VirtualTradeScreen'; import VirtualHistoryScreen from './VirtualHistoryScreen'; import { MarketSearchPanel } from '@frontend/components/MarketSearchPanel'; +import { VirtualScroll } from '@frontend/components/common/VirtualScroll'; export default function VirtualTradingScreen() { const { @@ -172,15 +173,23 @@ export default function VirtualTradingScreen() { )} ) : ( -
- {targets.map(t => { + t.market} + innerClassName="vl-scroll-inner" + aria-label="투자 대상 목록" + > + {t => { const strategyId = resolveVirtualTargetStrategyId(t, session.globalStrategyId); const snapKey = `${t.market}:${strategyId ?? ''}`; const snap = snapshots[snapKey] ?? snapshots[t.market]; const signalLoading = snapshotLoadingByMarket[t.market]; return ( +
void handleTogglePin(t.market)} onRemove={() => handleRemoveTarget(t.market)} /> +
); - })} -
+ }} + )} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e243aa6..81e2e07 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@stomp/stompjs": "^7.3.0", + "@tanstack/react-virtual": "^3.14.2", "@xyflow/react": "^12.10.2", "lightweight-charts": "^5.2.0", "lightweight-charts-indicators": "^0.4.1", @@ -1153,6 +1154,33 @@ "integrity": "sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==", "license": "Apache-2.0" }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz", + "integrity": "sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.17.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.0.tgz", + "integrity": "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index 62d9e2c..5f4140c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@stomp/stompjs": "^7.3.0", + "@tanstack/react-virtual": "^3.14.2", "@xyflow/react": "^12.10.2", "lightweight-charts": "^5.2.0", "lightweight-charts-indicators": "^0.4.1", diff --git a/frontend/src/components/MarketSearchPanel.tsx b/frontend/src/components/MarketSearchPanel.tsx index caf7b27..8277bb9 100644 --- a/frontend/src/components/MarketSearchPanel.tsx +++ b/frontend/src/components/MarketSearchPanel.tsx @@ -10,6 +10,7 @@ import { UPBIT_API } from '../utils/upbitApi'; import { fetchTickersThrottled } from '../utils/upbitTickerFetch'; import { useIsCompactDevice } from '../hooks/useMediaQuery'; import { safeToFixed } from '../utils/safeFormat'; +import { VirtualScroll } from './common/VirtualScroll'; // ─── Types ─────────────────────────────────────────────────────────────────── interface MarketInfo { @@ -373,98 +374,105 @@ export const MarketSearchPanel: React.FC = ({ )} - {/* 목록 */} -
- {loading && ( + {/* 목록 — 가상 스크롤 (업비트 전 종목) */} + {loading ? ( +
마켓 목록 로딩 중...
- )} - {!loading && sorted.length === 0 && ( -
검색 결과가 없습니다
- )} - {sorted.map(m => { - const tk = tickers[m.market]; - const isFav = favs.has(m.market); - const isAdded = addedMarkets?.has(m.market) ?? false; - const addBlocked = maxTargetsReached && !isAdded; - const isActive = m.market === currentMarket; - const rate = tk ? formatChangeRate(tk.signed_change_rate) : null; - const isUp = rate !== null && rate >= 0; +
+ ) : ( + m.market} + empty={
검색 결과가 없습니다
} + aria-label="종목 검색 결과" + > + {m => { + const tk = tickers[m.market]; + const isFav = favs.has(m.market); + const isAdded = addedMarkets?.has(m.market) ?? false; + const addBlocked = maxTargetsReached && !isAdded; + const isActive = m.market === currentMarket; + const rate = tk ? formatChangeRate(tk.signed_change_rate) : null; + const isUp = rate !== null && rate >= 0; - return ( -
handleRowSelect(m.market)} - > - {!isCompactDropdown && ( - actionMode === 'add' ? ( - - ) : ( - toggleFav(m.market, e)} - title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'} - > - {isFav ? '★' : '☆'} - - ) - )} - - - {m.korean_name} - {!isCompactDropdown && {m.symbol}} - - - {!embedded && ( - <> - - {tk && Number.isFinite(Number(tk.trade_price)) - ? Number(tk.trade_price).toLocaleString() - : '-'} - - - {rate === null - ? '-' - : `${isUp ? '+' : ''}${safeToFixed(rate, 2)}%`} - - {!isCompactDropdown && ( - - {tk?.acc_trade_price_24h ? formatVol(tk.acc_trade_price_24h) : '-'} + return ( +
handleRowSelect(m.market)} + > + {!isCompactDropdown && ( + actionMode === 'add' ? ( + + ) : ( + toggleFav(m.market, e)} + title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'} + > + {isFav ? '★' : '☆'} - )} - - )} -
- ); - })} -
+ ) + )} + + + {m.korean_name} + {!isCompactDropdown && {m.symbol}} + + + {!embedded && ( + <> + + {tk && Number.isFinite(Number(tk.trade_price)) + ? Number(tk.trade_price).toLocaleString() + : '-'} + + + {rate === null + ? '-' + : `${isUp ? '+' : ''}${safeToFixed(rate, 2)}%`} + + {!isCompactDropdown && ( + + {tk?.acc_trade_price_24h ? formatVol(tk.acc_trade_price_24h) : '-'} + + )} + + )} +
+ ); + }} + + )} diff --git a/frontend/src/components/TradeNotificationListPage.tsx b/frontend/src/components/TradeNotificationListPage.tsx index 8eadf76..7a8f0ae 100644 --- a/frontend/src/components/TradeNotificationListPage.tsx +++ b/frontend/src/components/TradeNotificationListPage.tsx @@ -35,6 +35,7 @@ import '../styles/virtualTradingDashboard.css'; import '../styles/builderPageShell.css'; import '../styles/tradeNotificationList.css'; import TradeNotificationListRow from './tradeNotification/TradeNotificationListRow'; +import { VirtualGridScroll, VirtualScroll } from './common/VirtualScroll'; import TradeNotificationGridLayoutPicker from './tradeNotification/TradeNotificationGridLayoutPicker'; import FullscreenChartModal from './tradeNotification/FullscreenChartModal'; import { @@ -425,6 +426,47 @@ export const TradeNotificationListPage: React.FC = ({ const isListView = listLayout === 'list'; const gridCols = getTradeNotificationGridPreset(gridPreset).cols; + const renderNotificationRow = useCallback((item: TradeNotificationItem) => ( + handleNotificationSelect(item)} + onDelete={e => void handleDeleteOne(item, e)} + onDetail={() => { + if (!item.isRead) markAsRead(item.id); + openDetail(item, { centered: true }); + }} + onGoToChart={() => { + markAsRead(item.id); + setFullscreenItem(item); + }} + onTrade={() => handleTradeFromAlert(item)} + onReport={() => void handleReportFromAlert(item)} + reportLoading={reportLoadingId === item.id} + tickers={tickers} + /> + ), [ + theme, + listLayout, + isListView, + selectedId, + chartLiveReceiveHighlight, + handleNotificationSelect, + handleDeleteOne, + markAsRead, + openDetail, + handleTradeFromAlert, + handleReportFromAlert, + reportLoadingId, + tickers, + ]); + return ( <> {fullscreenItem && ( @@ -588,41 +630,39 @@ export const TradeNotificationListPage: React.FC = ({
{normalizedQuery ? `"${query}" 에 해당하는 알림이 없습니다.` : '표시할 알림이 없습니다.'}
+ ) : isListView ? ( + item.id} + role="list" + aria-label="매매 시그널 알림 목록" + > + {item => renderNotificationRow(item)} + ) : ( -
    item.id} + role="list" + aria-label="매매 시그널 알림 그리드" > - {sorted.map(item => ( - handleNotificationSelect(item)} - onDelete={e => void handleDeleteOne(item, e)} - onDetail={() => { - if (!item.isRead) markAsRead(item.id); - openDetail(item, { centered: true }); - }} - onGoToChart={() => { - markAsRead(item.id); - setFullscreenItem(item); - }} - onTrade={() => handleTradeFromAlert(item)} - onReport={() => void handleReportFromAlert(item)} - reportLoading={reportLoadingId === item.id} - tickers={tickers} - /> - ))} -
+ {item => renderNotificationRow(item)} + )} diff --git a/frontend/src/components/backtest/BacktestExecutionList.tsx b/frontend/src/components/backtest/BacktestExecutionList.tsx index 4004e18..c09671c 100644 --- a/frontend/src/components/backtest/BacktestExecutionList.tsx +++ b/frontend/src/components/backtest/BacktestExecutionList.tsx @@ -1,4 +1,5 @@ import React, { useMemo, useState } from 'react'; +import { VirtualScroll } from '../common/VirtualScroll'; import type { BacktestResultRecord } from '../../utils/backendApi'; import type { LiveExecutionItem } from '../../utils/liveExecutionGroups'; import BacktestSparkline from './BacktestSparkline'; @@ -164,9 +165,14 @@ export default function BacktestExecutionList({ )} -
- {tab === 'backtest' ? ( - filteredBacktests.length === 0 ? ( + {tab === 'backtest' ? ( + r.id ?? r.createdAt} + empty={(
{normalizedQuery ? (

'{query}'에 해당하는 결과가 없습니다.

@@ -177,51 +183,62 @@ export default function BacktestExecutionList({ )}
- ) : ( - filteredBacktests.map(r => { - const ret = r.totalReturn ?? 0; - const positive = ret >= 0; - const active = selectedBacktestId === r.id; - const market = toUpbitMarket(r.symbol); - const ko = getKoreanName(market); - const strategy = r.strategyName || '전략 없음'; - return ( - - ); - }) - ) - ) : filteredLiveItems.length === 0 ? ( -
- {normalizedQuery ? ( -

'{query}'에 해당하는 결과가 없습니다.

- ) : ( - <> -

실시간 매매 이력이 없습니다.

-

가상투자 화면에서 자동·수동
매매를 실행하세요.

- - )} -
- ) : ( - filteredLiveItems.map(item => { +
+ + 승률 {pctAbs(r.winRate ?? 0)} + 총 수익률 {pct(ret)} +
+
+ + ); + }} + + ) : ( + item.id} + empty={( +
+ {normalizedQuery ? ( +

'{query}'에 해당하는 결과가 없습니다.

+ ) : ( + <> +

실시간 매매 이력이 없습니다.

+

가상투자 화면에서 자동·수동
매매를 실행하세요.

+ + )} +
+ )} + aria-label="실시간 매매 목록" + > + {item => { const positive = item.totalReturnPct >= 0; const active = selectedLiveId === item.id; const market = toUpbitMarket(item.symbol); const ko = getKoreanName(market); return ( - ); - }) - )} - + }} +
+ )} ); } diff --git a/frontend/src/components/common/VirtualScroll.tsx b/frontend/src/components/common/VirtualScroll.tsx new file mode 100644 index 0000000..0dc8e00 --- /dev/null +++ b/frontend/src/components/common/VirtualScroll.tsx @@ -0,0 +1,241 @@ +/** + * 가상 스크롤 목록 — 화면에 보이는 행만 DOM 에 유지 (@tanstack/react-virtual) + */ +import React, { forwardRef, useLayoutEffect, useRef, type CSSProperties, type ReactNode } from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import '../../styles/virtualList.css'; + +export interface VirtualScrollProps { + items: T[]; + estimateSize: number; + overscan?: number; + className?: string; + style?: CSSProperties; + innerClassName?: string; + empty?: ReactNode; + getItemKey?: (item: T, index: number) => string | number; + /** 행 높이 가변 시 DOM 측정 */ + measureDynamic?: boolean; + children: (item: T, index: number) => ReactNode; + role?: string; + 'aria-label'?: string; +} + +function mergeRefs(...refs: Array | undefined>) { + return (value: T | null) => { + refs.forEach(ref => { + if (!ref) return; + if (typeof ref === 'function') ref(value); + else (ref as React.MutableRefObject).current = value; + }); + }; +} + +function VirtualScrollInner( + { + items, + estimateSize, + overscan = 6, + className = 'vl-scroll', + style, + innerClassName = 'vl-scroll-inner', + empty, + getItemKey, + measureDynamic = false, + children, + role, + 'aria-label': ariaLabel, + }: VirtualScrollProps, + forwardedRef: React.ForwardedRef, +) { + const parentRef = useRef(null); + + const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => parentRef.current, + estimateSize: () => estimateSize, + overscan, + getItemKey: getItemKey + ? index => String(getItemKey(items[index]!, index)) + : undefined, + measureElement: measureDynamic + ? el => el?.getBoundingClientRect().height ?? estimateSize + : undefined, + }); + + if (items.length === 0) { + return ( +
+ {empty} +
+ ); + } + + return ( +
+
+ {virtualizer.getVirtualItems().map(vi => ( +
+ {children(items[vi.index]!, vi.index)} +
+ ))} +
+
+ ); +} + +export const VirtualScroll = forwardRef(VirtualScrollInner) as ( + props: VirtualScrollProps & { ref?: React.ForwardedRef }, +) => ReturnType; + +export interface VirtualGridScrollProps { + items: T[]; + columns: number; + estimateRowSize: number; + rowGap?: number; + overscan?: number; + className?: string; + style?: CSSProperties; + rowClassName?: string; + empty?: ReactNode; + getItemKey?: (item: T, index: number) => string | number; + measureDynamic?: boolean; + /** 카드 높이 변경 시 재측정 트리거 */ + remeasureKey?: unknown; + children: (item: T, index: number) => ReactNode; + role?: string; + 'aria-label'?: string; +} + +function VirtualGridScrollInner( + { + items, + columns, + estimateRowSize, + rowGap = 14, + overscan = 4, + className = 'vl-scroll', + style, + rowClassName = 'vl-grid-row', + empty, + getItemKey, + measureDynamic = false, + remeasureKey, + children, + role, + 'aria-label': ariaLabel, + }: VirtualGridScrollProps, + forwardedRef: React.ForwardedRef, +) { + const parentRef = useRef(null); + const rowCount = Math.ceil(items.length / Math.max(1, columns)); + const rowStride = estimateRowSize + rowGap; + + const virtualizer = useVirtualizer({ + count: rowCount, + getScrollElement: () => parentRef.current, + estimateSize: () => rowStride, + overscan, + getItemKey: index => { + const start = index * columns; + const first = items[start]; + return getItemKey && first ? String(getItemKey(first, start)) : `row-${index}`; + }, + measureElement: measureDynamic + ? el => { + const h = el?.getBoundingClientRect().height ?? 0; + return h > 0 ? h : rowStride; + } + : undefined, + }); + + useLayoutEffect(() => { + if (!measureDynamic) return; + virtualizer.measure(); + }, [measureDynamic, remeasureKey, items.length, columns, rowStride, virtualizer]); + + if (items.length === 0) { + return ( +
+ {empty} +
+ ); + } + + const colCount = Math.max(1, columns); + + return ( +
+
+ {virtualizer.getVirtualItems().map(vi => { + const startIdx = vi.index * colCount; + const rowItems = items.slice(startIdx, startIdx + colCount); + return ( +
+ {rowItems.map((item, i) => ( + + {children(item, startIdx + i)} + + ))} +
+ ); + })} +
+
+ ); +} + +export const VirtualGridScroll = forwardRef(VirtualGridScrollInner) as ( + props: VirtualGridScrollProps & { ref?: React.ForwardedRef }, +) => ReturnType; diff --git a/frontend/src/components/paper/PaperTradeHistoryList.tsx b/frontend/src/components/paper/PaperTradeHistoryList.tsx index b585d5c..fd2eea6 100644 --- a/frontend/src/components/paper/PaperTradeHistoryList.tsx +++ b/frontend/src/components/paper/PaperTradeHistoryList.tsx @@ -1,4 +1,5 @@ import React, { useMemo } from 'react'; +import { VirtualScroll } from '../common/VirtualScroll'; import type { PaperTradeDto } from '../../utils/backendApi'; import type { TickerData } from '../../hooks/useMarketTicker'; import { resolveVirtualTargetNames } from '../../utils/virtualTargetNames'; @@ -58,9 +59,17 @@ const PaperTradeHistoryList: React.FC = ({ {trades.length === 0 ? (

{emptyText}

) : ( -
-
- {displayTrades.map(t => { + t.id} + innerClassName="vl-scroll-inner vtd-target-list vtd-trade-list" + aria-label="거래 내역" + > + {t => { const { koreanName: ko, englishName: en } = resolveVirtualTargetNames( t.symbol, tickers?.get(t.symbol)?.koreanName, @@ -80,7 +89,6 @@ const PaperTradeHistoryList: React.FC = ({ return (
= ({
); - })} -
- + }} + )} diff --git a/frontend/src/components/tradeNotification/TradeNotificationListRow.tsx b/frontend/src/components/tradeNotification/TradeNotificationListRow.tsx index df8f772..7760322 100644 --- a/frontend/src/components/tradeNotification/TradeNotificationListRow.tsx +++ b/frontend/src/components/tradeNotification/TradeNotificationListRow.tsx @@ -66,6 +66,8 @@ interface Props { /** 상세분석 레포트 버튼 클릭 */ onReport?: () => void; reportLoading?: boolean; + /** 가상 스크롤 목록에서는 div + role=listitem */ + itemTag?: 'li' | 'div'; } const MAX_INDICATOR_CARDS = 8; @@ -85,6 +87,7 @@ const TradeNotificationListRow: React.FC = ({ onTrade, onReport, reportLoading = false, + itemTag = 'li', }) => { useTradeAlertTimeFormat(); const isBuy = item.signalType === 'BUY'; @@ -106,7 +109,8 @@ const TradeNotificationListRow: React.FC = ({ || strategy?.name || strategyRow?.value || '실시간 전략'; - const rowRef = useRef(null); + const rowRef = useRef(null); + const RowTag = itemTag; const [inView, setInView] = useState(false); /** 펼쳐보기 중인 차트: 'candle' | 지표 id | null */ const [expandedChartKey, setExpandedChartKey] = useState(null); @@ -348,34 +352,32 @@ const TradeNotificationListRow: React.FC = ({ ); + const rowClassName = [ + 'tnl-row', + isListLayout ? 'tnl-row--gallery' : 'tnl-row--grid', + !item.isRead ? 'tnl-row--unread' : '', + isSelected ? 'tnl-row--selected' : '', + receiving ? 'tnl-row--receiving' : '', + ].filter(Boolean).join(' '); + if (!isListLayout) { return ( -
  • } + role={itemTag === 'div' ? 'listitem' : undefined} + className={rowClassName} onClick={handleRowClick} > {summaryCard} -
  • + ); } return ( -
  • } + role={itemTag === 'div' ? 'listitem' : undefined} + className={rowClassName} onClick={handleRowClick} >
    = ({ )}
    -
  • + ); }; diff --git a/frontend/src/components/trendSearch/TrendSearchResultsCardGrid.tsx b/frontend/src/components/trendSearch/TrendSearchResultsCardGrid.tsx index 23ce424..6a8e436 100644 --- a/frontend/src/components/trendSearch/TrendSearchResultsCardGrid.tsx +++ b/frontend/src/components/trendSearch/TrendSearchResultsCardGrid.tsx @@ -5,6 +5,7 @@ import type { Theme } from '../../types'; import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData'; import type { TickerData } from '../../hooks/useMarketTicker'; import TrendSearchResultCard, { type TrendSearchDisplayMode } from './TrendSearchResultCard'; +import { VirtualGridScroll } from '../common/VirtualScroll'; interface Props { results: TrendSearchResultDto[]; @@ -74,12 +75,27 @@ const TrendSearchResultsCardGrid: React.FC = ({ ); } + const gridCols = displayMode === 'chart' ? 2 : 3; + const rowEstimate = displayMode === 'chart' ? 420 : 300; + return ( -
    -
    - {sortedResults.map(row => ( +
    + row.market} + aria-label="추세검색 카드 그리드" + > + {row => ( = ({ onAddTarget={onAddTarget ? () => onAddTarget(row) : undefined} onRemoveTarget={onRemoveTarget ? () => onRemoveTarget(row.market) : undefined} /> - ))} -
    + )} +
    ); }; diff --git a/frontend/src/components/trendSearch/TrendSearchResultsGrid.tsx b/frontend/src/components/trendSearch/TrendSearchResultsGrid.tsx index 21394d3..3847f8c 100644 --- a/frontend/src/components/trendSearch/TrendSearchResultsGrid.tsx +++ b/frontend/src/components/trendSearch/TrendSearchResultsGrid.tsx @@ -1,6 +1,7 @@ import React, { useMemo } from 'react'; import type { TrendSearchResultDto } from '../../utils/trendSearchApi'; import { formatUpbitKrwPrice } from '../../utils/safeFormat'; +import { VirtualScroll } from '../common/VirtualScroll'; interface Props { results: TrendSearchResultDto[]; @@ -36,6 +37,10 @@ const TrendSearchResultsGrid: React.FC = ({ return Math.round(results.reduce((s, r) => s + r.matchRate, 0) / results.length); }, [results]); + const emptyBody = loading && results.length === 0 + ?
    스캔 중…
    + :
    조건에 맞는 종목이 없습니다. 필터를 조정해 보세요.
    ; + return (
    @@ -49,8 +54,8 @@ const TrendSearchResultsGrid: React.FC = ({
    -
    - +
    +
    @@ -60,48 +65,51 @@ const TrendSearchResultsGrid: React.FC = ({ - - {loading && results.length === 0 && ( - - )} - {!loading && results.length === 0 && ( - - )} - {results.map(row => { - const up = row.changeRate >= 0; - const active = selectedMarket === row.market; - const flash = flashMarkets?.has(row.market); - return ( - onSelect(row)} - > - - - - - - - ); - })} -
    종목명비고
    스캔 중…
    조건에 맞는 종목이 없습니다. 필터를 조정해 보세요.
    - {coinLabel(row.market, row.koreanName)} - {fmtPrice(row.currentPrice)}{fmtPct(row.changeRate)} -
    -
    -
    -
    - {row.matchRate}% -
    -
    - {row.highMatch && HIGH MATCH} - {!row.highMatch && row.matchRate >= 70 && MATCH} -
    + row.market} + empty={emptyBody} + role="rowgroup" + aria-label="추세검색 결과" + > + {row => { + const up = row.changeRate >= 0; + const active = selectedMarket === row.market; + const flash = flashMarkets?.has(row.market); + return ( +
    onSelect(row)} + > +
    + {coinLabel(row.market, row.koreanName)} +
    +
    {fmtPrice(row.currentPrice)}
    +
    {fmtPct(row.changeRate)}
    +
    +
    +
    +
    +
    + {row.matchRate}% +
    +
    +
    + {row.highMatch && HIGH MATCH} + {!row.highMatch && row.matchRate >= 70 && MATCH} +
    +
    + ); + }} +
    ); diff --git a/frontend/src/components/verificationBoard/VerificationIssueAttachmentsSection.tsx b/frontend/src/components/verificationBoard/VerificationIssueAttachmentsSection.tsx index 0969372..dea7c12 100644 --- a/frontend/src/components/verificationBoard/VerificationIssueAttachmentsSection.tsx +++ b/frontend/src/components/verificationBoard/VerificationIssueAttachmentsSection.tsx @@ -342,7 +342,7 @@ const VerificationIssueAttachmentsSection: React.FC = ({ title={`${p.fileName} — 더블클릭하여 열기`} onDoubleClick={() => openImage(p.src)} > - {p.fileName} + {p.fileName}
    {p.fileName} diff --git a/frontend/src/components/verificationBoard/VerificationIssueListPanel.tsx b/frontend/src/components/verificationBoard/VerificationIssueListPanel.tsx index f98dcff..75ecacf 100644 --- a/frontend/src/components/verificationBoard/VerificationIssueListPanel.tsx +++ b/frontend/src/components/verificationBoard/VerificationIssueListPanel.tsx @@ -11,6 +11,7 @@ import { } from '../../utils/verificationBoardStages'; import VerificationStageIcon from './VerificationStageIcon'; import { formatIsoDateTime } from '../../utils/timezone'; +import { VirtualScroll } from '../common/VirtualScroll'; interface Props { issues: VerificationIssueDto[]; @@ -72,36 +73,42 @@ const VerificationIssueListPanel: React.FC = ({ )}
    -
      - {filtered.length === 0 ? ( -
    • 표시할 검증 이슈가 없습니다.
    • - ) : filtered.map(issue => { + issue.id ?? `issue-${index}`} + empty={
      표시할 검증 이슈가 없습니다.
      } + role="list" + aria-label="검증 이슈 목록" + > + {issue => { const active = issue.id === selectedId; return ( -
    • - -
    • + {issue.title} + + ); - })} -
    + }} + ); }; diff --git a/frontend/src/components/virtual/VirtualLeftTargetPanel.tsx b/frontend/src/components/virtual/VirtualLeftTargetPanel.tsx index 8c52135..ba923c0 100644 --- a/frontend/src/components/virtual/VirtualLeftTargetPanel.tsx +++ b/frontend/src/components/virtual/VirtualLeftTargetPanel.tsx @@ -14,6 +14,7 @@ import { parseTargetStrategySelectValue, targetStrategySelectValue, } from '../../utils/virtualTargetStrategy'; +import { VirtualScroll } from '../common/VirtualScroll'; interface Props { targets: VirtualTargetItem[]; @@ -131,11 +132,18 @@ const VirtualLeftTargetPanel: React.FC = ({ {targets.length}/{virtualTargetMaxCount}종목 -
    - {targets.length === 0 && ( -

    검색 후 + 버튼으로 종목을 추가하세요.

    - )} - {targets.map(item => { + {targets.length === 0 ? ( +

    검색 후 + 버튼으로 종목을 추가하세요.

    + ) : ( + item.market} + aria-label="투자대상 목록" + > + {item => { const { koreanName: ko, englishName: en } = resolveVirtualTargetNames( item.market, item.koreanName, @@ -144,7 +152,6 @@ const VirtualLeftTargetPanel: React.FC = ({ const active = item.market === selectedMarket; return (
    = ({
    ); - })} - + }} + + )} ); diff --git a/frontend/src/components/virtual/VirtualTargetGrid.tsx b/frontend/src/components/virtual/VirtualTargetGrid.tsx index aa426cb..0a46717 100644 --- a/frontend/src/components/virtual/VirtualTargetGrid.tsx +++ b/frontend/src/components/virtual/VirtualTargetGrid.tsx @@ -17,6 +17,7 @@ import { getTradeNotificationGridPreset, type TradeNotificationGridPresetId, } from '../../utils/tradeNotificationGridPresets'; +import { VirtualGridScroll } from '../common/VirtualScroll'; interface Props { targets: VirtualTargetItem[]; @@ -60,6 +61,17 @@ const VirtualTargetGrid: React.FC = ({ gridPreset, }) => { const gridCols = getTradeNotificationGridPreset(gridPreset).cols; + + const gridRowEstimate = useMemo(() => { + if (viewMode === 'detail') return 540; + if (globalDisplayMode === 'chart') return 460; + return 400; + }, [viewMode, globalDisplayMode]); + + const gridRemeasureKey = useMemo( + () => `${viewMode}:${globalDisplayMode}:${targets.map(t => t.market).join(',')}:${Object.values(snapshots).map(s => s?.updatedAt ?? 0).join('|')}`, + [viewMode, globalDisplayMode, targets, snapshots], + ); const [cardDisplayOverrides, setCardDisplayOverrides] = useState>({}); const [focusMarket, setFocusMarket] = useState(null); @@ -144,20 +156,29 @@ const VirtualTargetGrid: React.FC = ({ englishName={focusTarget.englishName} /> ) : ( -
    t.market} + aria-label="가상매매 종목 그리드" > - {targets.map(t => { + {t => { const strat = strategies.find(s => s.id === resolveVirtualTargetStrategyId(t, session.globalStrategyId)); return ( = ({ theme={theme} chartRealtimeSource={chartRealtimeSource} chartCandleAreaPriceLabels={chartCandleAreaPriceLabels} - chartSeriesPriceLabels={chartSeriesPriceLabels} - chartPaneSeparator={chartPaneSeparator} + chartSeriesPriceLabels={chartSeriesPriceLabels} + chartPaneSeparator={chartPaneSeparator} chartLiveReceiveHighlight={chartLiveReceiveHighlight} ticker={tickers?.get(t.market)} koreanName={t.koreanName} englishName={t.englishName} /> ); - })} -
    + }} + )} ); diff --git a/frontend/src/hooks/useTradeNotificationSignalSnapshot.ts b/frontend/src/hooks/useTradeNotificationSignalSnapshot.ts index 3e8ebeb..59f53bc 100644 --- a/frontend/src/hooks/useTradeNotificationSignalSnapshot.ts +++ b/frontend/src/hooks/useTradeNotificationSignalSnapshot.ts @@ -3,11 +3,30 @@ */ import { useCallback, useEffect, useRef, useState } from 'react'; import { isStrategyMissingOnServer, loadStrategy, type StrategyDto } from '../utils/backendApi'; +import { extractVirtualConditions } from '../utils/virtualStrategyConditions'; +import { normalizeConditionRow } from '../utils/virtualSignalMetrics'; import { fetchLiveSnapshot, type VirtualIndicatorSnapshot, } from './useVirtualIndicatorSnapshots'; +function staticFallback( + market: string, + strategyId: number, + strategy: StrategyDto, +): VirtualIndicatorSnapshot | undefined { + const baseRows = extractVirtualConditions(strategy); + if (baseRows.length === 0) return undefined; + return { + market, + strategyId, + timeframe: [...new Set(baseRows.map(r => r.timeframe))].join(', '), + rows: baseRows.map(r => normalizeConditionRow({ ...r, currentValue: null, satisfied: null })), + updatedAt: Date.now(), + matchRate: 0, + }; +} + export function useTradeNotificationSignalSnapshot( market: string, strategyId: number | null | undefined, @@ -19,6 +38,7 @@ export function useTradeNotificationSignalSnapshot( const [loading, setLoading] = useState(false); const emptyRetryRef = useRef(0); const genRef = useRef(0); + const refreshInFlightRef = useRef(false); /** strategy prop 이 undefined 일 때 직접 로드한 전략 캐시 */ const fallbackStrategyRef = useRef(null); @@ -28,40 +48,52 @@ export function useTradeNotificationSignalSnapshot( setLoading(false); return; } + if (refreshInFlightRef.current) return; const gen = ++genRef.current; + refreshInFlightRef.current = true; setLoading(true); - // strategy prop 이 undefined 면 내부에서 직접 로드해 baseRows 를 확보한다. - // 이렇게 하면 strategy 가 늦게 주입되어도 조건 목록이 즉시 표시된다. - let effectiveStrategy = strategy; - if (!effectiveStrategy) { - if (fallbackStrategyRef.current) { - effectiveStrategy = fallbackStrategyRef.current; - } else if (!isStrategyMissingOnServer(strategyId)) { - try { - const loaded = await loadStrategy(strategyId); - if (loaded) { - fallbackStrategyRef.current = loaded; - effectiveStrategy = loaded; - } - } catch { /* ignore */ } - if (gen !== genRef.current) return; + try { + let effectiveStrategy = strategy; + if (!effectiveStrategy) { + if (fallbackStrategyRef.current) { + effectiveStrategy = fallbackStrategyRef.current; + } else if (!isStrategyMissingOnServer(strategyId)) { + try { + const loaded = await loadStrategy(strategyId); + if (loaded) { + fallbackStrategyRef.current = loaded; + effectiveStrategy = loaded; + } + } catch { /* ignore */ } + if (gen !== genRef.current) return; + } + } + + const pinFirst = emptyRetryRef.current === 0; + let snap = await fetchLiveSnapshot(market, strategyId, effectiveStrategy, pinFirst); + if (gen !== genRef.current) return; + + if (!snap && effectiveStrategy) { + snap = staticFallback(market, strategyId, effectiveStrategy) ?? null; + } + + if (snap && snap.rows.length > 0) { + emptyRetryRef.current = 0; + } else { + emptyRetryRef.current += 1; + } + + if (gen === genRef.current) { + setSnapshot(snap ?? undefined); + } + } finally { + refreshInFlightRef.current = false; + if (gen === genRef.current) { + setLoading(false); } } - - const pinFirst = emptyRetryRef.current === 0; - const snap = await fetchLiveSnapshot(market, strategyId, effectiveStrategy, pinFirst); - if (gen !== genRef.current) return; - - if (snap && snap.rows.length > 0) { - emptyRetryRef.current = 0; - } else { - emptyRetryRef.current += 1; - } - - setSnapshot(snap ?? undefined); - setLoading(false); }, [market, strategyId, strategy, enabled]); useEffect(() => { @@ -72,13 +104,14 @@ export function useTradeNotificationSignalSnapshot( return; } emptyRetryRef.current = 0; - fallbackStrategyRef.current = null; // strategy prop 변경 시 캐시 초기화 - setLoading(true); // 활성화 즉시 로딩 표시 + fallbackStrategyRef.current = null; + setLoading(true); void refresh(); const id = window.setInterval(() => void refresh(), pollMs); return () => { clearInterval(id); genRef.current += 1; + refreshInFlightRef.current = false; }; }, [market, strategyId, enabled, pollMs, refresh]); diff --git a/frontend/src/hooks/useVirtualIndicatorSnapshots.ts b/frontend/src/hooks/useVirtualIndicatorSnapshots.ts index 8b0135b..9a0c895 100644 --- a/frontend/src/hooks/useVirtualIndicatorSnapshots.ts +++ b/frontend/src/hooks/useVirtualIndicatorSnapshots.ts @@ -4,7 +4,11 @@ * running 시 3초 폴링, 미수집 시 재시도 */ import { useCallback, useEffect, useRef, useState } from 'react'; -import type { StrategyDto } from '../utils/backendApi'; +import { + isStrategyMissingOnServer, + loadStrategy, + type StrategyDto, +} from '../utils/backendApi'; import { fetchLiveConditionStatus, pinCandleWatch, @@ -107,6 +111,12 @@ function staticSnapshot( }; } +function statusMatches(status: { market?: string; strategyId?: number }, market: string, strategyId: number): boolean { + if (!status?.market || status.market !== market) return false; + const sid = coerceFiniteNumber(status.strategyId); + return sid === strategyId; +} + /** 백엔드가 업비트 REST·WS로 캔들 warm-up 후 Ta4j 평가 */ export async function fetchLiveSnapshot( market: string, @@ -133,13 +143,16 @@ export async function fetchLiveSnapshot( status = null; } - if (!status || status.market !== market || status.strategyId !== strategyId) { + if (!status || !statusMatches(status, market, strategyId)) { if (baseRows.length === 0) return null; return staticSnapshot(market, strategyId, baseRows); } - const rows = mergeRows(baseRows, status.rows); - if (rows.length === 0) return null; + const rows = mergeRows(baseRows, status.rows ?? []); + if (rows.length === 0) { + if (baseRows.length === 0) return null; + return staticSnapshot(market, strategyId, baseRows); + } return { market, @@ -151,6 +164,34 @@ export async function fetchLiveSnapshot( }; } +async function resolveStrategy( + strategyId: number, + strategies: StrategyDto[], + cache: Map, +): Promise { + const cached = strategies.find(s => s.id === strategyId); + if (cached) return cached; + + if (cache.has(strategyId)) { + const hit = cache.get(strategyId); + return hit ?? undefined; + } + + if (isStrategyMissingOnServer(strategyId)) { + cache.set(strategyId, null); + return undefined; + } + + try { + const loaded = await loadStrategy(strategyId); + cache.set(strategyId, loaded); + return loaded ?? undefined; + } catch { + cache.set(strategyId, null); + return undefined; + } +} + interface TargetRef { market: string; strategyId: number | null; @@ -166,47 +207,16 @@ export function useVirtualIndicatorSnapshots( const [loadingByMarket, setLoadingByMarket] = useState>({}); const strategiesRef = useRef(strategies); strategiesRef.current = strategies; - const pinnedRef = useRef>(new Set()); + const strategyLoadCacheRef = useRef>(new Map()); const refreshGenRef = useRef>({}); const emptyRetryRef = useRef>({}); + const refreshInFlightRef = useRef(false); const targetsKey = targets.map(t => `${t.market}:${t.strategyId ?? ''}`).join('|'); - - const pinTargets = useCallback(async () => { - const strats = strategiesRef.current; - const pins = new Set(); - for (const t of targets) { - if (t.strategyId == null) continue; - try { - const tfs = await pinStrategyEvaluationTimeframes(t.market, t.strategyId); - for (const tf of tfs) pins.add(`${t.market}:${tf}`); - } catch { - const strat = strats.find(s => s.id === t.strategyId); - const conditions = strat ? extractVirtualConditions(strat) : []; - const tfs = conditions.length > 0 - ? [...new Set(conditions.map(c => c.timeframe))] - : ['1m']; - for (const tf of tfs) { - const key = `${t.market}:${tf}`; - pins.add(key); - if (!pinnedRef.current.has(key)) { - await pinCandleWatch(t.market, tf); - pinnedRef.current.add(key); - } - } - const k1 = `${t.market}:1m`; - pins.add(k1); - if (!pinnedRef.current.has(k1)) { - await pinCandleWatch(t.market, '1m'); - pinnedRef.current.add(k1); - } - } - } - for (const k of pinnedRef.current) { - if (!pins.has(k)) pinnedRef.current.delete(k); - } - }, [targets]); + const strategiesKey = strategies.map(s => s.id).join('|'); const refresh = useCallback(async () => { + if (refreshInFlightRef.current) return; + const strats = strategiesRef.current; const activeTargets = targets.filter(t => t.strategyId != null); if (activeTargets.length === 0) { @@ -215,69 +225,78 @@ export function useVirtualIndicatorSnapshots( return; } + refreshInFlightRef.current = true; setLoadingByMarket(prev => { const next = { ...prev }; for (const t of activeTargets) next[t.market] = true; return next; }); - await pinTargets(); + try { + const jobs = activeTargets.map(async t => { + const market = t.market; + const strategyId = t.strategyId!; + const gen = (refreshGenRef.current[market] ?? 0) + 1; + refreshGenRef.current[market] = gen; - const jobs = activeTargets.map(async t => { - const gen = (refreshGenRef.current[t.market] ?? 0) + 1; - refreshGenRef.current[t.market] = gen; - const strat = strats.find(s => s.id === t.strategyId); - const baseRows = strat ? extractVirtualConditions(strat) : []; - const needsLiveApi = running || baseRows.length === 0; + try { + const strat = await resolveStrategy(strategyId, strats, strategyLoadCacheRef.current); + if (refreshGenRef.current[market] !== gen) return; - let snap: VirtualIndicatorSnapshot | null = null; - if (needsLiveApi) { - const retry = emptyRetryRef.current[t.market] ?? 0; - snap = await fetchLiveSnapshot(t.market, t.strategyId!, strat, retry === 0); - } else { - snap = staticSnapshot(t.market, t.strategyId!, baseRows); - } + const baseRows = strat ? extractVirtualConditions(strat) : []; + const retry = emptyRetryRef.current[market] ?? 0; + let snap = await fetchLiveSnapshot(market, strategyId, strat, retry === 0); - if (!snap || refreshGenRef.current[t.market] !== gen) return snap; - if (snap.strategyId !== t.strategyId) return snap; + if (!snap && baseRows.length > 0) { + snap = staticSnapshot(market, strategyId, baseRows); + } - if (snap.rows.length === 0) { - const n = (emptyRetryRef.current[t.market] ?? 0) + 1; - emptyRetryRef.current[t.market] = n; - } else { - emptyRetryRef.current[t.market] = 0; - } + if (!snap || refreshGenRef.current[market] !== gen) return; + if (snap.strategyId !== strategyId) return; - setSnapshots(prev => ({ ...prev, [snap.market]: snap })); - setLoadingByMarket(prev => ({ ...prev, [t.market]: false })); - return snap; - }); - await Promise.all(jobs); + if (snap.rows.length === 0) { + emptyRetryRef.current[market] = (emptyRetryRef.current[market] ?? 0) + 1; + } else { + emptyRetryRef.current[market] = 0; + } - const activeMarkets = new Set(targets.map(t => t.market)); - setSnapshots(prev => { - const next = { ...prev }; - let changed = false; - for (const key of Object.keys(next)) { - if (!activeMarkets.has(key)) { - delete next[key]; - changed = true; + setSnapshots(prev => ({ ...prev, [snap!.market]: snap! })); + } finally { + if (refreshGenRef.current[market] === gen) { + setLoadingByMarket(prev => ({ ...prev, [market]: false })); + } } - } - return changed ? next : prev; - }); - setLoadingByMarket(prev => { - const next = { ...prev }; - let changed = false; - for (const key of Object.keys(next)) { - if (!activeMarkets.has(key)) { - delete next[key]; - changed = true; + }); + + await Promise.all(jobs); + + const activeMarkets = new Set(targets.map(t => t.market)); + setSnapshots(prev => { + const next = { ...prev }; + let changed = false; + for (const key of Object.keys(next)) { + if (!activeMarkets.has(key)) { + delete next[key]; + changed = true; + } } - } - return changed ? next : prev; - }); - }, [targets, running, pinTargets]); + return changed ? next : prev; + }); + setLoadingByMarket(prev => { + const next = { ...prev }; + let changed = false; + for (const key of Object.keys(next)) { + if (!activeMarkets.has(key)) { + delete next[key]; + changed = true; + } + } + return changed ? next : prev; + }); + } finally { + refreshInFlightRef.current = false; + } + }, [targets, running]); const hasStrategyTargets = targets.some(t => t.strategyId != null); @@ -285,17 +304,17 @@ export function useVirtualIndicatorSnapshots( if (targets.length === 0) { setSnapshots({}); setLoadingByMarket({}); - pinnedRef.current.clear(); refreshGenRef.current = {}; emptyRetryRef.current = {}; + strategyLoadCacheRef.current.clear(); return; } void refresh(); - if (!running && !hasStrategyTargets) return; + if (!hasStrategyTargets) return; const intervalMs = running ? pollMs : 5000; const id = window.setInterval(() => void refresh(), intervalMs); return () => clearInterval(id); - }, [targetsKey, running, hasStrategyTargets, pollMs, refresh]); + }, [targetsKey, strategiesKey, running, hasStrategyTargets, pollMs, refresh]); return { snapshots, loadingByMarket }; } diff --git a/frontend/src/styles/backtestDashboard.css b/frontend/src/styles/backtestDashboard.css index 14b2c88..f487bd7 100644 --- a/frontend/src/styles/backtestDashboard.css +++ b/frontend/src/styles/backtestDashboard.css @@ -553,6 +553,10 @@ padding: 4px 2px 8px; } +.btd-exec-scroll.vl-scroll .vl-scroll-row { + padding-bottom: 8px; +} + .btd-exec-item { width: 100%; text-align: left; diff --git a/frontend/src/styles/tradeNotificationList.css b/frontend/src/styles/tradeNotificationList.css index 0e13578..9a1b824 100644 --- a/frontend/src/styles/tradeNotificationList.css +++ b/frontend/src/styles/tradeNotificationList.css @@ -1051,6 +1051,20 @@ gap: 12px; } +.tnl-list.vl-scroll { + list-style: none; + margin: 0; + padding: 4px 4px 16px; +} + +.tnl-list.vl-scroll.tnl-list--gallery .vl-scroll-row { + padding-bottom: 12px; +} + +.tnl-list.vl-scroll.tnl-list--grid { + padding: 4px 4px 16px; +} + /* 우측 매매·호가 패널 접기/펼치기 */ .tnl-side-wrap--right { display: flex; diff --git a/frontend/src/styles/trendSearchDashboard.css b/frontend/src/styles/trendSearchDashboard.css index 7848530..871f816 100644 --- a/frontend/src/styles/trendSearchDashboard.css +++ b/frontend/src/styles/trendSearchDashboard.css @@ -572,6 +572,21 @@ border-radius: 10px; } +.tsd-results-table-wrap--virtual { + display: flex; + flex-direction: column; + overflow: hidden; +} + +.tsd-results-table--head { + flex-shrink: 0; +} + +.tsd-virtual-empty { + padding: 24px 16px; + text-align: center; +} + .tsd-results-table { width: 100%; border-collapse: collapse; diff --git a/frontend/src/styles/verificationBoard.css b/frontend/src/styles/verificationBoard.css index e6b1444..ceee095 100644 --- a/frontend/src/styles/verificationBoard.css +++ b/frontend/src/styles/verificationBoard.css @@ -99,6 +99,10 @@ min-height: 0; } +.vbd-list.vl-scroll { + display: block; +} + .vbd-list-empty { padding: 24px 12px; text-align: center; diff --git a/frontend/src/styles/virtualList.css b/frontend/src/styles/virtualList.css new file mode 100644 index 0000000..a699da6 --- /dev/null +++ b/frontend/src/styles/virtualList.css @@ -0,0 +1,104 @@ +/* 가상 스크롤 공통 — @tanstack/react-virtual */ +.vl-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; +} + +.vl-scroll-inner { + position: relative; + width: 100%; +} + +.vl-scroll-row { + width: 100%; +} + +.vl-grid-row { + display: grid; + width: 100%; + align-items: start; +} + +.vl-scroll--list { + list-style: none; + margin: 0; + padding: 0; +} + +.vl-scroll-item { + width: 100%; + list-style: none; +} + +/* 추세검색 — 테이블 대체 가상 행 */ +.tsd-virtual-body.vl-scroll { + border-top: none; +} + +.tsd-virtual-row { + display: grid; + grid-template-columns: minmax(100px, 1.4fr) minmax(72px, 0.9fr) minmax(56px, 0.7fr) minmax(120px, 1.5fr) minmax(72px, 0.8fr); + align-items: center; + cursor: pointer; + font-size: 11px; + border-bottom: 1px solid color-mix(in srgb, var(--tsd-border) 60%, transparent); + transition: background 0.15s; +} + +.tsd-virtual-row:hover { + background: color-mix(in srgb, var(--tsd-gold) 6%, transparent); +} + +.tsd-virtual-row.tsd-row--sel { + background: color-mix(in srgb, var(--tsd-gold) 12%, transparent); + box-shadow: inset 3px 0 0 var(--tsd-gold); +} + +.tsd-virtual-cell { + padding: 9px 10px; + color: var(--tsd-text); + min-width: 0; +} + +.vtd-grid-wrap--virtual { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +/* 가상 그리드 — 스크롤 컨테이너는 block, 행 단위로 CSS grid */ +.vtd-grid-scroll { + flex: 1; + min-height: 0; + width: 100%; + display: block; + padding: 4px 4px 12px; + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; +} + +.vtd-grid-row { + display: grid; + grid-template-columns: repeat(var(--vtd-grid-cols, 2), minmax(0, 1fr)); + gap: 14px; + align-items: start; + width: 100%; +} + +.vtd-grid-row .vtd-card--detail { + height: auto; + align-self: start; +} + +.notify-virtual-list.vl-scroll, +.virtual-target-virtual-list.vl-scroll { + flex: 1; + min-height: 0; +} diff --git a/frontend/src/styles/virtualTradingDashboard.css b/frontend/src/styles/virtualTradingDashboard.css index 30692c6..0031f66 100644 --- a/frontend/src/styles/virtualTradingDashboard.css +++ b/frontend/src/styles/virtualTradingDashboard.css @@ -1303,6 +1303,10 @@ .vtd-grid--layout-preset { grid-template-columns: minmax(0, 1fr) !important; } + + .vtd-grid-scroll .vtd-grid-row { + grid-template-columns: minmax(0, 1fr) !important; + } } .vtd-card { diff --git a/package-lock.json b/package-lock.json index 4e4991b..b0ac8c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,7 @@ "version": "0.1.0", "dependencies": { "@stomp/stompjs": "^7.3.0", + "@tanstack/react-virtual": "^3.14.2", "@xyflow/react": "^12.10.2", "lightweight-charts": "^5.2.0", "lightweight-charts-indicators": "^0.4.1", @@ -1501,6 +1502,33 @@ "integrity": "sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==", "license": "Apache-2.0" }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz", + "integrity": "sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.17.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.0.tgz", + "integrity": "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",