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,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ module Container
6
+ # The frame previewed standalone: config-driven series colors flowing
7
+ # into inline SVG through var(--color-<key>) - the whole container
8
+ # contract, before any engine exists.
9
+ class Preview < Poetry::Core::Preview::Base
10
+ CONFIG = {
11
+ desktop: { label: "Desktop", color: "var(--chart-1)" },
12
+ mobile: { label: "Mobile", color: "var(--chart-2)" }
13
+ }.freeze
14
+
15
+ def default
16
+ render_component(config: CONFIG, id: "preview") do
17
+ <<~SVG.html_safe
18
+ <svg viewBox="0 0 300 150" style="height:100%;width:100%" role="img" aria-label="Placeholder chart">
19
+ <rect x="20" y="60" width="40" height="80" rx="4" fill="var(--color-desktop)"></rect>
20
+ <rect x="70" y="30" width="40" height="110" rx="4" fill="var(--color-mobile)"></rect>
21
+ <rect x="130" y="80" width="40" height="60" rx="4" fill="var(--color-desktop)"></rect>
22
+ <rect x="180" y="45" width="40" height="95" rx="4" fill="var(--color-mobile)"></rect>
23
+ </svg>
24
+ SVG
25
+ end
26
+ end
27
+
28
+ def themed
29
+ render_component(
30
+ config: {
31
+ revenue: { label: "Revenue", theme: { light: "oklch(0.6 0.15 250)", dark: "oklch(0.75 0.12 250)" } }
32
+ },
33
+ id: "preview-themed"
34
+ ) do
35
+ <<~SVG.html_safe
36
+ <svg viewBox="0 0 300 150" style="height:100%;width:100%" role="img" aria-label="Themed placeholder chart">
37
+ <circle cx="150" cy="75" r="50" fill="var(--color-revenue)"></circle>
38
+ </svg>
39
+ SVG
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ module Container
6
+ # The chart frame chrome, with NO descendant overrides into the
7
+ # chart body - descendant selectors exist to style DOM one does not
8
+ # own, and poetry OWNS its SVG, so tick text (muted-foreground),
9
+ # grid (border/50), and sector/dot outline-hidden land as direct
10
+ # classes on the engine's own parts; the container keeps only what
11
+ # is truly container-level. Adapter/island content brings its own
12
+ # inner styling (a declared degradation).
13
+ class Style < Poetry::Core::Style
14
+ # relative anchors the absolute tooltip layer (visual no-op).
15
+ # flex-col stacks the legend under (or over, via order-first) the
16
+ # chart SVG, reserving plot space for it; a row layout would park
17
+ # the legend beside the plot.
18
+ base "relative flex aspect-video flex-col items-center justify-center text-xs [&_svg]:outline-hidden"
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ # Error bars for line, bar, and scatter series. A series slot takes
6
+ # error_key: naming a row key holding either a symmetric offset (err)
7
+ # or [low, high] offsets from the value; the whisker renders
8
+ # cap - stem - cap in the foreground color (a neutral that stays
9
+ # visible over same-colored marks), width 5.
10
+ # @api private
11
+ module ErrorBars
12
+ # Whisker cap width in pixels.
13
+ DEFAULT_WIDTH = 5
14
+
15
+ # [low_value, high_value] in DATA units, or nil when the row has no
16
+ # error entry.
17
+ def error_range(row, error_key, value)
18
+ err = row.to_h.transform_keys(&:to_s)[error_key.to_s]
19
+ return nil if err.nil?
20
+
21
+ low, high = err.is_a?(Array) ? err : [err, err]
22
+ [value - low.to_f, value + high.to_f]
23
+ end
24
+
25
+ # cap - stem - cap around a vertical span, in pixels.
26
+ def error_bar_path(center_x, y_low, y_high, width)
27
+ half = width.to_f / 2.0
28
+ f = ->(v) { fnum(v) }
29
+ "M#{f.call(center_x - half)},#{f.call(y_low)}H#{f.call(center_x + half)}" \
30
+ "M#{f.call(center_x)},#{f.call(y_low)}V#{f.call(y_high)}" \
31
+ "M#{f.call(center_x - half)},#{f.call(y_high)}H#{f.call(center_x + half)}"
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,10 @@
1
+ <%= content_tag(:div, **root_attributes.to_attributes) do %>
2
+ <% rows.each do |row| %>
3
+ <%= content_tag(toggle ? :button : :div, **(toggle ? toggle_attributes(row) : { data: { slot: "chart-legend-item" }, class: css(:item) })) do %>
4
+ <% unless hide_icon || row[:color].nil? %>
5
+ <div data-slot="chart-legend-swatch" class="<%= css(:swatch) %>" style="<%= swatch_style(row[:color]) %>"></div>
6
+ <% end %>
7
+ <%= row[:name] %>
8
+ <% end %>
9
+ <% end %>
10
+ <% end %>
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ # The chart legend chrome.
6
+ module LegendContent
7
+ # The legend chrome: a centered row of swatch + label pairs. Items
8
+ # default to the config's colored entries (the common case - every
9
+ # configured series, in config order); pass items: [{ key:, color: }]
10
+ # to override, e.g. per-slice pie legends.
11
+ #
12
+ # @example
13
+ # <%= poetry_chart_legend_content(config: config) %>
14
+ class Component < Poetry::Core::Component
15
+ # Projected into the registry and agent surface.
16
+ AGENT_RULES = [
17
+ "The legend derives from the chart config by default - omit items: unless slices differ from series.",
18
+ "align: :top pads below (pb-3), :bottom (default) pads above (pt-3) - matching the chart edge it sits on."
19
+ ].freeze
20
+
21
+ style :align, default: :bottom, required: true, variants: %i[top bottom],
22
+ doc: "Which chart edge the legend sits on: :top pads below it, :bottom (the default) pads " \
23
+ "above. A style axis, not an option - options silently drop the dictionary's variant " \
24
+ "classes."
25
+
26
+ option :config, ActiveModel::Type::Value.new, required: true,
27
+ doc: "The series config - key => { label:, color: } - the " \
28
+ "default item source."
29
+ option :items, ActiveModel::Type::Value.new,
30
+ doc: "Explicit entries ([{ key:, name:, color: }]) overriding the config-derived list."
31
+ option :hide_icon, :boolean, default: false, doc: "Hides the color swatches, leaving labels only."
32
+ option :toggle, :boolean, default: false,
33
+ doc: "Interactive legend: items render as buttons that toggle their series through " \
34
+ "the live controller (the host chart guards that live: is on)."
35
+
36
+ part "chart-legend-content", "The legend row (<div>) - centered swatch + label pairs"
37
+ part "chart-legend-item", "One legend entry (<div>; a <button> in toggle mode)",
38
+ states: {
39
+ "data-key" => "in toggle mode - the series key the button toggles",
40
+ "data-hidden" => "in toggle mode - the item's series is toggled off (the live " \
41
+ "controller stamps it at runtime; the item dims)"
42
+ }
43
+ part "chart-legend-swatch", "The color swatch (<div>) - inline background-color carries " \
44
+ "the entry's color; omitted with hide_icon or colorless entries"
45
+
46
+ # The config: option wrapped as a {Poetry::Charts::Config}.
47
+ # @api private
48
+ def chart_config
49
+ @chart_config ||= Poetry::Charts::Config.wrap(config)
50
+ end
51
+
52
+ # The resolved entries: explicit items:, else the config's
53
+ # colored entries in config order.
54
+ # @api private
55
+ def rows
56
+ @rows ||= if items.present?
57
+ Array(items).map { |item| resolve(item.symbolize_keys) }
58
+ else
59
+ chart_config.color_entries.map do |entry|
60
+ { key: entry.key, name: entry.label || entry.key,
61
+ color: entry.color_for(:light) ? swatch_color(entry) : nil }
62
+ end
63
+ end
64
+ end
65
+
66
+ # The toggle buttons speak straight to the live controller on the
67
+ # chart frame (Stimulus action params carry the series key).
68
+ # @api private
69
+ def toggle_attributes(row)
70
+ {
71
+ type: "button",
72
+ data: {
73
+ slot: "chart-legend-item",
74
+ key: row[:key],
75
+ action: "click->poetry--charts--live#toggleSeries",
76
+ "poetry--charts--live-key-param": row[:key]
77
+ },
78
+ class: "#{css(:item)} #{css(:toggle)}"
79
+ }
80
+ end
81
+
82
+ # The legend row's attributes with the part self-identification.
83
+ # @api private
84
+ def root_attributes
85
+ html_attributes.merge_if_not_set(
86
+ { "data-slot" => "chart-legend-content" }.merge(component_data_attributes)
87
+ )
88
+ end
89
+
90
+ # A swatch's guarded inline background (colors reaching a style
91
+ # attribute must be CSS-safe).
92
+ # @api private
93
+ def swatch_style(color)
94
+ return nil unless color
95
+ raise ArgumentError, "legend color #{color.inspect} is not CSS-safe" unless color.match?(Config::COLOR)
96
+
97
+ "background-color: #{color};"
98
+ end
99
+
100
+ private
101
+
102
+ # Themed entries swatch through var(--color-<key>) (the container's
103
+ # emission), so the swatch follows dark mode; flat colors inline.
104
+ def swatch_color(entry)
105
+ entry.theme ? "var(--color-#{entry.key})" : entry.color
106
+ end
107
+
108
+ def resolve(item)
109
+ key = (item[:key] || item[:name]).to_s
110
+ entry = chart_config[key]
111
+ {
112
+ key: key,
113
+ name: chart_config.label_for(key, item[:name]),
114
+ color: item[:color] || (entry && swatch_color(entry))
115
+ }
116
+ end
117
+
118
+ private :chart_config, :rows, :toggle_attributes, :root_attributes, :swatch_style
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ module LegendContent
6
+ # The legend chrome: config-derived (the default) and explicit items.
7
+ class Preview < Poetry::Core::Preview::Base
8
+ CONFIG = {
9
+ desktop: { label: "Desktop", color: "var(--chart-1)" },
10
+ mobile: { label: "Mobile", color: "var(--chart-2)" }
11
+ }.freeze
12
+
13
+ def default
14
+ render_component(config: CONFIG)
15
+ end
16
+
17
+ def top_aligned
18
+ render_component(config: CONFIG, align: :top)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ module LegendContent
6
+ # Re-expressed through the cn-* theme layer. flex-wrap stays
7
+ # poetry's one structural addition (long lists wrap in the real flex
8
+ # column); order-first stays inline in the top align (layout
9
+ # mechanism - the padding rides the theme rule). The toggle's
10
+ # data-hidden dim is live-controller state, inline.
11
+ class Style < Poetry::Core::Style
12
+ base "cn-chart-legend flex flex-wrap items-center justify-center"
13
+
14
+ variant :align, {
15
+ top: "cn-chart-legend-align-top order-first",
16
+ bottom: "cn-chart-legend-align-bottom"
17
+ }
18
+ element :item, "cn-chart-legend-item flex items-center"
19
+ element :swatch, "cn-chart-legend-swatch shrink-0"
20
+ # Toggle buttons dim when their series hides (the live controller
21
+ # stamps data-hidden).
22
+ element :toggle, "cursor-pointer data-hidden:opacity-40"
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,85 @@
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
+ <% cartesian.y_ticks.each do |tick| %>
8
+ <line class="<%= css(:grid_line) %>" x1="<%= fnum(cartesian.plot_left) %>" x2="<%= fnum(cartesian.plot_right) %>" y1="<%= fnum(cartesian.y_scale.call(tick)) %>" y2="<%= fnum(cartesian.y_scale.call(tick)) %>" />
9
+ <% end %>
10
+ <% end %>
11
+ <% if grid_config.vertical %>
12
+ <% cartesian.x_centers.each do |x| %>
13
+ <line class="<%= css(:grid_line) %>" x1="<%= fnum(x) %>" x2="<%= fnum(x) %>" y1="<%= fnum(cartesian.plot_top) %>" y2="<%= fnum(cartesian.plot_bottom) %>" />
14
+ <% end %>
15
+ <% end %>
16
+ </g>
17
+ <% end %>
18
+ <%= cursor_svg(:line) %>
19
+ <g data-slot="chart-lines">
20
+ <% series_entries.each do |entry| %>
21
+ <% if (d = series_path(entry)) %>
22
+ <%# pathLength="1" normalizes the path to unit length - the motion stylesheet's dash draw-in needs no measurement. %>
23
+ <path data-slot="chart-line" data-key="<%= entry.key %>" d="<%= d %>" fill="none" stroke="var(--color-<%= entry.key %>)" stroke-width="<%= entry.stroke_width %>"<% if animate? %> pathLength="1"<% end %> />
24
+ <% end %>
25
+ <% if entry.dots %>
26
+ <g data-slot="chart-dots" data-key="<%= entry.key %>">
27
+ <% markers(entry).each do |marker| %>
28
+ <circle data-slot="chart-dot" cx="<%= fnum(marker[:x]) %>" cy="<%= fnum(marker[:y]) %>" r="<%= entry.dot_radius %>" fill="<%= dot_fill(entry, marker) %>" />
29
+ <% end %>
30
+ </g>
31
+ <% end %>
32
+ <% if entry.error_key %>
33
+ <g data-slot="chart-error-bars" data-key="<%= entry.key %>" aria-hidden="true">
34
+ <% markers(entry).each do |marker| %>
35
+ <% range = error_range(marker[:row], entry.error_key, marker[:value]) %>
36
+ <% if range %>
37
+ <path class="<%= css(:error_bar) %>" d="<%= error_bar_path(marker[:x], cartesian.y_scale.call(range[0]), cartesian.y_scale.call(range[1]), entry.error_width) %>" fill="none" />
38
+ <% end %>
39
+ <% end %>
40
+ </g>
41
+ <% end %>
42
+ <% if entry.labels %>
43
+ <g data-slot="chart-labels" data-key="<%= entry.key %>">
44
+ <% markers(entry).each do |marker| %>
45
+ <text class="<%= css(:label) %>" x="<%= fnum(marker[:x]) %>" y="<%= fnum(marker[:y] - 12) %>" text-anchor="middle"><%= marker_label(marker) %></text>
46
+ <% end %>
47
+ </g>
48
+ <% end %>
49
+ <% end %>
50
+ </g>
51
+ <%= reference_marks_svg %>
52
+ <% if tooltip? %>
53
+ <g data-slot="chart-active-dots" aria-hidden="true">
54
+ <% series_entries.each do |entry| %>
55
+ <% active_dot_markers(entry).each do |marker| %>
56
+ <circle data-slot="chart-active-dot" data-key="<%= entry.key %>" data-index="<%= marker[:index] %>" cx="<%= fnum(marker[:x]) %>" cy="<%= fnum(marker[:y]) %>" r="4" fill="var(--color-<%= entry.key %>)" display="none" />
57
+ <% end %>
58
+ <% end %>
59
+ </g>
60
+ <% end %>
61
+ <% if x_axis? %>
62
+ <g data-slot="chart-x-axis">
63
+ <% cartesian.categories.each_with_index do |category, i| %>
64
+ <text class="<%= css(:tick) %>" x="<%= fnum(cartesian.x_centers[i]) %>" y="<%= fnum(cartesian.plot_bottom + x_axis_config.tick_margin) %>" dy="0.71em" text-anchor="middle"><%= x_tick_label(category) %></text>
65
+ <% end %>
66
+ </g>
67
+ <% end %>
68
+ <% if y_axis? %>
69
+ <g data-slot="chart-y-axis">
70
+ <% cartesian.y_ticks.each do |tick| %>
71
+ <text class="<%= css(:tick) %>" x="<%= fnum(cartesian.plot_left - y_axis_config.tick_margin) %>" y="<%= fnum(cartesian.y_scale.call(tick)) %>" dy="0.355em" text-anchor="end"><%= y_tick_label(tick) %></text>
72
+ <% end %>
73
+ </g>
74
+ <% end %>
75
+ <%= brush_svg %>
76
+ <%= zoom_selection_svg %>
77
+ </svg>
78
+ <% if legend? %>
79
+ <%= render legend_component %>
80
+ <% end %>
81
+ <% if tooltip? %><%= render tooltip_layer_component %><% end %>
82
+ <script type="application/json" data-slot="chart-coordinates" <%= tag.attributes(stimulus_attributes_for(:coordinates)) %>><%= script_json(coordinates_json) %></script>
83
+ <% if live? %><script type="application/json" data-slot="chart-live-payload" <%= tag.attributes(stimulus_attributes_for(:live_payload)) %>><%= script_json(live_payload_json) %></script>
84
+ <% end %> <% end %>
85
+ <% end %>
@@ -0,0 +1,252 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ # The line chart family.
6
+ module LineChart
7
+ # Renders a line chart - one stroked curve per series, no fills -
8
+ # through the cartesian pipeline. Adds dot variants (solid
9
+ # series-colored dots, or per-point colors read from a data key),
10
+ # value labels stamped above the points, and error whiskers.
11
+ #
12
+ # @example Two series over a formatted month axis
13
+ # <%= poetry_chart :line, data:, config:, margin: { left: 12, right: 12 } do |c| %>
14
+ # <% c.with_grid %>
15
+ # <% c.with_x_axis data_key: :month, tick_formatter: ->(v) { v[0, 3] } %>
16
+ # <% c.with_line data_key: :desktop %>
17
+ # <% c.with_line data_key: :mobile %>
18
+ # <% end %>
19
+ class Component < Poetry::Core::Component
20
+ include Poetry::Charts::ChartFamily
21
+ include Poetry::Charts::TooltipWiring
22
+ include Poetry::Charts::Motion
23
+ include Poetry::Charts::Live
24
+ include Poetry::Charts::ReferenceMarks
25
+ include Poetry::Charts::ErrorBars
26
+
27
+ # Projected into the registry and agent surface.
28
+ AGENT_RULES = [
29
+ "Compose from slots: with_grid / with_x_axis(data_key:) / with_line(data_key:) / with_legend.",
30
+ "Lines default to stroke-width 2 and NO dots (the ported block look); dots: true adds them.",
31
+ "dot_color_key: reads a per-row data key for per-point dot colors (the dots-colors block).",
32
+ "labels: true stamps each value above its point; give the chart margin top when using it.",
33
+ "Colors come from the config - never set stroke on a line directly.",
34
+ "Entrance animation is on by default (source parity); animate: false for a static chart. " \
35
+ "Reduced-motion users always get the finished chart."
36
+ ].freeze
37
+
38
+ # One with_line call's captured series config.
39
+ Series = Data.define(:key, :curve, :stroke_width, :dots, :dot_radius, :dot_color_key, :labels,
40
+ :error_key, :error_width) do
41
+ # The cartesian pipeline contract (lines never stack).
42
+ def stack = nil
43
+ end
44
+
45
+ option :data, ActiveModel::Type::Value.new, required: true,
46
+ doc: "The rows to plot: an array of hashes, one per x category."
47
+ option :config, ActiveModel::Type::Value.new, required: true,
48
+ doc: "The series config - key => { label:, color: } - naming " \
49
+ "and coloring every series."
50
+ option :id, :string,
51
+ doc: "Explicit DOM id token, stable across renders; otherwise the chart gets a unique per-render id."
52
+ option :width, :integer, default: 640,
53
+ doc: "ViewBox width in pixels; the rendered chart scales to its container."
54
+ option :height, :integer, default: 360, doc: "ViewBox height in pixels."
55
+ option :margin, ActiveModel::Type::Value.new,
56
+ doc: "Plot margin overrides ({ top:, right:, bottom:, left: }), merged over the defaults."
57
+ option :label, :string,
58
+ doc: "Accessible name for the chart SVG; defaults to one built from the configured series."
59
+
60
+ motion_options
61
+ live_option
62
+
63
+ part "chart-svg", "The chart canvas (<svg>) - server-computed geometry in a fixed viewBox; " \
64
+ "role=img, or the focusable role=application accessibilityLayer when the " \
65
+ "tooltip attaches",
66
+ states: {
67
+ "data-animate" => "present when animate (the default) - the motion stylesheet and " \
68
+ "controller key the entrance off it",
69
+ "data-motion" => { condition: "runtime - the motion rig stamps the animation " \
70
+ "lifecycle (entrance/morph, then settled)",
71
+ values: %w[entrance morph settled] }
72
+ },
73
+ vars: {
74
+ "--poetry-motion-duration" => "the entrance/morph duration (animation_duration, ms)",
75
+ "--poetry-motion-easing" => "the animation easing keyword (animation_easing)",
76
+ "--poetry-motion-delay" => "the pre-animation hold (animation_begin, ms)"
77
+ }
78
+ part "chart-grid", "The gridline group (with_grid) - horizontal and/or vertical rules " \
79
+ "across the plot"
80
+ part "chart-cursor", "The hover cursor, hidden until the tooltip controller positions " \
81
+ "and reveals it at the active index - a vertical rule or a " \
82
+ "translucent band rect (bar charts)"
83
+ part "chart-lines", "The line-mark group - each series' curve plus its companion marks"
84
+ part "chart-line", "One series' stroked curve - pathLength=1 when animating so the " \
85
+ "dash draw-in needs no measurement",
86
+ states: { "data-key" => "the series key" }
87
+ part "chart-dots", "One series' point-dot group (dots: true)",
88
+ states: { "data-key" => "the series key" }
89
+ part "chart-dot", "One point dot (<circle>)"
90
+ part "chart-error-bars", "One series' error-whisker group (error_key:) - cap-stem-cap " \
91
+ "paths in the foreground color",
92
+ states: { "data-key" => "the series key" }
93
+ part "chart-labels", "One series' value-label group (labels: true)",
94
+ states: { "data-key" => "the series key" }
95
+ part "chart-reference", "The reference-mark group (with_reference_line/_area/_dot), " \
96
+ "painted above the series"
97
+ part "chart-active-dots", "The hover-marker group (with_tooltip) - pre-rendered hidden " \
98
+ "circles for every series x index"
99
+ part "chart-active-dot", "One hover marker - display=none until the tooltip controller " \
100
+ "reveals the active index's dot",
101
+ states: {
102
+ "data-key" => "the series key",
103
+ "data-index" => "the datum index",
104
+ "data-active" => "runtime - rides the marker while its index is the active one"
105
+ }
106
+ part "chart-x-axis", "The x-axis tick-label group (with_x_axis)"
107
+ part "chart-coordinates", "The embedded per-index geometry payload " \
108
+ "(<script type=application/json>) the tooltip controller " \
109
+ "reads - zero chart math in the browser"
110
+
111
+ renders_many :lines,
112
+ doc: "A line series bound to data_key:. dots: marks each point; dot_color_key: reads per-point " \
113
+ "dot colors from the row; labels: stamps each value above its point; error_key: adds " \
114
+ "error whiskers.",
115
+ renders: lambda { |data_key:, curve: :natural, stroke_width: 2, dots: false,
116
+ dot_radius: 3, dot_color_key: nil, labels: false, error_key: nil, error_width: 5|
117
+ raise ArgumentError, "unknown curve #{curve.inspect}" unless CURVES.include?(curve.to_sym)
118
+
119
+ (@series_entries ||= []) << Series.new(key: data_key.to_s, curve: curve.to_sym, stroke_width:,
120
+ dots:, dot_radius:, dot_color_key: dot_color_key&.to_s,
121
+ labels:, error_key: error_key&.to_s, error_width:)
122
+ nil
123
+ }
124
+
125
+ include Poetry::Charts::CartesianFamily
126
+
127
+ # The captured Series configs, forcing lazy slot evaluation.
128
+ # @api private
129
+ def series_entries
130
+ lines? # force slot evaluation (slots evaluate lazily)
131
+ @series_entries ||= []
132
+ end
133
+
134
+ # The chart's cartesian geometry, built once per render.
135
+ # @api private
136
+ def cartesian
137
+ @cartesian ||= Cartesian.new(
138
+ data: data,
139
+ series: series_entries,
140
+ width: width,
141
+ height: height,
142
+ x_key: x_axis_config&.data_key,
143
+ margin: live_margin,
144
+ category_axis: x_axis?,
145
+ value_axis: y_axis?,
146
+ y_tick_count: y_axis_config&.tick_count || 5
147
+ )
148
+ end
149
+
150
+ # One series' stroked curve path.
151
+ # @api private
152
+ def series_path(entry)
153
+ points = cartesian.points(entry)
154
+ Geometry::Line.new(
155
+ x: ->(p, _i) { p[:x] },
156
+ y: ->(p, _i) { p[:y1] },
157
+ curve: entry.curve,
158
+ defined: ->(p, _i) { !p[:value].nan? }
159
+ ).path(points)
160
+ end
161
+
162
+ # The visible points for dots/labels (NaN values render nothing).
163
+ # @api private
164
+ def markers(entry)
165
+ cartesian.points(entry).each_with_index.filter_map do |point, i|
166
+ next if point[:value].nan?
167
+
168
+ { x: point[:x], y: point[:y1], value: point[:value], row: data[i] }
169
+ end
170
+ end
171
+
172
+ # Per-point dot color: the dot_color_key row value (CSS-validated),
173
+ # else the series color.
174
+ # @api private
175
+ def dot_fill(entry, marker)
176
+ if entry.dot_color_key
177
+ color = marker[:row].to_h.transform_keys(&:to_s)[entry.dot_color_key].to_s
178
+ raise ArgumentError, "dot color #{color.inspect} is not CSS-safe" unless color.match?(Config::COLOR)
179
+
180
+ color
181
+ else
182
+ "var(--color-#{entry.key})"
183
+ end
184
+ end
185
+
186
+ # One x tick's label, through the slot's formatter when given.
187
+ # @api private
188
+ def x_tick_label(category)
189
+ formatter = x_axis_config&.tick_formatter
190
+ formatter ? formatter.call(category).to_s : category.to_s
191
+ end
192
+
193
+ # One y tick's label, through the slot's formatter when given.
194
+ # @api private
195
+ def y_tick_label(tick)
196
+ formatter = y_axis_config&.tick_formatter
197
+ formatter ? formatter.call(tick).to_s : Geometry.js_number(tick.to_f)
198
+ end
199
+
200
+ # A point's value label, integers shown bare.
201
+ # @api private
202
+ def marker_label(marker)
203
+ value = marker[:value]
204
+ value == value.to_i ? value.to_i.to_s : value.to_s
205
+ end
206
+
207
+ # ChartFamily#svg_label's chart-type lead-in.
208
+ # @api private
209
+ def svg_label_prefix = "Line chart"
210
+
211
+ # The embedded per-index geometry payload the tooltip controller
212
+ # reads.
213
+ # @api private
214
+ def coordinates_json
215
+ cartesian.coordinates.to_json
216
+ end
217
+
218
+ # -- live mode ---------------------------------------------------
219
+
220
+ # The chart type in the live spec.
221
+ # @api private
222
+ def live_type = :line
223
+
224
+ # The series list serialized into the live spec.
225
+ # @api private
226
+ def live_series
227
+ series_entries.map { |e| { data_key: e.key, curve: e.curve } }
228
+ end
229
+
230
+ # The axis config serialized into the live spec.
231
+ # @api private
232
+ def live_axes
233
+ axes = {}
234
+ axes[:x] = { data_key: x_axis_config.data_key } if x_axis_config&.data_key
235
+ axes[:y] = { tick_count: y_axis_config.tick_count } if y_axis_config
236
+ axes
237
+ end
238
+
239
+ # The x scale kind the live renderer rebuilds.
240
+ # @api private
241
+ def live_x_scale_type = :point
242
+ # Whether the live frame renders a category axis.
243
+ # @api private
244
+ def live_category_axis? = x_axis?
245
+
246
+ private :cartesian, :series_path, :markers, :dot_fill, :x_tick_label, :y_tick_label
247
+ private :marker_label, :svg_label_prefix, :coordinates_json, :live_type, :live_series, :live_axes
248
+ private :live_x_scale_type, :live_category_axis?
249
+ end
250
+ end
251
+ end
252
+ end