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.
- checksums.yaml +4 -4
- data/app/assets/vendor/lightning_ui_kit.css +21 -12
- data/app/assets/vendor/lightning_ui_kit.js +6 -6
- data/app/components/lightning_ui_kit/chart_component.html.erb +31 -9
- data/app/components/lightning_ui_kit/chart_component.rb +317 -46
- data/app/components/lightning_ui_kit/description_list/item_component.html.erb +2 -12
- data/app/components/lightning_ui_kit/description_list/item_component.rb +28 -1
- data/app/components/lightning_ui_kit/description_list_component.html.erb +2 -2
- data/app/components/lightning_ui_kit/description_list_component.rb +27 -1
- data/app/components/lightning_ui_kit/file_input_component.rb +1 -1
- data/app/components/lightning_ui_kit/table_component.html.erb +102 -100
- data/app/components/lightning_ui_kit/table_component.rb +26 -4
- data/app/javascript/lightning_ui_kit/controllers/chart_controller.js +61 -5
- data/config/deploy.yml +1 -1
- data/lib/lightning_ui_kit/version.rb +1 -1
- data/vendor/bundle/ruby/3.4.0/cache/ostruct-0.6.3.gem +0 -0
- data/vendor/bundle/ruby/3.4.0/extensions/x86_64-linux/3.4.0/bigdecimal-4.1.2/gem_make.out +5 -5
- data/vendor/bundle/ruby/3.4.0/extensions/x86_64-linux/3.4.0/bigdecimal-4.1.2/mkmf.log +2 -2
- data/vendor/bundle/ruby/3.4.0/extensions/x86_64-linux/3.4.0/date-3.4.1/gem_make.out +5 -5
- data/vendor/bundle/ruby/3.4.0/extensions/x86_64-linux/3.4.0/erb-5.0.2/gem_make.out +5 -5
- data/vendor/bundle/ruby/3.4.0/extensions/x86_64-linux/3.4.0/io-console-0.8.1/gem_make.out +5 -5
- data/vendor/bundle/ruby/3.4.0/extensions/x86_64-linux/3.4.0/json-2.18.1/gem_make.out +5 -5
- data/vendor/bundle/ruby/3.4.0/extensions/x86_64-linux/3.4.0/nio4r-2.7.4/gem_make.out +5 -5
- data/vendor/bundle/ruby/3.4.0/extensions/x86_64-linux/3.4.0/prism-1.9.0/gem_make.out +5 -5
- data/vendor/bundle/ruby/3.4.0/extensions/x86_64-linux/3.4.0/psych-5.2.6/gem_make.out +5 -5
- data/vendor/bundle/ruby/3.4.0/extensions/x86_64-linux/3.4.0/racc-1.8.1/gem_make.out +5 -5
- data/vendor/bundle/ruby/3.4.0/extensions/x86_64-linux/3.4.0/stringio-3.1.7/gem_make.out +5 -5
- data/vendor/bundle/ruby/3.4.0/extensions/x86_64-linux/3.4.0/websocket-driver-0.8.0/gem_make.out +5 -5
- data/vendor/bundle/ruby/3.4.0/gems/ostruct-0.6.3/BSDL +22 -0
- data/vendor/bundle/ruby/3.4.0/gems/ostruct-0.6.3/COPYING +56 -0
- data/vendor/bundle/ruby/3.4.0/gems/ostruct-0.6.3/Gemfile +10 -0
- data/vendor/bundle/ruby/3.4.0/gems/ostruct-0.6.3/README.md +69 -0
- data/vendor/bundle/ruby/3.4.0/gems/ostruct-0.6.3/Rakefile +18 -0
- data/vendor/bundle/ruby/3.4.0/gems/ostruct-0.6.3/bin/console +14 -0
- data/vendor/bundle/ruby/3.4.0/gems/ostruct-0.6.3/bin/setup +8 -0
- data/vendor/bundle/ruby/3.4.0/gems/ostruct-0.6.3/lib/ostruct.rb +492 -0
- data/vendor/bundle/ruby/3.4.0/gems/ostruct-0.6.3/ostruct.gemspec +26 -0
- data/vendor/bundle/ruby/3.4.0/specifications/ostruct-0.6.3.gemspec +22 -0
- 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
|
|
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="
|
|
24
|
-
<stop offset="
|
|
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
|
-
<%
|
|
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
|
-
<%
|
|
59
|
+
<% segments = line_segments(s) %>
|
|
60
|
+
<% strokes = segments.select { |points| points.size > 1 } %>
|
|
52
61
|
<% if area? %>
|
|
53
|
-
|
|
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
|
-
|
|
56
|
-
<%
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
157
|
+
return plot_bottom if value.nil?
|
|
158
|
+
|
|
159
|
+
span = domain_max - domain_min
|
|
160
|
+
return plot_bottom if span <= 0
|
|
96
161
|
|
|
97
|
-
|
|
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 =
|
|
172
|
+
def y_ticks(steps = Y_TICK_STEPS)
|
|
173
|
+
span = domain_max - domain_min
|
|
174
|
+
|
|
107
175
|
(0..steps).map do |i|
|
|
108
|
-
value =
|
|
109
|
-
{label:
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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 +
|
|
128
|
-
@series.each_with_index.
|
|
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 *
|
|
133
|
-
y: y,
|
|
134
|
-
width:
|
|
135
|
-
height:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
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 = [
|
|
279
|
+
r = [BAR_RADIUS, w / 2.0, h].min
|
|
173
280
|
x2 = x + w
|
|
174
|
-
|
|
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(
|
|
177
|
-
"L #{r2(x)} #{r2(
|
|
178
|
-
"Q #{r2(x)} #{r2(
|
|
179
|
-
"L #{r2(x2 - r)} #{r2(
|
|
180
|
-
"Q #{r2(x2)} #{r2(
|
|
181
|
-
"L #{r2(x2)} #{r2(
|
|
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
|
-
{
|
|
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:
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
2
|
-
<%= @label %>
|
|
3
|
-
</dt>
|
|
1
|
+
<%= tag.dt(@label, class: label_classes) %>
|
|
4
2
|
|
|
5
|
-
|
|
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
|
-
|
|
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,3 +1,29 @@
|
|
|
1
1
|
class LightningUiKit::DescriptionListComponent < LightningUiKit::BaseComponent
|
|
2
|
-
renders_many :items,
|
|
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
|