Class: Clowk::Phlex::Charts::Sparkline

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

Overview

Components::UI::Sparkline — area + stroke time-series chart with per-point hover tooltips.

Anatomy:

┌──────────────────────────────────┐
        ╱╲                  ╱╲      smoothed area + stroke
                                pulse dot on the last point
   ╲___╱    ╲______________╱     
└──────────────────────────────────┘

Hover behaviour:

- One invisible vertical strip per point covers the chart.
- mouseenter / mousemove fires the sparkline-tooltip Stimulus
controller, which:
  1. positions a fixed-position tooltip above the hovered point
  2. draws a small dot at the point + a dashed vertical line
  3. shows the formatted value + the timestamp.
- mouseleave hides both.

Data shape:

points: [{ ts: "2026-05-24T09:00:00Z", value: 12.4, formatted: "12.4%" }, ...]

ts is the ISO timestamp; value the raw numeric (drives the curve); formatted is what the tooltip shows. Format-on-Rails-side keeps the controller unaware of units (%, MB, GB) — that knowledge stays in MetricsData where the metric name is in scope.

Empty / single-point data → renders nothing (caller's StatCard already hides the wrapper when points.blank?).

Constant Summary collapse

PAD =
4

Instance Method Summary collapse

Constructor Details

#initialize(points:, color: "#34d399", width: 220, height: 56, show_fill: true, stroke: 1.5) ⇒ Sparkline

Returns a new instance of Sparkline.



37
38
39
40
41
42
43
44
# File 'lib/clowk/phlex/charts/sparkline.rb', line 37

def initialize(points:, color: "#34d399", width: 220, height: 56, show_fill: true, stroke: 1.5)
  @points = Array(points).map { |p| normalize(p) }
  @color = color
  @width = width
  @height = height
  @show_fill = show_fill
  @stroke = stroke
end

Instance Method Details

#view_templateObject



46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
# File 'lib/clowk/phlex/charts/sparkline.rb', line 46

def view_template
  return if @points.empty?

  # Single-point edge case (warehouse warming up; range so short
  # that only one bucket has data; metric just started reporting).
  # We used to `return` here, which made the StatCard render its
  # "no data" placeholder — confusing when the headline IS showing
  # a value pulled from that same point. Synthesise a second point
  # at the SAME value so the curve renders as a flat line at the
  # measured level; honest visual ("we have one reading, no trend
  # to draw yet") instead of misleading empty state.
  if @points.size == 1
    only = @points.first
    @points = [
      only.merge(ts: only[:ts]),
      only.merge(ts: only[:ts]) # duplicate; flat segment
    ]
  end

  pts = projected_points
  d_line = path_for(pts)
  d_area = "#{d_line} L #{pts.last[0]} #{@height} L #{pts.first[0]} #{@height} Z"
  gid = gradient_id

  svg(
    width: "100%", height: @height,
    viewBox: "0 0 #{@width} #{@height}",
    preserveAspectRatio: "none",
    class: "block overflow-visible",
    # data-tz carries the operator's chosen IANA zone name into JS
    # so the tooltip controller's formatTs renders timestamps in
    # Settings → Display preferences instead of the browser's
    # local TZ (the previous default). Same WebTime.zone_name
    # source as the chart axis ticks + ChartCard headlines.
    data: {controller: "sparkline-tooltip"},
    style: "--clowk-spark-color: #{@color};"
  ) do |s|
    s.defs do
      s.linearGradient(id: gid, x1: 0, x2: 0, y1: 0, y2: 1) do
        s.stop(offset: "0%", "stop-color": @color, "stop-opacity": "0.32")
        s.stop(offset: "100%", "stop-color": @color, "stop-opacity": "0")
      end
    end

    s.path(d: d_area, fill: "url(##{gid})") if @show_fill
    s.path(
      d: d_line, fill: "none",
      stroke: @color, "stroke-width": @stroke,
      "stroke-linecap": "round", "stroke-linejoin": "round"
    )

    # Y-axis labels overlay — small muted text in the corners
    # showing the data range. Top-right: max, bottom-right: min.
    # Renders the formatted value (from MetricsData) so units
    # stay consistent ("18.7%" / "805.3 MB" / etc.).
    axis_labels(s)

    # Always-on dot at the latest point — the "current value"
    # affordance from the original sparkline. Stays put when the
    # operator isn't hovering; gets hidden by the Stimulus
    # controller while hovering (so the focus marker on the
    # hovered point doesn't clash).
    cx, cy = pts.last
    s.circle(
      cx: cx, cy: cy, r: 5, fill: @color, opacity: "0.18",
      data: {sparkline_tooltip_target: "tailDot"}
    )
    s.circle(
      cx: cx, cy: cy, r: 2.5, fill: @color,
      data: {sparkline_tooltip_target: "tailDot"}
    )

    # Per-point hover strips. Invisible, full-height, mouse
    # events feed the Stimulus controller. preserveAspectRatio
    # is `none` so widths scale 1:1 with viewBox even when the
    # SVG is `width: 100%` — the strip's x stays aligned with
    # its point's underlying viewBox x.
    hover_strips(s, pts)
  end
end