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