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,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AhoyAnalytics
4
+ class SearchTermsController < AhoyAnalytics::BaseController
5
+ def index
6
+ # TODO(GSC): Real Google Search Console integration. Placeholder mirrors Plausible UX signals.
7
+ return render(json: { errorCode: "not_configured", isAdmin: true }, status: :unprocessable_entity) unless gsc_configured?
8
+ return render(json: { errorCode: "unsupported_filters" }, status: :unprocessable_entity) if unsupported_gsc_filters?(@query)
9
+
10
+ limit, page = parsed_pagination
11
+ search = normalized_search
12
+ payload = cache_for([ :search_terms, limit, page, search, params[:order_by] ]) do
13
+ search_terms_payload(@query, limit:, page:, search:)
14
+ end
15
+
16
+ range, = Ahoy::Visit.range_and_interval_for(@query[:period], nil, @query)
17
+ if payload[:results].blank? && (Time.zone.now - range.begin < 72.hours)
18
+ return render json: { errorCode: "period_too_recent" }, status: :unprocessable_entity
19
+ end
20
+
21
+ render json: camelize_keys(payload)
22
+ end
23
+
24
+ private
25
+ def gsc_configured?
26
+ configured = AhoyAnalytics.config.gsc_configured
27
+ configured = instance_exec(&configured) if configured.respond_to?(:call)
28
+ return configured unless configured.nil?
29
+
30
+ # Prefer DB flag when available, then Rails config, then ENV
31
+ db = AhoyAnalytics::Setting.get_bool("gsc_configured", fallback: nil)
32
+ return db unless db.nil?
33
+ v = Rails.configuration.x.analytics&.gsc_configured
34
+ v = ENV["ANALYTICS_GSC_CONFIGURED"] if v.nil?
35
+ ActiveModel::Type::Boolean.new.cast(v)
36
+ end
37
+
38
+ def unsupported_gsc_filters?(query)
39
+ filters = (query[:filters] || {}).stringify_keys
40
+ disallowed = %w[channel referrer utm_source utm_medium utm_campaign utm_content utm_term entry_page exit_page]
41
+ return true if filters.keys.any? { |k| disallowed.include?(k) }
42
+ adv = Array(query[:advanced_filters])
43
+ return true if adv.any?
44
+ false
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AhoyAnalytics
4
+ class SourcesController < AhoyAnalytics::BaseController
5
+ def index
6
+ limit, page = parsed_pagination
7
+ search = normalized_search
8
+ payload = cache_for([ :sources, @query[:mode], limit, page, search, params[:order_by] ]) do
9
+ sources_payload(@query, limit:, page:, search:)
10
+ end
11
+ render json: camelize_keys(payload)
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AhoyAnalytics
4
+ class TopStatsController < AhoyAnalytics::BaseController
5
+ def show
6
+ cached = cache_for(:top_stats) { top_stats_payload(@query) }
7
+ if cached[:top_stats]&.first&.dig(:name) == "Live visitors"
8
+ cached[:top_stats][0][:value] = Ahoy::Visit.live_visitors_count
9
+ end
10
+ render json: camelize_keys(cached)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AhoyAnalytics
4
+ module SetCurrentRequest
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ before_action do
9
+ AhoyAnalytics::Current.request = request
10
+ end
11
+ end
12
+
13
+ def default_url_options
14
+ { host: AhoyAnalytics::Current.request_host, protocol: AhoyAnalytics::Current.request_protocol }.compact_blank
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,165 @@
1
+ import { useEffect, useMemo, useRef } from 'react'
2
+ import { useThree } from '@react-three/fiber'
3
+ import * as THREE from 'three'
4
+ // three's BufferGeometryUtils helper for merging many hex geometries into one
5
+ import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUtils.js'
6
+ import ConicPolygonGeometry from 'three-conic-polygon-geometry'
7
+ import { latLngToCell, cellToBoundary, cellToLatLng } from 'h3-js'
8
+
9
+ type Dot = { lat: number; lng: number; type: 'visitor'; city?: string | null }
10
+
11
+ type Props = {
12
+ data: Dot[]
13
+ resolution?: number
14
+ margin?: number
15
+ // Base altitude just above land hexes (land ~0.002).
16
+ altitudeBase?: number
17
+ onHover?: (t: { x: number; y: number; label: string } | null) => void
18
+ }
19
+
20
+ // Draws real hexes (same geometry/orientation as the land layer) at the H3 cell
21
+ // containing each dot. This guarantees exact alignment with the grid at all zooms.
22
+ export default function HexHighlights({ data, resolution = 3, margin = 0.2, altitudeBase = 0.00205, onHover }: Props) {
23
+ const { scene, camera, gl } = useThree()
24
+ const groupRef = useRef<THREE.Group | null>(null)
25
+
26
+ const { visitorCells, cellLabels } = useMemo(() => {
27
+ const v = new Set<string>()
28
+ const labels = new Map<string, string>()
29
+ for (const d of data) {
30
+ try {
31
+ const cell = latLngToCell(d.lat, d.lng, resolution)
32
+ v.add(cell)
33
+ if (!labels.has(cell)) labels.set(cell, (d.city || 'Unknown') as string)
34
+ } catch {}
35
+ }
36
+ return { visitorCells: Array.from(v), cellLabels: labels }
37
+ }, [data, resolution])
38
+
39
+ useEffect(() => {
40
+ const group = new THREE.Group()
41
+ group.renderOrder = 2
42
+
43
+ const makeLayer = (
44
+ cells: string[],
45
+ color: string,
46
+ altitude: number,
47
+ renderOrder: number,
48
+ options?: { outline?: boolean }
49
+ ) => {
50
+ if (cells.length === 0) return
51
+ const geoms: THREE.BufferGeometry[] = []
52
+ for (const idx of cells) {
53
+ // Get hex boundary and center
54
+ const center = cellToLatLng(idx) // [lat,lng]
55
+ const centerLat = center[0]
56
+ const centerLng = center[1]
57
+ let boundary = cellToBoundary(idx, true) // [[lng,lat], ...]
58
+ // Ensure winding similar to three-globe (reverse)
59
+ boundary = boundary.slice().reverse()
60
+
61
+ // Apply same margin logic as three-globe to keep inner hex size consistent
62
+ const shrink = (elng: number, elat: number) => {
63
+ const lerp = (a: number, b: number, t: number) => a * (1 - t) + b * t
64
+ return [lerp(elng, centerLng, margin), lerp(elat, centerLat, margin)] as [number, number]
65
+ }
66
+ const shrunk = margin === 0 ? boundary : boundary.map(([lng, lat]) => shrink(lng, lat))
67
+
68
+ // Build a single ConicPolygonGeometry for this hex from radius 1 to 1+altitude
69
+ try {
70
+ const geo = new ConicPolygonGeometry([shrunk], 1, 1 + altitude, false, true, false, 4)
71
+ geoms.push(geo)
72
+ } catch {}
73
+ }
74
+ if (geoms.length > 0) {
75
+ const merged = BufferGeometryUtils.mergeGeometries(geoms, false) as THREE.BufferGeometry
76
+ const mat = new THREE.MeshLambertMaterial({ color, transparent: false, depthWrite: true })
77
+ const mesh = new THREE.Mesh(merged, mat)
78
+ mesh.renderOrder = renderOrder
79
+ group.add(mesh)
80
+
81
+ if (options?.outline) {
82
+ // Crisp 1px white outline above the fill
83
+ const edges = new THREE.EdgesGeometry(merged)
84
+ const lineMat = new THREE.LineBasicMaterial({ color: '#ffffff', transparent: true, opacity: 0.95, depthTest: false })
85
+ const lines = new THREE.LineSegments(edges, lineMat)
86
+ lines.renderOrder = renderOrder + 1
87
+ group.add(lines)
88
+ }
89
+ }
90
+ }
91
+
92
+ const visitorAlt = altitudeBase + 0.00008
93
+ makeLayer(visitorCells, '#2563eb', visitorAlt, 3)
94
+
95
+ scene.add(group)
96
+ groupRef.current = group
97
+
98
+ return () => {
99
+ if (groupRef.current) {
100
+ scene.remove(groupRef.current)
101
+ groupRef.current.traverse((obj) => {
102
+ const mesh = obj as THREE.Mesh
103
+ if ((mesh as any).geometry) (mesh as any).geometry.dispose?.()
104
+ if ((mesh as any).material) {
105
+ const m = (mesh as any).material as THREE.Material | THREE.Material[]
106
+ Array.isArray(m) ? m.forEach((mm) => mm.dispose?.()) : m.dispose?.()
107
+ }
108
+ })
109
+ groupRef.current = null
110
+ }
111
+ }
112
+ }, [scene, visitorCells, margin, altitudeBase])
113
+
114
+ // Pointer interaction: map cursor to globe point, then to H3 cell, then show label
115
+ useEffect(() => {
116
+ if (!onHover) return
117
+ const sphereRadius = 1
118
+ const handleMove = (ev: PointerEvent) => {
119
+ const rect = gl.domElement.getBoundingClientRect()
120
+ const nx = ((ev.clientX - rect.left) / rect.width) * 2 - 1
121
+ const ny = -((ev.clientY - rect.top) / rect.height) * 2 + 1
122
+
123
+ const ray = new THREE.Ray()
124
+ ray.origin.copy((camera as THREE.PerspectiveCamera).position)
125
+ ray.direction.set(nx, ny, 0.5).unproject(camera as THREE.PerspectiveCamera).sub(ray.origin).normalize()
126
+
127
+ // Ray-sphere intersection (center at 0, radius=1)
128
+ const o = ray.origin
129
+ const d = ray.direction
130
+ const b = o.dot(d)
131
+ const c = o.lengthSq() - sphereRadius * sphereRadius
132
+ const disc = b * b - c
133
+ if (disc < 0) { onHover(null); return }
134
+ const t = -b - Math.sqrt(disc)
135
+ if (t <= 0) { onHover(null); return }
136
+ const p = new THREE.Vector3().copy(d).multiplyScalar(t).add(o)
137
+
138
+ // Convert to lat/lng
139
+ const r = p.length()
140
+ const phi = Math.acos(p.y / r)
141
+ const theta = Math.atan2(p.z, p.x)
142
+ const lat = 90 - (phi * 180 / Math.PI)
143
+ const lng = 90 - (theta * 180 / Math.PI) - (theta < -Math.PI / 2 ? 360 : 0)
144
+
145
+ try {
146
+ const cell = latLngToCell(lat, lng, resolution)
147
+ if (cellLabels.has(cell)) {
148
+ const label = cellLabels.get(cell) as string
149
+ onHover({ x: ev.clientX - rect.left + 10, y: ev.clientY - rect.top + 12, label })
150
+ return
151
+ }
152
+ } catch {}
153
+ onHover(null)
154
+ }
155
+ const leave = () => onHover(null)
156
+ gl.domElement.addEventListener('pointermove', handleMove)
157
+ gl.domElement.addEventListener('pointerleave', leave)
158
+ return () => {
159
+ gl.domElement.removeEventListener('pointermove', handleMove)
160
+ gl.domElement.removeEventListener('pointerleave', leave)
161
+ }
162
+ }, [camera, gl, onHover, cellLabels, resolution])
163
+
164
+ return null
165
+ }
@@ -0,0 +1,61 @@
1
+ import { useEffect, useMemo, useRef } from 'react'
2
+ import { useThree } from '@react-three/fiber'
3
+ import ThreeGlobe from 'three-globe'
4
+ import type { Feature, FeatureCollection, Geometry, GeoJsonProperties } from 'geojson'
5
+ import globeData from '@/data/globe-data.json'
6
+
7
+ // Import as GeoJSON FeatureCollection
8
+ const rawFeatures = globeData as unknown as FeatureCollection<Geometry, GeoJsonProperties>
9
+
10
+ export default function HexLandLayer() {
11
+ const { scene } = useThree()
12
+ const globeRef = useRef<ThreeGlobe | null>(null)
13
+
14
+ const landFeatures = useMemo(() => {
15
+ if (!rawFeatures?.features) return [] as Feature<Geometry, GeoJsonProperties>[]
16
+
17
+ // Features are already individual Polygons with holes removed from build script
18
+ return rawFeatures.features as Feature<Geometry, GeoJsonProperties>[]
19
+ }, [])
20
+
21
+ useEffect(() => {
22
+ if (!landFeatures.length) return
23
+
24
+ const globe = new ThreeGlobe({ animateIn: false })
25
+ globe.hexPolygonsData(landFeatures as any)
26
+ globe.hexPolygonResolution(3) // Medium resolution hexagons
27
+ globe.hexPolygonMargin(0.2) // Small margin for tight spacing
28
+ globe.hexPolygonUseDots(false) // Use filled hexagons, not dots
29
+ const SCALE = 0.01
30
+ globe.hexPolygonAltitude(() => 0.002) // Slight elevation
31
+ // In-between Tailwind sky-300 and sky-400 (~"sky-350") for softer contrast
32
+ globe.hexPolygonColor(() => '#5ac8fa')
33
+ globe.hexPolygonsTransitionDuration(0)
34
+ globe.showAtmosphere(false)
35
+ globe.scale.setScalar(SCALE)
36
+
37
+ const baseMaterial = globe.globeMaterial()
38
+ baseMaterial.transparent = true
39
+ baseMaterial.opacity = 0
40
+ baseMaterial.depthWrite = false
41
+
42
+ // Access and configure hexagon material for better rendering
43
+ const hexMaterialAccessor = (globe as unknown as { hexPolygonsMaterial?: () => any }).hexPolygonsMaterial
44
+ if (hexMaterialAccessor) {
45
+ const mat = hexMaterialAccessor.call(globe)
46
+ mat.transparent = false
47
+ mat.opacity = 1.0
48
+ mat.depthWrite = true
49
+ }
50
+
51
+ globeRef.current = globe
52
+ scene.add(globe)
53
+
54
+ return () => {
55
+ scene.remove(globe)
56
+ globeRef.current = null
57
+ }
58
+ }, [landFeatures, scene])
59
+
60
+ return null
61
+ }
@@ -0,0 +1,138 @@
1
+ import { Line } from 'react-chartjs-2'
2
+ import { ArrowUpIcon, ArrowDownIcon } from 'lucide-react'
3
+ import { Card, CardContent } from '@/components/ui/card'
4
+ import {
5
+ Chart as ChartJS,
6
+ CategoryScale,
7
+ LinearScale,
8
+ PointElement,
9
+ LineElement,
10
+ Title,
11
+ Tooltip,
12
+ Legend,
13
+ Filler
14
+ } from 'chart.js'
15
+
16
+ // Register Chart.js components
17
+ ChartJS.register(
18
+ CategoryScale,
19
+ LinearScale,
20
+ PointElement,
21
+ LineElement,
22
+ Title,
23
+ Tooltip,
24
+ Legend,
25
+ Filler
26
+ )
27
+
28
+ type SparklineSeries = number[] | { today: number[]; yesterday?: number[] }
29
+
30
+ type MetricCardProps = {
31
+ title: string
32
+ value: string | number
33
+ change?: number
34
+ sparklineData?: SparklineSeries
35
+ variant?: 'default' | 'large'
36
+ showChange?: boolean
37
+ }
38
+
39
+ export function MetricCard({
40
+ title,
41
+ value,
42
+ change,
43
+ sparklineData,
44
+ variant = 'default',
45
+ showChange = true
46
+ }: MetricCardProps) {
47
+ const hasChange = change !== undefined && change !== null && !isNaN(change)
48
+ const isPositive = hasChange && change > 0
49
+ const isNegative = hasChange && change < 0
50
+ const normalized = Array.isArray(sparklineData)
51
+ ? { today: sparklineData, yesterday: undefined as number[] | undefined }
52
+ : (sparklineData || { today: [], yesterday: undefined })
53
+ const showSparkline = normalized.today && normalized.today.length > 0
54
+ const hasMeaningfulChange = isPositive || isNegative
55
+
56
+ return (
57
+ <Card className="overflow-hidden rounded-xl border border-white/12 bg-[#11131b] shadow-[0_12px_26px_rgba(7,9,16,0.32)] !py-0">
58
+ <CardContent className="px-5 py-3">
59
+ <div className="flex flex-col gap-2">
60
+ <span className="text-sm font-semibold text-foreground/80">{title}</span>
61
+
62
+ <div className="grid grid-cols-2 items-center gap-3">
63
+ <div className="flex items-baseline gap-2">
64
+ <span className={variant === 'large' ? 'text-xl font-semibold text-foreground' : 'text-lg font-semibold text-foreground'}>
65
+ {value}
66
+ </span>
67
+
68
+ {showChange && (
69
+ hasMeaningfulChange ? (
70
+ <div className="flex items-center gap-1 text-xs">
71
+ {isPositive && <ArrowUpIcon className="size-3 text-emerald-400" />}
72
+ {isNegative && <ArrowDownIcon className="size-3 text-rose-400" />}
73
+ <span className={isPositive ? 'text-emerald-400' : 'text-rose-400'}>
74
+ {Math.abs(change ?? 0)}%
75
+ </span>
76
+ </div>
77
+ ) : (
78
+ <span className="text-xs font-medium text-muted-foreground">—</span>
79
+ )
80
+ )}
81
+ </div>
82
+
83
+ {showSparkline && (
84
+ <div className="ml-auto h-8 w-full max-w-[92px] overflow-hidden rounded-md bg-[#0d0f16]/80">
85
+ <Line
86
+ data={{
87
+ labels: Array.from({ length: Math.max(normalized.today.length, normalized.yesterday?.length || 0) }, (_, i) => i),
88
+ datasets: [
89
+ ...(normalized.yesterday && normalized.yesterday.length > 0
90
+ ? [{
91
+ data: normalized.yesterday,
92
+ borderColor: 'rgba(56, 189, 248, 0.55)',
93
+ backgroundColor: 'transparent',
94
+ borderWidth: 1,
95
+ borderDash: [3, 3] as [number, number],
96
+ pointRadius: 0,
97
+ pointHoverRadius: 0,
98
+ tension: 0.45,
99
+ fill: false
100
+ }]
101
+ : []),
102
+ {
103
+ data: normalized.today,
104
+ borderColor: 'rgba(56, 189, 248, 1)',
105
+ backgroundColor: 'rgba(56, 189, 248, 0.08)',
106
+ borderWidth: 1.3,
107
+ pointRadius: 0,
108
+ pointHoverRadius: 0,
109
+ tension: 0.45,
110
+ fill: true
111
+ }
112
+ ]
113
+ }}
114
+ options={{
115
+ responsive: true,
116
+ maintainAspectRatio: false,
117
+ plugins: {
118
+ legend: { display: false },
119
+ tooltip: { enabled: false }
120
+ },
121
+ scales: {
122
+ x: { display: false },
123
+ y: { display: false }
124
+ },
125
+ interaction: {
126
+ mode: 'index',
127
+ intersect: false
128
+ }
129
+ }}
130
+ />
131
+ </div>
132
+ )}
133
+ </div>
134
+ </div>
135
+ </CardContent>
136
+ </Card>
137
+ )
138
+ }
@@ -0,0 +1,62 @@
1
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
2
+
3
+ type LocationSession = {
4
+ country: string
5
+ city: string
6
+ region?: string
7
+ countryCode: string
8
+ visitors: number
9
+ }
10
+
11
+ export function SessionsByLocation({ sessions }: { sessions: LocationSession[] }) {
12
+ if (!sessions || sessions.length === 0) {
13
+ return (
14
+ <Card className="gap-0 rounded-xl border border-white/12 bg-[#11131b] shadow-[0_12px_26px_rgba(7,9,16,0.32)] py-0">
15
+ <CardHeader className="px-5 pt-4 pb-2">
16
+ <CardTitle className="text-sm font-semibold text-foreground/80">Sessions by location</CardTitle>
17
+ </CardHeader>
18
+ <CardContent className="px-5 pb-4 pt-2">
19
+ <div className="py-4 text-center text-xs text-muted-foreground/80">
20
+ No active sessions
21
+ </div>
22
+ </CardContent>
23
+ </Card>
24
+ )
25
+ }
26
+
27
+ const maxVisitors = Math.max(...sessions.map(s => s.visitors))
28
+
29
+ return (
30
+ <Card className="gap-0 rounded-xl border border-white/12 bg-[#11131b] shadow-[0_12px_26px_rgba(7,9,16,0.32)] py-0">
31
+ <CardHeader className="px-5 pt-4 pb-2">
32
+ <CardTitle className="text-sm font-semibold text-foreground/80">Sessions by location</CardTitle>
33
+ </CardHeader>
34
+ <CardContent className="space-y-2.5 px-5 pb-4 pt-2">
35
+ {sessions.map((session, i) => {
36
+ const locationParts = [
37
+ session.country,
38
+ session.region,
39
+ session.city
40
+ ].filter(Boolean)
41
+
42
+ return (
43
+ <div key={i} className="space-y-2">
44
+ <div className="flex items-center justify-between text-[12px] text-foreground/80">
45
+ <span className="truncate font-medium text-foreground">
46
+ {locationParts.join(' - ')}
47
+ </span>
48
+ <span className="ml-2 flex-shrink-0 text-muted-foreground/80">{session.visitors}</span>
49
+ </div>
50
+ <div className="h-[6px] overflow-hidden rounded-full bg-white/10">
51
+ <div
52
+ className="h-full rounded-full bg-cyan-400 transition-all duration-300"
53
+ style={{ width: `${(session.visitors / maxVisitors) * 100}%` }}
54
+ />
55
+ </div>
56
+ </div>
57
+ )
58
+ })}
59
+ </CardContent>
60
+ </Card>
61
+ )
62
+ }