Files
stock_chart_site/web/components/SymbolSidebar.tsx
claude-owner 645e93e7bf feat(market): 거래대금 상위 + 52주 위치 게이지
- /api/markets/movers: by_trading_value (close*volume DESC) 카테고리 추가
- MarketsOverview: 4 컬럼 그리드 (등락 상위/하위/거래대금/거래량), 거래대금은 억·조 표기
- SymbolSidebar: 52주 범위 내 현재가 위치 게이지 (그라데이션 바 + 화이트 점)
2026-05-28 15:21:27 +09:00

169 lines
5.2 KiB
TypeScript

"use client";
// 토스증권 우측 정보 사이드바를 흉내낸 카드 묶음.
//
// 표시 항목 (백엔드가 안정적으로 제공하는 값으로 한정):
// - 오늘: 시가 / 고가 / 저가 / 거래량
// - 52주: 최고 / 최저 (1Y 일봉에서 계산)
//
// 백엔드에 PER/EPS/시총 데이터가 아직 없으므로 그 항목들은 의도적으로 제외.
// 차트 폴링과 독립적으로 1Y 일봉을 한 번만 받아 52주 범위만 계산한다.
import { useEffect, useMemo, useState } from "react";
import { api, type ChartPayload } from "../lib/api";
export function SymbolSidebar({ code }: { code: string }) {
const [chart, setChart] = useState<ChartPayload | null>(null);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
let alive = true;
setErr(null);
setChart(null);
api
.getChart(code, 365, "1d")
.then((c) => {
if (alive) setChart(c);
})
.catch((e) => {
if (alive) setErr(e instanceof Error ? e.message : String(e));
});
return () => {
alive = false;
};
}, [code]);
const stats = useMemo(() => {
if (!chart) return null;
const points = chart.ohlcv.filter((p) => p.close != null);
if (!points.length) return null;
const last = points[points.length - 1];
let yearHigh = -Infinity;
let yearLow = Infinity;
for (const p of points) {
if (p.high != null && p.high > yearHigh) yearHigh = p.high;
if (p.low != null && p.low < yearLow) yearLow = p.low;
}
const yh = yearHigh === -Infinity ? null : yearHigh;
const yl = yearLow === Infinity ? null : yearLow;
// 52주 위치 (0~1). 종가 기준, 분모 0 방어.
let pos: number | null = null;
if (yh != null && yl != null && yh > yl && last.close != null) {
pos = Math.min(1, Math.max(0, (last.close - yl) / (yh - yl)));
}
return {
open: last.open,
high: last.high,
low: last.low,
close: last.close,
volume: last.volume,
yearHigh: yh,
yearLow: yl,
yearPos: pos,
};
}, [chart]);
if (err) {
return (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4 text-xs text-red-400">
: {err}
</div>
);
}
if (!stats) {
return (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4 text-xs text-zinc-500">
</div>
);
}
return (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
<div className="mb-3 text-sm font-medium text-zinc-200"> </div>
<dl className="grid grid-cols-2 gap-y-2 text-sm">
<Row label="시가" value={fmt(stats.open)} />
<Row label="고가" value={fmt(stats.high)} tone="up" />
<Row label="저가" value={fmt(stats.low)} tone="down" />
<Row label="거래량" value={fmtCompact(stats.volume)} />
<Row label="52주 최고" value={fmt(stats.yearHigh)} tone="up" />
<Row label="52주 최저" value={fmt(stats.yearLow)} tone="down" />
</dl>
{stats.yearPos != null && (
<YearRangeBar
low={stats.yearLow}
high={stats.yearHigh}
pos={stats.yearPos}
/>
)}
</div>
);
}
// 52주 범위 내 현재가 위치 — 토스 종목 상세에 있는 가로 게이지를 흉내냄.
function YearRangeBar({
low,
high,
pos,
}: {
low: number | null;
high: number | null;
pos: number;
}) {
const pct = pos * 100;
return (
<div className="mt-4 border-t border-zinc-800 pt-3">
<div className="mb-1.5 flex items-center justify-between text-[10px] text-zinc-500">
<span>52 </span>
<span className="tabular-nums text-zinc-300">{pct.toFixed(0)}%</span>
</div>
<div className="relative h-1.5 w-full rounded-full bg-zinc-800">
<div
className="absolute left-0 top-0 h-full rounded-full bg-gradient-to-r from-sky-500/70 via-amber-400/70 to-rose-500/70"
style={{ width: "100%" }}
/>
<div
className="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-zinc-950 bg-zinc-100 shadow"
style={{ left: `${pct}%` }}
/>
</div>
<div className="mt-1 flex justify-between text-[10px] tabular-nums text-zinc-500">
<span>{fmt(low)}</span>
<span>{fmt(high)}</span>
</div>
</div>
);
}
function Row({
label,
value,
tone,
}: {
label: string;
value: string;
tone?: "up" | "down";
}) {
const toneCls =
tone === "up" ? "text-rose-300" : tone === "down" ? "text-sky-300" : "text-zinc-100";
return (
<>
<dt className="text-zinc-500">{label}</dt>
<dd className={`text-right tabular-nums ${toneCls}`}>{value}</dd>
</>
);
}
function fmt(n: number | null | undefined): string {
if (n == null || !Number.isFinite(n)) return "-";
return n.toLocaleString(undefined, { maximumFractionDigits: 0 });
}
function fmtCompact(n: number | null | undefined): string {
if (n == null || !Number.isFinite(n)) return "-";
if (n >= 1e8) return `${(n / 1e8).toFixed(1)}`;
if (n >= 1e4) return `${(n / 1e4).toFixed(1)}`;
return n.toLocaleString();
}