When showSma is true, the indicator chips (MA5/20/60 + RSI + MACD) and the action buttons (수평선 / CSV / 전체화면, plus the fullscreen symbol label) were sharing one flex-wrap row. On narrow widths the actions wrapped into the middle of the indicators because `ml-auto` only enforces right-pull while everything fits on one line — once wrap kicks in the right-side group loses its anchor. Split into two rows so wrap in either group stays local: - Indicators row: rendered only when showSma. Hidden in 1D (intraday) where none of these are meaningful, matching prior behavior. - Actions row: always rendered. Leading fullscreen label + `ml-auto` to push the three action buttons right; on narrow widths they wrap among themselves without colliding with indicator chips.
1178 lines
44 KiB
TypeScript
1178 lines
44 KiB
TypeScript
"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<HTMLDivElement | null>(null);
|
|
const chartRef = useRef<IChartApi | null>(null);
|
|
const candleRef = useRef<ISeriesApi<"Candlestick"> | null>(null);
|
|
const volumeRef = useRef<ISeriesApi<"Histogram"> | null>(null);
|
|
const predRef = useRef<ISeriesApi<"Candlestick"> | null>(null);
|
|
const smaRefs = useRef<Map<SmaWindow, ISeriesApi<"Line">>>(new Map());
|
|
// RSI 보조차트 — 별도 chart 인스턴스로 메인 아래에 붙여 timeScale 동기화.
|
|
const rsiContainerRef = useRef<HTMLDivElement | null>(null);
|
|
const rsiChartRef = useRef<IChartApi | null>(null);
|
|
const rsiSeriesRef = useRef<ISeriesApi<"Line"> | null>(null);
|
|
// MACD 보조차트 — RSI 와 같은 패턴 (별 인스턴스, 단방향 sync).
|
|
const macdContainerRef = useRef<HTMLDivElement | null>(null);
|
|
const macdChartRef = useRef<IChartApi | null>(null);
|
|
const macdLineRef = useRef<ISeriesApi<"Line"> | null>(null);
|
|
const macdSignalRef = useRef<ISeriesApi<"Line"> | null>(null);
|
|
const macdHistRef = useRef<ISeriesApi<"Histogram"> | null>(null);
|
|
// 목표가/손절가 가로 라인 — candleRef 의 priceLine 으로 부착.
|
|
const targetLineRef = useRef<IPriceLine | null>(null);
|
|
const stopLineRef = useRef<IPriceLine | null>(null);
|
|
// 사용자 수평선 — id → IPriceLine. localStorage 에 영속.
|
|
const hlineRefs = useRef<Map<string, IPriceLine>>(new Map());
|
|
|
|
// 보조 차트 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);
|
|
const [macdOn, setMacdOn] = useState(false);
|
|
// crosshair 위에 떠 있는 한국어 라벨 — null 이면 hover 안 함 (기본 마지막 봉 표시).
|
|
const [hover, setHover] = useState<CrosshairInfo | null>(null);
|
|
// 전체화면 모드 — Fullscreen API 가 동작하면 그게 우선, 안되면 fixed inset 오버레이 폴백.
|
|
// 두 경로 모두 같은 state 로 통일 — UI(버튼 라벨/컨테이너 클래스) 분기 단순.
|
|
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
|
const [fullscreen, setFullscreen] = useState(false);
|
|
// 사용자 수평선 — localStorage 동기. 종목 전환 시 reload.
|
|
const [hlines, setHlines] = useState<HLine[]>([]);
|
|
|
|
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<number, number>();
|
|
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<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 });
|
|
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<number, ChartPayload["ohlcv"][number]>();
|
|
for (const p of chart.ohlcv) m.set(isoToUtcTs(p.date) as number, p);
|
|
return m;
|
|
}, [chart]);
|
|
|
|
const lastSummary = useMemo<CrosshairInfo | null>(() => {
|
|
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<number | null>(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 를 꽉 채워서 차트 면적 최대화.
|
|
// 일반 모드는 기존 카드 스타일 유지.
|
|
const wrapperCls = fullscreen
|
|
? "fixed inset-0 z-50 flex w-full flex-col overflow-auto bg-zinc-950 p-3"
|
|
: "w-full rounded-lg border border-zinc-800 bg-zinc-900/30 p-2";
|
|
// 차트 본체 높이 — 전체화면이면 가용 공간을 차지하도록 flex-1.
|
|
const chartBoxCls = fullscreen
|
|
? "relative min-h-[300px] flex-1 w-full"
|
|
: "relative h-[460px] w-full";
|
|
|
|
return (
|
|
<div ref={wrapperRef} className={wrapperCls}>
|
|
{/*
|
|
툴바를 2 그룹으로 분리:
|
|
A) 보조지표 (MA5/20/60 + RSI + MACD) — 일봉 계열에서만 의미. 안 보이면 row 자체 생략.
|
|
B) 액션 (전체화면 종목 라벨 + 수평선 + CSV + 전체화면 토글) — 항상 표시.
|
|
같은 줄에 다 묶었을 때 모바일/좁은 폭에서 indicators 와 actions 가 섞여서 wrap 되며
|
|
"ml-auto" 가 줄바꿈 직전에서만 분리 효과를 주고 그 외엔 actions 가 indicators 사이로 끼어듦.
|
|
분리하면 indicators wrap 은 indicators 안에서만 일어남 — actions 그룹은 자체 정렬 유지.
|
|
*/}
|
|
{showSma && (
|
|
<div className="mb-1 flex flex-wrap 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>
|
|
);
|
|
})}
|
|
<span className="mx-1 text-zinc-700">|</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => setRsiOn((v) => !v)}
|
|
className={`flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] transition ${
|
|
rsiOn
|
|
? "border-zinc-600 bg-zinc-800/80 text-zinc-100"
|
|
: "border-zinc-800 bg-transparent text-zinc-500 hover:text-zinc-300"
|
|
}`}
|
|
aria-pressed={rsiOn}
|
|
title="상대강도지수 (Relative Strength Index, 14일)"
|
|
>
|
|
<span
|
|
className="inline-block h-2 w-2 rounded-full"
|
|
style={{ backgroundColor: rsiOn ? "#e879f9" : "transparent", border: "1px solid #e879f9" }}
|
|
/>
|
|
RSI
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setMacdOn((v) => !v)}
|
|
className={`flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] transition ${
|
|
macdOn
|
|
? "border-zinc-600 bg-zinc-800/80 text-zinc-100"
|
|
: "border-zinc-800 bg-transparent text-zinc-500 hover:text-zinc-300"
|
|
}`}
|
|
aria-pressed={macdOn}
|
|
title="MACD (12/26/9) — 추세 전환 신호"
|
|
>
|
|
<span
|
|
className="inline-block h-2 w-2 rounded-full"
|
|
style={{ backgroundColor: macdOn ? "#60a5fa" : "transparent", border: "1px solid #60a5fa" }}
|
|
/>
|
|
MACD
|
|
</button>
|
|
</div>
|
|
)}
|
|
<div className="mb-1 flex flex-wrap items-center gap-1.5 px-1">
|
|
{/*
|
|
전체화면 모드 한정 종목 라벨 — 일반 모드는 위에 PriceHero 가 종목명/코드를 크게 보여주지만
|
|
fullscreen 은 fixed inset 으로 PriceHero 가 가려져서 무슨 종목 차트인지 식별이 어려워짐.
|
|
actions row leading 자리에 작은 라벨을 끼워 시각적 노이즈 최소로 보완.
|
|
(캔버스 위의 CrosshairLabel 과 위치 겹치지 않도록 캔버스가 아닌 툴바에 둠.)
|
|
*/}
|
|
{fullscreen && (
|
|
<span className="mr-1 flex items-baseline gap-1.5 text-xs">
|
|
<span className="font-semibold text-zinc-100">{chart.name}</span>
|
|
<span className="font-mono text-[10px] text-zinc-500">{chart.code}</span>
|
|
</span>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const price = hover?.close ?? lastSummary?.close ?? null;
|
|
if (price != null && Number.isFinite(price) && price > 0) {
|
|
setHlines(addLine(chart.code, price));
|
|
}
|
|
}}
|
|
disabled={hlines.length >= 10 || (hover?.close ?? lastSummary?.close ?? null) == null}
|
|
className="ml-auto flex items-center gap-1 rounded-full border border-zinc-800 px-2 py-0.5 text-[10px] text-zinc-400 transition hover:border-zinc-600 hover:text-zinc-200 disabled:cursor-not-allowed disabled:opacity-40"
|
|
title="현재 가격(hover 또는 마지막 봉 종가)에 수평선 추가 (H · Shift+클릭으로 임의 위치 · Alt+클릭으로 라인 제거)"
|
|
>
|
|
─ 수평선 ({hlines.length}/10)
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const filename = csvFilename(
|
|
chart.name,
|
|
chart.code,
|
|
chart.interval,
|
|
chart.range.from,
|
|
chart.range.to,
|
|
);
|
|
downloadCsv(filename, ohlcvToCsv(chart.ohlcv));
|
|
}}
|
|
disabled={!chart.ohlcv.length}
|
|
className="flex items-center gap-1 rounded-full border border-zinc-800 px-2 py-0.5 text-[10px] text-zinc-400 transition hover:border-zinc-600 hover:text-zinc-200 disabled:cursor-not-allowed disabled:opacity-40"
|
|
title={`현재 로드된 기간(${chart.range.from} ~ ${chart.range.to}) 의 봉 데이터를 CSV 로 내보내기 (UTF-8 BOM, Excel 호환)`}
|
|
>
|
|
⬇ CSV
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void toggleFullscreen()}
|
|
className="flex items-center gap-1 rounded-full border border-zinc-800 px-2 py-0.5 text-[10px] text-zinc-400 transition hover:border-zinc-600 hover:text-zinc-200"
|
|
title={fullscreen ? "전체화면 해제 (ESC / F)" : "전체화면 (F)"}
|
|
aria-pressed={fullscreen}
|
|
>
|
|
{fullscreen ? "⛶ 해제" : "⛶ 전체화면"}
|
|
</button>
|
|
</div>
|
|
{hlines.length > 0 && (
|
|
<div className="mb-1 flex flex-wrap items-center gap-1 px-1">
|
|
<span className="text-[10px] text-zinc-500">수평선:</span>
|
|
{hlines.map((hl) => (
|
|
<span
|
|
key={hl.id}
|
|
className="flex items-center gap-1 rounded-full border border-zinc-800 bg-zinc-900/60 pl-2 pr-1 py-0.5 text-[10px] tabular-nums text-zinc-300"
|
|
>
|
|
{fmtKPrice(hl.price)}
|
|
<button
|
|
type="button"
|
|
onClick={() => setHlines(removeLine(chart.code, hl.id))}
|
|
className="rounded-full px-1 text-[10px] text-zinc-500 hover:text-zinc-100"
|
|
title="이 수평선 제거"
|
|
aria-label={`${fmtKPrice(hl.price)} 원 수평선 제거`}
|
|
>
|
|
✕
|
|
</button>
|
|
</span>
|
|
))}
|
|
<button
|
|
type="button"
|
|
onClick={() => setHlines(clearLines(chart.code))}
|
|
className="ml-1 rounded-full border border-zinc-800 px-2 py-0.5 text-[10px] text-zinc-500 hover:border-zinc-600 hover:text-zinc-200"
|
|
title="모든 수평선 제거"
|
|
>
|
|
전체 해제
|
|
</button>
|
|
</div>
|
|
)}
|
|
<div className={chartBoxCls}>
|
|
<div ref={containerRef} className="h-full w-full" />
|
|
<CrosshairLabel info={hover ?? lastSummary} isHover={hover != null} />
|
|
</div>
|
|
{showRsi && (
|
|
<div className="mt-2 h-[110px] w-full border-t border-zinc-800 pt-1">
|
|
<div ref={rsiContainerRef} className="h-full w-full" />
|
|
</div>
|
|
)}
|
|
{showMacd && (
|
|
<div className="mt-2 h-[110px] w-full border-t border-zinc-800 pt-1">
|
|
<div ref={macdContainerRef} className="h-full w-full" />
|
|
</div>
|
|
)}
|
|
{prediction?.found && !isIntraday && (
|
|
<div className="mt-1 flex items-center justify-end gap-2 px-2 text-[11px] text-zinc-500">
|
|
<span className="inline-block h-2 w-2 rounded-sm bg-rose-300" />
|
|
<span className="inline-block h-2 w-2 rounded-sm bg-sky-300" />
|
|
<span>예측 (꼬리 = q10~q90 신뢰구간)</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<div
|
|
className={`pointer-events-none absolute left-2 top-2 z-10 rounded-md border border-zinc-700/70 bg-zinc-950/80 px-2.5 py-1.5 text-[11px] tabular-nums shadow backdrop-blur-sm ${
|
|
isHover ? "opacity-100" : "opacity-80"
|
|
}`}
|
|
>
|
|
<div className="mb-0.5 flex items-center gap-1.5 text-zinc-400">
|
|
<span>{label}</span>
|
|
{pctText && <span className={pctCls}>{pctText}</span>}
|
|
</div>
|
|
<div className="flex flex-wrap gap-x-2 gap-y-0.5 text-zinc-200">
|
|
<CrosshairCell k="시" v={fmtKPrice(open)} />
|
|
<CrosshairCell k="고" v={fmtKPrice(high)} tone="up" />
|
|
<CrosshairCell k="저" v={fmtKPrice(low)} tone="down" />
|
|
<CrosshairCell k="종" v={fmtKPrice(close)} strong />
|
|
<CrosshairCell k="거래량" v={fmtKVolume(volume)} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<span className="flex items-baseline gap-1">
|
|
<span className="text-zinc-500">{k}</span>
|
|
<span className={tcls}>{v}</span>
|
|
</span>
|
|
);
|
|
}
|