Files
stock_chart_site/web/components/StockChart.tsx
claude-owner 1e0c917f68 feat(chart): CSV export of current chart range + fix ShareButton comment
- New lib/csv.ts: RFC 4180 escaping, UTF-8 BOM prefix (한글 Excel 호환),
  Blob + temporary anchor download, filename
  `<name>_<code>_<interval>_<from>_<to>.csv` with filesystem-illegal
  characters replaced by `_`.
- StockChart toolbar now always renders (was hidden in 10m intraday mode
  because it gated on showSma) and includes a right-aligned `⬇ CSV`
  button next to the SMA/RSI/MACD chips. Button is disabled when ohlcv
  is empty.

Also addresses the reviewer's non-blocking note on f18493f:
ShareButton.tsx header comment claimed clipboard-first, but the code
calls navigator.share() first. Comment now matches the actual order:
share → clipboard → execCommand → "복사 안됨".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 00:11:57 +09:00

932 lines
32 KiB
TypeScript

"use client";
import { 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 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);
// 보조 차트 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);
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]);
// 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]);
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]);
const toggle = (w: SmaWindow) => {
setActive((cur) => {
const next = new Set(cur);
if (next.has(w)) next.delete(w);
else next.add(w);
return next;
});
};
return (
<div className="w-full rounded-lg border border-zinc-800 bg-zinc-900/30 p-2">
<div className="mb-1 flex flex-wrap items-center gap-1.5 px-1">
{showSma && (
<>
{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>
</>
)}
<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="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="현재 봉 데이터를 CSV 로 내보내기 (UTF-8 BOM, Excel 호환)"
>
CSV
</button>
</div>
<div className="relative h-[460px] w-full">
<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>
);
}