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.
@@ -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