poetry-charts 0.1.3 → 0.1.5

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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/.controller_docs +1 -0
  3. data/.jsdoc_floor +1 -0
  4. data/.mcp.json +8 -0
  5. data/.yard-lint.yml +25 -0
  6. data/.yard_coverage_all +1 -0
  7. data/Archspec.rb +17 -0
  8. data/CHANGELOG.md +20 -0
  9. data/app/components/poetry/charts/area_chart/component.html.erb +1 -14
  10. data/app/components/poetry/charts/bar_chart/component.rb +72 -2
  11. data/app/components/poetry/charts/bar_math.rb +2 -0
  12. data/app/components/poetry/charts/cartesian_family.rb +25 -1
  13. data/app/components/poetry/charts/composed_chart/component.html.erb +1 -14
  14. data/app/components/poetry/charts/composed_chart/component.rb +1 -0
  15. data/app/components/poetry/charts/container/component.html.erb +1 -1
  16. data/app/components/poetry/charts/container/component.rb +3 -4
  17. data/app/components/poetry/charts/error_bars.rb +2 -0
  18. data/app/components/poetry/charts/legend_content/component.html.erb +1 -1
  19. data/app/components/poetry/charts/legend_content/component.rb +3 -5
  20. data/app/components/poetry/charts/line_chart/component.html.erb +1 -14
  21. data/app/components/poetry/charts/live.rb +12 -2
  22. data/app/components/poetry/charts/motion.rb +1 -0
  23. data/app/components/poetry/charts/polar_family.rb +1 -0
  24. data/app/components/poetry/charts/reference_marks.rb +3 -0
  25. data/app/components/poetry/charts/tooltip_content/component.html.erb +1 -1
  26. data/app/components/poetry/charts/tooltip_content/component.rb +7 -5
  27. data/app/components/poetry/charts/tooltip_layer/component.html.erb +1 -1
  28. data/app/components/poetry/charts/tooltip_layer/component.rb +3 -5
  29. data/app/javascript/poetry/charts/adapter_controller.js +12 -7
  30. data/app/javascript/poetry/charts/live/renderer.js +6 -1
  31. data/app/javascript/poetry/charts/live_controller.js +20 -18
  32. data/app/javascript/poetry/charts/motion_controller.js +30 -29
  33. data/app/javascript/poetry/charts/tooltip_controller.js +19 -10
  34. data/app/javascript/poetry/charts/window_controller.js +13 -9
  35. data/config/component_registry.yml +13 -1
  36. data/config/controllers_manifest.json +56 -10
  37. data/eslint.config.mjs +34 -0
  38. data/lib/poetry/charts/cartesian.rb +3 -0
  39. data/lib/poetry/charts/config.rb +5 -0
  40. data/lib/poetry/charts/geometry/area.rb +13 -0
  41. data/lib/poetry/charts/geometry/curve.rb +27 -0
  42. data/lib/poetry/charts/geometry/line.rb +12 -0
  43. data/lib/poetry/charts/geometry/nice_ticks.rb +42 -0
  44. data/lib/poetry/charts/geometry/path.rb +21 -0
  45. data/lib/poetry/charts/geometry/scale/band.rb +24 -0
  46. data/lib/poetry/charts/geometry/scale/linear.rb +3 -0
  47. data/lib/poetry/charts/geometry/stack.rb +8 -0
  48. data/lib/poetry/charts/geometry/ticks.rb +13 -1
  49. data/lib/poetry/charts/geometry.rb +6 -0
  50. data/lib/poetry/charts/polar.rb +45 -2
  51. data/lib/poetry/charts/spec.rb +3 -0
  52. data/lib/poetry/charts/theme_style.rb +2 -0
  53. data/lib/poetry/charts/version.rb +1 -1
  54. data/lib/poetry/charts.rb +4 -1
  55. data/rubydex_linter/rules/registry_class.rb +44 -0
  56. data/rubydex_linter/rules/style_sidecar.rb +38 -0
  57. metadata +13 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2b2a5247d39050b61ead0f68c9b575831fa82326b7b9571925890df8c1cfff37
4
- data.tar.gz: 8a0cb5648b7c2ecc0c018d089360715cf42e8c7bdd1e2b8a5cf504eafad5741f
3
+ metadata.gz: 72e699a235f9e9de484beb4a29c916524bda13d60ded979c372520be33189275
4
+ data.tar.gz: 0f1b5d2e8299f0195f5554d026d7006daa3df6b47b976c7ff01d7abd163f84d2
5
5
  SHA512:
6
- metadata.gz: 47b7b7940d112e8d37c40be21efca67c0a07bb045a85be69d329755835604c135b333c4d20360813d6d051904d4f943db52856ecec744d8cb032b6cfa56f4776
7
- data.tar.gz: 6a397692dd6c25fd37ae21b96293db1db28f26226c7746b6a11d2d735ee7ec00b7b7f614a4f8a2b7ffa17167ccb989808780947a652efe17bf4db398c75d438d
6
+ metadata.gz: 518bf2c87c5a39bd5915fbd143d9342846f8156c9e9382aca1c8d9e09518d024f1ae651ebb1bbfc655eed55bc787e4999823335fda7bcfd0f0ff1aae89b0eb0f
7
+ data.tar.gz: 54b8823fea3ab138f3a5394cdf4a90188c21b9f937964ad770fa09238ea6cff00c893df3c059d999a416cade33e9bc38c43192edff3158a17a0d98879c8d7037
data/.controller_docs ADDED
@@ -0,0 +1 @@
1
+ 0
data/.jsdoc_floor ADDED
@@ -0,0 +1 @@
1
+ 53
data/.mcp.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "mcpServers": {
3
+ "rubydex": {
4
+ "command": "bundle",
5
+ "args": ["exec", "rdx", "mcp"]
6
+ }
7
+ }
8
+ }
data/.yard-lint.yml ADDED
@@ -0,0 +1,25 @@
1
+ # yard-lint: the documentation tier YARD's own gates miss - a documented
2
+ # method missing a @param, an @example that does not parse, option tags,
3
+ # tag order, invalid types. Runs as rake yard:lint (in the default task).
4
+ # UndocumentedObjects stays off: yard:coverage gates public objects at its
5
+ # recorded floor, and this validator counts the @api private internals the
6
+ # gems hide on purpose (--hide-api is not honoured here). MissingReturn is
7
+ # off until the @param backlog is cleared; revisit then.
8
+ AllValidators:
9
+ YardOptions:
10
+ - --no-private
11
+ Documentation/UndocumentedObjects:
12
+ Enabled: false
13
+ Documentation/MissingReturn:
14
+ Enabled: false
15
+ Tags/InvalidTypes:
16
+ Enabled: true
17
+ # A **options splat passed through to a control is documented as @param
18
+ # options [Hash], not one @option per key; and @example blocks here are
19
+ # ERB as often as Ruby, which this validator cannot parse.
20
+ Documentation/UndocumentedOptions:
21
+ Enabled: false
22
+ Tags/OptionTags:
23
+ Enabled: false
24
+ Tags/ExampleSyntax:
25
+ Enabled: false
@@ -0,0 +1 @@
1
+ 0
data/Archspec.rb ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The architecture the family enforces by review, as checks (rake arch:check).
4
+ # charts depends on core, and on ui only through the registry: no chart
5
+ # names a ui component class.
6
+ root "."
7
+ source "app/**/*.rb", "lib/**/*.rb"
8
+
9
+ component :components, in: "app/components/**/*.rb"
10
+ component :lib, in: "lib/**/*.rb"
11
+
12
+ lib.cannot_use :components, because: "lib reaches the components through the registry"
13
+ lib.cannot_reference_constants "Poetry::Ui", "Poetry::Agent", "Poetry::Extract", "ApplicationController",
14
+ because: "charts depends on core, on ui through the registry only, never the host"
15
+ components.cannot_reference_constants "Poetry::Ui", "Poetry::Agent", "Poetry::Extract", "ApplicationController",
16
+ because: "charts depends on core, on ui through the registry only, never the host"
17
+ no_cycles among: %i[lib components]
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.5] - 2026-09-18
4
+
5
+ ### Added
6
+
7
+ - The controllers manifest carries the prose beside each controller: its purpose (a JSDoc block above the class), the meaning of every value, and a summary of every action method, harvested when the manifest is generated; `rake stimulus:docs` holds the count of gaps at a committed floor, now zero.
8
+
9
+ ### Changed
10
+
11
+ - The registry carries the gem's internal namespaces (`internals`), so `poetry check` can warn a host that names one.
12
+ - 9 methods the reference already hid with `@api private` are Ruby-private now: each was called only by its own class or template, so the runtime enforces what the tag only stated. A host that reached one gets a NoMethodError instead of an internal that may change without notice. The tag remains on the internals the family shares between its gems and on whole internal classes.
13
+ - Every class, module and method carries a one-sentence description, private helpers included: `rake yard:coverage:all` measures the whole tree (a tag-only docstring counts as blank) and the committed floor now stands at zero.
14
+ - The area, line and composed charts draw their gridlines through one `grid_svg` builder instead of three identical template blocks; every visual baseline still matches.
15
+ - The container, legend, tooltip and tooltip layer roots ride the core default `root_attributes`, passing only their own markup up; the rendered attributes are unchanged apart from their order on the element.
16
+
17
+ ## [0.1.4] - 2026-09-15
18
+
19
+ ### Changed
20
+
21
+ - A horizontal bar chart's category strip fits its labels. The reserved left strip held about eight characters at the tick size, so a merchant name or a title was cut at the SVG edge. The strip now grows through the left margin to fit the longest formatted label (estimated by character count, capped at forty percent of the width), labels past the cap end in an ellipsis, and a live chart's recompute prints the same. An explicit `margin: { left: }` is the caller's layout and keeps the reserved strip.
22
+
3
23
  ## [0.1.3] - 2026-09-13
4
24
 
5
25
  Lockstep release with the family; no changes in this gem.
@@ -15,20 +15,7 @@
15
15
  <% end %>
16
16
  </defs>
17
17
  <% end %>
18
- <% if grid? %>
19
- <g data-slot="chart-grid" aria-hidden="true">
20
- <% if grid_config.horizontal %>
21
- <% cartesian.y_ticks.each do |tick| %>
22
- <line class="<%= css(:grid_line) %>" x1="<%= fnum(cartesian.plot_left) %>" x2="<%= fnum(cartesian.plot_right) %>" y1="<%= fnum(cartesian.y_scale.call(tick)) %>" y2="<%= fnum(cartesian.y_scale.call(tick)) %>" />
23
- <% end %>
24
- <% end %>
25
- <% if grid_config.vertical %>
26
- <% cartesian.x_centers.each do |x| %>
27
- <line class="<%= css(:grid_line) %>" x1="<%= fnum(x) %>" x2="<%= fnum(x) %>" y1="<%= fnum(cartesian.plot_top) %>" y2="<%= fnum(cartesian.plot_bottom) %>" />
28
- <% end %>
29
- <% end %>
30
- </g>
31
- <% end %>
18
+ <%= grid_svg %>
32
19
  <%= cursor_svg(:line) %>
33
20
  <g data-slot="chart-areas"<% if animate? %> clip-path="url(#<%= chart_id %>-reveal)"<% end %>>
34
21
  <% series_entries.each do |entry| %>
@@ -72,7 +72,9 @@ module Poetry
72
72
  option :orientation, :symbol, default: :vertical,
73
73
  doc: ":vertical = columns (the default); :horizontal = bars growing rightward " \
74
74
  "- the category axis moves to the Y side (with_y_axis data_key:) and the " \
75
- "numeric axis hides."
75
+ "numeric axis hides. The category strip widens to fit the longest label " \
76
+ "(up to 40% of the width; longer labels end in an ellipsis) unless " \
77
+ "margin: sets left: yourself."
76
78
 
77
79
  validates :offset, inclusion: { in: Cartesian::OFFSETS }
78
80
  validates :orientation, inclusion: { in: Cartesian::LAYOUTS }
@@ -242,9 +244,74 @@ module Poetry
242
244
  formatter ? formatter.call(category).to_s : category.to_s
243
245
  end
244
246
 
245
- # The horizontal layout's category labels (the Y side).
247
+ # The horizontal layout's category labels (the Y side), cut to the
248
+ # strip with an ellipsis when the strip is auto-sized and capped.
246
249
  # @api private
247
250
  def category_tick_label(category)
251
+ label = formatted_category_label(category)
252
+ limit = category_label_max_chars
253
+ limit && label.length > limit ? "#{label[0, limit - 1]}…" : label
254
+ end
255
+
256
+ # The reserved left strip (Cartesian::Y_AXIS_WIDTH) fits about
257
+ # eight characters at the 12px tick size. In the horizontal layout
258
+ # the strip grows through the left margin to fit the longest
259
+ # formatted label - estimated by character count, capped at
260
+ # CATEGORY_STRIP_SHARE of the width - so merchant names and titles
261
+ # are not clipped at the SVG edge. An explicit margin left: is the
262
+ # caller's layout and switches the estimate (and the ellipsis) off.
263
+ CATEGORY_LABEL_CHAR_WIDTH = 6.4
264
+ # The widest the auto-sized strip may grow, as a share of the width.
265
+ CATEGORY_STRIP_SHARE = 0.4
266
+
267
+ # The axis margin, widened on the left by the category strip allowance when the strip auto-sizes.
268
+ # @api private
269
+ def axis_margin
270
+ base = super
271
+ allowance = category_strip_allowance
272
+ allowance.positive? ? base.merge(left: Cartesian::DEFAULT_MARGIN[:left] + allowance) : base
273
+ end
274
+
275
+ # Whether the category strip sizes itself: a horizontal chart with a y axis and no explicit left margin.
276
+ # @api private
277
+ def auto_category_strip?
278
+ horizontal? && y_axis? && !(margin || {}).to_h.symbolize_keys.key?(:left)
279
+ end
280
+
281
+ # Pixels the left margin grows by so the strip fits the labels.
282
+ # @api private
283
+ def category_strip_allowance
284
+ return 0 unless auto_category_strip?
285
+
286
+ needed = (longest_category_label * CATEGORY_LABEL_CHAR_WIDTH) + y_axis_config.tick_margin.to_f
287
+ [[needed, category_strip_cap].min - Cartesian::Y_AXIS_WIDTH, 0].max.ceil
288
+ end
289
+
290
+ # The most pixels the category strip may take, a share of the chart width.
291
+ # @api private
292
+ def category_strip_cap
293
+ (width * CATEGORY_STRIP_SHARE).floor
294
+ end
295
+
296
+ # Characters a label may keep before the ellipsis, nil when the
297
+ # strip is not auto-sized.
298
+ # @api private
299
+ def category_label_max_chars
300
+ return nil unless auto_category_strip?
301
+
302
+ [((category_strip_cap - y_axis_config.tick_margin.to_f) / CATEGORY_LABEL_CHAR_WIDTH).floor, 2].max
303
+ end
304
+
305
+ # The longest formatted category label, in characters.
306
+ # @api private
307
+ def longest_category_label
308
+ key = y_axis_config.data_key
309
+ data.map { |row| formatted_category_label(row.to_h[key] || row.to_h[key.to_sym]).length }.max || 0
310
+ end
311
+
312
+ # A category through the y axis's tick formatter when one is given, else as text.
313
+ # @api private
314
+ def formatted_category_label(category)
248
315
  formatter = y_axis_config&.tick_formatter
249
316
  formatter ? formatter.call(category).to_s : category.to_s
250
317
  end
@@ -305,12 +372,15 @@ module Poetry
305
372
  {
306
373
  "barGap" => bar_gap,
307
374
  "barCategoryGap" => bar_category_gap,
375
+ "categoryLabelMaxChars" => category_label_max_chars,
308
376
  "series" => series_entries.to_h { |e| [e.key, { "radius" => e.radius }] }
309
377
  }
310
378
  end
311
379
 
312
380
  private :cartesian, :bar_slots, :cells, :bar_path, :cell_fill, :active?
313
381
  private :cell_label, :x_tick_label, :category_tick_label, :y_tick_label, :svg_label_prefix, :coordinates_json
382
+ private :axis_margin, :auto_category_strip?, :category_strip_allowance, :category_strip_cap
383
+ private :category_label_max_chars, :longest_category_label, :formatted_category_label
314
384
  private :live_type, :live_series, :live_axes, :live_x_scale_type, :live_category_axis?, :live_frame_extras
315
385
  end
316
386
  end
@@ -90,6 +90,8 @@ module Poetry
90
90
  text = value.to_s
91
91
  text.end_with?("%") ? total * (text.to_f / 100.0) : text.to_f
92
92
  end
93
+
94
+ private :bar_slots_for, :bar_cells, :motion_origin, :bar_path_for, :bar_percent_value
93
95
  end
94
96
  end
95
97
  end
@@ -25,6 +25,7 @@ module Poetry
25
25
  # include Poetry::Charts::CartesianFamily
26
26
  # end
27
27
  module CartesianFamily
28
+ # Declares the x axis, the value axis, the grid and the class-level helpers on the including chart.
28
29
  def self.included(base)
29
30
  base.extend(ClassMethods)
30
31
 
@@ -99,7 +100,30 @@ module Poetry
99
100
  @grid_config
100
101
  end
101
102
 
102
- private :x_axis_config, :y_axis_config, :grid_config
103
+ # The gridlines group, per the grid slot: horizontal rules at the value
104
+ # ticks, vertical rules at the category centers; nil without the slot.
105
+ def grid_svg
106
+ return unless grid?
107
+
108
+ lines = []
109
+ if grid_config.horizontal
110
+ cartesian.y_ticks.each do |tick|
111
+ y = cartesian.y_scale.call(tick)
112
+ lines << grid_line(cartesian.plot_left, cartesian.plot_right, y, y)
113
+ end
114
+ end
115
+ if grid_config.vertical
116
+ cartesian.x_centers.each { |x| lines << grid_line(x, x, cartesian.plot_top, cartesian.plot_bottom) }
117
+ end
118
+ tag.g(safe_join(lines), "data-slot": "chart-grid", "aria-hidden": true)
119
+ end
120
+
121
+ # One gridline from one point to another.
122
+ def grid_line(from_x, to_x, from_y, to_y)
123
+ tag.line(class: css(:grid_line), x1: fnum(from_x), x2: fnum(to_x), y1: fnum(from_y), y2: fnum(to_y))
124
+ end
125
+
126
+ private :x_axis_config, :y_axis_config, :grid_config, :grid_svg, :grid_line
103
127
  end
104
128
  end
105
129
  end
@@ -15,20 +15,7 @@
15
15
  <% end %>
16
16
  </defs>
17
17
  <% end %>
18
- <% if grid? %>
19
- <g data-slot="chart-grid" aria-hidden="true">
20
- <% if grid_config.horizontal %>
21
- <% cartesian.y_ticks.each do |tick| %>
22
- <line class="<%= css(:grid_line) %>" x1="<%= fnum(cartesian.plot_left) %>" x2="<%= fnum(cartesian.plot_right) %>" y1="<%= fnum(cartesian.y_scale.call(tick)) %>" y2="<%= fnum(cartesian.y_scale.call(tick)) %>" />
23
- <% end %>
24
- <% end %>
25
- <% if grid_config.vertical %>
26
- <% cartesian.x_centers.each do |x| %>
27
- <line class="<%= css(:grid_line) %>" x1="<%= fnum(x) %>" x2="<%= fnum(x) %>" y1="<%= fnum(cartesian.plot_top) %>" y2="<%= fnum(cartesian.plot_bottom) %>" />
28
- <% end %>
29
- <% end %>
30
- </g>
31
- <% end %>
18
+ <%= grid_svg %>
32
19
  <%= cursor_svg(:line) %>
33
20
  <%# Declaration order IS paint order. %>
34
21
  <% series_entries.each do |entry| %>
@@ -290,6 +290,7 @@ module Poetry
290
290
 
291
291
  private
292
292
 
293
+ # Records one series of the given mark with the defaults filled in, validating its curve.
293
294
  def push_series(mark, key:, **attrs)
294
295
  attrs = SERIES_DEFAULTS.merge(attrs)
295
296
  if attrs[:curve] && !CURVES.include?(attrs[:curve])
@@ -1,4 +1,4 @@
1
- <%= content_tag(:div, **root_attributes.to_attributes) do %>
1
+ <%= content_tag(:div, **root_attributes) do %>
2
2
  <% if (css_text = theme_css) %><style><%= css_text.html_safe %></style><% end %>
3
3
  <%= content %>
4
4
  <% end %>
@@ -63,17 +63,16 @@ module Poetry
63
63
 
64
64
  # The frame div's attributes: part self-identification plus the
65
65
  # data-chart scope.
66
- # @api private
67
66
  def root_attributes
68
- html_attributes.merge_if_not_set(
67
+ super(
69
68
  {
70
69
  "data-slot" => "chart",
71
70
  "data-chart" => chart_id
72
- }.merge(component_data_attributes)
71
+ }
73
72
  )
74
73
  end
75
74
 
76
- private :chart_config, :chart_id, :theme_css, :root_attributes
75
+ private :chart_config, :chart_id, :theme_css
77
76
  end
78
77
  end
79
78
  end
@@ -30,6 +30,8 @@ module Poetry
30
30
  "M#{f.call(center_x)},#{f.call(y_low)}V#{f.call(y_high)}" \
31
31
  "M#{f.call(center_x - half)},#{f.call(y_high)}H#{f.call(center_x + half)}"
32
32
  end
33
+
34
+ private :error_range, :error_bar_path
33
35
  end
34
36
  end
35
37
  end
@@ -1,4 +1,4 @@
1
- <%= content_tag(:div, **root_attributes.to_attributes) do %>
1
+ <%= content_tag(:div, **root_attributes) do %>
2
2
  <% rows.each do |row| %>
3
3
  <%= content_tag(toggle ? :button : :div, **(toggle ? toggle_attributes(row) : { data: { slot: "chart-legend-item" }, class: css(:item) })) do %>
4
4
  <% unless hide_icon || row[:color].nil? %>
@@ -80,11 +80,8 @@ module Poetry
80
80
  end
81
81
 
82
82
  # The legend row's attributes with the part self-identification.
83
- # @api private
84
83
  def root_attributes
85
- html_attributes.merge_if_not_set(
86
- { "data-slot" => "chart-legend-content" }.merge(component_data_attributes)
87
- )
84
+ super({ "data-slot" => "chart-legend-content" })
88
85
  end
89
86
 
90
87
  # A swatch's guarded inline background (colors reaching a style
@@ -105,6 +102,7 @@ module Poetry
105
102
  entry.theme ? "var(--color-#{entry.key})" : entry.color
106
103
  end
107
104
 
105
+ # One legend item's key, label and color, the config filling what the item omits.
108
106
  def resolve(item)
109
107
  key = (item[:key] || item[:name]).to_s
110
108
  entry = chart_config[key]
@@ -115,7 +113,7 @@ module Poetry
115
113
  }
116
114
  end
117
115
 
118
- private :chart_config, :rows, :toggle_attributes, :root_attributes, :swatch_style
116
+ private :chart_config, :rows, :toggle_attributes, :swatch_style
119
117
  end
120
118
  end
121
119
  end
@@ -1,20 +1,7 @@
1
1
  <%= render Poetry::Charts::Container::Component.new(config: chart_config, id: chart_id.delete_prefix("chart-")) do %>
2
2
  <%= content_tag(:div, frame_attributes.merge("class" => css(:frame))) do %>
3
3
  <svg viewBox="0 0 <%= width %> <%= height %>" class="<%= css(:svg) %>" data-slot="chart-svg" aria-label="<%= svg_label %>" preserveAspectRatio="xMidYMid meet" <%= tag.attributes(svg_interaction_attributes) %>>
4
- <% if grid? %>
5
- <g data-slot="chart-grid" aria-hidden="true">
6
- <% if grid_config.horizontal %>
7
- <% cartesian.y_ticks.each do |tick| %>
8
- <line class="<%= css(:grid_line) %>" x1="<%= fnum(cartesian.plot_left) %>" x2="<%= fnum(cartesian.plot_right) %>" y1="<%= fnum(cartesian.y_scale.call(tick)) %>" y2="<%= fnum(cartesian.y_scale.call(tick)) %>" />
9
- <% end %>
10
- <% end %>
11
- <% if grid_config.vertical %>
12
- <% cartesian.x_centers.each do |x| %>
13
- <line class="<%= css(:grid_line) %>" x1="<%= fnum(x) %>" x2="<%= fnum(x) %>" y1="<%= fnum(cartesian.plot_top) %>" y2="<%= fnum(cartesian.plot_bottom) %>" />
14
- <% end %>
15
- <% end %>
16
- </g>
17
- <% end %>
4
+ <%= grid_svg %>
18
5
  <%= cursor_svg(:line) %>
19
6
  <g data-slot="chart-lines">
20
7
  <% series_entries.each do |entry| %>
@@ -17,6 +17,7 @@ module Poetry
17
17
  # The live controller's full-string identifier.
18
18
  CONTROLLER = "poetry--charts--live"
19
19
 
20
+ # Declares the live update wiring on the including chart: registration, the tooltip re-read and the zoom drag.
20
21
  def self.included(base)
21
22
  base.extend(ClassMethods)
22
23
 
@@ -136,13 +137,22 @@ module Poetry
136
137
  # bottom margin the cartesian sees.
137
138
  # @api private
138
139
  def live_margin
139
- base = (margin || {}).to_h.symbolize_keys
140
+ base = axis_margin
140
141
  return base unless brush_config
141
142
 
142
143
  bottom = (base[:bottom] || Cartesian::DEFAULT_MARGIN[:bottom]).to_f
143
144
  base.merge(bottom: bottom + brush_config[:height] + BRUSH_GAP)
144
145
  end
145
146
 
147
+ # Hook: the caller's margin, before the brush reserve - a family
148
+ # that sizes an axis strip from its labels (the bar chart's
149
+ # horizontal category strip) grows a side here, so the server
150
+ # geometry and the client recompute read the same margin.
151
+ # @api private
152
+ def axis_margin
153
+ (margin || {}).to_h.symbolize_keys
154
+ end
155
+
146
156
  # The brush strip's y position - below the plot, above the reserved
147
157
  # margin.
148
158
  # @api private
@@ -288,7 +298,7 @@ module Poetry
288
298
  series_entries.any? { |entry| entry.respond_to?(:labels) && entry.labels }
289
299
  end
290
300
 
291
- private :brush_config, :zoom?, :window_features?, :live_margin, :brush_top, :brush_svg
301
+ private :brush_config, :zoom?, :window_features?, :live_margin, :axis_margin, :brush_top, :brush_svg
292
302
  private :zoom_selection_svg, :window_plot_json, :window_brush_json, :live_payload, :live_payload_json, :live_spec
293
303
  private :live_frame, :live_frame_extras, :ensure_live_compatible!, :live_labels_configured?
294
304
  end
@@ -15,6 +15,7 @@ module Poetry
15
15
  # The motion controller's full-string identifier.
16
16
  CONTROLLER = "poetry--charts--motion"
17
17
 
18
+ # Declares the motion controller on the including chart's frame.
18
19
  def self.included(base)
19
20
  base.extend(ClassMethods)
20
21
 
@@ -20,6 +20,7 @@ module Poetry
20
20
  # The default polar margin - a slim, even inset on all sides.
21
21
  MARGIN = { top: 5, right: 5, bottom: 5, left: 5 }.freeze
22
22
 
23
+ # Declares the tooltip wiring on the including chart's svg.
23
24
  def self.included(base)
24
25
  base.use_stimulus do
25
26
  on :svg do
@@ -14,6 +14,7 @@ module Poetry
14
14
  # and fnum. Vertical layouts only (a horizontal bar raises - a
15
15
  # declared limit).
16
16
  module ReferenceMarks
17
+ # Declares the reference line, area and dot slots on the including chart.
17
18
  def self.included(base)
18
19
  # A dashed rule across the plot: x: (a category) draws it
19
20
  # vertical, y: (a value) horizontal; label: annotates it.
@@ -89,6 +90,7 @@ module Poetry
89
90
 
90
91
  private
91
92
 
93
+ # One reference mark's SVG, by its kind.
92
94
  def reference_mark_svg(mark)
93
95
  case mark[:kind]
94
96
  when :line then reference_line_svg(mark)
@@ -139,6 +141,7 @@ module Poetry
139
141
  safe_join([rect, label])
140
142
  end
141
143
 
144
+ # A reference dot's SVG: the circle, and its label above it when given.
142
145
  def reference_dot_svg(mark)
143
146
  cx = ref_x_pixel(mark[:x])
144
147
  cy = ref_y_pixel(mark[:y])
@@ -1,4 +1,4 @@
1
- <%= content_tag(:div, **root_attributes.to_attributes) do %>
1
+ <%= content_tag(:div, **root_attributes) do %>
2
2
  <% if label_text && !nest_label? %><div data-slot="chart-tooltip-label" class="<%= css(:label) %>"><%= label_text %></div><% end %>
3
3
  <div class="<%= css(:inner) %>">
4
4
  <% rows.each do |row| %>
@@ -110,37 +110,40 @@ module Poetry
110
110
  end
111
111
 
112
112
  # The box's attributes with the part self-identification.
113
- # @api private
114
113
  def root_attributes
115
- html_attributes.merge_if_not_set(
116
- { "data-slot" => "chart-tooltip-content" }.merge(component_data_attributes)
117
- )
114
+ super("data-slot" => "chart-tooltip-content")
118
115
  end
119
116
 
120
117
  # One resolved tooltip row: name via config, color via item-else-config.
121
118
  # @api private
122
119
  class Row
120
+ # Wraps one tooltip item with the chart config it resolves against.
123
121
  def initialize(item, config)
124
122
  @item = item
125
123
  @config = config
126
124
  end
127
125
 
126
+ # The row's series key: the item's key, else its name, else value.
128
127
  def key
129
128
  (@item[:key] || @item[:name] || "value").to_s
130
129
  end
131
130
 
131
+ # The row's label, through the config.
132
132
  def name
133
133
  @config.label_for(key, @item[:name])
134
134
  end
135
135
 
136
+ # The row's swatch color: the item's own, else the config entry's.
136
137
  def color
137
138
  @item[:color] || @config[key]&.color
138
139
  end
139
140
 
141
+ # The row's raw value.
140
142
  def value
141
143
  @item[:value]
142
144
  end
143
145
 
146
+ # The value as text, a number with thousands separators; nil stays nil.
144
147
  def formatted_value
145
148
  return nil if value.nil?
146
149
  return ActiveSupport::NumberHelper.number_to_delimited(value) if value.is_a?(Numeric)
@@ -161,7 +164,6 @@ module Poetry
161
164
  end
162
165
 
163
166
  private :chart_config, :rows, :nest_label?, :label_text, :row_classes, :indicator_classes, :value_wrap_classes
164
- private :root_attributes
165
167
  end
166
168
  end
167
169
  end
@@ -1,4 +1,4 @@
1
- <%= content_tag(:div, **root_attributes.to_attributes) do %>
1
+ <%= content_tag(:div, **root_attributes) do %>
2
2
  <%= render Poetry::Charts::TooltipContent::Component.new(
3
3
  config: chart_config, items: items, label: hide_label ? nil : "…",
4
4
  indicator: indicator, hide_label: hide_label, hide_indicator: hide_indicator
@@ -43,18 +43,16 @@ module Poetry
43
43
 
44
44
  # The hidden box's attributes: part self-identification plus the
45
45
  # parent frame controller's tooltip target.
46
- # @api private
47
46
  def root_attributes
48
- html_attributes.merge_if_not_set(
47
+ super(
49
48
  {
50
49
  "data-slot" => "chart-tooltip",
51
50
  "hidden" => ""
52
- }.merge(stimulus_attributes_for(:root))
53
- .merge(component_data_attributes)
51
+ }
54
52
  )
55
53
  end
56
54
 
57
- private :chart_config, :items, :root_attributes
55
+ private :chart_config, :items
58
56
  end
59
57
  end
60
58
  end
@@ -1,19 +1,24 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
  import { chartAdapter } from "@poetry/charts/adapter_registry"
3
3
 
4
- // The adapter mount: reads the FROZEN chart-spec v1 the
5
- // server embedded, hands it to the registered engine adapter, and owns
6
- // the lifecycle - render on connect, destroy on disconnect (Turbo-safe),
7
- // themeChanged on dark-mode flips. The helpers close the canvas gap:
8
- // resolveColor turns var(--color-key)/var(--chart-N) into concrete
9
- // values at paint time (CSS variables cannot reach a canvas).
4
+ /**
5
+ * The adapter mount: reads the FROZEN chart-spec v1 the
6
+ * server embedded, hands it to the registered engine adapter, and owns
7
+ * the lifecycle - render on connect, destroy on disconnect (Turbo-safe),
8
+ * themeChanged on dark-mode flips. The helpers close the canvas gap:
9
+ * resolveColor turns var(--color-key)/var(--chart-N) into concrete
10
+ * values at paint time (CSS variables cannot reach a canvas).
11
+ */
10
12
  export default class ChartAdapterController extends Controller {
11
13
  // The events this controller dispatches (manifest surface;
12
14
  // events_declaration.test.js enforces the list stays honest).
13
15
  static events = ["poetry--charts--adapter:rendered"]
14
16
 
15
17
  static targets = ["mount", "spec"]
16
- static values = { engine: String }
18
+ static values = {
19
+ // The registered engine adapter that renders the embedded spec.
20
+ engine: String
21
+ }
17
22
 
18
23
  /**
19
24
  * Mounts the declared engine: parses the embedded spec, validates the
@@ -29,6 +29,11 @@ import {
29
29
  const X_AXIS_HEIGHT = 30
30
30
  const Y_AXIS_WIDTH = 60
31
31
 
32
+ // The server cuts a horizontal chart's category labels to the auto-sized
33
+ // strip with an ellipsis; a recompute prints them the same way.
34
+ const categoryLabel = (label, maxChars) =>
35
+ maxChars && label.length > maxChars ? `${label.slice(0, maxChars - 1)}…` : label
36
+
32
37
  const CURVES = {
33
38
  linear: curveLinear,
34
39
  natural: curveNatural,
@@ -413,7 +418,7 @@ function applyAxes(svg, geometry, payload) {
413
418
  reconcile(yAxis.querySelectorAll("text"), geometry.categories.length).forEach((text, i) => {
414
419
  text.setAttribute("x", fnum(geometry.plotLeft - (frame.yTickMargin ?? 8)))
415
420
  text.setAttribute("y", fnum(geometry.xCenters[i]))
416
- text.textContent = String(geometry.categories[i])
421
+ text.textContent = categoryLabel(String(geometry.categories[i]), frame.categoryLabelMaxChars)
417
422
  })
418
423
  } else {
419
424
  reconcile(yAxis.querySelectorAll("text"), geometry.yTicks.length).forEach((text, i) => {