테마 적용

This commit is contained in:
2026-02-11 14:06:06 +09:00
parent def87bd47a
commit 95291e6922
30 changed files with 1209 additions and 496 deletions

View File

@@ -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>
)}