Class: Clowk::Phlex::Charts::TimeSeries

Inherits:
Clowk::Phlex::Component show all
Defined in:
lib/clowk/phlex/charts/time_series.rb

Overview

Clowk::Phlex::Charts::TimeSeries — the big multi-series time chart (area/line/bars).— the BIG time-series chart on /metrics. Distinct from Components::UI::Sparkline (which is the tiny inline chart on StatCards) because the metrics page has room — and the need — for full axes, gridlines, restart annotations, and a proper hover tooltip.

Ported 1:1 from design-webui-inspiration/pages-metrics.jsx (MetricChart, lines 177-333). The Catmull-Rom→bezier smoothing is identical to Sparkline's path_for; we don't dedup to keep each chart's coordinate space (with axis padding) local — they diverge enough that extraction would be premature.

Markup pattern:

... rendered server-side ...

The SVG itself is server-rendered (axes, gridlines, path) so the initial paint shows a complete chart even before JS hydrates. The Stimulus controller only adds the crosshair + tooltip on mouseover; without JS the static chart still reads.

Constant Summary collapse

PAD_LEFT_FULL =

Padding for the full chart (with visible axes). Compact mode (axes hidden) collapses these to tiny gutters.

44
PAD_RIGHT_FULL =

room for y-axis labels

12
PAD_TOP_FULL =
14
PAD_BOTTOM_FULL =

room for x-axis labels

22
PAD_LEFT_COMPACT =
4
PAD_RIGHT_COMPACT =
4
PAD_TOP_COMPACT =
4
PAD_BOTTOM_COMPACT =
4
Y_TICKS =
5
X_TICKS =
5
STYLES =

axes: true → big chart with full Y/X labels + gridlines false → compact (sparkline-like): same curve + same hover crosshair + tooltip, no visible axes. Used by Overview StatCards and Pod show StatCards so all three chart surfaces (Overview, Pod show, /metrics) share the same SVG/JS rendering engine. style — how the series is drawn, all sharing the same axes / timeline / hover / restart annotations / brush-to-zoom:

:area (default) — smoothed curve + gradient fill
:bars           — one filled column per bucket (discrete counts)
:line           — smoothed curve + a dot on each point, NO fill
%i[area bars line].freeze

Instance Method Summary collapse

Constructor Details

#initialize(points:, color:, unit:, label:, range_ms:, height: 200, width: 600, axes: true, style: :area, zoom_url: nil, series: nil, key: nil) ⇒ TimeSeries

series — OPTIONAL multi-series data (pilot: Line only). An array of color:, points: [{ts,value,formatted]}. When present the chart draws ONE line (+ dots) per series on shared axes (y = max across all series, x = union of their time range). points/color are ignored.



65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
# File 'lib/clowk/phlex/charts/time_series.rb', line 65

def initialize(points:, color:, unit:, label:, range_ms:, height: 200, width: 600, axes: true, style: :area, zoom_url: nil, series: nil, key: nil)
  # key — a STABLE per-chart id (the panel_key on a dashboard). Emitted so the
  # multi-series controller can persist which lines the operator hid ACROSS a
  # realtime Turbo Stream refresh (which replaces the chart DOM + reconnects).
  @key = key
  @series = series.is_a?(Array) ? series : nil
  @points = Array(points)
  @color = color
  @unit = unit
  @label = label
  @range_ms = range_ms.to_i
  @height = height
  @width = width
  @axes = axes
  @style = STYLES.include?(style&.to_sym) ? style.to_sym : :area
  # zoom_url — the /metrics/chart endpoint URL for THIS chart (with its
  # metric/scope/server params). Present only when the chart lives inside
  # the expand modal: brush-to-zoom then re-fetches the modal body at the
  # brushed window (range=custom&from&until) instead of navigating the whole
  # page — which would tear the modal down. Absent on the grid → brush does
  # a full-page Turbo.visit, which is the right behavior there.
  @zoom_url = zoom_url
end

Instance Method Details

#area?Boolean

Returns:

  • (Boolean)


93
# File 'lib/clowk/phlex/charts/time_series.rb', line 93

def area? = @style == :area

#bars?Boolean

Returns:

  • (Boolean)


89
# File 'lib/clowk/phlex/charts/time_series.rb', line 89

def bars? = @style == :bars

#line?Boolean

Returns:

  • (Boolean)


91
# File 'lib/clowk/phlex/charts/time_series.rb', line 91

def line? = @style == :line

#multi?Boolean

Returns:

  • (Boolean)


111
# File 'lib/clowk/phlex/charts/time_series.rb', line 111

def multi? = !@series.nil? && @series.any? { |s| Array(s[:points]).any? }

#pad_bottomObject



107
108
109
# File 'lib/clowk/phlex/charts/time_series.rb', line 107

def pad_bottom
  @axes ? PAD_BOTTOM_FULL : PAD_BOTTOM_COMPACT
end

#pad_leftObject



95
96
97
# File 'lib/clowk/phlex/charts/time_series.rb', line 95

def pad_left
  @axes ? PAD_LEFT_FULL : PAD_LEFT_COMPACT
end

#pad_rightObject



99
100
101
# File 'lib/clowk/phlex/charts/time_series.rb', line 99

def pad_right
  @axes ? PAD_RIGHT_FULL : PAD_RIGHT_COMPACT
end

#pad_topObject



103
104
105
# File 'lib/clowk/phlex/charts/time_series.rb', line 103

def pad_top
  @axes ? PAD_TOP_FULL : PAD_TOP_COMPACT
end

#view_templateObject



113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
# File 'lib/clowk/phlex/charts/time_series.rb', line 113

def view_template
  return render_multi if multi?

  # Truly empty → honest "no data" placeholder (cold boot, no
  # samples on disk or in warehouse for this range yet).
  if @points.empty?
    return div(
      class: "flex items-center justify-center text-clowk-muted text-[12px]",
      style: "height: #{@height}px;"
    ) { "no data" }
  end

  # Single point edge case — happens when the range/interval is
  # narrow enough that only one bucket has samples (warehouse
  # warming up, brand-new server, or very short range). The
  # operator already sees a meaningful value in the StatCard
  # headline pulled from that same point; the chart should render
  # a flat line at that level rather than say "no data" (which
  # contradicts the headline + min/avg/max next to it). We
  # duplicate the point so the curve has 2 vertices to draw.
  if @points.size == 1
    only = @points.first
    @points = [only, only]
  end

  pts = projected_points
  y_max = y_axis_max
  x_min, x_max = time_bounds

  div(
    class: "relative w-full",
    data: {
      controller: "metrics-chart",
      metrics_chart_points_value: json_data(points_for_js),
      metrics_chart_segments_value: json_data(normalized_segments),
      metrics_chart_color_value: @color,
      metrics_chart_unit_value: @unit,
      metrics_chart_label_value: @label,
      metrics_chart_width_value: @width,
      metrics_chart_height_value: @height,
      metrics_chart_pad_left_value: pad_left,
      metrics_chart_pad_right_value: pad_right,
      metrics_chart_pad_top_value: pad_top,
      metrics_chart_pad_bottom_value: pad_bottom,
      metrics_chart_baseline_y_value: baseline_y,
      # Interpolation for the path REBUILT on resize: "linear" (Line style,
      # straight point-to-point) vs "step" (area — honest step-after). Must
      # match the server-rendered path above or a resize would flip the look.
      metrics_chart_interp_value: (line? ? "linear" : "step"),
      # responsive: client measures actual container width on
      # connect + on resize, then rewrites viewBox to
      # `0 0 <measuredW> <height>` and reprojects path + axis
      # ticks using the normalized segments above. Result: chart
      # fills container fully WITHOUT squishing text (every SVG
      # unit == 1 CSS pixel after takeover). Server-rendered
      # snapshot below uses @width/@height as a no-JS fallback.
      metrics_chart_responsive_value: true,
      # Timezone the JS tooltip should format timestamps in.
      # Matches the same Clowk::Phlex::Charts::WebTime.zone_name driving the server-
      # rendered X-axis ticks, so a hover label and the axis
      # tick directly below agree on TZ.
      metrics_chart_timezone_value: Clowk::Phlex::Charts::WebTime.zone_name,
      # Stable panel id so the "Show dots" pref (options menu) keys correctly.
      **(@key.present? ? {metrics_chart_key_value: @key} : {}),
      # Only emitted in the expand modal (see @zoom_url). Its mere
      # PRESENCE is what the controller keys on (hasZoomUrlValue) to
      # pick the in-modal re-fetch over a full-page navigation, so it
      # must stay absent — not empty — on the grid.
      **(@zoom_url.present? ? {metrics_chart_zoom_url_value: @zoom_url} : {})
    }
  ) do
    # ── Responsive strategy ────────────────────────────────────
    #
    # The server emits a COMPLETE chart at viewBox=@width × @height
    # (default 600×200) so no-JS users see a coherent snapshot.
    # `preserveAspectRatio="xMidYMid meet"` (default) keeps the
    # aspect intact — text stays round, dots stay circular —
    # accepting horizontal whitespace on wider containers.
    #
    # Then metrics_chart_controller.js takes over: it measures
    # the container's CSS width, sets viewBox to `0 0 W <height>`,
    # and rewrites every x-coordinate (path, axis tick labels,
    # spanning lines, clip + overlay rects) so 1 viewBox unit ==
    # 1 CSS pixel post-takeover. That keeps text at its design
    # size (10pt SVG = 10px on screen) AND fills the full width.
    #
    # Elements that need x-repositioning on resize are tagged
    # with Stimulus targets below:
    #   line / area              — path rebuilt from segmentsValue
    #   clipRect / overlayRect   — width updated
    #   hLine (multi)            — x2 updated to W - padRight
    #   xTick (multi)            — x updated to padLeft + t * innerW
    svg(
      width: "100%", height: @height,
      viewBox: "0 0 #{@width} #{@height}",
      class: "block overflow-visible",
      style: "touch-action: pan-y;",
      data: {metrics_chart_target: "svg"}
    ) do |s|
      s.defs do
        s.linearGradient(id: gradient_id, x1: 0, y1: 0, x2: 0, y2: 1) do
          s.stop(offset: "0%", "stop-color": @color, "stop-opacity": "0.30")
          s.stop(offset: "100%", "stop-color": @color, "stop-opacity": "0")
        end

        # Bars carry a richer fade (bright top → soft bottom) so a single
        # column still reads as filled, not as a faint sliver.
        if bars?
          s.linearGradient(id: bars_gradient_id, x1: 0, y1: 0, x2: 0, y2: 1) do
            s.stop(offset: "0%", "stop-color": @color, "stop-opacity": "0.85")
            s.stop(offset: "100%", "stop-color": @color, "stop-opacity": "0.22")
          end
        end

        # clipPath bounds the curve + area fill to the chart
        # drawing area. Catmull-Rom bezier interpolation can
        # overshoot the data envelope when adjacent points
        # zig-zag (peak → 0 → peak makes the curve dip BELOW
        # the y=0 baseline, which then bleeds the area fill
        # into the x-axis label region). Clipping is the
        # standard fix; cheaper than swapping to a monotone
        # spline + keeps the smooth aesthetic.
        s.clipPath(id: clip_id) do
          s.rect(
            x: pad_left, y: pad_top,
            width: @width - pad_left - pad_right,
            height: @height - pad_top - pad_bottom,
            data: {metrics_chart_target: "clipRect"}
          )
        end
      end

      # Axes are visible on the big chart (Metrics page). Hidden
      # in compact mode so the StatCards on Overview / Pod show
      # render as bare sparklines — same engine, no clutter.
      if @axes
        render_y_axis(s, y_max)
        render_x_axis(s, x_min, x_max)
      end

      # Style switch — all three share axes/hover/brush; only the mark differs.
      #   bars → one filled column per bucket
      #   line → smoothed stroke + a dot per point, NO fill
      #   area → smoothed stroke + gradient fill
      if bars?
        render_bars(s, pts, clip_id)
      else
        # Stroke (and, for area, the fill) go through the clip so a
        # Catmull-Rom overshoot below the baseline is invisibly cropped.
        s.g("clip-path": "url(##{clip_id})") do
          if area?
            s.path(
              d: area_path_for(pts), fill: "url(##{gradient_id})",
              data: {metrics_chart_target: "area"}
            )
          end

          s.path(
            d: path_for(pts), fill: "none", stroke: @color, "stroke-width": "1.5",
            "stroke-linecap": "round", "stroke-linejoin": "round",
            data: {metrics_chart_target: "line"}
          )
        end

        # Line style: a dot on each data point (OUTSIDE the clip so an edge
        # dot isn't half-cropped). Reprojected on resize via data-x-norm.
        render_dots(s, pts) if line?
      end

      # Frame baseline — solid line at the bottom of the chart
      # area, distinct from the dashed y=0 gridline since the
      # chart's bottom often clips into the x-axis label band.
      # Compact mode skips it (no axis area to demarcate).
      if @axes
        s.line(
          x1: pad_left, x2: @width - pad_right,
          y1: baseline_y, y2: baseline_y,
          stroke: "var(--clowk-border)",
          data: {metrics_chart_target: "hLine"}
        )
      end

      # Hover overlay rect — full chart area minus padding.
      # Single rect (vs one-per-point) because the JS finds the
      # nearest point via x-distance, matching the inspiration's
      # `onMove` handler. Cursor crosshair signals interactivity.
      s.rect(
        x: pad_left, y: pad_top,
        width: @width - pad_left - pad_right,
        height: @height - pad_top - pad_bottom,
        fill: "transparent", "pointer-events": "all",
        style: "cursor: crosshair;",
        data: {
          metrics_chart_target: "overlay",
          # Brush-to-zoom (drag a time range → reload at range=custom) works
          # for every style — a time sub-range maps the same whether the mark
          # is an area, a line, or bars.
          action: "mousemove->metrics-chart#move mouseleave->metrics-chart#leave mousedown->metrics-chart#brushStart"
        }
      )
    end
  end
end