react-threat-map 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/DECISIONS.md CHANGED
@@ -394,6 +394,28 @@ their own render there. Every one of those call sites issues a single `setState`
394
394
  practical cost is nil — but a future hook that sets two pieces of state in one async
395
395
  callback would render twice on the floor and once on 18+.
396
396
 
397
+ ### Known behavioural differences on the floor
398
+
399
+ Each of these is invisible to a suite running on 18, so each carries a guard that fails on
400
+ any version. Append to this list rather than fixing quietly — the entries are the only
401
+ record of what the static allowlist cannot reach.
402
+
403
+ **1. Numeric style values.** React appends `px` to numeric `style` values unless the property
404
+ is on its unitless allowlist, and `aspectRatio` only joined that list in React 18. The
405
+ wrapper's fallback ratio was emitted as a number, so on 16.14/17 it serialized to
406
+ `aspect-ratio: 2px` — invalid, dropped by the browser, wrapper collapsed to zero height,
407
+ absolutely-positioned canvases gated off, **map entirely invisible**. It affected every
408
+ projection, not just custom ones, and the whole no-explicit-`height` default path.
409
+
410
+ Found by a consumer on React 16.14, not by CI — precisely the gap this section predicted.
411
+ Fixed by `aspectRatioStyleFor` in `src/render/projection.ts`, which stringifies deliberately;
412
+ guarded by `tests/render/projection.test.ts`, which asserts the emitted **type** rather than
413
+ the rendered value, since the rendered value is correct on 18 either way.
414
+
415
+ The general rule this implies: **any numeric value handed to `style` must be checked against
416
+ React 17's unitless allowlist, or stringified.** `lineHeight` and `flexGrow` are on that list
417
+ and safe; `scale`, `translate`, `rotate` and `inset` are not.
418
+
397
419
  ---
398
420
 
399
421
  ## 8. Package entry points: per-condition types, plus a `typesVersions` fallback
package/README.md CHANGED
@@ -32,6 +32,7 @@ import { ThreatMap } from 'react-threat-map';
32
32
  ## Contents
33
33
 
34
34
  - [Install](#install)
35
+ - [Browser support](#browser-support)
35
36
  - [Quick start](#quick-start)
36
37
  - [Aggregation](#aggregation)
37
38
  - [Customization](#customization)
@@ -62,6 +63,41 @@ React 16.14, 17, 18, and 19 are all supported.
62
63
 
63
64
  ---
64
65
 
66
+ ## Browser support
67
+
68
+ | Browser | Minimum |
69
+ | --- | --- |
70
+ | Chrome / Edge | 88 |
71
+ | Firefox | 89 |
72
+ | Safari (macOS) | 15 |
73
+ | Safari (iOS) | 15 |
74
+
75
+ The floor is set by CSS [`aspect-ratio`][ar], which is how a `<ThreatMap>` given no
76
+ height derives one — the canvases are absolutely positioned, so nothing else in the
77
+ box has a height to contribute. Below that, the component falls back to a
78
+ `min-height` computed from the measured width, so it still renders; the map just
79
+ stops responding to a container whose height is set purely in CSS.
80
+
81
+ Everything else the library touches is older than that floor: Canvas 2D with
82
+ `Path2D` and the `lighter` composite operation, `ResizeObserver` (feature-detected,
83
+ with a `getBoundingClientRect` fallback), and `matchMedia` (both the modern
84
+ `addEventListener` and the `addListener` form Safari needed before 14). The
85
+ published bundle is ES2020 and ships no polyfills.
86
+
87
+ Two things degrade quietly rather than breaking, both on Safari below 16:
88
+
89
+ - The `resolution` media feature is missing, so the canvas is not re-sharpened when
90
+ a window moves to a display with a different pixel ratio. The ratio picked at
91
+ mount stays.
92
+ - `prefers-reduced-motion` still works — that query has been supported since Safari
93
+ 10.1.
94
+
95
+ There is no IE11 support and none is planned; `Path2D` alone rules it out.
96
+
97
+ [ar]: https://developer.mozilla.org/en-US/docs/Web/CSS/aspect-ratio
98
+
99
+ ---
100
+
65
101
  ## Quick start
66
102
 
67
103
  The only required prop is `attacks`. The map sizes itself to its container, loads its own
package/dist/index.cjs CHANGED
@@ -345,6 +345,9 @@ function defaultHeightFor(spec, width) {
345
345
  function aspectRatioFor(spec) {
346
346
  return 1 / ratioFor(spec);
347
347
  }
348
+ function aspectRatioStyleFor(spec) {
349
+ return String(aspectRatioFor(spec));
350
+ }
348
351
  var SPHERE = { type: "Sphere" };
349
352
  function createProjection(spec, width, height) {
350
353
  const projection = typeof spec === "string" ? (factories[spec] ?? factories.naturalEarth1)() : spec;
@@ -427,17 +430,24 @@ function usePixelRatio() {
427
430
  const [ratio, setRatio] = react.useState(currentRatio);
428
431
  react.useEffect(() => {
429
432
  if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
430
- let query;
433
+ let unlisten;
431
434
  const update = () => {
435
+ unlisten?.();
432
436
  setRatio(currentRatio());
433
437
  listen();
434
438
  };
435
439
  const listen = () => {
436
- query = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
437
- query.addEventListener("change", update, { once: true });
440
+ const query = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
441
+ if (typeof query.addEventListener === "function") {
442
+ query.addEventListener("change", update);
443
+ unlisten = () => query.removeEventListener("change", update);
444
+ return;
445
+ }
446
+ query.addListener(update);
447
+ unlisten = () => query.removeListener(update);
438
448
  };
439
449
  listen();
440
- return () => query?.removeEventListener("change", update);
450
+ return () => unlisten?.();
441
451
  }, []);
442
452
  return ratio;
443
453
  }
@@ -449,11 +459,23 @@ function useReducedMotion() {
449
459
  const query = window.matchMedia(QUERY);
450
460
  setReduced(query.matches);
451
461
  const update = (event) => setReduced(event.matches);
452
- query.addEventListener("change", update);
453
- return () => query.removeEventListener("change", update);
462
+ if (typeof query.addEventListener === "function") {
463
+ query.addEventListener("change", update);
464
+ return () => query.removeEventListener("change", update);
465
+ }
466
+ query.addListener(update);
467
+ return () => query.removeListener(update);
454
468
  }, []);
455
469
  return reduced;
456
470
  }
471
+ function useSupportsAspectRatio() {
472
+ const [supported, setSupported] = react.useState(true);
473
+ react.useEffect(() => {
474
+ if (typeof CSS === "undefined" || typeof CSS.supports !== "function") return;
475
+ setSupported(CSS.supports("aspect-ratio", "1"));
476
+ }, []);
477
+ return supported;
478
+ }
457
479
  function configEqual(a, b) {
458
480
  const aKeys = Object.keys(a);
459
481
  if (aKeys.length !== Object.keys(b).length) return false;
@@ -576,7 +598,7 @@ function BaseMapCanvas(props) {
576
598
  ref: canvasRef,
577
599
  width: Math.max(1, Math.round(width * pixelRatio)),
578
600
  height: Math.max(1, Math.round(height * pixelRatio)),
579
- style: { position: "absolute", inset: 0, width: "100%", height: "100%" },
601
+ style: { position: "absolute", top: 0, left: 0, width: "100%", height: "100%" },
580
602
  "aria-hidden": "true"
581
603
  }
582
604
  );
@@ -1221,7 +1243,9 @@ function ThreatCanvas(props) {
1221
1243
  height: Math.max(1, Math.round(height * pixelRatio)),
1222
1244
  style: {
1223
1245
  position: "absolute",
1224
- inset: 0,
1246
+ // Longhand: the `inset` shorthand is Safari 14.1+.
1247
+ top: 0,
1248
+ left: 0,
1225
1249
  width: "100%",
1226
1250
  height: "100%",
1227
1251
  // Without handlers the canvas must not eat pointer events aimed at
@@ -1271,6 +1295,7 @@ function ThreatMap(props) {
1271
1295
  );
1272
1296
  const measured = useElementSize(containerRef, widthProp === void 0 || heightProp === void 0);
1273
1297
  const pixelRatio = usePixelRatio();
1298
+ const supportsAspectRatio = useSupportsAspectRatio();
1274
1299
  const width = widthProp ?? measured?.width ?? 0;
1275
1300
  const height = heightProp ?? (measured && measured.height > 0 ? measured.height : width > 0 ? defaultHeightFor(projectionProp, width) : 0);
1276
1301
  const projection = react.useMemo(
@@ -1326,8 +1351,16 @@ function ThreatMap(props) {
1326
1351
  // which gates off the canvases, which keeps it collapsed. `aspect-ratio`
1327
1352
  // is inert the moment anything else determines the height — a CSS class,
1328
1353
  // a flex parent, or the consumer's own `style` below — so it sets a
1329
- // floor without taking the decision away from them.
1330
- ...heightProp !== void 0 ? { height: heightProp } : { aspectRatio: aspectRatioFor(projectionProp) },
1354
+ // floor without taking the decision away from them. The value is a string
1355
+ // for React 16.14/17's sake; see aspectRatioStyleFor.
1356
+ //
1357
+ // Where `aspect-ratio` is not implemented at all (Safari/iOS <15, Chrome
1358
+ // <88, Firefox <89) the declaration is dropped and the wrapper collapses,
1359
+ // so fall back to a `min-height` derived from the width. `min-height`
1360
+ // rather than `height` keeps the "floor, not a decision" property, and
1361
+ // deriving it from the *width* rather than the measured height is what
1362
+ // stops it feeding back into its own measurement.
1363
+ ...heightProp !== void 0 ? { height: heightProp } : supportsAspectRatio ? { aspectRatio: aspectRatioStyleFor(projectionProp) } : width > 0 ? { minHeight: defaultHeightFor(projectionProp, width) } : {},
1331
1364
  ...style
1332
1365
  },
1333
1366
  role: "img",
package/dist/index.js CHANGED
@@ -344,6 +344,9 @@ function defaultHeightFor(spec, width) {
344
344
  function aspectRatioFor(spec) {
345
345
  return 1 / ratioFor(spec);
346
346
  }
347
+ function aspectRatioStyleFor(spec) {
348
+ return String(aspectRatioFor(spec));
349
+ }
347
350
  var SPHERE = { type: "Sphere" };
348
351
  function createProjection(spec, width, height) {
349
352
  const projection = typeof spec === "string" ? (factories[spec] ?? factories.naturalEarth1)() : spec;
@@ -426,17 +429,24 @@ function usePixelRatio() {
426
429
  const [ratio, setRatio] = useState(currentRatio);
427
430
  useEffect(() => {
428
431
  if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
429
- let query;
432
+ let unlisten;
430
433
  const update = () => {
434
+ unlisten?.();
431
435
  setRatio(currentRatio());
432
436
  listen();
433
437
  };
434
438
  const listen = () => {
435
- query = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
436
- query.addEventListener("change", update, { once: true });
439
+ const query = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
440
+ if (typeof query.addEventListener === "function") {
441
+ query.addEventListener("change", update);
442
+ unlisten = () => query.removeEventListener("change", update);
443
+ return;
444
+ }
445
+ query.addListener(update);
446
+ unlisten = () => query.removeListener(update);
437
447
  };
438
448
  listen();
439
- return () => query?.removeEventListener("change", update);
449
+ return () => unlisten?.();
440
450
  }, []);
441
451
  return ratio;
442
452
  }
@@ -448,11 +458,23 @@ function useReducedMotion() {
448
458
  const query = window.matchMedia(QUERY);
449
459
  setReduced(query.matches);
450
460
  const update = (event) => setReduced(event.matches);
451
- query.addEventListener("change", update);
452
- return () => query.removeEventListener("change", update);
461
+ if (typeof query.addEventListener === "function") {
462
+ query.addEventListener("change", update);
463
+ return () => query.removeEventListener("change", update);
464
+ }
465
+ query.addListener(update);
466
+ return () => query.removeListener(update);
453
467
  }, []);
454
468
  return reduced;
455
469
  }
470
+ function useSupportsAspectRatio() {
471
+ const [supported, setSupported] = useState(true);
472
+ useEffect(() => {
473
+ if (typeof CSS === "undefined" || typeof CSS.supports !== "function") return;
474
+ setSupported(CSS.supports("aspect-ratio", "1"));
475
+ }, []);
476
+ return supported;
477
+ }
456
478
  function configEqual(a, b) {
457
479
  const aKeys = Object.keys(a);
458
480
  if (aKeys.length !== Object.keys(b).length) return false;
@@ -575,7 +597,7 @@ function BaseMapCanvas(props) {
575
597
  ref: canvasRef,
576
598
  width: Math.max(1, Math.round(width * pixelRatio)),
577
599
  height: Math.max(1, Math.round(height * pixelRatio)),
578
- style: { position: "absolute", inset: 0, width: "100%", height: "100%" },
600
+ style: { position: "absolute", top: 0, left: 0, width: "100%", height: "100%" },
579
601
  "aria-hidden": "true"
580
602
  }
581
603
  );
@@ -1220,7 +1242,9 @@ function ThreatCanvas(props) {
1220
1242
  height: Math.max(1, Math.round(height * pixelRatio)),
1221
1243
  style: {
1222
1244
  position: "absolute",
1223
- inset: 0,
1245
+ // Longhand: the `inset` shorthand is Safari 14.1+.
1246
+ top: 0,
1247
+ left: 0,
1224
1248
  width: "100%",
1225
1249
  height: "100%",
1226
1250
  // Without handlers the canvas must not eat pointer events aimed at
@@ -1270,6 +1294,7 @@ function ThreatMap(props) {
1270
1294
  );
1271
1295
  const measured = useElementSize(containerRef, widthProp === void 0 || heightProp === void 0);
1272
1296
  const pixelRatio = usePixelRatio();
1297
+ const supportsAspectRatio = useSupportsAspectRatio();
1273
1298
  const width = widthProp ?? measured?.width ?? 0;
1274
1299
  const height = heightProp ?? (measured && measured.height > 0 ? measured.height : width > 0 ? defaultHeightFor(projectionProp, width) : 0);
1275
1300
  const projection = useMemo(
@@ -1325,8 +1350,16 @@ function ThreatMap(props) {
1325
1350
  // which gates off the canvases, which keeps it collapsed. `aspect-ratio`
1326
1351
  // is inert the moment anything else determines the height — a CSS class,
1327
1352
  // a flex parent, or the consumer's own `style` below — so it sets a
1328
- // floor without taking the decision away from them.
1329
- ...heightProp !== void 0 ? { height: heightProp } : { aspectRatio: aspectRatioFor(projectionProp) },
1353
+ // floor without taking the decision away from them. The value is a string
1354
+ // for React 16.14/17's sake; see aspectRatioStyleFor.
1355
+ //
1356
+ // Where `aspect-ratio` is not implemented at all (Safari/iOS <15, Chrome
1357
+ // <88, Firefox <89) the declaration is dropped and the wrapper collapses,
1358
+ // so fall back to a `min-height` derived from the width. `min-height`
1359
+ // rather than `height` keeps the "floor, not a decision" property, and
1360
+ // deriving it from the *width* rather than the measured height is what
1361
+ // stops it feeding back into its own measurement.
1362
+ ...heightProp !== void 0 ? { height: heightProp } : supportsAspectRatio ? { aspectRatio: aspectRatioStyleFor(projectionProp) } : width > 0 ? { minHeight: defaultHeightFor(projectionProp, width) } : {},
1330
1363
  ...style
1331
1364
  },
1332
1365
  role: "img",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-threat-map",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "A React component that renders animated cyberattack threats on a static world map, with intelligent per-region aggregation and first-class US state support.",
5
5
  "keywords": [
6
6
  "react",