rvgp 0.3.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +8 -0
- data/.rubocop.yml +23 -0
- data/LICENSE +504 -0
- data/README.md +223 -0
- data/Rakefile +32 -0
- data/bin/rvgp +8 -0
- data/lib/rvgp/application/config.rb +159 -0
- data/lib/rvgp/application/descendant_registry.rb +122 -0
- data/lib/rvgp/application/status_output.rb +139 -0
- data/lib/rvgp/application.rb +170 -0
- data/lib/rvgp/base/command.rb +457 -0
- data/lib/rvgp/base/grid.rb +531 -0
- data/lib/rvgp/base/reader.rb +29 -0
- data/lib/rvgp/base/reconciler.rb +434 -0
- data/lib/rvgp/base/validation.rb +261 -0
- data/lib/rvgp/commands/cashflow.rb +160 -0
- data/lib/rvgp/commands/grid.rb +70 -0
- data/lib/rvgp/commands/ireconcile.rb +95 -0
- data/lib/rvgp/commands/new_project.rb +296 -0
- data/lib/rvgp/commands/plot.rb +41 -0
- data/lib/rvgp/commands/publish_gsheets.rb +83 -0
- data/lib/rvgp/commands/reconcile.rb +58 -0
- data/lib/rvgp/commands/rotate_year.rb +202 -0
- data/lib/rvgp/commands/validate_journal.rb +59 -0
- data/lib/rvgp/commands/validate_system.rb +44 -0
- data/lib/rvgp/commands.rb +160 -0
- data/lib/rvgp/dashboard.rb +252 -0
- data/lib/rvgp/fakers/fake_feed.rb +245 -0
- data/lib/rvgp/fakers/fake_journal.rb +57 -0
- data/lib/rvgp/fakers/fake_reconciler.rb +88 -0
- data/lib/rvgp/fakers/faker_helpers.rb +25 -0
- data/lib/rvgp/gem.rb +80 -0
- data/lib/rvgp/journal/commodity.rb +453 -0
- data/lib/rvgp/journal/complex_commodity.rb +214 -0
- data/lib/rvgp/journal/currency.rb +101 -0
- data/lib/rvgp/journal/journal.rb +141 -0
- data/lib/rvgp/journal/posting.rb +156 -0
- data/lib/rvgp/journal/pricer.rb +267 -0
- data/lib/rvgp/journal.rb +24 -0
- data/lib/rvgp/plot/gnuplot.rb +478 -0
- data/lib/rvgp/plot/google-drive/output_csv.rb +44 -0
- data/lib/rvgp/plot/google-drive/output_google_sheets.rb +434 -0
- data/lib/rvgp/plot/google-drive/sheet.rb +67 -0
- data/lib/rvgp/plot.rb +293 -0
- data/lib/rvgp/pta/hledger.rb +237 -0
- data/lib/rvgp/pta/ledger.rb +308 -0
- data/lib/rvgp/pta.rb +311 -0
- data/lib/rvgp/reconcilers/csv_reconciler.rb +424 -0
- data/lib/rvgp/reconcilers/journal_reconciler.rb +41 -0
- data/lib/rvgp/reconcilers/shorthand/finance_gem_hacks.rb +48 -0
- data/lib/rvgp/reconcilers/shorthand/international_atm.rb +152 -0
- data/lib/rvgp/reconcilers/shorthand/investment.rb +144 -0
- data/lib/rvgp/reconcilers/shorthand/mortgage.rb +195 -0
- data/lib/rvgp/utilities/grid_query.rb +190 -0
- data/lib/rvgp/utilities/yaml.rb +131 -0
- data/lib/rvgp/utilities.rb +44 -0
- data/lib/rvgp/validations/balance_validation.rb +68 -0
- data/lib/rvgp/validations/duplicate_tags_validation.rb +48 -0
- data/lib/rvgp/validations/uncategorized_validation.rb +15 -0
- data/lib/rvgp.rb +66 -0
- data/resources/README.MD/2022-cashflow-google.png +0 -0
- data/resources/README.MD/2022-cashflow.png +0 -0
- data/resources/README.MD/all-wealth-growth-google.png +0 -0
- data/resources/README.MD/all-wealth-growth.png +0 -0
- data/resources/gnuplot/default.yml +80 -0
- data/resources/i18n/en.yml +192 -0
- data/resources/iso-4217-currencies.json +171 -0
- data/resources/skel/Rakefile +5 -0
- data/resources/skel/app/grids/cashflow_grid.rb +27 -0
- data/resources/skel/app/grids/monthly_income_and_expenses_grid.rb +25 -0
- data/resources/skel/app/grids/wealth_growth_grid.rb +35 -0
- data/resources/skel/app/plots/cashflow.yml +33 -0
- data/resources/skel/app/plots/monthly-income-and-expenses.yml +17 -0
- data/resources/skel/app/plots/wealth-growth.yml +20 -0
- data/resources/skel/config/csv-format-acme-checking.yml +9 -0
- data/resources/skel/config/google-secrets.yml +5 -0
- data/resources/skel/config/rvgp.yml +0 -0
- data/resources/skel/journals/prices.db +0 -0
- data/rvgp.gemspec +6 -0
- data/test/assets/ledger_total_monthly_liabilities_with_empty.xml +383 -0
- data/test/assets/ledger_total_monthly_liabilities_with_empty2.xml +428 -0
- data/test/test_command_base.rb +61 -0
- data/test/test_commodity.rb +270 -0
- data/test/test_csv_reconciler.rb +60 -0
- data/test/test_currency.rb +24 -0
- data/test/test_fake_feed.rb +228 -0
- data/test/test_fake_journal.rb +98 -0
- data/test/test_fake_reconciler.rb +60 -0
- data/test/test_journal_parse.rb +545 -0
- data/test/test_ledger.rb +102 -0
- data/test/test_plot.rb +133 -0
- data/test/test_posting.rb +50 -0
- data/test/test_pricer.rb +139 -0
- data/test/test_pta_adapter.rb +575 -0
- data/test/test_utilities.rb +45 -0
- metadata +268 -0
data/lib/rvgp/journal.rb
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'journal/journal'
|
4
|
+
require_relative 'journal/currency'
|
5
|
+
require_relative 'journal/commodity'
|
6
|
+
require_relative 'journal/complex_commodity'
|
7
|
+
require_relative 'journal/posting'
|
8
|
+
|
9
|
+
# Extensions to the ruby stdlib implementation of String. Offered as a convenience.
|
10
|
+
class String
|
11
|
+
# Given a string, such as "$ 20.57", or "1 MERCEDESBENZ", Construct and return a {RVGP::Journal::Commodity}
|
12
|
+
# representation.
|
13
|
+
# see {RVGP::Journal::Commodity.from_s}
|
14
|
+
# @return [RVGP::Journal::Commodity] the parsed string
|
15
|
+
def to_commodity
|
16
|
+
RVGP::Journal::Commodity.from_s self
|
17
|
+
end
|
18
|
+
|
19
|
+
# Parse a string, into a {RVGP::Journal::Posting::Tag} object
|
20
|
+
# see {RVGP::Journal::Posting::Tag.from_s}
|
21
|
+
def to_tag
|
22
|
+
RVGP::Journal::Posting::Tag.from_s self
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,478 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'open3'
|
4
|
+
|
5
|
+
module RVGP
|
6
|
+
class Plot
|
7
|
+
# This module contains the code needed to produce Gnuplot .gpi files, from a grid, and
|
8
|
+
# styling options.
|
9
|
+
module Gnuplot
|
10
|
+
# Palette(s) are loaded from a template, and contain logic related to coloring
|
11
|
+
# base elements (fonts/background/line-colors/etc), as well as relating to
|
12
|
+
# series/element colors in the plot.
|
13
|
+
class Palette
|
14
|
+
# @param [Hash] opts The options to configure this palette with
|
15
|
+
# @option opts [Hash<Symbol, String>] :base The base colors for this plot. Currently, the following base colors
|
16
|
+
# are supported: :title_rgb, :background_rgb, :font_rgb, :grid_rgb,
|
17
|
+
# :axis_rgb, :key_text_rgb . This option expects keys to be one of the
|
18
|
+
# supported colors, and values to be in the 'standard' html color
|
19
|
+
# format, resembling "#rrggbb" (with rr, gg, and bb being a
|
20
|
+
# hexadecimal color code)
|
21
|
+
#
|
22
|
+
# @option opts [Array<String>] :series An array of colors, in the 'standard' html color format. There is no
|
23
|
+
# limit to the size of this array.
|
24
|
+
def initialize(opts = {})
|
25
|
+
@series_colors = opts[:series]
|
26
|
+
@base_colors = opts[:base]
|
27
|
+
@last_series_color = -1
|
28
|
+
@last_series_direction = 1
|
29
|
+
end
|
30
|
+
|
31
|
+
# Return the current series color. And, increments the 'current' color pointer, so that a subsequent call to
|
32
|
+
# this method returns 'the next color', and continues the cycle. Should there be no
|
33
|
+
# further series colors available, at the time of advance, the 'current' color
|
34
|
+
# moves back to the first element in the provided :series colors.
|
35
|
+
# @return [String] The html color code of the color, before we advanced the series
|
36
|
+
def series_next!
|
37
|
+
@last_series_color += @last_series_direction
|
38
|
+
@series_colors[@last_series_color % @series_colors.length]
|
39
|
+
end
|
40
|
+
|
41
|
+
# @!visibility private
|
42
|
+
def respond_to_missing?(name, _include_private = false)
|
43
|
+
@base_colors.key? name
|
44
|
+
end
|
45
|
+
|
46
|
+
# Unhandled methods, are assumed to be base_color requests. And, commensurately, the :base option
|
47
|
+
# in the constructor is used to satisfy any such methods
|
48
|
+
# @return [String] An html color code, for the requested base color
|
49
|
+
def method_missing(name)
|
50
|
+
@base_colors.key?(name) ? base_color(name) : super(name)
|
51
|
+
end
|
52
|
+
|
53
|
+
# Returns the base colors, currently supported, that were supplied in the {#initialize} base: option
|
54
|
+
# @return [Hash<Symbol, String>] An html color code, for the requested base color
|
55
|
+
def base_to_h
|
56
|
+
# We'll probably want to expand this more at some point...
|
57
|
+
{ title_rgb: title, background_rgb: background, font_rgb: font,
|
58
|
+
grid_rgb: grid, axis_rgb: axis, key_text_rgb: key_text }
|
59
|
+
end
|
60
|
+
|
61
|
+
# This is used by some charts, due to gnuplot requiring us to inverse the
|
62
|
+
# order of series. The from_origin parameter, is the new origin, by which
|
63
|
+
# we'll begin to assign colors, in reverse order
|
64
|
+
def reverse_series_colors!(from_origin)
|
65
|
+
@last_series_color = from_origin
|
66
|
+
@last_series_direction = -1
|
67
|
+
end
|
68
|
+
|
69
|
+
private
|
70
|
+
|
71
|
+
def base_color(name)
|
72
|
+
raise StandardError, format('No such base_color "%s"', name) unless @base_colors.key? name
|
73
|
+
|
74
|
+
@base_colors[name].is_a?(String) ? @base_colors[name] : base_color(@base_colors[name])
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
# This base class offers some helpers, used by our Element classes to handle
|
79
|
+
# common options, and keep things DRY.
|
80
|
+
class ChartBuilder
|
81
|
+
ONE_MONTH_IN_SECONDS = 2_592_000 # 30 days
|
82
|
+
|
83
|
+
# Create a chart
|
84
|
+
# @param [Hash] opts options to configure this chart
|
85
|
+
# @option opts [Symbol] :domain This option specifies the 'type' of the domain. Currently, the only supported
|
86
|
+
# type is :monthly
|
87
|
+
# @option opts [Integer,Date] :xrange_start The plot domain origin, either the number 1, or a date
|
88
|
+
# @option opts [Date] :xrange_end The end of the plot domain
|
89
|
+
# @option opts [Hash<Symbol, String>] :axis Axis labels. At the moment, :bottom and :left are supported keys.
|
90
|
+
# @param [RVGP::Plot::Gnuplot::Plot] gnuplot A Plot to attach this Chart to
|
91
|
+
def initialize(opts, gnuplot)
|
92
|
+
# TODO: At some point, we probably want to support inverting the key order.
|
93
|
+
# which, as best I can tell, will involve writing a 'fake' chart,
|
94
|
+
# that's not displayed. But which will create a key, which is
|
95
|
+
# displayed, in the order we want
|
96
|
+
@gnuplot = gnuplot
|
97
|
+
|
98
|
+
if opts[:domain]
|
99
|
+
@domain = opts[:domain].to_sym
|
100
|
+
case @domain
|
101
|
+
when :monthly
|
102
|
+
# This is mostly needed, because gnuplot doesn't always do the best
|
103
|
+
# job of automatically detecting the domain bounds...
|
104
|
+
gnuplot.set 'xdata', 'time'
|
105
|
+
gnuplot.set 'xtics', ONE_MONTH_IN_SECONDS
|
106
|
+
|
107
|
+
dates = gnuplot.column(0).map { |xtic| Date.strptime xtic, '%m-%y' }.sort
|
108
|
+
is_multiyear = dates.first.year != dates.last.year
|
109
|
+
|
110
|
+
unless dates.empty?
|
111
|
+
opts[:xrange_start] ||= is_multiyear ? dates.first : Date.new(dates.first.year, 1, 1)
|
112
|
+
opts[:xrange_end] ||= is_multiyear ? dates.last : Date.new(dates.last.year, 12, 31)
|
113
|
+
end
|
114
|
+
else
|
115
|
+
raise StandardError, format('Unsupported domain %s', @domain.inspect)
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
gnuplot.set 'xrange', format_xrange(opts) if xrange? opts
|
120
|
+
gnuplot.set 'xlabel', opts[:axis][:bottom] if opts[:axis] && opts[:axis][:bottom]
|
121
|
+
gnuplot.set 'ylabel', opts[:axis][:left] if opts[:axis] && opts[:axis][:left]
|
122
|
+
end
|
123
|
+
|
124
|
+
# Returns a enumerator, for use by {Plot#plot_command}, when building charts. Mostly,
|
125
|
+
# this method is what determines if the series are started from one, going up to numcols. Or, are started
|
126
|
+
# from num_cols, and go down to one.
|
127
|
+
# @return [Enumerator] An enumerator to progress through the chart's series
|
128
|
+
def series_range(num_cols)
|
129
|
+
reverse_series_range? ? (num_cols - 1).downto(1) : 1.upto(num_cols - 1)
|
130
|
+
end
|
131
|
+
|
132
|
+
# Returns the column number specifier, a string, for use by {Plot#plot_command}, when building charts.
|
133
|
+
# In some charts, this is as simple as num + 1. In others, this line can contain more complex gnuplot code.
|
134
|
+
# @return [String] Returns the gnuplot formatted series_num, for the series at position num.
|
135
|
+
def format_num(num)
|
136
|
+
(num + 1).to_s
|
137
|
+
end
|
138
|
+
|
139
|
+
private
|
140
|
+
|
141
|
+
def reverse_series_range?
|
142
|
+
@reverse_series_range || false
|
143
|
+
end
|
144
|
+
|
145
|
+
def format_xrange(opts)
|
146
|
+
fmt_parts = %i[xrange_start xrange_end].each_with_object({}) do |attr, ret|
|
147
|
+
value = opts[attr]
|
148
|
+
value = value.strftime('%m-%y') if value.respond_to?(:strftime)
|
149
|
+
ret.merge!(attr => value.is_a?(String) ? format('"%s"', value) : value.to_s)
|
150
|
+
end
|
151
|
+
format '[%<xrange_start>s:%<xrange_end>s]', fmt_parts
|
152
|
+
end
|
153
|
+
|
154
|
+
def xrange?(opts)
|
155
|
+
%i[xrange_start xrange_end].any? { |attr| opts[attr] }
|
156
|
+
end
|
157
|
+
|
158
|
+
def reverse_series_colors!
|
159
|
+
@gnuplot.palette.reverse_series_colors! @gnuplot.num_cols - 1
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
# This Chart element contains the logic necessary to render Integrals
|
164
|
+
# (shaded areas, under a line), onto the plot canvas.
|
165
|
+
class AreaChart < ChartBuilder
|
166
|
+
# (see ChartBuilder#initialize)
|
167
|
+
# @option opts [TrueClass,FalseClass] :is_stacked Whether the series on this chart are offset from the origin,
|
168
|
+
# or are offset from each other (aka 'stacked on top of each
|
169
|
+
# other')
|
170
|
+
def initialize(opts, gnuplot)
|
171
|
+
super opts, gnuplot
|
172
|
+
@reverse_series_range = opts[:is_stacked]
|
173
|
+
reverse_series_colors! if reverse_series_range?
|
174
|
+
end
|
175
|
+
|
176
|
+
# The gnuplot data specifier components, for series n
|
177
|
+
# @param _ [Integer] Series number
|
178
|
+
# @return [Hash<Symbol, Object>] :using and :with strings, for use by gnuplot
|
179
|
+
def series(_)
|
180
|
+
{ using: [1, using_data],
|
181
|
+
with: "filledcurves x1 fillcolor '%<rgb>s'" }
|
182
|
+
end
|
183
|
+
|
184
|
+
# The chart types we support, intended for use in the chart_type parameter of your plot yaml.
|
185
|
+
# This class supports: 'area'
|
186
|
+
# @return [Array<String>] Supported chart types.
|
187
|
+
def self.types
|
188
|
+
%w[area]
|
189
|
+
end
|
190
|
+
|
191
|
+
private
|
192
|
+
|
193
|
+
def using_data
|
194
|
+
if reverse_series_range?
|
195
|
+
'(sum [col=2:%<num>s] (valid(col) ? column(col) : 0.0))'
|
196
|
+
else
|
197
|
+
'(valid(%<num>s) ? column(%<num>s) : 0.0)'
|
198
|
+
end
|
199
|
+
end
|
200
|
+
end
|
201
|
+
|
202
|
+
# This Chart element contains the logic used to render histograms (bars) along with lines,
|
203
|
+
# onto the plot canvas
|
204
|
+
class ColumnAndLineChart < ChartBuilder
|
205
|
+
# (see ChartBuilder#initialize)
|
206
|
+
# @option opts [TrueClass, FalseClass] :is_clustered (false) A flag indicating whether to cluster (true) or
|
207
|
+
# row-stack (false) the bar series.
|
208
|
+
# @option opts [Symbol] :columns_rendered_as There are two methods that can be used to render columns
|
209
|
+
# (:histograms & :boxes). The :boxes method supports time-format
|
210
|
+
# domains. While :histograms supports non-reversed series ranges.
|
211
|
+
# @option opts [Hash<String, Symbol>] :series_types A hash, indexed by series name, whose value is either
|
212
|
+
# :column, or :line.
|
213
|
+
def initialize(opts, gnuplot)
|
214
|
+
super opts, gnuplot
|
215
|
+
@is_clustered = opts[:is_clustered]
|
216
|
+
@columns_rendered_as = opts[:columns_rendered_as]
|
217
|
+
@columns_rendered_as ||= @domain == :monthly ? :boxes : :histograms
|
218
|
+
|
219
|
+
@series_types = {}
|
220
|
+
@series_types = opts[:series_types].transform_keys(&:to_s) if opts[:series_types]
|
221
|
+
|
222
|
+
case @columns_rendered_as
|
223
|
+
when :histograms
|
224
|
+
gnuplot.set 'style', format('histogram %s', clustered? ? 'clustered' : 'rowstacked')
|
225
|
+
gnuplot.set 'style', 'fill solid'
|
226
|
+
when :boxes
|
227
|
+
@reverse_series_range = true
|
228
|
+
# This puts a black line around the columns:
|
229
|
+
gnuplot.set 'style', 'fill solid border -1'
|
230
|
+
reverse_series_colors!
|
231
|
+
|
232
|
+
# TODO: The box width straddles the tic, which, causes the box widths to
|
233
|
+
# be half-width on the left and right sides of the plot. Roughly here,
|
234
|
+
# we want to expand that xrange start/end by maybe two weeks.
|
235
|
+
# This will require a bit more work than we want atm, because:
|
236
|
+
# 1. We'd have to change the timefmt, and the grids, to report days
|
237
|
+
# 2. We need to move the gnuplot.set in the initializer() into something
|
238
|
+
# farther down the code path.
|
239
|
+
else
|
240
|
+
raise StandardError, format('Unsupported columns_rendered_as %s', @columns_rendered_as.inspect)
|
241
|
+
end
|
242
|
+
end
|
243
|
+
|
244
|
+
# Returns the value of the :is_clusted initialization parameter.
|
245
|
+
# @return [TrueClass, FalseClass] Whether or not this chart is clustered
|
246
|
+
def clustered?
|
247
|
+
@is_clustered
|
248
|
+
end
|
249
|
+
|
250
|
+
# The gnuplot data specifier components, for series n
|
251
|
+
# @param num [Integer] Series number
|
252
|
+
# @return [Hash<Symbol, Object>] :using and :with strings, for use by gnuplot
|
253
|
+
def series(num)
|
254
|
+
type = series_type num
|
255
|
+
using = [using(type)]
|
256
|
+
|
257
|
+
using.send(*@columns_rendered_as == :histograms ? [:push, 'xtic(1)'] : [:unshift, '1'])
|
258
|
+
|
259
|
+
{ using: using, with: with(type) }
|
260
|
+
end
|
261
|
+
|
262
|
+
# Given the provided column number, return either :column, or :line, depending on whether this column
|
263
|
+
# has a value, as specified in the initialization parameter :series_types
|
264
|
+
# @return [Symbol] Either :column, or :line
|
265
|
+
def series_type(num)
|
266
|
+
title = @gnuplot.series_name(num)
|
267
|
+
@series_types.key?(title) ? @series_types[title].downcase.to_sym : :column
|
268
|
+
end
|
269
|
+
|
270
|
+
# (see ChartBuilder#series_range)
|
271
|
+
def series_range(num_cols)
|
272
|
+
ret = super num_cols
|
273
|
+
return ret unless @columns_rendered_as == :boxes
|
274
|
+
|
275
|
+
# We want the lines to draw over the columns. This achieves that.
|
276
|
+
# It's possible that you want lines behind the columns. If so, add
|
277
|
+
# an option to the class and submit a pr..
|
278
|
+
ret.sort_by { |n| series_type(n) == :column ? 0 : 1 }
|
279
|
+
end
|
280
|
+
|
281
|
+
# (see ChartBuilder#format_num)
|
282
|
+
def format_num(num)
|
283
|
+
if reverse_series_range? && series_type(num) == :column
|
284
|
+
columns_for_sum = num.downto(1).map do |n|
|
285
|
+
# We need to handle empty numbers, in order to fix that weird double-wide column bug
|
286
|
+
format '(valid(%<num>s) ? column(%<num>s) : 0.0)', num: n + 1 if series_type(n) == :column
|
287
|
+
end
|
288
|
+
format '(%s)', columns_for_sum.compact.join('+')
|
289
|
+
else
|
290
|
+
super(num)
|
291
|
+
end
|
292
|
+
end
|
293
|
+
|
294
|
+
# The chart types we support, intended for use in the chart_type parameter of your plot yaml.
|
295
|
+
# This class supports: COMBO column_and_lines column lines.
|
296
|
+
# @return [Array<String>] Supported chart types.
|
297
|
+
def self.types
|
298
|
+
%w[COMBO column_and_lines column lines]
|
299
|
+
end
|
300
|
+
|
301
|
+
private
|
302
|
+
|
303
|
+
def using(type)
|
304
|
+
type == :column && clustered? ? '(valid(%<num>s) ? column(%<num>s) : 0.0)' : '%<num>s'
|
305
|
+
end
|
306
|
+
|
307
|
+
def with(type)
|
308
|
+
case type
|
309
|
+
when :column
|
310
|
+
"#{@columns_rendered_as} linetype rgb '%<rgb>s'"
|
311
|
+
when :line
|
312
|
+
"lines smooth unique lc rgb '%<rgb>s' lt 1 lw 2"
|
313
|
+
else
|
314
|
+
raise StandardError, format('Unsupported series_type %s', series_type.inspect)
|
315
|
+
end
|
316
|
+
end
|
317
|
+
end
|
318
|
+
|
319
|
+
# This class represents, and generates, a gnuplot gpi file. Either to string, or, to the filesystem.
|
320
|
+
# This class will typically work with classes derived from {RVGP::Plot::Gnuplot::ChartBuilder}, and
|
321
|
+
# an instance of this class is provided as a parameter to the #initialize method of a ChartBuilder.
|
322
|
+
# @attr_reader [Array<String>] additional_lines Arbitrary lines, presumably of gnuplot code, that are appended to
|
323
|
+
# the generated gpi, after the settings, and before the plot
|
324
|
+
# commands(s).
|
325
|
+
# @attr_reader [Hash[String, String]] settings A hash of setting to value pairs, whech are transcribed (via the
|
326
|
+
# 'set' directive) to the plot
|
327
|
+
# @attr_reader [Hash[Symbol, Object]] template A hash containing a :header string, and a :colors Hash. These
|
328
|
+
# objects are used to construct the aesthetics of the generated gpi.
|
329
|
+
# For more details on what options are supported in the :colors key,
|
330
|
+
# see the colors section of:
|
331
|
+
# {https://github.com/brighton36/rra/blob/main/resources/gnuplot/default.yml default.yml}
|
332
|
+
# @attr_reader [RVGP::Plot::Gnuplot::ChartBuilder] element An instance of {Plot::ELEMENTS}, to which plot
|
333
|
+
# directive generation is delegated.
|
334
|
+
# @attr_reader [Array<Array<String>>] dataset A grid, whose first row contains the headers, and in which each
|
335
|
+
# additional row's first element, is a keystone.
|
336
|
+
class Plot
|
337
|
+
# These are the gnuplot elements, that we currently support:
|
338
|
+
ELEMENTS = [AreaChart, ColumnAndLineChart].freeze
|
339
|
+
# These attributes were pulled from the gnuplot gem, and indicate which #set key's require string quoting.
|
340
|
+
# This implementation isn't very good, but, was copied out of the Gnuplot gem
|
341
|
+
SET_QUOTED = %w[title output xlabel x2label ylabel y2label clabel cblabel zlabel].freeze
|
342
|
+
# This is a string formatting specifier, used to composed a plot directive, to gnuplot
|
343
|
+
PLOT_COMMAND_LINE = ['%<using>s', 'title %<title>s', 'with %<with>s'].compact.join(" \\\n ").freeze
|
344
|
+
|
345
|
+
attr_accessor :additional_lines
|
346
|
+
attr_reader :settings, :template, :element, :dataset
|
347
|
+
|
348
|
+
# Create a plot
|
349
|
+
# @param [String] title The title of this plot
|
350
|
+
# @param [Array<Array<String>>] dataset A grid, whose first row contains the headers, and in which each
|
351
|
+
# additional row's first element, is a keystone.
|
352
|
+
# @param [Hash] opts options to configure this plot, and its {element}. Unrecognized options, in this
|
353
|
+
# parameter, are delegated to the specified :chart_type for further handling.
|
354
|
+
# @option opts [Symbol] :additional_lines ([]) see {additional_lines}
|
355
|
+
# @option opts [Symbol] :template see {template}
|
356
|
+
# @option opts [String] :chart_type A string, that is matched against .types of available ELEMENTS, and
|
357
|
+
# used to initialize an instance of a matched class, to create our {element}
|
358
|
+
def initialize(title, dataset, opts = {})
|
359
|
+
@title = title
|
360
|
+
@dataset = dataset
|
361
|
+
@settings = []
|
362
|
+
@additional_lines = Array(opts[:additional_lines])
|
363
|
+
@template = opts[:template]
|
364
|
+
|
365
|
+
element_klass = ELEMENTS.find { |element| element.types.any? opts[:chart_type] }
|
366
|
+
raise StandardError, format('Unsupported chart_type %s', opts[:chart_type]) unless element_klass
|
367
|
+
|
368
|
+
@element = element_klass.new opts, self
|
369
|
+
end
|
370
|
+
|
371
|
+
# Assembles a gpi file's contents, and returns them as a string
|
372
|
+
# @return [String] the generated gpi file
|
373
|
+
def script
|
374
|
+
vars = { title: @title }.merge palette.base_to_h
|
375
|
+
|
376
|
+
[format("$DATA << EOD\n%sEOD\n", to_csv),
|
377
|
+
format(template[:header], vars),
|
378
|
+
@settings.map { |setting| setting.map(&:to_s).join(' ') << "\n" },
|
379
|
+
format(@additional_lines.join("\n"), vars),
|
380
|
+
plot_command, "\n"].flatten.join
|
381
|
+
end
|
382
|
+
|
383
|
+
# Runs the gnuplot command, feeding the contents of {script} to it's stdin and returns the output.
|
384
|
+
# raises StandardError, citing errors, if gnuplot returns errors.
|
385
|
+
# @return [String] the output of gnuplot, with some selective squelching of output
|
386
|
+
def execute!(persist: true)
|
387
|
+
output, errors, status = Open3.capture3 Gnuplot.gnuplot(persist), stdin_data: script
|
388
|
+
|
389
|
+
# For reasons unknown, this is sent to stderr, in response to the
|
390
|
+
# 'set decimal locale' instruction. Which we need to set.
|
391
|
+
errors = errors.lines.reject { |line| /^decimal_sign in locale is/.match(line) }
|
392
|
+
|
393
|
+
unless status.success? || !errors.empty?
|
394
|
+
raise StandardError,
|
395
|
+
format('gnuplot exited non-zero (%<status>s): %<errors>s',
|
396
|
+
status: status.exitstatus,
|
397
|
+
errors: errors.join("\n"))
|
398
|
+
end
|
399
|
+
|
400
|
+
output
|
401
|
+
end
|
402
|
+
|
403
|
+
# Transcribes a 'set' directive, into the generated plot, with the given key as the set variable name,
|
404
|
+
# and the provided value, as that set's value. Some values (See {SET_QUOTED}) are interpolated. Others
|
405
|
+
# are merely transcribed directly as provided, without escaping.
|
406
|
+
# @param [String] key The gnuplot variable you wish to set
|
407
|
+
# @param [String] value The value to set to, if any
|
408
|
+
# @return [void]
|
409
|
+
def set(key, value = nil)
|
410
|
+
quoted_value = value && SET_QUOTED.include?(key) ? quote_value(value) : value
|
411
|
+
@settings << [:set, key, quoted_value].compact
|
412
|
+
nil
|
413
|
+
end
|
414
|
+
|
415
|
+
# Transcribes an 'unset' directive, into the generated plot, with the given key as the unset variable name.
|
416
|
+
# @param [String] key The gnuplot variable you wish to unset
|
417
|
+
# @return [void]
|
418
|
+
def unset(key)
|
419
|
+
@settings << [:unset, key]
|
420
|
+
nil
|
421
|
+
end
|
422
|
+
|
423
|
+
# Returns column n of dataset, not including the header row
|
424
|
+
# @param [Integer] num The column number to return
|
425
|
+
# @return [Array<String>] The column that was found, from top to bottom
|
426
|
+
def column(num)
|
427
|
+
dataset[1...].map { |row| row[num] }
|
428
|
+
end
|
429
|
+
|
430
|
+
# Returns the header row, at position num
|
431
|
+
# @param [Integer] num The series number to query
|
432
|
+
# @return [String] The name of the series, at row num
|
433
|
+
def series_name(num)
|
434
|
+
dataset[0][num]
|
435
|
+
end
|
436
|
+
|
437
|
+
# Returns the number of columns in the dataset, including the keystone
|
438
|
+
# @return [Integer] the length of dataset[0]
|
439
|
+
def num_cols
|
440
|
+
dataset[0].length
|
441
|
+
end
|
442
|
+
|
443
|
+
# The current {Palette} instance that we're using for our color queries
|
444
|
+
def palette
|
445
|
+
@palette ||= Palette.new @template[:colors]
|
446
|
+
end
|
447
|
+
|
448
|
+
private
|
449
|
+
|
450
|
+
def to_csv
|
451
|
+
CSV.generate { |csv| dataset.each { |row| csv << row } }
|
452
|
+
end
|
453
|
+
|
454
|
+
def plot_command
|
455
|
+
# NOTE: n == 0 is the keystone.
|
456
|
+
plot_command_lines = element.series_range(num_cols).map.with_index do |n, i|
|
457
|
+
title = series_name n
|
458
|
+
|
459
|
+
# Note that the gnuplot calls these series 'elements', but, we're keeping
|
460
|
+
# with series
|
461
|
+
series = { title: "'#{title}'" }.merge(element.series(n))
|
462
|
+
series[:using] = format(' %<prefix>s using %<usings>s',
|
463
|
+
prefix: i.zero? ? nil : ' \'\'',
|
464
|
+
usings: Array(series[:using]).map(&:to_s).join(':'))
|
465
|
+
|
466
|
+
format(format(PLOT_COMMAND_LINE, series), { rgb: palette.series_next!, num: element.format_num(n) })
|
467
|
+
end
|
468
|
+
|
469
|
+
format("plot $DATA \\\n%<lines>s", lines: plot_command_lines.join(", \\\n"))
|
470
|
+
end
|
471
|
+
|
472
|
+
def quote_value(value)
|
473
|
+
value =~ /^["'].*['"]$/ ? value : "\"#{value}\""
|
474
|
+
end
|
475
|
+
end
|
476
|
+
end
|
477
|
+
end
|
478
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RVGP
|
4
|
+
class Plot
|
5
|
+
module GoogleDrive
|
6
|
+
# This class is roughly, an kind of diagnostic alternative to {RVGP::Plot::GoogleDrive::ExportSheets},
|
7
|
+
# which implements the :csvdir option of the {RVGP::Commands::PublishGsheets} command.
|
8
|
+
# Mostly, this object offers the methods that {RVGP::Plot::GoogleDrive::ExportSheets} provides, and
|
9
|
+
# writes the sheets that would have otherwise been published to google - into a
|
10
|
+
# local directory, with csv files representing the Google sheet. This is mostly
|
11
|
+
# a debugging and diagnostic function.
|
12
|
+
# @attr_reader [String] destination The destination path, provided in the constructor
|
13
|
+
class ExportLocalCsvs
|
14
|
+
attr_accessor :destination
|
15
|
+
|
16
|
+
# Output google-intentioned spreadsheet sheets, to csvs in a directory
|
17
|
+
# @param [Hash] options The parameters governing this export
|
18
|
+
# @option options [String] :format What format, to output the csvs in. Currently, The only supported value is
|
19
|
+
# 'csv'.
|
20
|
+
# @option options [String] :destination The path to a folder, to export sheets into
|
21
|
+
def initialize(options)
|
22
|
+
unless [(options[:format] == 'csv'), File.directory?(options[:destination])].all?
|
23
|
+
raise StandardError, 'Invalid Options, missing :destination'
|
24
|
+
end
|
25
|
+
|
26
|
+
@destination = options[:destination]
|
27
|
+
end
|
28
|
+
|
29
|
+
# Ouput the provided sheet, into the destination path, as a csv
|
30
|
+
# @param [RVGP::Plot::GoogleDrive::Sheet] sheet The options, and data, for this sheet
|
31
|
+
# @return [void]
|
32
|
+
def sheet(sheet)
|
33
|
+
shortname = sheet.title.tr('^a-zA-Z0-9', '_').gsub(/_+/, '_').downcase.chomp('_')
|
34
|
+
|
35
|
+
CSV.open([destination.chomp('/'), '/', shortname, '.csv'].join, 'wb') do |csv|
|
36
|
+
([sheet.columns] + sheet.rows).each do |row|
|
37
|
+
csv << row.map { |c| c.is_a?(Date) ? c.strftime('%m/%d/%Y') : c }
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|