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,1058 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Clowk::Phlex::Charts::TimeSeries — the big multi-series time chart (area/line/bars).— the BIG time-series chart on /metrics.
4
+ # Distinct from Components::UI::Sparkline (which is the tiny inline
5
+ # chart on StatCards) because the metrics page has room — and the
6
+ # need — for full axes, gridlines, restart annotations, and a
7
+ # proper hover tooltip.
8
+ #
9
+ # Ported 1:1 from design-webui-inspiration/pages-metrics.jsx
10
+ # (MetricChart, lines 177-333). The Catmull-Rom→bezier smoothing
11
+ # is identical to Sparkline's path_for; we don't dedup to keep
12
+ # each chart's coordinate space (with axis padding) local — they
13
+ # diverge enough that extraction would be premature.
14
+ #
15
+ # Markup pattern:
16
+ #
17
+ # <div data-controller="metrics-chart"
18
+ # data-metrics-chart-points-value="[{ts, value, formatted}]"
19
+ # data-metrics-chart-color-value="#34d399"
20
+ # data-metrics-chart-unit-value="%"
21
+ # data-metrics-chart-label-value="CPU">
22
+ # <svg ...> <!-- axes + path + (JS-injected hover overlay) -->
23
+ # ... rendered server-side ...
24
+ # </svg>
25
+ # <!-- floating tooltip rendered into <body> by Stimulus -->
26
+ # </div>
27
+ #
28
+ # The SVG itself is server-rendered (axes, gridlines, path) so the
29
+ # initial paint shows a complete chart even before JS hydrates.
30
+ # The Stimulus controller only adds the crosshair + tooltip on
31
+ # mouseover; without JS the static chart still reads.
32
+ class Clowk::Phlex::Charts::TimeSeries < Clowk::Phlex::Component
33
+ # Padding for the full chart (with visible axes). Compact mode
34
+ # (axes hidden) collapses these to tiny gutters.
35
+ PAD_LEFT_FULL = 44 # room for y-axis labels
36
+ PAD_RIGHT_FULL = 12
37
+ PAD_TOP_FULL = 14
38
+ PAD_BOTTOM_FULL = 22 # room for x-axis labels
39
+
40
+ PAD_LEFT_COMPACT = 4
41
+ PAD_RIGHT_COMPACT = 4
42
+ PAD_TOP_COMPACT = 4
43
+ PAD_BOTTOM_COMPACT = 4
44
+
45
+ Y_TICKS = 5
46
+ X_TICKS = 5
47
+
48
+ # axes: true → big chart with full Y/X labels + gridlines
49
+ # false → compact (sparkline-like): same curve + same
50
+ # hover crosshair + tooltip, no visible axes. Used by
51
+ # Overview StatCards and Pod show StatCards so all three
52
+ # chart surfaces (Overview, Pod show, /metrics) share
53
+ # the same SVG/JS rendering engine.
54
+ # style — how the series is drawn, all sharing the same axes / timeline /
55
+ # hover / restart annotations / brush-to-zoom:
56
+ # :area (default) — smoothed curve + gradient fill
57
+ # :bars — one filled column per bucket (discrete counts)
58
+ # :line — smoothed curve + a dot on each point, NO fill
59
+ STYLES = %i[area bars line].freeze
60
+
61
+ # series — OPTIONAL multi-series data (pilot: Line only). An array of
62
+ # {label:, color:, points: [{ts,value,formatted}]}. When present the chart
63
+ # draws ONE line (+ dots) per series on shared axes (y = max across all
64
+ # series, x = union of their time range). `points`/`color` are ignored.
65
+ def initialize(points:, color:, unit:, label:, range_ms:, height: 200, width: 600, axes: true, style: :area, zoom_url: nil, series: nil, key: nil)
66
+ # key — a STABLE per-chart id (the panel_key on a dashboard). Emitted so the
67
+ # multi-series controller can persist which lines the operator hid ACROSS a
68
+ # realtime Turbo Stream refresh (which replaces the chart DOM + reconnects).
69
+ @key = key
70
+ @series = series.is_a?(Array) ? series : nil
71
+ @points = Array(points)
72
+ @color = color
73
+ @unit = unit
74
+ @label = label
75
+ @range_ms = range_ms.to_i
76
+ @height = height
77
+ @width = width
78
+ @axes = axes
79
+ @style = STYLES.include?(style&.to_sym) ? style.to_sym : :area
80
+ # zoom_url — the /metrics/chart endpoint URL for THIS chart (with its
81
+ # metric/scope/server params). Present only when the chart lives inside
82
+ # the expand modal: brush-to-zoom then re-fetches the modal body at the
83
+ # brushed window (range=custom&from&until) instead of navigating the whole
84
+ # page — which would tear the modal down. Absent on the grid → brush does
85
+ # a full-page Turbo.visit, which is the right behavior there.
86
+ @zoom_url = zoom_url
87
+ end
88
+
89
+ def bars? = @style == :bars
90
+
91
+ def line? = @style == :line
92
+
93
+ def area? = @style == :area
94
+
95
+ def pad_left
96
+ @axes ? PAD_LEFT_FULL : PAD_LEFT_COMPACT
97
+ end
98
+
99
+ def pad_right
100
+ @axes ? PAD_RIGHT_FULL : PAD_RIGHT_COMPACT
101
+ end
102
+
103
+ def pad_top
104
+ @axes ? PAD_TOP_FULL : PAD_TOP_COMPACT
105
+ end
106
+
107
+ def pad_bottom
108
+ @axes ? PAD_BOTTOM_FULL : PAD_BOTTOM_COMPACT
109
+ end
110
+
111
+ def multi? = !@series.nil? && @series.any? { |s| Array(s[:points]).any? }
112
+
113
+ def view_template
114
+ return render_multi if multi?
115
+
116
+ # Truly empty → honest "no data" placeholder (cold boot, no
117
+ # samples on disk or in warehouse for this range yet).
118
+ if @points.empty?
119
+ return div(
120
+ class: "flex items-center justify-center text-clowk-muted text-[12px]",
121
+ style: "height: #{@height}px;"
122
+ ) { "no data" }
123
+ end
124
+
125
+ # Single point edge case — happens when the range/interval is
126
+ # narrow enough that only one bucket has samples (warehouse
127
+ # warming up, brand-new server, or very short range). The
128
+ # operator already sees a meaningful value in the StatCard
129
+ # headline pulled from that same point; the chart should render
130
+ # a flat line at that level rather than say "no data" (which
131
+ # contradicts the headline + min/avg/max next to it). We
132
+ # duplicate the point so the curve has 2 vertices to draw.
133
+ if @points.size == 1
134
+ only = @points.first
135
+ @points = [only, only]
136
+ end
137
+
138
+ pts = projected_points
139
+ y_max = y_axis_max
140
+ x_min, x_max = time_bounds
141
+
142
+ div(
143
+ class: "relative w-full",
144
+ data: {
145
+ controller: "metrics-chart",
146
+ metrics_chart_points_value: json_data(points_for_js),
147
+ metrics_chart_segments_value: json_data(normalized_segments),
148
+ metrics_chart_color_value: @color,
149
+ metrics_chart_unit_value: @unit,
150
+ metrics_chart_label_value: @label,
151
+ metrics_chart_width_value: @width,
152
+ metrics_chart_height_value: @height,
153
+ metrics_chart_pad_left_value: pad_left,
154
+ metrics_chart_pad_right_value: pad_right,
155
+ metrics_chart_pad_top_value: pad_top,
156
+ metrics_chart_pad_bottom_value: pad_bottom,
157
+ metrics_chart_baseline_y_value: baseline_y,
158
+ # Interpolation for the path REBUILT on resize: "linear" (Line style,
159
+ # straight point-to-point) vs "step" (area — honest step-after). Must
160
+ # match the server-rendered path above or a resize would flip the look.
161
+ metrics_chart_interp_value: (line? ? "linear" : "step"),
162
+ # responsive: client measures actual container width on
163
+ # connect + on resize, then rewrites viewBox to
164
+ # `0 0 <measuredW> <height>` and reprojects path + axis
165
+ # ticks using the normalized segments above. Result: chart
166
+ # fills container fully WITHOUT squishing text (every SVG
167
+ # unit == 1 CSS pixel after takeover). Server-rendered
168
+ # snapshot below uses @width/@height as a no-JS fallback.
169
+ metrics_chart_responsive_value: true,
170
+ # Timezone the JS tooltip should format timestamps in.
171
+ # Matches the same Clowk::Phlex::Charts::WebTime.zone_name driving the server-
172
+ # rendered X-axis ticks, so a hover label and the axis
173
+ # tick directly below agree on TZ.
174
+ metrics_chart_timezone_value: Clowk::Phlex::Charts::WebTime.zone_name,
175
+ # Stable panel id so the "Show dots" pref (options menu) keys correctly.
176
+ **(@key.present? ? {metrics_chart_key_value: @key} : {}),
177
+ # Only emitted in the expand modal (see @zoom_url). Its mere
178
+ # PRESENCE is what the controller keys on (hasZoomUrlValue) to
179
+ # pick the in-modal re-fetch over a full-page navigation, so it
180
+ # must stay absent — not empty — on the grid.
181
+ **(@zoom_url.present? ? {metrics_chart_zoom_url_value: @zoom_url} : {})
182
+ }
183
+ ) do
184
+ # ── Responsive strategy ────────────────────────────────────
185
+ #
186
+ # The server emits a COMPLETE chart at viewBox=@width × @height
187
+ # (default 600×200) so no-JS users see a coherent snapshot.
188
+ # `preserveAspectRatio="xMidYMid meet"` (default) keeps the
189
+ # aspect intact — text stays round, dots stay circular —
190
+ # accepting horizontal whitespace on wider containers.
191
+ #
192
+ # Then metrics_chart_controller.js takes over: it measures
193
+ # the container's CSS width, sets viewBox to `0 0 W <height>`,
194
+ # and rewrites every x-coordinate (path, axis tick labels,
195
+ # spanning lines, clip + overlay rects) so 1 viewBox unit ==
196
+ # 1 CSS pixel post-takeover. That keeps text at its design
197
+ # size (10pt SVG = 10px on screen) AND fills the full width.
198
+ #
199
+ # Elements that need x-repositioning on resize are tagged
200
+ # with Stimulus targets below:
201
+ # line / area — path rebuilt from segmentsValue
202
+ # clipRect / overlayRect — width updated
203
+ # hLine (multi) — x2 updated to W - padRight
204
+ # xTick (multi) — x updated to padLeft + t * innerW
205
+ svg(
206
+ width: "100%", height: @height,
207
+ viewBox: "0 0 #{@width} #{@height}",
208
+ class: "block overflow-visible",
209
+ style: "touch-action: pan-y;",
210
+ data: {metrics_chart_target: "svg"}
211
+ ) do |s|
212
+ s.defs do
213
+ s.linearGradient(id: gradient_id, x1: 0, y1: 0, x2: 0, y2: 1) do
214
+ s.stop(offset: "0%", "stop-color": @color, "stop-opacity": "0.30")
215
+ s.stop(offset: "100%", "stop-color": @color, "stop-opacity": "0")
216
+ end
217
+
218
+ # Bars carry a richer fade (bright top → soft bottom) so a single
219
+ # column still reads as filled, not as a faint sliver.
220
+ if bars?
221
+ s.linearGradient(id: bars_gradient_id, x1: 0, y1: 0, x2: 0, y2: 1) do
222
+ s.stop(offset: "0%", "stop-color": @color, "stop-opacity": "0.85")
223
+ s.stop(offset: "100%", "stop-color": @color, "stop-opacity": "0.22")
224
+ end
225
+ end
226
+
227
+ # clipPath bounds the curve + area fill to the chart
228
+ # drawing area. Catmull-Rom bezier interpolation can
229
+ # overshoot the data envelope when adjacent points
230
+ # zig-zag (peak → 0 → peak makes the curve dip BELOW
231
+ # the y=0 baseline, which then bleeds the area fill
232
+ # into the x-axis label region). Clipping is the
233
+ # standard fix; cheaper than swapping to a monotone
234
+ # spline + keeps the smooth aesthetic.
235
+ s.clipPath(id: clip_id) do
236
+ s.rect(
237
+ x: pad_left, y: pad_top,
238
+ width: @width - pad_left - pad_right,
239
+ height: @height - pad_top - pad_bottom,
240
+ data: {metrics_chart_target: "clipRect"}
241
+ )
242
+ end
243
+ end
244
+
245
+ # Axes are visible on the big chart (Metrics page). Hidden
246
+ # in compact mode so the StatCards on Overview / Pod show
247
+ # render as bare sparklines — same engine, no clutter.
248
+ if @axes
249
+ render_y_axis(s, y_max)
250
+ render_x_axis(s, x_min, x_max)
251
+ end
252
+
253
+ # Style switch — all three share axes/hover/brush; only the mark differs.
254
+ # bars → one filled column per bucket
255
+ # line → smoothed stroke + a dot per point, NO fill
256
+ # area → smoothed stroke + gradient fill
257
+ if bars?
258
+ render_bars(s, pts, clip_id)
259
+ else
260
+ # Stroke (and, for area, the fill) go through the clip so a
261
+ # Catmull-Rom overshoot below the baseline is invisibly cropped.
262
+ s.g("clip-path": "url(##{clip_id})") do
263
+ if area?
264
+ s.path(
265
+ d: area_path_for(pts), fill: "url(##{gradient_id})",
266
+ data: {metrics_chart_target: "area"}
267
+ )
268
+ end
269
+
270
+ s.path(
271
+ d: path_for(pts), fill: "none", stroke: @color, "stroke-width": "1.5",
272
+ "stroke-linecap": "round", "stroke-linejoin": "round",
273
+ data: {metrics_chart_target: "line"}
274
+ )
275
+ end
276
+
277
+ # Line style: a dot on each data point (OUTSIDE the clip so an edge
278
+ # dot isn't half-cropped). Reprojected on resize via data-x-norm.
279
+ render_dots(s, pts) if line?
280
+ end
281
+
282
+ # Frame baseline — solid line at the bottom of the chart
283
+ # area, distinct from the dashed y=0 gridline since the
284
+ # chart's bottom often clips into the x-axis label band.
285
+ # Compact mode skips it (no axis area to demarcate).
286
+ if @axes
287
+ s.line(
288
+ x1: pad_left, x2: @width - pad_right,
289
+ y1: baseline_y, y2: baseline_y,
290
+ stroke: "var(--clowk-border)",
291
+ data: {metrics_chart_target: "hLine"}
292
+ )
293
+ end
294
+
295
+ # Hover overlay rect — full chart area minus padding.
296
+ # Single rect (vs one-per-point) because the JS finds the
297
+ # nearest point via x-distance, matching the inspiration's
298
+ # `onMove` handler. Cursor crosshair signals interactivity.
299
+ s.rect(
300
+ x: pad_left, y: pad_top,
301
+ width: @width - pad_left - pad_right,
302
+ height: @height - pad_top - pad_bottom,
303
+ fill: "transparent", "pointer-events": "all",
304
+ style: "cursor: crosshair;",
305
+ data: {
306
+ metrics_chart_target: "overlay",
307
+ # Brush-to-zoom (drag a time range → reload at range=custom) works
308
+ # for every style — a time sub-range maps the same whether the mark
309
+ # is an area, a line, or bars.
310
+ action: "mousemove->metrics-chart#move mouseleave->metrics-chart#leave mousedown->metrics-chart#brushStart"
311
+ }
312
+ )
313
+ end
314
+ end
315
+ end
316
+
317
+ private
318
+
319
+ # json_data — to_json that always returns a UTF-8 string. json 2.20 can hand
320
+ # back an ASCII-8BIT buffer for payloads carrying multibyte labels (the "·"
321
+ # separators in pod names), which then clashes with Phlex's UTF-8 output
322
+ # buffer (Encoding::CompatibilityError). Relabel to UTF-8 so the data-attribute
323
+ # concatenation stays compatible.
324
+ def json_data(obj)
325
+ obj.to_json.dup.force_encoding(Encoding::UTF_8)
326
+ end
327
+
328
+ # ── Multi-series (pilot: Line) ─────────────────────────────────────────────
329
+ #
330
+ # One line + dots per series on SHARED axes (y = nice_ceil of the max value
331
+ # across all series, x = union time range). Reuses the single-series axes
332
+ # (render_y_axis/render_x_axis), clip, linear stroke (linear_segment_path),
333
+ # and the responsive machinery — dots carry x_norm like the single path, and
334
+ # each line carries a series_index so the resize rebuild targets it.
335
+
336
+ def render_multi
337
+ bounds = multi_bounds
338
+ inner_w = (@width - pad_left - pad_right).to_f
339
+
340
+ div(
341
+ class: "relative w-full",
342
+ data: {
343
+ controller: "metrics-chart",
344
+ metrics_chart_series_value: json_data(multi_series_for_js(bounds)),
345
+ metrics_chart_multi_value: true,
346
+ metrics_chart_color_value: @color,
347
+ metrics_chart_unit_value: @unit,
348
+ metrics_chart_label_value: @label,
349
+ metrics_chart_width_value: @width,
350
+ metrics_chart_height_value: @height,
351
+ metrics_chart_pad_left_value: pad_left,
352
+ metrics_chart_pad_right_value: pad_right,
353
+ metrics_chart_pad_top_value: pad_top,
354
+ metrics_chart_pad_bottom_value: pad_bottom,
355
+ metrics_chart_baseline_y_value: baseline_y,
356
+ metrics_chart_interp_value: "linear",
357
+ metrics_chart_responsive_value: true,
358
+ metrics_chart_timezone_value: Clowk::Phlex::Charts::WebTime.zone_name,
359
+ # Stable id so hidden-line selections survive a realtime stream refresh.
360
+ **(@key.present? ? {metrics_chart_key_value: @key} : {})
361
+ }
362
+ ) do
363
+ svg(
364
+ width: "100%", height: @height, viewBox: "0 0 #{@width} #{@height}",
365
+ class: "block overflow-visible", style: "touch-action: pan-y;",
366
+ data: {metrics_chart_target: "svg"}
367
+ ) do |s|
368
+ s.defs do
369
+ s.clipPath(id: clip_id) do
370
+ s.rect(
371
+ x: pad_left, y: pad_top,
372
+ width: @width - pad_left - pad_right, height: @height - pad_top - pad_bottom,
373
+ data: {metrics_chart_target: "clipRect"}
374
+ )
375
+ end
376
+ end
377
+
378
+ if @axes
379
+ render_y_axis(s, bounds[:y_max])
380
+ render_x_axis(s, bounds[:x_min], bounds[:x_max])
381
+ end
382
+
383
+ # Grouped-bars geometry (bars style only): N series share each bucket
384
+ # slot side-by-side. group_w spans ~72% of the slot; each series gets a
385
+ # 1/N-wide column offset within it.
386
+ group_w, bar_w = multi_bar_dims(bounds) if bars?
387
+
388
+ @series.each_with_index do |ser, i|
389
+ pts = project_series(Array(ser[:points]), bounds)
390
+ next if pts.empty?
391
+
392
+ color = ser[:color] || @color
393
+
394
+ if bars?
395
+ render_multi_bars(s, pts, i, color, group_w, bar_w, inner_w)
396
+
397
+ next
398
+ end
399
+
400
+ s.g("clip-path": "url(##{clip_id})") do
401
+ # Area multi = the same raio line + a translucent fill down to the
402
+ # baseline. Low opacity so up to 5 overlapping fills stay readable.
403
+ if area?
404
+ s.path(
405
+ d: linear_area_path(pts), fill: color, "fill-opacity": "0.14", stroke: "none",
406
+ data: {metrics_chart_target: "area", series_index: i}
407
+ )
408
+ end
409
+
410
+ s.path(
411
+ d: linear_segment_path(pts), fill: "none", stroke: color, "stroke-width": "1.5",
412
+ "stroke-linecap": "round", "stroke-linejoin": "round",
413
+ data: {metrics_chart_target: "line", series_index: i}
414
+ )
415
+ end
416
+
417
+ pts.each do |x, y|
418
+ x_norm = (inner_w.positive? ? (x - pad_left) / inner_w : 0).round(5)
419
+
420
+ s.circle(cx: x.round(2), cy: y.round(2), r: "2.5", fill: color,
421
+ data: {metrics_chart_target: "dot", x_norm: x_norm, series_index: i})
422
+ end
423
+ end
424
+
425
+ if @axes
426
+ s.line(
427
+ x1: pad_left, x2: @width - pad_right, y1: baseline_y, y2: baseline_y,
428
+ stroke: "var(--clowk-border)", data: {metrics_chart_target: "hLine"}
429
+ )
430
+ end
431
+
432
+ s.rect(
433
+ x: pad_left, y: pad_top,
434
+ width: @width - pad_left - pad_right, height: @height - pad_top - pad_bottom,
435
+ fill: "transparent", "pointer-events": "all", style: "cursor: crosshair;",
436
+ data: {
437
+ metrics_chart_target: "overlay",
438
+ action: "mousemove->metrics-chart#move mouseleave->metrics-chart#leave mousedown->metrics-chart#brushStart"
439
+ }
440
+ )
441
+ end
442
+
443
+ render_multi_legend
444
+ end
445
+ end
446
+
447
+ # multi_bar_dims — [group_w, bar_w] for grouped bars: the group spans ~72% of
448
+ # a bucket slot (derived from the first multi-point series' spacing), split
449
+ # evenly across the series so each pod gets its own column, side-by-side.
450
+ def multi_bar_dims(bounds)
451
+ count = [@series.size, 1].max
452
+ sample = @series.map { |ser| project_series(Array(ser[:points]), bounds) }.find { |p| p.size >= 2 }
453
+ slot = sample ? (sample[1][0] - sample[0][0]) : ((@width - pad_left - pad_right).to_f)
454
+ group_w = [slot * 0.72, count.to_f].max
455
+
456
+ [group_w, group_w / count]
457
+ end
458
+
459
+ # render_multi_bars — one column per bucket for series `idx`, offset within the
460
+ # shared group so the pods cluster side-by-side. Each rect carries x_norm/w_norm
461
+ # (the JS repositions bars on resize) + series_index (legend hide/highlight).
462
+ def render_multi_bars(svg, pts, idx, color, group_w, bar_w, inner_w)
463
+ draw_w = bar_w * 0.86
464
+
465
+ svg.g("clip-path": "url(##{clip_id})") do |g|
466
+ pts.each do |x, y|
467
+ x0 = x - group_w / 2 + idx * bar_w
468
+ h = baseline_y - y
469
+ next if h <= 0.4
470
+
471
+ x_norm = (inner_w.positive? ? (x0 - pad_left) / inner_w : 0).round(5)
472
+ w_norm = (inner_w.positive? ? draw_w / inner_w : 0).round(5)
473
+
474
+ g.rect(
475
+ x: x0.round(2), y: y.round(2), width: draw_w.round(2), height: h.round(2), rx: "0.75", fill: color,
476
+ data: {metrics_chart_target: "bar", x_norm: x_norm, w_norm: w_norm, series_index: idx}
477
+ )
478
+ end
479
+ end
480
+ end
481
+
482
+ # render_multi_legend — the interactive key under a multi-series chart. Each
483
+ # entry is a BUTTON living INSIDE the metrics-chart controller root (so it can
484
+ # reach the lines/dots by series_index):
485
+ # HOVER → spotlight this series (the controller dims the others)
486
+ # CLICK → toggle this line's visibility (hide/show)
487
+ # Wraps on narrow cards; the swatch + label read the same as the old footer
488
+ # legend, now with pointer affordance + a11y aria-pressed.
489
+ def render_multi_legend
490
+ div(class: "flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] font-clowk-mono mt-2 px-0.5") do
491
+ @series.each_with_index do |ser, i|
492
+ color = ser[:color] || @color
493
+ cur = ser[:current]
494
+
495
+ button(
496
+ type: "button",
497
+ class: "inline-flex items-center gap-1.5 min-w-0 cursor-pointer select-none transition-opacity",
498
+ aria: {pressed: "true", label: "Toggle #{ser[:label]} line"},
499
+ title: "Click to hide/show · hover to highlight",
500
+ data: {
501
+ metrics_chart_target: "legendItem",
502
+ series_index: i,
503
+ action: "mouseenter->metrics-chart#highlightSeries " \
504
+ "mouseleave->metrics-chart#unhighlightSeries " \
505
+ "click->metrics-chart#toggleSeries"
506
+ }
507
+ ) do
508
+ span(class: "inline-block w-2.5 h-2.5 rounded-sm shrink-0", style: "background: #{color};")
509
+ span(class: "text-clowk-text-2 truncate", data: {legend_label: true}) { ser[:label].to_s }
510
+ span(class: "text-clowk-muted shrink-0") { format_series_current(cur) } unless cur.nil?
511
+ end
512
+ end
513
+ end
514
+ end
515
+
516
+ # format_series_current — the legend's latest value per series. Mirrors
517
+ # ChartCard#format_current (percent bakes the % in; others go number-only).
518
+ def format_series_current(v)
519
+ return "—" if v.nil?
520
+
521
+ (@unit == "%") ? Clowk::Phlex::Charts::Format.percent(v) : Clowk::Phlex::Charts::Format.number(v)
522
+ end
523
+
524
+ # multi_bounds — shared axes: y ceiling = nice_ceil of the max value anywhere;
525
+ # x = union time range. Every series projects onto these.
526
+ def multi_bounds
527
+ values = @series.flat_map { |s| Array(s[:points]).map { |p| p[:value].to_f } }
528
+ times = @series.flat_map { |s| Array(s[:points]).map { |p| parse_ts_ms(p[:ts]) } }
529
+
530
+ {y_max: nice_ceil(values.max || 0), x_min: times.min || 0, x_max: times.max || 0}
531
+ end
532
+
533
+ # project_series — one series' points → [[x,y]] on the shared bounds.
534
+ def project_series(points, bounds)
535
+ inner_w = (@width - pad_left - pad_right).to_f
536
+ inner_h = (@height - pad_top - pad_bottom).to_f
537
+ x_span = [(bounds[:x_max] - bounds[:x_min]).to_f, 1.0].max
538
+ y_span = [bounds[:y_max].to_f, 0.0001].max
539
+
540
+ points.map do |p|
541
+ t = parse_ts_ms(p[:ts])
542
+ x = pad_left + ((t - bounds[:x_min]) / x_span) * inner_w
543
+ y = pad_top + (1 - (p[:value].to_f / y_span)) * inner_h
544
+ [x, y]
545
+ end
546
+ end
547
+
548
+ # multi_series_for_js — per series: color, label, and points with x_norm
549
+ # (0..1 on the shared x) + y (viewBox units) so the controller rebuilds each
550
+ # line on resize and drives the shared hover tooltip.
551
+ def multi_series_for_js(bounds)
552
+ inner_w = (@width - pad_left - pad_right).to_f
553
+
554
+ @series.map do |s|
555
+ pts = Array(s[:points])
556
+ proj = project_series(pts, bounds)
557
+
558
+ {
559
+ label: s[:label].to_s,
560
+ color: s[:color] || @color,
561
+ points: pts.each_with_index.map do |p, i|
562
+ x, y = proj[i]
563
+
564
+ {ts: p[:ts], value: p[:value], formatted: p[:formatted],
565
+ x_norm: (inner_w.positive? ? (x - pad_left) / inner_w : 0).round(5), y: y.round(2)}
566
+ end
567
+ }
568
+ end
569
+ end
570
+
571
+ # points_for_js — pre-projected points the Stimulus controller
572
+ # consumes for hover nearest-x lookup. Keeps the coordinate math
573
+ # in one place (server-side) and lets the JS stay tiny: find
574
+ # nearest by x, position tooltip + crosshair using the same px
575
+ # the SVG already painted.
576
+ #
577
+ # x_norm is the point's x position normalized to [0, 1] within
578
+ # the inner chart area (between padLeft and width-padRight). On
579
+ # resize the controller recomputes the absolute x via
580
+ # `padLeft + x_norm * innerW` so hover lookup follows the chart
581
+ # as its viewBox stretches to match the container.
582
+ def points_for_js
583
+ pts = projected_points
584
+ inner_w = (@width - pad_left - pad_right).to_f
585
+
586
+ return [] if inner_w <= 0
587
+
588
+ @points.each_with_index.map do |p, i|
589
+ abs_x = pts[i][0]
590
+ {
591
+ ts: p[:ts],
592
+ value: p[:value],
593
+ formatted: p[:formatted],
594
+ x: abs_x.round(2),
595
+ x_norm: ((abs_x - pad_left) / inner_w).round(5),
596
+ y: pts[i][1].round(2)
597
+ }
598
+ end
599
+ end
600
+
601
+ # normalized_segments — same gap-detected step-after segments
602
+ # used by the server-side path, but with each point's x stored
603
+ # as a 0-1 ratio of the inner chart area. The Stimulus
604
+ # controller rebuilds the path d-strings on resize by mapping
605
+ # `x_norm → padLeft + x_norm * (W - padLeft - padRight)` against
606
+ # the measured container width.
607
+ #
608
+ # Returning normalized segments (instead of raw points) means
609
+ # the chart honours its gap policy across resize too: a 3-hour
610
+ # outage stays as two disconnected servers of data in the wide
611
+ # post-resize chart, never auto-bridged.
612
+ def normalized_segments
613
+ pts = projected_points
614
+ inner_w = (@width - pad_left - pad_right).to_f
615
+
616
+ return [] if pts.empty? || inner_w <= 0
617
+
618
+ segments_of(pts).map do |seg|
619
+ seg.map { |x, y| [((x - pad_left) / inner_w).round(5), y.round(2)] }
620
+ end
621
+ end
622
+
623
+ def projected_points
624
+ y_max = y_axis_max
625
+ x_min, x_max = time_bounds
626
+
627
+ inner_w = (@width - pad_left - pad_right).to_f
628
+ inner_h = (@height - pad_top - pad_bottom).to_f
629
+ x_span = [(x_max - x_min).to_f, 1.0].max
630
+ y_span = [y_max.to_f, 0.0001].max
631
+
632
+ @points.map do |p|
633
+ t = parse_ts_ms(p[:ts])
634
+ x = pad_left + ((t - x_min) / x_span) * inner_w
635
+ y = pad_top + (1 - (p[:value].to_f / y_span)) * inner_h
636
+ [x, y]
637
+ end
638
+ end
639
+
640
+ def raw_max
641
+ @points.map { |p| p[:value].to_f }.max
642
+ end
643
+
644
+ # y_axis_max — single source of truth for the chart's Y ceiling.
645
+ # Both the gridline renderer (render_y_axis) and the point
646
+ # projection (projected_points) must agree, or the dots float
647
+ # above the top gridline. Centralised here.
648
+ #
649
+ # No multiplicative padding — `nice_ceil` already rounds UP to
650
+ # the next clean number (5, 7.5, 10, 25, 50, 75, 100, …), which
651
+ # naturally puts a peak below the chart's top edge in every
652
+ # case EXCEPT when the peak sits exactly at a bucket boundary
653
+ # (e.g. CPU pegged at 100%). At that boundary we accept the
654
+ # peak kissing the top — it's honest ("you're at the cap") and
655
+ # adding padding would jump the axis to 2× the bucket size
656
+ # (100 × 1.05 = 105 → ceil → 200, the bug the operator caught).
657
+ def y_axis_max
658
+ nice_ceil(raw_max)
659
+ end
660
+
661
+ def time_bounds
662
+ parsed = @points.map { |p| parse_ts_ms(p[:ts]) }
663
+ [parsed.min, parsed.max]
664
+ end
665
+
666
+ def parse_ts_ms(iso)
667
+ return 0 if iso.blank?
668
+
669
+ Time.iso8601(iso.to_s).to_f * 1000
670
+ rescue ArgumentError
671
+ 0
672
+ end
673
+
674
+ def baseline_y
675
+ @height - pad_bottom
676
+ end
677
+
678
+ # render_y_axis — 5 horizontal gridlines + numeric labels on the
679
+ # left. The first (y=0) gridline is invisible (the frame baseline
680
+ # already draws there). Labels use the chart's "max value" rounded
681
+ # to a nice number so axis values read like 0, 25, 50, 75, 100
682
+ # instead of 0, 23.7, 47.4, 71.1, 94.8.
683
+ def render_y_axis(svg, y_max)
684
+ (0..(Y_TICKS - 1)).each do |i|
685
+ t = i.to_f / (Y_TICKS - 1)
686
+ v = t * y_max
687
+ y = pad_top + (1 - t) * (@height - pad_top - pad_bottom)
688
+
689
+ svg.line(
690
+ x1: pad_left, x2: @width - pad_right,
691
+ y1: y, y2: y,
692
+ stroke: "var(--clowk-border)",
693
+ "stroke-opacity": i.zero? ? "0" : "0.5",
694
+ data: {metrics_chart_target: "hLine"}
695
+ )
696
+
697
+ # Y-axis label x stays at pad_left - 6 across resize (the left gutter
698
+ # doesn't change with width). The tick RATIO (t) rides on the element so a
699
+ # multi-series chart can relabel it when a legend toggle rescales the Y
700
+ # ceiling (label value = t × y_max) — see metrics-chart#applyYScale.
701
+ svg.text(
702
+ x: pad_left - 6, y: y + 3.5,
703
+ "text-anchor": "end",
704
+ "font-size": "10",
705
+ fill: "var(--clowk-muted-2)",
706
+ "font-family": "var(--clowk-font-mono, ui-monospace, monospace)",
707
+ data: {metrics_chart_target: "yTick", y_tick_ratio: t}
708
+ ) { format_axis_number(v) }
709
+ end
710
+ end
711
+
712
+ # render_x_axis — 5 timestamps along the bottom. Format adapts
713
+ # to the range (HH:MM:SS for ≤1h, HH:MM for ≤24h, MM/DD beyond).
714
+ # Same logic as fmtAxisTime in pages-metrics.jsx so the labels
715
+ # match the inspiration. Timestamps are converted into the
716
+ # operator's preferred timezone (Settings → Display preferences)
717
+ # via WebTime so chart x-axis ticks read in local time, not UTC.
718
+ def render_x_axis(svg, x_min, x_max)
719
+ span = x_max - x_min
720
+
721
+ (0..(X_TICKS - 1)).each do |i|
722
+ t = i.to_f / (X_TICKS - 1)
723
+ ts_ms = x_min + t * span
724
+ ts = Time.at(ts_ms / 1000.0)
725
+
726
+ # X-axis labels: x is recomputed on resize as
727
+ # `pad_left + (t * inner_w)`. Stash t on the element so JS
728
+ # can rescale without re-doing the tick loop.
729
+ svg.text(
730
+ x: pad_left + t * (@width - pad_left - pad_right),
731
+ y: @height - 5,
732
+ "text-anchor": "middle",
733
+ "font-size": "10",
734
+ fill: "var(--clowk-muted-2)",
735
+ "font-family": "var(--clowk-font-mono, ui-monospace, monospace)",
736
+ data: {
737
+ metrics_chart_target: "xTick",
738
+ x_tick_ratio: t.round(4)
739
+ }
740
+ ) { format_axis_time(ts) }
741
+ end
742
+ end
743
+
744
+ # format_axis_time — pick format that fits the chart's range and
745
+ # render in the operator's timezone:
746
+ # ≤ 1h → HH:MM:SS
747
+ # ≤ 24h → HH:MM
748
+ # beyond → MM/DD
749
+ def format_axis_time(ts)
750
+ pattern =
751
+ if @range_ms <= 60 * 60 * 1000 then "%H:%M:%S"
752
+ elsif @range_ms <= 24 * 60 * 60 * 1000 then "%H:%M"
753
+ else "%m/%d"
754
+ end
755
+
756
+ Clowk::Phlex::Charts::WebTime.strftime(ts, pattern) || ""
757
+ end
758
+
759
+ # format_axis_number — Y-axis label compaction. "Nk" for >=1000
760
+ # keeps labels short in the 38-pixel left gutter; below 1000 we
761
+ # defer to Clowk::Phlex::Charts::Format.number so sub-1 values keep enough
762
+ # precision to read as more than "0.0" (otherwise a chart with
763
+ # peaks at 0.05 had every gridline labelled "0.0", masking the
764
+ # actual scale).
765
+ def format_axis_number(v)
766
+ abs = v.abs
767
+ return "#{(v / 1000.0).round(1)}k" if abs >= 1000
768
+
769
+ Clowk::Phlex::Charts::Format.number(v)
770
+ end
771
+
772
+ # nice_ceil — rounds a max value up to a "nice" axis ceiling
773
+ # (1, 2, 2.5, 4, 5, 7.5, 10 × 10^n). Without this the Y-axis
774
+ # labels would be ugly raw numbers like 23.7, 47.4, 71.1; with
775
+ # it we get 25, 40, 50, 75, 100.
776
+ #
777
+ # Buckets, with rationale per stop:
778
+ # 1 — small values (0..1)
779
+ # 2 — peaks at ~1.x get a clean ceiling of 2
780
+ # 2.5 — peaks at ~2 get 25 instead of 40 (gridlines 0/6.25/12.5/...)
781
+ # 4 — peaks at ~3 get 40 instead of 50 (gridlines 0/10/20/30/40)
782
+ # Without this, 29.4 → mantissa 2.94 → bucket 5 → axis 50,
783
+ # wasting ~40% of the chart's vertical space.
784
+ # 5 — peaks at ~4 get 50
785
+ # 7.5 — peaks at ~6 get 75 instead of 100 (avoids over-zoom 2x)
786
+ # 10 — peaks at ~8-9 land at the next decade
787
+ def nice_ceil(v)
788
+ return 1 if v <= 0
789
+
790
+ exp = Math.log10(v).floor
791
+ factor = 10.0**exp
792
+ mantissa = v / factor
793
+
794
+ ceil = case mantissa
795
+ when 0..1 then 1
796
+ when 1..2 then 2
797
+ when 2..2.5 then 2.5
798
+ when 2.5..4 then 4
799
+ when 4..5 then 5
800
+ when 5..7.5 then 7.5
801
+ when 7.5..10 then 10
802
+ else 10
803
+ end
804
+
805
+ ceil * factor
806
+ end
807
+
808
+ # GAP_FACTOR — how many "median deltas" between consecutive points
809
+ # we allow before declaring a gap. 3× picks up real outages (host
810
+ # off for hours) without false-firing on the warehouse sync jitter
811
+ # (a tick that took 35s instead of 30s isn't a gap, it's noise).
812
+ GAP_FACTOR = 3.0
813
+
814
+ # path_for — step-after (LOCF) path with gap detection.
815
+ #
816
+ # Splits the projected points into contiguous segments separated
817
+ # by detected gaps (median × GAP_FACTOR threshold), then renders
818
+ # each segment as a step-after staircase via `segment_path`.
819
+ #
820
+ # Why step-after instead of the previous Catmull-Rom bezier:
821
+ # the chart's X axis is timestamp-based and non-uniform, so a
822
+ # bezier connecting sparse samples drew a diagonal "ramp" that
823
+ # implied gradual transition between them — actively misleading
824
+ # when the truth was just "no data in between." See `segment_path`
825
+ # for the full rationale + the path-shape math.
826
+ #
827
+ # Gap detection (segments_of) is still useful even with step:
828
+ # for very long outages (host offline for hours), holding the
829
+ # previous Y as a single flat line all the way to the post-outage
830
+ # sample would imply "value stayed at X for hours" which is
831
+ # equally dishonest. A real gap breaks the path so the chart
832
+ # shows two disconnected servers of data with empty space between.
833
+ def path_for(pts)
834
+ builder = line? ? :linear_segment_path : :segment_path
835
+ segments_of(pts).map { |seg| send(builder, seg) }.reject(&:empty?).join(" ")
836
+ end
837
+
838
+ # linear_segment_path — straight diagonal from point to point ("raio"), the
839
+ # classic line-chart look the Line STYLE wants. area/bar keep the honest
840
+ # STEP-AFTER (see segment_path); Line trades that for a flowing line the
841
+ # operator explicitly opted into by picking it.
842
+ def linear_segment_path(pts)
843
+ return "" if pts.size < 2
844
+
845
+ "M " + pts.map { |x, y| "#{x} #{y}" }.join(" L ")
846
+ end
847
+
848
+ # linear_area_path — the linear (raio) stroke closed down to the baseline, for
849
+ # the Area multi fill. Mirrors linearPath+baseline in the JS resize rebuild.
850
+ def linear_area_path(pts)
851
+ return "" if pts.size < 2
852
+
853
+ "#{linear_segment_path(pts)} L #{pts.last[0]} #{baseline_y} L #{pts.first[0]} #{baseline_y} Z"
854
+ end
855
+
856
+ # area_path_for — like path_for but each segment is independently
857
+ # closed down to the baseline. Without this, the area fill would
858
+ # close from the post-gap rightmost point ALL THE WAY LEFT to the
859
+ # first sample, creating a translucent polygon covering the entire
860
+ # gap region (visually pretending there was data). Per-segment
861
+ # closure means each "server" of real data gets its own area fill
862
+ # rooted to the baseline — the gap is honest empty space.
863
+ def area_path_for(pts)
864
+ segments_of(pts).map do |seg|
865
+ next "" if seg.size < 2
866
+
867
+ "#{segment_path(seg)} L #{seg.last[0]} #{baseline_y} L #{seg.first[0]} #{baseline_y} Z"
868
+ end.reject(&:empty?).join(" ")
869
+ end
870
+
871
+ # segments_of — splits the projected points into contiguous runs
872
+ # separated by gaps. A gap is any X distance > GAP_FACTOR × median
873
+ # delta. The result is an Array of Arrays of points (each inner
874
+ # Array is one segment ready for its own M+C+...+L baseline pass).
875
+ #
876
+ # Median-based threshold (not mean) so the appended latest point
877
+ # — typically closer to the last bucket than buckets are to each
878
+ # other — doesn't skew the cutoff. Catches multi-hour outages
879
+ # without false-firing on natural sync jitter.
880
+ def segments_of(pts)
881
+ return [pts] if pts.size < 3
882
+
883
+ threshold = gap_threshold_for(pts)
884
+ segments = [[pts[0]]]
885
+
886
+ (1...pts.size).each do |i|
887
+ if (pts[i][0] - pts[i - 1][0]) > threshold
888
+ segments << [pts[i]]
889
+ else
890
+ segments.last << pts[i]
891
+ end
892
+ end
893
+
894
+ segments
895
+ end
896
+
897
+ # gap_threshold_for — derives the "this is a gap" cutoff from the
898
+ # actual sample spacing in the series, not from a fixed value.
899
+ def gap_threshold_for(pts)
900
+ return Float::INFINITY if pts.size < 3
901
+
902
+ deltas = (1...pts.size).map { |i| pts[i][0] - pts[i - 1][0] }
903
+ median = deltas.sort[deltas.size / 2]
904
+ median * GAP_FACTOR
905
+ end
906
+
907
+ # segment_path — single-segment STEP-AFTER (LOCF) path.
908
+ #
909
+ # Why step instead of the previous Catmull-Rom bezier:
910
+ #
911
+ # With time-bucketed metrics the X axis is non-uniform — a 1h
912
+ # range showing two samples 70 minutes apart used to draw a
913
+ # diagonal bezier ramp between them. That's actively misleading:
914
+ # the operator sees "value smoothly climbed from 184 to 372 over
915
+ # an hour" when the actual truth is "184 was the last measurement,
916
+ # and the next measurement (whenever it arrived) was 372 — we
917
+ # have NO data on what happened in between."
918
+ #
919
+ # Step-after semantics:
920
+ # - Hold the previous Y until the NEXT sample's X
921
+ # - Step vertically AT the next sample's X (instantaneous jump
922
+ # because that's where the new measurement landed)
923
+ #
924
+ # Path shape for samples A → B → C:
925
+ # M Ax Ay L Bx Ay L Bx By L Cx By L Cx Cy
926
+ # └ flat ┘└ jump ┘└ flat ┘└ jump ┘
927
+ #
928
+ # Standard monitoring-tool default (Grafana, Datadog, Prometheus
929
+ # graph). Honest about sparse data; matches operator mental model
930
+ # for both gauges ("last value held until refreshed") and counters
931
+ # ("the bucket recorded this many; nothing recorded between
932
+ # buckets means we don't know what was happening").
933
+ #
934
+ # Trade-off: for pure counters where "no sample = 0 traffic"
935
+ # (req_count, bytes_out), LOCF holds the previous non-zero value
936
+ # across an actual no-traffic gap, which is slightly less honest
937
+ # than backfilling 0. That's a warehouse-side concern (see
938
+ # MetricsWarehouse#aggregate_for); chart-side step is the right
939
+ # default until/unless we add per-metric backfill semantics.
940
+ def segment_path(pts)
941
+ return "" if pts.size < 2
942
+
943
+ d = "M #{pts[0][0]} #{pts[0][1]}"
944
+
945
+ (1...pts.size).each do |i|
946
+ prev_y = pts[i - 1][1]
947
+ curr_x = pts[i][0]
948
+ curr_y = pts[i][1]
949
+
950
+ d += " L #{curr_x} #{prev_y} L #{curr_x} #{curr_y}"
951
+ end
952
+
953
+ d
954
+ end
955
+
956
+ # gradient_id — UNIQUE PER CHART INSTANCE (same reason as clip_id below).
957
+ # It used to be stable per color, but a per-color id collides when several
958
+ # same-color charts share the page: every `fill="url(#id)"` resolved to the
959
+ # FIRST gradient in the DOM. Harmless while that first one is visible — but
960
+ # collapsing its dashboard group (display:none) leaves the survivors pointing
961
+ # at a paint server inside a hidden subtree, which Chromium won't apply, so
962
+ # their area fill vanishes. `object_id` makes each chart reference its own
963
+ # gradient. Memoised so the def and the `url(#…)` reference agree.
964
+ def gradient_id
965
+ @gradient_id ||= "clowk-metric-#{Digest::MD5.hexdigest("#{@color}-#{object_id}")[0, 8]}"
966
+ end
967
+
968
+ def bars_gradient_id
969
+ @bars_gradient_id ||= "#{gradient_id}-bars"
970
+ end
971
+
972
+ # render_bars — one filled column per projected point, from its value down to
973
+ # the baseline. Zero-height buckets are skipped (a gap, honestly empty). Bar
974
+ # width ≈ the bucket spacing so dense data tiles into a solid fill and sparse
975
+ # counts stand out as discrete columns.
976
+ def render_bars(svg, pts, clip_id)
977
+ bw = bar_width(pts)
978
+ inner = (@width - pad_left - pad_right).to_f
979
+ w_norm = (inner.positive? ? bw / inner : 0).round(5)
980
+
981
+ # data-x-norm / data-w-norm let the metrics-chart controller reposition
982
+ # each bar on resize (it rewrites the viewBox; without this the bars stay
983
+ # in the old coordinate space and clip/vanish — the "flickering" bug).
984
+ svg.g("clip-path": "url(##{clip_id})") do |g|
985
+ # Baseline floor across the full width: a mostly-zero count series still
986
+ # reads as a chart instead of an empty hole. Only in compact mode — with
987
+ # axes, the frame baseline + y=0 gridline already draw the floor. hLine
988
+ # target → the controller stretches its x2 on resize.
989
+ unless @axes
990
+ g.line(
991
+ x1: pad_left, x2: @width - pad_right,
992
+ y1: baseline_y, y2: baseline_y,
993
+ stroke: @color, "stroke-opacity": "0.4", "stroke-width": "1",
994
+ data: {metrics_chart_target: "hLine"}
995
+ )
996
+ end
997
+
998
+ pts.each do |x, y|
999
+ h = baseline_y - y
1000
+ next if h <= 0.4
1001
+
1002
+ x_norm = (inner.positive? ? (x - pad_left) / inner : 0).round(5)
1003
+ # Solid fill (flat look) for now. The bars gradient (bars_gradient_id,
1004
+ # defined in `defs`) is intentionally kept so we can switch back to the
1005
+ # top-bright→bottom-soft fade by swapping this for `url(##{bars_gradient_id})`.
1006
+ g.rect(
1007
+ x: x.round(2), y: y.round(2),
1008
+ width: bw, height: h.round(2),
1009
+ rx: "0.75", fill: @color,
1010
+ data: {metrics_chart_target: "bar", x_norm: x_norm, w_norm: w_norm}
1011
+ )
1012
+ end
1013
+ end
1014
+ end
1015
+
1016
+ # render_dots — a filled dot on each data point (line style), OUTSIDE the
1017
+ # clip so an edge dot isn't half-cropped. data-x-norm lets the controller
1018
+ # reposition each on resize (same mechanism as bars); cy is value-based and
1019
+ # unchanged by a width change.
1020
+ def render_dots(svg, pts)
1021
+ inner = (@width - pad_left - pad_right).to_f
1022
+
1023
+ pts.each do |x, y|
1024
+ x_norm = (inner.positive? ? (x - pad_left) / inner : 0).round(5)
1025
+
1026
+ svg.circle(
1027
+ cx: x.round(2), cy: y.round(2), r: "2.5", fill: @color,
1028
+ data: {metrics_chart_target: "dot", x_norm: x_norm}
1029
+ )
1030
+ end
1031
+ end
1032
+
1033
+ # bar_width — the bucket spacing (minus a hair for a gap), clamped so a
1034
+ # single-point chart still draws a visible column.
1035
+ def bar_width(pts)
1036
+ return (@width - pad_left - pad_right) * 0.6 if pts.size < 2
1037
+
1038
+ spacing = pts[1][0] - pts[0][0]
1039
+ [(spacing * 0.86).round(2), 1.0].max
1040
+ end
1041
+
1042
+ # clip_id — UNIQUE PER CHART INSTANCE. The clipRect holds this chart's
1043
+ # own (resized) geometry, so its id must not collide with any other
1044
+ # chart on the page. Color + dimensions ISN'T unique enough: two panels
1045
+ # of the same metric on one page (e.g. Host CPU + FreeSwitch CPU, both
1046
+ # purple, same size — common when stacking dashboards) produced the
1047
+ # SAME id, so the browser resolved every `url(#id)` to the FIRST
1048
+ # clipPath in the DOM and clipped the later chart's curve to the wrong
1049
+ # rect — truncating it visually while its data stayed intact. `object_id`
1050
+ # makes each rendered chart's clip id distinct. Memoised so the def and
1051
+ # the `url(#…)` reference within one render agree.
1052
+ def clip_id
1053
+ @clip_id ||= begin
1054
+ seed = "#{@color}-#{@width}-#{@height}-#{pad_left}-#{pad_top}-#{pad_right}-#{pad_bottom}-#{object_id}"
1055
+ "clowk-metric-clip-#{Digest::MD5.hexdigest(seed)[0, 8]}"
1056
+ end
1057
+ end
1058
+ end