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,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
|