278 lines
11 KiB
TypeScript
278 lines
11 KiB
TypeScript
/**
|
|
* 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,
|
|
loadTrendSearchResults,
|
|
fetchTrendSearchDetail,
|
|
type TrendSearchRequest,
|
|
type TrendSearchResultDto,
|
|
} from '../utils/trendSearchApi';
|
|
import { sortTrendSearchByStrength } from '../utils/trendSearchMetrics';
|
|
import { resolveVirtualTargetNames } from '../utils/virtualTargetNames';
|
|
import { useVirtualTargetRegistry } from '../hooks/useVirtualTargetRegistry';
|
|
import { getAppSettingsCache, useAppSettings } from '../hooks/useAppSettings';
|
|
import {
|
|
mergeFiltersFromTrendSearchSettings,
|
|
mergeTrendSearchSettingsFromRequest,
|
|
resolveTrendSearchAppSettings,
|
|
trendSearchSettingsSignature,
|
|
type TrendSearchAppSettings,
|
|
} from '../utils/trendSearchAppSettings';
|
|
import { autoAddTrendSearchTargets } from '../utils/trendSearchAutoAddTargets';
|
|
import '../styles/trendSearchDashboard.css';
|
|
import '../styles/virtualTradingDashboard.css';
|
|
|
|
interface Props {
|
|
theme?: Theme;
|
|
chartRealtimeSource?: ChartRealtimeSource;
|
|
chartCandleAreaPriceLabels?: boolean;
|
|
chartSeriesPriceLabels?: boolean;
|
|
tickers?: Map<string, TickerData>;
|
|
}
|
|
|
|
const TrendSearchPage: React.FC<Props> = ({
|
|
theme = 'dark',
|
|
chartRealtimeSource = 'BACKEND_STOMP',
|
|
chartCandleAreaPriceLabels = true,
|
|
chartSeriesPriceLabels = true,
|
|
tickers,
|
|
}) => {
|
|
const { defaults, isLoaded: appSettingsLoaded, save: saveAppSettings } = useAppSettings();
|
|
const tsSettings = defaults.trendSearchSettings;
|
|
const tsSettingsSig = trendSearchSettingsSignature(tsSettings);
|
|
const [filters, setFilters] = useState<TrendSearchRequest>(() =>
|
|
mergeFiltersFromTrendSearchSettings(
|
|
{ ...DEFAULT_TREND_SEARCH_REQUEST },
|
|
resolveTrendSearchAppSettings(),
|
|
),
|
|
);
|
|
const [results, setResults] = useState<TrendSearchResultDto[]>([]);
|
|
const [selectedMarket, setSelectedMarket] = useState<string | null>(null);
|
|
const [searching, setSearching] = useState(false);
|
|
const [displayMode, setDisplayMode] = useState<TrendSearchDisplayMode>(
|
|
() => tsSettings.displayMode ?? 'summary',
|
|
);
|
|
const [flashMarkets, setFlashMarkets] = useState<Set<string>>(new Set());
|
|
const [lastUpdatedAt, setLastUpdatedAt] = useState<number>(Date.now());
|
|
const filtersRef = useRef(filters);
|
|
filtersRef.current = filters;
|
|
const tsSettingsRef = useRef(tsSettings);
|
|
tsSettingsRef.current = tsSettings;
|
|
const lastSyncedSettingsSigRef = useRef<string | null>(null);
|
|
const schedulerSeededRef = useRef(false);
|
|
const { isInTargets, isPinned, add, remove, busyMarket } = useVirtualTargetRegistry();
|
|
|
|
useEffect(() => {
|
|
if (!appSettingsLoaded) return;
|
|
if (lastSyncedSettingsSigRef.current === tsSettingsSig) return;
|
|
lastSyncedSettingsSigRef.current = tsSettingsSig;
|
|
setFilters(prev => mergeFiltersFromTrendSearchSettings(prev, tsSettings));
|
|
if (tsSettings.displayMode) setDisplayMode(tsSettings.displayMode);
|
|
}, [appSettingsLoaded, tsSettingsSig, tsSettings]);
|
|
|
|
useEffect(() => {
|
|
if (!appSettingsLoaded || schedulerSeededRef.current) return;
|
|
schedulerSeededRef.current = true;
|
|
saveAppSettings({ trendSearchSettings: tsSettingsRef.current });
|
|
}, [appSettingsLoaded, saveAppSettings]);
|
|
|
|
const persistTrendSettings = useCallback((patch: Partial<TrendSearchAppSettings>) => {
|
|
const base = resolveTrendSearchAppSettings(
|
|
getAppSettingsCache().trendSearchSettings as Partial<TrendSearchAppSettings> | undefined,
|
|
);
|
|
const next = resolveTrendSearchAppSettings({ ...base, ...patch });
|
|
lastSyncedSettingsSigRef.current = trendSearchSettingsSignature(next);
|
|
saveAppSettings({ trendSearchSettings: next });
|
|
}, [saveAppSettings]);
|
|
|
|
const handleFiltersChange = useCallback((next: TrendSearchRequest) => {
|
|
setFilters(next);
|
|
const base = resolveTrendSearchAppSettings(
|
|
getAppSettingsCache().trendSearchSettings as Partial<TrendSearchAppSettings> | undefined,
|
|
);
|
|
const merged = mergeTrendSearchSettingsFromRequest(base, next);
|
|
lastSyncedSettingsSigRef.current = trendSearchSettingsSignature(merged);
|
|
saveAppSettings({ trendSearchSettings: merged });
|
|
}, [saveAppSettings]);
|
|
|
|
const applyResults = useCallback(async (sorted: TrendSearchResultDto[], scannedAtMs?: number) => {
|
|
setResults(sorted);
|
|
setLastUpdatedAt(scannedAtMs ?? Date.now());
|
|
setSelectedMarket(prev => {
|
|
if (prev && sorted.some(r => r.market === prev)) return prev;
|
|
return sorted[0]?.market ?? null;
|
|
});
|
|
if (sorted.length > 0) {
|
|
setFlashMarkets(new Set(sorted.slice(0, 5).map(r => r.market)));
|
|
setTimeout(() => setFlashMarkets(new Set()), 600);
|
|
}
|
|
await autoAddTrendSearchTargets(
|
|
sorted,
|
|
tsSettingsRef.current,
|
|
filtersRef.current.timeframe,
|
|
);
|
|
}, []);
|
|
|
|
const loadFromDb = useCallback(async (opts?: { silent?: boolean }) => {
|
|
try {
|
|
const payload = await loadTrendSearchResults();
|
|
const sorted = sortTrendSearchByStrength(payload.results ?? []);
|
|
const scannedAt = payload.scannedAt ? Date.parse(payload.scannedAt) : undefined;
|
|
await applyResults(sorted, Number.isFinite(scannedAt) ? scannedAt : undefined);
|
|
} catch (e) {
|
|
console.warn('[TrendSearch] load results', e);
|
|
if (!opts?.silent) window.alert('추세검색 결과를 불러오지 못했습니다. 백엔드 연결을 확인하세요.');
|
|
}
|
|
}, [applyResults]);
|
|
|
|
const runScan = useCallback(async () => {
|
|
setSearching(true);
|
|
try {
|
|
const list = await scanTrendSearch(filtersRef.current);
|
|
const sorted = sortTrendSearchByStrength(list);
|
|
await applyResults(sorted);
|
|
} catch (e) {
|
|
console.warn('[TrendSearch] scan', e);
|
|
window.alert('추세검색 스캔에 실패했습니다. 백엔드 연결을 확인하세요.');
|
|
} finally {
|
|
setSearching(false);
|
|
}
|
|
}, [applyResults]);
|
|
|
|
useEffect(() => {
|
|
if (!appSettingsLoaded) return;
|
|
void loadFromDb({ silent: true });
|
|
}, [appSettingsLoaded, loadFromDb]);
|
|
|
|
useEffect(() => {
|
|
if (!tsSettings.autoRefreshEnabled) return;
|
|
const ms = Math.max(1, tsSettings.autoRefreshSeconds) * 1000;
|
|
const id = window.setInterval(() => void loadFromDb({ silent: true }), ms);
|
|
return () => clearInterval(id);
|
|
}, [tsSettings.autoRefreshEnabled, tsSettings.autoRefreshSeconds, loadFromDb]);
|
|
|
|
useEffect(() => {
|
|
if (!appSettingsLoaded) return;
|
|
if (tsSettings.displayMode === displayMode) return;
|
|
persistTrendSettings({ displayMode });
|
|
}, [displayMode, appSettingsLoaded, tsSettings.displayMode, persistTrendSettings]);
|
|
|
|
const handleSelect = useCallback(async (row: TrendSearchResultDto) => {
|
|
setSelectedMarket(row.market);
|
|
try {
|
|
const detail = await fetchTrendSearchDetail(row.market, filters.timeframe);
|
|
if (detail) {
|
|
setResults(prev => sortTrendSearchByStrength(
|
|
prev.map(r => (r.market === detail.market ? detail : r)),
|
|
));
|
|
}
|
|
} catch { /* keep row */ }
|
|
}, [filters.timeframe]);
|
|
|
|
const handleAddTarget = useCallback((row: TrendSearchResultDto) => {
|
|
const { koreanName, englishName } = resolveVirtualTargetNames(row.market, row.koreanName);
|
|
void add(row.market, {
|
|
koreanName,
|
|
englishName,
|
|
});
|
|
}, [add]);
|
|
|
|
const handleRemoveTarget = useCallback((market: string) => {
|
|
void remove(market);
|
|
}, [remove]);
|
|
|
|
const handleAutoAddToggle = useCallback((enabled: boolean) => {
|
|
persistTrendSettings({ autoAddTargetsEnabled: enabled });
|
|
if (enabled && results.length > 0) {
|
|
void autoAddTrendSearchTargets(results, { ...tsSettingsRef.current, autoAddTargetsEnabled: true }, filtersRef.current.timeframe);
|
|
}
|
|
}, [persistTrendSettings, results]);
|
|
|
|
const handleAutoRefreshToggle = useCallback((enabled: boolean) => {
|
|
persistTrendSettings({ autoRefreshEnabled: enabled });
|
|
}, [persistTrendSettings]);
|
|
|
|
const handleRefreshSecondsChange = useCallback((sec: number) => {
|
|
persistTrendSettings({ autoRefreshSeconds: sec });
|
|
}, [persistTrendSettings]);
|
|
|
|
return (
|
|
<BuilderPageShell
|
|
theme={theme}
|
|
title="추세검색"
|
|
subtitle="Golden Analysis · Trend Search"
|
|
pageClassName="bps-page--tsd bps-page--vtd"
|
|
banner={searching ? (
|
|
<div className="tsd-search-loading" role="status" aria-live="polite" aria-busy="true">
|
|
<div className="tsd-search-loading-track" role="progressbar" aria-label="추세검색 스캔 중">
|
|
<div className="tsd-search-loading-fill" />
|
|
</div>
|
|
<span className="tsd-search-loading-label">추세검색 스캔 중…</span>
|
|
</div>
|
|
) : undefined}
|
|
headerActions={(
|
|
<TrendSearchViewHeaderControls
|
|
displayMode={displayMode}
|
|
onDisplayModeChange={setDisplayMode}
|
|
autoRefresh={tsSettings.autoRefreshEnabled}
|
|
onAutoRefreshChange={handleAutoRefreshToggle}
|
|
refreshSeconds={tsSettings.autoRefreshSeconds}
|
|
onRefreshSecondsChange={handleRefreshSecondsChange}
|
|
autoAddTargets={tsSettings.autoAddTargetsEnabled}
|
|
onAutoAddTargetsChange={handleAutoAddToggle}
|
|
searching={searching}
|
|
onRefresh={() => void loadFromDb()}
|
|
resultCount={results.length}
|
|
/>
|
|
)}
|
|
leftStorageKey="tsd-left-width"
|
|
leftDefaultWidth={320}
|
|
leftCollapsedStorageKey="tsd-left-open"
|
|
collapsiblePanels
|
|
left={(
|
|
<TrendSearchFilterPanel
|
|
filters={filters}
|
|
onChange={handleFiltersChange}
|
|
onSearch={() => void runScan()}
|
|
searching={searching}
|
|
/>
|
|
)}
|
|
center={(
|
|
<TrendSearchResultsCardGrid
|
|
results={results}
|
|
timeframe={filters.timeframe}
|
|
displayMode={displayMode}
|
|
selectedMarket={selectedMarket}
|
|
onSelect={row => void handleSelect(row)}
|
|
flashMarkets={flashMarkets}
|
|
theme={theme}
|
|
chartRealtimeSource={chartRealtimeSource}
|
|
chartCandleAreaPriceLabels={chartCandleAreaPriceLabels}
|
|
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
|
chartPaneSeparator={defaults.chartPaneSeparator}
|
|
tickers={tickers}
|
|
lastUpdatedAt={lastUpdatedAt}
|
|
isInTargets={isInTargets}
|
|
isPinned={isPinned}
|
|
onAddTarget={handleAddTarget}
|
|
onRemoveTarget={handleRemoveTarget}
|
|
targetBusyMarket={busyMarket}
|
|
/>
|
|
)}
|
|
/>
|
|
);
|
|
};
|
|
|
|
export default TrendSearchPage;
|