feat(chart): SMA(5/20/60) 토글 오버레이
- lightweight-charts addLineSeries 로 종가 기반 단순이동평균 라인 추가 - 칩 토글 (기본 MA20 ON), intraday(10분봉)에선 UI/시리즈 모두 숨김 - fitContent 는 호출 안 함 — 사용자 줌 유지
This commit is contained in:
@@ -1,11 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
createChart,
|
createChart,
|
||||||
type CandlestickData,
|
type CandlestickData,
|
||||||
type IChartApi,
|
type IChartApi,
|
||||||
type ISeriesApi,
|
type ISeriesApi,
|
||||||
|
type LineData,
|
||||||
type UTCTimestamp,
|
type UTCTimestamp,
|
||||||
} from "lightweight-charts";
|
} from "lightweight-charts";
|
||||||
import type { ChartPayload, LatestPredictionResponse } from "../lib/api";
|
import type { ChartPayload, LatestPredictionResponse } from "../lib/api";
|
||||||
@@ -43,13 +44,53 @@ const COLOR_DOWN = "#3b82f6";
|
|||||||
const PRED_UP = "#fda4af";
|
const PRED_UP = "#fda4af";
|
||||||
const PRED_DOWN = "#93c5fd";
|
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" },
|
||||||
|
];
|
||||||
|
|
||||||
export function StockChart({ chart, prediction }: Props) {
|
export function StockChart({ chart, prediction }: Props) {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const chartRef = useRef<IChartApi | null>(null);
|
const chartRef = useRef<IChartApi | null>(null);
|
||||||
const candleRef = useRef<ISeriesApi<"Candlestick"> | null>(null);
|
const candleRef = useRef<ISeriesApi<"Candlestick"> | null>(null);
|
||||||
const predRef = useRef<ISeriesApi<"Candlestick"> | null>(null);
|
const predRef = useRef<ISeriesApi<"Candlestick"> | null>(null);
|
||||||
|
const smaRefs = useRef<Map<SmaWindow, ISeriesApi<"Line">>>(new Map());
|
||||||
|
|
||||||
|
// 토글 상태 — 기본 MA20 만 ON. 사용자 컨트롤이 너무 시끄럽지 않게.
|
||||||
|
const [active, setActive] = useState<Set<SmaWindow>>(() => new Set<SmaWindow>([20]));
|
||||||
|
|
||||||
const isIntraday = chart.interval === "10m";
|
const isIntraday = chart.interval === "10m";
|
||||||
|
const showSma = !isIntraday; // 10분봉에선 의미 적음 — UI 도 숨김.
|
||||||
|
|
||||||
// chart 생성 — interval 바뀌면 timeVisible 토글을 위해 재생성.
|
// chart 생성 — interval 바뀌면 timeVisible 토글을 위해 재생성.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -82,11 +123,13 @@ export function StockChart({ chart, prediction }: Props) {
|
|||||||
});
|
});
|
||||||
chartRef.current = c;
|
chartRef.current = c;
|
||||||
candleRef.current = candle;
|
candleRef.current = candle;
|
||||||
|
const smaMap = smaRefs.current;
|
||||||
return () => {
|
return () => {
|
||||||
c.remove();
|
c.remove();
|
||||||
chartRef.current = null;
|
chartRef.current = null;
|
||||||
candleRef.current = null;
|
candleRef.current = null;
|
||||||
predRef.current = null;
|
predRef.current = null;
|
||||||
|
smaMap.clear();
|
||||||
};
|
};
|
||||||
}, [isIntraday]);
|
}, [isIntraday]);
|
||||||
|
|
||||||
@@ -106,6 +149,50 @@ export function StockChart({ chart, prediction }: Props) {
|
|||||||
chartRef.current?.timeScale().fitContent();
|
chartRef.current?.timeScale().fitContent();
|
||||||
}, [chart, isIntraday]);
|
}, [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:
|
// 합성 OHLC:
|
||||||
// open[i] = i==0 ? base_close : prev.point_close
|
// open[i] = i==0 ? base_close : prev.point_close
|
||||||
@@ -167,8 +254,43 @@ export function StockChart({ chart, prediction }: Props) {
|
|||||||
chartRef.current.timeScale().fitContent();
|
chartRef.current.timeScale().fitContent();
|
||||||
}, [prediction, isIntraday]);
|
}, [prediction, isIntraday]);
|
||||||
|
|
||||||
|
const toggle = (w: SmaWindow) => {
|
||||||
|
setActive((cur) => {
|
||||||
|
const next = new Set(cur);
|
||||||
|
if (next.has(w)) next.delete(w);
|
||||||
|
else next.add(w);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full rounded-lg border border-zinc-800 bg-zinc-900/30 p-2">
|
<div className="w-full rounded-lg border border-zinc-800 bg-zinc-900/30 p-2">
|
||||||
|
{showSma && (
|
||||||
|
<div className="mb-1 flex items-center gap-1.5 px-1">
|
||||||
|
{SMA_PRESETS.map((p) => {
|
||||||
|
const on = active.has(p.window);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p.window}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggle(p.window)}
|
||||||
|
className={`flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] transition ${
|
||||||
|
on
|
||||||
|
? "border-zinc-600 bg-zinc-800/80 text-zinc-100"
|
||||||
|
: "border-zinc-800 bg-transparent text-zinc-500 hover:text-zinc-300"
|
||||||
|
}`}
|
||||||
|
aria-pressed={on}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="inline-block h-2 w-2 rounded-full"
|
||||||
|
style={{ backgroundColor: on ? p.color : "transparent", border: `1px solid ${p.color}` }}
|
||||||
|
/>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="h-[460px] w-full">
|
<div className="h-[460px] w-full">
|
||||||
<div ref={containerRef} className="h-full w-full" />
|
<div ref={containerRef} className="h-full w-full" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user