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,329 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poetry
|
|
4
|
+
module Charts
|
|
5
|
+
# The radar chart family.
|
|
6
|
+
module RadarChart
|
|
7
|
+
# Renders a radar chart: categories spaced clockwise from
|
|
8
|
+
# 12 o'clock, values on a nice [0, auto] radius scale, series as
|
|
9
|
+
# closed polygons with a 0.6 fill, polygon or circle grids at the
|
|
10
|
+
# radius ticks, and angle-axis labels around the rim. The tooltip
|
|
11
|
+
# hits per CATEGORY through server-rendered transparent wedges
|
|
12
|
+
# (fill=transparent stays hit-testable) - the multi-series chrome
|
|
13
|
+
# with the category label, zero client math.
|
|
14
|
+
#
|
|
15
|
+
# @example One filled series over month categories
|
|
16
|
+
# <%= poetry_chart :radar, data:, config: do |c| %>
|
|
17
|
+
# <% c.with_angle_axis data_key: :month %>
|
|
18
|
+
# <% c.with_grid %>
|
|
19
|
+
# <% c.with_radar data_key: :desktop %>
|
|
20
|
+
# <% c.with_tooltip %>
|
|
21
|
+
# <% end %>
|
|
22
|
+
class Component < Poetry::Core::Component
|
|
23
|
+
include Poetry::Charts::ChartFamily
|
|
24
|
+
include Poetry::Charts::TooltipWiring
|
|
25
|
+
include Poetry::Charts::Motion
|
|
26
|
+
|
|
27
|
+
# Projected into the registry and agent surface.
|
|
28
|
+
AGENT_RULES = [
|
|
29
|
+
"Compose from slots: with_angle_axis(data_key:) / with_grid / with_radar(data_key:) / with_legend.",
|
|
30
|
+
"Radars fill at 0.6 opacity by default; lines-only = fill_opacity: 0, stroke_width: 2.",
|
|
31
|
+
"with_grid type: :circle swaps polygons for circles; fill: :desktop tints every grid ring " \
|
|
32
|
+
"(opacity 0.2, compounding toward the center - the grid-fill look).",
|
|
33
|
+
"dots: true marks every vertex (r 4, solid).",
|
|
34
|
+
"Colors come from the config - never set fill/stroke on a radar directly.",
|
|
35
|
+
"Entrance animation is on by default (source parity); animate: false for a static chart. " \
|
|
36
|
+
"Reduced-motion users always get the finished chart."
|
|
37
|
+
].freeze
|
|
38
|
+
|
|
39
|
+
# One with_radar call's captured series config.
|
|
40
|
+
Series = Data.define(:data_key, :fill_opacity, :stroke_width, :dots, :dot_radius) do
|
|
41
|
+
# The TooltipWiring contract.
|
|
42
|
+
def key = data_key
|
|
43
|
+
end
|
|
44
|
+
# The with_grid slot's captured config.
|
|
45
|
+
GridConfig = Data.define(:type, :radial_lines, :fill, :opacity)
|
|
46
|
+
|
|
47
|
+
option :data, ActiveModel::Type::Value.new, required: true,
|
|
48
|
+
doc: "The rows to plot: an array of hashes, one per category."
|
|
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: 250,
|
|
55
|
+
doc: "ViewBox width in pixels; the rendered chart scales to its container."
|
|
56
|
+
option :height, :integer, default: 250, doc: "ViewBox height in pixels."
|
|
57
|
+
option :margin, ActiveModel::Type::Value.new,
|
|
58
|
+
doc: "Margin overrides ({ top:, right:, bottom:, left: }), merged over the slim polar default."
|
|
59
|
+
option :label, :string,
|
|
60
|
+
doc: "Accessible name for the chart SVG; defaults to one built from the configured series."
|
|
61
|
+
|
|
62
|
+
motion_options
|
|
63
|
+
option :outer_radius, ActiveModel::Type::Value.new, default: "80%",
|
|
64
|
+
doc: "The rim radius: a percent string of the max " \
|
|
65
|
+
"radius, or pixels."
|
|
66
|
+
|
|
67
|
+
part "chart-svg", "The chart canvas (<svg>) - the aria-label surface, the tooltip's " \
|
|
68
|
+
"focus/keyboard surface (role=application when it attaches), and " \
|
|
69
|
+
"the motion rig's mount",
|
|
70
|
+
states: {
|
|
71
|
+
"data-animate" => "when animate (the default) - the entrance tier's flag the " \
|
|
72
|
+
"motion stylesheet and controller key off",
|
|
73
|
+
"data-motion" => { condition: "runtime, when animate - the motion engine's lifecycle stamp",
|
|
74
|
+
values: %w[entrance morph settled] }
|
|
75
|
+
},
|
|
76
|
+
vars: {
|
|
77
|
+
"--poetry-motion-delay" => "the motion rig's entrance delay (animation_begin)",
|
|
78
|
+
"--poetry-motion-duration" => "the motion rig's entrance duration (animation_duration)",
|
|
79
|
+
"--poetry-motion-easing" => "the motion rig's easing keyword (animation_easing)",
|
|
80
|
+
"--poetry-motion-center" => "the polar center the CSS entrance scales the series from"
|
|
81
|
+
}
|
|
82
|
+
part "chart-polar-grid", "The polar grid (<g>, aria-hidden) - ring and spoke linework " \
|
|
83
|
+
"behind the series"
|
|
84
|
+
part "chart-radars", "The series layer (<g>) - every radar polygon renders here; the " \
|
|
85
|
+
"CSS entrance scales this group from the polar center"
|
|
86
|
+
part "chart-radar", "One series' closed polygon (<path>) - config color, 0.6 fill by default",
|
|
87
|
+
states: { "data-key" => "always - the series key" }
|
|
88
|
+
part "chart-dots", "A series' vertex dots (<g>), rendered when dots: true",
|
|
89
|
+
states: { "data-key" => "always - the series key" }
|
|
90
|
+
part "chart-dot", "One vertex dot (<circle>) - solid, at the series color"
|
|
91
|
+
part "chart-angle-axis", "The category labels around the rim (<g> of <text>)"
|
|
92
|
+
part "chart-hit-wedges", "The tooltip's hit layer (<g>), rendered when the tooltip attaches"
|
|
93
|
+
part "chart-hit-wedge", "One per-category hit wedge (<path>, transparent but painted so " \
|
|
94
|
+
"it hit-tests) - the tooltip's hover target",
|
|
95
|
+
states: {
|
|
96
|
+
"data-index" => "always - the datum index",
|
|
97
|
+
"data-active" => "the hovered/arrow-keyed category - the tooltip controller " \
|
|
98
|
+
"reflects the active index here at runtime"
|
|
99
|
+
}
|
|
100
|
+
part "chart-coordinates", "The embedded JSON payload (<script>) the tooltip controller " \
|
|
101
|
+
"reads - per-category anchors and pre-formatted values, zero " \
|
|
102
|
+
"chart math in the browser"
|
|
103
|
+
|
|
104
|
+
renders_many :radars,
|
|
105
|
+
doc: "A radar series bound to data_key:. fill_opacity: 0 with stroke_width: 2 draws lines " \
|
|
106
|
+
"only; dots: marks every vertex.",
|
|
107
|
+
renders: lambda { |data_key:, fill_opacity: 0.6, stroke_width: 0, dots: false, dot_radius: 4|
|
|
108
|
+
(@series_entries ||= []) << Series.new(data_key: data_key.to_s, fill_opacity:, stroke_width:,
|
|
109
|
+
dots:, dot_radius:)
|
|
110
|
+
nil
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
renders_one :angle_axis,
|
|
114
|
+
doc: "The category labels around the rim: data_key: names the field; tick_formatter: reshapes " \
|
|
115
|
+
"each label.",
|
|
116
|
+
renders: lambda { |data_key:, tick_formatter: nil|
|
|
117
|
+
@angle_axis_config = { data_key: data_key.to_s, tick_formatter: tick_formatter }
|
|
118
|
+
nil
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
renders_one :grid,
|
|
122
|
+
doc: "The polar grid: type: :circle swaps polygons for circles; radial_lines: false drops the " \
|
|
123
|
+
"spokes; fill: tints every ring with a series color at opacity:.",
|
|
124
|
+
renders: lambda { |type: :polygon, radial_lines: true, fill: nil, opacity: 0.2|
|
|
125
|
+
@grid_config = GridConfig.new(type: type.to_sym, radial_lines:, fill: fill&.to_s, opacity:)
|
|
126
|
+
nil
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
renders_one :legend,
|
|
130
|
+
doc: "The legend row: align:, items:, and hide_icon:.",
|
|
131
|
+
renders: lambda { |**options|
|
|
132
|
+
@legend_config = options
|
|
133
|
+
nil
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
renders_one :tooltip,
|
|
137
|
+
doc: "The hover tooltip - multi-series rows under the category label.",
|
|
138
|
+
renders: lambda { |**options|
|
|
139
|
+
@tooltip_config = options
|
|
140
|
+
nil
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
# The polar chassis (margin/plot/center + the pointerover hit).
|
|
144
|
+
# PolarFamily alone: radar keeps TooltipWiring's multi-series
|
|
145
|
+
# chrome and its own payload below.
|
|
146
|
+
include Poetry::Charts::PolarFamily
|
|
147
|
+
|
|
148
|
+
# The captured Series configs, forcing lazy slot evaluation.
|
|
149
|
+
# @api private
|
|
150
|
+
def series_entries
|
|
151
|
+
radars? # force slot evaluation (slots evaluate lazily)
|
|
152
|
+
@series_entries ||= []
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# The angle-axis slot's captured config, forcing lazy slot
|
|
156
|
+
# evaluation.
|
|
157
|
+
# @api private
|
|
158
|
+
def angle_axis_config
|
|
159
|
+
angle_axis?
|
|
160
|
+
@angle_axis_config
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# The grid slot's captured config, forcing lazy slot evaluation.
|
|
164
|
+
# @api private
|
|
165
|
+
def grid_config
|
|
166
|
+
grid?
|
|
167
|
+
@grid_config
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# -- geometry ---------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
# The data rows with stringified keys.
|
|
173
|
+
# @api private
|
|
174
|
+
def rows
|
|
175
|
+
@rows ||= data.map { |row| row.to_h.transform_keys(&:to_s) }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# The category labels: the angle axis's data key values, else
|
|
179
|
+
# bare indexes.
|
|
180
|
+
# @api private
|
|
181
|
+
def categories
|
|
182
|
+
key = angle_axis_config&.fetch(:data_key)
|
|
183
|
+
key ? rows.map { |row| row[key] } : (0...rows.length).to_a
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Clockwise from 12 o'clock: category 0 sits at the top
|
|
187
|
+
# (90 degrees), later categories sweep clockwise.
|
|
188
|
+
# @api private
|
|
189
|
+
def angle_at(index)
|
|
190
|
+
90 - (index * (360.0 / rows.length))
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# The rim radius resolved to pixels.
|
|
194
|
+
# @api private
|
|
195
|
+
def radius_px
|
|
196
|
+
@radius_px ||= Polar.percent_value(outer_radius, Polar.max_radius(plot[:width], plot[:height]),
|
|
197
|
+
Polar.max_radius(plot[:width], plot[:height]) * 0.8)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# The radius domain auto-nices [0, data max] into 5 ticks, so the
|
|
201
|
+
# outer ring is the ROUNDED max and the top datum sits just
|
|
202
|
+
# inside it (data max 305 -> [0, 320], the peak at 95.3%).
|
|
203
|
+
# @api private
|
|
204
|
+
def radius_ticks
|
|
205
|
+
@radius_ticks ||= begin
|
|
206
|
+
max = series_entries.flat_map { |e| rows.map { |row| row[e.data_key].to_f } }.max || 1
|
|
207
|
+
Geometry::NiceTicks.nice_ticks([0, max], 5)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# A value's distance from the center, scaled to the tick domain.
|
|
212
|
+
# @api private
|
|
213
|
+
def radius_for(value)
|
|
214
|
+
(value.to_f / radius_ticks.last) * radius_px
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# One series point: the category's angle at the value's radius.
|
|
218
|
+
# @api private
|
|
219
|
+
def vertex(entry, index)
|
|
220
|
+
Polar.polar_to_cartesian(cx, cy, radius_for(rows[index][entry.data_key]), angle_at(index))
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# A closed polygon path through the given points.
|
|
224
|
+
# @api private
|
|
225
|
+
def polygon_path(points)
|
|
226
|
+
"M#{points.map { |x, y| "#{fnum(x)},#{fnum(y)}" }.join("L")}Z"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# One series' closed polygon through all its vertices.
|
|
230
|
+
# @api private
|
|
231
|
+
def series_path(entry)
|
|
232
|
+
polygon_path(rows.each_index.map { |i| vertex(entry, i) })
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Grid polygons/circles at each nonzero radius tick.
|
|
236
|
+
# @api private
|
|
237
|
+
def grid_radii
|
|
238
|
+
radius_ticks.reject(&:zero?).map { |tick| radius_for(tick) }
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# One grid ring's polygon at the given radius.
|
|
242
|
+
# @api private
|
|
243
|
+
def grid_polygon(radius)
|
|
244
|
+
polygon_path(rows.each_index.map { |i| Polar.polar_to_cartesian(cx, cy, radius, angle_at(i)) })
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# The radial spoke's outer endpoint (template helper - bare Polar
|
|
248
|
+
# does not resolve from compiled ERB scope).
|
|
249
|
+
# @api private
|
|
250
|
+
def spoke_end(index)
|
|
251
|
+
Polar.polar_to_cartesian(cx, cy, radius_px, angle_at(index))
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Lands in an inline style= (the class's fill-none beats a fill
|
|
255
|
+
# attribute), so the token is validated - never interpolate a raw
|
|
256
|
+
# string into CSS.
|
|
257
|
+
# @api private
|
|
258
|
+
def grid_fill_attribute
|
|
259
|
+
return nil unless grid_config&.fill
|
|
260
|
+
|
|
261
|
+
token = grid_config.fill
|
|
262
|
+
raise ArgumentError, "grid fill #{token.inspect} is not a series key" unless token.match?(/\A[\w-]+\z/)
|
|
263
|
+
|
|
264
|
+
"var(--color-#{token})"
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Angle-axis labels around the rim, anchored by which side of the
|
|
268
|
+
# circle they sit on.
|
|
269
|
+
# @api private
|
|
270
|
+
def angle_label(index)
|
|
271
|
+
angle = angle_at(index)
|
|
272
|
+
x, y = Polar.polar_to_cartesian(cx, cy, radius_px + 10, angle)
|
|
273
|
+
cos = Math.cos(-Polar::RADIAN * angle)
|
|
274
|
+
anchor = if cos > 0.2 then "start"
|
|
275
|
+
elsif cos < -0.2 then "end"
|
|
276
|
+
else "middle"
|
|
277
|
+
end
|
|
278
|
+
category = categories[index]
|
|
279
|
+
formatter = angle_axis_config[:tick_formatter]
|
|
280
|
+
{ x: x, y: y, anchor: anchor, text: formatter ? formatter.call(category).to_s : category.to_s }
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# The invisible per-category hit wedges the tooltip rides
|
|
284
|
+
# (fill=transparent is painted, so it hit-tests; fill=none would not).
|
|
285
|
+
# @api private
|
|
286
|
+
def hit_wedge(index)
|
|
287
|
+
half = 180.0 / rows.length
|
|
288
|
+
Polar.sector_path(cx: cx, cy: cy, inner_radius: 0, outer_radius: radius_px,
|
|
289
|
+
start_angle: angle_at(index) - half, end_angle: angle_at(index) + half)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# -- the tooltip payload: polar anchors, cartesian-style rows ----------
|
|
293
|
+
|
|
294
|
+
# The embedded per-category geometry payload the tooltip
|
|
295
|
+
# controller reads.
|
|
296
|
+
# @api private
|
|
297
|
+
def coordinates_json
|
|
298
|
+
{
|
|
299
|
+
"layout" => "polar",
|
|
300
|
+
"categories" => categories.map(&:to_s),
|
|
301
|
+
"anchors" => rows.each_index.map do |i|
|
|
302
|
+
Polar.polar_to_cartesian(cx, cy, radius_px * 0.7, angle_at(i)).map { |v| v.round(2) }
|
|
303
|
+
end,
|
|
304
|
+
"values" => series_entries.to_h do |entry|
|
|
305
|
+
[entry.data_key, rows.map { |row| Poetry::Charts.display_value(row[entry.data_key]) }]
|
|
306
|
+
end
|
|
307
|
+
}.to_json
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# ChartFamily#svg_label's chart-type lead-in.
|
|
311
|
+
# @api private
|
|
312
|
+
def svg_label_prefix = "Radar chart"
|
|
313
|
+
|
|
314
|
+
# The polar center, so the motion stylesheet can scale the
|
|
315
|
+
# entrance from it - a uniform scale from (cx, cy) matches
|
|
316
|
+
# per-vertex interpolation frame for frame.
|
|
317
|
+
# @api private
|
|
318
|
+
def motion_style_extras
|
|
319
|
+
"--poetry-motion-center: #{fnum(cx)}px #{fnum(cy)}px"
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
private :angle_axis_config, :grid_config, :rows, :categories, :angle_at, :radius_px
|
|
323
|
+
private :radius_ticks, :radius_for, :vertex, :polygon_path, :series_path, :grid_radii, :grid_polygon, :spoke_end
|
|
324
|
+
private :grid_fill_attribute, :angle_label, :hit_wedge, :coordinates_json, :svg_label_prefix
|
|
325
|
+
private :motion_style_extras
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poetry
|
|
4
|
+
module Charts
|
|
5
|
+
module RadarChart
|
|
6
|
+
# The radar family across its variants: default, dots,
|
|
7
|
+
# lines-only, multiple series, circle grid, tinted grid disc,
|
|
8
|
+
# gridless, and legend.
|
|
9
|
+
class Preview < Poetry::Core::Preview::Base
|
|
10
|
+
DATA = AreaChart::Preview::DATA
|
|
11
|
+
ONE = AreaChart::Preview::ONE
|
|
12
|
+
TWO = AreaChart::Preview::TWO
|
|
13
|
+
|
|
14
|
+
def default
|
|
15
|
+
simple(id: "radar-default") { |chart| chart.with_tooltip(indicator: :line) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def dots
|
|
19
|
+
simple(id: "radar-dots", dots: true)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# BOTH series draw as strokes only, and the grid's radial
|
|
23
|
+
# spokes turn off.
|
|
24
|
+
def lines_only
|
|
25
|
+
render_component(data: DATA, config: TWO, id: "radar-lines-only") do |chart|
|
|
26
|
+
chart.with_angle_axis(data_key: :month)
|
|
27
|
+
chart.with_grid(radial_lines: false)
|
|
28
|
+
chart.with_radar(data_key: :desktop, fill_opacity: 0, stroke_width: 2)
|
|
29
|
+
chart.with_radar(data_key: :mobile, fill_opacity: 0, stroke_width: 2)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Only the FIRST series is translucent (fill_opacity 0.6);
|
|
34
|
+
# the second keeps the opaque default and covers the overlap.
|
|
35
|
+
def multiple
|
|
36
|
+
render_component(data: DATA, config: TWO, id: "radar-multiple") do |chart|
|
|
37
|
+
chart.with_angle_axis(data_key: :month)
|
|
38
|
+
chart.with_grid
|
|
39
|
+
chart.with_radar(data_key: :desktop)
|
|
40
|
+
chart.with_radar(data_key: :mobile, fill_opacity: 1)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def grid_circle
|
|
45
|
+
simple(id: "radar-grid-circle", grid_type: :circle, dots: true)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def grid_fill
|
|
49
|
+
render_component(data: DATA, config: ONE, id: "radar-grid-fill") do |chart|
|
|
50
|
+
chart.with_angle_axis(data_key: :month)
|
|
51
|
+
chart.with_grid(fill: :desktop)
|
|
52
|
+
chart.with_radar(data_key: :desktop)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def grid_none
|
|
57
|
+
render_component(data: DATA, config: ONE, id: "radar-grid-none") do |chart|
|
|
58
|
+
chart.with_angle_axis(data_key: :month)
|
|
59
|
+
chart.with_radar(data_key: :desktop, dots: true)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def legend
|
|
64
|
+
render_component(data: DATA, config: TWO, id: "radar-legend") do |chart|
|
|
65
|
+
chart.with_angle_axis(data_key: :month)
|
|
66
|
+
chart.with_grid
|
|
67
|
+
chart.with_radar(data_key: :desktop)
|
|
68
|
+
chart.with_radar(data_key: :mobile, fill_opacity: 1)
|
|
69
|
+
chart.with_legend
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# A sync group member: the sync value the stimulus contract
|
|
74
|
+
# holds to the DOM.
|
|
75
|
+
def synced
|
|
76
|
+
render_component(data: DATA, config: ONE, id: "radar-synced", sync: "radar-demo") do |chart|
|
|
77
|
+
chart.with_angle_axis(data_key: :month)
|
|
78
|
+
chart.with_grid
|
|
79
|
+
chart.with_radar(data_key: :desktop)
|
|
80
|
+
chart.with_tooltip
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def simple(id:, dots: false, grid_type: :polygon)
|
|
87
|
+
render_component(data: DATA, config: ONE, id: id) do |chart|
|
|
88
|
+
chart.with_angle_axis(data_key: :month)
|
|
89
|
+
chart.with_grid(type: grid_type)
|
|
90
|
+
chart.with_radar(data_key: :desktop, dots: dots)
|
|
91
|
+
yield chart if block_given?
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poetry
|
|
4
|
+
module Charts
|
|
5
|
+
module RadarChart
|
|
6
|
+
# The radar chrome on the cn-chart-* theme rules: polar grid
|
|
7
|
+
# lines take FULL border (cartesian grids use /50, polar does not).
|
|
8
|
+
# The tinted grid-fill discs keep stroke-none inline (no paint - a
|
|
9
|
+
# mechanism guard, they overlay the linework).
|
|
10
|
+
class Style < Poetry::Core::Style
|
|
11
|
+
base ""
|
|
12
|
+
|
|
13
|
+
element :frame, "contents"
|
|
14
|
+
element :svg, "w-full min-h-0 flex-1"
|
|
15
|
+
element :grid_line, "cn-chart-polar-grid-line"
|
|
16
|
+
element :grid_fill, "stroke-none"
|
|
17
|
+
element :tick, "cn-chart-tick"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
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 polar_grid? %>
|
|
5
|
+
<g data-slot="chart-polar-grid" aria-hidden="true">
|
|
6
|
+
<% if polar_grid_config[:radial_lines] %>
|
|
7
|
+
<% angle_tick_values.each do |tick| %>
|
|
8
|
+
<% x1, y1, x2, y2 = spoke_points(tick) %>
|
|
9
|
+
<line class="<%= css(:grid_circle) %>" x1="<%= fnum(x1) %>" y1="<%= fnum(y1) %>" x2="<%= fnum(x2) %>" y2="<%= fnum(y2) %>" />
|
|
10
|
+
<% end %>
|
|
11
|
+
<% end %>
|
|
12
|
+
<% grid_radii.each_with_index do |radius, i| %>
|
|
13
|
+
<circle class="<%= grid_fill_class(i) %>" cx="<%= fnum(cx) %>" cy="<%= fnum(cy) %>" r="<%= fnum(radius) %>" />
|
|
14
|
+
<% end %>
|
|
15
|
+
</g>
|
|
16
|
+
<% end %>
|
|
17
|
+
<% series_entries.each_with_index do |entry, series_index| %>
|
|
18
|
+
<g data-slot="chart-radial-series" data-key="<%= entry.data_key %>">
|
|
19
|
+
<% segments(entry).each do |segment| %>
|
|
20
|
+
<% if entry.background %>
|
|
21
|
+
<path data-slot="chart-radial-background" class="<%= css(:background_ring) %>" d="<%= background_path(segment) %>" />
|
|
22
|
+
<% end %>
|
|
23
|
+
<% end %>
|
|
24
|
+
<% segments(entry).each do |segment| %>
|
|
25
|
+
<path data-slot="chart-radial-bar" data-key="<%= entry.data_key %>"<% if series_index.zero? %> data-index="<%= segment.index %>"<% end %><% if animate? %> data-motion-sector="<%= motion_sector_value(cx, cy, segment.ring_inner, segment.ring_outer, segment.seg_start, segment.seg_end) %>" data-motion-group="ring-<%= segment.index %>"<% end %> d="<%= segment.path %>" fill="<%= segment.fill %>" />
|
|
26
|
+
<% end %>
|
|
27
|
+
<% if entry.labels == :inside_start %>
|
|
28
|
+
<g data-slot="chart-labels" data-key="<%= entry.data_key %>" aria-hidden="true">
|
|
29
|
+
<% segments(entry).each do |segment| %>
|
|
30
|
+
<% placement = label_placement(segment) %>
|
|
31
|
+
<text class="<%= css(:inside_label) %>" x="<%= fnum(placement[:x]) %>" y="<%= fnum(placement[:y]) %>" dy="0.355em" text-anchor="<%= placement[:anchor] %>" transform="rotate(<%= fnum(placement[:rotate]) %>, <%= fnum(placement[:x]) %>, <%= fnum(placement[:y]) %>)"><%= entry.label_key ? rows[segment.index][entry.label_key] : segment.value %></text>
|
|
32
|
+
<% end %>
|
|
33
|
+
</g>
|
|
34
|
+
<% end %>
|
|
35
|
+
</g>
|
|
36
|
+
<% end %>
|
|
37
|
+
<% if center_label? %>
|
|
38
|
+
<%# Full gauge: text-4xl centered on cy, dy=0.355em (the
|
|
39
|
+
dominant-baseline: middle offset, stated inline), caption 24
|
|
40
|
+
below. Compact (stacked half-gauge): text-2xl baseline at
|
|
41
|
+
cy-16, caption at cy+4 - the smaller number sits just above
|
|
42
|
+
the flat baseline. %>
|
|
43
|
+
<% compact = center_label_config[:compact] %>
|
|
44
|
+
<text data-slot="chart-center-label" x="<%= fnum(cx) %>" y="<%= fnum(cy) %>" text-anchor="middle">
|
|
45
|
+
<% if compact %>
|
|
46
|
+
<tspan class="<%= css(:center_title_compact) %>" x="<%= fnum(cx) %>" y="<%= fnum(cy - 16) %>"><%= center_label_config[:title] %></tspan>
|
|
47
|
+
<% if center_label_config[:subtitle] %>
|
|
48
|
+
<tspan class="<%= css(:center_subtitle) %>" x="<%= fnum(cx) %>" y="<%= fnum(cy + 4) %>"><%= center_label_config[:subtitle] %></tspan>
|
|
49
|
+
<% end %>
|
|
50
|
+
<% else %>
|
|
51
|
+
<tspan class="<%= css(:center_title) %>" x="<%= fnum(cx) %>" y="<%= fnum(cy) %>" dy="0.355em"><%= center_label_config[:title] %></tspan>
|
|
52
|
+
<% if center_label_config[:subtitle] %>
|
|
53
|
+
<tspan class="<%= css(:center_subtitle) %>" x="<%= fnum(cx) %>" y="<%= fnum(cy + 24) %>" dy="0.355em"><%= center_label_config[:subtitle] %></tspan>
|
|
54
|
+
<% end %>
|
|
55
|
+
<% end %>
|
|
56
|
+
</text>
|
|
57
|
+
<% end %>
|
|
58
|
+
</svg>
|
|
59
|
+
<% if legend? %>
|
|
60
|
+
<%= render legend_component %>
|
|
61
|
+
<% end %>
|
|
62
|
+
<% if tooltip? %><%= render tooltip_layer_component %><% end %>
|
|
63
|
+
<script type="application/json" data-slot="chart-coordinates" <%= tag.attributes(stimulus_attributes_for(:coordinates)) %>><%= script_json(coordinates_json) %></script>
|
|
64
|
+
<% end %>
|
|
65
|
+
<% end %>
|