react-threat-map 0.1.2 → 0.2.1

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 CHANGED
@@ -247,7 +247,24 @@ Every runtime dependency, and why it earns its place:
247
247
  Both are declared `external`, so consumers dedupe them against their own copies of d3
248
248
  rather than shipping a second one.
249
249
 
250
- `react` is a **peer** dependency (>=18), never bundled.
250
+ `react` is a **peer** dependency (>=16.14.0), never bundled.
251
+
252
+ The floor is 16.14.0, not 16.8.0 (hooks) as one might expect: the compiled output uses
253
+ the automatic JSX runtime, so it imports `react/jsx-runtime`, and that entry point first
254
+ ships in **16.14.0**. On 16.8–16.13 the package installs and then fails at bundle time on
255
+ an unresolved module, so those versions must stay outside the range.
256
+
257
+ Nothing in the library needs more than that floor — it uses only `useState`, `useEffect`,
258
+ `useRef`, `useMemo`, and `useCallback`. The range was originally `>=18` for no recorded
259
+ reason, which excluded working consumers on React 16.14/17; widened in 0.2.1 after
260
+ verifying typecheck and a mount/re-render/unmount render pass on 16.14, 17, 18, and 19.
261
+
262
+ Supporting that range is also why the public types import `CSSProperties`, `MouseEvent`,
263
+ `ReactElement`, and `RefObject` **by name** from `react` rather than reaching through the
264
+ `React` UMD global or the global `JSX` namespace. A bare `JSX.Element` in an emitted `.d.ts`
265
+ resolves against whatever `@types/react` the consumer has — and `@types/react@19` removed
266
+ the global `JSX` namespace, so it broke React 19 consumers compiling with
267
+ `skipLibCheck: false`. Named type imports are identical across `@types/react` 16 to 19.
251
268
 
252
269
  Deliberately **not** dependencies: no state manager, no styling library, no CSS-in-JS, no
253
270
  animation library, no `d3-selection`/`d3-zoom`/`d3-scale`. The library ships zero CSS —
@@ -273,3 +290,59 @@ one line per origin→destination region pair. Pure origin collapsing is availab
273
290
  containing one critical and forty low attacks renders critical. For a security display,
274
291
  under-reporting the worst thing in a bucket is the more dangerous failure mode. This is
275
292
  overridable via `aggregation.severity`.
293
+
294
+ ---
295
+
296
+ ## 6. Same-place threats render as a loop, not a point
297
+
298
+ An attack can legitimately begin and end in the same place. Germany has no subdivisions in
299
+ the region table, so a domestic German incident is `DE → DE` and both ends resolve to one
300
+ country anchor. The same happens for `US-CA → US-CA`, and for two hosts given identical
301
+ city coordinates.
302
+
303
+ Geometrically this is a zero-length chord, and the arc builder's whole model — sample a
304
+ geodesic, push each sample perpendicular to the chord by a sine-weighted lift — degenerates:
305
+ there is no direction to travel and no perpendicular to lift along. Every sample lands on
306
+ the same pixel.
307
+
308
+ ### What that used to look like
309
+
310
+ Nothing, essentially. The renderer accumulates polylines and skips any arc of zero length,
311
+ so a same-place threat drew no track and no trail. What remained was the origin dot, the
312
+ head sitting on top of it, and the impact ripple firing in place. It also could not be
313
+ hovered: hit-testing measures distance to line segments, and there were none.
314
+
315
+ That is a defensible reading — the library has one coordinate for California, so it cannot
316
+ draw a journey across it — but it is the wrong one for a threat map. A domestic incident is
317
+ an event, and it disappeared into a dot indistinguishable from a static origin marker.
318
+
319
+ ### What it does instead
320
+
321
+ `buildArc` detects the degenerate case and emits a circle tangent to the shared point: the
322
+ origin marker sits on the point, the head travels the loop, and the ripple fires back on the
323
+ point as it closes. It is ordinary geometry, so batching, fading, and hit-testing all work
324
+ with no special cases downstream.
325
+
326
+ Three parameter choices, all of which were the actual decisions:
327
+
328
+ **The threshold is half a pixel, not exact equality.** Endpoints do not have to be *equal*
329
+ to be indistinguishable. Two hosts in one city, or any pair on a far-zoomed view, project
330
+ sub-pixel apart. A half-pixel line cannot be seen or hovered, so drawing it as a line is
331
+ strictly worse than drawing it as a loop. The cost is that two genuinely distinct points
332
+ can render as a loop when the viewport is zoomed far enough out.
333
+
334
+ **The radius is clamped in pixels (6–18), scaled off the map's lift ceiling.** A radius
335
+ derived from the geometry would be zero, and one derived from the viewport alone would
336
+ vanish on a world map — which is exactly where a same-city attack most needs to stay
337
+ visible.
338
+
339
+ **The radius ignores `curvature`.** Tempting, since curvature controls arc height
340
+ elsewhere. But curvature is the height of a lift applied to a chord, and a self-loop has no
341
+ chord. Coupling them would make `curvature: 0` — a reasonable choice for flat, straight
342
+ lines — silently erase every self-directed threat on the map.
343
+
344
+ ### Tradeoff accepted
345
+
346
+ Consumers upgrading from 0.1.x see new animated loops wherever their feed contains
347
+ same-region attacks that previously rendered as static dots. This is why the change went
348
+ out as a minor bump rather than a patch.
package/README.md CHANGED
@@ -49,12 +49,17 @@ import { ThreatMap } from 'react-threat-map';
49
49
  npm install react-threat-map
50
50
  ```
51
51
 
52
- React 18+ is a peer dependency:
52
+ React is a peer dependency:
53
53
 
54
54
  ```json
55
- { "peerDependencies": { "react": ">=18.0.0" } }
55
+ { "peerDependencies": { "react": ">=16.14.0" } }
56
56
  ```
57
57
 
58
+ 16.14.0 is the floor because that is the first release to ship `react/jsx-runtime`,
59
+ which the compiled output imports. The library uses only `useState`, `useEffect`,
60
+ `useRef`, `useMemo`, and `useCallback`, so nothing above that floor is required —
61
+ React 16.14, 17, 18, and 19 are all supported.
62
+
58
63
  ---
59
64
 
60
65
  ## Quick start
@@ -315,6 +320,35 @@ arcs render statically and the component costs nothing per frame. The map also r
315
320
  `prefers-reduced-motion` automatically; opt out with
316
321
  `animation={{ respectReducedMotion: false }}`.
317
322
 
323
+ ### Attacks that start and end in the same place
324
+
325
+ An attack whose origin and destination resolve to the same point — a domestic incident in
326
+ a country with no subdivisions, lateral movement inside one US state, two hosts in one
327
+ city — has no chord to draw a line along:
328
+
329
+ ```tsx
330
+ <ThreatMap
331
+ attacks={[
332
+ { id: '1', from: 'DE', to: 'DE', severity: 'high' },
333
+ { id: '2', from: 'US-CA', to: 'US-CA', severity: 'critical' },
334
+ { id: '3', from: { lat: 50.11, lng: 8.68 }, to: { lat: 50.11, lng: 8.68 } },
335
+ ]}
336
+ />
337
+ ```
338
+
339
+ These render as a **self-loop**: a small circle tangent to the shared point, with the
340
+ origin marker on the point, the head travelling the loop, and the impact ripple firing
341
+ back on the same spot. It is hoverable like any other arc.
342
+
343
+ The loop radius scales with the map and is clamped to 6–18 px, so a same-place attack
344
+ stays visible on a world map instead of shrinking to nothing. It deliberately does **not**
345
+ scale with `curvature`: curvature is the height of a lift applied to a chord, and a
346
+ self-loop has no chord — tying them together would make `curvature: 0` erase these
347
+ threats entirely.
348
+
349
+ Endpoints do not have to be exactly equal. Anything projecting to within half a pixel
350
+ counts as the same place, because a sub-pixel line cannot be seen or hovered anyway.
351
+
318
352
  ### Projections
319
353
 
320
354
  ```tsx
@@ -625,8 +659,9 @@ npm run dev
625
659
 
626
660
  [DECISIONS.md](./DECISIONS.md) covers the load-bearing choices and their tradeoffs:
627
661
  why `d3-geo` + Canvas over MapLibre/Leaflet/react-simple-maps, why Canvas 2D over SVG and
628
- WebGL, how the Natural Earth data is bundled and split, and why resolving and drawing
629
- deliberately use different datasets.
662
+ WebGL, how the Natural Earth data is bundled and split, why resolving and drawing
663
+ deliberately use different datasets, and why an attack that starts and ends in the same
664
+ place is drawn as a loop rather than a point.
630
665
 
631
666
  ## Data
632
667
 
package/dist/geo.d.cts CHANGED
@@ -1,5 +1,6 @@
1
- import { G as GeoData } from './regions-neHSPgtu.cjs';
2
- export { R as RegionEntry, a as RegionIndex, U as UNKNOWN_REGION, g as getRegionById, l as listRegions, b as lookupRegionCode } from './regions-neHSPgtu.cjs';
1
+ import { G as GeoData } from './regions-Bsys6EFr.cjs';
2
+ export { R as RegionEntry, a as RegionIndex, U as UNKNOWN_REGION, g as getRegionById, l as listRegions, b as lookupRegionCode } from './regions-Bsys6EFr.cjs';
3
+ import 'react';
3
4
 
4
5
  /**
5
6
  * Boundary geometry loading — the `react-threat-map/geo` entry point.
package/dist/geo.d.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { G as GeoData } from './regions-neHSPgtu.js';
2
- export { R as RegionEntry, a as RegionIndex, U as UNKNOWN_REGION, g as getRegionById, l as listRegions, b as lookupRegionCode } from './regions-neHSPgtu.js';
1
+ import { G as GeoData } from './regions-Bsys6EFr.js';
2
+ export { R as RegionEntry, a as RegionIndex, U as UNKNOWN_REGION, g as getRegionById, l as listRegions, b as lookupRegionCode } from './regions-Bsys6EFr.js';
3
+ import 'react';
3
4
 
4
5
  /**
5
6
  * Boundary geometry loading — the `react-threat-map/geo` entry point.
package/dist/index.cjs CHANGED
@@ -592,6 +592,7 @@ function buildArc(from, to, projection, curvature, segments, maxLift = Infinity)
592
592
  const dx = endPx[0] - startPx[0];
593
593
  const dy = endPx[1] - startPx[1];
594
594
  const chord = Math.hypot(dx, dy);
595
+ if (chord <= SAME_PLACE_EPSILON) return buildSelfLoop(startPx, count, steps, maxLift);
595
596
  const nx = chord > 0 ? -dy / chord : 0;
596
597
  const ny = chord > 0 ? dx / chord : 0;
597
598
  const rawLift = chord * curvature;
@@ -628,6 +629,28 @@ function buildArc(from, to, projection, curvature, segments, maxLift = Infinity)
628
629
  return { points, breakAt, distances, length };
629
630
  }
630
631
  var ANTIMERIDIAN_JUMP = 180;
632
+ var SAME_PLACE_EPSILON = 0.5;
633
+ var SELF_LOOP_LIFT_FRACTION = 0.06;
634
+ var SELF_LOOP_MIN_RADIUS = 6;
635
+ var SELF_LOOP_MAX_RADIUS = 18;
636
+ var SELF_LOOP_DEFAULT_RADIUS = 10;
637
+ function selfLoopRadius(maxLift) {
638
+ const derived = Number.isFinite(maxLift) ? maxLift * SELF_LOOP_LIFT_FRACTION : SELF_LOOP_DEFAULT_RADIUS;
639
+ return Math.min(SELF_LOOP_MAX_RADIUS, Math.max(SELF_LOOP_MIN_RADIUS, derived));
640
+ }
641
+ function buildSelfLoop(center, count, steps, maxLift) {
642
+ const radius = selfLoopRadius(maxLift);
643
+ const points = new Float32Array(count * 2);
644
+ const cx = center[0];
645
+ const cy = center[1] - radius;
646
+ for (let i = 0; i < count; i++) {
647
+ const angle = i / steps * Math.PI * 2;
648
+ points[i * 2] = cx + Math.sin(angle) * radius;
649
+ points[i * 2 + 1] = cy + Math.cos(angle) * radius;
650
+ }
651
+ const { distances, length } = measure(points, count, -1);
652
+ return { points, breakAt: -1, distances, length };
653
+ }
631
654
  function isFinitePoint(p) {
632
655
  return Number.isFinite(p[0]) && Number.isFinite(p[1]);
633
656
  }
package/dist/index.d.cts CHANGED
@@ -1,5 +1,6 @@
1
- import { T as ThreatMapProps, A as AnimationConfig, L as LineStyleConfig, c as RegionsConfig, d as ThreatMapTheme, e as AggregationConfig, a as RegionIndex, f as Attack, h as Threat, I as IntensityScale, S as Severity, i as ResolvedRegion, j as AggregationGranularity, k as LatLng, m as AttackLocation } from './regions-neHSPgtu.cjs';
2
- export { n as AggregationGroupBy, o as AggregationKeyFn, p as AggregationSeverityFn, C as Color, E as EasingName, G as GeoData, q as GeoFeature, r as GeoFeatureCollection, s as GeoGeometry, t as GeoProjectionLike, P as ProjectionName, u as ProjectionSpec, v as RegionCode, R as RegionEntry, w as RegionKind, x as RegionRenderContext, y as RegionRenderer, z as ThreatMapError, B as ThreatRenderContext, D as ThreatRenderer, U as UNKNOWN_REGION, g as getRegionById, l as listRegions, b as lookupRegionCode } from './regions-neHSPgtu.cjs';
1
+ import { ReactElement } from 'react';
2
+ import { T as ThreatMapProps, A as AnimationConfig, L as LineStyleConfig, c as RegionsConfig, d as ThreatMapTheme, e as AggregationConfig, a as RegionIndex, f as Attack, h as Threat, I as IntensityScale, S as Severity, i as ResolvedRegion, j as AggregationGranularity, k as LatLng, m as AttackLocation } from './regions-Bsys6EFr.cjs';
3
+ export { n as AggregationGroupBy, o as AggregationKeyFn, p as AggregationSeverityFn, C as Color, E as EasingName, G as GeoData, q as GeoFeature, r as GeoFeatureCollection, s as GeoGeometry, t as GeoProjectionLike, P as ProjectionName, u as ProjectionSpec, v as RegionCode, R as RegionEntry, w as RegionKind, x as RegionRenderContext, y as RegionRenderer, z as ThreatMapError, B as ThreatRenderContext, D as ThreatRenderer, U as UNKNOWN_REGION, g as getRegionById, l as listRegions, b as lookupRegionCode } from './regions-Bsys6EFr.cjs';
3
4
 
4
5
  /**
5
6
  * The `<ThreatMap>` component.
@@ -37,7 +38,7 @@ export { n as AggregationGroupBy, o as AggregationKeyFn, p as AggregationSeverit
37
38
  * />
38
39
  * ```
39
40
  */
40
- declare function ThreatMap<TMeta = unknown>(props: ThreatMapProps<TMeta>): JSX.Element;
41
+ declare function ThreatMap<TMeta = unknown>(props: ThreatMapProps<TMeta>): ReactElement;
41
42
 
42
43
  /**
43
44
  * Default theme and configuration objects.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { T as ThreatMapProps, A as AnimationConfig, L as LineStyleConfig, c as RegionsConfig, d as ThreatMapTheme, e as AggregationConfig, a as RegionIndex, f as Attack, h as Threat, I as IntensityScale, S as Severity, i as ResolvedRegion, j as AggregationGranularity, k as LatLng, m as AttackLocation } from './regions-neHSPgtu.js';
2
- export { n as AggregationGroupBy, o as AggregationKeyFn, p as AggregationSeverityFn, C as Color, E as EasingName, G as GeoData, q as GeoFeature, r as GeoFeatureCollection, s as GeoGeometry, t as GeoProjectionLike, P as ProjectionName, u as ProjectionSpec, v as RegionCode, R as RegionEntry, w as RegionKind, x as RegionRenderContext, y as RegionRenderer, z as ThreatMapError, B as ThreatRenderContext, D as ThreatRenderer, U as UNKNOWN_REGION, g as getRegionById, l as listRegions, b as lookupRegionCode } from './regions-neHSPgtu.js';
1
+ import { ReactElement } from 'react';
2
+ import { T as ThreatMapProps, A as AnimationConfig, L as LineStyleConfig, c as RegionsConfig, d as ThreatMapTheme, e as AggregationConfig, a as RegionIndex, f as Attack, h as Threat, I as IntensityScale, S as Severity, i as ResolvedRegion, j as AggregationGranularity, k as LatLng, m as AttackLocation } from './regions-Bsys6EFr.js';
3
+ export { n as AggregationGroupBy, o as AggregationKeyFn, p as AggregationSeverityFn, C as Color, E as EasingName, G as GeoData, q as GeoFeature, r as GeoFeatureCollection, s as GeoGeometry, t as GeoProjectionLike, P as ProjectionName, u as ProjectionSpec, v as RegionCode, R as RegionEntry, w as RegionKind, x as RegionRenderContext, y as RegionRenderer, z as ThreatMapError, B as ThreatRenderContext, D as ThreatRenderer, U as UNKNOWN_REGION, g as getRegionById, l as listRegions, b as lookupRegionCode } from './regions-Bsys6EFr.js';
3
4
 
4
5
  /**
5
6
  * The `<ThreatMap>` component.
@@ -37,7 +38,7 @@ export { n as AggregationGroupBy, o as AggregationKeyFn, p as AggregationSeverit
37
38
  * />
38
39
  * ```
39
40
  */
40
- declare function ThreatMap<TMeta = unknown>(props: ThreatMapProps<TMeta>): JSX.Element;
41
+ declare function ThreatMap<TMeta = unknown>(props: ThreatMapProps<TMeta>): ReactElement;
41
42
 
42
43
  /**
43
44
  * Default theme and configuration objects.
package/dist/index.js CHANGED
@@ -591,6 +591,7 @@ function buildArc(from, to, projection, curvature, segments, maxLift = Infinity)
591
591
  const dx = endPx[0] - startPx[0];
592
592
  const dy = endPx[1] - startPx[1];
593
593
  const chord = Math.hypot(dx, dy);
594
+ if (chord <= SAME_PLACE_EPSILON) return buildSelfLoop(startPx, count, steps, maxLift);
594
595
  const nx = chord > 0 ? -dy / chord : 0;
595
596
  const ny = chord > 0 ? dx / chord : 0;
596
597
  const rawLift = chord * curvature;
@@ -627,6 +628,28 @@ function buildArc(from, to, projection, curvature, segments, maxLift = Infinity)
627
628
  return { points, breakAt, distances, length };
628
629
  }
629
630
  var ANTIMERIDIAN_JUMP = 180;
631
+ var SAME_PLACE_EPSILON = 0.5;
632
+ var SELF_LOOP_LIFT_FRACTION = 0.06;
633
+ var SELF_LOOP_MIN_RADIUS = 6;
634
+ var SELF_LOOP_MAX_RADIUS = 18;
635
+ var SELF_LOOP_DEFAULT_RADIUS = 10;
636
+ function selfLoopRadius(maxLift) {
637
+ const derived = Number.isFinite(maxLift) ? maxLift * SELF_LOOP_LIFT_FRACTION : SELF_LOOP_DEFAULT_RADIUS;
638
+ return Math.min(SELF_LOOP_MAX_RADIUS, Math.max(SELF_LOOP_MIN_RADIUS, derived));
639
+ }
640
+ function buildSelfLoop(center, count, steps, maxLift) {
641
+ const radius = selfLoopRadius(maxLift);
642
+ const points = new Float32Array(count * 2);
643
+ const cx = center[0];
644
+ const cy = center[1] - radius;
645
+ for (let i = 0; i < count; i++) {
646
+ const angle = i / steps * Math.PI * 2;
647
+ points[i * 2] = cx + Math.sin(angle) * radius;
648
+ points[i * 2 + 1] = cy + Math.cos(angle) * radius;
649
+ }
650
+ const { distances, length } = measure(points, count, -1);
651
+ return { points, breakAt: -1, distances, length };
652
+ }
630
653
  function isFinitePoint(p) {
631
654
  return Number.isFinite(p[0]) && Number.isFinite(p[1]);
632
655
  }
@@ -1,3 +1,5 @@
1
+ import { CSSProperties } from 'react';
2
+
1
3
  /**
2
4
  * Public data types for `react-threat-map`.
3
5
  *
@@ -6,6 +8,7 @@
6
8
  *
7
9
  * @packageDocumentation
8
10
  */
11
+
9
12
  /**
10
13
  * A raw geographic coordinate in degrees (WGS84).
11
14
  *
@@ -651,7 +654,7 @@ interface ThreatMapProps<TMeta = unknown> {
651
654
  /** Applied to the wrapper element. */
652
655
  readonly className?: string;
653
656
  /** Applied to the wrapper element. The canvases fill it. */
654
- readonly style?: React.CSSProperties;
657
+ readonly style?: CSSProperties;
655
658
  /** Accessible label for the map. Default `"Cyberattack threat map"`. */
656
659
  readonly ariaLabel?: string;
657
660
  }
@@ -1,3 +1,5 @@
1
+ import { CSSProperties } from 'react';
2
+
1
3
  /**
2
4
  * Public data types for `react-threat-map`.
3
5
  *
@@ -6,6 +8,7 @@
6
8
  *
7
9
  * @packageDocumentation
8
10
  */
11
+
9
12
  /**
10
13
  * A raw geographic coordinate in degrees (WGS84).
11
14
  *
@@ -651,7 +654,7 @@ interface ThreatMapProps<TMeta = unknown> {
651
654
  /** Applied to the wrapper element. */
652
655
  readonly className?: string;
653
656
  /** Applied to the wrapper element. The canvases fill it. */
654
- readonly style?: React.CSSProperties;
657
+ readonly style?: CSSProperties;
655
658
  /** Accessible label for the map. Default `"Cyberattack threat map"`. */
656
659
  readonly ariaLabel?: string;
657
660
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-threat-map",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "description": "A React component that renders animated cyberattack threats on a static world map, with intelligent per-region aggregation and first-class US state support.",
5
5
  "keywords": [
6
6
  "react",
@@ -64,7 +64,7 @@
64
64
  "example": "npm --prefix examples/demo run dev"
65
65
  },
66
66
  "peerDependencies": {
67
- "react": ">=18.0.0"
67
+ "react": ">=16.14.0"
68
68
  },
69
69
  "dependencies": {
70
70
  "d3-geo": "^3.1.1",