diff --git a/web/app/[code]/page.tsx b/web/app/[code]/page.tsx index df7eb68..e1bfcad 100644 --- a/web/app/[code]/page.tsx +++ b/web/app/[code]/page.tsx @@ -161,22 +161,62 @@ export default function CodePage({ params }: { params: { code: string } }) { }; }, [code]); - // 현재가/전일가 + 헤더 sparkline 시리즈 — ohlcv 끝부분에서 산출. - const { current, prev, asOf, sparkSeries } = useMemo(() => { - if (!chart || !chart.ohlcv.length) - return { current: null, prev: null, asOf: null, sparkSeries: [] as number[] }; + // 현재가/전일가 + 헤더 sparkline 시리즈 + 오늘 흐름 OHLV — ohlcv 끝부분에서 산출. + // + // flow 의 정의 (period 모드별로 "오늘" 의 의미가 다름): + // - 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); - if (!valid.length) - return { current: null, prev: null, asOf: null, sparkSeries: [] as number[] }; + if (!valid.length) return empty; const last = valid[valid.length - 1]; const prevPt = valid.length >= 2 ? valid[valid.length - 2] : null; // 최근 30개 종가 (없으면 가용 전체). 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 { current: last.close, prev: prevPt?.close ?? null, asOf: last.date, sparkSeries: recent, + flow, }; }, [chart]); @@ -238,6 +278,7 @@ export default function CodePage({ params }: { params: { code: string } }) { asOf={asOf} series={sparkSeries} viewsToday={viewsToday} + flow={flow} />
diff --git a/web/components/PriceHero.tsx b/web/components/PriceHero.tsx index 57de371..05a9c73 100644 --- a/web/components/PriceHero.tsx +++ b/web/components/PriceHero.tsx @@ -17,8 +17,20 @@ type Props = { series?: number[]; // social-proof — "오늘 N명이 봤어요" 배지. null/0 이면 숨김. 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({ name, code, @@ -28,6 +40,7 @@ export function PriceHero({ asOf, series, viewsToday, + flow, }: Props) { const change = current != null && prev != null ? current - prev : null; const pct = change != null && prev ? (change / prev) * 100 : null; @@ -71,6 +84,37 @@ export function PriceHero({ )}
+ {flow && ( + // 오늘의 흐름 요약 — 시/고/저/거래량 4-KPI mini strip. 차트 hover 없이도 진입 즉시 보임. + // 고/저 가격 자체에는 색을 칠하지 않음 (양수/음수 의미가 없으므로 톤이 거짓 신호가 됨). + // 대신 라벨만 sky-300(저) / rose-300(고) 으로 살짝 칠해 직관 보조. +
+ + 시{" "} + + {flow.open.toLocaleString()} + + + + {" "} + + {flow.high.toLocaleString()} + + + + {" "} + + {flow.low.toLocaleString()} + + + + 거래량{" "} + + {fmtVolume(flow.volume)} + + +
+ )}
{series && series.length >= 2 && (