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,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ # Reference marks - annotation lines, areas, and dots - for every
6
+ # cartesian family. Values speak the chart's own axes - categories on
7
+ # the category axis (the band/point center), numbers on the value
8
+ # axis (scatter overrides both to numeric) - and render as a single
9
+ # group painted ABOVE the series so annotations stay readable over
10
+ # the marks. Labels are strings (the live rule: no lambdas).
11
+ #
12
+ # Hosts provide ref_x_pixel/ref_y_pixel (the concern's defaults speak
13
+ # cartesian), plot edges via cartesian, css(:reference_line/:reference_area/:tick),
14
+ # and fnum. Vertical layouts only (a horizontal bar raises - a
15
+ # declared limit).
16
+ module ReferenceMarks
17
+ def self.included(base)
18
+ # A dashed rule across the plot: x: (a category) draws it
19
+ # vertical, y: (a value) horizontal; label: annotates it.
20
+ base.renders_many :reference_lines, lambda { |x: nil, y: nil, label: nil, stroke_dasharray: "3 3"|
21
+ (@reference_marks ||= []) << { kind: :line, x: x, y: y, label: label,
22
+ dasharray: stroke_dasharray.to_s }
23
+ nil
24
+ }
25
+
26
+ # A shaded band: any subset of x1/x2/y1/y2 - missing edges extend
27
+ # to the plot's edge.
28
+ base.renders_many :reference_areas, lambda { |x1: nil, x2: nil, y1: nil, y2: nil,
29
+ label: nil, fill_opacity: 0.15|
30
+ (@reference_marks ||= []) << { kind: :area, x1: x1, x2: x2, y1: y1, y2: y2,
31
+ label: label, fill_opacity: fill_opacity.to_f }
32
+ nil
33
+ }
34
+
35
+ # A marker circle at (x category, y value); r: sets its radius.
36
+ base.renders_many :reference_dots, lambda { |x:, y:, r: 8, label: nil|
37
+ (@reference_marks ||= []) << { kind: :dot, x: x, y: y, r: r.to_f, label: label }
38
+ nil
39
+ }
40
+ end
41
+
42
+ # Every declared mark in declaration order, forcing lazy slot
43
+ # evaluation.
44
+ # @api private
45
+ def reference_marks
46
+ reference_lines?
47
+ reference_areas?
48
+ reference_dots?
49
+ @reference_marks ||= []
50
+ end
51
+
52
+ # Category-axis values resolve through the category list (teaching
53
+ # error on an unknown category); scatter overrides to numeric.
54
+ # @api private
55
+ def ref_x_pixel(value)
56
+ index = cartesian.categories.index(value)
57
+ unless index
58
+ raise ArgumentError, "reference x #{value.inspect} is not a category " \
59
+ "(one of #{cartesian.categories.join(", ")})"
60
+ end
61
+ cartesian.x_centers[index]
62
+ end
63
+
64
+ # Value-axis values map through the y scale.
65
+ # @api private
66
+ def ref_y_pixel(value)
67
+ cartesian.y_scale.call(value.to_f)
68
+ end
69
+
70
+ # The plot rect's edges, keyed for the mark painters.
71
+ # @api private
72
+ def ref_plot
73
+ { left: cartesian.plot_left, right: cartesian.plot_right,
74
+ top: cartesian.plot_top, bottom: cartesian.plot_bottom }
75
+ end
76
+
77
+ # The single annotation group, painted above the series.
78
+ # @api private
79
+ def reference_marks_svg
80
+ marks = reference_marks
81
+ return if marks.empty?
82
+ raise ArgumentError, "reference marks support vertical layouts only" if
83
+ respond_to?(:horizontal?) && horizontal?
84
+
85
+ tag.g("data-slot": "chart-reference", "aria-hidden": true) do
86
+ safe_join(marks.map { |mark| reference_mark_svg(mark) })
87
+ end
88
+ end
89
+
90
+ private
91
+
92
+ def reference_mark_svg(mark)
93
+ case mark[:kind]
94
+ when :line then reference_line_svg(mark)
95
+ when :area then reference_area_svg(mark)
96
+ when :dot then reference_dot_svg(mark)
97
+ end
98
+ end
99
+
100
+ # x: -> a vertical rule at the category; y: -> a horizontal rule at
101
+ # the value.
102
+ def reference_line_svg(mark)
103
+ plot = ref_plot
104
+ points = if mark[:y]
105
+ py = ref_y_pixel(mark[:y])
106
+ { x1: plot[:left], x2: plot[:right], y1: py, y2: py }
107
+ elsif mark[:x]
108
+ px = ref_x_pixel(mark[:x])
109
+ { x1: px, x2: px, y1: plot[:top], y2: plot[:bottom] }
110
+ else
111
+ raise ArgumentError, "a reference line needs x: or y:"
112
+ end
113
+
114
+ line = tag.line(class: css(:reference_line), "stroke-dasharray": mark[:dasharray],
115
+ **points.transform_values { |v| fnum(v) })
116
+ return line unless mark[:label]
117
+
118
+ label = tag.text(mark[:label], class: css(:tick), x: fnum(points[:x2] - 4),
119
+ y: fnum(points[:y1] - 6), "text-anchor": mark[:y] ? "end" : "start")
120
+ safe_join([line, label])
121
+ end
122
+
123
+ # Any subset of x1/x2/y1/y2; missing edges default to the plot
124
+ # rect.
125
+ def reference_area_svg(mark)
126
+ plot = ref_plot
127
+ x1 = mark[:x1] ? ref_x_pixel(mark[:x1]) : plot[:left]
128
+ x2 = mark[:x2] ? ref_x_pixel(mark[:x2]) : plot[:right]
129
+ y1 = mark[:y1] ? ref_y_pixel(mark[:y1]) : plot[:bottom]
130
+ y2 = mark[:y2] ? ref_y_pixel(mark[:y2]) : plot[:top]
131
+
132
+ rect = tag.rect(class: css(:reference_area), x: fnum([x1, x2].min), y: fnum([y1, y2].min),
133
+ width: fnum((x2 - x1).abs), height: fnum((y2 - y1).abs),
134
+ "fill-opacity": mark[:fill_opacity])
135
+ return rect unless mark[:label]
136
+
137
+ label = tag.text(mark[:label], class: css(:tick), x: fnum(([x1, x2].min + [x1, x2].max) / 2.0),
138
+ y: fnum([y1, y2].min + 14), "text-anchor": "middle")
139
+ safe_join([rect, label])
140
+ end
141
+
142
+ def reference_dot_svg(mark)
143
+ cx = ref_x_pixel(mark[:x])
144
+ cy = ref_y_pixel(mark[:y])
145
+ dot = tag.circle(class: css(:reference_dot), cx: fnum(cx), cy: fnum(cy), r: fnum(mark[:r]))
146
+ return dot unless mark[:label]
147
+
148
+ label = tag.text(mark[:label], class: css(:tick), x: fnum(cx),
149
+ y: fnum(cy - mark[:r] - 6), "text-anchor": "middle")
150
+ safe_join([dot, label])
151
+ end
152
+
153
+ private :ref_x_pixel, :ref_y_pixel, :ref_plot, :reference_marks_svg
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,55 @@
1
+ <%= render Poetry::Charts::Container::Component.new(config: chart_config, id: chart_id.delete_prefix("chart-")) do %>
2
+ <%= content_tag(:div, frame_attributes.merge("class" => css(:frame))) do %>
3
+ <svg viewBox="0 0 <%= width %> <%= height %>" class="<%= css(:svg) %>" data-slot="chart-svg" aria-label="<%= svg_label %>" preserveAspectRatio="xMidYMid meet" <%= tag.attributes(svg_interaction_attributes) %>>
4
+ <% if grid? %>
5
+ <g data-slot="chart-grid" aria-hidden="true">
6
+ <% if grid_config.horizontal %>
7
+ <% y_ticks.each do |tick| %>
8
+ <line class="<%= css(:grid_line) %>" x1="<%= fnum(plot_left) %>" x2="<%= fnum(plot_right) %>" y1="<%= fnum(y_scale.call(tick)) %>" y2="<%= fnum(y_scale.call(tick)) %>" />
9
+ <% end %>
10
+ <% end %>
11
+ <% if grid_config.vertical %>
12
+ <% x_ticks.each do |tick| %>
13
+ <line class="<%= css(:grid_line) %>" x1="<%= fnum(x_scale.call(tick)) %>" x2="<%= fnum(x_scale.call(tick)) %>" y1="<%= fnum(plot_top) %>" y2="<%= fnum(plot_bottom) %>" />
14
+ <% end %>
15
+ <% end %>
16
+ </g>
17
+ <% end %>
18
+ <g data-slot="chart-scatters">
19
+ <% points.each do |point| %>
20
+ <circle data-slot="chart-scatter-point" data-key="<%= point.key %>" data-index="<%= point.index %>" cx="<%= fnum(point.cx) %>" cy="<%= fnum(point.cy) %>" r="<%= fnum(point.r) %>" fill="var(--color-<%= point.key %>)" />
21
+ <% end %>
22
+ </g>
23
+ <% series_entries.select(&:error_key).each do |entry| %>
24
+ <g data-slot="chart-error-bars" data-key="<%= entry.key %>" aria-hidden="true">
25
+ <% points.select { |p| p.key == entry.key }.each do |point| %>
26
+ <% range = error_range(point.row, entry.error_key, point.row[y_axis_config.data_key].to_f) %>
27
+ <% if range %>
28
+ <path class="<%= css(:error_bar) %>" d="<%= error_bar_path(point.cx, y_scale.call(range[0]), y_scale.call(range[1]), entry.error_width) %>" fill="none" />
29
+ <% end %>
30
+ <% end %>
31
+ </g>
32
+ <% end %>
33
+ <%= reference_marks_svg %>
34
+ <% if x_axis? %>
35
+ <g data-slot="chart-x-axis">
36
+ <% x_ticks.each do |tick| %>
37
+ <text class="<%= css(:tick) %>" x="<%= fnum(x_scale.call(tick)) %>" y="<%= fnum(plot_bottom + x_axis_config.tick_margin) %>" dy="0.71em" text-anchor="middle"><%= tick_label(tick) %></text>
38
+ <% end %>
39
+ </g>
40
+ <% end %>
41
+ <% if y_axis? %>
42
+ <g data-slot="chart-y-axis">
43
+ <% y_ticks.each do |tick| %>
44
+ <text class="<%= css(:tick) %>" x="<%= fnum(plot_left - y_axis_config.tick_margin) %>" y="<%= fnum(y_scale.call(tick)) %>" dy="0.355em" text-anchor="end"><%= tick_label(tick) %></text>
45
+ <% end %>
46
+ </g>
47
+ <% end %>
48
+ </svg>
49
+ <% if legend? %>
50
+ <%= render legend_component %>
51
+ <% end %>
52
+ <% if tooltip? %><%= render tooltip_layer_component %><% end %>
53
+ <script type="application/json" data-slot="chart-coordinates" <%= tag.attributes(stimulus_attributes_for(:coordinates)) %>><%= script_json(coordinates_json) %></script>
54
+ <% end %>
55
+ <% end %>
@@ -0,0 +1,396 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ # The scatter chart family.
6
+ module ScatterChart
7
+ # Renders a scatter chart: BOTH axes numeric - two auto-niced
8
+ # linear scales - with per-point marks. Each with_scatter series
9
+ # brings its own rows (or shares the chart data); the z axis sizes
10
+ # markers by AREA in px2 (default range [64, 64]; r =
11
+ # sqrt(area/pi)). The tooltip hits per POINT (data-index +
12
+ # pointerover) and the chrome shows the x/y(/z) rows with the
13
+ # point's series name and color retinting per index.
14
+ #
15
+ # @example Points over two named numeric axes
16
+ # <%= poetry_chart :scatter, data: points, config: config do |c| %>
17
+ # <% c.with_grid %>
18
+ # <% c.with_x_axis data_key: :height, name: "Height" %>
19
+ # <% c.with_y_axis data_key: :weight, name: "Weight" %>
20
+ # <% c.with_scatter key: :sample %>
21
+ # <% c.with_tooltip %>
22
+ # <% end %>
23
+ class Component < Poetry::Core::Component
24
+ include Poetry::Charts::ChartFamily
25
+ include Poetry::Charts::TooltipWiring
26
+ include Poetry::Charts::Motion
27
+ include Poetry::Charts::ReferenceMarks
28
+ include Poetry::Charts::ErrorBars
29
+
30
+ # Projected into the registry and agent surface.
31
+ AGENT_RULES = [
32
+ "Both axes are numeric: with_x_axis(data_key:) / with_y_axis(data_key:) name the row keys to plot.",
33
+ "with_scatter(key:) colors points var(--color-<key>); data: gives a series its own rows.",
34
+ "with_z_axis(data_key:, range: [64, 144]) sizes points by a third dimension - " \
35
+ "the range is marker AREA in px2 (the source's z-axis contract).",
36
+ "The tooltip hits per point and shows the x/y(/z) values with the series color.",
37
+ "Entrance animation is on by default (400ms linear, source-exact); animate: false for a static chart."
38
+ ].freeze
39
+
40
+ # One with_scatter call's captured series config.
41
+ Series = Data.define(:key, :data, :error_key, :error_width)
42
+ # One numeric axis slot's captured config.
43
+ AxisConfig = Data.define(:data_key, :tick_count, :tick_margin, :name)
44
+ # The marker AREA range (px2) when no z axis sizes the points.
45
+ DEFAULT_Z_RANGE = [64, 64].freeze
46
+
47
+ option :data, ActiveModel::Type::Value.new,
48
+ doc: "Default rows for series that don't bring their own data: - one hash per point."
49
+ option :config, ActiveModel::Type::Value.new, required: true,
50
+ doc: "The series config - key => { label:, color: } - naming " \
51
+ "and coloring every series."
52
+ option :id, :string,
53
+ doc: "Explicit DOM id token, stable across renders; otherwise the chart gets a unique per-render id."
54
+ option :width, :integer, default: 640,
55
+ doc: "ViewBox width in pixels; the rendered chart scales to its container."
56
+ option :height, :integer, default: 360, doc: "ViewBox height in pixels."
57
+ option :margin, ActiveModel::Type::Value.new,
58
+ doc: "Plot margin overrides ({ top:, right:, bottom:, left: }), merged over the defaults."
59
+ option :label, :string,
60
+ doc: "Accessible name for the chart SVG; defaults to one built from the configured series."
61
+
62
+ motion_options(duration: 400, easing: :linear)
63
+
64
+ part "chart-svg", "The chart canvas (<svg>) - server-computed geometry in a fixed viewBox; " \
65
+ "role=img, or the focusable role=application accessibilityLayer when the " \
66
+ "tooltip attaches",
67
+ states: {
68
+ "data-animate" => "present when animate (the default) - the motion stylesheet and " \
69
+ "controller key the entrance off it",
70
+ "data-motion" => { condition: "runtime - the motion rig stamps the animation " \
71
+ "lifecycle (entrance/morph, then settled)",
72
+ values: %w[entrance morph settled] }
73
+ },
74
+ vars: {
75
+ "--poetry-motion-duration" => "the entrance/morph duration (animation_duration, ms)",
76
+ "--poetry-motion-easing" => "the animation easing keyword (animation_easing)",
77
+ "--poetry-motion-delay" => "the pre-animation hold (animation_begin, ms)"
78
+ }
79
+ part "chart-grid", "The gridline group (with_grid) - horizontal and/or vertical rules " \
80
+ "across the plot"
81
+ part "chart-scatters", "The scatter-mark group - every series' points flattened with " \
82
+ "a global index"
83
+ part "chart-scatter-point", "One data point (<circle>) - r carries the z-axis area sizing",
84
+ states: {
85
+ "data-key" => "the series key",
86
+ "data-index" => "the point's global index across every series",
87
+ "data-active" => "runtime - the tooltip controller marks the hovered point"
88
+ }
89
+ part "chart-error-bars", "One series' error-whisker group (error_key:) - cap-stem-cap " \
90
+ "paths in the foreground color",
91
+ states: { "data-key" => "the series key" }
92
+ part "chart-reference", "The reference-mark group (with_reference_line/_area/_dot), " \
93
+ "painted above the series"
94
+ part "chart-x-axis", "The x-axis tick-label group (with_x_axis)"
95
+ part "chart-y-axis", "The y-axis tick-label group (with_y_axis)"
96
+ part "chart-coordinates", "The embedded per-index geometry payload " \
97
+ "(<script type=application/json>) the tooltip controller " \
98
+ "reads - zero chart math in the browser"
99
+
100
+ renders_many :scatters,
101
+ doc: "A point series colored by key:. data: gives it its own rows; error_key: adds error " \
102
+ "whiskers.",
103
+ renders: lambda { |key:, data: nil, error_key: nil, error_width: 5|
104
+ (@series_entries ||= []) << Series.new(key: key.to_s, data: data,
105
+ error_key: error_key&.to_s, error_width:)
106
+ nil
107
+ }
108
+
109
+ renders_one :x_axis,
110
+ doc: "The numeric x axis: data_key: names the row key to plot; name: labels its tooltip row.",
111
+ renders: lambda { |data_key:, tick_count: 5, tick_margin: 8, name: nil|
112
+ @x_axis_config = AxisConfig.new(data_key: data_key.to_s, tick_count:, tick_margin:, name:)
113
+ nil
114
+ }
115
+
116
+ renders_one :y_axis,
117
+ doc: "The numeric y axis: data_key: names the row key to plot; name: labels its tooltip row.",
118
+ renders: lambda { |data_key:, tick_count: 5, tick_margin: 8, name: nil|
119
+ @y_axis_config = AxisConfig.new(data_key: data_key.to_s, tick_count:, tick_margin:, name:)
120
+ nil
121
+ }
122
+
123
+ renders_one :z_axis,
124
+ doc: "A third dimension sizing the markers: range: is marker AREA in px2 mapped linearly from " \
125
+ "the data_key: values.",
126
+ renders: lambda { |data_key:, range: DEFAULT_Z_RANGE|
127
+ @z_axis_config = { data_key: data_key.to_s, range: range }
128
+ nil
129
+ }
130
+
131
+ renders_one :grid,
132
+ doc: "The gridlines: both directions by default.",
133
+ renders: lambda { |vertical: true, horizontal: true|
134
+ @grid_config = GridConfig.new(vertical:, horizontal:)
135
+ nil
136
+ }
137
+
138
+ renders_one :legend,
139
+ doc: "The legend row: align:, items:, and hide_icon:.",
140
+ renders: lambda { |**options|
141
+ @legend_config = options
142
+ nil
143
+ }
144
+
145
+ renders_one :tooltip,
146
+ doc: "The hover tooltip - per-point x/y(/z) rows under the series name.",
147
+ renders: lambda { |**options|
148
+ @tooltip_config = options
149
+ nil
150
+ }
151
+
152
+ # Points are hit by pointerover on the marked circle, not bisect -
153
+ # the svg gains the enter action after the module's set.
154
+ use_stimulus do
155
+ on :svg do
156
+ controller(TooltipWiring::CONTROLLER, if: :tooltip?) { action :enter, on: :pointerover }
157
+ end
158
+ end
159
+
160
+ # The captured Series configs, forcing lazy slot evaluation.
161
+ # @api private
162
+ def series_entries
163
+ scatters? # force slot evaluation (slots evaluate lazily)
164
+ @series_entries ||= []
165
+ end
166
+
167
+ # The x-axis slot's captured config, forcing lazy slot evaluation.
168
+ # @api private
169
+ def x_axis_config
170
+ x_axis?
171
+ @x_axis_config
172
+ end
173
+
174
+ # The y-axis slot's captured config, forcing lazy slot evaluation.
175
+ # @api private
176
+ def y_axis_config
177
+ y_axis?
178
+ @y_axis_config
179
+ end
180
+
181
+ # The z-axis slot's captured config, forcing lazy slot evaluation.
182
+ # @api private
183
+ def z_axis_config
184
+ z_axis?
185
+ @z_axis_config
186
+ end
187
+
188
+ # The grid slot's captured config, forcing lazy slot evaluation.
189
+ # @api private
190
+ def grid_config
191
+ grid?
192
+ @grid_config
193
+ end
194
+
195
+ # -- geometry: two auto-niced linear scales ---------------------------
196
+
197
+ # The default margin - a slim, even inset on all sides.
198
+ MARGIN = { top: 5, right: 5, bottom: 5, left: 5 }.freeze
199
+
200
+ # The margin option merged over the defaults.
201
+ # @api private
202
+ def margins
203
+ @margins ||= MARGIN.merge((margin || {}).to_h.symbolize_keys)
204
+ end
205
+
206
+ # The plot rect's left edge, inset for the y axis when present.
207
+ # @api private
208
+ def plot_left = margins[:left].to_f + (y_axis? ? Cartesian::Y_AXIS_WIDTH : 0)
209
+ # The plot rect's right edge.
210
+ # @api private
211
+ def plot_right = width - margins[:right]
212
+ # The plot rect's top edge.
213
+ # @api private
214
+ def plot_top = margins[:top].to_f
215
+ # The plot rect's bottom edge, inset for the x axis when present.
216
+ # @api private
217
+ def plot_bottom = height - margins[:bottom] - (x_axis? ? Cartesian::X_AXIS_HEIGHT : 0)
218
+
219
+ # Memoized per SLOT (object identity), never per key: two series may
220
+ # share a key with different data:, and a key-keyed memo would
221
+ # silently render the first series' rows twice.
222
+ # @api private
223
+ def rows(entry)
224
+ @rows ||= {}.compare_by_identity
225
+ @rows[entry] ||= (entry.data || data || []).map { |row| row.to_h.transform_keys(&:to_s) }
226
+ end
227
+
228
+ # Every series' values for one row key, flattened.
229
+ # @api private
230
+ def axis_values(key)
231
+ series_entries.flat_map { |entry| rows(entry).map { |row| row[key].to_f } }
232
+ end
233
+
234
+ # Both axes ride the shared nice-ticks over [0, auto], the same
235
+ # convention as every numeric axis in the engine.
236
+ # @api private
237
+ def x_ticks
238
+ @x_ticks ||= nice_ticks(axis_values(x_axis_config.data_key), x_axis_config.tick_count)
239
+ end
240
+
241
+ # The y axis's niced tick values.
242
+ # @api private
243
+ def y_ticks
244
+ @y_ticks ||= nice_ticks(axis_values(y_axis_config.data_key), y_axis_config.tick_count)
245
+ end
246
+
247
+ # Niced ticks over the values' [min-or-0, max] domain.
248
+ # @api private
249
+ def nice_ticks(values, count)
250
+ max = values.max || 1
251
+ Geometry::NiceTicks.nice_ticks([[0, values.min || 0].min, max], count)
252
+ end
253
+
254
+ # The x scale from the tick domain onto the plot width.
255
+ # @api private
256
+ def x_scale
257
+ @x_scale ||= Geometry::Scale::Linear.new(domain: [x_ticks.first, x_ticks.last],
258
+ range: [plot_left, plot_right])
259
+ end
260
+
261
+ # The y scale from the tick domain onto the plot height.
262
+ # @api private
263
+ def y_scale
264
+ @y_scale ||= Geometry::Scale::Linear.new(domain: [y_ticks.first, y_ticks.last],
265
+ range: [plot_bottom, plot_top])
266
+ end
267
+
268
+ # The z axis: a linear scale from the z data domain onto the AREA
269
+ # range; the circle radius is sqrt(area / pi).
270
+ # @api private
271
+ def z_scale
272
+ @z_scale ||= begin
273
+ values = axis_values(z_axis_config[:data_key])
274
+ Geometry::Scale::Linear.new(domain: [values.min || 0, values.max || 1],
275
+ range: z_axis_config[:range].map(&:to_f))
276
+ end
277
+ end
278
+
279
+ # A point's radius from its z value (or the default area).
280
+ # @api private
281
+ def point_radius(row)
282
+ area = if z_axis_config
283
+ value = row[z_axis_config[:data_key]].to_f
284
+ z_scale.domain.first == z_scale.domain.last ? z_axis_config[:range].first.to_f : z_scale.call(value)
285
+ else
286
+ DEFAULT_Z_RANGE.first.to_f
287
+ end
288
+ Math.sqrt(area / Math::PI)
289
+ end
290
+
291
+ # One computed point: its position, radius, series key, and row.
292
+ Point = Data.define(:index, :key, :cx, :cy, :r, :row)
293
+
294
+ # All series flattened with a GLOBAL index - the tooltip's per-point
295
+ # hit space.
296
+ # @api private
297
+ def points
298
+ @points ||= begin
299
+ index = -1
300
+ series_entries.flat_map do |entry|
301
+ rows(entry).map do |row|
302
+ index += 1
303
+ Point.new(index: index, key: entry.key,
304
+ cx: x_scale.call(row[x_axis_config.data_key].to_f),
305
+ cy: y_scale.call(row[y_axis_config.data_key].to_f),
306
+ r: point_radius(row), row: row)
307
+ end
308
+ end
309
+ end
310
+ end
311
+
312
+ # -- the tooltip payload (the per-index anchors wire) ------------------
313
+
314
+ # An axis's tooltip-row label: its name:, else the fallback.
315
+ # @api private
316
+ def axis_label(axis_config, fallback)
317
+ (axis_config.respond_to?(:name) && axis_config.name.presence) || fallback
318
+ end
319
+
320
+ # The embedded per-point geometry payload the tooltip controller
321
+ # reads.
322
+ # @api private
323
+ def coordinates_json
324
+ value_rows = { "x" => x_axis_config, "y" => y_axis_config }
325
+ # No "names": the x/y rows keep their axis labels; the point's
326
+ # series shows as the tooltip LABEL (categories) and the
327
+ # indicator retints through colors.
328
+ payload = {
329
+ "layout" => "polar",
330
+ "categories" => points.map { |p| chart_config.label_for(p.key, p.key) },
331
+ "colors" => points.map { |p| chart_config[p.key]&.color || "var(--color-#{p.key})" },
332
+ "anchors" => points.map { |p| [p.cx.round(2), p.cy.round(2)] },
333
+ "values" => value_rows.to_h do |dimension, axis|
334
+ [dimension, points.map { |p| Poetry::Charts.display_value(p.row[axis.data_key]) }]
335
+ end
336
+ }
337
+ if z_axis_config
338
+ payload["values"]["z"] = points.map { |p| Poetry::Charts.display_value(p.row[z_axis_config[:data_key]]) }
339
+ end
340
+ payload.to_json
341
+ end
342
+
343
+ # The chrome rows are the axis DIMENSIONS (x/y/z), labeled from the
344
+ # axis name: (or its data key); the indicator retints per point.
345
+ # @api private
346
+ def tooltip_layer_component
347
+ entries = {
348
+ x: { label: axis_label(x_axis_config, x_axis_config.data_key) },
349
+ y: { label: axis_label(y_axis_config, y_axis_config.data_key) }
350
+ }
351
+ entries[:z] = { label: z_axis_config[:data_key] } if z_axis_config
352
+ TooltipLayer::Component.new(
353
+ config: Poetry::Charts::Config.wrap(entries),
354
+ series_keys: entries.keys.map(&:to_s),
355
+ indicator: tooltip_config.fetch(:indicator, :dot),
356
+ hide_label: tooltip_config.fetch(:hide_label, false),
357
+ hide_indicator: tooltip_config.fetch(:hide_indicator, false)
358
+ )
359
+ end
360
+
361
+ # The whole-name override of ChartFamily#svg_label: scatter's
362
+ # series read from the config by key, not from its entries.
363
+ # @api private
364
+ def svg_label
365
+ label.presence ||
366
+ "Scatter chart: #{series_entries.map { |e| chart_config[e.key]&.label || e.key }.join(", ")}"
367
+ end
368
+
369
+ # Reference marks speak numbers on BOTH axes here (the concern's
370
+ # defaults are categorical).
371
+ # @api private
372
+ def ref_x_pixel(value) = x_scale.call(value.to_f)
373
+ # Reference y values map through the y scale.
374
+ # @api private
375
+ def ref_y_pixel(value) = y_scale.call(value.to_f)
376
+
377
+ # The plot rect's edges, keyed for the mark painters.
378
+ # @api private
379
+ def ref_plot
380
+ { left: plot_left, right: plot_right, top: plot_top, bottom: plot_bottom }
381
+ end
382
+
383
+ # A tick's label, integers shown bare.
384
+ # @api private
385
+ def tick_label(tick)
386
+ Geometry.js_number(tick.to_f)
387
+ end
388
+
389
+ private :x_axis_config, :y_axis_config, :z_axis_config, :grid_config, :margins, :plot_left
390
+ private :plot_right, :plot_top, :plot_bottom, :rows, :axis_values, :x_ticks, :y_ticks, :nice_ticks, :x_scale
391
+ private :y_scale, :z_scale, :point_radius, :points, :axis_label, :coordinates_json, :tooltip_layer_component
392
+ private :svg_label, :ref_x_pixel, :ref_y_pixel, :ref_plot, :tick_label
393
+ end
394
+ end
395
+ end
396
+ end