목록 로딩 로직 개선
This commit is contained in:
@@ -10,6 +10,7 @@ import { UPBIT_API } from '../utils/upbitApi';
|
||||
import { fetchTickersThrottled } from '../utils/upbitTickerFetch';
|
||||
import { useIsCompactDevice } from '../hooks/useMediaQuery';
|
||||
import { safeToFixed } from '../utils/safeFormat';
|
||||
import { VirtualScroll } from './common/VirtualScroll';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
interface MarketInfo {
|
||||
@@ -373,98 +374,105 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 목록 */}
|
||||
<div className="msp-list">
|
||||
{loading && (
|
||||
{/* 목록 — 가상 스크롤 (업비트 전 종목) */}
|
||||
{loading ? (
|
||||
<div className="msp-list">
|
||||
<div className="msp-empty">마켓 목록 로딩 중...</div>
|
||||
)}
|
||||
{!loading && sorted.length === 0 && (
|
||||
<div className="msp-empty">검색 결과가 없습니다</div>
|
||||
)}
|
||||
{sorted.map(m => {
|
||||
const tk = tickers[m.market];
|
||||
const isFav = favs.has(m.market);
|
||||
const isAdded = addedMarkets?.has(m.market) ?? false;
|
||||
const addBlocked = maxTargetsReached && !isAdded;
|
||||
const isActive = m.market === currentMarket;
|
||||
const rate = tk ? formatChangeRate(tk.signed_change_rate) : null;
|
||||
const isUp = rate !== null && rate >= 0;
|
||||
</div>
|
||||
) : (
|
||||
<VirtualScroll
|
||||
className="msp-list"
|
||||
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 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 (
|
||||
<div
|
||||
key={m.market}
|
||||
className={[
|
||||
'msp-item',
|
||||
embedded ? 'msp-item--embedded' : '',
|
||||
isCompactDropdown ? 'msp-item--compact' : '',
|
||||
isActive ? 'active' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => handleRowSelect(m.market)}
|
||||
>
|
||||
{!isCompactDropdown && (
|
||||
actionMode === 'add' ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`msp-add-btn${isAdded ? ' msp-add-btn--done' : ''}${addBlocked ? ' msp-add-btn--blocked' : ''}`}
|
||||
onClick={e => handleAddClick(m.market, e)}
|
||||
disabled={addBlocked}
|
||||
title={
|
||||
isAdded
|
||||
? '투자대상에서 제거'
|
||||
: addBlocked
|
||||
? '투자대상 최대 개수에 도달했습니다'
|
||||
: '투자대상에 추가'
|
||||
}
|
||||
>
|
||||
{isAdded ? (
|
||||
<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"/>
|
||||
</svg>
|
||||
) : (
|
||||
<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"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className={`msp-col msp-col-fav msp-star${isFav ? ' starred' : ''}`}
|
||||
onClick={e => toggleFav(m.market, e)}
|
||||
title={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) : '-'}
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'msp-item',
|
||||
embedded ? 'msp-item--embedded' : '',
|
||||
isCompactDropdown ? 'msp-item--compact' : '',
|
||||
isActive ? 'active' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => handleRowSelect(m.market)}
|
||||
>
|
||||
{!isCompactDropdown && (
|
||||
actionMode === 'add' ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`msp-add-btn${isAdded ? ' msp-add-btn--done' : ''}${addBlocked ? ' msp-add-btn--blocked' : ''}`}
|
||||
onClick={e => handleAddClick(m.market, e)}
|
||||
disabled={addBlocked}
|
||||
title={
|
||||
isAdded
|
||||
? '투자대상에서 제거'
|
||||
: addBlocked
|
||||
? '투자대상 최대 개수에 도달했습니다'
|
||||
: '투자대상에 추가'
|
||||
}
|
||||
>
|
||||
{isAdded ? (
|
||||
<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"/>
|
||||
</svg>
|
||||
) : (
|
||||
<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"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className={`msp-col msp-col-fav msp-star${isFav ? ' starred' : ''}`}
|
||||
onClick={e => toggleFav(m.market, e)}
|
||||
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
||||
>
|
||||
{isFav ? '★' : '☆'}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</VirtualScroll>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -35,6 +35,7 @@ import '../styles/virtualTradingDashboard.css';
|
||||
import '../styles/builderPageShell.css';
|
||||
import '../styles/tradeNotificationList.css';
|
||||
import TradeNotificationListRow from './tradeNotification/TradeNotificationListRow';
|
||||
import { VirtualGridScroll, VirtualScroll } from './common/VirtualScroll';
|
||||
import TradeNotificationGridLayoutPicker from './tradeNotification/TradeNotificationGridLayoutPicker';
|
||||
import FullscreenChartModal from './tradeNotification/FullscreenChartModal';
|
||||
import {
|
||||
@@ -425,6 +426,47 @@ export const TradeNotificationListPage: React.FC<Props> = ({
|
||||
const isListView = listLayout === 'list';
|
||||
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 (
|
||||
<>
|
||||
{fullscreenItem && (
|
||||
@@ -588,41 +630,39 @@ export const TradeNotificationListPage: React.FC<Props> = ({
|
||||
<div className="tnl-empty">
|
||||
{normalizedQuery ? `"${query}" 에 해당하는 알림이 없습니다.` : '표시할 알림이 없습니다.'}
|
||||
</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={[
|
||||
'tnl-list',
|
||||
isListView ? 'tnl-list--gallery' : 'tnl-list--grid',
|
||||
!isListView ? `tnl-grid--cols-${gridCols}` : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
style={!isListView ? { gridTemplateColumns: `repeat(${gridCols}, minmax(0, 1fr))` } : undefined}
|
||||
'tnl-list--grid',
|
||||
'vl-scroll',
|
||||
`tnl-grid--cols-${gridCols}`,
|
||||
].join(' ')}
|
||||
items={sorted}
|
||||
columns={gridCols}
|
||||
estimateRowSize={300}
|
||||
rowGap={14}
|
||||
measureDynamic
|
||||
overscan={2}
|
||||
getItemKey={item => item.id}
|
||||
role="list"
|
||||
aria-label="매매 시그널 알림 그리드"
|
||||
>
|
||||
{sorted.map(item => (
|
||||
<TradeNotificationListRow
|
||||
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>
|
||||
{item => renderNotificationRow(item)}
|
||||
</VirtualGridScroll>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { VirtualScroll } from '../common/VirtualScroll';
|
||||
import type { BacktestResultRecord } from '../../utils/backendApi';
|
||||
import type { LiveExecutionItem } from '../../utils/liveExecutionGroups';
|
||||
import BacktestSparkline from './BacktestSparkline';
|
||||
@@ -164,9 +165,14 @@ export default function BacktestExecutionList({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="btd-exec-scroll">
|
||||
{tab === 'backtest' ? (
|
||||
filteredBacktests.length === 0 ? (
|
||||
{tab === 'backtest' ? (
|
||||
<VirtualScroll
|
||||
className="btd-exec-scroll vl-scroll"
|
||||
items={filteredBacktests}
|
||||
estimateSize={88}
|
||||
measureDynamic
|
||||
getItemKey={r => r.id ?? r.createdAt}
|
||||
empty={(
|
||||
<div className="btd-sidebar-empty">
|
||||
{normalizedQuery ? (
|
||||
<p>'{query}'에 해당하는 결과가 없습니다.</p>
|
||||
@@ -177,51 +183,62 @@ export default function BacktestExecutionList({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
filteredBacktests.map(r => {
|
||||
const ret = r.totalReturn ?? 0;
|
||||
const positive = ret >= 0;
|
||||
const active = selectedBacktestId === r.id;
|
||||
const market = toUpbitMarket(r.symbol);
|
||||
const ko = getKoreanName(market);
|
||||
const strategy = r.strategyName || '전략 없음';
|
||||
return (
|
||||
<button key={r.id} 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-main">
|
||||
<span className="btd-history-ko">{ko}</span>
|
||||
<span className="btd-history-strategy">{strategy} · {formatTimeframeKo(r.timeframe)}</span>
|
||||
<span className="btd-history-date">{fmtListTimestamp(r.createdAt)}</span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
aria-label="백테스팅 실행 목록"
|
||||
>
|
||||
{r => {
|
||||
const ret = r.totalReturn ?? 0;
|
||||
const positive = ret >= 0;
|
||||
const active = selectedBacktestId === r.id;
|
||||
const market = toUpbitMarket(r.symbol);
|
||||
const ko = getKoreanName(market);
|
||||
const strategy = r.strategyName || '전략 없음';
|
||||
return (
|
||||
<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-main">
|
||||
<span className="btd-history-ko">{ko}</span>
|
||||
<span className="btd-history-strategy">{strategy} · {formatTimeframeKo(r.timeframe)}</span>
|
||||
<span className="btd-history-date">{fmtListTimestamp(r.createdAt)}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)
|
||||
) : filteredLiveItems.length === 0 ? (
|
||||
<div className="btd-sidebar-empty">
|
||||
{normalizedQuery ? (
|
||||
<p>'{query}'에 해당하는 결과가 없습니다.</p>
|
||||
) : (
|
||||
<>
|
||||
<p>실시간 매매 이력이 없습니다.</p>
|
||||
<p className="btd-sidebar-hint">가상투자 화면에서 자동·수동<br />매매를 실행하세요.</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
filteredLiveItems.map(item => {
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</VirtualScroll>
|
||||
) : (
|
||||
<VirtualScroll
|
||||
className="btd-exec-scroll vl-scroll"
|
||||
items={filteredLiveItems}
|
||||
estimateSize={88}
|
||||
measureDynamic
|
||||
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 active = selectedLiveId === item.id;
|
||||
const market = toUpbitMarket(item.symbol);
|
||||
const ko = getKoreanName(market);
|
||||
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-main">
|
||||
<span className="btd-history-ko">{ko}</span>
|
||||
@@ -236,9 +253,9 @@ export default function BacktestExecutionList({
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
}}
|
||||
</VirtualScroll>
|
||||
)}
|
||||
</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 { VirtualScroll } from '../common/VirtualScroll';
|
||||
import type { PaperTradeDto } from '../../utils/backendApi';
|
||||
import type { TickerData } from '../../hooks/useMarketTicker';
|
||||
import { resolveVirtualTargetNames } from '../../utils/virtualTargetNames';
|
||||
@@ -58,9 +59,17 @@ const PaperTradeHistoryList: React.FC<Props> = ({
|
||||
{trades.length === 0 ? (
|
||||
<p className="vtd-muted ptd-trade-history-empty">{emptyText}</p>
|
||||
) : (
|
||||
<div className="ptd-trade-history-scroll">
|
||||
<div className="vtd-target-list vtd-trade-list">
|
||||
{displayTrades.map(t => {
|
||||
<VirtualScroll
|
||||
className="ptd-trade-history-scroll vl-scroll"
|
||||
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(
|
||||
t.symbol,
|
||||
tickers?.get(t.symbol)?.koreanName,
|
||||
@@ -80,7 +89,6 @@ const PaperTradeHistoryList: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
className={[
|
||||
'vtd-target-item',
|
||||
'vtd-trade-item',
|
||||
@@ -162,9 +170,8 @@ const PaperTradeHistoryList: React.FC<Props> = ({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
}}
|
||||
</VirtualScroll>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -66,6 +66,8 @@ interface Props {
|
||||
/** 상세분석 레포트 버튼 클릭 */
|
||||
onReport?: () => void;
|
||||
reportLoading?: boolean;
|
||||
/** 가상 스크롤 목록에서는 div + role=listitem */
|
||||
itemTag?: 'li' | 'div';
|
||||
}
|
||||
|
||||
const MAX_INDICATOR_CARDS = 8;
|
||||
@@ -85,6 +87,7 @@ const TradeNotificationListRow: React.FC<Props> = ({
|
||||
onTrade,
|
||||
onReport,
|
||||
reportLoading = false,
|
||||
itemTag = 'li',
|
||||
}) => {
|
||||
useTradeAlertTimeFormat();
|
||||
const isBuy = item.signalType === 'BUY';
|
||||
@@ -106,7 +109,8 @@ const TradeNotificationListRow: React.FC<Props> = ({
|
||||
|| strategy?.name
|
||||
|| strategyRow?.value
|
||||
|| '실시간 전략';
|
||||
const rowRef = useRef<HTMLLIElement>(null);
|
||||
const rowRef = useRef<HTMLLIElement | HTMLDivElement>(null);
|
||||
const RowTag = itemTag;
|
||||
const [inView, setInView] = useState(false);
|
||||
/** 펼쳐보기 중인 차트: 'candle' | 지표 id | null */
|
||||
const [expandedChartKey, setExpandedChartKey] = useState<string | null>(null);
|
||||
@@ -348,34 +352,32 @@ const TradeNotificationListRow: React.FC<Props> = ({
|
||||
</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) {
|
||||
return (
|
||||
<li
|
||||
ref={rowRef}
|
||||
className={[
|
||||
'tnl-row',
|
||||
'tnl-row--grid',
|
||||
!item.isRead ? 'tnl-row--unread' : '',
|
||||
isSelected ? 'tnl-row--selected' : '',
|
||||
receiving ? 'tnl-row--receiving' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
<RowTag
|
||||
ref={rowRef as React.Ref<HTMLLIElement & HTMLDivElement>}
|
||||
role={itemTag === 'div' ? 'listitem' : undefined}
|
||||
className={rowClassName}
|
||||
onClick={handleRowClick}
|
||||
>
|
||||
{summaryCard}
|
||||
</li>
|
||||
</RowTag>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={rowRef}
|
||||
className={[
|
||||
'tnl-row',
|
||||
'tnl-row--gallery',
|
||||
!item.isRead ? 'tnl-row--unread' : '',
|
||||
isSelected ? 'tnl-row--selected' : '',
|
||||
receiving ? 'tnl-row--receiving' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
<RowTag
|
||||
ref={rowRef as React.Ref<HTMLLIElement & HTMLDivElement>}
|
||||
role={itemTag === 'div' ? 'listitem' : undefined}
|
||||
className={rowClassName}
|
||||
onClick={handleRowClick}
|
||||
>
|
||||
<div
|
||||
@@ -455,7 +457,7 @@ const TradeNotificationListRow: React.FC<Props> = ({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
</RowTag>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Theme } from '../../types';
|
||||
import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
|
||||
import type { TickerData } from '../../hooks/useMarketTicker';
|
||||
import TrendSearchResultCard, { type TrendSearchDisplayMode } from './TrendSearchResultCard';
|
||||
import { VirtualGridScroll } from '../common/VirtualScroll';
|
||||
|
||||
interface Props {
|
||||
results: TrendSearchResultDto[];
|
||||
@@ -74,12 +75,27 @@ const TrendSearchResultsCardGrid: React.FC<Props> = ({
|
||||
);
|
||||
}
|
||||
|
||||
const gridCols = displayMode === 'chart' ? 2 : 3;
|
||||
const rowEstimate = displayMode === 'chart' ? 420 : 300;
|
||||
|
||||
return (
|
||||
<div className="vtd-grid-wrap">
|
||||
<div className={`vtd-grid${displayMode === 'chart' ? ' vtd-grid--chart-mode' : ''}`}>
|
||||
{sortedResults.map(row => (
|
||||
<div className="vtd-grid-wrap vtd-grid-wrap--virtual">
|
||||
<VirtualGridScroll
|
||||
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
|
||||
key={row.market}
|
||||
result={row}
|
||||
timeframe={timeframe}
|
||||
displayMode={displayMode}
|
||||
@@ -99,8 +115,8 @@ const TrendSearchResultsCardGrid: React.FC<Props> = ({
|
||||
onAddTarget={onAddTarget ? () => onAddTarget(row) : undefined}
|
||||
onRemoveTarget={onRemoveTarget ? () => onRemoveTarget(row.market) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</VirtualGridScroll>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import type { TrendSearchResultDto } from '../../utils/trendSearchApi';
|
||||
import { formatUpbitKrwPrice } from '../../utils/safeFormat';
|
||||
import { VirtualScroll } from '../common/VirtualScroll';
|
||||
|
||||
interface Props {
|
||||
results: TrendSearchResultDto[];
|
||||
@@ -36,6 +37,10 @@ const TrendSearchResultsGrid: React.FC<Props> = ({
|
||||
return Math.round(results.reduce((s, r) => s + r.matchRate, 0) / results.length);
|
||||
}, [results]);
|
||||
|
||||
const emptyBody = loading && results.length === 0
|
||||
? <div className="tsd-virtual-empty tsd-empty">스캔 중…</div>
|
||||
: <div className="tsd-virtual-empty tsd-empty">조건에 맞는 종목이 없습니다. 필터를 조정해 보세요.</div>;
|
||||
|
||||
return (
|
||||
<div className="tsd-results">
|
||||
<div className="tsd-results-head">
|
||||
@@ -49,8 +54,8 @@ const TrendSearchResultsGrid: React.FC<Props> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tsd-results-table-wrap">
|
||||
<table className="tsd-results-table">
|
||||
<div className="tsd-results-table-wrap tsd-results-table-wrap--virtual">
|
||||
<table className="tsd-results-table tsd-results-table--head">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>종목명</th>
|
||||
@@ -60,48 +65,51 @@ const TrendSearchResultsGrid: React.FC<Props> = ({
|
||||
<th>비고</th>
|
||||
</tr>
|
||||
</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>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -342,7 +342,7 @@ const VerificationIssueAttachmentsSection: React.FC<Props> = ({
|
||||
title={`${p.fileName} — 더블클릭하여 열기`}
|
||||
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>
|
||||
<figcaption className="vbd-attach-caption" title={p.fileName}>
|
||||
{p.fileName}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '../../utils/verificationBoardStages';
|
||||
import VerificationStageIcon from './VerificationStageIcon';
|
||||
import { formatIsoDateTime } from '../../utils/timezone';
|
||||
import { VirtualScroll } from '../common/VirtualScroll';
|
||||
|
||||
interface Props {
|
||||
issues: VerificationIssueDto[];
|
||||
@@ -72,36 +73,42 @@ const VerificationIssueListPanel: React.FC<Props> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="vbd-list">
|
||||
{filtered.length === 0 ? (
|
||||
<li className="vbd-list-empty">표시할 검증 이슈가 없습니다.</li>
|
||||
) : filtered.map(issue => {
|
||||
<VirtualScroll
|
||||
className="vbd-list vl-scroll"
|
||||
items={filtered}
|
||||
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;
|
||||
return (
|
||||
<li key={issue.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`vbd-list-item${active ? ' vbd-list-item--active' : ''}`}
|
||||
onClick={() => onSelect(issue)}
|
||||
>
|
||||
<VerificationStageIcon stage={issue.stage} size={34} />
|
||||
<span className="vbd-list-item-body">
|
||||
<span className="vbd-list-item-top">
|
||||
<span
|
||||
className="vbd-stage-badge"
|
||||
style={{ backgroundColor: `${stageColor(issue.stage)}22`, color: stageColor(issue.stage) }}
|
||||
>
|
||||
{stageLabel(issue.stage)}
|
||||
</span>
|
||||
<span className="vbd-list-meta">{formatIsoDateTime(issue.updatedAt ?? issue.createdAt, undefined, 'short')}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`vbd-list-item${active ? ' vbd-list-item--active' : ''}`}
|
||||
role="listitem"
|
||||
onClick={() => onSelect(issue)}
|
||||
>
|
||||
<VerificationStageIcon stage={issue.stage} size={34} />
|
||||
<span className="vbd-list-item-body">
|
||||
<span className="vbd-list-item-top">
|
||||
<span
|
||||
className="vbd-stage-badge"
|
||||
style={{ backgroundColor: `${stageColor(issue.stage)}22`, color: stageColor(issue.stage) }}
|
||||
>
|
||||
{stageLabel(issue.stage)}
|
||||
</span>
|
||||
<span className="vbd-list-title">{issue.title}</span>
|
||||
<span className="vbd-list-meta">{formatIsoDateTime(issue.updatedAt ?? issue.createdAt, undefined, 'short')}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
<span className="vbd-list-title">{issue.title}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
}}
|
||||
</VirtualScroll>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
parseTargetStrategySelectValue,
|
||||
targetStrategySelectValue,
|
||||
} from '../../utils/virtualTargetStrategy';
|
||||
import { VirtualScroll } from '../common/VirtualScroll';
|
||||
|
||||
interface Props {
|
||||
targets: VirtualTargetItem[];
|
||||
@@ -131,11 +132,18 @@ const VirtualLeftTargetPanel: React.FC<Props> = ({
|
||||
<span className="vtd-target-count">{targets.length}/{virtualTargetMaxCount}종목</span>
|
||||
</div>
|
||||
|
||||
<div className="vtd-target-list">
|
||||
{targets.length === 0 && (
|
||||
<p className="vtd-muted">검색 후 + 버튼으로 종목을 추가하세요.</p>
|
||||
)}
|
||||
{targets.map(item => {
|
||||
{targets.length === 0 ? (
|
||||
<p className="vtd-muted">검색 후 + 버튼으로 종목을 추가하세요.</p>
|
||||
) : (
|
||||
<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(
|
||||
item.market,
|
||||
item.koreanName,
|
||||
@@ -144,7 +152,6 @@ const VirtualLeftTargetPanel: React.FC<Props> = ({
|
||||
const active = item.market === selectedMarket;
|
||||
return (
|
||||
<div
|
||||
key={item.market}
|
||||
className={[
|
||||
'vtd-target-item',
|
||||
active ? 'vtd-target-item--active' : '',
|
||||
@@ -213,8 +220,9 @@ const VirtualLeftTargetPanel: React.FC<Props> = ({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}}
|
||||
</VirtualScroll>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
getTradeNotificationGridPreset,
|
||||
type TradeNotificationGridPresetId,
|
||||
} from '../../utils/tradeNotificationGridPresets';
|
||||
import { VirtualGridScroll } from '../common/VirtualScroll';
|
||||
|
||||
interface Props {
|
||||
targets: VirtualTargetItem[];
|
||||
@@ -60,6 +61,17 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
||||
gridPreset,
|
||||
}) => {
|
||||
const gridCols = getTradeNotificationGridPreset(gridPreset).cols;
|
||||
|
||||
const gridRowEstimate = useMemo(() => {
|
||||
if (viewMode === 'detail') return 540;
|
||||
if (globalDisplayMode === 'chart') return 460;
|
||||
return 400;
|
||||
}, [viewMode, globalDisplayMode]);
|
||||
|
||||
const gridRemeasureKey = useMemo(
|
||||
() => `${viewMode}:${globalDisplayMode}:${targets.map(t => t.market).join(',')}:${Object.values(snapshots).map(s => s?.updatedAt ?? 0).join('|')}`,
|
||||
[viewMode, globalDisplayMode, targets, snapshots],
|
||||
);
|
||||
const [cardDisplayOverrides, setCardDisplayOverrides] = useState<Record<string, VirtualCardDisplayMode>>({});
|
||||
const [focusMarket, setFocusMarket] = useState<string | null>(null);
|
||||
|
||||
@@ -144,20 +156,29 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
||||
englishName={focusTarget.englishName}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
<VirtualGridScroll
|
||||
className={[
|
||||
'vtd-grid',
|
||||
'vtd-grid--layout-preset',
|
||||
'vtd-grid-scroll',
|
||||
'vl-scroll',
|
||||
`vtd-grid--${viewMode}`,
|
||||
`vtd-grid--cols-${gridCols}`,
|
||||
].join(' ')}
|
||||
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));
|
||||
return (
|
||||
<VirtualTargetCard
|
||||
key={t.market}
|
||||
market={t.market}
|
||||
strategy={strat}
|
||||
strategies={strategies}
|
||||
@@ -178,16 +199,16 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
||||
theme={theme}
|
||||
chartRealtimeSource={chartRealtimeSource}
|
||||
chartCandleAreaPriceLabels={chartCandleAreaPriceLabels}
|
||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||
chartPaneSeparator={chartPaneSeparator}
|
||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||
chartPaneSeparator={chartPaneSeparator}
|
||||
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
|
||||
ticker={tickers?.get(t.market)}
|
||||
koreanName={t.koreanName}
|
||||
englishName={t.englishName}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}}
|
||||
</VirtualGridScroll>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user