Files
stock_chart_site/web/components/InvestorCumulative.tsx
claude-owner be1f8870b4 refactor(card): extend CardHeader with right slot, absorb InvestorCumulative
본문 카드 헤더 단일 source of truth 한 단계 확장.

CardHeader:
- 옵셔널 right?: ReactNode 슬롯 추가. 제공되면 subtitle 대신 렌더. subtitle 은
  텍스트 케이스(text-[11px] text-zinc-500 자동) 편의 prop 으로 유지.
- 컨테이너에 gap-2 추가. 기존 8개 caller 는 justify-between 으로 충분히 떨어져
  있어 시각 변화 없고, 좁은 폭에서 title↔우측 슬롯 충돌 방지 효과.
- 주석: 적용 카드 8 → 9 (InvestorCumulative 추가). 제외 카드 PredictionPanel
  유지 — title 이 제목+설명 2줄 stack 이고 큰 button 정렬을 위해 items-center
  필요해 wrapper API 확장 비용보다 자체 헤더 유지가 surgical.

InvestorCumulative:
- 자체 헤더 markup 을 <CardHeader title=... right={...} /> 로 교체. window
  선택 button 그룹(20/60/120일 · aria-pressed) 은 right slot 으로 전달, 기존
  inline className/동작 그대로 보존.
- 시각 결과 완전 동일 — 기존 헤더가 character-by-character 로 CardHeader 와
  같은 mb-3 flex items-baseline justify-between gap-2 였음을 확인.

검증: tsc/next lint 통과.
2026-06-01 11:02:20 +09:00

179 lines
5.6 KiB
TypeScript

"use client";
// 투자자별 누적 순매수 미니 차트.
//
// 토스 종목 상세의 "투자자별 매매" 탭에서 인상이 강한 위젯 — 외국인/기관/개인
// 각각의 N일 누적 순매수를 작은 라인으로 보여줘서 "누가 모으고 누가 분배하고 있는가"
// 를 한 눈에 잡게 한다.
//
// 데이터 소스는 ChartPayload.trading_value (이미 페이지에 있음) 그대로. 백엔드 호출 0.
// 단위: 원 → 억원(1e8) 환산.
//
// 색은 한국 컬러 — 누적 +면 빨강(매수 우위), -면 파랑(매도 우위). 라인 자체 색은
// 주체별로 고정해서 비교 가능하게 (외국인=amber, 기관=cyan, 개인=violet).
import { useMemo, useState } from "react";
import type { TradingValuePoint } from "../lib/api";
import { CardHeader } from "./CardHeader";
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<Window>(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<Subject, number[]> = {
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 (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4 text-xs text-zinc-500">
</div>
);
}
return (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
<CardHeader
title="투자자별 누적 순매수"
right={
<div className="flex items-center gap-1.5 text-[10px]">
{WINDOW_OPTIONS.map((w) => {
const on = win === w.v;
return (
<button
key={w.v}
type="button"
onClick={() => setWin(w.v)}
className={`rounded-full border px-2 py-0.5 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}
>
{w.label}
</button>
);
})}
</div>
}
/>
<div className="space-y-2.5">
{SUBJECTS.map((s) => (
<SubjectRow key={s.key} label={s.label} color={s.color} values={series.out[s.key]} />
))}
</div>
<div className="mt-3 text-[10px] text-zinc-500">
단위: 억원 · {effectiveWin} ( 0)
</div>
</div>
);
}
// 한 주체에 대한 라벨 + 누적 라인 + 끝 값.
// 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 (
<div className="flex items-center gap-2 text-[11px] text-zinc-500">
<span className="w-10 text-zinc-400">{label}</span>
<span></span>
</div>
);
}
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 (
<div className="flex items-center gap-2">
<span className="w-10 shrink-0 text-[11px] text-zinc-400">{label}</span>
<svg
viewBox={`0 0 ${w} ${h}`}
preserveAspectRatio="none"
className="h-7 flex-1"
aria-hidden
>
<line
x1={0}
y1={baseY}
x2={w}
y2={baseY}
stroke="#3f3f46"
strokeDasharray="2 2"
strokeWidth={0.5}
/>
<path d={path} fill="none" stroke={color} strokeWidth={1.2} strokeLinejoin="round" />
</svg>
<span className={`w-16 shrink-0 text-right text-[11px] tabular-nums ${tone}`}>
{sign}
{endEok.toLocaleString(undefined, { maximumFractionDigits: 0 })}
</span>
</div>
);
}