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,100 @@
1
+ {
2
+ "poetry--charts--tooltip": {
3
+ "targets": [
4
+ "data",
5
+ "svg",
6
+ "tooltip"
7
+ ],
8
+ "values": {
9
+ "sync": {
10
+ "type": "String"
11
+ }
12
+ },
13
+ "methods": [
14
+ "blur",
15
+ "connect",
16
+ "count",
17
+ "disconnect",
18
+ "enter",
19
+ "focus",
20
+ "keydown",
21
+ "leave",
22
+ "move",
23
+ "refresh",
24
+ "show"
25
+ ],
26
+ "events": [
27
+ "poetry--charts--tooltip:hide",
28
+ "poetry--charts--tooltip:show"
29
+ ]
30
+ },
31
+ "poetry--charts--adapter": {
32
+ "targets": [
33
+ "mount",
34
+ "spec"
35
+ ],
36
+ "values": {
37
+ "engine": {
38
+ "type": "String"
39
+ }
40
+ },
41
+ "methods": [
42
+ "connect",
43
+ "disconnect"
44
+ ],
45
+ "events": [
46
+ "poetry--charts--adapter:rendered"
47
+ ]
48
+ },
49
+ "poetry--charts--motion": {
50
+ "targets": [],
51
+ "values": {},
52
+ "methods": [
53
+ "connect",
54
+ "disconnect"
55
+ ],
56
+ "events": [
57
+ "poetry--charts--motion:settled"
58
+ ]
59
+ },
60
+ "poetry--charts--live": {
61
+ "targets": [
62
+ "payload"
63
+ ],
64
+ "values": {},
65
+ "methods": [
66
+ "connect",
67
+ "disconnect",
68
+ "receive",
69
+ "setWindow",
70
+ "toggleSeries",
71
+ "update"
72
+ ],
73
+ "events": [
74
+ "poetry--charts--live:settled",
75
+ "poetry--charts--live:updated"
76
+ ]
77
+ },
78
+ "poetry--charts--window": {
79
+ "targets": [],
80
+ "values": {
81
+ "zoom": {
82
+ "type": "Boolean"
83
+ },
84
+ "plot": {
85
+ "type": "Array"
86
+ },
87
+ "brush": {
88
+ "type": "Array"
89
+ }
90
+ },
91
+ "methods": [
92
+ "connect",
93
+ "disconnect",
94
+ "reset",
95
+ "startBrush",
96
+ "startZoom"
97
+ ],
98
+ "events": []
99
+ }
100
+ }
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # poetry-charts' importmap pins (the importmap-first channel): the
4
+ # host app's importmap merges these, so the tooltip chrome runs with zero
5
+ # build. Bundler hosts use the @poetry/charts npm package instead - one
6
+ # source, two channels.
7
+
8
+ pin "@poetry/charts", to: "poetry/charts/index.js"
9
+ pin_all_from File.expand_path("../app/javascript/poetry/charts", __dir__),
10
+ under: "@poetry/charts", to: "poetry/charts"
@@ -0,0 +1,277 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ # The server-side cartesian layout pipeline: data + series ->
6
+ # plot rectangle, scales, ticks, and per-series pixel points -
7
+ # computed top-down in one pass, everything a renderer needs before
8
+ # writing a path.
9
+ #
10
+ # Layout conventions: default margin 5 on every side, x-axis strip
11
+ # height 30 at the bottom when shown, category x positions from a
12
+ # zero-padding point scale (first/last categories AT the plot
13
+ # edges - add left/right margin 12 when edge labels need room),
14
+ # numeric y domain [0, auto] niced decimal-exactly (implicit tick
15
+ # count 5), stacking per stack id with :none/:expand offsets.
16
+ #
17
+ # @example Lay out a plot and read one series' pixel points
18
+ # plot = Poetry::Charts::Cartesian.new(
19
+ # data: rows, series: series, width: 600, height: 300
20
+ # )
21
+ # plot.points(series.first) # => [{x:, y0:, y1:, value:}, ...]
22
+ class Cartesian
23
+ # The default plot margin - a slim, even inset on all sides.
24
+ DEFAULT_MARGIN = { top: 5, right: 5, bottom: 5, left: 5 }.freeze
25
+ # The bottom strip reserved for the category axis when shown.
26
+ X_AXIS_HEIGHT = 30
27
+ # The reserved left strip - where a visible value axis's labels
28
+ # (or the horizontal layout's category labels) live.
29
+ Y_AXIS_WIDTH = 60
30
+
31
+ # The stack baseline modes: raw values, or 100%-normalized.
32
+ OFFSETS = %i[none expand].freeze
33
+ # The chart orientations: :vertical columns, or :horizontal bars.
34
+ LAYOUTS = %i[vertical horizontal].freeze
35
+
36
+ attr_reader :width, :height, :margin, :y_tick_count, :offset, :layout
37
+
38
+ # @param data [Array<Hash>] the rows, one hash per category
39
+ # @param series [Array<#key>] series entries (key + optional stack id)
40
+ # @param width [Numeric] outer SVG width in pixels
41
+ # @param height [Numeric] outer SVG height in pixels
42
+ # @param x_key [String, Symbol, nil] the category key; nil indexes rows
43
+ # @param margin [Hash] per-side overrides merged over DEFAULT_MARGIN
44
+ # @param category_axis [Boolean] reserve the category-axis strip
45
+ # @param value_axis [Boolean] a visible value axis reserves the left strip
46
+ # @param y_tick_count [Integer] requested tick count for the niced domain
47
+ # @param offset [Symbol] :none, or :expand for 100%-stacked
48
+ # @param x_scale_type [Symbol] :point (line/area) or :band (bars)
49
+ # @param layout [Symbol] :vertical (values up y) or :horizontal
50
+ def initialize(data:, series:, width:, height:, x_key: nil, margin: {},
51
+ category_axis: true, value_axis: false, y_tick_count: 5, offset: :none,
52
+ x_scale_type: :point, layout: :vertical)
53
+ @data = data.map { |row| row.to_h.transform_keys(&:to_s) }
54
+ @series = series
55
+ @width = width.to_f
56
+ @height = height.to_f
57
+ @x_key = x_key&.to_s
58
+ @margin = DEFAULT_MARGIN.merge(margin.to_h.symbolize_keys)
59
+ @category_axis = category_axis
60
+ @value_axis = value_axis
61
+ @y_tick_count = y_tick_count
62
+ @x_scale_type = x_scale_type.to_sym
63
+ @layout = layout.to_sym
64
+ raise ArgumentError, "unknown layout #{layout.inspect}" unless LAYOUTS.include?(@layout)
65
+ raise ArgumentError, "unknown offset #{offset.inspect}" unless OFFSETS.include?(offset.to_sym)
66
+
67
+ @offset = offset.to_sym
68
+ end
69
+
70
+ # Whether categories run down the Y side (bars growing rightward).
71
+ def horizontal?
72
+ layout == :horizontal
73
+ end
74
+
75
+ # -- the plot rectangle ---------------------------------------------------
76
+ # The category-axis strip sits at the bottom (vertical layout, height
77
+ # 30) or the left (horizontal layout, width 60). A VISIBLE value
78
+ # axis in the vertical layout reserves the same left strip - without
79
+ # it, shown y labels would clip at the plot edge.
80
+
81
+ # The plot rect's left edge, inset for a reserved left strip.
82
+ def plot_left
83
+ reserved = (horizontal? && @category_axis) || (!horizontal? && @value_axis)
84
+ margin[:left].to_f + (reserved ? Y_AXIS_WIDTH : 0)
85
+ end
86
+
87
+ # The plot rect's right edge.
88
+ def plot_right = width - margin[:right]
89
+ # The plot rect's top edge.
90
+ def plot_top = margin[:top].to_f
91
+
92
+ # The plot rect's bottom edge, inset for the category axis strip.
93
+ def plot_bottom
94
+ height - margin[:bottom] - (!horizontal? && @category_axis ? X_AXIS_HEIGHT : 0)
95
+ end
96
+
97
+ # -- x: categories on a point scale ---------------------------------------
98
+
99
+ # The category values: the x_key column, else bare row indexes.
100
+ def categories
101
+ @categories ||= @x_key ? @data.map { |row| row[@x_key] } : (0...@data.length).to_a
102
+ end
103
+
104
+ # Point for line/area (categories AT the edges); band for bars
105
+ # (a zero-padding band - the bar gaps come from the gap math inside
106
+ # the band, not scale padding). Horizontal layout runs the category
107
+ # scale down the Y side.
108
+ def category_range
109
+ horizontal? ? [plot_top, plot_bottom] : [plot_left, plot_right]
110
+ end
111
+
112
+ # The category scale over the category range (point or band).
113
+ def x_scale
114
+ @x_scale ||= if @x_scale_type == :band
115
+ Geometry::Scale::Band.new(domain: categories, range: category_range)
116
+ else
117
+ Geometry::Scale::Point.new(domain: categories, range: category_range)
118
+ end
119
+ end
120
+
121
+ # Each category's scale position (a band's leading edge).
122
+ def x_positions
123
+ x_scale.positions
124
+ end
125
+
126
+ # One category band's width (0 on a point scale).
127
+ def band_width
128
+ x_scale.bandwidth
129
+ end
130
+
131
+ # The per-category CENTER - where ticks, vertical grid lines, and the
132
+ # tooltip's hit columns sit (band centers; point positions verbatim).
133
+ def x_centers
134
+ @x_centers ||= if @x_scale_type == :band
135
+ half = band_width / 2.0
136
+ x_positions.map { |x| x + half }
137
+ else
138
+ x_positions
139
+ end
140
+ end
141
+
142
+ # -- y: numeric domain, auto-niced -----------------------------------------
143
+
144
+ # The niced value ticks ([0, 1] when 100%-stacked).
145
+ def y_ticks
146
+ @y_ticks ||= if offset == :expand
147
+ Geometry::NiceTicks.nice_ticks([0, 1], y_tick_count)
148
+ else
149
+ Geometry::NiceTicks.nice_ticks(raw_domain, y_tick_count)
150
+ end
151
+ end
152
+
153
+ # The niced value domain - the first and last tick.
154
+ def y_domain
155
+ [y_ticks.first, y_ticks.last]
156
+ end
157
+
158
+ # The value scale: y in the vertical layout (inverted - SVG y grows
159
+ # down), x in the horizontal one.
160
+ def y_scale
161
+ @y_scale ||= Geometry::Scale::Linear.new(
162
+ domain: y_domain,
163
+ range: horizontal? ? [plot_left, plot_right] : [plot_bottom, plot_top]
164
+ )
165
+ end
166
+
167
+ # The area baseline: the y pixel of 0, clamped into the domain so
168
+ # an all-positive domain pins the baseline at its bottom edge.
169
+ def baseline
170
+ y_scale.call(0.0.clamp(y_domain.first, y_domain.last))
171
+ end
172
+
173
+ # -- per-series points -----------------------------------------------------
174
+
175
+ # [{x:, y0:, y1:, value:}] for one series entry - stacked entries ride
176
+ # their stack group's offsets; independent entries base on the
177
+ # baseline. NaN values (missing data) flow through as NaN, which the
178
+ # generators' defined-gap machinery turns into path gaps.
179
+ #
180
+ # @param entry [#key] a series entry (key + optional stack id)
181
+ # @return [Array<Hash>] one orientation-aware point hash per category
182
+ def points(entry)
183
+ key = entry.key
184
+ if entry.stack
185
+ bands = stacked_bands(entry.stack)[key]
186
+ bands.each_with_index.map do |(lo, hi), i|
187
+ point_for(i, key, y_scale.call(lo), y_scale.call(hi))
188
+ end
189
+ else
190
+ @data.each_with_index.map do |_row, i|
191
+ point_for(i, key, baseline, y_scale.call(value_at(i, key)))
192
+ end
193
+ end
194
+ end
195
+
196
+ # -- the tooltip handoff: the embedded coordinates -------------------------
197
+
198
+ # Compact per-series pixel coordinates the tooltip controller reads -
199
+ # no chart math in the browser.
200
+ def coordinates
201
+ top_key = horizontal? ? :x1 : :y1
202
+ band = ({ "x" => x_positions.map { |x| x.round(2) }, "width" => band_width.round(2) } if @x_scale_type == :band)
203
+ {
204
+ "layout" => layout.to_s,
205
+ "categories" => categories,
206
+ # The category-center axis the tooltip bisects along: x in the
207
+ # vertical layout, y in the horizontal one.
208
+ (horizontal? ? "y" : "x") => x_centers.map { |c| c.round(2) },
209
+ "series" => @series.to_h do |entry|
210
+ tops = points(entry).map { |p| p[top_key].nan? ? nil : p[top_key].round(2) }
211
+ [entry.key, tops]
212
+ end,
213
+ # PRE-FORMATTED display strings (from the RAW row values, so
214
+ # integers stay integers) - the controller never formats.
215
+ "values" => @series.to_h do |entry|
216
+ [entry.key, @data.map { |row| display_value(row[entry.key]) }]
217
+ end
218
+ }.merge(band ? { "band" => band } : {})
219
+ end
220
+
221
+ private
222
+
223
+ # Orientation-aware point: vertical = {x, y0, y1}; horizontal =
224
+ # {y, x0, x1} (values run along x, categories down y).
225
+ def point_for(index, key, base_px, top_px)
226
+ value = value_at(index, key)
227
+ if horizontal?
228
+ { y: x_centers[index], x0: base_px, x1: top_px, value: value }
229
+ else
230
+ { x: x_centers[index], y0: base_px, y1: top_px, value: value }
231
+ end
232
+ end
233
+
234
+ def value_at(index, key)
235
+ value = @data[index][key]
236
+ value.nil? ? Float::NAN : value.to_f
237
+ end
238
+
239
+ def display_value(value)
240
+ Poetry::Charts.display_value(value)
241
+ end
242
+
243
+ # Stack bands per stack id, memoized: { key => [[lo, hi], ...] }.
244
+ def stacked_bands(stack_id)
245
+ @stacked_bands ||= {}
246
+ @stacked_bands[stack_id] ||= begin
247
+ keys = @series.select { |entry| entry.stack == stack_id }.map(&:key)
248
+ stack = Geometry::Stack.new(keys: keys, offset: offset == :expand ? :expand : :none)
249
+ stack.series(@data).to_h { |series| [series.key, series.points] }
250
+ end
251
+ end
252
+
253
+ # The raw numeric domain before nicing: [min(0, data), max(data)] -
254
+ # the default [0, auto] domain, stacked series contributing their
255
+ # cumulative tops.
256
+ def raw_domain
257
+ values = []
258
+ @series.each do |entry|
259
+ if entry.stack
260
+ stacked_bands(entry.stack)[entry.key].each do |lo, hi|
261
+ values << lo unless lo.nan?
262
+ values << hi unless hi.nan?
263
+ end
264
+ else
265
+ @data.each_index do |i|
266
+ value = value_at(i, entry.key)
267
+ values << value unless value.nan?
268
+ end
269
+ end
270
+ end
271
+ return [0, 1] if values.empty?
272
+
273
+ [[0, values.min].min, values.max]
274
+ end
275
+ end
276
+ end
277
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ # The chart config contract:
6
+ # series key -> { label:, icon:, color: } or { label:, icon:, theme:
7
+ # { light:, dark: } }. The config is the SINGLE place series get their
8
+ # human labels and colors; the container's <style> emission, the tooltip
9
+ # chrome, and the legend chrome all resolve through it.
10
+ #
11
+ # Values land inside a <style> element and inline style attributes, so
12
+ # every color (and key) is validated against a conservative character
13
+ # set at wrap time - a config can never smuggle CSS out of its block.
14
+ #
15
+ # @example
16
+ # config = Poetry::Charts::Config.new(
17
+ # revenue: { label: "Revenue", color: "var(--chart-1)" }
18
+ # )
19
+ # config.label_for("revenue") # => "Revenue"
20
+ class Config
21
+ # A series key becomes a --color-<key> custom property.
22
+ KEY = /\A[a-zA-Z][a-zA-Z0-9_-]*\z/
23
+ # Any CSS color form (var(--chart-1), #hex, oklch(...), named...) minus
24
+ # everything that could terminate a declaration or escape the block.
25
+ COLOR = /\A[^{};<>&"']+\z/
26
+
27
+ # The theme map's allowed modes.
28
+ THEMES = %i[light dark].freeze
29
+
30
+ # One validated config entry: a series key with its label, icon,
31
+ # and flat or themed color.
32
+ Entry = Data.define(:key, :label, :icon, :color, :theme) do
33
+ # The per-theme color: the flat color, or the theme map's value.
34
+ def color_for(theme_name)
35
+ theme ? theme[theme_name] : color
36
+ end
37
+
38
+ # Whether the entry carries any color (flat or themed).
39
+ def colored?
40
+ !!(color || theme)
41
+ end
42
+ end
43
+
44
+ class << self
45
+ # Accepts a Config (pass-through) or a Hash keyed by series name.
46
+ #
47
+ # @param source [Config, Hash, nil]
48
+ # @return [Config]
49
+ def wrap(source)
50
+ source.is_a?(Config) ? source : new(source || {})
51
+ end
52
+ end
53
+
54
+ attr_reader :entries
55
+
56
+ # Builds and validates entries from a Hash keyed by series name;
57
+ # a CSS-unsafe key or color raises here, at wrap time.
58
+ #
59
+ # @param hash [Hash]
60
+ def initialize(hash)
61
+ raise ArgumentError, "chart config must be a Hash, got #{hash.class}" unless hash.is_a?(Hash)
62
+
63
+ @entries = hash.map { |key, value| build_entry(key, value || {}) }
64
+ end
65
+
66
+ # The entry for a series key.
67
+ #
68
+ # @param key [String, Symbol]
69
+ # @return [Entry, nil]
70
+ def [](key)
71
+ key = key.to_s
72
+ entries.find { |entry| entry.key == key }
73
+ end
74
+
75
+ # Every series key, in config order.
76
+ #
77
+ # @return [Array<String>]
78
+ def keys
79
+ entries.map(&:key)
80
+ end
81
+
82
+ # The entries that carry a color (flat or themed) - the set the
83
+ # container's <style> emission covers, in config order.
84
+ def color_entries
85
+ entries.select(&:colored?)
86
+ end
87
+
88
+ # The label for a series key, falling back to the key itself - the
89
+ # tooltip/legend resolution rule.
90
+ #
91
+ # @param key [String, Symbol] the series key
92
+ # @param fallback [String, nil] preferred over the key when no label is set
93
+ # @return [String]
94
+ def label_for(key, fallback = nil)
95
+ self[key]&.label || fallback || key.to_s
96
+ end
97
+
98
+ # The config back as a plain Hash (label/color/theme per key),
99
+ # compacted.
100
+ #
101
+ # @return [Hash]
102
+ def to_h
103
+ entries.to_h do |entry|
104
+ value = { label: entry.label }.compact
105
+ value[:color] = entry.color if entry.color
106
+ value[:theme] = entry.theme if entry.theme
107
+ [entry.key, value]
108
+ end
109
+ end
110
+
111
+ private
112
+
113
+ def build_entry(key, value)
114
+ key = key.to_s
115
+ raise ArgumentError, "chart config key #{key.inspect} is not CSS-safe" unless key.match?(KEY)
116
+
117
+ value = value.symbolize_keys
118
+ color = validate_color!(key, value[:color])
119
+ theme = validate_theme!(key, value[:theme])
120
+ raise ArgumentError, "chart config #{key.inspect} sets both color: and theme:" if color && theme
121
+
122
+ Entry.new(key:, label: value[:label], icon: value[:icon], color:, theme:)
123
+ end
124
+
125
+ def validate_color!(key, color)
126
+ return nil if color.nil?
127
+
128
+ color = color.to_s
129
+ unless color.match?(COLOR)
130
+ raise ArgumentError,
131
+ "chart config #{key.inspect} color #{color.inspect} is not CSS-safe"
132
+ end
133
+
134
+ color
135
+ end
136
+
137
+ def validate_theme!(key, theme)
138
+ return nil if theme.nil?
139
+ raise ArgumentError, "chart config #{key.inspect} theme must be a Hash" unless theme.is_a?(Hash)
140
+
141
+ theme = theme.symbolize_keys
142
+ unknown = theme.keys - THEMES
143
+ raise ArgumentError, "chart config #{key.inspect} theme has unknown modes #{unknown.inspect}" if unknown.any?
144
+
145
+ theme.to_h { |mode, color| [mode, validate_color!(key, color)] }
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+
5
+ module Poetry
6
+ module Charts
7
+ # The Rails engine: wires poetry-charts into the host app - component
8
+ # autoload paths, the Stimulus controllers manifest, the view helper,
9
+ # preview and asset paths, and the importmap pins. Loading the gem is
10
+ # the only integration step.
11
+ #
12
+ # @example Install via the host Gemfile
13
+ # gem "poetry-charts"
14
+ class Engine < ::Rails::Engine
15
+ # Standard engine layout: app/components is picked up like poetry-ui's.
16
+ config.autoload_paths << "#{Poetry::Charts.root}/app/components"
17
+ config.eager_load_paths << "#{Poetry::Charts.root}/app/components"
18
+
19
+ # The charts controllers manifest joins core's catalog, so the
20
+ # Builder and use_stimulus declarations validate poetry--charts--*
21
+ # names exactly like core's own (runs before components load -
22
+ # declarations validate at class-load time).
23
+ initializer "poetry_charts.controllers_manifest", before: :eager_load! do
24
+ Poetry::Core::Stimulus::Manifest.register(
25
+ "#{Poetry::Charts.root}/config/controllers_manifest.json"
26
+ )
27
+ end
28
+
29
+ # ComponentsHelper lives in app/helpers (Zeitwerk-autoloaded), so it is
30
+ # NOT loaded at gem-require time. A host that forces ActionView to load
31
+ # during initialization (an editor gem patching a view helper at
32
+ # require time) fires the on_load(:action_view) hook right here, before
33
+ # the engine's app/helpers constant is resolvable - NameError at boot.
34
+ # Registering the hook from a to_prepare block runs it only once the
35
+ # autoload paths are wired (and re-runs safely on reload); the same fix
36
+ # as poetry-ui's. poetry-core's TagHelper avoids it only because it
37
+ # lives in lib/ and is required eagerly. EngineBootTest guards this.
38
+ initializer "poetry_charts.helpers" do |app|
39
+ app.config.to_prepare do
40
+ ActiveSupport.on_load(:action_view) do
41
+ include Poetry::Charts::ComponentsHelper
42
+ end
43
+ end
44
+ end
45
+
46
+ initializer "poetry_charts.previews" do |app|
47
+ if app.config.respond_to?(:view_component)
48
+ app.config.view_component.previews.paths << "#{Poetry::Charts.root}/app/components"
49
+ end
50
+ end
51
+
52
+ # Lookbook is a dev-only dependency; guard so the engine never crashes
53
+ # a production (or lean test) host that does not load it (the
54
+ # poetry-core pattern).
55
+ initializer "poetry_charts.setup_lookbook" do |app|
56
+ app.config.lookbook.preview_paths << "#{Poetry::Charts.root}/app/components" if defined?(Lookbook)
57
+ end
58
+
59
+ # Make the client chrome + the motion stylesheet servable by Propshaft.
60
+ initializer "poetry_charts.assets" do |app|
61
+ if app.config.respond_to?(:assets)
62
+ app.config.assets.paths << Poetry::Charts.root.join("app/javascript").to_s
63
+ app.config.assets.paths << Poetry::Charts.root.join("app/assets/stylesheets").to_s
64
+ end
65
+ end
66
+
67
+ # The importmap-first JS channel (the poetry-core shape).
68
+ initializer "poetry_charts.importmap", before: "importmap" do |app|
69
+ if app.config.respond_to?(:importmap)
70
+ app.config.importmap.paths << Poetry::Charts.root.join("config/importmap.rb")
71
+ app.config.importmap.cache_sweepers << Poetry::Charts.root.join("app/javascript")
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ module Geometry
6
+ # The area generator: the filled band between a top line (x/x1, y1)
7
+ # and a baseline (x0, y0), walked forward along the top and
8
+ # BACKWARD along the buffered baseline per defined-segment (the
9
+ # x0z/y0z buffers). Stacked areas feed y0/y1 from Stack series;
10
+ # simple areas use a constant y0 (the axis line).
11
+ #
12
+ # @example A simple area over a constant baseline
13
+ # Poetry::Charts::Geometry::Area.new(y0: 250.0).path(points)
14
+ class Area
15
+ def initialize(x: nil, x1: nil, y0: nil, y1: nil, curve: :linear, defined: nil, digits: 3)
16
+ @x0 = Line::Accessor.wrap(x) { |d, _i| d[0] }
17
+ @x1 = x1.nil? ? nil : Line::Accessor.wrap(x1) { |d, _i| d[0] }
18
+ @y0 = Line::Accessor.wrap(y0) { |_d, _i| 0.0 }
19
+ @y1 = y1.nil? ? nil : Line::Accessor.wrap(y1) { |d, _i| d[1] }
20
+ @curve = curve
21
+ @digits = digits
22
+ @defined = Line::Accessor.wrap(defined) { |_d, _i| true }
23
+ end
24
+
25
+ # The SVG path for the data (nil when nothing was defined).
26
+ #
27
+ # @param data [Enumerable]
28
+ # @return [String, nil]
29
+ def path(data)
30
+ data = data.to_a
31
+ n = data.length
32
+ buffer = Path.new(digits: @digits)
33
+ output = Curve.build(@curve, buffer)
34
+ defined0 = false
35
+ x0z = Array.new(n)
36
+ y0z = Array.new(n)
37
+ j = 0
38
+
39
+ (0..n).each do |i|
40
+ d = i < n ? data[i] : nil
41
+ if (!(i < n && @defined.call(d, i))) == defined0
42
+ defined0 = !defined0
43
+ if defined0
44
+ j = i
45
+ output.area_start
46
+ output.line_start
47
+ else
48
+ output.line_end
49
+ output.line_start
50
+ (i - 1).downto(j) { |k| output.point(x0z[k], y0z[k]) }
51
+ output.line_end
52
+ output.area_end
53
+ end
54
+ end
55
+ next unless defined0
56
+
57
+ x0z[i] = @x0.call(d, i).to_f
58
+ y0z[i] = @y0.call(d, i).to_f
59
+ output.point(@x1 ? @x1.call(d, i).to_f : x0z[i],
60
+ @y1 ? @y1.call(d, i).to_f : y0z[i])
61
+ end
62
+
63
+ buffer.empty? ? nil : buffer.to_s
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end