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,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ # The identity chassis every chart family shares: the data-chart id
6
+ # scope, the wrapped config, the accessible SVG name, and the SVG
7
+ # number formatter. Families supply svg_label_prefix (the accessible
8
+ # name's chart-type lead-in); a family whose default name reads from a
9
+ # different surface (scatter: series keys through the config) overrides
10
+ # svg_label itself.
11
+ #
12
+ # @example A minimal chart family
13
+ # class Sparkline::Component < Poetry::Core::Component
14
+ # include Poetry::Charts::ChartFamily
15
+ #
16
+ # option :config, ActiveModel::Type::Value.new, required: true
17
+ # option :id, :string
18
+ # option :label, :string
19
+ #
20
+ # # The accessible name's chart-type lead-in.
21
+ # def svg_label_prefix = "Sparkline"
22
+ # end
23
+ module ChartFamily
24
+ # The data-chart scope: explicit id when given (stable for tests /
25
+ # multiple charts), else unique per render.
26
+ #
27
+ # @return [String]
28
+ def chart_id
29
+ @chart_id ||= (dom_id_token(id) ? "chart-#{dom_id_token(id)}" : poetry_instance_id("chart"))
30
+ end
31
+
32
+ # The config: option wrapped as a {Poetry::Charts::Config} - series
33
+ # entries with labels and colors.
34
+ #
35
+ # @return [Poetry::Charts::Config]
36
+ def chart_config
37
+ @chart_config ||= Poetry::Charts::Config.wrap(config)
38
+ end
39
+
40
+ # The accessible name for the role=img SVG: explicit label: or a
41
+ # sensible default from the configured series.
42
+ #
43
+ # @return [String]
44
+ def svg_label
45
+ label.presence || "#{svg_label_prefix}: #{chart_config.entries.map { |e| e.label || e.key }.join(", ")}"
46
+ end
47
+
48
+ # SVG attribute numbers: 2-decimal rounding, script-style formatting
49
+ # (bare integers) - keeps the markup compact and stable.
50
+ # @api private
51
+ def fnum(value)
52
+ Geometry.js_number((value * 100).round / 100.0)
53
+ end
54
+
55
+ private :fnum
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,95 @@
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
+ <% area_entries = series_entries.select { |e| e.mark == :area } %>
5
+ <% if area_entries.any?(&:gradient) || (animate? && area_entries.any?) %>
6
+ <defs>
7
+ <% area_entries.select(&:gradient).each do |entry| %>
8
+ <linearGradient id="<%= gradient_id(entry) %>" x1="0" y1="0" x2="0" y2="1">
9
+ <stop offset="5%" stop-color="var(--color-<%= entry.key %>)" stop-opacity="0.8" />
10
+ <stop offset="95%" stop-color="var(--color-<%= entry.key %>)" stop-opacity="0.1" />
11
+ </linearGradient>
12
+ <% end %>
13
+ <% if animate? && area_entries.any? %>
14
+ <clipPath id="<%= chart_id %>-reveal"><rect data-slot="chart-motion-reveal" x="0" y="0" width="<%= width %>" height="<%= height %>" /></clipPath>
15
+ <% end %>
16
+ </defs>
17
+ <% end %>
18
+ <% if grid? %>
19
+ <g data-slot="chart-grid" aria-hidden="true">
20
+ <% if grid_config.horizontal %>
21
+ <% cartesian.y_ticks.each do |tick| %>
22
+ <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)) %>" />
23
+ <% end %>
24
+ <% end %>
25
+ <% if grid_config.vertical %>
26
+ <% cartesian.x_centers.each do |x| %>
27
+ <line class="<%= css(:grid_line) %>" x1="<%= fnum(x) %>" x2="<%= fnum(x) %>" y1="<%= fnum(cartesian.plot_top) %>" y2="<%= fnum(cartesian.plot_bottom) %>" />
28
+ <% end %>
29
+ <% end %>
30
+ </g>
31
+ <% end %>
32
+ <%= cursor_svg(:line) %>
33
+ <%# Declaration order IS paint order. %>
34
+ <% series_entries.each do |entry| %>
35
+ <% case entry.mark %>
36
+ <% when :area %>
37
+ <g data-slot="chart-areas"<% if animate? %> clip-path="url(#<%= chart_id %>-reveal)"<% end %>>
38
+ <% if (d = area_path(entry)) %>
39
+ <path data-slot="chart-area" data-key="<%= entry.key %>" d="<%= d %>" fill="<%= area_fill(entry) %>" fill-opacity="<%= entry.fill_opacity %>" stroke="none" />
40
+ <path data-slot="chart-area-stroke" data-key="<%= entry.key %>" d="<%= top_line_path(entry) %>" fill="none" stroke="var(--color-<%= entry.key %>)" stroke-width="<%= entry.stroke_width %>" />
41
+ <% end %>
42
+ </g>
43
+ <% when :bar %>
44
+ <g data-slot="chart-bar-series" data-key="<%= entry.key %>">
45
+ <% cells(entry).each do |cell| %>
46
+ <path data-slot="chart-bar" data-key="<%= entry.key %>" data-index="<%= cell[:index] %>" d="<%= bar_path(entry, cell) %>" fill="var(--color-<%= entry.key %>)"<% if animate? %> data-motion-origin="<%= motion_origin(cell) %>"<% end %> />
47
+ <% end %>
48
+ </g>
49
+ <% when :line %>
50
+ <g data-slot="chart-lines">
51
+ <% if (d = top_line_path(entry)) %>
52
+ <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 %> />
53
+ <% end %>
54
+ <% if entry.dots %>
55
+ <g data-slot="chart-dots" data-key="<%= entry.key %>">
56
+ <% line_markers(entry).each do |marker| %>
57
+ <circle data-slot="chart-dot" cx="<%= fnum(marker[:x]) %>" cy="<%= fnum(marker[:y]) %>" r="<%= entry.dot_radius %>" fill="var(--color-<%= entry.key %>)" />
58
+ <% end %>
59
+ </g>
60
+ <% end %>
61
+ </g>
62
+ <% end %>
63
+ <% end %>
64
+ <%= reference_marks_svg %>
65
+ <% if tooltip? %>
66
+ <g data-slot="chart-active-dots" aria-hidden="true">
67
+ <% series_entries.each do |entry| %>
68
+ <% active_dot_markers(entry).each do |marker| %>
69
+ <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" />
70
+ <% end %>
71
+ <% end %>
72
+ </g>
73
+ <% end %>
74
+ <% if x_axis? %>
75
+ <g data-slot="chart-x-axis">
76
+ <% cartesian.categories.each_with_index do |category, i| %>
77
+ <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>
78
+ <% end %>
79
+ </g>
80
+ <% end %>
81
+ <% if y_axis? %>
82
+ <g data-slot="chart-y-axis">
83
+ <% cartesian.y_ticks.each do |tick| %>
84
+ <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>
85
+ <% end %>
86
+ </g>
87
+ <% end %>
88
+ </svg>
89
+ <% if legend? %>
90
+ <%= render legend_component %>
91
+ <% end %>
92
+ <% if tooltip? %><%= render tooltip_layer_component %><% end %>
93
+ <script type="application/json" data-slot="chart-coordinates" <%= tag.attributes(stimulus_attributes_for(:coordinates)) %>><%= script_json(coordinates_json) %></script>
94
+ <% end %>
95
+ <% end %>
@@ -0,0 +1,309 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ # The composed chart family.
6
+ module ComposedChart
7
+ # Renders a composed chart: area, bar, and line marks on ONE
8
+ # shared cartesian. All mark slots push into a single accumulator,
9
+ # so slot declaration order IS paint order. The x scale is always a
10
+ # band (bars need one; lines and areas ride the band centers), the
11
+ # y domain is shared across every mark, and stack ids are
12
+ # namespaced per mark type so an area stack can never co-stack
13
+ # with a bar stack by accident.
14
+ #
15
+ # @example Bars with a trend line on one plot
16
+ # <%= poetry_chart :composed, data: data, config: config do |c| %>
17
+ # <% c.with_grid %>
18
+ # <% c.with_x_axis data_key: :month %>
19
+ # <% c.with_bar data_key: :visitors, radius: 4 %>
20
+ # <% c.with_line data_key: :trend, stroke_width: 2 %>
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::BarMath
28
+ include Poetry::Charts::ReferenceMarks
29
+
30
+ # Projected into the registry and agent surface.
31
+ AGENT_RULES = [
32
+ "Mix marks freely: with_area / with_bar / with_line - declaration order is paint order.",
33
+ "All marks share the x band and ONE y domain; lines and areas ride the band centers.",
34
+ "stack: ids only combine within the same mark type (a bar stack never joins an area stack).",
35
+ "Colors come from the config - never set fill/stroke on a mark directly.",
36
+ "Entrance animation is on by default; each mark keeps its own reveal mechanism."
37
+ ].freeze
38
+
39
+ # One mark slot's captured series config, tagged by mark type.
40
+ Series = Data.define(:mark, :key, :stack, :curve, :fill_opacity, :gradient,
41
+ :stroke_width, :dots, :dot_radius, :radius) do
42
+ # The band-slot group id: the stack, else the series' own key.
43
+ def stack_or_self = stack || key
44
+ end
45
+
46
+ option :data, ActiveModel::Type::Value.new, required: true,
47
+ doc: "The rows to plot: an array of hashes, one per x category."
48
+ option :config, ActiveModel::Type::Value.new, required: true,
49
+ doc: "The series config - key => { label:, color: } - naming " \
50
+ "and coloring every series."
51
+ option :id, :string,
52
+ doc: "Explicit DOM id token, stable across renders; otherwise the chart gets a unique per-render id."
53
+ option :width, :integer, default: 640,
54
+ doc: "ViewBox width in pixels; the rendered chart scales to its container."
55
+ option :height, :integer, default: 360, doc: "ViewBox height in pixels."
56
+ option :margin, ActiveModel::Type::Value.new,
57
+ doc: "Plot margin overrides ({ top:, right:, bottom:, left: }), merged over the defaults."
58
+ option :label, :string,
59
+ doc: "Accessible name for the chart SVG; defaults to one built from the configured series."
60
+
61
+ motion_options
62
+ option :bar_gap, :integer, default: 4, doc: "Pixels between side-by-side bars inside one category band."
63
+ option :bar_category_gap, :string, default: "10%",
64
+ doc: "Band trim on each side: a percent string of the band width, or a " \
65
+ "bare pixel number."
66
+
67
+ part "chart-svg", "The chart canvas (<svg>) - server-computed geometry in a fixed viewBox; " \
68
+ "role=img, or the focusable role=application accessibilityLayer when the " \
69
+ "tooltip attaches",
70
+ states: {
71
+ "data-animate" => "present when animate (the default) - the motion stylesheet and " \
72
+ "controller key the entrance off it",
73
+ "data-motion" => { condition: "runtime - the motion rig stamps the animation " \
74
+ "lifecycle (entrance/morph, then settled)",
75
+ values: %w[entrance morph settled] }
76
+ },
77
+ vars: {
78
+ "--poetry-motion-duration" => "the entrance/morph duration (animation_duration, ms)",
79
+ "--poetry-motion-easing" => "the animation easing keyword (animation_easing)",
80
+ "--poetry-motion-delay" => "the pre-animation hold (animation_begin, ms)"
81
+ }
82
+ part "chart-motion-reveal", "The entrance clipPath rect (the ported area reveal) - the " \
83
+ "motion stylesheet scales it 0 -> 1; only when animate"
84
+ part "chart-grid", "The gridline group (with_grid) - horizontal and/or vertical rules " \
85
+ "across the plot"
86
+ part "chart-cursor", "The hover cursor, hidden until the tooltip controller positions " \
87
+ "and reveals it at the active index - a vertical rule or a " \
88
+ "translucent band rect (bar charts)"
89
+ part "chart-areas", "The area-mark group - a fill path plus top-curve stroke per series, " \
90
+ "clipped by the reveal rect while animating"
91
+ part "chart-area", "One series' fill path (var(--color-<key>) or its gradient)",
92
+ states: { "data-key" => "the series key" }
93
+ part "chart-area-stroke", "One series' top-curve stroke path (the source strokes the " \
94
+ "curve, never the area outline)",
95
+ states: { "data-key" => "the series key" }
96
+ part "chart-bar-series", "One series' bar group",
97
+ states: { "data-key" => "the series key" }
98
+ part "chart-bar", "One bar cell (a per-corner rounded-rect path)",
99
+ states: {
100
+ "data-key" => "the series key",
101
+ "data-index" => "the datum index",
102
+ "data-active" => "runtime - the tooltip controller marks the hovered index",
103
+ "data-motion-origin" => { condition: "when animate - the zero edge the entrance " \
104
+ "grows from",
105
+ values: %w[bottom top] }
106
+ }
107
+ part "chart-lines", "The line-mark group - each series' curve plus its companion marks"
108
+ part "chart-line", "One series' stroked curve - pathLength=1 when animating so the " \
109
+ "dash draw-in needs no measurement",
110
+ states: { "data-key" => "the series key" }
111
+ part "chart-dots", "One series' point-dot group (dots: true)",
112
+ states: { "data-key" => "the series key" }
113
+ part "chart-dot", "One point dot (<circle>)"
114
+ part "chart-active-dots", "The hover-marker group (with_tooltip) - pre-rendered hidden " \
115
+ "circles for every series x index"
116
+ part "chart-active-dot", "One hover marker - display=none until the tooltip controller " \
117
+ "reveals the active index's dot",
118
+ states: {
119
+ "data-key" => "the series key",
120
+ "data-index" => "the datum index",
121
+ "data-active" => "runtime - rides the marker while its index is the active one"
122
+ }
123
+ part "chart-x-axis", "The x-axis tick-label group (with_x_axis)"
124
+ part "chart-coordinates", "The embedded per-index geometry payload " \
125
+ "(<script type=application/json>) the tooltip controller " \
126
+ "reads - zero chart math in the browser"
127
+
128
+ renders_many :areas,
129
+ doc: "An area mark bound to data_key:; areas sharing a stack: id pile up (area stacks never " \
130
+ "join bar stacks).",
131
+ renders: lambda { |data_key:, stack: nil, curve: :natural, fill_opacity: 0.4,
132
+ gradient: false, stroke_width: 1|
133
+ push_series(:area, key: data_key, stack: stack && "area-#{stack}",
134
+ curve: curve.to_sym, fill_opacity:, gradient:, stroke_width:)
135
+ }
136
+
137
+ renders_many :bars,
138
+ doc: "A bar mark bound to data_key:; radius: rounds corners; bars sharing a stack: id pile up " \
139
+ "within the bar marks.",
140
+ renders: lambda { |data_key:, stack: nil, radius: 0|
141
+ push_series(:bar, key: data_key, stack: stack && "bar-#{stack}", radius: radius)
142
+ }
143
+
144
+ renders_many :lines,
145
+ doc: "A line mark bound to data_key:; dots: marks each point.",
146
+ renders: lambda { |data_key:, curve: :natural, stroke_width: 2,
147
+ dots: false, dot_radius: 4|
148
+ push_series(:line, key: data_key, curve: curve.to_sym, stroke_width:,
149
+ dots:, dot_radius:)
150
+ }
151
+
152
+ include Poetry::Charts::CartesianFamily
153
+
154
+ # The captured Series configs in declaration order across every
155
+ # mark type.
156
+ # @api private
157
+ def series_entries
158
+ # Force every mark slot (slots evaluate lazily); the shared
159
+ # accumulator preserves declaration order across slot types.
160
+ areas?
161
+ bars?
162
+ lines?
163
+ @series_entries ||= []
164
+ end
165
+
166
+ # Just the bar-mark series, for the band slot math.
167
+ # @api private
168
+ def bar_entries = series_entries.select { |e| e.mark == :bar }
169
+
170
+ # The BarMath host contract: composed charts are vertical only.
171
+ # @api private
172
+ def horizontal? = false
173
+
174
+ # The chart's cartesian geometry, built once per render.
175
+ # @api private
176
+ def cartesian
177
+ @cartesian ||= Cartesian.new(
178
+ data: data,
179
+ series: series_entries,
180
+ width: width,
181
+ height: height,
182
+ x_key: x_axis_config&.data_key,
183
+ margin: margin || {},
184
+ category_axis: x_axis?,
185
+ value_axis: y_axis?,
186
+ y_tick_count: y_axis_config&.tick_count || 5,
187
+ x_scale_type: :band
188
+ )
189
+ end
190
+
191
+ # -- per-mark geometry -------------------------------------------------
192
+
193
+ # One area mark's fill path.
194
+ # @api private
195
+ def area_path(entry)
196
+ points = cartesian.points(entry)
197
+ Geometry::Area.new(
198
+ x: ->(p, _i) { p[:x] }, y0: ->(p, _i) { p[:y0] }, y1: ->(p, _i) { p[:y1] },
199
+ curve: entry.curve, defined: ->(p, _i) { !p[:value].nan? }
200
+ ).path(points)
201
+ end
202
+
203
+ # A mark's top-curve stroke path (line marks, and area strokes).
204
+ # @api private
205
+ def top_line_path(entry)
206
+ points = cartesian.points(entry)
207
+ Geometry::Line.new(
208
+ x: ->(p, _i) { p[:x] }, y: ->(p, _i) { p[:y1] },
209
+ curve: entry.curve, defined: ->(p, _i) { !p[:value].nan? }
210
+ ).path(points)
211
+ end
212
+
213
+ # The per-group band slots for the bar marks.
214
+ # @api private
215
+ def bar_slots
216
+ @bar_slots ||= bar_slots_for(bar_entries)
217
+ end
218
+
219
+ # One bar series' rects, positioned in its band slot.
220
+ # @api private
221
+ def cells(entry)
222
+ bar_cells(entry, bar_slots.fetch(entry.stack_or_self))
223
+ end
224
+
225
+ # One bar cell's rounded-rect path.
226
+ # @api private
227
+ def bar_path(entry, cell)
228
+ bar_path_for(entry.radius, cell)
229
+ end
230
+
231
+ # The visible points for a line mark's dots (NaN renders nothing).
232
+ # @api private
233
+ def line_markers(entry)
234
+ cartesian.points(entry).each_with_index.filter_map do |point, i|
235
+ next if point[:value].nan?
236
+
237
+ { index: i, x: point[:x], y: point[:y1] }
238
+ end
239
+ end
240
+
241
+ # One x tick's label, through the slot's formatter when given.
242
+ # @api private
243
+ def x_tick_label(category)
244
+ formatter = x_axis_config&.tick_formatter
245
+ formatter ? formatter.call(category).to_s : category.to_s
246
+ end
247
+
248
+ # One y tick's label, through the slot's formatter when given.
249
+ # @api private
250
+ def y_tick_label(tick)
251
+ formatter = y_axis_config&.tick_formatter
252
+ formatter ? formatter.call(tick).to_s : Geometry.js_number(tick.to_f)
253
+ end
254
+
255
+ # The per-series gradient def's id, scoped by the chart id.
256
+ # @api private
257
+ def gradient_id(entry)
258
+ "#{chart_id}-fill-#{entry.key}"
259
+ end
260
+
261
+ # An area mark's fill: its gradient url when gradient:, else its
262
+ # config color var.
263
+ # @api private
264
+ def area_fill(entry)
265
+ entry.gradient ? "url(##{gradient_id(entry)})" : "var(--color-#{entry.key})"
266
+ end
267
+
268
+ # ChartFamily#svg_label's chart-type lead-in.
269
+ # @api private
270
+ def svg_label_prefix = "Composed chart"
271
+
272
+ # The embedded per-index geometry payload the tooltip controller
273
+ # reads.
274
+ # @api private
275
+ def coordinates_json
276
+ cartesian.coordinates.to_json
277
+ end
278
+
279
+ # Line and area marks show hover dots; bars reflect via data-index.
280
+ # @api private
281
+ def active_dot_markers(entry)
282
+ return [] if entry.mark == :bar
283
+
284
+ super
285
+ end
286
+
287
+ SERIES_DEFAULTS = { stack: nil, curve: nil, fill_opacity: nil, gradient: nil,
288
+ stroke_width: nil, dots: nil, dot_radius: nil, radius: nil }.freeze
289
+ private_constant :SERIES_DEFAULTS
290
+
291
+ private
292
+
293
+ def push_series(mark, key:, **attrs)
294
+ attrs = SERIES_DEFAULTS.merge(attrs)
295
+ if attrs[:curve] && !CURVES.include?(attrs[:curve])
296
+ raise ArgumentError, "unknown curve #{attrs[:curve].inspect}"
297
+ end
298
+
299
+ (@series_entries ||= []) << Series.new(mark: mark, key: key.to_s, **attrs)
300
+ nil
301
+ end
302
+
303
+ private :bar_entries, :cartesian, :area_path, :top_line_path, :bar_slots, :cells
304
+ private :bar_path, :line_markers, :x_tick_label, :y_tick_label, :gradient_id, :area_fill, :svg_label_prefix
305
+ private :coordinates_json, :active_dot_markers
306
+ end
307
+ end
308
+ end
309
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ module ComposedChart
6
+ # The composed family: bars with a trend line, and the full
7
+ # area + bar + line mix.
8
+ class Preview < Poetry::Core::Preview::Base
9
+ DATA = [
10
+ { month: "January", visitors: 320, revenue: 214, trend: 240 },
11
+ { month: "February", visitors: 410, revenue: 305, trend: 290 },
12
+ { month: "March", visitors: 380, revenue: 237, trend: 320 },
13
+ { month: "April", visitors: 250, revenue: 173, trend: 300 },
14
+ { month: "May", visitors: 445, revenue: 209, trend: 340 },
15
+ { month: "June", visitors: 480, revenue: 264, trend: 390 }
16
+ ].freeze
17
+
18
+ CONFIG = {
19
+ visitors: { label: "Visitors", color: "var(--chart-1)" },
20
+ revenue: { label: "Revenue", color: "var(--chart-2)" },
21
+ trend: { label: "Trend", color: "var(--chart-3)" }
22
+ }.freeze
23
+
24
+ def bars_with_trend
25
+ render_component(data: DATA, config: CONFIG, id: "composed-trend") do |chart|
26
+ chart.with_grid
27
+ chart.with_x_axis(data_key: :month, tick_formatter: ->(v) { v[0, 3] })
28
+ chart.with_bar(data_key: :visitors, radius: 4)
29
+ chart.with_line(data_key: :trend, stroke_width: 2, dots: true)
30
+ chart.with_tooltip
31
+ end
32
+ end
33
+
34
+ def full_mix
35
+ render_component(data: DATA, config: CONFIG, id: "composed-mix") do |chart|
36
+ chart.with_grid
37
+ chart.with_x_axis(data_key: :month, tick_formatter: ->(v) { v[0, 3] })
38
+ chart.with_area(data_key: :revenue, fill_opacity: 0.25)
39
+ chart.with_bar(data_key: :visitors, radius: [4, 4, 0, 0])
40
+ chart.with_line(data_key: :trend, curve: :monotone_x, stroke_width: 2)
41
+ chart.with_legend
42
+ chart.with_tooltip
43
+ end
44
+ end
45
+
46
+ # A sync group member: charts sharing sync: broadcast/receive
47
+ # the active index - the sync value the stimulus contract holds
48
+ # to the DOM.
49
+ def synced
50
+ render_component(data: DATA, config: CONFIG, id: "composed-synced",
51
+ sync: "composed-demo") do |chart|
52
+ chart.with_grid
53
+ chart.with_x_axis(data_key: :month, tick_formatter: ->(v) { v[0, 3] })
54
+ chart.with_bar(data_key: :visitors, radius: 4)
55
+ chart.with_line(data_key: :trend, stroke_width: 2, dots: true)
56
+ chart.with_tooltip
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ module ComposedChart
6
+ # The composed chrome on the shared cn-chart-* theme rules.
7
+ # Mark fills/strokes 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 :cursor_line, "cn-chart-cursor-line"
16
+ element :cursor_band, "cn-chart-cursor-band"
17
+ element :reference_line, "cn-chart-reference-line"
18
+ element :reference_area, "cn-chart-reference-area"
19
+ element :reference_dot, "cn-chart-reference-dot"
20
+ element :error_bar, "cn-chart-error-bar"
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,4 @@
1
+ <%= content_tag(:div, **root_attributes.to_attributes) do %>
2
+ <% if (css_text = theme_css) %><style><%= css_text.html_safe %></style><% end %>
3
+ <%= content %>
4
+ <% end %>
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ # The chart container.
6
+ module Container
7
+ # The chart frame: the engine-agnostic outer layer every chart
8
+ # (poetry's own SVG engine, an adapter engine, or a
9
+ # Stimulus-mounted island) sits inside. It scopes the chart id, emits
10
+ # the per-series --color-<key> custom properties from the config
11
+ # (ThemeStyle, both themes), and carries the aspect-ratio sizing
12
+ # chrome. The content block is the chart body.
13
+ #
14
+ # @example
15
+ # <%= poetry_chart_container(config: { desktop: { label: "Desktop", color: "var(--chart-1)" } }) do %>
16
+ # ...the chart body...
17
+ # <% end %>
18
+ class Component < Poetry::Core::Component
19
+ # Projected into the registry and agent surface.
20
+ AGENT_RULES = [
21
+ "Every chart lives inside poetry_chart_container(config:) - the config maps series keys to labels/colors.",
22
+ "Reference series colors as var(--color-<key>); never hard-code a color in chart markup.",
23
+ "Config colors point at theme tokens (var(--chart-1..5)) or use theme: { light:, dark: } maps.",
24
+ "Give charts an explicit id: when the page renders more than one of the same chart."
25
+ ].freeze
26
+
27
+ option :config, ActiveModel::Type::Value.new, required: true,
28
+ doc: "The series config - key => { label:, color: } - driving " \
29
+ "the per-series color emission."
30
+ option :id, :string,
31
+ doc: "Explicit DOM id token, stable across renders; otherwise the frame gets a unique per-render id."
32
+
33
+ part "chart", "The chart frame (<div>) - the aspect-video chrome, the tooltip layer's " \
34
+ "positioning anchor, and the id scope the per-series colors are emitted for",
35
+ states: {
36
+ "data-chart" => "always - the chart instance id (explicit id: or unique per " \
37
+ "render); the scoped color emission keys off it"
38
+ },
39
+ vars: {
40
+ "--color-*" => "per-series color, one entry per series key - the frame's " \
41
+ "<style> block emits them for [data-chart=<id>] in both themes"
42
+ }
43
+
44
+ # The config: option wrapped as a {Poetry::Charts::Config}.
45
+ # @api private
46
+ def chart_config
47
+ @chart_config ||= Poetry::Charts::Config.wrap(config)
48
+ end
49
+
50
+ # The data-chart scope: explicit id when given (stable for tests /
51
+ # multiple charts), else unique per render.
52
+ # @api private
53
+ def chart_id
54
+ @chart_id ||= (dom_id_token(id) ? "chart-#{dom_id_token(id)}" : poetry_instance_id("chart"))
55
+ end
56
+
57
+ # The scoped per-series color declarations for the frame's
58
+ # <style> block, both themes.
59
+ # @api private
60
+ def theme_css
61
+ Poetry::Charts::ThemeStyle.new(id: chart_id, config: chart_config).css
62
+ end
63
+
64
+ # The frame div's attributes: part self-identification plus the
65
+ # data-chart scope.
66
+ # @api private
67
+ def root_attributes
68
+ html_attributes.merge_if_not_set(
69
+ {
70
+ "data-slot" => "chart",
71
+ "data-chart" => chart_id
72
+ }.merge(component_data_attributes)
73
+ )
74
+ end
75
+
76
+ private :chart_config, :chart_id, :theme_css, :root_attributes
77
+ end
78
+ end
79
+ end
80
+ end