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 +275 -0
- package/LICENSE +21 -0
- package/README.md +645 -0
- package/dist/chunk-43FECBAY.js +202 -0
- package/dist/chunk-PM2OVFS5.js +9 -0
- package/dist/chunk-S6KRDFNO.cjs +13 -0
- package/dist/chunk-Y7WYSA4G.cjs +208 -0
- package/dist/countries-F7ATTG3H.cjs +20 -0
- package/dist/countries-NSR2XH7C.js +11 -0
- package/dist/geo.cjs +69 -0
- package/dist/geo.d.cts +56 -0
- package/dist/geo.d.ts +56 -0
- package/dist/geo.js +47 -0
- package/dist/index.cjs +1351 -0
- package/dist/index.d.cts +301 -0
- package/dist/index.d.ts +301 -0
- package/dist/index.js +1321 -0
- package/dist/regions-neHSPgtu.d.cts +765 -0
- package/dist/regions-neHSPgtu.d.ts +765 -0
- package/dist/small-countries-6O4AI3HD.js +8 -0
- package/dist/small-countries-D4QUXHH3.cjs +14 -0
- package/dist/states-DZ3SOMAC.js +11 -0
- package/dist/states-H54VC3H5.cjs +20 -0
- package/package.json +91 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1321 @@
|
|
|
1
|
+
import { lookupRegionCode, UNKNOWN_REGION, RegionIndex } from './chunk-43FECBAY.js';
|
|
2
|
+
export { UNKNOWN_REGION, getRegionById, listRegions, lookupRegionCode } from './chunk-43FECBAY.js';
|
|
3
|
+
import './chunk-PM2OVFS5.js';
|
|
4
|
+
import { useRef, useMemo, useCallback, useState, useEffect } from 'react';
|
|
5
|
+
import { geoOrthographic, geoMercator, geoEquirectangular, geoNaturalEarth1, geoPath, geoGraticule10, geoInterpolate } from 'd3-geo';
|
|
6
|
+
import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
|
|
7
|
+
|
|
8
|
+
// src/config.ts
|
|
9
|
+
var defaultTheme = Object.freeze({
|
|
10
|
+
ocean: "#080c18",
|
|
11
|
+
land: "#18213a",
|
|
12
|
+
border: "#2b3858",
|
|
13
|
+
borderWidth: 0.5,
|
|
14
|
+
stateBorder: "#212c4a",
|
|
15
|
+
stateBorderWidth: 0.4,
|
|
16
|
+
severityColors: Object.freeze({
|
|
17
|
+
low: "#22d3ee",
|
|
18
|
+
medium: "#fbbf24",
|
|
19
|
+
high: "#fb7185",
|
|
20
|
+
critical: "#ef4444"
|
|
21
|
+
}),
|
|
22
|
+
headColor: "#ffffff",
|
|
23
|
+
originColor: "#64748b",
|
|
24
|
+
impactColor: "#ffffff"
|
|
25
|
+
});
|
|
26
|
+
var defaultLineStyle = Object.freeze({
|
|
27
|
+
curvature: 0.22,
|
|
28
|
+
width: 1.2,
|
|
29
|
+
trackOpacity: 0.28,
|
|
30
|
+
trailOpacity: 0.95,
|
|
31
|
+
trailLength: 0.18,
|
|
32
|
+
glow: 0.5,
|
|
33
|
+
headRadius: 2,
|
|
34
|
+
showOrigin: true,
|
|
35
|
+
showImpact: true,
|
|
36
|
+
segments: 48
|
|
37
|
+
});
|
|
38
|
+
var defaultAnimation = Object.freeze({
|
|
39
|
+
enabled: true,
|
|
40
|
+
speed: 0.5,
|
|
41
|
+
easing: "easeInOutQuad",
|
|
42
|
+
stagger: 1,
|
|
43
|
+
loop: true,
|
|
44
|
+
fadeIn: 400,
|
|
45
|
+
fadeOut: 600,
|
|
46
|
+
respectReducedMotion: true
|
|
47
|
+
});
|
|
48
|
+
var defaultRegions = Object.freeze({
|
|
49
|
+
showCountries: true,
|
|
50
|
+
showStates: false,
|
|
51
|
+
showGraticule: false,
|
|
52
|
+
graticuleColor: "rgba(255,255,255,0.05)",
|
|
53
|
+
showSphere: true
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// src/geo/resolve.ts
|
|
57
|
+
function isLatLng(location) {
|
|
58
|
+
return typeof location === "object" && location !== null && "lat" in location && "lng" in location;
|
|
59
|
+
}
|
|
60
|
+
function isValidCoordinate(point) {
|
|
61
|
+
return Number.isFinite(point.lat) && Number.isFinite(point.lng) && point.lat >= -90 && point.lat <= 90 && point.lng >= -180 && point.lng <= 180;
|
|
62
|
+
}
|
|
63
|
+
function resolveLocation(location, index, preferStates = true) {
|
|
64
|
+
if (typeof location === "string") {
|
|
65
|
+
const entry = lookupRegionCode(location);
|
|
66
|
+
if (!entry) {
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
reason: `Unknown region code "${location}". Expected an ISO 3166-1 alpha-2/alpha-3 country code (e.g. "FR", "FRA") or an ISO 3166-2 US state code (e.g. "US-CA").`
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
return { ok: true, value: { point: entry.anchor, region: toRegion(entry) } };
|
|
73
|
+
}
|
|
74
|
+
if (!isLatLng(location)) {
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
reason: `Invalid location ${JSON.stringify(location)}. Expected a region code string or a {lat, lng} object.`
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
const point = { lat: location.lat, lng: location.lng };
|
|
81
|
+
if (!isValidCoordinate(point)) {
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
reason: `Invalid coordinates {lat: ${location.lat}, lng: ${location.lng}}. Latitude must be within [-90, 90] and longitude within [-180, 180].`
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
if (location.region) {
|
|
88
|
+
const entry = lookupRegionCode(location.region);
|
|
89
|
+
if (entry) return { ok: true, value: { point, region: toRegion(entry) } };
|
|
90
|
+
}
|
|
91
|
+
const region = index ? index.resolve(point, preferStates) : UNKNOWN_REGION;
|
|
92
|
+
return { ok: true, value: { point, region } };
|
|
93
|
+
}
|
|
94
|
+
function toRegion(entry) {
|
|
95
|
+
return { id: entry.id, name: entry.name, kind: entry.kind, countryCode: entry.countryCode };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// src/utils/merge.ts
|
|
99
|
+
function mergeDefined(defaults, overrides) {
|
|
100
|
+
if (!overrides) return { ...defaults };
|
|
101
|
+
const result = { ...defaults };
|
|
102
|
+
for (const key of Object.keys(overrides)) {
|
|
103
|
+
const value = overrides[key];
|
|
104
|
+
if (value !== void 0) {
|
|
105
|
+
result[key] = value;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// src/aggregation/keys.ts
|
|
112
|
+
var SEVERITY_ORDER = ["low", "medium", "high", "critical"];
|
|
113
|
+
var SEVERITY_RANK = new Map(SEVERITY_ORDER.map((s, i) => [s, i]));
|
|
114
|
+
function severityRank(severity) {
|
|
115
|
+
return SEVERITY_RANK.get(severity) ?? 1;
|
|
116
|
+
}
|
|
117
|
+
function maxSeverity(severities) {
|
|
118
|
+
let best = severities[0] ?? "medium";
|
|
119
|
+
let bestRank = severityRank(best);
|
|
120
|
+
for (let i = 1; i < severities.length; i++) {
|
|
121
|
+
const current = severities[i];
|
|
122
|
+
const rank = severityRank(current);
|
|
123
|
+
if (rank > bestRank) {
|
|
124
|
+
best = current;
|
|
125
|
+
bestRank = rank;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return best;
|
|
129
|
+
}
|
|
130
|
+
function regionKey(region, granularity) {
|
|
131
|
+
if (region.kind === "unknown") return region.id;
|
|
132
|
+
if (granularity === "country") {
|
|
133
|
+
return region.countryCode;
|
|
134
|
+
}
|
|
135
|
+
return region.id;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// src/aggregation/scale.ts
|
|
139
|
+
var MAX_INTENSITY = 6;
|
|
140
|
+
var defaultIntensityScale = (count) => {
|
|
141
|
+
if (!Number.isFinite(count) || count <= 1) return 1;
|
|
142
|
+
const intensity = 1 + Math.log2(count) * 0.5;
|
|
143
|
+
return Math.min(intensity, MAX_INTENSITY);
|
|
144
|
+
};
|
|
145
|
+
function clampIntensity(value) {
|
|
146
|
+
if (Number.isNaN(value)) return 1;
|
|
147
|
+
return Math.max(0, Math.min(value, MAX_INTENSITY));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// src/aggregation/aggregate.ts
|
|
151
|
+
var defaultAggregation = Object.freeze({
|
|
152
|
+
enabled: true,
|
|
153
|
+
granularity: "auto",
|
|
154
|
+
groupBy: "origin-destination",
|
|
155
|
+
minCount: 2,
|
|
156
|
+
scale: defaultIntensityScale,
|
|
157
|
+
severity: maxSeverity
|
|
158
|
+
});
|
|
159
|
+
function aggregateAttacks(attacks, options = {}) {
|
|
160
|
+
const { index = null, onError } = options;
|
|
161
|
+
const config = resolveConfig(options.config);
|
|
162
|
+
const prepared = prepareAll(attacks, index, config.granularity, onError);
|
|
163
|
+
if (!config.enabled) {
|
|
164
|
+
return sortThreats(prepared.map((p, i) => toSoloThreat(p, i)));
|
|
165
|
+
}
|
|
166
|
+
const { groups, ungrouped } = groupAll(prepared, config);
|
|
167
|
+
const threats = [];
|
|
168
|
+
for (const p of ungrouped) {
|
|
169
|
+
threats.push(toSoloThreat(p, threats.length));
|
|
170
|
+
}
|
|
171
|
+
for (const group of groups) {
|
|
172
|
+
if (group.members.length < config.minCount) {
|
|
173
|
+
for (const p of group.members) threats.push(toSoloThreat(p, threats.length));
|
|
174
|
+
} else {
|
|
175
|
+
threats.push(collapse(group, config));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const sorted = sortThreats(threats);
|
|
179
|
+
return config.maxGroups != null && config.maxGroups >= 0 ? sorted.slice(0, config.maxGroups) : sorted;
|
|
180
|
+
}
|
|
181
|
+
function resolveConfig(config) {
|
|
182
|
+
const base = defaultAggregation;
|
|
183
|
+
if (config === false) {
|
|
184
|
+
return { ...base, enabled: false };
|
|
185
|
+
}
|
|
186
|
+
return mergeDefined(base, config);
|
|
187
|
+
}
|
|
188
|
+
function prepareAll(attacks, index, granularity, onError) {
|
|
189
|
+
const preferStates = granularity !== "country";
|
|
190
|
+
const out = [];
|
|
191
|
+
for (const attack of attacks) {
|
|
192
|
+
const from = resolveLocation(attack.from, index, preferStates);
|
|
193
|
+
if (!from.ok) {
|
|
194
|
+
onError?.(`Could not resolve attack origin: ${from.reason}`, attack);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const to = resolveLocation(attack.to, index, preferStates);
|
|
198
|
+
if (!to.ok) {
|
|
199
|
+
onError?.(`Could not resolve attack destination: ${to.reason}`, attack);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const rawWeight = attack.weight ?? 1;
|
|
203
|
+
out.push({
|
|
204
|
+
attack,
|
|
205
|
+
from: from.value.point,
|
|
206
|
+
to: to.value.point,
|
|
207
|
+
fromRegion: from.value.region,
|
|
208
|
+
toRegion: to.value.region,
|
|
209
|
+
// A non-finite or negative weight would poison totalWeight and, through
|
|
210
|
+
// it, the intensity scale and the maxGroups ordering.
|
|
211
|
+
weight: Number.isFinite(rawWeight) && rawWeight > 0 ? rawWeight : 1,
|
|
212
|
+
severity: attack.severity ?? "medium"
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
function groupAll(prepared, config) {
|
|
218
|
+
const groups = /* @__PURE__ */ new Map();
|
|
219
|
+
const ungrouped = [];
|
|
220
|
+
for (const p of prepared) {
|
|
221
|
+
const key = deriveKey(p, config);
|
|
222
|
+
if (key === null) {
|
|
223
|
+
ungrouped.push(p);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
let group = groups.get(key);
|
|
227
|
+
if (!group) {
|
|
228
|
+
group = { key, members: [], totalWeight: 0 };
|
|
229
|
+
groups.set(key, group);
|
|
230
|
+
}
|
|
231
|
+
group.members.push(p);
|
|
232
|
+
group.totalWeight += p.weight;
|
|
233
|
+
}
|
|
234
|
+
return { groups: [...groups.values()], ungrouped };
|
|
235
|
+
}
|
|
236
|
+
function deriveKey(p, config) {
|
|
237
|
+
if (config.key) {
|
|
238
|
+
return config.key(p.attack, p.fromRegion, p.toRegion);
|
|
239
|
+
}
|
|
240
|
+
const origin = regionKey(p.fromRegion, config.granularity);
|
|
241
|
+
if (config.groupBy === "origin") return origin;
|
|
242
|
+
const destination = regionKey(p.toRegion, config.granularity);
|
|
243
|
+
return `${origin}>${destination}`;
|
|
244
|
+
}
|
|
245
|
+
function collapse(group, config) {
|
|
246
|
+
const members = group.members;
|
|
247
|
+
const first = members[0];
|
|
248
|
+
const representative = config.groupBy === "origin" ? dominantDestination(members) : first;
|
|
249
|
+
const count = members.length;
|
|
250
|
+
const severity = config.severity(members.map((m) => m.severity));
|
|
251
|
+
const intensity = clampIntensity(config.scale(count, group.totalWeight));
|
|
252
|
+
return {
|
|
253
|
+
id: group.key,
|
|
254
|
+
// Origin is the shared property of the group, so any member's coordinate
|
|
255
|
+
// would do — but averaging them puts the line's tail at the centre of where
|
|
256
|
+
// the attacks actually came from, which reads better than snapping to one
|
|
257
|
+
// arbitrary member or to the region's anchor.
|
|
258
|
+
from: meanPoint(members.map((m) => m.from)),
|
|
259
|
+
to: representative.to,
|
|
260
|
+
fromRegion: first.fromRegion,
|
|
261
|
+
toRegion: representative.toRegion,
|
|
262
|
+
count,
|
|
263
|
+
totalWeight: group.totalWeight,
|
|
264
|
+
severity,
|
|
265
|
+
intensity,
|
|
266
|
+
attacks: members.map((m) => m.attack)
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
function dominantDestination(members) {
|
|
270
|
+
const weightByRegion = /* @__PURE__ */ new Map();
|
|
271
|
+
for (const m of members) {
|
|
272
|
+
weightByRegion.set(m.toRegion.id, (weightByRegion.get(m.toRegion.id) ?? 0) + m.weight);
|
|
273
|
+
}
|
|
274
|
+
let bestId = "";
|
|
275
|
+
let bestWeight = -1;
|
|
276
|
+
for (const [id, weight] of weightByRegion) {
|
|
277
|
+
if (weight > bestWeight || weight === bestWeight && id < bestId) {
|
|
278
|
+
bestWeight = weight;
|
|
279
|
+
bestId = id;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return members.find((m) => m.toRegion.id === bestId);
|
|
283
|
+
}
|
|
284
|
+
function meanPoint(points) {
|
|
285
|
+
if (points.length === 1) return points[0];
|
|
286
|
+
let latSum = 0;
|
|
287
|
+
let x = 0;
|
|
288
|
+
let y = 0;
|
|
289
|
+
for (const p of points) {
|
|
290
|
+
latSum += p.lat;
|
|
291
|
+
const rad = p.lng * Math.PI / 180;
|
|
292
|
+
x += Math.cos(rad);
|
|
293
|
+
y += Math.sin(rad);
|
|
294
|
+
}
|
|
295
|
+
const lat = latSum / points.length;
|
|
296
|
+
if (Math.abs(x) < 1e-12 && Math.abs(y) < 1e-12) {
|
|
297
|
+
return { lat, lng: points[0].lng };
|
|
298
|
+
}
|
|
299
|
+
return { lat, lng: Math.atan2(y, x) * 180 / Math.PI };
|
|
300
|
+
}
|
|
301
|
+
function toSoloThreat(p, ordinal) {
|
|
302
|
+
return {
|
|
303
|
+
id: attackId(p.attack, ordinal),
|
|
304
|
+
from: p.from,
|
|
305
|
+
to: p.to,
|
|
306
|
+
fromRegion: p.fromRegion,
|
|
307
|
+
toRegion: p.toRegion,
|
|
308
|
+
count: 1,
|
|
309
|
+
totalWeight: p.weight,
|
|
310
|
+
severity: p.severity,
|
|
311
|
+
intensity: 1,
|
|
312
|
+
attacks: [p.attack]
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
function attackId(attack, ordinal) {
|
|
316
|
+
if (attack.id) return attack.id;
|
|
317
|
+
const from = typeof attack.from === "string" ? attack.from : `${attack.from.lat},${attack.from.lng}`;
|
|
318
|
+
const to = typeof attack.to === "string" ? attack.to : `${attack.to.lat},${attack.to.lng}`;
|
|
319
|
+
return `${from}>${to}@${attack.timestamp ?? ordinal}`;
|
|
320
|
+
}
|
|
321
|
+
function sortThreats(threats) {
|
|
322
|
+
return threats.sort((a, b) => b.totalWeight - a.totalWeight || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
323
|
+
}
|
|
324
|
+
var factories = Object.freeze({
|
|
325
|
+
naturalEarth1: () => geoNaturalEarth1(),
|
|
326
|
+
equirectangular: () => geoEquirectangular(),
|
|
327
|
+
mercator: () => geoMercator(),
|
|
328
|
+
orthographic: () => geoOrthographic()
|
|
329
|
+
});
|
|
330
|
+
var aspectRatios = Object.freeze({
|
|
331
|
+
naturalEarth1: 0.5,
|
|
332
|
+
equirectangular: 0.5,
|
|
333
|
+
// Mercator is infinite at the poles, so it is clipped to ~±85° — which is
|
|
334
|
+
// very nearly square.
|
|
335
|
+
mercator: 0.72,
|
|
336
|
+
orthographic: 1
|
|
337
|
+
});
|
|
338
|
+
function ratioFor(spec) {
|
|
339
|
+
return typeof spec === "string" ? aspectRatios[spec] ?? 0.5 : 0.5;
|
|
340
|
+
}
|
|
341
|
+
function defaultHeightFor(spec, width) {
|
|
342
|
+
return Math.round(width * ratioFor(spec));
|
|
343
|
+
}
|
|
344
|
+
function aspectRatioFor(spec) {
|
|
345
|
+
return 1 / ratioFor(spec);
|
|
346
|
+
}
|
|
347
|
+
var SPHERE = { type: "Sphere" };
|
|
348
|
+
function createProjection(spec, width, height) {
|
|
349
|
+
const projection = typeof spec === "string" ? (factories[spec] ?? factories.naturalEarth1)() : spec;
|
|
350
|
+
if (typeof projection.fitExtent !== "function") return projection;
|
|
351
|
+
const w = Math.max(1, width);
|
|
352
|
+
const h = Math.max(1, height);
|
|
353
|
+
try {
|
|
354
|
+
return projection.fitExtent(
|
|
355
|
+
[
|
|
356
|
+
[0, 0],
|
|
357
|
+
[w, h]
|
|
358
|
+
],
|
|
359
|
+
SPHERE
|
|
360
|
+
);
|
|
361
|
+
} catch {
|
|
362
|
+
return projection;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
function useElementSize(ref, enabled = true) {
|
|
366
|
+
const [size, setSize] = useState(null);
|
|
367
|
+
useEffect(() => {
|
|
368
|
+
if (!enabled) return;
|
|
369
|
+
const element = ref.current;
|
|
370
|
+
if (!element) return;
|
|
371
|
+
if (typeof ResizeObserver === "undefined") {
|
|
372
|
+
const rect = element.getBoundingClientRect();
|
|
373
|
+
setSize({ width: rect.width, height: rect.height });
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const observer = new ResizeObserver((entries) => {
|
|
377
|
+
const entry = entries[0];
|
|
378
|
+
if (!entry) return;
|
|
379
|
+
const { width, height } = entry.contentRect;
|
|
380
|
+
setSize(
|
|
381
|
+
(previous) => previous && previous.width === width && previous.height === height ? previous : { width, height }
|
|
382
|
+
);
|
|
383
|
+
});
|
|
384
|
+
observer.observe(element);
|
|
385
|
+
return () => observer.disconnect();
|
|
386
|
+
}, [ref, enabled]);
|
|
387
|
+
return size;
|
|
388
|
+
}
|
|
389
|
+
var EMPTY = Object.freeze({ geo: null, index: null, loading: true });
|
|
390
|
+
function useGeoData(source, wantStates, onError) {
|
|
391
|
+
const [state, setState] = useState(EMPTY);
|
|
392
|
+
useEffect(() => {
|
|
393
|
+
let cancelled = false;
|
|
394
|
+
const apply = (geo) => {
|
|
395
|
+
if (cancelled) return;
|
|
396
|
+
setState({ geo, index: new RegionIndex(geo), loading: false });
|
|
397
|
+
};
|
|
398
|
+
const fail = (cause) => {
|
|
399
|
+
if (cancelled) return;
|
|
400
|
+
setState({ geo: null, index: null, loading: false });
|
|
401
|
+
onError?.({
|
|
402
|
+
kind: "geo-load",
|
|
403
|
+
message: "Failed to load map geometry. The map will render without boundaries.",
|
|
404
|
+
cause
|
|
405
|
+
});
|
|
406
|
+
};
|
|
407
|
+
if (source && typeof source !== "function") {
|
|
408
|
+
apply(source);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
setState((previous) => previous.loading ? previous : { ...previous, loading: true });
|
|
412
|
+
const load = source ?? (() => import('./geo.js').then((m) => m.loadGeoData({ states: wantStates })));
|
|
413
|
+
Promise.resolve().then(load).then(apply).catch(fail);
|
|
414
|
+
return () => {
|
|
415
|
+
cancelled = true;
|
|
416
|
+
};
|
|
417
|
+
}, [source, wantStates]);
|
|
418
|
+
return state;
|
|
419
|
+
}
|
|
420
|
+
var MAX_PIXEL_RATIO = 2;
|
|
421
|
+
function currentRatio() {
|
|
422
|
+
if (typeof window === "undefined") return 1;
|
|
423
|
+
return Math.min(window.devicePixelRatio || 1, MAX_PIXEL_RATIO);
|
|
424
|
+
}
|
|
425
|
+
function usePixelRatio() {
|
|
426
|
+
const [ratio, setRatio] = useState(currentRatio);
|
|
427
|
+
useEffect(() => {
|
|
428
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
|
|
429
|
+
let query;
|
|
430
|
+
const update = () => {
|
|
431
|
+
setRatio(currentRatio());
|
|
432
|
+
listen();
|
|
433
|
+
};
|
|
434
|
+
const listen = () => {
|
|
435
|
+
query = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
|
|
436
|
+
query.addEventListener("change", update, { once: true });
|
|
437
|
+
};
|
|
438
|
+
listen();
|
|
439
|
+
return () => query?.removeEventListener("change", update);
|
|
440
|
+
}, []);
|
|
441
|
+
return ratio;
|
|
442
|
+
}
|
|
443
|
+
var QUERY = "(prefers-reduced-motion: reduce)";
|
|
444
|
+
function useReducedMotion() {
|
|
445
|
+
const [reduced, setReduced] = useState(false);
|
|
446
|
+
useEffect(() => {
|
|
447
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
|
|
448
|
+
const query = window.matchMedia(QUERY);
|
|
449
|
+
setReduced(query.matches);
|
|
450
|
+
const update = (event) => setReduced(event.matches);
|
|
451
|
+
query.addEventListener("change", update);
|
|
452
|
+
return () => query.removeEventListener("change", update);
|
|
453
|
+
}, []);
|
|
454
|
+
return reduced;
|
|
455
|
+
}
|
|
456
|
+
function drawBaseMap(options) {
|
|
457
|
+
const { ctx, width, height, projection, geo, theme, regions } = options;
|
|
458
|
+
ctx.clearRect(0, 0, width, height);
|
|
459
|
+
const path = geoPath(projection, ctx);
|
|
460
|
+
drawOcean(ctx, width, height, theme, regions, path);
|
|
461
|
+
if (regions.showGraticule) {
|
|
462
|
+
ctx.beginPath();
|
|
463
|
+
path(geoGraticule10());
|
|
464
|
+
ctx.strokeStyle = regions.graticuleColor;
|
|
465
|
+
ctx.lineWidth = 0.5;
|
|
466
|
+
ctx.stroke();
|
|
467
|
+
}
|
|
468
|
+
if (!geo) return;
|
|
469
|
+
if (regions.showCountries) {
|
|
470
|
+
drawFeatures(options, path, geo.countries.features, theme.border, theme.borderWidth);
|
|
471
|
+
}
|
|
472
|
+
if (regions.showStates && geo.states) {
|
|
473
|
+
drawFeatures(options, path, geo.states.features, theme.stateBorder, theme.stateBorderWidth);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
function drawOcean(ctx, width, height, theme, regions, path) {
|
|
477
|
+
if (regions.showSphere) {
|
|
478
|
+
ctx.beginPath();
|
|
479
|
+
path({ type: "Sphere" });
|
|
480
|
+
ctx.fillStyle = theme.ocean;
|
|
481
|
+
ctx.fill();
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
ctx.fillStyle = theme.ocean;
|
|
485
|
+
ctx.fillRect(0, 0, width, height);
|
|
486
|
+
}
|
|
487
|
+
function drawFeatures(options, path, features, borderColor, borderWidth) {
|
|
488
|
+
const { ctx, theme, renderRegion, weights, onError } = options;
|
|
489
|
+
let hookFailed = false;
|
|
490
|
+
const batched = [];
|
|
491
|
+
for (const featureItem of features) {
|
|
492
|
+
if (renderRegion && !hookFailed) {
|
|
493
|
+
try {
|
|
494
|
+
const handled = renderRegion(ctx, {
|
|
495
|
+
feature: featureItem,
|
|
496
|
+
id: featureItem.id,
|
|
497
|
+
kind: featureItem.properties.kind,
|
|
498
|
+
path: (f) => path(f),
|
|
499
|
+
theme,
|
|
500
|
+
weight: weights?.get(featureItem.id) ?? 0
|
|
501
|
+
});
|
|
502
|
+
if (handled === true) continue;
|
|
503
|
+
} catch (error) {
|
|
504
|
+
hookFailed = true;
|
|
505
|
+
onError?.("renderRegion threw; falling back to the built-in renderer.", error);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
batched.push(featureItem);
|
|
509
|
+
}
|
|
510
|
+
if (batched.length === 0) return;
|
|
511
|
+
ctx.beginPath();
|
|
512
|
+
for (const featureItem of batched) {
|
|
513
|
+
path(featureItem);
|
|
514
|
+
}
|
|
515
|
+
ctx.fillStyle = theme.land;
|
|
516
|
+
ctx.fill();
|
|
517
|
+
if (borderWidth > 0) {
|
|
518
|
+
ctx.strokeStyle = borderColor;
|
|
519
|
+
ctx.lineWidth = borderWidth;
|
|
520
|
+
ctx.lineJoin = "round";
|
|
521
|
+
ctx.stroke();
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
function BaseMapCanvas(props) {
|
|
525
|
+
const canvasRef = useRef(null);
|
|
526
|
+
const { width, height, pixelRatio, projection, geo, theme, regions, renderRegion, weights, onError } = props;
|
|
527
|
+
useEffect(() => {
|
|
528
|
+
const canvas = canvasRef.current;
|
|
529
|
+
if (!canvas || !projection || width <= 0 || height <= 0) return;
|
|
530
|
+
const ctx = canvas.getContext("2d");
|
|
531
|
+
if (!ctx) {
|
|
532
|
+
onError?.("Could not acquire a 2D canvas context; the base map will not render.", void 0);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
|
536
|
+
drawBaseMap({ ctx, width, height, projection, geo, theme, regions, renderRegion, weights, onError });
|
|
537
|
+
}, [width, height, pixelRatio, projection, geo, theme, regions, renderRegion, weights, onError]);
|
|
538
|
+
return /* @__PURE__ */ jsx(
|
|
539
|
+
"canvas",
|
|
540
|
+
{
|
|
541
|
+
ref: canvasRef,
|
|
542
|
+
width: Math.max(1, Math.round(width * pixelRatio)),
|
|
543
|
+
height: Math.max(1, Math.round(height * pixelRatio)),
|
|
544
|
+
style: { position: "absolute", inset: 0, width: "100%", height: "100%" },
|
|
545
|
+
"aria-hidden": "true"
|
|
546
|
+
}
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
function buildArc(from, to, projection, curvature, segments, maxLift = Infinity) {
|
|
550
|
+
const steps = Math.max(2, Math.floor(segments));
|
|
551
|
+
const count = steps + 1;
|
|
552
|
+
const a = [from.lng, from.lat];
|
|
553
|
+
const b = [to.lng, to.lat];
|
|
554
|
+
const startPx = projection(a);
|
|
555
|
+
const endPx = projection(b);
|
|
556
|
+
if (!startPx || !endPx || !isFinitePoint(startPx) || !isFinitePoint(endPx)) return null;
|
|
557
|
+
const dx = endPx[0] - startPx[0];
|
|
558
|
+
const dy = endPx[1] - startPx[1];
|
|
559
|
+
const chord = Math.hypot(dx, dy);
|
|
560
|
+
const nx = chord > 0 ? -dy / chord : 0;
|
|
561
|
+
const ny = chord > 0 ? dx / chord : 0;
|
|
562
|
+
const rawLift = chord * curvature;
|
|
563
|
+
const lift = Math.sign(rawLift) * Math.min(Math.abs(rawLift), maxLift);
|
|
564
|
+
const interpolate = geoInterpolate(a, b);
|
|
565
|
+
const points = new Float32Array(count * 2);
|
|
566
|
+
let breakAt = -1;
|
|
567
|
+
let prevX = 0;
|
|
568
|
+
let written = 0;
|
|
569
|
+
for (let i = 0; i < count; i++) {
|
|
570
|
+
const t = i / steps;
|
|
571
|
+
const projected = projection(interpolate(t));
|
|
572
|
+
if (!projected || !isFinitePoint(projected)) {
|
|
573
|
+
const x2 = startPx[0] + dx * t;
|
|
574
|
+
const y2 = startPx[1] + dy * t;
|
|
575
|
+
points[i * 2] = x2;
|
|
576
|
+
points[i * 2 + 1] = y2;
|
|
577
|
+
prevX = x2;
|
|
578
|
+
written++;
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
const offset = Math.sin(Math.PI * t) * lift;
|
|
582
|
+
const x = projected[0] + nx * offset;
|
|
583
|
+
const y = projected[1] + ny * offset;
|
|
584
|
+
if (written > 0 && breakAt === -1 && Math.abs(x - prevX) > ANTIMERIDIAN_JUMP) {
|
|
585
|
+
breakAt = i;
|
|
586
|
+
}
|
|
587
|
+
points[i * 2] = x;
|
|
588
|
+
points[i * 2 + 1] = y;
|
|
589
|
+
prevX = x;
|
|
590
|
+
written++;
|
|
591
|
+
}
|
|
592
|
+
const { distances, length } = measure(points, count, breakAt);
|
|
593
|
+
return { points, breakAt, distances, length };
|
|
594
|
+
}
|
|
595
|
+
var ANTIMERIDIAN_JUMP = 180;
|
|
596
|
+
function isFinitePoint(p) {
|
|
597
|
+
return Number.isFinite(p[0]) && Number.isFinite(p[1]);
|
|
598
|
+
}
|
|
599
|
+
function measure(points, count, breakAt) {
|
|
600
|
+
const distances = new Float32Array(count);
|
|
601
|
+
let total = 0;
|
|
602
|
+
for (let i = 1; i < count; i++) {
|
|
603
|
+
if (i !== breakAt) {
|
|
604
|
+
const dx = points[i * 2] - points[(i - 1) * 2];
|
|
605
|
+
const dy = points[i * 2 + 1] - points[(i - 1) * 2 + 1];
|
|
606
|
+
total += Math.hypot(dx, dy);
|
|
607
|
+
}
|
|
608
|
+
distances[i] = total;
|
|
609
|
+
}
|
|
610
|
+
return { distances, length: total };
|
|
611
|
+
}
|
|
612
|
+
function pointAt(geometry, t, out) {
|
|
613
|
+
const { points, distances, length } = geometry;
|
|
614
|
+
const count = distances.length;
|
|
615
|
+
const clamped = Math.max(0, Math.min(1, t));
|
|
616
|
+
const target = clamped * length;
|
|
617
|
+
if (length === 0) {
|
|
618
|
+
out[0] = points[0];
|
|
619
|
+
out[1] = points[1];
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
const i = upperBound(distances, target);
|
|
623
|
+
if (i <= 0) {
|
|
624
|
+
out[0] = points[0];
|
|
625
|
+
out[1] = points[1];
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
if (i >= count) {
|
|
629
|
+
out[0] = points[(count - 1) * 2];
|
|
630
|
+
out[1] = points[(count - 1) * 2 + 1];
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
const d0 = distances[i - 1];
|
|
634
|
+
const d1 = distances[i];
|
|
635
|
+
const span = d1 - d0;
|
|
636
|
+
const f = span > 0 ? (target - d0) / span : 0;
|
|
637
|
+
const x0 = points[(i - 1) * 2];
|
|
638
|
+
const y0 = points[(i - 1) * 2 + 1];
|
|
639
|
+
const x1 = points[i * 2];
|
|
640
|
+
const y1 = points[i * 2 + 1];
|
|
641
|
+
out[0] = x0 + (x1 - x0) * f;
|
|
642
|
+
out[1] = y0 + (y1 - y0) * f;
|
|
643
|
+
}
|
|
644
|
+
function upperBound(distances, target) {
|
|
645
|
+
let low = 0;
|
|
646
|
+
let high = distances.length - 1;
|
|
647
|
+
while (low < high) {
|
|
648
|
+
const mid = low + high >>> 1;
|
|
649
|
+
if (distances[mid] < target) low = mid + 1;
|
|
650
|
+
else high = mid;
|
|
651
|
+
}
|
|
652
|
+
return low;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// src/render/drawThreats.ts
|
|
656
|
+
var buckets = /* @__PURE__ */ new Map();
|
|
657
|
+
var IMPACT_WINDOW = 0.12;
|
|
658
|
+
var IMPACT_STEPS = 6;
|
|
659
|
+
var IMPACT_RADIUS = 9;
|
|
660
|
+
var scratch = new Float32Array(2);
|
|
661
|
+
var ALPHA_STEPS = 16;
|
|
662
|
+
function drawThreats(options) {
|
|
663
|
+
const { ctx, width, height, threats, theme, line, renderThreat, onError } = options;
|
|
664
|
+
ctx.clearRect(0, 0, width, height);
|
|
665
|
+
ctx.lineCap = "round";
|
|
666
|
+
ctx.lineJoin = "round";
|
|
667
|
+
buckets.clear();
|
|
668
|
+
const decorations = { origins: /* @__PURE__ */ new Map(), impacts: /* @__PURE__ */ new Map() };
|
|
669
|
+
let hookFailed = false;
|
|
670
|
+
for (const renderable of threats) {
|
|
671
|
+
if (renderable.alpha <= 0) continue;
|
|
672
|
+
if (renderThreat && !hookFailed) {
|
|
673
|
+
const handled = runHook(options, renderable, () => {
|
|
674
|
+
hookFailed = true;
|
|
675
|
+
});
|
|
676
|
+
if (handled) continue;
|
|
677
|
+
}
|
|
678
|
+
accumulate(renderable, theme, line);
|
|
679
|
+
accumulateDecorations(renderable, line, decorations);
|
|
680
|
+
}
|
|
681
|
+
paint(ctx, line, theme);
|
|
682
|
+
paintDecorations(ctx, theme, decorations);
|
|
683
|
+
if (hookFailed) {
|
|
684
|
+
onError?.("renderThreat threw; falling back to the built-in renderer.", void 0);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
function runHook(options, renderable, markFailed) {
|
|
688
|
+
const { ctx, theme, line, pixelRatio, elapsed, renderThreat } = options;
|
|
689
|
+
ctx.save();
|
|
690
|
+
try {
|
|
691
|
+
const handled = renderThreat(ctx, {
|
|
692
|
+
threat: renderable.threat,
|
|
693
|
+
points: renderable.geometry.points,
|
|
694
|
+
progress: renderable.progress,
|
|
695
|
+
alpha: renderable.alpha,
|
|
696
|
+
theme,
|
|
697
|
+
line,
|
|
698
|
+
pixelRatio,
|
|
699
|
+
elapsed
|
|
700
|
+
});
|
|
701
|
+
return handled === true;
|
|
702
|
+
} catch {
|
|
703
|
+
markFailed();
|
|
704
|
+
return false;
|
|
705
|
+
} finally {
|
|
706
|
+
ctx.restore();
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
function colorFor(threat, theme) {
|
|
710
|
+
return theme.severityColors[threat.severity] ?? theme.severityColors.medium ?? "#fbbf24";
|
|
711
|
+
}
|
|
712
|
+
function accumulate(renderable, theme, line) {
|
|
713
|
+
const { threat, geometry, progress, alpha } = renderable;
|
|
714
|
+
const color = colorFor(threat, theme);
|
|
715
|
+
const rawWidth = line.width * threat.intensity;
|
|
716
|
+
const width = Math.max(0.25, Math.round(rawWidth * 2) / 2);
|
|
717
|
+
const alphaStep = Math.max(1, Math.round(alpha * ALPHA_STEPS));
|
|
718
|
+
const key = `${color}|${width}|${alphaStep}`;
|
|
719
|
+
let bucket = buckets.get(key);
|
|
720
|
+
if (!bucket) {
|
|
721
|
+
bucket = {
|
|
722
|
+
color,
|
|
723
|
+
width,
|
|
724
|
+
alpha: alphaStep / ALPHA_STEPS,
|
|
725
|
+
track: new Path2D(),
|
|
726
|
+
trail: new Path2D(),
|
|
727
|
+
heads: new Path2D(),
|
|
728
|
+
hasTrack: false,
|
|
729
|
+
hasTrail: false,
|
|
730
|
+
hasHeads: false
|
|
731
|
+
};
|
|
732
|
+
buckets.set(key, bucket);
|
|
733
|
+
}
|
|
734
|
+
appendPolyline(bucket.track, geometry, 0, 1);
|
|
735
|
+
bucket.hasTrack = true;
|
|
736
|
+
if (line.trailLength > 0 && progress > 0) {
|
|
737
|
+
const start = Math.max(0, progress - line.trailLength);
|
|
738
|
+
appendPolyline(bucket.trail, geometry, start, progress);
|
|
739
|
+
bucket.hasTrail = true;
|
|
740
|
+
}
|
|
741
|
+
if (line.headRadius > 0) {
|
|
742
|
+
pointAt(geometry, progress, scratch);
|
|
743
|
+
const radius = line.headRadius * Math.max(1, Math.sqrt(threat.intensity));
|
|
744
|
+
bucket.heads.moveTo(scratch[0] + radius, scratch[1]);
|
|
745
|
+
bucket.heads.arc(scratch[0], scratch[1], radius, 0, Math.PI * 2);
|
|
746
|
+
bucket.hasHeads = true;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
function accumulateDecorations(renderable, line, decorations) {
|
|
750
|
+
const { geometry, progress, threat, alpha } = renderable;
|
|
751
|
+
const { points } = geometry;
|
|
752
|
+
const alphaStep = Math.max(1, Math.round(alpha * ALPHA_STEPS));
|
|
753
|
+
if (line.showOrigin) {
|
|
754
|
+
const radius2 = Math.max(1, 1.5 * Math.sqrt(threat.intensity));
|
|
755
|
+
const x2 = points[0];
|
|
756
|
+
const y2 = points[1];
|
|
757
|
+
let origins = decorations.origins.get(alphaStep);
|
|
758
|
+
if (!origins) {
|
|
759
|
+
origins = new Path2D();
|
|
760
|
+
decorations.origins.set(alphaStep, origins);
|
|
761
|
+
}
|
|
762
|
+
origins.moveTo(x2 + radius2, y2);
|
|
763
|
+
origins.arc(x2, y2, radius2, 0, Math.PI * 2);
|
|
764
|
+
}
|
|
765
|
+
if (!line.showImpact || progress < 1 - IMPACT_WINDOW) return;
|
|
766
|
+
const phase = (progress - (1 - IMPACT_WINDOW)) / IMPACT_WINDOW;
|
|
767
|
+
const phaseStep = Math.min(IMPACT_STEPS - 1, Math.floor(phase * IMPACT_STEPS));
|
|
768
|
+
const last = points.length - 2;
|
|
769
|
+
const x = points[last];
|
|
770
|
+
const y = points[last + 1];
|
|
771
|
+
const radius = Math.max(0.5, phase * IMPACT_RADIUS * Math.sqrt(threat.intensity));
|
|
772
|
+
const key = `${phaseStep}:${alphaStep}`;
|
|
773
|
+
let path = decorations.impacts.get(key);
|
|
774
|
+
if (!path) {
|
|
775
|
+
path = new Path2D();
|
|
776
|
+
decorations.impacts.set(key, path);
|
|
777
|
+
}
|
|
778
|
+
path.moveTo(x + radius, y);
|
|
779
|
+
path.arc(x, y, radius, 0, Math.PI * 2);
|
|
780
|
+
}
|
|
781
|
+
function paintDecorations(ctx, theme, decorations) {
|
|
782
|
+
if (decorations.origins.size > 0) {
|
|
783
|
+
ctx.fillStyle = theme.originColor;
|
|
784
|
+
for (const [alphaStep, path] of decorations.origins) {
|
|
785
|
+
ctx.globalAlpha = 0.9 * (alphaStep / ALPHA_STEPS);
|
|
786
|
+
ctx.fill(path);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
if (decorations.impacts.size > 0) {
|
|
790
|
+
const previousComposite = ctx.globalCompositeOperation;
|
|
791
|
+
ctx.globalCompositeOperation = "lighter";
|
|
792
|
+
ctx.strokeStyle = theme.impactColor;
|
|
793
|
+
ctx.lineWidth = 1;
|
|
794
|
+
for (const [key, path] of decorations.impacts) {
|
|
795
|
+
const [phaseStep, alphaStep] = key.split(":");
|
|
796
|
+
const phase = (Number(phaseStep) + 0.5) / IMPACT_STEPS;
|
|
797
|
+
const fade = Number(alphaStep) / ALPHA_STEPS;
|
|
798
|
+
ctx.globalAlpha = (1 - phase) * 0.7 * fade;
|
|
799
|
+
ctx.stroke(path);
|
|
800
|
+
}
|
|
801
|
+
ctx.globalCompositeOperation = previousComposite;
|
|
802
|
+
}
|
|
803
|
+
ctx.globalAlpha = 1;
|
|
804
|
+
}
|
|
805
|
+
function appendPolyline(path, geometry, from, to) {
|
|
806
|
+
const { points, distances, length, breakAt } = geometry;
|
|
807
|
+
const count = distances.length;
|
|
808
|
+
if (count < 2 || length <= 0) return;
|
|
809
|
+
const startDistance = from * length;
|
|
810
|
+
const endDistance = to * length;
|
|
811
|
+
let started = false;
|
|
812
|
+
for (let i = 0; i < count; i++) {
|
|
813
|
+
const distance = distances[i];
|
|
814
|
+
if (distance < startDistance) {
|
|
815
|
+
continue;
|
|
816
|
+
}
|
|
817
|
+
if (distance > endDistance) break;
|
|
818
|
+
const x = points[i * 2];
|
|
819
|
+
const y = points[i * 2 + 1];
|
|
820
|
+
if (!started) {
|
|
821
|
+
pointAt(geometry, from, scratch);
|
|
822
|
+
path.moveTo(scratch[0], scratch[1]);
|
|
823
|
+
started = true;
|
|
824
|
+
}
|
|
825
|
+
if (i === breakAt) {
|
|
826
|
+
path.moveTo(x, y);
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
path.lineTo(x, y);
|
|
830
|
+
}
|
|
831
|
+
if (!started) {
|
|
832
|
+
pointAt(geometry, from, scratch);
|
|
833
|
+
path.moveTo(scratch[0], scratch[1]);
|
|
834
|
+
}
|
|
835
|
+
pointAt(geometry, to, scratch);
|
|
836
|
+
path.lineTo(scratch[0], scratch[1]);
|
|
837
|
+
}
|
|
838
|
+
function paint(ctx, line, theme) {
|
|
839
|
+
const previousComposite = ctx.globalCompositeOperation;
|
|
840
|
+
ctx.globalCompositeOperation = "lighter";
|
|
841
|
+
for (const bucket of buckets.values()) {
|
|
842
|
+
const alpha = bucket.alpha;
|
|
843
|
+
if (bucket.hasTrack && line.trackOpacity > 0) {
|
|
844
|
+
ctx.globalAlpha = line.trackOpacity * alpha;
|
|
845
|
+
ctx.strokeStyle = bucket.color;
|
|
846
|
+
ctx.lineWidth = bucket.width;
|
|
847
|
+
ctx.stroke(bucket.track);
|
|
848
|
+
}
|
|
849
|
+
if (bucket.hasTrail) {
|
|
850
|
+
if (line.glow > 0) {
|
|
851
|
+
ctx.globalAlpha = line.trailOpacity * alpha * 0.22 * line.glow;
|
|
852
|
+
ctx.strokeStyle = bucket.color;
|
|
853
|
+
ctx.lineWidth = bucket.width * 4;
|
|
854
|
+
ctx.stroke(bucket.trail);
|
|
855
|
+
}
|
|
856
|
+
ctx.globalAlpha = line.trailOpacity * alpha;
|
|
857
|
+
ctx.strokeStyle = bucket.color;
|
|
858
|
+
ctx.lineWidth = bucket.width;
|
|
859
|
+
ctx.stroke(bucket.trail);
|
|
860
|
+
}
|
|
861
|
+
if (bucket.hasHeads) {
|
|
862
|
+
ctx.globalAlpha = alpha;
|
|
863
|
+
ctx.fillStyle = theme.headColor ?? bucket.color;
|
|
864
|
+
ctx.fill(bucket.heads);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
ctx.globalAlpha = 1;
|
|
868
|
+
ctx.globalCompositeOperation = previousComposite;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// src/render/easing.ts
|
|
872
|
+
var easings = Object.freeze({
|
|
873
|
+
linear: (t) => t,
|
|
874
|
+
easeInQuad: (t) => t * t,
|
|
875
|
+
easeOutQuad: (t) => t * (2 - t),
|
|
876
|
+
easeInOutQuad: (t) => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t,
|
|
877
|
+
easeInOutCubic: (t) => t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1
|
|
878
|
+
});
|
|
879
|
+
function resolveEasing(easing) {
|
|
880
|
+
if (typeof easing === "function") {
|
|
881
|
+
return (t) => {
|
|
882
|
+
const eased = easing(t);
|
|
883
|
+
return Number.isFinite(eased) ? Math.max(0, Math.min(1, eased)) : t;
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
return easings[easing] ?? easings.linear;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// src/render/hitTest.ts
|
|
890
|
+
var HIT_TOLERANCE = 5;
|
|
891
|
+
function distanceSquaredToSegment(px, py, ax, ay, bx, by) {
|
|
892
|
+
const dx = bx - ax;
|
|
893
|
+
const dy = by - ay;
|
|
894
|
+
const lengthSquared = dx * dx + dy * dy;
|
|
895
|
+
if (lengthSquared === 0) {
|
|
896
|
+
const cx2 = px - ax;
|
|
897
|
+
const cy2 = py - ay;
|
|
898
|
+
return cx2 * cx2 + cy2 * cy2;
|
|
899
|
+
}
|
|
900
|
+
let t = ((px - ax) * dx + (py - ay) * dy) / lengthSquared;
|
|
901
|
+
t = t < 0 ? 0 : t > 1 ? 1 : t;
|
|
902
|
+
const cx = px - (ax + t * dx);
|
|
903
|
+
const cy = py - (ay + t * dy);
|
|
904
|
+
return cx * cx + cy * cy;
|
|
905
|
+
}
|
|
906
|
+
function distanceToArcSquared(geometry, x, y, tolerance) {
|
|
907
|
+
const { points, breakAt, distances } = geometry;
|
|
908
|
+
const count = distances.length;
|
|
909
|
+
if (count < 2) return Infinity;
|
|
910
|
+
let best = Infinity;
|
|
911
|
+
for (let i = 1; i < count; i++) {
|
|
912
|
+
if (i === breakAt) continue;
|
|
913
|
+
const ax = points[(i - 1) * 2];
|
|
914
|
+
const ay = points[(i - 1) * 2 + 1];
|
|
915
|
+
const bx = points[i * 2];
|
|
916
|
+
const by = points[i * 2 + 1];
|
|
917
|
+
if (ax < x - tolerance && bx < x - tolerance || ax > x + tolerance && bx > x + tolerance || ay < y - tolerance && by < y - tolerance || ay > y + tolerance && by > y + tolerance) {
|
|
918
|
+
continue;
|
|
919
|
+
}
|
|
920
|
+
const d = distanceSquaredToSegment(x, y, ax, ay, bx, by);
|
|
921
|
+
if (d < best) best = d;
|
|
922
|
+
}
|
|
923
|
+
return best;
|
|
924
|
+
}
|
|
925
|
+
function hitTest(candidates2, x, y, baseWidth) {
|
|
926
|
+
let best = null;
|
|
927
|
+
let bestDistance = Infinity;
|
|
928
|
+
for (const candidate of candidates2) {
|
|
929
|
+
const tolerance = HIT_TOLERANCE + baseWidth * candidate.intensity / 2;
|
|
930
|
+
const distance = distanceToArcSquared(candidate.geometry, x, y, tolerance);
|
|
931
|
+
if (distance <= tolerance * tolerance && distance < bestDistance) {
|
|
932
|
+
bestDistance = distance;
|
|
933
|
+
best = candidate.value;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
return best;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// src/hooks/useThreatAnimation.ts
|
|
940
|
+
function phaseFor(id) {
|
|
941
|
+
let hash = 2166136261;
|
|
942
|
+
for (let i = 0; i < id.length; i++) {
|
|
943
|
+
hash ^= id.charCodeAt(i);
|
|
944
|
+
hash = Math.imul(hash, 16777619);
|
|
945
|
+
}
|
|
946
|
+
return (hash >>> 0) % 1e4 / 1e4;
|
|
947
|
+
}
|
|
948
|
+
function useThreatAnimation(options) {
|
|
949
|
+
const entries = useRef(/* @__PURE__ */ new Map());
|
|
950
|
+
const latest = useRef(options);
|
|
951
|
+
latest.current = options;
|
|
952
|
+
const startedAt = useRef(0);
|
|
953
|
+
const frame = useRef(0);
|
|
954
|
+
const { threats, projection, line, width, height } = options;
|
|
955
|
+
useEffect(() => {
|
|
956
|
+
const map = entries.current;
|
|
957
|
+
const now = performance.now();
|
|
958
|
+
const seen = /* @__PURE__ */ new Set();
|
|
959
|
+
for (const threat of threats) {
|
|
960
|
+
seen.add(threat.id);
|
|
961
|
+
const existing = map.get(threat.id);
|
|
962
|
+
if (existing) {
|
|
963
|
+
existing.threat = threat;
|
|
964
|
+
existing.diedAt = null;
|
|
965
|
+
} else {
|
|
966
|
+
map.set(threat.id, {
|
|
967
|
+
threat,
|
|
968
|
+
geometry: null,
|
|
969
|
+
phase: phaseFor(threat.id),
|
|
970
|
+
bornAt: now,
|
|
971
|
+
diedAt: null
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
for (const [id, entry] of map) {
|
|
976
|
+
if (!seen.has(id) && entry.diedAt === null) entry.diedAt = now;
|
|
977
|
+
}
|
|
978
|
+
}, [threats]);
|
|
979
|
+
useEffect(() => {
|
|
980
|
+
if (!projection) return;
|
|
981
|
+
const maxLift = height > 0 ? height / 3 : Infinity;
|
|
982
|
+
for (const entry of entries.current.values()) {
|
|
983
|
+
entry.geometry = buildArc(
|
|
984
|
+
entry.threat.from,
|
|
985
|
+
entry.threat.to,
|
|
986
|
+
projection,
|
|
987
|
+
line.curvature,
|
|
988
|
+
line.segments,
|
|
989
|
+
maxLift
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
}, [projection, line.curvature, line.segments, threats, width, height]);
|
|
993
|
+
useEffect(() => {
|
|
994
|
+
const canvas = options.canvas.current;
|
|
995
|
+
if (!canvas || width <= 0 || height <= 0) return;
|
|
996
|
+
const ctx = canvas.getContext("2d");
|
|
997
|
+
if (!ctx) {
|
|
998
|
+
options.onError?.("Could not acquire a 2D canvas context; the threat layer will not render.", void 0);
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
if (startedAt.current === 0) startedAt.current = performance.now();
|
|
1002
|
+
const renderFrame = (now) => {
|
|
1003
|
+
const current = latest.current;
|
|
1004
|
+
const elapsed = now - startedAt.current;
|
|
1005
|
+
const renderables = collect(entries.current, current, elapsed, now);
|
|
1006
|
+
ctx.setTransform(current.pixelRatio, 0, 0, current.pixelRatio, 0, 0);
|
|
1007
|
+
drawThreats({
|
|
1008
|
+
ctx,
|
|
1009
|
+
width: current.width,
|
|
1010
|
+
height: current.height,
|
|
1011
|
+
threats: renderables,
|
|
1012
|
+
theme: current.theme,
|
|
1013
|
+
line: current.line,
|
|
1014
|
+
pixelRatio: current.pixelRatio,
|
|
1015
|
+
elapsed,
|
|
1016
|
+
renderThreat: current.renderThreat,
|
|
1017
|
+
onError: current.onError
|
|
1018
|
+
});
|
|
1019
|
+
if (current.animation.enabled) {
|
|
1020
|
+
frame.current = requestAnimationFrame(renderFrame);
|
|
1021
|
+
}
|
|
1022
|
+
};
|
|
1023
|
+
frame.current = requestAnimationFrame(renderFrame);
|
|
1024
|
+
return () => cancelAnimationFrame(frame.current);
|
|
1025
|
+
}, [
|
|
1026
|
+
options.canvas,
|
|
1027
|
+
width,
|
|
1028
|
+
height,
|
|
1029
|
+
options.pixelRatio,
|
|
1030
|
+
options.animation,
|
|
1031
|
+
options.theme,
|
|
1032
|
+
options.line,
|
|
1033
|
+
options.renderThreat,
|
|
1034
|
+
threats,
|
|
1035
|
+
projection
|
|
1036
|
+
]);
|
|
1037
|
+
const pickAt = useCallback(
|
|
1038
|
+
(x, y) => {
|
|
1039
|
+
const current = latest.current;
|
|
1040
|
+
return hitTest(candidates(entries.current), x, y, current.line.width);
|
|
1041
|
+
},
|
|
1042
|
+
[]
|
|
1043
|
+
);
|
|
1044
|
+
return { pickAt };
|
|
1045
|
+
}
|
|
1046
|
+
function* candidates(map) {
|
|
1047
|
+
for (const entry of map.values()) {
|
|
1048
|
+
if (!entry.geometry || entry.diedAt !== null) continue;
|
|
1049
|
+
yield { value: entry.threat, geometry: entry.geometry, intensity: entry.threat.intensity };
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
function collect(map, options, elapsed, now) {
|
|
1053
|
+
const { animation } = options;
|
|
1054
|
+
const easing = resolveEasing(animation.easing);
|
|
1055
|
+
const out = [];
|
|
1056
|
+
let expired = null;
|
|
1057
|
+
for (const [id, entry] of map) {
|
|
1058
|
+
if (!entry.geometry) continue;
|
|
1059
|
+
const alpha = alphaFor(entry, animation, now);
|
|
1060
|
+
if (alpha <= 0 && entry.diedAt !== null) {
|
|
1061
|
+
(expired ?? (expired = [])).push(id);
|
|
1062
|
+
continue;
|
|
1063
|
+
}
|
|
1064
|
+
out.push({
|
|
1065
|
+
threat: entry.threat,
|
|
1066
|
+
geometry: entry.geometry,
|
|
1067
|
+
progress: easing(progressFor(entry, animation, elapsed)),
|
|
1068
|
+
alpha
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
if (expired) {
|
|
1072
|
+
for (const id of expired) map.delete(id);
|
|
1073
|
+
}
|
|
1074
|
+
return out;
|
|
1075
|
+
}
|
|
1076
|
+
function progressFor(entry, animation, elapsed) {
|
|
1077
|
+
if (!animation.enabled) return 1;
|
|
1078
|
+
const stagger = Math.max(0, Math.min(1, animation.stagger));
|
|
1079
|
+
const cycles = elapsed / 1e3 * animation.speed + entry.phase * stagger;
|
|
1080
|
+
if (animation.loop) return cycles % 1;
|
|
1081
|
+
return Math.min(1, cycles);
|
|
1082
|
+
}
|
|
1083
|
+
function alphaFor(entry, animation, now) {
|
|
1084
|
+
if (entry.diedAt !== null) {
|
|
1085
|
+
if (animation.fadeOut <= 0) return 0;
|
|
1086
|
+
return Math.max(0, 1 - (now - entry.diedAt) / animation.fadeOut);
|
|
1087
|
+
}
|
|
1088
|
+
if (animation.fadeIn <= 0) return 1;
|
|
1089
|
+
return Math.min(1, (now - entry.bornAt) / animation.fadeIn);
|
|
1090
|
+
}
|
|
1091
|
+
function ThreatCanvas(props) {
|
|
1092
|
+
const canvasRef = useRef(null);
|
|
1093
|
+
const hovered = useRef(null);
|
|
1094
|
+
const {
|
|
1095
|
+
width,
|
|
1096
|
+
height,
|
|
1097
|
+
pixelRatio,
|
|
1098
|
+
projection,
|
|
1099
|
+
threats,
|
|
1100
|
+
theme,
|
|
1101
|
+
line,
|
|
1102
|
+
animation,
|
|
1103
|
+
renderThreat,
|
|
1104
|
+
onThreatClick,
|
|
1105
|
+
onThreatHover,
|
|
1106
|
+
onError
|
|
1107
|
+
} = props;
|
|
1108
|
+
const { pickAt } = useThreatAnimation({
|
|
1109
|
+
canvas: canvasRef,
|
|
1110
|
+
threats,
|
|
1111
|
+
projection,
|
|
1112
|
+
width,
|
|
1113
|
+
height,
|
|
1114
|
+
pixelRatio,
|
|
1115
|
+
theme,
|
|
1116
|
+
line,
|
|
1117
|
+
animation,
|
|
1118
|
+
renderThreat,
|
|
1119
|
+
onError
|
|
1120
|
+
});
|
|
1121
|
+
const interactive = Boolean(onThreatClick ?? onThreatHover);
|
|
1122
|
+
const pick = useCallback(
|
|
1123
|
+
(event) => {
|
|
1124
|
+
const canvas = canvasRef.current;
|
|
1125
|
+
if (!canvas) return null;
|
|
1126
|
+
const rect = canvas.getBoundingClientRect();
|
|
1127
|
+
return pickAt(event.clientX - rect.left, event.clientY - rect.top);
|
|
1128
|
+
},
|
|
1129
|
+
[pickAt]
|
|
1130
|
+
);
|
|
1131
|
+
const handleMove = useCallback(
|
|
1132
|
+
(event) => {
|
|
1133
|
+
if (!onThreatHover) return;
|
|
1134
|
+
const threat = pick(event);
|
|
1135
|
+
const id = threat?.id ?? null;
|
|
1136
|
+
if (id === hovered.current) return;
|
|
1137
|
+
hovered.current = id;
|
|
1138
|
+
onThreatHover(threat, event.nativeEvent);
|
|
1139
|
+
},
|
|
1140
|
+
[onThreatHover, pick]
|
|
1141
|
+
);
|
|
1142
|
+
const handleLeave = useCallback(
|
|
1143
|
+
(event) => {
|
|
1144
|
+
if (!onThreatHover || hovered.current === null) return;
|
|
1145
|
+
hovered.current = null;
|
|
1146
|
+
onThreatHover(null, event.nativeEvent);
|
|
1147
|
+
},
|
|
1148
|
+
[onThreatHover]
|
|
1149
|
+
);
|
|
1150
|
+
const handleClick = useCallback(
|
|
1151
|
+
(event) => {
|
|
1152
|
+
if (!onThreatClick) return;
|
|
1153
|
+
const threat = pick(event);
|
|
1154
|
+
if (threat) onThreatClick(threat, event.nativeEvent);
|
|
1155
|
+
},
|
|
1156
|
+
[onThreatClick, pick]
|
|
1157
|
+
);
|
|
1158
|
+
return /* @__PURE__ */ jsx(
|
|
1159
|
+
"canvas",
|
|
1160
|
+
{
|
|
1161
|
+
ref: canvasRef,
|
|
1162
|
+
width: Math.max(1, Math.round(width * pixelRatio)),
|
|
1163
|
+
height: Math.max(1, Math.round(height * pixelRatio)),
|
|
1164
|
+
style: {
|
|
1165
|
+
position: "absolute",
|
|
1166
|
+
inset: 0,
|
|
1167
|
+
width: "100%",
|
|
1168
|
+
height: "100%",
|
|
1169
|
+
// Without handlers the canvas must not eat pointer events aimed at
|
|
1170
|
+
// whatever the consumer layered underneath.
|
|
1171
|
+
pointerEvents: interactive ? "auto" : "none"
|
|
1172
|
+
},
|
|
1173
|
+
onMouseMove: interactive ? handleMove : void 0,
|
|
1174
|
+
onMouseLeave: interactive ? handleLeave : void 0,
|
|
1175
|
+
onClick: interactive ? handleClick : void 0,
|
|
1176
|
+
"aria-hidden": "true"
|
|
1177
|
+
}
|
|
1178
|
+
);
|
|
1179
|
+
}
|
|
1180
|
+
function ThreatMap(props) {
|
|
1181
|
+
const {
|
|
1182
|
+
attacks,
|
|
1183
|
+
width: widthProp,
|
|
1184
|
+
height: heightProp,
|
|
1185
|
+
projection: projectionProp = "naturalEarth1",
|
|
1186
|
+
geo: geoProp,
|
|
1187
|
+
renderThreat,
|
|
1188
|
+
renderRegion,
|
|
1189
|
+
onThreatClick,
|
|
1190
|
+
onThreatHover,
|
|
1191
|
+
onError,
|
|
1192
|
+
className,
|
|
1193
|
+
style,
|
|
1194
|
+
ariaLabel = "Cyberattack threat map"
|
|
1195
|
+
} = props;
|
|
1196
|
+
const containerRef = useRef(null);
|
|
1197
|
+
const theme = useMemo(() => {
|
|
1198
|
+
const merged = mergeDefined(defaultTheme, props.theme);
|
|
1199
|
+
return props.theme?.severityColors ? { ...merged, severityColors: { ...defaultTheme.severityColors, ...props.theme.severityColors } } : merged;
|
|
1200
|
+
}, [props.theme]);
|
|
1201
|
+
const line = useMemo(() => mergeDefined(defaultLineStyle, props.line), [props.line]);
|
|
1202
|
+
const regions = useMemo(() => mergeDefined(defaultRegions, props.regions), [props.regions]);
|
|
1203
|
+
const reducedMotion = useReducedMotion();
|
|
1204
|
+
const animation = useMemo(() => {
|
|
1205
|
+
const merged = mergeDefined(defaultAnimation, props.animation);
|
|
1206
|
+
return merged.respectReducedMotion && reducedMotion ? { ...merged, enabled: false } : merged;
|
|
1207
|
+
}, [props.animation, reducedMotion]);
|
|
1208
|
+
const measured = useElementSize(containerRef, widthProp === void 0 || heightProp === void 0);
|
|
1209
|
+
const pixelRatio = usePixelRatio();
|
|
1210
|
+
const width = widthProp ?? measured?.width ?? 0;
|
|
1211
|
+
const height = heightProp ?? (measured && measured.height > 0 ? measured.height : width > 0 ? defaultHeightFor(projectionProp, width) : 0);
|
|
1212
|
+
const projection = useMemo(
|
|
1213
|
+
() => width > 0 && height > 0 ? createProjection(projectionProp, width, height) : null,
|
|
1214
|
+
[projectionProp, width, height]
|
|
1215
|
+
);
|
|
1216
|
+
const handleError = useCallback(
|
|
1217
|
+
(error) => {
|
|
1218
|
+
if (onError) {
|
|
1219
|
+
onError(error);
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
if (process.env.NODE_ENV !== "production") {
|
|
1223
|
+
console.warn(`[react-threat-map] ${error.message}`, error.cause ?? "");
|
|
1224
|
+
}
|
|
1225
|
+
},
|
|
1226
|
+
[onError]
|
|
1227
|
+
);
|
|
1228
|
+
const needsStates = regions.showStates || wantsStateResolution(props, attacks);
|
|
1229
|
+
const { geo, index } = useGeoData(geoProp, needsStates, handleError);
|
|
1230
|
+
const aggregationConfig = props.aggregation;
|
|
1231
|
+
const threats = useMemo(
|
|
1232
|
+
() => aggregateAttacks(attacks, {
|
|
1233
|
+
config: aggregationConfig,
|
|
1234
|
+
index,
|
|
1235
|
+
onError: (message, attack) => handleError({ kind: "resolve", message, attack })
|
|
1236
|
+
}),
|
|
1237
|
+
[attacks, aggregationConfig, index, handleError]
|
|
1238
|
+
);
|
|
1239
|
+
const weights = useMemo(() => {
|
|
1240
|
+
if (!renderRegion) return void 0;
|
|
1241
|
+
const map = /* @__PURE__ */ new Map();
|
|
1242
|
+
for (const threat of threats) {
|
|
1243
|
+
map.set(threat.fromRegion.id, (map.get(threat.fromRegion.id) ?? 0) + threat.totalWeight);
|
|
1244
|
+
}
|
|
1245
|
+
return map;
|
|
1246
|
+
}, [threats, renderRegion]);
|
|
1247
|
+
const handleRenderError = useCallback(
|
|
1248
|
+
(message, cause) => handleError({ kind: "render", message, cause }),
|
|
1249
|
+
[handleError]
|
|
1250
|
+
);
|
|
1251
|
+
return /* @__PURE__ */ jsx(
|
|
1252
|
+
"div",
|
|
1253
|
+
{
|
|
1254
|
+
ref: containerRef,
|
|
1255
|
+
className,
|
|
1256
|
+
style: {
|
|
1257
|
+
position: "relative",
|
|
1258
|
+
...widthProp !== void 0 ? { width: widthProp } : { width: "100%" },
|
|
1259
|
+
// With no explicit height, give the box an aspect ratio rather than a
|
|
1260
|
+
// pixel height. The canvases are absolutely positioned and so contribute
|
|
1261
|
+
// no height of their own; without this the wrapper collapses to zero,
|
|
1262
|
+
// which gates off the canvases, which keeps it collapsed. `aspect-ratio`
|
|
1263
|
+
// is inert the moment anything else determines the height — a CSS class,
|
|
1264
|
+
// a flex parent, or the consumer's own `style` below — so it sets a
|
|
1265
|
+
// floor without taking the decision away from them.
|
|
1266
|
+
...heightProp !== void 0 ? { height: heightProp } : { aspectRatio: aspectRatioFor(projectionProp) },
|
|
1267
|
+
...style
|
|
1268
|
+
},
|
|
1269
|
+
role: "img",
|
|
1270
|
+
"aria-label": ariaLabel,
|
|
1271
|
+
children: width > 0 && height > 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
1272
|
+
/* @__PURE__ */ jsx(
|
|
1273
|
+
BaseMapCanvas,
|
|
1274
|
+
{
|
|
1275
|
+
width,
|
|
1276
|
+
height,
|
|
1277
|
+
pixelRatio,
|
|
1278
|
+
projection,
|
|
1279
|
+
geo,
|
|
1280
|
+
theme,
|
|
1281
|
+
regions,
|
|
1282
|
+
renderRegion,
|
|
1283
|
+
weights,
|
|
1284
|
+
onError: handleRenderError
|
|
1285
|
+
}
|
|
1286
|
+
),
|
|
1287
|
+
/* @__PURE__ */ jsx(
|
|
1288
|
+
ThreatCanvas,
|
|
1289
|
+
{
|
|
1290
|
+
width,
|
|
1291
|
+
height,
|
|
1292
|
+
pixelRatio,
|
|
1293
|
+
projection,
|
|
1294
|
+
threats,
|
|
1295
|
+
theme,
|
|
1296
|
+
line,
|
|
1297
|
+
animation,
|
|
1298
|
+
renderThreat,
|
|
1299
|
+
onThreatClick,
|
|
1300
|
+
onThreatHover,
|
|
1301
|
+
onError: handleRenderError
|
|
1302
|
+
}
|
|
1303
|
+
)
|
|
1304
|
+
] }) : null
|
|
1305
|
+
}
|
|
1306
|
+
);
|
|
1307
|
+
}
|
|
1308
|
+
function wantsStateResolution(props, attacks) {
|
|
1309
|
+
const aggregation = props.aggregation;
|
|
1310
|
+
if (aggregation === false || aggregation?.enabled === false) return false;
|
|
1311
|
+
if (aggregation?.granularity === "country") return false;
|
|
1312
|
+
for (const attack of attacks) {
|
|
1313
|
+
if (isBareCoordinate(attack.from) || isBareCoordinate(attack.to)) return true;
|
|
1314
|
+
}
|
|
1315
|
+
return false;
|
|
1316
|
+
}
|
|
1317
|
+
function isBareCoordinate(location) {
|
|
1318
|
+
return typeof location !== "string" && !("region" in location && location.region);
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
export { MAX_INTENSITY, SEVERITY_ORDER, ThreatMap, aggregateAttacks, defaultAggregation, defaultAnimation, defaultIntensityScale, defaultLineStyle, defaultRegions, defaultTheme, maxSeverity, regionKey, resolveLocation, severityRank };
|