diff --git a/web/components/StockChart.tsx b/web/components/StockChart.tsx index bb43c6a..155e633 100644 --- a/web/components/StockChart.tsx +++ b/web/components/StockChart.tsx @@ -9,6 +9,7 @@ import { type ISeriesApi, type LineData, type LogicalRange, + type MouseEventParams, type UTCTimestamp, } from "lightweight-charts"; import type { ChartPayload, LatestPredictionResponse } from "../lib/api"; @@ -42,6 +43,46 @@ function isoToUtcTs(s: string): 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"; @@ -179,6 +220,8 @@ export function StockChart({ chart, prediction }: Props) { const [active, setActive] = useState>(() => new Set([20])); const [rsiOn, setRsiOn] = useState(false); const [macdOn, setMacdOn] = useState(false); + // crosshair 위에 떠 있는 한국어 라벨 — null 이면 hover 안 함 (기본 마지막 봉 표시). + const [hover, setHover] = useState(null); const isIntraday = chart.interval === "10m"; const showSma = !isIntraday; // 10분봉에선 의미 적음 — UI 도 숨김. @@ -536,6 +579,69 @@ export function StockChart({ chart, prediction }: Props) { sSeries.setData(sLine); }, [closes, chart, showMacd]); + // crosshair → 한국어 오버레이. + // - 같은 time(UTCTimestamp) 의 ohlcv 포인트를 O(1) 조회용 Map 으로 미리 만든다. + // - hover 안 했을 때는 마지막 봉을 기본으로 표시 (토스 패턴 — 차트에 들어오자마자 정보 보임). + const ohlcvByTs = useMemo(() => { + const m = new Map(); + for (const p of chart.ohlcv) m.set(isoToUtcTs(p.date) as number, p); + return m; + }, [chart]); + + const lastSummary = useMemo(() => { + 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 가 자동 표시됨. + if (!param.time || !param.point) { + setHover(null); + return; + } + const t = param.time as UTCTimestamp; + const p = ohlcvByTs.get(t as number); + if (!p) { + setHover(null); + 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, + }); + }; + c.subscribeCrosshairMove(handler); + return () => { + c.unsubscribeCrosshairMove(handler); + }; + }, [ohlcvByTs, isIntraday]); + // 메인 ↔ 보조패널 timeScale 단방향 동기화. RSI/MACD 둘 다 처리. useEffect(() => { const main = chartRef.current; @@ -630,8 +736,9 @@ export function StockChart({ chart, prediction }: Props) { )} -
+
+
{showRsi && (
@@ -653,3 +760,71 @@ export function StockChart({ chart, prediction }: Props) {
); } + +// 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 ( +
+
+ {label} + {pctText && {pctText}} +
+
+ + + + + +
+
+ ); +} + +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 ( + + {k} + {v} + + ); +}