feat(monitor/frontend): globe centerpiece (merged-points, imperative camera, no decorative motion)

react-globe.gl 2.37 + three 0.184 added. Globe component is React.memo'd,
absolute-fills the .center grid cell, sizes itself via ResizeObserver on
the wrapper div (the library defaults to window.innerWidth/Height which
would blow past the 1247px cell). pointsMerge: true so hundreds of
points cost one ThreeJS Mesh draw call.

Layer dataset built by useGlobePoints / useGlobeRings hooks in
globeLayers.ts, projecting each store point onto color/altitude/radius
lookup tables keyed on the 6 GlobePointTypes:
  earthquake/ransomware/scan = monochrome (--fg-2/--fg-1/--fg-4)
  iss = --ok (live position is functional, not decorative)
  outage/hijack = --amber (the only globe surfaces with alarm color —
  earthquakes and ransomware stay monochrome because an M2.5 quake or
  daily victim is not user-actionable)

Rings are simple white pulses (no per-ring color decoration), single
pulse (ringRepeatPeriod=0). Camera panTo() in globeCamera.ts is the
only motion API; the dashboard lifecycle (Task 24) will trigger it on
high-mag quakes etc. Auto-rotate disabled on globe ready — motion is
meaning, a globe that spins for no reason violates the rule.

atmosphereAltitude=0.12, atmosphereColor=#1f2937 per spec §10.4 — a thin
glow that gives the sphere silhouette without becoming "3D space
ambiance." backgroundColor=rgba(0,0,0,0) so the panel cell --bg shows
through. No starfield, no nebulas. No globeImageUrl — keeps the build
self-contained (no CDN dep); we add a local texture later if geographic
context needs to be richer.

Plan 5 Task 12 Design QA: no starfield/nebula/extra glow, monochrome
quake dots, amber confined to outage/hijack, no hover tooltip on globe.
This commit is contained in:
CarterPerez-dev 2026-05-03 07:09:44 -04:00
parent 02ecc1c214
commit e3af0197b7
7 changed files with 626 additions and 3 deletions

View File

@ -22,10 +22,12 @@
"react": "^19.2.1",
"react-dom": "^19.2.0",
"react-error-boundary": "^6.0.0",
"react-globe.gl": "^2.37.1",
"react-icon": "^1.0.0",
"react-icons": "^5.5.0",
"react-router-dom": "^7.1.1",
"sonner": "^2.0.7",
"three": "^0.184.0",
"zod": "^4.1.13",
"zustand": "^5.0.9"
},

View File

@ -26,6 +26,9 @@ importers:
react-error-boundary:
specifier: ^6.0.0
version: 6.0.0(react@19.2.1)
react-globe.gl:
specifier: ^2.37.1
version: 2.37.1(react@19.2.1)
react-icon:
specifier: ^1.0.0
version: 1.0.0(babel-runtime@5.8.38)(react@19.2.1)
@ -38,6 +41,9 @@ importers:
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
three:
specifier: ^0.184.0
version: 0.184.0
zod:
specifier: ^4.1.13
version: 4.1.13
@ -536,6 +542,18 @@ packages:
peerDependencies:
react: ^18 || ^19
'@turf/boolean-point-in-polygon@7.3.5':
resolution: {integrity: sha512-ba7+B0wzaS9GtERZOoXUZ6oW8IcIJHNQZf3c+tiD9ESjcsPO1Q/4qIJGTKl92nBLhhracHJxMWBM/U6hAVkaRg==}
'@turf/helpers@7.3.5':
resolution: {integrity: sha512-E/NMGV5MwbjjP7AJXBtsanC3yY8N2MQ87IGdIgkB2ji5AtBpwnH4L3gEqpYN4RlCJJWbLbzO91BbKv2waUd0eg==}
'@turf/invariant@7.3.5':
resolution: {integrity: sha512-ZVIvsBvjr8lO7WxC5zYNjRsjSDvyGvWkJMjuWaJjTU8x+1tmfNnw3gDX/TI2Sit83gcRYLYkNo23lB/udqx/Hg==}
'@tweenjs/tween.js@25.0.0':
resolution: {integrity: sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==}
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
@ -560,6 +578,9 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/geojson@7946.0.16':
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
'@types/node@24.10.2':
resolution: {integrity: sha512-WOhQTZ4G8xZ1tjJTvKOpyEVSGgOTvJAfDK3FNFgELyaTpzhdgHVHeqW8V+UJvzF5BT+/B54T/1S2K6gd9c7bbA==}
@ -617,6 +638,10 @@ packages:
'@vitest/utils@4.1.5':
resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==}
accessor-fn@1.5.3:
resolution: {integrity: sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==}
engines: {node: '>=12'}
ajv@8.17.1:
resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
@ -740,6 +765,65 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
d3-array@3.2.4:
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
engines: {node: '>=12'}
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-delaunay@6.0.4:
resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==}
engines: {node: '>=12'}
d3-format@3.1.2:
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
engines: {node: '>=12'}
d3-geo-voronoi@2.1.0:
resolution: {integrity: sha512-kqE4yYuOjPbKdBXG0xztCacPwkVSK2REF1opSNrnqqtXJmNcM++UbwQ8SxvwP6IQTj9RvIjjK4qeiVsEfj0Z2Q==}
engines: {node: '>=12'}
d3-geo@3.1.1:
resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==}
engines: {node: '>=12'}
d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
d3-octree@1.1.0:
resolution: {integrity: sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==}
d3-scale-chromatic@3.1.0:
resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==}
engines: {node: '>=12'}
d3-scale@4.0.2:
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
engines: {node: '>=12'}
d3-selection@3.0.0:
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
engines: {node: '>=12'}
d3-time-format@4.1.0:
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
engines: {node: '>=12'}
d3-time@3.1.0:
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
engines: {node: '>=12'}
d3-tricontour@1.1.0:
resolution: {integrity: sha512-G7gHKj89n2owmkGb6WX6ixcnQ0Kf/0wpa9VIh9DGdbHu8wdrlaHU4ir3/bFNERl8N8nn4G7e7qbtBG8N9caihQ==}
engines: {node: '>=12'}
data-bind-mapper@1.0.3:
resolution: {integrity: sha512-QmU3lyEnbENQPo0M1F9BMu4s6cqNNp8iJA+b/HP2sSb7pf3dxwF3+EP1eO69rwBfH9kFJ1apmzrtogAmVt2/Xw==}
engines: {node: '>=12'}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@ -749,6 +833,9 @@ packages:
supports-color:
optional: true
delaunator@5.1.0:
resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
@ -770,6 +857,9 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
earcut@3.0.2:
resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==}
electron-to-chromium@1.5.267:
resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
@ -862,6 +952,10 @@ packages:
flatted@3.4.2:
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
float-tooltip@1.7.5:
resolution: {integrity: sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==}
engines: {node: '>=12'}
follow-redirects@1.15.11:
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
engines: {node: '>=4.0'}
@ -875,6 +969,9 @@ packages:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'}
frame-ticker@1.0.3:
resolution: {integrity: sha512-E0X2u2JIvbEMrqEg5+4BpTqaD22OwojJI63K7MdKHdncjtAhGRbCR8nJCr2vwEt9NWBPCPcu70X9smPviEBy8Q==}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@ -911,6 +1008,10 @@ packages:
resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==}
engines: {node: '>=10'}
globe.gl@2.45.3:
resolution: {integrity: sha512-oR7iZBjD60ltBcRlHFvlEzKGL8PMfk2Xl19vSfu1SN4SWXAs3NB3kfelanL+wj5XgG2rTQiNtPQncCR2O9oj+w==}
engines: {node: '>=12'}
globjoin@0.1.4:
resolution: {integrity: sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==}
@ -921,6 +1022,10 @@ packages:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
h3-js@4.4.0:
resolution: {integrity: sha512-DvJh07MhGgY2KcC4OeZc8SSyA+ZXpdvoh6uCzGpoKvWtZxJB+g6VXXC1+eWYkaMIsLz7J/ErhOalHCpcs1KYog==}
engines: {node: '>=4', npm: '>=3', yarn: '>=1.3.0'}
happy-dom@20.9.0:
resolution: {integrity: sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==}
engines: {node: '>=20.0.0'}
@ -971,9 +1076,17 @@ packages:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'}
index-array-by@1.4.2:
resolution: {integrity: sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw==}
engines: {node: '>=12'}
ini@1.3.8:
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
internmap@2.0.3:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
is-arrayish@0.2.1:
resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
@ -1000,6 +1113,10 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
jerrypick@1.1.2:
resolution: {integrity: sha512-YKnxXEekXKzhpf7CLYA0A+oDP8V0OhICNCr5lv96FvSsDEmrb0GKM776JgQvHTMjr7DTTPEVv/1Ciaw0uEWzBA==}
engines: {node: '>=12'}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@ -1023,6 +1140,10 @@ packages:
engines: {node: '>=6'}
hasBin: true
kapsule@1.16.3:
resolution: {integrity: sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==}
engines: {node: '>=12'}
keyv@5.5.5:
resolution: {integrity: sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==}
@ -1110,9 +1231,16 @@ packages:
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
lodash-es@4.18.1:
resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
lodash.truncate@4.4.2:
resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==}
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@ -1174,6 +1302,10 @@ packages:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
obug@2.1.1:
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
@ -1203,6 +1335,13 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
point-in-polygon-hao@1.2.4:
resolution: {integrity: sha512-x2pcvXeqhRHlNRdhLs/tgFapAbSSe86wa/eqmj1G6pWftbEs5aVRJhRGM6FYSUERKu0PjekJzMq0gsI2XyiclQ==}
polished@4.3.1:
resolution: {integrity: sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==}
engines: {node: '>=10'}
postcss-media-query-parser@0.2.3:
resolution: {integrity: sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==}
@ -1232,6 +1371,12 @@ packages:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
preact@10.29.1:
resolution: {integrity: sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==}
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
@ -1252,6 +1397,12 @@ packages:
peerDependencies:
react: '>=16.13.1'
react-globe.gl@2.37.1:
resolution: {integrity: sha512-/YvnaB+ID+WTBTx5Qa67fGkGhco2jPlKg4jbXNTNWSl22/it7GrfByZ4nksgUDy4176VWuHh4BFvQg+hziSk8Q==}
engines: {node: '>=12'}
peerDependencies:
react: '*'
react-icon@1.0.0:
resolution: {integrity: sha512-VzSlpBHnLanVw79mOxyq98hWDi6DlxK9qPiZ1bAK6bLurMBCaxO/jjyYUrRx9+JGLc/NbnwOmyE/W5Qglbb2QA==}
peerDependencies:
@ -1263,6 +1414,15 @@ packages:
peerDependencies:
react: '*'
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-kapsule@2.5.7:
resolution: {integrity: sha512-kifAF4ZPD77qZKc4CKLmozq6GY1sBzPEJTIJb0wWFK6HsePJatK3jXplZn2eeAt3x67CDozgi7/rO8fNQ/AL7A==}
engines: {node: '>=12'}
peerDependencies:
react: '>=16.13.1'
react-refresh@0.18.0:
resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
engines: {node: '>=0.10.0'}
@ -1308,6 +1468,9 @@ packages:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
robust-predicates@3.0.3:
resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==}
rolldown-vite@7.2.5:
resolution: {integrity: sha512-u09tdk/huMiN8xwoiBbig197jKdCamQTtOruSalOzbqGje3jdHiV0njQlAW0YvzoahkirFePNQ4RYlfnRQpXZA==}
engines: {node: ^20.19.0 || >=22.12.0}
@ -1378,6 +1541,9 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
simplesignal@2.1.7:
resolution: {integrity: sha512-PEo2qWpUke7IMhlqiBxrulIFvhJRLkl1ih52Rwa+bPjzhJepcd4GIjn2RiQmFSx3dQvsEAgF0/lXMwMN7vODaA==}
sirv@3.0.2:
resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
engines: {node: '>=18'}
@ -1479,9 +1645,45 @@ packages:
resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==}
engines: {node: '>=10.0.0'}
three-conic-polygon-geometry@2.1.2:
resolution: {integrity: sha512-NaP3RWLJIyPGI+zyaZwd0Yj6rkoxm4FJHqAX1Enb4L64oNYLCn4bz1ESgOEYavgcUwCNYINu1AgEoUBJr1wZcA==}
engines: {node: '>=12'}
peerDependencies:
three: '>=0.72.0'
three-geojson-geometry@2.1.1:
resolution: {integrity: sha512-dC7bF3ri1goDcihYhzACHOBQqu7YNNazYLa2bSydVIiJUb3jDFojKSy+gNj2pMkqZNSVjssSmdY9zlmnhEpr1w==}
engines: {node: '>=12'}
peerDependencies:
three: '>=0.72.0'
three-globe@2.45.2:
resolution: {integrity: sha512-3qJE2LAdyHsUPt02mgMRc+PG3j9kGEA0fUYrwKPGIVtvMR1XjDn9hCXu31AWocdgHOFcXkrRVz7jJZzTIvR0eQ==}
engines: {node: '>=12'}
peerDependencies:
three: '>=0.154'
three-render-objects@1.41.1:
resolution: {integrity: sha512-0H7l7yREPVKfO3HL7RjPQ67T0phHgnyMeEc4ww/OCEfK6jbsm7psEcrR0SGFqGDyS/pDQTPi4DyPbS/xlHRJKw==}
engines: {node: '>=12'}
peerDependencies:
three: '>=0.179'
three-slippy-map-globe@1.0.6:
resolution: {integrity: sha512-PCUR+X+1kYFYtQBf8+b/ct8xBHtnkeu7FItRYBeFxyIe3ksnGuLi0H9RAxAfVSSUsZVbKIKNz9q1atEjynrrkg==}
engines: {node: '>=12'}
peerDependencies:
three: '>=0.154'
three@0.184.0:
resolution: {integrity: sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinycolor2@1.6.0:
resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==}
tinyexec@1.1.2:
resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==}
engines: {node: '>=18'}
@ -2010,6 +2212,27 @@ snapshots:
'@tanstack/query-core': 5.90.12
react: 19.2.1
'@turf/boolean-point-in-polygon@7.3.5':
dependencies:
'@turf/helpers': 7.3.5
'@turf/invariant': 7.3.5
'@types/geojson': 7946.0.16
point-in-polygon-hao: 1.2.4
tslib: 2.8.1
'@turf/helpers@7.3.5':
dependencies:
'@types/geojson': 7946.0.16
tslib: 2.8.1
'@turf/invariant@7.3.5':
dependencies:
'@turf/helpers': 7.3.5
'@types/geojson': 7946.0.16
tslib: 2.8.1
'@tweenjs/tween.js@25.0.0': {}
'@tybys/wasm-util@0.10.1':
dependencies:
tslib: 2.8.1
@ -2045,6 +2268,8 @@ snapshots:
'@types/estree@1.0.8': {}
'@types/geojson@7946.0.16': {}
'@types/node@24.10.2':
dependencies:
undici-types: 7.16.0
@ -2127,6 +2352,8 @@ snapshots:
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
accessor-fn@1.5.3: {}
ajv@8.17.1:
dependencies:
fast-deep-equal: 3.1.3
@ -2239,10 +2466,75 @@ snapshots:
csstype@3.2.3: {}
d3-array@3.2.4:
dependencies:
internmap: 2.0.3
d3-color@3.1.0: {}
d3-delaunay@6.0.4:
dependencies:
delaunator: 5.1.0
d3-format@3.1.2: {}
d3-geo-voronoi@2.1.0:
dependencies:
d3-array: 3.2.4
d3-delaunay: 6.0.4
d3-geo: 3.1.1
d3-tricontour: 1.1.0
d3-geo@3.1.1:
dependencies:
d3-array: 3.2.4
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
d3-octree@1.1.0: {}
d3-scale-chromatic@3.1.0:
dependencies:
d3-color: 3.1.0
d3-interpolate: 3.0.1
d3-scale@4.0.2:
dependencies:
d3-array: 3.2.4
d3-format: 3.1.2
d3-interpolate: 3.0.1
d3-time: 3.1.0
d3-time-format: 4.1.0
d3-selection@3.0.0: {}
d3-time-format@4.1.0:
dependencies:
d3-time: 3.1.0
d3-time@3.1.0:
dependencies:
d3-array: 3.2.4
d3-tricontour@1.1.0:
dependencies:
d3-delaunay: 6.0.4
d3-scale: 4.0.2
data-bind-mapper@1.0.3:
dependencies:
accessor-fn: 1.5.3
debug@4.4.3:
dependencies:
ms: 2.1.3
delaunator@5.1.0:
dependencies:
robust-predicates: 3.0.3
delayed-stream@1.0.0: {}
detect-libc@1.0.3:
@ -2260,6 +2552,8 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
earcut@3.0.2: {}
electron-to-chromium@1.5.267: {}
emoji-regex@8.0.0: {}
@ -2339,6 +2633,12 @@ snapshots:
flatted@3.4.2: {}
float-tooltip@1.7.5:
dependencies:
d3-selection: 3.0.0
kapsule: 1.16.3
preact: 10.29.1
follow-redirects@1.15.11: {}
form-data@4.0.5:
@ -2349,6 +2649,10 @@ snapshots:
hasown: 2.0.2
mime-types: 2.1.35
frame-ticker@1.0.3:
dependencies:
simplesignal: 2.1.7
fsevents@2.3.3:
optional: true
@ -2397,12 +2701,23 @@ snapshots:
merge2: 1.4.1
slash: 3.0.0
globe.gl@2.45.3:
dependencies:
'@tweenjs/tween.js': 25.0.0
accessor-fn: 1.5.3
kapsule: 1.16.3
three: 0.184.0
three-globe: 2.45.2(three@0.184.0)
three-render-objects: 1.41.1(three@0.184.0)
globjoin@0.1.4: {}
globrex@0.1.2: {}
gopd@1.2.0: {}
h3-js@4.4.0: {}
happy-dom@20.9.0:
dependencies:
'@types/node': 24.10.2
@ -2448,8 +2763,12 @@ snapshots:
imurmurhash@0.1.4: {}
index-array-by@1.4.2: {}
ini@1.3.8: {}
internmap@2.0.3: {}
is-arrayish@0.2.1: {}
is-extglob@2.1.1: {}
@ -2466,6 +2785,8 @@ snapshots:
isexe@2.0.0: {}
jerrypick@1.1.2: {}
js-tokens@4.0.0: {}
js-yaml@4.1.1:
@ -2480,6 +2801,10 @@ snapshots:
json5@2.2.3: {}
kapsule@1.16.3:
dependencies:
lodash-es: 4.18.1
keyv@5.5.5:
dependencies:
'@keyv/serialize': 1.1.1
@ -2539,8 +2864,14 @@ snapshots:
lines-and-columns@1.2.4: {}
lodash-es@4.18.1: {}
lodash.truncate@4.4.2: {}
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1
@ -2585,6 +2916,8 @@ snapshots:
normalize-path@3.0.0: {}
object-assign@4.1.1: {}
obug@2.1.1: {}
parent-module@1.0.1:
@ -2608,6 +2941,14 @@ snapshots:
picomatch@4.0.3: {}
point-in-polygon-hao@1.2.4:
dependencies:
robust-predicates: 3.0.3
polished@4.3.1:
dependencies:
'@babel/runtime': 7.28.4
postcss-media-query-parser@0.2.3: {}
postcss-resolve-nested-selector@0.1.6: {}
@ -2633,6 +2974,14 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
preact@10.29.1: {}
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0
object-assign: 4.1.1
react-is: 16.13.1
proxy-from-env@1.1.0: {}
qified@0.5.3:
@ -2651,6 +3000,13 @@ snapshots:
'@babel/runtime': 7.28.4
react: 19.2.1
react-globe.gl@2.37.1(react@19.2.1):
dependencies:
globe.gl: 2.45.3
prop-types: 15.8.1
react: 19.2.1
react-kapsule: 2.5.7(react@19.2.1)
react-icon@1.0.0(babel-runtime@5.8.38)(react@19.2.1):
dependencies:
babel-runtime: 5.8.38
@ -2660,6 +3016,13 @@ snapshots:
dependencies:
react: 19.2.1
react-is@16.13.1: {}
react-kapsule@2.5.7(react@19.2.1):
dependencies:
jerrypick: 1.1.2
react: 19.2.1
react-refresh@0.18.0: {}
react-router-dom@7.10.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1):
@ -2688,6 +3051,8 @@ snapshots:
reusify@1.1.0: {}
robust-predicates@3.0.3: {}
rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0):
dependencies:
'@oxc-project/runtime': 0.97.0
@ -2744,6 +3109,8 @@ snapshots:
signal-exit@4.1.0: {}
simplesignal@2.1.7: {}
sirv@3.0.2:
dependencies:
'@polka/url': 1.0.0-next.29
@ -2885,8 +3252,67 @@ snapshots:
string-width: 4.2.3
strip-ansi: 6.0.1
three-conic-polygon-geometry@2.1.2(three@0.184.0):
dependencies:
'@turf/boolean-point-in-polygon': 7.3.5
d3-array: 3.2.4
d3-geo: 3.1.1
d3-geo-voronoi: 2.1.0
d3-scale: 4.0.2
delaunator: 5.1.0
earcut: 3.0.2
three: 0.184.0
three-geojson-geometry@2.1.1(three@0.184.0):
dependencies:
d3-geo: 3.1.1
d3-interpolate: 3.0.1
earcut: 3.0.2
three: 0.184.0
three-globe@2.45.2(three@0.184.0):
dependencies:
'@tweenjs/tween.js': 25.0.0
accessor-fn: 1.5.3
d3-array: 3.2.4
d3-color: 3.1.0
d3-geo: 3.1.1
d3-interpolate: 3.0.1
d3-scale: 4.0.2
d3-scale-chromatic: 3.1.0
data-bind-mapper: 1.0.3
frame-ticker: 1.0.3
h3-js: 4.4.0
index-array-by: 1.4.2
kapsule: 1.16.3
three: 0.184.0
three-conic-polygon-geometry: 2.1.2(three@0.184.0)
three-geojson-geometry: 2.1.1(three@0.184.0)
three-slippy-map-globe: 1.0.6(three@0.184.0)
tinycolor2: 1.6.0
three-render-objects@1.41.1(three@0.184.0):
dependencies:
'@tweenjs/tween.js': 25.0.0
accessor-fn: 1.5.3
float-tooltip: 1.7.5
kapsule: 1.16.3
polished: 4.3.1
three: 0.184.0
three-slippy-map-globe@1.0.6(three@0.184.0):
dependencies:
d3-geo: 3.1.1
d3-octree: 1.1.0
d3-scale: 4.0.2
three: 0.184.0
three@0.184.0: {}
tinybench@2.9.0: {}
tinycolor2@1.6.0: {}
tinyexec@1.1.2: {}
tinyglobby@0.2.15:
@ -2906,8 +3332,7 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
tslib@2.8.1:
optional: true
tslib@2.8.1: {}
typescript@5.9.3: {}

View File

@ -1,6 +1,7 @@
// ©AngelaMos | 2026
// Dashboard.tsx
import { Globe } from '@/pages/globe/Globe'
import { useUIStore } from '@/stores/ui'
import { AlertBanner } from './AlertBanner'
import { BottomTicker } from './BottomTicker'
@ -22,7 +23,9 @@ export function Dashboard(): React.ReactElement {
<AlertBanner />
<main className={styles.grid}>
<aside className={styles.left} />
<section className={styles.center} />
<section className={styles.center}>
<Globe />
</section>
<aside className={styles.right} />
</main>
<BottomTicker />

View File

@ -0,0 +1,11 @@
// ©AngelaMos | 2026
// Globe.module.scss
.globeWrap {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}

View File

@ -0,0 +1,84 @@
// ©AngelaMos | 2026
// Globe.tsx
import { memo, useEffect, useRef, useState } from 'react'
import GlobeGL, { type GlobeMethods } from 'react-globe.gl'
import { useGlobeEvents } from '@/stores/globeEvents'
import styles from './Globe.module.scss'
import { panTo } from './globeCamera'
import { useGlobePoints, useGlobeRings } from './globeLayers'
const ATMOSPHERE_COLOR = '#1f2937'
const ATMOSPHERE_ALTITUDE = 0.12
const RING_COLOR = '#e5e5e5'
const RING_MAX_RADIUS = 4
const RING_PROPAGATION_SPEED = 4
const RING_REPEAT_PERIOD = 0
export const Globe = memo(function Globe(): React.ReactElement {
const wrapRef = useRef<HTMLDivElement>(null)
const globeRef = useRef<GlobeMethods | undefined>(undefined)
const [size, setSize] = useState({ w: 0, h: 0 })
const points = useGlobePoints()
const rings = useGlobeRings()
const focusEvent = useGlobeEvents((s) => s.focusEvent)
useEffect(() => {
const wrap = wrapRef.current
if (!wrap) return
const ro = new ResizeObserver((entries) => {
const entry = entries[0]
if (!entry) return
setSize({
w: entry.contentRect.width,
h: entry.contentRect.height,
})
})
ro.observe(wrap)
return () => ro.disconnect()
}, [])
useEffect(() => {
if (size.w === 0) return
const controls = globeRef.current?.controls()
if (!controls) return
controls.autoRotate = false
}, [size.w])
useEffect(() => {
if (!focusEvent) return
panTo(globeRef, focusEvent.lat, focusEvent.lng)
}, [focusEvent])
return (
<div ref={wrapRef} className={styles.globeWrap}>
{size.w > 0 && size.h > 0 && (
<GlobeGL
ref={globeRef}
width={size.w}
height={size.h}
pointsData={points}
pointsMerge
pointLat="lat"
pointLng="lng"
pointColor="color"
pointAltitude="altitude"
pointRadius="radius"
ringsData={rings}
ringLat="lat"
ringLng="lng"
ringColor={() => RING_COLOR}
ringMaxRadius={RING_MAX_RADIUS}
ringPropagationSpeed={RING_PROPAGATION_SPEED}
ringRepeatPeriod={RING_REPEAT_PERIOD}
atmosphereColor={ATMOSPHERE_COLOR}
atmosphereAltitude={ATMOSPHERE_ALTITUDE}
backgroundColor="rgba(0,0,0,0)"
/>
)}
</div>
)
})
Globe.displayName = 'Globe'

View File

@ -0,0 +1,20 @@
// ©AngelaMos | 2026
// globeCamera.ts
import type { MutableRefObject } from 'react'
import type { GlobeMethods } from 'react-globe.gl'
const DEFAULT_PAN_DURATION_MS = 1200
const DEFAULT_FOCUS_ALTITUDE = 1.8
export type GlobeRef = MutableRefObject<GlobeMethods | undefined>
export function panTo(
ref: GlobeRef,
lat: number,
lng: number,
durationMs: number = DEFAULT_PAN_DURATION_MS,
altitude: number = DEFAULT_FOCUS_ALTITUDE
): void {
ref.current?.pointOfView({ lat, lng, altitude }, durationMs)
}

View File

@ -0,0 +1,78 @@
// ©AngelaMos | 2026
// globeLayers.ts
import { useMemo } from 'react'
import { type GlobePointType, useGlobeEvents } from '@/stores/globeEvents'
const COLOR_BY_TYPE: Record<GlobePointType, string> = {
earthquake: '#a3a3a3',
ransomware: '#e5e5e5',
scan: '#404040',
iss: '#4ade80',
outage: '#f59e0b',
hijack: '#f59e0b',
}
const ALTITUDE_BY_TYPE: Record<GlobePointType, number> = {
earthquake: 0.01,
ransomware: 0.01,
scan: 0.005,
iss: 0.06,
outage: 0.005,
hijack: 0.005,
}
const RADIUS_BY_TYPE: Record<GlobePointType, number> = {
earthquake: 0.4,
ransomware: 0.3,
scan: 0.15,
iss: 0.5,
outage: 0.4,
hijack: 0.4,
}
export interface GlobePointDatum {
id: string
type: GlobePointType
lat: number
lng: number
color: string
altitude: number
radius: number
}
export interface GlobeRingDatum {
id: string
lat: number
lng: number
}
export function useGlobePoints(): GlobePointDatum[] {
const points = useGlobeEvents((s) => s.points)
return useMemo(
() =>
points.map((p) => ({
id: p.id,
type: p.type,
lat: p.lat,
lng: p.lng,
color: COLOR_BY_TYPE[p.type],
altitude: ALTITUDE_BY_TYPE[p.type],
radius: RADIUS_BY_TYPE[p.type],
})),
[points]
)
}
export function useGlobeRings(): GlobeRingDatum[] {
const rings = useGlobeEvents((s) => s.rings)
return useMemo(
() =>
rings.map((r) => ({
id: r.id,
lat: r.lat,
lng: r.lng,
})),
[rings]
)
}