차트 수정

This commit is contained in:
2026-02-11 11:18:15 +09:00
parent e5a518b211
commit 89bad1d141
13 changed files with 927 additions and 333 deletions

View File

@@ -16,24 +16,28 @@ import { fetchStockChart } from "@/features/dashboard/apis/kis-stock.api";
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
import type {
DashboardChartTimeframe,
DashboardRealtimeTradeTick,
StockCandlePoint,
} from "@/features/dashboard/types/dashboard.types";
import { cn } from "@/lib/utils";
import {
type ChartBar,
convertCandleToBar,
formatKstCrosshairTime,
formatKstTickMark,
formatPrice,
formatSignedPercent,
isMinuteTimeframe,
mergeBars,
normalizeCandles,
toRealtimeTickBar,
upsertRealtimeBar,
} from "./chart-utils";
const UP_COLOR = "#ef4444";
const DOWN_COLOR = "#2563eb";
const MINUTE_SYNC_INTERVAL_MS = 5000;
const REALTIME_STALE_THRESHOLD_MS = 12000;
// 분봉 드롭다운 옵션
const MINUTE_TIMEFRAMES: Array<{
value: DashboardChartTimeframe;
label: string;
@@ -43,7 +47,6 @@ const MINUTE_TIMEFRAMES: Array<{
{ value: "1h", label: "1시간" },
];
// 일봉 이상 개별 버튼
const PERIOD_TIMEFRAMES: Array<{
value: DashboardChartTimeframe;
label: string;
@@ -52,23 +55,23 @@ const PERIOD_TIMEFRAMES: Array<{
{ value: "1w", label: "주" },
];
// ChartBar 타입은 chart-utils.ts에서 import
interface StockLineChartProps {
symbol?: string;
candles: StockCandlePoint[];
credentials?: KisRuntimeCredentials | null;
latestTick?: DashboardRealtimeTradeTick | null;
}
/**
* @description TradingView 스타일 캔들 차트 + 거래량 + 무한 과거 로딩
* @see https://tradingview.github.io/lightweight-charts/tutorials/demos/realtime-updates
* @see https://tradingview.github.io/lightweight-charts/tutorials/demos/infinite-history
* @description TradingView 스타일 캔들 차트를 렌더링하고, timeframe별 KIS 차트 API를 조회합니다.
* @see features/dashboard/apis/kis-stock.api.ts fetchStockChart
* @see lib/kis/domestic.ts getDomesticChart
*/
export function StockLineChart({
symbol,
candles,
credentials,
latestTick,
}: StockLineChartProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const chartRef = useRef<IChartApi | null>(null);
@@ -83,11 +86,14 @@ export function StockLineChart({
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [isChartReady, setIsChartReady] = useState(false);
const lastRealtimeKeyRef = useRef<string>("");
const lastRealtimeAppliedAtRef = useRef(0);
// 복수 이벤트에서 중복 로드를 막기 위한 ref 상태
const loadingMoreRef = useRef(false);
const loadMoreHandlerRef = useRef<() => Promise<void>>(async () => {});
const initialLoadCompleteRef = useRef(false);
// candles prop을 ref로 관리하여 useEffect 디펜던시에서 제거 (무한 페칭 방지)
// API 오류 시 fallback 용도로 유지
const latestCandlesRef = useRef(candles);
useEffect(() => {
latestCandlesRef.current = candles;
@@ -97,8 +103,10 @@ export function StockLineChart({
const prevClose = bars.length > 1 ? (bars.at(-2)?.close ?? 0) : 0;
const change = latest ? latest.close - prevClose : 0;
const changeRate = prevClose > 0 ? (change / prevClose) * 100 : 0;
const renderableBars = useMemo(() => {
const dedup = new Map<number, ChartBar>();
for (const bar of bars) {
if (
!Number.isFinite(bar.time) ||
@@ -110,23 +118,25 @@ export function StockLineChart({
) {
continue;
}
dedup.set(bar.time, bar);
}
return [...dedup.values()].sort((a, b) => a.time - b.time);
}, [bars]);
/**
* @description lightweight-charts 시리즈 데이터에 안전한 OHLCV 값을 주입합니다.
* @see features/dashboard/components/chart/StockLineChart.tsx renderableBars useMemo
*/
const setSeriesData = useCallback((nextBars: ChartBar[]) => {
const candleSeries = candleSeriesRef.current;
const volumeSeries = volumeSeriesRef.current;
if (!candleSeries || !volumeSeries) return;
// lightweight-charts는 시간 오름차순/유효 숫자 조건이 깨지면 렌더를 멈출 수 있어
// 전달 직전 데이터를 한 번 더 정리합니다.
const safeBars = nextBars;
try {
candleSeries.setData(
safeBars.map((bar) => ({
nextBars.map((bar) => ({
time: bar.time,
open: bar.open,
high: bar.high,
@@ -136,7 +146,7 @@ export function StockLineChart({
);
volumeSeries.setData(
safeBars.map((bar) => ({
nextBars.map((bar) => ({
time: bar.time,
value: Number.isFinite(bar.volume) ? bar.volume : 0,
color:
@@ -150,14 +160,16 @@ export function StockLineChart({
}
}, []);
/**
* @description 좌측 스크롤 시 cursor 기반 과거 캔들을 추가 로드합니다.
* @see lib/kis/domestic.ts getDomesticChart cursor
*/
const handleLoadMore = useCallback(async () => {
// 분봉은 당일 데이터만 제공되므로 과거 로딩 불가
if (isMinuteTimeframe(timeframe)) return;
if (!symbol || !credentials || !nextCursor || loadingMoreRef.current)
return;
if (!symbol || !credentials || !nextCursor || loadingMoreRef.current) return;
loadingMoreRef.current = true;
setIsLoadingMore(true);
try {
const response = await fetchStockChart(
symbol,
@@ -165,15 +177,16 @@ export function StockLineChart({
credentials,
nextCursor,
);
const older = normalizeCandles(response.candles, timeframe);
setBars((prev) => mergeBars(older, prev));
const olderBars = normalizeCandles(response.candles, timeframe);
setBars((prev) => mergeBars(olderBars, prev));
setNextCursor(response.hasMore ? response.nextCursor : null);
} catch (error) {
const msg =
const message =
error instanceof Error
? error.message
: "과거 차트 조회에 실패했습니다.";
toast.error(msg);
: "과거 차트 데이터를 불러오지 못했습니다.";
toast.error(message);
} finally {
loadingMoreRef.current = false;
setIsLoadingMore(false);
@@ -184,16 +197,18 @@ export function StockLineChart({
loadMoreHandlerRef.current = handleLoadMore;
}, [handleLoadMore]);
useEffect(() => {
lastRealtimeKeyRef.current = "";
lastRealtimeAppliedAtRef.current = 0;
}, [symbol, timeframe]);
useEffect(() => {
const container = containerRef.current;
if (!container || chartRef.current) return;
const initialWidth = Math.max(container.clientWidth, 320);
const initialHeight = Math.max(container.clientHeight, 340);
const chart = createChart(container, {
width: initialWidth,
height: initialHeight,
width: Math.max(container.clientWidth, 320),
height: Math.max(container.clientHeight, 340),
layout: {
background: { type: ColorType.Solid, color: "#ffffff" },
textColor: "#475569",
@@ -201,6 +216,7 @@ export function StockLineChart({
},
localization: {
locale: "ko-KR",
timeFormatter: formatKstCrosshairTime,
},
rightPriceScale: {
borderColor: "#e2e8f0",
@@ -222,6 +238,7 @@ export function StockLineChart({
timeVisible: true,
secondsVisible: false,
rightOffset: 2,
tickMarkFormatter: formatKstTickMark,
},
handleScroll: {
mouseWheel: true,
@@ -260,19 +277,15 @@ export function StockLineChart({
borderVisible: false,
});
// 스크롤 디바운스 타이머
let scrollTimeout: NodeJS.Timeout | null = null;
let scrollTimeout: number | undefined;
chart.timeScale().subscribeVisibleLogicalRangeChange((range) => {
if (!range) return;
if (!range || !initialLoadCompleteRef.current) return;
if (range.from >= 10) return;
// 분봉은 당일 데이터만 제공되므로 무한 스크롤 비활성화
if (range.from < 10 && initialLoadCompleteRef.current) {
if (scrollTimeout) clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
void loadMoreHandlerRef.current();
}, 300);
}
if (scrollTimeout !== undefined) window.clearTimeout(scrollTimeout);
scrollTimeout = window.setTimeout(() => {
void loadMoreHandlerRef.current();
}, 250);
});
chartRef.current = chart;
@@ -281,20 +294,22 @@ export function StockLineChart({
setIsChartReady(true);
const resizeObserver = new ResizeObserver(() => {
const nextWidth = Math.max(container.clientWidth, 320);
const nextHeight = Math.max(container.clientHeight, 340);
chart.resize(nextWidth, nextHeight);
chart.resize(
Math.max(container.clientWidth, 320),
Math.max(container.clientHeight, 340),
);
});
resizeObserver.observe(container);
// 첫 렌더 직후 부모 레이아웃 계산이 끝난 시점에 한 번 더 사이즈를 맞춥니다.
const rafId = window.requestAnimationFrame(() => {
const nextWidth = Math.max(container.clientWidth, 320);
const nextHeight = Math.max(container.clientHeight, 340);
chart.resize(nextWidth, nextHeight);
chart.resize(
Math.max(container.clientWidth, 320),
Math.max(container.clientHeight, 340),
);
});
return () => {
if (scrollTimeout !== undefined) window.clearTimeout(scrollTimeout);
window.cancelAnimationFrame(rafId);
resizeObserver.disconnect();
chart.remove();
@@ -307,6 +322,8 @@ export function StockLineChart({
useEffect(() => {
if (symbol && credentials) return;
// 인증 전/종목 미선택 상태는 overview 캔들로 fallback
setBars(normalizeCandles(candles, "1d"));
setNextCursor(null);
}, [candles, credentials, symbol]);
@@ -314,33 +331,56 @@ export function StockLineChart({
useEffect(() => {
if (!symbol || !credentials) return;
// 초기 로딩 보호 플래그 초기화 (타임프레임/종목 변경 시)
initialLoadCompleteRef.current = false;
let disposed = false;
const load = async () => {
setIsLoading(true);
try {
const response = await fetchStockChart(symbol, timeframe, credentials);
const firstPage = await fetchStockChart(symbol, timeframe, credentials);
if (disposed) return;
const normalized = normalizeCandles(response.candles, timeframe);
setBars(normalized);
setNextCursor(response.hasMore ? response.nextCursor : null);
let mergedBars = normalizeCandles(firstPage.candles, timeframe);
let resolvedNextCursor = firstPage.hasMore ? firstPage.nextCursor : null;
// 초기 로딩 완료 후 500ms 지연 후 무한 스크롤 활성화
// (fitContent 후 range가 0이 되어 즉시 트리거되는 것 방지)
setTimeout(() => {
if (!disposed) {
initialLoadCompleteRef.current = true;
// 분봉은 기본 2페이지를 붙여서 "당일만 보이는" 느낌을 줄입니다.
if (
isMinuteTimeframe(timeframe) &&
firstPage.hasMore &&
firstPage.nextCursor
) {
try {
const secondPage = await fetchStockChart(
symbol,
timeframe,
credentials,
firstPage.nextCursor,
);
const olderBars = normalizeCandles(secondPage.candles, timeframe);
mergedBars = mergeBars(olderBars, mergedBars);
resolvedNextCursor = secondPage.hasMore ? secondPage.nextCursor : null;
} catch {
// 2페이지 실패는 치명적이지 않으므로 1페이지 데이터는 유지합니다.
}
}, 500);
}
setBars(mergedBars);
setNextCursor(resolvedNextCursor);
window.setTimeout(() => {
if (!disposed) initialLoadCompleteRef.current = true;
}, 350);
} catch (error) {
if (disposed) return;
const message =
error instanceof Error ? error.message : "차트 조회에 실패했습니다.";
error instanceof Error
? error.message
: "차트 조회 중 오류가 발생했습니다.";
toast.error(message);
// 에러 발생 시 fallback으로 props로 전달된 candles 사용
setBars(normalizeCandles(latestCandlesRef.current, timeframe));
setNextCursor(null);
} finally {
@@ -349,6 +389,7 @@ export function StockLineChart({
};
void load();
return () => {
disposed = true;
};
@@ -356,37 +397,84 @@ export function StockLineChart({
useEffect(() => {
if (!isChartReady) return;
setSeriesData(renderableBars);
// 초기 로딩 시에만 fitContent 수행
setSeriesData(renderableBars);
if (!initialLoadCompleteRef.current && renderableBars.length > 0) {
chartRef.current?.timeScale().fitContent();
}
}, [isChartReady, renderableBars, setSeriesData]);
const latestRealtime = useMemo(() => candles.at(-1), [candles]);
/**
* @description WebSocket 체결 틱을 현재 timeframe 캔들에 반영합니다.
* @see features/dashboard/hooks/useKisTradeWebSocket.ts latestTick
* @see features/dashboard/components/chart/chart-utils.ts toRealtimeTickBar
*/
useEffect(() => {
if (!latestRealtime || bars.length === 0) return;
if (timeframe === "1w" && !latestRealtime.timestamp && !latestRealtime.time)
return;
if (!latestTick) return;
if (bars.length === 0) return;
const key = `${latestRealtime.time}-${latestRealtime.price}-${latestRealtime.volume ?? 0}`;
if (lastRealtimeKeyRef.current === key) return;
lastRealtimeKeyRef.current = key;
const dedupeKey = `${timeframe}:${latestTick.tickTime}:${latestTick.price}:${latestTick.tradeVolume}`;
if (lastRealtimeKeyRef.current === dedupeKey) return;
const nextBar = convertCandleToBar(latestRealtime, timeframe);
if (!nextBar) return;
const realtimeBar = toRealtimeTickBar(latestTick, timeframe);
if (!realtimeBar) return;
setBars((prev) => upsertRealtimeBar(prev, nextBar));
}, [bars.length, candles, latestRealtime, timeframe]);
lastRealtimeKeyRef.current = dedupeKey;
lastRealtimeAppliedAtRef.current = Date.now();
setBars((prev) => upsertRealtimeBar(prev, realtimeBar));
}, [bars.length, latestTick, timeframe]);
/**
* @description 분봉(1m/30m/1h)에서는 WS 공백 상황을 대비해 최신 페이지를 주기적으로 동기화합니다.
* @see features/dashboard/apis/kis-stock.api.ts fetchStockChart
* @see lib/kis/domestic.ts getDomesticChart
*/
useEffect(() => {
if (!symbol || !credentials) return;
if (!isMinuteTimeframe(timeframe)) return;
let disposed = false;
const syncLatestMinuteBars = async () => {
const now = Date.now();
const isRealtimeFresh =
now - lastRealtimeAppliedAtRef.current < REALTIME_STALE_THRESHOLD_MS;
if (isRealtimeFresh) return;
try {
const response = await fetchStockChart(symbol, timeframe, credentials);
if (disposed) return;
const latestPageBars = normalizeCandles(response.candles, timeframe);
const recentBars = latestPageBars.slice(-10);
if (recentBars.length === 0) return;
setBars((prev) => mergeBars(prev, recentBars));
} catch {
// 폴링 실패는 치명적이지 않으므로 조용히 다음 주기에서 재시도합니다.
}
};
const intervalId = window.setInterval(() => {
void syncLatestMinuteBars();
}, MINUTE_SYNC_INTERVAL_MS);
return () => {
disposed = true;
window.clearInterval(intervalId);
};
}, [credentials, symbol, timeframe]);
// 상태 메시지 결정 (오버레이로 표시)
const statusMessage = (() => {
if (isLoading && bars.length === 0)
if (isLoading && bars.length === 0) {
return "차트 데이터를 불러오는 중입니다.";
if (bars.length === 0) return "차트 데이터가 없습니다.";
if (renderableBars.length === 0)
return "차트 데이터 형식이 올바르지 않아 렌더링할 수 없습니다.";
}
if (bars.length === 0) {
return "차트 데이터 없습니다.";
}
if (renderableBars.length === 0) {
return "차트 데이터 형식이 올바르지 않습니다.";
}
return null;
})();
@@ -395,24 +483,24 @@ export function StockLineChart({
{/* ========== CHART TOOLBAR ========== */}
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200 px-2 py-2 sm:px-3">
<div className="flex flex-wrap items-center gap-1 text-xs sm:text-sm">
{/* 분봉 드롭다운 */}
<div className="relative">
<button
type="button"
onClick={() => setIsMinuteDropdownOpen((v) => !v)}
onClick={() => setIsMinuteDropdownOpen((prev) => !prev)}
onBlur={() =>
setTimeout(() => setIsMinuteDropdownOpen(false), 200)
window.setTimeout(() => setIsMinuteDropdownOpen(false), 200)
}
className={cn(
"flex items-center gap-1 rounded px-2 py-1 text-slate-600 transition-colors hover:bg-slate-100",
MINUTE_TIMEFRAMES.some((t) => t.value === timeframe) &&
MINUTE_TIMEFRAMES.some((item) => item.value === timeframe) &&
"bg-brand-100 font-semibold text-brand-700",
)}
>
{MINUTE_TIMEFRAMES.find((t) => t.value === timeframe)?.label ??
"분봉"}
{MINUTE_TIMEFRAMES.find((item) => item.value === timeframe)
?.label ?? "분봉"}
<ChevronDown className="h-3 w-3" />
</button>
{isMinuteDropdownOpen && (
<div className="absolute left-0 top-full z-10 mt-1 rounded border border-slate-200 bg-white shadow-lg">
{MINUTE_TIMEFRAMES.map((item) => (
@@ -436,7 +524,6 @@ export function StockLineChart({
)}
</div>
{/* 일/주 버튼 */}
{PERIOD_TIMEFRAMES.map((item) => (
<button
key={item.value}
@@ -451,28 +538,27 @@ export function StockLineChart({
{item.label}
</button>
))}
{isLoadingMore && (
<span className="ml-2 text-[11px] text-muted-foreground">
...
...
</span>
)}
</div>
<div className="text-[11px] text-slate-600 sm:text-xs">
O {formatPrice(latest?.open ?? 0)} H {formatPrice(latest?.high ?? 0)}{" "}
L {formatPrice(latest?.low ?? 0)} C{" "}
O {formatPrice(latest?.open ?? 0)} H {formatPrice(latest?.high ?? 0)} L{" "}
{formatPrice(latest?.low ?? 0)} C{" "}
<span className={cn(change >= 0 ? "text-red-600" : "text-blue-600")}>
{formatPrice(latest?.close ?? 0)} ({formatSignedPercent(changeRate)}
)
{formatPrice(latest?.close ?? 0)} ({formatSignedPercent(changeRate)})
</span>
</div>
</div>
{/* ========== CHART BODY ========== */}
<div className="relative min-h-0 flex-1 overflow-hidden">
{/* 차트 캔버스 컨테이너 - 항상 렌더링 */}
<div ref={containerRef} className="h-full w-full" />
{/* 상태 메시지 오버레이 */}
{statusMessage && (
<div className="absolute inset-0 flex items-center justify-center bg-white/80 text-sm text-muted-foreground">
{statusMessage}