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,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ module LineChart
6
+ # The line family across its variants: default (natural),
7
+ # linear, step, multiple series, dots, per-point dot colors, and
8
+ # point labels. Shared sample data.
9
+ class Preview < Poetry::Core::Preview::Base
10
+ DATA = AreaChart::Preview::DATA
11
+ ONE = AreaChart::Preview::ONE
12
+ TWO = AreaChart::Preview::TWO
13
+ MARGIN = AreaChart::Preview::MARGIN
14
+
15
+ BROWSER_CONFIG = {
16
+ visitors: { label: "Visitors", color: "var(--chart-2)" },
17
+ chrome: { label: "Chrome", color: "var(--chart-1)" },
18
+ safari: { label: "Safari", color: "var(--chart-2)" },
19
+ firefox: { label: "Firefox", color: "var(--chart-3)" },
20
+ edge: { label: "Edge", color: "var(--chart-4)" },
21
+ other: { label: "Other", color: "var(--chart-5)" }
22
+ }.freeze
23
+
24
+ BROWSER_DATA = [
25
+ { browser: "chrome", visitors: 275, fill: "var(--color-chrome)" },
26
+ { browser: "safari", visitors: 200, fill: "var(--color-safari)" },
27
+ { browser: "firefox", visitors: 187, fill: "var(--color-firefox)" },
28
+ { browser: "edge", visitors: 173, fill: "var(--color-edge)" },
29
+ { browser: "other", visitors: 90, fill: "var(--color-other)" }
30
+ ].freeze
31
+
32
+ def default
33
+ simple_chart(id: "line-default", tooltip: true)
34
+ end
35
+
36
+ def linear
37
+ simple_chart(id: "line-linear", curve: :linear)
38
+ end
39
+
40
+ def step
41
+ simple_chart(id: "line-step", curve: :step)
42
+ end
43
+
44
+ def multiple
45
+ render_component(data: DATA, config: TWO, id: "line-multiple", margin: MARGIN) do |chart|
46
+ chart.with_grid
47
+ chart.with_x_axis(data_key: :month, tick_formatter: ->(v) { v[0, 3] })
48
+ chart.with_line(data_key: :desktop)
49
+ chart.with_line(data_key: :mobile)
50
+ end
51
+ end
52
+
53
+ def dots
54
+ simple_chart(id: "line-dots", dots: true)
55
+ end
56
+
57
+ def dots_colors
58
+ render_component(data: BROWSER_DATA, config: BROWSER_CONFIG, id: "line-dots-colors",
59
+ margin: { top: 24, left: 24, right: 24 }) do |chart|
60
+ chart.with_grid
61
+ chart.with_line(data_key: :visitors, dots: true, dot_radius: 5, dot_color_key: :fill)
62
+ end
63
+ end
64
+
65
+ def label
66
+ render_component(data: DATA, config: ONE, id: "line-label",
67
+ margin: { top: 20, left: 12, right: 12 }) do |chart|
68
+ chart.with_grid
69
+ chart.with_x_axis(data_key: :month, tick_formatter: ->(v) { v[0, 3] })
70
+ chart.with_line(data_key: :desktop, dots: true, labels: true)
71
+ end
72
+ end
73
+
74
+ def references_and_errors
75
+ data = AreaChart::Preview::DATA.map.with_index do |row, i|
76
+ row.merge(err: [12 + (i * 3), 20 - i])
77
+ end
78
+ render_component(data: data, config: ONE, id: "line-refs",
79
+ margin: { left: 12, right: 12 }) do |chart|
80
+ chart.with_grid
81
+ chart.with_x_axis(data_key: :month, tick_formatter: ->(v) { v[0, 3] })
82
+ chart.with_line(data_key: :desktop, error_key: :err)
83
+ chart.with_reference_line(y: 204, label: "avg")
84
+ chart.with_reference_area(y1: 280, y2: 320, label: "goal")
85
+ chart.with_tooltip
86
+ end
87
+ end
88
+
89
+ # The full live-window surface in one frame (see BarChart::Preview#
90
+ # live_window) - no tick_formatter: lambdas cannot ride the payload.
91
+ def live_window
92
+ render_component(data: DATA, config: ONE, id: "line-live", margin: MARGIN, live: true,
93
+ zoom: true, sync: "line-demo") do |chart|
94
+ chart.with_grid
95
+ chart.with_x_axis(data_key: :month)
96
+ chart.with_line(data_key: :desktop)
97
+ chart.with_tooltip(hide_label: true)
98
+ chart.with_brush
99
+ end
100
+ end
101
+
102
+ private
103
+
104
+ def simple_chart(id:, curve: :natural, dots: false, tooltip: false)
105
+ render_component(data: DATA, config: ONE, id: id, margin: MARGIN) do |chart|
106
+ chart.with_grid
107
+ chart.with_x_axis(data_key: :month, tick_formatter: ->(v) { v[0, 3] })
108
+ chart.with_line(data_key: :desktop, curve: curve, dots: dots)
109
+ chart.with_tooltip(hide_label: true) if tooltip
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 LineChart
6
+ # The cartesian chrome on the shared cn-chart-* theme rules
7
+ # + the point-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,296 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ # Live mode: a chart that opts in with `live: true` embeds a
6
+ # {spec, frame} payload the client renderer recomputes geometry from
7
+ # when data changes too often to round-trip to the server. `spec` is
8
+ # the FROZEN spec v1 built by the same Poetry::Charts::Spec the
9
+ # adapter seam uses (the spec stays closed); `frame` is a PRIVATE engine
10
+ # envelope carrying the geometry-affecting knobs the spec deliberately
11
+ # omits. Everything else about the chart stays server-rendered.
12
+ #
13
+ # Live charts cannot carry Ruby lambdas to the browser: tick
14
+ # formatters and label slots raise a teaching error - format data
15
+ # host-side (pre-formatted category strings) instead.
16
+ module Live
17
+ # The live controller's full-string identifier.
18
+ CONTROLLER = "poetry--charts--live"
19
+
20
+ def self.included(base)
21
+ base.extend(ClassMethods)
22
+
23
+ # Declared after TooltipWiring's and Motion's frame wirings
24
+ # (include order IS emission order): live registration + the host
25
+ # update hook, the tooltip re-read on live's updated dispatch, and
26
+ # the window controller's server-computed rects. The SVG gains the
27
+ # zoom drag actions; the payload <script> is live's data target.
28
+ base.use_stimulus do
29
+ on :frame do
30
+ controller CONTROLLER, if: :live? do
31
+ register
32
+ action :receive, on: "poetry-chart:update"
33
+ end
34
+ controller TooltipWiring::CONTROLLER, if: -> { live? && tooltip? } do
35
+ action :refresh, on: event(CONTROLLER, :updated)
36
+ end
37
+ controller WINDOW_CONTROLLER, if: :window_features? do
38
+ register
39
+ value :zoom, from: :zoom?
40
+ value :plot, from: :window_plot_json
41
+ value :brush, from: :window_brush_json, if: -> { brush_config }
42
+ end
43
+ end
44
+ on :svg do
45
+ controller WINDOW_CONTROLLER, if: :zoom? do
46
+ action :startZoom, on: :pointerdown
47
+ action :reset, on: :dblclick
48
+ end
49
+ end
50
+ on :live_payload do
51
+ controller(CONTROLLER) { target :payload }
52
+ end
53
+ # Rendered by brush_svg via the stimulus_action descriptor -
54
+ # declared so the contract owns the token (and flags drift).
55
+ on :brush do
56
+ controller(WINDOW_CONTROLLER, if: -> { brush_config }) do
57
+ action :startBrush, on: :pointerdown
58
+ end
59
+ end
60
+ end
61
+
62
+ # The live-window anatomy (all server-rendered; the window
63
+ # controller only drags and repaints) - declared here so every
64
+ # live-capable family publishes the same styling surface.
65
+ base.part "chart-brush", "The brush strip group (with_brush): track + window + two " \
66
+ "handles below the x axis"
67
+ base.part "chart-brush-track", "The full-width brush rail"
68
+ base.part "chart-brush-window", "The selected-range rect the drag moves"
69
+ base.part "chart-brush-handle", "One draggable window edge",
70
+ states: { "data-edge" => { condition: "always - which edge",
71
+ values: %w[start end] } }
72
+ base.part "chart-zoom-selection", "The zoom drag-selection overlay (zoom: true), " \
73
+ "hidden until a drag starts"
74
+ base.part "chart-live-payload", "The embedded {spec, frame} JSON the live renderer " \
75
+ "recomputes geometry from"
76
+ end
77
+
78
+ # The class-level macro families call to opt into live mode.
79
+ module ClassMethods
80
+ # Declares the live-mode surface: the live: and zoom: options plus
81
+ # the with_brush slot.
82
+ def live_option
83
+ option :live, :boolean, default: false,
84
+ doc: "Embeds the {spec, frame} payload so the client renderer can recompute " \
85
+ "geometry when data changes without a server round-trip."
86
+ option :zoom, :boolean, default: false,
87
+ doc: "Drag-to-zoom on the plot; slices the data client-side, so it needs live: true."
88
+ renders_one :brush,
89
+ doc: "The brush strip below the x axis - drag its window to slice the visible range; needs " \
90
+ "live: true.",
91
+ renders: lambda { |height: 30|
92
+ @brush_config = { height: height.to_f }
93
+ nil
94
+ }
95
+ end
96
+ end
97
+
98
+ # The live: option cast to a strict boolean.
99
+ # @api private
100
+ def live?
101
+ !!live
102
+ end
103
+
104
+ # The window controller's full-string identifier.
105
+ WINDOW_CONTROLLER = "poetry--charts--window"
106
+ # Pixels between the plot's bottom margin and the brush strip.
107
+ BRUSH_GAP = 8
108
+
109
+ # The brush slot's captured config, guarded: the window recomputes
110
+ # client-side, so a brush without live: true raises.
111
+ # @api private
112
+ def brush_config
113
+ brush?
114
+ if @brush_config && !live?
115
+ raise ArgumentError, "with_brush needs live: true - the window recomputes client-side"
116
+ end
117
+
118
+ @brush_config
119
+ end
120
+
121
+ # The zoom: option, guarded the same way as the brush.
122
+ # @api private
123
+ def zoom?
124
+ raise ArgumentError, "zoom: true needs live: true - the window recomputes client-side" if zoom && !live?
125
+
126
+ !!zoom
127
+ end
128
+
129
+ # Whether any windowing feature (brush or zoom) is active.
130
+ # @api private
131
+ def window_features?
132
+ !!brush_config || zoom?
133
+ end
134
+
135
+ # The brush strip reserves space below the x axis by growing the
136
+ # bottom margin the cartesian sees.
137
+ # @api private
138
+ def live_margin
139
+ base = (margin || {}).to_h.symbolize_keys
140
+ return base unless brush_config
141
+
142
+ bottom = (base[:bottom] || Cartesian::DEFAULT_MARGIN[:bottom]).to_f
143
+ base.merge(bottom: bottom + brush_config[:height] + BRUSH_GAP)
144
+ end
145
+
146
+ # The brush strip's y position - below the plot, above the reserved
147
+ # margin.
148
+ # @api private
149
+ def brush_top
150
+ original_bottom = (margin || {}).to_h.symbolize_keys.fetch(:bottom, Cartesian::DEFAULT_MARGIN[:bottom]).to_f
151
+ height - original_bottom - brush_config[:height]
152
+ end
153
+
154
+ # The strip: track + window + two handles, all server-rendered; the
155
+ # window controller drags them and repaints on window changes.
156
+ # @api private
157
+ def brush_svg
158
+ config = brush_config
159
+ return unless config
160
+
161
+ left = cartesian.plot_left
162
+ width = cartesian.plot_right - left
163
+ top = brush_top
164
+ handle = 6.0
165
+
166
+ tag.g("data-slot": "chart-brush", "aria-hidden": true,
167
+ "data-action": stimulus_action(:startBrush, on: :pointerdown)) do
168
+ safe_join([
169
+ tag.rect(class: css(:brush_track), "data-slot": "chart-brush-track",
170
+ x: fnum(left), y: fnum(top), width: fnum(width),
171
+ height: fnum(config[:height]), rx: 4),
172
+ tag.rect(class: css(:brush_window), "data-slot": "chart-brush-window",
173
+ x: fnum(left), y: fnum(top), width: fnum(width),
174
+ height: fnum(config[:height]), rx: 4),
175
+ tag.rect(class: css(:brush_handle), "data-slot": "chart-brush-handle", "data-edge": "start",
176
+ x: fnum(left - (handle / 2)), y: fnum(top), width: fnum(handle),
177
+ height: fnum(config[:height]), rx: 2),
178
+ tag.rect(class: css(:brush_handle), "data-slot": "chart-brush-handle", "data-edge": "end",
179
+ x: fnum(left + width - (handle / 2)), y: fnum(top), width: fnum(handle),
180
+ height: fnum(config[:height]), rx: 2)
181
+ ])
182
+ end
183
+ end
184
+
185
+ # The zoom drag-selection overlay (hidden until a drag starts).
186
+ # @api private
187
+ def zoom_selection_svg
188
+ return unless zoom?
189
+
190
+ tag.rect(class: css(:reference_area), "data-slot": "chart-zoom-selection", "aria-hidden": true,
191
+ x: 0, y: fnum(cartesian.plot_top), width: 0,
192
+ height: fnum(cartesian.plot_bottom - cartesian.plot_top),
193
+ "fill-opacity": 0.15, display: "none")
194
+ end
195
+
196
+ # Value readers for the window controller's declared frame wiring:
197
+ # the plot rect and the brush strip rect, server-computed (the
198
+ # controller's only layout math is fractions of these).
199
+ # @api private
200
+ def window_plot_json
201
+ [cartesian.plot_left, cartesian.plot_right, cartesian.plot_top, cartesian.plot_bottom].to_json
202
+ end
203
+
204
+ # The brush strip rect for the window controller, server-computed.
205
+ # @api private
206
+ def window_brush_json
207
+ [cartesian.plot_left, brush_top, cartesian.plot_right - cartesian.plot_left,
208
+ brush_config[:height]].to_json
209
+ end
210
+
211
+ # The embedded {spec, frame} payload the live renderer recomputes
212
+ # geometry from, validated for serializability first.
213
+ # @api private
214
+ def live_payload
215
+ ensure_live_compatible!
216
+ {
217
+ "version" => 1,
218
+ "spec" => live_spec.to_h,
219
+ "frame" => live_frame
220
+ }
221
+ end
222
+
223
+ # The payload serialized for the embedded <script> tag.
224
+ # @api private
225
+ def live_payload_json
226
+ live_payload.to_json
227
+ end
228
+
229
+ # The chart reduced to the closed spec (type, data, series, axes).
230
+ # @api private
231
+ def live_spec
232
+ Spec.new(type: live_type, data: data, series: live_series, axes: live_axes)
233
+ end
234
+
235
+ # The geometry-affecting knobs shared by the cartesian trio; families
236
+ # merge their extras (bar gaps/radii) via live_frame_extras.
237
+ # @api private
238
+ def live_frame
239
+ {
240
+ "width" => width,
241
+ "height" => height,
242
+ # live_margin, NOT the raw margin: the brush reserve must survive
243
+ # into client recomputes, or every window change drops the plot
244
+ # into the brush strip (the y-baseline sliding ~35px down).
245
+ "margin" => Cartesian::DEFAULT_MARGIN.merge(live_margin)
246
+ .transform_keys(&:to_s),
247
+ "layout" => cartesian.layout.to_s,
248
+ "xScaleType" => live_x_scale_type.to_s,
249
+ "categoryAxis" => live_category_axis?,
250
+ "valueAxis" => !cartesian.horizontal? && !y_axis_config.nil?,
251
+ "yTickCount" => cartesian.y_tick_count,
252
+ "offset" => cartesian.offset.to_s,
253
+ "xTickMargin" => x_axis_config&.tick_margin,
254
+ "yTickMargin" => y_axis_config&.tick_margin
255
+ }.merge(live_frame_extras).compact
256
+ end
257
+
258
+ # Hook: families merge extra geometry-affecting knobs into the frame
259
+ # envelope.
260
+ # @api private
261
+ def live_frame_extras
262
+ {}
263
+ end
264
+
265
+ # Lambdas cannot ride a JSON payload - fail at render, with the fix.
266
+ # @api private
267
+ def ensure_live_compatible!
268
+ if x_axis_config&.tick_formatter || y_axis_config&.tick_formatter
269
+ raise ArgumentError, "live: charts cannot serialize tick_formatter lambdas - " \
270
+ "pre-format the category strings in your data instead"
271
+ end
272
+ if series_entries.any? { |entry| entry.respond_to?(:error_key) && entry.error_key }
273
+ raise ArgumentError, "live: charts do not support error bars yet - " \
274
+ "drop error_key: or render without live:"
275
+ end
276
+ if respond_to?(:reference_marks) && reference_marks.any?
277
+ raise ArgumentError, "live: charts do not support reference marks yet"
278
+ end
279
+ return unless live_labels_configured?
280
+
281
+ raise ArgumentError, "live: charts do not support labels yet - " \
282
+ "drop labels: or render without live:"
283
+ end
284
+
285
+ # Whether any series slot asked for value labels.
286
+ # @api private
287
+ def live_labels_configured?
288
+ series_entries.any? { |entry| entry.respond_to?(:labels) && entry.labels }
289
+ end
290
+
291
+ private :brush_config, :zoom?, :window_features?, :live_margin, :brush_top, :brush_svg
292
+ private :zoom_selection_svg, :window_plot_json, :window_brush_json, :live_payload, :live_payload_json, :live_spec
293
+ private :live_frame, :live_frame_extras, :ensure_live_compatible!, :live_labels_configured?
294
+ end
295
+ end
296
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ # Shared animation surface for the chart families: chart-level
6
+ # options (animate / animation_duration / animation_easing /
7
+ # animation_begin, defaults set per family), emitted as data-animate plus
8
+ # --poetry-motion-* custom properties on the SVG. The animations
9
+ # themselves are CSS (the motion stylesheet) and the motion controller -
10
+ # the server computes all geometry; the client only interpolates
11
+ # between server-computed states.
12
+ module Motion
13
+ # The allowed animation_easing keywords.
14
+ EASINGS = %i[ease linear ease_in ease_out ease_in_out].freeze
15
+ # The motion controller's full-string identifier.
16
+ CONTROLLER = "poetry--charts--motion"
17
+
18
+ def self.included(base)
19
+ base.extend(ClassMethods)
20
+
21
+ # The motion controller rides the same frame element TooltipWiring
22
+ # declares (include order IS emission order: tooltip, motion, then
23
+ # live/window).
24
+ base.use_stimulus do
25
+ on :frame do
26
+ controller(CONTROLLER, if: :animate?) { register }
27
+ end
28
+ end
29
+ end
30
+
31
+ # The class-level macro families call to declare their animation
32
+ # options.
33
+ module ClassMethods
34
+ # Declares the animation options with the family's defaults
35
+ # (bar 400ms, pie delayed 400ms, scatter 400ms linear,
36
+ # everything else 1500ms ease with no delay).
37
+ def motion_options(duration: 1500, delay: 0, easing: :ease)
38
+ option :animate, :boolean, default: true,
39
+ doc: "Entrance animation switch - reduced-motion users always get the finished " \
40
+ "chart regardless."
41
+ option :animation_duration, :integer, default: duration, doc: "Entrance/morph duration in milliseconds."
42
+ option :animation_easing, :symbol, default: easing, doc: "Animation easing keyword (see EASINGS)."
43
+ option :animation_begin, :integer, default: delay, doc: "Pre-animation hold in milliseconds."
44
+ end
45
+ end
46
+
47
+ # The animate: option cast to a strict boolean.
48
+ # @api private
49
+ def animate?
50
+ !!animate
51
+ end
52
+
53
+ # Merged into the SVG tag by TooltipWiring#svg_interaction_attributes.
54
+ # Duration/begin are :integer-typed and easing is enum-validated, so
55
+ # nothing user-controlled reaches the style attribute unguarded.
56
+ # @api private
57
+ def motion_svg_attributes
58
+ return {} unless animate?
59
+
60
+ { "data-animate" => "", "style" => motion_style }
61
+ end
62
+
63
+ # The --poetry-motion-* custom-property declarations for the SVG's
64
+ # style attribute.
65
+ # @api private
66
+ def motion_style
67
+ easing = animation_easing.to_sym
68
+ raise ArgumentError, "unknown easing #{animation_easing.inspect}" unless EASINGS.include?(easing)
69
+
70
+ style = "--poetry-motion-duration: #{animation_duration}ms; " \
71
+ "--poetry-motion-easing: #{easing.to_s.tr("_", "-")}; " \
72
+ "--poetry-motion-delay: #{animation_begin}ms"
73
+ extra = motion_style_extras
74
+ extra ? "#{style}; #{extra}" : style
75
+ end
76
+
77
+ # Hook: families append extra custom properties (radar ships the
78
+ # polar center so CSS can scale from it).
79
+ #
80
+ # @return [String, nil]
81
+ def motion_style_extras
82
+ nil
83
+ end
84
+
85
+ # data-motion-sector: the server-computed sector params the
86
+ # fan-out sweep reads (4-decimal formatting, matching sector_path's
87
+ # own fmt so mid-sweep client paths stay byte-compatible).
88
+ # @api private
89
+ def motion_sector_value(cx, cy, inner, outer, start_angle, end_angle)
90
+ [cx, cy, inner, outer, start_angle, end_angle]
91
+ .map { |v| Geometry.js_number((v.to_f * 10_000).round / 10_000.0) }
92
+ .join(" ")
93
+ end
94
+
95
+ private :animate?, :motion_style, :motion_sector_value
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,36 @@
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
+ <% series_entries.each_with_index do |entry, pie_index| %>
5
+ <g data-slot="chart-pie" data-key="<%= entry.data_key %>">
6
+ <% slices(entry).each do |slice| %>
7
+ <path data-slot="chart-pie-sector" data-key="<%= entry.data_key %>"<% if pie_index.zero? %> data-index="<%= slice.index %>"<% end %><% if entry.active_index == slice.index %> data-active=""<% end %><% if animate? %> data-motion-sector="<%= motion_sector_value(cx, cy, slice.inner, slice.outer, slice.start_angle, slice.end_angle) %>" data-motion-group="pie-<%= pie_index %>"<% end %> d="<%= slice.path %>" fill="<%= slice.fill %>"<% if entry.stroke_width.positive? %> stroke="var(--background)" stroke-width="<%= entry.stroke_width %>"<% end %> />
8
+ <% end %>
9
+ </g>
10
+ <% if entry.labels == :list %>
11
+ <g data-slot="chart-labels" data-key="<%= entry.data_key %>" aria-hidden="true">
12
+ <% slices(entry).each do |slice| %>
13
+ <text class="<%= css(:inside_label) %>" x="<%= fnum(slice.label_point[0]) %>" y="<%= fnum(slice.label_point[1]) %>" text-anchor="middle" dy="0.355em"><%= slice_label(entry, slice) %></text>
14
+ <% end %>
15
+ </g>
16
+ <% end %>
17
+ <% end %>
18
+ <% if center_label? %>
19
+ <%# dy=0.355em centers each line's midline on its y (the
20
+ dominant-baseline: middle offset, stated inline); the subtitle
21
+ sits 24 below the title, both absolute. %>
22
+ <text data-slot="chart-center-label" x="<%= fnum(cx) %>" y="<%= fnum(cy) %>" text-anchor="middle">
23
+ <tspan class="<%= css(:center_title) %>" x="<%= fnum(cx) %>" y="<%= fnum(cy) %>" dy="0.355em"><%= center_label_config[:title] %></tspan>
24
+ <% if center_label_config[:subtitle] %>
25
+ <tspan class="<%= css(:center_subtitle) %>" x="<%= fnum(cx) %>" y="<%= fnum(cy + 24) %>" dy="0.355em"><%= center_label_config[:subtitle] %></tspan>
26
+ <% end %>
27
+ </text>
28
+ <% end %>
29
+ </svg>
30
+ <% if legend? %>
31
+ <%= render legend_component %>
32
+ <% end %>
33
+ <% if tooltip? %><%= render tooltip_layer_component %><% end %>
34
+ <script type="application/json" data-slot="chart-coordinates" <%= tag.attributes(stimulus_attributes_for(:coordinates)) %>><%= script_json(coordinates_json) %></script>
35
+ <% end %>
36
+ <% end %>