analytics_plane 1.0.2

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 (50) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +11 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +8 -0
  5. data/CHANGELOG.md +5 -0
  6. data/CODE_OF_CONDUCT.md +132 -0
  7. data/Gemfile +13 -0
  8. data/Gemfile.lock +271 -0
  9. data/LICENSE.txt +21 -0
  10. data/README.md +468 -0
  11. data/Rakefile +12 -0
  12. data/analytics_plane.gemspec +31 -0
  13. data/bin/console +11 -0
  14. data/bin/setup +8 -0
  15. data/lib/analytics_plane/adapters/active_record/chart_query.rb +106 -0
  16. data/lib/analytics_plane/adapters/active_record/report_query.rb +88 -0
  17. data/lib/analytics_plane/adapters/active_record_adapter.rb +39 -0
  18. data/lib/analytics_plane/adapters/base.rb +21 -0
  19. data/lib/analytics_plane/adapters/registry.rb +23 -0
  20. data/lib/analytics_plane/builders/chart_builder.rb +65 -0
  21. data/lib/analytics_plane/builders/report_builder.rb +50 -0
  22. data/lib/analytics_plane/data_sources/base.rb +13 -0
  23. data/lib/analytics_plane/data_sources/registry.rb +24 -0
  24. data/lib/analytics_plane/railtie.rb +10 -0
  25. data/lib/analytics_plane/registrar.rb +15 -0
  26. data/lib/analytics_plane/services/dataset_fetcher.rb +17 -0
  27. data/lib/analytics_plane/version.rb +5 -0
  28. data/lib/analytics_plane.rb +23 -0
  29. data/lib/generators/fios/chart/chart_generator.rb +34 -0
  30. data/lib/generators/fios/dashboard/dashboard_generator.rb +41 -0
  31. data/lib/generators/fios/data_source/data_source_generator.rb +16 -0
  32. data/lib/generators/fios/dataset/dataset_generator.rb +34 -0
  33. data/lib/generators/fios/install/install_generator.rb +15 -0
  34. data/lib/generators/fios/report/report_generator.rb +34 -0
  35. data/lib/generators/fios/templates/analytics_plane_initializer.rb +6 -0
  36. data/lib/generators/fios/templates/chart_model.rb +5 -0
  37. data/lib/generators/fios/templates/create_charts.rb +9 -0
  38. data/lib/generators/fios/templates/create_dashboard_widgets.rb +9 -0
  39. data/lib/generators/fios/templates/create_dashboards.rb +8 -0
  40. data/lib/generators/fios/templates/create_datasets.rb +11 -0
  41. data/lib/generators/fios/templates/create_reports.rb +9 -0
  42. data/lib/generators/fios/templates/dashboard_model.rb +7 -0
  43. data/lib/generators/fios/templates/dashboard_widget_model.rb +5 -0
  44. data/lib/generators/fios/templates/data_source.rb +7 -0
  45. data/lib/generators/fios/templates/dataset_model.rb +5 -0
  46. data/lib/generators/fios/templates/report_model.rb +5 -0
  47. data/sig/analytics_plane.rbs +4 -0
  48. data/spec/analytics_plane_spec.rb +7 -0
  49. data/spec/spec_helper.rb +15 -0
  50. metadata +112 -0
data/README.md ADDED
@@ -0,0 +1,468 @@
1
+ # AnalyticsPlane
2
+
3
+ AnalyticsPlane is an opinionated analytics framework for Ruby on Rails applications.
4
+
5
+ It provides a structured, explicit way to model analytics concerns — datasets, data sources, adapters, charts, reports, and dashboards — as first-class, persisted application concepts.
6
+
7
+ The goal is not to make analytics “easy”, but to make analytics code **understandable, testable, and changeable** as applications and business questions evolve.
8
+
9
+ By separating *what data exists*, *where it comes from*, and *how it is queried and presented*, AnalyticsPlane gives analytics logic a clear home outside controllers and one-off services, without tying it to a specific ORM, database, or frontend.
10
+
11
+
12
+ ## Features
13
+
14
+ - 📊 Persisted datasets with explicit metadata
15
+ - 🔌 Adapters to control how data is fetched (ActiveRecord, SQL, APIs, etc.)
16
+ - 📈 Chart and report builders that produce frontend-agnostic data
17
+ - 🧠 Registry-based architecture (explicit, inspectable, no magic)
18
+ - ⚙️ Rails generators to establish structure quickly
19
+ - ♻️ Works with or without ActiveRecord models
20
+
21
+
22
+ ## Installation
23
+
24
+ Add the gem to your Gemfile:
25
+ ```
26
+ gem "analytics_plane"
27
+ ```
28
+
29
+ Then install:
30
+ ```
31
+ bundle install
32
+ ```
33
+
34
+
35
+ ## Getting Started
36
+
37
+ ### 1. Run the installer
38
+ ```
39
+ bin/rails generate analytics_plane:install
40
+ ```
41
+
42
+ This will:
43
+ * Add a AnalyticsPlane initializer
44
+ * Set up registry hooks using config.to_prepare
45
+
46
+ ### 2. Generate core models
47
+
48
+ #### Chart
49
+ ```
50
+ bin/rails generate analytics_plane:chart Chart
51
+ ```
52
+
53
+ Creates:
54
+ * Chart model
55
+ * Migration
56
+
57
+ #### Report
58
+ ```
59
+ bin/rails generate analytics_plane:report Report
60
+ ```
61
+
62
+ Creates:
63
+ * Report model
64
+ * Migration
65
+
66
+ #### Dashboard
67
+ ```
68
+ bin/rails generate analytics_plane:dashboard Dashboard
69
+ ```
70
+
71
+ Creates:
72
+ * Dashboard model
73
+ * DashboardWidget model
74
+ * Migrations
75
+
76
+ #### Dataset
77
+ ```
78
+ bin/rails generate analytics_plane:dataset Dataset
79
+ ```
80
+
81
+ Creates:
82
+ * Dataset model
83
+ * Migration
84
+
85
+ ### 3. Generate a Data Source
86
+ ```
87
+ bin/rails generate analytics_plane:data_source EmployeeReport
88
+ ```
89
+
90
+ This creates:
91
+
92
+ ```
93
+ # app/datasets/employee_report.rb
94
+ class EmployeeReport
95
+ include AnalyticsPlane::DataSources::Base
96
+
97
+ def self.dataset_key
98
+ :employee_report
99
+ end
100
+ end
101
+ ```
102
+
103
+
104
+ ## Minimal End-to-End Example
105
+
106
+ This example shows the complete flow from a persisted Dataset to chart-ready data.
107
+
108
+ ### 1. Dataset Record
109
+
110
+ Create a Dataset record:
111
+
112
+ ```
113
+ Dataset.create!(
114
+ slug: "employee_report",
115
+ name: "Employee Report",
116
+ description: "Employees grouped by department",
117
+ adapter: "active_record"
118
+ )
119
+ ```
120
+
121
+ ### 2. Data Source
122
+
123
+ Define where the data comes from:
124
+
125
+ ```
126
+ # app/datasets/employee_report.rb
127
+ class EmployeeReport
128
+ include AnalyticsPlane::DataSources::Base
129
+
130
+ def self.dataset_key
131
+ :employee_report
132
+ end
133
+
134
+ def self.all
135
+ Employee.all
136
+ end
137
+ end
138
+ ```
139
+
140
+ This data source does not need to be an ActiveRecord model.
141
+
142
+ ### 3. Register the Data Source and Adapter
143
+
144
+ Register your Data Source:
145
+
146
+ ```
147
+ # config/initializers/analytics_plane.rb
148
+ Rails.application.config.to_prepare do
149
+ AnalyticsPlane::Registrar.register do
150
+ adapter AnalyticsPlane::Adapters::ActiveRecordAdapter
151
+ data_source EmployeeReport
152
+ end
153
+ end
154
+ ```
155
+
156
+ ### 4. Create a Chart
157
+
158
+ ```
159
+ chart = Chart.create!(
160
+ name: "Employees by Department",
161
+ configuration: {
162
+ dataset_id: Dataset.find_by!(slug: "employee_report").id,
163
+ chart_type: "column",
164
+ x_axis: { attr: "department", label: "Department" },
165
+ y_axes: [
166
+ { attr: "id", label: "Employees", aggregation: "count" }
167
+ ],
168
+ filters: [
169
+ { attr: "job", type: "string", operator: "=", value: "Clerk" }
170
+ ]
171
+ }
172
+ )
173
+ ```
174
+
175
+ ### 5. Fetch Chart Data
176
+
177
+ ChartBuilder.build returns a frontend-agnostic hash shaped for charting libraries.
178
+
179
+ ```
180
+ data = AnalyticsPlane::Builders::ChartBuilder.build(chart)
181
+ ```
182
+
183
+ Result:
184
+ ```
185
+ {
186
+ 'chart': {
187
+ 'type': 'column'
188
+ },
189
+
190
+ 'title': {
191
+ 'text': 'Employees by Department'
192
+ },
193
+
194
+ 'xAxis': {
195
+ 'categories': ['Engineering', 'Sales', 'HR']
196
+ },
197
+
198
+ 'yAxis': {
199
+ 'title': {
200
+ 'text': nil
201
+ }
202
+ },
203
+
204
+ 'series': [{ name: "Employees", data: [12, 8, 5] }]
205
+ }
206
+ ```
207
+
208
+ This data can be passed directly to a frontend charting library such as Highcharts.
209
+
210
+ ### 6. Create a Report
211
+
212
+ ```
213
+ report = Report.create!(
214
+ name: "Employees by Department",
215
+ configuration: {
216
+ dataset_id: Dataset.find_by!(slug: "employee_report").id,
217
+ aggregated: true,
218
+ columns: [
219
+ { name: "department", type: "string", selected: true, group_by: true, count: false, count_distinct: false, average: false, max: false, min: false, sum: false },
220
+ { name: "id", type: "string", selected: true, group_by: false, count: true, count_distinct: false, average: false, max: false, min: false, sum: false }
221
+ ],
222
+ filters: [
223
+ { name: "job", type: "string", operator: "=", value: "Clerk" }
224
+ ]
225
+ }
226
+ )
227
+ ```
228
+
229
+ ### 7. Fetch Report Data
230
+
231
+ ReportBuilder.build returns a frontend-agnostic array shaped for tabular reporting.
232
+
233
+ ```
234
+ data = AnalyticsPlane::Builders::ReportBuilder.build(report)
235
+ ```
236
+
237
+
238
+ ## Core Concepts
239
+
240
+ ### Dataset (Persisted)
241
+
242
+ A Dataset is a persisted record that describes a data source available to the application.
243
+
244
+ Schema:
245
+ ```
246
+ t.string :slug
247
+ t.string :name
248
+ t.text :description
249
+ t.string :adapter
250
+ ```
251
+
252
+ Responsibilities:
253
+ * Identifies the Data Source via slug.
254
+ * Declares which adapter should be used.
255
+ * Acts as the stable reference point for Charts and Reports.
256
+ * A Dataset must always exist in the database.
257
+
258
+ ### Data Sources
259
+
260
+ A Data Source is a Ruby class that defines how a Dataset’s data is retrieved.
261
+ * May be an ActiveRecord model, a database view, or a plain Ruby class
262
+ * Must define a dataset_key
263
+ * Is looked up using Dataset.slug
264
+
265
+ ```
266
+ class EmployeeReport
267
+ include AnalyticsPlane::DataSources::Base
268
+
269
+ def self.dataset_key
270
+ :employee_report
271
+ end
272
+ end
273
+ ```
274
+
275
+ Each Data Source has exactly one corresponding Dataset record.
276
+
277
+ Instantiation of Data Sources (if any) is controlled entirely by the Adapter.
278
+
279
+ ### Adapters
280
+
281
+ Adapters define how data is fetched and shaped from a Data Source.
282
+
283
+ Examples:
284
+ * ActiveRecord
285
+ * Raw SQL
286
+ * External APIs
287
+ * In-memory or computed datasets
288
+
289
+ ```
290
+ class ActiveRecordAdapter
291
+ include AnalyticsPlane::Adapters::Base
292
+
293
+ def self.adapter_key
294
+ :active_record
295
+ end
296
+
297
+ def self.fetch_chart_data(data_source, chart)
298
+ # returns chart-ready data
299
+ end
300
+
301
+ def self.fetch_report_data(data_source, report)
302
+ # returns tabular data
303
+ end
304
+ end
305
+ ```
306
+
307
+ Adapters are responsible for:
308
+ * Querying
309
+ * Aggregation
310
+ * Filtering
311
+ * Formatting output
312
+
313
+ ### Charts
314
+
315
+ Charts:
316
+ * Reference a Dataset
317
+ * Store filters and chart configuration in configuration
318
+ * Produce chart-ready data (series, categories, metadata)
319
+
320
+ ### Reports
321
+
322
+ Reports:
323
+ * Reference a Dataset
324
+ * Store filters and column configuration in configuration
325
+ * Produce tabular data
326
+
327
+ ### Registries
328
+
329
+ AnalyticsPlane uses registries instead of global constants.
330
+
331
+ ```
332
+ AnalyticsPlane::DataSources::Registry.data_sources
333
+ # => { employee_report: EmployeeReport }
334
+
335
+ AnalyticsPlane::Adapters::Registry.adapters
336
+ # => { active_record: ActiveRecordAdapter }
337
+ ```
338
+
339
+ Benefits:
340
+ * Explicit registration
341
+ * Inspectable state
342
+ * Predictable behavior
343
+ * No implicit class loading
344
+
345
+ ### Initializer
346
+
347
+ All Datasets and Adapters are registered explicitly:
348
+
349
+ ```
350
+ # config/initializers/analytics_plane.rb
351
+ Rails.application.config.to_prepare do
352
+ AnalyticsPlane::Registrar.register do
353
+ adapter AnalyticsPlane::Adapters::ActiveRecordAdapter
354
+ data_source EmployeeReport
355
+ end
356
+ end
357
+ ```
358
+
359
+ This works correctly in:
360
+ * development (reloadable)
361
+ * test
362
+ * production
363
+
364
+ No eager loading required.
365
+
366
+
367
+ ## Architecture Philosophy
368
+
369
+ AnalyticsPlane is built around a few guiding principles:
370
+ * Analytics logic should live outside controllers
371
+ * Datasets should be explicit and persisted
372
+ * Data Sources describe where data comes from
373
+ * Adapters describe how data is fetched
374
+ * Registration should be opt-in and predictable
375
+ * Frameworks should clarify behavior, not hide it
376
+
377
+
378
+ ## Why AnalyticsPlane Exists
379
+
380
+ Analytics code in Rails applications often grows organically:
381
+ * Queries live in controllers or services
382
+ * Charts embed SQL or ActiveRecord logic
383
+ * Reports duplicate filtering and aggregation logic
384
+ * Business rules become scattered and hard to reason about
385
+
386
+ Over time, analytics becomes:
387
+ * difficult to test
388
+ * difficult to extend
389
+ * risky to change
390
+
391
+ AnalyticsPlane exists to solve this problem by making analytics a first-class, persisted concern.
392
+
393
+ Instead of hiding complexity, AnalyticsPlane makes analytics code:
394
+ * explicit
395
+ * structured
396
+ * inspectable
397
+
398
+ AnalyticsPlane is designed to make analytics code easier to change as business questions evolve, not just easier to write the first time.
399
+
400
+ ### What AnalyticsPlane Does Differently
401
+
402
+ AnalyticsPlane separates analytics into clear responsibilities:
403
+ * Datasets describe what data exists (persisted metadata)
404
+ * Data Sources describe where data comes from
405
+ * Adapters describe how data is fetched and shaped
406
+ * Charts and Reports describe how data is queried and presented
407
+
408
+ This separation:
409
+ * avoids tight coupling to ActiveRecord
410
+ * supports multiple data sources
411
+ * keeps analytics logic out of controllers
412
+ * encourages reuse instead of duplication
413
+
414
+ ### Practical Consequences of This Design
415
+
416
+ Because analytics concepts in AnalyticsPlane are explicit and persisted, this enables capabilities that are difficult to achieve with ad-hoc analytics code:
417
+
418
+ * Charts, reports, and dashboards can be seeded, versioned, or migrated between environments or even between applications, instead of being hard-coded.
419
+
420
+ * AnalyticsPlane can act as a stable backend for custom chart, report, or dashboard builders, with configuration stored as data rather than Ruby code.
421
+
422
+ * Developers can reference their own data sources — ActiveRecord models, POROs, database views, or external APIs — without changing how analytics are defined or consumed.
423
+
424
+ These capabilities are a direct result of treating analytics as structured application data rather than incidental code.
425
+
426
+ ### Who AnalyticsPlane Is For
427
+
428
+ AnalyticsPlane is designed for teams that:
429
+ * build internal tools or data-heavy applications
430
+ * need dashboards and reports backed by real business logic
431
+ * want to expose analytics configuration via admin UIs or builders
432
+ * care about maintainability more than “quick charts”
433
+ * want analytics code that survives beyond the first version
434
+
435
+ AnalyticsPlane is not a BI tool, a charting library, or a UI framework.
436
+
437
+ It is the analytics layer that sits between your application data and whatever frontend or visualization tool you choose.
438
+
439
+ ### What AnalyticsPlane Is Not
440
+
441
+ AnalyticsPlane is intentionally not:
442
+ * a BI tool
443
+ * a charting or visualization library
444
+ * a drag-and-drop dashboard builder
445
+ * a replacement for SQL or data warehouses
446
+
447
+ AnalyticsPlane focuses on structuring analytics logic inside Rails applications,
448
+ not on visualisation or end-user reporting UX.
449
+
450
+ ### Philosophy
451
+
452
+ AnalyticsPlane is built on a few core beliefs:
453
+ * Analytics deserves the same structure as the rest of your application
454
+ * Explicit registration is better than magic loading
455
+ * Query logic should be testable Ruby code
456
+ * Frameworks should help you understand your system, not obscure it
457
+
458
+ If your analytics logic is becoming hard to reason about, AnalyticsPlane gives it a home.
459
+
460
+
461
+ ## License
462
+
463
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
464
+
465
+
466
+ ## Code of Conduct
467
+
468
+ Everyone interacting in the AnalyticsPlane project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/CodeTectonics/analytics_plane/blob/master/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/analytics_plane/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "analytics_plane"
7
+ spec.version = AnalyticsPlane::VERSION
8
+ spec.authors = ["Mark Harbison"]
9
+ spec.email = ["mark@tyne-solutions.com"]
10
+
11
+ spec.summary = "A data analytics framework for building charts, dashboards, and reports in Ruby."
12
+ spec.description = "A data analytics framework for building charts, dashboards, and reports in Ruby."
13
+ spec.homepage = "https://github.com/CodeTectonics/analytics_plane"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 3.2.0"
16
+
17
+ spec.metadata["homepage_uri"] = spec.homepage
18
+ spec.metadata["source_code_uri"] = spec.homepage
19
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
20
+
21
+ spec.files = `git ls-files`.split("\n")
22
+ spec.test_files = `git ls-files -- spec/*`.split("\n")
23
+ spec.require_paths = ["lib"]
24
+
25
+ # Uncomment to register a new dependency of your gem
26
+ # spec.add_dependency "example-gem", "~> 1.0"
27
+ spec.add_dependency "rails", ">= 6.1"
28
+
29
+ # For more information and examples about making a new gem, check out our
30
+ # guide at: https://bundler.io/guides/creating_gem.html
31
+ end
data/bin/console ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "analytics_plane"
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ require "irb"
11
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,106 @@
1
+ module AnalyticsPlane
2
+ module Adapters
3
+ module ActiveRecord
4
+ class ChartQuery
5
+ def self.add_select_clause(query, chart_config)
6
+ fields = []
7
+ fields << chart_config['x_axis']['attr']
8
+
9
+ chart_config['y_axes'].each do |column|
10
+ aggregate = aggregated_attr(column['attr'], column['aggregation'])
11
+ fields << "#{aggregate[0]} AS '#{aggregate[1]}'"
12
+ end
13
+
14
+ query.select(fields)
15
+ end
16
+
17
+ def self.aggregated_attr(attr, aggregation)
18
+ case aggregation
19
+ when 'count'
20
+ ["COUNT(#{attr})", "num_#{attr}"]
21
+ when 'count_distinct'
22
+ ["COUNT(DISTINCT #{attr})", "num_uniq_#{attr}"]
23
+ when 'average'
24
+ ["AVG(#{attr})", "avg_#{attr}"]
25
+ when 'min'
26
+ ["MIN(#{attr})", "min_#{attr}"]
27
+ when 'max'
28
+ ["MAX(#{attr})", "max_#{attr}"]
29
+ when 'sum'
30
+ ["SUM(#{attr})", "sum_#{attr}"]
31
+ else
32
+ attr
33
+ end
34
+ end
35
+
36
+ def self.add_where_clause(query, chart_config)
37
+ return query if chart_config['filters'].blank?
38
+
39
+ chart_config['filters'].each do |filter|
40
+ field = filter['attr']
41
+ operator = filter['operator']
42
+ value = filter['value']
43
+
44
+ case operator
45
+ when '='
46
+ query = query.where(field => value)
47
+ when '!='
48
+ query = query.where.not(field => value)
49
+ when '>'
50
+ query = query.where("#{field} > ?", value)
51
+ when '>='
52
+ query = query.where("#{field} >= ?", value)
53
+ when '<'
54
+ query = query.where("#{field} < ?", value)
55
+ when '<='
56
+ query = query.where("#{field} <= ?", value)
57
+ when 'contains'
58
+ query = query.where("#{field} LIKE ?", "%#{value}%")
59
+ when 'starts_with'
60
+ query = query.where("#{field} LIKE ?", "#{value}%")
61
+ when 'ends_with'
62
+ query = query.where("#{field} LIKE ?", "%#{value}")
63
+ when 'one_of'
64
+ query = query.where("#{field} IN (?)", value)
65
+ when 'not_one_of'
66
+ query = query.where.not("#{field} IN (?)", value)
67
+ end
68
+ end
69
+
70
+ query
71
+ end
72
+
73
+ def self.add_group_clause(query, chart_config)
74
+ group_field = chart_config['x_axis']['attr']
75
+ query.group(group_field)
76
+ end
77
+
78
+ def self.parse_series_data(data, chart_config)
79
+ return [] if data.empty?
80
+
81
+ attrs = data[0].attribute_names
82
+ attrs.delete(chart_config['x_axis']['attr'])
83
+
84
+ attrs.map do |attr|
85
+ y_axis = chart_config['y_axes'].find do |col|
86
+ attr == aggregated_attr(col['attr'], col['aggregation'])[1]
87
+ end
88
+
89
+ { name: y_axis['label'], data: data.pluck(attr) }
90
+ end
91
+ end
92
+
93
+ def self.parse_category_data(data, chart_config, data_source)
94
+ x_axis_attr = chart_config['x_axis']['attr']
95
+
96
+ translatable = data_source.translated_columns.include?(x_axis_attr.to_sym)
97
+ data.map do |row|
98
+ return row[x_axis_attr] unless translatable
99
+
100
+ I18n.t(row[x_axis_attr], default: row[x_axis_attr])
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end