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 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user