dashboard 형태의 UI 수정 및 고도화
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getStockIndices, getStockNews } from '../../api';
|
||||
import { getStockIndices, getStockNews, getFearAndGreed, getVix } from '../../api';
|
||||
import Loading from '../../components/Loading';
|
||||
import FearGreedGauge from '../../components/FearGreedGauge';
|
||||
import './Stock.css';
|
||||
|
||||
const formatDate = (value) => {
|
||||
@@ -11,21 +12,6 @@ const formatDate = (value) => {
|
||||
return date.toLocaleString('sv-SE');
|
||||
};
|
||||
|
||||
const toDateValue = (value) => {
|
||||
if (!value) return null;
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
};
|
||||
|
||||
const getLatestBy = (items, key) => {
|
||||
const filtered = items
|
||||
.map((item) => ({ ...item, __date: toDateValue(item?.[key]) }))
|
||||
.filter((item) => item.__date);
|
||||
if (!filtered.length) return null;
|
||||
filtered.sort((a, b) => b.__date - a.__date);
|
||||
return filtered[0]?.[key] ?? null;
|
||||
};
|
||||
|
||||
const normalizeIndices = (data) => {
|
||||
if (!data) return [];
|
||||
|
||||
@@ -65,23 +51,40 @@ const normalizeIndices = (data) => {
|
||||
};
|
||||
|
||||
const getDirection = (change, percent, direction) => {
|
||||
if (direction === 'red') return 'up';
|
||||
if (direction === 'blue') return 'down';
|
||||
const pick = (value) =>
|
||||
value === undefined || value === null || value === '' ? null : value;
|
||||
const raw = pick(change) ?? pick(percent);
|
||||
if (!raw) return '';
|
||||
const str = String(raw).trim();
|
||||
if (str.startsWith('-')) return 'down';
|
||||
if (str.startsWith('+')) return 'up';
|
||||
const numeric = Number(str.replace(/[^0-9.-]/g, ''));
|
||||
if (Number.isFinite(numeric)) {
|
||||
if (numeric > 0) return 'up';
|
||||
if (numeric < 0) return 'down';
|
||||
// 숫자 부호로 방향 추출 (percent → change 순서로 시도)
|
||||
const fromStr = (s) => {
|
||||
if (s === undefined || s === null || s === '') return null;
|
||||
const str = String(s).trim();
|
||||
if (str.startsWith('-')) return 'down';
|
||||
if (str.startsWith('+')) return 'up';
|
||||
const numeric = Number(str.replace(/[^0-9.-]/g, ''));
|
||||
if (Number.isFinite(numeric) && numeric !== 0) {
|
||||
return numeric > 0 ? 'up' : 'down';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
// percent 필드가 부호를 가장 신뢰성 있게 포함하는 경우가 많음
|
||||
const byPercent = fromStr(percent);
|
||||
if (byPercent) return byPercent;
|
||||
const byChange = fromStr(change);
|
||||
if (byChange) return byChange;
|
||||
// 숫자로 판별 불가 시 direction 필드 fallback
|
||||
if (direction) {
|
||||
const d = String(direction).toLowerCase();
|
||||
if (d === 'red' || d === 'up' || d === 'rise' || d === 'positive') return 'up';
|
||||
if (d === 'blue' || d === 'down' || d === 'fall' || d === 'negative') return 'down';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const getVixLevel = (score) => {
|
||||
if (score < 12) return { label: '극히 낮음', color: '#22c55e' };
|
||||
if (score < 20) return { label: '정상', color: '#84cc16' };
|
||||
if (score < 30) return { label: '보통', color: '#eab308' };
|
||||
if (score < 40) return { label: '높음', color: '#f97316' };
|
||||
return { label: '극단', color: '#ef4444' };
|
||||
};
|
||||
|
||||
const Stock = () => {
|
||||
const [newsDomestic, setNewsDomestic] = useState([]);
|
||||
const [newsOverseas, setNewsOverseas] = useState([]);
|
||||
@@ -94,14 +97,13 @@ const Stock = () => {
|
||||
const [indicesLoading, setIndicesLoading] = useState(false);
|
||||
const [autoRefreshMs] = useState(180000);
|
||||
|
||||
const [fgData, setFgData] = useState(null);
|
||||
const [vixData, setVixData] = useState(null);
|
||||
|
||||
const combinedNews = useMemo(
|
||||
() => [...newsDomestic, ...newsOverseas],
|
||||
[newsDomestic, newsOverseas]
|
||||
);
|
||||
const latestPublished = useMemo(
|
||||
() => getLatestBy(combinedNews, 'published_at'),
|
||||
[combinedNews]
|
||||
);
|
||||
|
||||
const loadNews = async () => {
|
||||
setLoading(true);
|
||||
@@ -143,6 +145,19 @@ const Stock = () => {
|
||||
return () => window.clearInterval(timer);
|
||||
}, [autoRefreshMs]);
|
||||
|
||||
useEffect(() => {
|
||||
getFearAndGreed()
|
||||
.then((data) => {
|
||||
const fg = data?.fear_and_greed ?? data;
|
||||
const score = typeof fg?.score === 'number' ? fg.score : parseFloat(fg?.score);
|
||||
if (!isNaN(score)) {
|
||||
setFgData({ score, timestamp: fg?.timestamp ?? null });
|
||||
}
|
||||
})
|
||||
.catch(() => { });
|
||||
getVix().then(setVixData).catch(() => { });
|
||||
}, []);
|
||||
|
||||
const indexOrder = [
|
||||
'KOSPI',
|
||||
'KOSDAQ',
|
||||
@@ -262,62 +277,53 @@ const Stock = () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 시장 심리 지표 행 */}
|
||||
<section className="stock-filter-row">
|
||||
<div className="stock-panel stock-panel--compact">
|
||||
<div className="stock-panel__head">
|
||||
<div>
|
||||
<p className="stock-panel__eyebrow">필터</p>
|
||||
<h3>뉴스 필터</h3>
|
||||
<p className="stock-panel__sub">
|
||||
표시할 뉴스 개수를 조정합니다.
|
||||
</p>
|
||||
<p className="stock-panel__eyebrow">심리 지표</p>
|
||||
<h3>Fear & Greed</h3>
|
||||
<p className="stock-panel__sub">시장 탐욕·공포 지수 (0–100)</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stock-filter">
|
||||
<label>
|
||||
표시 개수
|
||||
<select
|
||||
value={limit}
|
||||
onChange={(event) =>
|
||||
setLimit(Number(event.target.value))
|
||||
}
|
||||
>
|
||||
{[10, 20, 30, 40].map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<p className="stock-filter__note">
|
||||
최신 뉴스가 먼저 표시됩니다.
|
||||
</p>
|
||||
</div>
|
||||
{fgData ? (
|
||||
<FearGreedGauge
|
||||
score={Math.round(fgData.score)}
|
||||
date={fgData.timestamp ? new Date(fgData.timestamp).toLocaleDateString('ko-KR') : undefined}
|
||||
showLevels
|
||||
/>
|
||||
) : (
|
||||
<p className="stock-empty" style={{ fontSize: 13 }}>데이터 없음</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="stock-panel stock-panel--compact">
|
||||
<div className="stock-panel__head">
|
||||
<div>
|
||||
<p className="stock-panel__eyebrow">요약</p>
|
||||
<h3>뉴스 요약</h3>
|
||||
<p className="stock-panel__sub">
|
||||
최신 발행 시각과 기사 수를 확인합니다.
|
||||
<p className="stock-panel__eyebrow">변동성 지수</p>
|
||||
<h3>VIX</h3>
|
||||
<p className="stock-panel__sub">CBOE 공포 지수</p>
|
||||
</div>
|
||||
</div>
|
||||
{vixData ? (
|
||||
<div className="stock-vix">
|
||||
<div className="stock-vix__score" style={{ color: getVixLevel(vixData.value ?? vixData.vix ?? 0).color }}>
|
||||
{vixData.value ?? vixData.vix ?? '--'}
|
||||
</div>
|
||||
<p className="stock-vix__label" style={{ color: getVixLevel(vixData.value ?? vixData.vix ?? 0).color }}>
|
||||
{getVixLevel(vixData.value ?? vixData.vix ?? 0).label}
|
||||
</p>
|
||||
<div className="stock-vix__legend">
|
||||
<span style={{ color: '#22c55e' }}>{'<12'} 극히낮음</span>
|
||||
<span style={{ color: '#84cc16' }}>12-20 정상</span>
|
||||
<span style={{ color: '#eab308' }}>20-30 보통</span>
|
||||
<span style={{ color: '#f97316' }}>30-40 높음</span>
|
||||
<span style={{ color: '#ef4444' }}>{'40+'} 극단</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stock-status">
|
||||
<div>
|
||||
<span>최신 발행</span>
|
||||
<strong>{formatDate(latestPublished)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>국내</span>
|
||||
<strong>{newsDomestic.length}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>해외</span>
|
||||
<strong>{newsOverseas.length}</strong>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="stock-empty" style={{ fontSize: 13 }}>데이터 없음</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -347,46 +353,46 @@ const Stock = () => {
|
||||
<p className="stock-empty">뉴스가 없습니다.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="stock-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={`stock-tab ${newsCategory === 'domestic'
|
||||
? 'is-active'
|
||||
: ''
|
||||
}`}
|
||||
onClick={() => setNewsCategory('domestic')}
|
||||
<div className="stock-news-toolbar">
|
||||
<div className="stock-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={`stock-tab ${newsCategory === 'domestic' ? 'is-active' : ''}`}
|
||||
onClick={() => setNewsCategory('domestic')}
|
||||
>
|
||||
국내 <span className="stock-tab-count">{newsDomestic.length}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`stock-tab ${newsCategory === 'overseas' ? 'is-active' : ''}`}
|
||||
onClick={() => setNewsCategory('overseas')}
|
||||
>
|
||||
해외 <span className="stock-tab-count">{newsOverseas.length}</span>
|
||||
</button>
|
||||
</div>
|
||||
<select
|
||||
className="stock-news-limit"
|
||||
value={limit}
|
||||
onChange={(e) => setLimit(Number(e.target.value))}
|
||||
>
|
||||
국내
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`stock-tab ${newsCategory === 'overseas'
|
||||
? 'is-active'
|
||||
: ''
|
||||
}`}
|
||||
onClick={() => setNewsCategory('overseas')}
|
||||
>
|
||||
해외
|
||||
</button>
|
||||
{[10, 20, 30, 40].map((v) => (
|
||||
<option key={v} value={v}>{v}개</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{activeNews.length === 0 ? (
|
||||
<p className="stock-empty">
|
||||
해당 카테고리 뉴스가 없습니다.
|
||||
</p>
|
||||
) : (
|
||||
<div className="stock-news">
|
||||
<div className="stock-news-grid">
|
||||
{activeNews.map((item) => (
|
||||
<article
|
||||
key={item.id ?? item.link}
|
||||
className="stock-news__item"
|
||||
className="stock-news-card"
|
||||
>
|
||||
<div>
|
||||
<p className="stock-news__title">
|
||||
{item.title}
|
||||
</p>
|
||||
</div>
|
||||
<div className="stock-news__meta">
|
||||
<span>
|
||||
<div className="stock-news-card__head">
|
||||
<span className="stock-news-card__date">
|
||||
{formatDate(item.published_at)}
|
||||
</span>
|
||||
{item.sentiment ? (
|
||||
@@ -394,14 +400,25 @@ const Stock = () => {
|
||||
{item.sentiment}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="stock-news-card__title">
|
||||
{item.title}
|
||||
</p>
|
||||
{item.summary && (
|
||||
<p className="stock-news-card__summary">
|
||||
{item.summary}
|
||||
</p>
|
||||
)}
|
||||
{item.link && (
|
||||
<a
|
||||
href={item.link}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="stock-news-card__link"
|
||||
>
|
||||
원문 보기
|
||||
원문 보기 →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user