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.
@@ -0,0 +1,765 @@
1
+ /**
2
+ * Public data types for `react-threat-map`.
3
+ *
4
+ * Everything a consumer touches is declared here: the attack input shape, the
5
+ * region/location model, theme and configuration objects, and the render hooks.
6
+ *
7
+ * @packageDocumentation
8
+ */
9
+ /**
10
+ * A raw geographic coordinate in degrees (WGS84).
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * const paris: LatLng = { lat: 48.8566, lng: 2.3522 };
15
+ * ```
16
+ */
17
+ interface LatLng {
18
+ /** Latitude in degrees, -90 (south pole) to 90 (north pole). */
19
+ readonly lat: number;
20
+ /** Longitude in degrees, -180 (west) to 180 (east). */
21
+ readonly lng: number;
22
+ }
23
+ /**
24
+ * A string identifier for a region the library can resolve to a coordinate.
25
+ *
26
+ * Accepted forms, matched case-insensitively:
27
+ *
28
+ * - ISO 3166-1 alpha-2 country code — `"FR"`, `"US"`, `"CN"`
29
+ * - ISO 3166-1 alpha-3 country code — `"FRA"`, `"USA"`, `"CHN"`
30
+ * - ISO 3166-2 US state code — `"US-CA"`, `"US-TX"`
31
+ * - Bare USPS state code — `"CA"`, `"TX"` (see the ambiguity note below)
32
+ *
33
+ * Note on bare two-letter codes: a handful collide between country and US state
34
+ * codes (`"CA"` is both Canada and California; also `"AL"`, `"CO"`, `"DE"`,
35
+ * `"GA"`, `"IN"`, `"LA"`, `"MD"`, `"MO"`, `"MS"`, `"MT"`, `"NE"`, `"PA"`, `"SC"`,
36
+ * `"SD"`, `"VA"`). Bare codes always resolve to the **country** — `"CA"` is
37
+ * Canada. Use the unambiguous `"US-CA"` form for states. Resolution never
38
+ * guesses; an unresolvable identifier is reported through `onError` rather than
39
+ * silently dropped.
40
+ *
41
+ * The type is `string` rather than a literal union on purpose: a closed union of
42
+ * ~300 codes produces unreadable IDE errors and breaks consumers whose data is
43
+ * typed as `string` from an API. Validation happens at resolution time, where a
44
+ * useful error message is possible.
45
+ */
46
+ type RegionCode = string;
47
+ /**
48
+ * Where an attack starts or ends.
49
+ *
50
+ * Three interchangeable forms, so you can pass whatever your data already has:
51
+ *
52
+ * 1. A {@link RegionCode} string — `"FR"`, `"US-CA"`. Resolved to that region's
53
+ * anchor coordinate. Cheapest option: no geometry needed, and aggregation
54
+ * knows the region without a reverse lookup.
55
+ * 2. A {@link LatLng} — exact coordinates. Aggregation reverse-resolves the
56
+ * region via point-in-polygon once the geo data has loaded.
57
+ * 3. A {@link LatLng} **with** a `region` — exact coordinates *and* an explicit
58
+ * aggregation key. Best of both: pixel-accurate placement, zero-cost grouping.
59
+ * Use this when your feed already carries geo-IP region data.
60
+ *
61
+ * @example
62
+ * ```ts
63
+ * const a: AttackLocation = 'US-CA';
64
+ * const b: AttackLocation = { lat: 34.05, lng: -118.24 };
65
+ * const c: AttackLocation = { lat: 34.05, lng: -118.24, region: 'US-CA' };
66
+ * ```
67
+ */
68
+ type AttackLocation = RegionCode | LatLng | (LatLng & {
69
+ readonly region?: RegionCode;
70
+ });
71
+ /**
72
+ * How specific a resolved region is.
73
+ *
74
+ * - `'country'` — resolved to a country (e.g. France).
75
+ * - `'state'` — resolved to a US state (e.g. California).
76
+ * - `'unknown'` — coordinates that fall outside all known boundaries (ocean,
77
+ * Antarctica, disputed areas), or a region code that could not be resolved.
78
+ */
79
+ type RegionKind = 'country' | 'state' | 'unknown';
80
+ /**
81
+ * A region resolved from an {@link AttackLocation}, as produced by the geo layer
82
+ * and handed to aggregation key functions and render hooks.
83
+ */
84
+ interface ResolvedRegion {
85
+ /**
86
+ * Canonical, stable identifier. Countries use ISO alpha-2 (`"FR"`); US states
87
+ * use ISO 3166-2 (`"US-CA"`). Unknown regions use `"??"`.
88
+ *
89
+ * This — not the display name — is what aggregation groups on.
90
+ */
91
+ readonly id: string;
92
+ /** Human-readable name, e.g. `"France"` or `"California"`. */
93
+ readonly name: string;
94
+ /** Granularity of this resolution. */
95
+ readonly kind: RegionKind;
96
+ /** ISO alpha-2 of the containing country. For `"US-CA"` this is `"US"`. */
97
+ readonly countryCode: string;
98
+ }
99
+ /**
100
+ * Severity of an attack. Drives the threat's color via {@link ThreatMapTheme.severityColors}
101
+ * and its baseline visual weight.
102
+ *
103
+ * Custom severity strings are permitted — supply a matching entry in
104
+ * `theme.severityColors` and it will be used. Unrecognized values fall back to
105
+ * `theme.severityColors.medium`.
106
+ */
107
+ type Severity = 'low' | 'medium' | 'high' | 'critical' | (string & {});
108
+ /**
109
+ * A single cyberattack: the library's input unit.
110
+ *
111
+ * Only `from` and `to` are required. Everything else is metadata that
112
+ * influences styling, grouping, or your own render hooks.
113
+ *
114
+ * @example Minimal
115
+ * ```ts
116
+ * const attacks: Attack[] = [{ from: 'CN', to: 'US-CA' }];
117
+ * ```
118
+ *
119
+ * @example Realistic feed row
120
+ * ```ts
121
+ * const attack: Attack = {
122
+ * id: 'evt-84321',
123
+ * from: { lat: 39.9, lng: 116.4, region: 'CN' },
124
+ * to: 'US-CA',
125
+ * timestamp: Date.now(),
126
+ * type: 'ssh-bruteforce',
127
+ * severity: 'high',
128
+ * meta: { targetPort: 22, attempts: 1_204 },
129
+ * };
130
+ * ```
131
+ */
132
+ interface Attack<TMeta = unknown> {
133
+ /**
134
+ * Stable unique identifier.
135
+ *
136
+ * Strongly recommended for streaming feeds: it is how the renderer keeps an
137
+ * in-flight animation attached to the right attack across re-renders. Without
138
+ * one, a fallback key is derived from `from`/`to`/`timestamp`, and attacks
139
+ * that collide on all three are treated as the same threat.
140
+ */
141
+ readonly id?: string;
142
+ /** Where the attack originates. */
143
+ readonly from: AttackLocation;
144
+ /** Where the attack is directed. */
145
+ readonly to: AttackLocation;
146
+ /** Epoch milliseconds. Used for time-window filtering and as a fallback key input. */
147
+ readonly timestamp?: number;
148
+ /** Free-form classification, e.g. `"ddos"`, `"phishing"`. Available to hooks; not interpreted by the library. */
149
+ readonly type?: string;
150
+ /** Severity band. Defaults to `'medium'` when omitted. */
151
+ readonly severity?: Severity;
152
+ /**
153
+ * Relative importance, default `1`. Aggregation sums weights rather than
154
+ * counting rows, so one row representing 500 blocked packets can carry
155
+ * `weight: 500` and size the line accordingly.
156
+ */
157
+ readonly weight?: number;
158
+ /** Your own payload. Passed through untouched to hooks and event handlers. */
159
+ readonly meta?: TMeta;
160
+ }
161
+ /**
162
+ * A renderable line, produced by the aggregation stage from one or more
163
+ * {@link Attack}s. This is what actually gets drawn, and what render hooks and
164
+ * event handlers receive.
165
+ *
166
+ * When aggregation is disabled, exactly one threat is produced per attack, with
167
+ * `count === 1` and `attacks.length === 1`.
168
+ */
169
+ interface Threat<TMeta = unknown> {
170
+ /** Stable identity across frames — the aggregation group key, or the attack id when ungrouped. */
171
+ readonly id: string;
172
+ /** Resolved origin coordinate, in degrees. */
173
+ readonly from: LatLng;
174
+ /** Resolved destination coordinate, in degrees. */
175
+ readonly to: LatLng;
176
+ /** Origin region. `kind: 'unknown'` if it could not be resolved. */
177
+ readonly fromRegion: ResolvedRegion;
178
+ /** Destination region. */
179
+ readonly toRegion: ResolvedRegion;
180
+ /** Number of underlying attacks. `1` when unaggregated. */
181
+ readonly count: number;
182
+ /** Sum of the underlying attacks' `weight` values (each defaulting to `1`). */
183
+ readonly totalWeight: number;
184
+ /** Effective severity. By default the **max** severity across members — see {@link AggregationConfig.severity}. */
185
+ readonly severity: Severity;
186
+ /**
187
+ * Visual weight multiplier derived from `count` via {@link AggregationConfig.scale}.
188
+ * `1` means baseline thickness; `2` means twice as heavy. Applied to line
189
+ * width, glow radius, and head size.
190
+ */
191
+ readonly intensity: number;
192
+ /** The attacks that were folded into this threat. Always at least one. */
193
+ readonly attacks: readonly Attack<TMeta>[];
194
+ }
195
+ /**
196
+ * How finely origins are grouped.
197
+ *
198
+ * - `'auto'` *(default)* — US origins group by **state**, everything else by
199
+ * **country**. This is what makes California and Texas distinct aggregates
200
+ * while keeping the rest of the world at country level.
201
+ * - `'state'` — always prefer state granularity where known (currently US only);
202
+ * elsewhere behaves like `'country'`.
203
+ * - `'country'` — always country level. California and Texas both become `"US"`.
204
+ */
205
+ type AggregationGranularity = 'auto' | 'state' | 'country';
206
+ /**
207
+ * What constitutes "the same threat".
208
+ *
209
+ * - `'origin-destination'` *(default)* — one line per origin→destination region
210
+ * pair. France→US and France→Japan stay separate, because they are visually
211
+ * distinct lines and collapsing them has no meaningful endpoint.
212
+ * - `'origin'` — one line per origin region, regardless of destination. The
213
+ * destination becomes the origin's most frequent target region. Use when you
214
+ * want strictly one line per attacking region.
215
+ */
216
+ type AggregationGroupBy = 'origin-destination' | 'origin';
217
+ /**
218
+ * Maps an aggregate's attack count to a visual weight multiplier.
219
+ *
220
+ * @param count - Number of attacks in the group (>= 1).
221
+ * @param totalWeight - Sum of member `weight` values.
222
+ * @returns Multiplier applied to line width/glow/head size. `1` is baseline.
223
+ */
224
+ type IntensityScale = (count: number, totalWeight: number) => number;
225
+ /**
226
+ * Derives a grouping key for an attack. Attacks yielding the same key merge into
227
+ * one threat.
228
+ *
229
+ * @param attack - The original attack.
230
+ * @param from - Its resolved origin region.
231
+ * @param to - Its resolved destination region.
232
+ * @returns A group key, or `null` to exclude this attack from aggregation and
233
+ * render it on its own.
234
+ *
235
+ * @example Group by origin country and attack type together
236
+ * ```ts
237
+ * const key: AggregationKeyFn = (attack, from) => `${from.id}:${attack.type ?? 'unknown'}`;
238
+ * ```
239
+ */
240
+ type AggregationKeyFn<TMeta = unknown> = (attack: Attack<TMeta>, from: ResolvedRegion, to: ResolvedRegion) => string | null;
241
+ /**
242
+ * Picks the severity for an aggregate from its members' severities.
243
+ *
244
+ * @param severities - Every member's severity, in input order. Never empty.
245
+ * @returns The severity the aggregated threat should render as.
246
+ */
247
+ type AggregationSeverityFn = (severities: readonly Severity[]) => Severity;
248
+ /**
249
+ * Controls how attacks sharing an origin collapse into single, heavier threats.
250
+ *
251
+ * Pass `aggregation={false}` on {@link ThreatMapProps} to disable entirely and
252
+ * render one line per attack.
253
+ *
254
+ * @example Group at country level only, and require 3 attacks before merging
255
+ * ```tsx
256
+ * <ThreatMap
257
+ * attacks={attacks}
258
+ * aggregation={{ granularity: 'country', minCount: 3 }}
259
+ * />
260
+ * ```
261
+ */
262
+ interface AggregationConfig<TMeta = unknown> {
263
+ /** Master switch. Default `true`. Equivalent to passing `aggregation={false}`. */
264
+ readonly enabled?: boolean;
265
+ /** Origin granularity. Default `'auto'` (US → state, elsewhere → country). */
266
+ readonly granularity?: AggregationGranularity;
267
+ /** What counts as the same threat. Default `'origin-destination'`. */
268
+ readonly groupBy?: AggregationGroupBy;
269
+ /**
270
+ * Full override of the grouping key. When provided, `granularity` and
271
+ * `groupBy` are ignored. Returning `null` renders that attack individually.
272
+ */
273
+ readonly key?: AggregationKeyFn<TMeta>;
274
+ /**
275
+ * Minimum group size to merge. Groups smaller than this are emitted as
276
+ * individual threats instead. Default `2` — a "group" of one is just an attack.
277
+ */
278
+ readonly minCount?: number;
279
+ /**
280
+ * Cap on rendered threats, keeping the heaviest groups by `totalWeight`.
281
+ * Unlimited by default. A backstop for feeds that can spike unboundedly.
282
+ */
283
+ readonly maxGroups?: number;
284
+ /**
285
+ * count → visual weight multiplier. Default is a logarithmic ramp
286
+ * (`1 + log2(count) * 0.5`), clamped to `[1, 6]`: it keeps a 500-attack group
287
+ * visibly heavier than a 5-attack one without rendering a line 500× thick.
288
+ */
289
+ readonly scale?: IntensityScale;
290
+ /** How an aggregate picks its severity. Default: the **max** severity among members. */
291
+ readonly severity?: AggregationSeverityFn;
292
+ }
293
+ /**
294
+ * Any CSS color string the Canvas 2D context accepts — `"#0af"`,
295
+ * `"rgb(0 170 255)"`, `"hsl(200 100% 50% / 0.5)"`, `"tomato"`.
296
+ */
297
+ type Color = string;
298
+ /**
299
+ * Colors for the map and the threats drawn over it.
300
+ *
301
+ * Every field is optional in {@link ThreatMapProps.theme}: what you pass is
302
+ * merged over {@link defaultTheme}, so you can restyle one color without
303
+ * restating the rest.
304
+ *
305
+ * @example Light theme
306
+ * ```tsx
307
+ * <ThreatMap attacks={attacks} theme={{ ocean: '#eef2f6', land: '#d6dee8', border: '#fff' }} />
308
+ * ```
309
+ */
310
+ interface ThreatMapTheme {
311
+ /** Background behind the landmasses. */
312
+ readonly ocean: Color;
313
+ /** Country/state fill. */
314
+ readonly land: Color;
315
+ /** Country border stroke. */
316
+ readonly border: Color;
317
+ /** Country border width in CSS pixels. */
318
+ readonly borderWidth: number;
319
+ /** US state border stroke, when `regions.showStates` is on. */
320
+ readonly stateBorder: Color;
321
+ /** US state border width in CSS pixels. */
322
+ readonly stateBorderWidth: number;
323
+ /**
324
+ * Threat line color per severity. Extra keys are allowed for custom severity
325
+ * strings; a severity with no entry falls back to `medium`.
326
+ */
327
+ readonly severityColors: Readonly<Record<string, Color>>;
328
+ /** Color of the dot travelling along the path. Defaults to the threat's severity color when `null`. */
329
+ readonly headColor: Color | null;
330
+ /** Origin marker color. Applies when `line.showOrigin` is on. */
331
+ readonly originColor: Color;
332
+ /** Impact ripple color at the destination. Applies when `line.showImpact` is on. */
333
+ readonly impactColor: Color;
334
+ }
335
+ /** Geometry and styling of the threat arcs. */
336
+ interface LineStyleConfig {
337
+ /**
338
+ * Arc height as a fraction of the chord length. `0` draws a straight geodesic;
339
+ * `0.3` bows noticeably. Default `0.22`. Negative values arc the other way.
340
+ */
341
+ readonly curvature: number;
342
+ /** Baseline stroke width in CSS pixels, before `intensity` scaling. Default `1.2`. */
343
+ readonly width: number;
344
+ /** Opacity of the full arc that sits behind the animated head, `0`–`1`. Default `0.28`. */
345
+ readonly trackOpacity: number;
346
+ /** Opacity of the lit trail behind the head, `0`–`1`. Default `0.95`. */
347
+ readonly trailOpacity: number;
348
+ /** Trail length as a fraction of the path, `0`–`1`. Default `0.18`. */
349
+ readonly trailLength: number;
350
+ /** Glow strength, `0` (off) to `1`. Rendered as a cheap wide/narrow double-stroke. Default `0.5`. */
351
+ readonly glow: number;
352
+ /** Radius of the travelling head dot in CSS pixels, before `intensity` scaling. Default `2`. */
353
+ readonly headRadius: number;
354
+ /** Draw a static marker at each origin. Default `true`. */
355
+ readonly showOrigin: boolean;
356
+ /** Draw an expanding ripple where a head lands. Default `true`. */
357
+ readonly showImpact: boolean;
358
+ /**
359
+ * Number of straight segments each arc is flattened into. Higher is smoother
360
+ * and costlier to precompute (not to draw). Default `48`.
361
+ */
362
+ readonly segments: number;
363
+ }
364
+ /** Named easing curves for the head's travel along the path. */
365
+ type EasingName = 'linear' | 'easeInQuad' | 'easeOutQuad' | 'easeInOutQuad' | 'easeInOutCubic';
366
+ /** Controls the animation loop. */
367
+ interface AnimationConfig {
368
+ /**
369
+ * Master switch. Default `true`. When `false` no `requestAnimationFrame` loop
370
+ * runs at all — arcs render statically and the component costs nothing per
371
+ * frame. Also respects `prefers-reduced-motion` unless
372
+ * {@link AnimationConfig.respectReducedMotion} is `false`.
373
+ */
374
+ readonly enabled: boolean;
375
+ /** Full path traversals per second. Default `0.5` (one trip every 2s). */
376
+ readonly speed: number;
377
+ /** Easing for head position. Name or your own `(t: 0..1) => 0..1`. Default `'easeInOutQuad'`. */
378
+ readonly easing: EasingName | ((t: number) => number);
379
+ /**
380
+ * Randomized phase offset spread, `0`–`1`. At `0` every threat launches in
381
+ * lockstep; at `1` phases are fully scattered. Default `1`.
382
+ */
383
+ readonly stagger: number;
384
+ /** Restart on completion. Default `true`. When `false` each threat animates once and holds. */
385
+ readonly loop: boolean;
386
+ /** Fade-in duration in ms for newly appearing threats. Default `400`. */
387
+ readonly fadeIn: number;
388
+ /** Fade-out duration in ms when a threat leaves `attacks`. Default `600`. */
389
+ readonly fadeOut: number;
390
+ /** Honor the `prefers-reduced-motion` media query. Default `true`. */
391
+ readonly respectReducedMotion: boolean;
392
+ }
393
+ /**
394
+ * Map projection.
395
+ *
396
+ * - `'naturalEarth1'` *(default)* — compromise projection; the usual choice for
397
+ * world maps. Balanced distortion, pleasant shape.
398
+ * - `'equirectangular'` — plate carrée. Lat/lng map linearly to x/y.
399
+ * - `'mercator'` — familiar from web maps; badly inflates high latitudes.
400
+ * - `'orthographic'` — globe view. Half the world is not visible.
401
+ *
402
+ * You may also pass any [d3-geo](https://github.com/d3/d3-geo) projection
403
+ * instance for full control; the library will fit it to the viewport.
404
+ */
405
+ type ProjectionName = 'naturalEarth1' | 'equirectangular' | 'mercator' | 'orthographic';
406
+ /** A projection name, or a d3-geo projection object. */
407
+ type ProjectionSpec = ProjectionName | GeoProjectionLike;
408
+ /**
409
+ * The slice of d3-geo's `GeoProjection` this library depends on. Structural, so
410
+ * any d3-geo projection satisfies it without importing d3's types.
411
+ */
412
+ interface GeoProjectionLike {
413
+ /** Project `[lng, lat]` degrees to `[x, y]` pixels, or `null` if unprojectable. */
414
+ (point: [number, number]): [number, number] | null;
415
+ /** Inverse-project pixels back to `[lng, lat]`. Optional — not all projections have one. */
416
+ invert?: (point: [number, number]) => [number, number] | null;
417
+ /** Scale the projection to fit `extent` around `object`. */
418
+ fitExtent?: (extent: [[number, number], [number, number]], object: unknown) => GeoProjectionLike;
419
+ /** Get/set the projection scale. */
420
+ scale?: {
421
+ (): number;
422
+ (scale: number): GeoProjectionLike;
423
+ };
424
+ /** Get/set the projection translation. */
425
+ translate?: {
426
+ (): [number, number];
427
+ (translate: [number, number]): GeoProjectionLike;
428
+ };
429
+ /** Get/set the projection rotation. */
430
+ rotate?: {
431
+ (): number[];
432
+ (angles: number[]): GeoProjectionLike;
433
+ };
434
+ }
435
+ /** Which boundaries to draw. */
436
+ interface RegionsConfig {
437
+ /** Draw country boundaries. Default `true`. */
438
+ readonly showCountries: boolean;
439
+ /**
440
+ * Draw US state boundaries. Default `false` — it costs a separate ~40 kB
441
+ * lazy chunk. Turn on when US-state-level origins matter visually.
442
+ *
443
+ * Independent of aggregation: `granularity: 'auto'` groups by state whether or
444
+ * not state borders are drawn.
445
+ */
446
+ readonly showStates: boolean;
447
+ /** Draw the graticule (lat/lng grid). Default `false`. */
448
+ readonly showGraticule: boolean;
449
+ /** Graticule stroke color. Default a low-alpha white. */
450
+ readonly graticuleColor: Color;
451
+ /** Draw the sphere outline around the projected globe. Default `true`. */
452
+ readonly showSphere: boolean;
453
+ }
454
+ /**
455
+ * Everything a custom threat renderer needs, handed to it per threat per frame.
456
+ */
457
+ interface ThreatRenderContext<TMeta = unknown> {
458
+ /** The threat being drawn, including its `count` and `intensity`. */
459
+ readonly threat: Threat<TMeta>;
460
+ /**
461
+ * The precomputed arc in screen pixels, flattened to `line.segments + 1`
462
+ * points as a flat `[x0, y0, x1, y1, ...]` array. Already projected, arced,
463
+ * and split at the antimeridian.
464
+ */
465
+ readonly points: Float32Array;
466
+ /** Animation progress of the head along the path, `0`–`1`, easing already applied. */
467
+ readonly progress: number;
468
+ /** Overall opacity `0`–`1`, accounting for fade-in/fade-out. Multiply your alpha by this. */
469
+ readonly alpha: number;
470
+ /** Resolved theme, with defaults filled in. */
471
+ readonly theme: ThreatMapTheme;
472
+ /** Resolved line config, with defaults filled in. */
473
+ readonly line: LineStyleConfig;
474
+ /** Device pixel ratio the canvas is scaled to. The context is already transformed; you rarely need this. */
475
+ readonly pixelRatio: number;
476
+ /** Milliseconds since the animation loop started. Useful for your own time-based effects. */
477
+ readonly elapsed: number;
478
+ }
479
+ /**
480
+ * Draws a single threat, replacing the built-in renderer for it.
481
+ *
482
+ * The context is pre-transformed for device pixel ratio, so draw in CSS pixels.
483
+ * Save/restore is handled around your call; you may mutate context state freely.
484
+ *
485
+ * Returning `false` falls through to the built-in renderer for that threat —
486
+ * useful for customizing only some threats.
487
+ *
488
+ * Note: opting into this hook opts that threat out of style batching, so it
489
+ * costs a draw call of its own. Fine for tens of threats; if you need custom
490
+ * drawing on hundreds, prefer restyling via `theme`/`line`.
491
+ *
492
+ * @example Label the heaviest aggregates
493
+ * ```tsx
494
+ * const renderThreat: ThreatRenderer = (ctx, { threat, points, alpha }) => {
495
+ * if (threat.count < 50) return false; // built-in renderer handles the rest
496
+ * ctx.globalAlpha = alpha;
497
+ * ctx.fillStyle = '#fff';
498
+ * ctx.fillText(`${threat.count}`, points[0], points[1] - 6);
499
+ * return false; // still draw the normal line underneath
500
+ * };
501
+ * ```
502
+ */
503
+ type ThreatRenderer<TMeta = unknown> = (ctx: CanvasRenderingContext2D, info: ThreatRenderContext<TMeta>) => void | boolean;
504
+ /** Everything a custom region renderer needs. */
505
+ interface RegionRenderContext {
506
+ /** The region's GeoJSON feature. */
507
+ readonly feature: GeoFeature;
508
+ /** Canonical region id — `"FR"`, `"US-CA"`. */
509
+ readonly id: string;
510
+ /** Granularity of this feature. */
511
+ readonly kind: Exclude<RegionKind, 'unknown'>;
512
+ /**
513
+ * A d3-geo path generator already bound to this canvas context and the active
514
+ * projection. Call `path(feature)` to lay down the outline, then fill/stroke.
515
+ */
516
+ readonly path: (feature: GeoFeature) => void;
517
+ /** Resolved theme. */
518
+ readonly theme: ThreatMapTheme;
519
+ /** Total attack weight originating in this region this frame — handy for choropleths. */
520
+ readonly weight: number;
521
+ }
522
+ /**
523
+ * Draws a single map region, replacing the built-in fill/stroke for it.
524
+ *
525
+ * Runs only when the base map is (re)rasterized — on mount, resize, and
526
+ * theme/projection change — not per animation frame.
527
+ *
528
+ * Returning `false` falls through to the built-in renderer.
529
+ *
530
+ * @example Heat-shade countries by attack volume
531
+ * ```tsx
532
+ * const renderRegion: RegionRenderer = (ctx, { feature, path, weight, theme }) => {
533
+ * ctx.beginPath();
534
+ * path(feature);
535
+ * ctx.fillStyle = weight > 0 ? `hsl(0 80% ${20 + Math.min(weight, 40)}%)` : theme.land;
536
+ * ctx.fill();
537
+ * return true; // we handled it
538
+ * };
539
+ * ```
540
+ */
541
+ type RegionRenderer = (ctx: CanvasRenderingContext2D, info: RegionRenderContext) => void | boolean;
542
+ /** A minimal GeoJSON geometry, narrowed to what this library draws. */
543
+ type GeoGeometry = {
544
+ readonly type: 'Polygon';
545
+ readonly coordinates: number[][][];
546
+ } | {
547
+ readonly type: 'MultiPolygon';
548
+ readonly coordinates: number[][][][];
549
+ };
550
+ /** A GeoJSON feature for one country or US state. */
551
+ interface GeoFeature {
552
+ readonly type: 'Feature';
553
+ /** Canonical region id — ISO alpha-2 for countries, ISO 3166-2 for states. */
554
+ readonly id: string;
555
+ readonly properties: {
556
+ /** Display name. */
557
+ readonly name: string;
558
+ /** Granularity. */
559
+ readonly kind: 'country' | 'state';
560
+ /** ISO alpha-2 of the containing country. */
561
+ readonly countryCode: string;
562
+ };
563
+ readonly geometry: GeoGeometry;
564
+ }
565
+ /** A GeoJSON feature collection of regions. */
566
+ interface GeoFeatureCollection {
567
+ readonly type: 'FeatureCollection';
568
+ readonly features: readonly GeoFeature[];
569
+ }
570
+ /**
571
+ * The boundary geometry the map draws and reverse region lookup tests against.
572
+ *
573
+ * Loaded lazily by default. Pass your own via {@link ThreatMapProps.geo} to
574
+ * self-host, preload, or substitute different boundaries.
575
+ */
576
+ interface GeoData {
577
+ /** Country boundaries. */
578
+ readonly countries: GeoFeatureCollection;
579
+ /** US state boundaries. Omit if you never enable states or state aggregation. */
580
+ readonly states?: GeoFeatureCollection;
581
+ }
582
+ /** An error the library recovered from, surfaced via {@link ThreatMapProps.onError}. */
583
+ interface ThreatMapError {
584
+ /**
585
+ * - `'geo-load'` — the geo data chunk failed to load. The map stays blank; threats with
586
+ * explicit region codes or coordinates still render.
587
+ * - `'resolve'` — an attack's `from`/`to` could not be resolved to a coordinate. That
588
+ * attack is skipped.
589
+ * - `'render'` — a `renderThreat`/`renderRegion` hook threw. The hook is disabled for
590
+ * the rest of the frame and the built-in renderer takes over.
591
+ */
592
+ readonly kind: 'geo-load' | 'resolve' | 'render';
593
+ /** Human-readable description. */
594
+ readonly message: string;
595
+ /** The underlying error, when there was one. */
596
+ readonly cause?: unknown;
597
+ /** The attack involved, for `'resolve'` errors. */
598
+ readonly attack?: Attack<unknown>;
599
+ }
600
+ /**
601
+ * Props for {@link ThreatMap}.
602
+ *
603
+ * Only `attacks` is required. Every config object is deeply merged over the
604
+ * defaults, so partial overrides are safe and stable.
605
+ */
606
+ interface ThreatMapProps<TMeta = unknown> {
607
+ /**
608
+ * The attacks to display. Treated as the complete current set: attacks that
609
+ * disappear from this array fade out.
610
+ *
611
+ * For streaming feeds, keep this array referentially stable between updates —
612
+ * a new array identity re-runs resolution and aggregation. Resolution is
613
+ * memoized per attack `id`, so appending to a growing array is cheap.
614
+ */
615
+ readonly attacks: readonly Attack<TMeta>[];
616
+ /** Width in CSS pixels. Omit to fill the container width and track it via `ResizeObserver`. */
617
+ readonly width?: number;
618
+ /** Height in CSS pixels. Omit to derive from width and the projection's aspect ratio. */
619
+ readonly height?: number;
620
+ /** Projection name or a d3-geo projection instance. Default `'naturalEarth1'`. */
621
+ readonly projection?: ProjectionSpec;
622
+ /** Color overrides, merged over {@link defaultTheme}. */
623
+ readonly theme?: Partial<ThreatMapTheme>;
624
+ /** Arc geometry/styling overrides, merged over {@link defaultLineStyle}. */
625
+ readonly line?: Partial<LineStyleConfig>;
626
+ /** Animation overrides, merged over {@link defaultAnimation}. */
627
+ readonly animation?: Partial<AnimationConfig>;
628
+ /** Boundary-drawing overrides, merged over {@link defaultRegions}. */
629
+ readonly regions?: Partial<RegionsConfig>;
630
+ /**
631
+ * Aggregation overrides, merged over {@link defaultAggregation}. Pass `false`
632
+ * to render one line per attack.
633
+ */
634
+ readonly aggregation?: Partial<AggregationConfig<TMeta>> | false;
635
+ /** Override how a threat is drawn. See {@link ThreatRenderer}. */
636
+ readonly renderThreat?: ThreatRenderer<TMeta>;
637
+ /** Override how a region is drawn. See {@link RegionRenderer}. */
638
+ readonly renderRegion?: RegionRenderer;
639
+ /**
640
+ * Supply geo data instead of lazy-loading it. Accepts the data directly or a
641
+ * loader function. Use to self-host the JSON, preload during app boot, or swap
642
+ * in different boundaries.
643
+ */
644
+ readonly geo?: GeoData | (() => Promise<GeoData>);
645
+ /** Called when a threat is clicked. Hit testing uses the arc, within a few pixels. */
646
+ readonly onThreatClick?: (threat: Threat<TMeta>, event: MouseEvent) => void;
647
+ /** Called when the pointer enters/leaves a threat. `null` on leave. */
648
+ readonly onThreatHover?: (threat: Threat<TMeta> | null, event: MouseEvent) => void;
649
+ /** Called for recoverable errors. Without a handler these are logged in dev and swallowed in prod. */
650
+ readonly onError?: (error: ThreatMapError) => void;
651
+ /** Applied to the wrapper element. */
652
+ readonly className?: string;
653
+ /** Applied to the wrapper element. The canvases fill it. */
654
+ readonly style?: React.CSSProperties;
655
+ /** Accessible label for the map. Default `"Cyberattack threat map"`. */
656
+ readonly ariaLabel?: string;
657
+ }
658
+
659
+ /**
660
+ * Reverse geo lookup: a raw `{lat, lng}` → the region that contains it.
661
+ *
662
+ * Only needed when a consumer supplies bare coordinates and wants aggregation.
663
+ * Attacks carrying an explicit region code skip this entirely — which is why
664
+ * `{ lat, lng, region }` is the recommended shape for high-volume feeds.
665
+ *
666
+ * @packageDocumentation
667
+ */
668
+
669
+ /**
670
+ * Point-in-region index over a {@link GeoData} set.
671
+ *
672
+ * Naive point-in-polygon against ~230 features costs ~230 polygon walks per
673
+ * query, and a 500-attack feed re-resolving on every update makes that the
674
+ * dominant cost in the whole pipeline. Three things fix it:
675
+ *
676
+ * 1. **Bounding-box rejection.** Four float compares eliminate ~99% of
677
+ * candidates before any polygon walk. Boxes are computed once at construction.
678
+ * 2. **States before countries.** A point inside California is also inside the
679
+ * US; testing states first means `'auto'` granularity gets the specific answer
680
+ * without testing twice.
681
+ * 3. **Memoization on a rounded key.** Attack feeds repeat origins heavily
682
+ * (the same datacenter, over and over), so a cache keyed on coordinates
683
+ * rounded to ~1 km turns the common case into a Map hit.
684
+ */
685
+ declare class RegionIndex {
686
+ #private;
687
+ constructor(geo: GeoData);
688
+ /** Whether US state geometry is present, and so whether state resolution can succeed. */
689
+ get hasStates(): boolean;
690
+ /**
691
+ * Find the region containing a point.
692
+ *
693
+ * @param point - Coordinates in degrees.
694
+ * @param preferStates - When `true`, return the US state if the point is in one.
695
+ * When `false`, always return the country. Maps to aggregation granularity.
696
+ * @returns The containing region, or {@link UNKNOWN_REGION} for points in the
697
+ * ocean or outside all known boundaries.
698
+ */
699
+ resolve(point: LatLng, preferStates: boolean): ResolvedRegion;
700
+ }
701
+
702
+ /**
703
+ * The inline region table: canonical codes and anchor coordinates for every
704
+ * country and US state.
705
+ *
706
+ * This is the one piece of geo data that is **not** lazy-loaded. It is small
707
+ * (~20 kB raw, ~6 kB gzipped) and it must be synchronous, because resolving
708
+ * `"US-CA"` to a coordinate is what lets aggregation stay a pure function with
709
+ * no async and no fetch. Boundary geometry — 100× larger and only needed to
710
+ * *draw* — is loaded separately. See DECISIONS.md §3.
711
+ *
712
+ * @packageDocumentation
713
+ */
714
+
715
+ /** A region entry with its anchor coordinate. */
716
+ interface RegionEntry extends ResolvedRegion {
717
+ /** The region's anchor point — see DECISIONS.md §3 on why this is not a true centroid. */
718
+ readonly anchor: LatLng;
719
+ /** ISO alpha-3 code. `null` for US states. */
720
+ readonly alpha3: string | null;
721
+ }
722
+ /**
723
+ * The sentinel region used when a location cannot be resolved. Never throws —
724
+ * unresolvable input degrades to this so one bad row cannot blank the map.
725
+ */
726
+ declare const UNKNOWN_REGION: ResolvedRegion;
727
+ /**
728
+ * Look up a region by its canonical id.
729
+ *
730
+ * @param id - `"FR"` or `"US-CA"`. Case-sensitive; use {@link lookupRegionCode} for user input.
731
+ * @returns The region, or `undefined`.
732
+ */
733
+ declare function getRegionById(id: string): RegionEntry | undefined;
734
+ /**
735
+ * Resolve a user-supplied region code to a region.
736
+ *
737
+ * Matching order — see {@link RegionCode} for the full contract:
738
+ * 1. Hyphenated (`"US-CA"`) → US state, exact.
739
+ * 2. Two letters (`"FR"`) → country first, then falling back to a US state.
740
+ * This is why `"CA"` is Canada and `"TX"` is Texas: `CA` is a real country
741
+ * code and `TX` is not.
742
+ * 3. Three letters (`"FRA"`) → country by alpha-3.
743
+ *
744
+ * @param code - The identifier. Leading/trailing space and case are ignored.
745
+ * @returns The matching region, or `undefined` if nothing matched.
746
+ *
747
+ * @example
748
+ * ```ts
749
+ * lookupRegionCode('us-ca')?.name; // 'California'
750
+ * lookupRegionCode('CA')?.name; // 'Canada' (country wins)
751
+ * lookupRegionCode('TX')?.name; // 'Texas' (no country 'TX')
752
+ * lookupRegionCode('FRA')?.name; // 'France'
753
+ * ```
754
+ */
755
+ declare function lookupRegionCode(code: string): RegionEntry | undefined;
756
+ /**
757
+ * Every region the library knows about, countries and US states.
758
+ *
759
+ * Useful for building region pickers or validating a feed before rendering.
760
+ *
761
+ * @returns A new array; the underlying entries are frozen and shared.
762
+ */
763
+ declare function listRegions(): RegionEntry[];
764
+
765
+ export { type AnimationConfig as A, type ThreatRenderContext as B, type Color as C, type ThreatRenderer as D, type EasingName as E, type GeoData as G, type IntensityScale as I, type LineStyleConfig as L, type ProjectionName as P, type RegionEntry as R, type Severity as S, type ThreatMapProps as T, UNKNOWN_REGION as U, RegionIndex as a, lookupRegionCode as b, type RegionsConfig as c, type ThreatMapTheme as d, type AggregationConfig as e, type Attack as f, getRegionById as g, type Threat as h, type ResolvedRegion as i, type AggregationGranularity as j, type LatLng as k, listRegions as l, type AttackLocation as m, type AggregationGroupBy as n, type AggregationKeyFn as o, type AggregationSeverityFn as p, type GeoFeature as q, type GeoFeatureCollection as r, type GeoGeometry as s, type GeoProjectionLike as t, type ProjectionSpec as u, type RegionCode as v, type RegionKind as w, type RegionRenderContext as x, type RegionRenderer as y, type ThreatMapError as z };