Compare commits
9 Commits
3b06b95586
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a09540e9f | ||
|
|
c172f244e3 | ||
|
|
0aebdc9bdb | ||
|
|
2b2d1ea661 | ||
|
|
be1f8870b4 | ||
|
|
fabc21e262 | ||
|
|
9e8c32e70c | ||
|
|
959e9afa80 | ||
|
|
232b94755a |
40
web/components/CardHeader.tsx
Normal file
40
web/components/CardHeader.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// 상세 페이지 본문 카드의 표준 헤더 — title 좌, 옵셔널 우측 슬롯 우.
|
||||||
|
// 작은 wrapper 지만 본문 카드 9개(Metrics / IntradayReturns / PeriodReturns /
|
||||||
|
// CompositeScoreCard / PeerComparePanel / TradingValuePanel / DisclosuresPanel /
|
||||||
|
// NewsList / InvestorCumulative) 가 같은 mb-3 flex items-baseline justify-between
|
||||||
|
// 패턴을 character-by-character 로 중복 정의하고 있어, ReturnBucket 분리와 같은
|
||||||
|
// 이유로 단일 source of truth 로 묶는다 — 한쪽만 마진/폰트 바꾸면 본문 카드들
|
||||||
|
// 사이에 톤 점프가 생기는 future drift 리스크 제거.
|
||||||
|
//
|
||||||
|
// 우측 슬롯은 두 형태:
|
||||||
|
// - subtitle: 단순 보조 텍스트 (text-[11px] text-zinc-500 자동 적용). 8개 카드 사용.
|
||||||
|
// - right : 임의 ReactNode (버튼 그룹 등 인터랙티브). InvestorCumulative 가 사용.
|
||||||
|
// 둘 중 하나만 지정. 동시 지정 시 right 우선 (호출자 의도 명확화는 호출 시점에서).
|
||||||
|
//
|
||||||
|
// 제외 카드 — PredictionPanel: title 자체가 제목 + 설명 2줄 stack 이고 우측 큰
|
||||||
|
// button 이라 flex 정렬이 items-center 필요. 별도 패턴이 한 곳뿐이라 wrapper 의
|
||||||
|
// API 를 확장하기보다는 자체 헤더 유지하는 게 surgical.
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
title: ReactNode;
|
||||||
|
// 우측 보조 텍스트. 없으면 헤더에 title 만 남음 (이 경우에도 mb-3 flex 컨테이너
|
||||||
|
// 는 유지 — MetricsPanel 같은 단일-제목 카드도 같은 라인 높이로 정렬).
|
||||||
|
subtitle?: ReactNode;
|
||||||
|
// 우측 임의 노드 (버튼 그룹 등). 제공되면 subtitle 대신 렌더.
|
||||||
|
right?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CardHeader({ title, subtitle, right }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="mb-3 flex items-baseline justify-between gap-2">
|
||||||
|
<div className="text-sm font-medium text-zinc-200">{title}</div>
|
||||||
|
{right != null ? (
|
||||||
|
right
|
||||||
|
) : subtitle != null ? (
|
||||||
|
<div className="text-[11px] text-zinc-500">{subtitle}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -54,7 +54,14 @@ export function Collapsible({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
className="flex w-full items-baseline justify-between gap-2 px-4 py-3 text-left transition hover:bg-zinc-900/60"
|
// hover 톤은 zinc-800/50 — zinc-900 보다 한 단계 밝은 색으로 잡아 부모의
|
||||||
|
// bg 불투명도와 무관하게 항상 highlight 방향으로 시각 신호. zinc-900/60
|
||||||
|
// 였을 땐 비-sticky 부모(/40) 에선 더 어두워져 highlight 가 됐지만,
|
||||||
|
// OrderbookPanel sticky 부모(/80) 에선 오히려 더 옅어져 hover 가 "뒤로
|
||||||
|
// 가는" 인상이 되는 톤 역전 발생. zinc-800 으로 한 단계 색을 올리면
|
||||||
|
// /50 opacity 라도 색 자체가 밝아 두 부모 모두에서 highlight 로 읽힘.
|
||||||
|
// 사이드바 list-row hover 톤(RelatedStocks/PeerList)과도 통일.
|
||||||
|
className="flex w-full items-baseline justify-between gap-2 px-4 py-3 text-left transition hover:bg-zinc-800/50"
|
||||||
>
|
>
|
||||||
<span className="flex min-w-0 items-center gap-2 text-sm font-medium text-zinc-200">
|
<span className="flex min-w-0 items-center gap-2 text-sm font-medium text-zinc-200">
|
||||||
<span
|
<span
|
||||||
@@ -72,7 +79,12 @@ export function Collapsible({
|
|||||||
// 손절 987,654") 사이드바 폭을 넘기는 대신 말줄임표로 끝나도록. shrink-0 였을 땐
|
// 손절 987,654") 사이드바 폭을 넘기는 대신 말줄임표로 끝나도록. shrink-0 였을 땐
|
||||||
// title 을 밀어내며 헤더가 두 줄로 깨질 수 있었음. title 쪽도 min-w-0 을 줘 flex
|
// title 을 밀어내며 헤더가 두 줄로 깨질 수 있었음. title 쪽도 min-w-0 을 줘 flex
|
||||||
// shrink 계산이 올바르게 되도록.
|
// shrink 계산이 올바르게 되도록.
|
||||||
<span className="min-w-0 truncate text-[10px] text-zinc-500">
|
//
|
||||||
|
// 크기 토큰: text-[11px] — CardHeader 의 subtitle 과 동일 토큰. 사이드바 패널
|
||||||
|
// 헤더(Collapsible) 와 본문 카드 헤더(CardHeader) 가 같은 페이지 좌우에 나란히
|
||||||
|
// 놓이므로 헤더 우측 보조 텍스트 크기를 통일. 280px 좁은 사이드바에서도 truncate
|
||||||
|
// 가 overflow 를 흡수해 1px 키워도 wrap 위험 없음.
|
||||||
|
<span className="min-w-0 truncate text-[11px] text-zinc-500">
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
// 종합 = 평균. 50 미만 = 약세(파랑), 50 초과 = 강세(빨강).
|
// 종합 = 평균. 50 미만 = 약세(파랑), 50 초과 = 강세(빨강).
|
||||||
|
|
||||||
import type { ChartPayload } from "../lib/api";
|
import type { ChartPayload } from "../lib/api";
|
||||||
|
import { CardHeader } from "./CardHeader";
|
||||||
|
|
||||||
type Scores = {
|
type Scores = {
|
||||||
momentum: number | null;
|
momentum: number | null;
|
||||||
@@ -111,12 +112,7 @@ export function CompositeScoreCard({ chart }: { chart: ChartPayload }) {
|
|||||||
const s = computeScores(chart);
|
const s = computeScores(chart);
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
|
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
|
||||||
<div className="mb-3 flex items-baseline justify-between">
|
<CardHeader title="종합 점수 (AI)" subtitle="모멘텀·수급·감성·추세 평균" />
|
||||||
<div className="text-sm font-medium text-zinc-200">종합 점수 (AI)</div>
|
|
||||||
<div className="text-[11px] text-zinc-500">
|
|
||||||
모멘텀·수급·감성·추세 평균
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-[auto_1fr] items-center gap-5">
|
<div className="grid grid-cols-[auto_1fr] items-center gap-5">
|
||||||
<ScoreDial value={s.composite} />
|
<ScoreDial value={s.composite} />
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { api, type NewsResponse } from "../lib/api";
|
import { api, type NewsResponse } from "../lib/api";
|
||||||
|
import { CardHeader } from "./CardHeader";
|
||||||
|
|
||||||
export function DisclosuresPanel({ code }: { code: string }) {
|
export function DisclosuresPanel({ code }: { code: string }) {
|
||||||
const [data, setData] = useState<NewsResponse | null>(null);
|
const [data, setData] = useState<NewsResponse | null>(null);
|
||||||
@@ -27,12 +28,10 @@ export function DisclosuresPanel({ code }: { code: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
|
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
|
||||||
<div className="mb-3 flex items-baseline justify-between">
|
<CardHeader
|
||||||
<div className="text-sm font-medium text-zinc-200">공시 (DART)</div>
|
title="공시 (DART)"
|
||||||
<div className="text-[11px] text-zinc-500">
|
subtitle={data ? `최근 ${data.items.length}건` : ""}
|
||||||
{data ? `최근 ${data.items.length}건` : ""}
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{err && <div className="text-xs text-red-400">공시 로딩 실패: {err}</div>}
|
{err && <div className="text-xs text-red-400">공시 로딩 실패: {err}</div>}
|
||||||
{!err && !data && <div className="text-xs text-zinc-500">공시 로딩 중…</div>}
|
{!err && !data && <div className="text-xs text-zinc-500">공시 로딩 중…</div>}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
// 추가 백엔드 호출 없음 — chart.ohlcv 슬라이싱만.
|
// 추가 백엔드 호출 없음 — chart.ohlcv 슬라이싱만.
|
||||||
|
|
||||||
import type { OhlcvPoint } from "../lib/api";
|
import type { OhlcvPoint } from "../lib/api";
|
||||||
|
import { CardHeader } from "./CardHeader";
|
||||||
|
import { ReturnBucket } from "./ReturnBucket";
|
||||||
|
|
||||||
const BUCKETS: { label: string; bars: number }[] = [
|
const BUCKETS: { label: string; bars: number }[] = [
|
||||||
{ label: "10분", bars: 1 },
|
{ label: "10분", bars: 1 },
|
||||||
@@ -49,33 +51,12 @@ export function IntradayReturns({ ohlcv }: { ohlcv: OhlcvPoint[] }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
|
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
|
||||||
<div className="mb-3 flex items-baseline justify-between">
|
<CardHeader title="단기 수익률" subtitle="10분봉 기준 · 60초 갱신" />
|
||||||
<div className="text-sm font-medium text-zinc-200">단기 수익률</div>
|
|
||||||
<div className="text-[11px] text-zinc-500">10분봉 기준 · 60초 갱신</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-3 gap-2 sm:grid-cols-5">
|
<div className="grid grid-cols-3 gap-2 sm:grid-cols-5">
|
||||||
{buckets.map((b) => (
|
{buckets.map((b) => (
|
||||||
<Bucket key={b.label} label={b.label} pct={b.pct} />
|
<ReturnBucket key={b.label} label={b.label} pct={b.pct} />
|
||||||
))}
|
))}
|
||||||
<Bucket label="시가대비" pct={todayPct} />
|
<ReturnBucket label="시가대비" pct={todayPct} />
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Bucket({ label, pct }: { label: string; pct: number | null }) {
|
|
||||||
// 한국식 컬러 — 상승=rose, 하락=sky, 0/null=zinc.
|
|
||||||
const tone =
|
|
||||||
pct == null || pct === 0
|
|
||||||
? "text-zinc-400"
|
|
||||||
: pct > 0
|
|
||||||
? "text-rose-400"
|
|
||||||
: "text-sky-400";
|
|
||||||
return (
|
|
||||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/30 px-2 py-2 text-center">
|
|
||||||
<div className="text-[11px] text-zinc-500">{label}</div>
|
|
||||||
<div className={`mt-1 text-sm font-medium tabular-nums ${tone}`}>
|
|
||||||
{pct == null ? "—" : `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%`}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import type { TradingValuePoint } from "../lib/api";
|
import type { TradingValuePoint } from "../lib/api";
|
||||||
|
import { CardHeader } from "./CardHeader";
|
||||||
|
|
||||||
type Window = 20 | 60 | 120;
|
type Window = 20 | 60 | 120;
|
||||||
|
|
||||||
@@ -66,29 +67,31 @@ export function InvestorCumulative({ data }: { data: TradingValuePoint[] }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
|
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
|
||||||
<div className="mb-3 flex items-baseline justify-between gap-2">
|
<CardHeader
|
||||||
<div className="text-sm font-medium text-zinc-200">투자자별 누적 순매수</div>
|
title="투자자별 누적 순매수"
|
||||||
<div className="flex items-center gap-1.5 text-[10px]">
|
right={
|
||||||
{WINDOW_OPTIONS.map((w) => {
|
<div className="flex items-center gap-1.5 text-[10px]">
|
||||||
const on = win === w.v;
|
{WINDOW_OPTIONS.map((w) => {
|
||||||
return (
|
const on = win === w.v;
|
||||||
<button
|
return (
|
||||||
key={w.v}
|
<button
|
||||||
type="button"
|
key={w.v}
|
||||||
onClick={() => setWin(w.v)}
|
type="button"
|
||||||
className={`rounded-full border px-2 py-0.5 transition ${
|
onClick={() => setWin(w.v)}
|
||||||
on
|
className={`rounded-full border px-2 py-0.5 transition ${
|
||||||
? "border-zinc-600 bg-zinc-800/80 text-zinc-100"
|
on
|
||||||
: "border-zinc-800 bg-transparent text-zinc-500 hover:text-zinc-300"
|
? "border-zinc-600 bg-zinc-800/80 text-zinc-100"
|
||||||
}`}
|
: "border-zinc-800 bg-transparent text-zinc-500 hover:text-zinc-300"
|
||||||
aria-pressed={on}
|
}`}
|
||||||
>
|
aria-pressed={on}
|
||||||
{w.label}
|
>
|
||||||
</button>
|
{w.label}
|
||||||
);
|
</button>
|
||||||
})}
|
);
|
||||||
</div>
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
{SUBJECTS.map((s) => (
|
{SUBJECTS.map((s) => (
|
||||||
<SubjectRow key={s.key} label={s.label} color={s.color} values={series.out[s.key]} />
|
<SubjectRow key={s.key} label={s.label} color={s.color} values={series.out[s.key]} />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { api, type MetricsResponse } from "../lib/api";
|
import { api, type MetricsResponse } from "../lib/api";
|
||||||
|
import { CardHeader } from "./CardHeader";
|
||||||
|
|
||||||
export function MetricsPanel({ code }: { code: string }) {
|
export function MetricsPanel({ code }: { code: string }) {
|
||||||
const [m, setM] = useState<MetricsResponse | null>(null);
|
const [m, setM] = useState<MetricsResponse | null>(null);
|
||||||
@@ -36,7 +37,7 @@ export function MetricsPanel({ code }: { code: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
|
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
|
||||||
<div className="mb-2 text-sm font-medium text-zinc-200">최근 30일 모델 성능</div>
|
<CardHeader title="최근 30일 모델 성능" />
|
||||||
<table className="w-full text-left text-sm">
|
<table className="w-full text-left text-sm">
|
||||||
<thead className="text-xs text-zinc-500">
|
<thead className="text-xs text-zinc-500">
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { api, type NewsItem, type NewsResponse } from "../lib/api";
|
import { api, type NewsItem, type NewsResponse } from "../lib/api";
|
||||||
|
import { CardHeader } from "./CardHeader";
|
||||||
|
|
||||||
export function NewsList({ code }: { code: string }) {
|
export function NewsList({ code }: { code: string }) {
|
||||||
const [data, setData] = useState<NewsResponse | null>(null);
|
const [data, setData] = useState<NewsResponse | null>(null);
|
||||||
@@ -34,10 +35,7 @@ export function NewsList({ code }: { code: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
|
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
|
||||||
<div className="mb-3 flex items-baseline justify-between">
|
<CardHeader title="뉴스 · 공시" subtitle={`최근 ${data.items.length}건`} />
|
||||||
<div className="text-sm font-medium text-zinc-200">뉴스 · 공시</div>
|
|
||||||
<div className="text-[11px] text-zinc-500">최근 {data.items.length}건</div>
|
|
||||||
</div>
|
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{data.items.map((n, i) => (
|
{data.items.map((n, i) => (
|
||||||
<NewsCard key={i} n={n} />
|
<NewsCard key={i} n={n} />
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { api, type PeerCompareItem, type PeerCompareResponse } from "../lib/api";
|
import { api, type PeerCompareItem, type PeerCompareResponse } from "../lib/api";
|
||||||
|
import { CardHeader } from "./CardHeader";
|
||||||
|
|
||||||
export function PeerComparePanel({ code }: { code: string }) {
|
export function PeerComparePanel({ code }: { code: string }) {
|
||||||
const [data, setData] = useState<PeerCompareResponse | null>(null);
|
const [data, setData] = useState<PeerCompareResponse | null>(null);
|
||||||
@@ -50,12 +51,10 @@ export function PeerComparePanel({ code }: { code: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
|
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
|
||||||
<div className="mb-3 flex items-baseline justify-between">
|
<CardHeader
|
||||||
<div className="text-sm font-medium text-zinc-200">동종업종 비교</div>
|
title="동종업종 비교"
|
||||||
<div className="text-[11px] text-zinc-500">
|
subtitle={`최근 ${data.window}거래일 · peer ${data.peer_count ?? 0}종목`}
|
||||||
최근 {data.window}거래일 · peer {data.peer_count ?? 0}종목
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-3 flex flex-wrap gap-1.5">
|
<div className="mb-3 flex flex-wrap gap-1.5">
|
||||||
{data.themes.map((t) => (
|
{data.themes.map((t) => (
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
// 거래일 기준 근사: 1주≈5, 1개월≈21, 3개월≈63, 6개월≈126, 1년≈252.
|
// 거래일 기준 근사: 1주≈5, 1개월≈21, 3개월≈63, 6개월≈126, 1년≈252.
|
||||||
|
|
||||||
import type { OhlcvPoint } from "../lib/api";
|
import type { OhlcvPoint } from "../lib/api";
|
||||||
|
import { CardHeader } from "./CardHeader";
|
||||||
|
import { ReturnBucket } from "./ReturnBucket";
|
||||||
|
|
||||||
const BUCKETS: { label: string; days: number }[] = [
|
const BUCKETS: { label: string; days: number }[] = [
|
||||||
{ label: "1주", days: 5 },
|
{ label: "1주", days: 5 },
|
||||||
@@ -39,34 +41,12 @@ export function PeriodReturns({ ohlcv }: { ohlcv: OhlcvPoint[] }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
|
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
|
||||||
<div className="mb-3 flex items-baseline justify-between">
|
<CardHeader title="기간 수익률" subtitle="차트 구간 기준" />
|
||||||
<div className="text-sm font-medium text-zinc-200">기간 수익률</div>
|
|
||||||
<div className="text-[11px] text-zinc-500">차트 구간 기준</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-3 gap-2 sm:grid-cols-6">
|
<div className="grid grid-cols-3 gap-2 sm:grid-cols-6">
|
||||||
{buckets.map((b) => (
|
{buckets.map((b) => (
|
||||||
<Bucket key={b.label} label={b.label} pct={b.pct} />
|
<ReturnBucket key={b.label} label={b.label} pct={b.pct} />
|
||||||
))}
|
))}
|
||||||
<Bucket label="전체" pct={all} />
|
<ReturnBucket label="전체" pct={all} />
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Bucket({ label, pct }: { label: string; pct: number | null }) {
|
|
||||||
const tone =
|
|
||||||
pct == null || pct === 0
|
|
||||||
? "text-zinc-400"
|
|
||||||
: pct > 0
|
|
||||||
? "text-rose-400"
|
|
||||||
: "text-sky-400";
|
|
||||||
return (
|
|
||||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/30 px-2 py-2 text-center">
|
|
||||||
<div className="text-[11px] text-zinc-500">{label}</div>
|
|
||||||
<div className={`mt-1 text-sm font-medium tabular-nums ${tone}`}>
|
|
||||||
{pct == null
|
|
||||||
? "—"
|
|
||||||
: `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%`}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -94,19 +94,30 @@ export function PredictionPanel({ code, initial, onResult }: Props) {
|
|||||||
|
|
||||||
<div className="mb-3 flex flex-wrap items-center gap-2 text-xs">
|
<div className="mb-3 flex flex-wrap items-center gap-2 text-xs">
|
||||||
<span className="text-zinc-500">예측 기간:</span>
|
<span className="text-zinc-500">예측 기간:</span>
|
||||||
{PREDICT_PRESETS.map((p, i) => (
|
{PREDICT_PRESETS.map((p, i) => {
|
||||||
<button
|
const on = presetIdx === i;
|
||||||
key={p.id}
|
return (
|
||||||
onClick={() => setPresetIdx(i)}
|
// segmented control — PeriodTabs / InvestorCumulative window / SMA·RSI·MACD 토글과
|
||||||
className={
|
// 같은 패턴. type="button" 으로 폼 submit 기본값 사고 차단, aria-pressed 로 스크린
|
||||||
presetIdx === i
|
// 리더에 현재 선택 노출. 시각 토큰은 PredictionPanel 자체 톤(bg-zinc-700 solid)
|
||||||
? "rounded-full bg-zinc-700 px-3 py-1 font-medium text-zinc-50"
|
// 유지 — 본문 카드 영역 안 primary 보조 컨트롤이라 툴바용 zinc-800/80 보다 한
|
||||||
: "rounded-full border border-zinc-700 px-3 py-1 text-zinc-300 hover:border-zinc-500"
|
// 단계 도드라지는 게 의도. 패딩(px-3 py-1) 도 옆 rose-600 실행 버튼과 시각
|
||||||
}
|
// 무게 균형을 위해 툴바 토글(px-2 py-0.5) 보다 큼.
|
||||||
>
|
<button
|
||||||
{p.label}
|
key={p.id}
|
||||||
</button>
|
type="button"
|
||||||
))}
|
onClick={() => setPresetIdx(i)}
|
||||||
|
aria-pressed={on}
|
||||||
|
className={
|
||||||
|
on
|
||||||
|
? "rounded-full bg-zinc-700 px-3 py-1 font-medium text-zinc-50"
|
||||||
|
: "rounded-full border border-zinc-700 px-3 py-1 text-zinc-300 hover:border-zinc-500"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
{presetIdx === 2 && (
|
{presetIdx === 2 && (
|
||||||
<span className="ml-1 text-[11px] text-amber-400">
|
<span className="ml-1 text-[11px] text-amber-400">
|
||||||
※ 30거래일 너머는 모델 학습 범위 밖 — 참고용
|
※ 30거래일 너머는 모델 학습 범위 밖 — 참고용
|
||||||
|
|||||||
28
web/components/ReturnBucket.tsx
Normal file
28
web/components/ReturnBucket.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// 수익률 미니 셀 — IntradayReturns(1D 10분봉 모드) 와 PeriodReturns(일봉 이상) 가 같은
|
||||||
|
// 슬롯에서 모드 전환으로 갈아끼우므로 두 패널의 Bucket 톤/색 규칙은 반드시 동일해야 한다.
|
||||||
|
// 예전엔 각 파일에 동일한 Bucket 이 중복 정의돼 있어 한쪽만 톤을 바꾸면 모드 전환 시
|
||||||
|
// 시각적 점프가 생길 위험이 있었다. 한 곳으로 묶어 단일 source of truth 로 둔다.
|
||||||
|
//
|
||||||
|
// 한국식 컬러 규칙: 상승 = rose, 하락 = sky, 0/null = zinc (Toss 톤).
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
label: string;
|
||||||
|
pct: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ReturnBucket({ label, pct }: Props) {
|
||||||
|
const tone =
|
||||||
|
pct == null || pct === 0
|
||||||
|
? "text-zinc-400"
|
||||||
|
: pct > 0
|
||||||
|
? "text-rose-400"
|
||||||
|
: "text-sky-400";
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-zinc-800 bg-zinc-900/30 px-2 py-2 text-center">
|
||||||
|
<div className="text-[11px] text-zinc-500">{label}</div>
|
||||||
|
<div className={`mt-1 text-sm font-medium tabular-nums ${tone}`}>
|
||||||
|
{pct == null ? "—" : `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -918,10 +918,14 @@ export function StockChart({ chart, prediction, targets }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 전체화면(native or 폴백) 일 때는 viewport 를 꽉 채워서 차트 면적 최대화.
|
// 전체화면(native or 폴백) 일 때는 viewport 를 꽉 채워서 차트 면적 최대화.
|
||||||
// 일반 모드는 기존 카드 스타일 유지.
|
// 일반 모드는 상세 페이지 본문 카드 표준 톤 (rounded-md / border-zinc-800 /
|
||||||
|
// bg-zinc-900/40) 채택 — 같은 좌측 컬럼 바로 아래의 PredictionPanel 과 톤 일치.
|
||||||
|
// padding 만 p-2 유지: 차트 캔버스가 자체 inner padding 을 들이므로 p-4 는 빈
|
||||||
|
// 가장자리 낭비. (참고: bg-zinc-900/30 은 ReturnBucket 같은 nested sub-card 용
|
||||||
|
// 보조 톤 — top-level 본문 카드는 /40 로 통일.)
|
||||||
const wrapperCls = fullscreen
|
const wrapperCls = fullscreen
|
||||||
? "fixed inset-0 z-50 flex w-full flex-col overflow-auto bg-zinc-950 p-3"
|
? "fixed inset-0 z-50 flex w-full flex-col overflow-auto bg-zinc-950 p-3"
|
||||||
: "w-full rounded-lg border border-zinc-800 bg-zinc-900/30 p-2";
|
: "w-full rounded-md border border-zinc-800 bg-zinc-900/40 p-2";
|
||||||
// 차트 본체 높이 — 전체화면이면 가용 공간을 차지하도록 flex-1.
|
// 차트 본체 높이 — 전체화면이면 가용 공간을 차지하도록 flex-1.
|
||||||
const chartBoxCls = fullscreen
|
const chartBoxCls = fullscreen
|
||||||
? "relative min-h-[300px] flex-1 w-full"
|
? "relative min-h-[300px] flex-1 w-full"
|
||||||
@@ -1046,7 +1050,16 @@ export function StockChart({ chart, prediction, targets }: Props) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void toggleFullscreen()}
|
onClick={() => void toggleFullscreen()}
|
||||||
className="flex items-center gap-1 rounded-full border border-zinc-800 px-2 py-0.5 text-[10px] text-zinc-400 transition hover:border-zinc-600 hover:text-zinc-200"
|
// aria-pressed 시각 피드백 — 같은 툴바의 SMA/RSI/MACD 토글과 동일한 canonical
|
||||||
|
// 토큰(border-zinc-600 bg-zinc-800/80 text-zinc-100) 으로 active 톤 맞춤.
|
||||||
|
// 이전엔 라벨 텍스트("⛶ 전체화면" → "⛶ 해제") 만 바뀌고 border/bg 변화가 없어,
|
||||||
|
// 시각적으로 옆 토글들과 "눌렸음" 신호의 강도가 달랐음. fullscreen 모드에서도
|
||||||
|
// 툴바는 그대로 노출되므로 (overlay 안에 같은 차트 + 툴바) 누른 상태 표시가 의미 있음.
|
||||||
|
className={`flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] transition ${
|
||||||
|
fullscreen
|
||||||
|
? "border-zinc-600 bg-zinc-800/80 text-zinc-100"
|
||||||
|
: "border-zinc-800 bg-transparent text-zinc-400 hover:border-zinc-600 hover:text-zinc-200"
|
||||||
|
}`}
|
||||||
title={fullscreen ? "전체화면 해제 (ESC / F)" : "전체화면 (F)"}
|
title={fullscreen ? "전체화면 해제 (ESC / F)" : "전체화면 (F)"}
|
||||||
aria-pressed={fullscreen}
|
aria-pressed={fullscreen}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
// 색: KR 관행 — 매수 우위(+) 빨강, 매도 우위(-) 파랑.
|
// 색: KR 관행 — 매수 우위(+) 빨강, 매도 우위(-) 파랑.
|
||||||
|
|
||||||
import type { TradingValuePoint } from "../lib/api";
|
import type { TradingValuePoint } from "../lib/api";
|
||||||
|
import { CardHeader } from "./CardHeader";
|
||||||
|
|
||||||
export function TradingValuePanel({
|
export function TradingValuePanel({
|
||||||
data,
|
data,
|
||||||
@@ -24,10 +25,10 @@ export function TradingValuePanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
|
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
|
||||||
<div className="mb-3 flex items-baseline justify-between">
|
<CardHeader
|
||||||
<div className="text-sm font-medium text-zinc-200">거래주체 순매수</div>
|
title="거래주체 순매수"
|
||||||
<div className="text-[11px] text-zinc-500">최근 {recent.length}일 · 단위: 억원</div>
|
subtitle={`최근 ${recent.length}일 · 단위: 억원`}
|
||||||
</div>
|
/>
|
||||||
<div className="overflow-hidden rounded-md border border-zinc-800">
|
<div className="overflow-hidden rounded-md border border-zinc-800">
|
||||||
<table className="w-full text-xs tabular-nums">
|
<table className="w-full text-xs tabular-nums">
|
||||||
<thead className="bg-zinc-900/60 text-[11px] text-zinc-500">
|
<thead className="bg-zinc-900/60 text-[11px] text-zinc-500">
|
||||||
|
|||||||
Reference in New Issue
Block a user