poetry-charts 0.0.2

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 (124) hide show
  1. checksums.yaml +7 -0
  2. data/.gitattributes +6 -0
  3. data/CHANGELOG.md +3 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +94 -0
  6. data/THIRD_PARTY_NOTICES.md +39 -0
  7. data/app/assets/stylesheets/poetry-charts.css +117 -0
  8. data/app/components/poetry/charts/adapter_chart/component.html.erb +6 -0
  9. data/app/components/poetry/charts/adapter_chart/component.rb +99 -0
  10. data/app/components/poetry/charts/adapter_chart/preview.rb +37 -0
  11. data/app/components/poetry/charts/adapter_chart/style.rb +16 -0
  12. data/app/components/poetry/charts/area_chart/component.html.erb +75 -0
  13. data/app/components/poetry/charts/area_chart/component.rb +251 -0
  14. data/app/components/poetry/charts/area_chart/preview.rb +95 -0
  15. data/app/components/poetry/charts/area_chart/style.rb +29 -0
  16. data/app/components/poetry/charts/bar_chart/component.html.erb +95 -0
  17. data/app/components/poetry/charts/bar_chart/component.rb +318 -0
  18. data/app/components/poetry/charts/bar_chart/preview.rb +115 -0
  19. data/app/components/poetry/charts/bar_chart/style.rb +28 -0
  20. data/app/components/poetry/charts/bar_math.rb +95 -0
  21. data/app/components/poetry/charts/cartesian_family.rb +105 -0
  22. data/app/components/poetry/charts/chart_family.rb +58 -0
  23. data/app/components/poetry/charts/composed_chart/component.html.erb +95 -0
  24. data/app/components/poetry/charts/composed_chart/component.rb +309 -0
  25. data/app/components/poetry/charts/composed_chart/preview.rb +62 -0
  26. data/app/components/poetry/charts/composed_chart/style.rb +24 -0
  27. data/app/components/poetry/charts/container/component.html.erb +4 -0
  28. data/app/components/poetry/charts/container/component.rb +80 -0
  29. data/app/components/poetry/charts/container/preview.rb +45 -0
  30. data/app/components/poetry/charts/container/style.rb +22 -0
  31. data/app/components/poetry/charts/error_bars.rb +35 -0
  32. data/app/components/poetry/charts/legend_content/component.html.erb +10 -0
  33. data/app/components/poetry/charts/legend_content/component.rb +122 -0
  34. data/app/components/poetry/charts/legend_content/preview.rb +23 -0
  35. data/app/components/poetry/charts/legend_content/style.rb +26 -0
  36. data/app/components/poetry/charts/line_chart/component.html.erb +85 -0
  37. data/app/components/poetry/charts/line_chart/component.rb +252 -0
  38. data/app/components/poetry/charts/line_chart/preview.rb +115 -0
  39. data/app/components/poetry/charts/line_chart/style.rb +28 -0
  40. data/app/components/poetry/charts/live.rb +296 -0
  41. data/app/components/poetry/charts/motion.rb +98 -0
  42. data/app/components/poetry/charts/pie_chart/component.html.erb +36 -0
  43. data/app/components/poetry/charts/pie_chart/component.rb +256 -0
  44. data/app/components/poetry/charts/pie_chart/preview.rb +100 -0
  45. data/app/components/poetry/charts/pie_chart/style.rb +19 -0
  46. data/app/components/poetry/charts/polar_family.rb +95 -0
  47. data/app/components/poetry/charts/radar_chart/component.html.erb +73 -0
  48. data/app/components/poetry/charts/radar_chart/component.rb +329 -0
  49. data/app/components/poetry/charts/radar_chart/preview.rb +97 -0
  50. data/app/components/poetry/charts/radar_chart/style.rb +21 -0
  51. data/app/components/poetry/charts/radial_bar_chart/component.html.erb +65 -0
  52. data/app/components/poetry/charts/radial_bar_chart/component.rb +424 -0
  53. data/app/components/poetry/charts/radial_bar_chart/preview.rb +99 -0
  54. data/app/components/poetry/charts/radial_bar_chart/style.rb +27 -0
  55. data/app/components/poetry/charts/reference_marks.rb +156 -0
  56. data/app/components/poetry/charts/scatter_chart/component.html.erb +55 -0
  57. data/app/components/poetry/charts/scatter_chart/component.rb +396 -0
  58. data/app/components/poetry/charts/scatter_chart/preview.rb +93 -0
  59. data/app/components/poetry/charts/scatter_chart/style.rb +22 -0
  60. data/app/components/poetry/charts/tooltip_content/component.html.erb +19 -0
  61. data/app/components/poetry/charts/tooltip_content/component.rb +168 -0
  62. data/app/components/poetry/charts/tooltip_content/preview.rb +39 -0
  63. data/app/components/poetry/charts/tooltip_content/style.rb +31 -0
  64. data/app/components/poetry/charts/tooltip_layer/component.html.erb +6 -0
  65. data/app/components/poetry/charts/tooltip_layer/component.rb +61 -0
  66. data/app/components/poetry/charts/tooltip_layer/style.rb +13 -0
  67. data/app/components/poetry/charts/tooltip_wiring.rb +167 -0
  68. data/app/helpers/poetry/charts/components_helper.rb +125 -0
  69. data/app/javascript/poetry/charts/adapter_controller.js +75 -0
  70. data/app/javascript/poetry/charts/adapter_registry.js +52 -0
  71. data/app/javascript/poetry/charts/adapters/chartjs.js +86 -0
  72. data/app/javascript/poetry/charts/d3.js +14 -0
  73. data/app/javascript/poetry/charts/index.js +67 -0
  74. data/app/javascript/poetry/charts/live/renderer.js +456 -0
  75. data/app/javascript/poetry/charts/live_controller.js +195 -0
  76. data/app/javascript/poetry/charts/motion/flip.js +169 -0
  77. data/app/javascript/poetry/charts/motion/sector.js +62 -0
  78. data/app/javascript/poetry/charts/motion/tween.js +105 -0
  79. data/app/javascript/poetry/charts/motion_controller.js +230 -0
  80. data/app/javascript/poetry/charts/tooltip_controller.js +331 -0
  81. data/app/javascript/poetry/charts/window_controller.js +195 -0
  82. data/config/component_descriptions.yml +14 -0
  83. data/config/component_registry.yml +3055 -0
  84. data/config/controllers_manifest.json +100 -0
  85. data/config/importmap.rb +10 -0
  86. data/lib/poetry/charts/cartesian.rb +277 -0
  87. data/lib/poetry/charts/config.rb +149 -0
  88. data/lib/poetry/charts/engine.rb +76 -0
  89. data/lib/poetry/charts/geometry/area.rb +68 -0
  90. data/lib/poetry/charts/geometry/curve.rb +327 -0
  91. data/lib/poetry/charts/geometry/line.rb +64 -0
  92. data/lib/poetry/charts/geometry/nice_ticks.rb +220 -0
  93. data/lib/poetry/charts/geometry/path.rb +96 -0
  94. data/lib/poetry/charts/geometry/scale/band.rb +81 -0
  95. data/lib/poetry/charts/geometry/scale/linear.rb +127 -0
  96. data/lib/poetry/charts/geometry/stack.rb +129 -0
  97. data/lib/poetry/charts/geometry/ticks.rb +103 -0
  98. data/lib/poetry/charts/geometry.rb +66 -0
  99. data/lib/poetry/charts/polar.rb +179 -0
  100. data/lib/poetry/charts/spec.rb +108 -0
  101. data/lib/poetry/charts/theme_style.rb +53 -0
  102. data/lib/poetry/charts/version.rb +8 -0
  103. data/lib/poetry/charts.rb +114 -0
  104. data/lib/poetry-charts.rb +4 -0
  105. data/themes/default.css +146 -0
  106. data/themes/luma.css +139 -0
  107. data/themes/lyra.css +140 -0
  108. data/themes/maia.css +139 -0
  109. data/themes/mira.css +139 -0
  110. data/themes/nova.css +139 -0
  111. data/themes/rhea.css +139 -0
  112. data/themes/sera.css +140 -0
  113. data/themes/vega.css +144 -0
  114. data/vendor/d3-kernel/LICENSE-d3.txt +17 -0
  115. data/vendor/d3-kernel/LICENSE-decimal.js-light.txt +25 -0
  116. data/vendor/d3-kernel/LICENSE-recharts.txt +26 -0
  117. data/vendor/d3-kernel/README.md +30 -0
  118. data/vendor/d3-kernel/build.mjs +66 -0
  119. data/vendor/d3-kernel/entry.js +27 -0
  120. data/vendor/d3-kernel/recharts/state/cartesianAxisSlice.ts +6 -0
  121. data/vendor/d3-kernel/recharts/util/scale/getNiceTickValues.ts +310 -0
  122. data/vendor/d3-kernel/recharts/util/scale/util/arithmetic.ts +54 -0
  123. data/vendor/d3-kernel/recharts/util/types.ts +5 -0
  124. metadata +186 -0
@@ -0,0 +1,424 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ # The radial bar chart family.
6
+ module RadialBarChart
7
+ # Renders a radial bar chart: one angular bar per data row on
8
+ # concentric rings between inner_radius and outer_radius, sweep
9
+ # proportional to value over the angle span. Rings divide the
10
+ # radial band with the SAME math as vertical bars (10% trim, 4px
11
+ # gaps); stacked radial bars share the ring and stack along the
12
+ # ANGLE; corner_radius rounds arc ends with tangent circles;
13
+ # `background: true` draws the muted track ring; polar-grid discs
14
+ # (the gauge look) and the donut-style center label round out the
15
+ # family.
16
+ #
17
+ # @example Rings with track backgrounds and a tooltip
18
+ # <%= poetry_chart :radial, data: browsers, config: config,
19
+ # inner_radius: 30, outer_radius: 110 do |c| %>
20
+ # <% c.with_radial_bar data_key: :visitors, background: true %>
21
+ # <% c.with_tooltip %>
22
+ # <% end %>
23
+ class Component < Poetry::Core::Component
24
+ include Poetry::Charts::ChartFamily
25
+ include Poetry::Charts::TooltipWiring
26
+ include Poetry::Charts::Motion
27
+
28
+ # Projected into the registry and agent surface.
29
+ AGENT_RULES = [
30
+ "One ring per data row; rows carry their color in a fill key (var(--color-<name>)).",
31
+ "background: true draws the muted track ring behind each bar (the gauge look).",
32
+ "Stack two radial bars with the same stack: id - they share the ring and stack by ANGLE.",
33
+ "corner_radius rounds the arc ends (10 on a 10px ring = full pill caps).",
34
+ "with_polar_grid(radii:, fills:) draws the shape/text blocks' disc track; " \
35
+ "with_center_label fills the middle.",
36
+ "Entrance animation is on by default (source parity); animate: false for a static chart. " \
37
+ "Reduced-motion users always get the finished chart."
38
+ ].freeze
39
+
40
+ # One with_radial_bar call's captured series config.
41
+ Series = Data.define(:data_key, :stack, :background, :corner_radius, :color_key,
42
+ :labels, :label_key)
43
+
44
+ option :data, ActiveModel::Type::Value.new, required: true,
45
+ doc: "The rows to plot: an array of hashes, one ring per row."
46
+ option :config, ActiveModel::Type::Value.new, required: true,
47
+ doc: "The series config - name => { label:, color: } - naming " \
48
+ "and coloring the rings."
49
+ option :id, :string,
50
+ doc: "Explicit DOM id token, stable across renders; otherwise the chart gets a unique per-render id."
51
+ option :width, :integer, default: 250,
52
+ doc: "ViewBox width in pixels; the rendered chart scales to its container."
53
+ option :height, :integer, default: 250, doc: "ViewBox height in pixels."
54
+ option :margin, ActiveModel::Type::Value.new,
55
+ doc: "Margin overrides ({ top:, right:, bottom:, left: }), merged over the slim polar default."
56
+ option :label, :string,
57
+ doc: "Accessible name for the chart SVG; defaults to one built from the configured series."
58
+
59
+ motion_options
60
+ option :name_key, :string, default: "name", doc: "The row key naming each ring."
61
+ option :start_angle, :integer, default: 0, doc: "Where the sweep starts, in degrees."
62
+ option :end_angle, :integer, default: 360, doc: "Where the sweep ends - 180 makes a half gauge."
63
+ option :inner_radius, ActiveModel::Type::Value.new, default: "20%",
64
+ doc: "The innermost ring edge: a percent string of the " \
65
+ "max radius, or pixels."
66
+ option :outer_radius, ActiveModel::Type::Value.new, default: "80%",
67
+ doc: "The outermost ring edge: a percent string of the " \
68
+ "max radius, or pixels."
69
+ option :max_value, ActiveModel::Type::Value.new,
70
+ doc: "The angle-axis maximum: nil = the largest single value, so the largest ring closes the full " \
71
+ "sweep EXACTLY (nicing it would leave a notch); stacked gauges pass the stack total when the " \
72
+ "segments should fill the span."
73
+
74
+ part "chart-svg", "The chart canvas (<svg>) - the aria-label surface, the tooltip's " \
75
+ "focus/keyboard surface (role=application when it attaches), and " \
76
+ "the motion rig's mount",
77
+ states: {
78
+ "data-animate" => "when animate (the default) - the entrance tier's flag the " \
79
+ "motion stylesheet and controller key off",
80
+ "data-motion" => { condition: "runtime, when animate - the motion engine's lifecycle stamp",
81
+ values: %w[entrance morph settled] }
82
+ },
83
+ vars: {
84
+ "--poetry-motion-delay" => "the motion rig's entrance delay (animation_begin)",
85
+ "--poetry-motion-duration" => "the motion rig's entrance duration (animation_duration)",
86
+ "--poetry-motion-easing" => "the motion rig's easing keyword (animation_easing)"
87
+ }
88
+ part "chart-polar-grid", "The polar grid (<g>, aria-hidden) - ring and spoke linework " \
89
+ "behind the series"
90
+ part "chart-radial-series", "One series' ring group (<g>)",
91
+ states: { "data-key" => "always - the series key" }
92
+ part "chart-radial-background", "The muted track ring (<path>) behind a bar, rendered " \
93
+ "when background: true"
94
+ part "chart-radial-bar", "One angular bar (<path>) - a ring per data row, sweep " \
95
+ "proportional to value",
96
+ states: {
97
+ "data-key" => "always - the series key",
98
+ "data-index" => "on the first series' bars - the datum index the tooltip walks",
99
+ "data-active" => "the hovered/arrow-keyed bar - the tooltip controller reflects " \
100
+ "the active index here at runtime",
101
+ "data-motion-group" => "when animate - the motion rig's sweep group (one per ring)",
102
+ "data-motion-sector" => "when animate - the motion rig's server-computed sector params " \
103
+ "for the fan-out sweep"
104
+ }
105
+ part "chart-labels", "A series' value labels (<g> of <text>, aria-hidden), rendered when " \
106
+ "the series opts into labels",
107
+ states: { "data-key" => "always - the series key" }
108
+ part "chart-center-label", "The center text (<text>) - title tspan plus optional subtitle " \
109
+ "filling the chart's middle"
110
+ part "chart-coordinates", "The embedded JSON payload (<script>) the tooltip controller " \
111
+ "reads - per-category anchors and pre-formatted values, zero " \
112
+ "chart math in the browser"
113
+
114
+ renders_many :radial_bars,
115
+ doc: "A radial series reading data_key: values. background: draws the muted track ring; bars " \
116
+ "sharing a stack: id share the ring and stack by angle; corner_radius: rounds the arc " \
117
+ "ends.",
118
+ renders: lambda { |data_key:, stack: nil, background: false, corner_radius: 0,
119
+ color_key: :fill, labels: nil, label_key: nil|
120
+ (@series_entries ||= []) << Series.new(data_key: data_key.to_s, stack:, background:,
121
+ corner_radius:, color_key: color_key&.to_s,
122
+ labels: labels&.to_sym, label_key: label_key&.to_s)
123
+ nil
124
+ }
125
+
126
+ renders_one :polar_grid,
127
+ doc: "The disc track behind the rings: radii: places the circles (default: each ring's " \
128
+ "centerline), fills: tints them, and radial_lines: draws the faint value spokes that show " \
129
+ "through ring gaps and the open wedge (on by default; gauges turn them off).",
130
+ renders: lambda { |radii: nil, fills: nil, radial_lines: true|
131
+ @polar_grid_config = { radii: radii, fills: Array(fills), radial_lines: radial_lines }
132
+ nil
133
+ }
134
+
135
+ renders_one :center_label,
136
+ doc: "The center text: a title line plus an optional subtitle. The default is the full gauge's " \
137
+ "big centered number; compact: shrinks it and sits it just above a half gauge's flat " \
138
+ "baseline.",
139
+ renders: lambda { |title:, subtitle: nil, compact: false|
140
+ @center_label_config = { title: title, subtitle: subtitle, compact: compact }
141
+ nil
142
+ }
143
+
144
+ renders_one :legend,
145
+ doc: "The legend row: align:, items:, and hide_icon:.",
146
+ renders: lambda { |**options|
147
+ @legend_config = options
148
+ nil
149
+ }
150
+
151
+ renders_one :tooltip,
152
+ doc: "The hover tooltip; the ring name carries the label, so hide_label defaults on.",
153
+ renders: lambda { |**options|
154
+ @tooltip_config = { hide_label: true }.merge(options)
155
+ nil
156
+ }
157
+
158
+ # The polar chassis: margin/plot/center geometry plus the per-arc
159
+ # pointerover hit; the single-series tooltip chrome and payload
160
+ # ride the polar_* hooks below.
161
+ include Poetry::Charts::PolarFamily
162
+ include Poetry::Charts::PolarFamily::SingleSeriesTooltip
163
+
164
+ # The captured Series configs, forcing lazy slot evaluation.
165
+ # @api private
166
+ def series_entries
167
+ radial_bars? # force slot evaluation (slots evaluate lazily)
168
+ @series_entries ||= []
169
+ end
170
+
171
+ # The polar-grid slot's captured config, forcing lazy slot
172
+ # evaluation.
173
+ # @api private
174
+ def polar_grid_config
175
+ polar_grid?
176
+ @polar_grid_config
177
+ end
178
+
179
+ # The center-label slot's captured config, forcing lazy slot
180
+ # evaluation.
181
+ # @api private
182
+ def center_label_config
183
+ center_label?
184
+ @center_label_config
185
+ end
186
+
187
+ # -- geometry ---------------------------------------------------------
188
+
189
+ # Band trim on each side of a ring, as a fraction of the band.
190
+ TRIM = 0.1
191
+ # Pixels between rings.
192
+ GAP = 4.0
193
+
194
+ # The data rows with stringified keys.
195
+ # @api private
196
+ def rows
197
+ @rows ||= data.map { |row| row.to_h.transform_keys(&:to_s) }
198
+ end
199
+
200
+ # The [inner, outer] radial band edges resolved to pixels.
201
+ # @api private
202
+ def radii
203
+ @radii ||= begin
204
+ max = Polar.max_radius(plot[:width], plot[:height])
205
+ [Polar.percent_value(inner_radius, max, max * 0.2),
206
+ Polar.percent_value(outer_radius, max, max * 0.8)]
207
+ end
208
+ end
209
+
210
+ # The radial band: one ring per ROW, the vertical-bar math rotated
211
+ # onto the radius axis (10% trim each side, 4px between rings).
212
+ # @api private
213
+ def ring(index)
214
+ inner, outer = radii
215
+ band = (outer - inner) / rows.length
216
+ trim = band * TRIM
217
+ thickness = band - (2 * trim)
218
+ ring_inner = inner + (band * index) + trim
219
+ [ring_inner, ring_inner + thickness]
220
+ end
221
+
222
+ # Whether any series declared a stack.
223
+ # @api private
224
+ def stacked?
225
+ series_entries.any?(&:stack)
226
+ end
227
+
228
+ # The default angle domain is [0, max] over the RAW cell values,
229
+ # NOT the stacked totals - so a stacked ring maps each segment
230
+ # through the max single value and the overflow past end_angle is
231
+ # clipped (a half gauge: 570 of max 1260 = 81deg; the next
232
+ # segment stacks on and clips at 180deg). An explicit max_value:
233
+ # overrides (a caller who wants the stack total to fill the span
234
+ # exactly passes it).
235
+ # @api private
236
+ def angle_max
237
+ @angle_max ||= if max_value
238
+ max_value.to_f
239
+ else
240
+ rows.flat_map { |row| series_entries.map { |e| row[e.data_key].to_f } }
241
+ .max&.nonzero? || 1
242
+ end
243
+ end
244
+
245
+ # A value's angular sweep across the span.
246
+ # @api private
247
+ def sweep(value)
248
+ (value.to_f / angle_max) * (end_angle - start_angle)
249
+ end
250
+
251
+ # Clip a stacked segment's end at end_angle - a stack running
252
+ # past the domain max never sweeps beyond the span.
253
+ # @api private
254
+ def clip_angle(angle)
255
+ Polar.sign(end_angle - start_angle).positive? ? [angle, end_angle.to_f].min : [angle, end_angle.to_f].max
256
+ end
257
+
258
+ # One computed arc: its ring and sweep geometry plus name, value,
259
+ # and fill.
260
+ Segment = Data.define(:index, :name, :value, :fill, :path, :ring_inner, :ring_outer,
261
+ :seg_start, :seg_end)
262
+
263
+ # Per-series segments: unstacked series sweep from start_angle;
264
+ # stacked series continue from the previous series' end.
265
+ # @api private
266
+ def segments(entry)
267
+ @segments ||= {}.compare_by_identity
268
+ # Slot-identity memo, never data_key: two series may share a
269
+ # data_key (different stacks), and a key-keyed memo would render
270
+ # the first series' segments twice.
271
+ @segments[entry] ||= rows.each_with_index.map do |row, i|
272
+ ring_inner, ring_outer = ring(i)
273
+ base = @segment_cursor&.dig(entry.stack, i) || start_angle.to_f
274
+ seg_start = entry.stack ? base : start_angle.to_f
275
+ seg_end = seg_start + sweep(row[entry.data_key])
276
+ seg_end = clip_angle(seg_end) if entry.stack
277
+ if entry.stack
278
+ @segment_cursor ||= {}
279
+ (@segment_cursor[entry.stack] ||= {})[i] = seg_end
280
+ end
281
+
282
+ Segment.new(
283
+ index: i,
284
+ name: row[name_key].to_s,
285
+ value: row[entry.data_key],
286
+ fill: segment_fill(entry, row),
287
+ path: Polar.sector_path_with_corners(cx: cx, cy: cy, inner_radius: ring_inner,
288
+ outer_radius: ring_outer, start_angle: seg_start,
289
+ end_angle: seg_end, corner_radius: entry.corner_radius),
290
+ ring_inner: ring_inner,
291
+ ring_outer: ring_outer,
292
+ seg_start: seg_start,
293
+ seg_end: seg_end
294
+ )
295
+ end
296
+ end
297
+
298
+ # The muted full-span track ring behind one bar.
299
+ # @api private
300
+ def background_path(segment)
301
+ Polar.sector_path(cx: cx, cy: cy, inner_radius: segment.ring_inner,
302
+ outer_radius: segment.ring_outer,
303
+ start_angle: start_angle, end_angle: end_angle)
304
+ end
305
+
306
+ # A ring's fill: the color_key row value (CSS-validated), else
307
+ # the config color for the series, else the series color var.
308
+ # @api private
309
+ def segment_fill(entry, row)
310
+ color = row[entry.color_key].to_s if entry.color_key
311
+ if color.present?
312
+ raise ArgumentError, "radial fill #{color.inspect} is not CSS-safe" unless color.match?(Config::COLOR)
313
+
314
+ color
315
+ else
316
+ chart_config[entry.data_key]&.color || "var(--color-#{entry.data_key})"
317
+ end
318
+ end
319
+
320
+ # Inside-start labels sit a FIXED ARC LENGTH into the sweep (not
321
+ # a fixed angle), so an inner ring - where the same ~9px arc
322
+ # spans a larger angle - tilts more, tapering to near-flat on the
323
+ # outer rings. Rotated onto the arc's TANGENT pointing into the
324
+ # sweep: SVG rotate() is clockwise-positive in the y-down plane,
325
+ # so the tangent at chart angle t is (-sin t, -cos t) for a CCW
326
+ # sweep. (90 - angle reads plausibly but points the text the
327
+ # OPPOSITE way: mirrored and upside down.) When the tangent would
328
+ # still leave the text inverted, flip it 180 and anchor the END
329
+ # at the start edge so the label stays readable.
330
+ LABEL_ARC = 9.0
331
+
332
+ # One segment's label position, rotation, and anchor.
333
+ # @api private
334
+ def label_placement(segment)
335
+ direction = Polar.sign(end_angle - start_angle)
336
+ radius = (segment.ring_inner + segment.ring_outer) / 2.0
337
+ angle = segment.seg_start + (direction * (LABEL_ARC / radius) / Polar::RADIAN)
338
+ x, y = Polar.polar_to_cartesian(cx, cy, radius, angle)
339
+ rad = Polar::RADIAN * angle
340
+ rotate = Math.atan2(-direction * Math.cos(rad), -direction * Math.sin(rad)) / Polar::RADIAN
341
+ anchor = "start"
342
+ if rotate > 90 || rotate <= -90
343
+ rotate -= 180 * Polar.sign(rotate)
344
+ anchor = "end"
345
+ end
346
+ { x: x, y: y, rotate: rotate, anchor: anchor }
347
+ end
348
+
349
+ # The grid circles' radii: the slot's radii:, else auto.
350
+ # @api private
351
+ def grid_radii
352
+ config_radii = polar_grid_config[:radii]
353
+ return config_radii if config_radii
354
+
355
+ # Auto: circles through each ring's CENTERLINE - each circle
356
+ # continues a bar's track through the uncovered wedge.
357
+ (0...rows.length).map { |i| ring(i).sum / 2.0 }
358
+ end
359
+
360
+ # Standard 1-2-5 ticks over the value domain (count 10) - the
361
+ # implicit angle axis, where the grid's radial spokes sit.
362
+ # @api private
363
+ def angle_tick_values
364
+ max = angle_max.to_f
365
+ return [] unless max.positive?
366
+
367
+ step0 = max / 10.0
368
+ power = 10.0**Math.log10(step0).floor
369
+ error = step0 / power
370
+ step = if error >= Math.sqrt(50) then power * 10
371
+ elsif error >= Math.sqrt(10) then power * 5
372
+ elsif error >= Math.sqrt(2) then power * 2
373
+ else power
374
+ end
375
+ (0..(max / step).floor).map { |i| i * step }
376
+ end
377
+
378
+ # A grid spoke at one value tick, spanning the chart's radial band
379
+ # (template helper - bare Polar does not resolve from compiled ERB).
380
+ # @api private
381
+ def spoke_points(tick)
382
+ inner, outer = radii
383
+ angle = start_angle + sweep(tick)
384
+ Polar.polar_to_cartesian(cx, cy, inner, angle) + Polar.polar_to_cartesian(cx, cy, outer, angle)
385
+ end
386
+
387
+ # One grid circle's class: its fills: tint, else the plain ring.
388
+ # @api private
389
+ def grid_fill_class(index)
390
+ token = polar_grid_config[:fills][index]
391
+ token ? css(:"grid_fill_#{token}") : css(:grid_circle)
392
+ end
393
+
394
+ # -- the tooltip payload (polar shape, ring anchors) -------------------
395
+
396
+ # The SingleSeriesTooltip hooks: the first series' segments are
397
+ # the items, anchored mid-ring / mid-sweep; values read the chart
398
+ # rows.
399
+ # @api private
400
+ def polar_items(entry) = segments(entry)
401
+
402
+ # Where the tooltip anchors on one segment.
403
+ # @api private
404
+ def polar_anchor(segment)
405
+ Polar.polar_to_cartesian(cx, cy, (segment.ring_inner + segment.ring_outer) / 2.0,
406
+ (segment.seg_start + segment.seg_end) / 2.0)
407
+ end
408
+
409
+ # The rows the tooltip values read from.
410
+ # @api private
411
+ def polar_value_rows(_entry) = rows
412
+
413
+ # ChartFamily#svg_label's chart-type lead-in.
414
+ # @api private
415
+ def svg_label_prefix = "Radial bar chart"
416
+
417
+ private :polar_grid_config, :center_label_config, :rows, :radii, :ring, :stacked?, :angle_max
418
+ private :sweep, :clip_angle, :segments, :background_path, :segment_fill, :label_placement, :grid_radii
419
+ private :angle_tick_values, :spoke_points, :grid_fill_class, :polar_items, :polar_anchor, :polar_value_rows
420
+ private :svg_label_prefix
421
+ end
422
+ end
423
+ end
424
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ module RadialBarChart
6
+ # The radial family across its variants: simple (rings +
7
+ # muted tracks), grid (disc boundaries), inside-start labels, the
8
+ # gauge shapes (disc tracks + center text), and the angle-stacked
9
+ # half gauge.
10
+ class Preview < Poetry::Core::Preview::Base
11
+ CONFIG = {
12
+ visitors: { label: "Visitors" },
13
+ chrome: { label: "Chrome", color: "var(--chart-1)" },
14
+ safari: { label: "Safari", color: "var(--chart-2)" },
15
+ firefox: { label: "Firefox", color: "var(--chart-3)" },
16
+ edge: { label: "Edge", color: "var(--chart-4)" },
17
+ other: { label: "Other", color: "var(--chart-5)" },
18
+ desktop: { label: "Desktop", color: "var(--chart-1)" },
19
+ mobile: { label: "Mobile", color: "var(--chart-2)" }
20
+ }.freeze
21
+
22
+ DATA = [
23
+ { browser: "chrome", visitors: 275, fill: "var(--color-chrome)" },
24
+ { browser: "safari", visitors: 200, fill: "var(--color-safari)" },
25
+ { browser: "firefox", visitors: 187, fill: "var(--color-firefox)" },
26
+ { browser: "edge", visitors: 173, fill: "var(--color-edge)" },
27
+ { browser: "other", visitors: 90, fill: "var(--color-other)" }
28
+ ].freeze
29
+
30
+ def simple
31
+ render_component(data: DATA, config: CONFIG, id: "radial-simple", name_key: :browser,
32
+ inner_radius: 30, outer_radius: 110) do |chart|
33
+ chart.with_radial_bar(data_key: :visitors, background: true)
34
+ chart.with_tooltip
35
+ end
36
+ end
37
+
38
+ def grid
39
+ render_component(data: DATA, config: CONFIG, id: "radial-grid", name_key: :browser,
40
+ inner_radius: 30, outer_radius: 100) do |chart|
41
+ chart.with_polar_grid
42
+ chart.with_radial_bar(data_key: :visitors)
43
+ end
44
+ end
45
+
46
+ def label
47
+ render_component(data: DATA, config: CONFIG, id: "radial-label", name_key: :browser,
48
+ start_angle: -90, end_angle: 380, inner_radius: 30, outer_radius: 110) do |chart|
49
+ chart.with_radial_bar(data_key: :visitors, background: true,
50
+ labels: :inside_start, label_key: :browser)
51
+ end
52
+ end
53
+
54
+ def shape
55
+ render_component(data: [{ browser: "safari", visitors: 1260, fill: "var(--color-safari)" }],
56
+ config: CONFIG, id: "radial-shape", name_key: :browser,
57
+ end_angle: 100, inner_radius: 65, outer_radius: 95) do |chart|
58
+ chart.with_polar_grid(radii: [86, 74], fills: %i[muted background], radial_lines: false)
59
+ chart.with_radial_bar(data_key: :visitors, background: true)
60
+ chart.with_center_label(title: "1,260", subtitle: "Visitors")
61
+ end
62
+ end
63
+
64
+ def text
65
+ render_component(data: [{ browser: "safari", visitors: 200, fill: "var(--color-safari)" }],
66
+ config: CONFIG, id: "radial-text", name_key: :browser,
67
+ start_angle: 0, end_angle: 250, inner_radius: 80, outer_radius: 90) do |chart|
68
+ chart.with_polar_grid(radii: [90, 80], fills: %i[muted background], radial_lines: false)
69
+ chart.with_radial_bar(data_key: :visitors, background: true, corner_radius: 10)
70
+ chart.with_center_label(title: "200", subtitle: "Visitors")
71
+ end
72
+ end
73
+
74
+ def stacked
75
+ data = [{ month: "january", desktop: 1260, mobile: 570 }]
76
+ # No max_value: the stack maps through [0, max] over the raw
77
+ # values (1260), so mobile fills 570/1260 = 81deg and desktop
78
+ # stacks on and clips at the 180deg end.
79
+ render_component(data: data, config: CONFIG, id: "radial-stacked", name_key: :month,
80
+ end_angle: 180, inner_radius: 80, outer_radius: 110) do |chart|
81
+ chart.with_radial_bar(data_key: :mobile, stack: :a, corner_radius: 5)
82
+ chart.with_radial_bar(data_key: :desktop, stack: :a, corner_radius: 5)
83
+ chart.with_center_label(title: "1,830", subtitle: "Visitors", compact: true)
84
+ end
85
+ end
86
+
87
+ # A sync group member: the sync value the stimulus contract
88
+ # holds to the DOM.
89
+ def synced
90
+ render_component(data: DATA, config: CONFIG, id: "radial-synced", name_key: :browser,
91
+ inner_radius: 30, outer_radius: 110, sync: "radial-demo") do |chart|
92
+ chart.with_radial_bar(data_key: :visitors, background: true)
93
+ chart.with_tooltip
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ module RadialBarChart
6
+ # The radial chrome on the cn-chart-* theme rules: the muted
7
+ # track ring, the disc track fills (the first:fill-muted /
8
+ # last:fill-background hack, still explicit), the inside-start
9
+ # label, and the per-family center typography (4xl gauge /
10
+ # 2xl compact; pie keeps 3xl).
11
+ class Style < Poetry::Core::Style
12
+ base ""
13
+
14
+ element :frame, "contents"
15
+ element :svg, "w-full min-h-0 flex-1"
16
+ element :background_ring, "cn-chart-radial-track"
17
+ element :grid_circle, "cn-chart-polar-grid-circle"
18
+ element :grid_fill_muted, "cn-chart-radial-disc-muted"
19
+ element :grid_fill_background, "cn-chart-radial-disc-background"
20
+ element :inside_label, "cn-chart-radial-inside-label"
21
+ element :center_title, "cn-chart-center-title-radial"
22
+ element :center_title_compact, "cn-chart-center-title-compact"
23
+ element :center_subtitle, "cn-chart-center-subtitle"
24
+ end
25
+ end
26
+ end
27
+ end