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 은 아래줄로 이동.
176 lines
5.6 KiB
TypeScript
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>
|
|
);
|
|
}
|