"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { CandlestickSeries, ColorType, HistogramSeries, createChart, type IChartApi, type ISeriesApi, type Time, type UTCTimestamp, } from "lightweight-charts"; import { ChevronDown } from "lucide-react"; import { toast } from "sonner"; import { fetchStockChart } from "@/features/dashboard/apis/kis-stock.api"; import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store"; import type { DashboardChartTimeframe, StockCandlePoint, } from "@/features/dashboard/types/dashboard.types"; import { cn } from "@/lib/utils"; const KRW_FORMATTER = new Intl.NumberFormat("ko-KR"); const UP_COLOR = "#ef4444"; const DOWN_COLOR = "#2563eb"; // 분봉 드롭다운 옵션 const MINUTE_TIMEFRAMES: Array<{ value: DashboardChartTimeframe; label: string; }> = [ { value: "1m", label: "1분" }, { value: "30m", label: "30분" }, { value: "1h", label: "1시간" }, ]; // 일봉 이상 개별 버튼 const PERIOD_TIMEFRAMES: Array<{ value: DashboardChartTimeframe; label: string; }> = [ { value: "1d", label: "일" }, { value: "1w", label: "주" }, ]; type ChartBar = { time: UTCTimestamp; open: number; high: number; low: number; close: number; volume: number; }; interface StockLineChartProps { symbol?: string; candles: StockCandlePoint[]; credentials?: KisRuntimeCredentials | 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 */ export function StockLineChart({ symbol, candles, credentials, }: StockLineChartProps) { 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 loadingMoreRef = useRef(false); const loadMoreHandlerRef = useRef<() => Promise>(async () => {}); const initialLoadCompleteRef = useRef(false); // candles prop을 ref로 관리하여 useEffect 디펜던시에서 제거 (무한 페칭 방지) const latestCandlesRef = useRef(candles); useEffect(() => { latestCandlesRef.current = candles; }, [candles]); 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]); 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) => ({ time: bar.time, open: bar.open, high: bar.high, low: bar.low, close: bar.close, })), ); volumeSeries.setData( safeBars.map((bar) => ({ time: bar.time, value: Number.isFinite(bar.volume) ? bar.volume : 0, color: bar.close >= bar.open ? "rgba(239,68,68,0.45)" : "rgba(37,99,235,0.45)", })), ); } catch (error) { console.error("Failed to render chart series data:", error); } }, []); 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 older = normalizeCandles(response.candles, timeframe); setBars((prev) => mergeBars(older, prev)); setNextCursor(response.hasMore ? 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]); 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, layout: { background: { type: ColorType.Solid, color: "#ffffff" }, textColor: "#475569", attributionLogo: true, }, localization: { locale: "ko-KR", }, rightPriceScale: { borderColor: "#e2e8f0", scaleMargins: { top: 0.08, bottom: 0.24, }, }, grid: { vertLines: { color: "#edf1f5" }, horzLines: { color: "#edf1f5" }, }, crosshair: { vertLine: { color: "#94a3b8", width: 1, style: 2 }, horzLine: { color: "#94a3b8", width: 1, style: 2 }, }, timeScale: { borderColor: "#e2e8f0", timeVisible: true, secondsVisible: false, rightOffset: 2, }, handleScroll: { mouseWheel: true, pressedMouseMove: true, }, handleScale: { mouseWheel: true, pinch: true, axisPressedMouseMove: true, }, }); const candleSeries = chart.addSeries(CandlestickSeries, { upColor: UP_COLOR, downColor: DOWN_COLOR, wickUpColor: UP_COLOR, wickDownColor: DOWN_COLOR, borderUpColor: UP_COLOR, borderDownColor: DOWN_COLOR, 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: NodeJS.Timeout | null = null; chart.timeScale().subscribeVisibleLogicalRangeChange((range) => { if (!range) return; // 초기 로딩 완료 후에만 무한 스크롤 트리거 // range.from이 0에 가까워지면(과거 데이터 필요) 로딩 if (range.from < 10 && initialLoadCompleteRef.current) { if (scrollTimeout) clearTimeout(scrollTimeout); scrollTimeout = setTimeout(() => { void loadMoreHandlerRef.current(); }, 300); // 300ms 디바운스 } }); chartRef.current = chart; candleSeriesRef.current = candleSeries; volumeSeriesRef.current = volumeSeries; setIsChartReady(true); const resizeObserver = new ResizeObserver(() => { const nextWidth = Math.max(container.clientWidth, 320); const nextHeight = Math.max(container.clientHeight, 340); chart.resize(nextWidth, nextHeight); }); 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); }); return () => { window.cancelAnimationFrame(rafId); resizeObserver.disconnect(); chart.remove(); chartRef.current = null; candleSeriesRef.current = null; volumeSeriesRef.current = null; setIsChartReady(false); }; }, []); useEffect(() => { if (symbol && credentials) return; setBars(normalizeCandles(candles, "1d")); setNextCursor(null); }, [candles, credentials, symbol]); useEffect(() => { if (!symbol || !credentials) return; // 초기 로딩 보호 플래그 초기화 (타임프레임/종목 변경 시) initialLoadCompleteRef.current = false; let disposed = false; const load = async () => { setIsLoading(true); try { const response = await fetchStockChart(symbol, timeframe, credentials); if (disposed) return; const normalized = normalizeCandles(response.candles, timeframe); setBars(normalized); setNextCursor(response.hasMore ? response.nextCursor : null); // 초기 로딩 완료 후 500ms 지연 후 무한 스크롤 활성화 // (fitContent 후 range가 0이 되어 즉시 트리거되는 것 방지) setTimeout(() => { if (!disposed) { initialLoadCompleteRef.current = true; } }, 500); } catch (error) { if (disposed) return; const message = error instanceof Error ? error.message : "차트 조회에 실패했습니다."; toast.error(message); // 에러 발생 시 fallback으로 props로 전달된 candles 사용 setBars(normalizeCandles(latestCandlesRef.current, timeframe)); setNextCursor(null); } finally { if (!disposed) setIsLoading(false); } }; void load(); return () => { disposed = true; }; }, [credentials, symbol, timeframe]); useEffect(() => { if (!isChartReady) return; setSeriesData(renderableBars); // 초기 로딩 시에만 fitContent 수행 if (!initialLoadCompleteRef.current && renderableBars.length > 0) { chartRef.current?.timeScale().fitContent(); } }, [isChartReady, renderableBars, setSeriesData]); const latestRealtime = useMemo(() => candles.at(-1), [candles]); useEffect(() => { if (!latestRealtime || bars.length === 0) return; if (timeframe === "1w" && !latestRealtime.timestamp && !latestRealtime.time) return; const key = `${latestRealtime.time}-${latestRealtime.price}-${latestRealtime.volume ?? 0}`; if (lastRealtimeKeyRef.current === key) return; lastRealtimeKeyRef.current = key; const nextBar = convertRealtimePointToBar(latestRealtime, timeframe); if (!nextBar) return; setBars((prev) => upsertRealtimeBar(prev, nextBar)); }, [bars.length, candles, latestRealtime, 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")}> {formatPrice(latest?.close ?? 0)} ({formatSignedPercent(changeRate)} )
{/* ========== CHART BODY ========== */}
{/* 차트 캔버스 컨테이너 - 항상 렌더링 */}
{/* 상태 메시지 오버레이 */} {statusMessage && (
{statusMessage}
)}
); } function normalizeCandles( candles: StockCandlePoint[], timeframe: DashboardChartTimeframe, ) { const rows = candles .map((item) => convertCandleToBar(item, timeframe)) .filter((item): item is ChartBar => Boolean(item)); return mergeBars([], rows); } function convertCandleToBar( candle: StockCandlePoint, timeframe: DashboardChartTimeframe, ): ChartBar | null { const close = candle.close ?? candle.price; if (!Number.isFinite(close) || close <= 0) return null; const open = candle.open ?? close; const high = candle.high ?? Math.max(open, close); const low = candle.low ?? Math.min(open, close); const volume = candle.volume ?? 0; const time = resolveBarTimestamp(candle, timeframe); if (!time) return null; return { time, open, high: Math.max(high, open, close), low: Math.min(low, open, close), close, volume, }; } function resolveBarTimestamp( candle: StockCandlePoint, timeframe: DashboardChartTimeframe, ): UTCTimestamp | null { if ( typeof candle.timestamp === "number" && Number.isFinite(candle.timestamp) ) { return adjustTimestampForTimeframe(candle.timestamp, timeframe); } const timeText = typeof candle.time === "string" ? candle.time.trim() : ""; if (!timeText) return null; if (/^\d{2}\/\d{2}$/.test(timeText)) { const [mm, dd] = timeText.split("/"); const year = new Date().getFullYear(); const d = new Date(`${year}-${mm}-${dd}T09:00:00+09:00`); return adjustTimestampForTimeframe( Math.floor(d.getTime() / 1000), timeframe, ); } if (/^\d{2}:\d{2}(:\d{2})?$/.test(timeText)) { const [hh, mi, ss] = timeText.split(":"); const now = new Date(); const y = now.getFullYear(); const m = `${now.getMonth() + 1}`.padStart(2, "0"); const d = `${now.getDate()}`.padStart(2, "0"); const ts = new Date( `${y}-${m}-${d}T${hh}:${mi}:${ss ?? "00"}+09:00`, ).getTime(); return adjustTimestampForTimeframe(Math.floor(ts / 1000), timeframe); } return null; } function adjustTimestampForTimeframe( timestamp: number, timeframe: DashboardChartTimeframe, ): UTCTimestamp { const date = new Date(timestamp * 1000); if (timeframe === "30m" || timeframe === "1h") { const bucketMinutes = timeframe === "30m" ? 30 : 60; const mins = date.getUTCMinutes(); const aligned = Math.floor(mins / bucketMinutes) * bucketMinutes; date.setUTCMinutes(aligned, 0, 0); } else if (timeframe === "1d") { date.setUTCHours(0, 0, 0, 0); } else if (timeframe === "1w") { const day = date.getUTCDay(); const diff = day === 0 ? -6 : 1 - day; date.setUTCDate(date.getUTCDate() + diff); date.setUTCHours(0, 0, 0, 0); } return Math.floor(date.getTime() / 1000) as UTCTimestamp; } function mergeBars(left: ChartBar[], right: ChartBar[]) { const map = new Map(); for (const bar of [...left, ...right]) { const prev = map.get(bar.time); if (!prev) { map.set(bar.time, bar); continue; } map.set(bar.time, { time: bar.time, open: prev.open, high: Math.max(prev.high, bar.high), low: Math.min(prev.low, bar.low), close: bar.close, volume: Math.max(prev.volume, bar.volume), }); } return [...map.values()].sort((a, b) => a.time - b.time); } function convertRealtimePointToBar( point: StockCandlePoint, timeframe: DashboardChartTimeframe, ) { return convertCandleToBar(point, timeframe); } function upsertRealtimeBar(prev: ChartBar[], incoming: ChartBar) { if (prev.length === 0) return [incoming]; const last = prev[prev.length - 1]; if (incoming.time > last.time) { return [...prev, incoming]; } if (incoming.time < last.time) return prev; return [ ...prev.slice(0, -1), { time: last.time, open: last.open, high: Math.max(last.high, incoming.high), low: Math.min(last.low, incoming.low), close: incoming.close, volume: Math.max(last.volume, incoming.volume), }, ]; } function formatPrice(value: number) { return KRW_FORMATTER.format(Math.round(value)); } function formatSignedPercent(value: number) { const sign = value > 0 ? "+" : ""; return `${sign}${value.toFixed(2)}%`; }