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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +163 -0
- data/Rakefile +6 -0
- data/app/assets/ahoy_analytics/build/assets/Combination-BpSXUjp9.js +41 -0
- data/app/assets/ahoy_analytics/build/assets/analytics-5KyfCxh6.css +1 -0
- data/app/assets/ahoy_analytics/build/assets/analytics-dashboard-uOXx8zYZ.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/analytics-layout-ClAft5OU.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/analytics-tracker-B3f8P98z.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/analytics-ui-DMSkNqd6.js +90 -0
- data/app/assets/ahoy_analytics/build/assets/behaviors-panel-ChNGYbdH.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/button-JVCrlR4s.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/cable-DO-7y1-E.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/createLucideIcon-BGzacY2v.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/date-range-dialog-DWDp3cLG.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/details-button-NqKfSGEG.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/devices-panel-cXvlmNBY.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/dialog-path-BBPNlB4Z.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/dropdown-menu-Adj3O5fh.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/filter-dialog-BN-rf4lp.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/index-B1K1NTKT.js +3 -0
- data/app/assets/ahoy_analytics/build/assets/index-BcHeb-Rh.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/index-DzpzLoG4.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/index-vX97OY1J.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/input-e4v_v0kE.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/jsx-runtime-u17CrQMm.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/last-load-context-De5uA95L.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/list-table-ChHEzzF9.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/live-Cp2MHECh.js +2 -0
- data/app/assets/ahoy_analytics/build/assets/locations-panel-BaISRmaQ.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/mercator-BnxX5RzL.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/pages-panel-Bh25L8mP.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/panel-tabs-B2kvGFJx.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/query-context-B-PgE00D.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/remote-details-dialog-DDTcKaM5.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/show-CCRicksg.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/simple-tabs-D6G6Bs0k.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/site-context-BNteYRlR.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/sources-panel-DyB21hxD.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/top-bar-FSiLBjq6.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/top-stats-context-DU15P9jS.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/use-debounce-VBpXQRL8.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/user-context-DbYteluY.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/visitor-globe-BWLDihid.js +4789 -0
- data/app/assets/ahoy_analytics/build/assets/visitor-graph-uKXjLvcu.js +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/brave.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/chrome.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/chromium.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/duckduckgo.svg +2151 -0
- data/app/assets/ahoy_analytics/images/icon/browser/edge.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/fallback.svg +5 -0
- data/app/assets/ahoy_analytics/images/icon/browser/firefox.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/opera.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/safari.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/browser/samsung-internet.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/uc.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/vivaldi.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/yandex.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/android.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/chrome_os.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/fallback.svg +5 -0
- data/app/assets/ahoy_analytics/images/icon/os/fedora.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/freebsd.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/gnu_linux.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/ios.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/ipad_os.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/mac.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/ubuntu.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/windows.png +0 -0
- data/app/assets/stylesheets/ahoy_analytics/application.css +15 -0
- data/app/channels/ahoy_analytics/analytics_channel.rb +9 -0
- data/app/controllers/ahoy_analytics/analytics_controller.rb +9 -0
- data/app/controllers/ahoy_analytics/application_controller.rb +8 -0
- data/app/controllers/ahoy_analytics/assets_controller.rb +46 -0
- data/app/controllers/ahoy_analytics/base_controller.rb +285 -0
- data/app/controllers/ahoy_analytics/behaviors_controller.rb +14 -0
- data/app/controllers/ahoy_analytics/devices_controller.rb +14 -0
- data/app/controllers/ahoy_analytics/export_controller.rb +12 -0
- data/app/controllers/ahoy_analytics/live_controller.rb +9 -0
- data/app/controllers/ahoy_analytics/locations_controller.rb +14 -0
- data/app/controllers/ahoy_analytics/main_graph_controller.rb +10 -0
- data/app/controllers/ahoy_analytics/pages_controller.rb +14 -0
- data/app/controllers/ahoy_analytics/referrers_controller.rb +34 -0
- data/app/controllers/ahoy_analytics/search_terms_controller.rb +47 -0
- data/app/controllers/ahoy_analytics/sources_controller.rb +14 -0
- data/app/controllers/ahoy_analytics/top_stats_controller.rb +13 -0
- data/app/controllers/concerns/ahoy_analytics/set_current_request.rb +17 -0
- data/app/frontend/components/analytics/hex-highlights.tsx +165 -0
- data/app/frontend/components/analytics/hex-land-layer.tsx +61 -0
- data/app/frontend/components/analytics/metric-card.tsx +138 -0
- data/app/frontend/components/analytics/sessions-by-location.tsx +62 -0
- data/app/frontend/components/analytics/visitor-globe.tsx +424 -0
- data/app/frontend/components/ui/accordion.tsx +64 -0
- data/app/frontend/components/ui/alert.tsx +66 -0
- data/app/frontend/components/ui/avatar.tsx +53 -0
- data/app/frontend/components/ui/badge.tsx +46 -0
- data/app/frontend/components/ui/button.tsx +62 -0
- data/app/frontend/components/ui/calendar.tsx +212 -0
- data/app/frontend/components/ui/card.tsx +91 -0
- data/app/frontend/components/ui/checkbox.tsx +32 -0
- data/app/frontend/components/ui/dropdown-menu.tsx +255 -0
- data/app/frontend/components/ui/input.tsx +21 -0
- data/app/frontend/components/ui/label.tsx +22 -0
- data/app/frontend/components/ui/popover.tsx +46 -0
- data/app/frontend/components/ui/select.tsx +183 -0
- data/app/frontend/components/ui/separator.tsx +26 -0
- data/app/frontend/components/ui/sheet.tsx +139 -0
- data/app/frontend/components/ui/sidebar.tsx +726 -0
- data/app/frontend/components/ui/skeleton.tsx +13 -0
- data/app/frontend/components/ui/sonner.tsx +33 -0
- data/app/frontend/components/ui/tooltip.tsx +59 -0
- data/app/frontend/data/countries-110m.json +1 -0
- data/app/frontend/data/globe-data.json +1 -0
- data/app/frontend/entrypoints/analytics-tracker.ts +680 -0
- data/app/frontend/entrypoints/analytics-ui.tsx +26 -0
- data/app/frontend/entrypoints/analytics.css +77 -0
- data/app/frontend/layouts/analytics-layout.tsx +28 -0
- data/app/frontend/lib/cable.ts +13 -0
- data/app/frontend/lib/geocode.ts +65 -0
- data/app/frontend/lib/utils.ts +6 -0
- data/app/frontend/pages/admin/analytics/api.ts +221 -0
- data/app/frontend/pages/admin/analytics/hooks/use-debounce.ts +36 -0
- data/app/frontend/pages/admin/analytics/last-load-context.tsx +29 -0
- data/app/frontend/pages/admin/analytics/lib/base-path.ts +28 -0
- data/app/frontend/pages/admin/analytics/lib/dialog-path.ts +242 -0
- data/app/frontend/pages/admin/analytics/lib/number-formatter.ts +100 -0
- data/app/frontend/pages/admin/analytics/live.tsx +608 -0
- data/app/frontend/pages/admin/analytics/query-context.tsx +61 -0
- data/app/frontend/pages/admin/analytics/show.tsx +40 -0
- data/app/frontend/pages/admin/analytics/site-context.tsx +22 -0
- data/app/frontend/pages/admin/analytics/top-stats-context.tsx +37 -0
- data/app/frontend/pages/admin/analytics/types.ts +161 -0
- data/app/frontend/pages/admin/analytics/ui/analytics-dashboard.tsx +60 -0
- data/app/frontend/pages/admin/analytics/ui/behaviors-panel.tsx +456 -0
- data/app/frontend/pages/admin/analytics/ui/date-range-dialog.tsx +173 -0
- data/app/frontend/pages/admin/analytics/ui/details-button.tsx +33 -0
- data/app/frontend/pages/admin/analytics/ui/devices-panel.tsx +474 -0
- data/app/frontend/pages/admin/analytics/ui/filter-dialog.tsx +558 -0
- data/app/frontend/pages/admin/analytics/ui/list-table.tsx +346 -0
- data/app/frontend/pages/admin/analytics/ui/locations-panel.tsx +566 -0
- data/app/frontend/pages/admin/analytics/ui/pages-panel.tsx +207 -0
- data/app/frontend/pages/admin/analytics/ui/panel-tabs.tsx +65 -0
- data/app/frontend/pages/admin/analytics/ui/remote-details-dialog.tsx +356 -0
- data/app/frontend/pages/admin/analytics/ui/simple-tabs.tsx +54 -0
- data/app/frontend/pages/admin/analytics/ui/sources-panel.tsx +771 -0
- data/app/frontend/pages/admin/analytics/ui/top-bar.tsx +793 -0
- data/app/frontend/pages/admin/analytics/ui/visitor-graph.tsx +891 -0
- data/app/frontend/pages/admin/analytics/user-context.tsx +22 -0
- data/app/frontend/styles/shared.css +156 -0
- data/app/helpers/ahoy_analytics/application_helper.rb +96 -0
- data/app/jobs/ahoy_analytics/application_job.rb +4 -0
- data/app/jobs/ahoy_analytics/update_job.rb +12 -0
- data/app/mailers/ahoy_analytics/application_mailer.rb +6 -0
- data/app/models/ahoy/event/filters.rb +7 -0
- data/app/models/ahoy/event.rb +9 -0
- data/app/models/ahoy/visit/cache_key.rb +15 -0
- data/app/models/ahoy/visit/constants.rb +11 -0
- data/app/models/ahoy/visit/devices.rb +144 -0
- data/app/models/ahoy/visit/export.rb +24 -0
- data/app/models/ahoy/visit/filters.rb +286 -0
- data/app/models/ahoy/visit/imports.rb +36 -0
- data/app/models/ahoy/visit/locations.rb +276 -0
- data/app/models/ahoy/visit/metrics.rb +473 -0
- data/app/models/ahoy/visit/ordering.rb +110 -0
- data/app/models/ahoy/visit/pages.rb +533 -0
- data/app/models/ahoy/visit/pagination.rb +17 -0
- data/app/models/ahoy/visit/ranges.rb +227 -0
- data/app/models/ahoy/visit/series.rb +177 -0
- data/app/models/ahoy/visit/sources.rb +418 -0
- data/app/models/ahoy/visit/url_labels.rb +32 -0
- data/app/models/ahoy/visit.rb +143 -0
- data/app/models/ahoy_analytics/application_record.rb +5 -0
- data/app/models/ahoy_analytics/current.rb +8 -0
- data/app/models/ahoy_analytics/funnel.rb +16 -0
- data/app/models/ahoy_analytics/imported_entry_page.rb +5 -0
- data/app/models/ahoy_analytics/imported_exit_page.rb +5 -0
- data/app/models/ahoy_analytics/imported_page.rb +5 -0
- data/app/models/ahoy_analytics/live_stats.rb +152 -0
- data/app/models/ahoy_analytics/setting.rb +19 -0
- data/app/models/analytics/source_catalog.rb +48 -0
- data/app/views/layouts/ahoy_analytics/application.html.erb +15 -0
- data/config/routes.rb +21 -0
- data/config/vite.json +22 -0
- data/db/migrate/20251006104056_create_ahoy_visits_and_events.rb +62 -0
- data/db/migrate/20251006105012_add_analytics_fields_to_ahoy_visits.rb +11 -0
- data/db/migrate/20251012090000_create_analytics_funnels_and_imports.rb +52 -0
- data/db/migrate/20251013021500_add_analytics_indexes.rb +14 -0
- data/lib/ahoy_analytics/ahoy_store.rb +429 -0
- data/lib/ahoy_analytics/asset_manifest.rb +56 -0
- data/lib/ahoy_analytics/device_bucket.rb +39 -0
- data/lib/ahoy_analytics/engine.rb +55 -0
- data/lib/ahoy_analytics/maxmind_geo.rb +77 -0
- data/lib/ahoy_analytics/version.rb +3 -0
- data/lib/ahoy_analytics.rb +52 -0
- data/lib/generators/ahoy_analytics/install/install_generator.rb +111 -0
- data/lib/generators/ahoy_analytics/install/templates/initializer.rb +28 -0
- data/lib/tasks/ahoy_analytics_tasks.rake +4 -0
- 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
|
+
}
|