649 lines
21 KiB
TypeScript
649 lines
21 KiB
TypeScript
"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,
|
|
MINUTE_SYNC_INTERVAL_MS,
|
|
MINUTE_TIMEFRAMES,
|
|
PERIOD_TIMEFRAMES,
|
|
REALTIME_STALE_THRESHOLD_MS,
|
|
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<HTMLDivElement | null>(null);
|
|
const chartRef = useRef<IChartApi | null>(null);
|
|
const candleSeriesRef = useRef<ISeriesApi<"Candlestick", Time> | null>(null);
|
|
const volumeSeriesRef = useRef<ISeriesApi<"Histogram", Time> | null>(null);
|
|
|
|
const [timeframe, setTimeframe] = useState<DashboardChartTimeframe>("1d");
|
|
const [isMinuteDropdownOpen, setIsMinuteDropdownOpen] = useState(false);
|
|
const [bars, setBars] = useState<ChartBar[]>([]);
|
|
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
|
const [isChartReady, setIsChartReady] = useState(false);
|
|
const lastRealtimeKeyRef = useRef<string>("");
|
|
const lastRealtimeAppliedAtRef = useRef(0);
|
|
const chartPaletteRef = useRef<ChartPalette>(DEFAULT_CHART_PALETTE);
|
|
const renderableBarsRef = useRef<ChartBar[]>([]);
|
|
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<void>>(async () => {});
|
|
const initialLoadCompleteRef = useRef(false);
|
|
|
|
// API 오류 시 fallback 용도로 유지
|
|
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<number, ChartBar>();
|
|
|
|
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 : 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(() => {
|
|
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: 2,
|
|
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;
|
|
chart.timeScale().subscribeVisibleLogicalRangeChange((range) => {
|
|
if (!range || !initialLoadCompleteRef.current) return;
|
|
if (range.from >= 10) return;
|
|
|
|
if (scrollTimeout !== undefined) window.clearTimeout(scrollTimeout);
|
|
scrollTimeout = window.setTimeout(() => {
|
|
void loadMoreHandlerRef.current();
|
|
}, 250);
|
|
});
|
|
|
|
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);
|
|
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;
|
|
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;
|
|
|
|
// 분봉은 기본 3페이지까지 순차 조회해 오전 구간 누락을 줄입니다.
|
|
if (
|
|
isMinuteTimeframe(timeframe) &&
|
|
firstPage.hasMore &&
|
|
firstPage.nextCursor
|
|
) {
|
|
let minuteCursor: string | null = firstPage.nextCursor;
|
|
let extraPageCount = 0;
|
|
|
|
while (minuteCursor && extraPageCount < 2) {
|
|
try {
|
|
const olderPage = await fetchStockChart(
|
|
symbol,
|
|
timeframe,
|
|
credentials,
|
|
minuteCursor,
|
|
);
|
|
|
|
const olderBars = normalizeCandles(olderPage.candles, timeframe);
|
|
mergedBars = mergeBars(olderBars, mergedBars);
|
|
resolvedNextCursor = olderPage.hasMore
|
|
? olderPage.nextCursor
|
|
: null;
|
|
minuteCursor = olderPage.hasMore ? olderPage.nextCursor : 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 (!initialLoadCompleteRef.current && renderableBars.length > 0) {
|
|
chartRef.current?.timeScale().fitContent();
|
|
}
|
|
}, [isChartReady, renderableBars, setSeriesData]);
|
|
|
|
/**
|
|
* @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/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 (
|
|
<div className="flex h-full min-h-[280px] flex-col bg-white dark:bg-brand-900/10 xl:min-h-0">
|
|
{/* ========== CHART TOOLBAR ========== */}
|
|
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-brand-100 bg-muted/20 px-2 py-2 sm:px-3 dark:border-brand-800/45 dark:bg-brand-900/35">
|
|
<div className="flex flex-wrap items-center gap-1 text-xs sm:text-sm">
|
|
<div className="relative">
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsMinuteDropdownOpen((prev) => !prev)}
|
|
onBlur={() =>
|
|
window.setTimeout(() => setIsMinuteDropdownOpen(false), 200)
|
|
}
|
|
className={cn(
|
|
"relative flex items-center gap-1 border-b-2 border-transparent px-2 py-1.5 text-muted-foreground transition-colors hover:text-foreground dark:text-brand-100/72 dark:hover:text-brand-50",
|
|
MINUTE_TIMEFRAMES.some((item) => item.value === timeframe) &&
|
|
"border-brand-400 font-semibold text-foreground dark:text-brand-50",
|
|
)}
|
|
>
|
|
{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-brand-100 bg-white shadow-lg dark:border-brand-700/45 dark:bg-[#1a1624]">
|
|
{MINUTE_TIMEFRAMES.map((item) => (
|
|
<button
|
|
key={item.value}
|
|
type="button"
|
|
onClick={() => {
|
|
setTimeframe(item.value);
|
|
setIsMinuteDropdownOpen(false);
|
|
}}
|
|
className={cn(
|
|
"block w-full whitespace-nowrap px-3 py-1.5 text-left hover:bg-brand-50 dark:text-brand-100 dark:hover:bg-brand-800/35",
|
|
timeframe === item.value &&
|
|
"bg-brand-50 font-semibold text-brand-700 dark:bg-brand-700/30 dark:text-brand-100",
|
|
)}
|
|
>
|
|
{item.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{PERIOD_TIMEFRAMES.map((item) => (
|
|
<button
|
|
key={item.value}
|
|
type="button"
|
|
onClick={() => setTimeframe(item.value)}
|
|
className={cn(
|
|
"relative border-b-2 border-transparent px-2 py-1.5 text-muted-foreground transition-colors hover:text-foreground dark:text-brand-100/72 dark:hover:text-brand-50",
|
|
timeframe === item.value &&
|
|
"border-brand-400 font-semibold text-foreground dark:text-brand-50",
|
|
)}
|
|
>
|
|
{item.label}
|
|
</button>
|
|
))}
|
|
|
|
{isLoadingMore && (
|
|
<span className="ml-2 text-[11px] text-muted-foreground dark:text-brand-200/70">
|
|
과거 데이터 로딩 중...
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="text-[11px] text-muted-foreground dark:text-brand-100/85 sm:text-xs">
|
|
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 dark:text-blue-400",
|
|
)}
|
|
>
|
|
{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 dark:bg-background/90 dark:text-brand-100/80">
|
|
{statusMessage}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|