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,329 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ # The radar chart family.
6
+ module RadarChart
7
+ # Renders a radar chart: categories spaced clockwise from
8
+ # 12 o'clock, values on a nice [0, auto] radius scale, series as
9
+ # closed polygons with a 0.6 fill, polygon or circle grids at the
10
+ # radius ticks, and angle-axis labels around the rim. The tooltip
11
+ # hits per CATEGORY through server-rendered transparent wedges
12
+ # (fill=transparent stays hit-testable) - the multi-series chrome
13
+ # with the category label, zero client math.
14
+ #
15
+ # @example One filled series over month categories
16
+ # <%= poetry_chart :radar, data:, config: do |c| %>
17
+ # <% c.with_angle_axis data_key: :month %>
18
+ # <% c.with_grid %>
19
+ # <% c.with_radar data_key: :desktop %>
20
+ # <% c.with_tooltip %>
21
+ # <% end %>
22
+ class Component < Poetry::Core::Component
23
+ include Poetry::Charts::ChartFamily
24
+ include Poetry::Charts::TooltipWiring
25
+ include Poetry::Charts::Motion
26
+
27
+ # Projected into the registry and agent surface.
28
+ AGENT_RULES = [
29
+ "Compose from slots: with_angle_axis(data_key:) / with_grid / with_radar(data_key:) / with_legend.",
30
+ "Radars fill at 0.6 opacity by default; lines-only = fill_opacity: 0, stroke_width: 2.",
31
+ "with_grid type: :circle swaps polygons for circles; fill: :desktop tints every grid ring " \
32
+ "(opacity 0.2, compounding toward the center - the grid-fill look).",
33
+ "dots: true marks every vertex (r 4, solid).",
34
+ "Colors come from the config - never set fill/stroke on a radar directly.",
35
+ "Entrance animation is on by default (source parity); animate: false for a static chart. " \
36
+ "Reduced-motion users always get the finished chart."
37
+ ].freeze
38
+
39
+ # One with_radar call's captured series config.
40
+ Series = Data.define(:data_key, :fill_opacity, :stroke_width, :dots, :dot_radius) do
41
+ # The TooltipWiring contract.
42
+ def key = data_key
43
+ end
44
+ # The with_grid slot's captured config.
45
+ GridConfig = Data.define(:type, :radial_lines, :fill, :opacity)
46
+
47
+ option :data, ActiveModel::Type::Value.new, required: true,
48
+ doc: "The rows to plot: an array of hashes, one per category."
49
+ option :config, ActiveModel::Type::Value.new, required: true,
50
+ doc: "The series config - key => { label:, color: } - naming " \
51
+ "and coloring every series."
52
+ option :id, :string,
53
+ doc: "Explicit DOM id token, stable across renders; otherwise the chart gets a unique per-render id."
54
+ option :width, :integer, default: 250,
55
+ doc: "ViewBox width in pixels; the rendered chart scales to its container."
56
+ option :height, :integer, default: 250, doc: "ViewBox height in pixels."
57
+ option :margin, ActiveModel::Type::Value.new,
58
+ doc: "Margin overrides ({ top:, right:, bottom:, left: }), merged over the slim polar default."
59
+ option :label, :string,
60
+ doc: "Accessible name for the chart SVG; defaults to one built from the configured series."
61
+
62
+ motion_options
63
+ option :outer_radius, ActiveModel::Type::Value.new, default: "80%",
64
+ doc: "The rim radius: a percent string of the max " \
65
+ "radius, or pixels."
66
+
67
+ part "chart-svg", "The chart canvas (<svg>) - the aria-label surface, the tooltip's " \
68
+ "focus/keyboard surface (role=application when it attaches), and " \
69
+ "the motion rig's mount",
70
+ states: {
71
+ "data-animate" => "when animate (the default) - the entrance tier's flag the " \
72
+ "motion stylesheet and controller key off",
73
+ "data-motion" => { condition: "runtime, when animate - the motion engine's lifecycle stamp",
74
+ values: %w[entrance morph settled] }
75
+ },
76
+ vars: {
77
+ "--poetry-motion-delay" => "the motion rig's entrance delay (animation_begin)",
78
+ "--poetry-motion-duration" => "the motion rig's entrance duration (animation_duration)",
79
+ "--poetry-motion-easing" => "the motion rig's easing keyword (animation_easing)",
80
+ "--poetry-motion-center" => "the polar center the CSS entrance scales the series from"
81
+ }
82
+ part "chart-polar-grid", "The polar grid (<g>, aria-hidden) - ring and spoke linework " \
83
+ "behind the series"
84
+ part "chart-radars", "The series layer (<g>) - every radar polygon renders here; the " \
85
+ "CSS entrance scales this group from the polar center"
86
+ part "chart-radar", "One series' closed polygon (<path>) - config color, 0.6 fill by default",
87
+ states: { "data-key" => "always - the series key" }
88
+ part "chart-dots", "A series' vertex dots (<g>), rendered when dots: true",
89
+ states: { "data-key" => "always - the series key" }
90
+ part "chart-dot", "One vertex dot (<circle>) - solid, at the series color"
91
+ part "chart-angle-axis", "The category labels around the rim (<g> of <text>)"
92
+ part "chart-hit-wedges", "The tooltip's hit layer (<g>), rendered when the tooltip attaches"
93
+ part "chart-hit-wedge", "One per-category hit wedge (<path>, transparent but painted so " \
94
+ "it hit-tests) - the tooltip's hover target",
95
+ states: {
96
+ "data-index" => "always - the datum index",
97
+ "data-active" => "the hovered/arrow-keyed category - the tooltip controller " \
98
+ "reflects the active index here at runtime"
99
+ }
100
+ part "chart-coordinates", "The embedded JSON payload (<script>) the tooltip controller " \
101
+ "reads - per-category anchors and pre-formatted values, zero " \
102
+ "chart math in the browser"
103
+
104
+ renders_many :radars,
105
+ doc: "A radar series bound to data_key:. fill_opacity: 0 with stroke_width: 2 draws lines " \
106
+ "only; dots: marks every vertex.",
107
+ renders: lambda { |data_key:, fill_opacity: 0.6, stroke_width: 0, dots: false, dot_radius: 4|
108
+ (@series_entries ||= []) << Series.new(data_key: data_key.to_s, fill_opacity:, stroke_width:,
109
+ dots:, dot_radius:)
110
+ nil
111
+ }
112
+
113
+ renders_one :angle_axis,
114
+ doc: "The category labels around the rim: data_key: names the field; tick_formatter: reshapes " \
115
+ "each label.",
116
+ renders: lambda { |data_key:, tick_formatter: nil|
117
+ @angle_axis_config = { data_key: data_key.to_s, tick_formatter: tick_formatter }
118
+ nil
119
+ }
120
+
121
+ renders_one :grid,
122
+ doc: "The polar grid: type: :circle swaps polygons for circles; radial_lines: false drops the " \
123
+ "spokes; fill: tints every ring with a series color at opacity:.",
124
+ renders: lambda { |type: :polygon, radial_lines: true, fill: nil, opacity: 0.2|
125
+ @grid_config = GridConfig.new(type: type.to_sym, radial_lines:, fill: fill&.to_s, opacity:)
126
+ nil
127
+ }
128
+
129
+ renders_one :legend,
130
+ doc: "The legend row: align:, items:, and hide_icon:.",
131
+ renders: lambda { |**options|
132
+ @legend_config = options
133
+ nil
134
+ }
135
+
136
+ renders_one :tooltip,
137
+ doc: "The hover tooltip - multi-series rows under the category label.",
138
+ renders: lambda { |**options|
139
+ @tooltip_config = options
140
+ nil
141
+ }
142
+
143
+ # The polar chassis (margin/plot/center + the pointerover hit).
144
+ # PolarFamily alone: radar keeps TooltipWiring's multi-series
145
+ # chrome and its own payload below.
146
+ include Poetry::Charts::PolarFamily
147
+
148
+ # The captured Series configs, forcing lazy slot evaluation.
149
+ # @api private
150
+ def series_entries
151
+ radars? # force slot evaluation (slots evaluate lazily)
152
+ @series_entries ||= []
153
+ end
154
+
155
+ # The angle-axis slot's captured config, forcing lazy slot
156
+ # evaluation.
157
+ # @api private
158
+ def angle_axis_config
159
+ angle_axis?
160
+ @angle_axis_config
161
+ end
162
+
163
+ # The grid slot's captured config, forcing lazy slot evaluation.
164
+ # @api private
165
+ def grid_config
166
+ grid?
167
+ @grid_config
168
+ end
169
+
170
+ # -- geometry ---------------------------------------------------------
171
+
172
+ # The data rows with stringified keys.
173
+ # @api private
174
+ def rows
175
+ @rows ||= data.map { |row| row.to_h.transform_keys(&:to_s) }
176
+ end
177
+
178
+ # The category labels: the angle axis's data key values, else
179
+ # bare indexes.
180
+ # @api private
181
+ def categories
182
+ key = angle_axis_config&.fetch(:data_key)
183
+ key ? rows.map { |row| row[key] } : (0...rows.length).to_a
184
+ end
185
+
186
+ # Clockwise from 12 o'clock: category 0 sits at the top
187
+ # (90 degrees), later categories sweep clockwise.
188
+ # @api private
189
+ def angle_at(index)
190
+ 90 - (index * (360.0 / rows.length))
191
+ end
192
+
193
+ # The rim radius resolved to pixels.
194
+ # @api private
195
+ def radius_px
196
+ @radius_px ||= Polar.percent_value(outer_radius, Polar.max_radius(plot[:width], plot[:height]),
197
+ Polar.max_radius(plot[:width], plot[:height]) * 0.8)
198
+ end
199
+
200
+ # The radius domain auto-nices [0, data max] into 5 ticks, so the
201
+ # outer ring is the ROUNDED max and the top datum sits just
202
+ # inside it (data max 305 -> [0, 320], the peak at 95.3%).
203
+ # @api private
204
+ def radius_ticks
205
+ @radius_ticks ||= begin
206
+ max = series_entries.flat_map { |e| rows.map { |row| row[e.data_key].to_f } }.max || 1
207
+ Geometry::NiceTicks.nice_ticks([0, max], 5)
208
+ end
209
+ end
210
+
211
+ # A value's distance from the center, scaled to the tick domain.
212
+ # @api private
213
+ def radius_for(value)
214
+ (value.to_f / radius_ticks.last) * radius_px
215
+ end
216
+
217
+ # One series point: the category's angle at the value's radius.
218
+ # @api private
219
+ def vertex(entry, index)
220
+ Polar.polar_to_cartesian(cx, cy, radius_for(rows[index][entry.data_key]), angle_at(index))
221
+ end
222
+
223
+ # A closed polygon path through the given points.
224
+ # @api private
225
+ def polygon_path(points)
226
+ "M#{points.map { |x, y| "#{fnum(x)},#{fnum(y)}" }.join("L")}Z"
227
+ end
228
+
229
+ # One series' closed polygon through all its vertices.
230
+ # @api private
231
+ def series_path(entry)
232
+ polygon_path(rows.each_index.map { |i| vertex(entry, i) })
233
+ end
234
+
235
+ # Grid polygons/circles at each nonzero radius tick.
236
+ # @api private
237
+ def grid_radii
238
+ radius_ticks.reject(&:zero?).map { |tick| radius_for(tick) }
239
+ end
240
+
241
+ # One grid ring's polygon at the given radius.
242
+ # @api private
243
+ def grid_polygon(radius)
244
+ polygon_path(rows.each_index.map { |i| Polar.polar_to_cartesian(cx, cy, radius, angle_at(i)) })
245
+ end
246
+
247
+ # The radial spoke's outer endpoint (template helper - bare Polar
248
+ # does not resolve from compiled ERB scope).
249
+ # @api private
250
+ def spoke_end(index)
251
+ Polar.polar_to_cartesian(cx, cy, radius_px, angle_at(index))
252
+ end
253
+
254
+ # Lands in an inline style= (the class's fill-none beats a fill
255
+ # attribute), so the token is validated - never interpolate a raw
256
+ # string into CSS.
257
+ # @api private
258
+ def grid_fill_attribute
259
+ return nil unless grid_config&.fill
260
+
261
+ token = grid_config.fill
262
+ raise ArgumentError, "grid fill #{token.inspect} is not a series key" unless token.match?(/\A[\w-]+\z/)
263
+
264
+ "var(--color-#{token})"
265
+ end
266
+
267
+ # Angle-axis labels around the rim, anchored by which side of the
268
+ # circle they sit on.
269
+ # @api private
270
+ def angle_label(index)
271
+ angle = angle_at(index)
272
+ x, y = Polar.polar_to_cartesian(cx, cy, radius_px + 10, angle)
273
+ cos = Math.cos(-Polar::RADIAN * angle)
274
+ anchor = if cos > 0.2 then "start"
275
+ elsif cos < -0.2 then "end"
276
+ else "middle"
277
+ end
278
+ category = categories[index]
279
+ formatter = angle_axis_config[:tick_formatter]
280
+ { x: x, y: y, anchor: anchor, text: formatter ? formatter.call(category).to_s : category.to_s }
281
+ end
282
+
283
+ # The invisible per-category hit wedges the tooltip rides
284
+ # (fill=transparent is painted, so it hit-tests; fill=none would not).
285
+ # @api private
286
+ def hit_wedge(index)
287
+ half = 180.0 / rows.length
288
+ Polar.sector_path(cx: cx, cy: cy, inner_radius: 0, outer_radius: radius_px,
289
+ start_angle: angle_at(index) - half, end_angle: angle_at(index) + half)
290
+ end
291
+
292
+ # -- the tooltip payload: polar anchors, cartesian-style rows ----------
293
+
294
+ # The embedded per-category geometry payload the tooltip
295
+ # controller reads.
296
+ # @api private
297
+ def coordinates_json
298
+ {
299
+ "layout" => "polar",
300
+ "categories" => categories.map(&:to_s),
301
+ "anchors" => rows.each_index.map do |i|
302
+ Polar.polar_to_cartesian(cx, cy, radius_px * 0.7, angle_at(i)).map { |v| v.round(2) }
303
+ end,
304
+ "values" => series_entries.to_h do |entry|
305
+ [entry.data_key, rows.map { |row| Poetry::Charts.display_value(row[entry.data_key]) }]
306
+ end
307
+ }.to_json
308
+ end
309
+
310
+ # ChartFamily#svg_label's chart-type lead-in.
311
+ # @api private
312
+ def svg_label_prefix = "Radar chart"
313
+
314
+ # The polar center, so the motion stylesheet can scale the
315
+ # entrance from it - a uniform scale from (cx, cy) matches
316
+ # per-vertex interpolation frame for frame.
317
+ # @api private
318
+ def motion_style_extras
319
+ "--poetry-motion-center: #{fnum(cx)}px #{fnum(cy)}px"
320
+ end
321
+
322
+ private :angle_axis_config, :grid_config, :rows, :categories, :angle_at, :radius_px
323
+ private :radius_ticks, :radius_for, :vertex, :polygon_path, :series_path, :grid_radii, :grid_polygon, :spoke_end
324
+ private :grid_fill_attribute, :angle_label, :hit_wedge, :coordinates_json, :svg_label_prefix
325
+ private :motion_style_extras
326
+ end
327
+ end
328
+ end
329
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ module RadarChart
6
+ # The radar family across its variants: default, dots,
7
+ # lines-only, multiple series, circle grid, tinted grid disc,
8
+ # gridless, and legend.
9
+ class Preview < Poetry::Core::Preview::Base
10
+ DATA = AreaChart::Preview::DATA
11
+ ONE = AreaChart::Preview::ONE
12
+ TWO = AreaChart::Preview::TWO
13
+
14
+ def default
15
+ simple(id: "radar-default") { |chart| chart.with_tooltip(indicator: :line) }
16
+ end
17
+
18
+ def dots
19
+ simple(id: "radar-dots", dots: true)
20
+ end
21
+
22
+ # BOTH series draw as strokes only, and the grid's radial
23
+ # spokes turn off.
24
+ def lines_only
25
+ render_component(data: DATA, config: TWO, id: "radar-lines-only") do |chart|
26
+ chart.with_angle_axis(data_key: :month)
27
+ chart.with_grid(radial_lines: false)
28
+ chart.with_radar(data_key: :desktop, fill_opacity: 0, stroke_width: 2)
29
+ chart.with_radar(data_key: :mobile, fill_opacity: 0, stroke_width: 2)
30
+ end
31
+ end
32
+
33
+ # Only the FIRST series is translucent (fill_opacity 0.6);
34
+ # the second keeps the opaque default and covers the overlap.
35
+ def multiple
36
+ render_component(data: DATA, config: TWO, id: "radar-multiple") do |chart|
37
+ chart.with_angle_axis(data_key: :month)
38
+ chart.with_grid
39
+ chart.with_radar(data_key: :desktop)
40
+ chart.with_radar(data_key: :mobile, fill_opacity: 1)
41
+ end
42
+ end
43
+
44
+ def grid_circle
45
+ simple(id: "radar-grid-circle", grid_type: :circle, dots: true)
46
+ end
47
+
48
+ def grid_fill
49
+ render_component(data: DATA, config: ONE, id: "radar-grid-fill") do |chart|
50
+ chart.with_angle_axis(data_key: :month)
51
+ chart.with_grid(fill: :desktop)
52
+ chart.with_radar(data_key: :desktop)
53
+ end
54
+ end
55
+
56
+ def grid_none
57
+ render_component(data: DATA, config: ONE, id: "radar-grid-none") do |chart|
58
+ chart.with_angle_axis(data_key: :month)
59
+ chart.with_radar(data_key: :desktop, dots: true)
60
+ end
61
+ end
62
+
63
+ def legend
64
+ render_component(data: DATA, config: TWO, id: "radar-legend") do |chart|
65
+ chart.with_angle_axis(data_key: :month)
66
+ chart.with_grid
67
+ chart.with_radar(data_key: :desktop)
68
+ chart.with_radar(data_key: :mobile, fill_opacity: 1)
69
+ chart.with_legend
70
+ end
71
+ end
72
+
73
+ # A sync group member: the sync value the stimulus contract
74
+ # holds to the DOM.
75
+ def synced
76
+ render_component(data: DATA, config: ONE, id: "radar-synced", sync: "radar-demo") do |chart|
77
+ chart.with_angle_axis(data_key: :month)
78
+ chart.with_grid
79
+ chart.with_radar(data_key: :desktop)
80
+ chart.with_tooltip
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ def simple(id:, dots: false, grid_type: :polygon)
87
+ render_component(data: DATA, config: ONE, id: id) do |chart|
88
+ chart.with_angle_axis(data_key: :month)
89
+ chart.with_grid(type: grid_type)
90
+ chart.with_radar(data_key: :desktop, dots: dots)
91
+ yield chart if block_given?
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ module RadarChart
6
+ # The radar chrome on the cn-chart-* theme rules: polar grid
7
+ # lines take FULL border (cartesian grids use /50, polar does not).
8
+ # The tinted grid-fill discs keep stroke-none inline (no paint - a
9
+ # mechanism guard, they overlay the linework).
10
+ class Style < Poetry::Core::Style
11
+ base ""
12
+
13
+ element :frame, "contents"
14
+ element :svg, "w-full min-h-0 flex-1"
15
+ element :grid_line, "cn-chart-polar-grid-line"
16
+ element :grid_fill, "stroke-none"
17
+ element :tick, "cn-chart-tick"
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,65 @@
1
+ <%= render Poetry::Charts::Container::Component.new(config: chart_config, id: chart_id.delete_prefix("chart-")) do %>
2
+ <%= content_tag(:div, frame_attributes.merge("class" => css(:frame))) do %>
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 polar_grid? %>
5
+ <g data-slot="chart-polar-grid" aria-hidden="true">
6
+ <% if polar_grid_config[:radial_lines] %>
7
+ <% angle_tick_values.each do |tick| %>
8
+ <% x1, y1, x2, y2 = spoke_points(tick) %>
9
+ <line class="<%= css(:grid_circle) %>" x1="<%= fnum(x1) %>" y1="<%= fnum(y1) %>" x2="<%= fnum(x2) %>" y2="<%= fnum(y2) %>" />
10
+ <% end %>
11
+ <% end %>
12
+ <% grid_radii.each_with_index do |radius, i| %>
13
+ <circle class="<%= grid_fill_class(i) %>" cx="<%= fnum(cx) %>" cy="<%= fnum(cy) %>" r="<%= fnum(radius) %>" />
14
+ <% end %>
15
+ </g>
16
+ <% end %>
17
+ <% series_entries.each_with_index do |entry, series_index| %>
18
+ <g data-slot="chart-radial-series" data-key="<%= entry.data_key %>">
19
+ <% segments(entry).each do |segment| %>
20
+ <% if entry.background %>
21
+ <path data-slot="chart-radial-background" class="<%= css(:background_ring) %>" d="<%= background_path(segment) %>" />
22
+ <% end %>
23
+ <% end %>
24
+ <% segments(entry).each do |segment| %>
25
+ <path data-slot="chart-radial-bar" data-key="<%= entry.data_key %>"<% if series_index.zero? %> data-index="<%= segment.index %>"<% end %><% if animate? %> data-motion-sector="<%= motion_sector_value(cx, cy, segment.ring_inner, segment.ring_outer, segment.seg_start, segment.seg_end) %>" data-motion-group="ring-<%= segment.index %>"<% end %> d="<%= segment.path %>" fill="<%= segment.fill %>" />
26
+ <% end %>
27
+ <% if entry.labels == :inside_start %>
28
+ <g data-slot="chart-labels" data-key="<%= entry.data_key %>" aria-hidden="true">
29
+ <% segments(entry).each do |segment| %>
30
+ <% placement = label_placement(segment) %>
31
+ <text class="<%= css(:inside_label) %>" x="<%= fnum(placement[:x]) %>" y="<%= fnum(placement[:y]) %>" dy="0.355em" text-anchor="<%= placement[:anchor] %>" transform="rotate(<%= fnum(placement[:rotate]) %>, <%= fnum(placement[:x]) %>, <%= fnum(placement[:y]) %>)"><%= entry.label_key ? rows[segment.index][entry.label_key] : segment.value %></text>
32
+ <% end %>
33
+ </g>
34
+ <% end %>
35
+ </g>
36
+ <% end %>
37
+ <% if center_label? %>
38
+ <%# Full gauge: text-4xl centered on cy, dy=0.355em (the
39
+ dominant-baseline: middle offset, stated inline), caption 24
40
+ below. Compact (stacked half-gauge): text-2xl baseline at
41
+ cy-16, caption at cy+4 - the smaller number sits just above
42
+ the flat baseline. %>
43
+ <% compact = center_label_config[:compact] %>
44
+ <text data-slot="chart-center-label" x="<%= fnum(cx) %>" y="<%= fnum(cy) %>" text-anchor="middle">
45
+ <% if compact %>
46
+ <tspan class="<%= css(:center_title_compact) %>" x="<%= fnum(cx) %>" y="<%= fnum(cy - 16) %>"><%= center_label_config[:title] %></tspan>
47
+ <% if center_label_config[:subtitle] %>
48
+ <tspan class="<%= css(:center_subtitle) %>" x="<%= fnum(cx) %>" y="<%= fnum(cy + 4) %>"><%= center_label_config[:subtitle] %></tspan>
49
+ <% end %>
50
+ <% else %>
51
+ <tspan class="<%= css(:center_title) %>" x="<%= fnum(cx) %>" y="<%= fnum(cy) %>" dy="0.355em"><%= center_label_config[:title] %></tspan>
52
+ <% if center_label_config[:subtitle] %>
53
+ <tspan class="<%= css(:center_subtitle) %>" x="<%= fnum(cx) %>" y="<%= fnum(cy + 24) %>" dy="0.355em"><%= center_label_config[:subtitle] %></tspan>
54
+ <% end %>
55
+ <% end %>
56
+ </text>
57
+ <% end %>
58
+ </svg>
59
+ <% if legend? %>
60
+ <%= render legend_component %>
61
+ <% end %>
62
+ <% if tooltip? %><%= render tooltip_layer_component %><% end %>
63
+ <script type="application/json" data-slot="chart-coordinates" <%= tag.attributes(stimulus_attributes_for(:coordinates)) %>><%= script_json(coordinates_json) %></script>
64
+ <% end %>
65
+ <% end %>