"use client"; import { useEffect, useRef } from "react"; import { createChart, type CandlestickData, type IChartApi, type ISeriesApi, type LineData, type UTCTimestamp, } from "lightweight-charts"; import type { ChartPayload, LatestPredictionResponse } from "../lib/api"; type Props = { chart: ChartPayload; prediction?: LatestPredictionResponse | null; }; // 'YYYY-MM-DD' 또는 'YYYY-MM-DDTHH:MM:SS' (KST naive, 백엔드가 +09:00 시각의 wall-clock 을 // 그대로 ISO 로 직렬화) 를 UTCTimestamp 로. lightweight-charts 는 timestamp 가 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; } // datetime: YYYY-MM-DDTHH:MM:SS 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; } export function StockChart({ chart, prediction }: Props) { const containerRef = useRef(null); const chartRef = useRef(null); const candleRef = useRef | null>(null); const predRef = useRef | null>(null); const predLowRef = useRef | null>(null); const predHighRef = useRef | null>(null); const isIntraday = chart.interval === "10m"; // create chart once (interval 바뀌면 timeVisible 토글 위해 의존성에 isIntraday 포함 — 재생성) 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, }); const candle = c.addCandlestickSeries({ upColor: "#22c55e", downColor: "#ef4444", borderUpColor: "#22c55e", borderDownColor: "#ef4444", wickUpColor: "#22c55e", wickDownColor: "#ef4444", }); chartRef.current = c; candleRef.current = candle; return () => { c.remove(); chartRef.current = null; candleRef.current = null; predRef.current = null; predLowRef.current = null; predHighRef.current = null; }; }, [isIntraday]); // push candle data + today marker 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); // 오늘 표시는 차트 본체 위가 아니라 컨테이너 아래 캡션 (return JSX) 으로 옮김. // lightweight-charts 의 timeScale tick 자체에 라벨을 끼울 공식 API 가 없어서, // 시각적으로 동일한 위치 (시간축 바로 아래) 에 별도 div 로 렌더. chartRef.current?.timeScale().fitContent(); }, [chart, isIntraday]); // push prediction overlay (10분봉에서는 표시 안 함 — 예측은 일봉 기준) useEffect(() => { if (!chartRef.current) return; if (predRef.current) { chartRef.current.removeSeries(predRef.current); predRef.current = null; } if (predLowRef.current) { chartRef.current.removeSeries(predLowRef.current); predLowRef.current = null; } if (predHighRef.current) { chartRef.current.removeSeries(predHighRef.current); predHighRef.current = null; } if (isIntraday) return; if (!prediction || !prediction.found || !prediction.steps?.length) return; const baseDate = prediction.base_date!; const baseClose = prediction.base_close; if (!baseClose) return; const sorted = [...prediction.steps].sort((a, b) => a.horizon - b.horizon); const med: LineData[] = [ { time: isoToUtcTs(baseDate), value: baseClose }, ...sorted .filter((s) => s.point_close !== null) .map((s) => ({ time: isoToUtcTs(s.target_date), value: s.point_close as number })), ]; const lo: LineData[] = [ { time: isoToUtcTs(baseDate), value: baseClose }, ...sorted .filter((s) => s.ci_low !== null) .map((s) => ({ time: isoToUtcTs(s.target_date), value: s.ci_low as number })), ]; const hi: LineData[] = [ { time: isoToUtcTs(baseDate), value: baseClose }, ...sorted .filter((s) => s.ci_high !== null) .map((s) => ({ time: isoToUtcTs(s.target_date), value: s.ci_high as number })), ]; const medLine = chartRef.current.addLineSeries({ color: "#a78bfa", lineWidth: 2, lineStyle: 2, // dashed priceLineVisible: false, lastValueVisible: true, title: "예측 median", }); medLine.setData(med); const loLine = chartRef.current.addLineSeries({ color: "#7c3aed", lineWidth: 1, lineStyle: 1, priceLineVisible: false, lastValueVisible: false, title: "q10", }); loLine.setData(lo); const hiLine = chartRef.current.addLineSeries({ color: "#7c3aed", lineWidth: 1, lineStyle: 1, priceLineVisible: false, lastValueVisible: false, title: "q90", }); hiLine.setData(hi); predRef.current = medLine; predLowRef.current = loLine; predHighRef.current = hiLine; chartRef.current.timeScale().fitContent(); }, [prediction, isIntraday]); // 오늘 라벨 — 차트 본체에 마커 대신 시간축 바로 아래에 작은 캡션으로. // 10분봉은 데이터 자체가 오늘 하루라 굳이 라벨 불필요. const todayLabel = !isIntraday && chart.today ? new Date(chart.today + "T00:00:00").toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit", weekday: "short", }) : null; return (
{todayLabel && (
오늘 · {todayLabel}
)}
); }