목록 로딩 로직 개선

This commit is contained in:
Macbook
2026-06-07 16:57:30 +09:00
parent 59c548cb41
commit 7ec754770d
25 changed files with 1081 additions and 431 deletions
@@ -5,6 +5,7 @@ import { useAuth } from '../../contexts/AuthContext';
import { useAppSettings, reloadAppSettingsCache } from '../../hooks/useAppSettings'; import { useAppSettings, reloadAppSettingsCache } from '../../hooks/useAppSettings';
import { useVirtualTradingCore } from '@frontend/hooks/useVirtualTradingCore'; import { useVirtualTradingCore } from '@frontend/hooks/useVirtualTradingCore';
import NotificationListRow from './NotificationListRow'; import NotificationListRow from './NotificationListRow';
import { VirtualScroll } from '@frontend/components/common/VirtualScroll';
import NotificationDetailScreen from './NotificationDetailScreen'; import NotificationDetailScreen from './NotificationDetailScreen';
import VirtualTradeScreen from '../virtual/VirtualTradeScreen'; import VirtualTradeScreen from '../virtual/VirtualTradeScreen';
@@ -133,12 +134,11 @@ export default function NotificationsScreen() {
)} )}
<div <div
ref={listRef}
className="notify-list-wrap" className="notify-list-wrap"
onTouchStart={onTouchStart} onTouchStart={onTouchStart}
onTouchMove={onTouchMove} onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd} onTouchEnd={onTouchEnd}
style={{ flex: 1, overflow: 'auto', WebkitOverflowScrolling: 'touch' }} style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}
> >
{(pullY > 0 || refreshing) && ( {(pullY > 0 || refreshing) && (
<div className="notify-pull-hint" style={{ textAlign: 'center', padding: pullY > 0 ? `${pullY / 4}px` : 8, fontSize: 12, color: 'var(--gc-text-muted)' }}> <div className="notify-pull-hint" style={{ textAlign: 'center', padding: pullY > 0 ? `${pullY / 4}px` : 8, fontSize: 12, color: 'var(--gc-text-muted)' }}>
@@ -155,23 +155,34 @@ export default function NotificationsScreen() {
</button> </button>
</div> </div>
) : ( ) : (
<div className="stack-list" style={{ padding: '0 16px' }}> <VirtualScroll
{allNotifications.map(item => ( ref={listRef}
<NotificationListRow className="stack-list notify-virtual-list"
key={item.id} items={allNotifications}
item={item} estimateSize={148}
onDetail={() => { measureDynamic
markAsRead(item.id); overscan={5}
goNotifyDetail(item.id, item.market); getItemKey={item => item.id}
}} innerClassName="vl-scroll-inner"
onTrade={side => { aria-label="알림 목록"
markAsRead(item.id); >
goNotifyTrade(item.market, side); {item => (
}} <div style={{ padding: '0 16px 10px' }}>
onDelete={() => void deleteNotification(item.id)} <NotificationListRow
/> item={item}
))} onDetail={() => {
</div> markAsRead(item.id);
goNotifyDetail(item.id, item.market);
}}
onTrade={side => {
markAsRead(item.id);
goNotifyTrade(item.market, side);
}}
onDelete={() => void deleteNotification(item.id)}
/>
</div>
)}
</VirtualScroll>
)} )}
</div> </div>
@@ -11,6 +11,7 @@ import VirtualFocusScreen from './VirtualFocusScreen';
import VirtualTradeScreen from './VirtualTradeScreen'; import VirtualTradeScreen from './VirtualTradeScreen';
import VirtualHistoryScreen from './VirtualHistoryScreen'; import VirtualHistoryScreen from './VirtualHistoryScreen';
import { MarketSearchPanel } from '@frontend/components/MarketSearchPanel'; import { MarketSearchPanel } from '@frontend/components/MarketSearchPanel';
import { VirtualScroll } from '@frontend/components/common/VirtualScroll';
export default function VirtualTradingScreen() { export default function VirtualTradingScreen() {
const { const {
@@ -172,15 +173,23 @@ export default function VirtualTradingScreen() {
)} )}
</div> </div>
) : ( ) : (
<div className="stack-list" style={{ padding: '0 16px' }}> <VirtualScroll
{targets.map(t => { className="stack-list virtual-target-virtual-list"
items={targets}
estimateSize={120}
measureDynamic
getItemKey={t => t.market}
innerClassName="vl-scroll-inner"
aria-label="투자 대상 목록"
>
{t => {
const strategyId = resolveVirtualTargetStrategyId(t, session.globalStrategyId); const strategyId = resolveVirtualTargetStrategyId(t, session.globalStrategyId);
const snapKey = `${t.market}:${strategyId ?? ''}`; const snapKey = `${t.market}:${strategyId ?? ''}`;
const snap = snapshots[snapKey] ?? snapshots[t.market]; const snap = snapshots[snapKey] ?? snapshots[t.market];
const signalLoading = snapshotLoadingByMarket[t.market]; const signalLoading = snapshotLoadingByMarket[t.market];
return ( return (
<div style={{ padding: '0 16px 10px' }}>
<VirtualTargetListRow <VirtualTargetListRow
key={t.market}
target={t} target={t}
globalStrategyId={session.globalStrategyId} globalStrategyId={session.globalStrategyId}
strategies={strategies} strategies={strategies}
@@ -192,9 +201,10 @@ export default function VirtualTradingScreen() {
onTogglePin={() => void handleTogglePin(t.market)} onTogglePin={() => void handleTogglePin(t.market)}
onRemove={() => handleRemoveTarget(t.market)} onRemove={() => handleRemoveTarget(t.market)}
/> />
</div>
); );
})} }}
</div> </VirtualScroll>
)} )}
<button type="button" className="fab" aria-label="종목 추가" onClick={() => setAddSheetOpen(true)}>+</button> <button type="button" className="fab" aria-label="종목 추가" onClick={() => setAddSheetOpen(true)}>+</button>
+28
View File
@@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@stomp/stompjs": "^7.3.0", "@stomp/stompjs": "^7.3.0",
"@tanstack/react-virtual": "^3.14.2",
"@xyflow/react": "^12.10.2", "@xyflow/react": "^12.10.2",
"lightweight-charts": "^5.2.0", "lightweight-charts": "^5.2.0",
"lightweight-charts-indicators": "^0.4.1", "lightweight-charts-indicators": "^0.4.1",
@@ -1153,6 +1154,33 @@
"integrity": "sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==", "integrity": "sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==",
"license": "Apache-2.0" "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": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+1
View File
@@ -10,6 +10,7 @@
}, },
"dependencies": { "dependencies": {
"@stomp/stompjs": "^7.3.0", "@stomp/stompjs": "^7.3.0",
"@tanstack/react-virtual": "^3.14.2",
"@xyflow/react": "^12.10.2", "@xyflow/react": "^12.10.2",
"lightweight-charts": "^5.2.0", "lightweight-charts": "^5.2.0",
"lightweight-charts-indicators": "^0.4.1", "lightweight-charts-indicators": "^0.4.1",
+97 -89
View File
@@ -10,6 +10,7 @@ import { UPBIT_API } from '../utils/upbitApi';
import { fetchTickersThrottled } from '../utils/upbitTickerFetch'; import { fetchTickersThrottled } from '../utils/upbitTickerFetch';
import { useIsCompactDevice } from '../hooks/useMediaQuery'; import { useIsCompactDevice } from '../hooks/useMediaQuery';
import { safeToFixed } from '../utils/safeFormat'; import { safeToFixed } from '../utils/safeFormat';
import { VirtualScroll } from './common/VirtualScroll';
// ─── Types ─────────────────────────────────────────────────────────────────── // ─── Types ───────────────────────────────────────────────────────────────────
interface MarketInfo { interface MarketInfo {
@@ -373,98 +374,105 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
)} )}
</div> </div>
{/* 목록 */} {/* 목록 — 가상 스크롤 (업비트 전 종목) */}
<div className="msp-list"> {loading ? (
{loading && ( <div className="msp-list">
<div className="msp-empty"> ...</div> <div className="msp-empty"> ...</div>
)} </div>
{!loading && sorted.length === 0 && ( ) : (
<div className="msp-empty"> </div> <VirtualScroll
)} className="msp-list"
{sorted.map(m => { items={sorted}
const tk = tickers[m.market]; estimateSize={embedded ? 34 : isCompactDropdown ? 36 : 38}
const isFav = favs.has(m.market); measureDynamic
const isAdded = addedMarkets?.has(m.market) ?? false; getItemKey={m => m.market}
const addBlocked = maxTargetsReached && !isAdded; empty={<div className="msp-empty"> </div>}
const isActive = m.market === currentMarket; aria-label="종목 검색 결과"
const rate = tk ? formatChangeRate(tk.signed_change_rate) : null; >
const isUp = rate !== null && rate >= 0; {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 ( return (
<div <div
key={m.market} className={[
className={[ 'msp-item',
'msp-item', embedded ? 'msp-item--embedded' : '',
embedded ? 'msp-item--embedded' : '', isCompactDropdown ? 'msp-item--compact' : '',
isCompactDropdown ? 'msp-item--compact' : '', isActive ? 'active' : '',
isActive ? 'active' : '', ].filter(Boolean).join(' ')}
].filter(Boolean).join(' ')} onClick={() => handleRowSelect(m.market)}
onClick={() => handleRowSelect(m.market)} >
> {!isCompactDropdown && (
{!isCompactDropdown && ( actionMode === 'add' ? (
actionMode === 'add' ? ( <button
<button type="button"
type="button" className={`msp-add-btn${isAdded ? ' msp-add-btn--done' : ''}${addBlocked ? ' msp-add-btn--blocked' : ''}`}
className={`msp-add-btn${isAdded ? ' msp-add-btn--done' : ''}${addBlocked ? ' msp-add-btn--blocked' : ''}`} onClick={e => handleAddClick(m.market, e)}
onClick={e => handleAddClick(m.market, e)} disabled={addBlocked}
disabled={addBlocked} title={
title={ isAdded
isAdded ? '투자대상에서 제거'
? '투자대상에서 제거' : addBlocked
: addBlocked ? '투자대상 최대 개수에 도달했습니다'
? '투자대상 최대 개수에 도달했습니다' : '투자대상에 추가'
: '투자대상에 추가' }
} >
> {isAdded ? (
{isAdded ? ( <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> <polyline points="20 6 9 17 4 12"/>
<polyline points="20 6 9 17 4 12"/> </svg>
</svg> ) : (
) : ( <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> <line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/> </svg>
</svg> )}
)} </button>
</button> ) : (
) : ( <span
<span className={`msp-col msp-col-fav msp-star${isFav ? ' starred' : ''}`}
className={`msp-col msp-col-fav msp-star${isFav ? ' starred' : ''}`} onClick={e => toggleFav(m.market, e)}
onClick={e => toggleFav(m.market, e)} title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'} >
> {isFav ? '★' : '☆'}
{isFav ? '★' : '☆'}
</span>
)
)}
<span className="msp-col msp-col-name">
<span className="msp-kor">{m.korean_name}</span>
{!isCompactDropdown && <span className="msp-sym">{m.symbol}</span>}
</span>
{!embedded && (
<>
<span className="msp-col msp-col-price">
{tk && Number.isFinite(Number(tk.trade_price))
? Number(tk.trade_price).toLocaleString()
: '-'}
</span>
<span className={`msp-col msp-col-change ${rate === null ? '' : isUp ? 'up' : 'down'}`}>
{rate === null
? '-'
: `${isUp ? '+' : ''}${safeToFixed(rate, 2)}%`}
</span>
{!isCompactDropdown && (
<span className="msp-col msp-col-vol">
{tk?.acc_trade_price_24h ? formatVol(tk.acc_trade_price_24h) : '-'}
</span> </span>
)} )
</> )}
)}
</div> <span className="msp-col msp-col-name">
); <span className="msp-kor">{m.korean_name}</span>
})} {!isCompactDropdown && <span className="msp-sym">{m.symbol}</span>}
</div> </span>
{!embedded && (
<>
<span className="msp-col msp-col-price">
{tk && Number.isFinite(Number(tk.trade_price))
? Number(tk.trade_price).toLocaleString()
: '-'}
</span>
<span className={`msp-col msp-col-change ${rate === null ? '' : isUp ? 'up' : 'down'}`}>
{rate === null
? '-'
: `${isUp ? '+' : ''}${safeToFixed(rate, 2)}%`}
</span>
{!isCompactDropdown && (
<span className="msp-col msp-col-vol">
{tk?.acc_trade_price_24h ? formatVol(tk.acc_trade_price_24h) : '-'}
</span>
)}
</>
)}
</div>
);
}}
</VirtualScroll>
)}
</div> </div>
</> </>
@@ -35,6 +35,7 @@ import '../styles/virtualTradingDashboard.css';
import '../styles/builderPageShell.css'; import '../styles/builderPageShell.css';
import '../styles/tradeNotificationList.css'; import '../styles/tradeNotificationList.css';
import TradeNotificationListRow from './tradeNotification/TradeNotificationListRow'; import TradeNotificationListRow from './tradeNotification/TradeNotificationListRow';
import { VirtualGridScroll, VirtualScroll } from './common/VirtualScroll';
import TradeNotificationGridLayoutPicker from './tradeNotification/TradeNotificationGridLayoutPicker'; import TradeNotificationGridLayoutPicker from './tradeNotification/TradeNotificationGridLayoutPicker';
import FullscreenChartModal from './tradeNotification/FullscreenChartModal'; import FullscreenChartModal from './tradeNotification/FullscreenChartModal';
import { import {
@@ -425,6 +426,47 @@ export const TradeNotificationListPage: React.FC<Props> = ({
const isListView = listLayout === 'list'; const isListView = listLayout === 'list';
const gridCols = getTradeNotificationGridPreset(gridPreset).cols; const gridCols = getTradeNotificationGridPreset(gridPreset).cols;
const renderNotificationRow = useCallback((item: TradeNotificationItem) => (
<TradeNotificationListRow
key={item.id}
item={item}
theme={theme}
layoutMode={listLayout}
itemTag="div"
isSelected={selectedId === item.id}
chartsEnabled={isListView}
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
onSelect={() => 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 ( return (
<> <>
{fullscreenItem && ( {fullscreenItem && (
@@ -588,41 +630,39 @@ export const TradeNotificationListPage: React.FC<Props> = ({
<div className="tnl-empty"> <div className="tnl-empty">
{normalizedQuery ? `"${query}" 에 해당하는 알림이 없습니다.` : '표시할 알림이 없습니다.'} {normalizedQuery ? `"${query}" 에 해당하는 알림이 없습니다.` : '표시할 알림이 없습니다.'}
</div> </div>
) : isListView ? (
<VirtualScroll
className="tnl-list tnl-list--gallery vl-scroll"
items={sorted}
estimateSize={384}
measureDynamic
overscan={3}
getItemKey={item => item.id}
role="list"
aria-label="매매 시그널 알림 목록"
>
{item => renderNotificationRow(item)}
</VirtualScroll>
) : ( ) : (
<ul <VirtualGridScroll
className={[ className={[
'tnl-list', 'tnl-list',
isListView ? 'tnl-list--gallery' : 'tnl-list--grid', 'tnl-list--grid',
!isListView ? `tnl-grid--cols-${gridCols}` : '', 'vl-scroll',
].filter(Boolean).join(' ')} `tnl-grid--cols-${gridCols}`,
style={!isListView ? { gridTemplateColumns: `repeat(${gridCols}, minmax(0, 1fr))` } : undefined} ].join(' ')}
items={sorted}
columns={gridCols}
estimateRowSize={300}
rowGap={14}
measureDynamic
overscan={2}
getItemKey={item => item.id}
role="list"
aria-label="매매 시그널 알림 그리드"
> >
{sorted.map(item => ( {item => renderNotificationRow(item)}
<TradeNotificationListRow </VirtualGridScroll>
key={item.id}
item={item}
theme={theme}
layoutMode={listLayout}
isSelected={selectedId === item.id}
chartsEnabled={isListView}
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
onSelect={() => 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}
/>
))}
</ul>
)} )}
</div> </div>
</div> </div>
@@ -1,4 +1,5 @@
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { VirtualScroll } from '../common/VirtualScroll';
import type { BacktestResultRecord } from '../../utils/backendApi'; import type { BacktestResultRecord } from '../../utils/backendApi';
import type { LiveExecutionItem } from '../../utils/liveExecutionGroups'; import type { LiveExecutionItem } from '../../utils/liveExecutionGroups';
import BacktestSparkline from './BacktestSparkline'; import BacktestSparkline from './BacktestSparkline';
@@ -164,9 +165,14 @@ export default function BacktestExecutionList({
</div> </div>
)} )}
<div className="btd-exec-scroll"> {tab === 'backtest' ? (
{tab === 'backtest' ? ( <VirtualScroll
filteredBacktests.length === 0 ? ( className="btd-exec-scroll vl-scroll"
items={filteredBacktests}
estimateSize={88}
measureDynamic
getItemKey={r => r.id ?? r.createdAt}
empty={(
<div className="btd-sidebar-empty"> <div className="btd-sidebar-empty">
{normalizedQuery ? ( {normalizedQuery ? (
<p>'{query}' .</p> <p>'{query}' .</p>
@@ -177,51 +183,62 @@ export default function BacktestExecutionList({
</> </>
)} )}
</div> </div>
) : ( )}
filteredBacktests.map(r => { aria-label="백테스팅 실행 목록"
const ret = r.totalReturn ?? 0; >
const positive = ret >= 0; {r => {
const active = selectedBacktestId === r.id; const ret = r.totalReturn ?? 0;
const market = toUpbitMarket(r.symbol); const positive = ret >= 0;
const ko = getKoreanName(market); const active = selectedBacktestId === r.id;
const strategy = r.strategyName || '전략 없음'; const market = toUpbitMarket(r.symbol);
return ( const ko = getKoreanName(market);
<button key={r.id} type="button" className={`btd-history-card${active ? ' btd-history-card--active' : ''}`} onClick={() => onSelectBacktest(r)}> const strategy = r.strategyName || '전략 없음';
<div className="btd-history-card-row"> return (
<div className="btd-history-card-main"> <button type="button" className={`btd-history-card${active ? ' btd-history-card--active' : ''}`} onClick={() => onSelectBacktest(r)}>
<span className="btd-history-ko">{ko}</span> <div className="btd-history-card-row">
<span className="btd-history-strategy">{strategy} · {formatTimeframeKo(r.timeframe)}</span> <div className="btd-history-card-main">
<span className="btd-history-date">{fmtListTimestamp(r.createdAt)}</span> <span className="btd-history-ko">{ko}</span>
</div> <span className="btd-history-strategy">{strategy} · {formatTimeframeKo(r.timeframe)}</span>
<div className="btd-history-card-side"> <span className="btd-history-date">{fmtListTimestamp(r.createdAt)}</span>
<BacktestSparkline curve={parseBacktestSpark(r)} positive={positive} width={56} height={22} />
<span className="btd-history-win"> {pctAbs(r.winRate ?? 0)}</span>
<span className={`btd-history-roi${positive ? ' up' : ' down'}`}> {pct(ret)}</span>
</div>
</div> </div>
</button> <div className="btd-history-card-side">
); <BacktestSparkline curve={parseBacktestSpark(r)} positive={positive} width={56} height={22} />
}) <span className="btd-history-win"> {pctAbs(r.winRate ?? 0)}</span>
) <span className={`btd-history-roi${positive ? ' up' : ' down'}`}> {pct(ret)}</span>
) : filteredLiveItems.length === 0 ? ( </div>
<div className="btd-sidebar-empty"> </div>
{normalizedQuery ? ( </button>
<p>'{query}' .</p> );
) : ( }}
<> </VirtualScroll>
<p> .</p> ) : (
<p className="btd-sidebar-hint"> ·<br /> .</p> <VirtualScroll
</> className="btd-exec-scroll vl-scroll"
)} items={filteredLiveItems}
</div> estimateSize={88}
) : ( measureDynamic
filteredLiveItems.map(item => { getItemKey={item => item.id}
empty={(
<div className="btd-sidebar-empty">
{normalizedQuery ? (
<p>'{query}' .</p>
) : (
<>
<p> .</p>
<p className="btd-sidebar-hint"> ·<br /> .</p>
</>
)}
</div>
)}
aria-label="실시간 매매 목록"
>
{item => {
const positive = item.totalReturnPct >= 0; const positive = item.totalReturnPct >= 0;
const active = selectedLiveId === item.id; const active = selectedLiveId === item.id;
const market = toUpbitMarket(item.symbol); const market = toUpbitMarket(item.symbol);
const ko = getKoreanName(market); const ko = getKoreanName(market);
return ( return (
<button key={item.id} type="button" className={`btd-history-card${active ? ' btd-history-card--active' : ''}`} onClick={() => onSelectLive(item)}> <button type="button" className={`btd-history-card${active ? ' btd-history-card--active' : ''}`} onClick={() => onSelectLive(item)}>
<div className="btd-history-card-row"> <div className="btd-history-card-row">
<div className="btd-history-card-main"> <div className="btd-history-card-main">
<span className="btd-history-ko">{ko}</span> <span className="btd-history-ko">{ko}</span>
@@ -236,9 +253,9 @@ export default function BacktestExecutionList({
</div> </div>
</button> </button>
); );
}) }}
)} </VirtualScroll>
</div> )}
</div> </div>
); );
} }
@@ -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<T> {
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<T>(...refs: Array<React.Ref<T> | undefined>) {
return (value: T | null) => {
refs.forEach(ref => {
if (!ref) return;
if (typeof ref === 'function') ref(value);
else (ref as React.MutableRefObject<T | null>).current = value;
});
};
}
function VirtualScrollInner<T>(
{
items,
estimateSize,
overscan = 6,
className = 'vl-scroll',
style,
innerClassName = 'vl-scroll-inner',
empty,
getItemKey,
measureDynamic = false,
children,
role,
'aria-label': ariaLabel,
}: VirtualScrollProps<T>,
forwardedRef: React.ForwardedRef<HTMLDivElement>,
) {
const parentRef = useRef<HTMLDivElement>(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 (
<div ref={forwardedRef} className={className} style={style} role={role} aria-label={ariaLabel}>
{empty}
</div>
);
}
return (
<div
ref={mergeRefs(parentRef, forwardedRef)}
className={className}
style={style}
role={role}
aria-label={ariaLabel}
>
<div
className={innerClassName}
style={{
height: virtualizer.getTotalSize(),
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map(vi => (
<div
key={vi.key}
data-index={vi.index}
ref={measureDynamic ? virtualizer.measureElement : undefined}
className="vl-scroll-row"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vi.start}px)`,
}}
>
{children(items[vi.index]!, vi.index)}
</div>
))}
</div>
</div>
);
}
export const VirtualScroll = forwardRef(VirtualScrollInner) as <T>(
props: VirtualScrollProps<T> & { ref?: React.ForwardedRef<HTMLDivElement> },
) => ReturnType<typeof VirtualScrollInner>;
export interface VirtualGridScrollProps<T> {
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<T>(
{
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<T>,
forwardedRef: React.ForwardedRef<HTMLDivElement>,
) {
const parentRef = useRef<HTMLDivElement>(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 (
<div ref={forwardedRef} className={className} style={style} role={role} aria-label={ariaLabel}>
{empty}
</div>
);
}
const colCount = Math.max(1, columns);
return (
<div
ref={mergeRefs(parentRef, forwardedRef)}
className={className}
style={style}
role={role}
aria-label={ariaLabel}
>
<div
className="vl-scroll-inner"
style={{
height: virtualizer.getTotalSize(),
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map(vi => {
const startIdx = vi.index * colCount;
const rowItems = items.slice(startIdx, startIdx + colCount);
return (
<div
key={vi.key}
data-index={vi.index}
ref={measureDynamic ? virtualizer.measureElement : undefined}
className={rowClassName}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vi.start}px)`,
boxSizing: 'border-box',
}}
>
{rowItems.map((item, i) => (
<React.Fragment key={getItemKey ? getItemKey(item, startIdx + i) : startIdx + i}>
{children(item, startIdx + i)}
</React.Fragment>
))}
</div>
);
})}
</div>
</div>
);
}
export const VirtualGridScroll = forwardRef(VirtualGridScrollInner) as <T>(
props: VirtualGridScrollProps<T> & { ref?: React.ForwardedRef<HTMLDivElement> },
) => ReturnType<typeof VirtualGridScrollInner>;
@@ -1,4 +1,5 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { VirtualScroll } from '../common/VirtualScroll';
import type { PaperTradeDto } from '../../utils/backendApi'; import type { PaperTradeDto } from '../../utils/backendApi';
import type { TickerData } from '../../hooks/useMarketTicker'; import type { TickerData } from '../../hooks/useMarketTicker';
import { resolveVirtualTargetNames } from '../../utils/virtualTargetNames'; import { resolveVirtualTargetNames } from '../../utils/virtualTargetNames';
@@ -58,9 +59,17 @@ const PaperTradeHistoryList: React.FC<Props> = ({
{trades.length === 0 ? ( {trades.length === 0 ? (
<p className="vtd-muted ptd-trade-history-empty">{emptyText}</p> <p className="vtd-muted ptd-trade-history-empty">{emptyText}</p>
) : ( ) : (
<div className="ptd-trade-history-scroll"> <VirtualScroll
<div className="vtd-target-list vtd-trade-list"> className="ptd-trade-history-scroll vl-scroll"
{displayTrades.map(t => { items={displayTrades}
estimateSize={210}
measureDynamic
overscan={4}
getItemKey={t => t.id}
innerClassName="vl-scroll-inner vtd-target-list vtd-trade-list"
aria-label="거래 내역"
>
{t => {
const { koreanName: ko, englishName: en } = resolveVirtualTargetNames( const { koreanName: ko, englishName: en } = resolveVirtualTargetNames(
t.symbol, t.symbol,
tickers?.get(t.symbol)?.koreanName, tickers?.get(t.symbol)?.koreanName,
@@ -80,7 +89,6 @@ const PaperTradeHistoryList: React.FC<Props> = ({
return ( return (
<div <div
key={t.id}
className={[ className={[
'vtd-target-item', 'vtd-target-item',
'vtd-trade-item', 'vtd-trade-item',
@@ -162,9 +170,8 @@ const PaperTradeHistoryList: React.FC<Props> = ({
</div> </div>
</div> </div>
); );
})} }}
</div> </VirtualScroll>
</div>
)} )}
</section> </section>
</div> </div>
@@ -66,6 +66,8 @@ interface Props {
/** 상세분석 레포트 버튼 클릭 */ /** 상세분석 레포트 버튼 클릭 */
onReport?: () => void; onReport?: () => void;
reportLoading?: boolean; reportLoading?: boolean;
/** 가상 스크롤 목록에서는 div + role=listitem */
itemTag?: 'li' | 'div';
} }
const MAX_INDICATOR_CARDS = 8; const MAX_INDICATOR_CARDS = 8;
@@ -85,6 +87,7 @@ const TradeNotificationListRow: React.FC<Props> = ({
onTrade, onTrade,
onReport, onReport,
reportLoading = false, reportLoading = false,
itemTag = 'li',
}) => { }) => {
useTradeAlertTimeFormat(); useTradeAlertTimeFormat();
const isBuy = item.signalType === 'BUY'; const isBuy = item.signalType === 'BUY';
@@ -106,7 +109,8 @@ const TradeNotificationListRow: React.FC<Props> = ({
|| strategy?.name || strategy?.name
|| strategyRow?.value || strategyRow?.value
|| '실시간 전략'; || '실시간 전략';
const rowRef = useRef<HTMLLIElement>(null); const rowRef = useRef<HTMLLIElement | HTMLDivElement>(null);
const RowTag = itemTag;
const [inView, setInView] = useState(false); const [inView, setInView] = useState(false);
/** 펼쳐보기 중인 차트: 'candle' | 지표 id | null */ /** 펼쳐보기 중인 차트: 'candle' | 지표 id | null */
const [expandedChartKey, setExpandedChartKey] = useState<string | null>(null); const [expandedChartKey, setExpandedChartKey] = useState<string | null>(null);
@@ -348,34 +352,32 @@ const TradeNotificationListRow: React.FC<Props> = ({
</article> </article>
); );
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) { if (!isListLayout) {
return ( return (
<li <RowTag
ref={rowRef} ref={rowRef as React.Ref<HTMLLIElement & HTMLDivElement>}
className={[ role={itemTag === 'div' ? 'listitem' : undefined}
'tnl-row', className={rowClassName}
'tnl-row--grid',
!item.isRead ? 'tnl-row--unread' : '',
isSelected ? 'tnl-row--selected' : '',
receiving ? 'tnl-row--receiving' : '',
].filter(Boolean).join(' ')}
onClick={handleRowClick} onClick={handleRowClick}
> >
{summaryCard} {summaryCard}
</li> </RowTag>
); );
} }
return ( return (
<li <RowTag
ref={rowRef} ref={rowRef as React.Ref<HTMLLIElement & HTMLDivElement>}
className={[ role={itemTag === 'div' ? 'listitem' : undefined}
'tnl-row', className={rowClassName}
'tnl-row--gallery',
!item.isRead ? 'tnl-row--unread' : '',
isSelected ? 'tnl-row--selected' : '',
receiving ? 'tnl-row--receiving' : '',
].filter(Boolean).join(' ')}
onClick={handleRowClick} onClick={handleRowClick}
> >
<div <div
@@ -455,7 +457,7 @@ const TradeNotificationListRow: React.FC<Props> = ({
</> </>
)} )}
</div> </div>
</li> </RowTag>
); );
}; };
@@ -5,6 +5,7 @@ import type { Theme } from '../../types';
import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData'; import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
import type { TickerData } from '../../hooks/useMarketTicker'; import type { TickerData } from '../../hooks/useMarketTicker';
import TrendSearchResultCard, { type TrendSearchDisplayMode } from './TrendSearchResultCard'; import TrendSearchResultCard, { type TrendSearchDisplayMode } from './TrendSearchResultCard';
import { VirtualGridScroll } from '../common/VirtualScroll';
interface Props { interface Props {
results: TrendSearchResultDto[]; results: TrendSearchResultDto[];
@@ -74,12 +75,27 @@ const TrendSearchResultsCardGrid: React.FC<Props> = ({
); );
} }
const gridCols = displayMode === 'chart' ? 2 : 3;
const rowEstimate = displayMode === 'chart' ? 420 : 300;
return ( return (
<div className="vtd-grid-wrap"> <div className="vtd-grid-wrap vtd-grid-wrap--virtual">
<div className={`vtd-grid${displayMode === 'chart' ? ' vtd-grid--chart-mode' : ''}`}> <VirtualGridScroll
{sortedResults.map(row => ( className={`vtd-grid-scroll vl-scroll${displayMode === 'chart' ? ' vtd-grid--chart-mode' : ''}`}
style={{ ['--vtd-grid-cols' as string]: gridCols }}
rowClassName="vl-grid-row vtd-grid-row"
items={sortedResults}
columns={gridCols}
estimateRowSize={rowEstimate}
rowGap={14}
measureDynamic
remeasureKey={`${displayMode}:${sortedResults.length}`}
overscan={2}
getItemKey={row => row.market}
aria-label="추세검색 카드 그리드"
>
{row => (
<TrendSearchResultCard <TrendSearchResultCard
key={row.market}
result={row} result={row}
timeframe={timeframe} timeframe={timeframe}
displayMode={displayMode} displayMode={displayMode}
@@ -99,8 +115,8 @@ const TrendSearchResultsCardGrid: React.FC<Props> = ({
onAddTarget={onAddTarget ? () => onAddTarget(row) : undefined} onAddTarget={onAddTarget ? () => onAddTarget(row) : undefined}
onRemoveTarget={onRemoveTarget ? () => onRemoveTarget(row.market) : undefined} onRemoveTarget={onRemoveTarget ? () => onRemoveTarget(row.market) : undefined}
/> />
))} )}
</div> </VirtualGridScroll>
</div> </div>
); );
}; };
@@ -1,6 +1,7 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import type { TrendSearchResultDto } from '../../utils/trendSearchApi'; import type { TrendSearchResultDto } from '../../utils/trendSearchApi';
import { formatUpbitKrwPrice } from '../../utils/safeFormat'; import { formatUpbitKrwPrice } from '../../utils/safeFormat';
import { VirtualScroll } from '../common/VirtualScroll';
interface Props { interface Props {
results: TrendSearchResultDto[]; results: TrendSearchResultDto[];
@@ -36,6 +37,10 @@ const TrendSearchResultsGrid: React.FC<Props> = ({
return Math.round(results.reduce((s, r) => s + r.matchRate, 0) / results.length); return Math.round(results.reduce((s, r) => s + r.matchRate, 0) / results.length);
}, [results]); }, [results]);
const emptyBody = loading && results.length === 0
? <div className="tsd-virtual-empty tsd-empty"> </div>
: <div className="tsd-virtual-empty tsd-empty"> . .</div>;
return ( return (
<div className="tsd-results"> <div className="tsd-results">
<div className="tsd-results-head"> <div className="tsd-results-head">
@@ -49,8 +54,8 @@ const TrendSearchResultsGrid: React.FC<Props> = ({
</div> </div>
</div> </div>
<div className="tsd-results-table-wrap"> <div className="tsd-results-table-wrap tsd-results-table-wrap--virtual">
<table className="tsd-results-table"> <table className="tsd-results-table tsd-results-table--head">
<thead> <thead>
<tr> <tr>
<th></th> <th></th>
@@ -60,48 +65,51 @@ const TrendSearchResultsGrid: React.FC<Props> = ({
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody>
{loading && results.length === 0 && (
<tr><td colSpan={5} className="tsd-empty"> </td></tr>
)}
{!loading && results.length === 0 && (
<tr><td colSpan={5} className="tsd-empty"> . .</td></tr>
)}
{results.map(row => {
const up = row.changeRate >= 0;
const active = selectedMarket === row.market;
const flash = flashMarkets?.has(row.market);
return (
<tr
key={row.market}
className={[
active ? 'tsd-row--sel' : '',
flash ? (up ? 'tsd-row--flash-up' : 'tsd-row--flash-down') : '',
].filter(Boolean).join(' ')}
onClick={() => onSelect(row)}
>
<td className="tsd-col-symbol">
<span className="tsd-symbol">{coinLabel(row.market, row.koreanName)}</span>
</td>
<td className="tsd-col-price">{fmtPrice(row.currentPrice)}</td>
<td className={`tsd-col-change${up ? ' up' : ' down'}`}>{fmtPct(row.changeRate)}</td>
<td className="tsd-col-match">
<div className="tsd-match-bar-wrap">
<div className="tsd-match-bar">
<div className="tsd-match-bar-fill" style={{ width: `${row.matchRate}%` }} />
</div>
<span className="tsd-match-pct">{row.matchRate}%</span>
</div>
</td>
<td className="tsd-col-badge">
{row.highMatch && <span className="tsd-badge-high">HIGH MATCH</span>}
{!row.highMatch && row.matchRate >= 70 && <span className="tsd-badge-mid">MATCH</span>}
</td>
</tr>
);
})}
</tbody>
</table> </table>
<VirtualScroll
className="tsd-virtual-body vl-scroll"
items={results}
estimateSize={40}
getItemKey={row => 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 (
<div
className={[
'tsd-virtual-row',
active ? 'tsd-row--sel' : '',
flash ? (up ? 'tsd-row--flash-up' : 'tsd-row--flash-down') : '',
].filter(Boolean).join(' ')}
role="row"
onClick={() => onSelect(row)}
>
<div className="tsd-virtual-cell tsd-col-symbol">
<span className="tsd-symbol">{coinLabel(row.market, row.koreanName)}</span>
</div>
<div className="tsd-virtual-cell tsd-col-price">{fmtPrice(row.currentPrice)}</div>
<div className={`tsd-virtual-cell tsd-col-change${up ? ' up' : ' down'}`}>{fmtPct(row.changeRate)}</div>
<div className="tsd-virtual-cell tsd-col-match">
<div className="tsd-match-bar-wrap">
<div className="tsd-match-bar">
<div className="tsd-match-bar-fill" style={{ width: `${row.matchRate}%` }} />
</div>
<span className="tsd-match-pct">{row.matchRate}%</span>
</div>
</div>
<div className="tsd-virtual-cell tsd-col-badge">
{row.highMatch && <span className="tsd-badge-high">HIGH MATCH</span>}
{!row.highMatch && row.matchRate >= 70 && <span className="tsd-badge-mid">MATCH</span>}
</div>
</div>
);
}}
</VirtualScroll>
</div> </div>
</div> </div>
); );
@@ -342,7 +342,7 @@ const VerificationIssueAttachmentsSection: React.FC<Props> = ({
title={`${p.fileName} — 더블클릭하여 열기`} title={`${p.fileName} — 더블클릭하여 열기`}
onDoubleClick={() => openImage(p.src)} onDoubleClick={() => openImage(p.src)}
> >
<img src={p.src} alt={p.fileName} draggable={false} /> <img src={p.src} alt={p.fileName} loading="lazy" draggable={false} />
</button> </button>
<figcaption className="vbd-attach-caption" title={p.fileName}> <figcaption className="vbd-attach-caption" title={p.fileName}>
{p.fileName} {p.fileName}
@@ -11,6 +11,7 @@ import {
} from '../../utils/verificationBoardStages'; } from '../../utils/verificationBoardStages';
import VerificationStageIcon from './VerificationStageIcon'; import VerificationStageIcon from './VerificationStageIcon';
import { formatIsoDateTime } from '../../utils/timezone'; import { formatIsoDateTime } from '../../utils/timezone';
import { VirtualScroll } from '../common/VirtualScroll';
interface Props { interface Props {
issues: VerificationIssueDto[]; issues: VerificationIssueDto[];
@@ -72,36 +73,42 @@ const VerificationIssueListPanel: React.FC<Props> = ({
)} )}
</div> </div>
<ul className="vbd-list"> <VirtualScroll
{filtered.length === 0 ? ( className="vbd-list vl-scroll"
<li className="vbd-list-empty"> .</li> items={filtered}
) : filtered.map(issue => { estimateSize={72}
measureDynamic
getItemKey={(issue, index) => issue.id ?? `issue-${index}`}
empty={<div className="vbd-list-empty"> .</div>}
role="list"
aria-label="검증 이슈 목록"
>
{issue => {
const active = issue.id === selectedId; const active = issue.id === selectedId;
return ( return (
<li key={issue.id}> <button
<button type="button"
type="button" className={`vbd-list-item${active ? ' vbd-list-item--active' : ''}`}
className={`vbd-list-item${active ? ' vbd-list-item--active' : ''}`} role="listitem"
onClick={() => onSelect(issue)} onClick={() => onSelect(issue)}
> >
<VerificationStageIcon stage={issue.stage} size={34} /> <VerificationStageIcon stage={issue.stage} size={34} />
<span className="vbd-list-item-body"> <span className="vbd-list-item-body">
<span className="vbd-list-item-top"> <span className="vbd-list-item-top">
<span <span
className="vbd-stage-badge" className="vbd-stage-badge"
style={{ backgroundColor: `${stageColor(issue.stage)}22`, color: stageColor(issue.stage) }} style={{ backgroundColor: `${stageColor(issue.stage)}22`, color: stageColor(issue.stage) }}
> >
{stageLabel(issue.stage)} {stageLabel(issue.stage)}
</span>
<span className="vbd-list-meta">{formatIsoDateTime(issue.updatedAt ?? issue.createdAt, undefined, 'short')}</span>
</span> </span>
<span className="vbd-list-title">{issue.title}</span> <span className="vbd-list-meta">{formatIsoDateTime(issue.updatedAt ?? issue.createdAt, undefined, 'short')}</span>
</span> </span>
</button> <span className="vbd-list-title">{issue.title}</span>
</li> </span>
</button>
); );
})} }}
</ul> </VirtualScroll>
</div> </div>
); );
}; };
@@ -14,6 +14,7 @@ import {
parseTargetStrategySelectValue, parseTargetStrategySelectValue,
targetStrategySelectValue, targetStrategySelectValue,
} from '../../utils/virtualTargetStrategy'; } from '../../utils/virtualTargetStrategy';
import { VirtualScroll } from '../common/VirtualScroll';
interface Props { interface Props {
targets: VirtualTargetItem[]; targets: VirtualTargetItem[];
@@ -131,11 +132,18 @@ const VirtualLeftTargetPanel: React.FC<Props> = ({
<span className="vtd-target-count">{targets.length}/{virtualTargetMaxCount}</span> <span className="vtd-target-count">{targets.length}/{virtualTargetMaxCount}</span>
</div> </div>
<div className="vtd-target-list"> {targets.length === 0 ? (
{targets.length === 0 && ( <p className="vtd-muted"> + .</p>
<p className="vtd-muted"> + .</p> ) : (
)} <VirtualScroll
{targets.map(item => { className="vtd-target-list vl-scroll"
items={targets}
estimateSize={132}
measureDynamic
getItemKey={item => item.market}
aria-label="투자대상 목록"
>
{item => {
const { koreanName: ko, englishName: en } = resolveVirtualTargetNames( const { koreanName: ko, englishName: en } = resolveVirtualTargetNames(
item.market, item.market,
item.koreanName, item.koreanName,
@@ -144,7 +152,6 @@ const VirtualLeftTargetPanel: React.FC<Props> = ({
const active = item.market === selectedMarket; const active = item.market === selectedMarket;
return ( return (
<div <div
key={item.market}
className={[ className={[
'vtd-target-item', 'vtd-target-item',
active ? 'vtd-target-item--active' : '', active ? 'vtd-target-item--active' : '',
@@ -213,8 +220,9 @@ const VirtualLeftTargetPanel: React.FC<Props> = ({
</div> </div>
</div> </div>
); );
})} }}
</div> </VirtualScroll>
)}
</section> </section>
</div> </div>
); );
@@ -17,6 +17,7 @@ import {
getTradeNotificationGridPreset, getTradeNotificationGridPreset,
type TradeNotificationGridPresetId, type TradeNotificationGridPresetId,
} from '../../utils/tradeNotificationGridPresets'; } from '../../utils/tradeNotificationGridPresets';
import { VirtualGridScroll } from '../common/VirtualScroll';
interface Props { interface Props {
targets: VirtualTargetItem[]; targets: VirtualTargetItem[];
@@ -60,6 +61,17 @@ const VirtualTargetGrid: React.FC<Props> = ({
gridPreset, gridPreset,
}) => { }) => {
const gridCols = getTradeNotificationGridPreset(gridPreset).cols; 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<Record<string, VirtualCardDisplayMode>>({}); const [cardDisplayOverrides, setCardDisplayOverrides] = useState<Record<string, VirtualCardDisplayMode>>({});
const [focusMarket, setFocusMarket] = useState<string | null>(null); const [focusMarket, setFocusMarket] = useState<string | null>(null);
@@ -144,20 +156,29 @@ const VirtualTargetGrid: React.FC<Props> = ({
englishName={focusTarget.englishName} englishName={focusTarget.englishName}
/> />
) : ( ) : (
<div <VirtualGridScroll
className={[ className={[
'vtd-grid', 'vtd-grid-scroll',
'vtd-grid--layout-preset', 'vl-scroll',
`vtd-grid--${viewMode}`, `vtd-grid--${viewMode}`,
`vtd-grid--cols-${gridCols}`, `vtd-grid--cols-${gridCols}`,
].join(' ')} ].join(' ')}
style={{ ['--vtd-grid-cols' as string]: gridCols }} style={{ ['--vtd-grid-cols' as string]: gridCols }}
rowClassName="vl-grid-row vtd-grid-row"
items={targets}
columns={gridCols}
estimateRowSize={gridRowEstimate}
rowGap={14}
measureDynamic
remeasureKey={gridRemeasureKey}
overscan={2}
getItemKey={t => t.market}
aria-label="가상매매 종목 그리드"
> >
{targets.map(t => { {t => {
const strat = strategies.find(s => s.id === resolveVirtualTargetStrategyId(t, session.globalStrategyId)); const strat = strategies.find(s => s.id === resolveVirtualTargetStrategyId(t, session.globalStrategyId));
return ( return (
<VirtualTargetCard <VirtualTargetCard
key={t.market}
market={t.market} market={t.market}
strategy={strat} strategy={strat}
strategies={strategies} strategies={strategies}
@@ -178,16 +199,16 @@ const VirtualTargetGrid: React.FC<Props> = ({
theme={theme} theme={theme}
chartRealtimeSource={chartRealtimeSource} chartRealtimeSource={chartRealtimeSource}
chartCandleAreaPriceLabels={chartCandleAreaPriceLabels} chartCandleAreaPriceLabels={chartCandleAreaPriceLabels}
chartSeriesPriceLabels={chartSeriesPriceLabels} chartSeriesPriceLabels={chartSeriesPriceLabels}
chartPaneSeparator={chartPaneSeparator} chartPaneSeparator={chartPaneSeparator}
chartLiveReceiveHighlight={chartLiveReceiveHighlight} chartLiveReceiveHighlight={chartLiveReceiveHighlight}
ticker={tickers?.get(t.market)} ticker={tickers?.get(t.market)}
koreanName={t.koreanName} koreanName={t.koreanName}
englishName={t.englishName} englishName={t.englishName}
/> />
); );
})} }}
</div> </VirtualGridScroll>
)} )}
</div> </div>
); );
@@ -3,11 +3,30 @@
*/ */
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { isStrategyMissingOnServer, loadStrategy, type StrategyDto } from '../utils/backendApi'; import { isStrategyMissingOnServer, loadStrategy, type StrategyDto } from '../utils/backendApi';
import { extractVirtualConditions } from '../utils/virtualStrategyConditions';
import { normalizeConditionRow } from '../utils/virtualSignalMetrics';
import { import {
fetchLiveSnapshot, fetchLiveSnapshot,
type VirtualIndicatorSnapshot, type VirtualIndicatorSnapshot,
} from './useVirtualIndicatorSnapshots'; } 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( export function useTradeNotificationSignalSnapshot(
market: string, market: string,
strategyId: number | null | undefined, strategyId: number | null | undefined,
@@ -19,6 +38,7 @@ export function useTradeNotificationSignalSnapshot(
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const emptyRetryRef = useRef(0); const emptyRetryRef = useRef(0);
const genRef = useRef(0); const genRef = useRef(0);
const refreshInFlightRef = useRef(false);
/** strategy prop 이 undefined 일 때 직접 로드한 전략 캐시 */ /** strategy prop 이 undefined 일 때 직접 로드한 전략 캐시 */
const fallbackStrategyRef = useRef<StrategyDto | null>(null); const fallbackStrategyRef = useRef<StrategyDto | null>(null);
@@ -28,40 +48,52 @@ export function useTradeNotificationSignalSnapshot(
setLoading(false); setLoading(false);
return; return;
} }
if (refreshInFlightRef.current) return;
const gen = ++genRef.current; const gen = ++genRef.current;
refreshInFlightRef.current = true;
setLoading(true); setLoading(true);
// strategy prop 이 undefined 면 내부에서 직접 로드해 baseRows 를 확보한다. try {
// 이렇게 하면 strategy 가 늦게 주입되어도 조건 목록이 즉시 표시된다. let effectiveStrategy = strategy;
let effectiveStrategy = strategy; if (!effectiveStrategy) {
if (!effectiveStrategy) { if (fallbackStrategyRef.current) {
if (fallbackStrategyRef.current) { effectiveStrategy = fallbackStrategyRef.current;
effectiveStrategy = fallbackStrategyRef.current; } else if (!isStrategyMissingOnServer(strategyId)) {
} else if (!isStrategyMissingOnServer(strategyId)) { try {
try { const loaded = await loadStrategy(strategyId);
const loaded = await loadStrategy(strategyId); if (loaded) {
if (loaded) { fallbackStrategyRef.current = loaded;
fallbackStrategyRef.current = loaded; effectiveStrategy = loaded;
effectiveStrategy = loaded; }
} } catch { /* ignore */ }
} catch { /* ignore */ } if (gen !== genRef.current) return;
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]); }, [market, strategyId, strategy, enabled]);
useEffect(() => { useEffect(() => {
@@ -72,13 +104,14 @@ export function useTradeNotificationSignalSnapshot(
return; return;
} }
emptyRetryRef.current = 0; emptyRetryRef.current = 0;
fallbackStrategyRef.current = null; // strategy prop 변경 시 캐시 초기화 fallbackStrategyRef.current = null;
setLoading(true); // 활성화 즉시 로딩 표시 setLoading(true);
void refresh(); void refresh();
const id = window.setInterval(() => void refresh(), pollMs); const id = window.setInterval(() => void refresh(), pollMs);
return () => { return () => {
clearInterval(id); clearInterval(id);
genRef.current += 1; genRef.current += 1;
refreshInFlightRef.current = false;
}; };
}, [market, strategyId, enabled, pollMs, refresh]); }, [market, strategyId, enabled, pollMs, refresh]);
@@ -4,7 +4,11 @@
* running 시 3초 폴링, 미수집 시 재시도 * running 시 3초 폴링, 미수집 시 재시도
*/ */
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import type { StrategyDto } from '../utils/backendApi'; import {
isStrategyMissingOnServer,
loadStrategy,
type StrategyDto,
} from '../utils/backendApi';
import { import {
fetchLiveConditionStatus, fetchLiveConditionStatus,
pinCandleWatch, 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 평가 */ /** 백엔드가 업비트 REST·WS로 캔들 warm-up 후 Ta4j 평가 */
export async function fetchLiveSnapshot( export async function fetchLiveSnapshot(
market: string, market: string,
@@ -133,13 +143,16 @@ export async function fetchLiveSnapshot(
status = null; status = null;
} }
if (!status || status.market !== market || status.strategyId !== strategyId) { if (!status || !statusMatches(status, market, strategyId)) {
if (baseRows.length === 0) return null; if (baseRows.length === 0) return null;
return staticSnapshot(market, strategyId, baseRows); return staticSnapshot(market, strategyId, baseRows);
} }
const rows = mergeRows(baseRows, status.rows); const rows = mergeRows(baseRows, status.rows ?? []);
if (rows.length === 0) return null; if (rows.length === 0) {
if (baseRows.length === 0) return null;
return staticSnapshot(market, strategyId, baseRows);
}
return { return {
market, market,
@@ -151,6 +164,34 @@ export async function fetchLiveSnapshot(
}; };
} }
async function resolveStrategy(
strategyId: number,
strategies: StrategyDto[],
cache: Map<number, StrategyDto | null>,
): Promise<StrategyDto | undefined> {
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 { interface TargetRef {
market: string; market: string;
strategyId: number | null; strategyId: number | null;
@@ -166,47 +207,16 @@ export function useVirtualIndicatorSnapshots(
const [loadingByMarket, setLoadingByMarket] = useState<Record<string, boolean>>({}); const [loadingByMarket, setLoadingByMarket] = useState<Record<string, boolean>>({});
const strategiesRef = useRef(strategies); const strategiesRef = useRef(strategies);
strategiesRef.current = strategies; strategiesRef.current = strategies;
const pinnedRef = useRef<Set<string>>(new Set()); const strategyLoadCacheRef = useRef<Map<number, StrategyDto | null>>(new Map());
const refreshGenRef = useRef<Record<string, number>>({}); const refreshGenRef = useRef<Record<string, number>>({});
const emptyRetryRef = useRef<Record<string, number>>({}); const emptyRetryRef = useRef<Record<string, number>>({});
const refreshInFlightRef = useRef(false);
const targetsKey = targets.map(t => `${t.market}:${t.strategyId ?? ''}`).join('|'); const targetsKey = targets.map(t => `${t.market}:${t.strategyId ?? ''}`).join('|');
const strategiesKey = strategies.map(s => s.id).join('|');
const pinTargets = useCallback(async () => {
const strats = strategiesRef.current;
const pins = new Set<string>();
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 refresh = useCallback(async () => { const refresh = useCallback(async () => {
if (refreshInFlightRef.current) return;
const strats = strategiesRef.current; const strats = strategiesRef.current;
const activeTargets = targets.filter(t => t.strategyId != null); const activeTargets = targets.filter(t => t.strategyId != null);
if (activeTargets.length === 0) { if (activeTargets.length === 0) {
@@ -215,69 +225,78 @@ export function useVirtualIndicatorSnapshots(
return; return;
} }
refreshInFlightRef.current = true;
setLoadingByMarket(prev => { setLoadingByMarket(prev => {
const next = { ...prev }; const next = { ...prev };
for (const t of activeTargets) next[t.market] = true; for (const t of activeTargets) next[t.market] = true;
return next; 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 => { try {
const gen = (refreshGenRef.current[t.market] ?? 0) + 1; const strat = await resolveStrategy(strategyId, strats, strategyLoadCacheRef.current);
refreshGenRef.current[t.market] = gen; if (refreshGenRef.current[market] !== gen) return;
const strat = strats.find(s => s.id === t.strategyId);
const baseRows = strat ? extractVirtualConditions(strat) : [];
const needsLiveApi = running || baseRows.length === 0;
let snap: VirtualIndicatorSnapshot | null = null; const baseRows = strat ? extractVirtualConditions(strat) : [];
if (needsLiveApi) { const retry = emptyRetryRef.current[market] ?? 0;
const retry = emptyRetryRef.current[t.market] ?? 0; let snap = await fetchLiveSnapshot(market, strategyId, strat, retry === 0);
snap = await fetchLiveSnapshot(t.market, t.strategyId!, strat, retry === 0);
} else {
snap = staticSnapshot(t.market, t.strategyId!, baseRows);
}
if (!snap || refreshGenRef.current[t.market] !== gen) return snap; if (!snap && baseRows.length > 0) {
if (snap.strategyId !== t.strategyId) return snap; snap = staticSnapshot(market, strategyId, baseRows);
}
if (snap.rows.length === 0) { if (!snap || refreshGenRef.current[market] !== gen) return;
const n = (emptyRetryRef.current[t.market] ?? 0) + 1; if (snap.strategyId !== strategyId) return;
emptyRetryRef.current[t.market] = n;
} else {
emptyRetryRef.current[t.market] = 0;
}
setSnapshots(prev => ({ ...prev, [snap.market]: snap })); if (snap.rows.length === 0) {
setLoadingByMarket(prev => ({ ...prev, [t.market]: false })); emptyRetryRef.current[market] = (emptyRetryRef.current[market] ?? 0) + 1;
return snap; } else {
}); emptyRetryRef.current[market] = 0;
await Promise.all(jobs); }
const activeMarkets = new Set(targets.map(t => t.market)); setSnapshots(prev => ({ ...prev, [snap!.market]: snap! }));
setSnapshots(prev => { } finally {
const next = { ...prev }; if (refreshGenRef.current[market] === gen) {
let changed = false; setLoadingByMarket(prev => ({ ...prev, [market]: false }));
for (const key of Object.keys(next)) { }
if (!activeMarkets.has(key)) {
delete next[key];
changed = true;
} }
} });
return changed ? next : prev;
}); await Promise.all(jobs);
setLoadingByMarket(prev => {
const next = { ...prev }; const activeMarkets = new Set(targets.map(t => t.market));
let changed = false; setSnapshots(prev => {
for (const key of Object.keys(next)) { const next = { ...prev };
if (!activeMarkets.has(key)) { let changed = false;
delete next[key]; for (const key of Object.keys(next)) {
changed = true; if (!activeMarkets.has(key)) {
delete next[key];
changed = true;
}
} }
} return changed ? next : prev;
return changed ? next : prev; });
}); setLoadingByMarket(prev => {
}, [targets, running, pinTargets]); 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); const hasStrategyTargets = targets.some(t => t.strategyId != null);
@@ -285,17 +304,17 @@ export function useVirtualIndicatorSnapshots(
if (targets.length === 0) { if (targets.length === 0) {
setSnapshots({}); setSnapshots({});
setLoadingByMarket({}); setLoadingByMarket({});
pinnedRef.current.clear();
refreshGenRef.current = {}; refreshGenRef.current = {};
emptyRetryRef.current = {}; emptyRetryRef.current = {};
strategyLoadCacheRef.current.clear();
return; return;
} }
void refresh(); void refresh();
if (!running && !hasStrategyTargets) return; if (!hasStrategyTargets) return;
const intervalMs = running ? pollMs : 5000; const intervalMs = running ? pollMs : 5000;
const id = window.setInterval(() => void refresh(), intervalMs); const id = window.setInterval(() => void refresh(), intervalMs);
return () => clearInterval(id); return () => clearInterval(id);
}, [targetsKey, running, hasStrategyTargets, pollMs, refresh]); }, [targetsKey, strategiesKey, running, hasStrategyTargets, pollMs, refresh]);
return { snapshots, loadingByMarket }; return { snapshots, loadingByMarket };
} }
@@ -553,6 +553,10 @@
padding: 4px 2px 8px; padding: 4px 2px 8px;
} }
.btd-exec-scroll.vl-scroll .vl-scroll-row {
padding-bottom: 8px;
}
.btd-exec-item { .btd-exec-item {
width: 100%; width: 100%;
text-align: left; text-align: left;
@@ -1051,6 +1051,20 @@
gap: 12px; 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 { .tnl-side-wrap--right {
display: flex; display: flex;
@@ -572,6 +572,21 @@
border-radius: 10px; 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 { .tsd-results-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
@@ -99,6 +99,10 @@
min-height: 0; min-height: 0;
} }
.vbd-list.vl-scroll {
display: block;
}
.vbd-list-empty { .vbd-list-empty {
padding: 24px 12px; padding: 24px 12px;
text-align: center; text-align: center;
+104
View File
@@ -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;
}
@@ -1303,6 +1303,10 @@
.vtd-grid--layout-preset { .vtd-grid--layout-preset {
grid-template-columns: minmax(0, 1fr) !important; grid-template-columns: minmax(0, 1fr) !important;
} }
.vtd-grid-scroll .vtd-grid-row {
grid-template-columns: minmax(0, 1fr) !important;
}
} }
.vtd-card { .vtd-card {
+28
View File
@@ -46,6 +46,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@stomp/stompjs": "^7.3.0", "@stomp/stompjs": "^7.3.0",
"@tanstack/react-virtual": "^3.14.2",
"@xyflow/react": "^12.10.2", "@xyflow/react": "^12.10.2",
"lightweight-charts": "^5.2.0", "lightweight-charts": "^5.2.0",
"lightweight-charts-indicators": "^0.4.1", "lightweight-charts-indicators": "^0.4.1",
@@ -1501,6 +1502,33 @@
"integrity": "sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==", "integrity": "sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==",
"license": "Apache-2.0" "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": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",