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,100 @@
|
|
|
1
|
+
{
|
|
2
|
+
"poetry--charts--tooltip": {
|
|
3
|
+
"targets": [
|
|
4
|
+
"data",
|
|
5
|
+
"svg",
|
|
6
|
+
"tooltip"
|
|
7
|
+
],
|
|
8
|
+
"values": {
|
|
9
|
+
"sync": {
|
|
10
|
+
"type": "String"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"methods": [
|
|
14
|
+
"blur",
|
|
15
|
+
"connect",
|
|
16
|
+
"count",
|
|
17
|
+
"disconnect",
|
|
18
|
+
"enter",
|
|
19
|
+
"focus",
|
|
20
|
+
"keydown",
|
|
21
|
+
"leave",
|
|
22
|
+
"move",
|
|
23
|
+
"refresh",
|
|
24
|
+
"show"
|
|
25
|
+
],
|
|
26
|
+
"events": [
|
|
27
|
+
"poetry--charts--tooltip:hide",
|
|
28
|
+
"poetry--charts--tooltip:show"
|
|
29
|
+
]
|
|
30
|
+
},
|
|
31
|
+
"poetry--charts--adapter": {
|
|
32
|
+
"targets": [
|
|
33
|
+
"mount",
|
|
34
|
+
"spec"
|
|
35
|
+
],
|
|
36
|
+
"values": {
|
|
37
|
+
"engine": {
|
|
38
|
+
"type": "String"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"methods": [
|
|
42
|
+
"connect",
|
|
43
|
+
"disconnect"
|
|
44
|
+
],
|
|
45
|
+
"events": [
|
|
46
|
+
"poetry--charts--adapter:rendered"
|
|
47
|
+
]
|
|
48
|
+
},
|
|
49
|
+
"poetry--charts--motion": {
|
|
50
|
+
"targets": [],
|
|
51
|
+
"values": {},
|
|
52
|
+
"methods": [
|
|
53
|
+
"connect",
|
|
54
|
+
"disconnect"
|
|
55
|
+
],
|
|
56
|
+
"events": [
|
|
57
|
+
"poetry--charts--motion:settled"
|
|
58
|
+
]
|
|
59
|
+
},
|
|
60
|
+
"poetry--charts--live": {
|
|
61
|
+
"targets": [
|
|
62
|
+
"payload"
|
|
63
|
+
],
|
|
64
|
+
"values": {},
|
|
65
|
+
"methods": [
|
|
66
|
+
"connect",
|
|
67
|
+
"disconnect",
|
|
68
|
+
"receive",
|
|
69
|
+
"setWindow",
|
|
70
|
+
"toggleSeries",
|
|
71
|
+
"update"
|
|
72
|
+
],
|
|
73
|
+
"events": [
|
|
74
|
+
"poetry--charts--live:settled",
|
|
75
|
+
"poetry--charts--live:updated"
|
|
76
|
+
]
|
|
77
|
+
},
|
|
78
|
+
"poetry--charts--window": {
|
|
79
|
+
"targets": [],
|
|
80
|
+
"values": {
|
|
81
|
+
"zoom": {
|
|
82
|
+
"type": "Boolean"
|
|
83
|
+
},
|
|
84
|
+
"plot": {
|
|
85
|
+
"type": "Array"
|
|
86
|
+
},
|
|
87
|
+
"brush": {
|
|
88
|
+
"type": "Array"
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
"methods": [
|
|
92
|
+
"connect",
|
|
93
|
+
"disconnect",
|
|
94
|
+
"reset",
|
|
95
|
+
"startBrush",
|
|
96
|
+
"startZoom"
|
|
97
|
+
],
|
|
98
|
+
"events": []
|
|
99
|
+
}
|
|
100
|
+
}
|
data/config/importmap.rb
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# poetry-charts' importmap pins (the importmap-first channel): the
|
|
4
|
+
# host app's importmap merges these, so the tooltip chrome runs with zero
|
|
5
|
+
# build. Bundler hosts use the @poetry/charts npm package instead - one
|
|
6
|
+
# source, two channels.
|
|
7
|
+
|
|
8
|
+
pin "@poetry/charts", to: "poetry/charts/index.js"
|
|
9
|
+
pin_all_from File.expand_path("../app/javascript/poetry/charts", __dir__),
|
|
10
|
+
under: "@poetry/charts", to: "poetry/charts"
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poetry
|
|
4
|
+
module Charts
|
|
5
|
+
# The server-side cartesian layout pipeline: data + series ->
|
|
6
|
+
# plot rectangle, scales, ticks, and per-series pixel points -
|
|
7
|
+
# computed top-down in one pass, everything a renderer needs before
|
|
8
|
+
# writing a path.
|
|
9
|
+
#
|
|
10
|
+
# Layout conventions: default margin 5 on every side, x-axis strip
|
|
11
|
+
# height 30 at the bottom when shown, category x positions from a
|
|
12
|
+
# zero-padding point scale (first/last categories AT the plot
|
|
13
|
+
# edges - add left/right margin 12 when edge labels need room),
|
|
14
|
+
# numeric y domain [0, auto] niced decimal-exactly (implicit tick
|
|
15
|
+
# count 5), stacking per stack id with :none/:expand offsets.
|
|
16
|
+
#
|
|
17
|
+
# @example Lay out a plot and read one series' pixel points
|
|
18
|
+
# plot = Poetry::Charts::Cartesian.new(
|
|
19
|
+
# data: rows, series: series, width: 600, height: 300
|
|
20
|
+
# )
|
|
21
|
+
# plot.points(series.first) # => [{x:, y0:, y1:, value:}, ...]
|
|
22
|
+
class Cartesian
|
|
23
|
+
# The default plot margin - a slim, even inset on all sides.
|
|
24
|
+
DEFAULT_MARGIN = { top: 5, right: 5, bottom: 5, left: 5 }.freeze
|
|
25
|
+
# The bottom strip reserved for the category axis when shown.
|
|
26
|
+
X_AXIS_HEIGHT = 30
|
|
27
|
+
# The reserved left strip - where a visible value axis's labels
|
|
28
|
+
# (or the horizontal layout's category labels) live.
|
|
29
|
+
Y_AXIS_WIDTH = 60
|
|
30
|
+
|
|
31
|
+
# The stack baseline modes: raw values, or 100%-normalized.
|
|
32
|
+
OFFSETS = %i[none expand].freeze
|
|
33
|
+
# The chart orientations: :vertical columns, or :horizontal bars.
|
|
34
|
+
LAYOUTS = %i[vertical horizontal].freeze
|
|
35
|
+
|
|
36
|
+
attr_reader :width, :height, :margin, :y_tick_count, :offset, :layout
|
|
37
|
+
|
|
38
|
+
# @param data [Array<Hash>] the rows, one hash per category
|
|
39
|
+
# @param series [Array<#key>] series entries (key + optional stack id)
|
|
40
|
+
# @param width [Numeric] outer SVG width in pixels
|
|
41
|
+
# @param height [Numeric] outer SVG height in pixels
|
|
42
|
+
# @param x_key [String, Symbol, nil] the category key; nil indexes rows
|
|
43
|
+
# @param margin [Hash] per-side overrides merged over DEFAULT_MARGIN
|
|
44
|
+
# @param category_axis [Boolean] reserve the category-axis strip
|
|
45
|
+
# @param value_axis [Boolean] a visible value axis reserves the left strip
|
|
46
|
+
# @param y_tick_count [Integer] requested tick count for the niced domain
|
|
47
|
+
# @param offset [Symbol] :none, or :expand for 100%-stacked
|
|
48
|
+
# @param x_scale_type [Symbol] :point (line/area) or :band (bars)
|
|
49
|
+
# @param layout [Symbol] :vertical (values up y) or :horizontal
|
|
50
|
+
def initialize(data:, series:, width:, height:, x_key: nil, margin: {},
|
|
51
|
+
category_axis: true, value_axis: false, y_tick_count: 5, offset: :none,
|
|
52
|
+
x_scale_type: :point, layout: :vertical)
|
|
53
|
+
@data = data.map { |row| row.to_h.transform_keys(&:to_s) }
|
|
54
|
+
@series = series
|
|
55
|
+
@width = width.to_f
|
|
56
|
+
@height = height.to_f
|
|
57
|
+
@x_key = x_key&.to_s
|
|
58
|
+
@margin = DEFAULT_MARGIN.merge(margin.to_h.symbolize_keys)
|
|
59
|
+
@category_axis = category_axis
|
|
60
|
+
@value_axis = value_axis
|
|
61
|
+
@y_tick_count = y_tick_count
|
|
62
|
+
@x_scale_type = x_scale_type.to_sym
|
|
63
|
+
@layout = layout.to_sym
|
|
64
|
+
raise ArgumentError, "unknown layout #{layout.inspect}" unless LAYOUTS.include?(@layout)
|
|
65
|
+
raise ArgumentError, "unknown offset #{offset.inspect}" unless OFFSETS.include?(offset.to_sym)
|
|
66
|
+
|
|
67
|
+
@offset = offset.to_sym
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Whether categories run down the Y side (bars growing rightward).
|
|
71
|
+
def horizontal?
|
|
72
|
+
layout == :horizontal
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# -- the plot rectangle ---------------------------------------------------
|
|
76
|
+
# The category-axis strip sits at the bottom (vertical layout, height
|
|
77
|
+
# 30) or the left (horizontal layout, width 60). A VISIBLE value
|
|
78
|
+
# axis in the vertical layout reserves the same left strip - without
|
|
79
|
+
# it, shown y labels would clip at the plot edge.
|
|
80
|
+
|
|
81
|
+
# The plot rect's left edge, inset for a reserved left strip.
|
|
82
|
+
def plot_left
|
|
83
|
+
reserved = (horizontal? && @category_axis) || (!horizontal? && @value_axis)
|
|
84
|
+
margin[:left].to_f + (reserved ? Y_AXIS_WIDTH : 0)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The plot rect's right edge.
|
|
88
|
+
def plot_right = width - margin[:right]
|
|
89
|
+
# The plot rect's top edge.
|
|
90
|
+
def plot_top = margin[:top].to_f
|
|
91
|
+
|
|
92
|
+
# The plot rect's bottom edge, inset for the category axis strip.
|
|
93
|
+
def plot_bottom
|
|
94
|
+
height - margin[:bottom] - (!horizontal? && @category_axis ? X_AXIS_HEIGHT : 0)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# -- x: categories on a point scale ---------------------------------------
|
|
98
|
+
|
|
99
|
+
# The category values: the x_key column, else bare row indexes.
|
|
100
|
+
def categories
|
|
101
|
+
@categories ||= @x_key ? @data.map { |row| row[@x_key] } : (0...@data.length).to_a
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Point for line/area (categories AT the edges); band for bars
|
|
105
|
+
# (a zero-padding band - the bar gaps come from the gap math inside
|
|
106
|
+
# the band, not scale padding). Horizontal layout runs the category
|
|
107
|
+
# scale down the Y side.
|
|
108
|
+
def category_range
|
|
109
|
+
horizontal? ? [plot_top, plot_bottom] : [plot_left, plot_right]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The category scale over the category range (point or band).
|
|
113
|
+
def x_scale
|
|
114
|
+
@x_scale ||= if @x_scale_type == :band
|
|
115
|
+
Geometry::Scale::Band.new(domain: categories, range: category_range)
|
|
116
|
+
else
|
|
117
|
+
Geometry::Scale::Point.new(domain: categories, range: category_range)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Each category's scale position (a band's leading edge).
|
|
122
|
+
def x_positions
|
|
123
|
+
x_scale.positions
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# One category band's width (0 on a point scale).
|
|
127
|
+
def band_width
|
|
128
|
+
x_scale.bandwidth
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# The per-category CENTER - where ticks, vertical grid lines, and the
|
|
132
|
+
# tooltip's hit columns sit (band centers; point positions verbatim).
|
|
133
|
+
def x_centers
|
|
134
|
+
@x_centers ||= if @x_scale_type == :band
|
|
135
|
+
half = band_width / 2.0
|
|
136
|
+
x_positions.map { |x| x + half }
|
|
137
|
+
else
|
|
138
|
+
x_positions
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# -- y: numeric domain, auto-niced -----------------------------------------
|
|
143
|
+
|
|
144
|
+
# The niced value ticks ([0, 1] when 100%-stacked).
|
|
145
|
+
def y_ticks
|
|
146
|
+
@y_ticks ||= if offset == :expand
|
|
147
|
+
Geometry::NiceTicks.nice_ticks([0, 1], y_tick_count)
|
|
148
|
+
else
|
|
149
|
+
Geometry::NiceTicks.nice_ticks(raw_domain, y_tick_count)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# The niced value domain - the first and last tick.
|
|
154
|
+
def y_domain
|
|
155
|
+
[y_ticks.first, y_ticks.last]
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# The value scale: y in the vertical layout (inverted - SVG y grows
|
|
159
|
+
# down), x in the horizontal one.
|
|
160
|
+
def y_scale
|
|
161
|
+
@y_scale ||= Geometry::Scale::Linear.new(
|
|
162
|
+
domain: y_domain,
|
|
163
|
+
range: horizontal? ? [plot_left, plot_right] : [plot_bottom, plot_top]
|
|
164
|
+
)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# The area baseline: the y pixel of 0, clamped into the domain so
|
|
168
|
+
# an all-positive domain pins the baseline at its bottom edge.
|
|
169
|
+
def baseline
|
|
170
|
+
y_scale.call(0.0.clamp(y_domain.first, y_domain.last))
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# -- per-series points -----------------------------------------------------
|
|
174
|
+
|
|
175
|
+
# [{x:, y0:, y1:, value:}] for one series entry - stacked entries ride
|
|
176
|
+
# their stack group's offsets; independent entries base on the
|
|
177
|
+
# baseline. NaN values (missing data) flow through as NaN, which the
|
|
178
|
+
# generators' defined-gap machinery turns into path gaps.
|
|
179
|
+
#
|
|
180
|
+
# @param entry [#key] a series entry (key + optional stack id)
|
|
181
|
+
# @return [Array<Hash>] one orientation-aware point hash per category
|
|
182
|
+
def points(entry)
|
|
183
|
+
key = entry.key
|
|
184
|
+
if entry.stack
|
|
185
|
+
bands = stacked_bands(entry.stack)[key]
|
|
186
|
+
bands.each_with_index.map do |(lo, hi), i|
|
|
187
|
+
point_for(i, key, y_scale.call(lo), y_scale.call(hi))
|
|
188
|
+
end
|
|
189
|
+
else
|
|
190
|
+
@data.each_with_index.map do |_row, i|
|
|
191
|
+
point_for(i, key, baseline, y_scale.call(value_at(i, key)))
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# -- the tooltip handoff: the embedded coordinates -------------------------
|
|
197
|
+
|
|
198
|
+
# Compact per-series pixel coordinates the tooltip controller reads -
|
|
199
|
+
# no chart math in the browser.
|
|
200
|
+
def coordinates
|
|
201
|
+
top_key = horizontal? ? :x1 : :y1
|
|
202
|
+
band = ({ "x" => x_positions.map { |x| x.round(2) }, "width" => band_width.round(2) } if @x_scale_type == :band)
|
|
203
|
+
{
|
|
204
|
+
"layout" => layout.to_s,
|
|
205
|
+
"categories" => categories,
|
|
206
|
+
# The category-center axis the tooltip bisects along: x in the
|
|
207
|
+
# vertical layout, y in the horizontal one.
|
|
208
|
+
(horizontal? ? "y" : "x") => x_centers.map { |c| c.round(2) },
|
|
209
|
+
"series" => @series.to_h do |entry|
|
|
210
|
+
tops = points(entry).map { |p| p[top_key].nan? ? nil : p[top_key].round(2) }
|
|
211
|
+
[entry.key, tops]
|
|
212
|
+
end,
|
|
213
|
+
# PRE-FORMATTED display strings (from the RAW row values, so
|
|
214
|
+
# integers stay integers) - the controller never formats.
|
|
215
|
+
"values" => @series.to_h do |entry|
|
|
216
|
+
[entry.key, @data.map { |row| display_value(row[entry.key]) }]
|
|
217
|
+
end
|
|
218
|
+
}.merge(band ? { "band" => band } : {})
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
private
|
|
222
|
+
|
|
223
|
+
# Orientation-aware point: vertical = {x, y0, y1}; horizontal =
|
|
224
|
+
# {y, x0, x1} (values run along x, categories down y).
|
|
225
|
+
def point_for(index, key, base_px, top_px)
|
|
226
|
+
value = value_at(index, key)
|
|
227
|
+
if horizontal?
|
|
228
|
+
{ y: x_centers[index], x0: base_px, x1: top_px, value: value }
|
|
229
|
+
else
|
|
230
|
+
{ x: x_centers[index], y0: base_px, y1: top_px, value: value }
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def value_at(index, key)
|
|
235
|
+
value = @data[index][key]
|
|
236
|
+
value.nil? ? Float::NAN : value.to_f
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def display_value(value)
|
|
240
|
+
Poetry::Charts.display_value(value)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Stack bands per stack id, memoized: { key => [[lo, hi], ...] }.
|
|
244
|
+
def stacked_bands(stack_id)
|
|
245
|
+
@stacked_bands ||= {}
|
|
246
|
+
@stacked_bands[stack_id] ||= begin
|
|
247
|
+
keys = @series.select { |entry| entry.stack == stack_id }.map(&:key)
|
|
248
|
+
stack = Geometry::Stack.new(keys: keys, offset: offset == :expand ? :expand : :none)
|
|
249
|
+
stack.series(@data).to_h { |series| [series.key, series.points] }
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# The raw numeric domain before nicing: [min(0, data), max(data)] -
|
|
254
|
+
# the default [0, auto] domain, stacked series contributing their
|
|
255
|
+
# cumulative tops.
|
|
256
|
+
def raw_domain
|
|
257
|
+
values = []
|
|
258
|
+
@series.each do |entry|
|
|
259
|
+
if entry.stack
|
|
260
|
+
stacked_bands(entry.stack)[entry.key].each do |lo, hi|
|
|
261
|
+
values << lo unless lo.nan?
|
|
262
|
+
values << hi unless hi.nan?
|
|
263
|
+
end
|
|
264
|
+
else
|
|
265
|
+
@data.each_index do |i|
|
|
266
|
+
value = value_at(i, entry.key)
|
|
267
|
+
values << value unless value.nan?
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
return [0, 1] if values.empty?
|
|
272
|
+
|
|
273
|
+
[[0, values.min].min, values.max]
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poetry
|
|
4
|
+
module Charts
|
|
5
|
+
# The chart config contract:
|
|
6
|
+
# series key -> { label:, icon:, color: } or { label:, icon:, theme:
|
|
7
|
+
# { light:, dark: } }. The config is the SINGLE place series get their
|
|
8
|
+
# human labels and colors; the container's <style> emission, the tooltip
|
|
9
|
+
# chrome, and the legend chrome all resolve through it.
|
|
10
|
+
#
|
|
11
|
+
# Values land inside a <style> element and inline style attributes, so
|
|
12
|
+
# every color (and key) is validated against a conservative character
|
|
13
|
+
# set at wrap time - a config can never smuggle CSS out of its block.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# config = Poetry::Charts::Config.new(
|
|
17
|
+
# revenue: { label: "Revenue", color: "var(--chart-1)" }
|
|
18
|
+
# )
|
|
19
|
+
# config.label_for("revenue") # => "Revenue"
|
|
20
|
+
class Config
|
|
21
|
+
# A series key becomes a --color-<key> custom property.
|
|
22
|
+
KEY = /\A[a-zA-Z][a-zA-Z0-9_-]*\z/
|
|
23
|
+
# Any CSS color form (var(--chart-1), #hex, oklch(...), named...) minus
|
|
24
|
+
# everything that could terminate a declaration or escape the block.
|
|
25
|
+
COLOR = /\A[^{};<>&"']+\z/
|
|
26
|
+
|
|
27
|
+
# The theme map's allowed modes.
|
|
28
|
+
THEMES = %i[light dark].freeze
|
|
29
|
+
|
|
30
|
+
# One validated config entry: a series key with its label, icon,
|
|
31
|
+
# and flat or themed color.
|
|
32
|
+
Entry = Data.define(:key, :label, :icon, :color, :theme) do
|
|
33
|
+
# The per-theme color: the flat color, or the theme map's value.
|
|
34
|
+
def color_for(theme_name)
|
|
35
|
+
theme ? theme[theme_name] : color
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Whether the entry carries any color (flat or themed).
|
|
39
|
+
def colored?
|
|
40
|
+
!!(color || theme)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
class << self
|
|
45
|
+
# Accepts a Config (pass-through) or a Hash keyed by series name.
|
|
46
|
+
#
|
|
47
|
+
# @param source [Config, Hash, nil]
|
|
48
|
+
# @return [Config]
|
|
49
|
+
def wrap(source)
|
|
50
|
+
source.is_a?(Config) ? source : new(source || {})
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
attr_reader :entries
|
|
55
|
+
|
|
56
|
+
# Builds and validates entries from a Hash keyed by series name;
|
|
57
|
+
# a CSS-unsafe key or color raises here, at wrap time.
|
|
58
|
+
#
|
|
59
|
+
# @param hash [Hash]
|
|
60
|
+
def initialize(hash)
|
|
61
|
+
raise ArgumentError, "chart config must be a Hash, got #{hash.class}" unless hash.is_a?(Hash)
|
|
62
|
+
|
|
63
|
+
@entries = hash.map { |key, value| build_entry(key, value || {}) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The entry for a series key.
|
|
67
|
+
#
|
|
68
|
+
# @param key [String, Symbol]
|
|
69
|
+
# @return [Entry, nil]
|
|
70
|
+
def [](key)
|
|
71
|
+
key = key.to_s
|
|
72
|
+
entries.find { |entry| entry.key == key }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Every series key, in config order.
|
|
76
|
+
#
|
|
77
|
+
# @return [Array<String>]
|
|
78
|
+
def keys
|
|
79
|
+
entries.map(&:key)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The entries that carry a color (flat or themed) - the set the
|
|
83
|
+
# container's <style> emission covers, in config order.
|
|
84
|
+
def color_entries
|
|
85
|
+
entries.select(&:colored?)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The label for a series key, falling back to the key itself - the
|
|
89
|
+
# tooltip/legend resolution rule.
|
|
90
|
+
#
|
|
91
|
+
# @param key [String, Symbol] the series key
|
|
92
|
+
# @param fallback [String, nil] preferred over the key when no label is set
|
|
93
|
+
# @return [String]
|
|
94
|
+
def label_for(key, fallback = nil)
|
|
95
|
+
self[key]&.label || fallback || key.to_s
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The config back as a plain Hash (label/color/theme per key),
|
|
99
|
+
# compacted.
|
|
100
|
+
#
|
|
101
|
+
# @return [Hash]
|
|
102
|
+
def to_h
|
|
103
|
+
entries.to_h do |entry|
|
|
104
|
+
value = { label: entry.label }.compact
|
|
105
|
+
value[:color] = entry.color if entry.color
|
|
106
|
+
value[:theme] = entry.theme if entry.theme
|
|
107
|
+
[entry.key, value]
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def build_entry(key, value)
|
|
114
|
+
key = key.to_s
|
|
115
|
+
raise ArgumentError, "chart config key #{key.inspect} is not CSS-safe" unless key.match?(KEY)
|
|
116
|
+
|
|
117
|
+
value = value.symbolize_keys
|
|
118
|
+
color = validate_color!(key, value[:color])
|
|
119
|
+
theme = validate_theme!(key, value[:theme])
|
|
120
|
+
raise ArgumentError, "chart config #{key.inspect} sets both color: and theme:" if color && theme
|
|
121
|
+
|
|
122
|
+
Entry.new(key:, label: value[:label], icon: value[:icon], color:, theme:)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def validate_color!(key, color)
|
|
126
|
+
return nil if color.nil?
|
|
127
|
+
|
|
128
|
+
color = color.to_s
|
|
129
|
+
unless color.match?(COLOR)
|
|
130
|
+
raise ArgumentError,
|
|
131
|
+
"chart config #{key.inspect} color #{color.inspect} is not CSS-safe"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
color
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def validate_theme!(key, theme)
|
|
138
|
+
return nil if theme.nil?
|
|
139
|
+
raise ArgumentError, "chart config #{key.inspect} theme must be a Hash" unless theme.is_a?(Hash)
|
|
140
|
+
|
|
141
|
+
theme = theme.symbolize_keys
|
|
142
|
+
unknown = theme.keys - THEMES
|
|
143
|
+
raise ArgumentError, "chart config #{key.inspect} theme has unknown modes #{unknown.inspect}" if unknown.any?
|
|
144
|
+
|
|
145
|
+
theme.to_h { |mode, color| [mode, validate_color!(key, color)] }
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/engine"
|
|
4
|
+
|
|
5
|
+
module Poetry
|
|
6
|
+
module Charts
|
|
7
|
+
# The Rails engine: wires poetry-charts into the host app - component
|
|
8
|
+
# autoload paths, the Stimulus controllers manifest, the view helper,
|
|
9
|
+
# preview and asset paths, and the importmap pins. Loading the gem is
|
|
10
|
+
# the only integration step.
|
|
11
|
+
#
|
|
12
|
+
# @example Install via the host Gemfile
|
|
13
|
+
# gem "poetry-charts"
|
|
14
|
+
class Engine < ::Rails::Engine
|
|
15
|
+
# Standard engine layout: app/components is picked up like poetry-ui's.
|
|
16
|
+
config.autoload_paths << "#{Poetry::Charts.root}/app/components"
|
|
17
|
+
config.eager_load_paths << "#{Poetry::Charts.root}/app/components"
|
|
18
|
+
|
|
19
|
+
# The charts controllers manifest joins core's catalog, so the
|
|
20
|
+
# Builder and use_stimulus declarations validate poetry--charts--*
|
|
21
|
+
# names exactly like core's own (runs before components load -
|
|
22
|
+
# declarations validate at class-load time).
|
|
23
|
+
initializer "poetry_charts.controllers_manifest", before: :eager_load! do
|
|
24
|
+
Poetry::Core::Stimulus::Manifest.register(
|
|
25
|
+
"#{Poetry::Charts.root}/config/controllers_manifest.json"
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# ComponentsHelper lives in app/helpers (Zeitwerk-autoloaded), so it is
|
|
30
|
+
# NOT loaded at gem-require time. A host that forces ActionView to load
|
|
31
|
+
# during initialization (an editor gem patching a view helper at
|
|
32
|
+
# require time) fires the on_load(:action_view) hook right here, before
|
|
33
|
+
# the engine's app/helpers constant is resolvable - NameError at boot.
|
|
34
|
+
# Registering the hook from a to_prepare block runs it only once the
|
|
35
|
+
# autoload paths are wired (and re-runs safely on reload); the same fix
|
|
36
|
+
# as poetry-ui's. poetry-core's TagHelper avoids it only because it
|
|
37
|
+
# lives in lib/ and is required eagerly. EngineBootTest guards this.
|
|
38
|
+
initializer "poetry_charts.helpers" do |app|
|
|
39
|
+
app.config.to_prepare do
|
|
40
|
+
ActiveSupport.on_load(:action_view) do
|
|
41
|
+
include Poetry::Charts::ComponentsHelper
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
initializer "poetry_charts.previews" do |app|
|
|
47
|
+
if app.config.respond_to?(:view_component)
|
|
48
|
+
app.config.view_component.previews.paths << "#{Poetry::Charts.root}/app/components"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Lookbook is a dev-only dependency; guard so the engine never crashes
|
|
53
|
+
# a production (or lean test) host that does not load it (the
|
|
54
|
+
# poetry-core pattern).
|
|
55
|
+
initializer "poetry_charts.setup_lookbook" do |app|
|
|
56
|
+
app.config.lookbook.preview_paths << "#{Poetry::Charts.root}/app/components" if defined?(Lookbook)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Make the client chrome + the motion stylesheet servable by Propshaft.
|
|
60
|
+
initializer "poetry_charts.assets" do |app|
|
|
61
|
+
if app.config.respond_to?(:assets)
|
|
62
|
+
app.config.assets.paths << Poetry::Charts.root.join("app/javascript").to_s
|
|
63
|
+
app.config.assets.paths << Poetry::Charts.root.join("app/assets/stylesheets").to_s
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The importmap-first JS channel (the poetry-core shape).
|
|
68
|
+
initializer "poetry_charts.importmap", before: "importmap" do |app|
|
|
69
|
+
if app.config.respond_to?(:importmap)
|
|
70
|
+
app.config.importmap.paths << Poetry::Charts.root.join("config/importmap.rb")
|
|
71
|
+
app.config.importmap.cache_sweepers << Poetry::Charts.root.join("app/javascript")
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poetry
|
|
4
|
+
module Charts
|
|
5
|
+
module Geometry
|
|
6
|
+
# The area generator: the filled band between a top line (x/x1, y1)
|
|
7
|
+
# and a baseline (x0, y0), walked forward along the top and
|
|
8
|
+
# BACKWARD along the buffered baseline per defined-segment (the
|
|
9
|
+
# x0z/y0z buffers). Stacked areas feed y0/y1 from Stack series;
|
|
10
|
+
# simple areas use a constant y0 (the axis line).
|
|
11
|
+
#
|
|
12
|
+
# @example A simple area over a constant baseline
|
|
13
|
+
# Poetry::Charts::Geometry::Area.new(y0: 250.0).path(points)
|
|
14
|
+
class Area
|
|
15
|
+
def initialize(x: nil, x1: nil, y0: nil, y1: nil, curve: :linear, defined: nil, digits: 3)
|
|
16
|
+
@x0 = Line::Accessor.wrap(x) { |d, _i| d[0] }
|
|
17
|
+
@x1 = x1.nil? ? nil : Line::Accessor.wrap(x1) { |d, _i| d[0] }
|
|
18
|
+
@y0 = Line::Accessor.wrap(y0) { |_d, _i| 0.0 }
|
|
19
|
+
@y1 = y1.nil? ? nil : Line::Accessor.wrap(y1) { |d, _i| d[1] }
|
|
20
|
+
@curve = curve
|
|
21
|
+
@digits = digits
|
|
22
|
+
@defined = Line::Accessor.wrap(defined) { |_d, _i| true }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The SVG path for the data (nil when nothing was defined).
|
|
26
|
+
#
|
|
27
|
+
# @param data [Enumerable]
|
|
28
|
+
# @return [String, nil]
|
|
29
|
+
def path(data)
|
|
30
|
+
data = data.to_a
|
|
31
|
+
n = data.length
|
|
32
|
+
buffer = Path.new(digits: @digits)
|
|
33
|
+
output = Curve.build(@curve, buffer)
|
|
34
|
+
defined0 = false
|
|
35
|
+
x0z = Array.new(n)
|
|
36
|
+
y0z = Array.new(n)
|
|
37
|
+
j = 0
|
|
38
|
+
|
|
39
|
+
(0..n).each do |i|
|
|
40
|
+
d = i < n ? data[i] : nil
|
|
41
|
+
if (!(i < n && @defined.call(d, i))) == defined0
|
|
42
|
+
defined0 = !defined0
|
|
43
|
+
if defined0
|
|
44
|
+
j = i
|
|
45
|
+
output.area_start
|
|
46
|
+
output.line_start
|
|
47
|
+
else
|
|
48
|
+
output.line_end
|
|
49
|
+
output.line_start
|
|
50
|
+
(i - 1).downto(j) { |k| output.point(x0z[k], y0z[k]) }
|
|
51
|
+
output.line_end
|
|
52
|
+
output.area_end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
next unless defined0
|
|
56
|
+
|
|
57
|
+
x0z[i] = @x0.call(d, i).to_f
|
|
58
|
+
y0z[i] = @y0.call(d, i).to_f
|
|
59
|
+
output.point(@x1 ? @x1.call(d, i).to_f : x0z[i],
|
|
60
|
+
@y1 ? @y1.call(d, i).to_f : y0z[i])
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
buffer.empty? ? nil : buffer.to_s
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|