rvgp 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.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
|