1- import { useRef , useEffect , useState , useCallback } from 'react'
1+ import { useRef , useEffect , useState , useCallback , useMemo } from 'react'
22// eslint-disable-next-line @typescript-eslint/ban-ts-comment
33// @ts -ignore — react-globe.gl ships CJS types that don't align with Vite ESM
44import GlobeGL from 'react-globe.gl'
@@ -8,44 +8,104 @@ import type { Topology } from 'topojson-specification'
88import { useThreatStore } from '../../stores/threatStore'
99import type { ThreatEvent } from '../../types/schema'
1010
11+ // ── Colour palettes ───────────────────────────────────────────────────────────
12+
1113const MALWARE_COLORS : Record < string , string > = {
12- Emotet : '#ff4444' ,
13- QakBot : '#ff8800' ,
14- IcedID : '#ffaa00' ,
15- TrickBot : '#ffe600' ,
16- Dridex : '#bb44ff' ,
17- BazarLoader : '#00aaff' ,
18- 'Cobalt Strike' : '#ff00cc' ,
19- AsyncRAT : '#00ffcc' ,
20- NanoCore : '#44ff66' ,
21- AgentTesla : '#ff6633' ,
22- FormBook : '#ff44bb' ,
23- Remcos : '#4488ff' ,
14+ Emotet : '#ff4444' , QakBot : '#ff8800' , IcedID : '#ffaa00' ,
15+ TrickBot : '#ffe600' , Dridex : '#bb44ff' , BazarLoader : '#00aaff' ,
16+ 'Cobalt Strike' : '#ff00cc' , AsyncRAT : '#00ffcc' , NanoCore : '#44ff66' ,
17+ AgentTesla : '#ff6633' , FormBook : '#ff44bb' , Remcos : '#4488ff' ,
18+ }
19+ const SEV_COLOR : Record < number , string > = {
20+ 4 : '#ff2244' , 3 : '#ff8800' , 2 : '#ffdd00' , 1 : '#4499ff' ,
2421}
2522const DEFAULT_COLOR = '#00ff41'
2623
2724function getColor ( family ?: string ) {
28- if ( ! family ) return DEFAULT_COLOR
29- return MALWARE_COLORS [ family ] ?? DEFAULT_COLOR
25+ return family ? ( MALWARE_COLORS [ family ] ?? DEFAULT_COLOR ) : DEFAULT_COLOR
26+ }
27+ function getSevColor ( sev : number ) {
28+ return SEV_COLOR [ sev ] ?? DEFAULT_COLOR
29+ }
30+
31+ // ── ISO 3166-1 numeric → alpha-2 (covers all 177 topojson geometries) ────────
32+
33+ const ISO_NUM_TO_A2 : Record < number , string > = {
34+ 4 :'AF' , 8 :'AL' , 10 :'AQ' , 12 :'DZ' , 24 :'AO' , 31 :'AZ' , 32 :'AR' , 36 :'AU' ,
35+ 40 :'AT' , 44 :'BS' , 50 :'BD' , 51 :'AM' , 56 :'BE' , 64 :'BT' , 68 :'BO' , 70 :'BA' ,
36+ 72 :'BW' , 76 :'BR' , 84 :'BZ' , 90 :'SB' , 96 :'BN' , 100 :'BG' , 104 :'MM' , 108 :'BI' ,
37+ 112 :'BY' , 116 :'KH' , 120 :'CM' , 124 :'CA' , 132 :'CV' , 140 :'CF' , 144 :'LK' ,
38+ 148 :'TD' , 152 :'CL' , 156 :'CN' , 158 :'TW' , 170 :'CO' , 178 :'CG' , 180 :'CD' ,
39+ 188 :'CR' , 191 :'HR' , 192 :'CU' , 196 :'CY' , 203 :'CZ' , 204 :'BJ' , 208 :'DK' ,
40+ 214 :'DO' , 218 :'EC' , 222 :'SV' , 226 :'GQ' , 231 :'ET' , 232 :'ER' , 233 :'EE' ,
41+ 238 :'FK' , 242 :'FJ' , 246 :'FI' , 250 :'FR' , 260 :'TF' , 262 :'DJ' , 266 :'GA' ,
42+ 268 :'GE' , 270 :'GM' , 275 :'PS' , 276 :'DE' , 288 :'GH' , 300 :'GR' , 304 :'GL' ,
43+ 320 :'GT' , 324 :'GN' , 328 :'GY' , 332 :'HT' , 340 :'HN' , 344 :'HK' , 348 :'HU' ,
44+ 352 :'IS' , 356 :'IN' , 360 :'ID' , 364 :'IR' , 368 :'IQ' , 372 :'IE' , 376 :'IL' ,
45+ 380 :'IT' , 384 :'CI' , 388 :'JM' , 392 :'JP' , 398 :'KZ' , 400 :'JO' , 404 :'KE' ,
46+ 408 :'KP' , 410 :'KR' , 414 :'KW' , 417 :'KG' , 418 :'LA' , 422 :'LB' , 426 :'LS' ,
47+ 428 :'LV' , 430 :'LR' , 434 :'LY' , 440 :'LT' , 442 :'LU' , 450 :'MG' , 454 :'MW' ,
48+ 458 :'MY' , 466 :'ML' , 478 :'MR' , 484 :'MX' , 496 :'MN' , 498 :'MD' , 499 :'ME' ,
49+ 504 :'MA' , 508 :'MZ' , 512 :'OM' , 516 :'NA' , 524 :'NP' , 528 :'NL' , 540 :'NC' ,
50+ 548 :'VU' , 554 :'NZ' , 558 :'NI' , 562 :'NE' , 566 :'NG' , 578 :'NO' , 586 :'PK' ,
51+ 591 :'PA' , 598 :'PG' , 600 :'PY' , 604 :'PE' , 608 :'PH' , 616 :'PL' , 620 :'PT' ,
52+ 624 :'GW' , 626 :'TL' , 630 :'PR' , 634 :'QA' , 642 :'RO' , 643 :'RU' , 646 :'RW' ,
53+ 682 :'SA' , 686 :'SN' , 688 :'RS' , 694 :'SL' , 703 :'SK' , 704 :'VN' , 705 :'SI' ,
54+ 706 :'SO' , 710 :'ZA' , 716 :'ZW' , 724 :'ES' , 728 :'SS' , 729 :'SD' , 732 :'EH' ,
55+ 740 :'SR' , 748 :'SZ' , 752 :'SE' , 756 :'CH' , 760 :'SY' , 762 :'TJ' , 764 :'TH' ,
56+ 768 :'TG' , 776 :'TO' , 780 :'TT' , 784 :'AE' , 788 :'TN' , 792 :'TR' , 795 :'TM' ,
57+ 800 :'UG' , 804 :'UA' , 807 :'MK' , 818 :'EG' , 826 :'GB' , 834 :'TZ' , 840 :'US' ,
58+ 854 :'BF' , 858 :'UY' , 860 :'UZ' , 862 :'VE' , 882 :'WS' , 887 :'YE' , 894 :'ZM' ,
59+ }
60+
61+ // ── Major internet infrastructure hubs (arc destinations) ────────────────────
62+
63+ const HUBS = [
64+ { lat : 40.71 , lng : - 74.01 } , // New York
65+ { lat : 51.51 , lng : - 0.13 } , // London
66+ { lat : 50.11 , lng : 8.68 } , // Frankfurt
67+ { lat : 35.68 , lng : 139.69 } , // Tokyo
68+ { lat : 1.35 , lng : 103.82 } , // Singapore
69+ { lat :- 23.55 , lng : - 46.63 } , // São Paulo
70+ ]
71+
72+ function pickHub ( lat : number , lng : number , ip : string ) {
73+ // Sort by geographic distance descending, pick from top-3 based on IP hash
74+ const sorted = [ ...HUBS ] . sort (
75+ ( a , b ) =>
76+ ( lat - b . lat ) ** 2 + ( lng - b . lng ) ** 2 -
77+ ( ( lat - a . lat ) ** 2 + ( lng - a . lng ) ** 2 )
78+ )
79+ const hash = ip . split ( '.' ) . reduce ( ( s , o ) => s + parseInt ( o || '0' ) , 0 )
80+ return sorted [ hash % 3 ]
3081}
3182
83+ // ── Types ─────────────────────────────────────────────────────────────────────
84+
3285interface GlobePoint {
33- lat : number
34- lng : number
35- size : number
36- color : string
86+ lat : number ; lng : number ; size : number ; color : string ; event : ThreatEvent
87+ }
88+ interface ArcPoint {
89+ startLat : number ; startLng : number
90+ endLat : number ; endLng : number
91+ color : [ string , string ]
92+ dashTime : number
3793 event : ThreatEvent
3894}
39-
4095// eslint-disable-next-line @typescript-eslint/no-explicit-any
4196type GeoFeature = any
4297
98+ type ViewMode = 'events' | 'arcs' | 'heat'
99+
100+ // ── Component ─────────────────────────────────────────────────────────────────
101+
43102export function ThreatGlobe ( ) {
44103 // eslint-disable-next-line @typescript-eslint/no-explicit-any
45104 const globeRef = useRef < any > ( null )
46105 const containerRef = useRef < HTMLDivElement > ( null )
47106 const [ dims , setDims ] = useState ( { width : 0 , height : 0 } )
48107 const [ countries , setCountries ] = useState < GeoFeature [ ] > ( [ ] )
108+ const [ viewMode , setViewMode ] = useState < ViewMode > ( 'events' )
49109 const { events, setSelected } = useThreatStore ( )
50110
51111 // Load vector country polygons
@@ -57,7 +117,7 @@ export function ThreatGlobe() {
57117 const fc = feature ( topo , ( topo . objects as any ) . countries ) as any
58118 setCountries ( fc . features )
59119 } )
60- . catch ( ( ) => { /* silently fall back to no polygons */ } )
120+ . catch ( ( ) => { } )
61121 } , [ ] )
62122
63123 // Track container size
@@ -72,67 +132,47 @@ export function ThreatGlobe() {
72132 return ( ) => ro . disconnect ( )
73133 } , [ ] )
74134
75- // Configure renderer, controls, and procedural star field after mount
135+ // Configure renderer, controls, and procedural star field
76136 useEffect ( ( ) => {
77137 const g = globeRef . current
78138 if ( ! g || dims . width === 0 ) return
79139
80- // Fix DPR for retina/HiDPI displays
81140 const renderer = g . renderer ( )
82141 renderer . setPixelRatio ( window . devicePixelRatio )
83142
84- // Max anisotropy on all textures for crisp quality at oblique angles
85143 const maxAniso = renderer . capabilities . getMaxAnisotropy ( )
86144 const scene = g . scene ( )
87145 scene . traverse ( ( obj : any ) => {
88146 if ( obj . isMesh && obj . material ) {
89147 const mats = Array . isArray ( obj . material ) ? obj . material : [ obj . material ]
90148 mats . forEach ( ( mat : any ) => {
91149 [ 'map' , 'bumpMap' , 'specularMap' , 'normalMap' ] . forEach ( ( key ) => {
92- if ( mat [ key ] ) {
93- mat [ key ] . anisotropy = maxAniso
94- mat [ key ] . needsUpdate = true
95- }
150+ if ( mat [ key ] ) { mat [ key ] . anisotropy = maxAniso ; mat [ key ] . needsUpdate = true }
96151 } )
97152 } )
98153 }
99154 } )
100155
101- // Procedural star field — point-based so it stays perfectly crisp at any zoom
156+ // Procedural star field — crisp at any zoom
102157 if ( ! scene . getObjectByName ( 'starfield' ) ) {
103- const COUNT = 8000
104- const R = 900
158+ const COUNT = 8000 , R = 900
105159 const positions = new Float32Array ( COUNT * 3 )
106160 const colors = new Float32Array ( COUNT * 3 )
107- // Tint palette: mostly white, occasional blue-white or warm-white
108- const tints = [
109- [ 1.0 , 1.0 , 1.0 ] ,
110- [ 0.85 , 0.92 , 1.0 ] , // cool blue-white
111- [ 1.0 , 0.95 , 0.85 ] , // warm white
112- [ 0.75 , 0.85 , 1.0 ] , // blue giant
113- ]
161+ const tints = [ [ 1 , 1 , 1 ] , [ 0.85 , 0.92 , 1 ] , [ 1 , 0.95 , 0.85 ] , [ 0.75 , 0.85 , 1 ] ]
114162 for ( let i = 0 ; i < COUNT ; i ++ ) {
115163 const theta = Math . random ( ) * Math . PI * 2
116164 const phi = Math . acos ( 2 * Math . random ( ) - 1 )
117- positions [ i * 3 ] = R * Math . sin ( phi ) * Math . cos ( theta )
118- positions [ i * 3 + 1 ] = R * Math . sin ( phi ) * Math . sin ( theta )
119- positions [ i * 3 + 2 ] = R * Math . cos ( phi )
120- const [ r , gv , b ] = tints [ Math . floor ( Math . random ( ) * tints . length ) ]
121- const brightness = 0.4 + Math . random ( ) * 0.6
122- colors [ i * 3 ] = r * brightness
123- colors [ i * 3 + 1 ] = gv * brightness
124- colors [ i * 3 + 2 ] = b * brightness
165+ positions [ i * 3 ] = R * Math . sin ( phi ) * Math . cos ( theta )
166+ positions [ i * 3 + 1 ] = R * Math . sin ( phi ) * Math . sin ( theta )
167+ positions [ i * 3 + 2 ] = R * Math . cos ( phi )
168+ const [ r , gv , b ] = tints [ Math . floor ( Math . random ( ) * tints . length ) ]
169+ const br = 0.4 + Math . random ( ) * 0.6
170+ colors [ i * 3 ] = r * br ; colors [ i * 3 + 1 ] = gv * br ; colors [ i * 3 + 2 ] = b * br
125171 }
126172 const geo = new THREE . BufferGeometry ( )
127173 geo . setAttribute ( 'position' , new THREE . BufferAttribute ( positions , 3 ) )
128174 geo . setAttribute ( 'color' , new THREE . BufferAttribute ( colors , 3 ) )
129- const mat = new THREE . PointsMaterial ( {
130- size : 0.6 ,
131- sizeAttenuation : false , // consistent pixel size regardless of zoom
132- vertexColors : true ,
133- transparent : true ,
134- opacity : 0.9 ,
135- } )
175+ const mat = new THREE . PointsMaterial ( { size :0.6 , sizeAttenuation :false , vertexColors :true , transparent :true , opacity :0.9 } )
136176 const stars = new THREE . Points ( geo , mat )
137177 stars . name = 'starfield'
138178 scene . add ( stars )
@@ -147,44 +187,104 @@ export function ThreatGlobe() {
147187 g . pointOfView ( { lat : 30 , lng : 15 , altitude : 2.2 } , 0 )
148188 } , [ dims . width ] )
149189
150- const points : GlobePoint [ ] = events . map ( ( e ) => ( {
151- lat : e . source . lat ,
152- lng : e . source . lon ,
153- size : e . severity * 0.18 + 0.12 ,
154- color : getColor ( e . malware_family ) ,
155- event : e ,
156- } ) )
190+ // ── Derived data ────────────────────────────────────────────────────────────
191+
192+ const points : GlobePoint [ ] = useMemo (
193+ ( ) => events . map ( ( e ) => ( {
194+ lat : e . source . lat , lng : e . source . lon ,
195+ size : e . severity * 0.18 + 0.12 ,
196+ color : getColor ( e . malware_family ) ,
197+ event : e ,
198+ } ) ) ,
199+ [ events ]
200+ )
201+
202+ // Top 150 events by severity → arcs toward internet hubs
203+ const arcs : ArcPoint [ ] = useMemo ( ( ) => {
204+ return [ ...events ]
205+ . filter ( ( e ) => e . source . lat !== 0 || e . source . lon !== 0 )
206+ . sort ( ( a , b ) => b . severity - a . severity )
207+ . slice ( 0 , 150 )
208+ . map ( ( e ) => {
209+ const hub = pickHub ( e . source . lat , e . source . lon , e . source . ip )
210+ const c = getSevColor ( e . severity )
211+ return {
212+ startLat : e . source . lat , startLng : e . source . lon ,
213+ endLat : hub . lat , endLng : hub . lng ,
214+ color : [ c , '#ffffff' ] as [ string , string ] ,
215+ dashTime : e . severity >= 3 ? 1200 : 2400 ,
216+ event : e ,
217+ }
218+ } )
219+ } , [ events ] )
220+
221+ // Events per country for heatmap
222+ const countryCounts = useMemo ( ( ) => {
223+ const m = new Map < string , number > ( )
224+ events . forEach ( ( e ) => {
225+ const cc = e . source . country_code
226+ m . set ( cc , ( m . get ( cc ) ?? 0 ) + 1 )
227+ } )
228+ return m
229+ } , [ events ] )
230+
231+ const maxCount = useMemo (
232+ ( ) => Math . max ( ...Array . from ( countryCounts . values ( ) ) , 1 ) ,
233+ [ countryCounts ]
234+ )
235+
236+ // ── Globe callbacks ─────────────────────────────────────────────────────────
237+
238+ const polygonCapColor = useCallback (
239+ ( feat : GeoFeature ) => {
240+ const alpha2 = ISO_NUM_TO_A2 [ feat . id as number ]
241+ const count = alpha2 ? ( countryCounts . get ( alpha2 ) ?? 0 ) : 0
242+ if ( viewMode !== 'heat' || count === 0 ) return 'rgba(14,30,50,0.55)'
243+ const t = Math . min ( count / maxCount , 1 )
244+ const r = Math . round ( 20 + t * 210 )
245+ const gv = Math . round ( 40 - t * 15 )
246+ const b = Math . round ( 60 - t * 35 )
247+ return `rgba(${ r } ,${ gv } ,${ b } ,${ 0.45 + t * 0.5 } )`
248+ } ,
249+ [ viewMode , countryCounts , maxCount ]
250+ )
251+
252+ const polygonAltitude = useCallback (
253+ ( feat : GeoFeature ) => {
254+ if ( viewMode !== 'heat' ) return 0.003
255+ const alpha2 = ISO_NUM_TO_A2 [ feat . id as number ]
256+ const count = alpha2 ? ( countryCounts . get ( alpha2 ) ?? 0 ) : 0
257+ if ( count === 0 ) return 0.001
258+ return 0.003 + ( Math . min ( count / maxCount , 1 ) ) * 0.06
259+ } ,
260+ [ viewMode , countryCounts , maxCount ]
261+ )
157262
158263 const pointLabel = useCallback ( ( d : object ) => {
159264 const p = d as GlobePoint
160265 const e = p . event
161266 const ts = new Date ( e . ts ) . toISOString ( ) . replace ( 'T' , ' ' ) . slice ( 0 , 16 )
162267 return `
163- <div style="
164- background:rgba(7,11,20,0.92);
165- border:1px solid rgba(56,189,248,0.3);
166- padding:8px 12px;border-radius:4px;
167- font-family:'JetBrains Mono',monospace;font-size:11px;
168- color:#e2e8f0;min-width:160px;
169- ">
268+ <div style="background:rgba(7,11,20,0.92);border:1px solid rgba(56,189,248,0.3);
269+ padding:8px 12px;border-radius:4px;font-family:'JetBrains Mono',monospace;
270+ font-size:11px;color:#e2e8f0;min-width:160px;">
170271 <div style="color:${ p . color } ;font-weight:700;margin-bottom:4px">
171- ${ e . malware_family ?? e . type . toUpperCase ( ) }
172- </div>
272+ ${ e . malware_family ?? e . type . toUpperCase ( ) } </div>
173273 <div style="color:#94a3b8">${ e . source . ip } </div>
174274 <div style="color:#64748b">${ e . source . country_name } </div>
175275 ${ e . source . as_org ? `<div style="color:#475569;font-size:10px">${ e . source . as_org } </div>` : '' }
176276 <div style="color:#334155;font-size:10px;margin-top:4px">
177- ${ e . feed . toUpperCase ( ) } · TLP:${ e . tlp } · ${ ts } UTC
178- </div>
179- </div>
180- `
277+ ${ e . feed . toUpperCase ( ) } · TLP:${ e . tlp } · ${ ts } UTC</div>
278+ </div>`
181279 } , [ ] )
182280
183281 const handleClick = useCallback (
184282 ( d : object ) => setSelected ( ( d as GlobePoint ) . event ) ,
185283 [ setSelected ]
186284 )
187285
286+ // ── Render ──────────────────────────────────────────────────────────────────
287+
188288 return (
189289 < div ref = { containerRef } className = "absolute inset-0" >
190290 { dims . width > 0 && (
@@ -198,14 +298,12 @@ export function ThreatGlobe() {
198298 atmosphereColor = "rgba(56,189,248,0.35)"
199299 atmosphereAltitude = { 0.13 }
200300
201- // Vector country polygons — crisp at any zoom level
202301 polygonsData = { countries }
203- polygonCapColor = { ( ) => 'rgba(14, 30, 50, 0.55)' }
302+ polygonCapColor = { polygonCapColor }
204303 polygonSideColor = { ( ) => 'rgba(0,0,0,0)' }
205304 polygonStrokeColor = { ( ) => 'rgba(56,189,248,0.25)' }
206- polygonAltitude = { 0.003 }
305+ polygonAltitude = { polygonAltitude }
207306
208- // Threat points
209307 pointsData = { points }
210308 pointLat = "lat"
211309 pointLng = "lng"
@@ -215,8 +313,37 @@ export function ThreatGlobe() {
215313 pointLabel = { pointLabel }
216314 pointsTransitionDuration = { 800 }
217315 onPointClick = { handleClick }
316+
317+ arcsData = { viewMode === 'arcs' ? arcs : [ ] }
318+ arcStartLat = "startLat"
319+ arcStartLng = "startLng"
320+ arcEndLat = "endLat"
321+ arcEndLng = "endLng"
322+ arcColor = "color"
323+ arcStroke = { 0.4 }
324+ arcDashLength = { 0.12 }
325+ arcDashGap = { 0.06 }
326+ arcDashAnimateTime = "dashTime"
327+ arcAltitudeAutoScale = { 0.4 }
218328 />
219329 ) }
330+
331+ { /* Layer toggle — bottom-centre of the globe */ }
332+ < div className = "absolute bottom-12 left-1/2 -translate-x-1/2 z-10 flex gap-1" >
333+ { ( [ 'events' , 'arcs' , 'heat' ] as const ) . map ( ( m ) => (
334+ < button
335+ key = { m }
336+ onClick = { ( ) => setViewMode ( m ) }
337+ className = { `px-3 py-1 text-[10px] font-mono tracking-widest border rounded-sm transition-all ${
338+ viewMode === m
339+ ? 'bg-sky-500/20 border-sky-500/60 text-sky-400'
340+ : 'bg-black/50 border-white/10 text-slate-600 hover:text-slate-300 hover:border-white/25'
341+ } `}
342+ >
343+ { m === 'events' ? 'EVENTS' : m === 'arcs' ? 'ARCS' : 'HEAT' }
344+ </ button >
345+ ) ) }
346+ </ div >
220347 </ div >
221348 )
222349}
0 commit comments