analytic 0.3.0 → 0.5.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
+
![Demo](https://raw.githubusercontent.com/ksylvest/analytic/refs/heads/main/demo.png)
|
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
|
);
|