react-threat-map 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DECISIONS.md +275 -0
- package/LICENSE +21 -0
- package/README.md +645 -0
- package/dist/chunk-43FECBAY.js +202 -0
- package/dist/chunk-PM2OVFS5.js +9 -0
- package/dist/chunk-S6KRDFNO.cjs +13 -0
- package/dist/chunk-Y7WYSA4G.cjs +208 -0
- package/dist/countries-F7ATTG3H.cjs +20 -0
- package/dist/countries-NSR2XH7C.js +11 -0
- package/dist/geo.cjs +69 -0
- package/dist/geo.d.cts +56 -0
- package/dist/geo.d.ts +56 -0
- package/dist/geo.js +47 -0
- package/dist/index.cjs +1351 -0
- package/dist/index.d.cts +301 -0
- package/dist/index.d.ts +301 -0
- package/dist/index.js +1321 -0
- package/dist/regions-neHSPgtu.d.cts +765 -0
- package/dist/regions-neHSPgtu.d.ts +765 -0
- package/dist/small-countries-6O4AI3HD.js +8 -0
- package/dist/small-countries-D4QUXHH3.cjs +14 -0
- package/dist/states-DZ3SOMAC.js +11 -0
- package/dist/states-H54VC3H5.cjs +20 -0
- package/package.json +91 -0
package/README.md
ADDED
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
# react-threat-map
|
|
2
|
+
|
|
3
|
+
[](https://github.com/BogdanTaranenko/react-threat-map/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/react-threat-map)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
|
|
7
|
+
Animated cyberattack threats on a static world map, for React. Aggregates attacks by
|
|
8
|
+
origin region — with **US states as first-class origins** — so a busy feed reads as a
|
|
9
|
+
map instead of a hairball.
|
|
10
|
+
|
|
11
|
+
```tsx
|
|
12
|
+
import { ThreatMap } from 'react-threat-map';
|
|
13
|
+
|
|
14
|
+
<ThreatMap attacks={[{ from: 'CN', to: 'US-CA', severity: 'high' }]} />;
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
- **Fast.** 500+ concurrent animated threats at 120fps in the bundled demo. Draw calls are
|
|
18
|
+
bounded by the number of distinct *styles*, so past a few dozen threats they stop growing
|
|
19
|
+
entirely — 500 threats and 2000 threats cost identical draw calls.
|
|
20
|
+
- **Aggregates intelligently.** Many attacks from California collapse into one heavier
|
|
21
|
+
line; California and Texas stay separate. Fully configurable, or turn it off.
|
|
22
|
+
- **No API keys, no tiles.** Boundaries are bundled Natural Earth data, lazy-loaded.
|
|
23
|
+
- **22.2 kB gzipped** main bundle. Geometry is a separate chunk you only pay for on mount.
|
|
24
|
+
- **Strictly typed**, with TSDoc on every public export.
|
|
25
|
+
- **Unopinionated.** No CSS, no state manager, no styling library. Two peer-free runtime
|
|
26
|
+
deps.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Contents
|
|
31
|
+
|
|
32
|
+
- [Install](#install)
|
|
33
|
+
- [Quick start](#quick-start)
|
|
34
|
+
- [Aggregation](#aggregation)
|
|
35
|
+
- [Customization](#customization)
|
|
36
|
+
- [API reference](#api-reference)
|
|
37
|
+
- [Performance](#performance)
|
|
38
|
+
- [Examples](#examples)
|
|
39
|
+
- [Design decisions](#design-decisions)
|
|
40
|
+
- [License](#license)
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
npm install react-threat-map
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
React 18+ is a peer dependency:
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{ "peerDependencies": { "react": ">=18.0.0" } }
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Quick start
|
|
59
|
+
|
|
60
|
+
The only required prop is `attacks`. The map sizes itself to its container, loads its own
|
|
61
|
+
geography, aggregates by origin region, and animates:
|
|
62
|
+
|
|
63
|
+
```tsx
|
|
64
|
+
import { ThreatMap, type Attack } from 'react-threat-map';
|
|
65
|
+
|
|
66
|
+
const attacks: Attack[] = [
|
|
67
|
+
{ id: '1', from: 'CN', to: 'US-CA', severity: 'high' },
|
|
68
|
+
{ id: '2', from: 'RU', to: 'US-NY', severity: 'critical' },
|
|
69
|
+
{ id: '3', from: 'BR', to: 'FR', severity: 'medium' },
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
export function Dashboard() {
|
|
73
|
+
return (
|
|
74
|
+
<div style={{ width: 900 }}>
|
|
75
|
+
<ThreatMap attacks={attacks} />
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Describing where an attack came from
|
|
82
|
+
|
|
83
|
+
`from` and `to` each accept three interchangeable shapes, so you can pass whatever your
|
|
84
|
+
data already has:
|
|
85
|
+
|
|
86
|
+
```tsx
|
|
87
|
+
// 1. A region code — cheapest. No geometry needed to place it.
|
|
88
|
+
{ from: 'FR', to: 'US-CA' }
|
|
89
|
+
|
|
90
|
+
// 2. Exact coordinates. Reverse-resolved to a region for aggregation.
|
|
91
|
+
{ from: { lat: 34.05, lng: -118.24 }, to: 'FR' }
|
|
92
|
+
|
|
93
|
+
// 3. Both — exact placement AND a free aggregation key.
|
|
94
|
+
// Best option if your feed already carries geo-IP region data.
|
|
95
|
+
{ from: { lat: 34.05, lng: -118.24, region: 'US-CA' }, to: 'FR' }
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Region codes accept ISO 3166-1 alpha-2 (`"FR"`), alpha-3 (`"FRA"`), and ISO 3166-2 US
|
|
99
|
+
state codes (`"US-CA"`). All 252 ISO-assigned countries resolve, including ones too small
|
|
100
|
+
to draw at world scale — `"SG"`, `"HK"`, `"MO"`, `"MT"`, `"MC"`. Bare coordinates inside
|
|
101
|
+
them resolve correctly too: a point in Singapore returns `SG`, not Malaysia.
|
|
102
|
+
|
|
103
|
+
> **A note on bare two-letter codes.** `"CA"` is both Canada and California. Bare codes
|
|
104
|
+
> always resolve to the **country**, so `"CA"` is Canada. `"TX"` resolves to Texas
|
|
105
|
+
> because no country uses that code. Use the unambiguous `"US-CA"` form for states.
|
|
106
|
+
|
|
107
|
+
### Streaming feeds
|
|
108
|
+
|
|
109
|
+
`attacks` is the complete current set. Attacks that disappear from the array fade out:
|
|
110
|
+
|
|
111
|
+
```tsx
|
|
112
|
+
const [attacks, setAttacks] = useState<Attack[]>([]);
|
|
113
|
+
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
const socket = new WebSocket('wss://your-feed');
|
|
116
|
+
socket.onmessage = (event) => {
|
|
117
|
+
const attack = JSON.parse(event.data) as Attack;
|
|
118
|
+
// Keep a bounded window; older attacks fade out automatically.
|
|
119
|
+
setAttacks((previous) => [...previous.slice(-499), attack]);
|
|
120
|
+
};
|
|
121
|
+
return () => socket.close();
|
|
122
|
+
}, []);
|
|
123
|
+
|
|
124
|
+
<ThreatMap attacks={attacks} />;
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Give each attack a stable `id`. It is how an in-flight animation stays attached to its
|
|
128
|
+
threat across re-renders.
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Aggregation
|
|
133
|
+
|
|
134
|
+
This is the feature the library exists for. A feed with 500 attacks contains maybe 30
|
|
135
|
+
meaningful origin→destination relationships; drawing 500 overlapping lines hides that.
|
|
136
|
+
Aggregation collapses attacks sharing an origin region into a single, visually heavier
|
|
137
|
+
threat.
|
|
138
|
+
|
|
139
|
+
```tsx
|
|
140
|
+
// Two attacks from California, one from Texas — all aimed at France.
|
|
141
|
+
const attacks = [
|
|
142
|
+
{ id: 'a', from: { lat: 34.05, lng: -118.24, region: 'US-CA' }, to: 'FR' },
|
|
143
|
+
{ id: 'b', from: { lat: 37.77, lng: -122.42, region: 'US-CA' }, to: 'FR' },
|
|
144
|
+
{ id: 'c', from: 'US-TX', to: 'FR' },
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
// Renders 2 threats:
|
|
148
|
+
// California → France (count 2, thicker)
|
|
149
|
+
// Texas → France (count 1)
|
|
150
|
+
<ThreatMap attacks={attacks} />;
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### It groups on regions, not coordinates
|
|
154
|
+
|
|
155
|
+
Two attacks from opposite ends of California still aggregate, because the key is the
|
|
156
|
+
*region*, not the point. When you pass bare `{lat, lng}`, the library reverse-resolves
|
|
157
|
+
each point to its region via point-in-polygon before grouping.
|
|
158
|
+
|
|
159
|
+
### US states are first-class
|
|
160
|
+
|
|
161
|
+
At the default `granularity: 'auto'`, US origins group by **state** and everything else
|
|
162
|
+
by **country**. California and Texas are distinct aggregates; France stays one aggregate.
|
|
163
|
+
|
|
164
|
+
| `granularity` | California | Texas | France |
|
|
165
|
+
| --- | --- | --- | --- |
|
|
166
|
+
| `'auto'` *(default)* | `US-CA` | `US-TX` | `FR` |
|
|
167
|
+
| `'country'` | `US` | `US` | `FR` |
|
|
168
|
+
| `'state'` | `US-CA` | `US-TX` | `FR` |
|
|
169
|
+
|
|
170
|
+
This does **not** require loading state borders — `regions.showStates` controls *drawing*
|
|
171
|
+
them and is independent.
|
|
172
|
+
|
|
173
|
+
### Visual weight scales with count
|
|
174
|
+
|
|
175
|
+
An aggregate's `intensity` multiplies line width, glow, and head size. The default ramp is
|
|
176
|
+
logarithmic (`1 + log₂(count) × 0.5`, clamped to `[1, 6]`) because attack volume is
|
|
177
|
+
heavily long-tailed — a linear ramp would let one 500-attack region smear across the whole
|
|
178
|
+
map next to a hairline:
|
|
179
|
+
|
|
180
|
+
| count | 1 | 2 | 10 | 100 | 500 | 5000 |
|
|
181
|
+
| --- | --- | --- | --- | --- | --- | --- |
|
|
182
|
+
| intensity | 1.0 | 1.5 | 2.7 | 4.3 | 5.5 | 6.0 |
|
|
183
|
+
|
|
184
|
+
```tsx
|
|
185
|
+
// Linear instead:
|
|
186
|
+
<ThreatMap attacks={attacks} aggregation={{ scale: (count) => 1 + count / 10 }} />
|
|
187
|
+
|
|
188
|
+
// Merge, but size every line identically:
|
|
189
|
+
<ThreatMap attacks={attacks} aggregation={{ scale: () => 1 }} />
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Use `weight` when one row stands for many events — aggregation sums weights as well as
|
|
193
|
+
counting rows:
|
|
194
|
+
|
|
195
|
+
```tsx
|
|
196
|
+
{ from: 'CN', to: 'US-CA', weight: 500 } // 500 blocked packets, one row
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### What counts as "the same threat"
|
|
200
|
+
|
|
201
|
+
By default, aggregation groups by origin **and** destination, so France→US and
|
|
202
|
+
France→Japan stay separate lines. Grouping on origin alone would collapse them into one
|
|
203
|
+
line with no coherent destination to draw to.
|
|
204
|
+
|
|
205
|
+
```tsx
|
|
206
|
+
// One line per origin region, whatever it targets.
|
|
207
|
+
// The destination becomes that origin's most frequent target.
|
|
208
|
+
<ThreatMap attacks={attacks} aggregation={{ groupBy: 'origin' }} />
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### Thresholds, caps, and turning it off
|
|
212
|
+
|
|
213
|
+
```tsx
|
|
214
|
+
// Don't merge until at least 5 attacks share an origin; smaller
|
|
215
|
+
// groups render as individual lines.
|
|
216
|
+
<ThreatMap attacks={attacks} aggregation={{ minCount: 5 }} />
|
|
217
|
+
|
|
218
|
+
// Never draw more than 40 lines — keeps the heaviest.
|
|
219
|
+
<ThreatMap attacks={attacks} aggregation={{ maxGroups: 40 }} />
|
|
220
|
+
|
|
221
|
+
// One line per attack.
|
|
222
|
+
<ThreatMap attacks={attacks} aggregation={false} />
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### Custom grouping
|
|
226
|
+
|
|
227
|
+
`key` overrides grouping entirely. Return `null` to render an attack on its own:
|
|
228
|
+
|
|
229
|
+
```tsx
|
|
230
|
+
// Group by origin country AND attack type.
|
|
231
|
+
<ThreatMap
|
|
232
|
+
attacks={attacks}
|
|
233
|
+
aggregation={{ key: (attack, from) => `${from.id}:${attack.type}` }}
|
|
234
|
+
/>
|
|
235
|
+
|
|
236
|
+
// Never aggregate critical attacks — show every one individually.
|
|
237
|
+
<ThreatMap
|
|
238
|
+
attacks={attacks}
|
|
239
|
+
aggregation={{
|
|
240
|
+
key: (attack, from, to) => (attack.severity === 'critical' ? null : `${from.id}>${to.id}`),
|
|
241
|
+
}}
|
|
242
|
+
/>
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### Severity of an aggregate
|
|
246
|
+
|
|
247
|
+
An aggregate takes the **max** severity of its members: a group with one critical and
|
|
248
|
+
forty low attacks renders critical. For a security display, under-reporting the worst
|
|
249
|
+
event in a bucket is the more dangerous failure. Override with `severity`:
|
|
250
|
+
|
|
251
|
+
```tsx
|
|
252
|
+
// Use the most common severity instead of the worst.
|
|
253
|
+
<ThreatMap attacks={attacks} aggregation={{ severity: (all) => mode(all) }} />
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
### Using aggregation without the map
|
|
257
|
+
|
|
258
|
+
`aggregateAttacks` is a pure function — no React, no canvas, no async. Reuse it for a
|
|
259
|
+
table view, a CSV export, or a test:
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
import { aggregateAttacks } from 'react-threat-map';
|
|
263
|
+
|
|
264
|
+
const threats = aggregateAttacks(attacks, { config: { granularity: 'country' } });
|
|
265
|
+
threats.forEach((t) => console.log(t.fromRegion.name, t.count, t.severity));
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
---
|
|
269
|
+
|
|
270
|
+
## Customization
|
|
271
|
+
|
|
272
|
+
### Theme
|
|
273
|
+
|
|
274
|
+
Pass any subset; it merges over the defaults.
|
|
275
|
+
|
|
276
|
+
```tsx
|
|
277
|
+
<ThreatMap
|
|
278
|
+
attacks={attacks}
|
|
279
|
+
theme={{
|
|
280
|
+
ocean: '#eef2f7',
|
|
281
|
+
land: '#cfd9e6',
|
|
282
|
+
border: '#ffffff',
|
|
283
|
+
severityColors: { critical: '#dc2626', high: '#ea580c' },
|
|
284
|
+
}}
|
|
285
|
+
/>
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
`severityColors` merges per-key, so overriding `critical` leaves `low`/`medium`/`high`
|
|
289
|
+
intact. Custom severity strings work — add a matching key and use it:
|
|
290
|
+
|
|
291
|
+
```tsx
|
|
292
|
+
<ThreatMap
|
|
293
|
+
attacks={[{ from: 'CN', to: 'US', severity: 'nation-state' }]}
|
|
294
|
+
theme={{ severityColors: { 'nation-state': '#a855f7' } }}
|
|
295
|
+
/>
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
### Lines and animation
|
|
299
|
+
|
|
300
|
+
```tsx
|
|
301
|
+
<ThreatMap
|
|
302
|
+
attacks={attacks}
|
|
303
|
+
line={{ curvature: 0.35, width: 2, glow: 0.8, trailLength: 0.25 }}
|
|
304
|
+
animation={{ speed: 1.2, easing: 'easeInOutCubic', stagger: 0.5 }}
|
|
305
|
+
/>
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
Set `curvature: 0` for straight geodesics, or negative to bow the other way. Arc height is
|
|
309
|
+
capped at a third of the map height so intercontinental arcs stay on screen.
|
|
310
|
+
|
|
311
|
+
`animation={{ enabled: false }}` schedules **no** `requestAnimationFrame` loop at all —
|
|
312
|
+
arcs render statically and the component costs nothing per frame. The map also respects
|
|
313
|
+
`prefers-reduced-motion` automatically; opt out with
|
|
314
|
+
`animation={{ respectReducedMotion: false }}`.
|
|
315
|
+
|
|
316
|
+
### Projections
|
|
317
|
+
|
|
318
|
+
```tsx
|
|
319
|
+
<ThreatMap attacks={attacks} projection="orthographic" />
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
Built in: `naturalEarth1` (default), `equirectangular`, `mercator`, `orthographic`. Or
|
|
323
|
+
pass any [d3-geo](https://github.com/d3/d3-geo) projection and it will be fitted for you:
|
|
324
|
+
|
|
325
|
+
```tsx
|
|
326
|
+
import { geoRobinson } from 'd3-geo-projection';
|
|
327
|
+
|
|
328
|
+
<ThreatMap attacks={attacks} projection={geoRobinson()} />;
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
### Boundaries
|
|
332
|
+
|
|
333
|
+
```tsx
|
|
334
|
+
<ThreatMap attacks={attacks} regions={{ showStates: true, showGraticule: true }} />
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
### Render hooks
|
|
338
|
+
|
|
339
|
+
`renderThreat` and `renderRegion` let you draw anything. Return `true` if you handled it,
|
|
340
|
+
or `false` to fall through to the built-in renderer.
|
|
341
|
+
|
|
342
|
+
```tsx
|
|
343
|
+
// Label heavy aggregates, but let the library still draw the lines.
|
|
344
|
+
<ThreatMap
|
|
345
|
+
attacks={attacks}
|
|
346
|
+
renderThreat={(ctx, { threat, points, alpha }) => {
|
|
347
|
+
if (threat.count < 20) return false;
|
|
348
|
+
ctx.globalAlpha = alpha;
|
|
349
|
+
ctx.fillStyle = '#fff';
|
|
350
|
+
ctx.font = '600 10px system-ui';
|
|
351
|
+
ctx.fillText(`${threat.count}`, points[0] + 4, points[1] - 4);
|
|
352
|
+
return false;
|
|
353
|
+
}}
|
|
354
|
+
/>
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
```tsx
|
|
358
|
+
// Heat-shade countries by attack volume.
|
|
359
|
+
<ThreatMap
|
|
360
|
+
attacks={attacks}
|
|
361
|
+
renderRegion={(ctx, { feature, path, weight, theme }) => {
|
|
362
|
+
ctx.beginPath();
|
|
363
|
+
path(feature);
|
|
364
|
+
ctx.fillStyle = weight > 0 ? `hsl(0 80% ${20 + Math.min(weight, 40)}%)` : theme.land;
|
|
365
|
+
ctx.fill();
|
|
366
|
+
return true;
|
|
367
|
+
}}
|
|
368
|
+
/>
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
The context is pre-transformed for device pixel ratio, so draw in CSS pixels. Both hooks
|
|
372
|
+
are wrapped in save/restore and are error-isolated: a hook that throws is disabled for the
|
|
373
|
+
rest of the frame and the built-in renderer takes over.
|
|
374
|
+
|
|
375
|
+
> `renderThreat` opts that threat out of style batching, costing it a draw call of its
|
|
376
|
+
> own. Fine for tens of threats; if you need custom drawing on hundreds, prefer restyling
|
|
377
|
+
> via `theme`/`line`.
|
|
378
|
+
|
|
379
|
+
### Interaction
|
|
380
|
+
|
|
381
|
+
```tsx
|
|
382
|
+
<ThreatMap
|
|
383
|
+
attacks={attacks}
|
|
384
|
+
onThreatClick={(threat) => console.log(threat.count, 'from', threat.fromRegion.name)}
|
|
385
|
+
onThreatHover={(threat) => setTooltip(threat)}
|
|
386
|
+
/>
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
Hit testing runs against the real arc geometry, with a hit radius that scales with line
|
|
390
|
+
thickness. `onThreatHover` fires only when the hovered threat *changes*, not on every
|
|
391
|
+
pointer pixel. Without any handler the canvas is `pointer-events: none` and never
|
|
392
|
+
intercepts clicks meant for your own UI.
|
|
393
|
+
|
|
394
|
+
### Sizing
|
|
395
|
+
|
|
396
|
+
| You provide | Result |
|
|
397
|
+
| --- | --- |
|
|
398
|
+
| Nothing | Fills container width; height from the projection's aspect ratio |
|
|
399
|
+
| A CSS height (class or `style`) | Fills the container in both axes |
|
|
400
|
+
| `width` only | Height derived from the projection's aspect ratio |
|
|
401
|
+
| `width` + `height` | Exactly that, in CSS pixels |
|
|
402
|
+
|
|
403
|
+
The map ships no CSS and never imposes a size beyond a fallback aspect ratio.
|
|
404
|
+
|
|
405
|
+
### Self-hosting geo data
|
|
406
|
+
|
|
407
|
+
```tsx
|
|
408
|
+
import { loadGeoData } from 'react-threat-map/geo';
|
|
409
|
+
|
|
410
|
+
// Preload during app boot so the map paints instantly on mount.
|
|
411
|
+
void loadGeoData({ states: true });
|
|
412
|
+
|
|
413
|
+
// Or supply your own boundaries entirely.
|
|
414
|
+
<ThreatMap attacks={attacks} geo={async () => fetch('/geo.json').then((r) => r.json())} />;
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
---
|
|
418
|
+
|
|
419
|
+
## API reference
|
|
420
|
+
|
|
421
|
+
### `<ThreatMap>`
|
|
422
|
+
|
|
423
|
+
| Prop | Type | Default | Description |
|
|
424
|
+
| --- | --- | --- | --- |
|
|
425
|
+
| `attacks` | `Attack[]` | — | **Required.** The current attack set. |
|
|
426
|
+
| `width` | `number` | container width | CSS pixels. |
|
|
427
|
+
| `height` | `number` | from aspect ratio | CSS pixels. |
|
|
428
|
+
| `projection` | `ProjectionSpec` | `'naturalEarth1'` | Name or a d3-geo projection. |
|
|
429
|
+
| `theme` | `Partial<ThreatMapTheme>` | `defaultTheme` | Colors. |
|
|
430
|
+
| `line` | `Partial<LineStyleConfig>` | `defaultLineStyle` | Arc geometry and styling. |
|
|
431
|
+
| `animation` | `Partial<AnimationConfig>` | `defaultAnimation` | Animation. |
|
|
432
|
+
| `regions` | `Partial<RegionsConfig>` | `defaultRegions` | Which boundaries to draw. |
|
|
433
|
+
| `aggregation` | `Partial<AggregationConfig> \| false` | `defaultAggregation` | Grouping. `false` disables. |
|
|
434
|
+
| `renderThreat` | `ThreatRenderer` | — | Custom threat drawing. |
|
|
435
|
+
| `renderRegion` | `RegionRenderer` | — | Custom region drawing. |
|
|
436
|
+
| `geo` | `GeoData \| (() => Promise<GeoData>)` | bundled | Supply or preload geometry. |
|
|
437
|
+
| `onThreatClick` | `(threat, event) => void` | — | Click handler. |
|
|
438
|
+
| `onThreatHover` | `(threat \| null, event) => void` | — | Hover handler. |
|
|
439
|
+
| `onError` | `(error: ThreatMapError) => void` | dev console | Recoverable errors. |
|
|
440
|
+
| `className` / `style` | | — | Applied to the wrapper. |
|
|
441
|
+
| `ariaLabel` | `string` | `'Cyberattack threat map'` | Accessible name. |
|
|
442
|
+
|
|
443
|
+
### `Attack`
|
|
444
|
+
|
|
445
|
+
| Field | Type | Default | Description |
|
|
446
|
+
| --- | --- | --- | --- |
|
|
447
|
+
| `from` | `AttackLocation` | — | **Required.** Origin. |
|
|
448
|
+
| `to` | `AttackLocation` | — | **Required.** Destination. |
|
|
449
|
+
| `id` | `string` | derived | Stable identity. Recommended for streaming. |
|
|
450
|
+
| `timestamp` | `number` | — | Epoch ms. |
|
|
451
|
+
| `type` | `string` | — | Free-form classification. |
|
|
452
|
+
| `severity` | `Severity` | `'medium'` | `low` \| `medium` \| `high` \| `critical` \| custom. |
|
|
453
|
+
| `weight` | `number` | `1` | Relative importance; summed by aggregation. |
|
|
454
|
+
| `meta` | `TMeta` | — | Your payload, passed through untouched. |
|
|
455
|
+
|
|
456
|
+
### `Threat`
|
|
457
|
+
|
|
458
|
+
What aggregation produces and hooks/handlers receive.
|
|
459
|
+
|
|
460
|
+
| Field | Type | Description |
|
|
461
|
+
| --- | --- | --- |
|
|
462
|
+
| `id` | `string` | Group key, stable across frames. |
|
|
463
|
+
| `from` / `to` | `LatLng` | Resolved coordinates. |
|
|
464
|
+
| `fromRegion` / `toRegion` | `ResolvedRegion` | `{ id, name, kind, countryCode }`. |
|
|
465
|
+
| `count` | `number` | Underlying attacks. `1` when unaggregated. |
|
|
466
|
+
| `totalWeight` | `number` | Sum of member weights. |
|
|
467
|
+
| `severity` | `Severity` | Max across members, by default. |
|
|
468
|
+
| `intensity` | `number` | Visual weight multiplier, `1`–`6`. |
|
|
469
|
+
| `attacks` | `Attack[]` | The attacks folded into this threat. |
|
|
470
|
+
|
|
471
|
+
### `AggregationConfig`
|
|
472
|
+
|
|
473
|
+
| Field | Type | Default | Description |
|
|
474
|
+
| --- | --- | --- | --- |
|
|
475
|
+
| `enabled` | `boolean` | `true` | Master switch. |
|
|
476
|
+
| `granularity` | `'auto' \| 'state' \| 'country'` | `'auto'` | Origin specificity. |
|
|
477
|
+
| `groupBy` | `'origin-destination' \| 'origin'` | `'origin-destination'` | What is "the same threat". |
|
|
478
|
+
| `key` | `AggregationKeyFn` | — | Full override. `null` opts an attack out. |
|
|
479
|
+
| `minCount` | `number` | `2` | Minimum group size to merge. |
|
|
480
|
+
| `maxGroups` | `number` | unlimited | Cap, keeping the heaviest. |
|
|
481
|
+
| `scale` | `IntensityScale` | log ramp | count → visual weight. |
|
|
482
|
+
| `severity` | `AggregationSeverityFn` | max | How an aggregate picks its severity. |
|
|
483
|
+
|
|
484
|
+
### `LineStyleConfig`
|
|
485
|
+
|
|
486
|
+
| Field | Default | Description |
|
|
487
|
+
| --- | --- | --- |
|
|
488
|
+
| `curvature` | `0.22` | Arc height as a fraction of chord length. `0` is flat. |
|
|
489
|
+
| `width` | `1.2` | Baseline stroke width, before `intensity`. |
|
|
490
|
+
| `trackOpacity` | `0.28` | Opacity of the full arc behind the head. |
|
|
491
|
+
| `trailOpacity` | `0.95` | Opacity of the lit trail. |
|
|
492
|
+
| `trailLength` | `0.18` | Trail length as a fraction of the path. |
|
|
493
|
+
| `glow` | `0.5` | Glow strength, `0`–`1`. |
|
|
494
|
+
| `headRadius` | `2` | Head dot radius, before `intensity`. |
|
|
495
|
+
| `showOrigin` | `true` | Static marker at each origin. |
|
|
496
|
+
| `showImpact` | `true` | Ripple where a head lands. |
|
|
497
|
+
| `segments` | `48` | Straight pieces per arc. |
|
|
498
|
+
|
|
499
|
+
### `AnimationConfig`
|
|
500
|
+
|
|
501
|
+
| Field | Default | Description |
|
|
502
|
+
| --- | --- | --- |
|
|
503
|
+
| `enabled` | `true` | `false` schedules no rAF loop at all. |
|
|
504
|
+
| `speed` | `0.5` | Full traversals per second. |
|
|
505
|
+
| `easing` | `'easeInOutQuad'` | Name or `(t) => number`. |
|
|
506
|
+
| `stagger` | `1` | Phase spread, `0`–`1`. `0` is lockstep. |
|
|
507
|
+
| `loop` | `true` | Restart on completion. |
|
|
508
|
+
| `fadeIn` / `fadeOut` | `400` / `600` | Milliseconds. |
|
|
509
|
+
| `respectReducedMotion` | `true` | Honor `prefers-reduced-motion`. |
|
|
510
|
+
|
|
511
|
+
### `ThreatMapTheme`
|
|
512
|
+
|
|
513
|
+
`ocean`, `land`, `border`, `borderWidth`, `stateBorder`, `stateBorderWidth`,
|
|
514
|
+
`severityColors`, `headColor`, `originColor`, `impactColor`.
|
|
515
|
+
|
|
516
|
+
### `RegionsConfig`
|
|
517
|
+
|
|
518
|
+
`showCountries` (`true`), `showStates` (`false`), `showGraticule` (`false`),
|
|
519
|
+
`graticuleColor`, `showSphere` (`true`).
|
|
520
|
+
|
|
521
|
+
### Exported functions
|
|
522
|
+
|
|
523
|
+
| Export | Description |
|
|
524
|
+
| --- | --- |
|
|
525
|
+
| `aggregateAttacks(attacks, options)` | The pure aggregation function. |
|
|
526
|
+
| `lookupRegionCode(code)` | `"us-ca"` → `{ id, name, kind, anchor, … }`. |
|
|
527
|
+
| `getRegionById(id)` | Exact lookup by canonical id. |
|
|
528
|
+
| `listRegions()` | Every known country and US state. |
|
|
529
|
+
| `resolveLocation(location, index?, preferStates?)` | Location → point + region. |
|
|
530
|
+
| `loadGeoData(options)` | From `react-threat-map/geo`. Preload boundaries. |
|
|
531
|
+
| `defaultTheme`, `defaultLineStyle`, `defaultAnimation`, `defaultRegions`, `defaultAggregation` | Frozen defaults, safe to spread. |
|
|
532
|
+
| `defaultIntensityScale`, `maxSeverity`, `severityRank`, `regionKey`, `SEVERITY_ORDER` | Aggregation internals, exported for reuse. |
|
|
533
|
+
|
|
534
|
+
### Error handling
|
|
535
|
+
|
|
536
|
+
The library never throws at render. Recoverable problems go to `onError`:
|
|
537
|
+
|
|
538
|
+
| `kind` | Meaning |
|
|
539
|
+
| --- | --- |
|
|
540
|
+
| `geo-load` | Geometry chunk failed to load. Threats still render on a blank map. |
|
|
541
|
+
| `resolve` | An attack's `from`/`to` could not be resolved. That attack is skipped. |
|
|
542
|
+
| `render` | A render hook threw. It is disabled for the frame; the built-in renderer takes over. |
|
|
543
|
+
|
|
544
|
+
Without a handler these are logged in development and silent in production.
|
|
545
|
+
|
|
546
|
+
---
|
|
547
|
+
|
|
548
|
+
## Performance
|
|
549
|
+
|
|
550
|
+
Measured on the bundled heavy-load demo — 500+ threats streaming at ~20/sec, on a modern
|
|
551
|
+
laptop:
|
|
552
|
+
|
|
553
|
+
> **120 fps**, with five maps animating on the same page.
|
|
554
|
+
|
|
555
|
+
The technique that gets there is **style batching**. The naive Canvas loop issues one
|
|
556
|
+
`stroke()` per threat per frame — and that is what usually makes people conclude Canvas is
|
|
557
|
+
too slow and reach for WebGL. Instead, threats are bucketed by `(color, width, alpha)`,
|
|
558
|
+
each bucket accumulates every member's geometry into a single `Path2D`, and each bucket
|
|
559
|
+
issues one `stroke()`.
|
|
560
|
+
|
|
561
|
+
The effect is that **draw calls stop growing with threat count**. Measured on the worst
|
|
562
|
+
realistic case — every threat a different severity, intensity, and animation phase:
|
|
563
|
+
|
|
564
|
+
| threats | draw calls | accumulate + paint |
|
|
565
|
+
| --- | --- | --- |
|
|
566
|
+
| 50 | 163 | 0.3 ms |
|
|
567
|
+
| 500 | 173 | 0.5 ms |
|
|
568
|
+
| 2000 | 173 | 1.5 ms |
|
|
569
|
+
|
|
570
|
+
Past a few dozen threats every style bucket is already occupied, so quadrupling the
|
|
571
|
+
threats adds no draw calls at all — only the linear, cheap work of walking more cached
|
|
572
|
+
geometry. The plateau is asserted in the test suite rather than merely claimed:
|
|
573
|
+
|
|
574
|
+
```ts
|
|
575
|
+
expect(drawCallsFor(2000)).toBe(drawCallsFor(500));
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
Alongside it:
|
|
579
|
+
|
|
580
|
+
- **Geometry is precomputed**, once per layout, never per frame. The loop only walks
|
|
581
|
+
cached `Float32Array` buffers.
|
|
582
|
+
- **Glow is a double-stroke**, not `shadowBlur` — visually equivalent on a line and
|
|
583
|
+
roughly an order of magnitude cheaper.
|
|
584
|
+
- **The per-threat path allocates nothing**, so the GC has no reason to interrupt an
|
|
585
|
+
animation.
|
|
586
|
+
- **The base map is a separate canvas**, rasterized once and never touched by the loop.
|
|
587
|
+
|
|
588
|
+
### Getting the most out of it
|
|
589
|
+
|
|
590
|
+
- **Give attacks stable `id`s.** Identity drives animation continuity.
|
|
591
|
+
- **Pass `region` alongside coordinates** when you have it — it skips point-in-polygon
|
|
592
|
+
entirely.
|
|
593
|
+
- **Bound your feed.** `maxGroups` caps rendered threats; slicing your array caps
|
|
594
|
+
resolution work.
|
|
595
|
+
- **`animation={{ enabled: false }}`** removes the rAF loop completely.
|
|
596
|
+
|
|
597
|
+
---
|
|
598
|
+
|
|
599
|
+
## Examples
|
|
600
|
+
|
|
601
|
+
A runnable Vite demo covering basic usage, 500+ streaming attacks with a live FPS counter,
|
|
602
|
+
one country under fire from five origins at different volumes, aggregation strategies side
|
|
603
|
+
by side, custom theming with render hooks, and raw-coordinate reverse geocoding with hover:
|
|
604
|
+
|
|
605
|
+
```bash
|
|
606
|
+
git clone https://github.com/BogdanTaranenko/react-threat-map
|
|
607
|
+
cd react-threat-map
|
|
608
|
+
npm install
|
|
609
|
+
npm run build # generates geo data + builds the library
|
|
610
|
+
cd examples/demo
|
|
611
|
+
npm install
|
|
612
|
+
npm run dev
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
---
|
|
616
|
+
|
|
617
|
+
## Design decisions
|
|
618
|
+
|
|
619
|
+
[DECISIONS.md](./DECISIONS.md) covers the load-bearing choices and their tradeoffs:
|
|
620
|
+
why `d3-geo` + Canvas over MapLibre/Leaflet/react-simple-maps, why Canvas 2D over SVG and
|
|
621
|
+
WebGL, how the Natural Earth data is bundled and split, and why resolving and drawing
|
|
622
|
+
deliberately use different datasets.
|
|
623
|
+
|
|
624
|
+
## Data
|
|
625
|
+
|
|
626
|
+
Boundaries from [Natural Earth](https://www.naturalearthdata.com/) — public domain, no
|
|
627
|
+
attribution required — via `world-atlas` and `us-atlas`, preprocessed at build time.
|
|
628
|
+
|
|
629
|
+
## Contributing
|
|
630
|
+
|
|
631
|
+
Issues and PRs welcome — see [CONTRIBUTING.md](./CONTRIBUTING.md) for setup, repo layout,
|
|
632
|
+
and the two non-obvious things (generated-but-committed geo data, and why resolving and
|
|
633
|
+
drawing use different datasets).
|
|
634
|
+
|
|
635
|
+
```bash
|
|
636
|
+
npm install
|
|
637
|
+
npm run geo # regenerate bundled geo data from Natural Earth
|
|
638
|
+
npm test # 228 tests
|
|
639
|
+
npm run typecheck # includes the type-contract suite
|
|
640
|
+
npm run build
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
## License
|
|
644
|
+
|
|
645
|
+
MIT © [Bogdan Taranenko](https://github.com/BogdanTaranenko)
|