From 0f89b2c04273c068fe85095235ac37bb56b58b0e Mon Sep 17 00:00:00 2001 From: claude-owner Date: Thu, 28 May 2026 18:27:27 +0900 Subject: [PATCH] =?UTF-8?q?feat(compare):=20=EC=A2=85=EB=AA=A9=20=EB=B9=84?= =?UTF-8?q?=EA=B5=90=20=ED=8E=98=EC=9D=B4=EC=A7=80=20(/compare)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 최대 5종목 누적수익률(%) 한 차트 비교, 기간 1M/3M/6M/1Y - URL ?codes=A,B,C 양방향 동기화 (Suspense + useSearchParams) - 검색 칩으로 종목 추가, ✕ 로 제거. 종목별 최종 수익률 칩 표시 - TopNav 우측에 "비교" 링크 --- web/app/compare/page.tsx | 414 ++++++++++++++++++++++++++++++++++++++ web/components/TopNav.tsx | 7 + 2 files changed, 421 insertions(+) create mode 100644 web/app/compare/page.tsx diff --git a/web/app/compare/page.tsx b/web/app/compare/page.tsx new file mode 100644 index 0000000..055dd29 --- /dev/null +++ b/web/app/compare/page.tsx @@ -0,0 +1,414 @@ +"use client"; + +// /compare?codes=005930,000660,... +// 최대 5 종목까지 같은 기간의 누적수익률(%)을 한 차트에 겹쳐서 비교. +// 토스의 "종목 비교" 화면 톤. 각 종목 첫 종가를 기준선(0%)으로 정규화. +// +// 구현: +// - URLSearchParams 의 codes (콤마구분) ↔ 내부 state 양방향 +// - 기간 토글: 1M/3M/6M/1Y (각각 21/63/126/252 거래일) +// - 종목별 /api/chart 병렬 fetch → 누적수익률로 매핑 → lightweight-charts line 시리즈 +// - 종목 추가는 헤더의 작은 검색바 (debounced) +// - 색상 팔레트 5색 순환 + +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Suspense, useEffect, useMemo, useRef, useState } from "react"; +import { + createChart, + type IChartApi, + type ISeriesApi, + type LineData, + type UTCTimestamp, +} from "lightweight-charts"; +import { api, type Symbol as SymbolItem } from "../../lib/api"; + +const MAX_CODES = 5; +const PALETTE = ["#f43f5e", "#22d3ee", "#a78bfa", "#facc15", "#34d399"]; + +type Range = "1M" | "3M" | "6M" | "1Y"; +const RANGE_DAYS: Record = { "1M": 21, "3M": 63, "6M": 126, "1Y": 252 }; + +type Series = { + code: string; + name: string; + points: { time: UTCTimestamp; value: number }[]; + finalPct: number | null; +}; + +function isoToUtcTs(s: string): UTCTimestamp { + return (Date.UTC( + Number(s.slice(0, 4)), + Number(s.slice(5, 7)) - 1, + Number(s.slice(8, 10)), + ) / 1000) as UTCTimestamp; +} + +export default function ComparePage() { + // useSearchParams 는 Suspense 안에서 안전. 내부 컴포넌트로 분리. + return ( + + + + ); +} + +function CompareInner() { + const router = useRouter(); + const search = useSearchParams(); + const codesParam = search.get("codes") ?? ""; + const [codes, setCodes] = useState(() => parseCodes(codesParam)); + const [range, setRange] = useState("3M"); + const [series, setSeries] = useState([]); + const [err, setErr] = useState(null); + + // URL ↔ state 동기화 — 외부에서 codes= 가 바뀌면 흡수. + useEffect(() => { + setCodes(parseCodes(codesParam)); + }, [codesParam]); + + // codes 변경을 URL 에 반영. + const writeUrl = (next: string[]) => { + const q = next.length ? `?codes=${next.join(",")}` : ""; + router.replace(`/compare${q}`, { scroll: false }); + }; + + const add = (code: string, _name?: string) => { + if (!code) return; + if (codes.includes(code)) return; + if (codes.length >= MAX_CODES) return; + const next = [...codes, code]; + setCodes(next); + writeUrl(next); + }; + const remove = (code: string) => { + const next = codes.filter((c) => c !== code); + setCodes(next); + writeUrl(next); + }; + + // 시리즈 fetch. + useEffect(() => { + let alive = true; + setErr(null); + if (codes.length === 0) { + setSeries([]); + return; + } + const days = RANGE_DAYS[range] + 8; // 비거래일 여유. + Promise.all( + codes.map(async (code) => { + const c = await api.getChart(code, days, "1d"); + const valid = c.ohlcv.filter((p) => p.close != null && p.close > 0); + if (!valid.length) { + return { + code, + name: c.name, + points: [], + finalPct: null, + } as Series; + } + const base = valid[0].close as number; + const pts = valid.map((p) => ({ + time: isoToUtcTs(p.date), + value: (((p.close as number) - base) / base) * 100, + })); + const final = pts[pts.length - 1].value; + return { code, name: c.name, points: pts, finalPct: final }; + }), + ) + .then((r) => { + if (alive) setSeries(r); + }) + .catch((e) => { + if (alive) setErr(e instanceof Error ? e.message : String(e)); + }); + return () => { + alive = false; + }; + }, [codes, range]); + + return ( +
+
+ + ← 홈 + + + {codes.length} / {MAX_CODES} 종목 + +
+ +

종목 비교

+

+ 기준일 종가를 0% 로 정규화한 누적수익률. 최대 {MAX_CODES} 종목까지 겹쳐서 보여줍니다. +

+ +
+ + = MAX_CODES} /> +
+ +
+ {series.map((s, i) => ( + remove(s.code)} + /> + ))} + {codes.length === 0 && ( + + 상단의 검색바에서 종목을 골라 추가하세요. + + )} +
+ + {err && ( +
+ 비교 데이터 로딩 실패: {err} +
+ )} + +
+ +
+
+ ); +} + +function parseCodes(raw: string): string[] { + if (!raw) return []; + return Array.from( + new Set( + raw + .split(",") + .map((s) => s.trim()) + .filter((s) => /^[0-9A-Za-z]{4,12}$/.test(s)), + ), + ).slice(0, MAX_CODES); +} + +function RangeTabs({ + value, + onChange, +}: { + value: Range; + onChange: (r: Range) => void; +}) { + return ( +
+ {(["1M", "3M", "6M", "1Y"] as Range[]).map((r) => ( + + ))} +
+ ); +} + +function SearchAdd({ + onPick, + disabled, +}: { + onPick: (code: string, name: string) => void; + disabled: boolean; +}) { + const [q, setQ] = useState(""); + const [items, setItems] = useState([]); + const [open, setOpen] = useState(false); + const wrapRef = useRef(null); + + useEffect(() => { + const term = q.trim(); + if (!term) { + setItems([]); + return; + } + const h = setTimeout(async () => { + try { + const r = await api.search(term, false, 6, false); + setItems(r.items); + } catch { + setItems([]); + } + }, 200); + return () => clearTimeout(h); + }, [q]); + + useEffect(() => { + const onDoc = (e: MouseEvent) => { + if (!wrapRef.current) return; + if (!wrapRef.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener("mousedown", onDoc); + return () => document.removeEventListener("mousedown", onDoc); + }, []); + + return ( +
+ { + setQ(e.target.value); + setOpen(true); + }} + onFocus={() => setOpen(true)} + disabled={disabled} + placeholder={disabled ? "최대치 도달" : "종목 추가 검색"} + className="w-56 rounded-md border border-zinc-700 bg-zinc-900 px-2.5 py-1 text-xs text-zinc-100 outline-none focus:border-zinc-500 disabled:cursor-not-allowed disabled:opacity-50" + /> + {open && q.trim() && items.length > 0 && ( +
    + {items.map((it) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} + +function Chip({ + color, + name, + code, + pct, + onRemove, +}: { + color: string; + name: string; + code: string; + pct: number | null; + onRemove: () => void; +}) { + const tone = pct == null ? "text-zinc-400" : pct > 0 ? "text-rose-300" : pct < 0 ? "text-sky-300" : "text-zinc-300"; + return ( +
+ + {name} + {code} + {pct != null && ( + + {pct >= 0 ? "+" : ""} + {pct.toFixed(2)}% + + )} + +
+ ); +} + +function CompareChart({ series }: { series: Series[] }) { + const containerRef = useRef(null); + const chartRef = useRef(null); + const seriesRef = useRef>>(new Map()); + + // chart 생성/파괴. + useEffect(() => { + if (!containerRef.current) return; + const c = createChart(containerRef.current, { + layout: { + background: { color: "transparent" }, + textColor: "#cbd5e1", + }, + grid: { + vertLines: { color: "#1f2937" }, + horzLines: { color: "#1f2937" }, + }, + rightPriceScale: { borderColor: "#374151" }, + timeScale: { borderColor: "#374151", timeVisible: false }, + autoSize: true, + crosshair: { mode: 1 }, + }); + chartRef.current = c; + const map = seriesRef.current; + return () => { + c.remove(); + chartRef.current = null; + map.clear(); + }; + }, []); + + // 시리즈 동기화. + useEffect(() => { + const c = chartRef.current; + if (!c) return; + const wanted = new Set(series.map((s) => s.code)); + // 제거된 종목 unsubscribe. + for (const [code, s] of seriesRef.current) { + if (!wanted.has(code)) { + c.removeSeries(s); + seriesRef.current.delete(code); + } + } + // 추가/갱신. + series.forEach((s, i) => { + let line = seriesRef.current.get(s.code); + if (!line) { + line = c.addLineSeries({ + color: PALETTE[i % PALETTE.length], + lineWidth: 2, + priceLineVisible: false, + lastValueVisible: false, + title: s.name, + }); + seriesRef.current.set(s.code, line); + } else { + line.applyOptions({ color: PALETTE[i % PALETTE.length] }); + } + const data: LineData[] = s.points.map((p) => ({ time: p.time, value: p.value })); + line.setData(data); + }); + c.timeScale().fitContent(); + }, [series]); + + const hasAny = useMemo(() => series.some((s) => s.points.length > 0), [series]); + + return ( +
+
+
+
+ {!hasAny && ( +
+ 비교할 종목을 추가하면 차트가 그려집니다. +
+ )} +
+ ); +} diff --git a/web/components/TopNav.tsx b/web/components/TopNav.tsx index 9b10fbb..734b469 100644 --- a/web/components/TopNav.tsx +++ b/web/components/TopNav.tsx @@ -151,6 +151,13 @@ export function TopNav() { )}
+ + 비교 +