clowk-phlex 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.
- checksums.yaml +7 -0
- data/lib/clowk/phlex/charts/card.rb +518 -0
- data/lib/clowk/phlex/charts/chart_shape.rb +86 -0
- data/lib/clowk/phlex/charts/display_settings.rb +121 -0
- data/lib/clowk/phlex/charts/format.rb +48 -0
- data/lib/clowk/phlex/charts/gauge_linear.rb +143 -0
- data/lib/clowk/phlex/charts/gauge_radial.rb +82 -0
- data/lib/clowk/phlex/charts/interval_picker.rb +117 -0
- data/lib/clowk/phlex/charts/number_card.rb +365 -0
- data/lib/clowk/phlex/charts/range_picker.rb +176 -0
- data/lib/clowk/phlex/charts/sparkline.rb +261 -0
- data/lib/clowk/phlex/charts/time_series.rb +1058 -0
- data/lib/clowk/phlex/charts/type_picker.rb +109 -0
- data/lib/clowk/phlex/charts/web_time.rb +31 -0
- data/lib/clowk/phlex/component.rb +38 -0
- data/lib/clowk/phlex/ui/icons.rb +24 -0
- data/lib/clowk/phlex/ui/switch.rb +33 -0
- data/lib/clowk/phlex/version.rb +10 -0
- data/lib/clowk/phlex.rb +45 -0
- data/lib/clowk-phlex.rb +4 -0
- data/styles/clowk-phlex.css +156 -0
- metadata +141 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Components::UI::Sparkline — area + stroke time-series chart with
|
|
4
|
+
# per-point hover tooltips.
|
|
5
|
+
#
|
|
6
|
+
# Anatomy:
|
|
7
|
+
#
|
|
8
|
+
# ┌──────────────────────────────────┐
|
|
9
|
+
# │ ╲ ╱╲ ╱╲ │ smoothed area + stroke
|
|
10
|
+
# │ ╲ ╱ ╲ ╱ ● │ pulse dot on the last point
|
|
11
|
+
# │ ╲___╱ ╲______________╱ │
|
|
12
|
+
# └──────────────────────────────────┘
|
|
13
|
+
#
|
|
14
|
+
# Hover behaviour:
|
|
15
|
+
# - One invisible vertical strip per point covers the chart.
|
|
16
|
+
# - mouseenter / mousemove fires the sparkline-tooltip Stimulus
|
|
17
|
+
# controller, which:
|
|
18
|
+
# 1. positions a fixed-position tooltip above the hovered point
|
|
19
|
+
# 2. draws a small dot at the point + a dashed vertical line
|
|
20
|
+
# 3. shows the formatted value + the timestamp.
|
|
21
|
+
# - mouseleave hides both.
|
|
22
|
+
#
|
|
23
|
+
# Data shape:
|
|
24
|
+
#
|
|
25
|
+
# points: [{ ts: "2026-05-24T09:00:00Z", value: 12.4, formatted: "12.4%" }, ...]
|
|
26
|
+
#
|
|
27
|
+
# `ts` is the ISO timestamp; `value` the raw numeric (drives the
|
|
28
|
+
# curve); `formatted` is what the tooltip shows. Format-on-Rails-side
|
|
29
|
+
# keeps the controller unaware of units (%, MB, GB) — that knowledge
|
|
30
|
+
# stays in MetricsData where the metric name is in scope.
|
|
31
|
+
#
|
|
32
|
+
# Empty / single-point data → renders nothing (caller's StatCard
|
|
33
|
+
# already hides the wrapper when `points.blank?`).
|
|
34
|
+
class Clowk::Phlex::Charts::Sparkline < Clowk::Phlex::Component
|
|
35
|
+
PAD = 4
|
|
36
|
+
|
|
37
|
+
def initialize(points:, color: "#34d399", width: 220, height: 56, show_fill: true, stroke: 1.5)
|
|
38
|
+
@points = Array(points).map { |p| normalize(p) }
|
|
39
|
+
@color = color
|
|
40
|
+
@width = width
|
|
41
|
+
@height = height
|
|
42
|
+
@show_fill = show_fill
|
|
43
|
+
@stroke = stroke
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def view_template
|
|
47
|
+
return if @points.empty?
|
|
48
|
+
|
|
49
|
+
# Single-point edge case (warehouse warming up; range so short
|
|
50
|
+
# that only one bucket has data; metric just started reporting).
|
|
51
|
+
# We used to `return` here, which made the StatCard render its
|
|
52
|
+
# "no data" placeholder — confusing when the headline IS showing
|
|
53
|
+
# a value pulled from that same point. Synthesise a second point
|
|
54
|
+
# at the SAME value so the curve renders as a flat line at the
|
|
55
|
+
# measured level; honest visual ("we have one reading, no trend
|
|
56
|
+
# to draw yet") instead of misleading empty state.
|
|
57
|
+
if @points.size == 1
|
|
58
|
+
only = @points.first
|
|
59
|
+
@points = [
|
|
60
|
+
only.merge(ts: only[:ts]),
|
|
61
|
+
only.merge(ts: only[:ts]) # duplicate; flat segment
|
|
62
|
+
]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
pts = projected_points
|
|
66
|
+
d_line = path_for(pts)
|
|
67
|
+
d_area = "#{d_line} L #{pts.last[0]} #{@height} L #{pts.first[0]} #{@height} Z"
|
|
68
|
+
gid = gradient_id
|
|
69
|
+
|
|
70
|
+
svg(
|
|
71
|
+
width: "100%", height: @height,
|
|
72
|
+
viewBox: "0 0 #{@width} #{@height}",
|
|
73
|
+
preserveAspectRatio: "none",
|
|
74
|
+
class: "block overflow-visible",
|
|
75
|
+
# data-tz carries the operator's chosen IANA zone name into JS
|
|
76
|
+
# so the tooltip controller's formatTs renders timestamps in
|
|
77
|
+
# Settings → Display preferences instead of the browser's
|
|
78
|
+
# local TZ (the previous default). Same WebTime.zone_name
|
|
79
|
+
# source as the chart axis ticks + ChartCard headlines.
|
|
80
|
+
data: {controller: "sparkline-tooltip"},
|
|
81
|
+
style: "--clowk-spark-color: #{@color};"
|
|
82
|
+
) do |s|
|
|
83
|
+
s.defs do
|
|
84
|
+
s.linearGradient(id: gid, x1: 0, x2: 0, y1: 0, y2: 1) do
|
|
85
|
+
s.stop(offset: "0%", "stop-color": @color, "stop-opacity": "0.32")
|
|
86
|
+
s.stop(offset: "100%", "stop-color": @color, "stop-opacity": "0")
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
s.path(d: d_area, fill: "url(##{gid})") if @show_fill
|
|
91
|
+
s.path(
|
|
92
|
+
d: d_line, fill: "none",
|
|
93
|
+
stroke: @color, "stroke-width": @stroke,
|
|
94
|
+
"stroke-linecap": "round", "stroke-linejoin": "round"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Y-axis labels overlay — small muted text in the corners
|
|
98
|
+
# showing the data range. Top-right: max, bottom-right: min.
|
|
99
|
+
# Renders the formatted value (from MetricsData) so units
|
|
100
|
+
# stay consistent ("18.7%" / "805.3 MB" / etc.).
|
|
101
|
+
axis_labels(s)
|
|
102
|
+
|
|
103
|
+
# Always-on dot at the latest point — the "current value"
|
|
104
|
+
# affordance from the original sparkline. Stays put when the
|
|
105
|
+
# operator isn't hovering; gets hidden by the Stimulus
|
|
106
|
+
# controller while hovering (so the focus marker on the
|
|
107
|
+
# hovered point doesn't clash).
|
|
108
|
+
cx, cy = pts.last
|
|
109
|
+
s.circle(
|
|
110
|
+
cx: cx, cy: cy, r: 5, fill: @color, opacity: "0.18",
|
|
111
|
+
data: {sparkline_tooltip_target: "tailDot"}
|
|
112
|
+
)
|
|
113
|
+
s.circle(
|
|
114
|
+
cx: cx, cy: cy, r: 2.5, fill: @color,
|
|
115
|
+
data: {sparkline_tooltip_target: "tailDot"}
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# Per-point hover strips. Invisible, full-height, mouse
|
|
119
|
+
# events feed the Stimulus controller. preserveAspectRatio
|
|
120
|
+
# is `none` so widths scale 1:1 with viewBox even when the
|
|
121
|
+
# SVG is `width: 100%` — the strip's x stays aligned with
|
|
122
|
+
# its point's underlying viewBox x.
|
|
123
|
+
hover_strips(s, pts)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
# normalize — accept rich `{ts:, value:, formatted:}` or a bare
|
|
130
|
+
# numeric. The bare path keeps the legacy `[Float]` call sites
|
|
131
|
+
# (anything not yet migrated to MetricsData#points_for) working;
|
|
132
|
+
# tooltip just shows the rounded value without a timestamp.
|
|
133
|
+
def normalize(p)
|
|
134
|
+
return p if p.is_a?(Hash)
|
|
135
|
+
|
|
136
|
+
{ts: nil, value: p.to_f, formatted: p.to_f.round(2).to_s}
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def projected_points
|
|
140
|
+
min = @points.map { |p| p[:value] }.min
|
|
141
|
+
max = @points.map { |p| p[:value] }.max
|
|
142
|
+
range = [max - min, 0.0001].max
|
|
143
|
+
dx = (@width - PAD * 2).to_f / (@points.size - 1)
|
|
144
|
+
|
|
145
|
+
@points.each_with_index.map do |p, i|
|
|
146
|
+
t = (p[:value] - min) / range
|
|
147
|
+
[PAD + i * dx, @height - PAD - t * (@height - PAD * 2)]
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# path_for — Catmull-Rom → cubic bezier smoothing.
|
|
152
|
+
#
|
|
153
|
+
# Same JS→Ruby porting gotcha as Components::Metrics::Chart:
|
|
154
|
+
# `pts[-1]` in Ruby returns the last element, NOT nil. The
|
|
155
|
+
# naive `pts[i - 1] || pts[i]` fallback never triggers, and
|
|
156
|
+
# the curve's first segment derives its control point from the
|
|
157
|
+
# span between the first and last points — bowing the line
|
|
158
|
+
# out of the chart box at the start. Explicit bounds checks
|
|
159
|
+
# below.
|
|
160
|
+
def path_for(pts)
|
|
161
|
+
d = "M #{pts[0][0]} #{pts[0][1]}"
|
|
162
|
+
|
|
163
|
+
(0...pts.size - 1).each do |i|
|
|
164
|
+
p0 = i.zero? ? pts[i] : pts[i - 1]
|
|
165
|
+
p1 = pts[i]
|
|
166
|
+
p2 = pts[i + 1]
|
|
167
|
+
p3 = (i + 2 < pts.size) ? pts[i + 2] : p2
|
|
168
|
+
|
|
169
|
+
cp1x = p1[0] + (p2[0] - p0[0]) / 6.0
|
|
170
|
+
cp1y = p1[1] + (p2[1] - p0[1]) / 6.0
|
|
171
|
+
cp2x = p2[0] - (p3[0] - p1[0]) / 6.0
|
|
172
|
+
cp2y = p2[1] - (p3[1] - p1[1]) / 6.0
|
|
173
|
+
|
|
174
|
+
d += " C #{cp1x} #{cp1y}, #{cp2x} #{cp2y}, #{p2[0]} #{p2[1]}"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
d
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# hover_strips — one rect per point, mid-aligned. Widths overlap
|
|
181
|
+
# at edges (each strip claims half the slot to its left + half to
|
|
182
|
+
# its right) so the mouse never falls between strips.
|
|
183
|
+
def hover_strips(svg, pts)
|
|
184
|
+
return if pts.size < 2
|
|
185
|
+
|
|
186
|
+
slot_w = (pts.last[0] - pts.first[0]).to_f / (pts.size - 1)
|
|
187
|
+
half = slot_w / 2.0
|
|
188
|
+
|
|
189
|
+
@points.each_with_index do |p, i|
|
|
190
|
+
x = pts[i][0] - half
|
|
191
|
+
w = slot_w
|
|
192
|
+
|
|
193
|
+
# Clamp the edge strips so they don't poke past the viewBox.
|
|
194
|
+
if i.zero?
|
|
195
|
+
x = 0
|
|
196
|
+
w = pts[i][0] + half
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
if i == @points.size - 1
|
|
200
|
+
w = @width - x
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
svg.rect(
|
|
204
|
+
x: x, y: 0, width: w, height: @height,
|
|
205
|
+
fill: "transparent", "pointer-events": "all",
|
|
206
|
+
# Cursor cue so operator notices the chart is interactive.
|
|
207
|
+
style: "cursor: crosshair;",
|
|
208
|
+
data: {
|
|
209
|
+
sparkline_tooltip_target: "strip",
|
|
210
|
+
action: "mouseenter->sparkline-tooltip#show mouseleave->sparkline-tooltip#hide",
|
|
211
|
+
ts: p[:ts] || "",
|
|
212
|
+
value: p[:value],
|
|
213
|
+
formatted: p[:formatted],
|
|
214
|
+
# Pre-compute the point coordinates so the JS doesn't
|
|
215
|
+
# need to re-project — saves doing the bezier math twice.
|
|
216
|
+
point_x: pts[i][0],
|
|
217
|
+
point_y: pts[i][1]
|
|
218
|
+
}
|
|
219
|
+
)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# axis_labels — tiny muted text overlay in the chart corners
|
|
224
|
+
# showing min/max of the visible series. Caller's StatCard
|
|
225
|
+
# already has the time-range chip ("[1h]"), so we don't repeat
|
|
226
|
+
# the x-axis here; just the y range.
|
|
227
|
+
#
|
|
228
|
+
# Positioned with text-anchor="end" so the values right-align
|
|
229
|
+
# against the SVG's right edge regardless of how long the
|
|
230
|
+
# formatted string is. Background is omitted because the values
|
|
231
|
+
# land in the corners where the curve almost never reaches —
|
|
232
|
+
# readable against the area gradient.
|
|
233
|
+
def axis_labels(svg)
|
|
234
|
+
max_p = @points.max_by { |p| p[:value] }
|
|
235
|
+
min_p = @points.min_by { |p| p[:value] }
|
|
236
|
+
|
|
237
|
+
return if max_p[:value] == min_p[:value]
|
|
238
|
+
|
|
239
|
+
# x=@width - PAD so the text right-aligns near the right edge.
|
|
240
|
+
# Y positions hug top and bottom but leave room for the
|
|
241
|
+
# 10px-tall text glyph itself.
|
|
242
|
+
label_attrs = {
|
|
243
|
+
"text-anchor": "end",
|
|
244
|
+
fill: "var(--clowk-muted-2, #6c7790)",
|
|
245
|
+
"font-size": "9px",
|
|
246
|
+
"font-family": "var(--clowk-font-mono, ui-monospace, monospace)",
|
|
247
|
+
"pointer-events": "none"
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
svg.text(x: @width - PAD, y: 10, **label_attrs) { max_p[:formatted] }
|
|
251
|
+
svg.text(x: @width - PAD, y: @height - 2, **label_attrs) { min_p[:formatted] }
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# gradient_id — stable SVG-safe id derived from the color string.
|
|
255
|
+
# See the long-form comment in git history for the parens-in-id
|
|
256
|
+
# gotcha that this avoids; short version: CSS var colors break
|
|
257
|
+
# the naive `delete("#")` approach.
|
|
258
|
+
def gradient_id
|
|
259
|
+
"clowk-spark-#{Digest::MD5.hexdigest(@color)[0, 8]}"
|
|
260
|
+
end
|
|
261
|
+
end
|