analytic 0.3.0 → 0.5.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 +4 -4
- data/README.md +98 -11
- data/Rakefile +5 -0
- data/app/assets/images/analytic/icon.svg +1 -0
- data/app/controllers/analytic/dashboard_controller.rb +2 -1
- data/app/controllers/concerns/analytic/trackable.rb +2 -2
- data/app/helpers/analytic/application_helper.rb +42 -0
- data/app/jobs/analytic/track_job.rb +1 -1
- data/app/models/analytic/application_record.rb +2 -0
- data/app/models/analytic/dashboard.rb +61 -29
- data/app/models/analytic/{view.rb → event.rb} +3 -3
- data/app/models/analytic/period.rb +41 -0
- data/app/models/analytic/stat.rb +30 -0
- data/app/packs/analytic/application/components/chart.tsx +148 -0
- data/app/packs/analytic/application/components/index.tsx +21 -0
- data/app/packs/analytic/application/index.ts +1 -0
- data/app/packs/analytic/application/initializers/fontawesome.ts +7 -1
- data/app/packs/analytic/entrypoints/application.tailwind.css +39 -3
- data/app/views/analytic/dashboard/show.html.erb +61 -14
- data/app/views/layouts/analytic/application.html.erb +1 -7
- data/db/migrate/{20240805210911_create_analytic_views.rb → 20240805210911_create_analytic_events.rb} +2 -2
- data/lib/analytic/config.rb +47 -5
- data/lib/analytic/version.rb +1 -1
- metadata +13 -11
- data/app/assets/builds/analytic/application.css +0 -810
- data/app/assets/builds/analytic/application.js +0 -5779
- data/app/assets/builds/analytic/application.js.map +0 -7
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e283c032e4bc20c3b16244bee1b0a4b65e236626bdd9d75c05ef44116c6aa679
|
4
|
+
data.tar.gz: 8f9e592c750579ed57665364b2b9f0fd5c7cf6455e68e134e1b7817eda7c2713
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: b88a20a9fcdebaa18d0702a0dc22ceaf02cf88d383d0f4d3de59558c6073b0d1b85993cc49ef1a19d39d98fed32c321aa82241df42a32440e7bdb8138eab4af6
|
7
|
+
data.tar.gz: 9c61764e8b904c336551099e1155c4bcfdcda88f32cd622bb7f38d57adc157bfd8c9a1583cbb9e09e7866a1103c127758239efd0431e00dd4b5aca25160be5f8
|
data/README.md
CHANGED
@@ -4,6 +4,8 @@
|
|
4
4
|
|
5
5
|
# Analytic
|
6
6
|
|
7
|
+

|
8
|
+
|
7
9
|
Analytic provides visitor / session / view tracking without the need for any third-party service.
|
8
10
|
|
9
11
|
## Installation
|
@@ -21,15 +23,6 @@ Rails.application.routes.draw do
|
|
21
23
|
end
|
22
24
|
```
|
23
25
|
|
24
|
-
```ruby
|
25
|
-
# config/initializer/analytic.rb
|
26
|
-
Analytic.configure do |config|
|
27
|
-
config.timezone = Time.find_zone('Canada/Pacific') # default is `Time.zone`
|
28
|
-
config.ip_v4_mask = 24 # '255.255.255.255' becomes '255.255.255.0'
|
29
|
-
config.ip_v6_mask = 48 # 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff' becomes 'ffff:ffff:ffff:0000:0000:0000:0000:0000'
|
30
|
-
end
|
31
|
-
```
|
32
|
-
|
33
26
|
## Usage
|
34
27
|
|
35
28
|
Default inline tracking is configured with:
|
@@ -38,7 +31,7 @@ Default inline tracking is configured with:
|
|
38
31
|
class ApplicationController
|
39
32
|
include Analytic::Trackable
|
40
33
|
|
41
|
-
before_action {
|
34
|
+
before_action :analytic_track!, if: -> { request.format.html? }
|
42
35
|
end
|
43
36
|
```
|
44
37
|
|
@@ -48,11 +41,105 @@ Alternative job tracking is configured with:
|
|
48
41
|
class ApplicationController
|
49
42
|
include Analytic::Trackable
|
50
43
|
|
51
|
-
before_action {
|
44
|
+
before_action :analytic_enqueue_track_job!, if: -> { request.format.html? }
|
45
|
+
end
|
52
46
|
```
|
53
47
|
|
54
48
|
_note: a queue such as sidekiq, rescue, etc must be running to see tracking_
|
55
49
|
|
50
|
+
## Configuration
|
51
|
+
|
52
|
+
### Authentication the Dashboard
|
53
|
+
|
54
|
+
By default **Analytic** is not authenticated. To authenticate using `Rack::Auth` generate a random username and password:
|
55
|
+
|
56
|
+
```ruby
|
57
|
+
$ bin/rails secret # generate a secret
|
58
|
+
$ bin/rails credentials:edit
|
59
|
+
```
|
60
|
+
|
61
|
+
```yaml
|
62
|
+
analytic:
|
63
|
+
username: abc...
|
64
|
+
password: def...
|
65
|
+
```
|
66
|
+
|
67
|
+
```ruby
|
68
|
+
# config/initializers/analytic.rb
|
69
|
+
|
70
|
+
def same?(src, dst)
|
71
|
+
# https://api.rubyonrails.org/classes/ActiveSupport/SecurityUtils.html
|
72
|
+
ActiveSupport::SecurityUtils.secure_compare(src, dst)
|
73
|
+
end
|
74
|
+
|
75
|
+
Analytic.configure do |config|
|
76
|
+
unless Rails.env.local?
|
77
|
+
config.use Rack::Auth::Basic do |username, password|
|
78
|
+
credentials = Rails.application.credentials.analytic
|
79
|
+
same?(username, credentials.username) && same?(password, credentials.password)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
```
|
84
|
+
|
85
|
+
### Capturing Extra Parameters
|
86
|
+
|
87
|
+
By default **Analytic** tracks `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, `utm_term`, `ref` and `source` parameters for each request. This list can be customized using:
|
88
|
+
|
89
|
+
```ruby
|
90
|
+
# config/initializer/analytic.rb
|
91
|
+
Analytic.configure do |config|
|
92
|
+
config.params << :gclid # e.g. Google
|
93
|
+
config.params << :msclkid # e.g. Bing
|
94
|
+
end
|
95
|
+
```
|
96
|
+
|
97
|
+
### Changing the Time Zone
|
98
|
+
|
99
|
+
By default **Analytic** uses `Time.zone`. The time zone can be changed using:
|
100
|
+
|
101
|
+
```ruby
|
102
|
+
# config/application.rb
|
103
|
+
class Application < Rails::Application
|
104
|
+
config.time_zone = 'Canada/Pacific'
|
105
|
+
end
|
106
|
+
```
|
107
|
+
|
108
|
+
The time zone may also be specified for **Analytic** using any `ActiveSupport::TimeZone`:
|
109
|
+
|
110
|
+
```ruby
|
111
|
+
# config/initializer/analytic.rb
|
112
|
+
Analytic.configure do |config|
|
113
|
+
config.time_zone = Time.find_zone('Canada/Pacific')
|
114
|
+
end
|
115
|
+
```
|
116
|
+
|
117
|
+
### Changing the Database
|
118
|
+
|
119
|
+
By default **Analytic** uses your apps main database. The database can be changed using:
|
120
|
+
|
121
|
+
```ruby
|
122
|
+
# config/initializer/analytic.rb
|
123
|
+
Analytic.configure do |config|
|
124
|
+
config.connects_to = { database: { writing: :primary, reading: :replica } }
|
125
|
+
end
|
126
|
+
```
|
127
|
+
|
128
|
+
### Overriding IP Masking Rules
|
129
|
+
|
130
|
+
By default IP addresses are masked as follows:
|
131
|
+
|
132
|
+
- IPv4: limit to leading 24-bits (e.g. '255.255.255.255' becomes '255.255.255.0')
|
133
|
+
- IPv6: limit to leading 48-bits (e.g. 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff' becomes 'ffff:ffff:ffff:0000:0000:0000:0000:0000')
|
134
|
+
|
135
|
+
To override both an `ip_v4_mask` and `ip_v6_mask` are assignable:
|
136
|
+
|
137
|
+
```ruby
|
138
|
+
Analytic.configure do |config|
|
139
|
+
config.ip_v4_mask = 24 # nil skips masking
|
140
|
+
config.ip_v6_mask = 48 # nil skips masking
|
141
|
+
```
|
142
|
+
|
56
143
|
## License
|
57
144
|
|
58
145
|
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
CHANGED
@@ -0,0 +1 @@
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512"><defs><linearGradient id="a" x1="0%" x2="100%" y1="100%" y2="0%"><stop offset="0%" stop-color="#06B6D4"/><stop offset="49.9%" stop-color="#6366F1"/><stop offset="100%" stop-color="#D946EF"/></linearGradient></defs><g fill="none" fill-rule="evenodd"><rect width="512" height="512" fill="url(#a)" rx="256"/><circle cx="256" cy="256" r="184" stroke="#FFF" stroke-width="48"/><rect width="48" height="128" x="232" y="210" fill="#FFF" rx="16"/><rect width="48" height="96" x="168" y="242" fill="#FFF" rx="16"/><rect width="48" height="164" x="296" y="174" fill="#FFF" rx="16"/></g></svg>
|
@@ -4,7 +4,7 @@ module Analytic
|
|
4
4
|
module Trackable
|
5
5
|
# @param params [Hash]
|
6
6
|
def analytic_track!
|
7
|
-
Analytic::
|
7
|
+
Analytic::Event.create!(
|
8
8
|
timestamp: Time.current,
|
9
9
|
session_id: analytic_session_id,
|
10
10
|
visitor_id: analytic_visitor_id,
|
@@ -73,7 +73,7 @@ module Analytic
|
|
73
73
|
|
74
74
|
# @return [Hash]
|
75
75
|
def analytic_params
|
76
|
-
params.
|
76
|
+
params.slice(*Analytic.config.params)
|
77
77
|
end
|
78
78
|
end
|
79
79
|
end
|
@@ -2,5 +2,47 @@
|
|
2
2
|
|
3
3
|
module Analytic
|
4
4
|
module ApplicationHelper
|
5
|
+
def react(component:)
|
6
|
+
tag.div(data: { react: component })
|
7
|
+
end
|
8
|
+
|
9
|
+
# @param icon [String] e.g. fa fa-user
|
10
|
+
def fa_icon_tag(icon)
|
11
|
+
tag.i(class: icon)
|
12
|
+
end
|
13
|
+
|
14
|
+
# @param delta [Float]
|
15
|
+
# @return [String]
|
16
|
+
def delta_icon_tag(delta)
|
17
|
+
if delta.zero?
|
18
|
+
fa_icon_tag('fa-solid fa-xmark')
|
19
|
+
elsif delta.positive?
|
20
|
+
fa_icon_tag('fa-solid fa-arrow-up')
|
21
|
+
elsif delta.negative?
|
22
|
+
fa_icon_tag('fa-solid fa-arrow-down')
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# @param delta [Float]
|
27
|
+
# @return [String]
|
28
|
+
def delta_percentage_tag(delta)
|
29
|
+
tag.span(number_to_percentage(delta.abs * 100.0, precision: 2), class: 'delta__value')
|
30
|
+
end
|
31
|
+
|
32
|
+
# @param delta [Float, nil]
|
33
|
+
def delta_tag(delta)
|
34
|
+
return if delta.nil?
|
35
|
+
|
36
|
+
modifier =
|
37
|
+
if delta.zero? then 'delta--neutral'
|
38
|
+
elsif delta.positive? then 'delta--positive'
|
39
|
+
elsif delta.negative? then 'delta--negative'
|
40
|
+
end
|
41
|
+
|
42
|
+
tag.div(class: "delta #{modifier}") do
|
43
|
+
concat(delta_icon_tag(delta))
|
44
|
+
concat(delta_percentage_tag(delta))
|
45
|
+
end
|
46
|
+
end
|
5
47
|
end
|
6
48
|
end
|
@@ -11,7 +11,7 @@ module Analytic
|
|
11
11
|
# @param path [String]
|
12
12
|
# @param params [Hash]
|
13
13
|
def perform(session_id:, visitor_id:, host:, path:, ip:, timestamp: Time.current, params: {})
|
14
|
-
Analytic::
|
14
|
+
Analytic::Event.create!(
|
15
15
|
timestamp:,
|
16
16
|
session_id:,
|
17
17
|
visitor_id:,
|
@@ -4,19 +4,40 @@ module Analytic
|
|
4
4
|
class Dashboard
|
5
5
|
PAGES_LIMIT = 8
|
6
6
|
|
7
|
+
# @return [String]
|
8
|
+
attr_reader :period
|
9
|
+
|
7
10
|
# @param period [String] today, yesterday, week, month, year
|
8
11
|
def initialize(period:)
|
9
12
|
@period = period
|
10
13
|
end
|
11
14
|
|
12
|
-
# @return [
|
13
|
-
|
15
|
+
# @return [Stat]
|
16
|
+
def views
|
17
|
+
Stat.new(
|
18
|
+
current: current.count,
|
19
|
+
prior: prior&.count,
|
20
|
+
name: 'Views'
|
21
|
+
)
|
22
|
+
end
|
14
23
|
|
15
|
-
# @return [
|
16
|
-
|
24
|
+
# @return [Stat]
|
25
|
+
def visitors
|
26
|
+
Stat.new(
|
27
|
+
current: current.distinct_visitors_count,
|
28
|
+
prior: prior&.distinct_visitors_count,
|
29
|
+
name: 'Visitors'
|
30
|
+
)
|
31
|
+
end
|
17
32
|
|
18
|
-
# @return [
|
19
|
-
|
33
|
+
# @return [Stat]
|
34
|
+
def sessions
|
35
|
+
Stat.new(
|
36
|
+
current: current.distinct_sessions_count,
|
37
|
+
prior: prior&.distinct_sessions_count,
|
38
|
+
name: 'Sessions'
|
39
|
+
)
|
40
|
+
end
|
20
41
|
|
21
42
|
# @return [String]
|
22
43
|
def name
|
@@ -27,39 +48,50 @@ module Analytic
|
|
27
48
|
end
|
28
49
|
end
|
29
50
|
|
30
|
-
# @return [
|
31
|
-
def
|
32
|
-
|
33
|
-
.group(:host)
|
34
|
-
.group(:path)
|
35
|
-
.order(count: :desc)
|
36
|
-
.limit(PAGES_LIMIT)
|
37
|
-
.pluck(:host, :path, Arel.sql('COUNT(*) AS count'))
|
51
|
+
# @return [Period]
|
52
|
+
def current
|
53
|
+
@current ||= Period.new(range:)
|
38
54
|
end
|
39
55
|
|
40
|
-
|
56
|
+
# @return [Period]
|
57
|
+
def prior
|
58
|
+
return unless range?
|
59
|
+
return @prior if defined?(@prior)
|
41
60
|
|
42
|
-
|
43
|
-
|
44
|
-
|
61
|
+
@prior ||= Period.new(range: ((range.min - duration)..(range.max - duration)))
|
62
|
+
end
|
63
|
+
|
64
|
+
# @return [Boolean]
|
65
|
+
def range?
|
66
|
+
@period.present?
|
45
67
|
end
|
46
68
|
|
47
69
|
# @return [Range<Time>]
|
48
70
|
def range
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
71
|
+
(now - duration)..now if range?
|
72
|
+
end
|
73
|
+
|
74
|
+
# @return [Interval]
|
75
|
+
def duration
|
76
|
+
@duration ||=
|
77
|
+
case @period
|
78
|
+
when '24h' then 24.hours
|
79
|
+
when '7d' then 7.days
|
80
|
+
when '4w' then 4.weeks
|
81
|
+
when '12m' then 12.months
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
protected
|
86
|
+
|
87
|
+
# @return [Time]
|
88
|
+
def now
|
89
|
+
@now ||= time_zone.now
|
58
90
|
end
|
59
91
|
|
60
92
|
# @return [ActiveSupport::TimeZone]
|
61
|
-
def
|
62
|
-
Analytic.config.
|
93
|
+
def time_zone
|
94
|
+
@time_zone ||= Analytic.config.time_zone || Time.zone
|
63
95
|
end
|
64
96
|
end
|
65
97
|
end
|
@@ -1,7 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module Analytic
|
4
|
-
class
|
4
|
+
class Event < ApplicationRecord
|
5
5
|
validates :timestamp, presence: true
|
6
6
|
validates :visitor_id, presence: true
|
7
7
|
validates :session_id, presence: true
|
@@ -13,12 +13,12 @@ module Analytic
|
|
13
13
|
|
14
14
|
# @return [Integer]
|
15
15
|
def self.distinct_visitors_count
|
16
|
-
count('DISTINCT "
|
16
|
+
count('DISTINCT "analytic_events"."visitor_id"')
|
17
17
|
end
|
18
18
|
|
19
19
|
# @return [Integer]
|
20
20
|
def self.distinct_sessions_count
|
21
|
-
count('DISTINCT "
|
21
|
+
count('DISTINCT "analytic_events"."session_id"')
|
22
22
|
end
|
23
23
|
end
|
24
24
|
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Analytic
|
4
|
+
class Period
|
5
|
+
PAGES_LIMIT = 8
|
6
|
+
|
7
|
+
# @return [Range <Time>]
|
8
|
+
attr_accessor :range
|
9
|
+
|
10
|
+
# @param dates [Range<Time>]
|
11
|
+
def initialize(range:)
|
12
|
+
@range = range
|
13
|
+
end
|
14
|
+
|
15
|
+
# @return [Integer]
|
16
|
+
delegate :count, to: :views
|
17
|
+
|
18
|
+
# @return [Integer]
|
19
|
+
delegate :distinct_visitors_count, to: :views
|
20
|
+
|
21
|
+
# @return [Integer]
|
22
|
+
delegate :distinct_sessions_count, to: :views
|
23
|
+
|
24
|
+
# @return [Array<Array(String, String, Integer)>]
|
25
|
+
def pages
|
26
|
+
views
|
27
|
+
.group(:host)
|
28
|
+
.group(:path)
|
29
|
+
.order(count: :desc)
|
30
|
+
.limit(PAGES_LIMIT)
|
31
|
+
.pluck(:host, :path, Arel.sql('COUNT(*) AS count'))
|
32
|
+
end
|
33
|
+
|
34
|
+
protected
|
35
|
+
|
36
|
+
# @return [Analytic::Event::ActiveRecord_Relation]
|
37
|
+
def views
|
38
|
+
@views ||= Analytic::Event.within(@range)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Analytic
|
4
|
+
class Stat
|
5
|
+
# @param current [Integer]
|
6
|
+
# @param prior [Integer]
|
7
|
+
# @param name [String]
|
8
|
+
def initialize(current:, prior:, name:)
|
9
|
+
@current = current
|
10
|
+
@prior = prior
|
11
|
+
@name = name
|
12
|
+
end
|
13
|
+
|
14
|
+
# @return [Integer]
|
15
|
+
def count
|
16
|
+
@current
|
17
|
+
end
|
18
|
+
|
19
|
+
# e.g. prior = 4 / current = 5 / delta = + 0.25
|
20
|
+
# e.g. prior = 4 / current = 3 / delta = - 0.25
|
21
|
+
#
|
22
|
+
# @return [Float, nil]
|
23
|
+
def delta
|
24
|
+
return if @prior.nil?
|
25
|
+
return if @prior.zero?
|
26
|
+
|
27
|
+
(@current - @prior).fdiv(@prior)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,148 @@
|
|
1
|
+
import { useMemo, type FC } from "react";
|
2
|
+
|
3
|
+
import { Group } from "@visx/group";
|
4
|
+
import { Bar } from "@visx/shape";
|
5
|
+
import { scaleBand, scaleLinear } from "@visx/scale";
|
6
|
+
import { AxisBottom, AxisLeft } from "@visx/axis";
|
7
|
+
import { GridColumns, GridRows } from "@visx/grid";
|
8
|
+
import { ParentSize } from "@visx/responsive";
|
9
|
+
|
10
|
+
const BAR_BG = "#6366f1"; // indigo-500
|
11
|
+
const CHART_BG = "#f8fafc"; // slate-50
|
12
|
+
const GRID_COLOR = "#e2e8f0"; // slate-200
|
13
|
+
const AXIS_TICK_COLOR = GRID_COLOR;
|
14
|
+
const AXIS_LINE_COLOR = GRID_COLOR;
|
15
|
+
|
16
|
+
const CHART_ML = 60; // px
|
17
|
+
const CHART_MB = 60; // px
|
18
|
+
const CHART_MT = 40; // px
|
19
|
+
const CHART_MR = 40; // px
|
20
|
+
|
21
|
+
const TICK_LABEL_PROPS = {
|
22
|
+
fill: "#718096", // slate-600
|
23
|
+
fontSize: 10,
|
24
|
+
fontFamily: `"ui-sans-serif", "system-ui", "sans-serif"`,
|
25
|
+
} as const;
|
26
|
+
|
27
|
+
type Entry = {
|
28
|
+
label: string;
|
29
|
+
value: number;
|
30
|
+
};
|
31
|
+
|
32
|
+
const ENTRIES: Entry[] = [
|
33
|
+
{ label: "Jan, 2024", value: 404 },
|
34
|
+
{ label: "Feb, 2024", value: 303 },
|
35
|
+
{ label: "Mar, 2024", value: 250 },
|
36
|
+
{ label: "Apr, 2024", value: 304 },
|
37
|
+
{ label: "May, 2024", value: 404 },
|
38
|
+
{ label: "Jun, 2024", value: 604 },
|
39
|
+
];
|
40
|
+
|
41
|
+
const Bars: FC<{
|
42
|
+
width: number;
|
43
|
+
height: number;
|
44
|
+
entries: Entry[];
|
45
|
+
}> = ({ width, height, entries }) => {
|
46
|
+
const xScale = useMemo(
|
47
|
+
() =>
|
48
|
+
scaleBand<string>({
|
49
|
+
range: [0, width],
|
50
|
+
round: true,
|
51
|
+
domain: entries.map(({ label }) => label),
|
52
|
+
padding: 0.4,
|
53
|
+
}),
|
54
|
+
[width]
|
55
|
+
);
|
56
|
+
const yScale = useMemo(
|
57
|
+
() =>
|
58
|
+
scaleLinear<number>({
|
59
|
+
range: [height, 0],
|
60
|
+
round: true,
|
61
|
+
domain: [0, Math.max(...entries.map(({ value }) => value))],
|
62
|
+
}),
|
63
|
+
[height]
|
64
|
+
);
|
65
|
+
|
66
|
+
return (
|
67
|
+
<>
|
68
|
+
<GridRows
|
69
|
+
scale={yScale}
|
70
|
+
width={width}
|
71
|
+
height={height}
|
72
|
+
stroke={GRID_COLOR}
|
73
|
+
/>
|
74
|
+
|
75
|
+
<GridColumns
|
76
|
+
scale={xScale}
|
77
|
+
width={width}
|
78
|
+
height={height}
|
79
|
+
stroke={GRID_COLOR}
|
80
|
+
/>
|
81
|
+
|
82
|
+
{entries.map((entry, index) => {
|
83
|
+
const barWidth = xScale.bandwidth();
|
84
|
+
const barHeight = height - yScale(entry.value);
|
85
|
+
const barX = xScale(entry.label);
|
86
|
+
const barY = height - barHeight;
|
87
|
+
|
88
|
+
return (
|
89
|
+
<Bar
|
90
|
+
key={index}
|
91
|
+
x={barX}
|
92
|
+
y={barY}
|
93
|
+
rx={8}
|
94
|
+
ry={8}
|
95
|
+
width={barWidth}
|
96
|
+
height={barHeight}
|
97
|
+
fill={BAR_BG}
|
98
|
+
/>
|
99
|
+
);
|
100
|
+
})}
|
101
|
+
|
102
|
+
<AxisBottom
|
103
|
+
top={height}
|
104
|
+
scale={xScale}
|
105
|
+
stroke={AXIS_LINE_COLOR}
|
106
|
+
tickStroke={AXIS_TICK_COLOR}
|
107
|
+
tickLabelProps={TICK_LABEL_PROPS}
|
108
|
+
/>
|
109
|
+
|
110
|
+
<AxisLeft
|
111
|
+
left={0}
|
112
|
+
scale={yScale}
|
113
|
+
stroke={AXIS_LINE_COLOR}
|
114
|
+
tickStroke={AXIS_TICK_COLOR}
|
115
|
+
tickLabelProps={TICK_LABEL_PROPS}
|
116
|
+
/>
|
117
|
+
</>
|
118
|
+
);
|
119
|
+
};
|
120
|
+
|
121
|
+
export const Chart: FC<{
|
122
|
+
height?: number;
|
123
|
+
entries?: Entry[];
|
124
|
+
}> = ({ height = 300, entries = ENTRIES }) => (
|
125
|
+
<ParentSize>
|
126
|
+
{({ width }) => (
|
127
|
+
<svg width={width} height={height}>
|
128
|
+
<rect
|
129
|
+
x={0}
|
130
|
+
y={0}
|
131
|
+
rx={4}
|
132
|
+
ry={4}
|
133
|
+
width={width}
|
134
|
+
height={height}
|
135
|
+
fill={CHART_BG}
|
136
|
+
/>
|
137
|
+
|
138
|
+
<Group top={CHART_MT} left={CHART_ML}>
|
139
|
+
<Bars
|
140
|
+
width={width - CHART_MR - CHART_ML}
|
141
|
+
height={height - CHART_MT - CHART_MB}
|
142
|
+
entries={entries}
|
143
|
+
/>
|
144
|
+
</Group>
|
145
|
+
</svg>
|
146
|
+
)}
|
147
|
+
</ParentSize>
|
148
|
+
);
|
@@ -0,0 +1,21 @@
|
|
1
|
+
import { type FC } from "react";
|
2
|
+
|
3
|
+
import { Chart } from "./chart";
|
4
|
+
import { createRoot } from "react-dom/client";
|
5
|
+
|
6
|
+
const COMPONENTS: Record<string, FC> = {
|
7
|
+
Chart,
|
8
|
+
};
|
9
|
+
|
10
|
+
document.addEventListener("DOMContentLoaded", () => {
|
11
|
+
const elements = document.querySelectorAll("[data-react]");
|
12
|
+
for (const element of elements) {
|
13
|
+
const root = createRoot(element);
|
14
|
+
|
15
|
+
const name = element.getAttribute("data-react");
|
16
|
+
|
17
|
+
const Component = COMPONENTS[name!];
|
18
|
+
|
19
|
+
root.render(<Component />);
|
20
|
+
}
|
21
|
+
});
|
@@ -10,14 +10,20 @@ import { faGlobe } from "@fortawesome/free-solid-svg-icons/faGlobe";
|
|
10
10
|
import { faHouse } from "@fortawesome/free-solid-svg-icons/faHouse";
|
11
11
|
import { faSliders } from "@fortawesome/free-solid-svg-icons/faSliders";
|
12
12
|
import { faUsers } from "@fortawesome/free-solid-svg-icons/faUsers";
|
13
|
+
import { faArrowUp } from "@fortawesome/free-solid-svg-icons/faArrowUp";
|
14
|
+
import { faArrowDown } from "@fortawesome/free-solid-svg-icons/faArrowDown";
|
15
|
+
import { faXmark } from "@fortawesome/free-solid-svg-icons/faXmark";
|
13
16
|
|
14
17
|
library.add(
|
18
|
+
faArrowDown,
|
19
|
+
faArrowUp,
|
15
20
|
faClock,
|
16
21
|
faEye,
|
22
|
+
faFileCode,
|
17
23
|
faGauge,
|
18
24
|
faGlobe,
|
19
25
|
faHouse,
|
20
26
|
faSliders,
|
21
27
|
faUsers,
|
22
|
-
|
28
|
+
faXmark
|
23
29
|
);
|