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,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ module Geometry
6
+ # The scale namespace: Linear, Band, and Point.
7
+ module Scale
8
+ # A band scale: categorical domain -> evenly stepped positions
9
+ # with inner/outer padding and alignment. Point is band with
10
+ # padding_inner = 1 (bandwidth 0), where padding: drives the
11
+ # outer padding - the axis shape line/area charts position
12
+ # categories with.
13
+ #
14
+ # @example
15
+ # Poetry::Charts::Geometry::Scale::Band
16
+ # .new(domain: %w[a b c], range: [0, 300]).positions
17
+ class Band
18
+ attr_reader :domain, :range, :padding_inner, :padding_outer, :align, :step, :bandwidth, :positions
19
+
20
+ def initialize(domain:, range:, padding_inner: 0.0, padding_outer: 0.0, align: 0.5, round: false)
21
+ @domain = domain.to_a
22
+ @range = range.map(&:to_f)
23
+ @padding_inner = [1.0, padding_inner.to_f].min
24
+ @padding_outer = padding_outer.to_f
25
+ @align = align.to_f.clamp(0.0, 1.0)
26
+ @round = round
27
+ rescale
28
+ end
29
+
30
+ # Convenience for the common single padding: knob - one value
31
+ # sets inner AND outer padding.
32
+ def self.padded(domain:, range:, padding: 0.0, align: 0.5, round: false)
33
+ new(domain:, range:, padding_inner: padding, padding_outer: padding, align:, round:)
34
+ end
35
+
36
+ # The band's leading-edge position for a category (nil when the
37
+ # category is unknown).
38
+ def call(value)
39
+ index = domain.index(value)
40
+ index && @positions[index]
41
+ end
42
+ alias [] call
43
+
44
+ private
45
+
46
+ def rescale
47
+ n = domain.length
48
+ reverse = range[1] < range[0]
49
+ start = reverse ? range[1] : range[0]
50
+ stop = reverse ? range[0] : range[1]
51
+
52
+ @step = (stop - start) / [1.0, n - padding_inner + (padding_outer * 2)].max
53
+ @step = @step.floor.to_f if @round
54
+ start += (stop - start - (@step * (n - padding_inner))) * align
55
+ @bandwidth = @step * (1 - padding_inner)
56
+ if @round
57
+ start = Geometry.js_round(start).to_f
58
+ @bandwidth = Geometry.js_round(@bandwidth).to_f
59
+ end
60
+
61
+ values = Array.new(n) { |i| start + (@step * i) }
62
+ @positions = reverse ? values.reverse : values
63
+ end
64
+ end
65
+
66
+ # A point scale: a band with padding_inner pinned to 1 - every
67
+ # category is a zero-width position, padding: is the outer
68
+ # padding.
69
+ #
70
+ # @example
71
+ # Poetry::Charts::Geometry::Scale::Point
72
+ # .new(domain: %w[a b c], range: [0, 300]).positions
73
+ class Point < Band
74
+ def initialize(domain:, range:, padding: 0.0, align: 0.5, round: false)
75
+ super(domain:, range:, padding_inner: 1.0, padding_outer: padding.to_f, align:, round:)
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ module Geometry
6
+ module Scale
7
+ # A linear scale reduced to the closed poetry surface: a
8
+ # two-point numeric domain/range with bimap normalization
9
+ # (descending domains supported), ticks via Geometry::Ticks, and
10
+ # the nice() domain extension. Degenerate domains map every
11
+ # input to the range midpoint.
12
+ #
13
+ # @example Map a value into an inverted pixel range
14
+ # Poetry::Charts::Geometry::Scale::Linear
15
+ # .new(domain: [0, 100], range: [300, 5]).call(50)
16
+ class Linear
17
+ attr_reader :domain, :range
18
+
19
+ # @param domain [Array<Numeric>] the two-point input interval
20
+ # @param range [Array<Numeric>] the two-point output interval
21
+ def initialize(domain: [0.0, 1.0], range: [0.0, 1.0])
22
+ @domain = domain.map(&:to_f)
23
+ @range = range.map(&:to_f)
24
+ end
25
+
26
+ # The range value for a domain value.
27
+ #
28
+ # @param value [Numeric]
29
+ # @return [Float]
30
+ def call(value)
31
+ value = value.to_f
32
+ d0, d1 = domain
33
+ r0, r1 = range
34
+
35
+ # bimap: descending domains swap both sides.
36
+ if d1 < d0
37
+ d0, d1 = d1, d0
38
+ r0, r1 = r1, r0
39
+ end
40
+
41
+ t = normalize(value, d0, d1)
42
+ r0 + ((r1 - r0) * t)
43
+ end
44
+ alias [] call
45
+
46
+ # The domain value for a range value - the inverse map.
47
+ #
48
+ # @param value [Numeric]
49
+ # @return [Float]
50
+ def invert(value)
51
+ value = value.to_f
52
+ d0, d1 = domain
53
+ r0, r1 = range
54
+
55
+ if d1 < d0
56
+ d0, d1 = d1, d0
57
+ r0, r1 = r1, r0
58
+ end
59
+
60
+ t = normalize(value, r0, r1)
61
+ d0 + ((d1 - d0) * t)
62
+ end
63
+
64
+ # Float-exact ticks across the domain.
65
+ #
66
+ # @return [Array<Numeric>]
67
+ def ticks(count = 10)
68
+ Ticks.ticks(domain.first, domain.last, count)
69
+ end
70
+
71
+ # Extends the domain to tick-increment boundaries, iterating
72
+ # until the increment is stable. Returns a NEW scale (poetry
73
+ # immutability).
74
+ #
75
+ # @return [Linear]
76
+ def nice(count = 10)
77
+ d = domain.dup
78
+ i0 = 0
79
+ i1 = d.length - 1
80
+ start = d[i0]
81
+ stop = d[i1]
82
+ prestep = nil
83
+ max_iter = 10
84
+
85
+ if stop < start
86
+ start, stop = stop, start
87
+ i0, i1 = i1, i0
88
+ end
89
+
90
+ while max_iter.positive?
91
+ max_iter -= 1
92
+ step = Ticks.tick_increment(start, stop, count)
93
+ if step == prestep
94
+ d[i0] = start
95
+ d[i1] = stop
96
+ break
97
+ elsif step.positive?
98
+ start = (start / step).floor * step
99
+ stop = (stop / step).ceil * step
100
+ elsif step.negative? && step.finite?
101
+ start = (start * step).ceil / step
102
+ stop = (stop * step).floor / step
103
+ else
104
+ # Degenerate domain (zero span): JS churns NaN until maxIter
105
+ # and leaves the domain unchanged; bail with the same result.
106
+ break
107
+ end
108
+ prestep = step
109
+ end
110
+
111
+ self.class.new(domain: d, range: range)
112
+ end
113
+
114
+ private
115
+
116
+ # A degenerate (zero-span) interval maps everything to t = 0.5.
117
+ def normalize(value, from, to)
118
+ span = to - from
119
+ return span.nan? ? Float::NAN : 0.5 if span.zero?
120
+
121
+ (value - from) / span
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ module Geometry
6
+ # The stack layout (declaration order): rows x series keys ->
7
+ # per-series [base, top] pairs. Missing values behave as
8
+ # JS +undefined = NaN (the offsets carry the exact NaN fallbacks).
9
+ # :expand normalizes each row to sum 1; :diverging routes negatives
10
+ # below the axis.
11
+ #
12
+ # @example Stack two series over shared rows
13
+ # Poetry::Charts::Geometry::Stack.new(keys: %w[a b]).series(rows)
14
+ class Stack
15
+ # The stack baseline modes.
16
+ OFFSETS = %i[none expand diverging].freeze
17
+
18
+ # One stacked series: its key, order index, and [base, top]
19
+ # points.
20
+ Series = ::Struct.new(:key, :index, :points) do
21
+ # The [base, top] pair at one row index.
22
+ def [](index)
23
+ points[index]
24
+ end
25
+
26
+ # The number of rows stacked.
27
+ def length
28
+ points.length
29
+ end
30
+ end
31
+
32
+ def initialize(keys:, value: nil, offset: :none)
33
+ @keys = keys.to_a
34
+ @value = value || ->(d, key) { d[key] }
35
+ raise ArgumentError, "unknown stack offset #{offset.inspect}" unless OFFSETS.include?(offset.to_sym)
36
+
37
+ @offset = offset.to_sym
38
+ end
39
+
40
+ # The stacked series for the rows, offsets applied.
41
+ #
42
+ # @param data [Enumerable] the rows
43
+ # @return [Array<Series>]
44
+ def series(data)
45
+ data = data.to_a
46
+ sz = @keys.map { |key| Series.new(key, nil, []) }
47
+
48
+ data.each do |d|
49
+ sz.each do |s|
50
+ value = @value.call(d, s.key)
51
+ s.points << [0.0, value.nil? ? Float::NAN : value.to_f]
52
+ end
53
+ end
54
+
55
+ order = (0...sz.length).to_a
56
+ order.each_with_index { |series_index, i| sz[series_index].index = i }
57
+
58
+ apply_offset(sz, order)
59
+ sz
60
+ end
61
+
62
+ private
63
+
64
+ def apply_offset(series, order)
65
+ case @offset
66
+ when :none then offset_none(series, order)
67
+ when :expand then offset_expand(series, order)
68
+ when :diverging then offset_diverging(series, order)
69
+ end
70
+ end
71
+
72
+ # Each series bases on the previous series' top (NaN tops fall back
73
+ # to the previous base - the JS isNaN branch).
74
+ def offset_none(series, order)
75
+ return unless series.length > 1
76
+
77
+ s1 = series[order[0]]
78
+ m = s1.length
79
+ (1...series.length).each do |i|
80
+ s0 = s1
81
+ s1 = series[order[i]]
82
+ (0...m).each do |j|
83
+ base = s0.points[j][1].nan? ? s0.points[j][0] : s0.points[j][1]
84
+ s1.points[j][0] = base
85
+ s1.points[j][1] += base
86
+ end
87
+ end
88
+ end
89
+
90
+ # Normalize each row to [0, 1] (percent stacking), then stack.
91
+ def offset_expand(series, order)
92
+ return if series.empty?
93
+
94
+ m = series[0].length
95
+ (0...m).each do |j|
96
+ sum = series.sum { |s| s.points[j][1].nan? ? 0.0 : s.points[j][1] }
97
+ series.each { |s| s.points[j][1] /= sum } unless sum.zero?
98
+ end
99
+ offset_none(series, order)
100
+ end
101
+
102
+ # Positives stack up from zero, negatives stack down.
103
+ def offset_diverging(series, order)
104
+ return if series.empty?
105
+
106
+ m = series[order[0]].length
107
+ (0...m).each do |j|
108
+ yp = 0.0
109
+ yn = 0.0
110
+ order.each do |series_index|
111
+ point = series[series_index].points[j]
112
+ dy = point[1] - point[0]
113
+ if dy.positive?
114
+ point[0] = yp
115
+ point[1] = (yp += dy)
116
+ elsif dy.negative?
117
+ point[1] = yn
118
+ point[0] = (yn += dy)
119
+ else
120
+ point[0] = 0.0
121
+ point[1] = dy
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ module Geometry
6
+ # Float-exact tick generation: ticks / tick_increment / tick_step.
7
+ # The 1-2-5-10 step selection runs against square-root thresholds,
8
+ # and the inverted-increment encoding (negative inc = divisor)
9
+ # keeps tick values float-exact - a sub-1 step divides by an
10
+ # integer instead of multiplying by a fraction.
11
+ #
12
+ # @example
13
+ # Poetry::Charts::Geometry::Ticks.ticks(0, 10, 5) # => [0, 2, 4, 6, 8, 10]
14
+ module Ticks
15
+ # The step-10 selection threshold: sqrt(50).
16
+ E10 = Math.sqrt(50)
17
+ # The step-5 selection threshold: sqrt(10).
18
+ E5 = Math.sqrt(10)
19
+ # The step-2 selection threshold: sqrt(2).
20
+ E2 = Math.sqrt(2)
21
+
22
+ module_function
23
+
24
+ # The [first index, last index, increment] spec for a tick run.
25
+ # @api private
26
+ def tick_spec(start, stop, count)
27
+ step = (stop - start) / [0, count].max.to_f
28
+ # JS runs degenerate inputs (zero span) through its float arithmetic
29
+ # and lands on inc = -Infinity; Ruby's floor would raise on the way.
30
+ # Return the same OBSERVABLE outputs analytically: tickIncrement
31
+ # gives -Infinity, tickStep gives 1/Infinity = 0, and the linearish
32
+ # nice() loop bails via its non-finite guard.
33
+ return [Float::NAN, Float::NAN, -Float::INFINITY] unless step.finite? && step.positive?
34
+
35
+ power = Math.log10(step).floor
36
+ error = step / (10.0**power)
37
+ factor = if error >= E10 then 10
38
+ elsif error >= E5 then 5
39
+ elsif error >= E2 then 2
40
+ else 1
41
+ end
42
+
43
+ if power.negative?
44
+ inc = (10.0**-power) / factor
45
+ i1 = Geometry.js_round(start * inc)
46
+ i2 = Geometry.js_round(stop * inc)
47
+ i1 += 1 if i1 / inc < start
48
+ i2 -= 1 if i2 / inc > stop
49
+ inc = -inc
50
+ else
51
+ inc = (10.0**power) * factor
52
+ i1 = Geometry.js_round(start / inc)
53
+ i2 = Geometry.js_round(stop / inc)
54
+ i1 += 1 if i1 * inc < start
55
+ i2 -= 1 if i2 * inc > stop
56
+ end
57
+
58
+ return tick_spec(start, stop, count * 2) if i2 < i1 && count >= 0.5 && count < 2
59
+
60
+ [i1, i2, inc]
61
+ end
62
+
63
+ # About `count` evenly stepped, float-exact values covering
64
+ # [start, stop].
65
+ #
66
+ # @return [Array<Numeric>]
67
+ def ticks(start, stop, count)
68
+ start = start.to_f
69
+ stop = stop.to_f
70
+ count = count.to_f
71
+ return [] unless count.positive?
72
+ return [start] if start == stop
73
+
74
+ reverse = stop < start
75
+ i1, i2, inc = reverse ? tick_spec(stop, start, count) : tick_spec(start, stop, count)
76
+ return [] if i2 < i1
77
+
78
+ n = i2 - i1 + 1
79
+ Array.new(n) do |i|
80
+ index = reverse ? i2 - i : i1 + i
81
+ inc.negative? ? index / -inc : index * inc
82
+ end
83
+ end
84
+
85
+ # The raw increment for the run - negative encodes a divisor.
86
+ def tick_increment(start, stop, count)
87
+ tick_spec(start.to_f, stop.to_f, count.to_f)[2]
88
+ end
89
+
90
+ # The absolute step size for the run (the increment decoded,
91
+ # signed by direction).
92
+ def tick_step(start, stop, count)
93
+ start = start.to_f
94
+ stop = stop.to_f
95
+ count = count.to_f
96
+ reverse = stop < start
97
+ inc = reverse ? tick_increment(stop, start, count) : tick_increment(start, stop, count)
98
+ (reverse ? -1 : 1) * (inc.negative? ? 1.0 / -inc : inc)
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ # The geometry core: the tick, scale, and shape math the charts stand
6
+ # on - array ticks, linear/band/point scales, line/area/curve
7
+ # generators, stack layout, and decimal-exact nice-ticks. Every piece
8
+ # is oracle-tested: committed fixtures
9
+ # (test/support/generate_geometry_fixtures.mjs) pin the expected
10
+ # outputs byte-for-byte, alongside translated spec cases.
11
+ #
12
+ # JS number semantics are part of the contract - path strings must
13
+ # match the fixture output byte-for-byte - so rounding and
14
+ # stringification go through js_round / js_number below, never
15
+ # through Ruby defaults (Ruby rounds half away from zero and prints
16
+ # "80.0"; JS floors x+0.5 and prints "80").
17
+ #
18
+ # @example
19
+ # Poetry::Charts::Geometry.js_number(80.0) # => "80"
20
+ module Geometry
21
+ module_function
22
+
23
+ # JS Math.round: floor(x + 0.5) - differs from Float#round at negative
24
+ # halves (JS rounds -2.5 to -2; Ruby to -3).
25
+ def js_round(value)
26
+ (value + 0.5).floor
27
+ end
28
+
29
+ # JS Number#toString for the values that appear in SVG path data:
30
+ # integral doubles print bare ("80", not "80.0"), -0 prints "0", and
31
+ # everything else uses shortest round-trip decimal (Ruby and V8 agree
32
+ # on shortest-repr in the post-rounding magnitude range; the exponent
33
+ # guard covers the sub-1e-4 corner where Ruby switches early).
34
+ def js_number(value)
35
+ return "0" if value.zero?
36
+
37
+ integral = value.to_i
38
+ return integral.to_s if value == integral
39
+
40
+ text = value.to_s
41
+ return text unless text.include?("e")
42
+
43
+ format("%.12f", value).sub(/0+\z/, "").delete_suffix(".")
44
+ end
45
+
46
+ # JS truthiness for the curve state machines (the line state flag
47
+ # runs nil | 0 | 1 | NaN): nil, 0, and NaN are falsy.
48
+ def js_truthy?(value)
49
+ return false if value.nil?
50
+ return false if value.is_a?(Float) && value.nan?
51
+
52
+ value != 0
53
+ end
54
+ end
55
+ end
56
+ end
57
+
58
+ require_relative "geometry/ticks"
59
+ require_relative "geometry/nice_ticks"
60
+ require_relative "geometry/scale/linear"
61
+ require_relative "geometry/scale/band"
62
+ require_relative "geometry/path"
63
+ require_relative "geometry/curve"
64
+ require_relative "geometry/line"
65
+ require_relative "geometry/area"
66
+ require_relative "geometry/stack"
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Charts
5
+ # The polar geometry: pie-sector accumulation, tangent-circle corner
6
+ # rounding, and wedge/ring paths. Angles are degrees COUNTERCLOCKWISE
7
+ # from 3 o'clock, negated into SVG's y-down plane by
8
+ # polar_to_cartesian; pies start at angle 0 and sweep to 360.
9
+ #
10
+ # @example Slice values into pie sector angles
11
+ # Poetry::Charts::Polar.pie_sectors([3, 1], padding_angle: 2)
12
+ module Polar
13
+ # Degrees-to-radians factor.
14
+ RADIAN = Math::PI / 180
15
+
16
+ module_function
17
+
18
+ # -1, 0, or 1 by the value's sign.
19
+ def sign(value)
20
+ return 0 if value.zero?
21
+
22
+ value.negative? ? -1 : 1
23
+ end
24
+
25
+ # The [x, y] point at (radius, angle) from the center, in SVG's
26
+ # y-down plane.
27
+ def polar_to_cartesian(cx, cy, radius, angle)
28
+ [cx + (Math.cos(-RADIAN * angle) * radius), cy + (Math.sin(-RADIAN * angle) * radius)]
29
+ end
30
+
31
+ # The largest radius fitting the plot: half the shorter side.
32
+ def max_radius(width, height)
33
+ [width, height].min / 2.0
34
+ end
35
+
36
+ # "80%" of the max radius, or a plain number.
37
+ def percent_value(value, total, default = 0)
38
+ return default if value.nil?
39
+
40
+ text = value.to_s
41
+ text.end_with?("%") ? total * (text.to_f / 100.0) : text.to_f
42
+ end
43
+
44
+ # The pie accumulation: values -> per-slice angles. Zero values
45
+ # collapse (and skip padding); paddings live BETWEEN non-zero
46
+ # slices (full circles pad after the last slice too, closing the
47
+ # ring).
48
+ def pie_sectors(values, start_angle: 0, end_angle: 360, padding_angle: 0, min_angle: 0)
49
+ numeric = values.map { |value| value.is_a?(Numeric) ? value.to_f : 0.0 }
50
+ sum = numeric.sum
51
+ return [] if sum <= 0
52
+
53
+ delta = sign(end_angle - start_angle) * [(end_angle - start_angle).abs, 360].min
54
+ abs_delta = delta.abs
55
+ direction = sign(delta)
56
+ not_zero = numeric.count { |value| !value.zero? }
57
+ total_padding = (abs_delta >= 360 ? not_zero : [not_zero - 1, 0].max) * padding_angle
58
+
59
+ needs_min = min_angle.positive? &&
60
+ numeric.any? { |value| !value.zero? && (value / sum) * abs_delta < min_angle }
61
+ effective_min = needs_min ? min_angle : 0
62
+
63
+ real_total = abs_delta - (not_zero * effective_min) - total_padding
64
+
65
+ cursor = start_angle.to_f
66
+ numeric.each_with_index.map do |value, i|
67
+ percent = value / sum
68
+ slice_start = if i.zero?
69
+ start_angle.to_f
70
+ else
71
+ cursor + (direction * padding_angle * (value.zero? ? 0 : 1))
72
+ end
73
+ slice_end = slice_start + (direction * ((value.zero? ? 0 : effective_min) + (percent * real_total)))
74
+ cursor = slice_end
75
+
76
+ {
77
+ value: value,
78
+ percent: percent,
79
+ start_angle: slice_start,
80
+ end_angle: slice_end,
81
+ mid_angle: (slice_start + slice_end) / 2.0
82
+ }
83
+ end
84
+ end
85
+
86
+ # The corner circle tangent to an arc (at `radius`) and a radial
87
+ # edge (at `angle`) - the rounded-corner primitive for the radial
88
+ # bar's corner_radius.
89
+ def tangent_circle(cx:, cy:, radius:, angle:, sign:, corner_radius:, external: false)
90
+ center_radius = (corner_radius * (external ? 1 : -1)) + radius
91
+ theta = Math.asin(corner_radius / center_radius) / RADIAN
92
+ center_angle = angle + (sign * theta)
93
+ {
94
+ circle_tangency: polar_to_cartesian(cx, cy, radius, center_angle),
95
+ line_tangency: polar_to_cartesian(cx, cy, center_radius * Math.cos(theta * RADIAN), angle),
96
+ theta: theta
97
+ }
98
+ end
99
+
100
+ # The ring segment with all four corners rounded by tangent
101
+ # circles. Falls back to the plain path when the sweep is too small
102
+ # to fit the corners.
103
+ def sector_path_with_corners(cx:, cy:, inner_radius:, outer_radius:, start_angle:, end_angle:,
104
+ corner_radius:, fmt: nil)
105
+ fmt ||= ->(v) { Geometry.js_number((v * 10_000).round / 10_000.0) }
106
+ corner = [corner_radius.to_f, (outer_radius - inner_radius).abs / 2.0].min
107
+ if corner <= 0 || (end_angle - start_angle).abs >= 360
108
+ return sector_path(cx:, cy:, inner_radius:, outer_radius:, start_angle:, end_angle:, fmt:)
109
+ end
110
+
111
+ s = sign(end_angle - start_angle)
112
+ so = tangent_circle(cx:, cy:, radius: outer_radius, angle: start_angle, sign: s, corner_radius: corner)
113
+ eo = tangent_circle(cx:, cy:, radius: outer_radius, angle: end_angle, sign: -s, corner_radius: corner)
114
+ outer_arc = (start_angle - end_angle).abs - so[:theta] - eo[:theta]
115
+ if outer_arc.negative?
116
+ return sector_path(cx:, cy:, inner_radius:, outer_radius:, start_angle:, end_angle:, fmt:)
117
+ end
118
+
119
+ ccw = s.negative? ? 1 : 0
120
+ p = ->(point) { "#{fmt.call(point[0])},#{fmt.call(point[1])}" }
121
+ path = "M#{p.call(so[:line_tangency])}" \
122
+ "A#{fmt.call(corner)},#{fmt.call(corner)},0,0,#{ccw},#{p.call(so[:circle_tangency])}" \
123
+ "A#{fmt.call(outer_radius)},#{fmt.call(outer_radius)},0,#{outer_arc > 180 ? 1 : 0},#{ccw}," \
124
+ "#{p.call(eo[:circle_tangency])}" \
125
+ "A#{fmt.call(corner)},#{fmt.call(corner)},0,0,#{ccw},#{p.call(eo[:line_tangency])}"
126
+
127
+ if inner_radius.positive?
128
+ si = tangent_circle(cx:, cy:, radius: inner_radius, angle: start_angle, sign: s,
129
+ corner_radius: corner, external: true)
130
+ ei = tangent_circle(cx:, cy:, radius: inner_radius, angle: end_angle, sign: -s,
131
+ corner_radius: corner, external: true)
132
+ inner_arc = (start_angle - end_angle).abs - si[:theta] - ei[:theta]
133
+ return "#{path}L#{fmt.call(cx)},#{fmt.call(cy)}Z" if inner_arc.negative? && corner.zero?
134
+
135
+ path << "L#{p.call(ei[:line_tangency])}" \
136
+ "A#{fmt.call(corner)},#{fmt.call(corner)},0,0,#{ccw},#{p.call(ei[:circle_tangency])}" \
137
+ "A#{fmt.call(inner_radius)},#{fmt.call(inner_radius)},0,#{inner_arc > 180 ? 1 : 0}," \
138
+ "#{s.positive? ? 1 : 0},#{p.call(si[:circle_tangency])}" \
139
+ "A#{fmt.call(corner)},#{fmt.call(corner)},0,0,#{ccw},#{p.call(si[:line_tangency])}Z"
140
+ else
141
+ path << "L#{fmt.call(cx)},#{fmt.call(cy)}Z"
142
+ end
143
+
144
+ path
145
+ end
146
+
147
+ # The wedge/ring path. The delta clamps at 359.999 so a full
148
+ # circle's endpoints never coincide.
149
+ def sector_path(cx:, cy:, inner_radius:, outer_radius:, start_angle:, end_angle:, fmt: nil)
150
+ # 4-decimal precision: a full circle's 359.999-degree endpoints
151
+ # differ only past the 2nd decimal - rounding coarser would collapse
152
+ # them and erase the arc.
153
+ fmt ||= ->(v) { Geometry.js_number((v * 10_000).round / 10_000.0) }
154
+ delta = sign(end_angle - start_angle) * [(end_angle - start_angle).abs, 359.999].min
155
+ temp_end = start_angle + delta
156
+ large = (delta.abs > 180 ? 1 : 0)
157
+
158
+ ox0, oy0 = polar_to_cartesian(cx, cy, outer_radius, start_angle)
159
+ ox1, oy1 = polar_to_cartesian(cx, cy, outer_radius, temp_end)
160
+
161
+ path = "M#{fmt.call(ox0)},#{fmt.call(oy0)}" \
162
+ "A#{fmt.call(outer_radius)},#{fmt.call(outer_radius)},0," \
163
+ "#{large},#{start_angle > temp_end ? 1 : 0},#{fmt.call(ox1)},#{fmt.call(oy1)}"
164
+
165
+ if inner_radius.positive?
166
+ ix0, iy0 = polar_to_cartesian(cx, cy, inner_radius, start_angle)
167
+ ix1, iy1 = polar_to_cartesian(cx, cy, inner_radius, temp_end)
168
+ path << "L#{fmt.call(ix1)},#{fmt.call(iy1)}" \
169
+ "A#{fmt.call(inner_radius)},#{fmt.call(inner_radius)},0," \
170
+ "#{large},#{start_angle <= temp_end ? 1 : 0},#{fmt.call(ix0)},#{fmt.call(iy0)}Z"
171
+ else
172
+ path << "L#{fmt.call(cx)},#{fmt.call(cy)}Z"
173
+ end
174
+
175
+ path
176
+ end
177
+ end
178
+ end
179
+ end