npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

react-threat-map

v0.2.3

Published

A React component that renders animated cyberattack threats on a static world map, with intelligent per-region aggregation and first-class US state support.

Readme

react-threat-map

CI npm license

Animated cyberattack threats on a static world map, for React. Aggregates attacks by origin region — with US states as first-class origins — so a busy feed reads as a map instead of a hairball.

→ Live demo

import { ThreatMap } from 'react-threat-map';

<ThreatMap attacks={[{ from: 'CN', to: 'US-CA', severity: 'high' }]} />;
  • Fast. 500+ concurrent animated threats at 120fps in the bundled demo. Draw calls are bounded by the number of distinct styles, so past a few dozen threats they stop growing entirely — 500 threats and 2000 threats cost identical draw calls.
  • Aggregates intelligently. Many attacks from California collapse into one heavier line; California and Texas stay separate. Fully configurable, or turn it off.
  • No API keys, no tiles. Boundaries are bundled Natural Earth data, lazy-loaded.
  • 22.2 kB gzipped main bundle. Geometry is a separate chunk you only pay for on mount.
  • Strictly typed, with TSDoc on every public export.
  • Unopinionated. No CSS, no state manager, no styling library. Two peer-free runtime deps.

Contents


Install

npm install react-threat-map

React is a peer dependency:

{ "peerDependencies": { "react": ">=16.14.0" } }

16.14.0 is the floor because that is the first release to ship react/jsx-runtime, which the compiled output imports. The library uses only useState, useEffect, useRef, useMemo, and useCallback, so nothing above that floor is required — React 16.14, 17, 18, and 19 are all supported.


Quick start

The only required prop is attacks. The map sizes itself to its container, loads its own geography, aggregates by origin region, and animates:

import { ThreatMap, type Attack } from 'react-threat-map';

const attacks: Attack[] = [
  { id: '1', from: 'CN', to: 'US-CA', severity: 'high' },
  { id: '2', from: 'RU', to: 'US-NY', severity: 'critical' },
  { id: '3', from: 'BR', to: 'FR', severity: 'medium' },
];

export function Dashboard() {
  return (
    <div style={{ width: 900 }}>
      <ThreatMap attacks={attacks} />
    </div>
  );
}

Describing where an attack came from

from and to each accept three interchangeable shapes, so you can pass whatever your data already has:

// 1. A region code — cheapest. No geometry needed to place it.
{ from: 'FR', to: 'US-CA' }

// 2. Exact coordinates. Reverse-resolved to a region for aggregation.
{ from: { lat: 34.05, lng: -118.24 }, to: 'FR' }

// 3. Both — exact placement AND a free aggregation key.
//    Best option if your feed already carries geo-IP region data.
{ from: { lat: 34.05, lng: -118.24, region: 'US-CA' }, to: 'FR' }

Region codes accept ISO 3166-1 alpha-2 ("FR"), alpha-3 ("FRA"), and ISO 3166-2 US state codes ("US-CA"). All 252 ISO-assigned countries resolve, including ones too small to draw at world scale — "SG", "HK", "MO", "MT", "MC". Bare coordinates inside them resolve correctly too: a point in Singapore returns SG, not Malaysia.

A note on bare two-letter codes. "CA" is both Canada and California. Bare codes always resolve to the country, so "CA" is Canada. "TX" resolves to Texas because no country uses that code. Use the unambiguous "US-CA" form for states.

Streaming feeds

attacks is the complete current set. Attacks that disappear from the array fade out:

const [attacks, setAttacks] = useState<Attack[]>([]);

useEffect(() => {
  const socket = new WebSocket('wss://your-feed');
  socket.onmessage = (event) => {
    const attack = JSON.parse(event.data) as Attack;
    // Keep a bounded window; older attacks fade out automatically.
    setAttacks((previous) => [...previous.slice(-499), attack]);
  };
  return () => socket.close();
}, []);

<ThreatMap attacks={attacks} />;

Give each attack a stable id. It is how an in-flight animation stays attached to its threat across re-renders.


Aggregation

This is the feature the library exists for. A feed with 500 attacks contains maybe 30 meaningful origin→destination relationships; drawing 500 overlapping lines hides that. Aggregation collapses attacks sharing an origin region into a single, visually heavier threat.

// Two attacks from California, one from Texas — all aimed at France.
const attacks = [
  { id: 'a', from: { lat: 34.05, lng: -118.24, region: 'US-CA' }, to: 'FR' },
  { id: 'b', from: { lat: 37.77, lng: -122.42, region: 'US-CA' }, to: 'FR' },
  { id: 'c', from: 'US-TX', to: 'FR' },
];

// Renders 2 threats:
//   California → France  (count 2, thicker)
//   Texas      → France  (count 1)
<ThreatMap attacks={attacks} />;

It groups on regions, not coordinates

Two attacks from opposite ends of California still aggregate, because the key is the region, not the point. When you pass bare {lat, lng}, the library reverse-resolves each point to its region via point-in-polygon before grouping.

US states are first-class

At the default granularity: 'auto', US origins group by state and everything else by country. California and Texas are distinct aggregates; France stays one aggregate.

| granularity | California | Texas | France | | --- | --- | --- | --- | | 'auto' (default) | US-CA | US-TX | FR | | 'country' | US | US | FR | | 'state' | US-CA | US-TX | FR |

This does not require loading state borders — regions.showStates controls drawing them and is independent.

Visual weight scales with count

An aggregate's intensity multiplies line width, glow, and head size. The default ramp is logarithmic (1 + log₂(count) × 0.5, clamped to [1, 6]) because attack volume is heavily long-tailed — a linear ramp would let one 500-attack region smear across the whole map next to a hairline:

| count | 1 | 2 | 10 | 100 | 500 | 5000 | | --- | --- | --- | --- | --- | --- | --- | | intensity | 1.0 | 1.5 | 2.7 | 4.3 | 5.5 | 6.0 |

// Linear instead:
<ThreatMap attacks={attacks} aggregation={{ scale: (count) => 1 + count / 10 }} />

// Merge, but size every line identically:
<ThreatMap attacks={attacks} aggregation={{ scale: () => 1 }} />

Use weight when one row stands for many events — aggregation sums weights as well as counting rows:

{ from: 'CN', to: 'US-CA', weight: 500 }  // 500 blocked packets, one row

What counts as "the same threat"

By default, aggregation groups by origin and destination, so France→US and France→Japan stay separate lines. Grouping on origin alone would collapse them into one line with no coherent destination to draw to.

// One line per origin region, whatever it targets.
// The destination becomes that origin's most frequent target.
<ThreatMap attacks={attacks} aggregation={{ groupBy: 'origin' }} />

Thresholds, caps, and turning it off

// Don't merge until at least 5 attacks share an origin; smaller
// groups render as individual lines.
<ThreatMap attacks={attacks} aggregation={{ minCount: 5 }} />

// Never draw more than 40 lines — keeps the heaviest.
<ThreatMap attacks={attacks} aggregation={{ maxGroups: 40 }} />

// One line per attack.
<ThreatMap attacks={attacks} aggregation={false} />

Custom grouping

key overrides grouping entirely. Return null to render an attack on its own:

// Group by origin country AND attack type.
<ThreatMap
  attacks={attacks}
  aggregation={{ key: (attack, from) => `${from.id}:${attack.type}` }}
/>

// Never aggregate critical attacks — show every one individually.
<ThreatMap
  attacks={attacks}
  aggregation={{
    key: (attack, from, to) => (attack.severity === 'critical' ? null : `${from.id}>${to.id}`),
  }}
/>

Severity of an aggregate

An aggregate takes the max severity of its members: a group with one critical and forty low attacks renders critical. For a security display, under-reporting the worst event in a bucket is the more dangerous failure. Override with severity:

// Use the most common severity instead of the worst.
<ThreatMap attacks={attacks} aggregation={{ severity: (all) => mode(all) }} />

Using aggregation without the map

aggregateAttacks is a pure function — no React, no canvas, no async. Reuse it for a table view, a CSV export, or a test:

import { aggregateAttacks } from 'react-threat-map';

const threats = aggregateAttacks(attacks, { config: { granularity: 'country' } });
threats.forEach((t) => console.log(t.fromRegion.name, t.count, t.severity));

Customization

Theme

Pass any subset; it merges over the defaults.

<ThreatMap
  attacks={attacks}
  theme={{
    ocean: '#eef2f7',
    land: '#cfd9e6',
    border: '#ffffff',
    severityColors: { critical: '#dc2626', high: '#ea580c' },
  }}
/>

severityColors merges per-key, so overriding critical leaves low/medium/high intact. Custom severity strings work — add a matching key and use it:

<ThreatMap
  attacks={[{ from: 'CN', to: 'US', severity: 'nation-state' }]}
  theme={{ severityColors: { 'nation-state': '#a855f7' } }}
/>

Lines and animation

<ThreatMap
  attacks={attacks}
  line={{ curvature: 0.35, width: 2, glow: 0.8, trailLength: 0.25 }}
  animation={{ speed: 1.2, easing: 'easeInOutCubic', stagger: 0.5 }}
/>

Set curvature: 0 for straight geodesics, or negative to bow the other way. Arc height is capped at a third of the map height so intercontinental arcs stay on screen.

animation={{ enabled: false }} schedules no requestAnimationFrame loop at all — arcs render statically and the component costs nothing per frame. The map also respects prefers-reduced-motion automatically; opt out with animation={{ respectReducedMotion: false }}.

Attacks that start and end in the same place

An attack whose origin and destination resolve to the same point — a domestic incident in a country with no subdivisions, lateral movement inside one US state, two hosts in one city — has no chord to draw a line along:

<ThreatMap
  attacks={[
    { id: '1', from: 'DE', to: 'DE', severity: 'high' },
    { id: '2', from: 'US-CA', to: 'US-CA', severity: 'critical' },
    { id: '3', from: { lat: 50.11, lng: 8.68 }, to: { lat: 50.11, lng: 8.68 } },
  ]}
/>

These render as a self-loop: a small circle tangent to the shared point, with the origin marker on the point, the head travelling the loop, and the impact ripple firing back on the same spot. It is hoverable like any other arc.

The loop radius scales with the map and is clamped to 6–18 px, so a same-place attack stays visible on a world map instead of shrinking to nothing. It deliberately does not scale with curvature: curvature is the height of a lift applied to a chord, and a self-loop has no chord — tying them together would make curvature: 0 erase these threats entirely.

Endpoints do not have to be exactly equal. Anything projecting to within half a pixel counts as the same place, because a sub-pixel line cannot be seen or hovered anyway.

Projections

<ThreatMap attacks={attacks} projection="orthographic" />

Built in: naturalEarth1 (default), equirectangular, mercator, orthographic. Or pass any d3-geo projection and it will be fitted for you:

import { geoRobinson } from 'd3-geo-projection';

<ThreatMap attacks={attacks} projection={geoRobinson()} />;

Boundaries

<ThreatMap attacks={attacks} regions={{ showStates: true, showGraticule: true }} />

Render hooks

renderThreat and renderRegion let you draw anything. Return true if you handled it, or false to fall through to the built-in renderer.

// Label heavy aggregates, but let the library still draw the lines.
<ThreatMap
  attacks={attacks}
  renderThreat={(ctx, { threat, points, alpha }) => {
    if (threat.count < 20) return false;
    ctx.globalAlpha = alpha;
    ctx.fillStyle = '#fff';
    ctx.font = '600 10px system-ui';
    ctx.fillText(`${threat.count}`, points[0] + 4, points[1] - 4);
    return false;
  }}
/>
// Heat-shade countries by attack volume.
<ThreatMap
  attacks={attacks}
  renderRegion={(ctx, { feature, path, weight, theme }) => {
    ctx.beginPath();
    path(feature);
    ctx.fillStyle = weight > 0 ? `hsl(0 80% ${20 + Math.min(weight, 40)}%)` : theme.land;
    ctx.fill();
    return true;
  }}
/>

The context is pre-transformed for device pixel ratio, so draw in CSS pixels. Both hooks are wrapped in save/restore and are error-isolated: a hook that throws is disabled for the rest of the frame and the built-in renderer takes over.

renderThreat opts that threat out of style batching, costing it a draw call of its own. Fine for tens of threats; if you need custom drawing on hundreds, prefer restyling via theme/line.

Interaction

<ThreatMap
  attacks={attacks}
  onThreatClick={(threat) => console.log(threat.count, 'from', threat.fromRegion.name)}
  onThreatHover={(threat) => setTooltip(threat)}
/>

Hit testing runs against the real arc geometry, with a hit radius that scales with line thickness. onThreatHover fires only when the hovered threat changes, not on every pointer pixel. Without any handler the canvas is pointer-events: none and never intercepts clicks meant for your own UI.

Sizing

| You provide | Result | | --- | --- | | Nothing | Fills container width; height from the projection's aspect ratio | | A CSS height (class or style) | Fills the container in both axes | | width only | Height derived from the projection's aspect ratio | | width + height | Exactly that, in CSS pixels |

The map ships no CSS and never imposes a size beyond a fallback aspect ratio.

Self-hosting geo data

import { loadGeoData } from 'react-threat-map/geo';

// Preload during app boot so the map paints instantly on mount.
void loadGeoData({ states: true });

// Or supply your own boundaries entirely.
<ThreatMap attacks={attacks} geo={async () => fetch('/geo.json').then((r) => r.json())} />;

API reference

<ThreatMap>

| Prop | Type | Default | Description | | --- | --- | --- | --- | | attacks | Attack[] | — | Required. The current attack set. | | width | number | container width | CSS pixels. | | height | number | from aspect ratio | CSS pixels. | | projection | ProjectionSpec | 'naturalEarth1' | Name or a d3-geo projection. | | theme | Partial<ThreatMapTheme> | defaultTheme | Colors. | | line | Partial<LineStyleConfig> | defaultLineStyle | Arc geometry and styling. | | animation | Partial<AnimationConfig> | defaultAnimation | Animation. | | regions | Partial<RegionsConfig> | defaultRegions | Which boundaries to draw. | | aggregation | Partial<AggregationConfig> \| false | defaultAggregation | Grouping. false disables. | | renderThreat | ThreatRenderer | — | Custom threat drawing. | | renderRegion | RegionRenderer | — | Custom region drawing. | | geo | GeoData \| (() => Promise<GeoData>) | bundled | Supply or preload geometry. | | onThreatClick | (threat, event) => void | — | Click handler. | | onThreatHover | (threat \| null, event) => void | — | Hover handler. | | onError | (error: ThreatMapError) => void | dev console | Recoverable errors. | | className / style | | — | Applied to the wrapper. | | ariaLabel | string | 'Cyberattack threat map' | Accessible name. |

Attack

| Field | Type | Default | Description | | --- | --- | --- | --- | | from | AttackLocation | — | Required. Origin. | | to | AttackLocation | — | Required. Destination. | | id | string | derived | Stable identity. Recommended for streaming. | | timestamp | number | — | Epoch ms. | | type | string | — | Free-form classification. | | severity | Severity | 'medium' | low | medium | high | critical | custom. | | weight | number | 1 | Relative importance; summed by aggregation. | | meta | TMeta | — | Your payload, passed through untouched. |

Threat

What aggregation produces and hooks/handlers receive.

| Field | Type | Description | | --- | --- | --- | | id | string | Group key, stable across frames. | | from / to | LatLng | Resolved coordinates. | | fromRegion / toRegion | ResolvedRegion | { id, name, kind, countryCode }. | | count | number | Underlying attacks. 1 when unaggregated. | | totalWeight | number | Sum of member weights. | | severity | Severity | Max across members, by default. | | intensity | number | Visual weight multiplier, 16. | | attacks | Attack[] | The attacks folded into this threat. |

AggregationConfig

| Field | Type | Default | Description | | --- | --- | --- | --- | | enabled | boolean | true | Master switch. | | granularity | 'auto' \| 'state' \| 'country' | 'auto' | Origin specificity. | | groupBy | 'origin-destination' \| 'origin' | 'origin-destination' | What is "the same threat". | | key | AggregationKeyFn | — | Full override. null opts an attack out. | | minCount | number | 2 | Minimum group size to merge. | | maxGroups | number | unlimited | Cap, keeping the heaviest. | | scale | IntensityScale | log ramp | count → visual weight. | | severity | AggregationSeverityFn | max | How an aggregate picks its severity. |

LineStyleConfig

| Field | Default | Description | | --- | --- | --- | | curvature | 0.22 | Arc height as a fraction of chord length. 0 is flat. | | width | 1.2 | Baseline stroke width, before intensity. | | trackOpacity | 0.28 | Opacity of the full arc behind the head. | | trailOpacity | 0.95 | Opacity of the lit trail. | | trailLength | 0.18 | Trail length as a fraction of the path. | | glow | 0.5 | Glow strength, 01. | | headRadius | 2 | Head dot radius, before intensity. | | showOrigin | true | Static marker at each origin. | | showImpact | true | Ripple where a head lands. | | segments | 48 | Straight pieces per arc. |

AnimationConfig

| Field | Default | Description | | --- | --- | --- | | enabled | true | false schedules no rAF loop at all. | | speed | 0.5 | Full traversals per second. | | easing | 'easeInOutQuad' | Name or (t) => number. | | stagger | 1 | Phase spread, 01. 0 is lockstep. | | loop | true | Restart on completion. | | fadeIn / fadeOut | 400 / 600 | Milliseconds. | | respectReducedMotion | true | Honor prefers-reduced-motion. |

ThreatMapTheme

ocean, land, border, borderWidth, stateBorder, stateBorderWidth, severityColors, headColor, originColor, impactColor.

RegionsConfig

showCountries (true), showStates (false), showGraticule (false), graticuleColor, showSphere (true).

Exported functions

| Export | Description | | --- | --- | | aggregateAttacks(attacks, options) | The pure aggregation function. | | lookupRegionCode(code) | "us-ca"{ id, name, kind, anchor, … }. | | getRegionById(id) | Exact lookup by canonical id. | | listRegions() | Every known country and US state. | | resolveLocation(location, index?, preferStates?) | Location → point + region. | | loadGeoData(options) | From react-threat-map/geo. Preload boundaries. | | defaultTheme, defaultLineStyle, defaultAnimation, defaultRegions, defaultAggregation | Frozen defaults, safe to spread. | | defaultIntensityScale, maxSeverity, severityRank, regionKey, SEVERITY_ORDER | Aggregation internals, exported for reuse. |

Error handling

The library never throws at render. Recoverable problems go to onError:

| kind | Meaning | | --- | --- | | geo-load | Geometry chunk failed to load. Threats still render on a blank map. | | resolve | An attack's from/to could not be resolved. That attack is skipped. | | render | A render hook threw. It is disabled for the frame; the built-in renderer takes over. |

Without a handler these are logged in development and silent in production.


Performance

Measured on the bundled heavy-load demo — 500+ threats streaming at ~20/sec, on a modern laptop:

120 fps, with five maps animating on the same page.

The technique that gets there is style batching. The naive Canvas loop issues one stroke() per threat per frame — and that is what usually makes people conclude Canvas is too slow and reach for WebGL. Instead, threats are bucketed by (color, width, alpha), each bucket accumulates every member's geometry into a single Path2D, and each bucket issues one stroke().

The effect is that draw calls stop growing with threat count. Measured on the worst realistic case — every threat a different severity, intensity, and animation phase:

| threats | draw calls | accumulate + paint | | --- | --- | --- | | 50 | 163 | 0.3 ms | | 500 | 173 | 0.5 ms | | 2000 | 173 | 1.5 ms |

Past a few dozen threats every style bucket is already occupied, so quadrupling the threats adds no draw calls at all — only the linear, cheap work of walking more cached geometry. The plateau is asserted in the test suite rather than merely claimed:

expect(drawCallsFor(2000)).toBe(drawCallsFor(500));

Alongside it:

  • Geometry is precomputed, once per layout, never per frame. The loop only walks cached Float32Array buffers.
  • Glow is a double-stroke, not shadowBlur — visually equivalent on a line and roughly an order of magnitude cheaper.
  • The per-threat path allocates nothing, so the GC has no reason to interrupt an animation.
  • The base map is a separate canvas, rasterized once and never touched by the loop.

Getting the most out of it

  • Give attacks stable ids. Identity drives animation continuity.
  • Pass region alongside coordinates when you have it — it skips point-in-polygon entirely.
  • Bound your feed. maxGroups caps rendered threats; slicing your array caps resolution work.
  • animation={{ enabled: false }} removes the rAF loop completely.

Examples

bogdantaranenko.github.io/react-threat-map — the demo below, hosted. Deployed from main on every push.

It covers basic usage, 500+ streaming attacks with a live FPS counter, one country under fire from five origins at different volumes, aggregation strategies side by side, custom theming with render hooks, and raw-coordinate reverse geocoding with hover.

To run it locally against the library source, so edits hot-reload:

git clone https://github.com/BogdanTaranenko/react-threat-map
cd react-threat-map
npm install
npm run build          # generates geo data + builds the library
cd examples/demo
npm install
npm run dev

Design decisions

DECISIONS.md covers the load-bearing choices and their tradeoffs: why d3-geo + Canvas over MapLibre/Leaflet/react-simple-maps, why Canvas 2D over SVG and WebGL, how the Natural Earth data is bundled and split, why resolving and drawing deliberately use different datasets, and why an attack that starts and ends in the same place is drawn as a loop rather than a point.

Data

Boundaries from Natural Earth — public domain, no attribution required — via world-atlas and us-atlas, preprocessed at build time.

Contributing

Issues and PRs welcome — see CONTRIBUTING.md for setup, repo layout, and the two non-obvious things (generated-but-committed geo data, and why resolving and drawing use different datasets).

npm install
npm run geo        # regenerate bundled geo data from Natural Earth
npm test           # 228 tests
npm run typecheck  # includes the type-contract suite
npm run build

License

MIT © Bogdan Taranenko