feat(compare): 종목 비교 페이지 (/compare)
- 최대 5종목 누적수익률(%) 한 차트 비교, 기간 1M/3M/6M/1Y - URL ?codes=A,B,C 양방향 동기화 (Suspense + useSearchParams) - 검색 칩으로 종목 추가, ✕ 로 제거. 종목별 최종 수익률 칩 표시 - TopNav 우측에 "비교" 링크
This commit is contained in:
414
web/app/compare/page.tsx
Normal file
414
web/app/compare/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user