Files
auto-trade/features/trade/components/chart/StockLineChart.tsx

740 lines
23 KiB
TypeScript
Raw Normal View History

2026-02-10 11:16:39 +09:00
"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";
2026-02-11 14:06:06 +09:00
import { useTheme } from "next-themes";
2026-02-10 11:16:39 +09:00
import { toast } from "sonner";
2026-02-11 16:31:28 +09:00
import { fetchStockChart } from "@/features/trade/apis/kis-stock.api";
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
2026-02-10 11:16:39 +09:00
import type {
DashboardChartTimeframe,
2026-02-11 11:18:15 +09:00
DashboardRealtimeTradeTick,
2026-02-10 11:16:39 +09:00
StockCandlePoint,
2026-02-11 16:31:28 +09:00
} from "@/features/trade/types/trade.types";
2026-02-10 11:16:39 +09:00
import { cn } from "@/lib/utils";
import {
type ChartBar,
2026-02-11 11:18:15 +09:00
formatKstCrosshairTime,
formatKstTickMark,
2026-02-10 11:16:39 +09:00
formatPrice,
formatSignedPercent,
isMinuteTimeframe,
mergeBars,
normalizeCandles,
2026-02-11 11:18:15 +09:00
toRealtimeTickBar,
2026-02-10 11:16:39 +09:00
upsertRealtimeBar,
} from "./chart-utils";
const UP_COLOR = "#ef4444";
2026-02-13 12:17:35 +09:00
const MINUTE_SYNC_INTERVAL_MS = 30000;
2026-02-11 11:18:15 +09:00
const REALTIME_STALE_THRESHOLD_MS = 12000;
const CHART_MIN_HEIGHT = 220;
2026-02-10 11:16:39 +09:00
2026-02-11 14:06:06 +09:00
interface ChartPalette {
backgroundColor: string;
downColor: string;
volumeDownColor: string;
textColor: string;
borderColor: string;
gridColor: string;
crosshairColor: string;
}
const DEFAULT_CHART_PALETTE: ChartPalette = {
backgroundColor: "#ffffff",
downColor: "#2563eb",
volumeDownColor: "rgba(37, 99, 235, 0.45)",
textColor: "#6d28d9",
borderColor: "#e9d5ff",
gridColor: "#f3e8ff",
crosshairColor: "#c084fc",
};
function readCssVar(name: string, fallback: string) {
if (typeof window === "undefined") return fallback;
const value = window
.getComputedStyle(document.documentElement)
.getPropertyValue(name)
.trim();
2026-02-11 14:06:06 +09:00
return value || fallback;
}
function getChartPaletteFromCssVars(themeMode: "light" | "dark"): ChartPalette {
const isDark = themeMode === "dark";
const backgroundVar = isDark
? "--brand-chart-background-dark"
: "--brand-chart-background-light";
const textVar = isDark
? "--brand-chart-text-dark"
: "--brand-chart-text-light";
const borderVar = isDark
? "--brand-chart-border-dark"
: "--brand-chart-border-light";
const gridVar = isDark
? "--brand-chart-grid-dark"
: "--brand-chart-grid-light";
2026-02-11 14:06:06 +09:00
const crosshairVar = isDark
? "--brand-chart-crosshair-dark"
: "--brand-chart-crosshair-light";
return {
backgroundColor: readCssVar(
backgroundVar,
DEFAULT_CHART_PALETTE.backgroundColor,
),
downColor: readCssVar(
"--brand-chart-down",
DEFAULT_CHART_PALETTE.downColor,
),
2026-02-11 14:06:06 +09:00
volumeDownColor: readCssVar(
"--brand-chart-volume-down",
DEFAULT_CHART_PALETTE.volumeDownColor,
),
textColor: readCssVar(textVar, DEFAULT_CHART_PALETTE.textColor),
borderColor: readCssVar(borderVar, DEFAULT_CHART_PALETTE.borderColor),
gridColor: readCssVar(gridVar, DEFAULT_CHART_PALETTE.gridColor),
crosshairColor: readCssVar(
crosshairVar,
DEFAULT_CHART_PALETTE.crosshairColor,
),
};
}
2026-02-10 11:16:39 +09:00
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: "주" },
];
interface StockLineChartProps {
symbol?: string;
candles: StockCandlePoint[];
credentials?: KisRuntimeCredentials | null;
2026-02-11 11:18:15 +09:00
latestTick?: DashboardRealtimeTradeTick | null;
2026-02-10 11:16:39 +09:00
}
/**
2026-02-11 11:18:15 +09:00
* @description TradingView , timeframe별 KIS API를 .
2026-02-11 16:31:28 +09:00
* @see features/trade/apis/kis-stock.api.ts fetchStockChart
2026-02-11 11:18:15 +09:00
* @see lib/kis/domestic.ts getDomesticChart
2026-02-10 11:16:39 +09:00
*/
export function StockLineChart({
symbol,
candles,
credentials,
2026-02-11 11:18:15 +09:00
latestTick,
2026-02-10 11:16:39 +09:00
}: StockLineChartProps) {
2026-02-11 14:06:06 +09:00
const { resolvedTheme } = useTheme();
2026-02-10 11:16:39 +09:00
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>("");
2026-02-11 11:18:15 +09:00
const lastRealtimeAppliedAtRef = useRef(0);
2026-02-11 14:06:06 +09:00
const chartPaletteRef = useRef<ChartPalette>(DEFAULT_CHART_PALETTE);
const renderableBarsRef = useRef<ChartBar[]>([]);
const activeThemeMode: "light" | "dark" =
resolvedTheme === "dark"
? "dark"
: resolvedTheme === "light"
? "light"
: typeof document !== "undefined" &&
document.documentElement.classList.contains("dark")
? "dark"
: "light";
2026-02-11 11:18:15 +09:00
// 복수 이벤트에서 중복 로드를 막기 위한 ref 상태
2026-02-10 11:16:39 +09:00
const loadingMoreRef = useRef(false);
const loadMoreHandlerRef = useRef<() => Promise<void>>(async () => {});
const initialLoadCompleteRef = useRef(false);
2026-02-11 11:18:15 +09:00
// API 오류 시 fallback 용도로 유지
2026-02-10 11:16:39 +09:00
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;
2026-02-11 11:18:15 +09:00
2026-02-10 11:16:39 +09:00
const renderableBars = useMemo(() => {
const dedup = new Map<number, ChartBar>();
2026-02-11 11:18:15 +09:00
2026-02-10 11:16:39 +09:00
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;
}
2026-02-11 11:18:15 +09:00
2026-02-10 11:16:39 +09:00
dedup.set(bar.time, bar);
}
2026-02-11 11:18:15 +09:00
2026-02-10 11:16:39 +09:00
return [...dedup.values()].sort((a, b) => a.time - b.time);
}, [bars]);
2026-02-11 14:06:06 +09:00
useEffect(() => {
renderableBarsRef.current = renderableBars;
}, [renderableBars]);
2026-02-11 11:18:15 +09:00
/**
* @description lightweight-charts OHLCV .
2026-02-11 16:31:28 +09:00
* @see features/trade/components/chart/StockLineChart.tsx renderableBars useMemo
2026-02-11 11:18:15 +09:00
*/
2026-02-10 11:16:39 +09:00
const setSeriesData = useCallback((nextBars: ChartBar[]) => {
const candleSeries = candleSeriesRef.current;
const volumeSeries = volumeSeriesRef.current;
if (!candleSeries || !volumeSeries) return;
try {
candleSeries.setData(
2026-02-11 11:18:15 +09:00
nextBars.map((bar) => ({
2026-02-10 11:16:39 +09:00
time: bar.time,
open: bar.open,
high: bar.high,
low: bar.low,
close: bar.close,
})),
);
volumeSeries.setData(
2026-02-11 11:18:15 +09:00
nextBars.map((bar) => ({
2026-02-10 11:16:39 +09:00
time: bar.time,
value: Number.isFinite(bar.volume) ? bar.volume : 0,
color:
bar.close >= bar.open
? "rgba(239,68,68,0.45)"
2026-02-11 14:06:06 +09:00
: chartPaletteRef.current.volumeDownColor,
2026-02-10 11:16:39 +09:00
})),
);
} catch (error) {
console.error("Failed to render chart series data:", error);
}
}, []);
2026-02-11 11:18:15 +09:00
/**
* @description cursor .
* @see lib/kis/domestic.ts getDomesticChart cursor
*/
2026-02-10 11:16:39 +09:00
const handleLoadMore = useCallback(async () => {
if (!symbol || !credentials || !nextCursor || loadingMoreRef.current)
return;
2026-02-10 11:16:39 +09:00
loadingMoreRef.current = true;
setIsLoadingMore(true);
2026-02-11 11:18:15 +09:00
2026-02-10 11:16:39 +09:00
try {
const response = await fetchStockChart(
symbol,
timeframe,
credentials,
nextCursor,
);
2026-02-11 11:18:15 +09:00
const olderBars = normalizeCandles(response.candles, timeframe);
setBars((prev) => mergeBars(olderBars, prev));
2026-02-10 11:16:39 +09:00
setNextCursor(response.hasMore ? response.nextCursor : null);
} catch (error) {
2026-02-11 11:18:15 +09:00
const message =
2026-02-10 11:16:39 +09:00
error instanceof Error
? error.message
2026-02-11 11:18:15 +09:00
: "과거 차트 데이터를 불러오지 못했습니다.";
toast.error(message);
2026-02-10 11:16:39 +09:00
} finally {
loadingMoreRef.current = false;
setIsLoadingMore(false);
}
}, [credentials, nextCursor, symbol, timeframe]);
useEffect(() => {
loadMoreHandlerRef.current = handleLoadMore;
}, [handleLoadMore]);
2026-02-11 11:18:15 +09:00
useEffect(() => {
lastRealtimeKeyRef.current = "";
lastRealtimeAppliedAtRef.current = 0;
}, [symbol, timeframe]);
2026-02-10 11:16:39 +09:00
useEffect(() => {
const container = containerRef.current;
if (!container || chartRef.current) return;
2026-02-11 14:06:06 +09:00
// 브랜드 색상은 globals.css의 CSS 변수에서 읽어 차트(canvas)에도 동일하게 적용합니다.
const palette = getChartPaletteFromCssVars(activeThemeMode);
chartPaletteRef.current = palette;
2026-02-10 11:16:39 +09:00
const chart = createChart(container, {
2026-02-11 11:18:15 +09:00
width: Math.max(container.clientWidth, 320),
height: Math.max(container.clientHeight, CHART_MIN_HEIGHT),
2026-02-10 11:16:39 +09:00
layout: {
2026-02-11 14:06:06 +09:00
background: { type: ColorType.Solid, color: palette.backgroundColor },
textColor: palette.textColor,
2026-02-10 11:16:39 +09:00
attributionLogo: true,
},
localization: {
locale: "ko-KR",
2026-02-11 11:18:15 +09:00
timeFormatter: formatKstCrosshairTime,
2026-02-10 11:16:39 +09:00
},
rightPriceScale: {
2026-02-11 14:06:06 +09:00
borderColor: palette.borderColor,
2026-02-10 11:16:39 +09:00
scaleMargins: {
top: 0.08,
bottom: 0.2,
2026-02-10 11:16:39 +09:00
},
},
grid: {
2026-02-11 14:06:06 +09:00
vertLines: { color: palette.gridColor },
horzLines: { color: palette.gridColor },
2026-02-10 11:16:39 +09:00
},
crosshair: {
2026-02-11 14:06:06 +09:00
vertLine: { color: palette.crosshairColor, width: 1, style: 2 },
horzLine: { color: palette.crosshairColor, width: 1, style: 2 },
2026-02-10 11:16:39 +09:00
},
timeScale: {
2026-02-11 14:06:06 +09:00
borderColor: palette.borderColor,
2026-02-10 11:16:39 +09:00
timeVisible: true,
secondsVisible: false,
rightOffset: 2,
2026-02-11 11:18:15 +09:00
tickMarkFormatter: formatKstTickMark,
2026-02-10 11:16:39 +09:00
},
handleScroll: {
mouseWheel: true,
pressedMouseMove: true,
},
handleScale: {
mouseWheel: true,
pinch: true,
axisPressedMouseMove: true,
},
});
const candleSeries = chart.addSeries(CandlestickSeries, {
upColor: UP_COLOR,
2026-02-11 14:06:06 +09:00
downColor: palette.downColor,
2026-02-10 11:16:39 +09:00
wickUpColor: UP_COLOR,
2026-02-11 14:06:06 +09:00
wickDownColor: palette.downColor,
2026-02-10 11:16:39 +09:00
borderUpColor: UP_COLOR,
2026-02-11 14:06:06 +09:00
borderDownColor: palette.downColor,
2026-02-10 11:16:39 +09:00
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,
});
2026-02-11 11:18:15 +09:00
let scrollTimeout: number | undefined;
2026-02-10 11:16:39 +09:00
chart.timeScale().subscribeVisibleLogicalRangeChange((range) => {
2026-02-11 11:18:15 +09:00
if (!range || !initialLoadCompleteRef.current) return;
if (range.from >= 10) return;
if (scrollTimeout !== undefined) window.clearTimeout(scrollTimeout);
scrollTimeout = window.setTimeout(() => {
void loadMoreHandlerRef.current();
}, 250);
2026-02-10 11:16:39 +09:00
});
chartRef.current = chart;
candleSeriesRef.current = candleSeries;
volumeSeriesRef.current = volumeSeries;
setIsChartReady(true);
const resizeObserver = new ResizeObserver(() => {
2026-02-11 11:18:15 +09:00
chart.resize(
Math.max(container.clientWidth, 320),
Math.max(container.clientHeight, CHART_MIN_HEIGHT),
2026-02-11 11:18:15 +09:00
);
2026-02-10 11:16:39 +09:00
});
resizeObserver.observe(container);
const rafId = window.requestAnimationFrame(() => {
2026-02-11 11:18:15 +09:00
chart.resize(
Math.max(container.clientWidth, 320),
Math.max(container.clientHeight, CHART_MIN_HEIGHT),
2026-02-11 11:18:15 +09:00
);
2026-02-10 11:16:39 +09:00
});
return () => {
2026-02-11 11:18:15 +09:00
if (scrollTimeout !== undefined) window.clearTimeout(scrollTimeout);
2026-02-10 11:16:39 +09:00
window.cancelAnimationFrame(rafId);
resizeObserver.disconnect();
chart.remove();
chartRef.current = null;
candleSeriesRef.current = null;
volumeSeriesRef.current = null;
setIsChartReady(false);
};
2026-02-11 14:06:06 +09:00
}, [activeThemeMode]);
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]);
2026-02-10 11:16:39 +09:00
useEffect(() => {
if (symbol && credentials) return;
2026-02-11 11:18:15 +09:00
// 인증 전/종목 미선택 상태는 overview 캔들로 fallback
2026-02-10 11:16:39 +09:00
setBars(normalizeCandles(candles, "1d"));
setNextCursor(null);
}, [candles, credentials, symbol]);
useEffect(() => {
if (!symbol || !credentials) return;
initialLoadCompleteRef.current = false;
let disposed = false;
2026-02-11 11:18:15 +09:00
2026-02-10 11:16:39 +09:00
const load = async () => {
setIsLoading(true);
2026-02-11 11:18:15 +09:00
2026-02-10 11:16:39 +09:00
try {
2026-02-11 11:18:15 +09:00
const firstPage = await fetchStockChart(symbol, timeframe, credentials);
2026-02-10 11:16:39 +09:00
if (disposed) return;
2026-02-11 11:18:15 +09:00
let mergedBars = normalizeCandles(firstPage.candles, timeframe);
let resolvedNextCursor = firstPage.hasMore
? firstPage.nextCursor
: null;
2026-02-11 11:18:15 +09:00
2026-02-11 14:06:06 +09:00
// 분봉은 기본 3페이지까지 순차 조회해 오전 구간 누락을 줄입니다.
2026-02-11 11:18:15 +09:00
if (
isMinuteTimeframe(timeframe) &&
firstPage.hasMore &&
firstPage.nextCursor
) {
2026-02-11 14:06:06 +09:00
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;
2026-02-11 14:06:06 +09:00
minuteCursor = olderPage.hasMore ? olderPage.nextCursor : null;
extraPageCount += 1;
} catch {
// 추가 페이지 실패는 치명적이지 않으므로 현재 데이터는 유지합니다.
minuteCursor = null;
}
2026-02-10 11:16:39 +09:00
}
2026-02-11 11:18:15 +09:00
}
setBars(mergedBars);
setNextCursor(resolvedNextCursor);
window.setTimeout(() => {
if (!disposed) initialLoadCompleteRef.current = true;
}, 350);
2026-02-10 11:16:39 +09:00
} catch (error) {
if (disposed) return;
2026-02-11 11:18:15 +09:00
2026-02-10 11:16:39 +09:00
const message =
2026-02-11 11:18:15 +09:00
error instanceof Error
? error.message
: "차트 조회 중 오류가 발생했습니다.";
2026-02-10 11:16:39 +09:00
toast.error(message);
2026-02-11 11:18:15 +09:00
2026-02-10 11:16:39 +09:00
setBars(normalizeCandles(latestCandlesRef.current, timeframe));
setNextCursor(null);
} finally {
if (!disposed) setIsLoading(false);
}
};
void load();
2026-02-11 11:18:15 +09:00
2026-02-10 11:16:39 +09:00
return () => {
disposed = true;
};
}, [credentials, symbol, timeframe]);
useEffect(() => {
if (!isChartReady) return;
2026-02-11 11:18:15 +09:00
setSeriesData(renderableBars);
2026-02-10 11:16:39 +09:00
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
*/
2026-02-11 11:18:15 +09:00
useEffect(() => {
if (!latestTick) return;
if (bars.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));
}, [bars.length, latestTick, timeframe]);
/**
* @description (1m/30m/1h) WS .
2026-02-11 16:31:28 +09:00
* @see features/trade/apis/kis-stock.api.ts fetchStockChart
2026-02-11 11:18:15 +09:00
* @see lib/kis/domestic.ts getDomesticChart
*/
2026-02-10 11:16:39 +09:00
useEffect(() => {
2026-02-11 11:18:15 +09:00
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;
2026-02-10 11:16:39 +09:00
2026-02-13 12:17:35 +09:00
setBars((prev) => {
const merged = mergeBars(prev, recentBars);
return areBarsEqual(prev, merged) ? prev : merged;
});
2026-02-11 11:18:15 +09:00
} catch {
// 폴링 실패는 치명적이지 않으므로 조용히 다음 주기에서 재시도합니다.
}
};
2026-02-10 11:16:39 +09:00
2026-02-11 11:18:15 +09:00
const intervalId = window.setInterval(() => {
void syncLatestMinuteBars();
}, MINUTE_SYNC_INTERVAL_MS);
2026-02-10 11:16:39 +09:00
2026-02-11 11:18:15 +09:00
return () => {
disposed = true;
window.clearInterval(intervalId);
};
}, [credentials, symbol, timeframe]);
2026-02-10 11:16:39 +09:00
const statusMessage = (() => {
2026-02-11 11:18:15 +09:00
if (isLoading && bars.length === 0) {
2026-02-10 11:16:39 +09:00
return "차트 데이터를 불러오는 중입니다.";
2026-02-11 11:18:15 +09:00
}
if (bars.length === 0) {
return "차트 데이터가 없습니다.";
}
if (renderableBars.length === 0) {
return "차트 데이터 형식이 올바르지 않습니다.";
}
2026-02-10 11:16:39 +09:00
return null;
})();
return (
<div className="flex h-full min-h-[280px] flex-col bg-white dark:bg-brand-900/10 xl:min-h-0">
2026-02-10 11:16:39 +09:00
{/* ========== CHART TOOLBAR ========== */}
2026-02-11 14:06:06 +09:00
<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">
2026-02-10 11:16:39 +09:00
<div className="flex flex-wrap items-center gap-1 text-xs sm:text-sm">
<div className="relative">
<button
type="button"
2026-02-11 11:18:15 +09:00
onClick={() => setIsMinuteDropdownOpen((prev) => !prev)}
2026-02-10 11:16:39 +09:00
onBlur={() =>
2026-02-11 11:18:15 +09:00
window.setTimeout(() => setIsMinuteDropdownOpen(false), 200)
2026-02-10 11:16:39 +09:00
}
className={cn(
2026-02-11 14:06:06 +09:00
"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",
2026-02-11 11:18:15 +09:00
MINUTE_TIMEFRAMES.some((item) => item.value === timeframe) &&
2026-02-11 14:06:06 +09:00
"border-brand-400 font-semibold text-foreground dark:text-brand-50",
2026-02-10 11:16:39 +09:00
)}
>
2026-02-11 11:18:15 +09:00
{MINUTE_TIMEFRAMES.find((item) => item.value === timeframe)
?.label ?? "분봉"}
2026-02-10 11:16:39 +09:00
<ChevronDown className="h-3 w-3" />
</button>
2026-02-11 11:18:15 +09:00
2026-02-10 11:16:39 +09:00
{isMinuteDropdownOpen && (
2026-02-11 14:06:06 +09:00
<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]">
2026-02-10 11:16:39 +09:00
{MINUTE_TIMEFRAMES.map((item) => (
<button
key={item.value}
type="button"
onClick={() => {
setTimeframe(item.value);
setIsMinuteDropdownOpen(false);
}}
className={cn(
2026-02-11 14:06:06 +09:00
"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",
2026-02-10 11:16:39 +09:00
timeframe === item.value &&
2026-02-11 14:06:06 +09:00
"bg-brand-50 font-semibold text-brand-700 dark:bg-brand-700/30 dark:text-brand-100",
2026-02-10 11:16:39 +09:00
)}
>
{item.label}
</button>
))}
</div>
)}
</div>
{PERIOD_TIMEFRAMES.map((item) => (
<button
key={item.value}
type="button"
onClick={() => setTimeframe(item.value)}
className={cn(
2026-02-11 14:06:06 +09:00
"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",
2026-02-10 11:16:39 +09:00
timeframe === item.value &&
2026-02-11 14:06:06 +09:00
"border-brand-400 font-semibold text-foreground dark:text-brand-50",
2026-02-10 11:16:39 +09:00
)}
>
{item.label}
</button>
))}
2026-02-11 11:18:15 +09:00
2026-02-10 11:16:39 +09:00
{isLoadingMore && (
2026-02-11 14:06:06 +09:00
<span className="ml-2 text-[11px] text-muted-foreground dark:text-brand-200/70">
2026-02-11 11:18:15 +09:00
...
2026-02-10 11:16:39 +09:00
</span>
)}
</div>
2026-02-11 11:18:15 +09:00
2026-02-11 14:06:06 +09:00
<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{" "}
2026-02-11 14:06:06 +09:00
<span
className={cn(
change >= 0 ? "text-red-600" : "text-blue-600 dark:text-blue-400",
)}
>
{formatPrice(latest?.close ?? 0)} ({formatSignedPercent(changeRate)}
)
2026-02-10 11:16:39 +09:00
</span>
</div>
</div>
{/* ========== CHART BODY ========== */}
<div className="relative min-h-0 flex-1 overflow-hidden">
<div ref={containerRef} className="h-full w-full" />
{statusMessage && (
2026-02-11 14:06:06 +09:00
<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">
2026-02-10 11:16:39 +09:00
{statusMessage}
</div>
)}
</div>
</div>
);
}
2026-02-13 12:17:35 +09:00
function areBarsEqual(left: ChartBar[], right: ChartBar[]) {
if (left.length !== right.length) return false;
for (let index = 0; index < left.length; index += 1) {
const lhs = left[index];
const rhs = right[index];
if (!lhs || !rhs) return false;
if (
lhs.time !== rhs.time ||
lhs.open !== rhs.open ||
lhs.high !== rhs.high ||
lhs.low !== rhs.low ||
lhs.close !== rhs.close ||
lhs.volume !== rhs.volume
) {
return false;
}
}
return true;
}