rcas 0.2.0
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/CITATION.cff +17 -0
- data/DESIGN.md +783 -0
- data/LICENSE +21 -0
- data/MANUAL.md +6265 -0
- data/README.md +267 -0
- data/bin/rcas +9 -0
- data/bin/rcas-app +9 -0
- data/bin/rcas-chat +9 -0
- data/lib/rcas/algebraic.rb +481 -0
- data/lib/rcas/analysis.rb +966 -0
- data/lib/rcas/app/launcher.rb +203 -0
- data/lib/rcas/app/public/app.css +402 -0
- data/lib/rcas/app/public/app.js +449 -0
- data/lib/rcas/app/public/index.html +46 -0
- data/lib/rcas/app/server.rb +220 -0
- data/lib/rcas/app/window.rb +94 -0
- data/lib/rcas/app/worksheet.rb +290 -0
- data/lib/rcas/app.rb +168 -0
- data/lib/rcas/background.rb +758 -0
- data/lib/rcas/chat/assistant.rb +199 -0
- data/lib/rcas/chat/picker.rb +164 -0
- data/lib/rcas/chat/repl.rb +583 -0
- data/lib/rcas/chat/session.rb +137 -0
- data/lib/rcas/chat/settings.rb +71 -0
- data/lib/rcas/chat/style.rb +30 -0
- data/lib/rcas/chat/tool.rb +53 -0
- data/lib/rcas/chat/ui.rb +316 -0
- data/lib/rcas/chat/usage.rb +62 -0
- data/lib/rcas/chat/workspace.rb +132 -0
- data/lib/rcas/chat.rb +54 -0
- data/lib/rcas/coefficients.rb +170 -0
- data/lib/rcas/combinatorics.rb +274 -0
- data/lib/rcas/complex_parts.rb +160 -0
- data/lib/rcas/constants.rb +129 -0
- data/lib/rcas/core_ext.rb +35 -0
- data/lib/rcas/decide.rb +501 -0
- data/lib/rcas/decompositions.rb +241 -0
- data/lib/rcas/differentiate.rb +144 -0
- data/lib/rcas/discussion.rb +558 -0
- data/lib/rcas/distributions.rb +980 -0
- data/lib/rcas/dixon.rb +95 -0
- data/lib/rcas/docs.rb +321 -0
- data/lib/rcas/domains.rb +728 -0
- data/lib/rcas/expand.rb +174 -0
- data/lib/rcas/expression.rb +613 -0
- data/lib/rcas/factor.rb +605 -0
- data/lib/rcas/finite_field.rb +577 -0
- data/lib/rcas/fourier.rb +118 -0
- data/lib/rcas/fps.rb +678 -0
- data/lib/rcas/fraction.rb +126 -0
- data/lib/rcas/functions.rb +1136 -0
- data/lib/rcas/gcd.rb +112 -0
- data/lib/rcas/geometry.rb +266 -0
- data/lib/rcas/groebner.rb +162 -0
- data/lib/rcas/hold.rb +277 -0
- data/lib/rcas/hypothesis.rb +364 -0
- data/lib/rcas/inequalities.rb +689 -0
- data/lib/rcas/integral_functions.rb +260 -0
- data/lib/rcas/integrate.rb +1589 -0
- data/lib/rcas/integrate_substitutions.rb +434 -0
- data/lib/rcas/interpolate.rb +40 -0
- data/lib/rcas/irb.rb +146 -0
- data/lib/rcas/laplace.rb +159 -0
- data/lib/rcas/latex.rb +556 -0
- data/lib/rcas/lattice.rb +172 -0
- data/lib/rcas/linear_algebra.rb +117 -0
- data/lib/rcas/linear_program.rb +416 -0
- data/lib/rcas/lint.rb +79 -0
- data/lib/rcas/matrix.rb +531 -0
- data/lib/rcas/matrix_multiply.rb +202 -0
- data/lib/rcas/multimodular.rb +286 -0
- data/lib/rcas/named_polynomials.rb +274 -0
- data/lib/rcas/number_theory.rb +443 -0
- data/lib/rcas/numerics.rb +825 -0
- data/lib/rcas/ode.rb +488 -0
- data/lib/rcas/openmath/objects.rb +364 -0
- data/lib/rcas/openmath/phrasebook.rb +551 -0
- data/lib/rcas/openmath/popcorn.rb +518 -0
- data/lib/rcas/openmath/xml.rb +309 -0
- data/lib/rcas/openmath.rb +49 -0
- data/lib/rcas/petkovsek.rb +165 -0
- data/lib/rcas/piecewise.rb +488 -0
- data/lib/rcas/plot.rb +763 -0
- data/lib/rcas/plot3d.rb +419 -0
- data/lib/rcas/poly_matrix.rb +318 -0
- data/lib/rcas/poly_recurrence.rb +117 -0
- data/lib/rcas/polynomial.rb +466 -0
- data/lib/rcas/precision.rb +925 -0
- data/lib/rcas/printer.rb +150 -0
- data/lib/rcas/product.rb +155 -0
- data/lib/rcas/q_difference.rb +296 -0
- data/lib/rcas/q_functions.rb +158 -0
- data/lib/rcas/q_summation.rb +308 -0
- data/lib/rcas/q_zeilberger.rb +199 -0
- data/lib/rcas/random.rb +506 -0
- data/lib/rcas/rational_function.rb +186 -0
- data/lib/rcas/recurrence.rb +323 -0
- data/lib/rcas/render.rb +431 -0
- data/lib/rcas/results.rb +192 -0
- data/lib/rcas/scalar.rb +219 -0
- data/lib/rcas/series.rb +726 -0
- data/lib/rcas/simplify.rb +649 -0
- data/lib/rcas/solve.rb +2002 -0
- data/lib/rcas/special.rb +163 -0
- data/lib/rcas/statistics.rb +175 -0
- data/lib/rcas/steps.rb +835 -0
- data/lib/rcas/summation.rb +532 -0
- data/lib/rcas/trig.rb +264 -0
- data/lib/rcas/van_hoeij.rb +241 -0
- data/lib/rcas/vector.rb +175 -0
- data/lib/rcas/vector_calculus.rb +411 -0
- data/lib/rcas/version.rb +5 -0
- data/lib/rcas/zeilberger.rb +358 -0
- data/lib/rcas.rb +91 -0
- data/package.json +8 -0
- metadata +206 -0
data/lib/rcas/plot.rb
ADDED
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RCAS
|
|
4
|
+
# Function plotting.
|
|
5
|
+
#
|
|
6
|
+
# plot(sin(x)) # -10..10, drawn in the terminal
|
|
7
|
+
# plot(x**2 - 1, x: -3..3)
|
|
8
|
+
# plot([sin(x), cos(x)], x: 0..2*PI)
|
|
9
|
+
# plot(Normal(0, 1)) # the density of a distribution
|
|
10
|
+
# scatter([1, 2, 3], [2, 4, 7]) # data, with linreg(...) alongside
|
|
11
|
+
# plot(1/x, x: -3..3).save("hyperbola.svg")
|
|
12
|
+
# plot(sin(x)).show # inline picture in iTerm2
|
|
13
|
+
#
|
|
14
|
+
# A Plot prints as Unicode braille art, which needs nothing but a terminal
|
|
15
|
+
# and is what `inspect` shows; `to_svg` and `save` write a picture, `to_png`
|
|
16
|
+
# and `show` rasterize it with headless Chrome (the renderer the typeset
|
|
17
|
+
# output already uses). Sampling is uniform; values that are complex,
|
|
18
|
+
# infinite or undefined leave a gap, and a jump across a pole breaks the
|
|
19
|
+
# line rather than drawing a vertical stroke.
|
|
20
|
+
#
|
|
21
|
+
# Sources (keys: MANUAL.md, Sources): line drawing [Bre65]; the braille
|
|
22
|
+
# canvas is the technique of drawille and UnicodePlots.jl; the y range for
|
|
23
|
+
# functions with poles is trimmed to the central 96 per cent of the sampled
|
|
24
|
+
# values, as gnuplot's autoscaling does in spirit.
|
|
25
|
+
class Plot
|
|
26
|
+
class Error < StandardError; end
|
|
27
|
+
|
|
28
|
+
# Braille cells are 2 by 4 dots; these are the bit values of the dots.
|
|
29
|
+
DOTS = [[0x01, 0x02, 0x04, 0x40], [0x08, 0x10, 0x20, 0x80]].freeze
|
|
30
|
+
BRAILLE_BLANK = 0x2800
|
|
31
|
+
COLORS = %w[#2563eb #dc2626 #059669 #d97706 #7c3aed #0891b2].freeze
|
|
32
|
+
DEFAULT_RANGE = (-10.0..10.0)
|
|
33
|
+
|
|
34
|
+
Curve = Struct.new(:label, :points, :marker, :width)
|
|
35
|
+
|
|
36
|
+
# How a front end shows a plot: :text (braille art, works everywhere) or
|
|
37
|
+
# :image (an inline picture, needs an iTerm2-style terminal and Chrome).
|
|
38
|
+
# RCAS_PLOT_STYLE sets the default; rcas-chat has /plotstyle.
|
|
39
|
+
STYLES = %i[text image].freeze
|
|
40
|
+
|
|
41
|
+
class << self
|
|
42
|
+
def style = @style || ENV.fetch("RCAS_PLOT_STYLE", "text").to_sym
|
|
43
|
+
|
|
44
|
+
def style=(value)
|
|
45
|
+
wanted = value.to_s.to_sym
|
|
46
|
+
raise Error, "plot style must be one of #{STYLES.join(', ')}" unless STYLES.include?(wanted)
|
|
47
|
+
@style = wanted
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def image? = style == :image
|
|
51
|
+
|
|
52
|
+
# Can this terminal show pictures at all?
|
|
53
|
+
def pictures?(io = $stdout) = Render.inline?(io) && !Render.which(*Render::KaTeX::CHROME_CANDIDATES).nil?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
attr_reader :curves, :var, :xlo, :xhi, :ylo, :yhi, :title, :width, :height, :ylabels, :xlabels, :notes
|
|
57
|
+
|
|
58
|
+
def initialize(curves, var:, xlo:, xhi:, ylo:, yhi:, title: nil, width: 60, height: 15, ylabels: nil, xlabels: nil, notes: [])
|
|
59
|
+
@curves = curves
|
|
60
|
+
@var = var
|
|
61
|
+
@xlo = xlo
|
|
62
|
+
@xhi = xhi
|
|
63
|
+
@ylo = ylo
|
|
64
|
+
@yhi = yhi
|
|
65
|
+
@title = title
|
|
66
|
+
@width = width
|
|
67
|
+
@height = height
|
|
68
|
+
@ylabels = ylabels # { value => text } instead of the y range
|
|
69
|
+
@xlabels = xlabels # [[value, text], ...] instead of the x range
|
|
70
|
+
@notes = notes # lines under the picture: why part of it is missing
|
|
71
|
+
freeze
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def x_range = (xlo..xhi)
|
|
75
|
+
def y_range = (ylo..yhi)
|
|
76
|
+
|
|
77
|
+
# ---- the terminal picture ------------------------------------------------
|
|
78
|
+
|
|
79
|
+
def to_s
|
|
80
|
+
canvas = Canvas.new(width, height)
|
|
81
|
+
draw_axes(canvas)
|
|
82
|
+
curves.each { |curve| draw_curve(canvas, curve) }
|
|
83
|
+
frame(canvas.rows)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def inspect = to_s
|
|
87
|
+
|
|
88
|
+
def draw_axes(canvas)
|
|
89
|
+
if ylo < 0 && yhi > 0
|
|
90
|
+
row = pixel_y(0)
|
|
91
|
+
(0...canvas.pixel_width).step(4) { |px| canvas.set(px, row) }
|
|
92
|
+
end
|
|
93
|
+
return unless xlo < 0 && xhi > 0
|
|
94
|
+
column = pixel_x(0)
|
|
95
|
+
(0...canvas.pixel_height).step(2) { |py| canvas.set(column, py) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def draw_curve(canvas, curve)
|
|
99
|
+
previous = nil
|
|
100
|
+
curve.points.each do |point|
|
|
101
|
+
if point.nil?
|
|
102
|
+
previous = nil
|
|
103
|
+
next
|
|
104
|
+
end
|
|
105
|
+
x, y = point
|
|
106
|
+
px = pixel_x(x)
|
|
107
|
+
py = pixel_y(y)
|
|
108
|
+
if curve.marker == :bar
|
|
109
|
+
draw_bar(canvas, x, y, curve.width)
|
|
110
|
+
previous = point
|
|
111
|
+
next
|
|
112
|
+
elsif curve.marker == :box
|
|
113
|
+
draw_box(canvas, curve)
|
|
114
|
+
return
|
|
115
|
+
elsif curve.marker == :stem
|
|
116
|
+
base = pixel_y([0.0, ylo].max)
|
|
117
|
+
canvas.line(px, base, px, py)
|
|
118
|
+
canvas.set(px, py)
|
|
119
|
+
elsif curve.marker
|
|
120
|
+
canvas.set(px, py)
|
|
121
|
+
elsif previous && !jump?(previous, point) && (inside?(previous[1]) || inside?(y))
|
|
122
|
+
# A segment with both ends outside the picture is the run-up to a
|
|
123
|
+
# pole (or the leap across one): drawing it leaves a vertical
|
|
124
|
+
# stroke at the frame edge where the graph has no line at all.
|
|
125
|
+
canvas.line(pixel_x(previous[0]), pixel_y(previous[1]), px, py)
|
|
126
|
+
elsif inside?(y)
|
|
127
|
+
canvas.set(px, py)
|
|
128
|
+
end
|
|
129
|
+
previous = point
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# A filled bar of +span+ x units, centred on x, standing on the baseline.
|
|
134
|
+
def draw_bar(canvas, x, y, span)
|
|
135
|
+
left = pixel_x(x - span / 2.0) + 1
|
|
136
|
+
right = pixel_x(x + span / 2.0) - 1
|
|
137
|
+
right = left if right < left
|
|
138
|
+
base = pixel_y([0.0, ylo].max)
|
|
139
|
+
top = pixel_y(y)
|
|
140
|
+
(left..right).each { |px| canvas.line(px, base, px, top) }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Box and whiskers: [low, q1, median, q3, high, *outliers] at one height.
|
|
144
|
+
def draw_box(canvas, curve)
|
|
145
|
+
low, q1, median, q3, high = curve.points.first(5).map(&:first)
|
|
146
|
+
y = curve.points.first.last
|
|
147
|
+
middle = pixel_y(y)
|
|
148
|
+
half = [(middle - pixel_y(y + curve.width)).abs, 1].max
|
|
149
|
+
canvas.line(pixel_x(low), middle, pixel_x(q1), middle) # whiskers
|
|
150
|
+
canvas.line(pixel_x(q3), middle, pixel_x(high), middle)
|
|
151
|
+
[low, high].each { |v| canvas.line(pixel_x(v), middle - half, pixel_x(v), middle + half) }
|
|
152
|
+
[q1, q3, median].each { |v| canvas.line(pixel_x(v), middle - half, pixel_x(v), middle + half) }
|
|
153
|
+
[middle - half, middle + half].each { |row| canvas.line(pixel_x(q1), row, pixel_x(q3), row) }
|
|
154
|
+
curve.points.drop(5).each { |v, _| canvas.set(pixel_x(v), middle) } # outliers
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# A pole: the two samples straddle the whole picture in opposite directions.
|
|
158
|
+
def jump?(a, b)
|
|
159
|
+
return false unless (b[1] - a[1]).abs > (yhi - ylo)
|
|
160
|
+
(a[1] <=> 0) != (b[1] <=> 0)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def inside?(y) = y >= ylo && y <= yhi
|
|
164
|
+
|
|
165
|
+
def pixel_x(x) = (((x - xlo) / (xhi - xlo)) * (width * 2 - 1)).round
|
|
166
|
+
def pixel_y(y) = (((yhi - y) / (yhi - ylo)) * (height * 4 - 1)).round
|
|
167
|
+
|
|
168
|
+
def self.label(value)
|
|
169
|
+
return "0" if value.zero?
|
|
170
|
+
text = format("%.4g", value)
|
|
171
|
+
text.include?("e") ? text : text.sub(/\.0+\z/, "")
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# y labels in a gutter, the canvas, then the x axis and its labels.
|
|
175
|
+
def frame(rows)
|
|
176
|
+
gutter_labels = row_labels(rows.size)
|
|
177
|
+
gutter = gutter_labels.compact.map(&:size).max || 0
|
|
178
|
+
lines = []
|
|
179
|
+
lines << title if title
|
|
180
|
+
rows.each_with_index do |row, i|
|
|
181
|
+
mark = gutter_labels[i] ? gutter_labels[i].rjust(gutter) + " ┤" : (" " * gutter) + " │"
|
|
182
|
+
lines << mark + row
|
|
183
|
+
end
|
|
184
|
+
lines << (" " * (gutter + 1)) + "└" + "─" * width
|
|
185
|
+
lines << (" " * (gutter + 2)) + column_labels
|
|
186
|
+
lines << " " + legend if legend && !legend.empty?
|
|
187
|
+
notes.each { |note| lines << " note: #{note}" }
|
|
188
|
+
lines.join("\n")
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# The label for each canvas row: the y range, or the names of a box plot.
|
|
192
|
+
def row_labels(count)
|
|
193
|
+
return Array.new(count) { |i| i.zero? ? self.class.label(yhi) : (i == count - 1 ? self.class.label(ylo) : nil) } unless ylabels
|
|
194
|
+
labels = Array.new(count)
|
|
195
|
+
ylabels.each do |value, text|
|
|
196
|
+
row = pixel_y(value) / 4
|
|
197
|
+
labels[row] = text.to_s if row.between?(0, count - 1)
|
|
198
|
+
end
|
|
199
|
+
labels
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Under the axis: the ends of the x range, or names centred on their bars.
|
|
203
|
+
def column_labels
|
|
204
|
+
unless xlabels
|
|
205
|
+
left = self.class.label(xlo)
|
|
206
|
+
right = self.class.label(xhi)
|
|
207
|
+
return left + (" " * [width - left.size - right.size, 1].max) + right
|
|
208
|
+
end
|
|
209
|
+
line = " " * width
|
|
210
|
+
xlabels.each do |value, text|
|
|
211
|
+
text = text.to_s
|
|
212
|
+
start = pixel_x(value) / 2 - (text.size - 1) / 2
|
|
213
|
+
start = [[start, 0].max, width - text.size].min
|
|
214
|
+
next if start.negative? || line[start, text.size].to_s.strip != ""
|
|
215
|
+
line[start, text.size] = text
|
|
216
|
+
end
|
|
217
|
+
line.rstrip
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def legend
|
|
221
|
+
names = curves.map(&:label).compact
|
|
222
|
+
names.empty? ? nil : names.join(", ")
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Counts deserve a whole number at the top of the axis.
|
|
226
|
+
def self.head_room(heights)
|
|
227
|
+
top = heights.max * 1.08
|
|
228
|
+
heights.all? { |h| h == h.round } ? top.ceil.to_f : top
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# ---- pictures --------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
def svg_size = [720, 440]
|
|
234
|
+
|
|
235
|
+
def to_svg(theme: nil)
|
|
236
|
+
w, h = svg_size
|
|
237
|
+
theme = (theme || Render.theme).to_s
|
|
238
|
+
background = theme == "light" ? "#ffffff" : "#111827"
|
|
239
|
+
foreground = theme == "light" ? "#111827" : "#e5e7eb"
|
|
240
|
+
grid = theme == "light" ? "#9ca3af" : "#4b5563"
|
|
241
|
+
left = 60
|
|
242
|
+
right = 20
|
|
243
|
+
top = title ? 34 : 16
|
|
244
|
+
bottom = 40
|
|
245
|
+
plot_w = w - left - right
|
|
246
|
+
plot_h = h - top - bottom
|
|
247
|
+
sx = ->(x) { left + (x - xlo) / (xhi - xlo) * plot_w }
|
|
248
|
+
sy = ->(y) { top + (yhi - y) / (yhi - ylo) * plot_h }
|
|
249
|
+
|
|
250
|
+
parts = []
|
|
251
|
+
parts << %(<rect width="#{w}" height="#{h}" fill="#{background}"/>)
|
|
252
|
+
parts << %(<text x="#{w / 2}" y="22" fill="#{foreground}" font-family="sans-serif" font-size="16" text-anchor="middle">#{escape(title)}</text>) if title
|
|
253
|
+
parts << %(<rect x="#{left}" y="#{top}" width="#{plot_w}" height="#{plot_h}" fill="none" stroke="#{grid}" stroke-width="1"/>)
|
|
254
|
+
if ylo < 0 && yhi > 0
|
|
255
|
+
y0 = sy.call(0).round(2)
|
|
256
|
+
parts << %(<line x1="#{left}" y1="#{y0}" x2="#{left + plot_w}" y2="#{y0}" stroke="#{grid}" stroke-width="1" stroke-dasharray="4 3"/>)
|
|
257
|
+
end
|
|
258
|
+
if xlo < 0 && xhi > 0
|
|
259
|
+
x0 = sx.call(0).round(2)
|
|
260
|
+
parts << %(<line x1="#{x0}" y1="#{top}" x2="#{x0}" y2="#{top + plot_h}" stroke="#{grid}" stroke-width="1" stroke-dasharray="4 3"/>)
|
|
261
|
+
end
|
|
262
|
+
[[xlo, :x], [xhi, :x]].each_with_index do |(value, _), i|
|
|
263
|
+
x = i.zero? ? left : left + plot_w
|
|
264
|
+
anchor = i.zero? ? "start" : "end"
|
|
265
|
+
parts << %(<text x="#{x}" y="#{h - 14}" fill="#{foreground}" font-family="sans-serif" font-size="13" text-anchor="#{anchor}">#{self.class.label(value)}</text>)
|
|
266
|
+
end
|
|
267
|
+
[[yhi, top + 4], [ylo, top + plot_h]].each do |value, y|
|
|
268
|
+
parts << %(<text x="#{left - 8}" y="#{y}" fill="#{foreground}" font-family="sans-serif" font-size="13" text-anchor="end">#{self.class.label(value)}</text>)
|
|
269
|
+
end
|
|
270
|
+
curves.each_with_index do |curve, i|
|
|
271
|
+
color = COLORS[i % COLORS.size]
|
|
272
|
+
if curve.marker == :bar
|
|
273
|
+
base = sy.call([0.0, ylo].max)
|
|
274
|
+
curve.points.each do |x, y|
|
|
275
|
+
x0 = sx.call(x - curve.width / 2.0)
|
|
276
|
+
x1 = sx.call(x + curve.width / 2.0)
|
|
277
|
+
top_y = sy.call(y)
|
|
278
|
+
parts << %(<rect x="#{x0.round(2)}" y="#{top_y.round(2)}" width="#{[(x1 - x0).abs - 1, 1].max.round(2)}" height="#{(base - top_y).abs.round(2)}" fill="#{color}" fill-opacity="0.75" stroke="#{color}"/>)
|
|
279
|
+
end
|
|
280
|
+
elsif curve.marker == :box
|
|
281
|
+
low, q1, median, q3, high = curve.points.first(5).map(&:first)
|
|
282
|
+
y = curve.points.first.last
|
|
283
|
+
middle = sy.call(y)
|
|
284
|
+
half = (middle - sy.call(y + curve.width)).abs
|
|
285
|
+
parts << %(<line x1="#{sx.call(low).round(2)}" y1="#{middle.round(2)}" x2="#{sx.call(high).round(2)}" y2="#{middle.round(2)}" stroke="#{color}" stroke-width="1.5"/>)
|
|
286
|
+
parts << %(<rect x="#{sx.call(q1).round(2)}" y="#{(middle - half).round(2)}" width="#{(sx.call(q3) - sx.call(q1)).abs.round(2)}" height="#{(2 * half).round(2)}" fill="#{color}" fill-opacity="0.2" stroke="#{color}" stroke-width="1.5"/>)
|
|
287
|
+
[[low, 0.6], [high, 0.6], [median, 1.0]].each do |value, scale_factor|
|
|
288
|
+
parts << %(<line x1="#{sx.call(value).round(2)}" y1="#{(middle - half * scale_factor).round(2)}" x2="#{sx.call(value).round(2)}" y2="#{(middle + half * scale_factor).round(2)}" stroke="#{color}" stroke-width="#{value == median ? 2.5 : 1.5}"/>)
|
|
289
|
+
end
|
|
290
|
+
curve.points.drop(5).each { |v, _| parts << %(<circle cx="#{sx.call(v).round(2)}" cy="#{middle.round(2)}" r="3" fill="none" stroke="#{color}"/>) }
|
|
291
|
+
elsif curve.marker
|
|
292
|
+
base = sy.call([0.0, ylo].max).round(2)
|
|
293
|
+
curve.points.compact.select { |_, y| inside?(y) }.each do |x, y|
|
|
294
|
+
cx = sx.call(x).round(2)
|
|
295
|
+
cy = sy.call(y).round(2)
|
|
296
|
+
parts << %(<line x1="#{cx}" y1="#{base}" x2="#{cx}" y2="#{cy}" stroke="#{color}" stroke-width="2"/>) if curve.marker == :stem
|
|
297
|
+
parts << %(<circle cx="#{cx}" cy="#{cy}" r="3.5" fill="#{color}"/>)
|
|
298
|
+
end
|
|
299
|
+
else
|
|
300
|
+
segments(curve).each do |segment|
|
|
301
|
+
points = segment.map { |x, y| "#{sx.call(x).round(2)},#{sy.call([[y, ylo].max, yhi].min).round(2)}" }.join(" ")
|
|
302
|
+
parts << %(<polyline points="#{points}" fill="none" stroke="#{color}" stroke-width="2" stroke-linejoin="round"/>)
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
next unless curve.label
|
|
306
|
+
parts << %(<text x="#{left + 10}" y="#{top + 18 + i * 18}" fill="#{color}" font-family="sans-serif" font-size="13">#{escape(curve.label)}</text>)
|
|
307
|
+
end
|
|
308
|
+
%(<svg xmlns="http://www.w3.org/2000/svg" width="#{w}" height="#{h}" viewBox="0 0 #{w} #{h}">#{parts.join}</svg>)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Pieces of the curve without gaps or jumps across a pole.
|
|
312
|
+
def segments(curve)
|
|
313
|
+
out = []
|
|
314
|
+
current = []
|
|
315
|
+
previous = nil
|
|
316
|
+
curve.points.each do |point|
|
|
317
|
+
if point.nil? || (previous && (jump?(previous, point) || !(inside?(previous[1]) || inside?(point[1]))))
|
|
318
|
+
out << current if current.size > 1
|
|
319
|
+
current = []
|
|
320
|
+
end
|
|
321
|
+
current << point if point
|
|
322
|
+
previous = point
|
|
323
|
+
end
|
|
324
|
+
out << current if current.size > 1
|
|
325
|
+
out
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def escape(text) = text.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">")
|
|
329
|
+
|
|
330
|
+
def to_png(path = nil, theme: nil)
|
|
331
|
+
chrome = Render.which(*Render::KaTeX::CHROME_CANDIDATES)
|
|
332
|
+
raise Error, "a picture needs Google Chrome or Chromium; save(\"plot.svg\") needs nothing" if chrome.nil?
|
|
333
|
+
w, h = svg_size
|
|
334
|
+
bytes = Dir.mktmpdir("rcas-plot") do |dir|
|
|
335
|
+
page = File.join(dir, "plot.html")
|
|
336
|
+
File.write(page, %(<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;padding:0}</style></head><body>#{to_svg(theme: theme)}</body></html>))
|
|
337
|
+
shot = File.join(dir, "plot.png")
|
|
338
|
+
Render.run(chrome, "--headless=new", "--disable-gpu", "--no-sandbox", "--hide-scrollbars", "--no-first-run",
|
|
339
|
+
"--force-device-scale-factor=#{Render.device_scale}", "--window-size=#{w},#{h}",
|
|
340
|
+
"--screenshot=#{shot}", "file://#{page}")
|
|
341
|
+
raise Error, "Chrome produced no picture" unless File.file?(shot)
|
|
342
|
+
File.binread(shot)
|
|
343
|
+
end
|
|
344
|
+
return bytes if path.nil?
|
|
345
|
+
File.binwrite(path, bytes)
|
|
346
|
+
path
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# "plot.svg" needs nothing, "plot.png" needs Chrome.
|
|
350
|
+
def save(path)
|
|
351
|
+
if File.extname(path).downcase == ".png"
|
|
352
|
+
to_png(path)
|
|
353
|
+
else
|
|
354
|
+
File.write(path, to_svg)
|
|
355
|
+
end
|
|
356
|
+
path
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def picture?(io = $stdout) = self.class.pictures?(io)
|
|
360
|
+
|
|
361
|
+
# The picture alone: true when one was written, false when it could not be.
|
|
362
|
+
def picture(io: $stdout)
|
|
363
|
+
return false unless picture?(io)
|
|
364
|
+
io.print(Render.inline_image(to_png, name: "plot.png"))
|
|
365
|
+
io.puts
|
|
366
|
+
true
|
|
367
|
+
rescue Error, Render::Error
|
|
368
|
+
false
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Inline in iTerm2 when the terminal and Chrome allow it, text otherwise.
|
|
372
|
+
def show(io: $stdout)
|
|
373
|
+
io.puts(to_s) unless picture(io: io)
|
|
374
|
+
nil
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# A grid of braille dots.
|
|
378
|
+
class Canvas
|
|
379
|
+
def initialize(width, height)
|
|
380
|
+
@width = width
|
|
381
|
+
@height = height
|
|
382
|
+
@cells = Array.new(width * height, 0)
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def pixel_width = @width * 2
|
|
386
|
+
def pixel_height = @height * 4
|
|
387
|
+
|
|
388
|
+
def set(px, py)
|
|
389
|
+
return if outside?(px, py)
|
|
390
|
+
@cells[(py / 4) * @width + (px / 2)] |= DOTS[px % 2][py % 4]
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# Take a dot away again: what a nearer surface covers (plot3d.rb).
|
|
394
|
+
def unset(px, py)
|
|
395
|
+
return if outside?(px, py)
|
|
396
|
+
@cells[(py / 4) * @width + (px / 2)] &= ~DOTS[px % 2][py % 4]
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def outside?(px, py) = px.negative? || py.negative? || px >= pixel_width || py >= pixel_height
|
|
400
|
+
|
|
401
|
+
def line(x0, y0, x1, y1) = trace(x0, y0, x1, y1) { |px, py| set(px, py) }
|
|
402
|
+
|
|
403
|
+
# Bresenham's line algorithm [Bre65]. The pixels are yielded rather
|
|
404
|
+
# than set, because plot3d.rb rubs a line out along the same path.
|
|
405
|
+
def trace(x0, y0, x1, y1)
|
|
406
|
+
dx = (x1 - x0).abs
|
|
407
|
+
dy = -(y1 - y0).abs
|
|
408
|
+
sx = x0 < x1 ? 1 : -1
|
|
409
|
+
sy = y0 < y1 ? 1 : -1
|
|
410
|
+
error = dx + dy
|
|
411
|
+
x = x0
|
|
412
|
+
y = y0
|
|
413
|
+
loop do
|
|
414
|
+
yield(x, y)
|
|
415
|
+
break if x == x1 && y == y1
|
|
416
|
+
double = 2 * error
|
|
417
|
+
if double >= dy
|
|
418
|
+
error += dy
|
|
419
|
+
x += sx
|
|
420
|
+
end
|
|
421
|
+
if double <= dx
|
|
422
|
+
error += dx
|
|
423
|
+
y += sy
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def rows
|
|
429
|
+
(0...@height).map do |cy|
|
|
430
|
+
(0...@width).map { |cx| (BRAILLE_BLANK + @cells[cy * @width + cx]).chr(Encoding::UTF_8) }.join
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# Building plots from expressions, distributions and data.
|
|
437
|
+
module Plotting
|
|
438
|
+
SAMPLES = 400
|
|
439
|
+
|
|
440
|
+
module_function
|
|
441
|
+
|
|
442
|
+
# plot(f, x: -5..5), plot([f, g], x: 0..1), plot(f, :t, -1, 1)
|
|
443
|
+
def plot(f, var = nil, from = nil, to = nil, x: nil, y: nil, n: SAMPLES, title: nil, labels: nil, width: 60, height: 15)
|
|
444
|
+
functions = f.is_a?(Array) ? f : [f]
|
|
445
|
+
return distribution_plot(functions, var, from, to, x, y, n, title, labels, width, height) if functions.first.is_a?(Distributions::Distribution)
|
|
446
|
+
functions = functions.map { |g| Expression.lift(g) }
|
|
447
|
+
variable = plot_variable(functions, var, x)
|
|
448
|
+
lo, hi = plot_range(var, from, to, x)
|
|
449
|
+
curves = functions.each_with_index.map do |g, i|
|
|
450
|
+
Curve_for(g, variable, lo, hi, n, labels ? labels[i] : (functions.size > 1 ? g.to_s : nil))
|
|
451
|
+
end
|
|
452
|
+
ylo, yhi = y_range(curves, y)
|
|
453
|
+
notes = functions.flat_map { |g| Analysis.root_hints(g, variable) }.uniq
|
|
454
|
+
notes = notes.select { |_| lo.negative? } # only where the picture reaches the missing part
|
|
455
|
+
Plot.new(curves, var: variable, xlo: lo, xhi: hi, ylo: ylo, yhi: yhi, title: title, width: width, height: height, notes: notes)
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
def Curve_for(g, variable, lo, hi, n, label)
|
|
459
|
+
Plot::Curve.new(label, sample(g, variable, lo, hi, n), false)
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
# The density of a distribution over a sensible range.
|
|
463
|
+
def distribution_plot(distributions, var, from, to, x, y, n, title, labels, width, height)
|
|
464
|
+
variable = Var.new(:x)
|
|
465
|
+
lo, hi = if x || from then plot_range(var, from, to, x) else natural_range(distributions) end
|
|
466
|
+
discrete = distributions.first.discrete?
|
|
467
|
+
curves = distributions.each_with_index.map do |d, i|
|
|
468
|
+
label = labels ? labels[i] : (distributions.size > 1 ? d.to_s : nil)
|
|
469
|
+
if discrete
|
|
470
|
+
points = (lo.ceil..hi.floor).map { |k| [k.to_f, numeric(d.pdf(k))] }.select { |_, v| v }
|
|
471
|
+
Plot::Curve.new(label, points, :stem)
|
|
472
|
+
else
|
|
473
|
+
Plot::Curve.new(label, sample(d.pdf(variable), variable, lo, hi, n), false)
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
ylo, yhi = y_range(curves, y)
|
|
477
|
+
ylo = 0.0 if ylo > 0
|
|
478
|
+
Plot.new(curves, var: variable, xlo: lo, xhi: hi, ylo: ylo, yhi: yhi,
|
|
479
|
+
title: title || (distributions.size == 1 ? distributions.first.to_s : nil), width: width, height: height)
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
# Mean plus or minus four standard deviations, kept inside the support.
|
|
483
|
+
def natural_range(distributions)
|
|
484
|
+
los = []
|
|
485
|
+
his = []
|
|
486
|
+
distributions.each do |d|
|
|
487
|
+
mean = numeric(d.mean)
|
|
488
|
+
spread = numeric(d.stdev)
|
|
489
|
+
raise Plot::Error, "plot: #{d} has no numeric mean and spread; give a range" if mean.nil? || spread.nil? || spread.zero?
|
|
490
|
+
low = mean - 4 * spread
|
|
491
|
+
high = mean + 4 * spread
|
|
492
|
+
bottom = numeric(d.support.first)
|
|
493
|
+
top = numeric(d.support.last)
|
|
494
|
+
low = bottom if bottom && bottom > low
|
|
495
|
+
high = top if top && top < high
|
|
496
|
+
los << low
|
|
497
|
+
his << high
|
|
498
|
+
end
|
|
499
|
+
[los.min, his.max]
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# A curve given by two components: parametric([cos(t), sin(t)], t: 0..2*PI).
|
|
503
|
+
# The points are joined in the order the parameter runs through them, so
|
|
504
|
+
# the curve may loop and cross itself.
|
|
505
|
+
def parametric(pair, var = nil, from = nil, to = nil, n: SAMPLES, title: nil, label: nil, x: nil, y: nil, width: 60, height: 15, **range)
|
|
506
|
+
raise ArgumentError, "parametric: two components are needed, [x(t), y(t)]" unless pair.is_a?(Array) && pair.size == 2
|
|
507
|
+
var, from, to = Functions.range_arguments(var, from, to, range, "parametric", discrete: false)
|
|
508
|
+
variable = Expression.lift(var)
|
|
509
|
+
lo = numeric(from)
|
|
510
|
+
hi = numeric(to)
|
|
511
|
+
raise ArgumentError, "parametric: the range needs two finite ends" if lo.nil? || hi.nil?
|
|
512
|
+
components = pair.map { |c| Expression.lift(c) }
|
|
513
|
+
points = (0..n).map do |i|
|
|
514
|
+
t = lo + (hi - lo) * i / n.to_f
|
|
515
|
+
values = components.map { |c| evaluate(c, variable.name, t) }
|
|
516
|
+
values.all? ? values : nil
|
|
517
|
+
end
|
|
518
|
+
curve = Plot::Curve.new(label, points, false)
|
|
519
|
+
drawn = points.compact
|
|
520
|
+
raise ArgumentError, "parametric: nothing to draw" if drawn.empty?
|
|
521
|
+
xlo, xhi = x ? [numeric(x.begin), numeric(x.end)] : padded(drawn.map(&:first))
|
|
522
|
+
ylo, yhi = y_range([curve], y)
|
|
523
|
+
Plot.new([curve], var: Var.new(:x), xlo: xlo, xhi: xhi, ylo: ylo, yhi: yhi, title: title, width: width, height: height)
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
# A curve in polar coordinates: polar(1 + cos(t), t: 0..2*PI), drawn as
|
|
527
|
+
# the parametric curve (r*cos(t), r*sin(t)). The angle runs over a full
|
|
528
|
+
# turn unless another range is given.
|
|
529
|
+
def polar(r, var = nil, from = nil, to = nil, **opts)
|
|
530
|
+
r = Expression.lift(r)
|
|
531
|
+
unless opts.empty? || opts.keys.none? { |k| opts[k].is_a?(Range) }
|
|
532
|
+
key = opts.keys.find { |k| opts[k].is_a?(Range) }
|
|
533
|
+
span = opts.delete(key)
|
|
534
|
+
var ||= key
|
|
535
|
+
from = span.begin
|
|
536
|
+
to = span.end
|
|
537
|
+
end
|
|
538
|
+
var ||= r.variables.first || :theta
|
|
539
|
+
variable = Expression.lift(var)
|
|
540
|
+
angle = [Fn.new(:cos, [variable]), Fn.new(:sin, [variable])]
|
|
541
|
+
parametric(angle.map { |c| (r * c).simplify }, variable, from || Num.new(0), to || (2 * PI).simplify, **opts)
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
# Points of data: scatter([1, 2], [3, 4]) or scatter([[1, 3], [2, 4]])
|
|
545
|
+
def scatter(xs, ys = nil, fit: false, title: nil, label: nil, x: nil, y: nil, width: 60, height: 15)
|
|
546
|
+
given = if ys.nil?
|
|
547
|
+
xs.map { |p| [p[0], p[1]] }
|
|
548
|
+
else
|
|
549
|
+
raise ArgumentError, "scatter: the lists must have the same length" unless xs.size == ys.size
|
|
550
|
+
xs.zip(ys)
|
|
551
|
+
end
|
|
552
|
+
given = given.select { |a, b| numeric(a) && numeric(b) }
|
|
553
|
+
pairs = given.map { |a, b| [numeric(a), numeric(b)] }
|
|
554
|
+
raise ArgumentError, "scatter: no points to draw" if pairs.empty?
|
|
555
|
+
lo, hi = x ? [numeric(x.begin), numeric(x.end)] : padded(pairs.map(&:first))
|
|
556
|
+
curves = [Plot::Curve.new(label, pairs, :dot)]
|
|
557
|
+
if fit # the least squares line of section 1.9
|
|
558
|
+
line = Statistics.linreg(given.map(&:first), given.map(&:last), :x)
|
|
559
|
+
curves << Plot::Curve.new(line.to_s, sample(line, Var.new(:x), lo, hi, 2), false)
|
|
560
|
+
end
|
|
561
|
+
ylo, yhi = y ? [numeric(y.begin), numeric(y.end)] : padded(curves.flat_map { |c| c.points.compact.map(&:last) })
|
|
562
|
+
Plot.new(curves, var: Var.new(:x), xlo: lo, xhi: hi, ylo: ylo, yhi: yhi, title: title, width: width, height: height)
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
# ---- statistical plots -------------------------------------------------------
|
|
566
|
+
|
|
567
|
+
# histogram(data, bins: 8): counts per bin, or shares with density: true.
|
|
568
|
+
# The default number of bins is Sturges' rule [Stu26].
|
|
569
|
+
def histogram(data, bins: nil, density: false, title: nil, label: nil, x: nil, y: nil, width: 60, height: 15)
|
|
570
|
+
values = Statistics.data(data, "histogram").map { |v| numeric(v) }.compact
|
|
571
|
+
raise ArgumentError, "histogram: no numeric data" if values.empty?
|
|
572
|
+
lo, hi = x ? [numeric(x.begin), numeric(x.end)] : [values.min, values.max]
|
|
573
|
+
lo, hi = [lo - 0.5, hi + 0.5] if (hi - lo).abs < 1e-12
|
|
574
|
+
count = bins || [[Math.log2(values.size).ceil + 1, 1].max, 50].min
|
|
575
|
+
raise ArgumentError, "histogram: bins must be a positive integer" unless count.is_a?(Integer) && count.positive?
|
|
576
|
+
step = (hi - lo) / count
|
|
577
|
+
counts = Array.new(count, 0)
|
|
578
|
+
values.each do |v|
|
|
579
|
+
next if v < lo || v > hi
|
|
580
|
+
index = [((v - lo) / step).floor, count - 1].min
|
|
581
|
+
counts[index] += 1
|
|
582
|
+
end
|
|
583
|
+
heights = density ? counts.map { |c| c.to_f / values.size } : counts.map(&:to_f)
|
|
584
|
+
points = counts.each_index.map { |i| [lo + (i + 0.5) * step, heights[i]] }
|
|
585
|
+
curve = Plot::Curve.new(label, points, :bar, step)
|
|
586
|
+
top = y ? numeric(y.end) : Plot.head_room(heights)
|
|
587
|
+
bottom = y ? numeric(y.begin) : 0.0
|
|
588
|
+
Plot.new([curve], var: Var.new(:x), xlo: lo, xhi: hi, ylo: bottom, yhi: top <= bottom ? bottom + 1 : top,
|
|
589
|
+
title: title, width: width, height: height)
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
# boxplot(data), boxplot([xs, ys]) or boxplot("before" => xs, "after" => ys):
|
|
593
|
+
# median, quartiles, whiskers to the last value within 1.5 interquartile
|
|
594
|
+
# ranges and the remaining values as outliers [Tuk77].
|
|
595
|
+
def boxplot(data, title: nil, x: nil, width: 60, height: nil)
|
|
596
|
+
series = series_of(data, "boxplot")
|
|
597
|
+
boxes = series.map do |name, values|
|
|
598
|
+
numbers = values.map { |v| numeric(v) }.compact.sort
|
|
599
|
+
raise ArgumentError, "boxplot: #{name || 'the data'} has no numeric values" if numbers.empty?
|
|
600
|
+
[name, numbers, five_numbers(numbers)]
|
|
601
|
+
end
|
|
602
|
+
all = boxes.flat_map { |_, numbers, _| numbers }
|
|
603
|
+
lo, hi = x ? [numeric(x.begin), numeric(x.end)] : padded(all)
|
|
604
|
+
curves = boxes.each_with_index.map do |(name, numbers, summary), i|
|
|
605
|
+
y = boxes.size - i
|
|
606
|
+
low, q1, median, q3, high = summary
|
|
607
|
+
outliers = numbers.reject { |v| v.between?(low, high) }.map { |v| [v, y] }
|
|
608
|
+
Plot::Curve.new(nil, [[low, y], [q1, y], [median, y], [q3, y], [high, y], *outliers], :box, 0.22)
|
|
609
|
+
end
|
|
610
|
+
labels = boxes.each_with_index.to_h { |(name, _, _), i| [boxes.size - i, name.to_s] }
|
|
611
|
+
Plot.new(curves, var: Var.new(:x), xlo: lo, xhi: hi, ylo: 0.4, yhi: boxes.size + 0.6,
|
|
612
|
+
title: title, width: width, height: height || [4 * boxes.size + 2, 6].max, ylabels: labels)
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
# [whisker low, q1, median, q3, whisker high]
|
|
616
|
+
def five_numbers(sorted)
|
|
617
|
+
q1 = numeric(Statistics.quantile(sorted, Rational(1, 4)))
|
|
618
|
+
median = numeric(Statistics.median(sorted))
|
|
619
|
+
q3 = numeric(Statistics.quantile(sorted, Rational(3, 4)))
|
|
620
|
+
reach = 1.5 * (q3 - q1)
|
|
621
|
+
low = sorted.find { |v| v >= q1 - reach } || sorted.first
|
|
622
|
+
high = sorted.reverse.find { |v| v <= q3 + reach } || sorted.last
|
|
623
|
+
[low, q1, median, q3, high]
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
# barchart(frequencies(data)), barchart({"apples" => 3, "pears" => 5}) or
|
|
627
|
+
# barchart(%w[a b], [3, 5]): one bar per category.
|
|
628
|
+
def barchart(categories, counts = nil, title: nil, label: nil, y: nil, width: 60, height: 15)
|
|
629
|
+
pairs = if counts
|
|
630
|
+
raise ArgumentError, "barchart: the lists must have the same length" unless categories.size == counts.size
|
|
631
|
+
categories.zip(counts)
|
|
632
|
+
else
|
|
633
|
+
categories.to_a
|
|
634
|
+
end
|
|
635
|
+
raise ArgumentError, "barchart: no categories" if pairs.empty?
|
|
636
|
+
heights = pairs.map { |_, c| numeric(c) or raise ArgumentError, "barchart: #{c} is not a number" }
|
|
637
|
+
points = heights.each_with_index.map { |h, i| [i + 1.0, h] }
|
|
638
|
+
curve = Plot::Curve.new(label, points, :bar, 0.72)
|
|
639
|
+
top = y ? numeric(y.end) : Plot.head_room(heights)
|
|
640
|
+
Plot.new([curve], var: Var.new(:x), xlo: 0.4, xhi: pairs.size + 0.6, ylo: y ? numeric(y.begin) : 0.0,
|
|
641
|
+
yhi: top.positive? ? top : 1.0, title: title, width: width, height: height,
|
|
642
|
+
xlabels: pairs.each_with_index.map { |(name, _), i| [i + 1.0, name.to_s] })
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
# A hash of named series, a list of series, or one series.
|
|
646
|
+
def series_of(data, name)
|
|
647
|
+
case data
|
|
648
|
+
when Hash then data.map { |k, v| [k, Statistics.data(v, name)] }
|
|
649
|
+
when Array
|
|
650
|
+
if data.first.is_a?(Array)
|
|
651
|
+
data.each_with_index.map { |values, i| [(i + 1).to_s, Statistics.data(values, name)] }
|
|
652
|
+
else
|
|
653
|
+
[["", Statistics.data(data, name)]]
|
|
654
|
+
end
|
|
655
|
+
else raise ArgumentError, "#{name}: give a list, a list of lists or a hash of named lists"
|
|
656
|
+
end
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
def padded(values)
|
|
660
|
+
lo = values.min
|
|
661
|
+
hi = values.max
|
|
662
|
+
return [lo - 1, hi + 1] if (hi - lo).abs < 1e-12
|
|
663
|
+
pad = (hi - lo) * 0.05
|
|
664
|
+
[lo - pad, hi + pad]
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
def plot_variable(functions, var, range)
|
|
668
|
+
name = var || (range.is_a?(Hash) ? range.keys.first : nil)
|
|
669
|
+
return Expression.lift(name) if name
|
|
670
|
+
names = functions.flat_map(&:variables).uniq
|
|
671
|
+
raise ArgumentError, "plot: #{functions.first} has no variable to plot over" if names.empty?
|
|
672
|
+
raise ArgumentError, "plot: which variable? give a range, e.g. plot(f, x: -1..1)" if names.size > 1
|
|
673
|
+
Var.new(names.first)
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
def plot_range(var, from, to, range)
|
|
677
|
+
if range
|
|
678
|
+
r = range.is_a?(Hash) ? range.values.first : range
|
|
679
|
+
raise ArgumentError, "plot: expected a range, e.g. x: -5..5" unless r.is_a?(Range)
|
|
680
|
+
from = r.begin
|
|
681
|
+
to = r.end
|
|
682
|
+
end
|
|
683
|
+
return [DEFAULT_LOW, DEFAULT_HIGH] if from.nil? && to.nil?
|
|
684
|
+
lo = numeric(from)
|
|
685
|
+
hi = numeric(to)
|
|
686
|
+
raise ArgumentError, "plot: the range must be finite, got #{from}..#{to}" if lo.nil? || hi.nil?
|
|
687
|
+
raise ArgumentError, "plot: the range is empty" if hi <= lo
|
|
688
|
+
[lo, hi]
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
DEFAULT_LOW = Plot::DEFAULT_RANGE.begin
|
|
692
|
+
DEFAULT_HIGH = Plot::DEFAULT_RANGE.end
|
|
693
|
+
|
|
694
|
+
def sample(g, variable, lo, hi, n)
|
|
695
|
+
tree = Expression.floatify_tree(Expression.lift(g))
|
|
696
|
+
name = variable.name
|
|
697
|
+
(0..n).map do |i|
|
|
698
|
+
x = lo + (hi - lo) * i / n.to_f
|
|
699
|
+
v = evaluate(tree, name, x)
|
|
700
|
+
v ? [x, v] : nil
|
|
701
|
+
end
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
def evaluate(tree, name, x)
|
|
705
|
+
v = tree.call(name => x)
|
|
706
|
+
v = v.value if v.is_a?(Num)
|
|
707
|
+
return nil unless v.is_a?(Numeric) && !v.is_a?(Complex)
|
|
708
|
+
v = v.to_f
|
|
709
|
+
v.finite? ? v : nil
|
|
710
|
+
rescue StandardError => rescued
|
|
711
|
+
RCAS.guard!(rescued)
|
|
712
|
+
nil
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
def numeric(value)
|
|
716
|
+
return nil if value.nil?
|
|
717
|
+
v = value.is_a?(Numeric) ? value : Expression.lift(value).evalf
|
|
718
|
+
v.is_a?(Numeric) && !v.is_a?(Complex) && v.finite? ? v.to_f : nil
|
|
719
|
+
rescue StandardError => rescued
|
|
720
|
+
RCAS.guard!(rescued)
|
|
721
|
+
nil
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
# The y range: the one given, or the one the samples ask for.
|
|
725
|
+
def y_range(curves, given)
|
|
726
|
+
if given
|
|
727
|
+
lo = numeric(given.begin)
|
|
728
|
+
hi = numeric(given.end)
|
|
729
|
+
raise ArgumentError, "plot: the y range must be finite" if lo.nil? || hi.nil?
|
|
730
|
+
return [lo, hi]
|
|
731
|
+
end
|
|
732
|
+
values = curves.flat_map { |c| c.points.compact.map(&:last) }.sort
|
|
733
|
+
raise Plot::Error, "plot: the function has no finite values in this range" if values.empty?
|
|
734
|
+
autoscale(values)
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
# The scale a sorted list of values deserves, cut back to Tukey's
|
|
738
|
+
# far-out fence when a pole would otherwise flatten the picture: a few
|
|
739
|
+
# samples beside a pole are arbitrarily large, and they must not decide
|
|
740
|
+
# the scale of the rest. The fence is wider than the data for a
|
|
741
|
+
# well-behaved function, so taking it together with the true range
|
|
742
|
+
# leaves that one alone. plot3d.rb asks the same of each of its axes.
|
|
743
|
+
def autoscale(values)
|
|
744
|
+
lo = values.first
|
|
745
|
+
hi = values.last
|
|
746
|
+
if values.size > 20
|
|
747
|
+
q1 = values[(values.size * 0.25).floor]
|
|
748
|
+
q3 = values[(values.size * 0.75).floor]
|
|
749
|
+
spread = q3 - q1
|
|
750
|
+
if spread.positive? && (hi - lo) > 20 * spread
|
|
751
|
+
lo = [lo, q1 - 3 * spread].max
|
|
752
|
+
hi = [hi, q3 + 3 * spread].min
|
|
753
|
+
end
|
|
754
|
+
end
|
|
755
|
+
if (hi - lo).abs < 1e-12
|
|
756
|
+
[lo - 1, hi + 1]
|
|
757
|
+
else
|
|
758
|
+
pad = (hi - lo) * 0.05
|
|
759
|
+
[lo - pad, hi + pad]
|
|
760
|
+
end
|
|
761
|
+
end
|
|
762
|
+
end
|
|
763
|
+
end
|