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.
@@ -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