목록 로딩 로직 개선
This commit is contained in:
@@ -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,10 +155,20 @@ export default function NotificationsScreen() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="stack-list" style={{ padding: '0 16px' }}>
|
<VirtualScroll
|
||||||
{allNotifications.map(item => (
|
ref={listRef}
|
||||||
|
className="stack-list notify-virtual-list"
|
||||||
|
items={allNotifications}
|
||||||
|
estimateSize={148}
|
||||||
|
measureDynamic
|
||||||
|
overscan={5}
|
||||||
|
getItemKey={item => item.id}
|
||||||
|
innerClassName="vl-scroll-inner"
|
||||||
|
aria-label="알림 목록"
|
||||||
|
>
|
||||||
|
{item => (
|
||||||
|
<div style={{ padding: '0 16px 10px' }}>
|
||||||
<NotificationListRow
|
<NotificationListRow
|
||||||
key={item.id}
|
|
||||||
item={item}
|
item={item}
|
||||||
onDetail={() => {
|
onDetail={() => {
|
||||||
markAsRead(item.id);
|
markAsRead(item.id);
|
||||||
@@ -170,9 +180,10 @@ export default function NotificationsScreen() {
|
|||||||
}}
|
}}
|
||||||
onDelete={() => void deleteNotification(item.id)}
|
onDelete={() => void deleteNotification(item.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</VirtualScroll>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{allNotifications.length > 0 && (
|
{allNotifications.length > 0 && (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Generated
+28
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,15 +374,22 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 목록 */}
|
{/* 목록 — 가상 스크롤 (업비트 전 종목) */}
|
||||||
|
{loading ? (
|
||||||
<div className="msp-list">
|
<div className="msp-list">
|
||||||
{loading && (
|
|
||||||
<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}
|
||||||
|
estimateSize={embedded ? 34 : isCompactDropdown ? 36 : 38}
|
||||||
|
measureDynamic
|
||||||
|
getItemKey={m => m.market}
|
||||||
|
empty={<div className="msp-empty">검색 결과가 없습니다</div>}
|
||||||
|
aria-label="종목 검색 결과"
|
||||||
|
>
|
||||||
|
{m => {
|
||||||
const tk = tickers[m.market];
|
const tk = tickers[m.market];
|
||||||
const isFav = favs.has(m.market);
|
const isFav = favs.has(m.market);
|
||||||
const isAdded = addedMarkets?.has(m.market) ?? false;
|
const isAdded = addedMarkets?.has(m.market) ?? false;
|
||||||
@@ -392,7 +400,6 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={m.market}
|
|
||||||
className={[
|
className={[
|
||||||
'msp-item',
|
'msp-item',
|
||||||
embedded ? 'msp-item--embedded' : '',
|
embedded ? 'msp-item--embedded' : '',
|
||||||
@@ -463,8 +470,9 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
}}
|
||||||
</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' ? (
|
||||||
filteredBacktests.length === 0 ? (
|
<VirtualScroll
|
||||||
|
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,8 +183,10 @@ export default function BacktestExecutionList({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
)}
|
||||||
filteredBacktests.map(r => {
|
aria-label="백테스팅 실행 목록"
|
||||||
|
>
|
||||||
|
{r => {
|
||||||
const ret = r.totalReturn ?? 0;
|
const ret = r.totalReturn ?? 0;
|
||||||
const positive = ret >= 0;
|
const positive = ret >= 0;
|
||||||
const active = selectedBacktestId === r.id;
|
const active = selectedBacktestId === r.id;
|
||||||
@@ -186,7 +194,7 @@ export default function BacktestExecutionList({
|
|||||||
const ko = getKoreanName(market);
|
const ko = getKoreanName(market);
|
||||||
const strategy = r.strategyName || '전략 없음';
|
const strategy = r.strategyName || '전략 없음';
|
||||||
return (
|
return (
|
||||||
<button key={r.id} type="button" className={`btd-history-card${active ? ' btd-history-card--active' : ''}`} onClick={() => onSelectBacktest(r)}>
|
<button type="button" className={`btd-history-card${active ? ' btd-history-card--active' : ''}`} onClick={() => onSelectBacktest(r)}>
|
||||||
<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>
|
||||||
@@ -201,9 +209,16 @@ export default function BacktestExecutionList({
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})
|
}}
|
||||||
)
|
</VirtualScroll>
|
||||||
) : filteredLiveItems.length === 0 ? (
|
) : (
|
||||||
|
<VirtualScroll
|
||||||
|
className="btd-exec-scroll vl-scroll"
|
||||||
|
items={filteredLiveItems}
|
||||||
|
estimateSize={88}
|
||||||
|
measureDynamic
|
||||||
|
getItemKey={item => item.id}
|
||||||
|
empty={(
|
||||||
<div className="btd-sidebar-empty">
|
<div className="btd-sidebar-empty">
|
||||||
{normalizedQuery ? (
|
{normalizedQuery ? (
|
||||||
<p>'{query}'에 해당하는 결과가 없습니다.</p>
|
<p>'{query}'에 해당하는 결과가 없습니다.</p>
|
||||||
@@ -214,14 +229,16 @@ export default function BacktestExecutionList({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
)}
|
||||||
filteredLiveItems.map(item => {
|
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>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isListLayout) {
|
const rowClassName = [
|
||||||
return (
|
|
||||||
<li
|
|
||||||
ref={rowRef}
|
|
||||||
className={[
|
|
||||||
'tnl-row',
|
'tnl-row',
|
||||||
'tnl-row--grid',
|
isListLayout ? 'tnl-row--gallery' : 'tnl-row--grid',
|
||||||
!item.isRead ? 'tnl-row--unread' : '',
|
!item.isRead ? 'tnl-row--unread' : '',
|
||||||
isSelected ? 'tnl-row--selected' : '',
|
isSelected ? 'tnl-row--selected' : '',
|
||||||
receiving ? 'tnl-row--receiving' : '',
|
receiving ? 'tnl-row--receiving' : '',
|
||||||
].filter(Boolean).join(' ')}
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
if (!isListLayout) {
|
||||||
|
return (
|
||||||
|
<RowTag
|
||||||
|
ref={rowRef as React.Ref<HTMLLIElement & HTMLDivElement>}
|
||||||
|
role={itemTag === 'div' ? 'listitem' : undefined}
|
||||||
|
className={rowClassName}
|
||||||
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>
|
</table>
|
||||||
{loading && results.length === 0 && (
|
<VirtualScroll
|
||||||
<tr><td colSpan={5} className="tsd-empty">스캔 중…</td></tr>
|
className="tsd-virtual-body vl-scroll"
|
||||||
)}
|
items={results}
|
||||||
{!loading && results.length === 0 && (
|
estimateSize={40}
|
||||||
<tr><td colSpan={5} className="tsd-empty">조건에 맞는 종목이 없습니다. 필터를 조정해 보세요.</td></tr>
|
getItemKey={row => row.market}
|
||||||
)}
|
empty={emptyBody}
|
||||||
{results.map(row => {
|
role="rowgroup"
|
||||||
|
aria-label="추세검색 결과"
|
||||||
|
>
|
||||||
|
{row => {
|
||||||
const up = row.changeRate >= 0;
|
const up = row.changeRate >= 0;
|
||||||
const active = selectedMarket === row.market;
|
const active = selectedMarket === row.market;
|
||||||
const flash = flashMarkets?.has(row.market);
|
const flash = flashMarkets?.has(row.market);
|
||||||
return (
|
return (
|
||||||
<tr
|
<div
|
||||||
key={row.market}
|
|
||||||
className={[
|
className={[
|
||||||
|
'tsd-virtual-row',
|
||||||
active ? 'tsd-row--sel' : '',
|
active ? 'tsd-row--sel' : '',
|
||||||
flash ? (up ? 'tsd-row--flash-up' : 'tsd-row--flash-down') : '',
|
flash ? (up ? 'tsd-row--flash-up' : 'tsd-row--flash-down') : '',
|
||||||
].filter(Boolean).join(' ')}
|
].filter(Boolean).join(' ')}
|
||||||
|
role="row"
|
||||||
onClick={() => onSelect(row)}
|
onClick={() => onSelect(row)}
|
||||||
>
|
>
|
||||||
<td className="tsd-col-symbol">
|
<div className="tsd-virtual-cell tsd-col-symbol">
|
||||||
<span className="tsd-symbol">{coinLabel(row.market, row.koreanName)}</span>
|
<span className="tsd-symbol">{coinLabel(row.market, row.koreanName)}</span>
|
||||||
</td>
|
</div>
|
||||||
<td className="tsd-col-price">{fmtPrice(row.currentPrice)}</td>
|
<div className="tsd-virtual-cell tsd-col-price">{fmtPrice(row.currentPrice)}</div>
|
||||||
<td className={`tsd-col-change${up ? ' up' : ' down'}`}>{fmtPct(row.changeRate)}</td>
|
<div className={`tsd-virtual-cell tsd-col-change${up ? ' up' : ' down'}`}>{fmtPct(row.changeRate)}</div>
|
||||||
<td className="tsd-col-match">
|
<div className="tsd-virtual-cell tsd-col-match">
|
||||||
<div className="tsd-match-bar-wrap">
|
<div className="tsd-match-bar-wrap">
|
||||||
<div className="tsd-match-bar">
|
<div className="tsd-match-bar">
|
||||||
<div className="tsd-match-bar-fill" style={{ width: `${row.matchRate}%` }} />
|
<div className="tsd-match-bar-fill" style={{ width: `${row.matchRate}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<span className="tsd-match-pct">{row.matchRate}%</span>
|
<span className="tsd-match-pct">{row.matchRate}%</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</div>
|
||||||
<td className="tsd-col-badge">
|
<div className="tsd-virtual-cell tsd-col-badge">
|
||||||
{row.highMatch && <span className="tsd-badge-high">HIGH MATCH</span>}
|
{row.highMatch && <span className="tsd-badge-high">HIGH MATCH</span>}
|
||||||
{!row.highMatch && row.matchRate >= 70 && <span className="tsd-badge-mid">MATCH</span>}
|
{!row.highMatch && row.matchRate >= 70 && <span className="tsd-badge-mid">MATCH</span>}
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
}}
|
||||||
</tbody>
|
</VirtualScroll>
|
||||||
</table>
|
|
||||||
</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,16 +73,23 @@ 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} />
|
||||||
@@ -98,10 +106,9 @@ const VerificationIssueListPanel: React.FC<Props> = ({
|
|||||||
<span className="vbd-list-title">{issue.title}</span>
|
<span className="vbd-list-title">{issue.title}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
|
||||||
);
|
);
|
||||||
})}
|
}}
|
||||||
</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>
|
||||||
)}
|
) : (
|
||||||
{targets.map(item => {
|
<VirtualScroll
|
||||||
|
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}
|
||||||
@@ -186,8 +207,8 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
|||||||
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,12 +48,13 @@ 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) {
|
||||||
@@ -51,17 +72,28 @@ export function useTradeNotificationSignalSnapshot(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pinFirst = emptyRetryRef.current === 0;
|
const pinFirst = emptyRetryRef.current === 0;
|
||||||
const snap = await fetchLiveSnapshot(market, strategyId, effectiveStrategy, pinFirst);
|
let snap = await fetchLiveSnapshot(market, strategyId, effectiveStrategy, pinFirst);
|
||||||
if (gen !== genRef.current) return;
|
if (gen !== genRef.current) return;
|
||||||
|
|
||||||
|
if (!snap && effectiveStrategy) {
|
||||||
|
snap = staticFallback(market, strategyId, effectiveStrategy) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
if (snap && snap.rows.length > 0) {
|
if (snap && snap.rows.length > 0) {
|
||||||
emptyRetryRef.current = 0;
|
emptyRetryRef.current = 0;
|
||||||
} else {
|
} else {
|
||||||
emptyRetryRef.current += 1;
|
emptyRetryRef.current += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (gen === genRef.current) {
|
||||||
setSnapshot(snap ?? undefined);
|
setSnapshot(snap ?? undefined);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
refreshInFlightRef.current = false;
|
||||||
|
if (gen === genRef.current) {
|
||||||
setLoading(false);
|
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,43 +225,49 @@ 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 jobs = activeTargets.map(async t => {
|
||||||
const gen = (refreshGenRef.current[t.market] ?? 0) + 1;
|
const market = t.market;
|
||||||
refreshGenRef.current[t.market] = gen;
|
const strategyId = t.strategyId!;
|
||||||
const strat = strats.find(s => s.id === t.strategyId);
|
const gen = (refreshGenRef.current[market] ?? 0) + 1;
|
||||||
const baseRows = strat ? extractVirtualConditions(strat) : [];
|
refreshGenRef.current[market] = gen;
|
||||||
const needsLiveApi = running || baseRows.length === 0;
|
|
||||||
|
|
||||||
let snap: VirtualIndicatorSnapshot | null = null;
|
try {
|
||||||
if (needsLiveApi) {
|
const strat = await resolveStrategy(strategyId, strats, strategyLoadCacheRef.current);
|
||||||
const retry = emptyRetryRef.current[t.market] ?? 0;
|
if (refreshGenRef.current[market] !== gen) return;
|
||||||
snap = await fetchLiveSnapshot(t.market, t.strategyId!, strat, retry === 0);
|
|
||||||
} else {
|
const baseRows = strat ? extractVirtualConditions(strat) : [];
|
||||||
snap = staticSnapshot(t.market, t.strategyId!, baseRows);
|
const retry = emptyRetryRef.current[market] ?? 0;
|
||||||
|
let snap = await fetchLiveSnapshot(market, strategyId, strat, retry === 0);
|
||||||
|
|
||||||
|
if (!snap && baseRows.length > 0) {
|
||||||
|
snap = staticSnapshot(market, strategyId, baseRows);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!snap || refreshGenRef.current[t.market] !== gen) return snap;
|
if (!snap || refreshGenRef.current[market] !== gen) return;
|
||||||
if (snap.strategyId !== t.strategyId) return snap;
|
if (snap.strategyId !== strategyId) return;
|
||||||
|
|
||||||
if (snap.rows.length === 0) {
|
if (snap.rows.length === 0) {
|
||||||
const n = (emptyRetryRef.current[t.market] ?? 0) + 1;
|
emptyRetryRef.current[market] = (emptyRetryRef.current[market] ?? 0) + 1;
|
||||||
emptyRetryRef.current[t.market] = n;
|
|
||||||
} else {
|
} else {
|
||||||
emptyRetryRef.current[t.market] = 0;
|
emptyRetryRef.current[market] = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSnapshots(prev => ({ ...prev, [snap.market]: snap }));
|
setSnapshots(prev => ({ ...prev, [snap!.market]: snap! }));
|
||||||
setLoadingByMarket(prev => ({ ...prev, [t.market]: false }));
|
} finally {
|
||||||
return snap;
|
if (refreshGenRef.current[market] === gen) {
|
||||||
|
setLoadingByMarket(prev => ({ ...prev, [market]: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all(jobs);
|
await Promise.all(jobs);
|
||||||
|
|
||||||
const activeMarkets = new Set(targets.map(t => t.market));
|
const activeMarkets = new Set(targets.map(t => t.market));
|
||||||
@@ -277,7 +293,10 @@ export function useVirtualIndicatorSnapshots(
|
|||||||
}
|
}
|
||||||
return changed ? next : prev;
|
return changed ? next : prev;
|
||||||
});
|
});
|
||||||
}, [targets, running, pinTargets]);
|
} 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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Generated
+28
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user