Spaces:
Sleeping
Sleeping
fix(ui): patch trim typeerror, globe invisible arcs, and leaflet latency
Browse files- frontend/app/page.module.css +20 -6
- frontend/app/page.tsx +172 -175
- frontend/components/case/AgentTerminal.tsx +94 -99
- frontend/components/case/EntityGraph.tsx +1 -1
- frontend/components/globe/GlobalThreatMap.tsx +8 -12
- frontend/components/ui/ReplayControls.tsx +33 -28
- frontend/components/ui/ui.module.css +57 -8
- frontend/lib/geoData.ts +84 -83
frontend/app/page.module.css
CHANGED
|
@@ -28,6 +28,7 @@
|
|
| 28 |
display: flex;
|
| 29 |
align-items: center;
|
| 30 |
gap: 10px;
|
|
|
|
| 31 |
}
|
| 32 |
|
| 33 |
.headerSep {
|
|
@@ -44,7 +45,6 @@
|
|
| 44 |
}
|
| 45 |
|
| 46 |
.headerCenter {
|
| 47 |
-
flex: 1;
|
| 48 |
display: flex;
|
| 49 |
justify-content: center;
|
| 50 |
padding: 0 20px;
|
|
@@ -53,7 +53,9 @@
|
|
| 53 |
.headerRight {
|
| 54 |
display: flex;
|
| 55 |
align-items: center;
|
|
|
|
| 56 |
gap: 12px;
|
|
|
|
| 57 |
}
|
| 58 |
|
| 59 |
/* ββ Main Content βββββββββββββββββββββββββββ */
|
|
@@ -86,13 +88,23 @@
|
|
| 86 |
}
|
| 87 |
|
| 88 |
@keyframes pageFadeIn {
|
| 89 |
-
from {
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
}
|
| 92 |
|
| 93 |
@keyframes pageFadeOut {
|
| 94 |
-
from {
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
}
|
| 97 |
|
| 98 |
@keyframes pageSlideIn {
|
|
@@ -101,10 +113,12 @@
|
|
| 101 |
transform: scale(1.05);
|
| 102 |
filter: brightness(2);
|
| 103 |
}
|
|
|
|
| 104 |
30% {
|
| 105 |
opacity: 1;
|
| 106 |
filter: brightness(1.5);
|
| 107 |
}
|
|
|
|
| 108 |
to {
|
| 109 |
opacity: 1;
|
| 110 |
transform: scale(1);
|
|
@@ -123,4 +137,4 @@
|
|
| 123 |
color: var(--nx-muted);
|
| 124 |
font-size: 12px;
|
| 125 |
letter-spacing: 0.06em;
|
| 126 |
-
}
|
|
|
|
| 28 |
display: flex;
|
| 29 |
align-items: center;
|
| 30 |
gap: 10px;
|
| 31 |
+
flex: 1;
|
| 32 |
}
|
| 33 |
|
| 34 |
.headerSep {
|
|
|
|
| 45 |
}
|
| 46 |
|
| 47 |
.headerCenter {
|
|
|
|
| 48 |
display: flex;
|
| 49 |
justify-content: center;
|
| 50 |
padding: 0 20px;
|
|
|
|
| 53 |
.headerRight {
|
| 54 |
display: flex;
|
| 55 |
align-items: center;
|
| 56 |
+
justify-content: flex-end;
|
| 57 |
gap: 12px;
|
| 58 |
+
flex: 1;
|
| 59 |
}
|
| 60 |
|
| 61 |
/* ββ Main Content βββββββββββββββββββββββββββ */
|
|
|
|
| 88 |
}
|
| 89 |
|
| 90 |
@keyframes pageFadeIn {
|
| 91 |
+
from {
|
| 92 |
+
opacity: 0;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
to {
|
| 96 |
+
opacity: 1;
|
| 97 |
+
}
|
| 98 |
}
|
| 99 |
|
| 100 |
@keyframes pageFadeOut {
|
| 101 |
+
from {
|
| 102 |
+
opacity: 1;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
to {
|
| 106 |
+
opacity: 0;
|
| 107 |
+
}
|
| 108 |
}
|
| 109 |
|
| 110 |
@keyframes pageSlideIn {
|
|
|
|
| 113 |
transform: scale(1.05);
|
| 114 |
filter: brightness(2);
|
| 115 |
}
|
| 116 |
+
|
| 117 |
30% {
|
| 118 |
opacity: 1;
|
| 119 |
filter: brightness(1.5);
|
| 120 |
}
|
| 121 |
+
|
| 122 |
to {
|
| 123 |
opacity: 1;
|
| 124 |
transform: scale(1);
|
|
|
|
| 137 |
color: var(--nx-muted);
|
| 138 |
font-size: 12px;
|
| 139 |
letter-spacing: 0.06em;
|
| 140 |
+
}
|
frontend/app/page.tsx
CHANGED
|
@@ -13,183 +13,180 @@ import type { ViewState, ReplayStep } from '@/lib/types';
|
|
| 13 |
|
| 14 |
// Dynamic import for map (no SSR)
|
| 15 |
const GlobalThreatMap = dynamic(
|
| 16 |
-
|
| 17 |
-
|
| 18 |
);
|
| 19 |
|
| 20 |
export default function Home() {
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
</div>
|
| 146 |
-
|
| 147 |
-
{view === 'case' && replayLoaded && (
|
| 148 |
-
<ReplayControls
|
| 149 |
-
currentStep={Math.max(0, currentStepIndex)}
|
| 150 |
-
totalSteps={allSteps.length}
|
| 151 |
-
isPlaying={isPlaying}
|
| 152 |
-
speed={speed}
|
| 153 |
-
onPlay={handlePlay}
|
| 154 |
-
onPause={handlePause}
|
| 155 |
-
onStepForward={handleStepForward}
|
| 156 |
-
onStepBack={handleStepBack}
|
| 157 |
-
onSeek={handleSeek}
|
| 158 |
-
onSpeedChange={handleSpeedChange}
|
| 159 |
-
/>
|
| 160 |
-
)}
|
| 161 |
-
</div>
|
| 162 |
-
<div className={styles.headerRight}>
|
| 163 |
-
<StatusIndicator
|
| 164 |
-
status={backendStatus}
|
| 165 |
-
label={backendStatus === 'connected' ? 'LIVE' : backendStatus === 'replay' ? 'REPLAY' : 'OFFLINE'}
|
| 166 |
-
/>
|
| 167 |
-
</div>
|
| 168 |
-
</header>
|
| 169 |
-
|
| 170 |
-
{/* View Content */}
|
| 171 |
-
<main className={`${styles.main} ${transitioning ? styles.transitioning : ''}`}>
|
| 172 |
-
{view === 'globe' && (
|
| 173 |
-
<div className={`${styles.viewContainer} ${transitioning ? styles.viewFadeOut : styles.viewFadeIn}`}>
|
| 174 |
-
<GlobalThreatMap
|
| 175 |
-
onCorridorSelect={handleCorridorSelect}
|
| 176 |
-
onHubSelect={handleHubSelect}
|
| 177 |
-
/>
|
| 178 |
-
</div>
|
| 179 |
-
)}
|
| 180 |
-
|
| 181 |
-
{view === 'case' && (
|
| 182 |
-
<div className={`${styles.viewContainer} ${transitioning ? styles.viewFadeOut : styles.viewSlideIn}`}>
|
| 183 |
-
<CaseTerminal
|
| 184 |
-
steps={replaySteps}
|
| 185 |
-
currentStepIndex={Math.max(0, currentStepIndex)}
|
| 186 |
-
onBack={handleBackToMap}
|
| 187 |
-
alertSummary={replayRef.current?.getMeta()?.alert?.summary}
|
| 188 |
-
caseId={replayRef.current?.getMeta()?.alert?.alert_id}
|
| 189 |
-
/>
|
| 190 |
-
</div>
|
| 191 |
-
)}
|
| 192 |
-
</main>
|
| 193 |
-
</div>
|
| 194 |
-
);
|
| 195 |
}
|
|
|
|
| 13 |
|
| 14 |
// Dynamic import for map (no SSR)
|
| 15 |
const GlobalThreatMap = dynamic(
|
| 16 |
+
() => import('@/components/globe/GlobalThreatMap'),
|
| 17 |
+
{ ssr: false, loading: () => <div className={styles.mapLoading}>> INITIALIZING MAP ENGINE...</div> }
|
| 18 |
);
|
| 19 |
|
| 20 |
export default function Home() {
|
| 21 |
+
const [view, setView] = useState<ViewState>('globe');
|
| 22 |
+
const [mode, setMode] = useState<'live' | 'replay'>('replay');
|
| 23 |
+
const [backendStatus, setBackendStatus] = useState<'connected' | 'replay' | 'disconnected'>('replay');
|
| 24 |
+
|
| 25 |
+
// Replay state
|
| 26 |
+
const replayRef = useRef<ReplayEngine | null>(null);
|
| 27 |
+
const [replaySteps, setReplaySteps] = useState<ReplayStep[]>([]);
|
| 28 |
+
const [currentStepIndex, setCurrentStepIndex] = useState(-1);
|
| 29 |
+
const [isPlaying, setIsPlaying] = useState(false);
|
| 30 |
+
const [speed, setSpeed] = useState(1);
|
| 31 |
+
const [replayLoaded, setReplayLoaded] = useState(false);
|
| 32 |
+
const [allSteps, setAllSteps] = useState<ReplayStep[]>([]);
|
| 33 |
+
|
| 34 |
+
// Transition state
|
| 35 |
+
const [transitioning, setTransitioning] = useState(false);
|
| 36 |
+
|
| 37 |
+
// Check backend availability
|
| 38 |
+
useEffect(() => {
|
| 39 |
+
isBackendAvailable().then(available => {
|
| 40 |
+
setBackendStatus(available ? 'connected' : 'replay');
|
| 41 |
+
setMode(available ? 'live' : 'replay');
|
| 42 |
+
});
|
| 43 |
+
}, []);
|
| 44 |
+
|
| 45 |
+
// Initialize replay engine
|
| 46 |
+
useEffect(() => {
|
| 47 |
+
const engine = new ReplayEngine();
|
| 48 |
+
replayRef.current = engine;
|
| 49 |
+
|
| 50 |
+
engine.load().then(() => {
|
| 51 |
+
setAllSteps(engine.getSteps());
|
| 52 |
+
setReplayLoaded(true);
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
const unsub = engine.subscribe((step, index) => {
|
| 56 |
+
setCurrentStepIndex(index);
|
| 57 |
+
setReplaySteps(engine.getSteps().slice(0, index + 1));
|
| 58 |
+
});
|
| 59 |
+
|
| 60 |
+
return () => unsub();
|
| 61 |
+
}, []);
|
| 62 |
+
|
| 63 |
+
// Handle corridor/hub clicks
|
| 64 |
+
const handleCorridorSelect = useCallback((corridorId: string) => {
|
| 65 |
+
setTransitioning(true);
|
| 66 |
+
setTimeout(() => {
|
| 67 |
+
setView('case');
|
| 68 |
+
setTransitioning(false);
|
| 69 |
+
// Start replay
|
| 70 |
+
if (replayRef.current && allSteps.length > 0) {
|
| 71 |
+
replayRef.current.seekTo(0);
|
| 72 |
+
setCurrentStepIndex(0);
|
| 73 |
+
setReplaySteps(allSteps.slice(0, 1));
|
| 74 |
+
}
|
| 75 |
+
}, 800);
|
| 76 |
+
}, [allSteps]);
|
| 77 |
+
|
| 78 |
+
const handleHubSelect = useCallback((hubId: string) => {
|
| 79 |
+
handleCorridorSelect(hubId);
|
| 80 |
+
}, [handleCorridorSelect]);
|
| 81 |
+
|
| 82 |
+
// Replay controls
|
| 83 |
+
const handlePlay = useCallback(() => {
|
| 84 |
+
replayRef.current?.play();
|
| 85 |
+
setIsPlaying(true);
|
| 86 |
+
}, []);
|
| 87 |
+
|
| 88 |
+
const handlePause = useCallback(() => {
|
| 89 |
+
replayRef.current?.pause();
|
| 90 |
+
setIsPlaying(false);
|
| 91 |
+
}, []);
|
| 92 |
+
|
| 93 |
+
const handleStepForward = useCallback(() => {
|
| 94 |
+
replayRef.current?.stepForward();
|
| 95 |
+
}, []);
|
| 96 |
+
|
| 97 |
+
const handleStepBack = useCallback(() => {
|
| 98 |
+
replayRef.current?.stepBack();
|
| 99 |
+
}, []);
|
| 100 |
+
|
| 101 |
+
const handleSeek = useCallback((step: number) => {
|
| 102 |
+
replayRef.current?.seekTo(step);
|
| 103 |
+
}, []);
|
| 104 |
+
|
| 105 |
+
const handleSpeedChange = useCallback((newSpeed: number) => {
|
| 106 |
+
replayRef.current?.setSpeed(newSpeed);
|
| 107 |
+
setSpeed(newSpeed);
|
| 108 |
+
}, []);
|
| 109 |
+
|
| 110 |
+
const handleBackToMap = useCallback(() => {
|
| 111 |
+
replayRef.current?.pause();
|
| 112 |
+
setIsPlaying(false);
|
| 113 |
+
setTransitioning(true);
|
| 114 |
+
setTimeout(() => {
|
| 115 |
+
setView('globe');
|
| 116 |
+
setTransitioning(false);
|
| 117 |
+
}, 600);
|
| 118 |
+
}, []);
|
| 119 |
+
|
| 120 |
+
return (
|
| 121 |
+
<div className={styles.app}>
|
| 122 |
+
{/* Global Header */}
|
| 123 |
+
<header className={styles.header}>
|
| 124 |
+
<div className={styles.headerLeft}>
|
| 125 |
+
<MemexLogo size="sm" />
|
| 126 |
+
<span className={styles.headerSep}>β</span>
|
| 127 |
+
{view === 'case' ? (
|
| 128 |
+
<>
|
| 129 |
+
<button className="nx-btn nx-btn-icon" onClick={handleBackToMap} style={{ padding: '4px 8px', fontSize: '10px' }}>
|
| 130 |
+
β MAP
|
| 131 |
+
</button>
|
| 132 |
+
<span className={styles.headerSep}>β</span>
|
| 133 |
+
<div style={{ display: 'flex', flexDirection: 'column', cursor: 'pointer' }}>
|
| 134 |
+
<span style={{ fontSize: '11px', fontWeight: 700, color: 'var(--nx-orange)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
| 135 |
+
{replayRef.current?.getMeta()?.alert?.alert_id || 'CASE-1MDB'}
|
| 136 |
+
</span>
|
| 137 |
+
</div>
|
| 138 |
+
</>
|
| 139 |
+
) : (
|
| 140 |
+
<span className={styles.headerSubtitle}>FINANCIAL CRIME INTELLIGENCE</span>
|
| 141 |
+
)}
|
| 142 |
+
</div>
|
| 143 |
+
<div className={styles.headerCenter}>
|
| 144 |
+
{view === 'case' && replayLoaded && (
|
| 145 |
+
<ReplayControls
|
| 146 |
+
currentStep={Math.max(0, currentStepIndex)}
|
| 147 |
+
totalSteps={allSteps.length}
|
| 148 |
+
isPlaying={isPlaying}
|
| 149 |
+
speed={speed}
|
| 150 |
+
onPlay={handlePlay}
|
| 151 |
+
onPause={handlePause}
|
| 152 |
+
onStepForward={handleStepForward}
|
| 153 |
+
onStepBack={handleStepBack}
|
| 154 |
+
onSeek={handleSeek}
|
| 155 |
+
onSpeedChange={handleSpeedChange}
|
| 156 |
+
/>
|
| 157 |
+
)}
|
| 158 |
+
</div>
|
| 159 |
+
<div className={styles.headerRight}>
|
| 160 |
+
<StatusIndicator
|
| 161 |
+
status={backendStatus}
|
| 162 |
+
label={backendStatus === 'connected' ? 'LIVE' : backendStatus === 'replay' ? 'REPLAY' : 'OFFLINE'}
|
| 163 |
+
/>
|
| 164 |
+
</div>
|
| 165 |
+
</header>
|
| 166 |
+
|
| 167 |
+
{/* View Content */}
|
| 168 |
+
<main className={`${styles.main} ${transitioning ? styles.transitioning : ''}`}>
|
| 169 |
+
{view === 'globe' && (
|
| 170 |
+
<div className={`${styles.viewContainer} ${transitioning ? styles.viewFadeOut : styles.viewFadeIn}`}>
|
| 171 |
+
<GlobalThreatMap
|
| 172 |
+
onCorridorSelect={handleCorridorSelect}
|
| 173 |
+
onHubSelect={handleHubSelect}
|
| 174 |
+
/>
|
| 175 |
+
</div>
|
| 176 |
+
)}
|
| 177 |
+
|
| 178 |
+
{view === 'case' && (
|
| 179 |
+
<div className={`${styles.viewContainer} ${transitioning ? styles.viewFadeOut : styles.viewSlideIn}`}>
|
| 180 |
+
<CaseTerminal
|
| 181 |
+
steps={replaySteps}
|
| 182 |
+
currentStepIndex={Math.max(0, currentStepIndex)}
|
| 183 |
+
onBack={handleBackToMap}
|
| 184 |
+
alertSummary={replayRef.current?.getMeta()?.alert?.summary}
|
| 185 |
+
caseId={replayRef.current?.getMeta()?.alert?.alert_id}
|
| 186 |
+
/>
|
| 187 |
+
</div>
|
| 188 |
+
)}
|
| 189 |
+
</main>
|
| 190 |
</div>
|
| 191 |
+
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
}
|
frontend/components/case/AgentTerminal.tsx
CHANGED
|
@@ -5,122 +5,117 @@ import styles from './case.module.css';
|
|
| 5 |
import type { ReplayStep } from '@/lib/types';
|
| 6 |
|
| 7 |
interface Props {
|
| 8 |
-
|
| 9 |
}
|
| 10 |
|
| 11 |
const TOOL_ICONS: Record<string, string> = {
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
};
|
| 26 |
|
| 27 |
export default function AgentTerminal({ steps }: Props) {
|
| 28 |
-
|
| 29 |
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
<span className={styles.termCmd}>init_investigation</span>
|
| 49 |
-
</div>
|
| 50 |
-
<div className={styles.termOutput}>> Environment loaded. Awaiting agent actions.</div>
|
| 51 |
-
</div>
|
| 52 |
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
<span className={styles.termStepNum}>STEP {String(step.step_number).padStart(2, '0')}</span>
|
| 67 |
-
<span className={styles.termReward} style={{ color: rewardColor }}>
|
| 68 |
-
{reward >= 0 ? '+' : ''}{reward.toFixed(2)}
|
| 69 |
-
</span>
|
| 70 |
-
</div>
|
| 71 |
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
<div key={key} className={styles.termParam}>
|
| 93 |
-
<span className={styles.termParamKey}>{key}:</span>
|
| 94 |
-
<span className={styles.termParamVal}>
|
| 95 |
-
{typeof val === 'object' ? JSON.stringify(val) : String(val)}
|
| 96 |
-
</span>
|
| 97 |
-
</div>
|
| 98 |
-
))}
|
| 99 |
-
</div>
|
| 100 |
-
)}
|
| 101 |
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
EPISODE COMPLETE β FINAL SCORE: {(step.observation.reward ?? 0) >= 0 ? '+' : ''}{(step.observation.reward ?? 0).toFixed(4)}
|
| 112 |
</div>
|
| 113 |
-
)}
|
| 114 |
</div>
|
| 115 |
-
);
|
| 116 |
-
})}
|
| 117 |
-
|
| 118 |
-
{/* Cursor blink */}
|
| 119 |
-
<div className={styles.termCursor}>
|
| 120 |
-
<span className={styles.termPS1}>memex@case:~$</span>
|
| 121 |
-
<span className={styles.termBlink}>β</span>
|
| 122 |
</div>
|
| 123 |
-
|
| 124 |
-
</div>
|
| 125 |
-
);
|
| 126 |
}
|
|
|
|
| 5 |
import type { ReplayStep } from '@/lib/types';
|
| 6 |
|
| 7 |
interface Props {
|
| 8 |
+
steps: ReplayStep[];
|
| 9 |
}
|
| 10 |
|
| 11 |
const TOOL_ICONS: Record<string, string> = {
|
| 12 |
+
review_alert: 'π',
|
| 13 |
+
get_customer_profile: 'π€',
|
| 14 |
+
query_transactions: 'π³',
|
| 15 |
+
check_watchlist: 'π',
|
| 16 |
+
trace_network: 'πΈ',
|
| 17 |
+
check_source_of_funds: 'π°',
|
| 18 |
+
write_to_case_file: 'πΎ',
|
| 19 |
+
update_system_prompt: 'β',
|
| 20 |
+
search_compliance_manual: 'π',
|
| 21 |
+
request_wire_trace: 'π‘',
|
| 22 |
+
retrieve_async_result: 'π₯',
|
| 23 |
+
file_sar: 'π¨',
|
| 24 |
+
close_alert: 'β
',
|
| 25 |
};
|
| 26 |
|
| 27 |
export default function AgentTerminal({ steps }: Props) {
|
| 28 |
+
const scrollRef = useRef<HTMLDivElement>(null);
|
| 29 |
|
| 30 |
+
useEffect(() => {
|
| 31 |
+
if (scrollRef.current) {
|
| 32 |
+
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
| 33 |
+
}
|
| 34 |
+
}, [steps.length]);
|
| 35 |
|
| 36 |
+
return (
|
| 37 |
+
<div className={styles.terminalContainer}>
|
| 38 |
+
<div className={styles.terminalBody} ref={scrollRef}>
|
| 39 |
+
{/* Initial prompt */}
|
| 40 |
+
<div className={styles.termEntry}>
|
| 41 |
+
<div className={styles.termPrompt}>
|
| 42 |
+
<span className={styles.termPS1}>memex@case:~$</span>
|
| 43 |
+
<span className={styles.termCmd}>init_investigation</span>
|
| 44 |
+
</div>
|
| 45 |
+
<div className={styles.termOutput}>> Environment loaded. Awaiting agent actions.</div>
|
| 46 |
+
</div>
|
| 47 |
|
| 48 |
+
{/* Step entries */}
|
| 49 |
+
{steps.map((step) => {
|
| 50 |
+
const icon = TOOL_ICONS[step.action.tool] || 'βΈ';
|
| 51 |
+
const reward = step.observation.reward ?? 0;
|
| 52 |
+
const rewardColor = reward >= 0.05 ? '#22C55E' : reward < 0 ? '#D4334A' : '#505055';
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
+
return (
|
| 55 |
+
<div
|
| 56 |
+
key={step.step_number}
|
| 57 |
+
className={`${styles.termEntry} ${step.observation.done ? styles.termEntryFinal : ''}`}
|
| 58 |
+
>
|
| 59 |
+
{/* Step header */}
|
| 60 |
+
<div className={styles.termStepHeader}>
|
| 61 |
+
<span className={styles.termStepNum}>STEP {String(step.step_number).padStart(2, '0')}</span>
|
| 62 |
+
<span className={styles.termReward} style={{ color: rewardColor }}>
|
| 63 |
+
{reward >= 0 ? '+' : ''}{reward.toFixed(2)}
|
| 64 |
+
</span>
|
| 65 |
+
</div>
|
| 66 |
|
| 67 |
+
{/* Reasoning */}
|
| 68 |
+
{step.reasoning && (
|
| 69 |
+
<div className={styles.termReasoning}>
|
| 70 |
+
<span className={styles.termReasonLabel}>REASONING</span>
|
| 71 |
+
<p className={styles.termReasonText}>{step.reasoning}</p>
|
| 72 |
+
</div>
|
| 73 |
+
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
+
{/* Tool call */}
|
| 76 |
+
<div className={styles.termPrompt}>
|
| 77 |
+
<span className={styles.termPS1}>memex@case:~$</span>
|
| 78 |
+
<span className={styles.termCmd}>
|
| 79 |
+
{icon} {step.action.tool}
|
| 80 |
+
</span>
|
| 81 |
+
</div>
|
| 82 |
|
| 83 |
+
{/* Parameters */}
|
| 84 |
+
{Object.keys(step.action.parameters).length > 0 && (
|
| 85 |
+
<div className={styles.termParams}>
|
| 86 |
+
{Object.entries(step.action.parameters).map(([key, val]) => (
|
| 87 |
+
<div key={key} className={styles.termParam}>
|
| 88 |
+
<span className={styles.termParamKey}>{key}:</span>
|
| 89 |
+
<span className={styles.termParamVal}>
|
| 90 |
+
{typeof val === 'object' ? JSON.stringify(val) : String(val)}
|
| 91 |
+
</span>
|
| 92 |
+
</div>
|
| 93 |
+
))}
|
| 94 |
+
</div>
|
| 95 |
+
)}
|
| 96 |
|
| 97 |
+
{/* Output */}
|
| 98 |
+
<div className={styles.termOutput}>
|
| 99 |
+
> {step.observation.message}
|
| 100 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
|
| 102 |
+
{/* Terminal marker for final step */}
|
| 103 |
+
{step.observation.done && (
|
| 104 |
+
<div className={styles.termFinal}>
|
| 105 |
+
<span className={styles.termFinalIcon}>β</span>
|
| 106 |
+
EPISODE COMPLETE β FINAL SCORE: {(step.observation.reward ?? 0) >= 0 ? '+' : ''}{(step.observation.reward ?? 0).toFixed(4)}
|
| 107 |
+
</div>
|
| 108 |
+
)}
|
| 109 |
+
</div>
|
| 110 |
+
);
|
| 111 |
+
})}
|
| 112 |
|
| 113 |
+
{/* Cursor blink */}
|
| 114 |
+
<div className={styles.termCursor}>
|
| 115 |
+
<span className={styles.termPS1}>memex@case:~$</span>
|
| 116 |
+
<span className={styles.termBlink}>β</span>
|
|
|
|
| 117 |
</div>
|
|
|
|
| 118 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
</div>
|
| 120 |
+
);
|
|
|
|
|
|
|
| 121 |
}
|
frontend/components/case/EntityGraph.tsx
CHANGED
|
@@ -104,7 +104,7 @@ export default function EntityGraph({ graphData }: Props) {
|
|
| 104 |
'shape': (ele: any) => NODE_SHAPES[ele.data('type')] || 'ellipse',
|
| 105 |
'color': '#D4D4D4',
|
| 106 |
'font-size': '9px',
|
| 107 |
-
'font-family': '
|
| 108 |
'text-valign': 'bottom',
|
| 109 |
'text-margin-y': 6,
|
| 110 |
'text-outline-width': 4,
|
|
|
|
| 104 |
'shape': (ele: any) => NODE_SHAPES[ele.data('type')] || 'ellipse',
|
| 105 |
'color': '#D4D4D4',
|
| 106 |
'font-size': '9px',
|
| 107 |
+
'font-family': 'JetBrains Mono, monospace',
|
| 108 |
'text-valign': 'bottom',
|
| 109 |
'text-margin-y': 6,
|
| 110 |
'text-outline-width': 4,
|
frontend/components/globe/GlobalThreatMap.tsx
CHANGED
|
@@ -138,13 +138,10 @@ export default function GlobalThreatMap({ onCorridorSelect, onHubSelect }: Props
|
|
| 138 |
const svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
| 139 |
svgEl.setAttribute('class', styles.arcOverlay);
|
| 140 |
svgEl.style.position = 'absolute';
|
| 141 |
-
svgEl.style.
|
| 142 |
-
svgEl.style.left = '0';
|
| 143 |
-
svgEl.style.width = '100%';
|
| 144 |
-
svgEl.style.height = '100%';
|
| 145 |
svgEl.style.pointerEvents = 'none';
|
| 146 |
svgEl.style.zIndex = '400';
|
| 147 |
-
map.
|
| 148 |
svgOverlay.current = svgEl;
|
| 149 |
|
| 150 |
// Pre-create glow filter defs
|
|
@@ -200,8 +197,7 @@ export default function GlobalThreatMap({ onCorridorSelect, onHubSelect }: Props
|
|
| 200 |
const drawArcs = () => {
|
| 201 |
if (!svgOverlay.current || !mapInstance.current) return;
|
| 202 |
const svg = svgOverlay.current;
|
| 203 |
-
|
| 204 |
-
svg.setAttribute('viewBox', `0 0 ${mapSize.x} ${mapSize.y}`);
|
| 205 |
|
| 206 |
const defsEl = svg.querySelector('defs');
|
| 207 |
while (svg.lastChild && svg.lastChild !== defsEl) {
|
|
@@ -213,8 +209,8 @@ export default function GlobalThreatMap({ onCorridorSelect, onHubSelect }: Props
|
|
| 213 |
const tgt = getHubById(corridor.target);
|
| 214 |
if (!src || !tgt) return;
|
| 215 |
|
| 216 |
-
const p1 = mapInstance.current!.
|
| 217 |
-
const p2 = mapInstance.current!.
|
| 218 |
|
| 219 |
const midX = (p1.x + p2.x) / 2;
|
| 220 |
const midY = (p1.y + p2.y) / 2;
|
|
@@ -278,10 +274,10 @@ export default function GlobalThreatMap({ onCorridorSelect, onHubSelect }: Props
|
|
| 278 |
rafId.current = requestAnimationFrame(drawArcs);
|
| 279 |
};
|
| 280 |
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
map.on('moveend', scheduleRedraw);
|
| 284 |
map.on('zoomend', scheduleRedraw);
|
|
|
|
| 285 |
setTimeout(drawArcs, 200);
|
| 286 |
setMapReady(true);
|
| 287 |
};
|
|
|
|
| 138 |
const svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
| 139 |
svgEl.setAttribute('class', styles.arcOverlay);
|
| 140 |
svgEl.style.position = 'absolute';
|
| 141 |
+
svgEl.style.overflow = 'visible';
|
|
|
|
|
|
|
|
|
|
| 142 |
svgEl.style.pointerEvents = 'none';
|
| 143 |
svgEl.style.zIndex = '400';
|
| 144 |
+
map.getPanes().overlayPane.appendChild(svgEl);
|
| 145 |
svgOverlay.current = svgEl;
|
| 146 |
|
| 147 |
// Pre-create glow filter defs
|
|
|
|
| 197 |
const drawArcs = () => {
|
| 198 |
if (!svgOverlay.current || !mapInstance.current) return;
|
| 199 |
const svg = svgOverlay.current;
|
| 200 |
+
// Don't need viewBox because the svg is simply a container with overflow:visible in the overlayPane
|
|
|
|
| 201 |
|
| 202 |
const defsEl = svg.querySelector('defs');
|
| 203 |
while (svg.lastChild && svg.lastChild !== defsEl) {
|
|
|
|
| 209 |
const tgt = getHubById(corridor.target);
|
| 210 |
if (!src || !tgt) return;
|
| 211 |
|
| 212 |
+
const p1 = mapInstance.current!.latLngToLayerPoint([src.lat, src.lng]);
|
| 213 |
+
const p2 = mapInstance.current!.latLngToLayerPoint([tgt.lat, tgt.lng]);
|
| 214 |
|
| 215 |
const midX = (p1.x + p2.x) / 2;
|
| 216 |
const midY = (p1.y + p2.y) / 2;
|
|
|
|
| 274 |
rafId.current = requestAnimationFrame(drawArcs);
|
| 275 |
};
|
| 276 |
|
| 277 |
+
// Since the SVG is in the overlay pane, it will naturally pan and zoom!
|
| 278 |
+
// We only need to redraw when the scale changes significantly, or layout changes
|
|
|
|
| 279 |
map.on('zoomend', scheduleRedraw);
|
| 280 |
+
map.on('moveend', scheduleRedraw);
|
| 281 |
setTimeout(drawArcs, 200);
|
| 282 |
setMapReady(true);
|
| 283 |
};
|
frontend/components/ui/ReplayControls.tsx
CHANGED
|
@@ -29,38 +29,43 @@ export default function ReplayControls({
|
|
| 29 |
|
| 30 |
return (
|
| 31 |
<div className={styles.replayBar}>
|
| 32 |
-
<
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
| 41 |
|
| 42 |
-
<
|
| 43 |
-
|
| 44 |
-
className={styles.replayScrubber}
|
| 45 |
-
min={0}
|
| 46 |
-
max={Math.max(totalSteps - 1, 0)}
|
| 47 |
-
value={currentStep}
|
| 48 |
-
onChange={e => onSeek(parseInt(e.target.value))}
|
| 49 |
-
/>
|
| 50 |
-
|
| 51 |
-
<span className={styles.replayStep}>
|
| 52 |
-
{String(currentStep + 1).padStart(2, '0')}/{String(totalSteps).padStart(2, '0')}
|
| 53 |
-
</span>
|
| 54 |
-
|
| 55 |
-
{speeds.map(s => (
|
| 56 |
<button
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
>
|
| 61 |
-
{
|
| 62 |
</button>
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
</div>
|
| 65 |
);
|
| 66 |
}
|
|
|
|
| 29 |
|
| 30 |
return (
|
| 31 |
<div className={styles.replayBar}>
|
| 32 |
+
<div className={styles.replayGroup}>
|
| 33 |
+
{speeds.map(s => (
|
| 34 |
+
<button
|
| 35 |
+
key={s}
|
| 36 |
+
className={`${styles.replayBtn} ${speed === s ? styles.replayBtnActive : ''}`}
|
| 37 |
+
onClick={() => onSpeedChange(s)}
|
| 38 |
+
>
|
| 39 |
+
{s}x
|
| 40 |
+
</button>
|
| 41 |
+
))}
|
| 42 |
+
</div>
|
| 43 |
|
| 44 |
+
<div className={styles.replayGroupCenter}>
|
| 45 |
+
<button className={styles.replayBtn} onClick={onStepBack} title="Step Back">β</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
<button
|
| 47 |
+
className={`${styles.replayBtn} ${isPlaying ? styles.replayBtnActive : ''} ${styles.replayPlayBtn}`}
|
| 48 |
+
onClick={isPlaying ? onPause : onPlay}
|
| 49 |
+
title={isPlaying ? 'Pause' : 'Play'}
|
| 50 |
>
|
| 51 |
+
{isPlaying ? 'βΈ' : 'βΆ'}
|
| 52 |
</button>
|
| 53 |
+
<button className={styles.replayBtn} onClick={onStepForward} title="Step Forward">βΊ</button>
|
| 54 |
+
</div>
|
| 55 |
+
|
| 56 |
+
<div className={styles.replayGroupRight}>
|
| 57 |
+
<span className={styles.replayStep}>
|
| 58 |
+
{String(currentStep + 1).padStart(2, '0')}/{String(totalSteps).padStart(2, '0')}
|
| 59 |
+
</span>
|
| 60 |
+
<input
|
| 61 |
+
type="range"
|
| 62 |
+
className={styles.replayScrubber}
|
| 63 |
+
min={0}
|
| 64 |
+
max={Math.max(totalSteps - 1, 0)}
|
| 65 |
+
value={currentStep}
|
| 66 |
+
onChange={e => onSeek(parseInt(e.target.value))}
|
| 67 |
+
/>
|
| 68 |
+
</div>
|
| 69 |
</div>
|
| 70 |
);
|
| 71 |
}
|
frontend/components/ui/ui.module.css
CHANGED
|
@@ -12,17 +12,34 @@
|
|
| 12 |
flex-shrink: 0;
|
| 13 |
}
|
| 14 |
|
| 15 |
-
.statusGreen {
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
.statusPulse {
|
| 20 |
animation: statusPulseAnim 2s ease-in-out infinite;
|
| 21 |
}
|
| 22 |
|
| 23 |
@keyframes statusPulseAnim {
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
}
|
| 27 |
|
| 28 |
.statusLabel {
|
|
@@ -36,11 +53,37 @@
|
|
| 36 |
.replayBar {
|
| 37 |
display: flex;
|
| 38 |
align-items: center;
|
|
|
|
| 39 |
gap: 8px;
|
| 40 |
padding: 6px 12px;
|
| 41 |
background: var(--nx-surface);
|
| 42 |
border: 1px solid var(--nx-border);
|
| 43 |
border-radius: 2px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
}
|
| 45 |
|
| 46 |
.replayBtn {
|
|
@@ -68,8 +111,14 @@
|
|
| 68 |
background: var(--nx-orange-glow2);
|
| 69 |
}
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
.replayScrubber {
|
| 72 |
-
|
|
|
|
| 73 |
height: 3px;
|
| 74 |
appearance: none;
|
| 75 |
background: var(--nx-border);
|
|
@@ -92,5 +141,5 @@
|
|
| 92 |
color: var(--nx-text-dim);
|
| 93 |
font-variant-numeric: tabular-nums;
|
| 94 |
min-width: 48px;
|
| 95 |
-
text-align:
|
| 96 |
-
}
|
|
|
|
| 12 |
flex-shrink: 0;
|
| 13 |
}
|
| 14 |
|
| 15 |
+
.statusGreen {
|
| 16 |
+
background: var(--nx-green);
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
.statusAmber {
|
| 20 |
+
background: var(--nx-yellow);
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
.statusRed {
|
| 24 |
+
background: var(--nx-red);
|
| 25 |
+
}
|
| 26 |
|
| 27 |
.statusPulse {
|
| 28 |
animation: statusPulseAnim 2s ease-in-out infinite;
|
| 29 |
}
|
| 30 |
|
| 31 |
@keyframes statusPulseAnim {
|
| 32 |
+
|
| 33 |
+
0%,
|
| 34 |
+
100% {
|
| 35 |
+
opacity: 1;
|
| 36 |
+
box-shadow: 0 0 0 0 currentColor;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
50% {
|
| 40 |
+
opacity: 0.6;
|
| 41 |
+
box-shadow: 0 0 6px 2px currentColor;
|
| 42 |
+
}
|
| 43 |
}
|
| 44 |
|
| 45 |
.statusLabel {
|
|
|
|
| 53 |
.replayBar {
|
| 54 |
display: flex;
|
| 55 |
align-items: center;
|
| 56 |
+
justify-content: space-between;
|
| 57 |
gap: 8px;
|
| 58 |
padding: 6px 12px;
|
| 59 |
background: var(--nx-surface);
|
| 60 |
border: 1px solid var(--nx-border);
|
| 61 |
border-radius: 2px;
|
| 62 |
+
width: 100%;
|
| 63 |
+
max-width: 600px;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
.replayGroup,
|
| 67 |
+
.replayGroupCenter,
|
| 68 |
+
.replayGroupRight {
|
| 69 |
+
display: flex;
|
| 70 |
+
align-items: center;
|
| 71 |
+
gap: 8px;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
.replayGroup {
|
| 75 |
+
flex: 1;
|
| 76 |
+
/* Match the space taken by scrubber so center is geometrically true */
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
.replayGroupCenter {
|
| 80 |
+
gap: 12px;
|
| 81 |
+
justify-content: center;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
.replayGroupRight {
|
| 85 |
+
flex: 1;
|
| 86 |
+
justify-content: flex-end;
|
| 87 |
}
|
| 88 |
|
| 89 |
.replayBtn {
|
|
|
|
| 111 |
background: var(--nx-orange-glow2);
|
| 112 |
}
|
| 113 |
|
| 114 |
+
.replayPlayBtn {
|
| 115 |
+
font-size: 14px;
|
| 116 |
+
padding: 4px 12px;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
.replayScrubber {
|
| 120 |
+
width: 120px;
|
| 121 |
+
/* Don't flex 1 here, let it be fixed size so it looks neat */
|
| 122 |
height: 3px;
|
| 123 |
appearance: none;
|
| 124 |
background: var(--nx-border);
|
|
|
|
| 141 |
color: var(--nx-text-dim);
|
| 142 |
font-variant-numeric: tabular-nums;
|
| 143 |
min-width: 48px;
|
| 144 |
+
text-align: right;
|
| 145 |
+
}
|
frontend/lib/geoData.ts
CHANGED
|
@@ -6,92 +6,92 @@ import { MLHub, MLCorridor } from './types';
|
|
| 6 |
|
| 7 |
// ββ Major Money Laundering Hub Locations ββββββββββββββββββββββ
|
| 8 |
export const ML_HUBS: MLHub[] = [
|
| 9 |
-
{ id: 'panama',
|
| 10 |
-
{ id: 'cayman',
|
| 11 |
-
{ id: 'bvi',
|
| 12 |
-
{ id: 'london',
|
| 13 |
-
{ id: 'zurich',
|
| 14 |
-
{ id: 'cyprus',
|
| 15 |
-
{ id: 'dubai',
|
| 16 |
-
{ id: 'singapore',
|
| 17 |
-
{ id: 'hongkong',
|
| 18 |
-
{ id: 'moscow',
|
| 19 |
-
{ id: 'lagos',
|
| 20 |
-
{ id: 'mumbai',
|
| 21 |
-
{ id: 'seychelles', name: 'Seychelles',
|
| 22 |
-
{ id: 'malta',
|
| 23 |
-
{ id: 'kualalumpur',name: 'Kuala Lumpur',
|
| 24 |
-
{ id: 'newyork',
|
| 25 |
-
{ id: 'liechtenstein', name: 'Vaduz',
|
| 26 |
-
{ id: 'bahamas',
|
| 27 |
-
{ id: 'macau',
|
| 28 |
-
{ id: 'myanmar',
|
| 29 |
];
|
| 30 |
|
| 31 |
// ββ Pre-built ML Corridors (static threat intel) βββββββββββββ
|
| 32 |
export const ML_CORRIDORS: MLCorridor[] = [
|
| 33 |
-
{ id: 'c-01', source: 'panama',
|
| 34 |
-
{ id: 'c-02', source: 'bvi',
|
| 35 |
-
{ id: 'c-03', source: 'dubai',
|
| 36 |
-
{ id: 'c-04', source: 'hongkong',
|
| 37 |
-
{ id: 'c-05', source: 'moscow',
|
| 38 |
-
{ id: 'c-06', source: 'lagos',
|
| 39 |
-
{ id: 'c-07', source: 'zurich',
|
| 40 |
-
{ id: 'c-08', source: 'kualalumpur', target: 'singapore',
|
| 41 |
-
{ id: 'c-09', source: 'cayman',
|
| 42 |
-
{ id: 'c-10', source: 'dubai',
|
| 43 |
-
{ id: 'c-11', source: 'malta',
|
| 44 |
-
{ id: 'c-12', source: 'seychelles',
|
| 45 |
-
{ id: 'c-13', source: 'macau',
|
| 46 |
-
{ id: 'c-14', source: 'myanmar',
|
| 47 |
-
{ id: 'c-15', source: 'bahamas',
|
| 48 |
];
|
| 49 |
|
| 50 |
// ββ Jurisdiction β Coordinates Lookup ββββββββββββββββββββββββ
|
| 51 |
export const JURISDICTION_COORDS: Record<string, [number, number]> = {
|
| 52 |
-
'malaysia':
|
| 53 |
-
'kuala lumpur':
|
| 54 |
-
'seychelles':
|
| 55 |
-
'british virgin islands': [18.4207,
|
| 56 |
-
'bvi':
|
| 57 |
-
'singapore':
|
| 58 |
-
'hong kong':
|
| 59 |
-
'london':
|
| 60 |
-
'united kingdom':
|
| 61 |
-
'switzerland':
|
| 62 |
-
'zurich':
|
| 63 |
-
'panama':
|
| 64 |
-
'cayman islands':
|
| 65 |
-
'cyprus':
|
| 66 |
-
'dubai':
|
| 67 |
-
'uae':
|
| 68 |
-
'united arab emirates':
|
| 69 |
-
'united states':
|
| 70 |
-
'usa':
|
| 71 |
-
'new york':
|
| 72 |
-
'moscow':
|
| 73 |
-
'russia':
|
| 74 |
-
'lagos':
|
| 75 |
-
'nigeria':
|
| 76 |
-
'mumbai':
|
| 77 |
-
'india':
|
| 78 |
-
'malta':
|
| 79 |
-
'liechtenstein':
|
| 80 |
-
'bahamas':
|
| 81 |
-
'macau':
|
| 82 |
-
'myanmar':
|
| 83 |
-
'isle of man':
|
| 84 |
-
'jersey':
|
| 85 |
-
'belize':
|
| 86 |
-
'mauritius':
|
| 87 |
-
'geneva':
|
| 88 |
-
'frankfurt':
|
| 89 |
-
'germany':
|
| 90 |
-
'amsterdam':
|
| 91 |
-
'tokyo':
|
| 92 |
-
'japan':
|
| 93 |
-
'toronto':
|
| 94 |
-
'canada':
|
| 95 |
};
|
| 96 |
|
| 97 |
// ββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -109,7 +109,8 @@ export function getCorridorCoords(corridor: MLCorridor): { from: [number, number
|
|
| 109 |
};
|
| 110 |
}
|
| 111 |
|
| 112 |
-
export function geocodeJurisdiction(jurisdiction: string): [number, number] | null {
|
|
|
|
| 113 |
const key = jurisdiction.toLowerCase().trim();
|
| 114 |
return JURISDICTION_COORDS[key] || null;
|
| 115 |
}
|
|
@@ -117,10 +118,10 @@ export function geocodeJurisdiction(jurisdiction: string): [number, number] | nu
|
|
| 117 |
export function riskColor(level: string): string {
|
| 118 |
switch (level) {
|
| 119 |
case 'critical': return '#E11D48';
|
| 120 |
-
case 'high':
|
| 121 |
-
case 'medium':
|
| 122 |
-
case 'low':
|
| 123 |
-
default:
|
| 124 |
}
|
| 125 |
}
|
| 126 |
|
|
|
|
| 6 |
|
| 7 |
// ββ Major Money Laundering Hub Locations ββββββββββββββββββββββ
|
| 8 |
export const ML_HUBS: MLHub[] = [
|
| 9 |
+
{ id: 'panama', name: 'Panama City', lat: 8.9824, lng: -79.5199, riskLevel: 'critical', entityCount: 340, region: 'Americas' },
|
| 10 |
+
{ id: 'cayman', name: 'Cayman Islands', lat: 19.3133, lng: -81.2546, riskLevel: 'critical', entityCount: 520, region: 'Americas' },
|
| 11 |
+
{ id: 'bvi', name: 'British Virgin Islands', lat: 18.4207, lng: -64.6400, riskLevel: 'critical', entityCount: 680, region: 'Americas' },
|
| 12 |
+
{ id: 'london', name: 'London', lat: 51.5074, lng: -0.1278, riskLevel: 'high', entityCount: 890, region: 'Europe' },
|
| 13 |
+
{ id: 'zurich', name: 'ZΓΌrich', lat: 47.3769, lng: 8.5417, riskLevel: 'high', entityCount: 420, region: 'Europe' },
|
| 14 |
+
{ id: 'cyprus', name: 'Nicosia', lat: 35.1856, lng: 33.3823, riskLevel: 'high', entityCount: 310, region: 'Europe' },
|
| 15 |
+
{ id: 'dubai', name: 'Dubai', lat: 25.2048, lng: 55.2708, riskLevel: 'critical', entityCount: 760, region: 'MENA' },
|
| 16 |
+
{ id: 'singapore', name: 'Singapore', lat: 1.3521, lng: 103.8198, riskLevel: 'high', entityCount: 540, region: 'Asia' },
|
| 17 |
+
{ id: 'hongkong', name: 'Hong Kong', lat: 22.3193, lng: 114.1694, riskLevel: 'high', entityCount: 620, region: 'Asia' },
|
| 18 |
+
{ id: 'moscow', name: 'Moscow', lat: 55.7558, lng: 37.6173, riskLevel: 'critical', entityCount: 480, region: 'Europe' },
|
| 19 |
+
{ id: 'lagos', name: 'Lagos', lat: 6.5244, lng: 3.3792, riskLevel: 'high', entityCount: 290, region: 'Africa' },
|
| 20 |
+
{ id: 'mumbai', name: 'Mumbai', lat: 19.0760, lng: 72.8777, riskLevel: 'medium', entityCount: 350, region: 'Asia' },
|
| 21 |
+
{ id: 'seychelles', name: 'Seychelles', lat: -4.6796, lng: 55.4920, riskLevel: 'critical', entityCount: 190, region: 'Africa' },
|
| 22 |
+
{ id: 'malta', name: 'Malta', lat: 35.8989, lng: 14.5146, riskLevel: 'high', entityCount: 210, region: 'Europe' },
|
| 23 |
+
{ id: 'kualalumpur', name: 'Kuala Lumpur', lat: 3.1390, lng: 101.6869, riskLevel: 'medium', entityCount: 280, region: 'Asia' },
|
| 24 |
+
{ id: 'newyork', name: 'New York', lat: 40.7128, lng: -74.0060, riskLevel: 'medium', entityCount: 450, region: 'Americas' },
|
| 25 |
+
{ id: 'liechtenstein', name: 'Vaduz', lat: 47.1410, lng: 9.5215, riskLevel: 'high', entityCount: 130, region: 'Europe' },
|
| 26 |
+
{ id: 'bahamas', name: 'Nassau', lat: 25.0343, lng: -77.3963, riskLevel: 'high', entityCount: 240, region: 'Americas' },
|
| 27 |
+
{ id: 'macau', name: 'Macau', lat: 22.1987, lng: 113.5439, riskLevel: 'high', entityCount: 180, region: 'Asia' },
|
| 28 |
+
{ id: 'myanmar', name: 'Yangon', lat: 16.8661, lng: 96.1951, riskLevel: 'critical', entityCount: 110, region: 'Asia' },
|
| 29 |
];
|
| 30 |
|
| 31 |
// ββ Pre-built ML Corridors (static threat intel) βββββββββββββ
|
| 32 |
export const ML_CORRIDORS: MLCorridor[] = [
|
| 33 |
+
{ id: 'c-01', source: 'panama', target: 'cayman', volume: 2400000000, riskScore: 92, typology: 'layering', label: 'Shell Company Pipeline' },
|
| 34 |
+
{ id: 'c-02', source: 'bvi', target: 'london', volume: 1800000000, riskScore: 88, typology: 'layering', label: 'Offshore β City Corridor' },
|
| 35 |
+
{ id: 'c-03', source: 'dubai', target: 'mumbai', volume: 3200000000, riskScore: 85, typology: 'trade_based_ml', label: 'Trade Invoice Manipulation' },
|
| 36 |
+
{ id: 'c-04', source: 'hongkong', target: 'singapore', volume: 2100000000, riskScore: 78, typology: 'layering', label: 'Asia-Pacific Layer' },
|
| 37 |
+
{ id: 'c-05', source: 'moscow', target: 'cyprus', volume: 4100000000, riskScore: 95, typology: 'layering', label: 'Russian Capital Flight' },
|
| 38 |
+
{ id: 'c-06', source: 'lagos', target: 'london', volume: 1200000000, riskScore: 82, typology: 'structuring', label: 'West Africa β UK Pipeline' },
|
| 39 |
+
{ id: 'c-07', source: 'zurich', target: 'seychelles', volume: 890000000, riskScore: 90, typology: 'layering', label: 'Alpine β Island Route' },
|
| 40 |
+
{ id: 'c-08', source: 'kualalumpur', target: 'singapore', volume: 681000000, riskScore: 94, typology: 'layering', label: '1MDB Corridor', caseId: 'case-1mdb' },
|
| 41 |
+
{ id: 'c-09', source: 'cayman', target: 'newyork', volume: 1500000000, riskScore: 75, typology: 'layering', label: 'Caribbean Integration' },
|
| 42 |
+
{ id: 'c-10', source: 'dubai', target: 'london', volume: 2800000000, riskScore: 87, typology: 'trade_based_ml', label: 'Gold & Commodities' },
|
| 43 |
+
{ id: 'c-11', source: 'malta', target: 'liechtenstein', volume: 450000000, riskScore: 80, typology: 'layering', label: 'EU Regulatory Arbitrage' },
|
| 44 |
+
{ id: 'c-12', source: 'seychelles', target: 'bvi', volume: 720000000, riskScore: 91, typology: 'layering', label: 'Island Hop Chain' },
|
| 45 |
+
{ id: 'c-13', source: 'macau', target: 'hongkong', volume: 1100000000, riskScore: 83, typology: 'structuring', label: 'Casino β Banking' },
|
| 46 |
+
{ id: 'c-14', source: 'myanmar', target: 'singapore', volume: 340000000, riskScore: 96, typology: 'trade_based_ml', label: 'Narco-Trade Flow' },
|
| 47 |
+
{ id: 'c-15', source: 'bahamas', target: 'panama', volume: 560000000, riskScore: 79, typology: 'layering', label: 'Caribbean Circuit' },
|
| 48 |
];
|
| 49 |
|
| 50 |
// ββ Jurisdiction β Coordinates Lookup ββββββββββββββββββββββββ
|
| 51 |
export const JURISDICTION_COORDS: Record<string, [number, number]> = {
|
| 52 |
+
'malaysia': [3.1390, 101.6869],
|
| 53 |
+
'kuala lumpur': [3.1390, 101.6869],
|
| 54 |
+
'seychelles': [-4.6796, 55.4920],
|
| 55 |
+
'british virgin islands': [18.4207, -64.6400],
|
| 56 |
+
'bvi': [18.4207, -64.6400],
|
| 57 |
+
'singapore': [1.3521, 103.8198],
|
| 58 |
+
'hong kong': [22.3193, 114.1694],
|
| 59 |
+
'london': [51.5074, -0.1278],
|
| 60 |
+
'united kingdom': [51.5074, -0.1278],
|
| 61 |
+
'switzerland': [47.3769, 8.5417],
|
| 62 |
+
'zurich': [47.3769, 8.5417],
|
| 63 |
+
'panama': [8.9824, -79.5199],
|
| 64 |
+
'cayman islands': [19.3133, -81.2546],
|
| 65 |
+
'cyprus': [35.1856, 33.3823],
|
| 66 |
+
'dubai': [25.2048, 55.2708],
|
| 67 |
+
'uae': [25.2048, 55.2708],
|
| 68 |
+
'united arab emirates': [25.2048, 55.2708],
|
| 69 |
+
'united states': [40.7128, -74.0060],
|
| 70 |
+
'usa': [40.7128, -74.0060],
|
| 71 |
+
'new york': [40.7128, -74.0060],
|
| 72 |
+
'moscow': [55.7558, 37.6173],
|
| 73 |
+
'russia': [55.7558, 37.6173],
|
| 74 |
+
'lagos': [6.5244, 3.3792],
|
| 75 |
+
'nigeria': [6.5244, 3.3792],
|
| 76 |
+
'mumbai': [19.0760, 72.8777],
|
| 77 |
+
'india': [19.0760, 72.8777],
|
| 78 |
+
'malta': [35.8989, 14.5146],
|
| 79 |
+
'liechtenstein': [47.1410, 9.5215],
|
| 80 |
+
'bahamas': [25.0343, -77.3963],
|
| 81 |
+
'macau': [22.1987, 113.5439],
|
| 82 |
+
'myanmar': [16.8661, 96.1951],
|
| 83 |
+
'isle of man': [54.2361, -4.5481],
|
| 84 |
+
'jersey': [49.2144, -2.1312],
|
| 85 |
+
'belize': [17.1899, -88.4976],
|
| 86 |
+
'mauritius': [-20.3484, 57.5522],
|
| 87 |
+
'geneva': [46.2044, 6.1432],
|
| 88 |
+
'frankfurt': [50.1109, 8.6821],
|
| 89 |
+
'germany': [50.1109, 8.6821],
|
| 90 |
+
'amsterdam': [52.3676, 4.9041],
|
| 91 |
+
'tokyo': [35.6762, 139.6503],
|
| 92 |
+
'japan': [35.6762, 139.6503],
|
| 93 |
+
'toronto': [43.6532, -79.3832],
|
| 94 |
+
'canada': [43.6532, -79.3832],
|
| 95 |
};
|
| 96 |
|
| 97 |
// ββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 109 |
};
|
| 110 |
}
|
| 111 |
|
| 112 |
+
export function geocodeJurisdiction(jurisdiction?: string | null): [number, number] | null {
|
| 113 |
+
if (!jurisdiction) return null;
|
| 114 |
const key = jurisdiction.toLowerCase().trim();
|
| 115 |
return JURISDICTION_COORDS[key] || null;
|
| 116 |
}
|
|
|
|
| 118 |
export function riskColor(level: string): string {
|
| 119 |
switch (level) {
|
| 120 |
case 'critical': return '#E11D48';
|
| 121 |
+
case 'high': return '#EA580C';
|
| 122 |
+
case 'medium': return '#EAB308';
|
| 123 |
+
case 'low': return '#22C55E';
|
| 124 |
+
default: return '#404040';
|
| 125 |
}
|
| 126 |
}
|
| 127 |
|