"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 && (
비교할 종목을 추가하면 차트가 그려집니다.
)}
); }