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 은 아래줄로 이동.
This commit is contained in:
175
web/components/InvestorCumulative.tsx
Normal file
175
web/components/InvestorCumulative.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
// 최소 서비스워커 등록.
|
||||
// - production 빌드에서만 등록 — dev (next dev / HMR) 환경에선 SW 가 HMR 응답을
|
||||
// 이상하게 가로채는 케이스가 있어 의도적으로 우회.
|
||||
// - 등록 실패는 silently 무시 (Safari private mode, http://0.0.0.0 같은 secure context
|
||||
// 아닌 호스트, 브라우저 정책 거부 등). PWA install 자격만 못 갖출 뿐 앱 기능엔 영향 없음.
|
||||
// - dev 에서도 등록한다: 이 사이트의 sw.js 는 fetch handler 가 없어 HMR/네트워크
|
||||
// 응답을 가로채지 않음 → HMR/캐시 꼬임 위험 0. 그리고 Docker 가 next dev 로
|
||||
// 돌고 있어 NODE_ENV 게이트를 두면 실제 배포에서 install 자격 자체가 안 잡힌다.
|
||||
// - 등록 실패는 silently 무시 (Safari private mode, http://192.168.x.x 같은 secure
|
||||
// context 아닌 호스트, 브라우저 정책 거부 등). PWA install 자격만 못 갖출 뿐 앱
|
||||
// 기능엔 영향 없음.
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
@@ -12,7 +14,6 @@ export function PwaRegister() {
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
if (process.env.NODE_ENV !== "production") return;
|
||||
navigator.serviceWorker.register("/sw.js").catch(() => {
|
||||
/* noop — 등록 실패 시 PWA 기능만 비활성. */
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user