feat(price-hero): 오늘의 흐름 OHLV mini strip under change line
PriceHero gains an optional flow={open,high,low,volume} prop. Rendered
as a compact 4-KPI inline strip (시/고/저/거래량) below the change
line. Wraps naturally on narrow widths via flex-wrap.
page.tsx computes flow from chart.ohlcv tail inside the existing
useMemo:
- 1D (10m): groups bars by same KST date as last bar, aggregates as
session OHLV (open=first, high=max, low=min, volume=sum).
- 1d/1w/1mo: just the last valid bar's OHLV (each bar already
represents one unit).
Volume formatted with the same 억/만 rule as StockChart fmtKVolume —
small enough to inline at use site instead of carving out a shared
format module. High/low labels get rose-300/sky-300 ink as a soft
directional hint while the numbers themselves stay neutral (zinc-200)
to avoid implying a misleading +/- sign.
This commit is contained in:
@@ -161,22 +161,62 @@ export default function CodePage({ params }: { params: { code: string } }) {
|
|||||||
};
|
};
|
||||||
}, [code]);
|
}, [code]);
|
||||||
|
|
||||||
// 현재가/전일가 + 헤더 sparkline 시리즈 — ohlcv 끝부분에서 산출.
|
// 현재가/전일가 + 헤더 sparkline 시리즈 + 오늘 흐름 OHLV — ohlcv 끝부분에서 산출.
|
||||||
const { current, prev, asOf, sparkSeries } = useMemo(() => {
|
//
|
||||||
if (!chart || !chart.ohlcv.length)
|
// flow 의 정의 (period 모드별로 "오늘" 의 의미가 다름):
|
||||||
return { current: null, prev: null, asOf: null, sparkSeries: [] as number[] };
|
// - 1D(10m): 같은 KST 날짜의 모든 봉을 묶어서 today 의 세션 OHLV 로 집계.
|
||||||
|
// open = 첫 봉 open, high = max(high), low = min(low), volume = sum(volume).
|
||||||
|
// - 1d/1w/1mo: 마지막 봉 자체가 한 단위 (일/주/월) — 그 봉의 OHLV 를 그대로 사용.
|
||||||
|
// PriceHero hero 라인 아래에 "시 / 고 / 저 / 거래량" mini KPI 로 표시 — 차트 hover 없이도
|
||||||
|
// 페이지 진입 즉시 그날 흐름이 한 줄로 보임.
|
||||||
|
const { current, prev, asOf, sparkSeries, flow } = useMemo(() => {
|
||||||
|
const empty = {
|
||||||
|
current: null as number | null,
|
||||||
|
prev: null as number | null,
|
||||||
|
asOf: null as string | null,
|
||||||
|
sparkSeries: [] as number[],
|
||||||
|
flow: null as { open: number; high: number; low: number; volume: number } | null,
|
||||||
|
};
|
||||||
|
if (!chart || !chart.ohlcv.length) return empty;
|
||||||
const valid = chart.ohlcv.filter((p) => p.close != null);
|
const valid = chart.ohlcv.filter((p) => p.close != null);
|
||||||
if (!valid.length)
|
if (!valid.length) return empty;
|
||||||
return { current: null, prev: null, asOf: null, sparkSeries: [] as number[] };
|
|
||||||
const last = valid[valid.length - 1];
|
const last = valid[valid.length - 1];
|
||||||
const prevPt = valid.length >= 2 ? valid[valid.length - 2] : null;
|
const prevPt = valid.length >= 2 ? valid[valid.length - 2] : null;
|
||||||
// 최근 30개 종가 (없으면 가용 전체).
|
// 최근 30개 종가 (없으면 가용 전체).
|
||||||
const recent = valid.slice(-30).map((p) => p.close as number);
|
const recent = valid.slice(-30).map((p) => p.close as number);
|
||||||
|
|
||||||
|
// flow 집계.
|
||||||
|
const isIntra = chart.interval === "10m";
|
||||||
|
let bars: typeof valid;
|
||||||
|
if (isIntra) {
|
||||||
|
// 10m 모드는 'YYYY-MM-DDTHH:MM:SS' — 'T' 이전이 KST 날짜. 마지막 봉의 날짜와 같은
|
||||||
|
// 봉만 묶음 (intraday 세션 = 오늘).
|
||||||
|
const lastDay = last.date.split("T")[0];
|
||||||
|
bars = valid.filter((p) => p.date.split("T")[0] === lastDay);
|
||||||
|
} else {
|
||||||
|
bars = [last];
|
||||||
|
}
|
||||||
|
const opens = bars.map((p) => p.open).filter((v): v is number => v != null);
|
||||||
|
const highs = bars.map((p) => p.high).filter((v): v is number => v != null);
|
||||||
|
const lows = bars.map((p) => p.low).filter((v): v is number => v != null);
|
||||||
|
const vols = bars.map((p) => p.volume).filter((v): v is number => v != null);
|
||||||
|
const flow =
|
||||||
|
opens.length && highs.length && lows.length
|
||||||
|
? {
|
||||||
|
open: opens[0],
|
||||||
|
high: Math.max(...highs),
|
||||||
|
low: Math.min(...lows),
|
||||||
|
// volume 누락 봉은 0 로 취급 (sum 안 그러면 NaN). 1d 모드는 단일 봉이라 무관.
|
||||||
|
volume: vols.reduce((a, b) => a + b, 0),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
current: last.close,
|
current: last.close,
|
||||||
prev: prevPt?.close ?? null,
|
prev: prevPt?.close ?? null,
|
||||||
asOf: last.date,
|
asOf: last.date,
|
||||||
sparkSeries: recent,
|
sparkSeries: recent,
|
||||||
|
flow,
|
||||||
};
|
};
|
||||||
}, [chart]);
|
}, [chart]);
|
||||||
|
|
||||||
@@ -238,6 +278,7 @@ export default function CodePage({ params }: { params: { code: string } }) {
|
|||||||
asOf={asOf}
|
asOf={asOf}
|
||||||
series={sparkSeries}
|
series={sparkSeries}
|
||||||
viewsToday={viewsToday}
|
viewsToday={viewsToday}
|
||||||
|
flow={flow}
|
||||||
/>
|
/>
|
||||||
<div className="grid gap-6 lg:grid-cols-[1fr_280px]">
|
<div className="grid gap-6 lg:grid-cols-[1fr_280px]">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -17,8 +17,20 @@ type Props = {
|
|||||||
series?: number[];
|
series?: number[];
|
||||||
// social-proof — "오늘 N명이 봤어요" 배지. null/0 이면 숨김.
|
// social-proof — "오늘 N명이 봤어요" 배지. null/0 이면 숨김.
|
||||||
viewsToday?: number | null;
|
viewsToday?: number | null;
|
||||||
|
// 오늘(또는 마지막 봉) 흐름 요약. page.tsx 에서 ohlcv 끝부분으로 계산해 전달.
|
||||||
|
// null 이면 strip 자체를 숨김 (데이터 없음 + 레이아웃 jitter 방지).
|
||||||
|
flow?: { open: number; high: number; low: number; volume: number } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 거래량 한국식 단위 (억/만) — StockChart 의 fmtKVolume 과 같은 규칙. 둘 다 작아서
|
||||||
|
// 별도 모듈로 빼지 않고 사용처에 인라인 유지.
|
||||||
|
function fmtVolume(n: number): string {
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return "-";
|
||||||
|
if (n >= 1e8) return `${(n / 1e8).toFixed(1)}억`;
|
||||||
|
if (n >= 1e4) return `${(n / 1e4).toFixed(1)}만`;
|
||||||
|
return n.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
export function PriceHero({
|
export function PriceHero({
|
||||||
name,
|
name,
|
||||||
code,
|
code,
|
||||||
@@ -28,6 +40,7 @@ export function PriceHero({
|
|||||||
asOf,
|
asOf,
|
||||||
series,
|
series,
|
||||||
viewsToday,
|
viewsToday,
|
||||||
|
flow,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const change = current != null && prev != null ? current - prev : null;
|
const change = current != null && prev != null ? current - prev : null;
|
||||||
const pct = change != null && prev ? (change / prev) * 100 : null;
|
const pct = change != null && prev ? (change / prev) * 100 : null;
|
||||||
@@ -71,6 +84,37 @@ export function PriceHero({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{flow && (
|
||||||
|
// 오늘의 흐름 요약 — 시/고/저/거래량 4-KPI mini strip. 차트 hover 없이도 진입 즉시 보임.
|
||||||
|
// 고/저 가격 자체에는 색을 칠하지 않음 (양수/음수 의미가 없으므로 톤이 거짓 신호가 됨).
|
||||||
|
// 대신 라벨만 sky-300(저) / rose-300(고) 으로 살짝 칠해 직관 보조.
|
||||||
|
<div className="mt-3 flex flex-wrap items-baseline gap-x-4 gap-y-1 text-[11px] text-zinc-400">
|
||||||
|
<span>
|
||||||
|
시{" "}
|
||||||
|
<span className="tabular-nums text-zinc-200">
|
||||||
|
{flow.open.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span className="text-rose-300">고</span>{" "}
|
||||||
|
<span className="tabular-nums text-zinc-200">
|
||||||
|
{flow.high.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span className="text-sky-300">저</span>{" "}
|
||||||
|
<span className="tabular-nums text-zinc-200">
|
||||||
|
{flow.low.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
거래량{" "}
|
||||||
|
<span className="tabular-nums text-zinc-200">
|
||||||
|
{fmtVolume(flow.volume)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{series && series.length >= 2 && (
|
{series && series.length >= 2 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user