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,318 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poetry
|
|
4
|
+
module Charts
|
|
5
|
+
# The bar chart family.
|
|
6
|
+
module BarChart
|
|
7
|
+
# Renders a bar chart, vertical columns by default
|
|
8
|
+
# (orientation: :horizontal flips them): band-scale positioning - a
|
|
9
|
+
# 10% category gap trims each side of the band, a 4px gap separates
|
|
10
|
+
# side-by-side groups, stacked bars share a slot. Rounded corners
|
|
11
|
+
# are per-corner (radius: 8 or [tl, tr, br, bl] - stacked segments
|
|
12
|
+
# round only their outer edge), negatives drop below the zero
|
|
13
|
+
# baseline, and per-cell fills come from a data key or a guarded
|
|
14
|
+
# proc (sign-based coloring).
|
|
15
|
+
#
|
|
16
|
+
# @example Columns with rounded corners
|
|
17
|
+
# <%= poetry_chart :bar, data:, config: do |c| %>
|
|
18
|
+
# <% c.with_grid %>
|
|
19
|
+
# <% c.with_x_axis data_key: :month, tick_formatter: ->(v) { v[0, 3] } %>
|
|
20
|
+
# <% c.with_bar data_key: :desktop, radius: 8 %>
|
|
21
|
+
# <% end %>
|
|
22
|
+
class Component < Poetry::Core::Component
|
|
23
|
+
include Poetry::Charts::ChartFamily
|
|
24
|
+
include Poetry::Charts::TooltipWiring
|
|
25
|
+
include Poetry::Charts::Motion
|
|
26
|
+
include Poetry::Charts::Live
|
|
27
|
+
include Poetry::Charts::BarMath
|
|
28
|
+
include Poetry::Charts::ReferenceMarks
|
|
29
|
+
include Poetry::Charts::ErrorBars
|
|
30
|
+
|
|
31
|
+
# Projected into the registry and agent surface.
|
|
32
|
+
AGENT_RULES = [
|
|
33
|
+
"Compose from slots: with_grid / with_x_axis(data_key:) / with_bar(data_key:) / with_legend.",
|
|
34
|
+
"radius: 8 rounds all corners; stacked bars use arrays - [0,0,4,4] bottom bar, [4,4,0,0] top bar.",
|
|
35
|
+
"Stack bars with the same stack: id; negatives automatically drop below the zero line.",
|
|
36
|
+
"cell_fill: ->(row, value) { ... } colors bars per datum (validated CSS-safe); color_key: reads a row key.",
|
|
37
|
+
"active_index: highlights one bar (fill-opacity 0.8 + dashed stroke - the active block look).",
|
|
38
|
+
"Entrance animation is on by default (source parity); animate: false for a static chart. " \
|
|
39
|
+
"Reduced-motion users always get the finished chart."
|
|
40
|
+
].freeze
|
|
41
|
+
|
|
42
|
+
# One with_bar call's captured series config.
|
|
43
|
+
Series = Data.define(:key, :stack, :radius, :labels, :label_key, :color_key,
|
|
44
|
+
:cell_fill, :active_index, :stroke_width, :error_key, :error_width) do
|
|
45
|
+
# The band-slot group id: the stack, else the series' own key.
|
|
46
|
+
def stack_or_self = stack || key
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
option :data, ActiveModel::Type::Value.new, required: true,
|
|
50
|
+
doc: "The rows to plot: an array of hashes, one per category."
|
|
51
|
+
option :config, ActiveModel::Type::Value.new, required: true,
|
|
52
|
+
doc: "The series config - key => { label:, color: } - naming " \
|
|
53
|
+
"and coloring every series."
|
|
54
|
+
option :id, :string,
|
|
55
|
+
doc: "Explicit DOM id token, stable across renders; otherwise the chart gets a unique per-render id."
|
|
56
|
+
option :width, :integer, default: 640,
|
|
57
|
+
doc: "ViewBox width in pixels; the rendered chart scales to its container."
|
|
58
|
+
option :height, :integer, default: 360, doc: "ViewBox height in pixels."
|
|
59
|
+
option :margin, ActiveModel::Type::Value.new,
|
|
60
|
+
doc: "Plot margin overrides ({ top:, right:, bottom:, left: }), merged over the defaults."
|
|
61
|
+
option :offset, :symbol, default: :none,
|
|
62
|
+
doc: "Stack baseline mode - :expand normalizes each stack to percentages."
|
|
63
|
+
option :label, :string,
|
|
64
|
+
doc: "Accessible name for the chart SVG; defaults to one built from the configured series."
|
|
65
|
+
|
|
66
|
+
motion_options(duration: 400)
|
|
67
|
+
live_option
|
|
68
|
+
option :bar_gap, :integer, default: 4, doc: "Pixels between side-by-side bars inside one category band."
|
|
69
|
+
option :bar_category_gap, :string, default: "10%",
|
|
70
|
+
doc: "Band trim on each side: a percent string of the band width, or a " \
|
|
71
|
+
"bare pixel number."
|
|
72
|
+
option :orientation, :symbol, default: :vertical,
|
|
73
|
+
doc: ":vertical = columns (the default); :horizontal = bars growing rightward " \
|
|
74
|
+
"- the category axis moves to the Y side (with_y_axis data_key:) and the " \
|
|
75
|
+
"numeric axis hides."
|
|
76
|
+
|
|
77
|
+
validates :offset, inclusion: { in: Cartesian::OFFSETS }
|
|
78
|
+
validates :orientation, inclusion: { in: Cartesian::LAYOUTS }
|
|
79
|
+
|
|
80
|
+
part "chart-svg", "The chart canvas (<svg>) - server-computed geometry in a fixed viewBox; " \
|
|
81
|
+
"role=img, or the focusable role=application accessibilityLayer when the " \
|
|
82
|
+
"tooltip attaches",
|
|
83
|
+
states: {
|
|
84
|
+
"data-animate" => "present when animate (the default) - the motion stylesheet and " \
|
|
85
|
+
"controller key the entrance off it",
|
|
86
|
+
"data-motion" => { condition: "runtime - the motion rig stamps the animation " \
|
|
87
|
+
"lifecycle (entrance/morph, then settled)",
|
|
88
|
+
values: %w[entrance morph settled] }
|
|
89
|
+
},
|
|
90
|
+
vars: {
|
|
91
|
+
"--poetry-motion-duration" => "the entrance/morph duration (animation_duration, ms)",
|
|
92
|
+
"--poetry-motion-easing" => "the animation easing keyword (animation_easing)",
|
|
93
|
+
"--poetry-motion-delay" => "the pre-animation hold (animation_begin, ms)"
|
|
94
|
+
}
|
|
95
|
+
part "chart-grid", "The gridline group (with_grid) - horizontal and/or vertical rules " \
|
|
96
|
+
"across the plot"
|
|
97
|
+
part "chart-cursor", "The hover cursor, hidden until the tooltip controller positions " \
|
|
98
|
+
"and reveals it at the active index - a vertical rule or a " \
|
|
99
|
+
"translucent band rect (bar charts)"
|
|
100
|
+
part "chart-bars", "The bar-mark group wrapping every series"
|
|
101
|
+
part "chart-bar-series", "One series' bar group",
|
|
102
|
+
states: { "data-key" => "the series key" }
|
|
103
|
+
part "chart-bar", "One bar cell (a per-corner rounded-rect path)",
|
|
104
|
+
states: {
|
|
105
|
+
"data-key" => "the series key",
|
|
106
|
+
"data-index" => "the datum index",
|
|
107
|
+
"data-active" => "the highlighted cell - server-rendered from active_index:, " \
|
|
108
|
+
"and the tooltip controller marks the hovered index at runtime",
|
|
109
|
+
"data-motion-origin" => { condition: "when animate - the zero edge the entrance " \
|
|
110
|
+
"grows from",
|
|
111
|
+
values: %w[bottom top left right] }
|
|
112
|
+
}
|
|
113
|
+
part "chart-labels", "One series' value-label group (labels: true)",
|
|
114
|
+
states: { "data-key" => "the series key" }
|
|
115
|
+
part "chart-x-axis", "The x-axis tick-label group (with_x_axis)"
|
|
116
|
+
part "chart-y-axis", "The y-axis tick-label group (with_y_axis)"
|
|
117
|
+
part "chart-coordinates", "The embedded per-index geometry payload " \
|
|
118
|
+
"(<script type=application/json>) the tooltip controller " \
|
|
119
|
+
"reads - zero chart math in the browser"
|
|
120
|
+
|
|
121
|
+
renders_many :bars,
|
|
122
|
+
doc: "A bar series bound to data_key:. Bars sharing a stack: id pile up; radius: rounds " \
|
|
123
|
+
"corners; labels:/label_key: stamp values; color_key:/cell_fill: color per cell; " \
|
|
124
|
+
"active_index: highlights one bar; error_key: adds whiskers.",
|
|
125
|
+
renders: lambda { |data_key:, stack: nil, radius: 0, labels: false, label_key: nil,
|
|
126
|
+
color_key: nil, cell_fill: nil, active_index: nil, stroke_width: 2,
|
|
127
|
+
error_key: nil, error_width: 5|
|
|
128
|
+
(@series_entries ||= []) << Series.new(key: data_key.to_s, stack:, radius:, labels:,
|
|
129
|
+
label_key: label_key&.to_s, color_key: color_key&.to_s,
|
|
130
|
+
cell_fill:, active_index:, stroke_width:,
|
|
131
|
+
error_key: error_key&.to_s, error_width:)
|
|
132
|
+
nil
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
# In the horizontal orientation the Y axis IS the category axis -
|
|
136
|
+
# give it the data_key. The CartesianFamily value-axis hook, so
|
|
137
|
+
# the include below declares this shape between x_axis and grid.
|
|
138
|
+
def self.value_axis_slot
|
|
139
|
+
renders_one :y_axis,
|
|
140
|
+
doc: "The y axis: data_key: makes it the category axis (horizontal orientation); " \
|
|
141
|
+
"tick_count:/tick_formatter:/tick_margin: as on the x axis.",
|
|
142
|
+
renders: lambda { |data_key: nil, tick_count: 3, tick_formatter: nil, tick_margin: 8|
|
|
143
|
+
@y_axis_config = AxisConfig.new(data_key: data_key&.to_s, tick_formatter:,
|
|
144
|
+
tick_margin:, tick_count:)
|
|
145
|
+
nil
|
|
146
|
+
}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
include Poetry::Charts::CartesianFamily
|
|
150
|
+
|
|
151
|
+
# The captured Series configs, forcing lazy slot evaluation.
|
|
152
|
+
# @api private
|
|
153
|
+
def series_entries
|
|
154
|
+
bars? # force slot evaluation (slots evaluate lazily)
|
|
155
|
+
@series_entries ||= []
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Whether bars grow rightward from a Y-side category axis.
|
|
159
|
+
# @api private
|
|
160
|
+
def horizontal?
|
|
161
|
+
orientation == :horizontal
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# The chart's cartesian geometry, built once per render.
|
|
165
|
+
# @api private
|
|
166
|
+
def cartesian
|
|
167
|
+
@cartesian ||= Cartesian.new(
|
|
168
|
+
data: data,
|
|
169
|
+
series: series_entries,
|
|
170
|
+
width: width,
|
|
171
|
+
height: height,
|
|
172
|
+
x_key: horizontal? ? y_axis_config&.data_key : x_axis_config&.data_key,
|
|
173
|
+
margin: live_margin,
|
|
174
|
+
category_axis: horizontal? ? y_axis? : x_axis?,
|
|
175
|
+
value_axis: !horizontal? && y_axis?,
|
|
176
|
+
# Horizontal charts hide the numeric axis (implicit tickCount 5);
|
|
177
|
+
# vertical ones take the visible Y axis's count when present.
|
|
178
|
+
y_tick_count: horizontal? ? 5 : (y_axis_config&.tick_count || 5),
|
|
179
|
+
offset: offset,
|
|
180
|
+
x_scale_type: :band,
|
|
181
|
+
layout: orientation
|
|
182
|
+
)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# The per-group band slots: stacked bars share a slot; groups sit
|
|
186
|
+
# side by side inside the band (no explicit bar size).
|
|
187
|
+
# @api private
|
|
188
|
+
def bar_slots
|
|
189
|
+
@bar_slots ||= bar_slots_for(series_entries)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# One series' bar rects, positioned in its band slot.
|
|
193
|
+
# @api private
|
|
194
|
+
def cells(entry)
|
|
195
|
+
bar_cells(entry, bar_slots.fetch(entry.stack_or_self))
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# One bar cell's rounded-rect path.
|
|
199
|
+
# @api private
|
|
200
|
+
def bar_path(entry, cell)
|
|
201
|
+
bar_path_for(entry.radius, cell)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Fill resolution: cell_fill proc, else color_key row value, else
|
|
205
|
+
# the series color - everything reaching the attribute is guarded.
|
|
206
|
+
# @api private
|
|
207
|
+
def cell_fill(entry, cell)
|
|
208
|
+
color = if entry.cell_fill
|
|
209
|
+
entry.cell_fill.call(cell[:row], cell[:value]).to_s
|
|
210
|
+
elsif entry.color_key
|
|
211
|
+
cell[:row].to_h.transform_keys(&:to_s)[entry.color_key].to_s
|
|
212
|
+
else
|
|
213
|
+
return "var(--color-#{entry.key})"
|
|
214
|
+
end
|
|
215
|
+
raise ArgumentError, "bar fill #{color.inspect} is not CSS-safe" unless color.match?(Config::COLOR)
|
|
216
|
+
|
|
217
|
+
color
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Whether this cell is the series' highlighted active_index.
|
|
221
|
+
# @api private
|
|
222
|
+
def active?(entry, cell)
|
|
223
|
+
entry.active_index == cell[:index]
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# A cell's value label: the label_key row value, else the value
|
|
227
|
+
# with integers shown bare.
|
|
228
|
+
# @api private
|
|
229
|
+
def cell_label(entry, cell)
|
|
230
|
+
if entry.label_key
|
|
231
|
+
cell[:row].to_h.transform_keys(&:to_s)[entry.label_key].to_s
|
|
232
|
+
else
|
|
233
|
+
value = cell[:value]
|
|
234
|
+
value == value.to_i ? value.to_i.to_s : value.to_s
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# One x tick's label, through the slot's formatter when given.
|
|
239
|
+
# @api private
|
|
240
|
+
def x_tick_label(category)
|
|
241
|
+
formatter = x_axis_config&.tick_formatter
|
|
242
|
+
formatter ? formatter.call(category).to_s : category.to_s
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# The horizontal layout's category labels (the Y side).
|
|
246
|
+
# @api private
|
|
247
|
+
def category_tick_label(category)
|
|
248
|
+
formatter = y_axis_config&.tick_formatter
|
|
249
|
+
formatter ? formatter.call(category).to_s : category.to_s
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# One y tick's label, through the slot's formatter when given.
|
|
253
|
+
# @api private
|
|
254
|
+
def y_tick_label(tick)
|
|
255
|
+
formatter = y_axis_config&.tick_formatter
|
|
256
|
+
formatter ? formatter.call(tick).to_s : Geometry.js_number(tick.to_f)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# ChartFamily#svg_label's chart-type lead-in.
|
|
260
|
+
# @api private
|
|
261
|
+
def svg_label_prefix = "Bar chart"
|
|
262
|
+
|
|
263
|
+
# The embedded per-index geometry payload the tooltip controller
|
|
264
|
+
# reads.
|
|
265
|
+
# @api private
|
|
266
|
+
def coordinates_json
|
|
267
|
+
cartesian.coordinates.to_json
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# -- live mode ---------------------------------------------------
|
|
271
|
+
|
|
272
|
+
# The chart type in the live spec.
|
|
273
|
+
# @api private
|
|
274
|
+
def live_type = :bar
|
|
275
|
+
|
|
276
|
+
# The series list serialized into the live spec.
|
|
277
|
+
# @api private
|
|
278
|
+
def live_series
|
|
279
|
+
series_entries.map { |e| { data_key: e.key, stack: e.stack }.compact }
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# The axis config serialized into the live spec.
|
|
283
|
+
# @api private
|
|
284
|
+
def live_axes
|
|
285
|
+
axes = {}
|
|
286
|
+
if horizontal?
|
|
287
|
+
axes[:y] = { data_key: y_axis_config.data_key } if y_axis_config&.data_key
|
|
288
|
+
else
|
|
289
|
+
axes[:x] = { data_key: x_axis_config.data_key } if x_axis_config&.data_key
|
|
290
|
+
axes[:y] = { tick_count: y_axis_config.tick_count } if y_axis_config
|
|
291
|
+
end
|
|
292
|
+
axes
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# The x scale kind the live renderer rebuilds.
|
|
296
|
+
# @api private
|
|
297
|
+
def live_x_scale_type = :band
|
|
298
|
+
# Whether the live frame renders a category axis.
|
|
299
|
+
# @api private
|
|
300
|
+
def live_category_axis? = horizontal? ? y_axis? : x_axis?
|
|
301
|
+
|
|
302
|
+
# Bar-specific geometry knobs merged into the frame envelope.
|
|
303
|
+
# @api private
|
|
304
|
+
def live_frame_extras
|
|
305
|
+
{
|
|
306
|
+
"barGap" => bar_gap,
|
|
307
|
+
"barCategoryGap" => bar_category_gap,
|
|
308
|
+
"series" => series_entries.to_h { |e| [e.key, { "radius" => e.radius }] }
|
|
309
|
+
}
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
private :cartesian, :bar_slots, :cells, :bar_path, :cell_fill, :active?
|
|
313
|
+
private :cell_label, :x_tick_label, :category_tick_label, :y_tick_label, :svg_label_prefix, :coordinates_json
|
|
314
|
+
private :live_type, :live_series, :live_axes, :live_x_scale_type, :live_category_axis?, :live_frame_extras
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poetry
|
|
4
|
+
module Charts
|
|
5
|
+
module BarChart
|
|
6
|
+
# The bar family (vertical) across its variants: default
|
|
7
|
+
# (radius 8), multiple, stacked (outer-edge radius arrays), negative
|
|
8
|
+
# (sign-colored cells + month labels), value labels, the active
|
|
9
|
+
# highlight, and the horizontal + mixed layouts.
|
|
10
|
+
class Preview < Poetry::Core::Preview::Base
|
|
11
|
+
DATA = AreaChart::Preview::DATA
|
|
12
|
+
ONE = AreaChart::Preview::ONE
|
|
13
|
+
TWO = AreaChart::Preview::TWO
|
|
14
|
+
|
|
15
|
+
NEGATIVE_DATA = [
|
|
16
|
+
{ month: "January", visitors: 186 },
|
|
17
|
+
{ month: "February", visitors: 205 },
|
|
18
|
+
{ month: "March", visitors: -207 },
|
|
19
|
+
{ month: "April", visitors: 173 },
|
|
20
|
+
{ month: "May", visitors: -209 },
|
|
21
|
+
{ month: "June", visitors: 214 }
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
VISITORS = { visitors: { label: "Visitors" } }.freeze
|
|
25
|
+
|
|
26
|
+
BROWSER_DATA = LineChart::Preview::BROWSER_DATA
|
|
27
|
+
BROWSER_CONFIG = LineChart::Preview::BROWSER_CONFIG
|
|
28
|
+
|
|
29
|
+
def default
|
|
30
|
+
render_component(data: DATA, config: ONE, id: "bar-default") do |chart|
|
|
31
|
+
chart.with_grid
|
|
32
|
+
chart.with_x_axis(data_key: :month, tick_formatter: ->(v) { v[0, 3] })
|
|
33
|
+
chart.with_bar(data_key: :desktop, radius: 8)
|
|
34
|
+
chart.with_tooltip(hide_label: true)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def multiple
|
|
39
|
+
render_component(data: DATA, config: TWO, id: "bar-multiple") do |chart|
|
|
40
|
+
chart.with_grid
|
|
41
|
+
chart.with_x_axis(data_key: :month, tick_formatter: ->(v) { v[0, 3] })
|
|
42
|
+
chart.with_bar(data_key: :desktop, radius: 4)
|
|
43
|
+
chart.with_bar(data_key: :mobile, radius: 4)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def stacked
|
|
48
|
+
render_component(data: DATA, config: TWO, id: "bar-stacked") do |chart|
|
|
49
|
+
chart.with_grid
|
|
50
|
+
chart.with_x_axis(data_key: :month, tick_formatter: ->(v) { v[0, 3] })
|
|
51
|
+
chart.with_bar(data_key: :desktop, stack: :a, radius: [0, 0, 4, 4])
|
|
52
|
+
chart.with_bar(data_key: :mobile, stack: :a, radius: [4, 4, 0, 0])
|
|
53
|
+
chart.with_legend
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def negative
|
|
58
|
+
render_component(data: NEGATIVE_DATA, config: VISITORS, id: "bar-negative") do |chart|
|
|
59
|
+
chart.with_grid
|
|
60
|
+
chart.with_bar(data_key: :visitors, labels: true, label_key: :month,
|
|
61
|
+
cell_fill: ->(_row, value) { value.positive? ? "var(--chart-1)" : "var(--chart-2)" })
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def label
|
|
66
|
+
render_component(data: DATA, config: ONE, id: "bar-label", margin: { top: 20 }) do |chart|
|
|
67
|
+
chart.with_grid
|
|
68
|
+
chart.with_x_axis(data_key: :month, tick_formatter: ->(v) { v[0, 3] })
|
|
69
|
+
chart.with_bar(data_key: :desktop, radius: 8, labels: true)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def active
|
|
74
|
+
render_component(data: BROWSER_DATA, config: BROWSER_CONFIG, id: "bar-active") do |chart|
|
|
75
|
+
chart.with_grid
|
|
76
|
+
chart.with_x_axis(data_key: :browser, tick_formatter: ->(v) { BROWSER_CONFIG[v.to_sym][:label] })
|
|
77
|
+
chart.with_bar(data_key: :visitors, radius: 8, color_key: :fill, active_index: 2)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def horizontal
|
|
82
|
+
render_component(data: DATA, config: ONE, id: "bar-horizontal",
|
|
83
|
+
orientation: :horizontal, margin: { left: -20 }) do |chart|
|
|
84
|
+
chart.with_y_axis(data_key: :month, tick_formatter: ->(v) { v[0, 3] }, tick_margin: 10)
|
|
85
|
+
chart.with_bar(data_key: :desktop, radius: 5)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def mixed
|
|
90
|
+
render_component(data: BROWSER_DATA, config: BROWSER_CONFIG, id: "bar-mixed",
|
|
91
|
+
orientation: :horizontal, margin: { left: 0 }) do |chart|
|
|
92
|
+
chart.with_y_axis(data_key: :browser, tick_margin: 10,
|
|
93
|
+
tick_formatter: ->(v) { BROWSER_CONFIG[v.to_sym][:label] })
|
|
94
|
+
chart.with_bar(data_key: :visitors, radius: 5, color_key: :fill)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The full live-window surface in one frame - the client renderer
|
|
99
|
+
# (live:), the zoom drag, the brush strip, and a sync group: the
|
|
100
|
+
# wiring the stimulus contract holds to the DOM. No tick_formatter
|
|
101
|
+
# (lambdas cannot ride the live payload).
|
|
102
|
+
def live_window
|
|
103
|
+
render_component(data: DATA, config: ONE, id: "bar-live", live: true, zoom: true,
|
|
104
|
+
sync: "bar-demo") do |chart|
|
|
105
|
+
chart.with_grid
|
|
106
|
+
chart.with_x_axis(data_key: :month)
|
|
107
|
+
chart.with_bar(data_key: :desktop, radius: 8)
|
|
108
|
+
chart.with_tooltip(hide_label: true)
|
|
109
|
+
chart.with_brush
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poetry
|
|
4
|
+
module Charts
|
|
5
|
+
module BarChart
|
|
6
|
+
# The cartesian chrome on the shared cn-chart-* theme rules
|
|
7
|
+
# + the bar label look.
|
|
8
|
+
class Style < Poetry::Core::Style
|
|
9
|
+
base ""
|
|
10
|
+
|
|
11
|
+
element :frame, "contents"
|
|
12
|
+
element :svg, "w-full min-h-0 flex-1"
|
|
13
|
+
element :grid_line, "cn-chart-grid-line"
|
|
14
|
+
element :tick, "cn-chart-tick"
|
|
15
|
+
element :cursor_line, "cn-chart-cursor-line"
|
|
16
|
+
element :cursor_band, "cn-chart-cursor-band"
|
|
17
|
+
element :reference_line, "cn-chart-reference-line"
|
|
18
|
+
element :reference_area, "cn-chart-reference-area"
|
|
19
|
+
element :reference_dot, "cn-chart-reference-dot"
|
|
20
|
+
element :error_bar, "cn-chart-error-bar"
|
|
21
|
+
element :brush_track, "cn-chart-brush-track"
|
|
22
|
+
element :brush_window, "cn-chart-brush-window"
|
|
23
|
+
element :brush_handle, "cn-chart-brush-handle"
|
|
24
|
+
element :label, "cn-chart-label"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poetry
|
|
4
|
+
module Charts
|
|
5
|
+
# The bar geometry shared by BarChart and ComposedChart: the slot
|
|
6
|
+
# math dividing each category band (no explicit bar size - stacked
|
|
7
|
+
# bars share a slot, groups sit side by side inside the band), the
|
|
8
|
+
# per-corner rounded rect path, and the entrance origin. Hosts
|
|
9
|
+
# provide cartesian, data, horizontal?, fnum, bar_gap, and
|
|
10
|
+
# bar_category_gap.
|
|
11
|
+
# @api private
|
|
12
|
+
module BarMath
|
|
13
|
+
# One {offset, size} slot per stack group, laid out inside the
|
|
14
|
+
# category band.
|
|
15
|
+
def bar_slots_for(entries)
|
|
16
|
+
groups = entries.map(&:stack_or_self).uniq
|
|
17
|
+
band = cartesian.band_width
|
|
18
|
+
gap = bar_gap.to_f
|
|
19
|
+
trim = bar_percent_value(bar_category_gap, band)
|
|
20
|
+
gap = 0.0 if band - (2 * trim) - ((groups.length - 1) * gap) <= 0
|
|
21
|
+
|
|
22
|
+
size = (band - (2 * trim) - ((groups.length - 1) * gap)) / groups.length
|
|
23
|
+
size = Geometry.js_round(size).to_f if size > 1
|
|
24
|
+
|
|
25
|
+
groups.each_with_index.to_h do |group, i|
|
|
26
|
+
[group, { offset: trim + ((size + gap) * i), size: size }]
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# One rect per category for a series: band position + slot offset
|
|
31
|
+
# along the category side, the value span on the other, normalized
|
|
32
|
+
# so width/height stay positive (negatives keep the zero edge).
|
|
33
|
+
def bar_cells(entry, slot)
|
|
34
|
+
points = cartesian.points(entry)
|
|
35
|
+
|
|
36
|
+
points.each_with_index.filter_map do |point, i|
|
|
37
|
+
next if point[:value].nan?
|
|
38
|
+
|
|
39
|
+
base = { index: i, value: point[:value], row: data[i] }
|
|
40
|
+
if horizontal?
|
|
41
|
+
left = [point[:x0], point[:x1]].min
|
|
42
|
+
right = [point[:x0], point[:x1]].max
|
|
43
|
+
base.merge(x: left, y: cartesian.x_positions[i] + slot[:offset],
|
|
44
|
+
width: right - left, height: slot[:size])
|
|
45
|
+
else
|
|
46
|
+
top = [point[:y0], point[:y1]].min
|
|
47
|
+
bottom = [point[:y0], point[:y1]].max
|
|
48
|
+
base.merge(x: cartesian.x_positions[i] + slot[:offset], y: top,
|
|
49
|
+
width: slot[:size], height: bottom - top)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The zero edge the entrance animation grows from: sign x
|
|
55
|
+
# orientation - stacked segments grow from their own zero-side
|
|
56
|
+
# edge, not the axis baseline.
|
|
57
|
+
def motion_origin(cell)
|
|
58
|
+
if horizontal?
|
|
59
|
+
cell[:value].negative? ? "right" : "left"
|
|
60
|
+
else
|
|
61
|
+
cell[:value].negative? ? "top" : "bottom"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# radius: Integer (all corners) or [tl, tr, br, bl] (the stacked
|
|
66
|
+
# blocks). Clamped to half the rect so corners never cross.
|
|
67
|
+
def bar_path_for(radius, cell)
|
|
68
|
+
radii = radius.is_a?(Array) ? radius.map(&:to_f) : Array.new(4, radius.to_f)
|
|
69
|
+
max = [cell[:width] / 2.0, cell[:height] / 2.0].min
|
|
70
|
+
tl, tr, br, bl = radii.map { |r| r.clamp(0.0, max) }
|
|
71
|
+
x = cell[:x]
|
|
72
|
+
y = cell[:y]
|
|
73
|
+
w = cell[:width]
|
|
74
|
+
h = cell[:height]
|
|
75
|
+
f = ->(v) { fnum(v) }
|
|
76
|
+
|
|
77
|
+
"M#{f.call(x)},#{f.call(y + tl)}" \
|
|
78
|
+
"#{"A#{f.call(tl)},#{f.call(tl)},0,0,1,#{f.call(x + tl)},#{f.call(y)}" if tl.positive?}" \
|
|
79
|
+
"L#{f.call(x + w - tr)},#{f.call(y)}" \
|
|
80
|
+
"#{"A#{f.call(tr)},#{f.call(tr)},0,0,1,#{f.call(x + w)},#{f.call(y + tr)}" if tr.positive?}" \
|
|
81
|
+
"L#{f.call(x + w)},#{f.call(y + h - br)}" \
|
|
82
|
+
"#{"A#{f.call(br)},#{f.call(br)},0,0,1,#{f.call(x + w - br)},#{f.call(y + h)}" if br.positive?}" \
|
|
83
|
+
"L#{f.call(x + bl)},#{f.call(y + h)}" \
|
|
84
|
+
"#{"A#{f.call(bl)},#{f.call(bl)},0,0,1,#{f.call(x)},#{f.call(y + h - bl)}" if bl.positive?}Z"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Percent-or-pixels: "10%" resolves against the total, a bare
|
|
88
|
+
# number is pixels.
|
|
89
|
+
def bar_percent_value(value, total)
|
|
90
|
+
text = value.to_s
|
|
91
|
+
text.end_with?("%") ? total * (text.to_f / 100.0) : text.to_f
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poetry
|
|
4
|
+
module Charts
|
|
5
|
+
# The shared cartesian slot grammar (area/line/bar/composed): the
|
|
6
|
+
# axis/grid/legend/tooltip slots plus the readers that force their
|
|
7
|
+
# lazy evaluation. Lambda slots accumulate config into ivars and
|
|
8
|
+
# return nil (the breadcrumb pattern - slot wrappers do not delegate
|
|
9
|
+
# to lambda return values); readers force evaluation via the slot
|
|
10
|
+
# predicate (slots evaluate lazily).
|
|
11
|
+
#
|
|
12
|
+
# Slots register at the include site because declaration order IS the
|
|
13
|
+
# registry's slot order - include this AFTER the family's mark slots.
|
|
14
|
+
# The value axis is a class-method hook: a family whose Y side
|
|
15
|
+
# diverges (bar - the horizontal orientation moves the category axis
|
|
16
|
+
# there) defines its own value_axis_slot before including.
|
|
17
|
+
#
|
|
18
|
+
# @example Adding the cartesian slot grammar to a family
|
|
19
|
+
# class StepChart::Component < Poetry::Core::Component
|
|
20
|
+
# include Poetry::Charts::ChartFamily
|
|
21
|
+
#
|
|
22
|
+
# # Mark slots first - declaration order is the registry's slot order.
|
|
23
|
+
# renders_many :steps, lambda { |data_key:| ... }
|
|
24
|
+
#
|
|
25
|
+
# include Poetry::Charts::CartesianFamily
|
|
26
|
+
# end
|
|
27
|
+
module CartesianFamily
|
|
28
|
+
def self.included(base)
|
|
29
|
+
base.extend(ClassMethods)
|
|
30
|
+
|
|
31
|
+
# The category axis: data_key: names the field ticks label;
|
|
32
|
+
# tick_formatter: reshapes each label; tick_margin: pads it from
|
|
33
|
+
# the plot.
|
|
34
|
+
base.renders_one :x_axis, lambda { |data_key:, tick_formatter: nil, tick_margin: 8|
|
|
35
|
+
@x_axis_config = AxisConfig.new(data_key: data_key.to_s, tick_formatter:, tick_margin:, tick_count: nil)
|
|
36
|
+
nil
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
base.value_axis_slot
|
|
40
|
+
|
|
41
|
+
# The gridlines: horizontal rules by default; vertical: true adds
|
|
42
|
+
# column rules.
|
|
43
|
+
base.renders_one :grid, lambda { |vertical: false, horizontal: true|
|
|
44
|
+
@grid_config = GridConfig.new(vertical:, horizontal:)
|
|
45
|
+
nil
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# The legend row under the plot: align:, items:, hide_icon:, and
|
|
49
|
+
# toggle: (live charts only - clicking an entry hides its series).
|
|
50
|
+
base.renders_one :legend, lambda { |**options|
|
|
51
|
+
@legend_config = options
|
|
52
|
+
nil
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# The hover tooltip: indicator:, hide_label:, hide_indicator:, and
|
|
56
|
+
# cursor: (the hover rule/band, on by default). The slot captures
|
|
57
|
+
# options only; the tooltip layer itself (controller + chrome
|
|
58
|
+
# wiring) is declared by TooltipWiring.
|
|
59
|
+
base.renders_one :tooltip, lambda { |**options|
|
|
60
|
+
@tooltip_config = options
|
|
61
|
+
nil
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The class-level hooks the include site drives.
|
|
66
|
+
module ClassMethods
|
|
67
|
+
# Declares the default numeric Y axis slot (tick count 3, no data
|
|
68
|
+
# key). Families whose value axis diverges override this before
|
|
69
|
+
# including the concern.
|
|
70
|
+
def value_axis_slot
|
|
71
|
+
renders_one :y_axis,
|
|
72
|
+
doc: "The value axis: tick_count: sets how many ticks show; tick_formatter: reshapes each " \
|
|
73
|
+
"label; tick_margin: pads it.",
|
|
74
|
+
renders: lambda { |tick_count: 3, tick_formatter: nil, tick_margin: 8|
|
|
75
|
+
@y_axis_config = AxisConfig.new(data_key: nil, tick_formatter:, tick_margin:, tick_count:)
|
|
76
|
+
nil
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The x-axis slot's captured config, forcing lazy slot evaluation.
|
|
82
|
+
# @api private
|
|
83
|
+
def x_axis_config
|
|
84
|
+
x_axis?
|
|
85
|
+
@x_axis_config
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The y-axis slot's captured config, forcing lazy slot evaluation.
|
|
89
|
+
# @api private
|
|
90
|
+
def y_axis_config
|
|
91
|
+
y_axis?
|
|
92
|
+
@y_axis_config
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The grid slot's captured config, forcing lazy slot evaluation.
|
|
96
|
+
# @api private
|
|
97
|
+
def grid_config
|
|
98
|
+
grid?
|
|
99
|
+
@grid_config
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private :x_axis_config, :y_axis_config, :grid_config
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|