추세검색 화면 적용
This commit is contained in:
@@ -20,6 +20,9 @@ import { normalizeSmaConfig, createDefaultSmaPlotVisibility } from '../utils/sma
|
||||
import { normalizeIchimokuConfig } from '../utils/ichimokuConfig';
|
||||
import { isUpbitMarket } from '../utils/upbitApi';
|
||||
import { useChartRealtimeData, type WsStatus } from '../hooks/useChartRealtimeData';
|
||||
import { useLiveReceiveFlash } from '../hooks/useLiveReceiveFlash';
|
||||
import VirtualLiveBadge from './virtual/VirtualLiveBadge';
|
||||
import type { VirtualLiveStatus } from '../hooks/useVirtualTargetLiveStatus';
|
||||
import { invalidateMarketCache } from '../utils/requestCache';
|
||||
import { useHistoryLoader, LOAD_MORE_TRIGGER } from '../hooks/useHistoryLoader';
|
||||
import { useIndicatorSettings } from '../hooks/useIndicatorSettings';
|
||||
@@ -39,6 +42,24 @@ import type { ChartManager } from '../utils/ChartManager';
|
||||
// ── 타임프레임 옵션 ────────────────────────────────────────────────────────
|
||||
const TF_OPTIONS: Timeframe[] = ['1m','3m','5m','10m','15m','30m','1h','4h','1D','1W','1M'];
|
||||
|
||||
function compactKoName(market: string): string {
|
||||
const ko = getKoreanName(market);
|
||||
if (ko && ko !== market) return ko;
|
||||
return market.replace(/^KRW-/, '');
|
||||
}
|
||||
|
||||
function wsToLiveStatus(status: WsStatus): VirtualLiveStatus {
|
||||
switch (status) {
|
||||
case 'connected': return 'live';
|
||||
case 'connecting': return 'connecting';
|
||||
case 'disconnected':
|
||||
case 'error':
|
||||
return 'disconnected';
|
||||
default:
|
||||
return 'idle';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ws 배지 ───────────────────────────────────────────────────────────────
|
||||
const WsBadge: React.FC<{ status: WsStatus; isUpbit: boolean }> = ({ status, isUpbit }) => {
|
||||
if (!isUpbit) return null;
|
||||
@@ -139,6 +160,8 @@ export interface ChartSlotProps {
|
||||
chartVolumeVisible?: boolean;
|
||||
chartRealtimeSource?: 'BACKEND_STOMP' | 'UPBIT_DIRECT';
|
||||
displayTimezone?: string;
|
||||
/** 멀티차트 — 가상투자 카드 차트처럼 컴팩트 카드 UI */
|
||||
compactMode?: boolean;
|
||||
}
|
||||
|
||||
const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot({
|
||||
@@ -159,6 +182,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
||||
chartVolumeVisible = true,
|
||||
chartRealtimeSource = 'BACKEND_STOMP',
|
||||
displayTimezone,
|
||||
compactMode = false,
|
||||
}, ref) {
|
||||
// ── 전역 지표 파라미터 + 시각 설정 (DB 동기화) ────────────────────────
|
||||
const { getParams, saveParams, getVisualConfig, saveVisual } = useIndicatorSettings();
|
||||
@@ -489,6 +513,13 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
||||
const dailyChange = currentBar ? currentBar.close - prevClose : 0;
|
||||
const dailyChangePct = prevClose > 0 ? dailyChange / prevClose * 100 : 0;
|
||||
|
||||
const receiveSignal = useMemo(() => {
|
||||
if (!useUpbit || wsStatus !== 'connected' || !latestBar) return null;
|
||||
return `${latestBar.time}|${latestBar.close}|${latestBar.volume}`;
|
||||
}, [useUpbit, wsStatus, latestBar]);
|
||||
|
||||
const receiving = useLiveReceiveFlash(receiveSignal, compactMode && useUpbit && wsStatus === 'connected');
|
||||
|
||||
// ── 인디케이터 핸들러 ─────────────────────────────────────────────────
|
||||
const handleIndicatorSave = useCallback((updated: IndicatorConfig) => {
|
||||
setIndicators(prev => prev.map(i => i.id === updated.id ? updated : i));
|
||||
@@ -564,36 +595,127 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
||||
setShowMarket(false);
|
||||
}, []);
|
||||
|
||||
const displayKoName = compactKoName(symbol);
|
||||
const trendUp = compactMode && currentBar != null && dailyChangePct >= 0;
|
||||
const trendDown = compactMode && currentBar != null && dailyChangePct < 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`chart-slot${active ? ' active' : ''}`}
|
||||
className={[
|
||||
'chart-slot',
|
||||
active ? 'active' : '',
|
||||
compactMode ? 'chart-slot--compact' : '',
|
||||
receiving ? 'chart-slot--receiving' : '',
|
||||
trendUp ? 'chart-slot--trend-up' : '',
|
||||
trendDown ? 'chart-slot--trend-down' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={onActivate}
|
||||
>
|
||||
{/* ── 슬롯 미니 헤더 ────────────────────────────────────────────────── */}
|
||||
<div className="slot-header" onClick={e => e.stopPropagation()}>
|
||||
{/* 심볼 버튼: 클릭 시 슬롯 영역 rect를 캡처 후 MarketSearchPanel Portal 오픈 */}
|
||||
<button
|
||||
className={`slot-sym-btn${showMarket ? ' open' : ''}`}
|
||||
title="종목 변경"
|
||||
onClick={() => {
|
||||
if (!showMarket) {
|
||||
// 클릭 시점에 슬롯 전체(.chart-slot) 컨테이너의 rect를 캡처
|
||||
const slotEl = slotContainerRef.current?.closest('.chart-slot') as HTMLElement | null;
|
||||
setSlotAnchorRect(slotEl?.getBoundingClientRect() ?? null);
|
||||
}
|
||||
setShowMarket(v => !v);
|
||||
}}
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.6" style={{ flexShrink: 0 }}>
|
||||
<circle cx="6" cy="6" r="4"/><line x1="9.5" y1="9.5" x2="13" y2="13"/>
|
||||
</svg>
|
||||
{getKoreanName(symbol) !== symbol
|
||||
? <><span className="slot-sym-kr">{getKoreanName(symbol)}</span><span className="slot-sym-code">{symbol}</span></>
|
||||
: <span className="slot-sym-kr">{symbol}</span>
|
||||
}
|
||||
</button>
|
||||
{/* ── 슬롯 헤더 ──────────────────────────────────────────────────────── */}
|
||||
<div
|
||||
className={`slot-header${compactMode ? ' slot-header--compact' : ''}`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{compactMode ? (
|
||||
<div className="slot-compact-row">
|
||||
<button
|
||||
type="button"
|
||||
className={`slot-compact-name${showMarket ? ' open' : ''}`}
|
||||
title={`${displayKoName} — 종목 변경`}
|
||||
onClick={() => {
|
||||
if (!showMarket) {
|
||||
const slotEl = slotContainerRef.current?.closest('.chart-slot') as HTMLElement | null;
|
||||
setSlotAnchorRect(slotEl?.getBoundingClientRect() ?? null);
|
||||
}
|
||||
setShowMarket(v => !v);
|
||||
}}
|
||||
>
|
||||
{displayKoName}
|
||||
</button>
|
||||
|
||||
{currentBar && (
|
||||
<div className="slot-compact-quote" aria-label="현재가">
|
||||
<span
|
||||
className="slot-compact-price"
|
||||
style={{ color: isUp ? 'var(--up)' : 'var(--down)' }}
|
||||
>
|
||||
{formatPrice(currentBar.close)}
|
||||
</span>
|
||||
<span
|
||||
className="slot-compact-change"
|
||||
style={{ color: isUp ? 'var(--up)' : 'var(--down)' }}
|
||||
>
|
||||
{isUp ? '+' : '-'}{Math.abs(dailyChangePct).toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="slot-compact-tf" onClick={e => e.stopPropagation()}>
|
||||
<span className="slot-compact-tf-label">시간봉</span>
|
||||
<select
|
||||
className="slot-compact-tf-select"
|
||||
value={timeframe}
|
||||
aria-label="시간봉 선택"
|
||||
onChange={e => setTimeframe(e.target.value as Timeframe)}
|
||||
>
|
||||
{TF_OPTIONS.map(tf => (
|
||||
<option key={tf} value={tf}>{tf}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="slot-compact-status">
|
||||
{isLoading && <span className="slot-loading">로딩…</span>}
|
||||
{useUpbit && (
|
||||
<VirtualLiveBadge status={wsToLiveStatus(wsStatus)} receiving={receiving} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className={`slot-sym-btn${showMarket ? ' open' : ''}`}
|
||||
title="종목 변경"
|
||||
onClick={() => {
|
||||
if (!showMarket) {
|
||||
const slotEl = slotContainerRef.current?.closest('.chart-slot') as HTMLElement | null;
|
||||
setSlotAnchorRect(slotEl?.getBoundingClientRect() ?? null);
|
||||
}
|
||||
setShowMarket(v => !v);
|
||||
}}
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.6" style={{ flexShrink: 0 }}>
|
||||
<circle cx="6" cy="6" r="4"/><line x1="9.5" y1="9.5" x2="13" y2="13"/>
|
||||
</svg>
|
||||
{getKoreanName(symbol) !== symbol
|
||||
? <><span className="slot-sym-kr">{getKoreanName(symbol)}</span><span className="slot-sym-code">{symbol}</span></>
|
||||
: <span className="slot-sym-kr">{symbol}</span>
|
||||
}
|
||||
</button>
|
||||
<div className="slot-tf-row">
|
||||
{TF_OPTIONS.map(tf => (
|
||||
<button
|
||||
key={tf}
|
||||
className={`slot-tf-btn${tf === timeframe ? ' active' : ''}`}
|
||||
onClick={() => setTimeframe(tf)}
|
||||
>
|
||||
{tf}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{currentBar && (
|
||||
<span className="slot-price" style={{ color: isUp ? 'var(--up)' : 'var(--down)' }}>
|
||||
{formatPrice(currentBar.close)}
|
||||
<span className="slot-change" style={{ color: isUp ? 'var(--up)' : 'var(--down)' }}>
|
||||
{isUp ? '▲' : '▼'} {Math.abs(dailyChangePct).toFixed(2)}%
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{useUpbit && <WsBadge status={wsStatus} isUpbit={true} />}
|
||||
{isLoading && <span className="slot-loading">로딩...</span>}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* MarketSearchPanel: body 포털로 렌더 + anchorRect로 해당 슬롯 영역 안에 표시 */}
|
||||
{showMarket && ReactDOM.createPortal(
|
||||
<MarketSearchPanel
|
||||
currentMarket={symbol}
|
||||
@@ -603,44 +725,22 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{/* 타임프레임 선택 */}
|
||||
<div className="slot-tf-row">
|
||||
{TF_OPTIONS.map(tf => (
|
||||
<button
|
||||
key={tf}
|
||||
className={`slot-tf-btn${tf === timeframe ? ' active' : ''}`}
|
||||
onClick={() => setTimeframe(tf)}
|
||||
>
|
||||
{tf}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 현재가 */}
|
||||
{currentBar && (
|
||||
<span className="slot-price" style={{ color: isUp ? 'var(--up)' : 'var(--down)' }}>
|
||||
{formatPrice(currentBar.close)}
|
||||
<span className="slot-change" style={{ color: isUp ? 'var(--up)' : 'var(--down)' }}>
|
||||
{isUp ? '▲' : '▼'} {Math.abs(dailyChangePct).toFixed(2)}%
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{useUpbit && <WsBadge status={wsStatus} isUpbit={true} />}
|
||||
{isLoading && <span className="slot-loading">로딩...</span>}
|
||||
</div>
|
||||
|
||||
{/* ── TradingChart ─────────────────────────────────────────────────── */}
|
||||
<div
|
||||
ref={slotContainerRef}
|
||||
className="slot-chart-wrap"
|
||||
className={compactMode ? 'vtd-card-chart-canvas slot-chart-wrap slot-chart-wrap--compact' : 'slot-chart-wrap'}
|
||||
style={{ flex: 1, minHeight: 0, position: 'relative', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
{useUpbit && isLoading && (
|
||||
<div className="chart-loading" style={{ position: 'absolute', zIndex: 10 }}>
|
||||
<div className="loading-spinner" />
|
||||
</div>
|
||||
compactMode ? (
|
||||
<div className="vtd-card-chart-loading">차트 로딩…</div>
|
||||
) : (
|
||||
<div className="chart-loading" style={{ position: 'absolute', zIndex: 10 }}>
|
||||
<div className="loading-spinner" />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{isLoadingMore && (
|
||||
<div className="chart-history-loading">
|
||||
@@ -663,8 +763,10 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
||||
drawingTool="cursor"
|
||||
drawings={drawings}
|
||||
logScale={false}
|
||||
drawingsLocked={false}
|
||||
drawingsVisible={true}
|
||||
drawingsLocked={compactMode}
|
||||
drawingsVisible={!compactMode}
|
||||
showHoverToolbar={!compactMode}
|
||||
volumeVisible={chartVolumeVisible}
|
||||
onCrosshair={data => {
|
||||
setLegend(data);
|
||||
if (data && onCrosshairTime) onCrosshairTime(data.time ?? 0);
|
||||
|
||||
@@ -11,7 +11,7 @@ import TradeAlertPopupMenubarSelect from './TradeAlertPopupMenubarSelect';
|
||||
import type { TradeAlertPopupLayout, TradeAlertPopupPosition } from '../utils/tradeAlertPopupLayout';
|
||||
import { canAccessMenu } from '../utils/permissions';
|
||||
|
||||
export type MenuPage = 'dashboard' | 'chart' | 'paper' | 'virtual' | 'strategy' | 'strategy-editor' | 'backtest' | 'notifications' | 'settings';
|
||||
export type MenuPage = 'dashboard' | 'chart' | 'paper' | 'virtual' | 'trend-search' | 'strategy' | 'strategy-editor' | 'backtest' | 'notifications' | 'settings';
|
||||
|
||||
interface TopMenuBarProps {
|
||||
activePage: MenuPage;
|
||||
@@ -137,11 +137,20 @@ const IcVirtual = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IcTrendSearch = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="7" cy="7" r="4.5"/>
|
||||
<path d="M10.5 10.5L14 14"/>
|
||||
<path d="M5 7h4M7 5v4"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const MENU_ITEMS: { page: MenuPage; label: string; icon: React.ReactNode }[] = [
|
||||
{ page: 'dashboard', label: '대시보드', icon: <IcDashboard /> },
|
||||
{ page: 'chart', label: '실시간차트', icon: <IcChart /> },
|
||||
{ page: 'paper', label: '모의투자', icon: <IcPaper /> },
|
||||
{ page: 'virtual', label: '가상투자', icon: <IcVirtual /> },
|
||||
{ page: 'trend-search', label: '추세검색', icon: <IcTrendSearch /> },
|
||||
{ page: 'strategy-editor', label: '전략편집기', icon: <IcStrategyEditor /> },
|
||||
{ page: 'backtest', label: '백테스팅', icon: <IcBacktest /> },
|
||||
{ page: 'settings', label: '설정', icon: <IcSettings /> },
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* GoldenChart 암호화폐 추세검색 (Trend Search)
|
||||
*/
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import BuilderPageShell from './layout/BuilderPageShell';
|
||||
import TrendSearchFilterPanel from './trendSearch/TrendSearchFilterPanel';
|
||||
import TrendSearchResultsCardGrid from './trendSearch/TrendSearchResultsCardGrid';
|
||||
import TrendSearchViewHeaderControls from './trendSearch/TrendSearchViewHeaderControls';
|
||||
import type { TrendSearchDisplayMode } from './trendSearch/TrendSearchResultCard';
|
||||
import type { Theme } from '../types';
|
||||
import type { ChartRealtimeSource } from '../hooks/useChartRealtimeData';
|
||||
import type { TickerData } from '../hooks/useMarketTicker';
|
||||
import {
|
||||
DEFAULT_TREND_SEARCH_REQUEST,
|
||||
scanTrendSearch,
|
||||
fetchTrendSearchDetail,
|
||||
type TrendSearchRequest,
|
||||
type TrendSearchResultDto,
|
||||
} from '../utils/trendSearchApi';
|
||||
import '../styles/trendSearchDashboard.css';
|
||||
import '../styles/virtualTradingDashboard.css';
|
||||
|
||||
const REFRESH_MS = 3000;
|
||||
const DISPLAY_MODE_KEY = 'tsd-display-mode';
|
||||
|
||||
function loadDisplayMode(): TrendSearchDisplayMode {
|
||||
try {
|
||||
const v = localStorage.getItem(DISPLAY_MODE_KEY);
|
||||
if (v === 'chart' || v === 'summary') return v;
|
||||
} catch { /* ignore */ }
|
||||
return 'summary';
|
||||
}
|
||||
|
||||
interface Props {
|
||||
theme?: Theme;
|
||||
chartRealtimeSource?: ChartRealtimeSource;
|
||||
chartSeriesPriceLabels?: boolean;
|
||||
tickers?: Map<string, TickerData>;
|
||||
}
|
||||
|
||||
const TrendSearchPage: React.FC<Props> = ({
|
||||
theme = 'dark',
|
||||
chartRealtimeSource = 'BACKEND_STOMP',
|
||||
chartSeriesPriceLabels = true,
|
||||
tickers,
|
||||
}) => {
|
||||
const [filters, setFilters] = useState<TrendSearchRequest>(() => ({ ...DEFAULT_TREND_SEARCH_REQUEST }));
|
||||
const [results, setResults] = useState<TrendSearchResultDto[]>([]);
|
||||
const [selectedMarket, setSelectedMarket] = useState<string | null>(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [displayMode, setDisplayMode] = useState<TrendSearchDisplayMode>(() => loadDisplayMode());
|
||||
const [flashMarkets, setFlashMarkets] = useState<Set<string>>(new Set());
|
||||
const [lastUpdatedAt, setLastUpdatedAt] = useState<number>(Date.now());
|
||||
const filtersRef = useRef(filters);
|
||||
filtersRef.current = filters;
|
||||
|
||||
const runSearch = useCallback(async (opts?: { silent?: boolean }) => {
|
||||
if (!opts?.silent) setSearching(true);
|
||||
try {
|
||||
const list = await scanTrendSearch(filtersRef.current);
|
||||
setResults(list);
|
||||
setLastUpdatedAt(Date.now());
|
||||
setSelectedMarket(prev => {
|
||||
if (prev && list.some(r => r.market === prev)) return prev;
|
||||
return list[0]?.market ?? null;
|
||||
});
|
||||
if (list.length > 0) {
|
||||
setFlashMarkets(new Set(list.slice(0, 5).map(r => r.market)));
|
||||
setTimeout(() => setFlashMarkets(new Set()), 600);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[TrendSearch]', e);
|
||||
if (!opts?.silent) window.alert('추세검색 스캔에 실패했습니다. 백엔드 연결을 확인하세요.');
|
||||
} finally {
|
||||
if (!opts?.silent) setSearching(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void runSearch(); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
const id = window.setInterval(() => void runSearch({ silent: true }), REFRESH_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [autoRefresh, runSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem(DISPLAY_MODE_KEY, displayMode); } catch { /* ignore */ }
|
||||
}, [displayMode]);
|
||||
|
||||
const handleSelect = useCallback(async (row: TrendSearchResultDto) => {
|
||||
setSelectedMarket(row.market);
|
||||
try {
|
||||
const detail = await fetchTrendSearchDetail(row.market, filters.timeframe);
|
||||
if (detail) {
|
||||
setResults(prev => prev.map(r => (r.market === detail.market ? detail : r)));
|
||||
}
|
||||
} catch { /* keep row */ }
|
||||
}, [filters.timeframe]);
|
||||
|
||||
return (
|
||||
<BuilderPageShell
|
||||
theme={theme}
|
||||
title="추세검색"
|
||||
subtitle="Golden Analysis · Trend Search"
|
||||
pageClassName="bps-page--tsd bps-page--vtd"
|
||||
headerActions={(
|
||||
<TrendSearchViewHeaderControls
|
||||
displayMode={displayMode}
|
||||
onDisplayModeChange={setDisplayMode}
|
||||
autoRefresh={autoRefresh}
|
||||
onAutoRefreshChange={setAutoRefresh}
|
||||
searching={searching}
|
||||
onRefresh={() => void runSearch()}
|
||||
resultCount={results.length}
|
||||
/>
|
||||
)}
|
||||
leftStorageKey="tsd-left-width"
|
||||
leftDefaultWidth={320}
|
||||
leftCollapsedStorageKey="tsd-left-open"
|
||||
collapsiblePanels
|
||||
left={(
|
||||
<TrendSearchFilterPanel
|
||||
filters={filters}
|
||||
onChange={setFilters}
|
||||
onSearch={() => void runSearch()}
|
||||
searching={searching}
|
||||
/>
|
||||
)}
|
||||
center={(
|
||||
<TrendSearchResultsCardGrid
|
||||
results={results}
|
||||
timeframe={filters.timeframe}
|
||||
displayMode={displayMode}
|
||||
loading={searching}
|
||||
selectedMarket={selectedMarket}
|
||||
onSelect={row => void handleSelect(row)}
|
||||
flashMarkets={flashMarkets}
|
||||
theme={theme}
|
||||
chartRealtimeSource={chartRealtimeSource}
|
||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||
tickers={tickers}
|
||||
lastUpdatedAt={lastUpdatedAt}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrendSearchPage;
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* 추세검색 카드 — 인라인 캔들 + RSI/MACD/BB
|
||||
*/
|
||||
import React, {
|
||||
useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState,
|
||||
} from 'react';
|
||||
import TradingChart from '../TradingChart';
|
||||
import { pinCandleWatch } from '../../utils/backendApi';
|
||||
import type {
|
||||
ChartMode, ChartType, Drawing, LegendData, OHLCVBar, Theme, Timeframe,
|
||||
} from '../../types';
|
||||
import { isUpbitMarket } from '../../utils/upbitApi';
|
||||
import { useChartRealtimeData, type ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
|
||||
import { useHistoryLoader } from '../../hooks/useHistoryLoader';
|
||||
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
|
||||
import type { ChartManager } from '../../utils/ChartManager';
|
||||
import { timeframeToCandleType } from '../../utils/chartCandleType';
|
||||
import { enrichIndicatorConfig } from '../../utils/indicatorRegistry';
|
||||
import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone';
|
||||
|
||||
interface Props {
|
||||
market: string;
|
||||
timeframe: string;
|
||||
theme?: Theme;
|
||||
chartRealtimeSource?: ChartRealtimeSource;
|
||||
chartSeriesPriceLabels?: boolean;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
const CHART_INDICATOR_TYPES = ['BollingerBands', 'RSI', 'MACD'] as const;
|
||||
|
||||
function toChartTimeframe(tf: string): Timeframe {
|
||||
if (tf === '1d') return '1D';
|
||||
if (tf === '1w') return '1W';
|
||||
if (tf === '1M') return '1M';
|
||||
return tf as Timeframe;
|
||||
}
|
||||
|
||||
const TrendSearchCardChart: React.FC<Props> = ({
|
||||
market,
|
||||
timeframe,
|
||||
theme = 'dark',
|
||||
chartRealtimeSource = 'BACKEND_STOMP',
|
||||
chartSeriesPriceLabels = true,
|
||||
}) => {
|
||||
const chartTf = toChartTimeframe(timeframe);
|
||||
const { getParams, getVisualConfig } = useIndicatorSettings();
|
||||
|
||||
const indicators = useMemo(
|
||||
() => CHART_INDICATOR_TYPES.map(type => {
|
||||
const base = enrichIndicatorConfig({
|
||||
id: `${type}-tsd-card`,
|
||||
type,
|
||||
params: { ...getParams(type) },
|
||||
});
|
||||
return {
|
||||
...base,
|
||||
plots: getVisualConfig(type)?.plots ?? base.plots,
|
||||
};
|
||||
}),
|
||||
[getParams, getVisualConfig],
|
||||
);
|
||||
|
||||
const managerRef = useRef<ChartManager | null>(null);
|
||||
const canvasWrapRef = useRef<HTMLDivElement | null>(null);
|
||||
const [chartReloadTick, setChartReloadTick] = useState(0);
|
||||
const chartReloadTriggeredRef = useRef(false);
|
||||
const pendingBarRef = useRef<OHLCVBar | null>(null);
|
||||
const barsMarketRef = useRef<string | null>(null);
|
||||
const chartLiveReadyRef = useRef(false);
|
||||
const marketRef = useRef(market);
|
||||
marketRef.current = market;
|
||||
|
||||
const handleCandlesReady = useCallback(() => {
|
||||
chartLiveReadyRef.current = true;
|
||||
const pending = pendingBarRef.current;
|
||||
const mgr = managerRef.current;
|
||||
if (!pending || !mgr || barsMarketRef.current !== marketRef.current) return;
|
||||
pendingBarRef.current = null;
|
||||
mgr.updateBar(pending);
|
||||
}, []);
|
||||
|
||||
const applyRealtimeBar = useCallback((bar: OHLCVBar, append: boolean) => {
|
||||
if (barsMarketRef.current !== marketRef.current) return;
|
||||
if (!chartLiveReadyRef.current || !managerRef.current) {
|
||||
pendingBarRef.current = bar;
|
||||
return;
|
||||
}
|
||||
if (append) managerRef.current.appendBar(bar);
|
||||
else managerRef.current.updateBar(bar);
|
||||
}, []);
|
||||
|
||||
const handleTickUpdate = useCallback((bar: OHLCVBar) => applyRealtimeBar(bar, false), [applyRealtimeBar]);
|
||||
const handleNewCandle = useCallback((bar: OHLCVBar) => applyRealtimeBar(bar, true), [applyRealtimeBar]);
|
||||
|
||||
const useUpbit = isUpbitMarket(market);
|
||||
|
||||
const { bars, barsMarket, isLoading } = useChartRealtimeData(
|
||||
market,
|
||||
chartTf,
|
||||
useMemo(() => ({
|
||||
onTickUpdate: handleTickUpdate,
|
||||
onNewCandle: handleNewCandle,
|
||||
enabled: useUpbit,
|
||||
source: chartRealtimeSource,
|
||||
}), [handleTickUpdate, handleNewCandle, useUpbit, chartRealtimeSource]),
|
||||
);
|
||||
|
||||
const { isLoadingMore } = useHistoryLoader({
|
||||
symbol: market,
|
||||
timeframe: chartTf,
|
||||
isUpbit: useUpbit,
|
||||
managerRef,
|
||||
});
|
||||
|
||||
barsMarketRef.current = barsMarket;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
chartLiveReadyRef.current = false;
|
||||
pendingBarRef.current = null;
|
||||
chartReloadTriggeredRef.current = false;
|
||||
}, [market, chartTf]);
|
||||
|
||||
useEffect(() => {
|
||||
const timers = [100, 300, 800, 1500].map(delay =>
|
||||
setTimeout(() => {
|
||||
const wrap = canvasWrapRef.current;
|
||||
const mgr = managerRef.current;
|
||||
if (!wrap) return;
|
||||
const h = wrap.clientHeight;
|
||||
if (h <= 0) return;
|
||||
if (mgr?.hasMainSeries()) mgr.resetPaneHeights(h);
|
||||
else if (!chartReloadTriggeredRef.current && delay >= 800 && bars.length > 0) {
|
||||
chartReloadTriggeredRef.current = true;
|
||||
setChartReloadTick(t => t + 1);
|
||||
}
|
||||
}, delay),
|
||||
);
|
||||
return () => timers.forEach(clearTimeout);
|
||||
}, [market, chartTf, bars.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!useUpbit) return;
|
||||
void pinCandleWatch(market, timeframeToCandleType(chartTf));
|
||||
}, [market, chartTf, useUpbit]);
|
||||
|
||||
return (
|
||||
<div className="vtd-card-chart-wrap">
|
||||
<div ref={canvasWrapRef} className="vtd-card-chart slot-chart-wrap">
|
||||
{isLoading && <div className="vtd-card-chart-loading">차트 로딩…</div>}
|
||||
{isLoadingMore && (
|
||||
<div className="chart-history-loading">
|
||||
<div className="loading-spinner" style={{ width: 14, height: 14 }} />
|
||||
<span>과거 데이터…</span>
|
||||
</div>
|
||||
)}
|
||||
<TradingChart
|
||||
key={`${market}-${chartTf}-${chartReloadTick}`}
|
||||
bars={bars}
|
||||
barsMarket={barsMarket}
|
||||
market={market}
|
||||
timeframe={chartTf}
|
||||
chartType={'candlestick' as ChartType}
|
||||
theme={theme}
|
||||
mode={'chart' as ChartMode}
|
||||
indicators={indicators}
|
||||
drawingTool="cursor"
|
||||
drawings={[] as Drawing[]}
|
||||
logScale={false}
|
||||
drawingsLocked
|
||||
drawingsVisible={false}
|
||||
displayTimezone={DEFAULT_DISPLAY_TIMEZONE}
|
||||
onCrosshair={noop as (d: LegendData | null) => void}
|
||||
onManagerReady={mgr => { managerRef.current = mgr; }}
|
||||
onAddDrawing={noop as (d: Drawing) => void}
|
||||
onCandlesReady={handleCandlesReady}
|
||||
magnifierEnabled={false}
|
||||
volumeVisible
|
||||
showHoverToolbar={false}
|
||||
seriesPriceLabelsEnabled={chartSeriesPriceLabels}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrendSearchCardChart;
|
||||
@@ -0,0 +1,70 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import type { TrendSearchConditionDto } from '../../utils/trendSearchApi';
|
||||
import { computeMatchRate, getTrafficLightState } from '../../utils/virtualSignalMetrics';
|
||||
import { buildTrendSearchMetrics } from '../../utils/trendSearchMetrics';
|
||||
import VirtualSignalEqualizer from '../virtual/VirtualSignalEqualizer';
|
||||
import VirtualConditionList from '../virtual/VirtualConditionList';
|
||||
import VirtualSignalTrafficLight from '../virtual/VirtualSignalTrafficLight';
|
||||
import VirtualIndicatorCompareTable from '../virtual/VirtualIndicatorCompareTable';
|
||||
|
||||
interface Props {
|
||||
conditions: TrendSearchConditionDto[];
|
||||
matchRate: number;
|
||||
matchedCount: number;
|
||||
totalConditions: number;
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
const TrendSearchCardSignalPanel: React.FC<Props> = ({
|
||||
conditions,
|
||||
matchRate,
|
||||
matchedCount,
|
||||
totalConditions,
|
||||
updatedAt,
|
||||
}) => {
|
||||
const metrics = useMemo(() => buildTrendSearchMetrics(conditions), [conditions]);
|
||||
const resolvedMatch = useMemo(
|
||||
() => computeMatchRate(metrics, matchRate),
|
||||
[metrics, matchRate],
|
||||
);
|
||||
const trafficState = useMemo(
|
||||
() => getTrafficLightState(resolvedMatch, metrics),
|
||||
[resolvedMatch, metrics],
|
||||
);
|
||||
|
||||
if (conditions.length === 0) {
|
||||
return <p className="vtd-muted vtd-card-empty">조건 데이터 없음</p>;
|
||||
}
|
||||
|
||||
const updatedLabel = updatedAt
|
||||
? new Date(updatedAt).toLocaleTimeString('ko-KR', {
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
|
||||
})
|
||||
: '—';
|
||||
|
||||
return (
|
||||
<div className="vtd-sig-panel-wrap vtd-sig-panel-wrap--detail">
|
||||
<div className="vtd-sig-panel vtd-sig-panel--detail-top">
|
||||
<div className="vtd-sig-panel-title">SIGNAL INTELLIGENCE & MATCH RATES</div>
|
||||
<div className="vtd-sig-visual">
|
||||
<div className="vtd-sig-visual-eq">
|
||||
<VirtualSignalEqualizer matchRate={resolvedMatch} />
|
||||
</div>
|
||||
<VirtualConditionList metrics={metrics} />
|
||||
<VirtualSignalTrafficLight state={trafficState} matchRate={resolvedMatch} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="vtd-sig-detail-table" aria-label="지표별 상태 목록">
|
||||
<VirtualIndicatorCompareTable metrics={metrics} />
|
||||
</div>
|
||||
<div className="vtd-card-foot">
|
||||
<span className="vtd-card-updated">갱신 {updatedLabel}</span>
|
||||
<span className="vtd-card-match-summary">
|
||||
{matchedCount}/{totalConditions} 조건 · 일치율 {resolvedMatch}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrendSearchCardSignalPanel;
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* 추세검색 — 우측 심층 차트
|
||||
*/
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import TradingChart from '../TradingChart';
|
||||
import type { ChartMode, ChartType, Drawing, LegendData, OHLCVBar, Theme, Timeframe, IndicatorConfig } from '../../types';
|
||||
import { pinCandleWatch } from '../../utils/backendApi';
|
||||
import { isUpbitMarket } from '../../utils/upbitApi';
|
||||
import { useChartRealtimeData, type ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
|
||||
import { useHistoryLoader } from '../../hooks/useHistoryLoader';
|
||||
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
|
||||
import type { ChartManager } from '../../utils/ChartManager';
|
||||
import { timeframeToCandleType } from '../../utils/chartCandleType';
|
||||
import { enrichIndicatorConfig } from '../../utils/indicatorRegistry';
|
||||
import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone';
|
||||
|
||||
interface Props {
|
||||
market: string;
|
||||
timeframe: string;
|
||||
theme?: Theme;
|
||||
chartRealtimeSource?: ChartRealtimeSource;
|
||||
chartSeriesPriceLabels?: boolean;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
function toChartTimeframe(tf: string): Timeframe {
|
||||
if (tf === '1d') return '1D';
|
||||
if (tf === '1w') return '1W';
|
||||
if (tf === '1M') return '1M';
|
||||
return tf as Timeframe;
|
||||
}
|
||||
|
||||
const CHART_INDICATOR_TYPES = ['BollingerBands', 'RSI', 'MACD'] as const;
|
||||
|
||||
const TrendSearchChartPanel: React.FC<Props> = ({
|
||||
market,
|
||||
timeframe,
|
||||
theme = 'dark',
|
||||
chartRealtimeSource = 'BACKEND_STOMP',
|
||||
chartSeriesPriceLabels = true,
|
||||
}) => {
|
||||
const chartTf = toChartTimeframe(timeframe);
|
||||
const { getParams, getVisualConfig } = useIndicatorSettings();
|
||||
|
||||
const indicators = useMemo((): IndicatorConfig[] =>
|
||||
CHART_INDICATOR_TYPES.map(type => {
|
||||
const base = enrichIndicatorConfig({
|
||||
id: `${type}-tsd`,
|
||||
type,
|
||||
params: { ...getParams(type) },
|
||||
});
|
||||
return {
|
||||
...base,
|
||||
plots: getVisualConfig(type)?.plots ?? base.plots,
|
||||
};
|
||||
}),
|
||||
[getParams, getVisualConfig]);
|
||||
|
||||
const managerRef = useRef<ChartManager | null>(null);
|
||||
const canvasWrapRef = useRef<HTMLDivElement | null>(null);
|
||||
const [chartReloadTick, setChartReloadTick] = useState(0);
|
||||
const chartReloadTriggeredRef = useRef(false);
|
||||
const pendingBarRef = useRef<OHLCVBar | null>(null);
|
||||
const barsMarketRef = useRef<string | null>(null);
|
||||
const chartLiveReadyRef = useRef(false);
|
||||
const marketRef = useRef(market);
|
||||
marketRef.current = market;
|
||||
|
||||
const handleCandlesReady = useCallback(() => {
|
||||
chartLiveReadyRef.current = true;
|
||||
const pending = pendingBarRef.current;
|
||||
const mgr = managerRef.current;
|
||||
if (!pending || !mgr || barsMarketRef.current !== marketRef.current) return;
|
||||
pendingBarRef.current = null;
|
||||
mgr.updateBar(pending);
|
||||
}, []);
|
||||
|
||||
const applyRealtimeBar = useCallback((bar: OHLCVBar, append: boolean) => {
|
||||
if (barsMarketRef.current !== marketRef.current) return;
|
||||
if (!chartLiveReadyRef.current || !managerRef.current) {
|
||||
pendingBarRef.current = bar;
|
||||
return;
|
||||
}
|
||||
if (append) managerRef.current.appendBar(bar);
|
||||
else managerRef.current.updateBar(bar);
|
||||
}, []);
|
||||
|
||||
const handleTickUpdate = useCallback((bar: OHLCVBar) => applyRealtimeBar(bar, false), [applyRealtimeBar]);
|
||||
const handleNewCandle = useCallback((bar: OHLCVBar) => applyRealtimeBar(bar, true), [applyRealtimeBar]);
|
||||
|
||||
const useUpbit = isUpbitMarket(market);
|
||||
|
||||
const { bars, barsMarket, isLoading } = useChartRealtimeData(
|
||||
market,
|
||||
chartTf,
|
||||
useMemo(() => ({
|
||||
onTickUpdate: handleTickUpdate,
|
||||
onNewCandle: handleNewCandle,
|
||||
enabled: useUpbit,
|
||||
source: chartRealtimeSource,
|
||||
}), [handleTickUpdate, handleNewCandle, useUpbit, chartRealtimeSource]),
|
||||
);
|
||||
|
||||
const { isLoadingMore } = useHistoryLoader({
|
||||
symbol: market,
|
||||
timeframe: chartTf,
|
||||
isUpbit: useUpbit,
|
||||
managerRef,
|
||||
});
|
||||
|
||||
barsMarketRef.current = barsMarket;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
chartLiveReadyRef.current = false;
|
||||
pendingBarRef.current = null;
|
||||
chartReloadTriggeredRef.current = false;
|
||||
}, [market, chartTf]);
|
||||
|
||||
useEffect(() => {
|
||||
const timers = [100, 300, 800, 1500].map(delay =>
|
||||
setTimeout(() => {
|
||||
const wrap = canvasWrapRef.current;
|
||||
const mgr = managerRef.current;
|
||||
if (!wrap) return;
|
||||
const h = wrap.clientHeight;
|
||||
if (h <= 0) return;
|
||||
if (mgr?.hasMainSeries()) mgr.resetPaneHeights(h);
|
||||
else if (!chartReloadTriggeredRef.current && delay >= 800 && bars.length > 0) {
|
||||
chartReloadTriggeredRef.current = true;
|
||||
setChartReloadTick(t => t + 1);
|
||||
}
|
||||
}, delay),
|
||||
);
|
||||
return () => timers.forEach(clearTimeout);
|
||||
}, [market, chartTf, bars.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!useUpbit) return;
|
||||
void pinCandleWatch(market, timeframeToCandleType(chartTf));
|
||||
if (timeframeToCandleType(chartTf) !== '1m') void pinCandleWatch(market, '1m');
|
||||
}, [market, chartTf, useUpbit]);
|
||||
|
||||
return (
|
||||
<div className="tsd-chart-panel">
|
||||
<div className="tsd-chart-toolbar">
|
||||
<span className="tsd-chart-toolbar-title">심층 차트 분석 영역</span>
|
||||
<div className="tsd-chart-tools" aria-label="차트 도구">
|
||||
{['↖', '/', '⌇', '□', 'T', '📷'].map((icon, i) => (
|
||||
<button key={i} type="button" className="tsd-chart-tool-btn" title="차트 도구">{icon}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div ref={canvasWrapRef} className="tsd-chart-body slot-chart-wrap">
|
||||
{isLoading && <div className="tsd-chart-loading">차트 로딩…</div>}
|
||||
{isLoadingMore && (
|
||||
<div className="chart-history-loading">
|
||||
<div className="loading-spinner" style={{ width: 14, height: 14 }} />
|
||||
<span>과거 데이터 로딩…</span>
|
||||
</div>
|
||||
)}
|
||||
<TradingChart
|
||||
key={`${market}-${chartTf}-${chartReloadTick}`}
|
||||
bars={bars}
|
||||
barsMarket={barsMarket}
|
||||
market={market}
|
||||
timeframe={chartTf}
|
||||
chartType={'candlestick' as ChartType}
|
||||
theme={theme}
|
||||
mode={'chart' as ChartMode}
|
||||
indicators={indicators}
|
||||
drawingTool="cursor"
|
||||
drawings={[] as Drawing[]}
|
||||
logScale={false}
|
||||
drawingsLocked
|
||||
drawingsVisible={false}
|
||||
displayTimezone={DEFAULT_DISPLAY_TIMEZONE}
|
||||
onCrosshair={noop as (d: LegendData | null) => void}
|
||||
onManagerReady={mgr => { managerRef.current = mgr; }}
|
||||
onAddDrawing={noop as (d: Drawing) => void}
|
||||
onCandlesReady={handleCandlesReady}
|
||||
magnifierEnabled={false}
|
||||
volumeVisible
|
||||
showHoverToolbar={false}
|
||||
seriesPriceLabelsEnabled={chartSeriesPriceLabels}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrendSearchChartPanel;
|
||||
@@ -0,0 +1,183 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import type { TrendSearchRequest } from '../../utils/trendSearchApi';
|
||||
import { TREND_TIMEFRAMES } from '../../utils/trendSearchApi';
|
||||
import {
|
||||
TREND_CATEGORY_ORDER,
|
||||
TREND_CONDITION_DEFS,
|
||||
TREND_SORT_CONDITION_IDS,
|
||||
type TrendConditionMode,
|
||||
} from '../../utils/trendSearchConditions';
|
||||
|
||||
interface Props {
|
||||
filters: TrendSearchRequest;
|
||||
onChange: (next: TrendSearchRequest) => void;
|
||||
onSearch: () => void;
|
||||
searching?: boolean;
|
||||
}
|
||||
|
||||
function tfLabel(tf: string): string {
|
||||
if (tf === '1M') return '1M';
|
||||
if (tf.endsWith('m')) return tf.replace('m', '분');
|
||||
if (tf.endsWith('h')) return tf.replace('h', '시간');
|
||||
if (tf.endsWith('d')) return tf.replace('d', '일');
|
||||
if (tf.endsWith('w')) return tf.replace('w', '주');
|
||||
return tf;
|
||||
}
|
||||
|
||||
function modeBadge(mode: TrendConditionMode): React.ReactNode {
|
||||
if (mode === 'filter') return <span className="tsd-cond-badge tsd-cond-badge--filter">필터</span>;
|
||||
if (mode === 'unsupported') return <span className="tsd-cond-badge tsd-cond-badge--na">미지원</span>;
|
||||
return <span className="tsd-cond-badge tsd-cond-badge--sort">정렬</span>;
|
||||
}
|
||||
|
||||
const TrendSearchFilterPanel: React.FC<Props> = ({ filters, onChange, onSearch, searching }) => {
|
||||
const patch = (p: Partial<TrendSearchRequest>) => onChange({ ...filters, ...p });
|
||||
|
||||
const sortOptions = useMemo(() => {
|
||||
return TREND_CONDITION_DEFS.filter(c => {
|
||||
if (c.mode !== 'sort') return false;
|
||||
if (!filters[c.categoryKey]) return false;
|
||||
return Boolean(filters[c.requestKey]);
|
||||
});
|
||||
}, [filters]);
|
||||
|
||||
const handleToggleCondition = (key: keyof TrendSearchRequest, checked: boolean, id: string, mode: TrendConditionMode) => {
|
||||
const next = { ...filters, [key]: checked };
|
||||
if (checked && mode === 'sort' && !TREND_SORT_CONDITION_IDS.includes(filters.sortBy)) {
|
||||
next.sortBy = id;
|
||||
}
|
||||
if (!checked && filters.sortBy === id) {
|
||||
const fallback = TREND_CONDITION_DEFS.find(
|
||||
c => c.mode === 'sort' && c.id !== id && Boolean(next[c.requestKey]),
|
||||
);
|
||||
if (fallback) next.sortBy = fallback.id;
|
||||
}
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tsd-filter-panel">
|
||||
<div className="tsd-panel-head">
|
||||
<span className="tsd-panel-icon">⚙</span>
|
||||
<div>
|
||||
<h3 className="tsd-panel-title">추세검색 조건</h3>
|
||||
<p className="tsd-panel-sub">20개 조건 · 필터 + 정렬</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{TREND_CATEGORY_ORDER.map(cat => {
|
||||
const items = TREND_CONDITION_DEFS.filter(c => c.category === cat.cat);
|
||||
const catOn = filters[cat.key];
|
||||
return (
|
||||
<div key={cat.key} className={`tsd-filter-cat${catOn ? ' tsd-filter-cat--on' : ''}`}>
|
||||
<div className="tsd-filter-cat-head">
|
||||
<span className="tsd-filter-cat-ko">{cat.label}</span>
|
||||
<label className="tsd-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={catOn}
|
||||
onChange={e => patch({ [cat.key]: e.target.checked })}
|
||||
/>
|
||||
<span className="tsd-toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
{catOn && (
|
||||
<div className="tsd-filter-items">
|
||||
{items.map(item => {
|
||||
const checked = Boolean(filters[item.requestKey]);
|
||||
const disabled = item.mode === 'unsupported';
|
||||
return (
|
||||
<div key={item.id} className={`tsd-filter-item${disabled ? ' tsd-filter-item--disabled' : ''}`}>
|
||||
<div className="tsd-filter-item-row">
|
||||
<label className="tsd-filter-item-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={e => handleToggleCondition(item.requestKey, e.target.checked, item.id, item.mode)}
|
||||
/>
|
||||
<span className="tsd-filter-item-label">{item.label}</span>
|
||||
{modeBadge(item.mode)}
|
||||
</label>
|
||||
{!disabled && (
|
||||
<span className="tsd-filter-item-desc-inline" title={item.desc}>
|
||||
{item.desc}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.id === 'newHighCount' && checked && (
|
||||
<div className="tsd-filter-params">
|
||||
<span>N=</span>
|
||||
<input
|
||||
type="number"
|
||||
className="tsd-num"
|
||||
value={filters.newHighDays}
|
||||
min={5}
|
||||
max={60}
|
||||
onChange={e => patch({ newHighDays: Number(e.target.value) })}
|
||||
/>
|
||||
<span>일</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="tsd-filter-section">
|
||||
<h4 className="tsd-filter-section-title">주 정렬 기준</h4>
|
||||
<select
|
||||
className="tsd-sort-select"
|
||||
value={sortOptions.some(o => o.id === filters.sortBy) ? filters.sortBy : sortOptions[0]?.id ?? 'near52wHigh'}
|
||||
onChange={e => patch({ sortBy: e.target.value })}
|
||||
>
|
||||
{sortOptions.length === 0 && <option value="near52wHigh">52주 신고가 근접률</option>}
|
||||
{sortOptions.map(o => (
|
||||
<option key={o.id} value={o.id}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="tsd-filter-section">
|
||||
<h4 className="tsd-filter-section-title">캔들 주기</h4>
|
||||
<div className="tsd-tf-grid">
|
||||
{TREND_TIMEFRAMES.map(tf => (
|
||||
<button
|
||||
key={tf}
|
||||
type="button"
|
||||
className={`tsd-tf-btn${filters.timeframe === tf ? ' tsd-tf-btn--on' : ''}`}
|
||||
onClick={() => patch({ timeframe: tf })}
|
||||
>
|
||||
{tfLabel(tf)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tsd-filter-section">
|
||||
<h4 className="tsd-filter-section-title">결과 개수</h4>
|
||||
<div className="tsd-limit-row">
|
||||
<input
|
||||
type="range"
|
||||
className="tsd-slider"
|
||||
min={5}
|
||||
max={50}
|
||||
value={filters.limit}
|
||||
onChange={e => patch({ limit: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="tsd-limit-val">{filters.limit}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" className="tsd-search-btn" disabled={searching} onClick={onSearch}>
|
||||
{searching ? '스캔 중…' : '조건 검색 실행'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrendSearchFilterPanel;
|
||||
@@ -0,0 +1,132 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { getKoreanName } from '../../utils/marketNameCache';
|
||||
import type { TrendSearchResultDto } from '../../utils/trendSearchApi';
|
||||
import { tfLabelShort } from '../../utils/trendSearchMetrics';
|
||||
import type { Theme } from '../../types';
|
||||
import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
|
||||
import type { TickerData } from '../../hooks/useMarketTicker';
|
||||
import VirtualTargetQuote from '../virtual/VirtualTargetQuote';
|
||||
import VirtualLiveBadge from '../virtual/VirtualLiveBadge';
|
||||
import TrendSearchCardSignalPanel from './TrendSearchCardSignalPanel';
|
||||
import TrendSearchCardChart from './TrendSearchCardChart';
|
||||
|
||||
export type TrendSearchDisplayMode = 'summary' | 'chart';
|
||||
|
||||
interface Props {
|
||||
result: TrendSearchResultDto;
|
||||
timeframe: string;
|
||||
displayMode: TrendSearchDisplayMode;
|
||||
selected?: boolean;
|
||||
onSelect?: () => void;
|
||||
theme?: Theme;
|
||||
chartRealtimeSource?: ChartRealtimeSource;
|
||||
chartSeriesPriceLabels?: boolean;
|
||||
ticker?: TickerData;
|
||||
updatedAt?: number;
|
||||
flash?: boolean;
|
||||
}
|
||||
|
||||
function resultToTicker(result: TrendSearchResultDto): TickerData {
|
||||
const rate = result.changeRate / 100;
|
||||
return {
|
||||
market: result.market,
|
||||
koreanName: result.koreanName,
|
||||
tradePrice: result.currentPrice,
|
||||
changeRate: rate,
|
||||
changePrice: null,
|
||||
accTradePrice24: 0,
|
||||
accTradeVolume24: null,
|
||||
openingPrice: null,
|
||||
highPrice: null,
|
||||
lowPrice: null,
|
||||
change: result.changeRate > 0 ? 'RISE' : result.changeRate < 0 ? 'FALL' : 'EVEN',
|
||||
};
|
||||
}
|
||||
|
||||
const TrendSearchResultCard: React.FC<Props> = ({
|
||||
result,
|
||||
timeframe,
|
||||
displayMode,
|
||||
selected = false,
|
||||
onSelect,
|
||||
theme = 'dark',
|
||||
chartRealtimeSource = 'BACKEND_STOMP',
|
||||
chartSeriesPriceLabels = true,
|
||||
ticker,
|
||||
updatedAt,
|
||||
flash = false,
|
||||
}) => {
|
||||
const ko = result.koreanName || getKoreanName(result.market);
|
||||
const sym = result.market.replace(/^KRW-/, '');
|
||||
const isChart = displayMode === 'chart';
|
||||
const quoteTicker = ticker ?? resultToTicker(result);
|
||||
|
||||
const flashCls = useMemo(() => {
|
||||
if (!flash) return '';
|
||||
return result.changeRate >= 0 ? 'tsd-card--flash-up' : 'tsd-card--flash-down';
|
||||
}, [flash, result.changeRate]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'vtd-card',
|
||||
'vtd-card--signal',
|
||||
'vtd-card--detail',
|
||||
isChart ? 'vtd-card--chart-mode' : '',
|
||||
selected ? 'vtd-card--selected' : '',
|
||||
flashCls,
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={onSelect}
|
||||
onKeyDown={e => {
|
||||
if (onSelect && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
onSelect();
|
||||
}
|
||||
}}
|
||||
role={onSelect ? 'button' : undefined}
|
||||
tabIndex={onSelect ? 0 : undefined}
|
||||
aria-pressed={onSelect ? selected : undefined}
|
||||
>
|
||||
<div className="vtd-card-head">
|
||||
<div className="vtd-card-head-main">
|
||||
<div className="vtd-card-title">
|
||||
<span className="vtd-card-ko">{ko}</span>
|
||||
<span className="vtd-card-sym">{sym}</span>
|
||||
</div>
|
||||
<span className="vtd-card-tf">시간봉 {tfLabelShort(timeframe)}</span>
|
||||
{result.highMatch && <span className="tsd-card-high-badge">HIGH MATCH</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vtd-card-meta">
|
||||
<VirtualTargetQuote market={result.market} ticker={quoteTicker} compact />
|
||||
{!isChart && (
|
||||
<span className="vtd-card-met-count">
|
||||
{result.matchedCount}/{result.totalConditions} 조건 충족
|
||||
</span>
|
||||
)}
|
||||
<VirtualLiveBadge status="live" receiving={flash} />
|
||||
</div>
|
||||
|
||||
{isChart ? (
|
||||
<TrendSearchCardChart
|
||||
market={result.market}
|
||||
timeframe={timeframe}
|
||||
theme={theme}
|
||||
chartRealtimeSource={chartRealtimeSource}
|
||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||
/>
|
||||
) : (
|
||||
<TrendSearchCardSignalPanel
|
||||
conditions={result.conditions}
|
||||
matchRate={result.matchRate}
|
||||
matchedCount={result.matchedCount}
|
||||
totalConditions={result.totalConditions}
|
||||
updatedAt={updatedAt}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrendSearchResultCard;
|
||||
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import type { TrendSearchResultDto } from '../../utils/trendSearchApi';
|
||||
import type { Theme } from '../../types';
|
||||
import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
|
||||
import type { TickerData } from '../../hooks/useMarketTicker';
|
||||
import TrendSearchResultCard, { type TrendSearchDisplayMode } from './TrendSearchResultCard';
|
||||
|
||||
interface Props {
|
||||
results: TrendSearchResultDto[];
|
||||
timeframe: string;
|
||||
displayMode: TrendSearchDisplayMode;
|
||||
loading?: boolean;
|
||||
selectedMarket?: string | null;
|
||||
onSelect?: (row: TrendSearchResultDto) => void;
|
||||
flashMarkets?: Set<string>;
|
||||
theme?: Theme;
|
||||
chartRealtimeSource?: ChartRealtimeSource;
|
||||
chartSeriesPriceLabels?: boolean;
|
||||
tickers?: Map<string, TickerData>;
|
||||
lastUpdatedAt?: number;
|
||||
}
|
||||
|
||||
const TrendSearchResultsCardGrid: React.FC<Props> = ({
|
||||
results,
|
||||
timeframe,
|
||||
displayMode,
|
||||
loading,
|
||||
selectedMarket,
|
||||
onSelect,
|
||||
flashMarkets,
|
||||
theme = 'dark',
|
||||
chartRealtimeSource = 'BACKEND_STOMP',
|
||||
chartSeriesPriceLabels = true,
|
||||
tickers,
|
||||
lastUpdatedAt,
|
||||
}) => {
|
||||
if (loading && results.length === 0) {
|
||||
return (
|
||||
<div className="vtd-grid-wrap">
|
||||
<div className="vtd-grid-empty">
|
||||
<p className="vtd-muted">스캔 중…</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!loading && results.length === 0) {
|
||||
return (
|
||||
<div className="vtd-grid-wrap">
|
||||
<div className="vtd-grid-empty">
|
||||
<p className="vtd-muted">조건에 맞는 종목이 없습니다. 좌측 필터를 조정해 보세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="vtd-grid-wrap">
|
||||
<div className={`vtd-grid${displayMode === 'chart' ? ' vtd-grid--chart-mode' : ''}`}>
|
||||
{results.map(row => (
|
||||
<TrendSearchResultCard
|
||||
key={row.market}
|
||||
result={row}
|
||||
timeframe={timeframe}
|
||||
displayMode={displayMode}
|
||||
selected={selectedMarket === row.market}
|
||||
onSelect={onSelect ? () => onSelect(row) : undefined}
|
||||
theme={theme}
|
||||
chartRealtimeSource={chartRealtimeSource}
|
||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||
ticker={tickers?.get(row.market)}
|
||||
updatedAt={lastUpdatedAt}
|
||||
flash={flashMarkets?.has(row.market)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrendSearchResultsCardGrid;
|
||||
@@ -0,0 +1,111 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import type { TrendSearchResultDto } from '../../utils/trendSearchApi';
|
||||
|
||||
interface Props {
|
||||
results: TrendSearchResultDto[];
|
||||
selectedMarket: string | null;
|
||||
onSelect: (row: TrendSearchResultDto) => void;
|
||||
loading?: boolean;
|
||||
flashMarkets?: Set<string>;
|
||||
}
|
||||
|
||||
function fmtPrice(n: number): string {
|
||||
if (n >= 1_000_000) return n.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
|
||||
if (n >= 100) return n.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
|
||||
return n.toLocaleString('ko-KR', { maximumFractionDigits: 4 });
|
||||
}
|
||||
|
||||
function fmtPct(n: number): string {
|
||||
const sign = n >= 0 ? '+' : '';
|
||||
return `${sign}${n.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function coinLabel(market: string, name?: string): string {
|
||||
const code = market.replace(/^KRW-/, '');
|
||||
return name ? `${code}/KRW` : `${code}/KRW`;
|
||||
}
|
||||
|
||||
const TrendSearchResultsGrid: React.FC<Props> = ({
|
||||
results,
|
||||
selectedMarket,
|
||||
onSelect,
|
||||
loading,
|
||||
flashMarkets,
|
||||
}) => {
|
||||
const avgMatch = useMemo(() => {
|
||||
if (!results.length) return 0;
|
||||
return Math.round(results.reduce((s, r) => s + r.matchRate, 0) / results.length);
|
||||
}, [results]);
|
||||
|
||||
return (
|
||||
<div className="tsd-results">
|
||||
<div className="tsd-results-head">
|
||||
<div>
|
||||
<h3 className="tsd-panel-title">실시간 종목 일치율 그리드</h3>
|
||||
<p className="tsd-panel-sub">Search Condition Match Rate % Sorting · 100ms 실시간 처리</p>
|
||||
</div>
|
||||
<div className="tsd-results-meta">
|
||||
<span className="tsd-meta-chip">평균 {avgMatch}%</span>
|
||||
<span className="tsd-meta-chip tsd-meta-chip--live">● LIVE</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tsd-results-table-wrap">
|
||||
<table className="tsd-results-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>종목명</th>
|
||||
<th>현재가</th>
|
||||
<th>전일대비</th>
|
||||
<th>일치율</th>
|
||||
<th>비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && results.length === 0 && (
|
||||
<tr><td colSpan={5} className="tsd-empty">스캔 중…</td></tr>
|
||||
)}
|
||||
{!loading && results.length === 0 && (
|
||||
<tr><td colSpan={5} className="tsd-empty">조건에 맞는 종목이 없습니다. 필터를 조정해 보세요.</td></tr>
|
||||
)}
|
||||
{results.map(row => {
|
||||
const up = row.changeRate >= 0;
|
||||
const active = selectedMarket === row.market;
|
||||
const flash = flashMarkets?.has(row.market);
|
||||
return (
|
||||
<tr
|
||||
key={row.market}
|
||||
className={[
|
||||
active ? 'tsd-row--sel' : '',
|
||||
flash ? (up ? 'tsd-row--flash-up' : 'tsd-row--flash-down') : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => onSelect(row)}
|
||||
>
|
||||
<td className="tsd-col-symbol">
|
||||
<span className="tsd-symbol">{coinLabel(row.market, row.koreanName)}</span>
|
||||
</td>
|
||||
<td className="tsd-col-price">{fmtPrice(row.currentPrice)}</td>
|
||||
<td className={`tsd-col-change${up ? ' up' : ' down'}`}>{fmtPct(row.changeRate)}</td>
|
||||
<td className="tsd-col-match">
|
||||
<div className="tsd-match-bar-wrap">
|
||||
<div className="tsd-match-bar">
|
||||
<div className="tsd-match-bar-fill" style={{ width: `${row.matchRate}%` }} />
|
||||
</div>
|
||||
<span className="tsd-match-pct">{row.matchRate}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="tsd-col-badge">
|
||||
{row.highMatch && <span className="tsd-badge-high">HIGH MATCH</span>}
|
||||
{!row.highMatch && row.matchRate >= 70 && <span className="tsd-badge-mid">MATCH</span>}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrendSearchResultsGrid;
|
||||
@@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
import type { TrendSearchConditionDto } from '../../utils/trendSearchApi';
|
||||
import VirtualSignalTrafficLight from '../virtual/VirtualSignalTrafficLight';
|
||||
import type { TrafficLightState } from '../../utils/virtualSignalMetrics';
|
||||
|
||||
interface Props {
|
||||
conditions: TrendSearchConditionDto[];
|
||||
matchRate: number;
|
||||
matchedCount: number;
|
||||
totalConditions: number;
|
||||
}
|
||||
|
||||
function resolveTraffic(matchRate: number, conditions: TrendSearchConditionDto[]): TrafficLightState {
|
||||
if (matchRate >= 100) return 'blue';
|
||||
const hasPending = conditions.some(c => c.status === 'pending');
|
||||
const hasPartial = conditions.some(c => c.status === 'match');
|
||||
if (hasPending || hasPartial || matchRate >= 40) return 'yellow';
|
||||
return 'red';
|
||||
}
|
||||
|
||||
const TrendSearchSignalPanel: React.FC<Props> = ({
|
||||
conditions,
|
||||
matchRate,
|
||||
matchedCount,
|
||||
totalConditions,
|
||||
}) => (
|
||||
<div className="tsd-signal-panel">
|
||||
<div className="tsd-signal-head">
|
||||
<div className="tsd-signal-intel">
|
||||
<span className="tsd-signal-label">Signal Intelligence</span>
|
||||
<span className="tsd-signal-count">{matchedCount}/{totalConditions} 조건 충족</span>
|
||||
</div>
|
||||
<VirtualSignalTrafficLight state={resolveTraffic(matchRate, conditions)} matchRate={matchRate} />
|
||||
</div>
|
||||
|
||||
<div className="tsd-compare-wrap">
|
||||
<h4 className="tsd-compare-title">REAL-TIME INDICATOR COMPARISON</h4>
|
||||
<table className="tsd-compare-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>TECHNICAL INDICATOR</th>
|
||||
<th>CURRENT</th>
|
||||
<th>THRESHOLD</th>
|
||||
<th>PROGRESS</th>
|
||||
<th>STATUS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{conditions.length === 0 ? (
|
||||
<tr><td colSpan={5} className="tsd-empty">종목을 선택하세요</td></tr>
|
||||
) : conditions.map(c => (
|
||||
<tr key={c.id} className={`tsd-compare-row--${c.status}`}>
|
||||
<td>{c.label}</td>
|
||||
<td>{c.currentValue != null ? c.currentValue.toLocaleString() : '—'}</td>
|
||||
<td>{c.thresholdLabel}</td>
|
||||
<td>
|
||||
<div className="tsd-progress-cell">
|
||||
<div className="tsd-progress-bar">
|
||||
<div
|
||||
className={`tsd-progress-fill tsd-progress-fill--${c.status}`}
|
||||
style={{ width: `${c.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span>{c.progress}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`tsd-status tsd-status--${c.status}`}>
|
||||
{c.status === 'match' ? 'Match' : c.status === 'pending' ? 'Pending' : 'Mismatch'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default TrendSearchSignalPanel;
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import type { TrendSearchDisplayMode } from './TrendSearchResultCard';
|
||||
|
||||
interface Props {
|
||||
displayMode: TrendSearchDisplayMode;
|
||||
onDisplayModeChange: (mode: TrendSearchDisplayMode) => void;
|
||||
autoRefresh: boolean;
|
||||
onAutoRefreshChange: (v: boolean) => void;
|
||||
searching?: boolean;
|
||||
onRefresh: () => void;
|
||||
resultCount?: number;
|
||||
}
|
||||
|
||||
const TrendSearchViewHeaderControls: React.FC<Props> = ({
|
||||
displayMode,
|
||||
onDisplayModeChange,
|
||||
autoRefresh,
|
||||
onAutoRefreshChange,
|
||||
searching,
|
||||
onRefresh,
|
||||
resultCount = 0,
|
||||
}) => (
|
||||
<div className="vtd-header-view tsd-header-view">
|
||||
<div className="vtd-grid-head-group" role="group" aria-label="검색 결과 표시">
|
||||
<div className="vtd-view-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={`vtd-view-toggle-btn${displayMode === 'summary' ? ' vtd-view-toggle-btn--on' : ''}`}
|
||||
onClick={() => onDisplayModeChange('summary')}
|
||||
>
|
||||
요약보기
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`vtd-view-toggle-btn${displayMode === 'chart' ? ' vtd-view-toggle-btn--on' : ''}`}
|
||||
onClick={() => onDisplayModeChange('chart')}
|
||||
>
|
||||
차트보기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span className="vtd-grid-head-divider" aria-hidden="true" />
|
||||
<span className="tsd-result-count">{resultCount}종목</span>
|
||||
<span className="vtd-grid-head-divider" aria-hidden="true" />
|
||||
<label className="tsd-auto-refresh">
|
||||
<input type="checkbox" checked={autoRefresh} onChange={e => onAutoRefreshChange(e.target.checked)} />
|
||||
<span>3초 자동 갱신</span>
|
||||
</label>
|
||||
<button type="button" className="tsd-refresh-btn" disabled={searching} onClick={onRefresh}>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default TrendSearchViewHeaderControls;
|
||||
Reference in New Issue
Block a user