lightning_ui_kit 0.4.0 → 0.4.1

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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/vendor/lightning_ui_kit.css +21 -12
  3. data/app/assets/vendor/lightning_ui_kit.js +6 -6
  4. data/app/components/lightning_ui_kit/chart_component.html.erb +31 -9
  5. data/app/components/lightning_ui_kit/chart_component.rb +317 -46
  6. data/app/components/lightning_ui_kit/description_list/item_component.html.erb +2 -12
  7. data/app/components/lightning_ui_kit/description_list/item_component.rb +28 -1
  8. data/app/components/lightning_ui_kit/description_list_component.html.erb +2 -2
  9. data/app/components/lightning_ui_kit/description_list_component.rb +27 -1
  10. data/app/components/lightning_ui_kit/file_input_component.rb +1 -1
  11. data/app/components/lightning_ui_kit/table_component.html.erb +102 -100
  12. data/app/components/lightning_ui_kit/table_component.rb +26 -4
  13. data/app/javascript/lightning_ui_kit/controllers/chart_controller.js +61 -5
  14. data/config/deploy.yml +1 -1
  15. data/lib/lightning_ui_kit/version.rb +1 -1
  16. data/vendor/bundle/ruby/3.4.0/cache/ostruct-0.6.3.gem +0 -0
  17. data/vendor/bundle/ruby/3.4.0/extensions/x86_64-linux/3.4.0/bigdecimal-4.1.2/gem_make.out +5 -5
  18. data/vendor/bundle/ruby/3.4.0/extensions/x86_64-linux/3.4.0/bigdecimal-4.1.2/mkmf.log +2 -2
  19. data/vendor/bundle/ruby/3.4.0/extensions/x86_64-linux/3.4.0/date-3.4.1/gem_make.out +5 -5
  20. data/vendor/bundle/ruby/3.4.0/extensions/x86_64-linux/3.4.0/erb-5.0.2/gem_make.out +5 -5
  21. data/vendor/bundle/ruby/3.4.0/extensions/x86_64-linux/3.4.0/io-console-0.8.1/gem_make.out +5 -5
  22. data/vendor/bundle/ruby/3.4.0/extensions/x86_64-linux/3.4.0/json-2.18.1/gem_make.out +5 -5
  23. data/vendor/bundle/ruby/3.4.0/extensions/x86_64-linux/3.4.0/nio4r-2.7.4/gem_make.out +5 -5
  24. data/vendor/bundle/ruby/3.4.0/extensions/x86_64-linux/3.4.0/prism-1.9.0/gem_make.out +5 -5
  25. data/vendor/bundle/ruby/3.4.0/extensions/x86_64-linux/3.4.0/psych-5.2.6/gem_make.out +5 -5
  26. data/vendor/bundle/ruby/3.4.0/extensions/x86_64-linux/3.4.0/racc-1.8.1/gem_make.out +5 -5
  27. data/vendor/bundle/ruby/3.4.0/extensions/x86_64-linux/3.4.0/stringio-3.1.7/gem_make.out +5 -5
  28. data/vendor/bundle/ruby/3.4.0/extensions/x86_64-linux/3.4.0/websocket-driver-0.8.0/gem_make.out +5 -5
  29. data/vendor/bundle/ruby/3.4.0/gems/ostruct-0.6.3/BSDL +22 -0
  30. data/vendor/bundle/ruby/3.4.0/gems/ostruct-0.6.3/COPYING +56 -0
  31. data/vendor/bundle/ruby/3.4.0/gems/ostruct-0.6.3/Gemfile +10 -0
  32. data/vendor/bundle/ruby/3.4.0/gems/ostruct-0.6.3/README.md +69 -0
  33. data/vendor/bundle/ruby/3.4.0/gems/ostruct-0.6.3/Rakefile +18 -0
  34. data/vendor/bundle/ruby/3.4.0/gems/ostruct-0.6.3/bin/console +14 -0
  35. data/vendor/bundle/ruby/3.4.0/gems/ostruct-0.6.3/bin/setup +8 -0
  36. data/vendor/bundle/ruby/3.4.0/gems/ostruct-0.6.3/lib/ostruct.rb +492 -0
  37. data/vendor/bundle/ruby/3.4.0/gems/ostruct-0.6.3/ostruct.gemspec +26 -0
  38. data/vendor/bundle/ruby/3.4.0/specifications/ostruct-0.6.3.gemspec +22 -0
  39. metadata +12 -1
@@ -8,7 +8,7 @@
8
8
  <div class="lui:mb-3 lui:flex lui:flex-wrap lui:items-center lui:gap-4">
9
9
  <% series.each do |s| %>
10
10
  <div class="lui:flex lui:items-center lui:gap-1.5 lui:text-xs lui:text-foreground-muted">
11
- <span class="lui:size-2.5 lui:rounded-sm" style="background-color: <%= s[:color] %>;"></span>
11
+ <span class="lui:size-2 lui:shrink-0 lui:rounded-[2px]" style="background-color: <%= s[:color] %>;"></span>
12
12
  <%= s[:label] %>
13
13
  </div>
14
14
  <% end %>
@@ -20,8 +20,8 @@
20
20
  <defs>
21
21
  <% series.each_with_index do |s, i| %>
22
22
  <linearGradient id="<%= gradient_id(i) %>" x1="0" y1="0" x2="0" y2="1">
23
- <stop offset="0%" stop-color="<%= s[:color] %>" stop-opacity="0.35" />
24
- <stop offset="100%" stop-color="<%= s[:color] %>" stop-opacity="0.02" />
23
+ <stop offset="5%" stop-color="<%= s[:color] %>" stop-opacity="0.8" />
24
+ <stop offset="95%" stop-color="<%= s[:color] %>" stop-opacity="0.1" />
25
25
  </linearGradient>
26
26
  <% end %>
27
27
  </defs>
@@ -37,26 +37,46 @@
37
37
  <% y_ticks.each do |tick| %>
38
38
  <text x="<%= plot_left - 8 %>" y="<%= (tick[:y] + 4).round(2) %>" text-anchor="end" font-size="11" fill="var(--lui-theme-foreground-muted)"><%= tick[:label] %></text>
39
39
  <% end %>
40
- <% x_positions.each do |pos| %>
40
+ <% x_axis_labels.each do |pos| %>
41
41
  <text x="<%= pos[:x].round(2) %>" y="<%= view_height - 8 %>" text-anchor="middle" font-size="11" fill="var(--lui-theme-foreground-muted)"><%= pos[:label] %></text>
42
42
  <% end %>
43
43
  <% end %>
44
44
 
45
+ <g data-lui-chart-target="cursor" style="display: none">
46
+ <% if bar? %>
47
+ <rect data-role="cursor-band" x="0" y="<%= plot_top %>" width="0" height="<%= (plot_bottom - plot_top).round(2) %>" fill="var(--lui-theme-surface-hover)" />
48
+ <% else %>
49
+ <line data-role="cursor-line" x1="0" x2="0" y1="<%= plot_top %>" y2="<%= plot_bottom %>" stroke="var(--lui-theme-border-hover)" stroke-width="1" stroke-dasharray="3 3" />
50
+ <% end %>
51
+ </g>
52
+
45
53
  <% if bar? %>
46
54
  <% bars.each do |bar| %>
47
55
  <path d="<%= bar_path(bar) %>" fill="<%= bar[:color] %>" data-role="bar" />
48
56
  <% end %>
49
57
  <% else %>
50
58
  <% series.each_with_index do |s, i| %>
51
- <% points = line_points(s) %>
59
+ <% segments = line_segments(s) %>
60
+ <% strokes = segments.select { |points| points.size > 1 } %>
52
61
  <% if area? %>
53
- <path d="<%= area_path(points) %>" fill="url(#<%= gradient_id(i) %>)" data-role="area" />
62
+ <% strokes.each do |points| %>
63
+ <path d="<%= area_path(points) %>" fill="url(#<%= gradient_id(i) %>)" fill-opacity="0.4" data-role="area" />
64
+ <% end %>
65
+ <% end %>
66
+ <% strokes.each do |points| %>
67
+ <path d="<%= line_path(points) %>" fill="none" stroke="<%= s[:color] %>" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-role="line" />
54
68
  <% end %>
55
- <polyline points="<%= polyline(points) %>" fill="none" stroke="<%= s[:color] %>" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-role="line" />
56
- <% points.each do |x, y| %>
69
+ <% dots = show_dots? ? segments : segments.select { |points| points.size == 1 } %>
70
+ <% dots.flatten(1).each do |x, y| %>
57
71
  <circle cx="<%= x.round(2) %>" cy="<%= y.round(2) %>" r="3" fill="var(--lui-theme-surface)" stroke="<%= s[:color] %>" stroke-width="2" data-role="point" />
58
72
  <% end %>
59
73
  <% end %>
74
+
75
+ <g data-lui-chart-target="markers" style="display: none">
76
+ <% series.each do |s| %>
77
+ <circle data-role="active-dot" cx="0" cy="0" r="4" fill="<%= s[:color] %>" stroke="var(--lui-theme-surface)" stroke-width="2" />
78
+ <% end %>
79
+ </g>
60
80
  <% end %>
61
81
 
62
82
  <% hover_columns.each do |col| %>
@@ -70,11 +90,13 @@
70
90
  data-role="column"
71
91
  data-label="<%= col[:label] %>"
72
92
  data-payload="<%= col[:payload] %>"
93
+ data-cursor-x="<%= col[:cursor_x].round(2) %>"
94
+ data-markers="<%= col[:markers] %>"
73
95
  data-action="mouseenter->lui-chart#show mousemove->lui-chart#move mouseleave->lui-chart#hide"
74
96
  />
75
97
  <% end %>
76
98
  </svg>
77
99
 
78
- <div data-lui-chart-target="tooltip" class="lui:hidden lui:pointer-events-none lui:absolute lui:z-50 lui:flex lui:min-w-[8rem] lui:flex-col lui:gap-1 lui:rounded-lg lui:border lui:border-border lui:bg-surface lui:px-2.5 lui:py-1.5 lui:text-xs lui:shadow-md"></div>
100
+ <div data-lui-chart-target="tooltip" class="lui:hidden lui:pointer-events-none lui:absolute lui:z-50 lui:grid lui:min-w-[8rem] lui:items-start lui:gap-1.5 lui:rounded-lg lui:border lui:border-border lui:bg-surface lui:px-2.5 lui:py-1.5 lui:text-xs lui:shadow-xl"></div>
79
101
  <% end %>
80
102
  <% end %>
@@ -1,12 +1,31 @@
1
1
  class LightningUiKit::ChartComponent < LightningUiKit::BaseComponent
2
2
  TYPES = %i[bar line area].freeze
3
+ UNITS = %i[decimal percent bytes count duration_ms].freeze
4
+ CURVES = %i[monotone linear].freeze
3
5
 
4
6
  VIEW_WIDTH = 640
5
7
  PAD_RIGHT = 16
6
8
  PAD_TOP = 12
7
9
  AXIS_PAD_LEFT = 44
8
10
 
9
- def initialize(data:, type: :bar, series: nil, x_key: :label, height: 260, show_grid: true, show_legend: true, show_axis: true, **options)
11
+ DEFAULT_MAX_X_LABELS = 8
12
+ Y_TICK_STEPS = 4
13
+ # Above this many points a dot per value reads as a bead string, so dots:
14
+ # :auto drops them and relies on the hover marker instead.
15
+ AUTO_DOT_LIMIT = 12
16
+ BAR_RADIUS = 4.0
17
+ # Share of the category band the bars occupy, and of each slot a bar fills.
18
+ BAR_BAND = 0.8
19
+ BAR_FILL = 0.9
20
+ # Rendered in place of a value for missing (nil) data points.
21
+ NIL_LABEL = "–".freeze
22
+ BYTE_UNITS = %w[B KiB MiB GiB TiB PiB EiB].freeze
23
+ DECIMAL_MULTIPLIERS = [1, 2, 5, 10].freeze
24
+ BINARY_MULTIPLIERS = [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024].freeze
25
+
26
+ def initialize(data:, type: :bar, series: nil, x_key: :label, height: 260, show_grid: true, show_legend: true, show_axis: true,
27
+ unit: :decimal, y_format: nil, max_x_labels: DEFAULT_MAX_X_LABELS, y_min: nil, y_max: nil, span_gaps: false,
28
+ curve: :monotone, dots: :auto, **options)
10
29
  @data = Array(data).map { |row| row.respond_to?(:to_h) ? row.to_h.symbolize_keys : row }
11
30
  @type = TYPES.include?(type) ? type : :bar
12
31
  @x_key = x_key.to_sym
@@ -14,6 +33,14 @@ class LightningUiKit::ChartComponent < LightningUiKit::BaseComponent
14
33
  @show_grid = show_grid
15
34
  @show_legend = show_legend
16
35
  @show_axis = show_axis
36
+ @unit = UNITS.include?(unit&.to_sym) ? unit.to_sym : :decimal
37
+ @y_format = y_format
38
+ @max_x_labels = max_x_labels
39
+ @y_min = y_min
40
+ @y_max = y_max
41
+ @span_gaps = span_gaps
42
+ @curve = (curve&.to_sym == :linear) ? :linear : :monotone
43
+ @dots = dots
17
44
  @series = build_series(series)
18
45
  @options = options
19
46
  end
@@ -44,6 +71,20 @@ class LightningUiKit::ChartComponent < LightningUiKit::BaseComponent
44
71
  @show_legend && @series.any?
45
72
  end
46
73
 
74
+ def span_gaps?
75
+ @span_gaps
76
+ end
77
+
78
+ def smooth?
79
+ @curve == :monotone
80
+ end
81
+
82
+ # shadcn draws line/area series without per-point dots; :auto keeps them only
83
+ # while the series is sparse enough for them to read as markers.
84
+ def show_dots?
85
+ (@dots == :auto) ? count <= AUTO_DOT_LIMIT : !!@dots
86
+ end
87
+
47
88
  def classes
48
89
  merge_classes([
49
90
  "lui:relative lui:w-full lui:text-foreground",
@@ -89,12 +130,37 @@ class LightningUiKit::ChartComponent < LightningUiKit::BaseComponent
89
130
 
90
131
  def x_label(row) = row[@x_key]
91
132
 
92
- def value_for(row, series) = row[series[:key]].to_f
133
+ # nil for a missing data point so lines can break and bars can be skipped.
134
+ # Anything unparseable ("n/a", "-", "") counts as missing rather than zero.
135
+ def value_for(row, series)
136
+ raw = row[series[:key]]
137
+ return nil if raw.nil?
138
+ return raw.to_f if raw.is_a?(Numeric)
139
+
140
+ Float(raw.to_s.strip, exception: false)
141
+ end
142
+
143
+ # --- y domain ---
144
+
145
+ def domain_min
146
+ @domain_min ||= (@y_min || default_domain_min).to_f
147
+ end
148
+
149
+ def domain_max
150
+ @domain_max ||= begin
151
+ top = @y_max ? @y_max.to_f : nice_domain_max
152
+ (top > domain_min) ? top : domain_min + 1.0
153
+ end
154
+ end
93
155
 
94
156
  def y_for(value)
95
- return plot_bottom if chart_max.zero?
157
+ return plot_bottom if value.nil?
158
+
159
+ span = domain_max - domain_min
160
+ return plot_bottom if span <= 0
96
161
 
97
- plot_bottom - (value.to_f / chart_max) * plot_height
162
+ ratio = ((value.to_f - domain_min) / span).clamp(0.0, 1.0)
163
+ plot_bottom - ratio * plot_height
98
164
  end
99
165
 
100
166
  def point_x(index)
@@ -103,10 +169,12 @@ class LightningUiKit::ChartComponent < LightningUiKit::BaseComponent
103
169
  plot_left + (index.to_f / (count - 1)) * plot_width
104
170
  end
105
171
 
106
- def y_ticks(steps = 4)
172
+ def y_ticks(steps = Y_TICK_STEPS)
173
+ span = domain_max - domain_min
174
+
107
175
  (0..steps).map do |i|
108
- value = chart_max * i / steps
109
- {label: format_number(value), y: y_for(value)}
176
+ value = domain_min + span * i / steps
177
+ {label: format_value(value), y: y_for(value)}
110
178
  end
111
179
  end
112
180
 
@@ -117,26 +185,44 @@ class LightningUiKit::ChartComponent < LightningUiKit::BaseComponent
117
185
  end
118
186
  end
119
187
 
188
+ # Evenly spaced subset of x_positions (first and last always kept) so dense
189
+ # series don't render overlapping axis text. Data points are unaffected.
190
+ def x_axis_labels
191
+ positions = x_positions
192
+ label_indices(positions.size).map { |i| positions[i] }
193
+ end
194
+
195
+ # Bars are measured from zero (or the nearest edge of the domain when zero
196
+ # falls outside it), so a negative value hangs below the baseline instead of
197
+ # growing upward from the domain floor.
198
+ def bar_baseline = 0.0.clamp(domain_min, domain_max)
199
+
120
200
  def bars
121
201
  group_width = bar_group_width
122
- inner = group_width * 0.2
123
- band = group_width - inner
124
- bar_width = @series.empty? ? band : band / @series.size
202
+ slot = bar_slot_width
203
+ width = slot * BAR_FILL
204
+ # Bars are centred in their category band so they line up with the tick
205
+ # label, the hover cursor and the grid.
206
+ cluster = (@series.size - 1) * slot + width
207
+ base_y = y_for(bar_baseline)
125
208
 
126
209
  @data.each_with_index.flat_map do |row, i|
127
- group_x = plot_left + i * group_width + inner / 2.0
128
- @series.each_with_index.map do |s, si|
210
+ group_x = plot_left + i * group_width + (group_width - cluster) / 2.0
211
+ @series.each_with_index.filter_map do |s, si|
129
212
  value = value_for(row, s)
213
+ next if value.nil?
214
+
130
215
  y = y_for(value)
131
216
  {
132
- x: group_x + si * bar_width,
133
- y: y,
134
- width: bar_width * 0.9,
135
- height: [plot_bottom - y, 0].max,
217
+ x: group_x + si * slot,
218
+ y: [y, base_y].min,
219
+ width: width,
220
+ height: (y - base_y).abs,
221
+ negative: y > base_y,
136
222
  color: s[:color],
137
223
  label: x_label(row),
138
224
  series_label: s[:label],
139
- value: format_number(value)
225
+ value: format_value(value)
140
226
  }
141
227
  end
142
228
  end
@@ -144,46 +230,71 @@ class LightningUiKit::ChartComponent < LightningUiKit::BaseComponent
144
230
 
145
231
  def line_points(series)
146
232
  @data.each_with_index.map do |row, i|
147
- [point_x(i), y_for(value_for(row, series))]
233
+ value = value_for(row, series)
234
+ value.nil? ? nil : [point_x(i), y_for(value)]
148
235
  end
149
236
  end
150
237
 
151
- def polyline(points)
152
- points.map { |x, y| "#{x.round(2)},#{y.round(2)}" }.join(" ")
238
+ # Contiguous runs of plottable points. A nil value ends the current run so the
239
+ # line shows a gap, unless span_gaps: was requested.
240
+ def line_segments(series)
241
+ points = line_points(series)
242
+ return [points.compact].reject(&:empty?) if span_gaps?
243
+
244
+ points.chunk_while { |a, b| !a.nil? && !b.nil? }
245
+ .map(&:compact)
246
+ .reject(&:empty?)
247
+ end
248
+
249
+ # Stroke path for one contiguous run of points.
250
+ def line_path(points)
251
+ return "" if points.empty?
252
+
253
+ "M #{r2(points.first[0])} #{r2(points.first[1])} #{curve_commands(points)}".strip
153
254
  end
154
255
 
256
+ # The stroke path closed down to the baseline on both ends.
155
257
  def area_path(points)
156
258
  return "" if points.empty?
157
259
 
158
- segments = ["M #{points.first[0].round(2)} #{plot_bottom.round(2)}"]
159
- points.each { |x, y| segments << "L #{x.round(2)} #{y.round(2)}" }
160
- segments << "L #{points.last[0].round(2)} #{plot_bottom.round(2)} Z"
161
- segments.join(" ")
260
+ first = points.first
261
+ last = points.last
262
+ [
263
+ "M #{r2(first[0])} #{r2(plot_bottom)}",
264
+ "L #{r2(first[0])} #{r2(first[1])}",
265
+ curve_commands(points),
266
+ "L #{r2(last[0])} #{r2(plot_bottom)}",
267
+ "Z"
268
+ ].reject(&:empty?).join(" ")
162
269
  end
163
270
 
164
- # Path for a bar with rounded top corners and a square base.
271
+ # Path for a bar with a square base at the baseline and rounded corners on the
272
+ # free end - the top for positive values, the bottom for negative ones.
165
273
  def bar_path(bar)
166
274
  x = bar[:x]
167
- y = bar[:y]
168
275
  w = bar[:width]
169
276
  h = bar[:height]
170
277
  return "" if h <= 0 || w <= 0
171
278
 
172
- r = [6.0, w / 2.0, h].min
279
+ r = [BAR_RADIUS, w / 2.0, h].min
173
280
  x2 = x + w
174
- bottom = y + h
281
+ base = bar[:negative] ? bar[:y] : bar[:y] + h
282
+ tip = bar[:negative] ? bar[:y] + h : bar[:y]
283
+ # Signed step from the tip back toward the baseline.
284
+ inset = bar[:negative] ? -r : r
175
285
  [
176
- "M #{r2(x)} #{r2(bottom)}",
177
- "L #{r2(x)} #{r2(y + r)}",
178
- "Q #{r2(x)} #{r2(y)} #{r2(x + r)} #{r2(y)}",
179
- "L #{r2(x2 - r)} #{r2(y)}",
180
- "Q #{r2(x2)} #{r2(y)} #{r2(x2)} #{r2(y + r)}",
181
- "L #{r2(x2)} #{r2(bottom)}",
286
+ "M #{r2(x)} #{r2(base)}",
287
+ "L #{r2(x)} #{r2(tip + inset)}",
288
+ "Q #{r2(x)} #{r2(tip)} #{r2(x + r)} #{r2(tip)}",
289
+ "L #{r2(x2 - r)} #{r2(tip)}",
290
+ "Q #{r2(x2)} #{r2(tip)} #{r2(x2)} #{r2(tip + inset)}",
291
+ "L #{r2(x2)} #{r2(base)}",
182
292
  "Z"
183
293
  ].join(" ")
184
294
  end
185
295
 
186
- # Invisible full-height bands (one per category) that drive the hover tooltip.
296
+ # Invisible full-height bands (one per category) that drive the hover tooltip,
297
+ # cursor and active dots.
187
298
  def hover_columns
188
299
  step = (count > 1) ? plot_width.to_f / (count - 1) : plot_width.to_f
189
300
 
@@ -197,29 +308,145 @@ class LightningUiKit::ChartComponent < LightningUiKit::BaseComponent
197
308
  end
198
309
  left = [x, plot_left].max
199
310
  right = [x + w, plot_right].min
200
- {x: left, width: [right - left, 0].max, label: x_label(row), payload: payload_for(i)}
311
+ {
312
+ x: left,
313
+ width: [right - left, 0].max,
314
+ cursor_x: bar? ? plot_left + i * bar_group_width + bar_group_width / 2.0 : point_x(i),
315
+ markers: markers_for(i),
316
+ label: x_label(row),
317
+ payload: payload_for(i)
318
+ }
201
319
  end
202
320
  end
203
321
 
322
+ # Y position of each series at the given index, or null where the value is
323
+ # missing, so the controller can place one active dot per series on hover.
324
+ def markers_for(index)
325
+ row = @data[index]
326
+ @series.map do |s|
327
+ value = value_for(row, s)
328
+ value.nil? ? nil : r2(y_for(value))
329
+ end.to_json
330
+ end
331
+
204
332
  def payload_for(index)
205
333
  row = @data[index]
206
334
  @series.map do |s|
207
- {label: s[:label], value: format_number(value_for(row, s)), color: s[:color]}
335
+ {label: s[:label], value: format_value(value_for(row, s)), color: s[:color]}
208
336
  end.to_json
209
337
  end
210
338
 
339
+ # Applies y_format: when given, otherwise the unit: preset. Shared by axis
340
+ # ticks and tooltip payloads so the two always agree.
341
+ def format_value(value)
342
+ return NIL_LABEL if value.nil?
343
+ return @y_format.call(value).to_s if @y_format.respond_to?(:call)
344
+
345
+ case @unit
346
+ when :percent then format_percent(value.to_f)
347
+ when :bytes then format_bytes(value.to_f)
348
+ when :count then format_count(value.to_f)
349
+ when :duration_ms then format_duration_ms(value.to_f)
350
+ else format_decimal(value.to_f)
351
+ end
352
+ end
353
+
211
354
  private
212
355
 
213
356
  def r2(number)
214
357
  number.round(2)
215
358
  end
216
359
 
217
- def chart_max
218
- @chart_max ||= begin
219
- values = @data.flat_map { |row| @series.map { |s| value_for(row, s) } }
220
- max = values.max || 0
221
- (max <= 0) ? 1.0 : max.to_f
360
+ # Segment commands after the opening `M`, honouring the curve: option.
361
+ def curve_commands(points)
362
+ return "" if points.size < 2
363
+ return points.drop(1).map { |x, y| "L #{r2(x)} #{r2(y)}" }.join(" ") unless smooth?
364
+
365
+ tangents = monotone_tangents(points)
366
+ points.each_cons(2).each_with_index.map do |(from, to), i|
367
+ dx = (to[0] - from[0]) / 3.0
368
+ c1 = [from[0] + dx, from[1] + tangents[i] * dx]
369
+ c2 = [to[0] - dx, to[1] - tangents[i + 1] * dx]
370
+ "C #{r2(c1[0])} #{r2(c1[1])} #{r2(c2[0])} #{r2(c2[1])} #{r2(to[0])} #{r2(to[1])}"
371
+ end.join(" ")
372
+ end
373
+
374
+ # Fritsch-Carlson slopes: a monotone cubic that never overshoots the data,
375
+ # matching the "monotone"/"natural" curve shadcn uses for lines and areas.
376
+ def monotone_tangents(points)
377
+ slopes = points.each_cons(2).map do |(x1, y1), (x2, y2)|
378
+ dx = x2 - x1
379
+ dx.zero? ? 0.0 : (y2 - y1) / dx
222
380
  end
381
+
382
+ tangents = [slopes.first]
383
+ slopes.each_cons(2) do |left, right|
384
+ tangents << ((left * right <= 0) ? 0.0 : (left + right) / 2.0)
385
+ end
386
+ tangents << slopes.last
387
+
388
+ slopes.each_with_index do |slope, i|
389
+ if slope.zero?
390
+ tangents[i] = 0.0
391
+ tangents[i + 1] = 0.0
392
+ next
393
+ end
394
+
395
+ a = tangents[i] / slope
396
+ b = tangents[i + 1] / slope
397
+ magnitude = a * a + b * b
398
+ next if magnitude <= 9
399
+
400
+ scale = 3.0 * slope / Math.sqrt(magnitude)
401
+ tangents[i] = scale * a
402
+ tangents[i + 1] = scale * b
403
+ end
404
+
405
+ tangents
406
+ end
407
+
408
+ def data_values
409
+ @data_values ||= @data.flat_map { |row| @series.map { |s| value_for(row, s) } }.compact
410
+ end
411
+
412
+ def default_domain_min
413
+ min = data_values.min
414
+ (min && min < 0) ? -nice_step(min.abs) : 0.0
415
+ end
416
+
417
+ # Round the data max up so that the axis lands on 1/2/5 × 10ⁿ values.
418
+ def nice_domain_max(steps = Y_TICK_STEPS)
419
+ max = data_values.max
420
+ span = max ? max - domain_min : 0
421
+ return domain_min + 1.0 if span <= 0
422
+
423
+ domain_min + nice_step(span / steps) * steps
424
+ end
425
+
426
+ # Smallest "round" value >= the given magnitude: 1/2/5 × 10ⁿ normally, or
427
+ # 1/2/4/…/1024 × 1024ⁿ for :bytes so ticks land on whole KiB/MiB/GiB.
428
+ def nice_step(value)
429
+ return 1.0 if value <= 0
430
+
431
+ radix, multipliers = (@unit == :bytes) ? [1024, BINARY_MULTIPLIERS] : [10, DECIMAL_MULTIPLIERS]
432
+ exponent = Math.log(value, radix).floor
433
+ base = radix.to_f**exponent
434
+ fraction = value / base
435
+
436
+ step = (multipliers.find { |m| fraction <= m } || multipliers.last) * base
437
+ # :count renders whole numbers, so a sub-integer step would repeat labels.
438
+ (@unit == :count) ? [step, 1.0].max : step
439
+ end
440
+
441
+ def label_indices(total)
442
+ return (0...total).to_a unless @max_x_labels
443
+
444
+ limit = @max_x_labels.to_i
445
+ return (0...total).to_a if limit <= 0 || total <= limit
446
+ return [0] if limit == 1
447
+
448
+ step = (total - 1) / (limit - 1).to_f
449
+ (0...limit).map { |i| (i * step).round }.uniq
223
450
  end
224
451
 
225
452
  def bar_group_width
@@ -228,6 +455,12 @@ class LightningUiKit::ChartComponent < LightningUiKit::BaseComponent
228
455
  plot_width.to_f / count
229
456
  end
230
457
 
458
+ # Horizontal room for one series within a category band.
459
+ def bar_slot_width
460
+ band = bar_group_width * BAR_BAND
461
+ @series.empty? ? band : band / @series.size
462
+ end
463
+
231
464
  def build_series(series)
232
465
  definitions =
233
466
  if series
@@ -250,14 +483,52 @@ class LightningUiKit::ChartComponent < LightningUiKit::BaseComponent
250
483
  return [] if @data.empty?
251
484
 
252
485
  candidates = @data.first.keys - [@x_key]
253
- numeric = candidates.select { |key| @data.all? { |row| row[key].is_a?(Numeric) } }
486
+ numeric = candidates.select { |key| @data.all? { |row| row[key].is_a?(Numeric) || row[key].nil? } }
254
487
  return numeric unless numeric.empty?
255
488
 
256
489
  @data.first.key?(:value) ? [:value] : []
257
490
  end
258
491
 
259
- def format_number(value)
260
- rounded = value.to_f.round(2)
261
- (rounded % 1).zero? ? rounded.to_i.to_s : rounded.to_s
492
+ def trim(number)
493
+ (number % 1).zero? ? number.to_i.to_s : number.to_s
494
+ end
495
+
496
+ def format_decimal(value)
497
+ trim(value.round(2))
498
+ end
499
+
500
+ def format_percent(value)
501
+ # Sub-1% values need a second decimal or neighbouring ticks collapse.
502
+ decimals = (!value.zero? && value.abs < 1) ? 2 : 1
503
+ "#{trim(value.round(decimals))}%"
504
+ end
505
+
506
+ def format_bytes(value)
507
+ return "0 B" if value.zero?
508
+
509
+ sign = value.negative? ? "-" : ""
510
+ magnitude = value.abs
511
+ exponent = Math.log(magnitude, 1024).floor.clamp(0, BYTE_UNITS.size - 1)
512
+ scaled = magnitude / (1024.0**exponent)
513
+
514
+ "#{sign}#{trim(scaled.round(exponent.zero? ? 0 : 2))} #{BYTE_UNITS[exponent]}"
515
+ end
516
+
517
+ def format_count(value)
518
+ ActiveSupport::NumberHelper.number_to_delimited(value.round)
519
+ end
520
+
521
+ def format_duration_ms(value)
522
+ magnitude = value.abs
523
+
524
+ if magnitude < 1
525
+ "#{trim(value.round(2))} ms"
526
+ elsif magnitude < 1_000
527
+ "#{trim(value.round((magnitude < 10) ? 1 : 0))} ms"
528
+ elsif magnitude < 60_000
529
+ "#{trim((value / 1_000.0).round(2))} s"
530
+ else
531
+ "#{trim((value / 60_000.0).round(2))} min"
532
+ end
262
533
  end
263
534
  end
@@ -1,13 +1,3 @@
1
- <dt class="lui:col-start-1 lui:border-t lui:border-border-subtle lui:pt-3 lui:text-foreground-muted lui:first:border-none lui:sm:border-t lui:sm:border-border-subtle lui:sm:py-3 lui:flex lui:items-center">
2
- <%= @label %>
3
- </dt>
1
+ <%= tag.dt(@label, class: label_classes) %>
4
2
 
5
- <dd class="lui:pt-1 lui:pb-3 lui:text-foreground lui:sm:border-t lui:sm:border-border-subtle lui:sm:py-3 lui:sm:nth-2:border-none lui:flex lui:items-center">
6
- <div class="lui:w-full">
7
- <% if @value %>
8
- <%= @value %>
9
- <% else %>
10
- <%= content %>
11
- <% end %>
12
- </div>
13
- </dd>
3
+ <%= tag.dd(value, class: value_classes, data: data) %>
@@ -1,7 +1,34 @@
1
1
  class LightningUiKit::DescriptionList::ItemComponent < LightningUiKit::BaseComponent
2
- def initialize(label:, value: nil, **options)
2
+ LABEL_CLASSES = "lui:col-start-1 lui:min-w-0 lui:break-words lui:border-t lui:border-border-subtle lui:pt-3 lui:text-foreground-muted lui:first:border-none lui:sm:border-t lui:sm:border-border-subtle lui:sm:py-3"
3
+ VALUE_CLASSES = "lui:min-w-0 lui:break-words lui:pt-1 lui:pb-3 lui:text-foreground lui:sm:border-t lui:sm:border-border-subtle lui:sm:py-3 lui:sm:first-of-type:border-none"
4
+
5
+ def initialize(label:, value: nil, body: nil, **options)
3
6
  @label = label
4
7
  @value = value
8
+ @body = body
5
9
  @options = options
6
10
  end
11
+
12
+ def label_classes
13
+ merge_classes([LABEL_CLASSES, @options[:label_class]].compact.join(" "))
14
+ end
15
+
16
+ def value_classes
17
+ merge_classes([VALUE_CLASSES, @options[:class]].compact.join(" "))
18
+ end
19
+
20
+ def data
21
+ @options[:data] || {}
22
+ end
23
+
24
+ def value
25
+ return @value unless @value.nil?
26
+ return content if @body.nil?
27
+
28
+ # Blocks that build markup write to the output buffer; blocks that just
29
+ # return a value (an Integer, a Date, ...) leave it empty — keep both.
30
+ returned = nil
31
+ buffer = view_context.with_output_buffer { returned = @body.call }
32
+ buffer.presence || returned
33
+ end
7
34
  end
@@ -1,5 +1,5 @@
1
- <dl class="lui:grid lui:grid-cols-1 lui:text-base/6 lui:sm:grid-cols-[min(50%,--spacing(80))_auto] lui:sm:text-sm/6">
1
+ <%= tag.dl(class: classes, data: data) do %>
2
2
  <% items.each do |item| %>
3
3
  <%= item %>
4
4
  <% end %>
5
- </dl>
5
+ <% end %>
@@ -1,3 +1,29 @@
1
1
  class LightningUiKit::DescriptionListComponent < LightningUiKit::BaseComponent
2
- renders_many :items, LightningUiKit::DescriptionList::ItemComponent
2
+ renders_many :items, ->(label:, value: nil, body: nil, **options) do
3
+ LightningUiKit::DescriptionList::ItemComponent.new(label: label, value: value, body: body, **options)
4
+ end
5
+
6
+ # ViewComponent funnels slot blocks through Rails' `capture`, which discards
7
+ # non-String return values (Integers, Dates, ...), rendering blank values.
8
+ # Route the block around that wrapper via the body: argument so the item can
9
+ # evaluate it instead.
10
+ alias_method :__with_item_slot, :with_item
11
+ def with_item(label:, value: nil, **options, &block)
12
+ __with_item_slot(label: label, value: value, body: block, **options)
13
+ end
14
+
15
+ def initialize(**options)
16
+ @options = options
17
+ end
18
+
19
+ def classes
20
+ merge_classes([
21
+ "lui:grid lui:grid-cols-1 lui:text-base/6 lui:sm:grid-cols-[min(50%,--spacing(80))_minmax(0,auto)] lui:sm:text-sm/6",
22
+ @options[:class]
23
+ ].compact.join(" "))
24
+ end
25
+
26
+ def data
27
+ @options[:data] || {}
28
+ end
3
29
  end