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,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
|