전체적인 리팩토링
This commit is contained in:
@@ -39,10 +39,14 @@ import {
|
||||
CHART_MIN_HEIGHT,
|
||||
DEFAULT_CHART_PALETTE,
|
||||
getChartPaletteFromCssVars,
|
||||
HISTORY_LOAD_TRIGGER_BARS_BEFORE,
|
||||
INITIAL_MINUTE_PREFETCH_BUDGET_MS,
|
||||
MINUTE_SYNC_INTERVAL_MS,
|
||||
MINUTE_TIMEFRAMES,
|
||||
PERIOD_TIMEFRAMES,
|
||||
REALTIME_STALE_THRESHOLD_MS,
|
||||
resolveInitialMinutePrefetchPages,
|
||||
resolveInitialMinuteTargetBars,
|
||||
UP_COLOR,
|
||||
} from "./stock-line-chart-meta";
|
||||
|
||||
@@ -101,6 +105,9 @@ export function StockLineChart({
|
||||
const loadingMoreRef = useRef(false);
|
||||
const loadMoreHandlerRef = useRef<() => Promise<void>>(async () => {});
|
||||
const initialLoadCompleteRef = useRef(false);
|
||||
const pendingFitContentRef = useRef(false);
|
||||
const nextCursorRef = useRef<string | null>(null);
|
||||
const autoFillLeftGapRef = useRef(false);
|
||||
|
||||
// API 오류 시 fallback 용도로 유지
|
||||
const latestCandlesRef = useRef(candles);
|
||||
@@ -108,6 +115,10 @@ export function StockLineChart({
|
||||
latestCandlesRef.current = candles;
|
||||
}, [candles]);
|
||||
|
||||
useEffect(() => {
|
||||
nextCursorRef.current = nextCursor;
|
||||
}, [nextCursor]);
|
||||
|
||||
const latest = bars.at(-1);
|
||||
const prevClose = bars.length > 1 ? (bars.at(-2)?.close ?? 0) : 0;
|
||||
const change = latest ? latest.close - prevClose : 0;
|
||||
@@ -196,7 +207,13 @@ export function StockLineChart({
|
||||
|
||||
const olderBars = normalizeCandles(response.candles, timeframe);
|
||||
setBars((prev) => mergeBars(olderBars, prev));
|
||||
setNextCursor(response.hasMore ? response.nextCursor : null);
|
||||
setNextCursor(
|
||||
response.hasMore &&
|
||||
response.nextCursor &&
|
||||
response.nextCursor !== nextCursor
|
||||
? response.nextCursor
|
||||
: null,
|
||||
);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
@@ -213,6 +230,58 @@ export function StockLineChart({
|
||||
loadMoreHandlerRef.current = handleLoadMore;
|
||||
}, [handleLoadMore]);
|
||||
|
||||
const fillLeftWhitespaceIfNeeded = useCallback(async () => {
|
||||
if (!isMinuteTimeframe(timeframe)) return;
|
||||
if (autoFillLeftGapRef.current) return;
|
||||
if (loadingMoreRef.current) return;
|
||||
if (!nextCursorRef.current) return;
|
||||
|
||||
const chart = chartRef.current;
|
||||
const candleSeries = candleSeriesRef.current;
|
||||
if (!chart || !candleSeries) return;
|
||||
|
||||
autoFillLeftGapRef.current = true;
|
||||
const startedAt = Date.now();
|
||||
let rounds = 0;
|
||||
|
||||
try {
|
||||
while (
|
||||
rounds < 16 &&
|
||||
Date.now() - startedAt < INITIAL_MINUTE_PREFETCH_BUDGET_MS
|
||||
) {
|
||||
const range = chart.timeScale().getVisibleLogicalRange();
|
||||
if (!range) break;
|
||||
|
||||
const barsInfo = candleSeries.barsInLogicalRange(range);
|
||||
const hasLeftWhitespace =
|
||||
Boolean(
|
||||
barsInfo &&
|
||||
Number.isFinite(barsInfo.barsBefore) &&
|
||||
barsInfo.barsBefore < 0,
|
||||
) || false;
|
||||
|
||||
if (!hasLeftWhitespace) break;
|
||||
|
||||
const cursorBefore = nextCursorRef.current;
|
||||
if (!cursorBefore) break;
|
||||
|
||||
await loadMoreHandlerRef.current();
|
||||
rounds += 1;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
window.setTimeout(() => resolve(), 120);
|
||||
});
|
||||
|
||||
chart.timeScale().fitContent();
|
||||
|
||||
const cursorAfter = nextCursorRef.current;
|
||||
if (!cursorAfter || cursorAfter === cursorBefore) break;
|
||||
}
|
||||
} finally {
|
||||
autoFillLeftGapRef.current = false;
|
||||
}
|
||||
}, [timeframe]);
|
||||
|
||||
useEffect(() => {
|
||||
lastRealtimeKeyRef.current = "";
|
||||
lastRealtimeAppliedAtRef.current = 0;
|
||||
@@ -257,7 +326,10 @@ export function StockLineChart({
|
||||
borderColor: palette.borderColor,
|
||||
timeVisible: true,
|
||||
secondsVisible: false,
|
||||
rightOffset: 2,
|
||||
rightOffset: 4,
|
||||
barSpacing: 6,
|
||||
minBarSpacing: 1,
|
||||
rightBarStaysOnScroll: true,
|
||||
tickMarkFormatter: formatKstTickMark,
|
||||
},
|
||||
handleScroll: {
|
||||
@@ -298,15 +370,29 @@ export function StockLineChart({
|
||||
});
|
||||
|
||||
let scrollTimeout: number | undefined;
|
||||
chart.timeScale().subscribeVisibleLogicalRangeChange((range) => {
|
||||
const handleVisibleLogicalRangeChange = (range: {
|
||||
from: number;
|
||||
to: number;
|
||||
} | null) => {
|
||||
if (!range || !initialLoadCompleteRef.current) return;
|
||||
if (range.from >= 10) return;
|
||||
|
||||
const barsInfo = candleSeries.barsInLogicalRange(range);
|
||||
if (!barsInfo) return;
|
||||
if (
|
||||
Number.isFinite(barsInfo.barsBefore) &&
|
||||
barsInfo.barsBefore > HISTORY_LOAD_TRIGGER_BARS_BEFORE
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (scrollTimeout !== undefined) window.clearTimeout(scrollTimeout);
|
||||
scrollTimeout = window.setTimeout(() => {
|
||||
void loadMoreHandlerRef.current();
|
||||
}, 250);
|
||||
});
|
||||
};
|
||||
chart
|
||||
.timeScale()
|
||||
.subscribeVisibleLogicalRangeChange(handleVisibleLogicalRangeChange);
|
||||
|
||||
chartRef.current = chart;
|
||||
candleSeriesRef.current = candleSeries;
|
||||
@@ -330,6 +416,9 @@ export function StockLineChart({
|
||||
|
||||
return () => {
|
||||
if (scrollTimeout !== undefined) window.clearTimeout(scrollTimeout);
|
||||
chart
|
||||
.timeScale()
|
||||
.unsubscribeVisibleLogicalRangeChange(handleVisibleLogicalRangeChange);
|
||||
window.cancelAnimationFrame(rafId);
|
||||
resizeObserver.disconnect();
|
||||
chart.remove();
|
||||
@@ -386,6 +475,8 @@ export function StockLineChart({
|
||||
if (!symbol || !credentials) return;
|
||||
|
||||
initialLoadCompleteRef.current = false;
|
||||
pendingFitContentRef.current = true;
|
||||
autoFillLeftGapRef.current = false;
|
||||
let disposed = false;
|
||||
let initialLoadTimer: number | null = null;
|
||||
|
||||
@@ -401,16 +492,24 @@ export function StockLineChart({
|
||||
? firstPage.nextCursor
|
||||
: null;
|
||||
|
||||
// 분봉은 기본 3페이지까지 순차 조회해 오전 구간 누락을 줄입니다.
|
||||
// 분봉은 시간프레임별 목표 봉 수까지 순차 조회해 초기 과거 가시성을 보강합니다.
|
||||
if (
|
||||
isMinuteTimeframe(timeframe) &&
|
||||
firstPage.hasMore &&
|
||||
firstPage.nextCursor
|
||||
) {
|
||||
const targetBars = resolveInitialMinuteTargetBars(timeframe);
|
||||
const maxPrefetchPages = resolveInitialMinutePrefetchPages(timeframe);
|
||||
const prefetchStartedAt = Date.now();
|
||||
let minuteCursor: string | null = firstPage.nextCursor;
|
||||
let extraPageCount = 0;
|
||||
|
||||
while (minuteCursor && extraPageCount < 2) {
|
||||
while (
|
||||
minuteCursor &&
|
||||
extraPageCount < maxPrefetchPages &&
|
||||
Date.now() - prefetchStartedAt < INITIAL_MINUTE_PREFETCH_BUDGET_MS &&
|
||||
mergedBars.length < targetBars
|
||||
) {
|
||||
try {
|
||||
const olderPage = await fetchStockChart(
|
||||
symbol,
|
||||
@@ -421,10 +520,14 @@ export function StockLineChart({
|
||||
|
||||
const olderBars = normalizeCandles(olderPage.candles, timeframe);
|
||||
mergedBars = mergeBars(olderBars, mergedBars);
|
||||
resolvedNextCursor = olderPage.hasMore
|
||||
const nextMinuteCursor = olderPage.hasMore
|
||||
? olderPage.nextCursor
|
||||
: null;
|
||||
minuteCursor = olderPage.hasMore ? olderPage.nextCursor : null;
|
||||
resolvedNextCursor = nextMinuteCursor;
|
||||
minuteCursor =
|
||||
nextMinuteCursor && nextMinuteCursor !== minuteCursor
|
||||
? nextMinuteCursor
|
||||
: null;
|
||||
extraPageCount += 1;
|
||||
} catch {
|
||||
// 추가 페이지 실패는 치명적이지 않으므로 현재 데이터는 유지합니다.
|
||||
@@ -469,10 +572,25 @@ export function StockLineChart({
|
||||
if (!isChartReady) return;
|
||||
|
||||
setSeriesData(renderableBars);
|
||||
if (!initialLoadCompleteRef.current && renderableBars.length > 0) {
|
||||
if (renderableBars.length === 0) return;
|
||||
|
||||
if (pendingFitContentRef.current) {
|
||||
chartRef.current?.timeScale().fitContent();
|
||||
pendingFitContentRef.current = false;
|
||||
} else if (!initialLoadCompleteRef.current) {
|
||||
chartRef.current?.timeScale().fitContent();
|
||||
}
|
||||
}, [isChartReady, renderableBars, setSeriesData]);
|
||||
|
||||
if (nextCursorRef.current) {
|
||||
void fillLeftWhitespaceIfNeeded();
|
||||
}
|
||||
}, [
|
||||
fillLeftWhitespaceIfNeeded,
|
||||
isChartReady,
|
||||
renderableBars,
|
||||
setSeriesData,
|
||||
timeframe,
|
||||
]);
|
||||
|
||||
/**
|
||||
* @description WebSocket 체결 틱을 현재 timeframe 캔들에 반영합니다.
|
||||
@@ -495,7 +613,7 @@ export function StockLineChart({
|
||||
}, [latestTick, timeframe]);
|
||||
|
||||
/**
|
||||
* @description 분봉(1m/30m/1h)에서는 WS 공백 상황을 대비해 최신 페이지를 주기적으로 동기화합니다.
|
||||
* @description 분봉(1m/5m/10m/15m/30m/1h)에서는 WS 공백 상황을 대비해 최신 페이지를 주기적으로 동기화합니다.
|
||||
* @see features/trade/apis/kis-stock.api.ts fetchStockChart
|
||||
* @see lib/kis/domestic.ts getDomesticChart
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user