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.
Files changed (117) hide show
  1. checksums.yaml +7 -0
  2. data/CITATION.cff +17 -0
  3. data/DESIGN.md +783 -0
  4. data/LICENSE +21 -0
  5. data/MANUAL.md +6265 -0
  6. data/README.md +267 -0
  7. data/bin/rcas +9 -0
  8. data/bin/rcas-app +9 -0
  9. data/bin/rcas-chat +9 -0
  10. data/lib/rcas/algebraic.rb +481 -0
  11. data/lib/rcas/analysis.rb +966 -0
  12. data/lib/rcas/app/launcher.rb +203 -0
  13. data/lib/rcas/app/public/app.css +402 -0
  14. data/lib/rcas/app/public/app.js +449 -0
  15. data/lib/rcas/app/public/index.html +46 -0
  16. data/lib/rcas/app/server.rb +220 -0
  17. data/lib/rcas/app/window.rb +94 -0
  18. data/lib/rcas/app/worksheet.rb +290 -0
  19. data/lib/rcas/app.rb +168 -0
  20. data/lib/rcas/background.rb +758 -0
  21. data/lib/rcas/chat/assistant.rb +199 -0
  22. data/lib/rcas/chat/picker.rb +164 -0
  23. data/lib/rcas/chat/repl.rb +583 -0
  24. data/lib/rcas/chat/session.rb +137 -0
  25. data/lib/rcas/chat/settings.rb +71 -0
  26. data/lib/rcas/chat/style.rb +30 -0
  27. data/lib/rcas/chat/tool.rb +53 -0
  28. data/lib/rcas/chat/ui.rb +316 -0
  29. data/lib/rcas/chat/usage.rb +62 -0
  30. data/lib/rcas/chat/workspace.rb +132 -0
  31. data/lib/rcas/chat.rb +54 -0
  32. data/lib/rcas/coefficients.rb +170 -0
  33. data/lib/rcas/combinatorics.rb +274 -0
  34. data/lib/rcas/complex_parts.rb +160 -0
  35. data/lib/rcas/constants.rb +129 -0
  36. data/lib/rcas/core_ext.rb +35 -0
  37. data/lib/rcas/decide.rb +501 -0
  38. data/lib/rcas/decompositions.rb +241 -0
  39. data/lib/rcas/differentiate.rb +144 -0
  40. data/lib/rcas/discussion.rb +558 -0
  41. data/lib/rcas/distributions.rb +980 -0
  42. data/lib/rcas/dixon.rb +95 -0
  43. data/lib/rcas/docs.rb +321 -0
  44. data/lib/rcas/domains.rb +728 -0
  45. data/lib/rcas/expand.rb +174 -0
  46. data/lib/rcas/expression.rb +613 -0
  47. data/lib/rcas/factor.rb +605 -0
  48. data/lib/rcas/finite_field.rb +577 -0
  49. data/lib/rcas/fourier.rb +118 -0
  50. data/lib/rcas/fps.rb +678 -0
  51. data/lib/rcas/fraction.rb +126 -0
  52. data/lib/rcas/functions.rb +1136 -0
  53. data/lib/rcas/gcd.rb +112 -0
  54. data/lib/rcas/geometry.rb +266 -0
  55. data/lib/rcas/groebner.rb +162 -0
  56. data/lib/rcas/hold.rb +277 -0
  57. data/lib/rcas/hypothesis.rb +364 -0
  58. data/lib/rcas/inequalities.rb +689 -0
  59. data/lib/rcas/integral_functions.rb +260 -0
  60. data/lib/rcas/integrate.rb +1589 -0
  61. data/lib/rcas/integrate_substitutions.rb +434 -0
  62. data/lib/rcas/interpolate.rb +40 -0
  63. data/lib/rcas/irb.rb +146 -0
  64. data/lib/rcas/laplace.rb +159 -0
  65. data/lib/rcas/latex.rb +556 -0
  66. data/lib/rcas/lattice.rb +172 -0
  67. data/lib/rcas/linear_algebra.rb +117 -0
  68. data/lib/rcas/linear_program.rb +416 -0
  69. data/lib/rcas/lint.rb +79 -0
  70. data/lib/rcas/matrix.rb +531 -0
  71. data/lib/rcas/matrix_multiply.rb +202 -0
  72. data/lib/rcas/multimodular.rb +286 -0
  73. data/lib/rcas/named_polynomials.rb +274 -0
  74. data/lib/rcas/number_theory.rb +443 -0
  75. data/lib/rcas/numerics.rb +825 -0
  76. data/lib/rcas/ode.rb +488 -0
  77. data/lib/rcas/openmath/objects.rb +364 -0
  78. data/lib/rcas/openmath/phrasebook.rb +551 -0
  79. data/lib/rcas/openmath/popcorn.rb +518 -0
  80. data/lib/rcas/openmath/xml.rb +309 -0
  81. data/lib/rcas/openmath.rb +49 -0
  82. data/lib/rcas/petkovsek.rb +165 -0
  83. data/lib/rcas/piecewise.rb +488 -0
  84. data/lib/rcas/plot.rb +763 -0
  85. data/lib/rcas/plot3d.rb +419 -0
  86. data/lib/rcas/poly_matrix.rb +318 -0
  87. data/lib/rcas/poly_recurrence.rb +117 -0
  88. data/lib/rcas/polynomial.rb +466 -0
  89. data/lib/rcas/precision.rb +925 -0
  90. data/lib/rcas/printer.rb +150 -0
  91. data/lib/rcas/product.rb +155 -0
  92. data/lib/rcas/q_difference.rb +296 -0
  93. data/lib/rcas/q_functions.rb +158 -0
  94. data/lib/rcas/q_summation.rb +308 -0
  95. data/lib/rcas/q_zeilberger.rb +199 -0
  96. data/lib/rcas/random.rb +506 -0
  97. data/lib/rcas/rational_function.rb +186 -0
  98. data/lib/rcas/recurrence.rb +323 -0
  99. data/lib/rcas/render.rb +431 -0
  100. data/lib/rcas/results.rb +192 -0
  101. data/lib/rcas/scalar.rb +219 -0
  102. data/lib/rcas/series.rb +726 -0
  103. data/lib/rcas/simplify.rb +649 -0
  104. data/lib/rcas/solve.rb +2002 -0
  105. data/lib/rcas/special.rb +163 -0
  106. data/lib/rcas/statistics.rb +175 -0
  107. data/lib/rcas/steps.rb +835 -0
  108. data/lib/rcas/summation.rb +532 -0
  109. data/lib/rcas/trig.rb +264 -0
  110. data/lib/rcas/van_hoeij.rb +241 -0
  111. data/lib/rcas/vector.rb +175 -0
  112. data/lib/rcas/vector_calculus.rb +411 -0
  113. data/lib/rcas/version.rb +5 -0
  114. data/lib/rcas/zeilberger.rb +358 -0
  115. data/lib/rcas.rb +91 -0
  116. data/package.json +8 -0
  117. 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("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
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