frontend 성능 개선
This commit is contained in:
@@ -590,9 +590,9 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
||||
useMemo(() => ({
|
||||
onTickUpdate: handleTickUpdate,
|
||||
onNewCandle: handleNewCandle,
|
||||
enabled: useUpbit,
|
||||
enabled: useUpbit && chartVisible,
|
||||
source: chartRealtimeSource,
|
||||
}), [handleTickUpdate, handleNewCandle, useUpbit, chartRealtimeSource]),
|
||||
}), [handleTickUpdate, handleNewCandle, useUpbit, chartVisible, chartRealtimeSource]),
|
||||
);
|
||||
|
||||
const bars = useUpbit ? upbitBars : simBars;
|
||||
|
||||
@@ -161,17 +161,22 @@ const PaperTradingPage: React.FC<Props> = ({
|
||||
const lastPriceKeyRef = useRef('');
|
||||
useEffect(() => {
|
||||
if (!priceKey || initialLoading || priceKey === lastPriceKeyRef.current) return;
|
||||
lastPriceKeyRef.current = priceKey;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const marks = buildMarkPrices(summaryRef.current, tickersRef.current);
|
||||
if (!Object.keys(marks).length) return;
|
||||
try {
|
||||
const full = await loadPaperSummary(marks);
|
||||
if (!cancelled) setSummary(full);
|
||||
} catch { /* ignore */ }
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
const timer = setTimeout(() => {
|
||||
lastPriceKeyRef.current = priceKey;
|
||||
void (async () => {
|
||||
const marks = buildMarkPrices(summaryRef.current, tickersRef.current);
|
||||
if (!Object.keys(marks).length) return;
|
||||
try {
|
||||
const full = await loadPaperSummary(marks);
|
||||
if (!cancelled) setSummary(full);
|
||||
} catch { /* ignore */ }
|
||||
})();
|
||||
}, 400);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [priceKey, initialLoading]);
|
||||
|
||||
const strategyNames = useMemo(
|
||||
|
||||
@@ -466,7 +466,7 @@ export const TradeNotificationListPage: React.FC<Props> = ({
|
||||
onTrade={() => handleTradeFromAlert(item)}
|
||||
onReport={() => void handleReportFromAlert(item)}
|
||||
reportLoading={reportLoadingId === item.id}
|
||||
tickers={tickers}
|
||||
ticker={tickers?.get(normalizeMarketCode(item.market)) ?? tickers?.get(item.market)}
|
||||
/>
|
||||
), [
|
||||
theme,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useRef, useEffect, useLayoutEffect, useState, useCallback } from 'react';
|
||||
import React, { useRef, useEffect, useLayoutEffect, useState, useCallback, memo } from 'react';
|
||||
import type { MouseEventParams, Time } from 'lightweight-charts';
|
||||
import type { OHLCVBar, ChartType, Theme, IndicatorConfig, LegendData, Drawing, ChartMode, Timeframe } from '../types';
|
||||
import { ChartManager } from '../utils/ChartManager';
|
||||
@@ -1996,4 +1996,4 @@ function indKey(inds: IndicatorConfig[]): string {
|
||||
return paramKey(inds) + '@@' + styleKey(inds);
|
||||
}
|
||||
|
||||
export default TradingChart;
|
||||
export default memo(TradingChart);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 매매 시그널 알림 목록 행 — 4칸이 목록 너비를 꽉 채움, 보조지표 2개↑ 시 우측 슬롯만 스크롤
|
||||
*/
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState, memo } from 'react';
|
||||
import type { Theme } from '../../types';
|
||||
import type { TickerData } from '../../hooks/useMarketTicker';
|
||||
import { useLiveReceiveFlash } from '../../hooks/useLiveReceiveFlash';
|
||||
@@ -59,7 +59,8 @@ interface Props {
|
||||
layoutMode?: TradeNotificationRowLayout;
|
||||
chartsEnabled?: boolean;
|
||||
chartLiveReceiveHighlight?: boolean;
|
||||
tickers?: Map<string, TickerData>;
|
||||
/** 해당 종목 시세 (전체 tickers Map 대신 행 단위 전달 — memo·리렌더 최소화) */
|
||||
ticker?: TickerData;
|
||||
onSelect: () => void;
|
||||
onDelete: (e: React.MouseEvent) => void;
|
||||
onDetail: () => void;
|
||||
@@ -84,7 +85,7 @@ const TradeNotificationListRow: React.FC<Props> = ({
|
||||
layoutMode = 'list',
|
||||
chartsEnabled = true,
|
||||
chartLiveReceiveHighlight = true,
|
||||
tickers,
|
||||
ticker,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onDetail,
|
||||
@@ -185,7 +186,6 @@ const TradeNotificationListRow: React.FC<Props> = ({
|
||||
const chartExpanded = isListLayout && expandedChartKey != null;
|
||||
const chartActive = isListLayout && chartsEnabled && panelActive;
|
||||
const signalCardEnabled = isListLayout && panelActive && !chartExpanded;
|
||||
const ticker = tickers?.get(marketCode) ?? tickers?.get(item.market);
|
||||
const expandedIndicator = indicatorCards.find(c => c.id === expandedChartKey);
|
||||
|
||||
const signalSnapshotEnabled = item.strategyId != null && (
|
||||
@@ -488,4 +488,35 @@ const TradeNotificationListRow: React.FC<Props> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default TradeNotificationListRow;
|
||||
function listRowPropsEqual(prev: Props, next: Props): boolean {
|
||||
if (prev.item !== next.item) {
|
||||
if (
|
||||
prev.item.id !== next.item.id
|
||||
|| prev.item.isRead !== next.item.isRead
|
||||
|| prev.item.receivedAt !== next.item.receivedAt
|
||||
|| prev.item.strategyId !== next.item.strategyId
|
||||
|| prev.item.strategyName !== next.item.strategyName
|
||||
) return false;
|
||||
}
|
||||
return (
|
||||
prev.theme === next.theme
|
||||
&& prev.isSelected === next.isSelected
|
||||
&& prev.layoutMode === next.layoutMode
|
||||
&& prev.chartsEnabled === next.chartsEnabled
|
||||
&& prev.chartLiveReceiveHighlight === next.chartLiveReceiveHighlight
|
||||
&& prev.reportLoading === next.reportLoading
|
||||
&& prev.itemTag === next.itemTag
|
||||
&& prev.prefetchedStrategies === next.prefetchedStrategies
|
||||
&& prev.ticker?.tradePrice === next.ticker?.tradePrice
|
||||
&& prev.ticker?.changeRate === next.ticker?.changeRate
|
||||
&& prev.ticker?.accTradePrice24 === next.ticker?.accTradePrice24
|
||||
&& prev.onSelect === next.onSelect
|
||||
&& prev.onDelete === next.onDelete
|
||||
&& prev.onDetail === next.onDetail
|
||||
&& prev.onGoToChart === next.onGoToChart
|
||||
&& prev.onTrade === next.onTrade
|
||||
&& prev.onReport === next.onReport
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(TradeNotificationListRow, listRowPropsEqual);
|
||||
|
||||
@@ -26,6 +26,8 @@ interface Props {
|
||||
chartCandleAreaPriceLabels?: boolean;
|
||||
chartSeriesPriceLabels?: boolean;
|
||||
chartPaneSeparator?: import('../../types/chartPaneSeparator').ChartPaneSeparatorOptions;
|
||||
/** false — 뷰포트 밖: STOMP·히스토리 중지 (기본 true) */
|
||||
chartStreamEnabled?: boolean;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
@@ -46,6 +48,7 @@ const TrendSearchCardChart: React.FC<Props> = ({
|
||||
chartCandleAreaPriceLabels = true,
|
||||
chartSeriesPriceLabels = true,
|
||||
chartPaneSeparator,
|
||||
chartStreamEnabled = true,
|
||||
}) => {
|
||||
const chartTf = toChartTimeframe(timeframe);
|
||||
const { getParams, getVisualConfig } = useIndicatorSettings();
|
||||
@@ -90,6 +93,7 @@ const TrendSearchCardChart: React.FC<Props> = ({
|
||||
const handleNewCandle = useCallback((bar: OHLCVBar) => applyRealtimeBar(bar, true), [applyRealtimeBar]);
|
||||
|
||||
const useUpbit = isUpbitMarket(market);
|
||||
const streamActive = useUpbit && chartStreamEnabled;
|
||||
|
||||
const { bars, barsMarket, isLoading } = useChartRealtimeData(
|
||||
market,
|
||||
@@ -97,15 +101,15 @@ const TrendSearchCardChart: React.FC<Props> = ({
|
||||
useMemo(() => ({
|
||||
onTickUpdate: handleTickUpdate,
|
||||
onNewCandle: handleNewCandle,
|
||||
enabled: useUpbit,
|
||||
enabled: streamActive,
|
||||
source: chartRealtimeSource,
|
||||
}), [handleTickUpdate, handleNewCandle, useUpbit, chartRealtimeSource]),
|
||||
}), [handleTickUpdate, handleNewCandle, streamActive, chartRealtimeSource]),
|
||||
);
|
||||
|
||||
const { isLoadingMore } = useHistoryLoader({
|
||||
symbol: market,
|
||||
timeframe: chartTf,
|
||||
isUpbit: useUpbit,
|
||||
isUpbit: streamActive,
|
||||
managerRef,
|
||||
});
|
||||
|
||||
@@ -151,9 +155,9 @@ const TrendSearchCardChart: React.FC<Props> = ({
|
||||
}, [market, chartTf, bars.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!useUpbit) return;
|
||||
if (!streamActive) return;
|
||||
void pinCandleWatch(market, timeframeToCandleType(chartTf));
|
||||
}, [market, chartTf, useUpbit]);
|
||||
}, [market, chartTf, streamActive]);
|
||||
|
||||
return (
|
||||
<div className="vtd-card-chart-panel vtd-card-chart-panel--fill">
|
||||
@@ -170,6 +174,7 @@ const TrendSearchCardChart: React.FC<Props> = ({
|
||||
)}
|
||||
<TradingChart
|
||||
key={`${market}-${chartTf}-${chartReloadTick}`}
|
||||
chartVisible={chartStreamEnabled}
|
||||
bars={bars}
|
||||
barsMarket={barsMarket}
|
||||
market={market}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { tfLabelShort } from '../../utils/trendSearchMetrics';
|
||||
import type { Theme } from '../../types';
|
||||
import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
|
||||
import type { TickerData } from '../../hooks/useMarketTicker';
|
||||
import { useIntersectionVisible } from '../../hooks/useIntersectionVisible';
|
||||
import VirtualTargetQuote from '../virtual/VirtualTargetQuote';
|
||||
import VirtualLiveBadge from '../virtual/VirtualLiveBadge';
|
||||
import TrendSearchCardSignalPanel from './TrendSearchCardSignalPanel';
|
||||
@@ -75,6 +76,8 @@ const TrendSearchResultCard: React.FC<Props> = ({
|
||||
const quoteTicker = ticker ?? resultToTicker(result);
|
||||
const ko = quoteTicker.koreanName || getKoreanName(result.market) || en;
|
||||
const isChart = displayMode === 'chart';
|
||||
const { ref: cardRef, visible: cardInView } = useIntersectionVisible();
|
||||
const chartStreamEnabled = isChart && cardInView;
|
||||
|
||||
const flashCls = useMemo(() => {
|
||||
if (!flash) return '';
|
||||
@@ -83,6 +86,7 @@ const TrendSearchResultCard: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={[
|
||||
'vtd-card',
|
||||
'vtd-card--signal',
|
||||
@@ -145,6 +149,7 @@ const TrendSearchResultCard: React.FC<Props> = ({
|
||||
chartCandleAreaPriceLabels={chartCandleAreaPriceLabels}
|
||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||
chartPaneSeparator={chartPaneSeparator}
|
||||
chartStreamEnabled={chartStreamEnabled}
|
||||
/>
|
||||
) : (
|
||||
<TrendSearchCardSignalPanel
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import type { StrategyDto } from '../../utils/backendApi';
|
||||
import {
|
||||
resolveVirtualTargetNames,
|
||||
@@ -11,6 +11,7 @@ import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
|
||||
import { buildConditionMetrics } from '../../utils/virtualSignalMetrics';
|
||||
import { resolveStrategyPrimaryTimeframe } from '../../utils/strategyToChartIndicators';
|
||||
import type { Timeframe } from '../../types';
|
||||
import { useIntersectionVisible } from '../../hooks/useIntersectionVisible';
|
||||
import VirtualLiveBadge from './VirtualLiveBadge';
|
||||
import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
|
||||
import type { VirtualCardViewMode } from '../../utils/virtualTradingStorage';
|
||||
@@ -128,9 +129,12 @@ const VirtualTargetCard: React.FC<Props> = ({
|
||||
}, [running, lastTickAt, snapshot?.updatedAt]);
|
||||
const receiving = useLiveReceiveFlash(receiveSignal, running);
|
||||
const highlightReceiving = chartLiveReceiveHighlight && receiving;
|
||||
const { ref: cardRef, visible: cardInView } = useIntersectionVisible();
|
||||
const chartStreamEnabled = isChart && cardInView;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={[
|
||||
'vtd-card',
|
||||
'vtd-card--signal',
|
||||
@@ -234,6 +238,7 @@ const VirtualTargetCard: React.FC<Props> = ({
|
||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||
chartPaneSeparator={chartPaneSeparator}
|
||||
chartTimeframe={chartTimeframe}
|
||||
chartStreamEnabled={chartStreamEnabled}
|
||||
/>
|
||||
) : (
|
||||
<VirtualTargetSignalPanel
|
||||
@@ -257,4 +262,4 @@ const VirtualTargetCard: React.FC<Props> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default VirtualTargetCard;
|
||||
export default memo(VirtualTargetCard);
|
||||
|
||||
@@ -37,6 +37,8 @@ interface Props {
|
||||
fillHeight?: boolean;
|
||||
/** 미지정 시 전략 DSL 대표 분봉 사용 */
|
||||
chartTimeframe?: Timeframe;
|
||||
/** false — 뷰포트 밖 등: STOMP·히스토리 구독 중지 (기본 true) */
|
||||
chartStreamEnabled?: boolean;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
@@ -52,6 +54,7 @@ const VirtualTargetCardChart: React.FC<Props> = ({
|
||||
chartPaneSeparator,
|
||||
fillHeight = false,
|
||||
chartTimeframe,
|
||||
chartStreamEnabled = true,
|
||||
}) => {
|
||||
const { getParams, getVisualConfig } = useIndicatorSettings();
|
||||
const tfOptions = useMemo(() => resolveStrategyTimeframes(strategy), [strategy]);
|
||||
@@ -120,6 +123,7 @@ const VirtualTargetCardChart: React.FC<Props> = ({
|
||||
}, [applyRealtimeBar]);
|
||||
|
||||
const useUpbit = isUpbitMarket(market);
|
||||
const streamActive = useUpbit && chartStreamEnabled;
|
||||
|
||||
const { bars, barsMarket, isLoading } = useChartRealtimeData(
|
||||
market,
|
||||
@@ -127,15 +131,15 @@ const VirtualTargetCardChart: React.FC<Props> = ({
|
||||
useMemo(() => ({
|
||||
onTickUpdate: handleTickUpdate,
|
||||
onNewCandle: handleNewCandle,
|
||||
enabled: useUpbit,
|
||||
enabled: streamActive,
|
||||
source: chartRealtimeSource,
|
||||
}), [handleTickUpdate, handleNewCandle, useUpbit, chartRealtimeSource]),
|
||||
}), [handleTickUpdate, handleNewCandle, streamActive, chartRealtimeSource]),
|
||||
);
|
||||
|
||||
const { isLoadingMore } = useHistoryLoader({
|
||||
symbol: market,
|
||||
timeframe,
|
||||
isUpbit: useUpbit,
|
||||
isUpbit: streamActive,
|
||||
managerRef,
|
||||
});
|
||||
|
||||
@@ -199,12 +203,12 @@ const VirtualTargetCardChart: React.FC<Props> = ({
|
||||
}, [barsReady, applyPaneHeights, requestChartReload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!useUpbit) return;
|
||||
if (!streamActive) return;
|
||||
void pinCandleWatch(market, timeframeToCandleType(timeframe));
|
||||
if (timeframeToCandleType(timeframe) !== '1m') {
|
||||
void pinCandleWatch(market, '1m');
|
||||
}
|
||||
}, [market, timeframe, useUpbit, running]);
|
||||
}, [market, timeframe, streamActive, running]);
|
||||
|
||||
return (
|
||||
<div className={`vtd-card-chart-panel${fillHeight ? ' vtd-card-chart-panel--fill' : ''}`}>
|
||||
@@ -239,7 +243,7 @@ const VirtualTargetCardChart: React.FC<Props> = ({
|
||||
)}
|
||||
<TradingChart
|
||||
key={`${market}-${timeframe}-${chartReloadTick}`}
|
||||
chartVisible
|
||||
chartVisible={chartStreamEnabled}
|
||||
bars={bars}
|
||||
barsMarket={barsMarket}
|
||||
market={market}
|
||||
|
||||
@@ -74,8 +74,8 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
||||
}, [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],
|
||||
() => `${viewMode}:${globalDisplayMode}:${targets.map(t => t.market).join(',')}`,
|
||||
[viewMode, globalDisplayMode, targets],
|
||||
);
|
||||
const [cardDisplayOverrides, setCardDisplayOverrides] = useState<Record<string, VirtualCardDisplayMode>>({});
|
||||
const [focusMarket, setFocusMarket] = useState<string | null>(null);
|
||||
|
||||
@@ -638,7 +638,7 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
|
||||
setDetailCentered(false);
|
||||
}, []);
|
||||
|
||||
const value: TradeNotificationContextValue = {
|
||||
const value = useMemo<TradeNotificationContextValue>(() => ({
|
||||
toastNotifications,
|
||||
allNotifications,
|
||||
unreadCount,
|
||||
@@ -657,7 +657,26 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
|
||||
detailCentered,
|
||||
openDetail,
|
||||
closeDetail,
|
||||
};
|
||||
}), [
|
||||
toastNotifications,
|
||||
allNotifications,
|
||||
unreadCount,
|
||||
addNotification,
|
||||
dismissToast,
|
||||
dismissToasts,
|
||||
dismissAllToasts,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
deleteNotification,
|
||||
deleteNotifications,
|
||||
deleteUnreadNotifications,
|
||||
deleteAllNotifications,
|
||||
refreshHistory,
|
||||
detailSignal,
|
||||
detailCentered,
|
||||
openDetail,
|
||||
closeDetail,
|
||||
]);
|
||||
|
||||
return (
|
||||
<TradeNotificationContext.Provider value={value}>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export interface UseIntersectionVisibleOptions {
|
||||
root?: Element | Document | null;
|
||||
rootMargin?: string;
|
||||
threshold?: number | number[];
|
||||
/** true — 포커스·전체화면 등 항상 활성 */
|
||||
initialVisible?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 요소가 뷰포트(또는 root)에 들어왔는지 — 차트·폴링 gating 용
|
||||
*/
|
||||
export function useIntersectionVisible(
|
||||
options: UseIntersectionVisibleOptions = {},
|
||||
): { ref: (node: HTMLElement | null) => void; visible: boolean } {
|
||||
const {
|
||||
root = null,
|
||||
rootMargin = '100px 0px',
|
||||
threshold = 0.05,
|
||||
initialVisible = false,
|
||||
} = options;
|
||||
const [element, setElement] = useState<HTMLElement | null>(null);
|
||||
const [visible, setVisible] = useState(initialVisible);
|
||||
|
||||
const ref = useCallback((node: HTMLElement | null) => {
|
||||
setElement(node);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!element) return;
|
||||
const io = new IntersectionObserver(
|
||||
entries => {
|
||||
setVisible(entries[0]?.isIntersecting ?? false);
|
||||
},
|
||||
{ root, rootMargin, threshold },
|
||||
);
|
||||
io.observe(element);
|
||||
return () => io.disconnect();
|
||||
}, [element, root, rootMargin, threshold]);
|
||||
|
||||
return { ref, visible };
|
||||
}
|
||||
Reference in New Issue
Block a user