"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { CandlestickSeries, ColorType, HistogramSeries, createChart, type IChartApi, type ISeriesApi, type Time, } from "lightweight-charts"; import { ChevronDown } from "lucide-react"; import { useTheme } from "next-themes"; import { toast } from "sonner"; import { fetchStockChart } from "@/features/trade/apis/kis-stock.api"; import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store"; import type { DashboardChartTimeframe, DashboardRealtimeTradeTick, StockCandlePoint, } from "@/features/trade/types/trade.types"; import { cn } from "@/lib/utils"; import { type ChartBar, formatKstCrosshairTime, formatKstTickMark, formatPrice, formatSignedPercent, isMinuteTimeframe, mergeBars, normalizeCandles, toRealtimeTickBar, upsertRealtimeBar, } from "./chart-utils"; import { areBarsEqual, type ChartPalette, 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"; interface StockLineChartProps { symbol?: string; candles: StockCandlePoint[]; credentials?: KisRuntimeCredentials | null; latestTick?: DashboardRealtimeTradeTick | null; } /** * @description TradingView 스타일 캔들 차트를 렌더링하고, timeframe별 KIS 차트 API를 조회합니다. * @see features/trade/apis/kis-stock.api.ts fetchStockChart * @see lib/kis/domestic.ts getDomesticChart */ export function StockLineChart({ symbol, candles, credentials, latestTick, }: StockLineChartProps) { const { resolvedTheme } = useTheme(); const containerRef = useRef(null); const chartRef = useRef(null); const candleSeriesRef = useRef | null>(null); const volumeSeriesRef = useRef | null>(null); const [timeframe, setTimeframe] = useState("1d"); const [isMinuteDropdownOpen, setIsMinuteDropdownOpen] = useState(false); const [bars, setBars] = useState([]); const [nextCursor, setNextCursor] = useState(null); const [isLoading, setIsLoading] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false); const [isChartReady, setIsChartReady] = useState(false); const lastRealtimeKeyRef = useRef(""); const lastRealtimeAppliedAtRef = useRef(0); const chartPaletteRef = useRef(DEFAULT_CHART_PALETTE); const renderableBarsRef = useRef([]); const initialThemeModeRef = useRef<"light" | "dark">("light"); const activeThemeMode: "light" | "dark" = resolvedTheme === "dark" ? "dark" : resolvedTheme === "light" ? "light" : typeof document !== "undefined" && document.documentElement.classList.contains("dark") ? "dark" : "light"; useEffect(() => { initialThemeModeRef.current = activeThemeMode; }, [activeThemeMode]); // 복수 이벤트에서 중복 로드를 막기 위한 ref 상태 const loadingMoreRef = useRef(false); const loadMoreHandlerRef = useRef<() => Promise>(async () => {}); const initialLoadCompleteRef = useRef(false); const pendingFitContentRef = useRef(false); const nextCursorRef = useRef(null); const autoFillLeftGapRef = useRef(false); // API 오류 시 fallback 용도로 유지 const latestCandlesRef = useRef(candles); useEffect(() => { 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; const changeRate = prevClose > 0 ? (change / prevClose) * 100 : 0; const renderableBars = useMemo(() => { const dedup = new Map(); for (const bar of bars) { if ( !Number.isFinite(bar.time) || !Number.isFinite(bar.open) || !Number.isFinite(bar.high) || !Number.isFinite(bar.low) || !Number.isFinite(bar.close) || bar.close <= 0 ) { continue; } dedup.set(bar.time, bar); } return [...dedup.values()].sort((a, b) => a.time - b.time); }, [bars]); useEffect(() => { renderableBarsRef.current = renderableBars; }, [renderableBars]); /** * @description lightweight-charts 시리즈 데이터에 안전한 OHLCV 값을 주입합니다. * @see features/trade/components/chart/StockLineChart.tsx renderableBars useMemo */ const setSeriesData = useCallback((nextBars: ChartBar[]) => { const candleSeries = candleSeriesRef.current; const volumeSeries = volumeSeriesRef.current; if (!candleSeries || !volumeSeries) return; try { candleSeries.setData( nextBars.map((bar) => ({ time: bar.time, open: bar.open, high: bar.high, low: bar.low, close: bar.close, })), ); volumeSeries.setData( nextBars.map((bar) => ({ time: bar.time, value: Number.isFinite(bar.volume) ? bar.volume : 0, color: bar.close >= bar.open ? "rgba(239,68,68,0.45)" : chartPaletteRef.current.volumeDownColor, })), ); } catch (error) { if (process.env.NODE_ENV !== "production") { console.error("Failed to render chart series data:", error); } } }, []); /** * @description 좌측 스크롤 시 cursor 기반 과거 캔들을 추가 로드합니다. * @see lib/kis/domestic.ts getDomesticChart cursor */ const handleLoadMore = useCallback(async () => { if (!symbol || !credentials || !nextCursor || loadingMoreRef.current) return; loadingMoreRef.current = true; setIsLoadingMore(true); try { const response = await fetchStockChart( symbol, timeframe, credentials, nextCursor, ); const olderBars = normalizeCandles(response.candles, timeframe); setBars((prev) => mergeBars(olderBars, prev)); setNextCursor( response.hasMore && response.nextCursor && response.nextCursor !== nextCursor ? response.nextCursor : null, ); } catch (error) { const message = error instanceof Error ? error.message : "과거 차트 데이터를 불러오지 못했습니다."; toast.error(message); } finally { loadingMoreRef.current = false; setIsLoadingMore(false); } }, [credentials, nextCursor, symbol, timeframe]); useEffect(() => { 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((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; }, [symbol, timeframe]); useEffect(() => { const container = containerRef.current; if (!container || chartRef.current) return; // 브랜드 색상은 globals.css의 CSS 변수에서 읽어 차트(canvas)에도 동일하게 적용합니다. const palette = getChartPaletteFromCssVars(initialThemeModeRef.current); chartPaletteRef.current = palette; const chart = createChart(container, { width: Math.max(container.clientWidth, 320), height: Math.max(container.clientHeight, CHART_MIN_HEIGHT), layout: { background: { type: ColorType.Solid, color: palette.backgroundColor }, textColor: palette.textColor, attributionLogo: true, }, localization: { locale: "ko-KR", timeFormatter: formatKstCrosshairTime, }, rightPriceScale: { borderColor: palette.borderColor, scaleMargins: { top: 0.08, bottom: 0.2, }, }, grid: { vertLines: { color: palette.gridColor }, horzLines: { color: palette.gridColor }, }, crosshair: { vertLine: { color: palette.crosshairColor, width: 1, style: 2 }, horzLine: { color: palette.crosshairColor, width: 1, style: 2 }, }, timeScale: { borderColor: palette.borderColor, timeVisible: true, secondsVisible: false, rightOffset: 4, barSpacing: 6, minBarSpacing: 1, rightBarStaysOnScroll: true, tickMarkFormatter: formatKstTickMark, }, handleScroll: { mouseWheel: true, pressedMouseMove: true, }, handleScale: { mouseWheel: true, pinch: true, axisPressedMouseMove: true, }, }); const candleSeries = chart.addSeries(CandlestickSeries, { upColor: UP_COLOR, downColor: palette.downColor, wickUpColor: UP_COLOR, wickDownColor: palette.downColor, borderUpColor: UP_COLOR, borderDownColor: palette.downColor, priceLineVisible: true, lastValueVisible: true, }); const volumeSeries = chart.addSeries(HistogramSeries, { priceScaleId: "volume", priceLineVisible: false, lastValueVisible: false, base: 0, }); chart.priceScale("volume").applyOptions({ scaleMargins: { top: 0.78, bottom: 0, }, borderVisible: false, }); let scrollTimeout: number | undefined; const handleVisibleLogicalRangeChange = (range: { from: number; to: number; } | null) => { if (!range || !initialLoadCompleteRef.current) 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; volumeSeriesRef.current = volumeSeries; setIsChartReady(true); const resizeObserver = new ResizeObserver(() => { chart.resize( Math.max(container.clientWidth, 320), Math.max(container.clientHeight, CHART_MIN_HEIGHT), ); }); resizeObserver.observe(container); const rafId = window.requestAnimationFrame(() => { chart.resize( Math.max(container.clientWidth, 320), Math.max(container.clientHeight, CHART_MIN_HEIGHT), ); }); return () => { if (scrollTimeout !== undefined) window.clearTimeout(scrollTimeout); chart .timeScale() .unsubscribeVisibleLogicalRangeChange(handleVisibleLogicalRangeChange); window.cancelAnimationFrame(rafId); resizeObserver.disconnect(); chart.remove(); chartRef.current = null; candleSeriesRef.current = null; volumeSeriesRef.current = null; setIsChartReady(false); }; }, []); useEffect(() => { const chart = chartRef.current; const candleSeries = candleSeriesRef.current; if (!chart || !candleSeries) return; const palette = getChartPaletteFromCssVars(activeThemeMode); chartPaletteRef.current = palette; chart.applyOptions({ layout: { background: { type: ColorType.Solid, color: palette.backgroundColor }, textColor: palette.textColor, }, rightPriceScale: { borderColor: palette.borderColor }, grid: { vertLines: { color: palette.gridColor }, horzLines: { color: palette.gridColor }, }, crosshair: { vertLine: { color: palette.crosshairColor, width: 1, style: 2 }, horzLine: { color: palette.crosshairColor, width: 1, style: 2 }, }, timeScale: { borderColor: palette.borderColor }, }); candleSeries.applyOptions({ downColor: palette.downColor, wickDownColor: palette.downColor, borderDownColor: palette.downColor, }); setSeriesData(renderableBarsRef.current); }, [activeThemeMode, setSeriesData]); useEffect(() => { if (symbol && credentials) return; // 인증 전/종목 미선택 상태는 overview 캔들로 fallback setBars(normalizeCandles(candles, "1d")); setNextCursor(null); }, [candles, credentials, symbol]); useEffect(() => { if (!symbol || !credentials) return; initialLoadCompleteRef.current = false; pendingFitContentRef.current = true; autoFillLeftGapRef.current = false; let disposed = false; let initialLoadTimer: number | null = null; const load = async () => { setIsLoading(true); try { const firstPage = await fetchStockChart(symbol, timeframe, credentials); if (disposed) return; let mergedBars = normalizeCandles(firstPage.candles, timeframe); let resolvedNextCursor = firstPage.hasMore ? firstPage.nextCursor : null; // 분봉은 시간프레임별 목표 봉 수까지 순차 조회해 초기 과거 가시성을 보강합니다. 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 < maxPrefetchPages && Date.now() - prefetchStartedAt < INITIAL_MINUTE_PREFETCH_BUDGET_MS && mergedBars.length < targetBars ) { try { const olderPage = await fetchStockChart( symbol, timeframe, credentials, minuteCursor, ); const olderBars = normalizeCandles(olderPage.candles, timeframe); mergedBars = mergeBars(olderBars, mergedBars); const nextMinuteCursor = olderPage.hasMore ? olderPage.nextCursor : null; resolvedNextCursor = nextMinuteCursor; minuteCursor = nextMinuteCursor && nextMinuteCursor !== minuteCursor ? nextMinuteCursor : null; extraPageCount += 1; } catch { // 추가 페이지 실패는 치명적이지 않으므로 현재 데이터는 유지합니다. minuteCursor = null; } } } setBars(mergedBars); setNextCursor(resolvedNextCursor); initialLoadTimer = window.setTimeout(() => { if (!disposed) initialLoadCompleteRef.current = true; }, 350); } catch (error) { if (disposed) return; const message = error instanceof Error ? error.message : "차트 조회 중 오류가 발생했습니다."; toast.error(message); setBars(normalizeCandles(latestCandlesRef.current, timeframe)); setNextCursor(null); } finally { if (!disposed) setIsLoading(false); } }; void load(); return () => { disposed = true; if (initialLoadTimer !== null) { window.clearTimeout(initialLoadTimer); } }; }, [credentials, symbol, timeframe]); useEffect(() => { if (!isChartReady) return; setSeriesData(renderableBars); if (renderableBars.length === 0) return; if (pendingFitContentRef.current) { chartRef.current?.timeScale().fitContent(); pendingFitContentRef.current = false; } else if (!initialLoadCompleteRef.current) { chartRef.current?.timeScale().fitContent(); } if (nextCursorRef.current) { void fillLeftWhitespaceIfNeeded(); } }, [ fillLeftWhitespaceIfNeeded, isChartReady, renderableBars, setSeriesData, timeframe, ]); /** * @description WebSocket 체결 틱을 현재 timeframe 캔들에 반영합니다. * @see features/trade/hooks/useKisTradeWebSocket.ts latestTick * @see features/trade/components/chart/chart-utils.ts toRealtimeTickBar */ useEffect(() => { if (!latestTick) return; if (renderableBarsRef.current.length === 0) return; const dedupeKey = `${timeframe}:${latestTick.tickTime}:${latestTick.price}:${latestTick.tradeVolume}`; if (lastRealtimeKeyRef.current === dedupeKey) return; const realtimeBar = toRealtimeTickBar(latestTick, timeframe); if (!realtimeBar) return; lastRealtimeKeyRef.current = dedupeKey; lastRealtimeAppliedAtRef.current = Date.now(); setBars((prev) => upsertRealtimeBar(prev, realtimeBar)); }, [latestTick, timeframe]); /** * @description 분봉(1m/5m/10m/15m/30m/1h)에서는 WS 공백 상황을 대비해 최신 페이지를 주기적으로 동기화합니다. * @see features/trade/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) => { const merged = mergeBars(prev, recentBars); return areBarsEqual(prev, merged) ? prev : merged; }); } 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) { return "차트 데이터를 불러오는 중입니다."; } if (bars.length === 0) { return "차트 데이터가 없습니다."; } if (renderableBars.length === 0) { return "차트 데이터 형식이 올바르지 않습니다."; } return null; })(); return (
{/* ========== CHART TOOLBAR ========== */}
{isMinuteDropdownOpen && (
{MINUTE_TIMEFRAMES.map((item) => ( ))}
)}
{PERIOD_TIMEFRAMES.map((item) => ( ))} {isLoadingMore && ( 과거 데이터 로딩 중... )}
O {formatPrice(latest?.open ?? 0)} H {formatPrice(latest?.high ?? 0)}{" "} L {formatPrice(latest?.low ?? 0)} C{" "} = 0 ? "text-red-600" : "text-blue-600 dark:text-blue-400", )} > {formatPrice(latest?.close ?? 0)} ({formatSignedPercent(changeRate)} )
{/* ========== CHART BODY ========== */}
{statusMessage && (
{statusMessage}
)}
); }