feat(chart): crosshair 한국어 OHLC 오버레이 (토스 스타일)

차트 위에 hover 위치 봉의 날짜(+요일)/OHLC/등락률/거래량을 띄우는 좌상단
정보 박스 추가. lightweight-charts subscribeCrosshairMove 로 hover 좌표를
잡고, ohlcv 를 timestamp 기준 Map 으로 O(1) 조회. hover 가 차트 밖일 때는
마지막 봉을 기본 표시해 페이지 진입 직후에도 정보가 보임.

- 거래량 fmt: 억/만 (사이드바와 동일)
- 등락률: (close-open)/open, 한국 컬러 (상승=#ef4444 / 하락=#3b82f6)
- intraday(10분봉) 면 시:분 까지 포함
This commit is contained in:
claude-owner
2026-05-28 19:31:35 +09:00
parent 52733df235
commit 38db4b8c24

View File

@@ -9,6 +9,7 @@ import {
type ISeriesApi, type ISeriesApi,
type LineData, type LineData,
type LogicalRange, type LogicalRange,
type MouseEventParams,
type UTCTimestamp, type UTCTimestamp,
} from "lightweight-charts"; } from "lightweight-charts";
import type { ChartPayload, LatestPredictionResponse } from "../lib/api"; import type { ChartPayload, LatestPredictionResponse } from "../lib/api";
@@ -42,6 +43,46 @@ function isoToUtcTs(s: string): UTCTimestamp {
// 한국 거래소 관행: 상승=빨강, 하락=파랑. // 한국 거래소 관행: 상승=빨강, 하락=파랑.
const COLOR_UP = "#ef4444"; const COLOR_UP = "#ef4444";
const COLOR_DOWN = "#3b82f6"; 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 색 옅은 톤. "어우러지되 미래 데이터임이 명확" 한 시각 신호. // 예측 캔들은 같은 up/down 색 옅은 톤. "어우러지되 미래 데이터임이 명확" 한 시각 신호.
const PRED_UP = "#fda4af"; const PRED_UP = "#fda4af";
const PRED_DOWN = "#93c5fd"; const PRED_DOWN = "#93c5fd";
@@ -179,6 +220,8 @@ export function StockChart({ chart, prediction }: Props) {
const [active, setActive] = useState<Set<SmaWindow>>(() => new Set<SmaWindow>([20])); const [active, setActive] = useState<Set<SmaWindow>>(() => new Set<SmaWindow>([20]));
const [rsiOn, setRsiOn] = useState(false); const [rsiOn, setRsiOn] = useState(false);
const [macdOn, setMacdOn] = useState(false); const [macdOn, setMacdOn] = useState(false);
// crosshair 위에 떠 있는 한국어 라벨 — null 이면 hover 안 함 (기본 마지막 봉 표시).
const [hover, setHover] = useState<CrosshairInfo | null>(null);
const isIntraday = chart.interval === "10m"; const isIntraday = chart.interval === "10m";
const showSma = !isIntraday; // 10분봉에선 의미 적음 — UI 도 숨김. const showSma = !isIntraday; // 10분봉에선 의미 적음 — UI 도 숨김.
@@ -536,6 +579,69 @@ export function StockChart({ chart, prediction }: Props) {
sSeries.setData(sLine); sSeries.setData(sLine);
}, [closes, chart, showMacd]); }, [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 가 자동 표시됨.
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 둘 다 처리. // 메인 ↔ 보조패널 timeScale 단방향 동기화. RSI/MACD 둘 다 처리.
useEffect(() => { useEffect(() => {
const main = chartRef.current; const main = chartRef.current;
@@ -630,8 +736,9 @@ export function StockChart({ chart, prediction }: Props) {
</button> </button>
</div> </div>
)} )}
<div className="h-[460px] w-full"> <div className="relative h-[460px] w-full">
<div ref={containerRef} className="h-full w-full" /> <div ref={containerRef} className="h-full w-full" />
<CrosshairLabel info={hover ?? lastSummary} isHover={hover != null} />
</div> </div>
{showRsi && ( {showRsi && (
<div className="mt-2 h-[110px] w-full border-t border-zinc-800 pt-1"> <div className="mt-2 h-[110px] w-full border-t border-zinc-800 pt-1">
@@ -653,3 +760,71 @@ export function StockChart({ chart, prediction }: Props) {
</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>
);
}