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,424 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poetry
|
|
4
|
+
module Charts
|
|
5
|
+
# The radial bar chart family.
|
|
6
|
+
module RadialBarChart
|
|
7
|
+
# Renders a radial bar chart: one angular bar per data row on
|
|
8
|
+
# concentric rings between inner_radius and outer_radius, sweep
|
|
9
|
+
# proportional to value over the angle span. Rings divide the
|
|
10
|
+
# radial band with the SAME math as vertical bars (10% trim, 4px
|
|
11
|
+
# gaps); stacked radial bars share the ring and stack along the
|
|
12
|
+
# ANGLE; corner_radius rounds arc ends with tangent circles;
|
|
13
|
+
# `background: true` draws the muted track ring; polar-grid discs
|
|
14
|
+
# (the gauge look) and the donut-style center label round out the
|
|
15
|
+
# family.
|
|
16
|
+
#
|
|
17
|
+
# @example Rings with track backgrounds and a tooltip
|
|
18
|
+
# <%= poetry_chart :radial, data: browsers, config: config,
|
|
19
|
+
# inner_radius: 30, outer_radius: 110 do |c| %>
|
|
20
|
+
# <% c.with_radial_bar data_key: :visitors, background: true %>
|
|
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
|
+
|
|
28
|
+
# Projected into the registry and agent surface.
|
|
29
|
+
AGENT_RULES = [
|
|
30
|
+
"One ring per data row; rows carry their color in a fill key (var(--color-<name>)).",
|
|
31
|
+
"background: true draws the muted track ring behind each bar (the gauge look).",
|
|
32
|
+
"Stack two radial bars with the same stack: id - they share the ring and stack by ANGLE.",
|
|
33
|
+
"corner_radius rounds the arc ends (10 on a 10px ring = full pill caps).",
|
|
34
|
+
"with_polar_grid(radii:, fills:) draws the shape/text blocks' disc track; " \
|
|
35
|
+
"with_center_label fills the middle.",
|
|
36
|
+
"Entrance animation is on by default (source parity); animate: false for a static chart. " \
|
|
37
|
+
"Reduced-motion users always get the finished chart."
|
|
38
|
+
].freeze
|
|
39
|
+
|
|
40
|
+
# One with_radial_bar call's captured series config.
|
|
41
|
+
Series = Data.define(:data_key, :stack, :background, :corner_radius, :color_key,
|
|
42
|
+
:labels, :label_key)
|
|
43
|
+
|
|
44
|
+
option :data, ActiveModel::Type::Value.new, required: true,
|
|
45
|
+
doc: "The rows to plot: an array of hashes, one ring per row."
|
|
46
|
+
option :config, ActiveModel::Type::Value.new, required: true,
|
|
47
|
+
doc: "The series config - name => { label:, color: } - naming " \
|
|
48
|
+
"and coloring the rings."
|
|
49
|
+
option :id, :string,
|
|
50
|
+
doc: "Explicit DOM id token, stable across renders; otherwise the chart gets a unique per-render id."
|
|
51
|
+
option :width, :integer, default: 250,
|
|
52
|
+
doc: "ViewBox width in pixels; the rendered chart scales to its container."
|
|
53
|
+
option :height, :integer, default: 250, doc: "ViewBox height in pixels."
|
|
54
|
+
option :margin, ActiveModel::Type::Value.new,
|
|
55
|
+
doc: "Margin overrides ({ top:, right:, bottom:, left: }), merged over the slim polar default."
|
|
56
|
+
option :label, :string,
|
|
57
|
+
doc: "Accessible name for the chart SVG; defaults to one built from the configured series."
|
|
58
|
+
|
|
59
|
+
motion_options
|
|
60
|
+
option :name_key, :string, default: "name", doc: "The row key naming each ring."
|
|
61
|
+
option :start_angle, :integer, default: 0, doc: "Where the sweep starts, in degrees."
|
|
62
|
+
option :end_angle, :integer, default: 360, doc: "Where the sweep ends - 180 makes a half gauge."
|
|
63
|
+
option :inner_radius, ActiveModel::Type::Value.new, default: "20%",
|
|
64
|
+
doc: "The innermost ring edge: a percent string of the " \
|
|
65
|
+
"max radius, or pixels."
|
|
66
|
+
option :outer_radius, ActiveModel::Type::Value.new, default: "80%",
|
|
67
|
+
doc: "The outermost ring edge: a percent string of the " \
|
|
68
|
+
"max radius, or pixels."
|
|
69
|
+
option :max_value, ActiveModel::Type::Value.new,
|
|
70
|
+
doc: "The angle-axis maximum: nil = the largest single value, so the largest ring closes the full " \
|
|
71
|
+
"sweep EXACTLY (nicing it would leave a notch); stacked gauges pass the stack total when the " \
|
|
72
|
+
"segments should fill the span."
|
|
73
|
+
|
|
74
|
+
part "chart-svg", "The chart canvas (<svg>) - the aria-label surface, the tooltip's " \
|
|
75
|
+
"focus/keyboard surface (role=application when it attaches), and " \
|
|
76
|
+
"the motion rig's mount",
|
|
77
|
+
states: {
|
|
78
|
+
"data-animate" => "when animate (the default) - the entrance tier's flag the " \
|
|
79
|
+
"motion stylesheet and controller key off",
|
|
80
|
+
"data-motion" => { condition: "runtime, when animate - the motion engine's lifecycle stamp",
|
|
81
|
+
values: %w[entrance morph settled] }
|
|
82
|
+
},
|
|
83
|
+
vars: {
|
|
84
|
+
"--poetry-motion-delay" => "the motion rig's entrance delay (animation_begin)",
|
|
85
|
+
"--poetry-motion-duration" => "the motion rig's entrance duration (animation_duration)",
|
|
86
|
+
"--poetry-motion-easing" => "the motion rig's easing keyword (animation_easing)"
|
|
87
|
+
}
|
|
88
|
+
part "chart-polar-grid", "The polar grid (<g>, aria-hidden) - ring and spoke linework " \
|
|
89
|
+
"behind the series"
|
|
90
|
+
part "chart-radial-series", "One series' ring group (<g>)",
|
|
91
|
+
states: { "data-key" => "always - the series key" }
|
|
92
|
+
part "chart-radial-background", "The muted track ring (<path>) behind a bar, rendered " \
|
|
93
|
+
"when background: true"
|
|
94
|
+
part "chart-radial-bar", "One angular bar (<path>) - a ring per data row, sweep " \
|
|
95
|
+
"proportional to value",
|
|
96
|
+
states: {
|
|
97
|
+
"data-key" => "always - the series key",
|
|
98
|
+
"data-index" => "on the first series' bars - the datum index the tooltip walks",
|
|
99
|
+
"data-active" => "the hovered/arrow-keyed bar - the tooltip controller reflects " \
|
|
100
|
+
"the active index here at runtime",
|
|
101
|
+
"data-motion-group" => "when animate - the motion rig's sweep group (one per ring)",
|
|
102
|
+
"data-motion-sector" => "when animate - the motion rig's server-computed sector params " \
|
|
103
|
+
"for the fan-out sweep"
|
|
104
|
+
}
|
|
105
|
+
part "chart-labels", "A series' value labels (<g> of <text>, aria-hidden), rendered when " \
|
|
106
|
+
"the series opts into labels",
|
|
107
|
+
states: { "data-key" => "always - the series key" }
|
|
108
|
+
part "chart-center-label", "The center text (<text>) - title tspan plus optional subtitle " \
|
|
109
|
+
"filling the chart's middle"
|
|
110
|
+
part "chart-coordinates", "The embedded JSON payload (<script>) the tooltip controller " \
|
|
111
|
+
"reads - per-category anchors and pre-formatted values, zero " \
|
|
112
|
+
"chart math in the browser"
|
|
113
|
+
|
|
114
|
+
renders_many :radial_bars,
|
|
115
|
+
doc: "A radial series reading data_key: values. background: draws the muted track ring; bars " \
|
|
116
|
+
"sharing a stack: id share the ring and stack by angle; corner_radius: rounds the arc " \
|
|
117
|
+
"ends.",
|
|
118
|
+
renders: lambda { |data_key:, stack: nil, background: false, corner_radius: 0,
|
|
119
|
+
color_key: :fill, labels: nil, label_key: nil|
|
|
120
|
+
(@series_entries ||= []) << Series.new(data_key: data_key.to_s, stack:, background:,
|
|
121
|
+
corner_radius:, color_key: color_key&.to_s,
|
|
122
|
+
labels: labels&.to_sym, label_key: label_key&.to_s)
|
|
123
|
+
nil
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
renders_one :polar_grid,
|
|
127
|
+
doc: "The disc track behind the rings: radii: places the circles (default: each ring's " \
|
|
128
|
+
"centerline), fills: tints them, and radial_lines: draws the faint value spokes that show " \
|
|
129
|
+
"through ring gaps and the open wedge (on by default; gauges turn them off).",
|
|
130
|
+
renders: lambda { |radii: nil, fills: nil, radial_lines: true|
|
|
131
|
+
@polar_grid_config = { radii: radii, fills: Array(fills), radial_lines: radial_lines }
|
|
132
|
+
nil
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
renders_one :center_label,
|
|
136
|
+
doc: "The center text: a title line plus an optional subtitle. The default is the full gauge's " \
|
|
137
|
+
"big centered number; compact: shrinks it and sits it just above a half gauge's flat " \
|
|
138
|
+
"baseline.",
|
|
139
|
+
renders: lambda { |title:, subtitle: nil, compact: false|
|
|
140
|
+
@center_label_config = { title: title, subtitle: subtitle, compact: compact }
|
|
141
|
+
nil
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
renders_one :legend,
|
|
145
|
+
doc: "The legend row: align:, items:, and hide_icon:.",
|
|
146
|
+
renders: lambda { |**options|
|
|
147
|
+
@legend_config = options
|
|
148
|
+
nil
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
renders_one :tooltip,
|
|
152
|
+
doc: "The hover tooltip; the ring name carries the label, so hide_label defaults on.",
|
|
153
|
+
renders: lambda { |**options|
|
|
154
|
+
@tooltip_config = { hide_label: true }.merge(options)
|
|
155
|
+
nil
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
# The polar chassis: margin/plot/center geometry plus the per-arc
|
|
159
|
+
# pointerover hit; the single-series tooltip chrome and payload
|
|
160
|
+
# ride the polar_* hooks below.
|
|
161
|
+
include Poetry::Charts::PolarFamily
|
|
162
|
+
include Poetry::Charts::PolarFamily::SingleSeriesTooltip
|
|
163
|
+
|
|
164
|
+
# The captured Series configs, forcing lazy slot evaluation.
|
|
165
|
+
# @api private
|
|
166
|
+
def series_entries
|
|
167
|
+
radial_bars? # force slot evaluation (slots evaluate lazily)
|
|
168
|
+
@series_entries ||= []
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# The polar-grid slot's captured config, forcing lazy slot
|
|
172
|
+
# evaluation.
|
|
173
|
+
# @api private
|
|
174
|
+
def polar_grid_config
|
|
175
|
+
polar_grid?
|
|
176
|
+
@polar_grid_config
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# The center-label slot's captured config, forcing lazy slot
|
|
180
|
+
# evaluation.
|
|
181
|
+
# @api private
|
|
182
|
+
def center_label_config
|
|
183
|
+
center_label?
|
|
184
|
+
@center_label_config
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# -- geometry ---------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
# Band trim on each side of a ring, as a fraction of the band.
|
|
190
|
+
TRIM = 0.1
|
|
191
|
+
# Pixels between rings.
|
|
192
|
+
GAP = 4.0
|
|
193
|
+
|
|
194
|
+
# The data rows with stringified keys.
|
|
195
|
+
# @api private
|
|
196
|
+
def rows
|
|
197
|
+
@rows ||= data.map { |row| row.to_h.transform_keys(&:to_s) }
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# The [inner, outer] radial band edges resolved to pixels.
|
|
201
|
+
# @api private
|
|
202
|
+
def radii
|
|
203
|
+
@radii ||= begin
|
|
204
|
+
max = Polar.max_radius(plot[:width], plot[:height])
|
|
205
|
+
[Polar.percent_value(inner_radius, max, max * 0.2),
|
|
206
|
+
Polar.percent_value(outer_radius, max, max * 0.8)]
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# The radial band: one ring per ROW, the vertical-bar math rotated
|
|
211
|
+
# onto the radius axis (10% trim each side, 4px between rings).
|
|
212
|
+
# @api private
|
|
213
|
+
def ring(index)
|
|
214
|
+
inner, outer = radii
|
|
215
|
+
band = (outer - inner) / rows.length
|
|
216
|
+
trim = band * TRIM
|
|
217
|
+
thickness = band - (2 * trim)
|
|
218
|
+
ring_inner = inner + (band * index) + trim
|
|
219
|
+
[ring_inner, ring_inner + thickness]
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Whether any series declared a stack.
|
|
223
|
+
# @api private
|
|
224
|
+
def stacked?
|
|
225
|
+
series_entries.any?(&:stack)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# The default angle domain is [0, max] over the RAW cell values,
|
|
229
|
+
# NOT the stacked totals - so a stacked ring maps each segment
|
|
230
|
+
# through the max single value and the overflow past end_angle is
|
|
231
|
+
# clipped (a half gauge: 570 of max 1260 = 81deg; the next
|
|
232
|
+
# segment stacks on and clips at 180deg). An explicit max_value:
|
|
233
|
+
# overrides (a caller who wants the stack total to fill the span
|
|
234
|
+
# exactly passes it).
|
|
235
|
+
# @api private
|
|
236
|
+
def angle_max
|
|
237
|
+
@angle_max ||= if max_value
|
|
238
|
+
max_value.to_f
|
|
239
|
+
else
|
|
240
|
+
rows.flat_map { |row| series_entries.map { |e| row[e.data_key].to_f } }
|
|
241
|
+
.max&.nonzero? || 1
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# A value's angular sweep across the span.
|
|
246
|
+
# @api private
|
|
247
|
+
def sweep(value)
|
|
248
|
+
(value.to_f / angle_max) * (end_angle - start_angle)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Clip a stacked segment's end at end_angle - a stack running
|
|
252
|
+
# past the domain max never sweeps beyond the span.
|
|
253
|
+
# @api private
|
|
254
|
+
def clip_angle(angle)
|
|
255
|
+
Polar.sign(end_angle - start_angle).positive? ? [angle, end_angle.to_f].min : [angle, end_angle.to_f].max
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# One computed arc: its ring and sweep geometry plus name, value,
|
|
259
|
+
# and fill.
|
|
260
|
+
Segment = Data.define(:index, :name, :value, :fill, :path, :ring_inner, :ring_outer,
|
|
261
|
+
:seg_start, :seg_end)
|
|
262
|
+
|
|
263
|
+
# Per-series segments: unstacked series sweep from start_angle;
|
|
264
|
+
# stacked series continue from the previous series' end.
|
|
265
|
+
# @api private
|
|
266
|
+
def segments(entry)
|
|
267
|
+
@segments ||= {}.compare_by_identity
|
|
268
|
+
# Slot-identity memo, never data_key: two series may share a
|
|
269
|
+
# data_key (different stacks), and a key-keyed memo would render
|
|
270
|
+
# the first series' segments twice.
|
|
271
|
+
@segments[entry] ||= rows.each_with_index.map do |row, i|
|
|
272
|
+
ring_inner, ring_outer = ring(i)
|
|
273
|
+
base = @segment_cursor&.dig(entry.stack, i) || start_angle.to_f
|
|
274
|
+
seg_start = entry.stack ? base : start_angle.to_f
|
|
275
|
+
seg_end = seg_start + sweep(row[entry.data_key])
|
|
276
|
+
seg_end = clip_angle(seg_end) if entry.stack
|
|
277
|
+
if entry.stack
|
|
278
|
+
@segment_cursor ||= {}
|
|
279
|
+
(@segment_cursor[entry.stack] ||= {})[i] = seg_end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
Segment.new(
|
|
283
|
+
index: i,
|
|
284
|
+
name: row[name_key].to_s,
|
|
285
|
+
value: row[entry.data_key],
|
|
286
|
+
fill: segment_fill(entry, row),
|
|
287
|
+
path: Polar.sector_path_with_corners(cx: cx, cy: cy, inner_radius: ring_inner,
|
|
288
|
+
outer_radius: ring_outer, start_angle: seg_start,
|
|
289
|
+
end_angle: seg_end, corner_radius: entry.corner_radius),
|
|
290
|
+
ring_inner: ring_inner,
|
|
291
|
+
ring_outer: ring_outer,
|
|
292
|
+
seg_start: seg_start,
|
|
293
|
+
seg_end: seg_end
|
|
294
|
+
)
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# The muted full-span track ring behind one bar.
|
|
299
|
+
# @api private
|
|
300
|
+
def background_path(segment)
|
|
301
|
+
Polar.sector_path(cx: cx, cy: cy, inner_radius: segment.ring_inner,
|
|
302
|
+
outer_radius: segment.ring_outer,
|
|
303
|
+
start_angle: start_angle, end_angle: end_angle)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# A ring's fill: the color_key row value (CSS-validated), else
|
|
307
|
+
# the config color for the series, else the series color var.
|
|
308
|
+
# @api private
|
|
309
|
+
def segment_fill(entry, row)
|
|
310
|
+
color = row[entry.color_key].to_s if entry.color_key
|
|
311
|
+
if color.present?
|
|
312
|
+
raise ArgumentError, "radial fill #{color.inspect} is not CSS-safe" unless color.match?(Config::COLOR)
|
|
313
|
+
|
|
314
|
+
color
|
|
315
|
+
else
|
|
316
|
+
chart_config[entry.data_key]&.color || "var(--color-#{entry.data_key})"
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Inside-start labels sit a FIXED ARC LENGTH into the sweep (not
|
|
321
|
+
# a fixed angle), so an inner ring - where the same ~9px arc
|
|
322
|
+
# spans a larger angle - tilts more, tapering to near-flat on the
|
|
323
|
+
# outer rings. Rotated onto the arc's TANGENT pointing into the
|
|
324
|
+
# sweep: SVG rotate() is clockwise-positive in the y-down plane,
|
|
325
|
+
# so the tangent at chart angle t is (-sin t, -cos t) for a CCW
|
|
326
|
+
# sweep. (90 - angle reads plausibly but points the text the
|
|
327
|
+
# OPPOSITE way: mirrored and upside down.) When the tangent would
|
|
328
|
+
# still leave the text inverted, flip it 180 and anchor the END
|
|
329
|
+
# at the start edge so the label stays readable.
|
|
330
|
+
LABEL_ARC = 9.0
|
|
331
|
+
|
|
332
|
+
# One segment's label position, rotation, and anchor.
|
|
333
|
+
# @api private
|
|
334
|
+
def label_placement(segment)
|
|
335
|
+
direction = Polar.sign(end_angle - start_angle)
|
|
336
|
+
radius = (segment.ring_inner + segment.ring_outer) / 2.0
|
|
337
|
+
angle = segment.seg_start + (direction * (LABEL_ARC / radius) / Polar::RADIAN)
|
|
338
|
+
x, y = Polar.polar_to_cartesian(cx, cy, radius, angle)
|
|
339
|
+
rad = Polar::RADIAN * angle
|
|
340
|
+
rotate = Math.atan2(-direction * Math.cos(rad), -direction * Math.sin(rad)) / Polar::RADIAN
|
|
341
|
+
anchor = "start"
|
|
342
|
+
if rotate > 90 || rotate <= -90
|
|
343
|
+
rotate -= 180 * Polar.sign(rotate)
|
|
344
|
+
anchor = "end"
|
|
345
|
+
end
|
|
346
|
+
{ x: x, y: y, rotate: rotate, anchor: anchor }
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# The grid circles' radii: the slot's radii:, else auto.
|
|
350
|
+
# @api private
|
|
351
|
+
def grid_radii
|
|
352
|
+
config_radii = polar_grid_config[:radii]
|
|
353
|
+
return config_radii if config_radii
|
|
354
|
+
|
|
355
|
+
# Auto: circles through each ring's CENTERLINE - each circle
|
|
356
|
+
# continues a bar's track through the uncovered wedge.
|
|
357
|
+
(0...rows.length).map { |i| ring(i).sum / 2.0 }
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Standard 1-2-5 ticks over the value domain (count 10) - the
|
|
361
|
+
# implicit angle axis, where the grid's radial spokes sit.
|
|
362
|
+
# @api private
|
|
363
|
+
def angle_tick_values
|
|
364
|
+
max = angle_max.to_f
|
|
365
|
+
return [] unless max.positive?
|
|
366
|
+
|
|
367
|
+
step0 = max / 10.0
|
|
368
|
+
power = 10.0**Math.log10(step0).floor
|
|
369
|
+
error = step0 / power
|
|
370
|
+
step = if error >= Math.sqrt(50) then power * 10
|
|
371
|
+
elsif error >= Math.sqrt(10) then power * 5
|
|
372
|
+
elsif error >= Math.sqrt(2) then power * 2
|
|
373
|
+
else power
|
|
374
|
+
end
|
|
375
|
+
(0..(max / step).floor).map { |i| i * step }
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# A grid spoke at one value tick, spanning the chart's radial band
|
|
379
|
+
# (template helper - bare Polar does not resolve from compiled ERB).
|
|
380
|
+
# @api private
|
|
381
|
+
def spoke_points(tick)
|
|
382
|
+
inner, outer = radii
|
|
383
|
+
angle = start_angle + sweep(tick)
|
|
384
|
+
Polar.polar_to_cartesian(cx, cy, inner, angle) + Polar.polar_to_cartesian(cx, cy, outer, angle)
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# One grid circle's class: its fills: tint, else the plain ring.
|
|
388
|
+
# @api private
|
|
389
|
+
def grid_fill_class(index)
|
|
390
|
+
token = polar_grid_config[:fills][index]
|
|
391
|
+
token ? css(:"grid_fill_#{token}") : css(:grid_circle)
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# -- the tooltip payload (polar shape, ring anchors) -------------------
|
|
395
|
+
|
|
396
|
+
# The SingleSeriesTooltip hooks: the first series' segments are
|
|
397
|
+
# the items, anchored mid-ring / mid-sweep; values read the chart
|
|
398
|
+
# rows.
|
|
399
|
+
# @api private
|
|
400
|
+
def polar_items(entry) = segments(entry)
|
|
401
|
+
|
|
402
|
+
# Where the tooltip anchors on one segment.
|
|
403
|
+
# @api private
|
|
404
|
+
def polar_anchor(segment)
|
|
405
|
+
Polar.polar_to_cartesian(cx, cy, (segment.ring_inner + segment.ring_outer) / 2.0,
|
|
406
|
+
(segment.seg_start + segment.seg_end) / 2.0)
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# The rows the tooltip values read from.
|
|
410
|
+
# @api private
|
|
411
|
+
def polar_value_rows(_entry) = rows
|
|
412
|
+
|
|
413
|
+
# ChartFamily#svg_label's chart-type lead-in.
|
|
414
|
+
# @api private
|
|
415
|
+
def svg_label_prefix = "Radial bar chart"
|
|
416
|
+
|
|
417
|
+
private :polar_grid_config, :center_label_config, :rows, :radii, :ring, :stacked?, :angle_max
|
|
418
|
+
private :sweep, :clip_angle, :segments, :background_path, :segment_fill, :label_placement, :grid_radii
|
|
419
|
+
private :angle_tick_values, :spoke_points, :grid_fill_class, :polar_items, :polar_anchor, :polar_value_rows
|
|
420
|
+
private :svg_label_prefix
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poetry
|
|
4
|
+
module Charts
|
|
5
|
+
module RadialBarChart
|
|
6
|
+
# The radial family across its variants: simple (rings +
|
|
7
|
+
# muted tracks), grid (disc boundaries), inside-start labels, the
|
|
8
|
+
# gauge shapes (disc tracks + center text), and the angle-stacked
|
|
9
|
+
# half gauge.
|
|
10
|
+
class Preview < Poetry::Core::Preview::Base
|
|
11
|
+
CONFIG = {
|
|
12
|
+
visitors: { label: "Visitors" },
|
|
13
|
+
chrome: { label: "Chrome", color: "var(--chart-1)" },
|
|
14
|
+
safari: { label: "Safari", color: "var(--chart-2)" },
|
|
15
|
+
firefox: { label: "Firefox", color: "var(--chart-3)" },
|
|
16
|
+
edge: { label: "Edge", color: "var(--chart-4)" },
|
|
17
|
+
other: { label: "Other", color: "var(--chart-5)" },
|
|
18
|
+
desktop: { label: "Desktop", color: "var(--chart-1)" },
|
|
19
|
+
mobile: { label: "Mobile", color: "var(--chart-2)" }
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
DATA = [
|
|
23
|
+
{ browser: "chrome", visitors: 275, fill: "var(--color-chrome)" },
|
|
24
|
+
{ browser: "safari", visitors: 200, fill: "var(--color-safari)" },
|
|
25
|
+
{ browser: "firefox", visitors: 187, fill: "var(--color-firefox)" },
|
|
26
|
+
{ browser: "edge", visitors: 173, fill: "var(--color-edge)" },
|
|
27
|
+
{ browser: "other", visitors: 90, fill: "var(--color-other)" }
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
def simple
|
|
31
|
+
render_component(data: DATA, config: CONFIG, id: "radial-simple", name_key: :browser,
|
|
32
|
+
inner_radius: 30, outer_radius: 110) do |chart|
|
|
33
|
+
chart.with_radial_bar(data_key: :visitors, background: true)
|
|
34
|
+
chart.with_tooltip
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def grid
|
|
39
|
+
render_component(data: DATA, config: CONFIG, id: "radial-grid", name_key: :browser,
|
|
40
|
+
inner_radius: 30, outer_radius: 100) do |chart|
|
|
41
|
+
chart.with_polar_grid
|
|
42
|
+
chart.with_radial_bar(data_key: :visitors)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def label
|
|
47
|
+
render_component(data: DATA, config: CONFIG, id: "radial-label", name_key: :browser,
|
|
48
|
+
start_angle: -90, end_angle: 380, inner_radius: 30, outer_radius: 110) do |chart|
|
|
49
|
+
chart.with_radial_bar(data_key: :visitors, background: true,
|
|
50
|
+
labels: :inside_start, label_key: :browser)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def shape
|
|
55
|
+
render_component(data: [{ browser: "safari", visitors: 1260, fill: "var(--color-safari)" }],
|
|
56
|
+
config: CONFIG, id: "radial-shape", name_key: :browser,
|
|
57
|
+
end_angle: 100, inner_radius: 65, outer_radius: 95) do |chart|
|
|
58
|
+
chart.with_polar_grid(radii: [86, 74], fills: %i[muted background], radial_lines: false)
|
|
59
|
+
chart.with_radial_bar(data_key: :visitors, background: true)
|
|
60
|
+
chart.with_center_label(title: "1,260", subtitle: "Visitors")
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def text
|
|
65
|
+
render_component(data: [{ browser: "safari", visitors: 200, fill: "var(--color-safari)" }],
|
|
66
|
+
config: CONFIG, id: "radial-text", name_key: :browser,
|
|
67
|
+
start_angle: 0, end_angle: 250, inner_radius: 80, outer_radius: 90) do |chart|
|
|
68
|
+
chart.with_polar_grid(radii: [90, 80], fills: %i[muted background], radial_lines: false)
|
|
69
|
+
chart.with_radial_bar(data_key: :visitors, background: true, corner_radius: 10)
|
|
70
|
+
chart.with_center_label(title: "200", subtitle: "Visitors")
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def stacked
|
|
75
|
+
data = [{ month: "january", desktop: 1260, mobile: 570 }]
|
|
76
|
+
# No max_value: the stack maps through [0, max] over the raw
|
|
77
|
+
# values (1260), so mobile fills 570/1260 = 81deg and desktop
|
|
78
|
+
# stacks on and clips at the 180deg end.
|
|
79
|
+
render_component(data: data, config: CONFIG, id: "radial-stacked", name_key: :month,
|
|
80
|
+
end_angle: 180, inner_radius: 80, outer_radius: 110) do |chart|
|
|
81
|
+
chart.with_radial_bar(data_key: :mobile, stack: :a, corner_radius: 5)
|
|
82
|
+
chart.with_radial_bar(data_key: :desktop, stack: :a, corner_radius: 5)
|
|
83
|
+
chart.with_center_label(title: "1,830", subtitle: "Visitors", compact: true)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# A sync group member: the sync value the stimulus contract
|
|
88
|
+
# holds to the DOM.
|
|
89
|
+
def synced
|
|
90
|
+
render_component(data: DATA, config: CONFIG, id: "radial-synced", name_key: :browser,
|
|
91
|
+
inner_radius: 30, outer_radius: 110, sync: "radial-demo") do |chart|
|
|
92
|
+
chart.with_radial_bar(data_key: :visitors, background: true)
|
|
93
|
+
chart.with_tooltip
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poetry
|
|
4
|
+
module Charts
|
|
5
|
+
module RadialBarChart
|
|
6
|
+
# The radial chrome on the cn-chart-* theme rules: the muted
|
|
7
|
+
# track ring, the disc track fills (the first:fill-muted /
|
|
8
|
+
# last:fill-background hack, still explicit), the inside-start
|
|
9
|
+
# label, and the per-family center typography (4xl gauge /
|
|
10
|
+
# 2xl compact; pie keeps 3xl).
|
|
11
|
+
class Style < Poetry::Core::Style
|
|
12
|
+
base ""
|
|
13
|
+
|
|
14
|
+
element :frame, "contents"
|
|
15
|
+
element :svg, "w-full min-h-0 flex-1"
|
|
16
|
+
element :background_ring, "cn-chart-radial-track"
|
|
17
|
+
element :grid_circle, "cn-chart-polar-grid-circle"
|
|
18
|
+
element :grid_fill_muted, "cn-chart-radial-disc-muted"
|
|
19
|
+
element :grid_fill_background, "cn-chart-radial-disc-background"
|
|
20
|
+
element :inside_label, "cn-chart-radial-inside-label"
|
|
21
|
+
element :center_title, "cn-chart-center-title-radial"
|
|
22
|
+
element :center_title_compact, "cn-chart-center-title-compact"
|
|
23
|
+
element :center_subtitle, "cn-chart-center-subtitle"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|