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,318 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ # The bar chart family.
6
+ module BarChart
7
+ # Renders a bar chart, vertical columns by default
8
+ # (orientation: :horizontal flips them): band-scale positioning - a
9
+ # 10% category gap trims each side of the band, a 4px gap separates
10
+ # side-by-side groups, stacked bars share a slot. Rounded corners
11
+ # are per-corner (radius: 8 or [tl, tr, br, bl] - stacked segments
12
+ # round only their outer edge), negatives drop below the zero
13
+ # baseline, and per-cell fills come from a data key or a guarded
14
+ # proc (sign-based coloring).
15
+ #
16
+ # @example Columns with rounded corners
17
+ # <%= poetry_chart :bar, data:, config: do |c| %>
18
+ # <% c.with_grid %>
19
+ # <% c.with_x_axis data_key: :month, tick_formatter: ->(v) { v[0, 3] } %>
20
+ # <% c.with_bar data_key: :desktop, radius: 8 %>
21
+ # <% end %>
22
+ class Component < Poetry::Core::Component
23
+ include Poetry::Charts::ChartFamily
24
+ include Poetry::Charts::TooltipWiring
25
+ include Poetry::Charts::Motion
26
+ include Poetry::Charts::Live
27
+ include Poetry::Charts::BarMath
28
+ include Poetry::Charts::ReferenceMarks
29
+ include Poetry::Charts::ErrorBars
30
+
31
+ # Projected into the registry and agent surface.
32
+ AGENT_RULES = [
33
+ "Compose from slots: with_grid / with_x_axis(data_key:) / with_bar(data_key:) / with_legend.",
34
+ "radius: 8 rounds all corners; stacked bars use arrays - [0,0,4,4] bottom bar, [4,4,0,0] top bar.",
35
+ "Stack bars with the same stack: id; negatives automatically drop below the zero line.",
36
+ "cell_fill: ->(row, value) { ... } colors bars per datum (validated CSS-safe); color_key: reads a row key.",
37
+ "active_index: highlights one bar (fill-opacity 0.8 + dashed stroke - the active block look).",
38
+ "Entrance animation is on by default (source parity); animate: false for a static chart. " \
39
+ "Reduced-motion users always get the finished chart."
40
+ ].freeze
41
+
42
+ # One with_bar call's captured series config.
43
+ Series = Data.define(:key, :stack, :radius, :labels, :label_key, :color_key,
44
+ :cell_fill, :active_index, :stroke_width, :error_key, :error_width) do
45
+ # The band-slot group id: the stack, else the series' own key.
46
+ def stack_or_self = stack || key
47
+ end
48
+
49
+ option :data, ActiveModel::Type::Value.new, required: true,
50
+ doc: "The rows to plot: an array of hashes, one per category."
51
+ option :config, ActiveModel::Type::Value.new, required: true,
52
+ doc: "The series config - key => { label:, color: } - naming " \
53
+ "and coloring every series."
54
+ option :id, :string,
55
+ doc: "Explicit DOM id token, stable across renders; otherwise the chart gets a unique per-render id."
56
+ option :width, :integer, default: 640,
57
+ doc: "ViewBox width in pixels; the rendered chart scales to its container."
58
+ option :height, :integer, default: 360, doc: "ViewBox height in pixels."
59
+ option :margin, ActiveModel::Type::Value.new,
60
+ doc: "Plot margin overrides ({ top:, right:, bottom:, left: }), merged over the defaults."
61
+ option :offset, :symbol, default: :none,
62
+ doc: "Stack baseline mode - :expand normalizes each stack to percentages."
63
+ option :label, :string,
64
+ doc: "Accessible name for the chart SVG; defaults to one built from the configured series."
65
+
66
+ motion_options(duration: 400)
67
+ live_option
68
+ option :bar_gap, :integer, default: 4, doc: "Pixels between side-by-side bars inside one category band."
69
+ option :bar_category_gap, :string, default: "10%",
70
+ doc: "Band trim on each side: a percent string of the band width, or a " \
71
+ "bare pixel number."
72
+ option :orientation, :symbol, default: :vertical,
73
+ doc: ":vertical = columns (the default); :horizontal = bars growing rightward " \
74
+ "- the category axis moves to the Y side (with_y_axis data_key:) and the " \
75
+ "numeric axis hides."
76
+
77
+ validates :offset, inclusion: { in: Cartesian::OFFSETS }
78
+ validates :orientation, inclusion: { in: Cartesian::LAYOUTS }
79
+
80
+ part "chart-svg", "The chart canvas (<svg>) - server-computed geometry in a fixed viewBox; " \
81
+ "role=img, or the focusable role=application accessibilityLayer when the " \
82
+ "tooltip attaches",
83
+ states: {
84
+ "data-animate" => "present when animate (the default) - the motion stylesheet and " \
85
+ "controller key the entrance off it",
86
+ "data-motion" => { condition: "runtime - the motion rig stamps the animation " \
87
+ "lifecycle (entrance/morph, then settled)",
88
+ values: %w[entrance morph settled] }
89
+ },
90
+ vars: {
91
+ "--poetry-motion-duration" => "the entrance/morph duration (animation_duration, ms)",
92
+ "--poetry-motion-easing" => "the animation easing keyword (animation_easing)",
93
+ "--poetry-motion-delay" => "the pre-animation hold (animation_begin, ms)"
94
+ }
95
+ part "chart-grid", "The gridline group (with_grid) - horizontal and/or vertical rules " \
96
+ "across the plot"
97
+ part "chart-cursor", "The hover cursor, hidden until the tooltip controller positions " \
98
+ "and reveals it at the active index - a vertical rule or a " \
99
+ "translucent band rect (bar charts)"
100
+ part "chart-bars", "The bar-mark group wrapping every series"
101
+ part "chart-bar-series", "One series' bar group",
102
+ states: { "data-key" => "the series key" }
103
+ part "chart-bar", "One bar cell (a per-corner rounded-rect path)",
104
+ states: {
105
+ "data-key" => "the series key",
106
+ "data-index" => "the datum index",
107
+ "data-active" => "the highlighted cell - server-rendered from active_index:, " \
108
+ "and the tooltip controller marks the hovered index at runtime",
109
+ "data-motion-origin" => { condition: "when animate - the zero edge the entrance " \
110
+ "grows from",
111
+ values: %w[bottom top left right] }
112
+ }
113
+ part "chart-labels", "One series' value-label group (labels: true)",
114
+ states: { "data-key" => "the series key" }
115
+ part "chart-x-axis", "The x-axis tick-label group (with_x_axis)"
116
+ part "chart-y-axis", "The y-axis tick-label group (with_y_axis)"
117
+ part "chart-coordinates", "The embedded per-index geometry payload " \
118
+ "(<script type=application/json>) the tooltip controller " \
119
+ "reads - zero chart math in the browser"
120
+
121
+ renders_many :bars,
122
+ doc: "A bar series bound to data_key:. Bars sharing a stack: id pile up; radius: rounds " \
123
+ "corners; labels:/label_key: stamp values; color_key:/cell_fill: color per cell; " \
124
+ "active_index: highlights one bar; error_key: adds whiskers.",
125
+ renders: lambda { |data_key:, stack: nil, radius: 0, labels: false, label_key: nil,
126
+ color_key: nil, cell_fill: nil, active_index: nil, stroke_width: 2,
127
+ error_key: nil, error_width: 5|
128
+ (@series_entries ||= []) << Series.new(key: data_key.to_s, stack:, radius:, labels:,
129
+ label_key: label_key&.to_s, color_key: color_key&.to_s,
130
+ cell_fill:, active_index:, stroke_width:,
131
+ error_key: error_key&.to_s, error_width:)
132
+ nil
133
+ }
134
+
135
+ # In the horizontal orientation the Y axis IS the category axis -
136
+ # give it the data_key. The CartesianFamily value-axis hook, so
137
+ # the include below declares this shape between x_axis and grid.
138
+ def self.value_axis_slot
139
+ renders_one :y_axis,
140
+ doc: "The y axis: data_key: makes it the category axis (horizontal orientation); " \
141
+ "tick_count:/tick_formatter:/tick_margin: as on the x axis.",
142
+ renders: lambda { |data_key: nil, tick_count: 3, tick_formatter: nil, tick_margin: 8|
143
+ @y_axis_config = AxisConfig.new(data_key: data_key&.to_s, tick_formatter:,
144
+ tick_margin:, tick_count:)
145
+ nil
146
+ }
147
+ end
148
+
149
+ include Poetry::Charts::CartesianFamily
150
+
151
+ # The captured Series configs, forcing lazy slot evaluation.
152
+ # @api private
153
+ def series_entries
154
+ bars? # force slot evaluation (slots evaluate lazily)
155
+ @series_entries ||= []
156
+ end
157
+
158
+ # Whether bars grow rightward from a Y-side category axis.
159
+ # @api private
160
+ def horizontal?
161
+ orientation == :horizontal
162
+ end
163
+
164
+ # The chart's cartesian geometry, built once per render.
165
+ # @api private
166
+ def cartesian
167
+ @cartesian ||= Cartesian.new(
168
+ data: data,
169
+ series: series_entries,
170
+ width: width,
171
+ height: height,
172
+ x_key: horizontal? ? y_axis_config&.data_key : x_axis_config&.data_key,
173
+ margin: live_margin,
174
+ category_axis: horizontal? ? y_axis? : x_axis?,
175
+ value_axis: !horizontal? && y_axis?,
176
+ # Horizontal charts hide the numeric axis (implicit tickCount 5);
177
+ # vertical ones take the visible Y axis's count when present.
178
+ y_tick_count: horizontal? ? 5 : (y_axis_config&.tick_count || 5),
179
+ offset: offset,
180
+ x_scale_type: :band,
181
+ layout: orientation
182
+ )
183
+ end
184
+
185
+ # The per-group band slots: stacked bars share a slot; groups sit
186
+ # side by side inside the band (no explicit bar size).
187
+ # @api private
188
+ def bar_slots
189
+ @bar_slots ||= bar_slots_for(series_entries)
190
+ end
191
+
192
+ # One series' bar rects, positioned in its band slot.
193
+ # @api private
194
+ def cells(entry)
195
+ bar_cells(entry, bar_slots.fetch(entry.stack_or_self))
196
+ end
197
+
198
+ # One bar cell's rounded-rect path.
199
+ # @api private
200
+ def bar_path(entry, cell)
201
+ bar_path_for(entry.radius, cell)
202
+ end
203
+
204
+ # Fill resolution: cell_fill proc, else color_key row value, else
205
+ # the series color - everything reaching the attribute is guarded.
206
+ # @api private
207
+ def cell_fill(entry, cell)
208
+ color = if entry.cell_fill
209
+ entry.cell_fill.call(cell[:row], cell[:value]).to_s
210
+ elsif entry.color_key
211
+ cell[:row].to_h.transform_keys(&:to_s)[entry.color_key].to_s
212
+ else
213
+ return "var(--color-#{entry.key})"
214
+ end
215
+ raise ArgumentError, "bar fill #{color.inspect} is not CSS-safe" unless color.match?(Config::COLOR)
216
+
217
+ color
218
+ end
219
+
220
+ # Whether this cell is the series' highlighted active_index.
221
+ # @api private
222
+ def active?(entry, cell)
223
+ entry.active_index == cell[:index]
224
+ end
225
+
226
+ # A cell's value label: the label_key row value, else the value
227
+ # with integers shown bare.
228
+ # @api private
229
+ def cell_label(entry, cell)
230
+ if entry.label_key
231
+ cell[:row].to_h.transform_keys(&:to_s)[entry.label_key].to_s
232
+ else
233
+ value = cell[:value]
234
+ value == value.to_i ? value.to_i.to_s : value.to_s
235
+ end
236
+ end
237
+
238
+ # One x tick's label, through the slot's formatter when given.
239
+ # @api private
240
+ def x_tick_label(category)
241
+ formatter = x_axis_config&.tick_formatter
242
+ formatter ? formatter.call(category).to_s : category.to_s
243
+ end
244
+
245
+ # The horizontal layout's category labels (the Y side).
246
+ # @api private
247
+ def category_tick_label(category)
248
+ formatter = y_axis_config&.tick_formatter
249
+ formatter ? formatter.call(category).to_s : category.to_s
250
+ end
251
+
252
+ # One y tick's label, through the slot's formatter when given.
253
+ # @api private
254
+ def y_tick_label(tick)
255
+ formatter = y_axis_config&.tick_formatter
256
+ formatter ? formatter.call(tick).to_s : Geometry.js_number(tick.to_f)
257
+ end
258
+
259
+ # ChartFamily#svg_label's chart-type lead-in.
260
+ # @api private
261
+ def svg_label_prefix = "Bar chart"
262
+
263
+ # The embedded per-index geometry payload the tooltip controller
264
+ # reads.
265
+ # @api private
266
+ def coordinates_json
267
+ cartesian.coordinates.to_json
268
+ end
269
+
270
+ # -- live mode ---------------------------------------------------
271
+
272
+ # The chart type in the live spec.
273
+ # @api private
274
+ def live_type = :bar
275
+
276
+ # The series list serialized into the live spec.
277
+ # @api private
278
+ def live_series
279
+ series_entries.map { |e| { data_key: e.key, stack: e.stack }.compact }
280
+ end
281
+
282
+ # The axis config serialized into the live spec.
283
+ # @api private
284
+ def live_axes
285
+ axes = {}
286
+ if horizontal?
287
+ axes[:y] = { data_key: y_axis_config.data_key } if y_axis_config&.data_key
288
+ else
289
+ axes[:x] = { data_key: x_axis_config.data_key } if x_axis_config&.data_key
290
+ axes[:y] = { tick_count: y_axis_config.tick_count } if y_axis_config
291
+ end
292
+ axes
293
+ end
294
+
295
+ # The x scale kind the live renderer rebuilds.
296
+ # @api private
297
+ def live_x_scale_type = :band
298
+ # Whether the live frame renders a category axis.
299
+ # @api private
300
+ def live_category_axis? = horizontal? ? y_axis? : x_axis?
301
+
302
+ # Bar-specific geometry knobs merged into the frame envelope.
303
+ # @api private
304
+ def live_frame_extras
305
+ {
306
+ "barGap" => bar_gap,
307
+ "barCategoryGap" => bar_category_gap,
308
+ "series" => series_entries.to_h { |e| [e.key, { "radius" => e.radius }] }
309
+ }
310
+ end
311
+
312
+ private :cartesian, :bar_slots, :cells, :bar_path, :cell_fill, :active?
313
+ private :cell_label, :x_tick_label, :category_tick_label, :y_tick_label, :svg_label_prefix, :coordinates_json
314
+ private :live_type, :live_series, :live_axes, :live_x_scale_type, :live_category_axis?, :live_frame_extras
315
+ end
316
+ end
317
+ end
318
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ module BarChart
6
+ # The bar family (vertical) across its variants: default
7
+ # (radius 8), multiple, stacked (outer-edge radius arrays), negative
8
+ # (sign-colored cells + month labels), value labels, the active
9
+ # highlight, and the horizontal + mixed layouts.
10
+ class Preview < Poetry::Core::Preview::Base
11
+ DATA = AreaChart::Preview::DATA
12
+ ONE = AreaChart::Preview::ONE
13
+ TWO = AreaChart::Preview::TWO
14
+
15
+ NEGATIVE_DATA = [
16
+ { month: "January", visitors: 186 },
17
+ { month: "February", visitors: 205 },
18
+ { month: "March", visitors: -207 },
19
+ { month: "April", visitors: 173 },
20
+ { month: "May", visitors: -209 },
21
+ { month: "June", visitors: 214 }
22
+ ].freeze
23
+
24
+ VISITORS = { visitors: { label: "Visitors" } }.freeze
25
+
26
+ BROWSER_DATA = LineChart::Preview::BROWSER_DATA
27
+ BROWSER_CONFIG = LineChart::Preview::BROWSER_CONFIG
28
+
29
+ def default
30
+ render_component(data: DATA, config: ONE, id: "bar-default") do |chart|
31
+ chart.with_grid
32
+ chart.with_x_axis(data_key: :month, tick_formatter: ->(v) { v[0, 3] })
33
+ chart.with_bar(data_key: :desktop, radius: 8)
34
+ chart.with_tooltip(hide_label: true)
35
+ end
36
+ end
37
+
38
+ def multiple
39
+ render_component(data: DATA, config: TWO, id: "bar-multiple") do |chart|
40
+ chart.with_grid
41
+ chart.with_x_axis(data_key: :month, tick_formatter: ->(v) { v[0, 3] })
42
+ chart.with_bar(data_key: :desktop, radius: 4)
43
+ chart.with_bar(data_key: :mobile, radius: 4)
44
+ end
45
+ end
46
+
47
+ def stacked
48
+ render_component(data: DATA, config: TWO, id: "bar-stacked") do |chart|
49
+ chart.with_grid
50
+ chart.with_x_axis(data_key: :month, tick_formatter: ->(v) { v[0, 3] })
51
+ chart.with_bar(data_key: :desktop, stack: :a, radius: [0, 0, 4, 4])
52
+ chart.with_bar(data_key: :mobile, stack: :a, radius: [4, 4, 0, 0])
53
+ chart.with_legend
54
+ end
55
+ end
56
+
57
+ def negative
58
+ render_component(data: NEGATIVE_DATA, config: VISITORS, id: "bar-negative") do |chart|
59
+ chart.with_grid
60
+ chart.with_bar(data_key: :visitors, labels: true, label_key: :month,
61
+ cell_fill: ->(_row, value) { value.positive? ? "var(--chart-1)" : "var(--chart-2)" })
62
+ end
63
+ end
64
+
65
+ def label
66
+ render_component(data: DATA, config: ONE, id: "bar-label", margin: { top: 20 }) do |chart|
67
+ chart.with_grid
68
+ chart.with_x_axis(data_key: :month, tick_formatter: ->(v) { v[0, 3] })
69
+ chart.with_bar(data_key: :desktop, radius: 8, labels: true)
70
+ end
71
+ end
72
+
73
+ def active
74
+ render_component(data: BROWSER_DATA, config: BROWSER_CONFIG, id: "bar-active") do |chart|
75
+ chart.with_grid
76
+ chart.with_x_axis(data_key: :browser, tick_formatter: ->(v) { BROWSER_CONFIG[v.to_sym][:label] })
77
+ chart.with_bar(data_key: :visitors, radius: 8, color_key: :fill, active_index: 2)
78
+ end
79
+ end
80
+
81
+ def horizontal
82
+ render_component(data: DATA, config: ONE, id: "bar-horizontal",
83
+ orientation: :horizontal, margin: { left: -20 }) do |chart|
84
+ chart.with_y_axis(data_key: :month, tick_formatter: ->(v) { v[0, 3] }, tick_margin: 10)
85
+ chart.with_bar(data_key: :desktop, radius: 5)
86
+ end
87
+ end
88
+
89
+ def mixed
90
+ render_component(data: BROWSER_DATA, config: BROWSER_CONFIG, id: "bar-mixed",
91
+ orientation: :horizontal, margin: { left: 0 }) do |chart|
92
+ chart.with_y_axis(data_key: :browser, tick_margin: 10,
93
+ tick_formatter: ->(v) { BROWSER_CONFIG[v.to_sym][:label] })
94
+ chart.with_bar(data_key: :visitors, radius: 5, color_key: :fill)
95
+ end
96
+ end
97
+
98
+ # The full live-window surface in one frame - the client renderer
99
+ # (live:), the zoom drag, the brush strip, and a sync group: the
100
+ # wiring the stimulus contract holds to the DOM. No tick_formatter
101
+ # (lambdas cannot ride the live payload).
102
+ def live_window
103
+ render_component(data: DATA, config: ONE, id: "bar-live", live: true, zoom: true,
104
+ sync: "bar-demo") do |chart|
105
+ chart.with_grid
106
+ chart.with_x_axis(data_key: :month)
107
+ chart.with_bar(data_key: :desktop, radius: 8)
108
+ chart.with_tooltip(hide_label: true)
109
+ chart.with_brush
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ module BarChart
6
+ # The cartesian chrome on the shared cn-chart-* theme rules
7
+ # + the bar label look.
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 :grid_line, "cn-chart-grid-line"
14
+ element :tick, "cn-chart-tick"
15
+ element :cursor_line, "cn-chart-cursor-line"
16
+ element :cursor_band, "cn-chart-cursor-band"
17
+ element :reference_line, "cn-chart-reference-line"
18
+ element :reference_area, "cn-chart-reference-area"
19
+ element :reference_dot, "cn-chart-reference-dot"
20
+ element :error_bar, "cn-chart-error-bar"
21
+ element :brush_track, "cn-chart-brush-track"
22
+ element :brush_window, "cn-chart-brush-window"
23
+ element :brush_handle, "cn-chart-brush-handle"
24
+ element :label, "cn-chart-label"
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ # The bar geometry shared by BarChart and ComposedChart: the slot
6
+ # math dividing each category band (no explicit bar size - stacked
7
+ # bars share a slot, groups sit side by side inside the band), the
8
+ # per-corner rounded rect path, and the entrance origin. Hosts
9
+ # provide cartesian, data, horizontal?, fnum, bar_gap, and
10
+ # bar_category_gap.
11
+ # @api private
12
+ module BarMath
13
+ # One {offset, size} slot per stack group, laid out inside the
14
+ # category band.
15
+ def bar_slots_for(entries)
16
+ groups = entries.map(&:stack_or_self).uniq
17
+ band = cartesian.band_width
18
+ gap = bar_gap.to_f
19
+ trim = bar_percent_value(bar_category_gap, band)
20
+ gap = 0.0 if band - (2 * trim) - ((groups.length - 1) * gap) <= 0
21
+
22
+ size = (band - (2 * trim) - ((groups.length - 1) * gap)) / groups.length
23
+ size = Geometry.js_round(size).to_f if size > 1
24
+
25
+ groups.each_with_index.to_h do |group, i|
26
+ [group, { offset: trim + ((size + gap) * i), size: size }]
27
+ end
28
+ end
29
+
30
+ # One rect per category for a series: band position + slot offset
31
+ # along the category side, the value span on the other, normalized
32
+ # so width/height stay positive (negatives keep the zero edge).
33
+ def bar_cells(entry, slot)
34
+ points = cartesian.points(entry)
35
+
36
+ points.each_with_index.filter_map do |point, i|
37
+ next if point[:value].nan?
38
+
39
+ base = { index: i, value: point[:value], row: data[i] }
40
+ if horizontal?
41
+ left = [point[:x0], point[:x1]].min
42
+ right = [point[:x0], point[:x1]].max
43
+ base.merge(x: left, y: cartesian.x_positions[i] + slot[:offset],
44
+ width: right - left, height: slot[:size])
45
+ else
46
+ top = [point[:y0], point[:y1]].min
47
+ bottom = [point[:y0], point[:y1]].max
48
+ base.merge(x: cartesian.x_positions[i] + slot[:offset], y: top,
49
+ width: slot[:size], height: bottom - top)
50
+ end
51
+ end
52
+ end
53
+
54
+ # The zero edge the entrance animation grows from: sign x
55
+ # orientation - stacked segments grow from their own zero-side
56
+ # edge, not the axis baseline.
57
+ def motion_origin(cell)
58
+ if horizontal?
59
+ cell[:value].negative? ? "right" : "left"
60
+ else
61
+ cell[:value].negative? ? "top" : "bottom"
62
+ end
63
+ end
64
+
65
+ # radius: Integer (all corners) or [tl, tr, br, bl] (the stacked
66
+ # blocks). Clamped to half the rect so corners never cross.
67
+ def bar_path_for(radius, cell)
68
+ radii = radius.is_a?(Array) ? radius.map(&:to_f) : Array.new(4, radius.to_f)
69
+ max = [cell[:width] / 2.0, cell[:height] / 2.0].min
70
+ tl, tr, br, bl = radii.map { |r| r.clamp(0.0, max) }
71
+ x = cell[:x]
72
+ y = cell[:y]
73
+ w = cell[:width]
74
+ h = cell[:height]
75
+ f = ->(v) { fnum(v) }
76
+
77
+ "M#{f.call(x)},#{f.call(y + tl)}" \
78
+ "#{"A#{f.call(tl)},#{f.call(tl)},0,0,1,#{f.call(x + tl)},#{f.call(y)}" if tl.positive?}" \
79
+ "L#{f.call(x + w - tr)},#{f.call(y)}" \
80
+ "#{"A#{f.call(tr)},#{f.call(tr)},0,0,1,#{f.call(x + w)},#{f.call(y + tr)}" if tr.positive?}" \
81
+ "L#{f.call(x + w)},#{f.call(y + h - br)}" \
82
+ "#{"A#{f.call(br)},#{f.call(br)},0,0,1,#{f.call(x + w - br)},#{f.call(y + h)}" if br.positive?}" \
83
+ "L#{f.call(x + bl)},#{f.call(y + h)}" \
84
+ "#{"A#{f.call(bl)},#{f.call(bl)},0,0,1,#{f.call(x)},#{f.call(y + h - bl)}" if bl.positive?}Z"
85
+ end
86
+
87
+ # Percent-or-pixels: "10%" resolves against the total, a bare
88
+ # number is pixels.
89
+ def bar_percent_value(value, total)
90
+ text = value.to_s
91
+ text.end_with?("%") ? total * (text.to_f / 100.0) : text.to_f
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ # The shared cartesian slot grammar (area/line/bar/composed): the
6
+ # axis/grid/legend/tooltip slots plus the readers that force their
7
+ # lazy evaluation. Lambda slots accumulate config into ivars and
8
+ # return nil (the breadcrumb pattern - slot wrappers do not delegate
9
+ # to lambda return values); readers force evaluation via the slot
10
+ # predicate (slots evaluate lazily).
11
+ #
12
+ # Slots register at the include site because declaration order IS the
13
+ # registry's slot order - include this AFTER the family's mark slots.
14
+ # The value axis is a class-method hook: a family whose Y side
15
+ # diverges (bar - the horizontal orientation moves the category axis
16
+ # there) defines its own value_axis_slot before including.
17
+ #
18
+ # @example Adding the cartesian slot grammar to a family
19
+ # class StepChart::Component < Poetry::Core::Component
20
+ # include Poetry::Charts::ChartFamily
21
+ #
22
+ # # Mark slots first - declaration order is the registry's slot order.
23
+ # renders_many :steps, lambda { |data_key:| ... }
24
+ #
25
+ # include Poetry::Charts::CartesianFamily
26
+ # end
27
+ module CartesianFamily
28
+ def self.included(base)
29
+ base.extend(ClassMethods)
30
+
31
+ # The category axis: data_key: names the field ticks label;
32
+ # tick_formatter: reshapes each label; tick_margin: pads it from
33
+ # the plot.
34
+ base.renders_one :x_axis, lambda { |data_key:, tick_formatter: nil, tick_margin: 8|
35
+ @x_axis_config = AxisConfig.new(data_key: data_key.to_s, tick_formatter:, tick_margin:, tick_count: nil)
36
+ nil
37
+ }
38
+
39
+ base.value_axis_slot
40
+
41
+ # The gridlines: horizontal rules by default; vertical: true adds
42
+ # column rules.
43
+ base.renders_one :grid, lambda { |vertical: false, horizontal: true|
44
+ @grid_config = GridConfig.new(vertical:, horizontal:)
45
+ nil
46
+ }
47
+
48
+ # The legend row under the plot: align:, items:, hide_icon:, and
49
+ # toggle: (live charts only - clicking an entry hides its series).
50
+ base.renders_one :legend, lambda { |**options|
51
+ @legend_config = options
52
+ nil
53
+ }
54
+
55
+ # The hover tooltip: indicator:, hide_label:, hide_indicator:, and
56
+ # cursor: (the hover rule/band, on by default). The slot captures
57
+ # options only; the tooltip layer itself (controller + chrome
58
+ # wiring) is declared by TooltipWiring.
59
+ base.renders_one :tooltip, lambda { |**options|
60
+ @tooltip_config = options
61
+ nil
62
+ }
63
+ end
64
+
65
+ # The class-level hooks the include site drives.
66
+ module ClassMethods
67
+ # Declares the default numeric Y axis slot (tick count 3, no data
68
+ # key). Families whose value axis diverges override this before
69
+ # including the concern.
70
+ def value_axis_slot
71
+ renders_one :y_axis,
72
+ doc: "The value axis: tick_count: sets how many ticks show; tick_formatter: reshapes each " \
73
+ "label; tick_margin: pads it.",
74
+ renders: lambda { |tick_count: 3, tick_formatter: nil, tick_margin: 8|
75
+ @y_axis_config = AxisConfig.new(data_key: nil, tick_formatter:, tick_margin:, tick_count:)
76
+ nil
77
+ }
78
+ end
79
+ end
80
+
81
+ # The x-axis slot's captured config, forcing lazy slot evaluation.
82
+ # @api private
83
+ def x_axis_config
84
+ x_axis?
85
+ @x_axis_config
86
+ end
87
+
88
+ # The y-axis slot's captured config, forcing lazy slot evaluation.
89
+ # @api private
90
+ def y_axis_config
91
+ y_axis?
92
+ @y_axis_config
93
+ end
94
+
95
+ # The grid slot's captured config, forcing lazy slot evaluation.
96
+ # @api private
97
+ def grid_config
98
+ grid?
99
+ @grid_config
100
+ end
101
+
102
+ private :x_axis_config, :y_axis_config, :grid_config
103
+ end
104
+ end
105
+ end