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/README.md ADDED
@@ -0,0 +1,645 @@
1
+ # react-threat-map
2
+
3
+ [![CI](https://github.com/BogdanTaranenko/react-threat-map/actions/workflows/ci.yml/badge.svg)](https://github.com/BogdanTaranenko/react-threat-map/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/react-threat-map.svg)](https://www.npmjs.com/package/react-threat-map)
5
+ [![license](https://img.shields.io/npm/l/react-threat-map.svg)](./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)