Files
stock_chart_site/web/components/InvestorCumulative.tsx
claude-owner 759ea20daa fix(pwa)+feat(investor): SW dev 등록 + 투자자별 누적 순매수
PwaRegister:
- production 게이트 제거. sw.js 는 fetch handler 가 없어 HMR/네트워크 응답을
  가로채지 않음 → dev 에서 등록해도 안전. Docker 가 next dev 로 도는 실제
  배포에서도 install 자격이 잡힘.
- 등록 실패는 그대로 silently 무시 (secure context 아님 등).

InvestorCumulative:
- chart.trading_value 를 재사용해 외국인/기관/개인 N일 누적 순매수를 미니
  SVG 라인으로 표시. 백엔드 호출 0.
- 윈도우 토글: 20일/60일/120일. 데이터 부족하면 가용분으로 자동 축소.
- 라인 색은 주체별 고정 (외국인=amber/기관=cyan/개인=violet) — 비교 가능.
- 끝값 라벨은 한국 컬러 (양수=rose-300, 음수=sky-300), 억원 단위.
- 단위는 원 → 1e8 환산. 시작 0 기준 누적 + 점선 기준선.
- TradingValuePanel (최근 5일 표) 옆 그리드 자리에 배치, MetricsPanel 은
  아래줄로 이동.
2026-05-28 23:44:24 +09:00

176 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";
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">
<div className="mb-3 flex items-baseline justify-between gap-2">
<div className="text-sm font-medium text-zinc-200"> </div>
<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>
<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>
);
}