import { useState } from 'react' import { useGeoNews, usePatternRelevance, useGeoRiskScore } from '../hooks/useApi' import clsx from 'clsx' import type { GeoNews } from '../types' import { Globe, ExternalLink, Search, Brain } from 'lucide-react' const CATEGORY_COLORS: Record = { military: 'badge-red', sanctions: 'badge-orange', elections: 'badge-purple', natural_disaster: 'badge-yellow', health_crisis: 'badge-orange', resource_scarcity: 'badge-yellow', trade_war: 'badge-blue', energy: 'badge-orange', political_speech: 'badge-blue', financial_crisis: 'badge-red', general: 'badge-blue', } const CATEGORY_LABELS: Record = { military: 'โš”๏ธ Military', sanctions: '๐Ÿšซ Sanctions', elections: '๐Ÿ—ณ๏ธ Elections', natural_disaster: '๐ŸŒช๏ธ Disaster', health_crisis: '๐Ÿฅ Health', resource_scarcity: 'โš ๏ธ Resources', trade_war: '๐Ÿค Trade', energy: 'โšก Energy', political_speech: '๐ŸŽ™๏ธ Speech', financial_crisis: '๐Ÿ’ธ Finance', general: '๐Ÿ“ฐ General', } const ASSET_LABELS: Record = { energy: 'โ›ฝ Energy', metals: '๐Ÿฅ‡ Metals', agriculture: '๐ŸŒพ Agri', equities: '๐Ÿ“ˆ Equities', indices: '๐Ÿ“Š Indices', forex: '๐Ÿ’ฑ Forex', } function ImpactBar({ value, label }: { value: number; label: string }) { const abs = Math.abs(value) const positive = value > 0 return (
{label}
{positive ? ( <>
) : ( <>
)}
{positive ? '+' : ''}{(value * 100).toFixed(0)}
) } function NewsCard({ news }: { news: GeoNews }) { const [expanded, setExpanded] = useState(false) return (
setExpanded(!expanded)}>
{news.title}
{CATEGORY_LABELS[news.category] ?? news.category} {news.source}
{Math.round(news.impact_score * 100)}
impact
{expanded && ( <> {/* AI insight */} {(news as any).ai_insight && (
{(news as any).ai_insight}
)} {/* AI directional signals */} {((news as any).ai_dir_energy || (news as any).ai_dir_metals || (news as any).ai_dir_indices) && (
{(['ai_dir_energy', 'ai_dir_metals', 'ai_dir_indices'] as const).map(field => { const dir = (news as any)[field] if (!dir || dir === 'neutral') return null const labels: Record = { ai_dir_energy: 'โ›ฝ', ai_dir_metals: '๐Ÿฅ‡', ai_dir_indices: '๐Ÿ“Š' } return ( {labels[field]} {dir === 'bullish' ? 'โ†‘' : 'โ†“'} ) })} {(news as any).ai_resolution && ( ๐Ÿ•Š resolution )}
)}

{news.summary}

{Object.keys(news.asset_impacts).length > 0 && (
Impact by asset class
{Object.entries(news.asset_impacts).map(([cls, val]) => ( ))}
)} {news.tags.length > 0 && (
{news.tags.map(tag => ( #{tag} ))}
)} {news.url && ( e.stopPropagation()}> Read article )} )}
{news.date?.slice(0, 16)} {expanded ? 'โ–ฒ Collapse' : 'โ–ผ Details'}
) } function RelevanceBar({ pct }: { pct: number }) { const color = pct >= 60 ? 'bg-emerald-500' : pct >= 30 ? 'bg-yellow-500' : 'bg-slate-600' return (
= 60 ? 'text-emerald-400' : pct >= 30 ? 'text-yellow-400' : 'text-slate-500')}> {pct}%
) } function PatternRelevanceCard({ p }: { p: any }) { const [expanded, setExpanded] = useState(false) const hasNews = p.matching_news?.length > 0 return (
= 60, 'border-yellow-700/30': p.relevance >= 30 && p.relevance < 60, 'border-slate-700/20 opacity-60': p.relevance === 0, })}>
{p.name}
{p.asset_class} {p.keyword_hits}/{p.keyword_total} keywords {hasNews && ( {p.matching_news.length} news )} {/* AI alignment badge */} {p.ai_scored_count > 0 && p.ai_alignment !== 0 && ( {p.ai_contra_signal ? `counter-signal (${p.ai_alignment > 0 ? '+' : ''}${p.ai_alignment}pts)` : `AI aligned (+${p.ai_alignment}pts)`} )}
{p.base_relevance !== undefined && p.ai_alignment !== 0 && (
base {p.base_relevance}%
)}
{/* Matched keywords */} {p.matched_keywords?.length > 0 && (
{p.matched_keywords.map((kw: string) => ( #{kw} ))}
)} {/* AI insights */} {p.ai_insights?.length > 0 && (
{p.ai_insights.map((insight: string, i: number) => (
{insight}
))}
)} {/* Toggle matching news */} {hasNews && ( <> {expanded && (
{p.matching_news.map((n: any, i: number) => (
{n.title}
{n.source} ยท {n.date}
{Math.round(n.impact * 100)}
{n.matched_keywords.map((kw: string) => ( #{kw} ))}
{n.url && ( e.stopPropagation()}> Read )}
))}
)} )} {!hasNews && p.relevance === 0 && (
No matching news for this period
)}
) } type SortMode = 'impact_desc' | 'impact_asc' | 'date_desc' | 'date_asc' type DateRange = 'all' | 'today' | '7d' | '30d' const DATE_RANGE_LABELS: Record = { all: 'All', today: 'Today', '7d': '7 days', '30d': '30 days', } const SORT_LABELS: Record = { impact_desc: 'Impact โ†“', impact_asc: 'Impact โ†‘', date_desc: 'Recent โ†“', date_asc: 'Oldest โ†‘', } function isWithinRange(dateStr: string, range: DateRange): boolean { if (range === 'all') return true const d = new Date(dateStr) const now = new Date() const diffMs = now.getTime() - d.getTime() if (range === 'today') return diffMs < 86_400_000 if (range === '7d') return diffMs < 7 * 86_400_000 if (range === '30d') return diffMs < 30 * 86_400_000 return true } export default function GeoRadar() { const { data: news, isLoading } = useGeoNews() const { data: riskScore } = useGeoRiskScore() const [filterCat, setFilterCat] = useState('all') const [sortMode, setSortMode] = useState('impact_desc') const [dateRange, setDateRange] = useState('all') const [tab, setTab] = useState<'news' | 'patterns'>('news') const [days, setDays] = useState(2) const { data: patternRelevance, isLoading: relLoading } = usePatternRelevance(days) const categories = ['all', ...Array.from(new Set(news?.map(n => n.category) ?? []))] const filtered = (news ?? []) .filter(n => filterCat === 'all' || n.category === filterCat) .filter(n => isWithinRange(n.date, dateRange)) .sort((a, b) => { if (sortMode === 'impact_desc') return b.impact_score - a.impact_score if (sortMode === 'impact_asc') return a.impact_score - b.impact_score if (sortMode === 'date_desc') return new Date(b.date).getTime() - new Date(a.date).getTime() return new Date(a.date).getTime() - new Date(b.date).getTime() }) const patternsWithNews = (patternRelevance ?? []).filter((p: any) => p.matching_news?.length > 0).length const totalPatterns = patternRelevance?.length ?? 0 return (

Geopolitical Radar

News feed ยท Pattern matching for trading signals

{riskScore && (
{riskScore.score}/100
{riskScore.level}
)}
{/* Tabs */}
{(['news', 'patterns'] as const).map(t => ( ))}
{/* Day selector โ€” only shown on patterns tab */} {tab === 'patterns' && (
Window:
{[1, 2, 7, 30].map(d => ( ))}
last days
)}
{/* News tab */} {tab === 'news' && ( <> {/* Filters row */}
{/* Category filter */}
{categories.slice(0, 8).map(cat => ( ))}
{/* Sort + date range row */}
Period:
{(['today', '7d', '30d', 'all'] as DateRange[]).map(r => ( ))}
Sort:
{(['impact_desc', 'impact_asc', 'date_desc', 'date_asc'] as SortMode[]).map(s => ( ))}
{filtered.length} news
{isLoading ? (
{[1,2,3,4].map(i =>
)}
) : (
{filtered.map((n, i) => )} {filtered.length === 0 && (
Start the backend to load news
)}
)} )} {/* Patterns relevance tab */} {tab === 'patterns' && ( <>
Matching each pattern's keywords against news from the last {days} days. This signal enriches the context sent to the AI during scoring. {patternsWithNews > 0 && ( {patternsWithNews} patterns have matching news. )}
{relLoading ? (
{[1,2,3].map(i =>
)}
) : (
{(patternRelevance ?? []).map((p: any) => ( ))} {(!patternRelevance || patternRelevance.length === 0) && (
Start the backend to compute news-pattern matching
)}
)} )}
) }