From 759ea20daaa8078d00f89db945150b44eed12c25 Mon Sep 17 00:00:00 2001 From: claude-owner Date: Thu, 28 May 2026 23:44:24 +0900 Subject: [PATCH] =?UTF-8?q?fix(pwa)+feat(investor):=20SW=20dev=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20+=20=ED=88=AC=EC=9E=90=EC=9E=90=EB=B3=84=20?= =?UTF-8?q?=EB=88=84=EC=A0=81=20=EC=88=9C=EB=A7=A4=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 은 아래줄로 이동. --- web/app/[code]/page.tsx | 4 + web/components/InvestorCumulative.tsx | 175 ++++++++++++++++++++++++++ web/components/PwaRegister.tsx | 11 +- 3 files changed, 185 insertions(+), 5 deletions(-) create mode 100644 web/components/InvestorCumulative.tsx diff --git a/web/app/[code]/page.tsx b/web/app/[code]/page.tsx index 6fd0694..265cb6f 100644 --- a/web/app/[code]/page.tsx +++ b/web/app/[code]/page.tsx @@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from "react"; import { AlertsPanel } from "../../components/AlertsPanel"; import { CompositeScoreCard } from "../../components/CompositeScoreCard"; import { DisclosuresPanel } from "../../components/DisclosuresPanel"; +import { InvestorCumulative } from "../../components/InvestorCumulative"; import { MetricsPanel } from "../../components/MetricsPanel"; import { PeerComparePanel } from "../../components/PeerComparePanel"; import { NewsList } from "../../components/NewsList"; @@ -170,6 +171,9 @@ export default function CodePage({ params }: { params: { code: string } }) {
+ +
+
diff --git a/web/components/InvestorCumulative.tsx b/web/components/InvestorCumulative.tsx new file mode 100644 index 0000000..081326c --- /dev/null +++ b/web/components/InvestorCumulative.tsx @@ -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(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 })}억 + +
+ ); +} diff --git a/web/components/PwaRegister.tsx b/web/components/PwaRegister.tsx index 210dd73..3896b4a 100644 --- a/web/components/PwaRegister.tsx +++ b/web/components/PwaRegister.tsx @@ -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 기능만 비활성. */ });