테마 적용
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
type Time,
|
||||
} from "lightweight-charts";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { toast } from "sonner";
|
||||
import { fetchStockChart } from "@/features/dashboard/apis/kis-stock.api";
|
||||
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
|
||||
@@ -34,10 +35,64 @@ import {
|
||||
} from "./chart-utils";
|
||||
|
||||
const UP_COLOR = "#ef4444";
|
||||
const DOWN_COLOR = "#2563eb";
|
||||
const MINUTE_SYNC_INTERVAL_MS = 5000;
|
||||
const REALTIME_STALE_THRESHOLD_MS = 12000;
|
||||
|
||||
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();
|
||||
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";
|
||||
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),
|
||||
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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const MINUTE_TIMEFRAMES: Array<{
|
||||
value: DashboardChartTimeframe;
|
||||
label: string;
|
||||
@@ -73,6 +128,7 @@ export function StockLineChart({
|
||||
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);
|
||||
@@ -87,6 +143,18 @@ export function StockLineChart({
|
||||
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 activeThemeMode: "light" | "dark" =
|
||||
resolvedTheme === "dark"
|
||||
? "dark"
|
||||
: resolvedTheme === "light"
|
||||
? "light"
|
||||
: typeof document !== "undefined" &&
|
||||
document.documentElement.classList.contains("dark")
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
// 복수 이벤트에서 중복 로드를 막기 위한 ref 상태
|
||||
const loadingMoreRef = useRef(false);
|
||||
@@ -125,6 +193,10 @@ export function StockLineChart({
|
||||
return [...dedup.values()].sort((a, b) => a.time - b.time);
|
||||
}, [bars]);
|
||||
|
||||
useEffect(() => {
|
||||
renderableBarsRef.current = renderableBars;
|
||||
}, [renderableBars]);
|
||||
|
||||
/**
|
||||
* @description lightweight-charts 시리즈 데이터에 안전한 OHLCV 값을 주입합니다.
|
||||
* @see features/dashboard/components/chart/StockLineChart.tsx renderableBars useMemo
|
||||
@@ -152,7 +224,7 @@ export function StockLineChart({
|
||||
color:
|
||||
bar.close >= bar.open
|
||||
? "rgba(239,68,68,0.45)"
|
||||
: "rgba(37,99,235,0.45)",
|
||||
: chartPaletteRef.current.volumeDownColor,
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -206,12 +278,16 @@ export function StockLineChart({
|
||||
const container = containerRef.current;
|
||||
if (!container || chartRef.current) return;
|
||||
|
||||
// 브랜드 색상은 globals.css의 CSS 변수에서 읽어 차트(canvas)에도 동일하게 적용합니다.
|
||||
const palette = getChartPaletteFromCssVars(activeThemeMode);
|
||||
chartPaletteRef.current = palette;
|
||||
|
||||
const chart = createChart(container, {
|
||||
width: Math.max(container.clientWidth, 320),
|
||||
height: Math.max(container.clientHeight, 340),
|
||||
layout: {
|
||||
background: { type: ColorType.Solid, color: "#ffffff" },
|
||||
textColor: "#475569",
|
||||
background: { type: ColorType.Solid, color: palette.backgroundColor },
|
||||
textColor: palette.textColor,
|
||||
attributionLogo: true,
|
||||
},
|
||||
localization: {
|
||||
@@ -219,22 +295,22 @@ export function StockLineChart({
|
||||
timeFormatter: formatKstCrosshairTime,
|
||||
},
|
||||
rightPriceScale: {
|
||||
borderColor: "#e2e8f0",
|
||||
borderColor: palette.borderColor,
|
||||
scaleMargins: {
|
||||
top: 0.08,
|
||||
bottom: 0.24,
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: "#edf1f5" },
|
||||
horzLines: { color: "#edf1f5" },
|
||||
vertLines: { color: palette.gridColor },
|
||||
horzLines: { color: palette.gridColor },
|
||||
},
|
||||
crosshair: {
|
||||
vertLine: { color: "#94a3b8", width: 1, style: 2 },
|
||||
horzLine: { color: "#94a3b8", width: 1, style: 2 },
|
||||
vertLine: { color: palette.crosshairColor, width: 1, style: 2 },
|
||||
horzLine: { color: palette.crosshairColor, width: 1, style: 2 },
|
||||
},
|
||||
timeScale: {
|
||||
borderColor: "#e2e8f0",
|
||||
borderColor: palette.borderColor,
|
||||
timeVisible: true,
|
||||
secondsVisible: false,
|
||||
rightOffset: 2,
|
||||
@@ -253,11 +329,11 @@ export function StockLineChart({
|
||||
|
||||
const candleSeries = chart.addSeries(CandlestickSeries, {
|
||||
upColor: UP_COLOR,
|
||||
downColor: DOWN_COLOR,
|
||||
downColor: palette.downColor,
|
||||
wickUpColor: UP_COLOR,
|
||||
wickDownColor: DOWN_COLOR,
|
||||
wickDownColor: palette.downColor,
|
||||
borderUpColor: UP_COLOR,
|
||||
borderDownColor: DOWN_COLOR,
|
||||
borderDownColor: palette.downColor,
|
||||
priceLineVisible: true,
|
||||
lastValueVisible: true,
|
||||
});
|
||||
@@ -318,7 +394,41 @@ export function StockLineChart({
|
||||
volumeSeriesRef.current = null;
|
||||
setIsChartReady(false);
|
||||
};
|
||||
}, []);
|
||||
}, [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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (symbol && credentials) return;
|
||||
@@ -344,25 +454,33 @@ export function StockLineChart({
|
||||
let mergedBars = normalizeCandles(firstPage.candles, timeframe);
|
||||
let resolvedNextCursor = firstPage.hasMore ? firstPage.nextCursor : null;
|
||||
|
||||
// 분봉은 기본 2페이지를 붙여서 "당일만 보이는" 느낌을 줄입니다.
|
||||
// 분봉은 기본 3페이지까지 순차 조회해 오전 구간 누락을 줄입니다.
|
||||
if (
|
||||
isMinuteTimeframe(timeframe) &&
|
||||
firstPage.hasMore &&
|
||||
firstPage.nextCursor
|
||||
) {
|
||||
try {
|
||||
const secondPage = await fetchStockChart(
|
||||
symbol,
|
||||
timeframe,
|
||||
credentials,
|
||||
firstPage.nextCursor,
|
||||
);
|
||||
let minuteCursor: string | null = firstPage.nextCursor;
|
||||
let extraPageCount = 0;
|
||||
|
||||
const olderBars = normalizeCandles(secondPage.candles, timeframe);
|
||||
mergedBars = mergeBars(olderBars, mergedBars);
|
||||
resolvedNextCursor = secondPage.hasMore ? secondPage.nextCursor : null;
|
||||
} catch {
|
||||
// 2페이지 실패는 치명적이지 않으므로 1페이지 데이터는 유지합니다.
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,9 +597,9 @@ export function StockLineChart({
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-[340px] flex-col bg-white">
|
||||
<div className="flex h-full min-h-[340px] flex-col bg-white dark:bg-brand-900/10">
|
||||
{/* ========== 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 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
|
||||
@@ -491,9 +609,9 @@ export function StockLineChart({
|
||||
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",
|
||||
"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) &&
|
||||
"bg-brand-100 font-semibold text-brand-700",
|
||||
"border-brand-400 font-semibold text-foreground dark:text-brand-50",
|
||||
)}
|
||||
>
|
||||
{MINUTE_TIMEFRAMES.find((item) => item.value === timeframe)
|
||||
@@ -502,7 +620,7 @@ export function StockLineChart({
|
||||
</button>
|
||||
|
||||
{isMinuteDropdownOpen && (
|
||||
<div className="absolute left-0 top-full z-10 mt-1 rounded border border-slate-200 bg-white shadow-lg">
|
||||
<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}
|
||||
@@ -512,9 +630,9 @@ export function StockLineChart({
|
||||
setIsMinuteDropdownOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"block w-full whitespace-nowrap px-3 py-1.5 text-left hover:bg-slate-100",
|
||||
"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",
|
||||
"bg-brand-50 font-semibold text-brand-700 dark:bg-brand-700/30 dark:text-brand-100",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
@@ -530,9 +648,9 @@ export function StockLineChart({
|
||||
type="button"
|
||||
onClick={() => setTimeframe(item.value)}
|
||||
className={cn(
|
||||
"rounded px-2 py-1 text-slate-600 transition-colors hover:bg-slate-100",
|
||||
"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 &&
|
||||
"bg-brand-100 font-semibold text-brand-700",
|
||||
"border-brand-400 font-semibold text-foreground dark:text-brand-50",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
@@ -540,16 +658,20 @@ export function StockLineChart({
|
||||
))}
|
||||
|
||||
{isLoadingMore && (
|
||||
<span className="ml-2 text-[11px] text-muted-foreground">
|
||||
<span className="ml-2 text-[11px] text-muted-foreground dark:text-brand-200/70">
|
||||
과거 데이터 로딩 중...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-[11px] text-slate-600 sm:text-xs">
|
||||
<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")}>
|
||||
<span
|
||||
className={cn(
|
||||
change >= 0 ? "text-red-600" : "text-blue-600 dark:text-blue-400",
|
||||
)}
|
||||
>
|
||||
{formatPrice(latest?.close ?? 0)} ({formatSignedPercent(changeRate)})
|
||||
</span>
|
||||
</div>
|
||||
@@ -560,7 +682,7 @@ export function StockLineChart({
|
||||
<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">
|
||||
<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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user