"use client"; // 투자자별 누적 순매수 미니 차트. // // 토스 종목 상세의 "투자자별 매매" 탭에서 인상이 강한 위젯 — 외국인/기관/개인 // 각각의 N일 누적 순매수를 작은 라인으로 보여줘서 "누가 모으고 누가 분배하고 있는가" // 를 한 눈에 잡게 한다. // // 데이터 소스는 ChartPayload.trading_value (이미 페이지에 있음) 그대로. 백엔드 호출 0. // 단위: 원 → 억원(1e8) 환산. // // 색은 한국 컬러 — 누적 +면 빨강(매수 우위), -면 파랑(매도 우위). 라인 자체 색은 // 주체별로 고정해서 비교 가능하게 (외국인=amber, 기관=cyan, 개인=violet). import { useMemo, useState } from "react"; import type { TradingValuePoint } from "../lib/api"; type Window = 20 | 60 | 120; const WINDOW_OPTIONS: { v: Window; label: string }[] = [ { v: 20, label: "20일" }, { v: 60, label: "60일" }, { v: 120, label: "120일" }, ]; type Subject = "foreign" | "institution" | "individual"; const SUBJECTS: { key: Subject; label: string; color: string; field: keyof TradingValuePoint }[] = [ { key: "foreign", label: "외국인", color: "#f59e0b", field: "foreign_net" }, { key: "institution", label: "기관", color: "#22d3ee", field: "institution_net" }, { key: "individual", label: "개인", color: "#a78bfa", field: "individual_net" }, ]; export function InvestorCumulative({ data }: { data: TradingValuePoint[] }) { const [win, setWin] = useState(20); // 데이터가 충분치 않으면 window 를 줄여서라도 그릴 수 있게. const effectiveWin = Math.min(win, data?.length ?? 0); const series = useMemo(() => { if (!data || data.length === 0) return null; const slice = data.slice(-effectiveWin); // 각 주체별 누적 순매수 (원 단위), 0 부터 시작. const out: Record = { foreign: [0], institution: [0], individual: [0], }; for (const p of slice) { for (const s of SUBJECTS) { const v = (p[s.field] as number | null) ?? 0; const prev = out[s.key][out[s.key].length - 1]; out[s.key].push(prev + v); } } return { slice, out }; }, [data, effectiveWin]); if (!series) { return (
투자자별 누적 데이터 없음
); } return (
투자자별 누적 순매수
{WINDOW_OPTIONS.map((w) => { const on = win === w.v; return ( ); })}
{SUBJECTS.map((s) => ( ))}
단위: 억원 · 마지막 {effectiveWin}일 누적 (시작 0)
); } // 한 주체에 대한 라벨 + 누적 라인 + 끝 값. // values: 길이 N+1 (시작 0 포함). viewBox 좌표는 0..100 / 0..30. function SubjectRow({ label, color, values, }: { label: string; color: string; values: number[]; }) { if (values.length < 2) { return (
{label}
); } const lo = Math.min(...values); const hi = Math.max(...values); const span = hi - lo || 1; const w = 100; const h = 30; const n = values.length; const path = values .map((v, i) => { const x = (i / (n - 1)) * w; // 0 이 위로 가도록 y 뒤집기. 패딩 1px. const y = h - 1 - ((v - lo) / span) * (h - 2); return `${i === 0 ? "M" : "L"}${x.toFixed(2)},${y.toFixed(2)}`; }) .join(" "); // 기준선 (시작 0) 위치. const baseY = h - 1 - ((0 - lo) / span) * (h - 2); const end = values[values.length - 1]; const endEok = end / 1e8; const tone = endEok === 0 ? "text-zinc-400" : endEok > 0 ? "text-rose-300" : "text-sky-300"; const sign = endEok > 0 ? "+" : ""; return (
{label} {sign} {endEok.toLocaleString(undefined, { maximumFractionDigits: 0 })}억
); }