railsui_charts 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/CHANGELOG.md +50 -0
- data/LICENSE.md +21 -0
- data/README.md +540 -0
- data/Rakefile +34 -0
- data/app/assets/stylesheets/railsui_charts.css +710 -0
- data/app/controllers/railsui_charts/demo_controller.rb +27 -0
- data/app/javascript/controllers/railsui_chart_controller.js +438 -0
- data/app/javascript/controllers/railsui_chart_filters_controller.js +14 -0
- data/app/javascript/controllers/railsui_metric_dialog_controller.js +27 -0
- data/app/views/railsui_charts/demo/index.html.erb +45 -0
- data/config/routes.rb +4 -0
- data/lib/generators/railsui_charts/install/install_generator.rb +52 -0
- data/lib/generators/railsui_charts/install/templates/railsui_chart_controller.js +438 -0
- data/lib/generators/railsui_charts/install/templates/railsui_chart_filters_controller.js +14 -0
- data/lib/generators/railsui_charts/install/templates/railsui_metric_dialog_controller.js +27 -0
- data/lib/railsui_charts/apex_options_builder.rb +1027 -0
- data/lib/railsui_charts/chart_helper.rb +195 -0
- data/lib/railsui_charts/configuration.rb +89 -0
- data/lib/railsui_charts/engine.rb +19 -0
- data/lib/railsui_charts/filter_helper.rb +90 -0
- data/lib/railsui_charts/filters.rb +130 -0
- data/lib/railsui_charts/interval.rb +110 -0
- data/lib/railsui_charts/metric_helper.rb +219 -0
- data/lib/railsui_charts/version.rb +5 -0
- data/lib/railsui_charts.rb +26 -0
- metadata +86 -0
|
@@ -0,0 +1,1027 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsuiCharts
|
|
4
|
+
class ApexOptionsBuilder
|
|
5
|
+
SUPPORTED_TYPES = %i[line area bar column sparkline pie donut scatter bubble radar polar_area range_bar].freeze
|
|
6
|
+
|
|
7
|
+
# Bars never fill their band. The leftover space is what keeps a chart quiet,
|
|
8
|
+
# so thickness scales down as the category count drops.
|
|
9
|
+
BAR_THICKNESS = [[3, "30%"], [6, "42%"], [12, "58%"]].freeze
|
|
10
|
+
DEFAULT_BAR_THICKNESS = "70%"
|
|
11
|
+
|
|
12
|
+
# Series that support an overlaid previous-period comparison.
|
|
13
|
+
COMPARABLE_TYPES = %i[line area column sparkline].freeze
|
|
14
|
+
|
|
15
|
+
# Both sides of a dual axis are cut into this many intervals, so one grid
|
|
16
|
+
# serves both scales.
|
|
17
|
+
AXIS_INTERVALS = 4
|
|
18
|
+
|
|
19
|
+
# Apex reserves a gutter between the axis labels and the edge of its canvas.
|
|
20
|
+
# On its own that is invisible; inside a card it is not, because the card's
|
|
21
|
+
# heading starts at the content edge and the axis labels start this far
|
|
22
|
+
# inside it. Cancelling it lines the chart up with the text above it.
|
|
23
|
+
AXIS_LABEL_GUTTER = 16
|
|
24
|
+
|
|
25
|
+
# A category axis reserves almost nothing, because Apex sizes that column to
|
|
26
|
+
# the text itself — the only thing outside it is the grid's own left
|
|
27
|
+
# padding. Cancelling the full gutter here pushed the longest name twelve
|
|
28
|
+
# pixels past the edge of the card.
|
|
29
|
+
CATEGORY_AXIS_GUTTER = 4
|
|
30
|
+
|
|
31
|
+
# Two rows' worth. Circular charts cap at four categories, which is the most
|
|
32
|
+
# that can wrap onto a second line in a narrow card.
|
|
33
|
+
CIRCULAR_LEGEND_HEIGHT = 52
|
|
34
|
+
|
|
35
|
+
# No points, or points that carry no value. A series of zeroes is real data
|
|
36
|
+
# and is not blank — a quiet day still has something to say.
|
|
37
|
+
def self.blank?(data)
|
|
38
|
+
return true if data.nil?
|
|
39
|
+
return data.all? { |series| blank?(series[:data] || series["data"]) } if series_form?(data)
|
|
40
|
+
|
|
41
|
+
points = data.respond_to?(:to_a) && !data.is_a?(Array) ? data.to_a : Array(data)
|
|
42
|
+
return true if points.empty?
|
|
43
|
+
|
|
44
|
+
points.all? { |point| value_of(point).nil? }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.value_of(point)
|
|
48
|
+
case point
|
|
49
|
+
when Hash
|
|
50
|
+
point = point.symbolize_keys if point.respond_to?(:symbolize_keys)
|
|
51
|
+
# A span carries its value in its edges rather than in a `y`. Without
|
|
52
|
+
# this a timeline looks empty to `blank?` and renders the empty state
|
|
53
|
+
# instead of itself — data present, chart gone, nothing raised.
|
|
54
|
+
return point[:from] || point[:to] if point.key?(:from) || point.key?(:to)
|
|
55
|
+
|
|
56
|
+
point[:y]
|
|
57
|
+
when Array then point[1]
|
|
58
|
+
else point
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def initialize(data, type: :line, compare: nil, **options)
|
|
63
|
+
@series = extract_series(data)
|
|
64
|
+
@data = @series.first[:data]
|
|
65
|
+
@compare = compare.nil? ? nil : normalize_data(compare)
|
|
66
|
+
@type = validate_type(type)
|
|
67
|
+
@options = options
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# A single series can be given bare; several arrive as an array of
|
|
71
|
+
# `{ name:, data: }`. The `data` key is what tells the two apart, since a
|
|
72
|
+
# bare series is an array of values or of `{ x:, y: }` points.
|
|
73
|
+
def self.series_form?(data)
|
|
74
|
+
data.is_a?(Array) && data.any? &&
|
|
75
|
+
data.all? { |entry| entry.is_a?(Hash) && (entry.key?(:data) || entry.key?("data")) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def build
|
|
79
|
+
config = deep_merge(base_options, chart_specific_options, comparison_options, multi_series_options, stacked_options, user_options)
|
|
80
|
+
return config if sparkline?
|
|
81
|
+
|
|
82
|
+
# Derived from the finished config, not from the defaults. Apex swaps a
|
|
83
|
+
# breakpoint's axis object in wholesale rather than merging it, so
|
|
84
|
+
# anything already decided — which side the scale hangs on, hidden
|
|
85
|
+
# labels — has to be carried across or it is lost at that width.
|
|
86
|
+
config.merge(responsive: responsive_options(config))
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
# Rails UI Charts Pro registers the forms this gem does not model, so a
|
|
92
|
+
# treemap arrives here as a first-class type rather than as raw Apex config
|
|
93
|
+
# smuggled past the builder.
|
|
94
|
+
def validate_type(type)
|
|
95
|
+
type = type.to_sym
|
|
96
|
+
allowed = SUPPORTED_TYPES + RailsuiCharts.config.extra_types
|
|
97
|
+
raise ArgumentError, "Unsupported chart type: #{type}. Supported: #{allowed.join(', ')}" unless allowed.include?(type)
|
|
98
|
+
|
|
99
|
+
type
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def extract_series(data)
|
|
103
|
+
return [{ name: nil, data: normalize_data(data) }] unless self.class.series_form?(data)
|
|
104
|
+
|
|
105
|
+
data.map do |entry|
|
|
106
|
+
entry = entry.symbolize_keys if entry.respond_to?(:symbolize_keys)
|
|
107
|
+
|
|
108
|
+
{
|
|
109
|
+
name: entry[:name],
|
|
110
|
+
data: normalize_data(entry[:data]),
|
|
111
|
+
# A series may carry its own shape, scale, and formatting. Together
|
|
112
|
+
# these are what makes a combo: revenue as columns against money on
|
|
113
|
+
# the left, a churn rate as a line against percent on the right.
|
|
114
|
+
type: entry[:type]&.to_sym,
|
|
115
|
+
axis: entry[:axis]&.to_sym,
|
|
116
|
+
format: entry[:format]&.to_sym
|
|
117
|
+
}
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def multi?
|
|
122
|
+
@series.length > 1
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# One chart drawing more than one shape. Any series naming its own type
|
|
126
|
+
# makes it so — there is no separate `type: :combo` to remember, because
|
|
127
|
+
# the series already say what they are.
|
|
128
|
+
def combo?
|
|
129
|
+
@series.any? { |series| series[:type].present? }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def dual_axis?
|
|
133
|
+
@series.any? { |series| series[:axis] == :right }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def series_type(series, index)
|
|
137
|
+
type = series[:type] || (index.zero? ? @type : @series.first[:type]) || @type
|
|
138
|
+
apex_type_for(type)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def all_points
|
|
142
|
+
@series.flat_map { |series| series[:data] }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def normalize_data(data)
|
|
146
|
+
data = data.to_a if data.respond_to?(:to_a) && !data.is_a?(Array)
|
|
147
|
+
|
|
148
|
+
data.map do |point|
|
|
149
|
+
case point
|
|
150
|
+
when Hash
|
|
151
|
+
point = point.symbolize_keys if point.respond_to?(:symbolize_keys)
|
|
152
|
+
{ x: point[:x], y: span_or_value(point), z: point[:z] }
|
|
153
|
+
when Array
|
|
154
|
+
{ x: point[0], y: point[1], z: point[2] }
|
|
155
|
+
else
|
|
156
|
+
{ x: nil, y: point, z: nil }
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# A range takes two values rather than one. `from:` and `to:` read better
|
|
162
|
+
# than a bare two-element array at the call site, which is what a timeline
|
|
163
|
+
# is written with, so both arrive here as the pair Apex wants.
|
|
164
|
+
def span_or_value(point)
|
|
165
|
+
return [point[:from], point[:to]] if point.key?(:from) || point.key?(:to)
|
|
166
|
+
|
|
167
|
+
point[:y]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def categories
|
|
171
|
+
@data.map { |d| d[:x] }.compact
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# A bare array of values carries no labels, and Apex sizes the x-axis from
|
|
175
|
+
# this list: given an empty one it draws nothing at all — no canvas, no
|
|
176
|
+
# error, just an element that stays empty. Positions stand in, which is
|
|
177
|
+
# what the accessibility table already does for exactly this data.
|
|
178
|
+
#
|
|
179
|
+
# Only the axis needs this. Circular types name their own slices, and
|
|
180
|
+
# handing them numbers would replace "Item 1" with "1".
|
|
181
|
+
def axis_categories
|
|
182
|
+
# A lane per label, not per span. Two spans on one row is the whole point
|
|
183
|
+
# of a timeline, and Apex draws both of them in the first matching lane
|
|
184
|
+
# either way — but left to derive the list itself it also reserves a
|
|
185
|
+
# second, empty lane underneath and labels it the same.
|
|
186
|
+
return categories.uniq if timeline?
|
|
187
|
+
return categories if categories.any? || @data.empty?
|
|
188
|
+
|
|
189
|
+
@data.each_index.map { |index| index + 1 }
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def series_values
|
|
193
|
+
@data.map { |d| d[:y] }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def scatter_series
|
|
197
|
+
@data.map { |d| [d[:x], d[:y]] }
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def bubble_series
|
|
201
|
+
@data.map { |d| { x: d[:x], y: d[:y], z: d[:z] || 1 } }
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def base_options
|
|
205
|
+
{
|
|
206
|
+
chart: {
|
|
207
|
+
type: apex_chart_type,
|
|
208
|
+
height: @options[:height] || RailsuiCharts.config.default_height,
|
|
209
|
+
toolbar: { show: false },
|
|
210
|
+
animations: { enabled: true, easing: "easeinout", speed: 400 },
|
|
211
|
+
parentHeightOffset: 0,
|
|
212
|
+
# Apex observes the parent box and redraws on any change. Inside a
|
|
213
|
+
# flex card the redraw can itself change that box, which spins into a
|
|
214
|
+
# repaint loop. Window resizes still redraw.
|
|
215
|
+
redrawOnParentResize: false,
|
|
216
|
+
redrawOnWindowResize: true,
|
|
217
|
+
fontFamily: font_family,
|
|
218
|
+
background: "transparent"
|
|
219
|
+
},
|
|
220
|
+
series: series_for_type,
|
|
221
|
+
xaxis: xaxis_options,
|
|
222
|
+
yaxis: yaxis_options,
|
|
223
|
+
grid: grid_options,
|
|
224
|
+
colors: colors_for_type,
|
|
225
|
+
dataLabels: { enabled: false },
|
|
226
|
+
stroke: stroke_options,
|
|
227
|
+
fill: fill_options,
|
|
228
|
+
markers: marker_options,
|
|
229
|
+
legend: { show: false },
|
|
230
|
+
# Apex darkens the whole mark on hover by default, which reads as a blob.
|
|
231
|
+
# The crosshair and tooltip carry the hover state instead.
|
|
232
|
+
states: {
|
|
233
|
+
hover: { filter: { type: "none" } },
|
|
234
|
+
active: { filter: { type: "none" } }
|
|
235
|
+
},
|
|
236
|
+
tooltip: tooltip_options,
|
|
237
|
+
theme: { mode: "light" }
|
|
238
|
+
}
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# A chart that only works at desktop width is not finished. On a phone the
|
|
242
|
+
# plot loses most of its horizontal room, so the chrome gives way first:
|
|
243
|
+
# fewer gridlines, smaller type, a tighter legend, and less height.
|
|
244
|
+
def responsive_options(config)
|
|
245
|
+
[
|
|
246
|
+
{
|
|
247
|
+
breakpoint: 640,
|
|
248
|
+
options: {
|
|
249
|
+
chart: { height: mobile_height },
|
|
250
|
+
legend: { fontSize: font_size_sm, itemMargin: { horizontal: 6, vertical: 2 } },
|
|
251
|
+
xaxis: carry_over(config[:xaxis], { labels: { style: { fontSize: font_size_sm } } }),
|
|
252
|
+
yaxis: mobile_yaxis(config[:yaxis]),
|
|
253
|
+
grid: carry_over(config[:grid], { padding: { left: 0, right: 0 } }),
|
|
254
|
+
markers: { hover: { size: 8 } }
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
]
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def mobile_yaxis(current)
|
|
261
|
+
overrides = { labels: { style: { fontSize: font_size_sm } } }
|
|
262
|
+
# A numeric axis already has bounds picked so its ticks land on round
|
|
263
|
+
# numbers. Forcing a count knocks them back off — 10..70 in four steps
|
|
264
|
+
# reads 10 / 25 / 40 / 55 / 70.
|
|
265
|
+
overrides[:tickAmount] = 4 unless current.is_a?(Hash) && current.key?(:min)
|
|
266
|
+
|
|
267
|
+
carry_over(current, overrides)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def carry_over(current, overrides)
|
|
271
|
+
current.is_a?(Hash) ? deep_merge(current, overrides) : overrides
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def mobile_height
|
|
275
|
+
height = @options[:height] || RailsuiCharts.config.default_height
|
|
276
|
+
[height.to_i, 260].min
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def chart_specific_options
|
|
280
|
+
case @type
|
|
281
|
+
when :sparkline
|
|
282
|
+
{
|
|
283
|
+
chart: { sparkline: { enabled: true } },
|
|
284
|
+
grid: { show: false },
|
|
285
|
+
xaxis: { labels: { show: false } },
|
|
286
|
+
yaxis: { labels: { show: false } }
|
|
287
|
+
}
|
|
288
|
+
when :bar
|
|
289
|
+
{ plotOptions: { bar: bar_plot_options.merge(horizontal: true, barHeight: bar_thickness) } }
|
|
290
|
+
when :column
|
|
291
|
+
{ plotOptions: { bar: bar_plot_options.merge(horizontal: false, columnWidth: bar_thickness) } }
|
|
292
|
+
when :range_bar
|
|
293
|
+
{
|
|
294
|
+
# Horizontal because a timeline reads left to right, and because the
|
|
295
|
+
# row labels are names rather than a scale — vertical would rotate
|
|
296
|
+
# them.
|
|
297
|
+
plotOptions: { bar: { horizontal: true, borderRadius: geometry(:bar_radius), barHeight: bar_thickness } },
|
|
298
|
+
xaxis: { type: "datetime" },
|
|
299
|
+
# The bar already spans its own dates. A gridline behind every row
|
|
300
|
+
# would be drawing the categories twice.
|
|
301
|
+
grid: { yaxis: { lines: { show: false } }, xaxis: { lines: { show: true } } },
|
|
302
|
+
tooltip: { x: { format: "d MMM yyyy" } }
|
|
303
|
+
}
|
|
304
|
+
when :pie, :donut
|
|
305
|
+
{
|
|
306
|
+
labels: circular_labels,
|
|
307
|
+
plotOptions: circular_plot_options,
|
|
308
|
+
dataLabels: { enabled: false },
|
|
309
|
+
legend: circular_legend,
|
|
310
|
+
stroke: { show: true, width: 2, colors: [surface_color] },
|
|
311
|
+
xaxis: { labels: { show: false } },
|
|
312
|
+
yaxis: { labels: { show: false } },
|
|
313
|
+
grid: { show: false }
|
|
314
|
+
}
|
|
315
|
+
when :scatter
|
|
316
|
+
{
|
|
317
|
+
markers: { size: 6, strokeWidth: 2, strokeColors: surface_color, hover: { size: 8 } },
|
|
318
|
+
xaxis: numeric_axis(:x),
|
|
319
|
+
yaxis: numeric_axis(:y)
|
|
320
|
+
}
|
|
321
|
+
when :bubble
|
|
322
|
+
{
|
|
323
|
+
plotOptions: { bubble: { zScaling: true, minBubbleRadius: 6, maxBubbleRadius: 28 } },
|
|
324
|
+
markers: { strokeWidth: 0 },
|
|
325
|
+
fill: { opacity: 0.75 },
|
|
326
|
+
xaxis: numeric_axis(:x),
|
|
327
|
+
yaxis: numeric_axis(:y)
|
|
328
|
+
}
|
|
329
|
+
when :radar
|
|
330
|
+
{
|
|
331
|
+
markers: { size: 4, strokeWidth: 0, hover: { size: 6 } },
|
|
332
|
+
stroke: { curve: "straight", width: 2, colors: [config_color(:primary)] },
|
|
333
|
+
fill: { opacity: 0.12, colors: [config_color(:primary)] },
|
|
334
|
+
plotOptions: { radar: radar_plot_options },
|
|
335
|
+
xaxis: { labels: { show: true, style: { colors: config_color(:text), fontFamily: font_family, fontSize: font_size } } },
|
|
336
|
+
# The concentric rings already carry magnitude; a numeric axis just
|
|
337
|
+
# overprints the polygon.
|
|
338
|
+
yaxis: { show: false },
|
|
339
|
+
# The radar's own polygons are the grid. Leaving the cartesian grid on
|
|
340
|
+
# rules horizontal lines straight through the shape.
|
|
341
|
+
grid: { show: false }
|
|
342
|
+
}
|
|
343
|
+
when :polar_area
|
|
344
|
+
{
|
|
345
|
+
labels: circular_labels,
|
|
346
|
+
plotOptions: { polarArea: { rings: { strokeWidth: 1, strokeColor: config_color(:grid) }, spokes: { strokeWidth: 1, connectorColors: config_color(:grid) } } },
|
|
347
|
+
dataLabels: { enabled: false },
|
|
348
|
+
legend: circular_legend,
|
|
349
|
+
stroke: { show: true, width: 2, colors: [surface_color] },
|
|
350
|
+
xaxis: { labels: { show: false } },
|
|
351
|
+
yaxis: { show: false },
|
|
352
|
+
grid: { show: false }
|
|
353
|
+
}
|
|
354
|
+
else
|
|
355
|
+
{}
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# A previous-period series rides underneath the current one as a dashed,
|
|
360
|
+
# muted line. Identity comes from the tooltip labels and the legend, never
|
|
361
|
+
# from the colour alone.
|
|
362
|
+
def comparison_options
|
|
363
|
+
return {} unless comparing?
|
|
364
|
+
|
|
365
|
+
{
|
|
366
|
+
colors: [config_color(:primary), config_color(:muted)],
|
|
367
|
+
stroke: {
|
|
368
|
+
curve: curve,
|
|
369
|
+
# Both periods take the same weight; the dash is what separates them,
|
|
370
|
+
# so thinning the comparison would say "less important" twice.
|
|
371
|
+
width: [geometry(:stroke_width), geometry(:stroke_width)],
|
|
372
|
+
dashArray: [0, 4]
|
|
373
|
+
},
|
|
374
|
+
fill: comparison_fill,
|
|
375
|
+
tooltip: { shared: true, intersect: false },
|
|
376
|
+
legend: comparison_legend,
|
|
377
|
+
# The comparison series plots against the current period's x positions,
|
|
378
|
+
# so its own dates would otherwise be lost. The tooltip wants them: a
|
|
379
|
+
# row reading "Aug 4" says more than a second row reading "Aug 11".
|
|
380
|
+
compare_categories: @compare.map { |point| point[:x] }.compact
|
|
381
|
+
}
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# Two or more series always carry a legend. Colour alone is never the only
|
|
385
|
+
# thing telling them apart.
|
|
386
|
+
def multi_series_options
|
|
387
|
+
return {} unless multi?
|
|
388
|
+
|
|
389
|
+
options = { legend: series_legend, tooltip: { shared: true, intersect: false } }
|
|
390
|
+
return options unless combo?
|
|
391
|
+
|
|
392
|
+
# Read by the Stimulus controller, not by Apex. A combo puts money on one
|
|
393
|
+
# row of the tooltip and a percentage on the next, and a single formatter
|
|
394
|
+
# across both dresses one of them wrongly.
|
|
395
|
+
options[:series_formats] = @series.map { |series| series[:format] || @options[:format] || :number }
|
|
396
|
+
options
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# Stacking answers "what is this made of over time", which a grouped chart
|
|
400
|
+
# cannot. `stacked: :percent` switches from totals to share.
|
|
401
|
+
def stacked_options
|
|
402
|
+
return {} unless @options[:stacked]
|
|
403
|
+
|
|
404
|
+
{
|
|
405
|
+
chart: { stacked: true, stackType: @options[:stacked].to_s == "percent" ? "100%" : "normal" },
|
|
406
|
+
# Segments separate with a gap in the surface colour rather than a
|
|
407
|
+
# stroke drawn around them, so the divider never reads as data.
|
|
408
|
+
stroke: { show: true, width: 2, colors: [surface_color] },
|
|
409
|
+
plotOptions: { bar: { borderRadius: geometry(:bar_radius), borderRadiusApplication: "end" } }
|
|
410
|
+
}
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def series_legend
|
|
414
|
+
{
|
|
415
|
+
show: true,
|
|
416
|
+
position: "top",
|
|
417
|
+
horizontalAlign: "left",
|
|
418
|
+
offsetX: -8,
|
|
419
|
+
fontFamily: font_family,
|
|
420
|
+
fontSize: font_size,
|
|
421
|
+
labels: { colors: config_color(:text) },
|
|
422
|
+
markers: { width: 8, height: 8, radius: 8, offsetX: -2 },
|
|
423
|
+
itemMargin: { horizontal: 8, vertical: 0 }
|
|
424
|
+
}
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
def comparison_fill
|
|
428
|
+
# The comparison series gets a flat gradient rather than `opacity: 0`,
|
|
429
|
+
# which would take its stroke down with it.
|
|
430
|
+
if @type == :area
|
|
431
|
+
return {
|
|
432
|
+
type: "gradient",
|
|
433
|
+
gradient: { shadeIntensity: 1, opacityFrom: [0.16, 0], opacityTo: [0, 0], stops: [0, 100] }
|
|
434
|
+
}
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
return { opacity: [1, 0.35] } if @type == :column
|
|
438
|
+
|
|
439
|
+
{ opacity: [1, 1] }
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def comparison_legend
|
|
443
|
+
return { show: false } if sparkline?
|
|
444
|
+
|
|
445
|
+
series_legend
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def comparing?
|
|
449
|
+
!@compare.nil? && !@compare.empty? && !multi? && COMPARABLE_TYPES.include?(@type)
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def bar_plot_options
|
|
453
|
+
{
|
|
454
|
+
borderRadius: geometry(:bar_radius),
|
|
455
|
+
# Round the data-end only; the baseline stays square so every bar
|
|
456
|
+
# grows from the same edge.
|
|
457
|
+
borderRadiusApplication: "end",
|
|
458
|
+
dataLabels: { position: "top" }
|
|
459
|
+
}
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def bar_thickness
|
|
463
|
+
count = [@data.size, 1].max
|
|
464
|
+
match = BAR_THICKNESS.find { |max, _| count <= max }
|
|
465
|
+
match ? match.last : DEFAULT_BAR_THICKNESS
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def radar_plot_options
|
|
469
|
+
{
|
|
470
|
+
polygons: {
|
|
471
|
+
strokeColors: config_color(:grid),
|
|
472
|
+
connectorColors: config_color(:grid),
|
|
473
|
+
fill: { colors: ["transparent"] }
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def circular_labels
|
|
479
|
+
categories.any? ? categories : @data.map.with_index { |_, i| "Item #{i + 1}" }
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
def circular_plot_options
|
|
483
|
+
case @type
|
|
484
|
+
when :donut
|
|
485
|
+
{ pie: { donut: { size: "62%", labels: { show: false } }, expandOnClick: false } }
|
|
486
|
+
when :pie
|
|
487
|
+
{ pie: { offsetX: 0, offsetY: 0, expandOnClick: false } }
|
|
488
|
+
else
|
|
489
|
+
{}
|
|
490
|
+
end
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
def circular_legend
|
|
494
|
+
{
|
|
495
|
+
show: true,
|
|
496
|
+
position: "bottom",
|
|
497
|
+
horizontalAlign: "center",
|
|
498
|
+
fontFamily: font_family,
|
|
499
|
+
fontSize: font_size,
|
|
500
|
+
# Reserve the band rather than letting Apex estimate it. Its own
|
|
501
|
+
# estimate runs a few pixels short, and since the canvas clips, the
|
|
502
|
+
# bottom row of labels loses its descenders — or the whole row, once a
|
|
503
|
+
# narrow card wraps the legend onto two lines.
|
|
504
|
+
height: CIRCULAR_LEGEND_HEIGHT,
|
|
505
|
+
itemMargin: { horizontal: 8, vertical: 2 },
|
|
506
|
+
labels: { colors: config_color(:text) },
|
|
507
|
+
markers: { width: 8, height: 8, radius: 8, offsetX: -2 }
|
|
508
|
+
}
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def series_for_type
|
|
512
|
+
return series_values if circular?
|
|
513
|
+
|
|
514
|
+
plotted = @series.each_with_index.map do |series, index|
|
|
515
|
+
entry = { name: series[:name] || (index.zero? ? series_label : "Series #{index + 1}"), data: points_for(series[:data]) }
|
|
516
|
+
# Only on a combo. Naming a type on every series of an ordinary chart
|
|
517
|
+
# would override the chart-level one and quietly ignore `type:`.
|
|
518
|
+
entry[:type] = series_type(series, index) if combo?
|
|
519
|
+
entry
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
comparing? ? plotted + [{ name: compare_label, data: @compare.map { |point| point[:y] } }] : plotted
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
def points_for(points)
|
|
526
|
+
case @type
|
|
527
|
+
when :scatter then points.map { |point| [point[:x], point[:y]] }
|
|
528
|
+
when :bubble then points.map { |point| { x: point[:x], y: point[:y], z: point[:z] || 1 } }
|
|
529
|
+
when :range_bar
|
|
530
|
+
# Both ends stay in the point. A range read off an axis is only half a
|
|
531
|
+
# range, and the label names the row rather than a position on a scale.
|
|
532
|
+
points.map { |point| { x: point[:x], y: Array(point[:y]).map { |edge| range_edge(edge) } } }
|
|
533
|
+
else
|
|
534
|
+
# A registered type may draw its own labels rather than read them off an
|
|
535
|
+
# axis, in which case flattening the point to a value loses them.
|
|
536
|
+
return points.map { |point| { x: point[:x], y: point[:y] } } if labelled_points?
|
|
537
|
+
|
|
538
|
+
points.map { |point| point[:y] }
|
|
539
|
+
end
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
def labelled_points?
|
|
543
|
+
RailsuiCharts.config.labelled_point_types.include?(@type)
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
# Apex plots a range against a time axis in milliseconds. Handing it a Time
|
|
547
|
+
# gives "Invalid Date" rather than an error, so the conversion happens here
|
|
548
|
+
# where the type is known and a plain number is still allowed through for
|
|
549
|
+
# a range that is not about time at all.
|
|
550
|
+
def range_edge(value)
|
|
551
|
+
return value.to_time.to_i * 1000 if value.respond_to?(:to_time)
|
|
552
|
+
|
|
553
|
+
value
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
def timeline?
|
|
557
|
+
@type == :range_bar
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
def series_label
|
|
561
|
+
@options[:label] || "Value"
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
def compare_label
|
|
565
|
+
@options[:compare_label] || "Previous period"
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
def xaxis_options
|
|
569
|
+
return { labels: { show: false } } if circular?
|
|
570
|
+
return numeric_axis(:x) if numeric_xaxis?
|
|
571
|
+
|
|
572
|
+
{
|
|
573
|
+
categories: axis_categories,
|
|
574
|
+
labels: {
|
|
575
|
+
show: categories.any? && !sparkline?,
|
|
576
|
+
style: { colors: config_color(:text), fontFamily: font_family, fontSize: font_size },
|
|
577
|
+
# Rotated labels are the loudest tell of an unstyled chart. Labels
|
|
578
|
+
# either fit horizontally or the tick count comes down.
|
|
579
|
+
rotate: 0,
|
|
580
|
+
rotateAlways: false,
|
|
581
|
+
hideOverlappingLabels: true,
|
|
582
|
+
trim: false
|
|
583
|
+
},
|
|
584
|
+
axisBorder: { show: false },
|
|
585
|
+
axisTicks: { show: false },
|
|
586
|
+
crosshairs: crosshair_options,
|
|
587
|
+
tooltip: { enabled: false },
|
|
588
|
+
type: "category"
|
|
589
|
+
}
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
# Scatter and bubble plot real x values. Handing Apex a `categories` array
|
|
593
|
+
# alongside numeric pair data makes it plot nothing at all, so the numeric
|
|
594
|
+
# axis is built without one.
|
|
595
|
+
def numeric_axis(axis)
|
|
596
|
+
base = {
|
|
597
|
+
labels: {
|
|
598
|
+
show: true,
|
|
599
|
+
style: { colors: config_color(:text), fontFamily: font_family, fontSize: font_size }
|
|
600
|
+
},
|
|
601
|
+
axisBorder: { show: false },
|
|
602
|
+
axisTicks: { show: false }
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
# Bubbles get a whole step of headroom because their radius is drawn
|
|
606
|
+
# around the point. Scatter dots are small enough that pixel padding on
|
|
607
|
+
# the grid keeps them clear of the axis without distorting the ticks.
|
|
608
|
+
bounds = nice_bounds(all_points.map { |point| point[axis] }, 5, pad: bubble?)
|
|
609
|
+
|
|
610
|
+
if axis == :x
|
|
611
|
+
return base.merge(bounds_for(bounds, :x), type: "numeric", crosshairs: crosshair_options, tooltip: { enabled: false })
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
base.merge(bounds_for(bounds, :y))
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
# Apex counts ticks differently per axis: a numeric x-axis renders
|
|
618
|
+
# `tickAmount + 2` labels (so it cuts the range into `tickAmount + 1`
|
|
619
|
+
# intervals), while the y-axis renders `tickAmount + 1`. Feeding both the
|
|
620
|
+
# same number is what puts x-ticks on values like 14.2.
|
|
621
|
+
def bounds_for(bounds, axis)
|
|
622
|
+
return { tickAmount: bounds[:intervals] } unless bounds.key?(:min)
|
|
623
|
+
|
|
624
|
+
intervals = bounds[:intervals]
|
|
625
|
+
{
|
|
626
|
+
min: bounds[:min],
|
|
627
|
+
max: bounds[:max],
|
|
628
|
+
tickAmount: axis == :x ? [intervals - 1, 1].max : intervals,
|
|
629
|
+
decimalsInFloat: bounds[:decimals]
|
|
630
|
+
}
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
# Apex splits a numeric range into equal parts, which lands ticks on values
|
|
634
|
+
# like 13.6 and 17.2. Snapping the bounds outward to a round step puts them
|
|
635
|
+
# back on numbers a reader can hold in their head.
|
|
636
|
+
def nice_bounds(values, target_intervals, pad: false)
|
|
637
|
+
values = values.compact.map(&:to_f)
|
|
638
|
+
return { intervals: target_intervals } if values.empty?
|
|
639
|
+
|
|
640
|
+
min, max = values.minmax
|
|
641
|
+
return { intervals: target_intervals } if min == max
|
|
642
|
+
|
|
643
|
+
step = nice_step((max - min) / target_intervals.to_f)
|
|
644
|
+
low = (min / step).floor * step
|
|
645
|
+
high = (max / step).ceil * step
|
|
646
|
+
|
|
647
|
+
# Bubbles are drawn around their point, so a mark sitting on the boundary
|
|
648
|
+
# gets sliced in half by the plot edge.
|
|
649
|
+
if pad
|
|
650
|
+
low -= step
|
|
651
|
+
high += step
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
{
|
|
655
|
+
min: trim_float(low),
|
|
656
|
+
max: trim_float(high),
|
|
657
|
+
intervals: ((high - low) / step).round,
|
|
658
|
+
decimals: step == step.to_i ? 0 : 1
|
|
659
|
+
}
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
def nice_step(raw)
|
|
663
|
+
return 1 if raw <= 0
|
|
664
|
+
|
|
665
|
+
magnitude = 10.0**Math.log10(raw).floor
|
|
666
|
+
[1, 2, 2.5, 5].each { |multiple| return multiple * magnitude if raw <= multiple * magnitude }
|
|
667
|
+
10 * magnitude
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
def trim_float(value)
|
|
671
|
+
value == value.to_i ? value.to_i : value.round(4)
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
def crosshair_options
|
|
675
|
+
return { show: false } if sparkline?
|
|
676
|
+
|
|
677
|
+
{
|
|
678
|
+
show: true,
|
|
679
|
+
stroke: { color: config_color(:grid), width: 1, dashArray: 0 }
|
|
680
|
+
}
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
def yaxis_options
|
|
684
|
+
return { labels: { show: false } } if circular?
|
|
685
|
+
return dual_yaxis_options if dual_axis?
|
|
686
|
+
|
|
687
|
+
{
|
|
688
|
+
# Stripe-style dashboards hang the scale on the right so the plot starts
|
|
689
|
+
# flush with the card's left edge.
|
|
690
|
+
opposite: @options[:axis] == :right,
|
|
691
|
+
labels: {
|
|
692
|
+
show: !sparkline?,
|
|
693
|
+
offsetX: axis_label_offset(@options[:axis] == :right),
|
|
694
|
+
style: { colors: config_color(:text), fontFamily: font_family, fontSize: font_size }
|
|
695
|
+
},
|
|
696
|
+
tickAmount: 5,
|
|
697
|
+
axisBorder: { show: false },
|
|
698
|
+
axisTicks: { show: false }
|
|
699
|
+
}
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
# Apex matches a yaxis array to the series by position, so there is one
|
|
703
|
+
# entry per series whether or not it draws a scale.
|
|
704
|
+
#
|
|
705
|
+
# Only the first series on each side draws one. Two series sharing the left
|
|
706
|
+
# scale would otherwise stack two identical rulers on top of each other,
|
|
707
|
+
# which reads as a rendering fault rather than as two series.
|
|
708
|
+
def dual_yaxis_options
|
|
709
|
+
drawn = { left: false, right: false }
|
|
710
|
+
scales = aligned_scales
|
|
711
|
+
|
|
712
|
+
@series.each_with_index.map do |series, index|
|
|
713
|
+
side = series[:axis] == :right ? :right : :left
|
|
714
|
+
show = !drawn[side]
|
|
715
|
+
drawn[side] = true
|
|
716
|
+
|
|
717
|
+
{
|
|
718
|
+
seriesName: series[:name] || (index.zero? ? series_label : "Series #{index + 1}"),
|
|
719
|
+
opposite: side == :right,
|
|
720
|
+
show: show,
|
|
721
|
+
**scales.fetch(side, {}),
|
|
722
|
+
# The reader has to know which scale belongs to which series, and on
|
|
723
|
+
# a two-axis chart position alone does not say. The labels take the
|
|
724
|
+
# colour of the series they measure.
|
|
725
|
+
labels: {
|
|
726
|
+
show: show,
|
|
727
|
+
offsetX: axis_label_offset(side == :right),
|
|
728
|
+
style: { colors: axis_ink(index), fontFamily: font_family, fontSize: font_size }
|
|
729
|
+
},
|
|
730
|
+
format: series[:format] || @options[:format],
|
|
731
|
+
axisBorder: { show: false },
|
|
732
|
+
axisTicks: { show: false }
|
|
733
|
+
}.compact
|
|
734
|
+
end
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
# Negative on both sides: Apex mirrors offsetX for an opposite axis, so the
|
|
738
|
+
# same sign pulls each toward its own edge rather than one of them inward.
|
|
739
|
+
# The right reserves two pixels less than the left, which is the axis border
|
|
740
|
+
# allowance it draws on one side only.
|
|
741
|
+
#
|
|
742
|
+
# A y-axis carrying category names rather than values — a horizontal bar or
|
|
743
|
+
# a timeline — reserves a different amount, so it gets its own figure.
|
|
744
|
+
def axis_label_offset(opposite)
|
|
745
|
+
return -CATEGORY_AXIS_GUTTER if category_yaxis?
|
|
746
|
+
|
|
747
|
+
opposite ? -(AXIS_LABEL_GUTTER - 2) : -AXIS_LABEL_GUTTER
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
def category_yaxis?
|
|
751
|
+
timeline? || @type == :bar
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
# Two scales, so the axis is coloured like its series. One scale keeps the
|
|
755
|
+
# quiet neutral: there is nothing to tell apart.
|
|
756
|
+
def axis_ink(index)
|
|
757
|
+
resolve_var(categorical_palette[index] || config_color(:text))
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
# Both sides fitted to their own values, then cut into the same number of
|
|
761
|
+
# intervals — that shared count is what makes one set of gridlines serve
|
|
762
|
+
# both scales.
|
|
763
|
+
#
|
|
764
|
+
# Apex's own forceNiceScale fits each axis independently and rounds hard:
|
|
765
|
+
# revenue topping out at 52K came back as a 0–100K scale with the columns
|
|
766
|
+
# filling half the plot while the line sat against a well-fitted one.
|
|
767
|
+
def aligned_scales
|
|
768
|
+
sides = %i[left right].to_h { |side| [side, fit_axis(axis_values(side))] }
|
|
769
|
+
return {} unless sides.values.all?
|
|
770
|
+
|
|
771
|
+
sides
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
# A scale fitted to its values in exactly AXIS_INTERVALS steps.
|
|
775
|
+
#
|
|
776
|
+
# The interval count is fixed rather than derived, because that is the
|
|
777
|
+
# whole alignment mechanism: two axes cut into the same number of pieces
|
|
778
|
+
# put their gridlines on each other whatever their ranges are.
|
|
779
|
+
#
|
|
780
|
+
# The step ladder is finer than the one a single axis uses. With a fixed
|
|
781
|
+
# count, a coarse ladder is what produces the too-tall scale: revenue
|
|
782
|
+
# topping out at 52K wants a step near 13K, and rounding that to 20K makes
|
|
783
|
+
# a 0–100K axis with the columns filling half the plot.
|
|
784
|
+
def fit_axis(values)
|
|
785
|
+
values = values.compact.map(&:to_f)
|
|
786
|
+
return nil if values.empty?
|
|
787
|
+
|
|
788
|
+
min, max = values.minmax
|
|
789
|
+
return nil if min == max
|
|
790
|
+
|
|
791
|
+
step = dual_step((max - min) / AXIS_INTERVALS.to_f)
|
|
792
|
+
# The floor moves down to a multiple of the step, which can leave the top
|
|
793
|
+
# short. Widening the step is what recovers it.
|
|
794
|
+
step = dual_step(step * 1.0001) while (min / step).floor * step + step * AXIS_INTERVALS < max
|
|
795
|
+
|
|
796
|
+
low = (min / step).floor * step
|
|
797
|
+
{ min: trim_float(low), max: trim_float(low + step * AXIS_INTERVALS), tickAmount: AXIS_INTERVALS,
|
|
798
|
+
decimalsInFloat: step == step.to_i ? 0 : 1 }
|
|
799
|
+
end
|
|
800
|
+
|
|
801
|
+
# 1.5 and 3 are on this ladder and not on nice_step's. They are the steps
|
|
802
|
+
# that keep a fixed-count scale close to its data instead of doubling past
|
|
803
|
+
# it.
|
|
804
|
+
DUAL_STEPS = [1, 1.5, 2, 2.5, 3, 4, 5, 7.5].freeze
|
|
805
|
+
|
|
806
|
+
# 10.0 and not 10: an Integer raised to a negative Integer gives a Rational
|
|
807
|
+
# in Ruby, and a Rational survives every calculation below to arrive in the
|
|
808
|
+
# browser as the string "5/2". Apex cannot read that, drops the bound, and
|
|
809
|
+
# quietly auto-scales — which clipped the top of the data rather than
|
|
810
|
+
# raising anything.
|
|
811
|
+
def dual_step(raw)
|
|
812
|
+
return 1 if raw <= 0
|
|
813
|
+
|
|
814
|
+
magnitude = 10.0**Math.log10(raw).floor
|
|
815
|
+
DUAL_STEPS.each { |multiple| return multiple * magnitude if raw <= multiple * magnitude }
|
|
816
|
+
10 * magnitude
|
|
817
|
+
end
|
|
818
|
+
|
|
819
|
+
def axis_values(side)
|
|
820
|
+
values = @series.select { |series| axis_side(series) == side }
|
|
821
|
+
.flat_map { |series| series[:data].map { |point| point[:y] } }
|
|
822
|
+
# A column grows from a baseline, so its scale has to contain one. A line
|
|
823
|
+
# can float, and forcing zero on a churn rate hovering near 3% would
|
|
824
|
+
# flatten it against the top of the plot.
|
|
825
|
+
values << 0 if values.any? && baseline?(side)
|
|
826
|
+
values
|
|
827
|
+
end
|
|
828
|
+
|
|
829
|
+
def axis_side(series)
|
|
830
|
+
series[:axis] == :right ? :right : :left
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
def baseline?(side)
|
|
834
|
+
@series.any? do |series|
|
|
835
|
+
axis_side(series) == side && %w[bar column].include?((series[:type] || @type).to_s)
|
|
836
|
+
end
|
|
837
|
+
end
|
|
838
|
+
|
|
839
|
+
def grid_options
|
|
840
|
+
return { show: false } if circular? || sparkline?
|
|
841
|
+
|
|
842
|
+
{
|
|
843
|
+
show: true,
|
|
844
|
+
borderColor: config_color(:grid),
|
|
845
|
+
# Solid hairlines. Dashes read as "projection" or "threshold" when all
|
|
846
|
+
# they are is a grid.
|
|
847
|
+
strokeDashArray: 0,
|
|
848
|
+
xaxis: { lines: { show: false } },
|
|
849
|
+
yaxis: { lines: { show: true } },
|
|
850
|
+
# Numeric plots put marks right on the boundary, so they need the marker
|
|
851
|
+
# radius as breathing room on both sides.
|
|
852
|
+
padding: numeric_xaxis? ? { top: 0, right: 12, bottom: 0, left: 12 } : { top: 0, right: 0, bottom: 0, left: 4 }
|
|
853
|
+
}
|
|
854
|
+
end
|
|
855
|
+
|
|
856
|
+
def marker_options
|
|
857
|
+
return { size: 0 } if sparkline?
|
|
858
|
+
|
|
859
|
+
{
|
|
860
|
+
size: geometry(:marker_size),
|
|
861
|
+
strokeWidth: geometry(:stroke_width),
|
|
862
|
+
strokeColors: surface_color,
|
|
863
|
+
hover: { size: geometry(:marker_hover_size), sizeOffset: 0 }
|
|
864
|
+
}
|
|
865
|
+
end
|
|
866
|
+
|
|
867
|
+
def tooltip_options
|
|
868
|
+
{
|
|
869
|
+
theme: "dark",
|
|
870
|
+
shared: !circular?,
|
|
871
|
+
intersect: false,
|
|
872
|
+
followCursor: false,
|
|
873
|
+
x: { show: !circular? && categories.any? },
|
|
874
|
+
marker: { show: true },
|
|
875
|
+
style: { fontFamily: font_family, fontSize: font_size }
|
|
876
|
+
}
|
|
877
|
+
end
|
|
878
|
+
|
|
879
|
+
def colors_for_type
|
|
880
|
+
return categorical_palette if circular? || radar? || bubble? || multi?
|
|
881
|
+
[config_color(:primary)]
|
|
882
|
+
end
|
|
883
|
+
|
|
884
|
+
# Assigned in fixed order and never cycled: a fifth slice takes slot 5, not
|
|
885
|
+
# slot 1 again. Callers past eight categories should fold the tail into an
|
|
886
|
+
# "Other" bucket rather than repeat a hue.
|
|
887
|
+
def categorical_palette
|
|
888
|
+
RailsuiCharts.config.series_colors.map { |value| resolve_var(value) }
|
|
889
|
+
end
|
|
890
|
+
|
|
891
|
+
def radar?
|
|
892
|
+
@type == :radar
|
|
893
|
+
end
|
|
894
|
+
|
|
895
|
+
def bubble?
|
|
896
|
+
@type == :bubble
|
|
897
|
+
end
|
|
898
|
+
|
|
899
|
+
def curve
|
|
900
|
+
@options[:curve] || "straight"
|
|
901
|
+
end
|
|
902
|
+
|
|
903
|
+
def stroke_options
|
|
904
|
+
case @type
|
|
905
|
+
when :sparkline
|
|
906
|
+
{ curve: curve, width: geometry(:stroke_width), lineCap: "round" }
|
|
907
|
+
when :bar, :column
|
|
908
|
+
{ show: true, width: 0, colors: ["transparent"] }
|
|
909
|
+
when :bubble
|
|
910
|
+
{ show: false }
|
|
911
|
+
else
|
|
912
|
+
{ curve: curve, width: geometry(:stroke_width), lineCap: "round" }
|
|
913
|
+
end
|
|
914
|
+
end
|
|
915
|
+
|
|
916
|
+
def fill_options
|
|
917
|
+
case @type
|
|
918
|
+
when :area
|
|
919
|
+
{
|
|
920
|
+
type: "gradient",
|
|
921
|
+
gradient: {
|
|
922
|
+
shadeIntensity: 1,
|
|
923
|
+
# A wash, never a saturated block.
|
|
924
|
+
opacityFrom: 0.16,
|
|
925
|
+
opacityTo: 0.0,
|
|
926
|
+
stops: [0, 100]
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
else
|
|
930
|
+
# Never zero. Apex derives a line's stroke alpha from fill.opacity, so
|
|
931
|
+
# `opacity: 0` renders the line fully transparent.
|
|
932
|
+
{ opacity: 1 }
|
|
933
|
+
end
|
|
934
|
+
end
|
|
935
|
+
|
|
936
|
+
def deep_merge(*hashes)
|
|
937
|
+
hashes.each_with_object({}) do |hash, result|
|
|
938
|
+
hash.each do |key, value|
|
|
939
|
+
result[key] = if value.is_a?(Hash) && result[key].is_a?(Hash)
|
|
940
|
+
deep_merge(result[key], value)
|
|
941
|
+
else
|
|
942
|
+
value
|
|
943
|
+
end
|
|
944
|
+
end
|
|
945
|
+
end
|
|
946
|
+
end
|
|
947
|
+
|
|
948
|
+
def user_options
|
|
949
|
+
opts = @options.except(:type, :height, :label, :compare, :compare_label, :curve, :axis, :accessible, :id)
|
|
950
|
+
opts[:colors] = Array(opts[:colors]).map { |c| config_color(c) || c } if opts.key?(:colors)
|
|
951
|
+
opts[:currency] ||= RailsuiCharts.config.default_currency if %w[currency short_currency].include?(opts[:format].to_s)
|
|
952
|
+
opts
|
|
953
|
+
end
|
|
954
|
+
|
|
955
|
+
def apex_chart_type
|
|
956
|
+
# A mixed chart takes its shape from each series, and Apex wants the
|
|
957
|
+
# chart-level type to be "line" while they do — set to "bar" it draws
|
|
958
|
+
# every series as bars whatever they asked for.
|
|
959
|
+
return "line" if combo?
|
|
960
|
+
|
|
961
|
+
apex_type_for(@type)
|
|
962
|
+
end
|
|
963
|
+
|
|
964
|
+
def apex_type_for(type)
|
|
965
|
+
case type&.to_sym
|
|
966
|
+
when :sparkline then "line"
|
|
967
|
+
when :range_bar then "rangeBar"
|
|
968
|
+
when :column, :bar then "bar"
|
|
969
|
+
when :donut then "donut"
|
|
970
|
+
when :polar_area then "polarArea"
|
|
971
|
+
else type.to_s
|
|
972
|
+
end
|
|
973
|
+
end
|
|
974
|
+
|
|
975
|
+
def sparkline?
|
|
976
|
+
@type == :sparkline
|
|
977
|
+
end
|
|
978
|
+
|
|
979
|
+
def circular?
|
|
980
|
+
%i[pie donut polar_area].include?(@type)
|
|
981
|
+
end
|
|
982
|
+
|
|
983
|
+
def numeric_xaxis?
|
|
984
|
+
%i[scatter bubble].include?(@type)
|
|
985
|
+
end
|
|
986
|
+
|
|
987
|
+
def config_color(key)
|
|
988
|
+
RailsuiCharts.config.colors[key]
|
|
989
|
+
end
|
|
990
|
+
|
|
991
|
+
# Type ships as a `var()` string and is resolved in the browser, the same
|
|
992
|
+
# way the colours are. Geometry cannot: Apex does arithmetic on a radius or
|
|
993
|
+
# a stroke width, and a resolved variable would arrive as a string.
|
|
994
|
+
def font_family
|
|
995
|
+
RailsuiCharts.config.typography[:family]
|
|
996
|
+
end
|
|
997
|
+
|
|
998
|
+
def font_size
|
|
999
|
+
RailsuiCharts.config.typography[:size]
|
|
1000
|
+
end
|
|
1001
|
+
|
|
1002
|
+
def font_size_sm
|
|
1003
|
+
RailsuiCharts.config.typography[:size_sm]
|
|
1004
|
+
end
|
|
1005
|
+
|
|
1006
|
+
def geometry(key)
|
|
1007
|
+
RailsuiCharts.config.geometry[key]
|
|
1008
|
+
end
|
|
1009
|
+
|
|
1010
|
+
def surface_color
|
|
1011
|
+
RailsuiCharts.config.colors[:surface] || "var(--rui-chart-surface, #ffffff)"
|
|
1012
|
+
end
|
|
1013
|
+
|
|
1014
|
+
def solid_color(key)
|
|
1015
|
+
resolve_var(config_color(key))
|
|
1016
|
+
end
|
|
1017
|
+
|
|
1018
|
+
# Apex resolves multi-series colour arrays before the Stimulus controller
|
|
1019
|
+
# gets a chance to swap CSS variables in, so these slots ship as hex.
|
|
1020
|
+
def resolve_var(value)
|
|
1021
|
+
value = value.to_s
|
|
1022
|
+
return value unless value.start_with?("var(")
|
|
1023
|
+
|
|
1024
|
+
value.match(/var\([^,]+,\s*([^)]+)\)/)&.captures&.first || value
|
|
1025
|
+
end
|
|
1026
|
+
end
|
|
1027
|
+
end
|