Files
stock_chart_site/web/components/StockChart.tsx
claude-owner 900c56550c feat(chart): split toolbar into indicators row + actions row
When showSma is true, the indicator chips (MA5/20/60 + RSI + MACD) and the
action buttons (수평선 / CSV / 전체화면, plus the fullscreen symbol label) were
sharing one flex-wrap row. On narrow widths the actions wrapped into the
middle of the indicators because `ml-auto` only enforces right-pull while
everything fits on one line — once wrap kicks in the right-side group loses
its anchor.

Split into two rows so wrap in either group stays local:
- Indicators row: rendered only when showSma. Hidden in 1D (intraday) where
  none of these are meaningful, matching prior behavior.
- Actions row: always rendered. Leading fullscreen label + `ml-auto` to push
  the three action buttons right; on narrow widths they wrap among themselves
  without colliding with indicator chips.
2026-05-29 00:54:04 +09:00

1178 lines
44 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
createChart,
type CandlestickData,
type HistogramData,
type IChartApi,
type IPriceLine,
type ISeriesApi,
type LineData,
type LogicalRange,
type MouseEventParams,
type UTCTimestamp,
} from "lightweight-charts";
import type { ChartPayload, LatestPredictionResponse } from "../lib/api";
import { csvFilename, downloadCsv, ohlcvToCsv } from "../lib/csv";
import { addLine, clearLines, type HLine, readLines, removeLine } from "../lib/lines";
import type { Targets } from "../lib/targets";
type Props = {
chart: ChartPayload;
prediction?: LatestPredictionResponse | null;
// 사용자가 저장한 목표가/손절가 — 메인 캔들 위 가로 라인 오버레이.
targets?: Targets | null;
};
// KST naive ISO ('YYYY-MM-DD' 또는 'YYYY-MM-DDTHH:MM:SS') 를 UTCTimestamp 로.
// lightweight-charts 는 UTC 라고 가정하지만 KST wall-clock 을 UTC 처럼 넣어
// timeScale 표시가 한국 사용자에게 자연스럽게 KST 그대로 나오도록 함.
function isoToUtcTs(s: string): UTCTimestamp {
if (s.length <= 10) {
return (Date.UTC(
Number(s.slice(0, 4)),
Number(s.slice(5, 7)) - 1,
Number(s.slice(8, 10)),
) / 1000) as UTCTimestamp;
}
return (Date.UTC(
Number(s.slice(0, 4)),
Number(s.slice(5, 7)) - 1,
Number(s.slice(8, 10)),
Number(s.slice(11, 13)),
Number(s.slice(14, 16)),
Number(s.slice(17, 19) || "0"),
) / 1000) as UTCTimestamp;
}
// 한국 거래소 관행: 상승=빨강, 하락=파랑.
const COLOR_UP = "#ef4444";
const COLOR_DOWN = "#3b82f6";
// crosshair overlay 용 — 토스 차트는 마우스 hover 시 좌상단에 한국어 정보 박스를 띄움.
// UTCTimestamp(초) → 'YYYY-MM-DD(요일)' / intraday 면 시:분 까지.
const WEEKDAYS = ["일", "월", "화", "수", "목", "금", "토"];
function tsToKLabel(ts: UTCTimestamp, intraday: boolean): string {
const d = new Date((ts as number) * 1000);
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
const day = String(d.getUTCDate()).padStart(2, "0");
const wd = WEEKDAYS[d.getUTCDay()];
const base = `${y}-${m}-${day}(${wd})`;
if (!intraday) return base;
const hh = String(d.getUTCHours()).padStart(2, "0");
const mm = String(d.getUTCMinutes()).padStart(2, "0");
return `${base} ${hh}:${mm}`;
}
// 거래량 한국식 단위 (억/만). 토스 사이드바 fmtCompact 와 동일.
function fmtKVolume(n: number | null | undefined): string {
if (n == null || !Number.isFinite(n)) return "-";
if (n >= 1e8) return `${(n / 1e8).toFixed(1)}`;
if (n >= 1e4) return `${(n / 1e4).toFixed(1)}`;
return n.toLocaleString();
}
function fmtKPrice(n: number | null | undefined): string {
if (n == null || !Number.isFinite(n)) return "-";
return n.toLocaleString(undefined, { maximumFractionDigits: 0 });
}
type CrosshairInfo = {
label: string; // '2025-11-28(금)' 또는 '2025-11-28(금) 14:30'
open: number | null;
high: number | null;
low: number | null;
close: number | null;
volume: number | null;
// 시가 → 종가 등락률 (%). null 이면 안 그림.
pct: number | null;
};
// 예측 캔들은 같은 up/down 색 옅은 톤. "어우러지되 미래 데이터임이 명확" 한 시각 신호.
const PRED_UP = "#fda4af";
const PRED_DOWN = "#93c5fd";
// 단순 이동평균. 윈도우 미만은 null (lightweight-charts 는 whitespace 로 처리됨).
function sma(values: (number | null)[], window: number): (number | null)[] {
const out: (number | null)[] = new Array(values.length).fill(null);
let sum = 0;
let count = 0;
const buf: (number | null)[] = [];
for (let i = 0; i < values.length; i++) {
const v = values[i];
buf.push(v);
if (v != null) {
sum += v;
count += 1;
}
if (buf.length > window) {
const drop = buf.shift();
if (drop != null) {
sum -= drop;
count -= 1;
}
}
if (buf.length === window && count === window) {
out[i] = sum / window;
}
}
return out;
}
type SmaWindow = 5 | 20 | 60;
const SMA_PRESETS: { window: SmaWindow; color: string; label: string }[] = [
{ window: 5, color: "#f59e0b", label: "MA5" },
{ window: 20, color: "#22d3ee", label: "MA20" },
{ window: 60, color: "#a78bfa", label: "MA60" },
];
// Wilder's smoothing RSI. period=14 가 표준. 워밍업 구간은 null.
// 첫 평균은 SMA, 이후는 ((prev*(n-1)) + cur) / n 점화식.
function rsi(closes: (number | null)[], period = 14): (number | null)[] {
const out: (number | null)[] = new Array(closes.length).fill(null);
if (closes.length <= period) return out;
let gainSum = 0;
let lossSum = 0;
// 첫 period 구간으로 초기 평균. null 이 끼면 직전 값 기준으로 변화량 산출하되,
// 둘 중 하나라도 null 이면 그 step 은 0 으로 취급 (보수적).
for (let i = 1; i <= period; i++) {
const a = closes[i];
const b = closes[i - 1];
if (a == null || b == null) continue;
const ch = a - b;
if (ch >= 0) gainSum += ch;
else lossSum -= ch;
}
let avgGain = gainSum / period;
let avgLoss = lossSum / period;
out[period] = avgLoss === 0 ? 100 : 100 - 100 / (1 + avgGain / avgLoss);
for (let i = period + 1; i < closes.length; i++) {
const a = closes[i];
const b = closes[i - 1];
let ch = 0;
if (a != null && b != null) ch = a - b;
const g = ch > 0 ? ch : 0;
const l = ch < 0 ? -ch : 0;
avgGain = (avgGain * (period - 1) + g) / period;
avgLoss = (avgLoss * (period - 1) + l) / period;
out[i] = avgLoss === 0 ? 100 : 100 - 100 / (1 + avgGain / avgLoss);
}
return out;
}
// 지수이동평균. 첫 유효 값에서 시드 시작, 이후 alpha=2/(n+1) 점화식.
// null 은 그대로 통과 (해당 시점 계산 안 함, 직전 ema 유지).
function ema(values: (number | null)[], period: number): (number | null)[] {
const out: (number | null)[] = new Array(values.length).fill(null);
const alpha = 2 / (period + 1);
let cur: number | null = null;
for (let i = 0; i < values.length; i++) {
const v = values[i];
if (v == null) {
out[i] = cur;
continue;
}
if (cur == null) cur = v;
else cur = alpha * v + (1 - alpha) * cur;
out[i] = cur;
}
return out;
}
// MACD = EMA12 - EMA26, signal = EMA9(MACD), hist = MACD - signal.
function macd(
closes: (number | null)[],
fast = 12,
slow = 26,
signal = 9,
): { macd: (number | null)[]; signal: (number | null)[]; hist: (number | null)[] } {
const ef = ema(closes, fast);
const es = ema(closes, slow);
const m: (number | null)[] = ef.map((a, i) => {
const b = es[i];
if (a == null || b == null) return null;
return a - b;
});
const sig = ema(m, signal);
const hist: (number | null)[] = m.map((a, i) => {
const b = sig[i];
if (a == null || b == null) return null;
return a - b;
});
return { macd: m, signal: sig, hist };
}
export function StockChart({ chart, prediction, targets }: Props) {
const containerRef = useRef<HTMLDivElement | null>(null);
const chartRef = useRef<IChartApi | null>(null);
const candleRef = useRef<ISeriesApi<"Candlestick"> | null>(null);
const volumeRef = useRef<ISeriesApi<"Histogram"> | null>(null);
const predRef = useRef<ISeriesApi<"Candlestick"> | null>(null);
const smaRefs = useRef<Map<SmaWindow, ISeriesApi<"Line">>>(new Map());
// RSI 보조차트 — 별도 chart 인스턴스로 메인 아래에 붙여 timeScale 동기화.
const rsiContainerRef = useRef<HTMLDivElement | null>(null);
const rsiChartRef = useRef<IChartApi | null>(null);
const rsiSeriesRef = useRef<ISeriesApi<"Line"> | null>(null);
// MACD 보조차트 — RSI 와 같은 패턴 (별 인스턴스, 단방향 sync).
const macdContainerRef = useRef<HTMLDivElement | null>(null);
const macdChartRef = useRef<IChartApi | null>(null);
const macdLineRef = useRef<ISeriesApi<"Line"> | null>(null);
const macdSignalRef = useRef<ISeriesApi<"Line"> | null>(null);
const macdHistRef = useRef<ISeriesApi<"Histogram"> | null>(null);
// 목표가/손절가 가로 라인 — candleRef 의 priceLine 으로 부착.
const targetLineRef = useRef<IPriceLine | null>(null);
const stopLineRef = useRef<IPriceLine | null>(null);
// 사용자 수평선 — id → IPriceLine. localStorage 에 영속.
const hlineRefs = useRef<Map<string, IPriceLine>>(new Map());
// 보조 차트 crosshair 동기화용 time→value lookup.
// 메인 차트에서 hover 한 시점의 RSI/MACD 값을 O(1) 로 찾아 setCrosshairPosition 에
// 넣어줘야 보조 차트의 가격 라벨이 정확히 뜬다.
const rsiByTsRef = useRef<Map<number, number>>(new Map());
const macdSignalByTsRef = useRef<Map<number, number>>(new Map());
// 토글 상태 — 기본 MA20 만 ON. 사용자 컨트롤이 너무 시끄럽지 않게.
const [active, setActive] = useState<Set<SmaWindow>>(() => new Set<SmaWindow>([20]));
const [rsiOn, setRsiOn] = useState(false);
const [macdOn, setMacdOn] = useState(false);
// crosshair 위에 떠 있는 한국어 라벨 — null 이면 hover 안 함 (기본 마지막 봉 표시).
const [hover, setHover] = useState<CrosshairInfo | null>(null);
// 전체화면 모드 — Fullscreen API 가 동작하면 그게 우선, 안되면 fixed inset 오버레이 폴백.
// 두 경로 모두 같은 state 로 통일 — UI(버튼 라벨/컨테이너 클래스) 분기 단순.
const wrapperRef = useRef<HTMLDivElement | null>(null);
const [fullscreen, setFullscreen] = useState(false);
// 사용자 수평선 — localStorage 동기. 종목 전환 시 reload.
const [hlines, setHlines] = useState<HLine[]>([]);
const isIntraday = chart.interval === "10m";
const showSma = !isIntraday; // 10분봉에선 의미 적음 — UI 도 숨김.
const showRsi = !isIntraday && rsiOn;
const showMacd = !isIntraday && macdOn;
// chart 생성 — interval 바뀌면 timeVisible 토글을 위해 재생성.
useEffect(() => {
if (!containerRef.current) return;
const c = createChart(containerRef.current, {
layout: {
background: { color: "transparent" },
textColor: "#cbd5e1",
},
grid: {
vertLines: { color: "#1f2937" },
horzLines: { color: "#1f2937" },
},
rightPriceScale: { borderColor: "#374151" },
timeScale: {
borderColor: "#374151",
timeVisible: isIntraday,
secondsVisible: false,
},
autoSize: true,
crosshair: { mode: 1 },
});
const candle = c.addCandlestickSeries({
upColor: COLOR_UP,
downColor: COLOR_DOWN,
borderUpColor: COLOR_UP,
borderDownColor: COLOR_DOWN,
wickUpColor: COLOR_UP,
wickDownColor: COLOR_DOWN,
});
// 캔들 priceScale 을 위쪽 78% 로 좁혀서 거래량 막대를 아래쪽에 둘 공간 확보.
c.priceScale("right").applyOptions({
scaleMargins: { top: 0.05, bottom: 0.22 },
});
// 거래량 막대 — overlay priceScale("volume") 로 같은 chart 안에 띄움.
// bottom 0/top 0.82 → 차트 영역의 아래 18% 만 사용. 가격축 라벨은 숨김.
const volume = c.addHistogramSeries({
priceFormat: { type: "volume" },
priceScaleId: "volume",
priceLineVisible: false,
lastValueVisible: false,
});
c.priceScale("volume").applyOptions({
scaleMargins: { top: 0.82, bottom: 0 },
});
chartRef.current = c;
candleRef.current = candle;
volumeRef.current = volume;
const smaMap = smaRefs.current;
return () => {
c.remove();
chartRef.current = null;
candleRef.current = null;
volumeRef.current = null;
predRef.current = null;
smaMap.clear();
};
}, [isIntraday]);
// 실 데이터 push.
useEffect(() => {
if (!candleRef.current) return;
const data: CandlestickData[] = chart.ohlcv
.filter((p) => p.open !== null && p.high !== null && p.low !== null && p.close !== null)
.map((p) => ({
time: isoToUtcTs(p.date),
open: p.open as number,
high: p.high as number,
low: p.low as number,
close: p.close as number,
}));
candleRef.current.setData(data);
chartRef.current?.timeScale().fitContent();
}, [chart, isIntraday]);
// 거래량 막대 — 종가 ≥ 시가면 한국 관행으로 빨강(상승), 아니면 파랑(하락).
// null volume 은 그 시점 캔들 자체가 빠진 케이스(상장폐지/거래정지) 라 건너뛴다.
useEffect(() => {
if (!volumeRef.current) return;
const data: HistogramData[] = chart.ohlcv
.filter((p) => p.volume != null && p.volume > 0)
.map((p) => {
const up = p.close != null && p.open != null ? p.close >= p.open : true;
return {
time: isoToUtcTs(p.date),
value: p.volume as number,
color: up ? "#ef444466" : "#3b82f666",
};
});
volumeRef.current.setData(data);
}, [chart, isIntraday]);
// SMA 시리즈 — chart.ohlcv 종가 기준. 토글 변화 또는 데이터 변화 시 갱신.
// 비활성 윈도우는 제거하고, 활성은 재계산. 마지막에 fitContent 는 호출 안 함 — 사용자 줌 유지.
const closes = useMemo(() => chart.ohlcv.map((p) => p.close), [chart]);
useEffect(() => {
const c = chartRef.current;
if (!c) return;
if (!showSma) {
// intraday: 다 지움.
for (const s of smaRefs.current.values()) c.removeSeries(s);
smaRefs.current.clear();
return;
}
// 비활성 윈도우 제거.
for (const [w, s] of smaRefs.current) {
if (!active.has(w)) {
c.removeSeries(s);
smaRefs.current.delete(w);
}
}
// 활성 윈도우 생성/갱신.
for (const preset of SMA_PRESETS) {
if (!active.has(preset.window)) continue;
const values = sma(closes, preset.window);
const lineData: LineData[] = [];
for (let i = 0; i < values.length; i++) {
const v = values[i];
if (v == null) continue;
lineData.push({ time: isoToUtcTs(chart.ohlcv[i].date), value: v });
}
let s = smaRefs.current.get(preset.window);
if (!s) {
s = c.addLineSeries({
color: preset.color,
lineWidth: 1,
priceLineVisible: false,
lastValueVisible: false,
title: preset.label,
});
smaRefs.current.set(preset.window, s);
}
s.setData(lineData);
}
}, [active, closes, chart, showSma]);
// 예측 오버레이를 캔들 시리즈로 렌더.
// 합성 OHLC:
// open[i] = i==0 ? base_close : prev.point_close
// close[i] = point_close
// high[i] = max(ci_high, open, close)
// low[i] = min(ci_low, open, close)
// → 몸통 = 기간 사이 예상 변동, 위/아래 꼬리 = 신뢰구간 폭 (불확실성).
// 10분봉(intraday) 에서는 예측 표시 안 함 — 모델은 일봉 기반.
useEffect(() => {
if (!chartRef.current) return;
if (predRef.current) {
chartRef.current.removeSeries(predRef.current);
predRef.current = null;
}
if (isIntraday) return;
if (!prediction?.found || !prediction.steps?.length) return;
const baseDate = prediction.base_date;
const baseClose = prediction.base_close;
if (!baseDate || baseClose == null) return;
const sorted = [...prediction.steps]
.filter(
(s) =>
s.point_close != null && s.ci_low != null && s.ci_high != null && s.target_date,
)
.sort((a, b) => a.horizon - b.horizon);
if (!sorted.length) return;
const data: CandlestickData[] = [];
let prevClose = baseClose;
for (const s of sorted) {
const open = prevClose;
const close = s.point_close as number;
const ciHi = s.ci_high as number;
const ciLo = s.ci_low as number;
data.push({
time: isoToUtcTs(s.target_date),
open,
close,
high: Math.max(ciHi, open, close),
low: Math.min(ciLo, open, close),
});
prevClose = close;
}
const pred = chartRef.current.addCandlestickSeries({
upColor: PRED_UP,
downColor: PRED_DOWN,
borderUpColor: PRED_UP,
borderDownColor: PRED_DOWN,
wickUpColor: PRED_UP,
wickDownColor: PRED_DOWN,
priceLineVisible: false,
lastValueVisible: true,
title: "예측",
});
pred.setData(data);
predRef.current = pred;
chartRef.current.timeScale().fitContent();
}, [prediction, isIntraday]);
// 목표가/손절가 가로 라인 — targets prop 또는 캔들 시리즈 재생성 시 동기화.
// candleRef 가 바뀌면 이전 라인 ref 는 이미 해제된 상태라 그냥 새로 만들면 됨.
useEffect(() => {
const candle = candleRef.current;
if (!candle) return;
// 이전 라인 제거 후 새로 그림 — 입력 바뀔 때마다 가격값 변경하지 않고 그냥 재생성.
if (targetLineRef.current) {
try { candle.removePriceLine(targetLineRef.current); } catch { /* 이미 해제됨 */ }
targetLineRef.current = null;
}
if (stopLineRef.current) {
try { candle.removePriceLine(stopLineRef.current); } catch { /* 이미 해제됨 */ }
stopLineRef.current = null;
}
if (!targets) return;
if (targets.target != null && Number.isFinite(targets.target)) {
targetLineRef.current = candle.createPriceLine({
price: targets.target,
color: "#fb7185", // rose-400 (한국 상승색 톤)
lineWidth: 1,
lineStyle: 2, // dashed
axisLabelVisible: true,
title: "목표",
});
}
if (targets.stop != null && Number.isFinite(targets.stop)) {
stopLineRef.current = candle.createPriceLine({
price: targets.stop,
color: "#60a5fa", // sky-400 (한국 하락색 톤)
lineWidth: 1,
lineStyle: 2,
axisLabelVisible: true,
title: "손절",
});
}
}, [targets, isIntraday]);
// 사용자 수평선 로드 — 종목 전환 또는 캔들 시리즈 재생성 시.
useEffect(() => {
setHlines(readLines(chart.code));
}, [chart.code, isIntraday]);
// 수평선 렌더링 — id 단위 diff 로 변경된 것만 add/remove. 단순화를 위해
// 매번 전부 지우고 다시 그리는 방식 채택 (라인 수가 10 개 cap 이라 비용 무시).
useEffect(() => {
const candle = candleRef.current;
if (!candle) return;
for (const line of hlineRefs.current.values()) {
try { candle.removePriceLine(line); } catch { /* 이미 해제됨 */ }
}
hlineRefs.current.clear();
for (const hl of hlines) {
const line = candle.createPriceLine({
price: hl.price,
color: "#a3a3a3", // zinc-400 — 중립 (목표/손절 의도 색과 구분).
lineWidth: 1,
lineStyle: 0, // solid — 점선인 목표/손절과 시각적으로 분리.
axisLabelVisible: true,
title: "",
});
hlineRefs.current.set(hl.id, line);
}
}, [hlines, isIntraday]);
// RSI 보조 차트 생성/제거 — toggle 또는 interval 변경 시. 메인 chart 와 별 인스턴스.
// priceScale fix (0-100) + 30/70 가이드 라인.
useEffect(() => {
if (!showRsi || !rsiContainerRef.current) return;
const c = createChart(rsiContainerRef.current, {
layout: {
background: { color: "transparent" },
textColor: "#cbd5e1",
},
grid: {
vertLines: { color: "#1f2937" },
horzLines: { color: "#1f2937" },
},
rightPriceScale: {
borderColor: "#374151",
scaleMargins: { top: 0.1, bottom: 0.1 },
},
timeScale: {
borderColor: "#374151",
timeVisible: false,
secondsVisible: false,
},
autoSize: true,
crosshair: { mode: 1 },
handleScroll: false,
handleScale: false,
});
const s = c.addLineSeries({
color: "#e879f9",
lineWidth: 1,
priceLineVisible: false,
lastValueVisible: true,
title: "RSI(14)",
priceFormat: { type: "price", precision: 1, minMove: 0.1 },
});
s.createPriceLine({
price: 70,
color: "#ef4444",
lineWidth: 1,
lineStyle: 2,
axisLabelVisible: true,
title: "70",
});
s.createPriceLine({
price: 30,
color: "#3b82f6",
lineWidth: 1,
lineStyle: 2,
axisLabelVisible: true,
title: "30",
});
rsiChartRef.current = c;
rsiSeriesRef.current = s;
return () => {
c.remove();
rsiChartRef.current = null;
rsiSeriesRef.current = null;
};
}, [showRsi]);
// RSI 데이터 push + crosshair sync 용 ts→value map 채우기.
useEffect(() => {
if (!rsiSeriesRef.current) return;
const values = rsi(closes, 14);
const data: LineData[] = [];
const lookup = new Map<number, number>();
for (let i = 0; i < values.length; i++) {
const v = values[i];
if (v == null) continue;
const t = isoToUtcTs(chart.ohlcv[i].date);
data.push({ time: t, value: v });
lookup.set(t as number, v);
}
rsiSeriesRef.current.setData(data);
rsiByTsRef.current = lookup;
}, [closes, chart, showRsi]);
// MACD 보조 차트 생성/제거.
useEffect(() => {
if (!showMacd || !macdContainerRef.current) return;
const c = createChart(macdContainerRef.current, {
layout: { background: { color: "transparent" }, textColor: "#cbd5e1" },
grid: {
vertLines: { color: "#1f2937" },
horzLines: { color: "#1f2937" },
},
rightPriceScale: {
borderColor: "#374151",
scaleMargins: { top: 0.1, bottom: 0.1 },
},
timeScale: {
borderColor: "#374151",
timeVisible: false,
secondsVisible: false,
},
autoSize: true,
crosshair: { mode: 1 },
handleScroll: false,
handleScale: false,
});
const hist = c.addHistogramSeries({
priceLineVisible: false,
lastValueVisible: false,
priceFormat: { type: "price", precision: 2, minMove: 0.01 },
});
const macdLine = c.addLineSeries({
color: "#60a5fa",
lineWidth: 1,
priceLineVisible: false,
lastValueVisible: true,
title: "MACD",
priceFormat: { type: "price", precision: 2, minMove: 0.01 },
});
const signalLine = c.addLineSeries({
color: "#fb923c",
lineWidth: 1,
priceLineVisible: false,
lastValueVisible: true,
title: "Signal",
priceFormat: { type: "price", precision: 2, minMove: 0.01 },
});
macdChartRef.current = c;
macdHistRef.current = hist;
macdLineRef.current = macdLine;
macdSignalRef.current = signalLine;
return () => {
c.remove();
macdChartRef.current = null;
macdHistRef.current = null;
macdLineRef.current = null;
macdSignalRef.current = null;
};
}, [showMacd]);
// MACD 데이터 push + crosshair sync 용 signal ts→value map 채우기.
// signal 라인 기준으로 crosshair 가격 라벨이 뜨도록 — MACD 라인보다 안정적으로 보임.
useEffect(() => {
const hSeries = macdHistRef.current;
const mSeries = macdLineRef.current;
const sSeries = macdSignalRef.current;
if (!hSeries || !mSeries || !sSeries) return;
const { macd: m, signal: sig, hist } = macd(closes, 12, 26, 9);
const mLine: LineData[] = [];
const sLine: LineData[] = [];
const hBars: HistogramData[] = [];
const signalLookup = new Map<number, number>();
for (let i = 0; i < closes.length; i++) {
const t = isoToUtcTs(chart.ohlcv[i].date);
if (m[i] != null) mLine.push({ time: t, value: m[i] as number });
if (sig[i] != null) {
sLine.push({ time: t, value: sig[i] as number });
signalLookup.set(t as number, sig[i] as number);
}
if (hist[i] != null) {
const h = hist[i] as number;
// 한국 관행: 양수 막대=상승=빨강, 음수=하락=파랑. 컬러 옅게 (배경 톤).
hBars.push({ time: t, value: h, color: h >= 0 ? "#ef444488" : "#3b82f688" });
}
}
hSeries.setData(hBars);
mSeries.setData(mLine);
sSeries.setData(sLine);
macdSignalByTsRef.current = signalLookup;
}, [closes, chart, showMacd]);
// crosshair → 한국어 오버레이.
// - 같은 time(UTCTimestamp) 의 ohlcv 포인트를 O(1) 조회용 Map 으로 미리 만든다.
// - hover 안 했을 때는 마지막 봉을 기본으로 표시 (토스 패턴 — 차트에 들어오자마자 정보 보임).
const ohlcvByTs = useMemo(() => {
const m = new Map<number, ChartPayload["ohlcv"][number]>();
for (const p of chart.ohlcv) m.set(isoToUtcTs(p.date) as number, p);
return m;
}, [chart]);
const lastSummary = useMemo<CrosshairInfo | null>(() => {
const pts = chart.ohlcv.filter((p) => p.close != null);
if (!pts.length) return null;
const last = pts[pts.length - 1];
const pct =
last.open != null && last.close != null && last.open > 0
? ((last.close - last.open) / last.open) * 100
: null;
return {
label: tsToKLabel(isoToUtcTs(last.date), isIntraday),
open: last.open,
high: last.high,
low: last.low,
close: last.close,
volume: last.volume,
pct,
};
}, [chart, isIntraday]);
// H 키로 수평선 추가 시 사용할 "현재 가격" ref — hover 있으면 hover.close, 없으면
// lastSummary.close. ref 로 들고 있어 keydown effect 가 매번 재구독되지 않음.
const currentPriceRef = useRef<number | null>(null);
useEffect(() => {
const p = hover?.close ?? lastSummary?.close ?? null;
currentPriceRef.current = p;
}, [hover, lastSummary]);
// 차트 위 모디파이어 클릭으로 수평선 추가/제거.
// Shift+Click — 클릭 y 좌표를 candle.coordinateToPrice 로 변환해 새 라인 추가.
// H 키 / 버튼은 hover 가격(또는 마지막 봉)에 묶여 있어 마우스 사용자가 "지금 hover 한
// 곳이 아닌 다른 위치" 에 그릴 방법이 없었음.
// Alt+Click — 클릭 y 좌표 근처(픽셀 임계값 내)의 hline 한 개 제거. chips 의 ✕ 버튼이
// 좁고 마우스 정밀 클릭을 요구하는 반면, 차트 위 라인 근처 클릭은 자연스러움.
// (lightweight-charts `IPriceLine` 자체에는 클릭 이벤트가 없어 좌표 매칭 패턴 사용.)
// 단순 Click 은 기존 차트 인터랙션(crosshair/drag) 그대로 — 모디파이어가 있을 때만 가로챔.
// Shift+Alt 동시 입력은 add 가 우선 (먼저 검사, 빈도 더 높음).
const HLINE_HIT_PX = 6; // 라인까지 ±6px 이내 클릭이면 그 라인 hit 으로 간주.
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const onClick = (e: MouseEvent) => {
if (!e.shiftKey && !e.altKey) return;
const candle = candleRef.current;
if (!candle) return;
const rect = el.getBoundingClientRect();
const y = e.clientY - rect.top;
if (e.shiftKey) {
if (hlines.length >= 10) return; // cap — chips 의 (N/10) 카운터로 확인 가능.
const raw = candle.coordinateToPrice(y);
if (raw == null) return;
const price = Number(raw);
if (!Number.isFinite(price) || price <= 0) return;
e.preventDefault();
setHlines(addLine(chart.code, price));
return;
}
// Alt+Click — 가장 가까운 hline 찾아서 임계값 내면 제거.
if (hlines.length === 0) return;
let nearestId: string | null = null;
let nearestDist = Infinity;
for (const hl of hlines) {
const yLine = candle.priceToCoordinate(hl.price);
if (yLine == null) continue;
const d = Math.abs(Number(yLine) - y);
if (d < nearestDist) {
nearestDist = d;
nearestId = hl.id;
}
}
if (nearestId != null && nearestDist <= HLINE_HIT_PX) {
e.preventDefault();
setHlines(removeLine(chart.code, nearestId));
}
};
el.addEventListener("click", onClick);
return () => el.removeEventListener("click", onClick);
}, [chart.code, hlines]);
useEffect(() => {
const c = chartRef.current;
if (!c) return;
const handler = (param: MouseEventParams) => {
// 차트 영역 밖이면 hover 해제 → lastSummary 가 자동 표시 + 보조 차트 crosshair clear.
if (!param.time || !param.point) {
setHover(null);
rsiChartRef.current?.clearCrosshairPosition();
macdChartRef.current?.clearCrosshairPosition();
return;
}
const t = param.time as UTCTimestamp;
const p = ohlcvByTs.get(t as number);
if (!p) {
setHover(null);
rsiChartRef.current?.clearCrosshairPosition();
macdChartRef.current?.clearCrosshairPosition();
return;
}
const pct =
p.open != null && p.close != null && p.open > 0
? ((p.close - p.open) / p.open) * 100
: null;
setHover({
label: tsToKLabel(t, isIntraday),
open: p.open,
high: p.high,
low: p.low,
close: p.close,
volume: p.volume,
pct,
});
// 보조 차트 crosshair 같은 시점으로 이동 — 그 시점 데이터가 있을 때만.
// setCrosshairPosition 은 series 의 price 좌표를 받음.
const rsiChart = rsiChartRef.current;
const rsiSeries = rsiSeriesRef.current;
if (rsiChart && rsiSeries) {
const v = rsiByTsRef.current.get(t as number);
if (v != null) rsiChart.setCrosshairPosition(v, t, rsiSeries);
else rsiChart.clearCrosshairPosition();
}
const macdChart = macdChartRef.current;
const macdSig = macdSignalRef.current;
if (macdChart && macdSig) {
const v = macdSignalByTsRef.current.get(t as number);
if (v != null) macdChart.setCrosshairPosition(v, t, macdSig);
else macdChart.clearCrosshairPosition();
}
};
c.subscribeCrosshairMove(handler);
return () => {
c.unsubscribeCrosshairMove(handler);
};
}, [ohlcvByTs, isIntraday]);
// 메인 ↔ 보조패널 timeScale 단방향 동기화. RSI/MACD 둘 다 처리.
useEffect(() => {
const main = chartRef.current;
if (!main) return;
const followers: IChartApi[] = [];
if (showRsi && rsiChartRef.current) followers.push(rsiChartRef.current);
if (showMacd && macdChartRef.current) followers.push(macdChartRef.current);
if (!followers.length) return;
const handler = (range: LogicalRange | null) => {
if (!range) return;
for (const f of followers) f.timeScale().setVisibleLogicalRange(range);
};
const initial = main.timeScale().getVisibleLogicalRange();
if (initial) {
for (const f of followers) f.timeScale().setVisibleLogicalRange(initial);
}
main.timeScale().subscribeVisibleLogicalRangeChange(handler);
return () => {
main.timeScale().unsubscribeVisibleLogicalRangeChange(handler);
};
}, [showRsi, showMacd, chart]);
// 전체화면 토글 — Fullscreen API 가능하면 native, 아니면 오버레이 폴백.
// iOS Safari 등 element.requestFullscreen 미지원 환경은 자연히 폴백 경로로.
const toggleFullscreen = useCallback(async () => {
const el = wrapperRef.current;
if (!el || typeof document === "undefined") return;
const docAny = document as Document & { fullscreenEnabled?: boolean };
if (docAny.fullscreenEnabled && typeof el.requestFullscreen === "function") {
try {
if (document.fullscreenElement) {
await document.exitFullscreen();
} else {
await el.requestFullscreen();
}
return;
} catch {
// 권한 거부/실패 → 오버레이 폴백.
}
}
setFullscreen((v) => !v);
}, []);
// Native fullscreen 상태 → state 동기화. ESC 로 빠져나오면 여기로 들어옴.
useEffect(() => {
if (typeof document === "undefined") return;
const onChange = () => setFullscreen(!!document.fullscreenElement);
document.addEventListener("fullscreenchange", onChange);
return () => document.removeEventListener("fullscreenchange", onChange);
}, []);
// F 키 = 토글, ESC = 폴백 오버레이 해제 (native 는 브라우저가 알아서).
// 입력 필드에 포커스가 있으면 무시 — 메모 작성 등 방해 금지.
useEffect(() => {
if (typeof window === "undefined") return;
const onKey = (e: KeyboardEvent) => {
const t = e.target as HTMLElement | null;
if (
t &&
(t.tagName === "INPUT" ||
t.tagName === "TEXTAREA" ||
t.tagName === "SELECT" ||
t.isContentEditable)
) {
return;
}
// 모달/대화상자가 열려 있으면 차트 단축키도 양보 — ShortcutsHelp 등이 열린 상태에서
// 뒤의 차트가 전체화면으로 토글되는 것을 막음.
if (document.querySelector('[role="dialog"][aria-modal="true"]')) return;
// Ctrl+F / Cmd+F (브라우저 찾기) / Alt+F (브라우저 메뉴) / Shift+F 는 가로채지 않음.
// 단순 F 만 차트 전체화면 토글로 사용.
const noMod = !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey;
if (noMod && e.key.toLowerCase() === "f") {
e.preventDefault();
void toggleFullscreen();
} else if (noMod && e.key.toLowerCase() === "h") {
// H 키 — 현재 가격(hover 또는 마지막 봉 종가)에 수평선 추가.
const price = currentPriceRef.current;
if (price != null && Number.isFinite(price) && price > 0) {
e.preventDefault();
setHlines(addLine(chart.code, price));
}
} else if (e.key === "Escape" && fullscreen && !document.fullscreenElement) {
setFullscreen(false);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [toggleFullscreen, fullscreen, chart.code]);
const toggle = (w: SmaWindow) => {
setActive((cur) => {
const next = new Set(cur);
if (next.has(w)) next.delete(w);
else next.add(w);
return next;
});
};
// 전체화면(native or 폴백) 일 때는 viewport 를 꽉 채워서 차트 면적 최대화.
// 일반 모드는 기존 카드 스타일 유지.
const wrapperCls = fullscreen
? "fixed inset-0 z-50 flex w-full flex-col overflow-auto bg-zinc-950 p-3"
: "w-full rounded-lg border border-zinc-800 bg-zinc-900/30 p-2";
// 차트 본체 높이 — 전체화면이면 가용 공간을 차지하도록 flex-1.
const chartBoxCls = fullscreen
? "relative min-h-[300px] flex-1 w-full"
: "relative h-[460px] w-full";
return (
<div ref={wrapperRef} className={wrapperCls}>
{/*
툴바를 2 그룹으로 분리:
A) 보조지표 (MA5/20/60 + RSI + MACD) — 일봉 계열에서만 의미. 안 보이면 row 자체 생략.
B) 액션 (전체화면 종목 라벨 + 수평선 + CSV + 전체화면 토글) — 항상 표시.
같은 줄에 다 묶었을 때 모바일/좁은 폭에서 indicators 와 actions 가 섞여서 wrap 되며
"ml-auto" 가 줄바꿈 직전에서만 분리 효과를 주고 그 외엔 actions 가 indicators 사이로 끼어듦.
분리하면 indicators wrap 은 indicators 안에서만 일어남 — actions 그룹은 자체 정렬 유지.
*/}
{showSma && (
<div className="mb-1 flex flex-wrap items-center gap-1.5 px-1">
{SMA_PRESETS.map((p) => {
const on = active.has(p.window);
return (
<button
key={p.window}
type="button"
onClick={() => toggle(p.window)}
className={`flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] transition ${
on
? "border-zinc-600 bg-zinc-800/80 text-zinc-100"
: "border-zinc-800 bg-transparent text-zinc-500 hover:text-zinc-300"
}`}
aria-pressed={on}
>
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: on ? p.color : "transparent", border: `1px solid ${p.color}` }}
/>
{p.label}
</button>
);
})}
<span className="mx-1 text-zinc-700">|</span>
<button
type="button"
onClick={() => setRsiOn((v) => !v)}
className={`flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] transition ${
rsiOn
? "border-zinc-600 bg-zinc-800/80 text-zinc-100"
: "border-zinc-800 bg-transparent text-zinc-500 hover:text-zinc-300"
}`}
aria-pressed={rsiOn}
title="상대강도지수 (Relative Strength Index, 14일)"
>
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: rsiOn ? "#e879f9" : "transparent", border: "1px solid #e879f9" }}
/>
RSI
</button>
<button
type="button"
onClick={() => setMacdOn((v) => !v)}
className={`flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] transition ${
macdOn
? "border-zinc-600 bg-zinc-800/80 text-zinc-100"
: "border-zinc-800 bg-transparent text-zinc-500 hover:text-zinc-300"
}`}
aria-pressed={macdOn}
title="MACD (12/26/9) — 추세 전환 신호"
>
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: macdOn ? "#60a5fa" : "transparent", border: "1px solid #60a5fa" }}
/>
MACD
</button>
</div>
)}
<div className="mb-1 flex flex-wrap items-center gap-1.5 px-1">
{/*
전체화면 모드 한정 종목 라벨 — 일반 모드는 위에 PriceHero 가 종목명/코드를 크게 보여주지만
fullscreen 은 fixed inset 으로 PriceHero 가 가려져서 무슨 종목 차트인지 식별이 어려워짐.
actions row leading 자리에 작은 라벨을 끼워 시각적 노이즈 최소로 보완.
(캔버스 위의 CrosshairLabel 과 위치 겹치지 않도록 캔버스가 아닌 툴바에 둠.)
*/}
{fullscreen && (
<span className="mr-1 flex items-baseline gap-1.5 text-xs">
<span className="font-semibold text-zinc-100">{chart.name}</span>
<span className="font-mono text-[10px] text-zinc-500">{chart.code}</span>
</span>
)}
<button
type="button"
onClick={() => {
const price = hover?.close ?? lastSummary?.close ?? null;
if (price != null && Number.isFinite(price) && price > 0) {
setHlines(addLine(chart.code, price));
}
}}
disabled={hlines.length >= 10 || (hover?.close ?? lastSummary?.close ?? null) == null}
className="ml-auto flex items-center gap-1 rounded-full border border-zinc-800 px-2 py-0.5 text-[10px] text-zinc-400 transition hover:border-zinc-600 hover:text-zinc-200 disabled:cursor-not-allowed disabled:opacity-40"
title="현재 가격(hover 또는 마지막 봉 종가)에 수평선 추가 (H · Shift+클릭으로 임의 위치 · Alt+클릭으로 라인 제거)"
>
({hlines.length}/10)
</button>
<button
type="button"
onClick={() => {
const filename = csvFilename(
chart.name,
chart.code,
chart.interval,
chart.range.from,
chart.range.to,
);
downloadCsv(filename, ohlcvToCsv(chart.ohlcv));
}}
disabled={!chart.ohlcv.length}
className="flex items-center gap-1 rounded-full border border-zinc-800 px-2 py-0.5 text-[10px] text-zinc-400 transition hover:border-zinc-600 hover:text-zinc-200 disabled:cursor-not-allowed disabled:opacity-40"
title={`현재 로드된 기간(${chart.range.from} ~ ${chart.range.to}) 의 봉 데이터를 CSV 로 내보내기 (UTF-8 BOM, Excel 호환)`}
>
CSV
</button>
<button
type="button"
onClick={() => void toggleFullscreen()}
className="flex items-center gap-1 rounded-full border border-zinc-800 px-2 py-0.5 text-[10px] text-zinc-400 transition hover:border-zinc-600 hover:text-zinc-200"
title={fullscreen ? "전체화면 해제 (ESC / F)" : "전체화면 (F)"}
aria-pressed={fullscreen}
>
{fullscreen ? "⛶ 해제" : "⛶ 전체화면"}
</button>
</div>
{hlines.length > 0 && (
<div className="mb-1 flex flex-wrap items-center gap-1 px-1">
<span className="text-[10px] text-zinc-500">:</span>
{hlines.map((hl) => (
<span
key={hl.id}
className="flex items-center gap-1 rounded-full border border-zinc-800 bg-zinc-900/60 pl-2 pr-1 py-0.5 text-[10px] tabular-nums text-zinc-300"
>
{fmtKPrice(hl.price)}
<button
type="button"
onClick={() => setHlines(removeLine(chart.code, hl.id))}
className="rounded-full px-1 text-[10px] text-zinc-500 hover:text-zinc-100"
title="이 수평선 제거"
aria-label={`${fmtKPrice(hl.price)} 원 수평선 제거`}
>
</button>
</span>
))}
<button
type="button"
onClick={() => setHlines(clearLines(chart.code))}
className="ml-1 rounded-full border border-zinc-800 px-2 py-0.5 text-[10px] text-zinc-500 hover:border-zinc-600 hover:text-zinc-200"
title="모든 수평선 제거"
>
</button>
</div>
)}
<div className={chartBoxCls}>
<div ref={containerRef} className="h-full w-full" />
<CrosshairLabel info={hover ?? lastSummary} isHover={hover != null} />
</div>
{showRsi && (
<div className="mt-2 h-[110px] w-full border-t border-zinc-800 pt-1">
<div ref={rsiContainerRef} className="h-full w-full" />
</div>
)}
{showMacd && (
<div className="mt-2 h-[110px] w-full border-t border-zinc-800 pt-1">
<div ref={macdContainerRef} className="h-full w-full" />
</div>
)}
{prediction?.found && !isIntraday && (
<div className="mt-1 flex items-center justify-end gap-2 px-2 text-[11px] text-zinc-500">
<span className="inline-block h-2 w-2 rounded-sm bg-rose-300" />
<span className="inline-block h-2 w-2 rounded-sm bg-sky-300" />
<span> ( = q10~q90 )</span>
</div>
)}
</div>
);
}
// Toss 차트의 hover 정보 박스. 좌상단 absolute 로 띄우고 pointer-events-none
// 으로 마우스 이벤트는 그대로 차트가 받음. info=null 이면 표시 안 함.
function CrosshairLabel({
info,
isHover,
}: {
info: CrosshairInfo | null;
isHover: boolean;
}) {
if (!info) return null;
const { label, open, high, low, close, volume, pct } = info;
// 등락률 색 — 한국 관행. 0 또는 null 은 회색.
const pctCls =
pct == null || pct === 0
? "text-zinc-400"
: pct > 0
? "text-rose-300"
: "text-sky-300";
const pctText =
pct == null ? "" : `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%`;
return (
<div
className={`pointer-events-none absolute left-2 top-2 z-10 rounded-md border border-zinc-700/70 bg-zinc-950/80 px-2.5 py-1.5 text-[11px] tabular-nums shadow backdrop-blur-sm ${
isHover ? "opacity-100" : "opacity-80"
}`}
>
<div className="mb-0.5 flex items-center gap-1.5 text-zinc-400">
<span>{label}</span>
{pctText && <span className={pctCls}>{pctText}</span>}
</div>
<div className="flex flex-wrap gap-x-2 gap-y-0.5 text-zinc-200">
<CrosshairCell k="시" v={fmtKPrice(open)} />
<CrosshairCell k="고" v={fmtKPrice(high)} tone="up" />
<CrosshairCell k="저" v={fmtKPrice(low)} tone="down" />
<CrosshairCell k="종" v={fmtKPrice(close)} strong />
<CrosshairCell k="거래량" v={fmtKVolume(volume)} />
</div>
</div>
);
}
function CrosshairCell({
k,
v,
tone,
strong,
}: {
k: string;
v: string;
tone?: "up" | "down";
strong?: boolean;
}) {
const tcls =
tone === "up"
? "text-rose-300"
: tone === "down"
? "text-sky-300"
: strong
? "text-zinc-100"
: "text-zinc-300";
return (
<span className="flex items-baseline gap-1">
<span className="text-zinc-500">{k}</span>
<span className={tcls}>{v}</span>
</span>
);
}