Class: LightningUiKit::ChartComponent
- Inherits:
-
BaseComponent
- Object
- BaseComponent
- LightningUiKit::ChartComponent
- 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
-
#height ⇒ Object
readonly
Returns the value of attribute height.
-
#series ⇒ Object
readonly
Returns the value of attribute series.
-
#type ⇒ Object
readonly
Returns the value of attribute type.
Instance Method Summary collapse
- #area? ⇒ Boolean
-
#area_path(points) ⇒ Object
The stroke path closed down to the baseline on both ends.
- #bar? ⇒ Boolean
-
#bar_baseline ⇒ Object
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.
-
#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.
- #bars ⇒ Object
- #chart_id ⇒ Object
- #classes ⇒ Object
- #count ⇒ Object
- #data_attrs ⇒ Object
- #domain_max ⇒ Object
-
#domain_min ⇒ Object
--- y domain ---.
- #empty? ⇒ Boolean
-
#format_value(value) ⇒ Object
Applies y_format: when given, otherwise the unit: preset.
- #gradient_id(index) ⇒ Object
-
#hover_columns ⇒ Object
Invisible full-height bands (one per category) that drive the hover tooltip, cursor and active dots.
-
#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
constructor
A new instance of ChartComponent.
-
#line_path(points) ⇒ Object
Stroke path for one contiguous run of points.
- #line_points(series) ⇒ Object
-
#line_segments(series) ⇒ Object
Contiguous runs of plottable points.
-
#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.
- #payload_for(index) ⇒ Object
- #plot_bottom ⇒ Object
- #plot_height ⇒ Object
- #plot_left ⇒ Object
- #plot_right ⇒ Object
- #plot_top ⇒ Object
- #plot_width ⇒ Object
- #point_x(index) ⇒ Object
- #show_axis? ⇒ Boolean
-
#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.
- #show_grid? ⇒ Boolean
- #show_legend? ⇒ Boolean
- #smooth? ⇒ Boolean
- #span_gaps? ⇒ Boolean
- #stimulus_data ⇒ Object
-
#value_for(row, series) ⇒ Object
nil for a missing data point so lines can break and bars can be skipped.
- #view_height ⇒ Object
-
#view_width ⇒ Object
--- geometry ---.
-
#x_axis_labels ⇒ Object
Evenly spaced subset of x_positions (first and last always kept) so dense series don't render overlapping axis text.
- #x_label(row) ⇒ Object
- #x_positions ⇒ Object
- #y_for(value) ⇒ Object
- #y_ticks(steps = Y_TICK_STEPS) ⇒ Object
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, **) @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 = end |
Instance Attribute Details
#height ⇒ Object (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 |
#series ⇒ Object (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 |
#type ⇒ Object (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
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
50 51 52 |
# File 'app/components/lightning_ui_kit/chart_component.rb', line 50 def @type == :bar end |
#bar_baseline ⇒ Object
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 = 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 () x = [:x] w = [:width] h = [:height] return "" if h <= 0 || w <= 0 r = [BAR_RADIUS, w / 2.0, h].min x2 = x + w base = [:negative] ? [:y] : [:y] + h tip = [:negative] ? [:y] + h : [:y] # Signed step from the tip back toward the baseline. inset = [: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 |
#bars ⇒ Object
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 group_width = slot = 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() @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_id ⇒ Object
95 96 97 |
# File 'app/components/lightning_ui_kit/chart_component.rb', line 95 def chart_id @chart_id ||= "lui-chart-#{object_id}" end |
#classes ⇒ Object
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 |
#count ⇒ Object
129 |
# File 'app/components/lightning_ui_kit/chart_component.rb', line 129 def count = @data.size |
#data_attrs ⇒ Object
107 108 109 |
# File 'app/components/lightning_ui_kit/chart_component.rb', line 107 def data_attrs @options[:data] || {} end |
#domain_max ⇒ Object
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_min ⇒ Object
--- 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
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_columns ⇒ Object
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 x = plot_left + i * w = 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: ? plot_left + i * + / 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_bottom ⇒ Object
123 |
# File 'app/components/lightning_ui_kit/chart_component.rb', line 123 def plot_bottom = @height - (@show_axis ? 28 : 8) |
#plot_height ⇒ Object
127 |
# File 'app/components/lightning_ui_kit/chart_component.rb', line 127 def plot_height = plot_bottom - plot_top |
#plot_left ⇒ Object
117 |
# File 'app/components/lightning_ui_kit/chart_component.rb', line 117 def plot_left = @show_axis ? AXIS_PAD_LEFT : 8 |
#plot_right ⇒ Object
119 |
# File 'app/components/lightning_ui_kit/chart_component.rb', line 119 def plot_right = VIEW_WIDTH - PAD_RIGHT |
#plot_top ⇒ Object
121 |
# File 'app/components/lightning_ui_kit/chart_component.rb', line 121 def plot_top = PAD_TOP |
#plot_width ⇒ Object
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
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.
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
62 63 64 |
# File 'app/components/lightning_ui_kit/chart_component.rb', line 62 def show_grid? @show_grid end |
#show_legend? ⇒ 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
78 79 80 |
# File 'app/components/lightning_ui_kit/chart_component.rb', line 78 def smooth? @curve == :monotone end |
#span_gaps? ⇒ Boolean
74 75 76 |
# File 'app/components/lightning_ui_kit/chart_component.rb', line 74 def span_gaps? @span_gaps end |
#stimulus_data ⇒ Object
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_height ⇒ Object
115 |
# File 'app/components/lightning_ui_kit/chart_component.rb', line 115 def view_height = @height |
#view_width ⇒ Object
--- geometry ---
113 |
# File 'app/components/lightning_ui_kit/chart_component.rb', line 113 def view_width = VIEW_WIDTH |
#x_axis_labels ⇒ Object
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_positions ⇒ Object
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 = ? plot_left + i * + / 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 |