react-threat-map 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/DECISIONS.md ADDED
@@ -0,0 +1,275 @@
1
+ # Technology Decisions
2
+
3
+ This document records the load-bearing technical choices behind `react-threat-map`,
4
+ and the tradeoffs each one accepts. It is written for someone deciding whether this
5
+ library fits their use case, or considering changing one of these choices later.
6
+
7
+ ---
8
+
9
+ ## 1. Map rendering technology: `d3-geo` projections + Canvas 2D
10
+
11
+ **Chosen:** `d3-geo` for projection math only, with our own Canvas 2D renderer for the
12
+ base map geometry. No map framework.
13
+
14
+ ### Options evaluated
15
+
16
+ | Option | Verdict |
17
+ | --- | --- |
18
+ | **MapLibre GL / Mapbox GL** | Rejected. Designed for interactive, tiled, zoomable slippy maps. ~800 kB of runtime for a map that never moves, and the good basemap styles want a tile endpoint — which the brief explicitly rules out. Its strengths (tile paging, zoom LOD, terrain) are all things a static world map does not need. |
19
+ | **Leaflet** | Rejected. Fundamentally a raster-tile viewer. Rendering our own GeoJSON through it means fighting its DOM/layer model, and we would ship a pan/zoom engine we do not use. |
20
+ | **`react-simple-maps`** | Rejected, though closest in spirit. It is a thin React/SVG wrapper over `d3-geo` — meaning we would take on its dependency tree and component model to get code we can write in ~150 lines, and still end up with an SVG base map we would have to reach around for the threat layer. It also pulls in `d3-zoom`/`d3-selection` for interaction we do not want. Its projection handling is exactly `d3-geo`, so we take that directly. |
21
+ | **`d3-geo` + custom Canvas renderer** | **Chosen.** |
22
+ | **Hand-rolled projection math** | Rejected. Writing and testing correct Natural Earth / Mercator / equirectangular forward projections, plus antimeridian clipping, is a real amount of subtle work that `d3-geo` has already done correctly for a decade. |
23
+
24
+ ### Why this one
25
+
26
+ - **`d3-geo` is projection math, not a framework.** It exposes `geoPath` with a Canvas
27
+ context target, so the same code path draws country outlines to Canvas that would
28
+ draw them to SVG. It brings no DOM opinions, no state management, and no styling.
29
+ - **Tree-shakeable and small.** We import four symbols (`geoPath`, `geoNaturalEarth1`,
30
+ `geoInterpolate`, `geoContains`-adjacent helpers). Bundlers drop the rest.
31
+ - **The static-map requirement is a gift here.** Because the base map never pans or
32
+ zooms, it is drawn **once** per size/theme change onto its own canvas and then left
33
+ alone. All per-frame cost belongs to the threat layer. A tile framework's entire
34
+ value proposition is amortizing work we do not have.
35
+ - **Customization stays ours.** Region fill/stroke/hover and the `renderRegion` hook are
36
+ plain draw calls we control, not a style spec we have to translate into.
37
+
38
+ ### Tradeoff accepted
39
+
40
+ No pan/zoom, and no basemap imagery. That is a deliberate reading of "static world map".
41
+ If a consumer later needs a slippy map, this library is the wrong tool and they should
42
+ reach for MapLibre — that is a better outcome than us growing into a worse MapLibre.
43
+
44
+ ---
45
+
46
+ ## 2. Threat layer: Canvas 2D on a second stacked canvas
47
+
48
+ **Chosen:** A dedicated `<canvas>` layered over the base map canvas, cleared and redrawn
49
+ each `requestAnimationFrame`, with **draw calls batched by style bucket**.
50
+
51
+ ### Options evaluated
52
+
53
+ **SVG — rejected.** 500 animated threats means 500+ live DOM nodes whose attributes
54
+ change every frame. That is style recalculation and layout on every tick; it falls over
55
+ well below the target. SVG is the right answer for ~20 threats and the wrong answer here.
56
+
57
+ **WebGL — rejected, but it is the escape hatch.** WebGL would raise the ceiling to tens
58
+ of thousands of threats. It costs: a shader pipeline, manual line tessellation (WebGL has
59
+ no usable thick-line primitive), context-loss handling, and a `renderThreat` hook that
60
+ consumers could no longer write in ordinary drawing code. The target is *hundreds* of
61
+ threats at 60fps, and Canvas 2D clears that bar with room to spare — the bundled demo runs
62
+ 500+ streaming threats at **120fps** with five maps animating on one page, and the layer
63
+ still only spends ~1.5 ms per frame at 2000 threats. Paying WebGL's complexity tax for
64
+ headroom nobody asked for is the wrong trade. If someone genuinely needs 10k+ threats, the
65
+ renderer is isolated behind `src/render/` and could be swapped without touching the public
66
+ API.
67
+
68
+ **Canvas 2D — chosen.**
69
+
70
+ ### The thing that makes Canvas 2D fast enough
71
+
72
+ The naive Canvas loop — `beginPath()`/`stroke()` per threat — costs one draw call per
73
+ threat per frame, and *that* is what usually makes people conclude "Canvas is too slow,
74
+ we need WebGL". We do not do that. Instead:
75
+
76
+ 1. **Geometry is precomputed, not recomputed.** Each threat's arc is projected and
77
+ flattened into a screen-space polyline **once**, cached, and invalidated only on
78
+ resize/projection/curvature change — not per frame. Per frame we only *walk* it.
79
+ 2. **Draw calls are batched by style bucket.** Threats are grouped by
80
+ `(color, width bucket, alpha step)`. Each bucket accumulates every member's geometry
81
+ into a single `Path2D` and issues **one** `stroke()`. Cost therefore scales with the
82
+ number of distinct *styles*, which is bounded — not with threat count, which is not.
83
+ Measured on the worst realistic case (every threat a different severity, intensity,
84
+ and phase): 50 threats cost 163 draw calls, 500 cost 173, and **2000 also cost 173**,
85
+ at 0.3 ms / 0.5 ms / 1.5 ms of CPU respectively. Past a few dozen threats every bucket
86
+ is already occupied and the draw-call count simply stops rising.
87
+ 3. **Glow is faked with a double-stroke, not `shadowBlur`.** `shadowBlur` is one of the
88
+ most expensive operations in Canvas 2D and is applied per-stroke. Drawing each bucket
89
+ twice — wide + low alpha, then narrow + full alpha — is visually equivalent for a glow
90
+ line and roughly an order of magnitude cheaper.
91
+ 4. **The render loop allocates nothing.** No per-frame arrays, objects, or closures, so
92
+ we do not hand the GC a reason to stutter mid-animation.
93
+
94
+ Base map and threats are separate canvases specifically so that the ~300 country/state
95
+ paths are rasterized once and never re-touched by the animation loop.
96
+
97
+ ---
98
+
99
+ ## 3. Geo data: Natural Earth, lazily loaded, with an inline centroid table
100
+
101
+ **Source:** [Natural Earth](https://www.naturalearthdata.com/) (public domain), consumed
102
+ via the `world-atlas` and `us-atlas` TopoJSON builds and **pre-processed at build time**
103
+ into artifacts we ship. Natural Earth is the standard answer here: public domain (no
104
+ attribution obligation to push onto consumers), cartographer-curated, and available at
105
+ the exact generalization level a small static map wants.
106
+
107
+ - Countries: `world-atlas` `countries-110m` (1:110m — right detail level for a world map
108
+ a few hundred pixels tall; 1:50m is wasted bytes at this size).
109
+ - US states: `us-atlas` `states-10m`, resampled during preprocessing.
110
+
111
+ **Format: TopoJSON, decoded at runtime with `topojson-client`.** TopoJSON stores shared
112
+ borders once instead of twice and quantizes coordinates to integers, which is ~60–70%
113
+ smaller than equivalent GeoJSON before compression and still meaningfully smaller after.
114
+ `topojson-client` is ~3 kB gzipped, zero-dependency, and MIT — a good trade for that.
115
+
116
+ ### Bundling strategy: split by how it is used
117
+
118
+ This is the important part. The data splits cleanly along an access pattern:
119
+
120
+ - **Boundary geometry** is only needed to *draw*, and is **lazy-loaded** via dynamic
121
+ `import()` into its own chunk. It never lands in a consumer's main bundle, and a
122
+ consumer who imports only `aggregateAttacks` never downloads it at all. US state
123
+ geometry is a *separate* chunk again, fetched only when it would change what renders.
124
+ - **The region table** (canonical codes + anchor coordinates) is needed to *resolve* —
125
+ turn `"US-CA"` into a coordinate — which must be synchronous and must work without the
126
+ geometry present. So it stays in the main bundle. This is what lets aggregation and
127
+ region resolution be pure, testable functions with no async and no fetch.
128
+
129
+ Measured from an actual `npm run build` (not estimates):
130
+
131
+ | Artifact | gzipped | When it loads |
132
+ | --- | --- | --- |
133
+ | Main bundle — component, renderer, aggregation, region table | **22.2 kB** | On import |
134
+ | `countries` chunk | **40.0 kB** | On mount, in parallel with first paint |
135
+ | `small-countries` chunk | **28.5 kB** | Alongside `countries` — see below |
136
+ | `states` chunk | **37.8 kB** | Only when state borders or coordinate→state resolution are needed |
137
+
138
+ The load chain is two dynamic hops — `index.js` → `geo.js` → `countries-*.js` — so a
139
+ bundler that respects `import()` code-splits all of it away from the entry automatically.
140
+ Nothing in the main bundle statically references geometry.
141
+
142
+ The state chunk is genuinely conditional: `<ThreatMap>` only asks for it when
143
+ `regions.showStates` is on, or when the feed contains bare `{lat, lng}` origins that
144
+ would need point-in-polygon to reach state granularity. A feed that uses `"US-CA"`-style
145
+ codes gets full state-level aggregation without ever fetching it.
146
+
147
+ Consumers who want to self-host or preload can pass `geo` directly and skip our loader.
148
+
149
+ ### Resolving and drawing use different datasets, on purpose
150
+
151
+ The obvious build is to derive everything — geometry *and* the region table — from
152
+ the one 1:110m file. That is what this library did first, and it was quietly broken.
153
+
154
+ Natural Earth's 1:110m file contains 177 countries because it omits every country
155
+ too small to draw at world scale. That is the right call for *drawing*: Singapore is
156
+ sub-pixel on a 900 px-wide map. But deriving the *resolution* table from it too left
157
+ **75 of 249 ISO countries with no anchor** — including Singapore, Hong Kong, Bahrain
158
+ and Malta. An attack from any of them resolved to nothing and was dropped with a
159
+ warning. Those are among the most active hosting regions on the internet; a threat
160
+ map that cannot draw a line from Singapore is broken for its actual purpose.
161
+
162
+ So the two concerns use different sources:
163
+
164
+ - **Drawing** uses 1:110m — 177 countries, the ones that read at world scale.
165
+ - **Resolving** uses 1:10m — 238 countries, plus a small hand-curated table for the
166
+ 11 territories (Réunion, Martinique, Mayotte…) that Natural Earth models inside
167
+ their parent country and so have no polygon of their own at any resolution.
168
+
169
+ All of it is a build-time read; the 1:10m file is a devDependency and not one byte
170
+ of it ships. The result is that **all 252 ISO 3166-1 assigned countries resolve**,
171
+ while the drawn map stays at the resolution that actually looks right. A threat from
172
+ Singapore renders its line correctly — Singapore just isn't painted as its own
173
+ landmass, which is what you want anyway.
174
+
175
+ `scripts/build-geo.mjs` asserts total ISO coverage and fails the build if any
176
+ assigned country lacks an anchor, so this cannot regress silently.
177
+
178
+ ### The same problem, again, for reverse lookup — and why it cost 28.5 kB
179
+
180
+ Fixing the *code* path (`"SG"` → coordinate) left the *coordinate* path still broken,
181
+ and broken in a nastier way. Reverse lookup point-in-polygons against the drawn
182
+ geometry, and at 1:110m the Johor Strait is not resolved — Singapore's island is drawn
183
+ as part of the Malay peninsula. So `{lat: 1.35, lng: 103.82}` did not fail; it
184
+ confidently returned **Malaysia**. Hong Kong returned **China**. Malta and Bahrain
185
+ returned ocean.
186
+
187
+ Returning the wrong sovereign country is worse than returning nothing, and worst of all
188
+ on a security display, where the whole output is an attribution claim. Bare coordinates
189
+ are a first-class documented input, so "pass a region code instead" is not an answer.
190
+
191
+ Hence a third artifact: `small-countries.json`, holding geometry for the 61 countries
192
+ absent from 1:110m, lazy-loaded beside the countries chunk.
193
+
194
+ - **Plain GeoJSON, not TopoJSON.** These are islands and enclaves that share no borders,
195
+ so TopoJSON's arc-sharing would save nothing and cost a build-time topology merge.
196
+ - **1:50m for 54 of them.** 22 kB gzipped versus 104 kB for 1:10m, and open water already
197
+ separates an island nation cleanly from everything else.
198
+ - **1:10m for 7 enclaves** (`HK`, `MO`, `SM`, `VA`, `LI`, `AD`, `MC`) — +5 kB. These sit
199
+ *inside* a much larger neighbour, so the border is the only thing distinguishing them,
200
+ and 1:50m does not resolve it: Hong Kong at 1:50m is a 39-point blob that misses Hong
201
+ Kong Island entirely, dropping Victoria Harbour back into `CN`. Hong Kong and Macao are
202
+ major hosting hubs and appear in real feeds constantly.
203
+ - **Small countries are tested first**, which is load-bearing: a Singapore point is inside
204
+ both Singapore's 1:50m polygon and Malaysia's 1:110m one, and the specific answer has to
205
+ win.
206
+
207
+ The trade is 28.5 kB on a lazy, cached chunk against silently misattributing attacks from
208
+ Singapore, Hong Kong, Macao, Malta, Bahrain, Luxembourg, Monaco and ~54 others. Tests
209
+ cover both directions — the microstates resolving correctly, *and* their large neighbours
210
+ (Shenzhen, Guangzhou, Johor Bahru, Nice, Zurich, Rome) not being stolen by them.
211
+
212
+ ### Centroid choice: largest-polygon centroid, not true centroid
213
+
214
+ A naive `geoCentroid` on a multipolygon country puts the United States' anchor point in
215
+ the Pacific (pulled west by Alaska and Hawaii) and France's in the Atlantic (pulled by
216
+ overseas territories). Both are useless as the visual origin of a threat line.
217
+
218
+ Instead the build script computes the centroid of each feature's **largest-area polygon**,
219
+ which resolves to the mainland landmass in every one of these cases. A small curated
220
+ override table handles the remaining awkward cases. Centroids are computed at build time
221
+ and shipped as data, so no runtime geometry or math is needed to resolve a region code.
222
+
223
+ ### Reverse resolution (raw `{lat, lng}` → region)
224
+
225
+ When a consumer passes raw coordinates but wants aggregation, we must determine which
226
+ region the point falls in. Testing a point against ~300 features with point-in-polygon is
227
+ too slow at 500 attacks/frame-batch, so we:
228
+
229
+ 1. Reject by precomputed bounding box first (removes ~99% of candidates in one compare).
230
+ 2. Run exact point-in-polygon only on survivors (typically 1–3).
231
+ 3. Memoize by rounded coordinate, since streaming attack feeds repeat origins heavily.
232
+
233
+ This requires geometry, so it is async and only active once the geo chunk resolves.
234
+ Attacks given an explicit region code (`"US-CA"`) skip all of it.
235
+
236
+ ---
237
+
238
+ ## 4. Runtime dependencies
239
+
240
+ Every runtime dependency, and why it earns its place:
241
+
242
+ | Package | Justification |
243
+ | --- | --- |
244
+ | `d3-geo` | Projections, `geoPath` (which targets a Canvas context directly), and `geoInterpolate`. Reimplementing correct projections and antimeridian clipping is weeks of subtle work. We import ~7 symbols; the rest tree-shakes. |
245
+ | `topojson-client` | Decodes the TopoJSON we ship. Zero-dependency, MIT, ~3 kB gzipped — and it pays for itself many times over in transfer size. Lands in the lazy geo chunk, never the main bundle. |
246
+
247
+ Both are declared `external`, so consumers dedupe them against their own copies of d3
248
+ rather than shipping a second one.
249
+
250
+ `react` is a **peer** dependency (>=18), never bundled.
251
+
252
+ Deliberately **not** dependencies: no state manager, no styling library, no CSS-in-JS, no
253
+ animation library, no `d3-selection`/`d3-zoom`/`d3-scale`. The library ships zero CSS —
254
+ it renders into a canvas you size — so it imposes no styling solution on the consumer.
255
+
256
+ ---
257
+
258
+ ## 5. API stability notes
259
+
260
+ Two decisions in the public API were close calls and are worth flagging, since they are
261
+ the ones most likely to be revisited:
262
+
263
+ **Default aggregation groups by origin *and* destination, not origin alone.** The brief
264
+ specifies the origin region as the aggregation key. Taken literally, attacks
265
+ France→US and France→Japan would collapse into one threat — which has no coherent
266
+ destination to draw to. Since the stated *purpose* of aggregation is to stop overlapping
267
+ lines, and those two lines do not overlap, the default is `groupBy: 'origin-destination'`:
268
+ one line per origin→destination region pair. Pure origin collapsing is available via
269
+ `groupBy: 'origin'` (destination becomes the origin's most frequent target), and
270
+ `aggregation.key` overrides grouping entirely.
271
+
272
+ **An aggregate takes the *max* severity of its members, not the mean or mode.** A group
273
+ containing one critical and forty low attacks renders critical. For a security display,
274
+ under-reporting the worst thing in a bucket is the more dangerous failure mode. This is
275
+ overridable via `aggregation.severity`.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bogdan Taranenko
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.