From ef127ae2e8d5f07f7ccbe463c8be3e7cdaab3788 Mon Sep 17 00:00:00 2001 From: claude-owner Date: Thu, 28 May 2026 23:49:29 +0900 Subject: [PATCH] =?UTF-8?q?feat(chart):=20=EB=A9=94=EC=9D=B8=20=E2=86=92?= =?UTF-8?q?=20RSI/MACD=20crosshair=20=EB=8F=99=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 토스 차트 인상 — 메인 캔들에 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. --- web/components/StockChart.tsx | 46 +++++++++++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/web/components/StockChart.tsx b/web/components/StockChart.tsx index 155e633..8205df1 100644 --- a/web/components/StockChart.tsx +++ b/web/components/StockChart.tsx @@ -216,6 +216,12 @@ export function StockChart({ chart, prediction }: Props) { const macdSignalRef = useRef | null>(null); const macdHistRef = useRef | null>(null); + // 보조 차트 crosshair 동기화용 time→value lookup. + // 메인 차트에서 hover 한 시점의 RSI/MACD 값을 O(1) 로 찾아 setCrosshairPosition 에 + // 넣어줘야 보조 차트의 가격 라벨이 정확히 뜬다. + const rsiByTsRef = useRef>(new Map()); + const macdSignalByTsRef = useRef>(new Map()); + // 토글 상태 — 기본 MA20 만 ON. 사용자 컨트롤이 너무 시끄럽지 않게. const [active, setActive] = useState>(() => new Set([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(); 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(); 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 () => {