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.
- checksums.yaml +7 -0
- data/.gitattributes +6 -0
- data/CHANGELOG.md +3 -0
- data/LICENSE.txt +21 -0
- data/README.md +94 -0
- data/THIRD_PARTY_NOTICES.md +39 -0
- data/app/assets/stylesheets/poetry-charts.css +117 -0
- data/app/components/poetry/charts/adapter_chart/component.html.erb +6 -0
- data/app/components/poetry/charts/adapter_chart/component.rb +99 -0
- data/app/components/poetry/charts/adapter_chart/preview.rb +37 -0
- data/app/components/poetry/charts/adapter_chart/style.rb +16 -0
- data/app/components/poetry/charts/area_chart/component.html.erb +75 -0
- data/app/components/poetry/charts/area_chart/component.rb +251 -0
- data/app/components/poetry/charts/area_chart/preview.rb +95 -0
- data/app/components/poetry/charts/area_chart/style.rb +29 -0
- data/app/components/poetry/charts/bar_chart/component.html.erb +95 -0
- data/app/components/poetry/charts/bar_chart/component.rb +318 -0
- data/app/components/poetry/charts/bar_chart/preview.rb +115 -0
- data/app/components/poetry/charts/bar_chart/style.rb +28 -0
- data/app/components/poetry/charts/bar_math.rb +95 -0
- data/app/components/poetry/charts/cartesian_family.rb +105 -0
- data/app/components/poetry/charts/chart_family.rb +58 -0
- data/app/components/poetry/charts/composed_chart/component.html.erb +95 -0
- data/app/components/poetry/charts/composed_chart/component.rb +309 -0
- data/app/components/poetry/charts/composed_chart/preview.rb +62 -0
- data/app/components/poetry/charts/composed_chart/style.rb +24 -0
- data/app/components/poetry/charts/container/component.html.erb +4 -0
- data/app/components/poetry/charts/container/component.rb +80 -0
- data/app/components/poetry/charts/container/preview.rb +45 -0
- data/app/components/poetry/charts/container/style.rb +22 -0
- data/app/components/poetry/charts/error_bars.rb +35 -0
- data/app/components/poetry/charts/legend_content/component.html.erb +10 -0
- data/app/components/poetry/charts/legend_content/component.rb +122 -0
- data/app/components/poetry/charts/legend_content/preview.rb +23 -0
- data/app/components/poetry/charts/legend_content/style.rb +26 -0
- data/app/components/poetry/charts/line_chart/component.html.erb +85 -0
- data/app/components/poetry/charts/line_chart/component.rb +252 -0
- data/app/components/poetry/charts/line_chart/preview.rb +115 -0
- data/app/components/poetry/charts/line_chart/style.rb +28 -0
- data/app/components/poetry/charts/live.rb +296 -0
- data/app/components/poetry/charts/motion.rb +98 -0
- data/app/components/poetry/charts/pie_chart/component.html.erb +36 -0
- data/app/components/poetry/charts/pie_chart/component.rb +256 -0
- data/app/components/poetry/charts/pie_chart/preview.rb +100 -0
- data/app/components/poetry/charts/pie_chart/style.rb +19 -0
- data/app/components/poetry/charts/polar_family.rb +95 -0
- data/app/components/poetry/charts/radar_chart/component.html.erb +73 -0
- data/app/components/poetry/charts/radar_chart/component.rb +329 -0
- data/app/components/poetry/charts/radar_chart/preview.rb +97 -0
- data/app/components/poetry/charts/radar_chart/style.rb +21 -0
- data/app/components/poetry/charts/radial_bar_chart/component.html.erb +65 -0
- data/app/components/poetry/charts/radial_bar_chart/component.rb +424 -0
- data/app/components/poetry/charts/radial_bar_chart/preview.rb +99 -0
- data/app/components/poetry/charts/radial_bar_chart/style.rb +27 -0
- data/app/components/poetry/charts/reference_marks.rb +156 -0
- data/app/components/poetry/charts/scatter_chart/component.html.erb +55 -0
- data/app/components/poetry/charts/scatter_chart/component.rb +396 -0
- data/app/components/poetry/charts/scatter_chart/preview.rb +93 -0
- data/app/components/poetry/charts/scatter_chart/style.rb +22 -0
- data/app/components/poetry/charts/tooltip_content/component.html.erb +19 -0
- data/app/components/poetry/charts/tooltip_content/component.rb +168 -0
- data/app/components/poetry/charts/tooltip_content/preview.rb +39 -0
- data/app/components/poetry/charts/tooltip_content/style.rb +31 -0
- data/app/components/poetry/charts/tooltip_layer/component.html.erb +6 -0
- data/app/components/poetry/charts/tooltip_layer/component.rb +61 -0
- data/app/components/poetry/charts/tooltip_layer/style.rb +13 -0
- data/app/components/poetry/charts/tooltip_wiring.rb +167 -0
- data/app/helpers/poetry/charts/components_helper.rb +125 -0
- data/app/javascript/poetry/charts/adapter_controller.js +75 -0
- data/app/javascript/poetry/charts/adapter_registry.js +52 -0
- data/app/javascript/poetry/charts/adapters/chartjs.js +86 -0
- data/app/javascript/poetry/charts/d3.js +14 -0
- data/app/javascript/poetry/charts/index.js +67 -0
- data/app/javascript/poetry/charts/live/renderer.js +456 -0
- data/app/javascript/poetry/charts/live_controller.js +195 -0
- data/app/javascript/poetry/charts/motion/flip.js +169 -0
- data/app/javascript/poetry/charts/motion/sector.js +62 -0
- data/app/javascript/poetry/charts/motion/tween.js +105 -0
- data/app/javascript/poetry/charts/motion_controller.js +230 -0
- data/app/javascript/poetry/charts/tooltip_controller.js +331 -0
- data/app/javascript/poetry/charts/window_controller.js +195 -0
- data/config/component_descriptions.yml +14 -0
- data/config/component_registry.yml +3055 -0
- data/config/controllers_manifest.json +100 -0
- data/config/importmap.rb +10 -0
- data/lib/poetry/charts/cartesian.rb +277 -0
- data/lib/poetry/charts/config.rb +149 -0
- data/lib/poetry/charts/engine.rb +76 -0
- data/lib/poetry/charts/geometry/area.rb +68 -0
- data/lib/poetry/charts/geometry/curve.rb +327 -0
- data/lib/poetry/charts/geometry/line.rb +64 -0
- data/lib/poetry/charts/geometry/nice_ticks.rb +220 -0
- data/lib/poetry/charts/geometry/path.rb +96 -0
- data/lib/poetry/charts/geometry/scale/band.rb +81 -0
- data/lib/poetry/charts/geometry/scale/linear.rb +127 -0
- data/lib/poetry/charts/geometry/stack.rb +129 -0
- data/lib/poetry/charts/geometry/ticks.rb +103 -0
- data/lib/poetry/charts/geometry.rb +66 -0
- data/lib/poetry/charts/polar.rb +179 -0
- data/lib/poetry/charts/spec.rb +108 -0
- data/lib/poetry/charts/theme_style.rb +53 -0
- data/lib/poetry/charts/version.rb +8 -0
- data/lib/poetry/charts.rb +114 -0
- data/lib/poetry-charts.rb +4 -0
- data/themes/default.css +146 -0
- data/themes/luma.css +139 -0
- data/themes/lyra.css +140 -0
- data/themes/maia.css +139 -0
- data/themes/mira.css +139 -0
- data/themes/nova.css +139 -0
- data/themes/rhea.css +139 -0
- data/themes/sera.css +140 -0
- data/themes/vega.css +144 -0
- data/vendor/d3-kernel/LICENSE-d3.txt +17 -0
- data/vendor/d3-kernel/LICENSE-decimal.js-light.txt +25 -0
- data/vendor/d3-kernel/LICENSE-recharts.txt +26 -0
- data/vendor/d3-kernel/README.md +30 -0
- data/vendor/d3-kernel/build.mjs +66 -0
- data/vendor/d3-kernel/entry.js +27 -0
- data/vendor/d3-kernel/recharts/state/cartesianAxisSlice.ts +6 -0
- data/vendor/d3-kernel/recharts/util/scale/getNiceTickValues.ts +310 -0
- data/vendor/d3-kernel/recharts/util/scale/util/arithmetic.ts +54 -0
- data/vendor/d3-kernel/recharts/util/types.ts +5 -0
- 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,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
|