feat(chart): 메인 → RSI/MACD crosshair 동기화

토스 차트 인상 — 메인 캔들에 hover 하면 RSI/MACD 보조 패널도 같은 시점을
가리키며 가격 라벨이 뜬다. lightweight-charts setCrosshairPosition /
clearCrosshairPosition API 활용.

- rsiByTsRef / macdSignalByTsRef: 보조 시계열의 ts→value O(1) lookup.
  RSI/MACD 데이터 push 할 때 동시에 채움.
- crosshair handler: hover 시 보조 차트도 setCrosshairPosition,
  마우스가 차트 밖이면 clearCrosshairPosition 으로 양쪽 다 해제.
- MACD 는 signal 라인 기준 — 가격 라벨이 안정적으로 보이도록.
- 보조 차트 toggle off / 데이터 부족 시점은 ref guard 와 lookup miss 로 자연스럽게 noop.
This commit is contained in:
claude-owner
2026-05-28 23:49:29 +09:00
parent 759ea20daa
commit ef127ae2e8

View File

@@ -216,6 +216,12 @@ export function StockChart({ chart, prediction }: Props) {
const macdSignalRef = useRef<ISeriesApi<"Line"> | null>(null);
const macdHistRef = useRef<ISeriesApi<"Histogram"> | 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);
@@ -484,17 +490,21 @@ export function StockChart({ chart, prediction }: Props) {
};
}, [showRsi]);
// RSI 데이터 push.
// 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;
data.push({ time: isoToUtcTs(chart.ohlcv[i].date), value: v });
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 보조 차트 생성/제거.
@@ -554,7 +564,8 @@ export function StockChart({ chart, prediction }: Props) {
};
}, [showMacd]);
// MACD 데이터 push.
// MACD 데이터 push + crosshair sync 용 signal ts→value map 채우기.
// signal 라인 기준으로 crosshair 가격 라벨이 뜨도록 — MACD 라인보다 안정적으로 보임.
useEffect(() => {
const hSeries = macdHistRef.current;
const mSeries = macdLineRef.current;
@@ -564,10 +575,14 @@ export function StockChart({ chart, prediction }: Props) {
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 });
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;
// 한국 관행: 양수 막대=상승=빨강, 음수=하락=파랑. 컬러 옅게 (배경 톤).
@@ -577,6 +592,7 @@ export function StockChart({ chart, prediction }: Props) {
hSeries.setData(hBars);
mSeries.setData(mLine);
sSeries.setData(sLine);
macdSignalByTsRef.current = signalLookup;
}, [closes, chart, showMacd]);
// crosshair → 한국어 오버레이.
@@ -611,15 +627,19 @@ export function StockChart({ chart, prediction }: Props) {
const c = chartRef.current;
if (!c) return;
const handler = (param: MouseEventParams) => {
// 차트 영역 밖이면 hover 해제 → lastSummary 가 자동 표시.
// 차트 영역 밖이면 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 =
@@ -635,6 +655,22 @@ export function StockChart({ chart, prediction }: Props) {
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 () => {