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,327 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poetry
|
|
4
|
+
module Charts
|
|
5
|
+
module Geometry
|
|
6
|
+
# The curve state machines - including the `_line`
|
|
7
|
+
# undefined/0/1/NaN dance that decides where subpaths close (JS
|
|
8
|
+
# truthiness via Geometry.js_truthy?; `1 - undefined` becomes NaN
|
|
9
|
+
# via js_flip). The set matches the families' curve: whitelist:
|
|
10
|
+
# linear, step (+before/after), natural, and monotone_x.
|
|
11
|
+
#
|
|
12
|
+
# @example Build a curve writing into a fresh path buffer
|
|
13
|
+
# Poetry::Charts::Geometry::Curve.build(:natural, Path.new)
|
|
14
|
+
module Curve
|
|
15
|
+
# Curve name -> builder for its state machine.
|
|
16
|
+
REGISTRY = {
|
|
17
|
+
linear: ->(context) { Linear.new(context) },
|
|
18
|
+
step: ->(context) { Step.new(context, 0.5) },
|
|
19
|
+
step_before: ->(context) { Step.new(context, 0.0) },
|
|
20
|
+
step_after: ->(context) { Step.new(context, 1.0) },
|
|
21
|
+
natural: ->(context) { Natural.new(context) },
|
|
22
|
+
monotone_x: ->(context) { MonotoneX.new(context) }
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# A curve state machine writing into the given path context.
|
|
28
|
+
def build(name, context)
|
|
29
|
+
builder = REGISTRY[name.to_sym] or
|
|
30
|
+
raise ArgumentError, "unknown curve #{name.inspect} (one of #{REGISTRY.keys.join(", ")})"
|
|
31
|
+
builder.call(context)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# JS `1 - line` where line may be undefined (nil) or NaN.
|
|
35
|
+
def self.js_flip(line)
|
|
36
|
+
return Float::NAN if line.nil? || (line.is_a?(Float) && line.nan?)
|
|
37
|
+
|
|
38
|
+
1 - line
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Shared area/line bookkeeping (identical across all four curves).
|
|
42
|
+
# @api private
|
|
43
|
+
module LineState
|
|
44
|
+
def area_start
|
|
45
|
+
@line = 0
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def area_end
|
|
49
|
+
@line = Float::NAN
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def line_truthy?
|
|
55
|
+
Geometry.js_truthy?(@line)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# JS: this._line || (this._line !== 0 && <extra>)
|
|
59
|
+
def close_on_line_end?(extra)
|
|
60
|
+
line_truthy? || (@line != 0 && extra)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def flip_line
|
|
64
|
+
@line = Curve.js_flip(@line)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Straight segments between points.
|
|
69
|
+
# @api private
|
|
70
|
+
class Linear
|
|
71
|
+
include LineState
|
|
72
|
+
|
|
73
|
+
def initialize(context)
|
|
74
|
+
@context = context
|
|
75
|
+
@line = nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def line_start
|
|
79
|
+
@point = 0
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def line_end
|
|
83
|
+
@context.close_path if close_on_line_end?(@point == 1)
|
|
84
|
+
flip_line
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def point(x, y)
|
|
88
|
+
x = x.to_f
|
|
89
|
+
y = y.to_f
|
|
90
|
+
case @point
|
|
91
|
+
when 0
|
|
92
|
+
@point = 1
|
|
93
|
+
line_truthy? ? @context.line_to(x, y) : @context.move_to(x, y)
|
|
94
|
+
else
|
|
95
|
+
@point = 2
|
|
96
|
+
@context.line_to(x, y)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Right-angle steps between points; t places the riser (0 before,
|
|
102
|
+
# 0.5 midway, 1 after).
|
|
103
|
+
# @api private
|
|
104
|
+
class Step
|
|
105
|
+
include LineState
|
|
106
|
+
|
|
107
|
+
def initialize(context, t)
|
|
108
|
+
@context = context
|
|
109
|
+
@t = t
|
|
110
|
+
@line = nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def line_start
|
|
114
|
+
@x = @y = Float::NAN
|
|
115
|
+
@point = 0
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def line_end
|
|
119
|
+
@context.line_to(@x, @y) if @t.positive? && @t < 1 && @point == 2
|
|
120
|
+
@context.close_path if close_on_line_end?(@point == 1)
|
|
121
|
+
# JS: if (this._line >= 0) - false for undefined and NaN.
|
|
122
|
+
return unless !@line.nil? && !(@line.is_a?(Float) && @line.nan?) && @line >= 0
|
|
123
|
+
|
|
124
|
+
@t = 1 - @t
|
|
125
|
+
@line = 1 - @line
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def point(x, y)
|
|
129
|
+
x = x.to_f
|
|
130
|
+
y = y.to_f
|
|
131
|
+
case @point
|
|
132
|
+
when 0
|
|
133
|
+
@point = 1
|
|
134
|
+
line_truthy? ? @context.line_to(x, y) : @context.move_to(x, y)
|
|
135
|
+
else
|
|
136
|
+
@point = 2 if @point == 1
|
|
137
|
+
if @t <= 0
|
|
138
|
+
@context.line_to(@x, y)
|
|
139
|
+
@context.line_to(x, y)
|
|
140
|
+
else
|
|
141
|
+
x1 = (@x * (1 - @t)) + (x * @t)
|
|
142
|
+
@context.line_to(x1, @y)
|
|
143
|
+
@context.line_to(x1, y)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
@x = x
|
|
147
|
+
@y = y
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# A natural cubic spline through every point.
|
|
152
|
+
# @api private
|
|
153
|
+
class Natural
|
|
154
|
+
include LineState
|
|
155
|
+
|
|
156
|
+
def initialize(context)
|
|
157
|
+
@context = context
|
|
158
|
+
@line = nil
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def line_start
|
|
162
|
+
@xs = []
|
|
163
|
+
@ys = []
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def line_end
|
|
167
|
+
x = @xs
|
|
168
|
+
y = @ys
|
|
169
|
+
n = x.length
|
|
170
|
+
|
|
171
|
+
if n.positive?
|
|
172
|
+
line_truthy? ? @context.line_to(x[0], y[0]) : @context.move_to(x[0], y[0])
|
|
173
|
+
if n == 2
|
|
174
|
+
@context.line_to(x[1], y[1])
|
|
175
|
+
elsif n > 2
|
|
176
|
+
px = Natural.control_points(x)
|
|
177
|
+
py = Natural.control_points(y)
|
|
178
|
+
(0...(n - 1)).each do |i0|
|
|
179
|
+
i1 = i0 + 1
|
|
180
|
+
@context.bezier_curve_to(px[0][i0], py[0][i0], px[1][i0], py[1][i0], x[i1], y[i1])
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
@context.close_path if close_on_line_end?(n == 1)
|
|
186
|
+
flip_line
|
|
187
|
+
@xs = @ys = nil
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def point(x, y)
|
|
191
|
+
@xs << x.to_f
|
|
192
|
+
@ys << y.to_f
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# The tridiagonal control-point solve
|
|
196
|
+
# (https://www.particleincell.com/2012/bezier-splines/).
|
|
197
|
+
def self.control_points(x)
|
|
198
|
+
n = x.length - 1
|
|
199
|
+
a = Array.new(n)
|
|
200
|
+
b = Array.new(n)
|
|
201
|
+
r = Array.new(n)
|
|
202
|
+
|
|
203
|
+
a[0] = 0.0
|
|
204
|
+
b[0] = 2.0
|
|
205
|
+
r[0] = x[0] + (2 * x[1])
|
|
206
|
+
(1...(n - 1)).each do |i|
|
|
207
|
+
a[i] = 1.0
|
|
208
|
+
b[i] = 4.0
|
|
209
|
+
r[i] = (4 * x[i]) + (2 * x[i + 1])
|
|
210
|
+
end
|
|
211
|
+
a[n - 1] = 2.0
|
|
212
|
+
b[n - 1] = 7.0
|
|
213
|
+
r[n - 1] = (8 * x[n - 1]) + x[n]
|
|
214
|
+
|
|
215
|
+
(1...n).each do |i|
|
|
216
|
+
m = a[i] / b[i - 1]
|
|
217
|
+
b[i] -= m
|
|
218
|
+
r[i] -= m * r[i - 1]
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
a[n - 1] = r[n - 1] / b[n - 1]
|
|
222
|
+
(n - 2).downto(0) { |i| a[i] = (r[i] - a[i + 1]) / b[i] }
|
|
223
|
+
b[n - 1] = (x[n] + a[n - 1]) / 2
|
|
224
|
+
(0...(n - 1)).each { |i| b[i] = (2 * x[i + 1]) - a[i + 1] }
|
|
225
|
+
|
|
226
|
+
[a, b]
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# A cubic that preserves monotonicity in x - no overshoot between
|
|
231
|
+
# points.
|
|
232
|
+
# @api private
|
|
233
|
+
class MonotoneX
|
|
234
|
+
include LineState
|
|
235
|
+
|
|
236
|
+
def initialize(context)
|
|
237
|
+
@context = context
|
|
238
|
+
@line = nil
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def line_start
|
|
242
|
+
@x0 = @x1 = @y0 = @y1 = @t0 = Float::NAN
|
|
243
|
+
@point = 0
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def line_end
|
|
247
|
+
case @point
|
|
248
|
+
when 2 then @context.line_to(@x1, @y1)
|
|
249
|
+
when 3 then emit(@t0, slope2(@t0))
|
|
250
|
+
end
|
|
251
|
+
@context.close_path if close_on_line_end?(@point == 1)
|
|
252
|
+
flip_line
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def point(x, y)
|
|
256
|
+
x = x.to_f
|
|
257
|
+
y = y.to_f
|
|
258
|
+
return if x == @x1 && y == @y1 # ignore coincident points
|
|
259
|
+
|
|
260
|
+
case @point
|
|
261
|
+
when 0
|
|
262
|
+
@point = 1
|
|
263
|
+
line_truthy? ? @context.line_to(x, y) : @context.move_to(x, y)
|
|
264
|
+
t1 = Float::NAN
|
|
265
|
+
when 1
|
|
266
|
+
@point = 2
|
|
267
|
+
t1 = Float::NAN
|
|
268
|
+
when 2
|
|
269
|
+
@point = 3
|
|
270
|
+
t1 = slope3(x, y)
|
|
271
|
+
emit(slope2(t1), t1)
|
|
272
|
+
else
|
|
273
|
+
t1 = slope3(x, y)
|
|
274
|
+
emit(@t0, t1)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
@x0 = @x1
|
|
278
|
+
@x1 = x
|
|
279
|
+
@y0 = @y1
|
|
280
|
+
@y1 = y
|
|
281
|
+
@t0 = t1
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
private
|
|
285
|
+
|
|
286
|
+
def sign(value)
|
|
287
|
+
value.negative? ? -1.0 : 1.0
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Steffen (1990) monotone tangent slopes. The JS `(h0 || h1 < 0 &&
|
|
291
|
+
# -0)` denominator trick yields signed zero when h0 is 0, so the
|
|
292
|
+
# division produces the correctly signed infinity.
|
|
293
|
+
def slope3(x2, y2)
|
|
294
|
+
h0 = @x1 - @x0
|
|
295
|
+
h1 = x2 - @x1
|
|
296
|
+
d0 = if h0.zero? && !h0.nan?
|
|
297
|
+
h1.negative? ? -0.0 : 0.0
|
|
298
|
+
else
|
|
299
|
+
h0
|
|
300
|
+
end
|
|
301
|
+
d1 = if h1.zero? && !h1.nan?
|
|
302
|
+
h0.negative? ? -0.0 : 0.0
|
|
303
|
+
else
|
|
304
|
+
h1
|
|
305
|
+
end
|
|
306
|
+
s0 = (@y1 - @y0) / d0
|
|
307
|
+
s1 = (y2 - @y1) / d1
|
|
308
|
+
p = ((s0 * h1) + (s1 * h0)) / (h0 + h1)
|
|
309
|
+
result = (sign(s0) + sign(s1)) * [s0.abs, s1.abs, 0.5 * p.abs].min
|
|
310
|
+
result.nan? ? 0.0 : result
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def slope2(t)
|
|
314
|
+
h = @x1 - @x0
|
|
315
|
+
Geometry.js_truthy?(h) ? (((3 * (@y1 - @y0)) / h) - t) / 2 : t
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Hermite -> cubic Bezier (control points at thirds).
|
|
319
|
+
def emit(t0, t1)
|
|
320
|
+
dx = (@x1 - @x0) / 3
|
|
321
|
+
@context.bezier_curve_to(@x0 + dx, @y0 + (dx * t0), @x1 - dx, @y1 - (dx * t1), @x1, @y1)
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poetry
|
|
4
|
+
module Charts
|
|
5
|
+
module Geometry
|
|
6
|
+
# The line generator: data -> SVG path string through a curve state
|
|
7
|
+
# machine, with defined-gaps starting new subpaths (a single toggle
|
|
8
|
+
# loop). Accessors are lambdas (d, i), symbols/strings (hash key
|
|
9
|
+
# lookup), or numeric constants; x/y default to the [x, y] pair
|
|
10
|
+
# convention.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# Poetry::Charts::Geometry::Line.new(curve: :monotone_x).path(points)
|
|
14
|
+
class Line
|
|
15
|
+
def initialize(x: nil, y: nil, curve: :linear, defined: nil, digits: 3)
|
|
16
|
+
@x = Accessor.wrap(x) { |d, _i| d[0] }
|
|
17
|
+
@y = Accessor.wrap(y) { |d, _i| d[1] }
|
|
18
|
+
@defined = Accessor.wrap(defined) { |_d, _i| true }
|
|
19
|
+
@curve = curve
|
|
20
|
+
@digits = digits
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The SVG path for the data (nil when nothing was defined).
|
|
24
|
+
#
|
|
25
|
+
# @param data [Enumerable]
|
|
26
|
+
# @return [String, nil]
|
|
27
|
+
def path(data)
|
|
28
|
+
data = data.to_a
|
|
29
|
+
n = data.length
|
|
30
|
+
buffer = Path.new(digits: @digits)
|
|
31
|
+
output = Curve.build(@curve, buffer)
|
|
32
|
+
defined0 = false
|
|
33
|
+
|
|
34
|
+
(0..n).each do |i|
|
|
35
|
+
d = i < n ? data[i] : nil
|
|
36
|
+
if (!(i < n && @defined.call(d, i))) == defined0
|
|
37
|
+
defined0 = !defined0
|
|
38
|
+
defined0 ? output.line_start : output.line_end
|
|
39
|
+
end
|
|
40
|
+
output.point(@x.call(d, i).to_f, @y.call(d, i).to_f) if defined0
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
buffer.empty? ? nil : buffer.to_s
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Accessor coercion shared by the generators.
|
|
47
|
+
module Accessor
|
|
48
|
+
module_function
|
|
49
|
+
|
|
50
|
+
# A (d, i) lambda from a lambda, key, constant, or the default.
|
|
51
|
+
def wrap(value, &default)
|
|
52
|
+
case value
|
|
53
|
+
when nil then default
|
|
54
|
+
when Proc then value.arity == 1 ? ->(d, _i) { value.call(d) } : value
|
|
55
|
+
when Symbol, String then ->(d, _i) { d[value] }
|
|
56
|
+
when Numeric then ->(_d, _i) { value }
|
|
57
|
+
else raise ArgumentError, "bad accessor #{value.inspect}"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
module Poetry
|
|
6
|
+
module Charts
|
|
7
|
+
module Geometry
|
|
8
|
+
# Adapted from an MIT-licensed source (source and license in
|
|
9
|
+
# THIRD_PARTY_NOTICES.md).
|
|
10
|
+
#
|
|
11
|
+
# The nice-ticks algorithm on BigDecimal, so the tick values stay
|
|
12
|
+
# decimal-exact. Every operation is decimal, not binary: values
|
|
13
|
+
# construct from the double's shortest decimal string, remainders
|
|
14
|
+
# truncate with the dividend's sign (#remainder), division carries
|
|
15
|
+
# 20 significant digits (PRECISION), and digit counts come from
|
|
16
|
+
# BigDecimal#exponent (exactly floor(log10) + 1).
|
|
17
|
+
#
|
|
18
|
+
# Oracle: translated spec cases pin the expected tick values.
|
|
19
|
+
#
|
|
20
|
+
# @example Nice a raw domain into tick values
|
|
21
|
+
# Poetry::Charts::Geometry::NiceTicks.nice_ticks([0, 97], 5)
|
|
22
|
+
module NiceTicks
|
|
23
|
+
# Significant digits carried through every division.
|
|
24
|
+
PRECISION = 20
|
|
25
|
+
# The step multiples the :snap125 mode snaps to.
|
|
26
|
+
SNAP_STEPS = [1r, 2r, 2.5r, 5r].map { |step| BigDecimal(step, 10) }.freeze
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# Coerce to BigDecimal via the double's shortest decimal string.
|
|
31
|
+
def dec(value)
|
|
32
|
+
value.is_a?(BigDecimal) ? value : BigDecimal(value.to_f.to_s)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Digit count: 1 for [1,10), 0 for [0.1,1), -1 for [0.01,0.1)...
|
|
36
|
+
# BigDecimal#exponent IS floor(log10(|v|)) + 1, exactly.
|
|
37
|
+
def digit_count(value)
|
|
38
|
+
return 1 if value.zero?
|
|
39
|
+
|
|
40
|
+
dec(value).exponent
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# [start, end) with a fixed decimal step.
|
|
44
|
+
def range_step(start, stop, step)
|
|
45
|
+
num = start
|
|
46
|
+
result = []
|
|
47
|
+
i = 0
|
|
48
|
+
while num < stop && i < 100_000
|
|
49
|
+
result << num.to_f
|
|
50
|
+
num += step
|
|
51
|
+
i += 1
|
|
52
|
+
end
|
|
53
|
+
result
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The interval sorted ascending.
|
|
57
|
+
def valid_interval(min, max)
|
|
58
|
+
min > max ? [max, min] : [min, max]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The default step function: amend the rough step to a value that
|
|
62
|
+
# reads well at its order of magnitude.
|
|
63
|
+
def adaptive_step(rough_step, allow_decimals, correction_factor)
|
|
64
|
+
return BigDecimal(0) if rough_step <= 0
|
|
65
|
+
|
|
66
|
+
digits = digit_count(rough_step.to_f)
|
|
67
|
+
digit_count_value = BigDecimal(10)**digits
|
|
68
|
+
step_ratio = rough_step.div(digit_count_value, PRECISION)
|
|
69
|
+
step_ratio_scale = digits == 1 ? BigDecimal("0.1") : BigDecimal("0.05")
|
|
70
|
+
amend_step_ratio = (BigDecimal(step_ratio.div(step_ratio_scale, PRECISION).to_f.ceil) +
|
|
71
|
+
correction_factor) * step_ratio_scale
|
|
72
|
+
format_step = amend_step_ratio * digit_count_value
|
|
73
|
+
|
|
74
|
+
allow_decimals ? BigDecimal(format_step.to_f.to_s) : BigDecimal(format_step.to_f.ceil)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The opt-in snap125 step: snap to 1 / 2 / 2.5 / 5 at each order of
|
|
78
|
+
# magnitude.
|
|
79
|
+
def snap125_step(rough_step, allow_decimals, correction_factor)
|
|
80
|
+
return BigDecimal(0) if rough_step <= 0
|
|
81
|
+
|
|
82
|
+
# Math.floor(abs.log(10)) == exponent - 1, exactly.
|
|
83
|
+
exponent = dec(rough_step.to_f).abs.exponent - 1
|
|
84
|
+
magnitude = BigDecimal(10)**exponent
|
|
85
|
+
normalized = rough_step.div(magnitude, PRECISION).to_f
|
|
86
|
+
|
|
87
|
+
nice_index = SNAP_STEPS.index { |step| step.to_f >= normalized - 1e-10 }
|
|
88
|
+
if nice_index.nil?
|
|
89
|
+
magnitude *= 10
|
|
90
|
+
nice_index = 0
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
nice_index += correction_factor
|
|
94
|
+
if nice_index >= SNAP_STEPS.length
|
|
95
|
+
extra_magnitude = nice_index / SNAP_STEPS.length
|
|
96
|
+
nice_index %= SNAP_STEPS.length
|
|
97
|
+
magnitude *= BigDecimal(10)**extra_magnitude
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
format_step = SNAP_STEPS[nice_index] * magnitude
|
|
101
|
+
allow_decimals ? format_step : BigDecimal(format_step.to_f.ceil)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The step function a mode selects.
|
|
105
|
+
def step_function(mode)
|
|
106
|
+
mode == :snap125 ? method(:snap125_step) : method(:adaptive_step)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Ticks when min == max: center a window of tickCount steps on the value.
|
|
110
|
+
def ticks_of_single_value(value, tick_count, allow_decimals)
|
|
111
|
+
step = BigDecimal(1)
|
|
112
|
+
middle = dec(value)
|
|
113
|
+
|
|
114
|
+
if middle.frac.nonzero? && allow_decimals
|
|
115
|
+
abs_value = value.abs
|
|
116
|
+
if abs_value < 1
|
|
117
|
+
step = BigDecimal(10)**(digit_count(value) - 1)
|
|
118
|
+
middle = BigDecimal(middle.div(step, PRECISION).to_f.floor) * step
|
|
119
|
+
elsif abs_value > 1
|
|
120
|
+
middle = BigDecimal(value.floor)
|
|
121
|
+
end
|
|
122
|
+
elsif value.zero?
|
|
123
|
+
middle = BigDecimal((tick_count - 1) / 2)
|
|
124
|
+
elsif !allow_decimals
|
|
125
|
+
middle = BigDecimal(value.floor)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
middle_index = (tick_count - 1) / 2
|
|
129
|
+
Array.new(tick_count) { |i| (middle + (BigDecimal(i - middle_index) * step)).to_f }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# The step + tick bounds for an interval (recursive: a correction
|
|
133
|
+
# factor grows the step until tickCount ticks cover the interval).
|
|
134
|
+
def calculate_step(min, max, tick_count, allow_decimals, correction_factor = 0, step_fn: method(:adaptive_step))
|
|
135
|
+
unless ((max - min) / (tick_count - 1)).finite?
|
|
136
|
+
return { step: BigDecimal(0), tick_min: BigDecimal(0), tick_max: BigDecimal(0) }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
step = step_fn.call((dec(max) - dec(min)).div(BigDecimal(tick_count - 1), PRECISION),
|
|
140
|
+
allow_decimals, correction_factor)
|
|
141
|
+
|
|
142
|
+
middle = if 0.between?(min, max)
|
|
143
|
+
BigDecimal(0)
|
|
144
|
+
else
|
|
145
|
+
mid = (dec(min) + dec(max)).div(BigDecimal(2), PRECISION)
|
|
146
|
+
mid - mid.remainder(step)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
below_count = (middle - dec(min)).div(step, PRECISION).to_f.ceil
|
|
150
|
+
up_count = (dec(max) - middle).div(step, PRECISION).to_f.ceil
|
|
151
|
+
scale_count = below_count + up_count + 1
|
|
152
|
+
|
|
153
|
+
if scale_count > tick_count
|
|
154
|
+
return calculate_step(min, max, tick_count, allow_decimals, correction_factor + 1, step_fn:)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
if scale_count < tick_count
|
|
158
|
+
up_count += (tick_count - scale_count) if max.positive?
|
|
159
|
+
below_count += (tick_count - scale_count) unless max.positive?
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
{
|
|
163
|
+
step: step,
|
|
164
|
+
tick_min: middle - (BigDecimal(below_count) * step),
|
|
165
|
+
tick_max: middle + (BigDecimal(up_count) * step)
|
|
166
|
+
}
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Nice ticks for [min, max] - ticks may run OUTSIDE the interval
|
|
170
|
+
# to stay round.
|
|
171
|
+
def nice_ticks(domain, tick_count = 6, allow_decimals: true, mode: :auto)
|
|
172
|
+
min, max = domain
|
|
173
|
+
count = [tick_count, 2].max
|
|
174
|
+
cormin, cormax = valid_interval(min, max)
|
|
175
|
+
|
|
176
|
+
if cormin == -Float::INFINITY || cormax == Float::INFINITY
|
|
177
|
+
values = if cormax == Float::INFINITY
|
|
178
|
+
[cormin] + Array.new(tick_count - 1, Float::INFINITY)
|
|
179
|
+
else
|
|
180
|
+
Array.new(tick_count - 1, -Float::INFINITY) + [cormax]
|
|
181
|
+
end
|
|
182
|
+
return min > max ? values.reverse : values
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
return ticks_of_single_value(cormin, tick_count, allow_decimals) if cormin == cormax
|
|
186
|
+
|
|
187
|
+
bounds = calculate_step(cormin, cormax, count, allow_decimals, 0, step_fn: step_function(mode))
|
|
188
|
+
values = range_step(bounds[:tick_min],
|
|
189
|
+
bounds[:tick_max] + (BigDecimal("0.1") * bounds[:step]),
|
|
190
|
+
bounds[:step])
|
|
191
|
+
|
|
192
|
+
min > max ? values.reverse : values
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Nice-stepped ticks CONSTRAINED to [min, max] - the domain
|
|
196
|
+
# boundary always closes the list.
|
|
197
|
+
def fixed_domain_ticks(domain, tick_count, allow_decimals: true, mode: :auto)
|
|
198
|
+
min, max = domain
|
|
199
|
+
cormin, cormax = valid_interval(min, max)
|
|
200
|
+
|
|
201
|
+
return [min, max] if cormin == -Float::INFINITY || cormax == Float::INFINITY
|
|
202
|
+
return [cormin] if cormin == cormax
|
|
203
|
+
|
|
204
|
+
count = [tick_count, 2].max
|
|
205
|
+
step = step_function(mode).call((dec(cormax) - dec(cormin)).div(BigDecimal(count - 1), PRECISION),
|
|
206
|
+
allow_decimals, 0)
|
|
207
|
+
values = range_step(dec(cormin), dec(cormax), step) + [cormax]
|
|
208
|
+
|
|
209
|
+
unless allow_decimals
|
|
210
|
+
values = values.map { |value| Geometry.js_round(value) }
|
|
211
|
+
last = values.length - 1
|
|
212
|
+
values = values[0...last] if last.positive? && values[last] == values[last - 1]
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
min > max ? values.reverse : values
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poetry
|
|
4
|
+
module Charts
|
|
5
|
+
module Geometry
|
|
6
|
+
# The path buffer the shape generators write into: move_to /
|
|
7
|
+
# line_to / bezier_curve_to / quadratic_curve_to / close_path.
|
|
8
|
+
# Numbers are rounded to `digits` decimals with JS Math.round
|
|
9
|
+
# semantics and stringified as JS does (digits defaults to 3) - the
|
|
10
|
+
# contract that keeps poetry's path strings byte-equal to the
|
|
11
|
+
# geometry fixtures. Internal cursor state keeps FULL precision
|
|
12
|
+
# (rounding is output-formatting only).
|
|
13
|
+
#
|
|
14
|
+
# The arc/sector verbs are deliberately absent - polar sector paths
|
|
15
|
+
# are built by Polar, not through this class.
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# path = Path.new
|
|
19
|
+
# path.move_to(0, 0)
|
|
20
|
+
# path.line_to(10, 20.5)
|
|
21
|
+
# path.to_s # => "M0,0L10,20.5"
|
|
22
|
+
class Path
|
|
23
|
+
# @param digits [Integer, nil] output rounding decimals; nil disables
|
|
24
|
+
# rounding (full-precision output)
|
|
25
|
+
def initialize(digits: 3)
|
|
26
|
+
@x0 = @y0 = @x1 = @y1 = nil
|
|
27
|
+
@data = +""
|
|
28
|
+
@scale = digits.nil? ? nil : 10**digits
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Starts a new subpath at (x, y).
|
|
32
|
+
def move_to(x, y)
|
|
33
|
+
x = x.to_f
|
|
34
|
+
y = y.to_f
|
|
35
|
+
@x0 = @x1 = x
|
|
36
|
+
@y0 = @y1 = y
|
|
37
|
+
@data << "M#{fmt(x)},#{fmt(y)}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# A straight segment to (x, y).
|
|
41
|
+
def line_to(x, y)
|
|
42
|
+
x = x.to_f
|
|
43
|
+
y = y.to_f
|
|
44
|
+
@x1 = x
|
|
45
|
+
@y1 = y
|
|
46
|
+
@data << "L#{fmt(x)},#{fmt(y)}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# A quadratic curve to (x, y) with one control point.
|
|
50
|
+
def quadratic_curve_to(cpx, cpy, x, y)
|
|
51
|
+
x = x.to_f
|
|
52
|
+
y = y.to_f
|
|
53
|
+
@x1 = x
|
|
54
|
+
@y1 = y
|
|
55
|
+
@data << "Q#{fmt(cpx.to_f)},#{fmt(cpy.to_f)},#{fmt(x)},#{fmt(y)}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# A cubic curve to (x, y) with two control points.
|
|
59
|
+
def bezier_curve_to(cp1x, cp1y, cp2x, cp2y, x, y)
|
|
60
|
+
x = x.to_f
|
|
61
|
+
y = y.to_f
|
|
62
|
+
@x1 = x
|
|
63
|
+
@y1 = y
|
|
64
|
+
@data << "C#{fmt(cp1x.to_f)},#{fmt(cp1y.to_f)},#{fmt(cp2x.to_f)},#{fmt(cp2y.to_f)},#{fmt(x)},#{fmt(y)}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Closes the current subpath back to its start (a no-op before
|
|
68
|
+
# any move).
|
|
69
|
+
def close_path
|
|
70
|
+
return if @x1.nil?
|
|
71
|
+
|
|
72
|
+
@x1 = @x0
|
|
73
|
+
@y1 = @y0
|
|
74
|
+
@data << "Z"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The accumulated SVG path data.
|
|
78
|
+
def to_s
|
|
79
|
+
@data
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Whether nothing has been written yet.
|
|
83
|
+
def empty?
|
|
84
|
+
@data.empty?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def fmt(value)
|
|
90
|
+
value = ((value * @scale) + 0.5).floor / @scale.to_f if @scale
|
|
91
|
+
Geometry.js_number(value)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|