feat(ui): 예측 오버레이 캔들화 + 7탭 기간 셀렉터 + 가격 헤로
- 예측 오버레이: 라인 시리즈 3개 (median/q10/q90) 를 단일 캔들 시리즈 하나로 합쳤다. 합성 OHLC: open=직전 close, close=point_close, high=ci_high, low=ci_low. 옅은 색 (up #fda4af / down #93c5fd) 으로 실 캔들과 시각 톤은 같고 명도만 낮춰 "어우러지되 미래"임이 보이게. - 예측 프리셋 단순화: 단기/중기/장기 + 직접입력 다 떼고 15일/30일/1년 3개로. 백엔드 horizon cap 30 → 252 로 확장 (1년 = 240 거래일). - PriceHero: 종목명/현재가/등락 큰 글씨 헤더. KR 관례대로 상승=빨강, 하락=파랑. - PeriodTabs: 1일/1주/1달/3달/1년/5년/최대 7탭. 기존 interval+days 이중 컨트롤 제거. 5년·최대 는 자동으로 주봉/월봉으로 폴백. - 차트 본체 캔들 색조도 KR 관례 (적/청) 로 통일. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,42 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { MetricsPanel } from "../../components/MetricsPanel";
|
||||
import { NewsList } from "../../components/NewsList";
|
||||
import { PeriodTabs, periodSpec, type Period } from "../../components/PeriodTabs";
|
||||
import { PredictionPanel } from "../../components/PredictionPanel";
|
||||
import { PriceHero } from "../../components/PriceHero";
|
||||
import { StockChart } from "../../components/StockChart";
|
||||
import {
|
||||
api,
|
||||
type ChartInterval,
|
||||
type ChartPayload,
|
||||
type LatestPredictionResponse,
|
||||
} from "../../lib/api";
|
||||
|
||||
const INTERVALS: { label: string; value: ChartInterval; defaultDays: number }[] = [
|
||||
{ label: "10분", value: "10m", defaultDays: 1 },
|
||||
{ label: "일", value: "1d", defaultDays: 180 },
|
||||
{ label: "주", value: "1w", defaultDays: 365 * 2 },
|
||||
{ label: "월", value: "1mo", defaultDays: 365 * 5 },
|
||||
];
|
||||
import { api, type ChartPayload, type LatestPredictionResponse } from "../../lib/api";
|
||||
|
||||
export default function CodePage({ params }: { params: { code: string } }) {
|
||||
const { code } = params;
|
||||
const [chart, setChart] = useState<ChartPayload | null>(null);
|
||||
const [prediction, setPrediction] = useState<LatestPredictionResponse | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [interval, setIntervalKind] = useState<ChartInterval>("1d");
|
||||
const [days, setDays] = useState(180);
|
||||
const [period, setPeriod] = useState<Period>("3M");
|
||||
|
||||
// interval 바꾸면 days 도 그 interval 에 맞는 기본값으로 (사용자가 명시적으로 다시 고를 수 있게).
|
||||
function pickInterval(v: ChartInterval) {
|
||||
const meta = INTERVALS.find((i) => i.value === v)!;
|
||||
setIntervalKind(v);
|
||||
setDays(meta.defaultDays);
|
||||
}
|
||||
const spec = periodSpec(period);
|
||||
const isIntraday = spec.interval === "10m";
|
||||
|
||||
// 초기/주기적 차트 로드. 10분봉이면 60초마다 폴링 — 백엔드가 캐시-then-fetch 로
|
||||
// 10분 이내면 DB 만 읽고, 넘었으면 KIS 호출. 폴링 부담은 낮음.
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setErr(null);
|
||||
@@ -44,7 +27,7 @@ export default function CodePage({ params }: { params: { code: string } }) {
|
||||
|
||||
const load = () => {
|
||||
api
|
||||
.getChart(code, days, interval)
|
||||
.getChart(code, spec.days, spec.interval)
|
||||
.then((c) => {
|
||||
if (alive) setChart(c);
|
||||
})
|
||||
@@ -54,7 +37,8 @@ export default function CodePage({ params }: { params: { code: string } }) {
|
||||
};
|
||||
load();
|
||||
|
||||
if (interval === "10m") {
|
||||
// 1일(10분봉) 모드만 60s 폴링 — 백엔드가 10분 내면 DB 만 읽음.
|
||||
if (isIntraday) {
|
||||
const h = window.setInterval(load, 60_000);
|
||||
return () => {
|
||||
alive = false;
|
||||
@@ -64,7 +48,7 @@ export default function CodePage({ params }: { params: { code: string } }) {
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [code, days, interval]);
|
||||
}, [code, spec.days, spec.interval, isIntraday]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
@@ -74,76 +58,54 @@ export default function CodePage({ params }: { params: { code: string } }) {
|
||||
if (alive && r.found) setPrediction(r);
|
||||
})
|
||||
.catch(() => {
|
||||
// 예측 이력 없는 경우는 무시.
|
||||
// 예측 이력 없음 — 무시
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [code]);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-5xl px-6 py-10">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Link href="/" className="text-xs text-zinc-500 hover:text-zinc-300">
|
||||
← 검색으로
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex overflow-hidden rounded-md border border-zinc-700 text-xs">
|
||||
{INTERVALS.map((it) => (
|
||||
<button
|
||||
key={it.value}
|
||||
onClick={() => pickInterval(it.value)}
|
||||
className={
|
||||
interval === it.value
|
||||
? "bg-emerald-700 px-3 py-1 text-white"
|
||||
: "bg-zinc-900 px-3 py-1 text-zinc-300 hover:bg-zinc-800"
|
||||
}
|
||||
>
|
||||
{it.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{interval !== "10m" && (
|
||||
<select
|
||||
value={days}
|
||||
onChange={(e) => setDays(Number(e.target.value))}
|
||||
className="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs"
|
||||
>
|
||||
<option value={60}>최근 3개월</option>
|
||||
<option value={180}>최근 6개월</option>
|
||||
<option value={365}>최근 1년</option>
|
||||
<option value={365 * 2}>최근 2년</option>
|
||||
<option value={365 * 5}>최근 5년</option>
|
||||
<option value={365 * 10}>최근 10년</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
// 현재가/전일가 — ohlcv 끝 두 점에서 산출.
|
||||
const { current, prev, asOf } = useMemo(() => {
|
||||
if (!chart || !chart.ohlcv.length) return { current: null, prev: null, asOf: null };
|
||||
const valid = chart.ohlcv.filter((p) => p.close != null);
|
||||
if (!valid.length) return { current: null, prev: null, asOf: null };
|
||||
const last = valid[valid.length - 1];
|
||||
const prevPt = valid.length >= 2 ? valid[valid.length - 2] : null;
|
||||
return {
|
||||
current: last.close,
|
||||
prev: prevPt?.close ?? null,
|
||||
asOf: last.date,
|
||||
};
|
||||
}, [chart]);
|
||||
|
||||
{chart && (
|
||||
<div className="mb-4 flex items-baseline justify-between">
|
||||
<h1 className="text-2xl font-semibold text-zinc-100">
|
||||
{chart.name}{" "}
|
||||
<span className="text-sm font-normal text-zinc-500">
|
||||
{chart.code} · {chart.market}
|
||||
</span>
|
||||
</h1>
|
||||
{interval === "10m" && (
|
||||
<div className="text-xs text-zinc-500">
|
||||
실시간 10분봉 · 60초마다 갱신
|
||||
{chart.intraday_status && (
|
||||
<span className="ml-2 text-zinc-600">[{chart.intraday_status}]</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
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>
|
||||
<PeriodTabs value={period} onChange={(id) => setPeriod(id)} />
|
||||
</div>
|
||||
|
||||
{err && <div className="mb-4 text-sm text-red-400">차트 로딩 실패: {err}</div>}
|
||||
|
||||
{chart && (
|
||||
<>
|
||||
<PriceHero
|
||||
name={chart.name}
|
||||
code={chart.code}
|
||||
market={chart.market}
|
||||
current={current}
|
||||
prev={prev}
|
||||
asOf={asOf}
|
||||
/>
|
||||
<StockChart chart={chart} prediction={prediction} />
|
||||
{isIntraday && chart.intraday_status && (
|
||||
<div className="mt-2 text-right text-[11px] text-zinc-500">
|
||||
실시간 10분봉 · 60초 갱신 · [{chart.intraday_status}]
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-6">
|
||||
<PredictionPanel code={code} initial={prediction} onResult={setPrediction} />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user