Class: LightningUiKit::ChartComponent

Inherits:
BaseComponent
  • Object
show all
Defined in:
app/components/lightning_ui_kit/chart_component.rb

Constant Summary collapse

TYPES =
%i[bar line area].freeze
UNITS =
%i[decimal percent bytes count duration_ms].freeze
CURVES =
%i[monotone linear].freeze
VIEW_WIDTH =
640
PAD_RIGHT =
16
PAD_TOP =
12
AXIS_PAD_LEFT =
44
DEFAULT_MAX_X_LABELS =
8
Y_TICK_STEPS =
4
AUTO_DOT_LIMIT =

Above this many points a dot per value reads as a bead string, so dots: :auto drops them and relies on the hover marker instead.

12
BAR_RADIUS =
4.0
BAR_BAND =

Share of the category band the bars occupy, and of each slot a bar fills.

0.8
BAR_FILL =
0.9
NIL_LABEL =

Rendered in place of a value for missing (nil) data points.

"".freeze
BYTE_UNITS =
%w[B KiB MiB GiB TiB PiB EiB].freeze
DECIMAL_MULTIPLIERS =
[1, 2, 5, 10].freeze
BINARY_MULTIPLIERS =
[1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(data:, type: :bar, series: nil, x_key: :label, height: 260, show_grid: true, show_legend: true, show_axis: true, unit: :decimal, y_format: nil, max_x_labels: DEFAULT_MAX_X_LABELS, y_min: nil, y_max: nil, span_gaps: false, curve: :monotone, dots: :auto, **options) ⇒ ChartComponent

Returns a new instance of ChartComponent.



26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# File 'app/components/lightning_ui_kit/chart_component.rb', line 26

def initialize(data:, type: :bar, series: nil, x_key: :label, height: 260, show_grid: true, show_legend: true, show_axis: true,
  unit: :decimal, y_format: nil, max_x_labels: DEFAULT_MAX_X_LABELS, y_min: nil, y_max: nil, span_gaps: false,
  curve: :monotone, dots: :auto, **options)
  @data = Array(data).map { |row| row.respond_to?(:to_h) ? row.to_h.symbolize_keys : row }
  @type = TYPES.include?(type) ? type : :bar
  @x_key = x_key.to_sym
  @height = height.to_i
  @show_grid = show_grid
  @show_legend = show_legend
  @show_axis = show_axis
  @unit = UNITS.include?(unit&.to_sym) ? unit.to_sym : :decimal
  @y_format = y_format
  @max_x_labels = max_x_labels
  @y_min = y_min
  @y_max = y_max
  @span_gaps = span_gaps
  @curve = (curve&.to_sym == :linear) ? :linear : :monotone
  @dots = dots
  @series = build_series(series)
  @options = options
end

Instance Attribute Details

#heightObject (readonly)

Returns the value of attribute height.



48
49
50
# File 'app/components/lightning_ui_kit/chart_component.rb', line 48

def height
  @height
end

#seriesObject (readonly)

Returns the value of attribute series.



48
49
50
# File 'app/components/lightning_ui_kit/chart_component.rb', line 48

def series
  @series
end

#typeObject (readonly)

Returns the value of attribute type.



48
49
50
# File 'app/components/lightning_ui_kit/chart_component.rb', line 48

def type
  @type
end

Instance Method Details

#area?Boolean

Returns:

  • (Boolean)


54
55
56
# File 'app/components/lightning_ui_kit/chart_component.rb', line 54

def area?
  @type == :area
end

#area_path(points) ⇒ Object

The stroke path closed down to the baseline on both ends.



257
258
259
260
261
262
263
264
265
266
267
268
269
# File 'app/components/lightning_ui_kit/chart_component.rb', line 257

def area_path(points)
  return "" if points.empty?

  first = points.first
  last = points.last
  [
    "M #{r2(first[0])} #{r2(plot_bottom)}",
    "L #{r2(first[0])} #{r2(first[1])}",
    curve_commands(points),
    "L #{r2(last[0])} #{r2(plot_bottom)}",
    "Z"
  ].reject(&:empty?).join(" ")
end

#bar?Boolean

Returns:

  • (Boolean)


50
51
52
# File 'app/components/lightning_ui_kit/chart_component.rb', line 50

def bar?
  @type == :bar
end

#bar_baselineObject

Bars are measured from zero (or the nearest edge of the domain when zero falls outside it), so a negative value hangs below the baseline instead of growing upward from the domain floor.



198
# File 'app/components/lightning_ui_kit/chart_component.rb', line 198

def bar_baseline = 0.0.clamp(domain_min, domain_max)

#bar_path(bar) ⇒ Object

Path for a bar with a square base at the baseline and rounded corners on the free end - the top for positive values, the bottom for negative ones.



273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
# File 'app/components/lightning_ui_kit/chart_component.rb', line 273

def bar_path(bar)
  x = bar[:x]
  w = bar[:width]
  h = bar[:height]
  return "" if h <= 0 || w <= 0

  r = [BAR_RADIUS, w / 2.0, h].min
  x2 = x + w
  base = bar[:negative] ? bar[:y] : bar[:y] + h
  tip = bar[:negative] ? bar[:y] + h : bar[:y]
  # Signed step from the tip back toward the baseline.
  inset = bar[:negative] ? -r : r
  [
    "M #{r2(x)} #{r2(base)}",
    "L #{r2(x)} #{r2(tip + inset)}",
    "Q #{r2(x)} #{r2(tip)} #{r2(x + r)} #{r2(tip)}",
    "L #{r2(x2 - r)} #{r2(tip)}",
    "Q #{r2(x2)} #{r2(tip)} #{r2(x2)} #{r2(tip + inset)}",
    "L #{r2(x2)} #{r2(base)}",
    "Z"
  ].join(" ")
end

#barsObject



200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
# File 'app/components/lightning_ui_kit/chart_component.rb', line 200

def bars
  group_width = bar_group_width
  slot = bar_slot_width
  width = slot * BAR_FILL
  # Bars are centred in their category band so they line up with the tick
  # label, the hover cursor and the grid.
  cluster = (@series.size - 1) * slot + width
  base_y = y_for(bar_baseline)

  @data.each_with_index.flat_map do |row, i|
    group_x = plot_left + i * group_width + (group_width - cluster) / 2.0
    @series.each_with_index.filter_map do |s, si|
      value = value_for(row, s)
      next if value.nil?

      y = y_for(value)
      {
        x: group_x + si * slot,
        y: [y, base_y].min,
        width: width,
        height: (y - base_y).abs,
        negative: y > base_y,
        color: s[:color],
        label: x_label(row),
        series_label: s[:label],
        value: format_value(value)
      }
    end
  end
end

#chart_idObject



95
96
97
# File 'app/components/lightning_ui_kit/chart_component.rb', line 95

def chart_id
  @chart_id ||= "lui-chart-#{object_id}"
end

#classesObject



88
89
90
91
92
93
# File 'app/components/lightning_ui_kit/chart_component.rb', line 88

def classes
  merge_classes([
    "lui:relative lui:w-full lui:text-foreground",
    @options[:class]
  ].compact.join(" "))
end

#countObject



129
# File 'app/components/lightning_ui_kit/chart_component.rb', line 129

def count = @data.size

#data_attrsObject



107
108
109
# File 'app/components/lightning_ui_kit/chart_component.rb', line 107

def data_attrs
  @options[:data] || {}
end

#domain_maxObject



149
150
151
152
153
154
# File 'app/components/lightning_ui_kit/chart_component.rb', line 149

def domain_max
  @domain_max ||= begin
    top = @y_max ? @y_max.to_f : nice_domain_max
    (top > domain_min) ? top : domain_min + 1.0
  end
end

#domain_minObject

--- y domain ---



145
146
147
# File 'app/components/lightning_ui_kit/chart_component.rb', line 145

def domain_min
  @domain_min ||= (@y_min || default_domain_min).to_f
end

#empty?Boolean

Returns:

  • (Boolean)


58
59
60
# File 'app/components/lightning_ui_kit/chart_component.rb', line 58

def empty?
  @data.empty? || @series.empty?
end

#format_value(value) ⇒ Object

Applies y_format: when given, otherwise the unit: preset. Shared by axis ticks and tooltip payloads so the two always agree.



341
342
343
344
345
346
347
348
349
350
351
352
# File 'app/components/lightning_ui_kit/chart_component.rb', line 341

def format_value(value)
  return NIL_LABEL if value.nil?
  return @y_format.call(value).to_s if @y_format.respond_to?(:call)

  case @unit
  when :percent then format_percent(value.to_f)
  when :bytes then format_bytes(value.to_f)
  when :count then format_count(value.to_f)
  when :duration_ms then format_duration_ms(value.to_f)
  else format_decimal(value.to_f)
  end
end

#gradient_id(index) ⇒ Object



99
100
101
# File 'app/components/lightning_ui_kit/chart_component.rb', line 99

def gradient_id(index)
  "#{chart_id}-grad-#{index}"
end

#hover_columnsObject

Invisible full-height bands (one per category) that drive the hover tooltip, cursor and active dots.



298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
# File 'app/components/lightning_ui_kit/chart_component.rb', line 298

def hover_columns
  step = (count > 1) ? plot_width.to_f / (count - 1) : plot_width.to_f

  @data.each_with_index.map do |row, i|
    if bar?
      x = plot_left + i * bar_group_width
      w = bar_group_width
    else
      x = point_x(i) - step / 2.0
      w = step
    end
    left = [x, plot_left].max
    right = [x + w, plot_right].min
    {
      x: left,
      width: [right - left, 0].max,
      cursor_x: bar? ? plot_left + i * bar_group_width + bar_group_width / 2.0 : point_x(i),
      markers: markers_for(i),
      label: x_label(row),
      payload: payload_for(i)
    }
  end
end

#line_path(points) ⇒ Object

Stroke path for one contiguous run of points.



250
251
252
253
254
# File 'app/components/lightning_ui_kit/chart_component.rb', line 250

def line_path(points)
  return "" if points.empty?

  "M #{r2(points.first[0])} #{r2(points.first[1])} #{curve_commands(points)}".strip
end

#line_points(series) ⇒ Object



231
232
233
234
235
236
# File 'app/components/lightning_ui_kit/chart_component.rb', line 231

def line_points(series)
  @data.each_with_index.map do |row, i|
    value = value_for(row, series)
    value.nil? ? nil : [point_x(i), y_for(value)]
  end
end

#line_segments(series) ⇒ Object

Contiguous runs of plottable points. A nil value ends the current run so the line shows a gap, unless span_gaps: was requested.



240
241
242
243
244
245
246
247
# File 'app/components/lightning_ui_kit/chart_component.rb', line 240

def line_segments(series)
  points = line_points(series)
  return [points.compact].reject(&:empty?) if span_gaps?

  points.chunk_while { |a, b| !a.nil? && !b.nil? }
    .map(&:compact)
    .reject(&:empty?)
end

#markers_for(index) ⇒ Object

Y position of each series at the given index, or null where the value is missing, so the controller can place one active dot per series on hover.



324
325
326
327
328
329
330
# File 'app/components/lightning_ui_kit/chart_component.rb', line 324

def markers_for(index)
  row = @data[index]
  @series.map do |s|
    value = value_for(row, s)
    value.nil? ? nil : r2(y_for(value))
  end.to_json
end

#payload_for(index) ⇒ Object



332
333
334
335
336
337
# File 'app/components/lightning_ui_kit/chart_component.rb', line 332

def payload_for(index)
  row = @data[index]
  @series.map do |s|
    {label: s[:label], value: format_value(value_for(row, s)), color: s[:color]}
  end.to_json
end

#plot_bottomObject



123
# File 'app/components/lightning_ui_kit/chart_component.rb', line 123

def plot_bottom = @height - (@show_axis ? 28 : 8)

#plot_heightObject



127
# File 'app/components/lightning_ui_kit/chart_component.rb', line 127

def plot_height = plot_bottom - plot_top

#plot_leftObject



117
# File 'app/components/lightning_ui_kit/chart_component.rb', line 117

def plot_left = @show_axis ? AXIS_PAD_LEFT : 8

#plot_rightObject



119
# File 'app/components/lightning_ui_kit/chart_component.rb', line 119

def plot_right = VIEW_WIDTH - PAD_RIGHT

#plot_topObject



121
# File 'app/components/lightning_ui_kit/chart_component.rb', line 121

def plot_top = PAD_TOP

#plot_widthObject



125
# File 'app/components/lightning_ui_kit/chart_component.rb', line 125

def plot_width = plot_right - plot_left

#point_x(index) ⇒ Object



166
167
168
169
170
# File 'app/components/lightning_ui_kit/chart_component.rb', line 166

def point_x(index)
  return plot_left + plot_width / 2.0 if count <= 1

  plot_left + (index.to_f / (count - 1)) * plot_width
end

#show_axis?Boolean

Returns:

  • (Boolean)


66
67
68
# File 'app/components/lightning_ui_kit/chart_component.rb', line 66

def show_axis?
  @show_axis
end

#show_dots?Boolean

shadcn draws line/area series without per-point dots; :auto keeps them only while the series is sparse enough for them to read as markers.

Returns:

  • (Boolean)


84
85
86
# File 'app/components/lightning_ui_kit/chart_component.rb', line 84

def show_dots?
  (@dots == :auto) ? count <= AUTO_DOT_LIMIT : !!@dots
end

#show_grid?Boolean

Returns:

  • (Boolean)


62
63
64
# File 'app/components/lightning_ui_kit/chart_component.rb', line 62

def show_grid?
  @show_grid
end

#show_legend?Boolean

Returns:

  • (Boolean)


70
71
72
# File 'app/components/lightning_ui_kit/chart_component.rb', line 70

def show_legend?
  @show_legend && @series.any?
end

#smooth?Boolean

Returns:

  • (Boolean)


78
79
80
# File 'app/components/lightning_ui_kit/chart_component.rb', line 78

def smooth?
  @curve == :monotone
end

#span_gaps?Boolean

Returns:

  • (Boolean)


74
75
76
# File 'app/components/lightning_ui_kit/chart_component.rb', line 74

def span_gaps?
  @span_gaps
end

#stimulus_dataObject



103
104
105
# File 'app/components/lightning_ui_kit/chart_component.rb', line 103

def stimulus_data
  {controller: "lui-chart"}.merge(data_attrs)
end

#value_for(row, series) ⇒ Object

nil for a missing data point so lines can break and bars can be skipped. Anything unparseable ("n/a", "-", "") counts as missing rather than zero.



135
136
137
138
139
140
141
# File 'app/components/lightning_ui_kit/chart_component.rb', line 135

def value_for(row, series)
  raw = row[series[:key]]
  return nil if raw.nil?
  return raw.to_f if raw.is_a?(Numeric)

  Float(raw.to_s.strip, exception: false)
end

#view_heightObject



115
# File 'app/components/lightning_ui_kit/chart_component.rb', line 115

def view_height = @height

#view_widthObject

--- geometry ---



113
# File 'app/components/lightning_ui_kit/chart_component.rb', line 113

def view_width = VIEW_WIDTH

#x_axis_labelsObject

Evenly spaced subset of x_positions (first and last always kept) so dense series don't render overlapping axis text. Data points are unaffected.



190
191
192
193
# File 'app/components/lightning_ui_kit/chart_component.rb', line 190

def x_axis_labels
  positions = x_positions
  label_indices(positions.size).map { |i| positions[i] }
end

#x_label(row) ⇒ Object



131
# File 'app/components/lightning_ui_kit/chart_component.rb', line 131

def x_label(row) = row[@x_key]

#x_positionsObject



181
182
183
184
185
186
# File 'app/components/lightning_ui_kit/chart_component.rb', line 181

def x_positions
  @data.each_with_index.map do |row, i|
    x = bar? ? plot_left + i * bar_group_width + bar_group_width / 2.0 : point_x(i)
    {x: x, label: x_label(row)}
  end
end

#y_for(value) ⇒ Object



156
157
158
159
160
161
162
163
164
# File 'app/components/lightning_ui_kit/chart_component.rb', line 156

def y_for(value)
  return plot_bottom if value.nil?

  span = domain_max - domain_min
  return plot_bottom if span <= 0

  ratio = ((value.to_f - domain_min) / span).clamp(0.0, 1.0)
  plot_bottom - ratio * plot_height
end

#y_ticks(steps = Y_TICK_STEPS) ⇒ Object



172
173
174
175
176
177
178
179
# File 'app/components/lightning_ui_kit/chart_component.rb', line 172

def y_ticks(steps = Y_TICK_STEPS)
  span = domain_max - domain_min

  (0..steps).map do |i|
    value = domain_min + span * i / steps
    {label: format_value(value), y: y_for(value)}
  end
end