"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createChart, type CandlestickData, type HistogramData, type IChartApi, type IPriceLine, type ISeriesApi, type LineData, type LogicalRange, type MouseEventParams, type UTCTimestamp, } from "lightweight-charts"; import type { ChartPayload, LatestPredictionResponse } from "../lib/api"; import { csvFilename, downloadCsv, ohlcvToCsv } from "../lib/csv"; import { addLine, clearLines, type HLine, readLines, removeLine } from "../lib/lines"; import type { Targets } from "../lib/targets"; type Props = { chart: ChartPayload; prediction?: LatestPredictionResponse | null; // 사용자가 저장한 목표가/손절가 — 메인 캔들 위 가로 라인 오버레이. targets?: Targets | null; }; // KST naive ISO ('YYYY-MM-DD' 또는 'YYYY-MM-DDTHH:MM:SS') 를 UTCTimestamp 로. // lightweight-charts 는 UTC 라고 가정하지만 KST wall-clock 을 UTC 처럼 넣어 // timeScale 표시가 한국 사용자에게 자연스럽게 KST 그대로 나오도록 함. function isoToUtcTs(s: string): UTCTimestamp { if (s.length <= 10) { return (Date.UTC( Number(s.slice(0, 4)), Number(s.slice(5, 7)) - 1, Number(s.slice(8, 10)), ) / 1000) as UTCTimestamp; } return (Date.UTC( Number(s.slice(0, 4)), Number(s.slice(5, 7)) - 1, Number(s.slice(8, 10)), Number(s.slice(11, 13)), Number(s.slice(14, 16)), Number(s.slice(17, 19) || "0"), ) / 1000) as 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"; // 단순 이동평균. 윈도우 미만은 null (lightweight-charts 는 whitespace 로 처리됨). function sma(values: (number | null)[], window: number): (number | null)[] { const out: (number | null)[] = new Array(values.length).fill(null); let sum = 0; let count = 0; const buf: (number | null)[] = []; for (let i = 0; i < values.length; i++) { const v = values[i]; buf.push(v); if (v != null) { sum += v; count += 1; } if (buf.length > window) { const drop = buf.shift(); if (drop != null) { sum -= drop; count -= 1; } } if (buf.length === window && count === window) { out[i] = sum / window; } } return out; } type SmaWindow = 5 | 20 | 60; const SMA_PRESETS: { window: SmaWindow; color: string; label: string }[] = [ { window: 5, color: "#f59e0b", label: "MA5" }, { window: 20, color: "#22d3ee", label: "MA20" }, { window: 60, color: "#a78bfa", label: "MA60" }, ]; // Wilder's smoothing RSI. period=14 가 표준. 워밍업 구간은 null. // 첫 평균은 SMA, 이후는 ((prev*(n-1)) + cur) / n 점화식. function rsi(closes: (number | null)[], period = 14): (number | null)[] { const out: (number | null)[] = new Array(closes.length).fill(null); if (closes.length <= period) return out; let gainSum = 0; let lossSum = 0; // 첫 period 구간으로 초기 평균. null 이 끼면 직전 값 기준으로 변화량 산출하되, // 둘 중 하나라도 null 이면 그 step 은 0 으로 취급 (보수적). for (let i = 1; i <= period; i++) { const a = closes[i]; const b = closes[i - 1]; if (a == null || b == null) continue; const ch = a - b; if (ch >= 0) gainSum += ch; else lossSum -= ch; } let avgGain = gainSum / period; let avgLoss = lossSum / period; out[period] = avgLoss === 0 ? 100 : 100 - 100 / (1 + avgGain / avgLoss); for (let i = period + 1; i < closes.length; i++) { const a = closes[i]; const b = closes[i - 1]; let ch = 0; if (a != null && b != null) ch = a - b; const g = ch > 0 ? ch : 0; const l = ch < 0 ? -ch : 0; avgGain = (avgGain * (period - 1) + g) / period; avgLoss = (avgLoss * (period - 1) + l) / period; out[i] = avgLoss === 0 ? 100 : 100 - 100 / (1 + avgGain / avgLoss); } return out; } // 지수이동평균. 첫 유효 값에서 시드 시작, 이후 alpha=2/(n+1) 점화식. // null 은 그대로 통과 (해당 시점 계산 안 함, 직전 ema 유지). function ema(values: (number | null)[], period: number): (number | null)[] { const out: (number | null)[] = new Array(values.length).fill(null); const alpha = 2 / (period + 1); let cur: number | null = null; for (let i = 0; i < values.length; i++) { const v = values[i]; if (v == null) { out[i] = cur; continue; } if (cur == null) cur = v; else cur = alpha * v + (1 - alpha) * cur; out[i] = cur; } return out; } // MACD = EMA12 - EMA26, signal = EMA9(MACD), hist = MACD - signal. function macd( closes: (number | null)[], fast = 12, slow = 26, signal = 9, ): { macd: (number | null)[]; signal: (number | null)[]; hist: (number | null)[] } { const ef = ema(closes, fast); const es = ema(closes, slow); const m: (number | null)[] = ef.map((a, i) => { const b = es[i]; if (a == null || b == null) return null; return a - b; }); const sig = ema(m, signal); const hist: (number | null)[] = m.map((a, i) => { const b = sig[i]; if (a == null || b == null) return null; return a - b; }); return { macd: m, signal: sig, hist }; } export function StockChart({ chart, prediction, targets }: Props) { const containerRef = useRef(null); const chartRef = useRef(null); const candleRef = useRef | null>(null); const volumeRef = useRef | null>(null); const predRef = useRef | null>(null); const smaRefs = useRef>>(new Map()); // RSI 보조차트 — 별도 chart 인스턴스로 메인 아래에 붙여 timeScale 동기화. const rsiContainerRef = useRef(null); const rsiChartRef = useRef(null); const rsiSeriesRef = useRef | null>(null); // MACD 보조차트 — RSI 와 같은 패턴 (별 인스턴스, 단방향 sync). const macdContainerRef = useRef(null); const macdChartRef = useRef(null); const macdLineRef = useRef | null>(null); const macdSignalRef = useRef | null>(null); const macdHistRef = useRef | null>(null); // 목표가/손절가 가로 라인 — candleRef 의 priceLine 으로 부착. const targetLineRef = useRef(null); const stopLineRef = useRef(null); // 사용자 수평선 — id → IPriceLine. localStorage 에 영속. const hlineRefs = useRef>(new Map()); // 보조 차트 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); const [macdOn, setMacdOn] = useState(false); // crosshair 위에 떠 있는 한국어 라벨 — null 이면 hover 안 함 (기본 마지막 봉 표시). const [hover, setHover] = useState(null); // 전체화면 모드 — Fullscreen API 가 동작하면 그게 우선, 안되면 fixed inset 오버레이 폴백. // 두 경로 모두 같은 state 로 통일 — UI(버튼 라벨/컨테이너 클래스) 분기 단순. const wrapperRef = useRef(null); const [fullscreen, setFullscreen] = useState(false); // 사용자 수평선 — localStorage 동기. 종목 전환 시 reload. const [hlines, setHlines] = useState([]); const isIntraday = chart.interval === "10m"; const showSma = !isIntraday; // 10분봉에선 의미 적음 — UI 도 숨김. const showRsi = !isIntraday && rsiOn; const showMacd = !isIntraday && macdOn; // chart 생성 — interval 바뀌면 timeVisible 토글을 위해 재생성. useEffect(() => { if (!containerRef.current) return; const c = createChart(containerRef.current, { layout: { background: { color: "transparent" }, textColor: "#cbd5e1", }, grid: { vertLines: { color: "#1f2937" }, horzLines: { color: "#1f2937" }, }, rightPriceScale: { borderColor: "#374151" }, timeScale: { borderColor: "#374151", timeVisible: isIntraday, secondsVisible: false, }, autoSize: true, crosshair: { mode: 1 }, }); const candle = c.addCandlestickSeries({ upColor: COLOR_UP, downColor: COLOR_DOWN, borderUpColor: COLOR_UP, borderDownColor: COLOR_DOWN, wickUpColor: COLOR_UP, wickDownColor: COLOR_DOWN, }); // 캔들 priceScale 을 위쪽 78% 로 좁혀서 거래량 막대를 아래쪽에 둘 공간 확보. c.priceScale("right").applyOptions({ scaleMargins: { top: 0.05, bottom: 0.22 }, }); // 거래량 막대 — overlay priceScale("volume") 로 같은 chart 안에 띄움. // bottom 0/top 0.82 → 차트 영역의 아래 18% 만 사용. 가격축 라벨은 숨김. const volume = c.addHistogramSeries({ priceFormat: { type: "volume" }, priceScaleId: "volume", priceLineVisible: false, lastValueVisible: false, }); c.priceScale("volume").applyOptions({ scaleMargins: { top: 0.82, bottom: 0 }, }); chartRef.current = c; candleRef.current = candle; volumeRef.current = volume; const smaMap = smaRefs.current; return () => { c.remove(); chartRef.current = null; candleRef.current = null; volumeRef.current = null; predRef.current = null; smaMap.clear(); }; }, [isIntraday]); // 실 데이터 push. useEffect(() => { if (!candleRef.current) return; const data: CandlestickData[] = chart.ohlcv .filter((p) => p.open !== null && p.high !== null && p.low !== null && p.close !== null) .map((p) => ({ time: isoToUtcTs(p.date), open: p.open as number, high: p.high as number, low: p.low as number, close: p.close as number, })); candleRef.current.setData(data); chartRef.current?.timeScale().fitContent(); }, [chart, isIntraday]); // 거래량 막대 — 종가 ≥ 시가면 한국 관행으로 빨강(상승), 아니면 파랑(하락). // null volume 은 그 시점 캔들 자체가 빠진 케이스(상장폐지/거래정지) 라 건너뛴다. useEffect(() => { if (!volumeRef.current) return; const data: HistogramData[] = chart.ohlcv .filter((p) => p.volume != null && p.volume > 0) .map((p) => { const up = p.close != null && p.open != null ? p.close >= p.open : true; return { time: isoToUtcTs(p.date), value: p.volume as number, color: up ? "#ef444466" : "#3b82f666", }; }); volumeRef.current.setData(data); }, [chart, isIntraday]); // SMA 시리즈 — chart.ohlcv 종가 기준. 토글 변화 또는 데이터 변화 시 갱신. // 비활성 윈도우는 제거하고, 활성은 재계산. 마지막에 fitContent 는 호출 안 함 — 사용자 줌 유지. const closes = useMemo(() => chart.ohlcv.map((p) => p.close), [chart]); useEffect(() => { const c = chartRef.current; if (!c) return; if (!showSma) { // intraday: 다 지움. for (const s of smaRefs.current.values()) c.removeSeries(s); smaRefs.current.clear(); return; } // 비활성 윈도우 제거. for (const [w, s] of smaRefs.current) { if (!active.has(w)) { c.removeSeries(s); smaRefs.current.delete(w); } } // 활성 윈도우 생성/갱신. for (const preset of SMA_PRESETS) { if (!active.has(preset.window)) continue; const values = sma(closes, preset.window); const lineData: LineData[] = []; for (let i = 0; i < values.length; i++) { const v = values[i]; if (v == null) continue; lineData.push({ time: isoToUtcTs(chart.ohlcv[i].date), value: v }); } let s = smaRefs.current.get(preset.window); if (!s) { s = c.addLineSeries({ color: preset.color, lineWidth: 1, priceLineVisible: false, lastValueVisible: false, title: preset.label, }); smaRefs.current.set(preset.window, s); } s.setData(lineData); } }, [active, closes, chart, showSma]); // 예측 오버레이를 캔들 시리즈로 렌더. // 합성 OHLC: // open[i] = i==0 ? base_close : prev.point_close // close[i] = point_close // high[i] = max(ci_high, open, close) // low[i] = min(ci_low, open, close) // → 몸통 = 기간 사이 예상 변동, 위/아래 꼬리 = 신뢰구간 폭 (불확실성). // 10분봉(intraday) 에서는 예측 표시 안 함 — 모델은 일봉 기반. useEffect(() => { if (!chartRef.current) return; if (predRef.current) { chartRef.current.removeSeries(predRef.current); predRef.current = null; } if (isIntraday) return; if (!prediction?.found || !prediction.steps?.length) return; const baseDate = prediction.base_date; const baseClose = prediction.base_close; if (!baseDate || baseClose == null) return; const sorted = [...prediction.steps] .filter( (s) => s.point_close != null && s.ci_low != null && s.ci_high != null && s.target_date, ) .sort((a, b) => a.horizon - b.horizon); if (!sorted.length) return; const data: CandlestickData[] = []; let prevClose = baseClose; for (const s of sorted) { const open = prevClose; const close = s.point_close as number; const ciHi = s.ci_high as number; const ciLo = s.ci_low as number; data.push({ time: isoToUtcTs(s.target_date), open, close, high: Math.max(ciHi, open, close), low: Math.min(ciLo, open, close), }); prevClose = close; } const pred = chartRef.current.addCandlestickSeries({ upColor: PRED_UP, downColor: PRED_DOWN, borderUpColor: PRED_UP, borderDownColor: PRED_DOWN, wickUpColor: PRED_UP, wickDownColor: PRED_DOWN, priceLineVisible: false, lastValueVisible: true, title: "예측", }); pred.setData(data); predRef.current = pred; chartRef.current.timeScale().fitContent(); }, [prediction, isIntraday]); // 목표가/손절가 가로 라인 — targets prop 또는 캔들 시리즈 재생성 시 동기화. // candleRef 가 바뀌면 이전 라인 ref 는 이미 해제된 상태라 그냥 새로 만들면 됨. useEffect(() => { const candle = candleRef.current; if (!candle) return; // 이전 라인 제거 후 새로 그림 — 입력 바뀔 때마다 가격값 변경하지 않고 그냥 재생성. if (targetLineRef.current) { try { candle.removePriceLine(targetLineRef.current); } catch { /* 이미 해제됨 */ } targetLineRef.current = null; } if (stopLineRef.current) { try { candle.removePriceLine(stopLineRef.current); } catch { /* 이미 해제됨 */ } stopLineRef.current = null; } if (!targets) return; if (targets.target != null && Number.isFinite(targets.target)) { targetLineRef.current = candle.createPriceLine({ price: targets.target, color: "#fb7185", // rose-400 (한국 상승색 톤) lineWidth: 1, lineStyle: 2, // dashed axisLabelVisible: true, title: "목표", }); } if (targets.stop != null && Number.isFinite(targets.stop)) { stopLineRef.current = candle.createPriceLine({ price: targets.stop, color: "#60a5fa", // sky-400 (한국 하락색 톤) lineWidth: 1, lineStyle: 2, axisLabelVisible: true, title: "손절", }); } }, [targets, isIntraday]); // 사용자 수평선 로드 — 종목 전환 또는 캔들 시리즈 재생성 시. useEffect(() => { setHlines(readLines(chart.code)); }, [chart.code, isIntraday]); // 수평선 렌더링 — id 단위 diff 로 변경된 것만 add/remove. 단순화를 위해 // 매번 전부 지우고 다시 그리는 방식 채택 (라인 수가 10 개 cap 이라 비용 무시). useEffect(() => { const candle = candleRef.current; if (!candle) return; for (const line of hlineRefs.current.values()) { try { candle.removePriceLine(line); } catch { /* 이미 해제됨 */ } } hlineRefs.current.clear(); for (const hl of hlines) { const line = candle.createPriceLine({ price: hl.price, color: "#a3a3a3", // zinc-400 — 중립 (목표/손절 의도 색과 구분). lineWidth: 1, lineStyle: 0, // solid — 점선인 목표/손절과 시각적으로 분리. axisLabelVisible: true, title: "", }); hlineRefs.current.set(hl.id, line); } }, [hlines, isIntraday]); // RSI 보조 차트 생성/제거 — toggle 또는 interval 변경 시. 메인 chart 와 별 인스턴스. // priceScale fix (0-100) + 30/70 가이드 라인. useEffect(() => { if (!showRsi || !rsiContainerRef.current) return; const c = createChart(rsiContainerRef.current, { layout: { background: { color: "transparent" }, textColor: "#cbd5e1", }, grid: { vertLines: { color: "#1f2937" }, horzLines: { color: "#1f2937" }, }, rightPriceScale: { borderColor: "#374151", scaleMargins: { top: 0.1, bottom: 0.1 }, }, timeScale: { borderColor: "#374151", timeVisible: false, secondsVisible: false, }, autoSize: true, crosshair: { mode: 1 }, handleScroll: false, handleScale: false, }); const s = c.addLineSeries({ color: "#e879f9", lineWidth: 1, priceLineVisible: false, lastValueVisible: true, title: "RSI(14)", priceFormat: { type: "price", precision: 1, minMove: 0.1 }, }); s.createPriceLine({ price: 70, color: "#ef4444", lineWidth: 1, lineStyle: 2, axisLabelVisible: true, title: "70", }); s.createPriceLine({ price: 30, color: "#3b82f6", lineWidth: 1, lineStyle: 2, axisLabelVisible: true, title: "30", }); rsiChartRef.current = c; rsiSeriesRef.current = s; return () => { c.remove(); rsiChartRef.current = null; rsiSeriesRef.current = null; }; }, [showRsi]); // 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; 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 보조 차트 생성/제거. useEffect(() => { if (!showMacd || !macdContainerRef.current) return; const c = createChart(macdContainerRef.current, { layout: { background: { color: "transparent" }, textColor: "#cbd5e1" }, grid: { vertLines: { color: "#1f2937" }, horzLines: { color: "#1f2937" }, }, rightPriceScale: { borderColor: "#374151", scaleMargins: { top: 0.1, bottom: 0.1 }, }, timeScale: { borderColor: "#374151", timeVisible: false, secondsVisible: false, }, autoSize: true, crosshair: { mode: 1 }, handleScroll: false, handleScale: false, }); const hist = c.addHistogramSeries({ priceLineVisible: false, lastValueVisible: false, priceFormat: { type: "price", precision: 2, minMove: 0.01 }, }); const macdLine = c.addLineSeries({ color: "#60a5fa", lineWidth: 1, priceLineVisible: false, lastValueVisible: true, title: "MACD", priceFormat: { type: "price", precision: 2, minMove: 0.01 }, }); const signalLine = c.addLineSeries({ color: "#fb923c", lineWidth: 1, priceLineVisible: false, lastValueVisible: true, title: "Signal", priceFormat: { type: "price", precision: 2, minMove: 0.01 }, }); macdChartRef.current = c; macdHistRef.current = hist; macdLineRef.current = macdLine; macdSignalRef.current = signalLine; return () => { c.remove(); macdChartRef.current = null; macdHistRef.current = null; macdLineRef.current = null; macdSignalRef.current = null; }; }, [showMacd]); // MACD 데이터 push + crosshair sync 용 signal ts→value map 채우기. // signal 라인 기준으로 crosshair 가격 라벨이 뜨도록 — MACD 라인보다 안정적으로 보임. useEffect(() => { const hSeries = macdHistRef.current; const mSeries = macdLineRef.current; const sSeries = macdSignalRef.current; if (!hSeries || !mSeries || !sSeries) return; const { macd: m, signal: sig, hist } = macd(closes, 12, 26, 9); 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 }); signalLookup.set(t as number, sig[i] as number); } if (hist[i] != null) { const h = hist[i] as number; // 한국 관행: 양수 막대=상승=빨강, 음수=하락=파랑. 컬러 옅게 (배경 톤). hBars.push({ time: t, value: h, color: h >= 0 ? "#ef444488" : "#3b82f688" }); } } hSeries.setData(hBars); mSeries.setData(mLine); sSeries.setData(sLine); macdSignalByTsRef.current = signalLookup; }, [closes, chart, showMacd]); // crosshair → 한국어 오버레이. // - 같은 time(UTCTimestamp) 의 ohlcv 포인트를 O(1) 조회용 Map 으로 미리 만든다. // - hover 안 했을 때는 마지막 봉을 기본으로 표시 (토스 패턴 — 차트에 들어오자마자 정보 보임). const ohlcvByTs = useMemo(() => { const m = new Map(); for (const p of chart.ohlcv) m.set(isoToUtcTs(p.date) as number, p); return m; }, [chart]); const lastSummary = useMemo(() => { 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]); // H 키로 수평선 추가 시 사용할 "현재 가격" ref — hover 있으면 hover.close, 없으면 // lastSummary.close. ref 로 들고 있어 keydown effect 가 매번 재구독되지 않음. const currentPriceRef = useRef(null); useEffect(() => { const p = hover?.close ?? lastSummary?.close ?? null; currentPriceRef.current = p; }, [hover, lastSummary]); // 차트 위 모디파이어 클릭으로 수평선 추가/제거. // Shift+Click — 클릭 y 좌표를 candle.coordinateToPrice 로 변환해 새 라인 추가. // H 키 / 버튼은 hover 가격(또는 마지막 봉)에 묶여 있어 마우스 사용자가 "지금 hover 한 // 곳이 아닌 다른 위치" 에 그릴 방법이 없었음. // Alt+Click — 클릭 y 좌표 근처(픽셀 임계값 내)의 hline 한 개 제거. chips 의 ✕ 버튼이 // 좁고 마우스 정밀 클릭을 요구하는 반면, 차트 위 라인 근처 클릭은 자연스러움. // (lightweight-charts `IPriceLine` 자체에는 클릭 이벤트가 없어 좌표 매칭 패턴 사용.) // 단순 Click 은 기존 차트 인터랙션(crosshair/drag) 그대로 — 모디파이어가 있을 때만 가로챔. // Shift+Alt 동시 입력은 add 가 우선 (먼저 검사, 빈도 더 높음). const HLINE_HIT_PX = 6; // 라인까지 ±6px 이내 클릭이면 그 라인 hit 으로 간주. useEffect(() => { const el = containerRef.current; if (!el) return; const onClick = (e: MouseEvent) => { if (!e.shiftKey && !e.altKey) return; const candle = candleRef.current; if (!candle) return; const rect = el.getBoundingClientRect(); const y = e.clientY - rect.top; if (e.shiftKey) { if (hlines.length >= 10) return; // cap — chips 의 (N/10) 카운터로 확인 가능. const raw = candle.coordinateToPrice(y); if (raw == null) return; const price = Number(raw); if (!Number.isFinite(price) || price <= 0) return; e.preventDefault(); setHlines(addLine(chart.code, price)); return; } // Alt+Click — 가장 가까운 hline 찾아서 임계값 내면 제거. if (hlines.length === 0) return; let nearestId: string | null = null; let nearestDist = Infinity; for (const hl of hlines) { const yLine = candle.priceToCoordinate(hl.price); if (yLine == null) continue; const d = Math.abs(Number(yLine) - y); if (d < nearestDist) { nearestDist = d; nearestId = hl.id; } } if (nearestId != null && nearestDist <= HLINE_HIT_PX) { e.preventDefault(); setHlines(removeLine(chart.code, nearestId)); } }; el.addEventListener("click", onClick); return () => el.removeEventListener("click", onClick); }, [chart.code, hlines]); useEffect(() => { const c = chartRef.current; if (!c) return; const handler = (param: MouseEventParams) => { // 차트 영역 밖이면 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 = 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, }); // 보조 차트 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 () => { c.unsubscribeCrosshairMove(handler); }; }, [ohlcvByTs, isIntraday]); // 메인 ↔ 보조패널 timeScale 단방향 동기화. RSI/MACD 둘 다 처리. useEffect(() => { const main = chartRef.current; if (!main) return; const followers: IChartApi[] = []; if (showRsi && rsiChartRef.current) followers.push(rsiChartRef.current); if (showMacd && macdChartRef.current) followers.push(macdChartRef.current); if (!followers.length) return; const handler = (range: LogicalRange | null) => { if (!range) return; for (const f of followers) f.timeScale().setVisibleLogicalRange(range); }; const initial = main.timeScale().getVisibleLogicalRange(); if (initial) { for (const f of followers) f.timeScale().setVisibleLogicalRange(initial); } main.timeScale().subscribeVisibleLogicalRangeChange(handler); return () => { main.timeScale().unsubscribeVisibleLogicalRangeChange(handler); }; }, [showRsi, showMacd, chart]); // 전체화면 토글 — Fullscreen API 가능하면 native, 아니면 오버레이 폴백. // iOS Safari 등 element.requestFullscreen 미지원 환경은 자연히 폴백 경로로. const toggleFullscreen = useCallback(async () => { const el = wrapperRef.current; if (!el || typeof document === "undefined") return; const docAny = document as Document & { fullscreenEnabled?: boolean }; if (docAny.fullscreenEnabled && typeof el.requestFullscreen === "function") { try { if (document.fullscreenElement) { await document.exitFullscreen(); } else { await el.requestFullscreen(); } return; } catch { // 권한 거부/실패 → 오버레이 폴백. } } setFullscreen((v) => !v); }, []); // Native fullscreen 상태 → state 동기화. ESC 로 빠져나오면 여기로 들어옴. useEffect(() => { if (typeof document === "undefined") return; const onChange = () => setFullscreen(!!document.fullscreenElement); document.addEventListener("fullscreenchange", onChange); return () => document.removeEventListener("fullscreenchange", onChange); }, []); // F 키 = 토글, ESC = 폴백 오버레이 해제 (native 는 브라우저가 알아서). // 입력 필드에 포커스가 있으면 무시 — 메모 작성 등 방해 금지. useEffect(() => { if (typeof window === "undefined") return; const onKey = (e: KeyboardEvent) => { const t = e.target as HTMLElement | null; if ( t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable) ) { return; } // 모달/대화상자가 열려 있으면 차트 단축키도 양보 — ShortcutsHelp 등이 열린 상태에서 // 뒤의 차트가 전체화면으로 토글되는 것을 막음. if (document.querySelector('[role="dialog"][aria-modal="true"]')) return; // Ctrl+F / Cmd+F (브라우저 찾기) / Alt+F (브라우저 메뉴) / Shift+F 는 가로채지 않음. // 단순 F 만 차트 전체화면 토글로 사용. const noMod = !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey; if (noMod && e.key.toLowerCase() === "f") { e.preventDefault(); void toggleFullscreen(); } else if (noMod && e.key.toLowerCase() === "h") { // H 키 — 현재 가격(hover 또는 마지막 봉 종가)에 수평선 추가. const price = currentPriceRef.current; if (price != null && Number.isFinite(price) && price > 0) { e.preventDefault(); setHlines(addLine(chart.code, price)); } } else if (e.key === "Escape" && fullscreen && !document.fullscreenElement) { setFullscreen(false); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [toggleFullscreen, fullscreen, chart.code]); const toggle = (w: SmaWindow) => { setActive((cur) => { const next = new Set(cur); if (next.has(w)) next.delete(w); else next.add(w); return next; }); }; // 전체화면(native or 폴백) 일 때는 viewport 를 꽉 채워서 차트 면적 최대화. // 일반 모드는 상세 페이지 본문 카드 표준 톤 (rounded-md / border-zinc-800 / // bg-zinc-900/40) 채택 — 같은 좌측 컬럼 바로 아래의 PredictionPanel 과 톤 일치. // padding 만 p-2 유지: 차트 캔버스가 자체 inner padding 을 들이므로 p-4 는 빈 // 가장자리 낭비. (참고: bg-zinc-900/30 은 ReturnBucket 같은 nested sub-card 용 // 보조 톤 — top-level 본문 카드는 /40 로 통일.) const wrapperCls = fullscreen ? "fixed inset-0 z-50 flex w-full flex-col overflow-auto bg-zinc-950 p-3" : "w-full rounded-md border border-zinc-800 bg-zinc-900/40 p-2"; // 차트 본체 높이 — 전체화면이면 가용 공간을 차지하도록 flex-1. const chartBoxCls = fullscreen ? "relative min-h-[300px] flex-1 w-full" : "relative h-[460px] w-full"; return (
{/* 툴바를 2 그룹으로 분리: A) 보조지표 (MA5/20/60 + RSI + MACD) — 일봉 계열에서만 의미. 안 보이면 row 자체 생략. B) 액션 (전체화면 종목 라벨 + 수평선 + CSV + 전체화면 토글) — 항상 표시. 같은 줄에 다 묶었을 때 모바일/좁은 폭에서 indicators 와 actions 가 섞여서 wrap 되며 "ml-auto" 가 줄바꿈 직전에서만 분리 효과를 주고 그 외엔 actions 가 indicators 사이로 끼어듦. 분리하면 indicators wrap 은 indicators 안에서만 일어남 — actions 그룹은 자체 정렬 유지. */} {showSma && (
{SMA_PRESETS.map((p) => { const on = active.has(p.window); return ( ); })} |
)}
{/* 전체화면 모드 한정 종목 라벨 — 일반 모드는 위에 PriceHero 가 종목명/코드를 크게 보여주지만 fullscreen 은 fixed inset 으로 PriceHero 가 가려져서 무슨 종목 차트인지 식별이 어려워짐. actions row leading 자리에 작은 라벨을 끼워 시각적 노이즈 최소로 보완. (캔버스 위의 CrosshairLabel 과 위치 겹치지 않도록 캔버스가 아닌 툴바에 둠.) */} {fullscreen && ( {chart.name} {chart.code} )}
{hlines.length > 0 && (
수평선: {hlines.map((hl) => ( {fmtKPrice(hl.price)} ))}
)}
{showRsi && (
)} {showMacd && (
)} {prediction?.found && !isIntraday && (
예측 (꼬리 = q10~q90 신뢰구간)
)}
); } // 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 (
{label} {pctText && {pctText}}
); } 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 ( {k} {v} ); }