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,256 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ # The pie chart family.
6
+ module PieChart
7
+ # Renders a pie or donut chart as server-computed polar sectors.
8
+ # Slices color from their row's fill key, the donut hole comes from
9
+ # inner_radius, stacked pies nest two rings with their own data,
10
+ # and the center label is plain SVG text. The 250x250 viewBox pairs
11
+ # with a square, max-height-capped container, so absolute-pixel
12
+ # radii (inner_radius: 60, active pop-outs of +10) keep the same
13
+ # proportion everywhere. Slices meet flush by default;
14
+ # stroke_width: N opts into var(--background) separators, which
15
+ # hold up in dark mode where a hard-coded white would not.
16
+ #
17
+ # @example A donut with a tooltip
18
+ # <%= poetry_chart :pie, data: browsers, config: config do |c| %>
19
+ # <% c.with_pie data_key: :visitors, name_key: :browser, inner_radius: 60 %>
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
+ "Rows carry their slice color in a fill key (var(--color-<name>)); the config maps names to labels.",
30
+ "inner_radius: 60 makes the donut; with_center_label(title:, subtitle:) fills the hole.",
31
+ "Stacked pies: two with_pie slots with their own data: and non-overlapping radii.",
32
+ "active_index: pops one slice out by 10px (the donut-active look).",
33
+ "The tooltip walks slices by hover AND arrow keys (role=application when attached).",
34
+ "Entrance animation is on by default (source parity); animate: false for a static chart. " \
35
+ "Reduced-motion users always get the finished chart."
36
+ ].freeze
37
+
38
+ # One with_pie call's captured series config.
39
+ Series = Data.define(:data, :data_key, :name_key, :inner_radius, :outer_radius,
40
+ :padding_angle, :stroke_width, :color_key, :labels, :label_key,
41
+ :active_index, :active_grow)
42
+
43
+ option :data, ActiveModel::Type::Value.new,
44
+ doc: "Default rows for pies that don't bring their own data: - one hash per slice."
45
+ option :config, ActiveModel::Type::Value.new, required: true,
46
+ doc: "The series config - name => { label:, color: } - naming " \
47
+ "and coloring the slices."
48
+ option :id, :string,
49
+ doc: "Explicit DOM id token, stable across renders; otherwise the chart gets a unique per-render id."
50
+ option :width, :integer, default: 250,
51
+ doc: "ViewBox width in pixels; the rendered chart scales to its container."
52
+ option :height, :integer, default: 250, doc: "ViewBox height in pixels."
53
+ option :margin, ActiveModel::Type::Value.new,
54
+ doc: "Margin overrides ({ top:, right:, bottom:, left: }), merged over the slim polar default."
55
+ option :label, :string,
56
+ doc: "Accessible name for the chart SVG; defaults to one built from the configured series."
57
+
58
+ motion_options(delay: 400)
59
+
60
+ part "chart-svg", "The chart canvas (<svg>) - the aria-label surface, the tooltip's " \
61
+ "focus/keyboard surface (role=application when it attaches), and " \
62
+ "the motion rig's mount",
63
+ states: {
64
+ "data-animate" => "when animate (the default) - the entrance tier's flag the " \
65
+ "motion stylesheet and controller key off",
66
+ "data-motion" => { condition: "runtime, when animate - the motion engine's lifecycle stamp",
67
+ values: %w[entrance morph settled] }
68
+ },
69
+ vars: {
70
+ "--poetry-motion-delay" => "the motion rig's entrance delay (animation_begin)",
71
+ "--poetry-motion-duration" => "the motion rig's entrance duration (animation_duration)",
72
+ "--poetry-motion-easing" => "the motion rig's easing keyword (animation_easing)"
73
+ }
74
+ part "chart-pie", "One pie's slice group (<g>) - a ring per with_pie slot",
75
+ states: { "data-key" => "always - the series key" }
76
+ part "chart-pie-sector", "One slice (<path>) - fill from its row's color, popped out when active",
77
+ states: {
78
+ "data-key" => "always - the series key",
79
+ "data-index" => "on the first pie's slices - the datum index the tooltip walks",
80
+ "data-active" => "the active slice - server-rendered via active_index:, and reflected " \
81
+ "onto the hovered/arrow-keyed index by the tooltip controller at runtime",
82
+ "data-motion-group" => "when animate - the motion rig's sweep group (one per ring)",
83
+ "data-motion-sector" => "when animate - the motion rig's server-computed sector params " \
84
+ "for the fan-out sweep"
85
+ }
86
+ part "chart-labels", "A series' value labels (<g> of <text>, aria-hidden), rendered when " \
87
+ "the series opts into labels",
88
+ states: { "data-key" => "always - the series key" }
89
+ part "chart-center-label", "The center text (<text>) - title tspan plus optional subtitle " \
90
+ "filling the chart's middle"
91
+ part "chart-coordinates", "The embedded JSON payload (<script>) the tooltip controller " \
92
+ "reads - per-category anchors and pre-formatted values, zero " \
93
+ "chart math in the browser"
94
+
95
+ renders_many :pies,
96
+ doc: "One ring of slices reading data_key: values and name_key: slice names. inner_radius: " \
97
+ "makes the donut; padding_angle: spaces the slices; active_index: pops one out by " \
98
+ "active_grow: pixels.",
99
+ renders: lambda { |data_key:, data: nil, name_key: :name, inner_radius: 0,
100
+ outer_radius: "80%", padding_angle: 0, stroke_width: 0,
101
+ color_key: :fill, labels: nil, label_key: nil,
102
+ active_index: nil, active_grow: 10|
103
+ (@series_entries ||= []) << Series.new(data: data, data_key: data_key.to_s,
104
+ name_key: name_key.to_s, inner_radius:,
105
+ outer_radius:, padding_angle:, stroke_width:,
106
+ color_key: color_key&.to_s, labels: labels&.to_sym,
107
+ label_key: label_key&.to_s, active_index:, active_grow:)
108
+ nil
109
+ }
110
+ # ActiveSupport singularizes "pies" to "py" - give the grammar its
111
+ # real name.
112
+ alias with_pie with_py
113
+
114
+ renders_one :center_label,
115
+ doc: "The donut-hole text: a title line plus an optional subtitle.",
116
+ renders: lambda { |title:, subtitle: nil|
117
+ @center_label_config = { title: title, subtitle: subtitle }
118
+ nil
119
+ }
120
+
121
+ renders_one :legend,
122
+ doc: "The legend row: align:, items:, and hide_icon:.",
123
+ renders: lambda { |**options|
124
+ @legend_config = options
125
+ nil
126
+ }
127
+
128
+ renders_one :tooltip,
129
+ doc: "The hover tooltip; the slice name carries the label, so hide_label defaults on.",
130
+ renders: lambda { |**options|
131
+ @tooltip_config = { hide_label: true }.merge(options)
132
+ nil
133
+ }
134
+
135
+ # The polar chassis: margin/plot/center geometry plus the
136
+ # per-slice pointerover hit; the single-series tooltip chrome and
137
+ # payload ride the polar_* hooks below.
138
+ include Poetry::Charts::PolarFamily
139
+ include Poetry::Charts::PolarFamily::SingleSeriesTooltip
140
+
141
+ # The captured Series configs, forcing lazy slot evaluation.
142
+ # @api private
143
+ def series_entries
144
+ pies? # force slot evaluation (slots evaluate lazily)
145
+ @series_entries ||= []
146
+ end
147
+
148
+ # The center-label slot's captured config, forcing lazy slot
149
+ # evaluation.
150
+ # @api private
151
+ def center_label_config
152
+ center_label?
153
+ @center_label_config
154
+ end
155
+
156
+ # -- geometry ---------------------------------------------------------
157
+
158
+ # Memoized per SLOT (object identity), never per data_key: two rings
159
+ # may share a data_key with different data:, and a key-keyed memo
160
+ # would silently render the first ring's rows twice.
161
+ # @api private
162
+ def rows(entry)
163
+ @rows ||= {}.compare_by_identity
164
+ @rows[entry] ||= (entry.data || data || []).map { |row| row.to_h.transform_keys(&:to_s) }
165
+ end
166
+
167
+ # One computed slice: its sector geometry plus name, value, and
168
+ # fill.
169
+ Slice = Data.define(:index, :name, :value, :fill, :path, :mid_angle, :middle_radius, :label_point,
170
+ :inner, :outer, :start_angle, :end_angle)
171
+
172
+ # Slot-identity memo, matching rows (a shared data_key must not
173
+ # collapse two rings into one geometry).
174
+ # @api private
175
+ def slices(entry)
176
+ @slices ||= {}.compare_by_identity
177
+ @slices[entry] ||= begin
178
+ entry_rows = rows(entry)
179
+ values = entry_rows.map { |row| row[entry.data_key] }
180
+ max = Polar.max_radius(plot[:width], plot[:height])
181
+ inner = Polar.percent_value(entry.inner_radius, max, 0)
182
+ outer = Polar.percent_value(entry.outer_radius, max, max * 0.8)
183
+
184
+ Polar.pie_sectors(values, padding_angle: entry.padding_angle).each_with_index.filter_map do |sector, i|
185
+ next if sector[:start_angle] == sector[:end_angle] && entry_rows.length != 1
186
+
187
+ grown = entry.active_index == i ? outer + entry.active_grow : outer
188
+ middle_radius = (inner + grown) / 2.0
189
+ Slice.new(
190
+ index: i,
191
+ name: entry_rows[i][entry.name_key].to_s,
192
+ value: sector[:value],
193
+ fill: slice_fill(entry, entry_rows[i]),
194
+ path: Polar.sector_path(cx: cx, cy: cy, inner_radius: inner, outer_radius: grown,
195
+ start_angle: sector[:start_angle], end_angle: sector[:end_angle]),
196
+ mid_angle: sector[:mid_angle],
197
+ middle_radius: middle_radius,
198
+ label_point: Polar.polar_to_cartesian(cx, cy, middle_radius, sector[:mid_angle]),
199
+ inner: inner,
200
+ outer: grown,
201
+ start_angle: sector[:start_angle],
202
+ end_angle: sector[:end_angle]
203
+ )
204
+ end
205
+ end
206
+ end
207
+
208
+ # A slice's fill: the color_key row value (CSS-validated), else
209
+ # the config color for its name, else the series color var.
210
+ # @api private
211
+ def slice_fill(entry, row)
212
+ color = row[entry.color_key].to_s if entry.color_key
213
+ if color.present?
214
+ raise ArgumentError, "slice fill #{color.inspect} is not CSS-safe" unless color.match?(Config::COLOR)
215
+
216
+ color
217
+ else
218
+ name = row[entry.name_key].to_s
219
+ chart_config[name]&.color || "var(--color-#{entry.data_key})"
220
+ end
221
+ end
222
+
223
+ # Named labels resolve through the config (raw "chrome" ->
224
+ # "Chrome"), the same lookup the tooltip and legend already do.
225
+ # @api private
226
+ def slice_label(entry, slice)
227
+ return slice.value.to_i.to_s unless entry.label_key
228
+
229
+ raw = rows(entry)[slice.index][entry.label_key].to_s
230
+ chart_config.label_for(raw, raw)
231
+ end
232
+
233
+ # -- the tooltip payload (polar shape) ---------------------------------
234
+
235
+ # The SingleSeriesTooltip hooks: the FIRST pie's slices are the
236
+ # items, anchored at their label points; values read that pie's
237
+ # own rows.
238
+ # @api private
239
+ def polar_items(entry) = slices(entry)
240
+ # Where the tooltip anchors on one slice.
241
+ # @api private
242
+ def polar_anchor(slice) = slice.label_point
243
+ # The rows the tooltip values read from.
244
+ # @api private
245
+ def polar_value_rows(entry) = rows(entry)
246
+
247
+ # ChartFamily#svg_label's chart-type lead-in.
248
+ # @api private
249
+ def svg_label_prefix = "Pie chart"
250
+
251
+ private :center_label_config, :rows, :slices, :slice_fill, :slice_label, :polar_items
252
+ private :polar_anchor, :polar_value_rows, :svg_label_prefix
253
+ end
254
+ end
255
+ end
256
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ module PieChart
6
+ # The pie family across its variants: simple, donut,
7
+ # donut-with-center-text, label-list (names inside slices), legend,
8
+ # stacked (two nested rings), and the popped active slice.
9
+ class Preview < Poetry::Core::Preview::Base
10
+ CONFIG = {
11
+ visitors: { label: "Visitors" },
12
+ chrome: { label: "Chrome", color: "var(--chart-1)" },
13
+ safari: { label: "Safari", color: "var(--chart-2)" },
14
+ firefox: { label: "Firefox", color: "var(--chart-3)" },
15
+ edge: { label: "Edge", color: "var(--chart-4)" },
16
+ other: { label: "Other", color: "var(--chart-5)" }
17
+ }.freeze
18
+
19
+ DATA = [
20
+ { browser: "chrome", visitors: 275, fill: "var(--color-chrome)" },
21
+ { browser: "safari", visitors: 200, fill: "var(--color-safari)" },
22
+ { browser: "firefox", visitors: 187, fill: "var(--color-firefox)" },
23
+ { browser: "edge", visitors: 173, fill: "var(--color-edge)" },
24
+ { browser: "other", visitors: 90, fill: "var(--color-other)" }
25
+ ].freeze
26
+
27
+ # The stacked block's second ring: desktop vs mobile by month.
28
+ MONTH_DATA = [
29
+ { month: "january", desktop: 186, fill: "var(--color-january)" },
30
+ { month: "february", desktop: 305, fill: "var(--color-february)" },
31
+ { month: "march", desktop: 237, fill: "var(--color-march)" }
32
+ ].freeze
33
+
34
+ STACKED_CONFIG = CONFIG.merge(
35
+ desktop: { label: "Desktop" },
36
+ january: { label: "January", color: "var(--chart-1)" },
37
+ february: { label: "February", color: "var(--chart-2)" },
38
+ march: { label: "March", color: "var(--chart-3)" }
39
+ ).freeze
40
+
41
+ def simple
42
+ render_component(data: DATA, config: CONFIG, id: "pie-simple") do |chart|
43
+ chart.with_pie(data_key: :visitors, name_key: :browser)
44
+ chart.with_tooltip
45
+ end
46
+ end
47
+
48
+ def donut
49
+ render_component(data: DATA, config: CONFIG, id: "pie-donut") do |chart|
50
+ chart.with_pie(data_key: :visitors, name_key: :browser, inner_radius: 60)
51
+ chart.with_tooltip
52
+ end
53
+ end
54
+
55
+ def donut_text
56
+ render_component(data: DATA, config: CONFIG, id: "pie-donut-text") do |chart|
57
+ chart.with_pie(data_key: :visitors, name_key: :browser, inner_radius: 60)
58
+ chart.with_center_label(title: "925", subtitle: "Visitors")
59
+ end
60
+ end
61
+
62
+ def label_list
63
+ render_component(data: DATA, config: CONFIG, id: "pie-label-list") do |chart|
64
+ chart.with_pie(data_key: :visitors, name_key: :browser, labels: :list, label_key: :browser)
65
+ end
66
+ end
67
+
68
+ def legend
69
+ render_component(data: DATA, config: CONFIG, id: "pie-legend") do |chart|
70
+ chart.with_pie(data_key: :visitors, name_key: :browser)
71
+ chart.with_legend
72
+ end
73
+ end
74
+
75
+ def stacked
76
+ render_component(config: STACKED_CONFIG, id: "pie-stacked") do |chart|
77
+ chart.with_pie(data: DATA, data_key: :visitors, name_key: :browser, outer_radius: 60)
78
+ chart.with_pie(data: MONTH_DATA, data_key: :desktop, name_key: :month,
79
+ inner_radius: 70, outer_radius: 90)
80
+ end
81
+ end
82
+
83
+ def donut_active
84
+ render_component(data: DATA, config: CONFIG, id: "pie-donut-active") do |chart|
85
+ chart.with_pie(data_key: :visitors, name_key: :browser, inner_radius: 60, active_index: 0)
86
+ end
87
+ end
88
+
89
+ # A sync group member: the sync value the stimulus contract
90
+ # holds to the DOM.
91
+ def synced
92
+ render_component(data: DATA, config: CONFIG, id: "pie-synced", sync: "pie-demo") do |chart|
93
+ chart.with_pie(data_key: :visitors, name_key: :browser)
94
+ chart.with_tooltip
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ module PieChart
6
+ # The polar chrome on the cn-chart-* theme rules: inside labels
7
+ # ride fill-background, the donut center label keeps its 3xl type.
8
+ class Style < Poetry::Core::Style
9
+ base ""
10
+
11
+ element :frame, "contents"
12
+ element :svg, "w-full min-h-0 flex-1"
13
+ element :inside_label, "cn-chart-inside-label"
14
+ element :center_title, "cn-chart-center-title"
15
+ element :center_subtitle, "cn-chart-center-subtitle"
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ # The polar family chassis (pie/radar/radial): the shared margin +
6
+ # plot/center geometry, and the per-sector pointer hit - polar marks
7
+ # are hit by pointerover on the marked sector/wedge, not bisect, so
8
+ # the svg gains the enter action after TooltipWiring's pointer/
9
+ # keyboard set (include order is emission order - include this after
10
+ # TooltipWiring).
11
+ #
12
+ # @example Adding the polar chassis to a family
13
+ # class GaugeChart::Component < Poetry::Core::Component
14
+ # include Poetry::Charts::ChartFamily
15
+ # include Poetry::Charts::TooltipWiring
16
+ # include Poetry::Charts::PolarFamily
17
+ # include Poetry::Charts::PolarFamily::SingleSeriesTooltip
18
+ # end
19
+ module PolarFamily
20
+ # The default polar margin - a slim, even inset on all sides.
21
+ MARGIN = { top: 5, right: 5, bottom: 5, left: 5 }.freeze
22
+
23
+ def self.included(base)
24
+ base.use_stimulus do
25
+ on :svg do
26
+ controller(TooltipWiring::CONTROLLER, if: :tooltip?) { action :enter, on: :pointerover }
27
+ end
28
+ end
29
+ end
30
+
31
+ # The margin-inset plot rect the polar geometry centers in.
32
+ # @api private
33
+ def plot
34
+ @plot ||= begin
35
+ m = MARGIN.merge((margin || {}).to_h.symbolize_keys)
36
+ { left: m[:left].to_f, top: m[:top].to_f,
37
+ width: width - m[:left] - m[:right], height: height - m[:top] - m[:bottom] }
38
+ end
39
+ end
40
+
41
+ # The polar center's x coordinate.
42
+ # @api private
43
+ def cx = plot[:left] + (plot[:width] / 2.0)
44
+ # The polar center's y coordinate.
45
+ # @api private
46
+ def cy = plot[:top] + (plot[:height] / 2.0)
47
+
48
+ # The single-series polar tooltip (pie/radial): the FIRST series
49
+ # drives the chrome, and per-index names/colors retint the one row.
50
+ # Families supply polar_items (the sector geometry), polar_anchor
51
+ # (where the tooltip anchors on one item), and polar_value_rows (the
52
+ # rows the values read from). Radar keeps TooltipWiring's
53
+ # multi-series chrome and its own payload, so it includes
54
+ # PolarFamily alone.
55
+ module SingleSeriesTooltip
56
+ # The embedded per-sector geometry payload the tooltip controller
57
+ # reads: polar layout, anchors, and per-index names/colors/values.
58
+ # @api private
59
+ def coordinates_json
60
+ entry = series_entries.first
61
+ return "{}" unless entry
62
+
63
+ items = polar_items(entry)
64
+ {
65
+ "layout" => "polar",
66
+ "categories" => items.map { |s| chart_config.label_for(s.name, s.name) },
67
+ "names" => items.map { |s| chart_config.label_for(s.name, s.name) },
68
+ "colors" => items.map(&:fill),
69
+ "anchors" => items.map { |s| polar_anchor(s).map { |v| v.round(2) } },
70
+ "values" => { entry.data_key => polar_value_rows(entry).map do |row|
71
+ Poetry::Charts.display_value(row[entry.data_key])
72
+ end }
73
+ }.to_json
74
+ end
75
+
76
+ # The TooltipLayer child scoped to the first series, label hidden
77
+ # by default (the per-index name carries it).
78
+ # @api private
79
+ def tooltip_layer_component
80
+ TooltipLayer::Component.new(
81
+ config: chart_config,
82
+ series_keys: [series_entries.first&.data_key].compact,
83
+ indicator: tooltip_config.fetch(:indicator, :dot),
84
+ hide_label: tooltip_config.fetch(:hide_label, true),
85
+ hide_indicator: tooltip_config.fetch(:hide_indicator, false)
86
+ )
87
+ end
88
+
89
+ private :coordinates_json, :tooltip_layer_component
90
+ end
91
+
92
+ private :plot, :cx, :cy
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,73 @@
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 grid? %>
5
+ <g data-slot="chart-polar-grid" aria-hidden="true">
6
+ <%# Linework FIRST (rings + spokes), so the tinted fills below can
7
+ wash over it - the translucent ring fills stack on top of the
8
+ grid strokes, leaving them near-invisible. %>
9
+ <% grid_radii.each do |radius| %>
10
+ <% if grid_config.type == :circle %>
11
+ <circle class="<%= css(:grid_line) %>" cx="<%= fnum(cx) %>" cy="<%= fnum(cy) %>" r="<%= fnum(radius) %>" />
12
+ <% else %>
13
+ <path class="<%= css(:grid_line) %>" d="<%= grid_polygon(radius) %>" />
14
+ <% end %>
15
+ <% end %>
16
+ <% if grid_config.radial_lines %>
17
+ <% rows.each_index do |i| %>
18
+ <% x, y = spoke_end(i) %>
19
+ <line class="<%= css(:grid_line) %>" x1="<%= fnum(cx) %>" y1="<%= fnum(cy) %>" x2="<%= fnum(x) %>" y2="<%= fnum(y) %>" />
20
+ <% end %>
21
+ <% end %>
22
+ <%# The tinted fills: EVERY ring fills at the low opacity,
23
+ painted OVER the linework, overlaps compounding toward the
24
+ center - one fill+opacity across the whole grid. Inline
25
+ style, because grid_line sets fill-none and a fill
26
+ ATTRIBUTE loses to that CSS. %>
27
+ <% if grid_fill_attribute %>
28
+ <% grid_radii.each do |radius| %>
29
+ <% if grid_config.type == :circle %>
30
+ <circle class="<%= css(:grid_fill) %>" cx="<%= fnum(cx) %>" cy="<%= fnum(cy) %>" r="<%= fnum(radius) %>" style="fill: <%= grid_fill_attribute %>" fill-opacity="<%= grid_config.opacity %>" />
31
+ <% else %>
32
+ <path class="<%= css(:grid_fill) %>" d="<%= grid_polygon(radius) %>" style="fill: <%= grid_fill_attribute %>" fill-opacity="<%= grid_config.opacity %>" />
33
+ <% end %>
34
+ <% end %>
35
+ <% end %>
36
+ </g>
37
+ <% end %>
38
+ <g data-slot="chart-radars">
39
+ <% series_entries.each do |entry| %>
40
+ <path data-slot="chart-radar" data-key="<%= entry.data_key %>" d="<%= series_path(entry) %>" fill="var(--color-<%= entry.data_key %>)" fill-opacity="<%= entry.fill_opacity %>"<% if entry.stroke_width.positive? %> stroke="var(--color-<%= entry.data_key %>)" stroke-width="<%= entry.stroke_width %>"<% end %> />
41
+ <% if entry.dots %>
42
+ <g data-slot="chart-dots" data-key="<%= entry.data_key %>">
43
+ <% rows.each_index do |i| %>
44
+ <% x, y = vertex(entry, i) %>
45
+ <circle data-slot="chart-dot" cx="<%= fnum(x) %>" cy="<%= fnum(y) %>" r="<%= entry.dot_radius %>" fill="var(--color-<%= entry.data_key %>)" />
46
+ <% end %>
47
+ </g>
48
+ <% end %>
49
+ <% end %>
50
+ </g>
51
+ <% if angle_axis? %>
52
+ <g data-slot="chart-angle-axis">
53
+ <% rows.each_index do |i| %>
54
+ <% placement = angle_label(i) %>
55
+ <text class="<%= css(:tick) %>" x="<%= fnum(placement[:x]) %>" y="<%= fnum(placement[:y]) %>" dy="0.355em" text-anchor="<%= placement[:anchor] %>"><%= placement[:text] %></text>
56
+ <% end %>
57
+ </g>
58
+ <% end %>
59
+ <% if tooltip? %>
60
+ <g data-slot="chart-hit-wedges">
61
+ <% rows.each_index do |i| %>
62
+ <path data-slot="chart-hit-wedge" data-index="<%= i %>" d="<%= hit_wedge(i) %>" fill="transparent" />
63
+ <% end %>
64
+ </g>
65
+ <% end %>
66
+ </svg>
67
+ <% if legend? %>
68
+ <%= render legend_component %>
69
+ <% end %>
70
+ <% if tooltip? %><%= render tooltip_layer_component %><% end %>
71
+ <script type="application/json" data-slot="chart-coordinates" <%= tag.attributes(stimulus_attributes_for(:coordinates)) %>><%= script_json(coordinates_json) %></script>
72
+ <% end %>
73
+ <% end %>