feat(compare): 종목 비교 페이지 (/compare)

- 최대 5종목 누적수익률(%) 한 차트 비교, 기간 1M/3M/6M/1Y
- URL ?codes=A,B,C 양방향 동기화 (Suspense + useSearchParams)
- 검색 칩으로 종목 추가, ✕ 로 제거. 종목별 최종 수익률 칩 표시
- TopNav 우측에 "비교" 링크
This commit is contained in:
claude-owner
2026-05-28 18:27:27 +09:00
parent 139403c133
commit 0f89b2c042
2 changed files with 421 additions and 0 deletions

414
web/app/compare/page.tsx Normal file
View File

@@ -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<Range, number> = { "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 (
<Suspense fallback={null}>
<CompareInner />
</Suspense>
);
}
function CompareInner() {
const router = useRouter();
const search = useSearchParams();
const codesParam = search.get("codes") ?? "";
const [codes, setCodes] = useState<string[]>(() => parseCodes(codesParam));
const [range, setRange] = useState<Range>("3M");
const [series, setSeries] = useState<Series[]>([]);
const [err, setErr] = useState<string | null>(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 (
<main className="mx-auto max-w-5xl px-6 py-8">
<div className="mb-4 flex items-center justify-between gap-3">
<Link href="/" className="text-xs text-zinc-500 hover:text-zinc-300">
</Link>
<span className="text-[11px] text-zinc-500">
{codes.length} / {MAX_CODES}
</span>
</div>
<h1 className="text-2xl font-bold text-zinc-50"> </h1>
<p className="mt-1 text-sm text-zinc-400">
0% . {MAX_CODES} .
</p>
<div className="mt-5 flex flex-wrap items-center gap-2">
<RangeTabs value={range} onChange={setRange} />
<SearchAdd onPick={add} disabled={codes.length >= MAX_CODES} />
</div>
<div className="mt-4 flex flex-wrap gap-2">
{series.map((s, i) => (
<Chip
key={s.code}
color={PALETTE[i % PALETTE.length]}
name={s.name}
code={s.code}
pct={s.finalPct}
onRemove={() => remove(s.code)}
/>
))}
{codes.length === 0 && (
<span className="text-xs text-zinc-500">
.
</span>
)}
</div>
{err && (
<div className="mt-4 rounded-md border border-rose-500/30 bg-rose-500/5 p-3 text-xs text-rose-200">
: {err}
</div>
)}
<div className="mt-4">
<CompareChart series={series} />
</div>
</main>
);
}
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 (
<div className="flex overflow-hidden rounded-md border border-zinc-700 text-[11px]">
{(["1M", "3M", "6M", "1Y"] as Range[]).map((r) => (
<button
key={r}
type="button"
onClick={() => onChange(r)}
className={`px-2.5 py-1 ${
value === r
? "bg-zinc-800 text-zinc-100"
: "bg-transparent text-zinc-500 hover:text-zinc-300"
}`}
>
{r}
</button>
))}
</div>
);
}
function SearchAdd({
onPick,
disabled,
}: {
onPick: (code: string, name: string) => void;
disabled: boolean;
}) {
const [q, setQ] = useState("");
const [items, setItems] = useState<SymbolItem[]>([]);
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement | null>(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 (
<div ref={wrapRef} className="relative">
<input
value={q}
onChange={(e) => {
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 && (
<ul className="absolute left-0 right-0 top-full z-10 mt-1 max-h-72 overflow-auto rounded-md border border-zinc-700 bg-zinc-950 shadow-xl">
{items.map((it) => (
<li key={it.code}>
<button
type="button"
onClick={() => {
onPick(it.code, it.name);
setQ("");
setOpen(false);
}}
className="flex w-full items-center justify-between gap-2 px-2.5 py-1.5 text-left text-xs hover:bg-zinc-800/60"
>
<span className="min-w-0 truncate text-zinc-100">{it.name}</span>
<span className="shrink-0 text-zinc-500">
{it.code} · {it.market}
</span>
</button>
</li>
))}
</ul>
)}
</div>
);
}
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 (
<div className="flex items-center gap-2 rounded-full border border-zinc-700 bg-zinc-900/50 px-2.5 py-1 text-xs">
<span className="inline-block h-2 w-2 rounded-full" style={{ backgroundColor: color }} />
<span className="text-zinc-100">{name}</span>
<span className="text-[10px] text-zinc-500">{code}</span>
{pct != null && (
<span className={`tabular-nums ${tone}`}>
{pct >= 0 ? "+" : ""}
{pct.toFixed(2)}%
</span>
)}
<button
type="button"
onClick={onRemove}
className="ml-1 text-zinc-500 hover:text-rose-300"
aria-label="제거"
>
</button>
</div>
);
}
function CompareChart({ series }: { series: Series[] }) {
const containerRef = useRef<HTMLDivElement | null>(null);
const chartRef = useRef<IChartApi | null>(null);
const seriesRef = useRef<Map<string, ISeriesApi<"Line">>>(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 (
<div className="w-full rounded-lg border border-zinc-800 bg-zinc-900/30 p-2">
<div className="h-[420px] w-full">
<div ref={containerRef} className="h-full w-full" />
</div>
{!hasAny && (
<div className="px-2 py-2 text-[11px] text-zinc-500">
.
</div>
)}
</div>
);
}

View File

@@ -151,6 +151,13 @@ export function TopNav() {
)}
</div>
<Link
href="/compare"
className="shrink-0 text-xs text-zinc-400 hover:text-zinc-200"
title="종목 비교"
>
</Link>
<Link
href="/alerts"
className="shrink-0 text-xs text-zinc-400 hover:text-zinc-200"