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:
@@ -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<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 도 숨김.
|
||||
@@ -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<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 둘 다 처리.
|
||||
useEffect(() => {
|
||||
const main = chartRef.current;
|
||||
@@ -630,8 +736,9 @@ export function StockChart({ chart, prediction }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="h-[460px] w-full">
|
||||
<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">
|
||||
@@ -653,3 +760,71 @@ export function StockChart({ chart, prediction }: Props) {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user