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,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ module ScatterChart
6
+ # The scatter family: simple, two series, and z-sized bubbles.
7
+ class Preview < Poetry::Core::Preview::Base
8
+ SAMPLE = [
9
+ { height: 161, weight: 51, bmi: 19.7 },
10
+ { height: 167, weight: 59, bmi: 21.2 },
11
+ { height: 170, weight: 68, bmi: 23.5 },
12
+ { height: 174, weight: 66, bmi: 21.8 },
13
+ { height: 178, weight: 79, bmi: 24.9 },
14
+ { height: 183, weight: 84, bmi: 25.1 },
15
+ { height: 189, weight: 95, bmi: 26.6 }
16
+ ].freeze
17
+
18
+ CONTROL = [
19
+ { height: 158, weight: 62, bmi: 24.8 },
20
+ { height: 165, weight: 71, bmi: 26.1 },
21
+ { height: 172, weight: 60, bmi: 20.3 },
22
+ { height: 180, weight: 88, bmi: 27.2 },
23
+ { height: 186, weight: 77, bmi: 22.3 }
24
+ ].freeze
25
+
26
+ ONE = { sample: { label: "Sample", color: "var(--chart-1)" } }.freeze
27
+ TWO = ONE.merge(control: { label: "Control", color: "var(--chart-2)" }).freeze
28
+
29
+ def default
30
+ render_component(data: SAMPLE, config: ONE, id: "scatter-default",
31
+ margin: { left: 12, right: 12 }) do |chart|
32
+ chart.with_grid
33
+ chart.with_x_axis(data_key: :height, name: "Height")
34
+ chart.with_y_axis(data_key: :weight, name: "Weight")
35
+ chart.with_scatter(key: :sample)
36
+ chart.with_tooltip
37
+ end
38
+ end
39
+
40
+ def multiple
41
+ render_component(data: SAMPLE, config: TWO, id: "scatter-multiple",
42
+ margin: { left: 12, right: 12 }) do |chart|
43
+ chart.with_grid
44
+ chart.with_x_axis(data_key: :height, name: "Height")
45
+ chart.with_y_axis(data_key: :weight, name: "Weight")
46
+ chart.with_scatter(key: :sample)
47
+ chart.with_scatter(key: :control, data: CONTROL)
48
+ chart.with_legend
49
+ chart.with_tooltip
50
+ end
51
+ end
52
+
53
+ def bubbles
54
+ render_component(data: SAMPLE, config: ONE, id: "scatter-bubbles",
55
+ margin: { left: 12, right: 12 }) do |chart|
56
+ chart.with_grid
57
+ chart.with_x_axis(data_key: :height, name: "Height")
58
+ chart.with_y_axis(data_key: :weight, name: "Weight")
59
+ chart.with_z_axis(data_key: :bmi, range: [64, 400])
60
+ chart.with_scatter(key: :sample)
61
+ chart.with_tooltip
62
+ end
63
+ end
64
+
65
+ def reference_zones
66
+ render_component(data: SAMPLE, config: ONE, id: "scatter-refs",
67
+ margin: { left: 12, right: 12 }) do |chart|
68
+ chart.with_grid
69
+ chart.with_x_axis(data_key: :height, name: "Height")
70
+ chart.with_y_axis(data_key: :weight, name: "Weight")
71
+ chart.with_scatter(key: :sample, error_key: :werr)
72
+ chart.with_reference_area(y1: 60, y2: 80, label: "target band")
73
+ chart.with_reference_line(x: 175)
74
+ chart.with_reference_dot(x: 172, y: 70, r: 10, label: "median")
75
+ chart.with_tooltip
76
+ end
77
+ end
78
+
79
+ # A sync group member: the sync value the stimulus contract
80
+ # holds to the DOM.
81
+ def synced
82
+ render_component(data: SAMPLE, config: ONE, id: "scatter-synced",
83
+ margin: { left: 12, right: 12 }, sync: "scatter-demo") do |chart|
84
+ chart.with_x_axis(data_key: :height, name: "Height")
85
+ chart.with_y_axis(data_key: :weight, name: "Weight")
86
+ chart.with_scatter(key: :sample)
87
+ chart.with_tooltip
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ module ScatterChart
6
+ # The scatter chrome on the shared cn-chart-* theme rules.
7
+ # Point fills are var(--color-<key>) ATTRIBUTES.
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 :reference_line, "cn-chart-reference-line"
16
+ element :reference_area, "cn-chart-reference-area"
17
+ element :reference_dot, "cn-chart-reference-dot"
18
+ element :error_bar, "cn-chart-error-bar"
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,19 @@
1
+ <%= content_tag(:div, **root_attributes.to_attributes) do %>
2
+ <% if label_text && !nest_label? %><div data-slot="chart-tooltip-label" class="<%= css(:label) %>"><%= label_text %></div><% end %>
3
+ <div class="<%= css(:inner) %>">
4
+ <% rows.each do |row| %>
5
+ <div data-slot="chart-tooltip-item" data-key="<%= row.key %>" class="<%= row_classes %>">
6
+ <% unless hide_indicator %>
7
+ <div data-slot="chart-tooltip-indicator" class="<%= indicator_classes(nested: nest_label?) %>"<% if row.indicator_style %> style="<%= row.indicator_style %>"<% end %>></div>
8
+ <% end %>
9
+ <div class="<%= value_wrap_classes %>">
10
+ <div class="<%= css(:inner) %>">
11
+ <% if label_text && nest_label? %><div data-slot="chart-tooltip-label" class="<%= css(:label) %>"><%= label_text %></div><% end %>
12
+ <span data-slot="chart-tooltip-name" class="<%= css(:name) %>"><%= row.name %></span>
13
+ </div>
14
+ <% if row.formatted_value %><span data-slot="chart-tooltip-value" class="<%= css(:value) %>"><%= row.formatted_value %></span><% end %>
15
+ </div>
16
+ </div>
17
+ <% end %>
18
+ </div>
19
+ <% end %>
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ # The chart tooltip chrome.
6
+ module TooltipContent
7
+ # The tooltip chrome - the styled info box: optional label, then
8
+ # one row per series item (indicator + name + tabular value). Pure
9
+ # server-rendered markup; the tooltip controller positions and
10
+ # repopulates it from server-embedded coordinates. Indicator colors
11
+ # ride the --color-bg/--color-border inline custom properties, so a
12
+ # row's swatch follows its series.
13
+ #
14
+ # @example
15
+ # <%= poetry_chart_tooltip_content(config: config,
16
+ # items: [{ key: :desktop, value: 186 }]) %>
17
+ #
18
+ # Items are `{ key:, name:, value:, color: }` hashes; names resolve
19
+ # through the config (config label, else the item name, else the
20
+ # key) and colors fall back to the config entry's color, all
21
+ # server-side.
22
+ class Component < Poetry::Core::Component
23
+ # Projected into the registry and agent surface.
24
+ AGENT_RULES = [
25
+ "Tooltip rows resolve names/colors through the chart config - pass key:, not a display string.",
26
+ "indicator: :dot (default) | :line | :dashed matches the ported variants.",
27
+ "Numeric values render delimited (1,234) in the mono tabular column automatically."
28
+ ].freeze
29
+
30
+ option :config, ActiveModel::Type::Value.new, required: true,
31
+ doc: "The series config - key => { label:, color: } - " \
32
+ "resolving row names and colors."
33
+ option :items, ActiveModel::Type::Value.new, required: true,
34
+ doc: "The rows: [{ key:, name:, value:, color: }] hashes, one " \
35
+ "per series."
36
+ option :indicator, :symbol, default: :dot, doc: "The row swatch shape."
37
+ option :label, :string, doc: "The category label above the rows, resolved through the config."
38
+ option :hide_label, :boolean, default: false, doc: "Hides the category label."
39
+ option :hide_indicator, :boolean, default: false, doc: "Hides the row swatches."
40
+
41
+ validates :indicator, inclusion: { in: %i[dot line dashed] }
42
+
43
+ part "chart-tooltip-content", "The tooltip box (<div>) - the styled chrome the chart's " \
44
+ "tooltip controller positions and text-swaps"
45
+ part "chart-tooltip-label", "The category label (<div>) - above the rows, or nested " \
46
+ "inside the single row for line/dashed indicators"
47
+ part "chart-tooltip-item", "One series row (<div>) - indicator + name + value",
48
+ states: { "data-key" => "always - the series key the controller matches values by" }
49
+ part "chart-tooltip-indicator", "The row's swatch (<div>) - dot/line/dashed per " \
50
+ "indicator:, hidden with hide_indicator",
51
+ vars: {
52
+ "--color-bg" => "the swatch fill - carries the row's series color (inline; polar " \
53
+ "charts retint it to the hovered slice's color at runtime)",
54
+ "--color-border" => "the swatch border - the same series color as the fill"
55
+ }
56
+ part "chart-tooltip-name", "The series name (<span>) - resolved through the chart config"
57
+ part "chart-tooltip-value", "The formatted value (<span>) - the mono tabular column, " \
58
+ "numbers delimited"
59
+
60
+ # The config: option wrapped as a {Poetry::Charts::Config}.
61
+ # @api private
62
+ def chart_config
63
+ @chart_config ||= Poetry::Charts::Config.wrap(config)
64
+ end
65
+
66
+ # The items resolved into Row objects.
67
+ # @api private
68
+ def rows
69
+ @rows ||= Array(items).map { |item| Row.new(item.symbolize_keys, chart_config) }
70
+ end
71
+
72
+ # The label nests inside the single row for line/dashed
73
+ # indicators - the layout that makes the 1-series line-indicator
74
+ # tooltip read as one unit.
75
+ # @api private
76
+ def nest_label?
77
+ rows.length == 1 && indicator != :dot
78
+ end
79
+
80
+ # Label resolution: config label for the key when one matches,
81
+ # else the given string.
82
+ # @api private
83
+ def label_text
84
+ return nil if hide_label || label.blank?
85
+
86
+ chart_config.label_for(label, label)
87
+ end
88
+
89
+ # A row's classes, with the dot layout when the indicator is one.
90
+ # @api private
91
+ def row_classes
92
+ classes = css(:row)
93
+ classes = "#{classes} #{css(:row_dot)}" if indicator == :dot
94
+ classes
95
+ end
96
+
97
+ # The swatch's classes for the indicator shape (nested dashes get
98
+ # their own alignment).
99
+ # @api private
100
+ def indicator_classes(nested: false)
101
+ classes = "#{css(:indicator)} #{css(:"indicator_#{indicator}")}"
102
+ classes = "#{classes} #{css(:indicator_dashed_nested)}" if nested && indicator == :dashed
103
+ classes
104
+ end
105
+
106
+ # The value column's alignment classes (nested labels bottom-align).
107
+ # @api private
108
+ def value_wrap_classes
109
+ "#{css(:value_wrap)} #{nest_label? ? css(:value_wrap_end) : css(:value_wrap_center)}"
110
+ end
111
+
112
+ # The box's attributes with the part self-identification.
113
+ # @api private
114
+ def root_attributes
115
+ html_attributes.merge_if_not_set(
116
+ { "data-slot" => "chart-tooltip-content" }.merge(component_data_attributes)
117
+ )
118
+ end
119
+
120
+ # One resolved tooltip row: name via config, color via item-else-config.
121
+ # @api private
122
+ class Row
123
+ def initialize(item, config)
124
+ @item = item
125
+ @config = config
126
+ end
127
+
128
+ def key
129
+ (@item[:key] || @item[:name] || "value").to_s
130
+ end
131
+
132
+ def name
133
+ @config.label_for(key, @item[:name])
134
+ end
135
+
136
+ def color
137
+ @item[:color] || @config[key]&.color
138
+ end
139
+
140
+ def value
141
+ @item[:value]
142
+ end
143
+
144
+ def formatted_value
145
+ return nil if value.nil?
146
+ return ActiveSupport::NumberHelper.number_to_delimited(value) if value.is_a?(Numeric)
147
+
148
+ value.to_s
149
+ end
150
+
151
+ # Guarded inline style: colors reaching a style attribute must be
152
+ # CSS-safe (config colors already are; item colors get the same rule).
153
+ def indicator_style
154
+ return nil unless color
155
+ unless color.match?(Config::COLOR)
156
+ raise ArgumentError, "tooltip item color #{color.inspect} is not CSS-safe"
157
+ end
158
+
159
+ "--color-bg: #{color}; --color-border: #{color};"
160
+ end
161
+ end
162
+
163
+ private :chart_config, :rows, :nest_label?, :label_text, :row_classes, :indicator_classes, :value_wrap_classes
164
+ private :root_attributes
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ module TooltipContent
6
+ # The tooltip chrome across its three indicator variants - every
7
+ # chart tooltip composes from exactly these pieces.
8
+ class Preview < Poetry::Core::Preview::Base
9
+ CONFIG = {
10
+ desktop: { label: "Desktop", color: "var(--chart-1)" },
11
+ mobile: { label: "Mobile", color: "var(--chart-2)" }
12
+ }.freeze
13
+
14
+ ITEMS = [
15
+ { key: "desktop", value: 1260 },
16
+ { key: "mobile", value: 570 }
17
+ ].freeze
18
+
19
+ def default
20
+ render_component(config: CONFIG, items: ITEMS, label: "January")
21
+ end
22
+
23
+ def line_indicator
24
+ render_component(config: CONFIG, items: [{ key: "desktop", value: 1260 }],
25
+ label: "January", indicator: :line)
26
+ end
27
+
28
+ def dashed_indicator
29
+ render_component(config: CONFIG, items: [{ key: "desktop", value: 1260 }],
30
+ label: "January", indicator: :dashed)
31
+ end
32
+
33
+ def no_label
34
+ render_component(config: CONFIG, items: ITEMS, hide_label: true)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ module TooltipContent
6
+ # Re-expressed through the cn-* theme layer: the box, type,
7
+ # and row treatments ride cn-chart-tooltip-* rules. The indicator's
8
+ # border-(--color-border)/bg-(--color-bg) arbitrary properties stay
9
+ # inline - they read the per-row inline custom properties the
10
+ # component sets to the series color (mechanism).
11
+ class Style < Poetry::Core::Style
12
+ base "cn-chart-tooltip grid min-w-[8rem] items-start"
13
+
14
+ element :label, "cn-chart-tooltip-label"
15
+ element :inner, "grid gap-1.5"
16
+ element :row, "cn-chart-tooltip-row flex w-full flex-wrap items-stretch"
17
+ element :row_dot, "items-center"
18
+ element :indicator, "shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)"
19
+ element :indicator_dot, "h-2.5 w-2.5"
20
+ element :indicator_line, "w-1"
21
+ element :indicator_dashed, "w-0 border-[1.5px] border-dashed bg-transparent"
22
+ element :indicator_dashed_nested, "my-0.5"
23
+ element :value_wrap, "flex flex-1 justify-between leading-none"
24
+ element :value_wrap_center, "items-center"
25
+ element :value_wrap_end, "items-end"
26
+ element :name, "cn-chart-tooltip-name"
27
+ element :value, "cn-chart-tooltip-value"
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,6 @@
1
+ <%= content_tag(:div, **root_attributes.to_attributes) do %>
2
+ <%= render Poetry::Charts::TooltipContent::Component.new(
3
+ config: chart_config, items: items, label: hide_label ? nil : "…",
4
+ indicator: indicator, hide_label: hide_label, hide_indicator: hide_indicator
5
+ ) %>
6
+ <% end %>
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ # The chart families' internal hover-tooltip mount.
6
+ module TooltipLayer
7
+ # The hidden tooltip box a chart family renders when with_tooltip is
8
+ # set: the TooltipContent chrome pre-rendered with one row per series
9
+ # (names and indicator colors resolved server-side), positioned and
10
+ # text-swapped by the tooltip controller. Rendered by the chart
11
+ # families, never composed directly.
12
+ # @api private
13
+ class Component < Poetry::Core::Component
14
+ # The box is the parent frame controller's tooltip target - no
15
+ # registration here (TooltipWiring registers on the chart frame).
16
+ use_stimulus do
17
+ on :root do
18
+ controller(TooltipWiring::CONTROLLER) { target :tooltip }
19
+ end
20
+ end
21
+
22
+ option :config, ActiveModel::Type::Value.new, required: true,
23
+ doc: "The series config - key => { label:, color: } - " \
24
+ "resolving row names and colors."
25
+ option :series_keys, ActiveModel::Type::Value.new, required: true,
26
+ doc: "The series keys to pre-render rows for."
27
+ option :indicator, :symbol, default: :dot, doc: "The row swatch shape."
28
+ option :hide_label, :boolean, default: false, doc: "Hides the category label."
29
+ option :hide_indicator, :boolean, default: false, doc: "Hides the row swatches."
30
+
31
+ # The config: option wrapped as a {Poetry::Charts::Config}.
32
+ # @api private
33
+ def chart_config
34
+ @chart_config ||= Poetry::Charts::Config.wrap(config)
35
+ end
36
+
37
+ # Placeholder rows: value 0 guarantees the value span exists for
38
+ # the controller to swap; the box is hidden until a point is active.
39
+ # @api private
40
+ def items
41
+ series_keys.map { |key| { key: key, value: 0 } }
42
+ end
43
+
44
+ # The hidden box's attributes: part self-identification plus the
45
+ # parent frame controller's tooltip target.
46
+ # @api private
47
+ def root_attributes
48
+ html_attributes.merge_if_not_set(
49
+ {
50
+ "data-slot" => "chart-tooltip",
51
+ "hidden" => ""
52
+ }.merge(stimulus_attributes_for(:root))
53
+ .merge(component_data_attributes)
54
+ )
55
+ end
56
+
57
+ private :chart_config, :items, :root_attributes
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ module TooltipLayer
6
+ # The floating box: absolute within the relative Container, above the
7
+ # SVG, never intercepting the pointer (the SVG owns the events).
8
+ class Style < Poetry::Core::Style
9
+ base "pointer-events-none absolute z-10 w-max"
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ # Shared tooltip wiring for the chart families: the frame
6
+ # wrapper carries the controller, the SVG carries targets/actions plus
7
+ # the accessibilityLayer floor (focusable, role=application, arrows walk
8
+ # categories), and the hidden chrome pre-renders per-series rows the
9
+ # controller text-swaps - zero chart math in the browser.
10
+ module TooltipWiring
11
+ # Full-string controller identifier - the bare :tooltip shorthand
12
+ # would be ambiguous with the core tooltip controller.
13
+ CONTROLLER = "poetry--charts--tooltip"
14
+
15
+ # The wiring is declared, not hand-built: the frame carries the
16
+ # controller + sync value, the SVG carries the target and the
17
+ # pointer/keyboard actions (the accessibilityLayer floor), and the
18
+ # coordinates <script> is the data target the controller reads.
19
+ def self.included(base)
20
+ # Sync-group id: charts sharing the same sync: broadcast and
21
+ # receive each other's active index.
22
+ base.option :sync, :string
23
+
24
+ base.use_stimulus do
25
+ on :frame do
26
+ controller CONTROLLER, if: :tooltip? do
27
+ register
28
+ value :sync, if: -> { sync.present? }
29
+ end
30
+ end
31
+ on :svg do
32
+ controller CONTROLLER, if: :tooltip? do
33
+ target :svg
34
+ action :move, on: :pointermove
35
+ action :leave, on: :pointerleave
36
+ action :focus, on: :focus
37
+ action :blur, on: :blur
38
+ action :keydown, on: :keydown
39
+ end
40
+ end
41
+ on :coordinates do
42
+ controller CONTROLLER, if: :tooltip? do
43
+ target :data
44
+ end
45
+ end
46
+ # Rendered BY the TooltipLayer child inside the frame - mirrored
47
+ # here so the family's contract owns its full anatomy (the gate
48
+ # flags phantom if the layer ever stops rendering it).
49
+ on :tooltip_layer do
50
+ controller CONTROLLER, if: :tooltip? do
51
+ target :tooltip
52
+ end
53
+ end
54
+ end
55
+ end
56
+
57
+ # The tooltip slot's captured options, forcing lazy slot evaluation.
58
+ # @api private
59
+ def tooltip_config
60
+ tooltip?
61
+ @tooltip_config || {}
62
+ end
63
+
64
+ # The legend slot's options, guarded: toggle needs the live renderer
65
+ # (hiding a series recomputes the domain client-side).
66
+ # @api private
67
+ def legend_config
68
+ legend?
69
+ config = @legend_config || {}
70
+ if config[:toggle] && !(respond_to?(:live?) && live?)
71
+ raise ArgumentError, "with_legend toggle: true needs live: true - " \
72
+ "the toggle recomputes the domain client-side"
73
+ end
74
+ config
75
+ end
76
+
77
+ # The with_legend options forwarded to the LegendContent child.
78
+ LEGEND_OPTIONS = %i[align items hide_icon toggle].freeze
79
+
80
+ # The LegendContent child built from the chart's config and legend
81
+ # options.
82
+ # @api private
83
+ def legend_component
84
+ LegendContent::Component.new(config: chart_config, **legend_config.slice(*LEGEND_OPTIONS))
85
+ end
86
+
87
+ # The hover cursor, on by default when a tooltip attaches: bars get
88
+ # a translucent band rect, line/area/composed a vertical rule -
89
+ # hidden until the tooltip controller positions it at the active
90
+ # index. Renders UNDER the series (after the grid) so marks stay
91
+ # on top.
92
+ # @api private
93
+ def cursor_svg(kind)
94
+ return unless tooltip? && tooltip_config.fetch(:cursor, true)
95
+
96
+ case kind
97
+ when :band
98
+ if respond_to?(:horizontal?) && horizontal?
99
+ tag.rect("data-slot": "chart-cursor", "aria-hidden": true, class: css(:cursor_band),
100
+ x: fnum(cartesian.plot_left), y: 0,
101
+ width: fnum(cartesian.plot_right - cartesian.plot_left), height: 0,
102
+ display: "none")
103
+ else
104
+ tag.rect("data-slot": "chart-cursor", "aria-hidden": true, class: css(:cursor_band),
105
+ x: fnum(cartesian.plot_left), y: fnum(cartesian.plot_top),
106
+ width: 0, height: fnum(cartesian.plot_bottom - cartesian.plot_top),
107
+ display: "none")
108
+ end
109
+ when :line
110
+ tag.line("data-slot": "chart-cursor", "aria-hidden": true, class: css(:cursor_line),
111
+ x1: 0, x2: 0, y1: fnum(cartesian.plot_top), y2: fnum(cartesian.plot_bottom),
112
+ display: "none")
113
+ end
114
+ end
115
+
116
+ # Attributes for the frame div (display: contents) wrapping svg +
117
+ # chrome + coordinates: the declared frame wiring - tooltip
118
+ # registration + sync here, whichever of motion/live/window the
119
+ # family mixes in declared by those modules - plus the part-contract
120
+ # self-identification: the frame is the chart type's own root (the
121
+ # Container wrapper self-ids as "container", so without this the
122
+ # SVG anatomy would have no owner in the part contract).
123
+ # @api private
124
+ def frame_attributes
125
+ { "data-component" => self.class.component_title }.merge(stimulus_attributes_for(:frame))
126
+ end
127
+
128
+ # role=img for static charts; when the tooltip attaches the SVG
129
+ # becomes the focusable accessibility layer (role=application +
130
+ # tabindex) so arrow keys walk the categories. Motion rides the
131
+ # same tag: data-animate + the --poetry-motion-* knobs.
132
+ # @api private
133
+ def svg_interaction_attributes
134
+ base = tooltip? ? { "role" => "application", "tabindex" => "0" } : { "role" => "img" }
135
+ base.merge!(stimulus_attributes_for(:svg))
136
+ respond_to?(:motion_svg_attributes) ? base.merge(motion_svg_attributes) : base
137
+ end
138
+
139
+ # The TooltipLayer child built from the chart's config and tooltip
140
+ # options.
141
+ # @api private
142
+ def tooltip_layer_component
143
+ TooltipLayer::Component.new(
144
+ config: chart_config,
145
+ series_keys: series_entries.map(&:key),
146
+ indicator: tooltip_config.fetch(:indicator, :dot),
147
+ hide_label: tooltip_config.fetch(:hide_label, false),
148
+ hide_indicator: tooltip_config.fetch(:hide_indicator, false)
149
+ )
150
+ end
151
+
152
+ # The hover markers lines/areas show at the active index -
153
+ # server-rendered hidden circles the controller toggles.
154
+ # @api private
155
+ def active_dot_markers(entry)
156
+ cartesian.points(entry).each_with_index.filter_map do |point, i|
157
+ next if point[:value].nan?
158
+
159
+ { index: i, x: point[:x], y: point[:y1] }
160
+ end
161
+ end
162
+
163
+ private :tooltip_config, :legend_config, :legend_component, :cursor_svg, :frame_attributes
164
+ private :svg_interaction_attributes, :tooltip_layer_component, :active_dot_markers
165
+ end
166
+ end
167
+ end