react-threat-map 0.1.1 → 0.2.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 +56 -0
- package/README.md +32 -2
- package/dist/index.cjs +72 -9
- package/dist/index.js +72 -9
- package/package.json +1 -1
package/DECISIONS.md
CHANGED
|
@@ -273,3 +273,59 @@ one line per origin→destination region pair. Pure origin collapsing is availab
|
|
|
273
273
|
containing one critical and forty low attacks renders critical. For a security display,
|
|
274
274
|
under-reporting the worst thing in a bucket is the more dangerous failure mode. This is
|
|
275
275
|
overridable via `aggregation.severity`.
|
|
276
|
+
|
|
277
|
+
---
|
|
278
|
+
|
|
279
|
+
## 6. Same-place threats render as a loop, not a point
|
|
280
|
+
|
|
281
|
+
An attack can legitimately begin and end in the same place. Germany has no subdivisions in
|
|
282
|
+
the region table, so a domestic German incident is `DE → DE` and both ends resolve to one
|
|
283
|
+
country anchor. The same happens for `US-CA → US-CA`, and for two hosts given identical
|
|
284
|
+
city coordinates.
|
|
285
|
+
|
|
286
|
+
Geometrically this is a zero-length chord, and the arc builder's whole model — sample a
|
|
287
|
+
geodesic, push each sample perpendicular to the chord by a sine-weighted lift — degenerates:
|
|
288
|
+
there is no direction to travel and no perpendicular to lift along. Every sample lands on
|
|
289
|
+
the same pixel.
|
|
290
|
+
|
|
291
|
+
### What that used to look like
|
|
292
|
+
|
|
293
|
+
Nothing, essentially. The renderer accumulates polylines and skips any arc of zero length,
|
|
294
|
+
so a same-place threat drew no track and no trail. What remained was the origin dot, the
|
|
295
|
+
head sitting on top of it, and the impact ripple firing in place. It also could not be
|
|
296
|
+
hovered: hit-testing measures distance to line segments, and there were none.
|
|
297
|
+
|
|
298
|
+
That is a defensible reading — the library has one coordinate for California, so it cannot
|
|
299
|
+
draw a journey across it — but it is the wrong one for a threat map. A domestic incident is
|
|
300
|
+
an event, and it disappeared into a dot indistinguishable from a static origin marker.
|
|
301
|
+
|
|
302
|
+
### What it does instead
|
|
303
|
+
|
|
304
|
+
`buildArc` detects the degenerate case and emits a circle tangent to the shared point: the
|
|
305
|
+
origin marker sits on the point, the head travels the loop, and the ripple fires back on the
|
|
306
|
+
point as it closes. It is ordinary geometry, so batching, fading, and hit-testing all work
|
|
307
|
+
with no special cases downstream.
|
|
308
|
+
|
|
309
|
+
Three parameter choices, all of which were the actual decisions:
|
|
310
|
+
|
|
311
|
+
**The threshold is half a pixel, not exact equality.** Endpoints do not have to be *equal*
|
|
312
|
+
to be indistinguishable. Two hosts in one city, or any pair on a far-zoomed view, project
|
|
313
|
+
sub-pixel apart. A half-pixel line cannot be seen or hovered, so drawing it as a line is
|
|
314
|
+
strictly worse than drawing it as a loop. The cost is that two genuinely distinct points
|
|
315
|
+
can render as a loop when the viewport is zoomed far enough out.
|
|
316
|
+
|
|
317
|
+
**The radius is clamped in pixels (6–18), scaled off the map's lift ceiling.** A radius
|
|
318
|
+
derived from the geometry would be zero, and one derived from the viewport alone would
|
|
319
|
+
vanish on a world map — which is exactly where a same-city attack most needs to stay
|
|
320
|
+
visible.
|
|
321
|
+
|
|
322
|
+
**The radius ignores `curvature`.** Tempting, since curvature controls arc height
|
|
323
|
+
elsewhere. But curvature is the height of a lift applied to a chord, and a self-loop has no
|
|
324
|
+
chord. Coupling them would make `curvature: 0` — a reasonable choice for flat, straight
|
|
325
|
+
lines — silently erase every self-directed threat on the map.
|
|
326
|
+
|
|
327
|
+
### Tradeoff accepted
|
|
328
|
+
|
|
329
|
+
Consumers upgrading from 0.1.x see new animated loops wherever their feed contains
|
|
330
|
+
same-region attacks that previously rendered as static dots. This is why the change went
|
|
331
|
+
out as a minor bump rather than a patch.
|
package/README.md
CHANGED
|
@@ -315,6 +315,35 @@ arcs render statically and the component costs nothing per frame. The map also r
|
|
|
315
315
|
`prefers-reduced-motion` automatically; opt out with
|
|
316
316
|
`animation={{ respectReducedMotion: false }}`.
|
|
317
317
|
|
|
318
|
+
### Attacks that start and end in the same place
|
|
319
|
+
|
|
320
|
+
An attack whose origin and destination resolve to the same point — a domestic incident in
|
|
321
|
+
a country with no subdivisions, lateral movement inside one US state, two hosts in one
|
|
322
|
+
city — has no chord to draw a line along:
|
|
323
|
+
|
|
324
|
+
```tsx
|
|
325
|
+
<ThreatMap
|
|
326
|
+
attacks={[
|
|
327
|
+
{ id: '1', from: 'DE', to: 'DE', severity: 'high' },
|
|
328
|
+
{ id: '2', from: 'US-CA', to: 'US-CA', severity: 'critical' },
|
|
329
|
+
{ id: '3', from: { lat: 50.11, lng: 8.68 }, to: { lat: 50.11, lng: 8.68 } },
|
|
330
|
+
]}
|
|
331
|
+
/>
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
These render as a **self-loop**: a small circle tangent to the shared point, with the
|
|
335
|
+
origin marker on the point, the head travelling the loop, and the impact ripple firing
|
|
336
|
+
back on the same spot. It is hoverable like any other arc.
|
|
337
|
+
|
|
338
|
+
The loop radius scales with the map and is clamped to 6–18 px, so a same-place attack
|
|
339
|
+
stays visible on a world map instead of shrinking to nothing. It deliberately does **not**
|
|
340
|
+
scale with `curvature`: curvature is the height of a lift applied to a chord, and a
|
|
341
|
+
self-loop has no chord — tying them together would make `curvature: 0` erase these
|
|
342
|
+
threats entirely.
|
|
343
|
+
|
|
344
|
+
Endpoints do not have to be exactly equal. Anything projecting to within half a pixel
|
|
345
|
+
counts as the same place, because a sub-pixel line cannot be seen or hovered anyway.
|
|
346
|
+
|
|
318
347
|
### Projections
|
|
319
348
|
|
|
320
349
|
```tsx
|
|
@@ -625,8 +654,9 @@ npm run dev
|
|
|
625
654
|
|
|
626
655
|
[DECISIONS.md](./DECISIONS.md) covers the load-bearing choices and their tradeoffs:
|
|
627
656
|
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,
|
|
629
|
-
deliberately use different datasets
|
|
657
|
+
WebGL, how the Natural Earth data is bundled and split, why resolving and drawing
|
|
658
|
+
deliberately use different datasets, and why an attack that starts and ends in the same
|
|
659
|
+
place is drawn as a loop rather than a point.
|
|
630
660
|
|
|
631
661
|
## Data
|
|
632
662
|
|
package/dist/index.cjs
CHANGED
|
@@ -454,6 +454,40 @@ function useReducedMotion() {
|
|
|
454
454
|
}, []);
|
|
455
455
|
return reduced;
|
|
456
456
|
}
|
|
457
|
+
function configEqual(a, b) {
|
|
458
|
+
const aKeys = Object.keys(a);
|
|
459
|
+
if (aKeys.length !== Object.keys(b).length) return false;
|
|
460
|
+
for (const key of aKeys) {
|
|
461
|
+
const av = a[key];
|
|
462
|
+
const bv = b[key];
|
|
463
|
+
if (Object.is(av, bv)) continue;
|
|
464
|
+
if (isPlainObject(av) && isPlainObject(bv)) {
|
|
465
|
+
if (!shallowEqual(av, bv)) return false;
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
return false;
|
|
469
|
+
}
|
|
470
|
+
return true;
|
|
471
|
+
}
|
|
472
|
+
function shallowEqual(a, b) {
|
|
473
|
+
const aKeys = Object.keys(a);
|
|
474
|
+
if (aKeys.length !== Object.keys(b).length) return false;
|
|
475
|
+
for (const key of aKeys) {
|
|
476
|
+
if (!Object.is(a[key], b[key])) return false;
|
|
477
|
+
}
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
function isPlainObject(value) {
|
|
481
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
482
|
+
}
|
|
483
|
+
function useStableConfig(defaults, overrides) {
|
|
484
|
+
const resolved = mergeDefined(defaults, overrides);
|
|
485
|
+
const ref = react.useRef(resolved);
|
|
486
|
+
if (!configEqual(ref.current, resolved)) {
|
|
487
|
+
ref.current = resolved;
|
|
488
|
+
}
|
|
489
|
+
return ref.current;
|
|
490
|
+
}
|
|
457
491
|
function drawBaseMap(options) {
|
|
458
492
|
const { ctx, width, height, projection, geo, theme, regions } = options;
|
|
459
493
|
ctx.clearRect(0, 0, width, height);
|
|
@@ -558,6 +592,7 @@ function buildArc(from, to, projection, curvature, segments, maxLift = Infinity)
|
|
|
558
592
|
const dx = endPx[0] - startPx[0];
|
|
559
593
|
const dy = endPx[1] - startPx[1];
|
|
560
594
|
const chord = Math.hypot(dx, dy);
|
|
595
|
+
if (chord <= SAME_PLACE_EPSILON) return buildSelfLoop(startPx, count, steps, maxLift);
|
|
561
596
|
const nx = chord > 0 ? -dy / chord : 0;
|
|
562
597
|
const ny = chord > 0 ? dx / chord : 0;
|
|
563
598
|
const rawLift = chord * curvature;
|
|
@@ -594,6 +629,28 @@ function buildArc(from, to, projection, curvature, segments, maxLift = Infinity)
|
|
|
594
629
|
return { points, breakAt, distances, length };
|
|
595
630
|
}
|
|
596
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
|
+
}
|
|
597
654
|
function isFinitePoint(p) {
|
|
598
655
|
return Number.isFinite(p[0]) && Number.isFinite(p[1]);
|
|
599
656
|
}
|
|
@@ -1195,17 +1252,23 @@ function ThreatMap(props) {
|
|
|
1195
1252
|
ariaLabel = "Cyberattack threat map"
|
|
1196
1253
|
} = props;
|
|
1197
1254
|
const containerRef = react.useRef(null);
|
|
1198
|
-
const
|
|
1199
|
-
|
|
1200
|
-
return props.theme
|
|
1255
|
+
const themeOverrides = react.useMemo(() => {
|
|
1256
|
+
if (!props.theme?.severityColors) return props.theme;
|
|
1257
|
+
return { ...props.theme, severityColors: { ...defaultTheme.severityColors, ...props.theme.severityColors } };
|
|
1201
1258
|
}, [props.theme]);
|
|
1202
|
-
const
|
|
1203
|
-
const
|
|
1259
|
+
const theme = useStableConfig(defaultTheme, themeOverrides);
|
|
1260
|
+
const line = useStableConfig(defaultLineStyle, props.line);
|
|
1261
|
+
const regions = useStableConfig(defaultRegions, props.regions);
|
|
1204
1262
|
const reducedMotion = useReducedMotion();
|
|
1205
|
-
const
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1263
|
+
const baseAnimation = useStableConfig(defaultAnimation, props.animation);
|
|
1264
|
+
const animation = react.useMemo(
|
|
1265
|
+
() => (
|
|
1266
|
+
// Someone who has asked their OS for less motion should not be handed a
|
|
1267
|
+
// screen of racing lines. The arcs still render; they just hold still.
|
|
1268
|
+
baseAnimation.respectReducedMotion && reducedMotion ? { ...baseAnimation, enabled: false } : baseAnimation
|
|
1269
|
+
),
|
|
1270
|
+
[baseAnimation, reducedMotion]
|
|
1271
|
+
);
|
|
1209
1272
|
const measured = useElementSize(containerRef, widthProp === void 0 || heightProp === void 0);
|
|
1210
1273
|
const pixelRatio = usePixelRatio();
|
|
1211
1274
|
const width = widthProp ?? measured?.width ?? 0;
|
package/dist/index.js
CHANGED
|
@@ -453,6 +453,40 @@ function useReducedMotion() {
|
|
|
453
453
|
}, []);
|
|
454
454
|
return reduced;
|
|
455
455
|
}
|
|
456
|
+
function configEqual(a, b) {
|
|
457
|
+
const aKeys = Object.keys(a);
|
|
458
|
+
if (aKeys.length !== Object.keys(b).length) return false;
|
|
459
|
+
for (const key of aKeys) {
|
|
460
|
+
const av = a[key];
|
|
461
|
+
const bv = b[key];
|
|
462
|
+
if (Object.is(av, bv)) continue;
|
|
463
|
+
if (isPlainObject(av) && isPlainObject(bv)) {
|
|
464
|
+
if (!shallowEqual(av, bv)) return false;
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
return false;
|
|
468
|
+
}
|
|
469
|
+
return true;
|
|
470
|
+
}
|
|
471
|
+
function shallowEqual(a, b) {
|
|
472
|
+
const aKeys = Object.keys(a);
|
|
473
|
+
if (aKeys.length !== Object.keys(b).length) return false;
|
|
474
|
+
for (const key of aKeys) {
|
|
475
|
+
if (!Object.is(a[key], b[key])) return false;
|
|
476
|
+
}
|
|
477
|
+
return true;
|
|
478
|
+
}
|
|
479
|
+
function isPlainObject(value) {
|
|
480
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
481
|
+
}
|
|
482
|
+
function useStableConfig(defaults, overrides) {
|
|
483
|
+
const resolved = mergeDefined(defaults, overrides);
|
|
484
|
+
const ref = useRef(resolved);
|
|
485
|
+
if (!configEqual(ref.current, resolved)) {
|
|
486
|
+
ref.current = resolved;
|
|
487
|
+
}
|
|
488
|
+
return ref.current;
|
|
489
|
+
}
|
|
456
490
|
function drawBaseMap(options) {
|
|
457
491
|
const { ctx, width, height, projection, geo, theme, regions } = options;
|
|
458
492
|
ctx.clearRect(0, 0, width, height);
|
|
@@ -557,6 +591,7 @@ function buildArc(from, to, projection, curvature, segments, maxLift = Infinity)
|
|
|
557
591
|
const dx = endPx[0] - startPx[0];
|
|
558
592
|
const dy = endPx[1] - startPx[1];
|
|
559
593
|
const chord = Math.hypot(dx, dy);
|
|
594
|
+
if (chord <= SAME_PLACE_EPSILON) return buildSelfLoop(startPx, count, steps, maxLift);
|
|
560
595
|
const nx = chord > 0 ? -dy / chord : 0;
|
|
561
596
|
const ny = chord > 0 ? dx / chord : 0;
|
|
562
597
|
const rawLift = chord * curvature;
|
|
@@ -593,6 +628,28 @@ function buildArc(from, to, projection, curvature, segments, maxLift = Infinity)
|
|
|
593
628
|
return { points, breakAt, distances, length };
|
|
594
629
|
}
|
|
595
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
|
+
}
|
|
596
653
|
function isFinitePoint(p) {
|
|
597
654
|
return Number.isFinite(p[0]) && Number.isFinite(p[1]);
|
|
598
655
|
}
|
|
@@ -1194,17 +1251,23 @@ function ThreatMap(props) {
|
|
|
1194
1251
|
ariaLabel = "Cyberattack threat map"
|
|
1195
1252
|
} = props;
|
|
1196
1253
|
const containerRef = useRef(null);
|
|
1197
|
-
const
|
|
1198
|
-
|
|
1199
|
-
return props.theme
|
|
1254
|
+
const themeOverrides = useMemo(() => {
|
|
1255
|
+
if (!props.theme?.severityColors) return props.theme;
|
|
1256
|
+
return { ...props.theme, severityColors: { ...defaultTheme.severityColors, ...props.theme.severityColors } };
|
|
1200
1257
|
}, [props.theme]);
|
|
1201
|
-
const
|
|
1202
|
-
const
|
|
1258
|
+
const theme = useStableConfig(defaultTheme, themeOverrides);
|
|
1259
|
+
const line = useStableConfig(defaultLineStyle, props.line);
|
|
1260
|
+
const regions = useStableConfig(defaultRegions, props.regions);
|
|
1203
1261
|
const reducedMotion = useReducedMotion();
|
|
1204
|
-
const
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1262
|
+
const baseAnimation = useStableConfig(defaultAnimation, props.animation);
|
|
1263
|
+
const animation = useMemo(
|
|
1264
|
+
() => (
|
|
1265
|
+
// Someone who has asked their OS for less motion should not be handed a
|
|
1266
|
+
// screen of racing lines. The arcs still render; they just hold still.
|
|
1267
|
+
baseAnimation.respectReducedMotion && reducedMotion ? { ...baseAnimation, enabled: false } : baseAnimation
|
|
1268
|
+
),
|
|
1269
|
+
[baseAnimation, reducedMotion]
|
|
1270
|
+
);
|
|
1208
1271
|
const measured = useElementSize(containerRef, widthProp === void 0 || heightProp === void 0);
|
|
1209
1272
|
const pixelRatio = usePixelRatio();
|
|
1210
1273
|
const width = widthProp ?? measured?.width ?? 0;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-threat-map",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|