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.
Files changed (124) hide show
  1. checksums.yaml +7 -0
  2. data/.gitattributes +6 -0
  3. data/CHANGELOG.md +3 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +94 -0
  6. data/THIRD_PARTY_NOTICES.md +39 -0
  7. data/app/assets/stylesheets/poetry-charts.css +117 -0
  8. data/app/components/poetry/charts/adapter_chart/component.html.erb +6 -0
  9. data/app/components/poetry/charts/adapter_chart/component.rb +99 -0
  10. data/app/components/poetry/charts/adapter_chart/preview.rb +37 -0
  11. data/app/components/poetry/charts/adapter_chart/style.rb +16 -0
  12. data/app/components/poetry/charts/area_chart/component.html.erb +75 -0
  13. data/app/components/poetry/charts/area_chart/component.rb +251 -0
  14. data/app/components/poetry/charts/area_chart/preview.rb +95 -0
  15. data/app/components/poetry/charts/area_chart/style.rb +29 -0
  16. data/app/components/poetry/charts/bar_chart/component.html.erb +95 -0
  17. data/app/components/poetry/charts/bar_chart/component.rb +318 -0
  18. data/app/components/poetry/charts/bar_chart/preview.rb +115 -0
  19. data/app/components/poetry/charts/bar_chart/style.rb +28 -0
  20. data/app/components/poetry/charts/bar_math.rb +95 -0
  21. data/app/components/poetry/charts/cartesian_family.rb +105 -0
  22. data/app/components/poetry/charts/chart_family.rb +58 -0
  23. data/app/components/poetry/charts/composed_chart/component.html.erb +95 -0
  24. data/app/components/poetry/charts/composed_chart/component.rb +309 -0
  25. data/app/components/poetry/charts/composed_chart/preview.rb +62 -0
  26. data/app/components/poetry/charts/composed_chart/style.rb +24 -0
  27. data/app/components/poetry/charts/container/component.html.erb +4 -0
  28. data/app/components/poetry/charts/container/component.rb +80 -0
  29. data/app/components/poetry/charts/container/preview.rb +45 -0
  30. data/app/components/poetry/charts/container/style.rb +22 -0
  31. data/app/components/poetry/charts/error_bars.rb +35 -0
  32. data/app/components/poetry/charts/legend_content/component.html.erb +10 -0
  33. data/app/components/poetry/charts/legend_content/component.rb +122 -0
  34. data/app/components/poetry/charts/legend_content/preview.rb +23 -0
  35. data/app/components/poetry/charts/legend_content/style.rb +26 -0
  36. data/app/components/poetry/charts/line_chart/component.html.erb +85 -0
  37. data/app/components/poetry/charts/line_chart/component.rb +252 -0
  38. data/app/components/poetry/charts/line_chart/preview.rb +115 -0
  39. data/app/components/poetry/charts/line_chart/style.rb +28 -0
  40. data/app/components/poetry/charts/live.rb +296 -0
  41. data/app/components/poetry/charts/motion.rb +98 -0
  42. data/app/components/poetry/charts/pie_chart/component.html.erb +36 -0
  43. data/app/components/poetry/charts/pie_chart/component.rb +256 -0
  44. data/app/components/poetry/charts/pie_chart/preview.rb +100 -0
  45. data/app/components/poetry/charts/pie_chart/style.rb +19 -0
  46. data/app/components/poetry/charts/polar_family.rb +95 -0
  47. data/app/components/poetry/charts/radar_chart/component.html.erb +73 -0
  48. data/app/components/poetry/charts/radar_chart/component.rb +329 -0
  49. data/app/components/poetry/charts/radar_chart/preview.rb +97 -0
  50. data/app/components/poetry/charts/radar_chart/style.rb +21 -0
  51. data/app/components/poetry/charts/radial_bar_chart/component.html.erb +65 -0
  52. data/app/components/poetry/charts/radial_bar_chart/component.rb +424 -0
  53. data/app/components/poetry/charts/radial_bar_chart/preview.rb +99 -0
  54. data/app/components/poetry/charts/radial_bar_chart/style.rb +27 -0
  55. data/app/components/poetry/charts/reference_marks.rb +156 -0
  56. data/app/components/poetry/charts/scatter_chart/component.html.erb +55 -0
  57. data/app/components/poetry/charts/scatter_chart/component.rb +396 -0
  58. data/app/components/poetry/charts/scatter_chart/preview.rb +93 -0
  59. data/app/components/poetry/charts/scatter_chart/style.rb +22 -0
  60. data/app/components/poetry/charts/tooltip_content/component.html.erb +19 -0
  61. data/app/components/poetry/charts/tooltip_content/component.rb +168 -0
  62. data/app/components/poetry/charts/tooltip_content/preview.rb +39 -0
  63. data/app/components/poetry/charts/tooltip_content/style.rb +31 -0
  64. data/app/components/poetry/charts/tooltip_layer/component.html.erb +6 -0
  65. data/app/components/poetry/charts/tooltip_layer/component.rb +61 -0
  66. data/app/components/poetry/charts/tooltip_layer/style.rb +13 -0
  67. data/app/components/poetry/charts/tooltip_wiring.rb +167 -0
  68. data/app/helpers/poetry/charts/components_helper.rb +125 -0
  69. data/app/javascript/poetry/charts/adapter_controller.js +75 -0
  70. data/app/javascript/poetry/charts/adapter_registry.js +52 -0
  71. data/app/javascript/poetry/charts/adapters/chartjs.js +86 -0
  72. data/app/javascript/poetry/charts/d3.js +14 -0
  73. data/app/javascript/poetry/charts/index.js +67 -0
  74. data/app/javascript/poetry/charts/live/renderer.js +456 -0
  75. data/app/javascript/poetry/charts/live_controller.js +195 -0
  76. data/app/javascript/poetry/charts/motion/flip.js +169 -0
  77. data/app/javascript/poetry/charts/motion/sector.js +62 -0
  78. data/app/javascript/poetry/charts/motion/tween.js +105 -0
  79. data/app/javascript/poetry/charts/motion_controller.js +230 -0
  80. data/app/javascript/poetry/charts/tooltip_controller.js +331 -0
  81. data/app/javascript/poetry/charts/window_controller.js +195 -0
  82. data/config/component_descriptions.yml +14 -0
  83. data/config/component_registry.yml +3055 -0
  84. data/config/controllers_manifest.json +100 -0
  85. data/config/importmap.rb +10 -0
  86. data/lib/poetry/charts/cartesian.rb +277 -0
  87. data/lib/poetry/charts/config.rb +149 -0
  88. data/lib/poetry/charts/engine.rb +76 -0
  89. data/lib/poetry/charts/geometry/area.rb +68 -0
  90. data/lib/poetry/charts/geometry/curve.rb +327 -0
  91. data/lib/poetry/charts/geometry/line.rb +64 -0
  92. data/lib/poetry/charts/geometry/nice_ticks.rb +220 -0
  93. data/lib/poetry/charts/geometry/path.rb +96 -0
  94. data/lib/poetry/charts/geometry/scale/band.rb +81 -0
  95. data/lib/poetry/charts/geometry/scale/linear.rb +127 -0
  96. data/lib/poetry/charts/geometry/stack.rb +129 -0
  97. data/lib/poetry/charts/geometry/ticks.rb +103 -0
  98. data/lib/poetry/charts/geometry.rb +66 -0
  99. data/lib/poetry/charts/polar.rb +179 -0
  100. data/lib/poetry/charts/spec.rb +108 -0
  101. data/lib/poetry/charts/theme_style.rb +53 -0
  102. data/lib/poetry/charts/version.rb +8 -0
  103. data/lib/poetry/charts.rb +114 -0
  104. data/lib/poetry-charts.rb +4 -0
  105. data/themes/default.css +146 -0
  106. data/themes/luma.css +139 -0
  107. data/themes/lyra.css +140 -0
  108. data/themes/maia.css +139 -0
  109. data/themes/mira.css +139 -0
  110. data/themes/nova.css +139 -0
  111. data/themes/rhea.css +139 -0
  112. data/themes/sera.css +140 -0
  113. data/themes/vega.css +144 -0
  114. data/vendor/d3-kernel/LICENSE-d3.txt +17 -0
  115. data/vendor/d3-kernel/LICENSE-decimal.js-light.txt +25 -0
  116. data/vendor/d3-kernel/LICENSE-recharts.txt +26 -0
  117. data/vendor/d3-kernel/README.md +30 -0
  118. data/vendor/d3-kernel/build.mjs +66 -0
  119. data/vendor/d3-kernel/entry.js +27 -0
  120. data/vendor/d3-kernel/recharts/state/cartesianAxisSlice.ts +6 -0
  121. data/vendor/d3-kernel/recharts/util/scale/getNiceTickValues.ts +310 -0
  122. data/vendor/d3-kernel/recharts/util/scale/util/arithmetic.ts +54 -0
  123. data/vendor/d3-kernel/recharts/util/types.ts +5 -0
  124. 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