Skip to content

Commit 581370a

Browse files
feat: Phase 3 — attack arcs, country heatmap, layer toggle
ARCS mode: top-150 events by severity animate as dashed arcs toward the nearest cross-continental internet hub (NY/London/Frankfurt/Tokyo/Singapore/ São Paulo). Arc colour encodes severity (red→yellow→blue); speed doubles for high-severity events. HEAT mode: country polygons coloured red-intensity by attack count and extruded up to 0.06 altitude units — visually shows which nations are the heaviest attack sources. Layer toggle (EVENTS / ARCS / HEAT) sits bottom-centre of the globe. Full ISO 3166-1 numeric→alpha-2 lookup table added for heatmap matching. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent fa3bedf commit 581370a

1 file changed

Lines changed: 204 additions & 77 deletions

File tree

src/components/Globe/Globe.tsx

Lines changed: 204 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
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
44
import GlobeGL from 'react-globe.gl'
@@ -8,44 +8,104 @@ import type { Topology } from 'topojson-specification'
88
import { useThreatStore } from '../../stores/threatStore'
99
import type { ThreatEvent } from '../../types/schema'
1010

11+
// ── Colour palettes ───────────────────────────────────────────────────────────
12+
1113
const 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
}
2522
const DEFAULT_COLOR = '#00ff41'
2623

2724
function 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+
3285
interface 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
4196
type GeoFeature = any
4297

98+
type ViewMode = 'events' | 'arcs' | 'heat'
99+
100+
// ── Component ─────────────────────────────────────────────────────────────────
101+
43102
export 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

Comments
 (0)