목록 로딩 로직 개선

This commit is contained in:
Macbook
2026-06-07 16:57:30 +09:00
parent 59c548cb41
commit 7ec754770d
25 changed files with 1081 additions and 431 deletions
@@ -5,6 +5,7 @@ import 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>
);