inkplot 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rubocop.yml +35 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +72 -0
- data/Rakefile +15 -0
- data/bench/inkplot.rb +12 -0
- data/examples/gallery/01-line.svg +1 -0
- data/examples/gallery/02-line-gaps.svg +1 -0
- data/examples/gallery/03-series.svg +1 -0
- data/examples/gallery/04-scatter.svg +1 -0
- data/examples/gallery/05-bars.svg +1 -0
- data/examples/gallery/06-horizontal-bars.svg +1 -0
- data/examples/gallery/07-grouped-bars.svg +1 -0
- data/examples/gallery/08-stacked-bars.svg +1 -0
- data/examples/gallery/10-area.svg +1 -0
- data/examples/gallery/11-step.svg +1 -0
- data/examples/gallery/README.md +11 -0
- data/examples/gallery.rb +49 -0
- data/lib/inkplot/core.rb +624 -0
- data/lib/inkplot/renderers/svg.rb +75 -0
- data/lib/inkplot/version.rb +5 -0
- data/lib/inkplot.rb +71 -0
- data/sig/inkplot.rbs +36 -0
- metadata +122 -0
data/lib/inkplot/core.rb
ADDED
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inkplot
|
|
4
|
+
module Data
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def number(value)
|
|
8
|
+
return nil if value.nil?
|
|
9
|
+
return value.to_f if value.is_a?(Numeric) && value.to_f.finite?
|
|
10
|
+
|
|
11
|
+
Float(value, exception: false)&.then { |number| number.finite? ? number : nil }
|
|
12
|
+
rescue RangeError
|
|
13
|
+
nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def points(data, values = nil, x: nil, y: nil, size: nil)
|
|
17
|
+
if values
|
|
18
|
+
xs = Array(data)
|
|
19
|
+
ys = Array(values)
|
|
20
|
+
raise ArgumentError, "x and y must have the same number of values" unless xs.length == ys.length
|
|
21
|
+
|
|
22
|
+
sizes = size.is_a?(Array) ? size : Array.new(xs.length, size)
|
|
23
|
+
return xs.each_index.map { |index| { x: xs[index], y: number(ys[index]), size: number(sizes[index]) } }
|
|
24
|
+
end
|
|
25
|
+
if data.is_a?(Hash) && x && y
|
|
26
|
+
x_values = value(data, x)
|
|
27
|
+
y_values = value(data, y)
|
|
28
|
+
return points(x_values, y_values, size: size && value(data, size)) if x_values && y_values
|
|
29
|
+
end
|
|
30
|
+
return data.map { |key, value| { x: key, y: number(value) } } if data.is_a?(Hash)
|
|
31
|
+
return rows(data, x:, y:, size:) if records?(data)
|
|
32
|
+
return data.map { |x_value, y_value| { x: x_value, y: number(y_value) } } if data.is_a?(Array) && data.first.is_a?(Array) && data.first.length == 2
|
|
33
|
+
|
|
34
|
+
Array(data).each_with_index.map { |value, index| { x: index, y: number(value) } }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def records?(data)
|
|
38
|
+
first = data.first if data.is_a?(Array)
|
|
39
|
+
first&.then { |row| row.is_a?(Hash) || (row.respond_to?(:to_h) && !row.is_a?(Array)) } || (data.respond_to?(:headers) && data.respond_to?(:each))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def rows(data, x: nil, y: nil, size: nil, group: nil)
|
|
43
|
+
source = data.respond_to?(:headers) && data.respond_to?(:each) ? data : Array(data)
|
|
44
|
+
source.map do |row|
|
|
45
|
+
record = row.respond_to?(:to_h) ? row.to_h : row
|
|
46
|
+
x_key = x || record.keys[0]
|
|
47
|
+
y_key = y || record.keys[1]
|
|
48
|
+
{ x: value(record, x_key), y: number(value(record, y_key)), size: number(value(record, size)), group: value(record, group) }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def value(record, key)
|
|
53
|
+
return nil if key.nil?
|
|
54
|
+
return record[key] if record.key?(key)
|
|
55
|
+
|
|
56
|
+
alternate = key.is_a?(Symbol) ? key.to_s : key.to_sym
|
|
57
|
+
record[alternate]
|
|
58
|
+
rescue NoMethodError
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
module Ticks
|
|
64
|
+
module_function
|
|
65
|
+
|
|
66
|
+
def linear(minimum, maximum, count: 5)
|
|
67
|
+
return [minimum] if minimum >= maximum
|
|
68
|
+
|
|
69
|
+
step = linear_step(minimum, maximum, count:)
|
|
70
|
+
first = (minimum / step).ceil * step
|
|
71
|
+
last = (maximum / step).floor * step
|
|
72
|
+
ticks = []
|
|
73
|
+
value = first
|
|
74
|
+
while value <= last + (step * 1e-10) && ticks.length < 1000
|
|
75
|
+
ticks << ((value / step).round(10) * step)
|
|
76
|
+
value += step
|
|
77
|
+
end
|
|
78
|
+
ticks.empty? ? [minimum, maximum].uniq : ticks
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def linear_step(minimum, maximum, count: 5)
|
|
82
|
+
return 1.0 unless maximum > minimum
|
|
83
|
+
|
|
84
|
+
raw = (maximum - minimum).to_f / [count - 1, 1].max
|
|
85
|
+
power = 10.0**Math.log10(raw).floor
|
|
86
|
+
fraction = raw / power
|
|
87
|
+
(if fraction <= 1
|
|
88
|
+
1
|
|
89
|
+
elsif fraction <= 2
|
|
90
|
+
2
|
|
91
|
+
else
|
|
92
|
+
fraction <= 5 ? 5 : 10
|
|
93
|
+
end) * power
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def log(minimum, maximum)
|
|
97
|
+
low = Math.log10(minimum).floor
|
|
98
|
+
high = Math.log10(maximum).ceil
|
|
99
|
+
exponents = (low..high).to_a
|
|
100
|
+
if high - low <= 2
|
|
101
|
+
exponents.flat_map { |power| [1, 2, 5].map { |multiple| multiple * (10.0**power) } }.grep(minimum..maximum)
|
|
102
|
+
else
|
|
103
|
+
exponents.map { |power| 10.0**power }.grep(minimum..maximum)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def number_label(value)
|
|
108
|
+
value.to_i == value ? value.to_i.to_s : format("%.8f", value).sub(/0+\z/, "").sub(/\.\z/, "")
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
module Scales
|
|
113
|
+
class Linear
|
|
114
|
+
attr_reader :domain, :ticks
|
|
115
|
+
attr_accessor :range
|
|
116
|
+
|
|
117
|
+
def initialize(values, options = {}, include_zero: false, **axis_options)
|
|
118
|
+
options = options.merge(axis_options)
|
|
119
|
+
values = values.filter_map { |value| Data.number(value) }
|
|
120
|
+
low, high = values.minmax
|
|
121
|
+
low ||= 0.0
|
|
122
|
+
high ||= 1.0
|
|
123
|
+
low = [low, 0.0].min if include_zero
|
|
124
|
+
high = [high, 0.0].max if include_zero
|
|
125
|
+
low = limit(options[:min], low, "minimum")
|
|
126
|
+
high = limit(options[:max], high, "maximum")
|
|
127
|
+
if low == high
|
|
128
|
+
raise ArgumentError, "linear axis minimum must be less than maximum" if options[:min] && options[:max]
|
|
129
|
+
|
|
130
|
+
low -= low.zero? ? 1 : low.abs * 0.1 if options[:min].nil?
|
|
131
|
+
high += high.zero? ? 1 : high.abs * 0.1 if options[:max].nil?
|
|
132
|
+
end
|
|
133
|
+
raise ArgumentError, "linear axis minimum must be less than maximum" unless low < high
|
|
134
|
+
|
|
135
|
+
@ticks = Ticks.linear(low, high)
|
|
136
|
+
step = Ticks.linear_step(low, high)
|
|
137
|
+
low = (low / step).floor * step if options[:min].nil?
|
|
138
|
+
high = (high / step).ceil * step if options[:max].nil?
|
|
139
|
+
@domain = [low, high]
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def map(value)
|
|
143
|
+
value = Data.number(value)
|
|
144
|
+
return nil unless value
|
|
145
|
+
|
|
146
|
+
@range[0] + ((value - @domain[0]) * (@range[1] - @range[0]) / (@domain[1] - @domain[0]))
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def format(value) = Ticks.number_label(value)
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
def limit(value, fallback, name)
|
|
154
|
+
return fallback if value.nil?
|
|
155
|
+
|
|
156
|
+
number = Data.number(value)
|
|
157
|
+
raise ArgumentError, "linear axis #{name} must be numeric" unless number
|
|
158
|
+
|
|
159
|
+
number
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
class Log
|
|
164
|
+
attr_reader :domain, :ticks
|
|
165
|
+
attr_accessor :range
|
|
166
|
+
|
|
167
|
+
def initialize(values, options = {})
|
|
168
|
+
positive = values.filter_map { |value| Data.number(value) }.select(&:positive?)
|
|
169
|
+
low, high = positive.minmax
|
|
170
|
+
low ||= 1.0
|
|
171
|
+
high ||= 10.0
|
|
172
|
+
low = axis_limit(options[:min], low, "minimum")
|
|
173
|
+
high = axis_limit(options[:max], high, "maximum")
|
|
174
|
+
raise ArgumentError, "log axis limits must be positive" unless low.positive? && high.positive?
|
|
175
|
+
raise ArgumentError, "log axis minimum must be less than maximum" if low == high && options[:min] && options[:max]
|
|
176
|
+
|
|
177
|
+
high = low * 10 if low == high
|
|
178
|
+
raise ArgumentError, "log axis minimum must be less than maximum" unless low < high
|
|
179
|
+
|
|
180
|
+
@ticks = Ticks.log(low, high)
|
|
181
|
+
@domain = [low, high]
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def map(value)
|
|
185
|
+
number = Data.number(value)
|
|
186
|
+
return nil unless number&.positive?
|
|
187
|
+
|
|
188
|
+
(@range[0] + ((Math.log10(number) - Math.log10(@domain[0])) * (@range[1] - @range[0]) / (Math.log10(@domain[1]) - Math.log10(@domain[0]))))
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def format(value) = Ticks.number_label(value)
|
|
192
|
+
|
|
193
|
+
private
|
|
194
|
+
|
|
195
|
+
def axis_limit(value, fallback, name)
|
|
196
|
+
return fallback if value.nil?
|
|
197
|
+
|
|
198
|
+
number = Data.number(value)
|
|
199
|
+
raise ArgumentError, "log axis #{name} must be finite and positive" unless number&.positive?
|
|
200
|
+
|
|
201
|
+
number
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
class Band
|
|
206
|
+
attr_reader :domain, :ticks
|
|
207
|
+
attr_accessor :range
|
|
208
|
+
|
|
209
|
+
def initialize(values, options = {})
|
|
210
|
+
values = values.compact.uniq
|
|
211
|
+
order = options[:order]
|
|
212
|
+
values = order.select { |value| values.include?(value) } + (values - order) if order
|
|
213
|
+
@domain = @ticks = values
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def map(value)
|
|
217
|
+
index = @domain.index(value)
|
|
218
|
+
return nil unless index
|
|
219
|
+
|
|
220
|
+
@range[0] + ((index + 0.5) * (@range[1] - @range[0]) / @domain.length)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def bandwidth = (@range[1] - @range[0]) / [@domain.length, 1].max
|
|
224
|
+
def format(value) = value.to_s
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
module TextMetrics
|
|
229
|
+
module_function
|
|
230
|
+
|
|
231
|
+
def width(text, size = 12)
|
|
232
|
+
if Inkplot.config.font
|
|
233
|
+
font = font_for(size)
|
|
234
|
+
return font.measure(text.to_s)[0]
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
text.to_s.each_char.sum { |character| character.ascii_only? ? size * 0.58 : size }
|
|
238
|
+
rescue LoadError
|
|
239
|
+
raise if Inkplot.config.font
|
|
240
|
+
|
|
241
|
+
text.to_s.length * size * 0.58
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def font_for(size = 12)
|
|
245
|
+
require "glyphic"
|
|
246
|
+
return Glyphic.load(Inkplot.config.font, size: size) if Inkplot.config.font
|
|
247
|
+
|
|
248
|
+
Glyphic.default
|
|
249
|
+
rescue LoadError => e
|
|
250
|
+
raise LoadError, "font metrics need glyphic; install it with `gem install glyphic` (#{e.message})"
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
module Theme
|
|
255
|
+
PALETTE = %w[#0072B2 #E69F00 #009E73 #D55E00 #CC79A7 #56B4E9 #F0E442 #333333].freeze
|
|
256
|
+
NAMED_COLORS = %w[red blue green black white orange gray grey purple yellow cyan magenta pink brown navy teal lime maroon olive silver aqua fuchsia rebeccapurple
|
|
257
|
+
transparent].freeze
|
|
258
|
+
VALUES = {
|
|
259
|
+
light: { background: "#FFFFFF", foreground: "#20242B", grid: "#E7EAF0", axis: "#626B78" },
|
|
260
|
+
dark: { background: "#171B22", foreground: "#E8ECF2", grid: "#303744", axis: "#A9B2C0" }
|
|
261
|
+
}.freeze
|
|
262
|
+
|
|
263
|
+
module_function
|
|
264
|
+
|
|
265
|
+
def colors(name)
|
|
266
|
+
VALUES.fetch(name.to_sym) { raise ArgumentError, "theme must be :light or :dark" }
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def series_color(index, theme)
|
|
270
|
+
color = PALETTE[index % PALETTE.length]
|
|
271
|
+
return color if index < PALETTE.length || theme.to_sym == :light
|
|
272
|
+
|
|
273
|
+
rgb = color.delete_prefix("#").scan(/../).map { |part| part.to_i(16) }
|
|
274
|
+
rgb.map { |channel| ((channel * 0.72) + (255 * 0.28)).round }.map { |channel| format("%02X", channel) }.join.prepend("#")
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def validate_color(color)
|
|
278
|
+
return nil if color.nil?
|
|
279
|
+
|
|
280
|
+
value = color.to_s
|
|
281
|
+
return value if value.match?(/\A(?:#[\da-fA-F]{3,4}|#[\da-fA-F]{6}|#[\da-fA-F]{8})\z/) || NAMED_COLORS.include?(value.downcase)
|
|
282
|
+
|
|
283
|
+
raise ArgumentError, "color must be a CSS color name or hexadecimal value"
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
class Builder
|
|
288
|
+
attr_reader :width, :height, :theme, :series, :x_options, :y_options, :notes
|
|
289
|
+
attr_accessor :title_text, :x_label_text, :y_label_text, :legend_options
|
|
290
|
+
|
|
291
|
+
def initialize(width: 640, height: 360, theme: :light, title: nil, x_label: nil, y_label: nil, **_options)
|
|
292
|
+
@width = Integer(width)
|
|
293
|
+
@height = Integer(height)
|
|
294
|
+
raise ArgumentError, "chart dimensions must be at least 120×100" if @width < 120 || @height < 100
|
|
295
|
+
|
|
296
|
+
@theme = theme.to_sym
|
|
297
|
+
Theme.colors(@theme)
|
|
298
|
+
@title_text = title
|
|
299
|
+
@x_label_text = x_label
|
|
300
|
+
@y_label_text = y_label
|
|
301
|
+
@x_options = {}
|
|
302
|
+
@y_options = {}
|
|
303
|
+
@legend_options = { position: :top_right }
|
|
304
|
+
@series = []
|
|
305
|
+
@notes = []
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def title(value) = @title_text = value.to_s
|
|
309
|
+
|
|
310
|
+
def x_axis(label: nil, type: nil, scale: nil, min: nil, max: nil, order: nil)
|
|
311
|
+
@x_label_text = label.to_s if label
|
|
312
|
+
@x_options = { type:, scale:, min:, max:, order: }.compact
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def y_axis(label: nil, type: nil, scale: nil, min: nil, max: nil)
|
|
316
|
+
@y_label_text = label.to_s if label
|
|
317
|
+
@y_options = { type:, scale:, min:, max: }.compact
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def legend(position: :top_right) = @legend_options = { position: position.to_sym }
|
|
321
|
+
|
|
322
|
+
def line(x, y = nil, label: nil, color: nil, dash: false, gaps: :break, **)
|
|
323
|
+
add(:line, Data.points(x, y), label:, color:, dash:, gaps:, **)
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def scatter(x, y = nil, label: nil, color: nil, size: nil, **)
|
|
327
|
+
add(:scatter, Data.points(x, y, size:), label:, color:, **)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def area(x, y = nil, label: nil, color: nil, **)
|
|
331
|
+
add(:area, Data.points(x, y), label:, color:, **)
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def step(x, y = nil, label: nil, color: nil, **)
|
|
335
|
+
add(:step, Data.points(x, y), label:, color:, **)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def bar(categories, values = nil, label: nil, color: nil, horizontal: false, stacked: false, **)
|
|
339
|
+
points = Data.points(categories, values)
|
|
340
|
+
add(:bar, points, label:, color:, horizontal:, stacked:, **)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def hline(value, label: nil, color: nil, dash: true)
|
|
344
|
+
number = Data.number(value)
|
|
345
|
+
raise ArgumentError, "horizontal rule value must be finite and numeric" unless number
|
|
346
|
+
|
|
347
|
+
@notes << { type: :hline, value: number, label:, color: Theme.validate_color(color), dash: }
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def vline(value, label: nil, color: nil, dash: true)
|
|
351
|
+
@notes << { type: :vline, value:, label:, color: Theme.validate_color(color), dash: }
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def add(type, points, label: nil, color: nil, **options)
|
|
355
|
+
raise ArgumentError, "unsupported chart mark: #{type}" unless %i[line scatter bar area step].include?(type.to_sym)
|
|
356
|
+
raise ArgumentError, "gaps must be :break or :connect" if options[:gaps] && !%i[break connect].include?(options[:gaps])
|
|
357
|
+
|
|
358
|
+
@series << Series.new(type: type.to_sym, points:, label: label&.to_s, color: Theme.validate_color(color), options:)
|
|
359
|
+
self
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
class Chart
|
|
364
|
+
attr_reader :builder
|
|
365
|
+
|
|
366
|
+
def initialize(builder)
|
|
367
|
+
@builder = builder
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def width = builder.width
|
|
371
|
+
def height = builder.height
|
|
372
|
+
|
|
373
|
+
def to_svg(width: self.width, height: self.height)
|
|
374
|
+
Renderers::SVG.render(SceneBuilder.call(self, width: width, height: height))
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def save(path, width: self.width, height: self.height)
|
|
378
|
+
case File.extname(String(path)).downcase
|
|
379
|
+
when ".svg" then File.write(path, to_svg(width:, height:))
|
|
380
|
+
else raise ArgumentError, "output path must end in .svg"
|
|
381
|
+
end
|
|
382
|
+
path
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
private
|
|
386
|
+
|
|
387
|
+
def series = builder.series
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
module SceneBuilder
|
|
391
|
+
module_function
|
|
392
|
+
|
|
393
|
+
def call(chart, width:, height:)
|
|
394
|
+
builder = chart.builder
|
|
395
|
+
width = Integer(width)
|
|
396
|
+
height = Integer(height)
|
|
397
|
+
raise ArgumentError, "chart dimensions must be at least 120×100" if width < 120 || height < 100
|
|
398
|
+
|
|
399
|
+
series = builder.series
|
|
400
|
+
raise ArgumentError, "chart has no data marks" if series.empty?
|
|
401
|
+
|
|
402
|
+
warn "Inkplot cycles its eight-color palette after eight series." if series.length > Theme::PALETTE.length
|
|
403
|
+
colors = Theme.colors(builder.theme)
|
|
404
|
+
x_values = series.flat_map { |item| item.points.flat_map { |point| [point[:x], point[:x2]].compact } }
|
|
405
|
+
y_values = series.flat_map { |item| item.points.map { |point| point[:y] } }
|
|
406
|
+
if series.any? { |item| item.options[:stacked] }
|
|
407
|
+
totals = stacked_totals(series)
|
|
408
|
+
y_values.concat(totals)
|
|
409
|
+
end
|
|
410
|
+
horizontal = series.any? { |item| item.type == :bar && item.options[:horizontal] }
|
|
411
|
+
if horizontal
|
|
412
|
+
x_values = series.flat_map { |item| item.points.map { |point| point[:y] } }
|
|
413
|
+
y_values = series.flat_map { |item| item.points.map { |point| point[:x] } }
|
|
414
|
+
x_values.concat(stacked_totals(series)) if series.any? { |item| item.options[:stacked] }
|
|
415
|
+
end
|
|
416
|
+
x_options = builder.x_options
|
|
417
|
+
y_options = builder.y_options
|
|
418
|
+
x_scale = build_scale(x_values, x_options, :x, series)
|
|
419
|
+
y_scale = build_scale(y_values, y_options, :y, series)
|
|
420
|
+
x_ticks = ticks(x_scale)
|
|
421
|
+
y_ticks = ticks(y_scale)
|
|
422
|
+
left = (y_ticks.map { |tick| TextMetrics.width(y_scale.format(tick), 11) }.max.to_f.ceil + 14).clamp(42, 120)
|
|
423
|
+
bottom = 34
|
|
424
|
+
bottom += 20 if builder.x_label_text
|
|
425
|
+
left += 18 if builder.y_label_text
|
|
426
|
+
top = builder.title_text ? 40 : 18
|
|
427
|
+
legend_items = series.each_with_index.filter_map { |item, index| [item.label, item.color || Theme.series_color(index, builder.theme)] if item.label }
|
|
428
|
+
legend_items.uniq!(&:first)
|
|
429
|
+
legend_width = [legend_items.map { |label, _| TextMetrics.width(label, 11) }.max.to_f.ceil + 30, 120].max
|
|
430
|
+
right = builder.legend_options[:position].to_s.end_with?("right") && legend_items.length > 1 ? legend_width + 18 : 18
|
|
431
|
+
x_scale.range = [left, width - right]
|
|
432
|
+
y_scale.range = [height - bottom, top]
|
|
433
|
+
clip = [left, top, width - right, height - bottom]
|
|
434
|
+
marks = []
|
|
435
|
+
elements = [{ type: :rect, x: 0, y: 0, width:, height:, fill: colors[:background] }]
|
|
436
|
+
|
|
437
|
+
y_ticks.each do |tick|
|
|
438
|
+
y = y_scale.map(tick)
|
|
439
|
+
next unless y
|
|
440
|
+
|
|
441
|
+
elements << { type: :line, class: "grid-line", points: [[left, y], [width - right, y]], stroke: colors[:grid], width: 1 }
|
|
442
|
+
elements << { type: :text, x: left - 8, y: y + 4, text: y_scale.format(tick), fill: colors[:axis], anchor: :end, size: 11 }
|
|
443
|
+
end
|
|
444
|
+
x_ticks.each_with_index do |tick, _index|
|
|
445
|
+
x = x_scale.map(tick)
|
|
446
|
+
next unless x
|
|
447
|
+
|
|
448
|
+
label = x_scale.format(tick)
|
|
449
|
+
elements << { type: :line, class: "grid-line", points: [[x, top], [x, height - bottom]], stroke: colors[:grid], width: 1 } if x_scale.is_a?(Scales::Band)
|
|
450
|
+
elements << { type: :text, x:, y: height - bottom + 18, text: label, fill: colors[:axis], anchor: :middle, size: 10 }
|
|
451
|
+
end
|
|
452
|
+
elements << { type: :line, class: "axis-line", points: [[left, top], [left, height - bottom], [width - right, height - bottom]], stroke: colors[:axis], width: 1 }
|
|
453
|
+
elements << { type: :text, x: width / 2.0, y: 22, text: builder.title_text, fill: colors[:foreground], anchor: :middle, size: 15 } if builder.title_text
|
|
454
|
+
if builder.x_label_text
|
|
455
|
+
elements << { type: :text, x: (left + width - right) / 2.0, y: height - 5, text: builder.x_label_text, fill: colors[:foreground], anchor: :middle,
|
|
456
|
+
size: 11 }
|
|
457
|
+
end
|
|
458
|
+
elements << { type: :text, x: 14, y: height / 2.0, text: builder.y_label_text, fill: colors[:foreground], anchor: :middle, size: 11, rotate: -90 } if builder.y_label_text
|
|
459
|
+
|
|
460
|
+
bars = series.select { |item| item.type == :bar }
|
|
461
|
+
bar_index = 0
|
|
462
|
+
positive_stack = Hash.new(0.0)
|
|
463
|
+
negative_stack = Hash.new(0.0)
|
|
464
|
+
series.each_with_index do |item, index|
|
|
465
|
+
color = item.color || Theme.series_color(index, builder.theme)
|
|
466
|
+
points = item.points
|
|
467
|
+
if item.type == :bar
|
|
468
|
+
current_bar_index = bar_index
|
|
469
|
+
bar_index += 1
|
|
470
|
+
total = [bars.length, 1].max
|
|
471
|
+
points.each do |point|
|
|
472
|
+
next unless point[:y] && point[:x]
|
|
473
|
+
|
|
474
|
+
if item.options[:horizontal]
|
|
475
|
+
band = y_scale.bandwidth * 0.72
|
|
476
|
+
value = point[:y]
|
|
477
|
+
base = if item.options[:stacked]
|
|
478
|
+
(value.negative? ? negative_stack : positive_stack)[point[:x]]
|
|
479
|
+
else
|
|
480
|
+
0
|
|
481
|
+
end
|
|
482
|
+
finish = base + value
|
|
483
|
+
if item.options[:stacked]
|
|
484
|
+
(value.negative? ? negative_stack : positive_stack)[point[:x]] = finish
|
|
485
|
+
end
|
|
486
|
+
value = x_scale.map(finish)
|
|
487
|
+
zero = x_scale.map(base)
|
|
488
|
+
next unless value && zero
|
|
489
|
+
|
|
490
|
+
category_y = y_scale.map(point[:x])
|
|
491
|
+
offset = item.options[:stacked] ? 0 : (current_bar_index - ((total - 1) / 2.0)) * band
|
|
492
|
+
marks << { type: :rect, class: "bar-mark", series_index: index, x: [value, zero].min, y: category_y - (band / 2) + offset, width: (value - zero).abs, height: band,
|
|
493
|
+
fill: color }
|
|
494
|
+
else
|
|
495
|
+
band = x_scale.bandwidth * (item.options[:stacked] ? 0.78 : 0.82 / total)
|
|
496
|
+
x = x_scale.map(point[:x])
|
|
497
|
+
value = point[:y]
|
|
498
|
+
stack = value.negative? ? negative_stack : positive_stack
|
|
499
|
+
base = item.options[:stacked] ? stack[point[:x]] : 0
|
|
500
|
+
stack[point[:x]] = base + value if item.options[:stacked]
|
|
501
|
+
y0 = y_scale.map(base)
|
|
502
|
+
y1 = y_scale.map(base + value)
|
|
503
|
+
offset = item.options[:stacked] ? 0 : (current_bar_index - ((total - 1) / 2.0)) * band
|
|
504
|
+
marks << { type: :rect, class: "bar-mark", series_index: index, x: x - (band / 2) + offset, y: [y0, y1].min, width: [band - 1, 1].max, height: (y0 - y1).abs,
|
|
505
|
+
fill: color }
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
next
|
|
509
|
+
end
|
|
510
|
+
drawable = points.map do |point|
|
|
511
|
+
px = x_scale.map(point[:x])
|
|
512
|
+
py = y_scale.map(point[:y])
|
|
513
|
+
px && py && [px, py, point]
|
|
514
|
+
end
|
|
515
|
+
case item.type
|
|
516
|
+
when :line, :step
|
|
517
|
+
segments = drawable_segments(drawable, item.options[:gaps] == :connect)
|
|
518
|
+
segments.each do |segment|
|
|
519
|
+
coordinates = segment.each_with_index.flat_map do |(px, py, _point), point_index|
|
|
520
|
+
if item.type == :step && point_index.positive?
|
|
521
|
+
[[px, segment[point_index - 1][1]], [px, py]]
|
|
522
|
+
else
|
|
523
|
+
[[px, py]]
|
|
524
|
+
end
|
|
525
|
+
end
|
|
526
|
+
marks << { type: :line, class: "mark-line", series_index: index, points: coordinates, stroke: color, width: item.options[:width] || 2, dash: item.options[:dash] }
|
|
527
|
+
end
|
|
528
|
+
when :area
|
|
529
|
+
drawable_segments(drawable, false).each do |segment|
|
|
530
|
+
next if segment.empty?
|
|
531
|
+
|
|
532
|
+
zero = y_scale.map(y_scale.is_a?(Scales::Log) ? y_scale.domain.first : 0)
|
|
533
|
+
polygon = [[segment.first[0], zero]] + segment.map { |px, py, _| [px, py] } + [[segment.last[0], zero]]
|
|
534
|
+
marks << { type: :polygon, class: "mark-area", series_index: index, points: polygon, fill: color, opacity: 0.28 }
|
|
535
|
+
marks << { type: :line, class: "series-area-line", series_index: index, points: segment.map { |px, py, _| [px, py] }, stroke: color, width: 2 }
|
|
536
|
+
end
|
|
537
|
+
when :scatter
|
|
538
|
+
valid = drawable.compact
|
|
539
|
+
range = valid.filter_map { |_, _, point| point[:size] }.minmax
|
|
540
|
+
valid.each do |px, py, point|
|
|
541
|
+
radius = 4
|
|
542
|
+
radius += (point[:size] - range[0]) / (range[1] - range[0]) * 5 if point[:size] && range && range[1] > range[0]
|
|
543
|
+
marks << { type: :circle, class: "series-point", series_index: index, cx: px, cy: py, r: radius, fill: color, stroke: colors[:background], stroke_width: 1 }
|
|
544
|
+
end
|
|
545
|
+
end
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
builder.notes.each do |note|
|
|
549
|
+
color = note[:color] || colors[:axis]
|
|
550
|
+
case note[:type]
|
|
551
|
+
when :hline
|
|
552
|
+
y = y_scale.map(note[:value])
|
|
553
|
+
marks << { type: :line, class: "rule-line", points: [[left, y], [width - right, y]], stroke: color, width: 1.5, dash: note[:dash], label: note[:label] } if y
|
|
554
|
+
when :vline
|
|
555
|
+
x = x_scale.map(note[:value])
|
|
556
|
+
marks << { type: :line, class: "rule-line", points: [[x, top], [x, height - bottom]], stroke: color, width: 1.5, dash: note[:dash] } if x
|
|
557
|
+
end
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
if legend_items.length > 1 || (legend_items.length == 1 && builder.legend_options[:show_single])
|
|
561
|
+
x, y = legend_position(builder.legend_options[:position], clip, legend_items.length, legend_width)
|
|
562
|
+
legend_items.each_with_index do |(label, color), index|
|
|
563
|
+
current_y = y + (index * 18)
|
|
564
|
+
elements << { type: :line, points: [[x, current_y - 4], [x + 16, current_y - 4]], stroke: color, width: 2 }
|
|
565
|
+
elements << { type: :text, x: x + 22, y: current_y, text: label, fill: colors[:foreground], anchor: :start, size: 11 }
|
|
566
|
+
end
|
|
567
|
+
end
|
|
568
|
+
Scene.new(width:, height:, background: colors[:background], clip:, marks:, elements:)
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
def build_scale(values, options, axis, series)
|
|
572
|
+
explicit = options[:scale] || options[:type]
|
|
573
|
+
horizontal_bar = series.any? { |item| item.type == :bar && item.options[:horizontal] }
|
|
574
|
+
bar_band = series.any? { |item| item.type == :bar && ((item.options[:horizontal] && axis == :y) || (!item.options[:horizontal] && axis == :x)) }
|
|
575
|
+
if explicit == :band || (explicit.nil? && (bar_band || values.any? { |value| !Data.number(value) }))
|
|
576
|
+
Scales::Band.new(values, options)
|
|
577
|
+
elsif explicit == :log
|
|
578
|
+
warn "Inkplot ignores zero and negative values on a log axis." if values.any? { |value| (number = Data.number(value)) && !number.positive? }
|
|
579
|
+
Scales::Log.new(values, options)
|
|
580
|
+
else
|
|
581
|
+
raise ArgumentError, "unknown #{axis}-axis scale: #{explicit}" if explicit && explicit != :linear
|
|
582
|
+
|
|
583
|
+
include_zero = series.any? do |item|
|
|
584
|
+
(axis == :y && %i[bar area].include?(item.type) && !item.options[:horizontal]) ||
|
|
585
|
+
(horizontal_bar && axis == :x && item.options[:horizontal])
|
|
586
|
+
end
|
|
587
|
+
Scales::Linear.new(values, options, include_zero: include_zero)
|
|
588
|
+
end
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
def ticks(scale)
|
|
592
|
+
scale.ticks
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
def stacked_totals(series)
|
|
596
|
+
series.select { |item| item.type == :bar && item.options[:stacked] }
|
|
597
|
+
.flat_map(&:points).group_by { |point| point[:x] }.values.flat_map do |points|
|
|
598
|
+
[points.sum { |point| [Data.number(point[:y]) || 0, 0].max }, points.sum { |point| [Data.number(point[:y]) || 0, 0].min }]
|
|
599
|
+
end
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
def drawable_segments(points, connect)
|
|
603
|
+
values = connect ? points.compact : points
|
|
604
|
+
values.each_with_object([[]]) do |point, segments|
|
|
605
|
+
if point
|
|
606
|
+
segments.last << point
|
|
607
|
+
elsif !connect && !segments.last.empty?
|
|
608
|
+
segments << []
|
|
609
|
+
end
|
|
610
|
+
end.reject(&:empty?)
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
def legend_position(position, clip, count, legend_width = 120)
|
|
614
|
+
x0, y0, x1, y1 = clip
|
|
615
|
+
case position.to_sym
|
|
616
|
+
when :top_left then [x0 + 8, y0 + 18]
|
|
617
|
+
when :bottom_left then [x0 + 8, y1 - (count * 18)]
|
|
618
|
+
when :bottom_right then [x1 - legend_width + 8, y1 - (count * 18)]
|
|
619
|
+
when :top then [((x0 + x1) / 2.0) - (legend_width / 2), y0 + 16]
|
|
620
|
+
else [x1 - legend_width + 8, y0 + 18]
|
|
621
|
+
end
|
|
622
|
+
end
|
|
623
|
+
end
|
|
624
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
|
|
5
|
+
module Inkplot
|
|
6
|
+
module Renderers
|
|
7
|
+
module SVG
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def render(scene)
|
|
11
|
+
width = scene.width
|
|
12
|
+
height = scene.height
|
|
13
|
+
x, y, right, bottom = scene.clip
|
|
14
|
+
output = [%(<?xml version="1.0" encoding="UTF-8"?>),
|
|
15
|
+
%(<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 #{width} #{height}" width="#{width}" height="#{height}" role="img" class="inkplot">),
|
|
16
|
+
%(<defs><clipPath id="plot-clip"><rect x="#{n(x)}" y="#{n(y)}" width="#{n(right - x)}" height="#{n(bottom - y)}"/></clipPath></defs>)]
|
|
17
|
+
output.concat(scene.elements.map { |element| element_svg(element) }.compact)
|
|
18
|
+
output << %(<g class="series-marks" clip-path="url(#plot-clip)">)
|
|
19
|
+
current_series = nil
|
|
20
|
+
scene.marks.each do |mark|
|
|
21
|
+
content = element_svg(mark)
|
|
22
|
+
next unless content
|
|
23
|
+
|
|
24
|
+
if mark[:series_index] != current_series
|
|
25
|
+
output << "</g>" if current_series
|
|
26
|
+
current_series = mark[:series_index]
|
|
27
|
+
output << %(<g class="series series-#{current_series}" data-series="#{current_series}">) if current_series
|
|
28
|
+
end
|
|
29
|
+
output << content
|
|
30
|
+
end
|
|
31
|
+
output << "</g>" if current_series
|
|
32
|
+
output << "</g>"
|
|
33
|
+
output << "</svg>"
|
|
34
|
+
output.join
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def element_svg(element)
|
|
38
|
+
case element[:type]
|
|
39
|
+
when :rect
|
|
40
|
+
klass = element[:class] ? %( class="#{attr(element[:class])}") : ""
|
|
41
|
+
%(<rect#{klass} x="#{n(element[:x])}" y="#{n(element[:y])}" width="#{n(element[:width])}" height="#{n(element[:height])}" fill="#{attr(element[:fill])}"/>)
|
|
42
|
+
when :line
|
|
43
|
+
points = element[:points]
|
|
44
|
+
return if points.length < 2
|
|
45
|
+
|
|
46
|
+
d = "M #{n(points[0][0])} #{n(points[0][1])} " + points.drop(1).map { |px, py| "L #{n(px)} #{n(py)}" }.join(" ")
|
|
47
|
+
dash = element[:dash] ? %( stroke-dasharray="5 4") : ""
|
|
48
|
+
%(<path class="#{attr(element[:class] || 'mark-line')}" d="#{d}" fill="none" stroke="#{attr(element[:stroke])}" stroke-width="#{n(element[:width] || 1)}"#{dash}/>)
|
|
49
|
+
when :polygon
|
|
50
|
+
points = element[:points]
|
|
51
|
+
commands = points.drop(1).map { |px, py| "L #{n(px)} #{n(py)}" }.join(" ")
|
|
52
|
+
d = "M #{n(points[0][0])} #{n(points[0][1])} #{commands} Z"
|
|
53
|
+
%(<path class="#{attr(element[:class] || 'mark-area')}" d="#{d}" fill="#{attr(element[:fill])}" fill-opacity="#{n(element[:opacity] || 1)}"/>)
|
|
54
|
+
when :circle
|
|
55
|
+
%(<circle class="#{attr(element[:class] || 'mark-point')}" cx="#{n(element[:cx])}" cy="#{n(element[:cy])}" r="#{n(element[:r])}" fill="#{attr(element[:fill])}" stroke="#{attr(element[:stroke])}" stroke-width="#{n(element[:stroke_width] || 0)}"/>)
|
|
56
|
+
when :text
|
|
57
|
+
text = CGI.escapeHTML(element[:text].to_s)
|
|
58
|
+
rotate = element[:rotate] ? %( transform="rotate(#{n(element[:rotate])} #{n(element[:x])} #{n(element[:y])})") : ""
|
|
59
|
+
anchor = { start: "start", middle: "middle", end: "end" }.fetch(element[:anchor] || :start)
|
|
60
|
+
%(<text x="#{n(element[:x])}" y="#{n(element[:y])}" fill="#{attr(element[:fill])}" text-anchor="#{anchor}" font-size="#{n(element[:size] || 12)}" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'"#{rotate}>#{text}</text>)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def attr(value)
|
|
65
|
+
CGI.escapeHTML(value.to_s)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def n(value)
|
|
69
|
+
number = Float(value || 0)
|
|
70
|
+
formatted = format("%.2f", number).sub(/0+\z/, "").sub(/\.\z/, "")
|
|
71
|
+
formatted == "-0" ? "0" : formatted
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|