ahoy_analytics 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.
Files changed (198) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +163 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/ahoy_analytics/build/assets/Combination-BpSXUjp9.js +41 -0
  6. data/app/assets/ahoy_analytics/build/assets/analytics-5KyfCxh6.css +1 -0
  7. data/app/assets/ahoy_analytics/build/assets/analytics-dashboard-uOXx8zYZ.js +1 -0
  8. data/app/assets/ahoy_analytics/build/assets/analytics-layout-ClAft5OU.js +1 -0
  9. data/app/assets/ahoy_analytics/build/assets/analytics-tracker-B3f8P98z.js +1 -0
  10. data/app/assets/ahoy_analytics/build/assets/analytics-ui-DMSkNqd6.js +90 -0
  11. data/app/assets/ahoy_analytics/build/assets/behaviors-panel-ChNGYbdH.js +1 -0
  12. data/app/assets/ahoy_analytics/build/assets/button-JVCrlR4s.js +1 -0
  13. data/app/assets/ahoy_analytics/build/assets/cable-DO-7y1-E.js +1 -0
  14. data/app/assets/ahoy_analytics/build/assets/createLucideIcon-BGzacY2v.js +1 -0
  15. data/app/assets/ahoy_analytics/build/assets/date-range-dialog-DWDp3cLG.js +1 -0
  16. data/app/assets/ahoy_analytics/build/assets/details-button-NqKfSGEG.js +1 -0
  17. data/app/assets/ahoy_analytics/build/assets/devices-panel-cXvlmNBY.js +1 -0
  18. data/app/assets/ahoy_analytics/build/assets/dialog-path-BBPNlB4Z.js +1 -0
  19. data/app/assets/ahoy_analytics/build/assets/dropdown-menu-Adj3O5fh.js +1 -0
  20. data/app/assets/ahoy_analytics/build/assets/filter-dialog-BN-rf4lp.js +1 -0
  21. data/app/assets/ahoy_analytics/build/assets/index-B1K1NTKT.js +3 -0
  22. data/app/assets/ahoy_analytics/build/assets/index-BcHeb-Rh.js +1 -0
  23. data/app/assets/ahoy_analytics/build/assets/index-DzpzLoG4.js +1 -0
  24. data/app/assets/ahoy_analytics/build/assets/index-vX97OY1J.js +1 -0
  25. data/app/assets/ahoy_analytics/build/assets/input-e4v_v0kE.js +1 -0
  26. data/app/assets/ahoy_analytics/build/assets/jsx-runtime-u17CrQMm.js +1 -0
  27. data/app/assets/ahoy_analytics/build/assets/last-load-context-De5uA95L.js +1 -0
  28. data/app/assets/ahoy_analytics/build/assets/list-table-ChHEzzF9.js +1 -0
  29. data/app/assets/ahoy_analytics/build/assets/live-Cp2MHECh.js +2 -0
  30. data/app/assets/ahoy_analytics/build/assets/locations-panel-BaISRmaQ.js +1 -0
  31. data/app/assets/ahoy_analytics/build/assets/mercator-BnxX5RzL.js +1 -0
  32. data/app/assets/ahoy_analytics/build/assets/pages-panel-Bh25L8mP.js +1 -0
  33. data/app/assets/ahoy_analytics/build/assets/panel-tabs-B2kvGFJx.js +1 -0
  34. data/app/assets/ahoy_analytics/build/assets/query-context-B-PgE00D.js +1 -0
  35. data/app/assets/ahoy_analytics/build/assets/remote-details-dialog-DDTcKaM5.js +1 -0
  36. data/app/assets/ahoy_analytics/build/assets/show-CCRicksg.js +1 -0
  37. data/app/assets/ahoy_analytics/build/assets/simple-tabs-D6G6Bs0k.js +1 -0
  38. data/app/assets/ahoy_analytics/build/assets/site-context-BNteYRlR.js +1 -0
  39. data/app/assets/ahoy_analytics/build/assets/sources-panel-DyB21hxD.js +1 -0
  40. data/app/assets/ahoy_analytics/build/assets/top-bar-FSiLBjq6.js +1 -0
  41. data/app/assets/ahoy_analytics/build/assets/top-stats-context-DU15P9jS.js +1 -0
  42. data/app/assets/ahoy_analytics/build/assets/use-debounce-VBpXQRL8.js +1 -0
  43. data/app/assets/ahoy_analytics/build/assets/user-context-DbYteluY.js +1 -0
  44. data/app/assets/ahoy_analytics/build/assets/visitor-globe-BWLDihid.js +4789 -0
  45. data/app/assets/ahoy_analytics/build/assets/visitor-graph-uKXjLvcu.js +1 -0
  46. data/app/assets/ahoy_analytics/images/icon/browser/brave.svg +1 -0
  47. data/app/assets/ahoy_analytics/images/icon/browser/chrome.svg +1 -0
  48. data/app/assets/ahoy_analytics/images/icon/browser/chromium.svg +1 -0
  49. data/app/assets/ahoy_analytics/images/icon/browser/duckduckgo.svg +2151 -0
  50. data/app/assets/ahoy_analytics/images/icon/browser/edge.svg +1 -0
  51. data/app/assets/ahoy_analytics/images/icon/browser/fallback.svg +5 -0
  52. data/app/assets/ahoy_analytics/images/icon/browser/firefox.svg +1 -0
  53. data/app/assets/ahoy_analytics/images/icon/browser/opera.svg +1 -0
  54. data/app/assets/ahoy_analytics/images/icon/browser/safari.png +0 -0
  55. data/app/assets/ahoy_analytics/images/icon/browser/samsung-internet.svg +1 -0
  56. data/app/assets/ahoy_analytics/images/icon/browser/uc.svg +1 -0
  57. data/app/assets/ahoy_analytics/images/icon/browser/vivaldi.svg +1 -0
  58. data/app/assets/ahoy_analytics/images/icon/browser/yandex.png +0 -0
  59. data/app/assets/ahoy_analytics/images/icon/os/android.png +0 -0
  60. data/app/assets/ahoy_analytics/images/icon/os/chrome_os.png +0 -0
  61. data/app/assets/ahoy_analytics/images/icon/os/fallback.svg +5 -0
  62. data/app/assets/ahoy_analytics/images/icon/os/fedora.png +0 -0
  63. data/app/assets/ahoy_analytics/images/icon/os/freebsd.png +0 -0
  64. data/app/assets/ahoy_analytics/images/icon/os/gnu_linux.png +0 -0
  65. data/app/assets/ahoy_analytics/images/icon/os/ios.png +0 -0
  66. data/app/assets/ahoy_analytics/images/icon/os/ipad_os.png +0 -0
  67. data/app/assets/ahoy_analytics/images/icon/os/mac.png +0 -0
  68. data/app/assets/ahoy_analytics/images/icon/os/ubuntu.png +0 -0
  69. data/app/assets/ahoy_analytics/images/icon/os/windows.png +0 -0
  70. data/app/assets/stylesheets/ahoy_analytics/application.css +15 -0
  71. data/app/channels/ahoy_analytics/analytics_channel.rb +9 -0
  72. data/app/controllers/ahoy_analytics/analytics_controller.rb +9 -0
  73. data/app/controllers/ahoy_analytics/application_controller.rb +8 -0
  74. data/app/controllers/ahoy_analytics/assets_controller.rb +46 -0
  75. data/app/controllers/ahoy_analytics/base_controller.rb +285 -0
  76. data/app/controllers/ahoy_analytics/behaviors_controller.rb +14 -0
  77. data/app/controllers/ahoy_analytics/devices_controller.rb +14 -0
  78. data/app/controllers/ahoy_analytics/export_controller.rb +12 -0
  79. data/app/controllers/ahoy_analytics/live_controller.rb +9 -0
  80. data/app/controllers/ahoy_analytics/locations_controller.rb +14 -0
  81. data/app/controllers/ahoy_analytics/main_graph_controller.rb +10 -0
  82. data/app/controllers/ahoy_analytics/pages_controller.rb +14 -0
  83. data/app/controllers/ahoy_analytics/referrers_controller.rb +34 -0
  84. data/app/controllers/ahoy_analytics/search_terms_controller.rb +47 -0
  85. data/app/controllers/ahoy_analytics/sources_controller.rb +14 -0
  86. data/app/controllers/ahoy_analytics/top_stats_controller.rb +13 -0
  87. data/app/controllers/concerns/ahoy_analytics/set_current_request.rb +17 -0
  88. data/app/frontend/components/analytics/hex-highlights.tsx +165 -0
  89. data/app/frontend/components/analytics/hex-land-layer.tsx +61 -0
  90. data/app/frontend/components/analytics/metric-card.tsx +138 -0
  91. data/app/frontend/components/analytics/sessions-by-location.tsx +62 -0
  92. data/app/frontend/components/analytics/visitor-globe.tsx +424 -0
  93. data/app/frontend/components/ui/accordion.tsx +64 -0
  94. data/app/frontend/components/ui/alert.tsx +66 -0
  95. data/app/frontend/components/ui/avatar.tsx +53 -0
  96. data/app/frontend/components/ui/badge.tsx +46 -0
  97. data/app/frontend/components/ui/button.tsx +62 -0
  98. data/app/frontend/components/ui/calendar.tsx +212 -0
  99. data/app/frontend/components/ui/card.tsx +91 -0
  100. data/app/frontend/components/ui/checkbox.tsx +32 -0
  101. data/app/frontend/components/ui/dropdown-menu.tsx +255 -0
  102. data/app/frontend/components/ui/input.tsx +21 -0
  103. data/app/frontend/components/ui/label.tsx +22 -0
  104. data/app/frontend/components/ui/popover.tsx +46 -0
  105. data/app/frontend/components/ui/select.tsx +183 -0
  106. data/app/frontend/components/ui/separator.tsx +26 -0
  107. data/app/frontend/components/ui/sheet.tsx +139 -0
  108. data/app/frontend/components/ui/sidebar.tsx +726 -0
  109. data/app/frontend/components/ui/skeleton.tsx +13 -0
  110. data/app/frontend/components/ui/sonner.tsx +33 -0
  111. data/app/frontend/components/ui/tooltip.tsx +59 -0
  112. data/app/frontend/data/countries-110m.json +1 -0
  113. data/app/frontend/data/globe-data.json +1 -0
  114. data/app/frontend/entrypoints/analytics-tracker.ts +680 -0
  115. data/app/frontend/entrypoints/analytics-ui.tsx +26 -0
  116. data/app/frontend/entrypoints/analytics.css +77 -0
  117. data/app/frontend/layouts/analytics-layout.tsx +28 -0
  118. data/app/frontend/lib/cable.ts +13 -0
  119. data/app/frontend/lib/geocode.ts +65 -0
  120. data/app/frontend/lib/utils.ts +6 -0
  121. data/app/frontend/pages/admin/analytics/api.ts +221 -0
  122. data/app/frontend/pages/admin/analytics/hooks/use-debounce.ts +36 -0
  123. data/app/frontend/pages/admin/analytics/last-load-context.tsx +29 -0
  124. data/app/frontend/pages/admin/analytics/lib/base-path.ts +28 -0
  125. data/app/frontend/pages/admin/analytics/lib/dialog-path.ts +242 -0
  126. data/app/frontend/pages/admin/analytics/lib/number-formatter.ts +100 -0
  127. data/app/frontend/pages/admin/analytics/live.tsx +608 -0
  128. data/app/frontend/pages/admin/analytics/query-context.tsx +61 -0
  129. data/app/frontend/pages/admin/analytics/show.tsx +40 -0
  130. data/app/frontend/pages/admin/analytics/site-context.tsx +22 -0
  131. data/app/frontend/pages/admin/analytics/top-stats-context.tsx +37 -0
  132. data/app/frontend/pages/admin/analytics/types.ts +161 -0
  133. data/app/frontend/pages/admin/analytics/ui/analytics-dashboard.tsx +60 -0
  134. data/app/frontend/pages/admin/analytics/ui/behaviors-panel.tsx +456 -0
  135. data/app/frontend/pages/admin/analytics/ui/date-range-dialog.tsx +173 -0
  136. data/app/frontend/pages/admin/analytics/ui/details-button.tsx +33 -0
  137. data/app/frontend/pages/admin/analytics/ui/devices-panel.tsx +474 -0
  138. data/app/frontend/pages/admin/analytics/ui/filter-dialog.tsx +558 -0
  139. data/app/frontend/pages/admin/analytics/ui/list-table.tsx +346 -0
  140. data/app/frontend/pages/admin/analytics/ui/locations-panel.tsx +566 -0
  141. data/app/frontend/pages/admin/analytics/ui/pages-panel.tsx +207 -0
  142. data/app/frontend/pages/admin/analytics/ui/panel-tabs.tsx +65 -0
  143. data/app/frontend/pages/admin/analytics/ui/remote-details-dialog.tsx +356 -0
  144. data/app/frontend/pages/admin/analytics/ui/simple-tabs.tsx +54 -0
  145. data/app/frontend/pages/admin/analytics/ui/sources-panel.tsx +771 -0
  146. data/app/frontend/pages/admin/analytics/ui/top-bar.tsx +793 -0
  147. data/app/frontend/pages/admin/analytics/ui/visitor-graph.tsx +891 -0
  148. data/app/frontend/pages/admin/analytics/user-context.tsx +22 -0
  149. data/app/frontend/styles/shared.css +156 -0
  150. data/app/helpers/ahoy_analytics/application_helper.rb +96 -0
  151. data/app/jobs/ahoy_analytics/application_job.rb +4 -0
  152. data/app/jobs/ahoy_analytics/update_job.rb +12 -0
  153. data/app/mailers/ahoy_analytics/application_mailer.rb +6 -0
  154. data/app/models/ahoy/event/filters.rb +7 -0
  155. data/app/models/ahoy/event.rb +9 -0
  156. data/app/models/ahoy/visit/cache_key.rb +15 -0
  157. data/app/models/ahoy/visit/constants.rb +11 -0
  158. data/app/models/ahoy/visit/devices.rb +144 -0
  159. data/app/models/ahoy/visit/export.rb +24 -0
  160. data/app/models/ahoy/visit/filters.rb +286 -0
  161. data/app/models/ahoy/visit/imports.rb +36 -0
  162. data/app/models/ahoy/visit/locations.rb +276 -0
  163. data/app/models/ahoy/visit/metrics.rb +473 -0
  164. data/app/models/ahoy/visit/ordering.rb +110 -0
  165. data/app/models/ahoy/visit/pages.rb +533 -0
  166. data/app/models/ahoy/visit/pagination.rb +17 -0
  167. data/app/models/ahoy/visit/ranges.rb +227 -0
  168. data/app/models/ahoy/visit/series.rb +177 -0
  169. data/app/models/ahoy/visit/sources.rb +418 -0
  170. data/app/models/ahoy/visit/url_labels.rb +32 -0
  171. data/app/models/ahoy/visit.rb +143 -0
  172. data/app/models/ahoy_analytics/application_record.rb +5 -0
  173. data/app/models/ahoy_analytics/current.rb +8 -0
  174. data/app/models/ahoy_analytics/funnel.rb +16 -0
  175. data/app/models/ahoy_analytics/imported_entry_page.rb +5 -0
  176. data/app/models/ahoy_analytics/imported_exit_page.rb +5 -0
  177. data/app/models/ahoy_analytics/imported_page.rb +5 -0
  178. data/app/models/ahoy_analytics/live_stats.rb +152 -0
  179. data/app/models/ahoy_analytics/setting.rb +19 -0
  180. data/app/models/analytics/source_catalog.rb +48 -0
  181. data/app/views/layouts/ahoy_analytics/application.html.erb +15 -0
  182. data/config/routes.rb +21 -0
  183. data/config/vite.json +22 -0
  184. data/db/migrate/20251006104056_create_ahoy_visits_and_events.rb +62 -0
  185. data/db/migrate/20251006105012_add_analytics_fields_to_ahoy_visits.rb +11 -0
  186. data/db/migrate/20251012090000_create_analytics_funnels_and_imports.rb +52 -0
  187. data/db/migrate/20251013021500_add_analytics_indexes.rb +14 -0
  188. data/lib/ahoy_analytics/ahoy_store.rb +429 -0
  189. data/lib/ahoy_analytics/asset_manifest.rb +56 -0
  190. data/lib/ahoy_analytics/device_bucket.rb +39 -0
  191. data/lib/ahoy_analytics/engine.rb +55 -0
  192. data/lib/ahoy_analytics/maxmind_geo.rb +77 -0
  193. data/lib/ahoy_analytics/version.rb +3 -0
  194. data/lib/ahoy_analytics.rb +52 -0
  195. data/lib/generators/ahoy_analytics/install/install_generator.rb +111 -0
  196. data/lib/generators/ahoy_analytics/install/templates/initializer.rb +28 -0
  197. data/lib/tasks/ahoy_analytics_tasks.rake +4 -0
  198. metadata +352 -0
@@ -0,0 +1,424 @@
1
+ import { Canvas } from '@react-three/fiber'
2
+ import { OrbitControls } from '@react-three/drei'
3
+ import HexLandLayer from '@/components/analytics/hex-land-layer'
4
+ import HexHighlights from '@/components/analytics/hex-highlights'
5
+ import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
6
+ import type React from 'react'
7
+ import * as THREE from 'three'
8
+ import type { OrbitControls as OrbitControlsImpl } from 'three-stdlib'
9
+
10
+ export const VISITOR_GLOBE_MIN_DISTANCE = 1.8
11
+ export const VISITOR_GLOBE_MAX_DISTANCE = 3.2
12
+ const ZOOM_STEP = 0.35
13
+ const INITIAL_LAT = 39 // continental US
14
+ const INITIAL_LNG = -98
15
+ const INITIAL_DISTANCE = VISITOR_GLOBE_MAX_DISTANCE // start at farthest zoom
16
+
17
+ type VisitorDot = {
18
+ lat: number
19
+ lng: number
20
+ type: 'visitor'
21
+ ts?: number // epoch ms; used for fading
22
+ }
23
+
24
+ type VisitorGlobeProps = {
25
+ visitors: VisitorDot[]
26
+ autoRotate?: boolean
27
+ onZoomChange?: (state: VisitorGlobeZoomState) => void
28
+ onViewChange?: (view: { lat: number; lng: number; distance: number }) => void
29
+ }
30
+
31
+ export type VisitorGlobeZoomState = {
32
+ distance: number
33
+ minDistance: number
34
+ maxDistance: number
35
+ }
36
+
37
+ export type VisitorGlobeHandle = {
38
+ zoomIn: () => void
39
+ zoomOut: () => void
40
+ getDistance: () => number
41
+ focusOn: (lat: number, lng: number, distance?: number) => void
42
+ flyTo: (lat: number, lng: number, distance?: number, durationMs?: number) => void
43
+ getView: () => { lat: number; lng: number; distance: number }
44
+ }
45
+
46
+ export const VisitorGlobe = forwardRef(function VisitorGlobe(
47
+ { visitors, autoRotate = false, onZoomChange, onViewChange }: VisitorGlobeProps,
48
+ ref: React.Ref<VisitorGlobeHandle>
49
+ ) {
50
+ // Tooltip removed for hex highlight approach; can be re-added later
51
+ const controlsRef = useRef<OrbitControlsImpl>(null)
52
+ const directionRef = useRef(new THREE.Vector3())
53
+ const tempPositionRef = useRef(new THREE.Vector3())
54
+ const animationFrameRef = useRef<number | null>(null)
55
+ const animationStateRef = useRef<
56
+ | { kind: 'distance'; from: number; to: number; start: number | null; duration: number }
57
+ | { kind: 'fly'; fromDir: THREE.Vector3; toDir: THREE.Vector3; distance: number; start: number | null; duration: number }
58
+ | null
59
+ >(null)
60
+
61
+ const getDistance = () => {
62
+ const controls = controlsRef.current
63
+ if (!controls) return VISITOR_GLOBE_MAX_DISTANCE
64
+ return (controls.object as THREE.PerspectiveCamera).position.distanceTo(controls.target)
65
+ }
66
+
67
+ const cancelAnimation = () => {
68
+ if (animationFrameRef.current !== null) {
69
+ cancelAnimationFrame(animationFrameRef.current)
70
+ animationFrameRef.current = null
71
+ }
72
+ animationStateRef.current = null
73
+ }
74
+
75
+ const applyDistance = (distance: number, emit = true) => {
76
+ const controls = controlsRef.current
77
+ if (!controls) return
78
+
79
+ const camera = controls.object as THREE.PerspectiveCamera
80
+ directionRef.current
81
+ .copy(camera.position)
82
+ .sub(controls.target)
83
+ .normalize()
84
+
85
+ tempPositionRef.current
86
+ .copy(directionRef.current)
87
+ .multiplyScalar(distance)
88
+ .add(controls.target)
89
+
90
+ camera.position.copy(tempPositionRef.current)
91
+ camera.updateProjectionMatrix()
92
+ controls.update()
93
+ if (emit) emitZoomChange(distance)
94
+ }
95
+
96
+ const emitZoomChange = (distance?: number) => {
97
+ if (!onZoomChange) return
98
+ const currentDistance = distance ?? getDistance()
99
+ onZoomChange({
100
+ distance: currentDistance,
101
+ minDistance: VISITOR_GLOBE_MIN_DISTANCE,
102
+ maxDistance: VISITOR_GLOBE_MAX_DISTANCE
103
+ })
104
+ }
105
+
106
+ const emitViewChange = () => {
107
+ if (!onViewChange || !controlsRef.current) return
108
+ const controls = controlsRef.current
109
+ const distance = getDistance()
110
+ const camera = controls.object as THREE.PerspectiveCamera
111
+ const { lat, lng } = vector3ToLatLng(camera.position)
112
+ onViewChange({ lat, lng, distance })
113
+ }
114
+
115
+ const animateToDistance = (targetDistance: number) => {
116
+ const controls = controlsRef.current
117
+ if (!controls) return
118
+
119
+ const from = getDistance()
120
+ const to = THREE.MathUtils.clamp(
121
+ targetDistance,
122
+ VISITOR_GLOBE_MIN_DISTANCE,
123
+ VISITOR_GLOBE_MAX_DISTANCE
124
+ )
125
+
126
+ if (Math.abs(to - from) < 0.001) {
127
+ applyDistance(to)
128
+ return
129
+ }
130
+
131
+ cancelAnimation()
132
+
133
+ const duration = 280
134
+ animationStateRef.current = { kind: 'distance', from, to, start: null, duration }
135
+
136
+ const step = (timestamp: number) => {
137
+ const state = animationStateRef.current
138
+ if (!state) return
139
+
140
+ if (state.kind !== 'distance') return
141
+ if (state.start === null) state.start = timestamp
142
+ const progress = Math.min((timestamp - state.start) / state.duration, 1)
143
+ const eased = progress * progress * (3 - 2 * progress) // smoothstep easing
144
+ const distance = THREE.MathUtils.lerp(state.from, state.to, eased)
145
+
146
+ // Emit on each frame so UI stays in sync.
147
+ applyDistance(distance)
148
+
149
+ if (progress < 1) {
150
+ animationFrameRef.current = requestAnimationFrame(step)
151
+ } else {
152
+ animationFrameRef.current = null
153
+ animationStateRef.current = null
154
+ }
155
+ }
156
+
157
+ animationFrameRef.current = requestAnimationFrame(step)
158
+ }
159
+
160
+ const zoomBy = (delta: number) => {
161
+ animateToDistance(getDistance() + delta)
162
+ }
163
+
164
+ const focusOn = (lat: number, lng: number, distance?: number) => {
165
+ const controls = controlsRef.current
166
+ if (!controls) return
167
+ const camera = controls.object as THREE.PerspectiveCamera
168
+ // Keep target at origin so we orbit the globe, not a surface point
169
+ controls.target.set(0, 0, 0)
170
+ const d = distance ?? getDistance()
171
+ camera.position.copy(latLngToVector3(lat, lng, d))
172
+ camera.lookAt(0, 0, 0)
173
+ camera.updateProjectionMatrix()
174
+ controls.update()
175
+ emitZoomChange(d)
176
+ emitViewChange()
177
+ }
178
+
179
+ const flyTo = (lat: number, lng: number, distance?: number, durationMs?: number) => {
180
+ const controls = controlsRef.current
181
+ if (!controls) return
182
+ const camera = controls.object as THREE.PerspectiveCamera
183
+ controls.target.set(0, 0, 0)
184
+ const fromDir = camera.position.clone().normalize()
185
+ const toDir = latLngToVector3(lat, lng, 1).normalize()
186
+ const targetDistance = distance ?? getDistance() // equals camera radius when target at origin
187
+ // If duration not provided, scale it with angular distance (slower for long spins)
188
+ const angle = THREE.MathUtils.clamp(fromDir.dot(toDir), -1, 1)
189
+ const theta = Math.acos(angle) // [0..PI]
190
+ const minMs = 600
191
+ const maxMs = 1800
192
+ const autoDuration = minMs + (theta / Math.PI) * (maxMs - minMs)
193
+ const duration = Math.max(300, Math.min(maxMs, durationMs ?? autoDuration))
194
+
195
+ cancelAnimation()
196
+ animationStateRef.current = { kind: 'fly', fromDir, toDir, distance: targetDistance, start: null, duration }
197
+ const tmp = new THREE.Vector3()
198
+ const identityQ = new THREE.Quaternion()
199
+ const rotQ = new THREE.Quaternion().setFromUnitVectors(fromDir.clone().normalize(), toDir.clone().normalize())
200
+ const qTmp = new THREE.Quaternion()
201
+
202
+ const step = (timestamp: number) => {
203
+ const state = animationStateRef.current
204
+ if (!state) return
205
+ if (state.kind !== 'fly') return
206
+ if (state.start === null) state.start = timestamp
207
+ const t = Math.min((timestamp - state.start) / state.duration, 1)
208
+ // ease-in-out quad
209
+ const eased = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t
210
+ qTmp.copy(identityQ).slerp(rotQ, eased)
211
+ tmp.copy(state.fromDir).applyQuaternion(qTmp).normalize()
212
+ const cameraRadius = state.distance
213
+ camera.position.copy(tmp.clone().multiplyScalar(cameraRadius))
214
+ camera.updateProjectionMatrix()
215
+ controls.update()
216
+ emitZoomChange(state.distance)
217
+ emitViewChange()
218
+ if (t < 1) {
219
+ animationFrameRef.current = requestAnimationFrame(step)
220
+ } else {
221
+ animationFrameRef.current = null
222
+ animationStateRef.current = null
223
+ }
224
+ }
225
+
226
+ animationFrameRef.current = requestAnimationFrame(step)
227
+ }
228
+
229
+ useImperativeHandle(ref, () => ({
230
+ zoomIn: () => zoomBy(-ZOOM_STEP),
231
+ zoomOut: () => zoomBy(ZOOM_STEP),
232
+ getDistance,
233
+ focusOn,
234
+ flyTo,
235
+ getView: () => {
236
+ const controls = controlsRef.current
237
+ if (!controls) return { lat: INITIAL_LAT, lng: INITIAL_LNG, distance: INITIAL_DISTANCE }
238
+ const { lat, lng } = vector3ToLatLng(controls.target)
239
+ return { lat, lng, distance: getDistance() }
240
+ }
241
+ }))
242
+
243
+ useEffect(() => {
244
+ const controls = controlsRef.current
245
+ if (!controls) return
246
+
247
+ const handleStart = () => cancelAnimation()
248
+ const handleChange = () => { emitZoomChange(); emitViewChange() }
249
+ controls.addEventListener('change', handleChange)
250
+ controls.addEventListener('start', handleStart)
251
+
252
+ // Emit once on mount so parent syncs initial state
253
+ emitZoomChange()
254
+ emitViewChange()
255
+
256
+ return () => {
257
+ controls.removeEventListener('start', handleStart)
258
+ controls.removeEventListener('change', handleChange)
259
+ }
260
+ }, [onZoomChange])
261
+
262
+ useEffect(() => () => cancelAnimation(), [])
263
+
264
+ // Initial focus on the Americas, slightly closer, no auto-rotate by default
265
+ useEffect(() => {
266
+ if (controlsRef.current) {
267
+ focusOn(INITIAL_LAT, INITIAL_LNG, INITIAL_DISTANCE)
268
+ } else {
269
+ // Defer one frame in case controls ref isn't ready yet
270
+ const raf = requestAnimationFrame(() => focusOn(INITIAL_LAT, INITIAL_LNG, INITIAL_DISTANCE))
271
+ return () => cancelAnimationFrame(raf)
272
+ }
273
+ }, [])
274
+
275
+ const [tooltip, setTooltip] = useState<{ x: number; y: number; label: string } | null>(null as any)
276
+
277
+ return (
278
+ <div className="relative h-full w-full overflow-hidden rounded-xl bg-[radial-gradient(circle_at_top_left,rgba(226,252,255,0.3),transparent_60%),radial-gradient(circle_at_bottom_right,rgba(191,219,254,0.28),transparent_55%),#0f1118]">
279
+ <Canvas
280
+ className="relative z-10"
281
+ camera={{ position: [0, 0, VISITOR_GLOBE_MAX_DISTANCE], fov: 45 }}
282
+ gl={{ alpha: true, antialias: true }}
283
+ onCreated={({ gl, camera }) => {
284
+ gl.setClearColor(0x000000, 0)
285
+ // Pre-orient the camera so the US faces forward even before OrbitControls attaches
286
+ const dir = latLngToVector3(INITIAL_LAT, INITIAL_LNG, INITIAL_DISTANCE)
287
+ ;(camera as THREE.PerspectiveCamera).position.copy(dir)
288
+ ;(camera as THREE.PerspectiveCamera).lookAt(0, 0, 0)
289
+ }}
290
+ >
291
+ <ambientLight intensity={1.05} color={new THREE.Color('#f1faff')} />
292
+ <hemisphereLight args={["#c4f1f9", "#dbeafe", 0.9]} />
293
+
294
+ <Globe />
295
+ <Halo />
296
+ <HexLandLayer />
297
+ <HexHighlights data={visitors} onHover={setTooltip} />
298
+
299
+ <OrbitControls
300
+ ref={controlsRef}
301
+ enableZoom
302
+ enablePan={false}
303
+ autoRotate={autoRotate}
304
+ autoRotateSpeed={0.3}
305
+ minDistance={VISITOR_GLOBE_MIN_DISTANCE}
306
+ maxDistance={VISITOR_GLOBE_MAX_DISTANCE}
307
+ zoomSpeed={0.45}
308
+ />
309
+ </Canvas>
310
+ {tooltip && (
311
+ <div className="pointer-events-none absolute z-20 rounded bg-black/70 px-2 py-1 text-xs text-white" style={{ left: tooltip.x, top: tooltip.y }}>
312
+ {tooltip.label}
313
+ </div>
314
+ )}
315
+ </div>
316
+ )
317
+ })
318
+
319
+ function Globe() {
320
+ const uniforms = useMemo(() => ({
321
+ uColorTop: { value: new THREE.Color('#f3fbff') },
322
+ uColorBottom: { value: new THREE.Color('#bde5ff') },
323
+ uRimColor: { value: new THREE.Color('#e0faff') },
324
+ uRimStrength: { value: 0.36 }
325
+ }), [])
326
+
327
+ return (
328
+ <mesh>
329
+ <sphereGeometry args={[1, 96, 96]} />
330
+ <shaderMaterial
331
+ transparent={false}
332
+ uniforms={uniforms}
333
+ vertexShader={`
334
+ varying vec3 vNormal;
335
+ varying vec3 vWorldPos;
336
+ void main(){
337
+ vNormal = normalize(normalMatrix * normal);
338
+ vec4 wp = modelMatrix * vec4(position,1.0);
339
+ vWorldPos = wp.xyz;
340
+ gl_Position = projectionMatrix * viewMatrix * wp;
341
+ }
342
+ `}
343
+ fragmentShader={`
344
+ uniform vec3 uColorTop;
345
+ uniform vec3 uColorBottom;
346
+ uniform vec3 uRimColor;
347
+ uniform float uRimStrength;
348
+ varying vec3 vNormal;
349
+ varying vec3 vWorldPos;
350
+ void main(){
351
+ float t = smoothstep(-0.25, 0.65, vNormal.y);
352
+ vec3 base = mix(uColorBottom, uColorTop, t);
353
+ float rim = pow(1.0 - max(dot(normalize(vNormal), vec3(0.0,0.0,1.0)), 0.0), 1.1);
354
+ vec3 color = mix(base, uRimColor, rim * uRimStrength);
355
+ gl_FragColor = vec4(color, 1.0);
356
+ }
357
+ `}
358
+ />
359
+ </mesh>
360
+ )
361
+ }
362
+
363
+ // Subtle rim lighting to hide the aliased edge (GitHub-style halo)
364
+ function Halo() {
365
+ const materialRef = useRef<THREE.ShaderMaterial>(null)
366
+ return (
367
+ <mesh>
368
+ <sphereGeometry args={[1.01, 64, 64]} />
369
+ <shaderMaterial
370
+ ref={materialRef}
371
+ transparent
372
+ depthWrite={false}
373
+ blending={THREE.AdditiveBlending}
374
+ vertexShader={`
375
+ varying vec3 vNormal;
376
+ void main() {
377
+ vNormal = normalize(normalMatrix * normal);
378
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
379
+ }
380
+ `}
381
+ fragmentShader={`
382
+ varying vec3 vNormal;
383
+ void main() {
384
+ float intensity = pow(1.0 - max(dot(vNormal, vec3(0.0, 0.0, 1.0)), 0.0), 1.05);
385
+ gl_FragColor = vec4(0.93, 0.99, 1.0, intensity * 0.32);
386
+ }
387
+ `}
388
+ />
389
+ </mesh>
390
+ )
391
+ }
392
+
393
+ // Removed sprite-based VisitorsPoints in favor of hex highlights
394
+
395
+ // Match three-globe's polar2Cartesian so our points align with its hex grid
396
+ // three-globe formula (before scaling):
397
+ // phi = (90 - lat) * DEG2RAD
398
+ // theta = (90 - lng) * DEG2RAD
399
+ // x = r * sin(phi) * cos(theta)
400
+ // y = r * cos(phi)
401
+ // z = r * sin(phi) * sin(theta)
402
+ function latLngToVector3(lat: number, lng: number, radius: number): THREE.Vector3 {
403
+ const phi = (90 - lat) * (Math.PI / 180)
404
+ const theta = (90 - lng) * (Math.PI / 180)
405
+ const sinPhi = Math.sin(phi)
406
+
407
+ return new THREE.Vector3(
408
+ radius * sinPhi * Math.cos(theta),
409
+ radius * Math.cos(phi),
410
+ radius * sinPhi * Math.sin(theta)
411
+ )
412
+ }
413
+
414
+ function vector3ToLatLng(v: THREE.Vector3): { lat: number; lng: number } {
415
+ const r = v.length()
416
+ if (r === 0) return { lat: 0, lng: 0 }
417
+ const y = v.y / r
418
+ const phi = Math.acos(THREE.MathUtils.clamp(y, -1, 1))
419
+ const theta = Math.atan2(v.z, v.x)
420
+ const lat = 90 - (phi * 180) / Math.PI
421
+ const lng = 90 - (theta * 180) / Math.PI
422
+ const normLng = ((lng + 180) % 360) - 180
423
+ return { lat, lng: normLng }
424
+ }
@@ -0,0 +1,64 @@
1
+ import * as React from "react"
2
+ import * as AccordionPrimitive from "@radix-ui/react-accordion"
3
+ import { ChevronDownIcon } from "lucide-react"
4
+
5
+ import { cn } from "@/lib/utils"
6
+
7
+ function Accordion({
8
+ ...props
9
+ }: React.ComponentProps<typeof AccordionPrimitive.Root>) {
10
+ return <AccordionPrimitive.Root data-slot="accordion" {...props} />
11
+ }
12
+
13
+ function AccordionItem({
14
+ className,
15
+ ...props
16
+ }: React.ComponentProps<typeof AccordionPrimitive.Item>) {
17
+ return (
18
+ <AccordionPrimitive.Item
19
+ data-slot="accordion-item"
20
+ className={cn("border-b last:border-b-0", className)}
21
+ {...props}
22
+ />
23
+ )
24
+ }
25
+
26
+ function AccordionTrigger({
27
+ className,
28
+ children,
29
+ ...props
30
+ }: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
31
+ return (
32
+ <AccordionPrimitive.Header className="flex">
33
+ <AccordionPrimitive.Trigger
34
+ data-slot="accordion-trigger"
35
+ className={cn(
36
+ "focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
37
+ className
38
+ )}
39
+ {...props}
40
+ >
41
+ {children}
42
+ <ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
43
+ </AccordionPrimitive.Trigger>
44
+ </AccordionPrimitive.Header>
45
+ )
46
+ }
47
+
48
+ function AccordionContent({
49
+ className,
50
+ children,
51
+ ...props
52
+ }: React.ComponentProps<typeof AccordionPrimitive.Content>) {
53
+ return (
54
+ <AccordionPrimitive.Content
55
+ data-slot="accordion-content"
56
+ className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
57
+ {...props}
58
+ >
59
+ <div className={cn("pt-0 pb-4", className)}>{children}</div>
60
+ </AccordionPrimitive.Content>
61
+ )
62
+ }
63
+
64
+ export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
@@ -0,0 +1,66 @@
1
+ import * as React from "react"
2
+ import { cva, type VariantProps } from "class-variance-authority"
3
+
4
+ import { cn } from "@/lib/utils"
5
+
6
+ const alertVariants = cva(
7
+ "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default: "bg-card text-card-foreground",
12
+ destructive:
13
+ "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
14
+ },
15
+ },
16
+ defaultVariants: {
17
+ variant: "default",
18
+ },
19
+ }
20
+ )
21
+
22
+ function Alert({
23
+ className,
24
+ variant,
25
+ ...props
26
+ }: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
27
+ return (
28
+ <div
29
+ data-slot="alert"
30
+ role="alert"
31
+ className={cn(alertVariants({ variant }), className)}
32
+ {...props}
33
+ />
34
+ )
35
+ }
36
+
37
+ function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
38
+ return (
39
+ <div
40
+ data-slot="alert-title"
41
+ className={cn(
42
+ "col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
43
+ className
44
+ )}
45
+ {...props}
46
+ />
47
+ )
48
+ }
49
+
50
+ function AlertDescription({
51
+ className,
52
+ ...props
53
+ }: React.ComponentProps<"div">) {
54
+ return (
55
+ <div
56
+ data-slot="alert-description"
57
+ className={cn(
58
+ "text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
59
+ className
60
+ )}
61
+ {...props}
62
+ />
63
+ )
64
+ }
65
+
66
+ export { Alert, AlertTitle, AlertDescription }
@@ -0,0 +1,53 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as AvatarPrimitive from "@radix-ui/react-avatar"
5
+
6
+ import { cn } from "@/lib/utils"
7
+
8
+ function Avatar({
9
+ className,
10
+ ...props
11
+ }: React.ComponentProps<typeof AvatarPrimitive.Root>) {
12
+ return (
13
+ <AvatarPrimitive.Root
14
+ data-slot="avatar"
15
+ className={cn(
16
+ "relative flex size-8 shrink-0 overflow-hidden rounded-full",
17
+ className
18
+ )}
19
+ {...props}
20
+ />
21
+ )
22
+ }
23
+
24
+ function AvatarImage({
25
+ className,
26
+ ...props
27
+ }: React.ComponentProps<typeof AvatarPrimitive.Image>) {
28
+ return (
29
+ <AvatarPrimitive.Image
30
+ data-slot="avatar-image"
31
+ className={cn("aspect-square size-full", className)}
32
+ {...props}
33
+ />
34
+ )
35
+ }
36
+
37
+ function AvatarFallback({
38
+ className,
39
+ ...props
40
+ }: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
41
+ return (
42
+ <AvatarPrimitive.Fallback
43
+ data-slot="avatar-fallback"
44
+ className={cn(
45
+ "bg-muted flex size-full items-center justify-center rounded-full",
46
+ className
47
+ )}
48
+ {...props}
49
+ />
50
+ )
51
+ }
52
+
53
+ export { Avatar, AvatarImage, AvatarFallback }
@@ -0,0 +1,46 @@
1
+ import * as React from "react"
2
+ import { Slot } from "@radix-ui/react-slot"
3
+ import { cva, type VariantProps } from "class-variance-authority"
4
+
5
+ import { cn } from "@/lib/utils"
6
+
7
+ const badgeVariants = cva(
8
+ "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default:
13
+ "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
14
+ secondary:
15
+ "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
16
+ destructive:
17
+ "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
18
+ outline:
19
+ "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
20
+ },
21
+ },
22
+ defaultVariants: {
23
+ variant: "default",
24
+ },
25
+ }
26
+ )
27
+
28
+ function Badge({
29
+ className,
30
+ variant,
31
+ asChild = false,
32
+ ...props
33
+ }: React.ComponentProps<"span"> &
34
+ VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
35
+ const Comp = asChild ? Slot : "span"
36
+
37
+ return (
38
+ <Comp
39
+ data-slot="badge"
40
+ className={cn(badgeVariants({ variant }), className)}
41
+ {...props}
42
+ />
43
+ )
44
+ }
45
+
46
+ export { Badge, badgeVariants }