inkplot 0.3.0 → 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 +4 -4
- data/.codespellignore +0 -0
- data/.yamllint +16 -0
- data/CHANGELOG.md +16 -1
- data/LICENSE.txt +1 -1
- data/README.md +43 -67
- data/bench/inkplot.rb +12 -3
- data/docs/index.html +31 -0
- data/examples/gallery/01-line.svg +1 -1
- data/examples/gallery/02-line-gaps.svg +1 -1
- data/examples/gallery/03-series.svg +1 -1
- data/examples/gallery/09-histogram.svg +1 -0
- data/examples/gallery/12-time.svg +1 -0
- data/examples/gallery/13-annotations.svg +1 -0
- data/examples/gallery/14-dark-theme.svg +1 -0
- data/examples/gallery/15-category-labels.svg +1 -0
- data/examples/gallery/16-custom-bins.svg +1 -0
- data/examples/gallery/17-connected-gaps.svg +1 -0
- data/examples/gallery/18-step-series.svg +1 -0
- data/examples/gallery/19-combined-marks.svg +1 -0
- data/examples/gallery/20-scatter-sizes.svg +1 -0
- data/examples/gallery/README.md +7 -7
- data/examples/gallery.rb +26 -44
- data/lib/inkplot/core.rb +351 -146
- data/lib/inkplot/renderers/png.rb +390 -0
- data/lib/inkplot/renderers/svg.rb +3 -1
- data/lib/inkplot/version.rb +1 -1
- data/lib/inkplot.rb +3 -3
- data/sig/inkplot.rbs +7 -6
- data/test/snapshots/inkplot/01-line.png +0 -0
- data/test/snapshots/inkplot/02-line-gaps.png +0 -0
- data/test/snapshots/inkplot/03-series.png +0 -0
- data/test/snapshots/inkplot/04-scatter.png +0 -0
- data/test/snapshots/inkplot/05-bars.png +0 -0
- data/test/snapshots/inkplot/06-horizontal-bars.png +0 -0
- data/test/snapshots/inkplot/07-grouped-bars.png +0 -0
- data/test/snapshots/inkplot/08-stacked-bars.png +0 -0
- data/test/snapshots/inkplot/09-histogram.png +0 -0
- data/test/snapshots/inkplot/10-area.png +0 -0
- data/test/snapshots/inkplot/11-step.png +0 -0
- data/test/snapshots/inkplot/12-time.png +0 -0
- data/test/snapshots/inkplot/13-annotations.png +0 -0
- data/test/snapshots/inkplot/14-dark-theme.png +0 -0
- data/test/snapshots/inkplot/15-category-labels.png +0 -0
- data/test/snapshots/inkplot/16-custom-bins.png +0 -0
- data/test/snapshots/inkplot/17-connected-gaps.png +0 -0
- data/test/snapshots/inkplot/18-step-series.png +0 -0
- data/test/snapshots/inkplot/19-combined-marks.png +0 -0
- data/test/snapshots/inkplot/20-scatter-sizes.png +0 -0
- metadata +41 -18
- data/examples/gallery/09-signed-stack.svg +0 -1
- data/examples/gallery/12-histogram.svg +0 -1
- data/examples/gallery/13-log-scale.svg +0 -1
- data/examples/gallery/14-time-axis.svg +0 -1
- data/examples/gallery/15-category-order.svg +0 -1
- data/examples/gallery/16-dark-theme.svg +0 -1
- data/examples/gallery/17-annotations.svg +0 -1
- data/examples/gallery/18-combo.svg +0 -1
- data/examples/gallery/19-legend-position.svg +0 -1
- data/examples/gallery/20-long-categories.svg +0 -1
- data/lib/inkplot/renderers/raster.rb +0 -322
data/lib/inkplot/core.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
3
5
|
module Inkplot
|
|
4
6
|
module Data
|
|
5
7
|
module_function
|
|
@@ -58,13 +60,82 @@ module Inkplot
|
|
|
58
60
|
rescue NoMethodError
|
|
59
61
|
nil
|
|
60
62
|
end
|
|
63
|
+
end
|
|
61
64
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
module Histogram
|
|
66
|
+
module_function
|
|
67
|
+
|
|
68
|
+
def points(values, bins)
|
|
69
|
+
values = Array(values).filter_map { |value| Data.number(value) }
|
|
70
|
+
raise ArgumentError, "histogram needs at least one finite numeric value" if values.empty?
|
|
71
|
+
|
|
72
|
+
edges = edges(values, bins)
|
|
73
|
+
counts = Array.new(edges.length - 1, 0)
|
|
74
|
+
values.each do |value|
|
|
75
|
+
upper = edges.bsearch_index { |edge| edge > value }
|
|
76
|
+
index = value == edges.last ? counts.length - 1 : upper && (upper - 1)
|
|
77
|
+
counts[index] += 1 if index&.between?(0, counts.length - 1)
|
|
78
|
+
end
|
|
79
|
+
counts.each_index.map do |index|
|
|
80
|
+
left, right = edges[index, 2]
|
|
81
|
+
span = right - left
|
|
82
|
+
center = span.finite? ? left + (span / 2.0) : (left / 2.0) + (right / 2.0)
|
|
83
|
+
{ x: center, x0: left, x1: right, y: counts[index] }
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def edges(values, bins)
|
|
88
|
+
return validate_edges(bins) if bins.is_a?(Array)
|
|
89
|
+
|
|
90
|
+
low, high = values.minmax
|
|
91
|
+
if low == high
|
|
92
|
+
lower = low - 0.5
|
|
93
|
+
upper = high + 0.5
|
|
94
|
+
low = lower.finite? && lower < low ? lower : low.prev_float
|
|
95
|
+
high = upper.finite? && upper > high ? upper : high.next_float
|
|
96
|
+
low = values.first unless low.finite?
|
|
97
|
+
high = values.first unless high.finite?
|
|
98
|
+
end
|
|
99
|
+
count = if bins == :auto
|
|
100
|
+
sturges = (Math.log2(values.length) + 1).ceil
|
|
101
|
+
width = 2 * (quantile(values, 0.75) - quantile(values, 0.25)) / (values.length**(1.0 / 3))
|
|
102
|
+
fd_count = ((high - low) / width).ceil if width.positive? && width.finite?
|
|
103
|
+
fd_count && fd_count <= values.length ? fd_count : sturges
|
|
104
|
+
elsif bins.is_a?(Integer) && bins.positive?
|
|
105
|
+
bins
|
|
106
|
+
else
|
|
107
|
+
raise ArgumentError, "bins must be :auto, a positive Integer, or an increasing edge Array"
|
|
108
|
+
end
|
|
109
|
+
count = count.clamp(1, values.length) if bins == :auto
|
|
110
|
+
span = high - low
|
|
111
|
+
Array.new(count + 1) do |index|
|
|
112
|
+
if index.zero?
|
|
113
|
+
low
|
|
114
|
+
elsif index == count
|
|
115
|
+
high
|
|
116
|
+
else
|
|
117
|
+
fraction = index.to_f / count
|
|
118
|
+
span.finite? ? low + (span * fraction) : (low * (1 - fraction)) + (high * fraction)
|
|
119
|
+
end
|
|
120
|
+
end.uniq
|
|
121
|
+
end
|
|
122
|
+
private_class_method :edges
|
|
123
|
+
|
|
124
|
+
def validate_edges(values)
|
|
125
|
+
edges = values.map { |value| Data.number(value) }
|
|
126
|
+
raise ArgumentError, "histogram bin edges must be finite and strictly increasing" unless edges.length >= 2 && edges.none?(&:nil?) && edges.each_cons(2).all? { |left, right| left < right }
|
|
127
|
+
|
|
128
|
+
edges
|
|
129
|
+
end
|
|
130
|
+
private_class_method :validate_edges
|
|
131
|
+
|
|
132
|
+
def quantile(values, probability)
|
|
133
|
+
sorted = values.sort
|
|
134
|
+
position = (sorted.length - 1) * probability
|
|
135
|
+
low = position.floor
|
|
136
|
+
sorted[low] + ((sorted[position.ceil] - sorted[low]) * (position - low))
|
|
67
137
|
end
|
|
138
|
+
private_class_method :quantile
|
|
68
139
|
end
|
|
69
140
|
|
|
70
141
|
module Ticks
|
|
@@ -72,6 +143,7 @@ module Inkplot
|
|
|
72
143
|
|
|
73
144
|
def linear(minimum, maximum, count: 5)
|
|
74
145
|
return [minimum] if minimum >= maximum
|
|
146
|
+
return [minimum, 0.0, maximum] unless (maximum - minimum).finite?
|
|
75
147
|
|
|
76
148
|
step = linear_step(minimum, maximum, count:)
|
|
77
149
|
first = (minimum / step).ceil * step
|
|
@@ -88,8 +160,12 @@ module Inkplot
|
|
|
88
160
|
def linear_step(minimum, maximum, count: 5)
|
|
89
161
|
return 1.0 unless maximum > minimum
|
|
90
162
|
|
|
91
|
-
|
|
163
|
+
span = (maximum - minimum).to_f
|
|
164
|
+
raw = span / [count - 1, 1].max
|
|
165
|
+
raw = span if raw.zero?
|
|
92
166
|
power = 10.0**Math.log10(raw).floor
|
|
167
|
+
return raw if power.zero?
|
|
168
|
+
|
|
93
169
|
fraction = raw / power
|
|
94
170
|
(if fraction <= 1
|
|
95
171
|
1
|
|
@@ -112,41 +188,83 @@ module Inkplot
|
|
|
112
188
|
end
|
|
113
189
|
|
|
114
190
|
def time(minimum, maximum, offset: 0)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
ticks = if step < 2_592_000
|
|
119
|
-
first = (minimum / step).ceil * step
|
|
120
|
-
(0..10).map { |index| first + (index * step) }.take_while { |value| value <= maximum }
|
|
121
|
-
elsif step < 31_536_000
|
|
122
|
-
time = Time.at(minimum).getlocal(offset)
|
|
123
|
-
month = time.month
|
|
124
|
-
month += 1 while Time.new(time.year, month, 1, 0, 0, 0, offset).to_f < minimum
|
|
125
|
-
month_ticks = Array.new(8) do |index|
|
|
126
|
-
absolute = (time.year * 12) + month - 1 + (index * (step / 2_592_000).round)
|
|
127
|
-
Time.new(absolute / 12, (absolute % 12) + 1, 1, 0, 0, 0, offset).to_f
|
|
128
|
-
end
|
|
129
|
-
month_ticks.take_while { |value| value <= maximum }
|
|
191
|
+
unit, amount = time_interval(maximum - minimum)
|
|
192
|
+
ticks = if %i[month year].include?(unit)
|
|
193
|
+
calendar_ticks(minimum, maximum, offset, unit, amount)
|
|
130
194
|
else
|
|
131
|
-
|
|
132
|
-
year = time.year
|
|
133
|
-
year += 1 while Time.new(year, 1, 1, 0, 0, 0, offset).to_f < minimum
|
|
134
|
-
Array.new(8) { |index| Time.new(year + (index * (step / 31_536_000).round), 1, 1, 0, 0, 0, offset).to_f }.take_while { |value| value <= maximum }
|
|
195
|
+
time_ticks(minimum, maximum, offset, unit, amount)
|
|
135
196
|
end
|
|
136
|
-
ticks.empty? ? [minimum, maximum].uniq : ticks
|
|
197
|
+
ticks.empty? ? [zoned_time(minimum, offset), zoned_time(maximum, offset)].uniq : ticks
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def time_interval(span)
|
|
201
|
+
intervals = [
|
|
202
|
+
[:second, 1, 1], [:second, 5, 5], [:second, 15, 15], [:second, 30, 30],
|
|
203
|
+
[:minute, 1, 60], [:minute, 5, 300], [:minute, 15, 900], [:minute, 30, 1800],
|
|
204
|
+
[:hour, 1, 3600], [:hour, 3, 10_800], [:hour, 6, 21_600], [:hour, 12, 43_200],
|
|
205
|
+
[:day, 1, 86_400], [:day, 2, 172_800], [:week, 1, 604_800], [:week, 2, 1_209_600],
|
|
206
|
+
[:month, 1, 2_629_746], [:month, 2, 5_259_492], [:month, 3, 7_889_238], [:month, 6, 15_778_476],
|
|
207
|
+
[:year, 1, 31_556_952], [:year, 2, 63_113_904], [:year, 5, 157_784_760], [:year, 10, 315_569_520]
|
|
208
|
+
]
|
|
209
|
+
unit, amount, = intervals.min_by { |_, _, seconds| Math.log(seconds / [span / 5.0, 1].max).abs }
|
|
210
|
+
[unit, amount]
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def time_ticks(minimum, maximum, offset, unit, amount)
|
|
214
|
+
seconds = { second: 1, minute: 60, hour: 3600, day: 86_400, week: 604_800 }.fetch(unit) * amount
|
|
215
|
+
anchor = unit == :week ? 345_600 : 0 # 1970-01-05, a Monday.
|
|
216
|
+
zone_offset = offset || Time.at(minimum).localtime.utc_offset
|
|
217
|
+
first = (((minimum + zone_offset - anchor) / seconds).ceil * seconds) + anchor - zone_offset
|
|
218
|
+
values = []
|
|
219
|
+
value = first
|
|
220
|
+
while value <= maximum && values.length < 1000
|
|
221
|
+
values << zoned_time(value, offset)
|
|
222
|
+
value += seconds
|
|
223
|
+
end
|
|
224
|
+
values
|
|
225
|
+
end
|
|
226
|
+
private_class_method :time_ticks
|
|
227
|
+
|
|
228
|
+
def calendar_ticks(minimum, maximum, offset, unit, amount)
|
|
229
|
+
first = zoned_time(minimum, offset)
|
|
230
|
+
last = zoned_time(maximum, offset)
|
|
231
|
+
if unit == :month
|
|
232
|
+
month_index = (first.year * 12) + first.month - 1
|
|
233
|
+
month_index = (month_index.to_f / amount).ceil * amount
|
|
234
|
+
values = []
|
|
235
|
+
while month_index <= (last.year * 12) + last.month - 1 && values.length < 1000
|
|
236
|
+
year, month = month_index.divmod(12)
|
|
237
|
+
values << time_at(year, month + 1, 1, offset)
|
|
238
|
+
month_index += amount
|
|
239
|
+
end
|
|
240
|
+
else
|
|
241
|
+
year = ((first.year.to_f / amount).ceil * amount).to_i
|
|
242
|
+
values = []
|
|
243
|
+
while year <= last.year && values.length < 1000
|
|
244
|
+
values << time_at(year, 1, 1, offset)
|
|
245
|
+
year += amount
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
values
|
|
137
249
|
end
|
|
250
|
+
private_class_method :calendar_ticks
|
|
138
251
|
|
|
139
|
-
def
|
|
140
|
-
time = Time.at(
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
252
|
+
def zoned_time(timestamp, offset)
|
|
253
|
+
time = Time.at(timestamp)
|
|
254
|
+
offset.nil? ? time.localtime : time.getlocal(offset)
|
|
255
|
+
end
|
|
256
|
+
private_class_method :zoned_time
|
|
257
|
+
|
|
258
|
+
def time_at(year, month, day, offset)
|
|
259
|
+
return Time.local(year, month, day) if offset.nil?
|
|
145
260
|
|
|
146
|
-
|
|
261
|
+
Time.new(year, month, day, 0, 0, 0, offset)
|
|
147
262
|
end
|
|
263
|
+
private_class_method :time_at
|
|
148
264
|
|
|
149
265
|
def number_label(value)
|
|
266
|
+
return format("%.8g", value) if !value.zero? && value.abs < 1e-6
|
|
267
|
+
|
|
150
268
|
value.to_i == value ? value.to_i.to_s : format("%.8f", value).sub(/0+\z/, "").sub(/\.\z/, "")
|
|
151
269
|
end
|
|
152
270
|
end
|
|
@@ -175,9 +293,11 @@ module Inkplot
|
|
|
175
293
|
raise ArgumentError, "linear axis minimum must be less than maximum" unless low < high
|
|
176
294
|
|
|
177
295
|
@ticks = Ticks.linear(low, high)
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
296
|
+
if (high - low).finite?
|
|
297
|
+
step = Ticks.linear_step(low, high)
|
|
298
|
+
low = (low / step).floor * step if options[:min].nil?
|
|
299
|
+
high = (high / step).ceil * step if options[:max].nil?
|
|
300
|
+
end
|
|
181
301
|
@domain = [low, high]
|
|
182
302
|
end
|
|
183
303
|
|
|
@@ -185,7 +305,14 @@ module Inkplot
|
|
|
185
305
|
value = Data.number(value)
|
|
186
306
|
return nil unless value
|
|
187
307
|
|
|
188
|
-
|
|
308
|
+
span = @domain[1] - @domain[0]
|
|
309
|
+
fraction = (value - @domain[0]) / span
|
|
310
|
+
unless span.finite?
|
|
311
|
+
numerator = (value / 2.0) - (@domain[0] / 2.0)
|
|
312
|
+
denominator = (@domain[1] / 2.0) - (@domain[0] / 2.0)
|
|
313
|
+
fraction = numerator / denominator
|
|
314
|
+
end
|
|
315
|
+
@range[0] + (fraction * (@range[1] - @range[0]))
|
|
189
316
|
end
|
|
190
317
|
|
|
191
318
|
def format(value) = Ticks.number_label(value)
|
|
@@ -266,48 +393,127 @@ module Inkplot
|
|
|
266
393
|
def format(value) = value.to_s
|
|
267
394
|
end
|
|
268
395
|
|
|
269
|
-
class
|
|
270
|
-
attr_reader :domain, :ticks
|
|
396
|
+
class Time
|
|
397
|
+
attr_reader :domain, :ticks
|
|
271
398
|
attr_accessor :range
|
|
272
399
|
|
|
273
400
|
def initialize(values, options = {})
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
raise ArgumentError, "time axis contains mixed UTC offsets" if offsets.length > 1
|
|
401
|
+
values = values.compact
|
|
402
|
+
raise ArgumentError, "time axis needs Time or Date values" unless values.all? { |value| value.is_a?(::Time) || value.is_a?(Date) }
|
|
277
403
|
|
|
278
|
-
|
|
279
|
-
|
|
404
|
+
times = values.grep(::Time)
|
|
405
|
+
zone_names = times.map(&:zone).uniq
|
|
406
|
+
offsets = values.filter_map { |value| time_offset(value) }.uniq
|
|
407
|
+
@local_time = !times.empty? && values.none?(DateTime) && times.all? { |value| local_time?(value) }
|
|
408
|
+
raise ArgumentError, "time axis values must use the same time zone" if !@local_time && (offsets.length > 1 || zone_names.length > 1)
|
|
409
|
+
|
|
410
|
+
@offset = @local_time ? nil : offsets.first || 0
|
|
411
|
+
numbers = values.map { |value| timestamp(value) }
|
|
280
412
|
low, high = numbers.minmax
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
413
|
+
raise ArgumentError, "time axis needs at least one date or time value" unless low
|
|
414
|
+
|
|
415
|
+
if options[:min]
|
|
416
|
+
validate_bound(options[:min], "minimum")
|
|
417
|
+
|
|
418
|
+
low = timestamp(options[:min])
|
|
419
|
+
raise ArgumentError, "time axis minimum must be a Time or Date" unless low
|
|
420
|
+
end
|
|
421
|
+
if options[:max]
|
|
422
|
+
validate_bound(options[:max], "maximum")
|
|
423
|
+
|
|
424
|
+
high = timestamp(options[:max])
|
|
425
|
+
raise ArgumentError, "time axis maximum must be a Time or Date" unless high
|
|
426
|
+
end
|
|
427
|
+
if low == high
|
|
428
|
+
raise ArgumentError, "time axis minimum must be less than maximum" if options[:min] && options[:max]
|
|
429
|
+
|
|
430
|
+
if options[:min]
|
|
431
|
+
high += 3600
|
|
432
|
+
elsif options[:max]
|
|
433
|
+
low -= 3600
|
|
434
|
+
else
|
|
435
|
+
low -= 1800
|
|
436
|
+
high += 1800
|
|
437
|
+
end
|
|
438
|
+
end
|
|
285
439
|
raise ArgumentError, "time axis minimum must be less than maximum" unless low < high
|
|
286
440
|
|
|
287
|
-
@ticks = Ticks.time(low, high, offset: @offset)
|
|
288
441
|
@domain = [low, high]
|
|
442
|
+
@tick_unit, = Ticks.time_interval(high - low)
|
|
443
|
+
@ticks = Ticks.time(low, high, offset: @offset)
|
|
289
444
|
end
|
|
290
445
|
|
|
291
446
|
def map(value)
|
|
292
|
-
|
|
293
|
-
return nil unless
|
|
294
|
-
|
|
447
|
+
number = timestamp(value)
|
|
448
|
+
return nil unless number
|
|
449
|
+
|
|
450
|
+
@range[0] + ((number - @domain[0]) * (@range[1] - @range[0]) / (@domain[1] - @domain[0]))
|
|
451
|
+
rescue ArgumentError, TypeError
|
|
452
|
+
nil
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def format(value)
|
|
456
|
+
time = if value.is_a?(::Time)
|
|
457
|
+
@local_time ? value.localtime : value.getlocal(@offset)
|
|
458
|
+
else
|
|
459
|
+
as_time(value)
|
|
460
|
+
end
|
|
461
|
+
format_string = case @tick_unit
|
|
462
|
+
when :second then "%H:%M:%S"
|
|
463
|
+
when :minute, :hour then "%H:%M"
|
|
464
|
+
when :day, :week then "%Y-%m-%d"
|
|
465
|
+
when :month then "%Y-%m"
|
|
466
|
+
when :year then "%Y"
|
|
467
|
+
end
|
|
468
|
+
time.strftime(format_string)
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
private
|
|
295
472
|
|
|
296
|
-
|
|
473
|
+
def time_offset(value)
|
|
474
|
+
return value.utc_offset if value.is_a?(::Time)
|
|
475
|
+
return (value.offset * 86_400).to_i if value.is_a?(DateTime)
|
|
476
|
+
|
|
477
|
+
nil
|
|
297
478
|
end
|
|
298
479
|
|
|
299
|
-
def
|
|
480
|
+
def local_time?(value)
|
|
481
|
+
::Time.at(value.to_r).localtime.zone == value.zone
|
|
482
|
+
end
|
|
300
483
|
|
|
301
|
-
|
|
484
|
+
def validate_bound(value, name)
|
|
485
|
+
raise ArgumentError, "time axis #{name} must be a Time or Date" unless value.is_a?(::Time) || value.is_a?(Date)
|
|
302
486
|
|
|
303
|
-
|
|
304
|
-
|
|
487
|
+
if @local_time
|
|
488
|
+
valid = value.is_a?(::Time) ? local_time?(value) : value.is_a?(Date) && !value.is_a?(DateTime)
|
|
489
|
+
raise ArgumentError, "time axis values must use the same time zone" unless valid
|
|
490
|
+
else
|
|
491
|
+
bound_offset = time_offset(value)
|
|
492
|
+
raise ArgumentError, "time axis values must use the same time zone" if bound_offset && bound_offset != @offset
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def timestamp(value)
|
|
497
|
+
return value.to_f if value.is_a?(::Time)
|
|
498
|
+
return value.to_time.to_f if value.is_a?(DateTime)
|
|
499
|
+
return date_time(value).to_f if value.is_a?(Date)
|
|
500
|
+
|
|
501
|
+
nil
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def as_time(value)
|
|
505
|
+
return ::Time.at(value.to_time.to_f).localtime if @local_time && value.is_a?(DateTime)
|
|
506
|
+
return ::Time.at(value.to_time.to_f).getlocal(@offset) if value.is_a?(DateTime)
|
|
507
|
+
return date_time(value) if value.is_a?(Date)
|
|
508
|
+
|
|
509
|
+
local_zone = ::Time.at(timestamp(value))
|
|
510
|
+
@local_time ? local_zone.localtime : local_zone.getlocal(@offset)
|
|
511
|
+
end
|
|
305
512
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
raise ArgumentError, "time axis contains mixed UTC offsets" unless time.utc_offset == @offset
|
|
513
|
+
def date_time(value)
|
|
514
|
+
return ::Time.local(value.year, value.month, value.day) if @local_time
|
|
309
515
|
|
|
310
|
-
|
|
516
|
+
::Time.new(value.year, value.month, value.day, 0, 0, 0, @offset)
|
|
311
517
|
end
|
|
312
518
|
end
|
|
313
519
|
end
|
|
@@ -330,11 +536,14 @@ module Inkplot
|
|
|
330
536
|
|
|
331
537
|
def font_for(size = 12)
|
|
332
538
|
require "glyphic"
|
|
333
|
-
|
|
539
|
+
if Inkplot.config.font
|
|
540
|
+
@fonts ||= {}
|
|
541
|
+
return @fonts[[Inkplot.config.font, size]] ||= Glyphic.load(Inkplot.config.font, size: size)
|
|
542
|
+
end
|
|
334
543
|
|
|
335
544
|
Glyphic.default
|
|
336
545
|
rescue LoadError => e
|
|
337
|
-
raise LoadError, "
|
|
546
|
+
raise LoadError, "font metrics need glyphic; install it with `gem install glyphic` (#{e.message})"
|
|
338
547
|
end
|
|
339
548
|
end
|
|
340
549
|
|
|
@@ -406,8 +615,8 @@ module Inkplot
|
|
|
406
615
|
|
|
407
616
|
def legend(position: :top_right) = @legend_options = { position: position.to_sym }
|
|
408
617
|
|
|
409
|
-
def line(x, y = nil, label: nil, color: nil, dash: false, gaps: :break, **)
|
|
410
|
-
add(:line, Data.points(x, y), label:, color:, dash:, gaps:, **)
|
|
618
|
+
def line(x, y = nil, label: nil, color: nil, dash: false, gaps: :break, cap: :butt, join: :miter, **)
|
|
619
|
+
add(:line, Data.points(x, y), label:, color:, dash:, gaps:, cap:, join:, **)
|
|
411
620
|
end
|
|
412
621
|
|
|
413
622
|
def scatter(x, y = nil, label: nil, color: nil, size: nil, **)
|
|
@@ -427,19 +636,8 @@ module Inkplot
|
|
|
427
636
|
add(:bar, points, label:, color:, horizontal:, stacked:, **)
|
|
428
637
|
end
|
|
429
638
|
|
|
430
|
-
def histogram(values, bins: :auto, label: nil, color: nil
|
|
431
|
-
|
|
432
|
-
raise ArgumentError, "histogram needs at least one numeric value" if numbers.empty?
|
|
433
|
-
|
|
434
|
-
numbers.sort!
|
|
435
|
-
edges = histogram_edges(numbers, bins)
|
|
436
|
-
counts = Array.new(edges.length - 1, 0)
|
|
437
|
-
numbers.each do |number|
|
|
438
|
-
index = edges.each_cons(2).find_index { |left, right| number >= left && (number < right || right == edges.last) }
|
|
439
|
-
counts[index] += 1 if index
|
|
440
|
-
end
|
|
441
|
-
points = counts.each_index.map { |index| { x: edges[index], x2: edges[index + 1], y: counts[index] } }
|
|
442
|
-
add(:bar, points, label:, color:, histogram: true, **)
|
|
639
|
+
def histogram(values, bins: :auto, label: nil, color: nil)
|
|
640
|
+
add(:histogram, Histogram.points(values, bins), label:, color:)
|
|
443
641
|
end
|
|
444
642
|
|
|
445
643
|
def hline(value, label: nil, color: nil, dash: true)
|
|
@@ -453,38 +651,30 @@ module Inkplot
|
|
|
453
651
|
@notes << { type: :vline, value:, label:, color: Theme.validate_color(color), dash: }
|
|
454
652
|
end
|
|
455
653
|
|
|
456
|
-
def
|
|
457
|
-
|
|
654
|
+
def annotate(x, y, text, color: nil, anchor: :start)
|
|
655
|
+
raise ArgumentError, "annotation text must not be empty" if text.to_s.empty?
|
|
656
|
+
raise ArgumentError, "annotation anchor must be :start, :middle, or :end" unless %i[start middle end].include?(anchor.to_sym)
|
|
657
|
+
|
|
658
|
+
@notes << { type: :text, x:, y:, text: text.to_s, color: Theme.validate_color(color), anchor: anchor.to_sym }
|
|
458
659
|
end
|
|
459
660
|
|
|
460
661
|
def add(type, points, label: nil, color: nil, **options)
|
|
461
|
-
|
|
662
|
+
type = type.to_sym
|
|
663
|
+
raise ArgumentError, "unsupported chart mark: #{type}" unless %i[line scatter bar area step histogram].include?(type)
|
|
462
664
|
raise ArgumentError, "gaps must be :break or :connect" if options[:gaps] && !%i[break connect].include?(options[:gaps])
|
|
463
665
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
666
|
+
if type == :line
|
|
667
|
+
options[:cap] = (options[:cap] || :butt).to_sym
|
|
668
|
+
options[:join] = (options[:join] || :miter).to_sym
|
|
669
|
+
raise ArgumentError, "line cap must be :butt or :round" unless %i[butt round].include?(options[:cap])
|
|
670
|
+
raise ArgumentError, "line join must be :miter, :round, or :bevel" unless %i[miter round bevel].include?(options[:join])
|
|
469
671
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
return [low - 0.5, high + 0.5] if low == high
|
|
473
|
-
|
|
474
|
-
if bins == :auto
|
|
475
|
-
q1 = numbers[(numbers.length * 0.25).floor]
|
|
476
|
-
q3 = numbers[(numbers.length * 0.75).floor]
|
|
477
|
-
width = 2 * (q3 - q1) / (numbers.length**(1.0 / 3))
|
|
478
|
-
count = width.positive? ? ((high - low) / width).ceil : (1 + Math.log2(numbers.length)).ceil
|
|
479
|
-
count = count.clamp(1, 512)
|
|
480
|
-
Array.new(count + 1) { |index| low + ((high - low) * index / count.to_f) }
|
|
481
|
-
elsif bins.is_a?(Integer) && bins.positive?
|
|
482
|
-
Array.new(bins + 1) { |index| low + ((high - low) * index / bins.to_f) }
|
|
483
|
-
elsif bins.is_a?(Array) && bins.length >= 2 && bins.all? { |value| Data.number(value) } && bins.each_cons(2).all? { |left, right| left < right }
|
|
484
|
-
bins.map(&:to_f)
|
|
485
|
-
else
|
|
486
|
-
raise ArgumentError, "bins must be :auto, a positive integer, or increasing edges"
|
|
672
|
+
options[:width] = Data.number(options[:width] || 2)
|
|
673
|
+
raise ArgumentError, "line width must be finite and positive" unless options[:width]&.positive?
|
|
487
674
|
end
|
|
675
|
+
|
|
676
|
+
@series << Series.new(type:, points:, label: label&.to_s, color: Theme.validate_color(color), options:)
|
|
677
|
+
self
|
|
488
678
|
end
|
|
489
679
|
end
|
|
490
680
|
|
|
@@ -502,19 +692,18 @@ module Inkplot
|
|
|
502
692
|
Renderers::SVG.render(SceneBuilder.call(self, width: width, height: height))
|
|
503
693
|
end
|
|
504
694
|
|
|
505
|
-
def to_image(scale: 1)
|
|
506
|
-
|
|
507
|
-
Renderers::Raster.render(SceneBuilder.call(self, width: width, height: height), scale: scale)
|
|
695
|
+
def to_image(width: self.width, height: self.height, scale: 1)
|
|
696
|
+
Renderers::PNG.render(SceneBuilder.call(self, width: width, height: height), scale:)
|
|
508
697
|
end
|
|
509
698
|
|
|
510
699
|
def to_png(width: self.width, height: self.height, scale: 1)
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
700
|
+
Renderers::PNG.encode(to_image(width:, height:, scale:))
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
def _repr_svg_ = to_svg
|
|
704
|
+
|
|
705
|
+
def to_inlay
|
|
706
|
+
{ svg: to_svg, png: -> { to_png } }
|
|
518
707
|
end
|
|
519
708
|
|
|
520
709
|
def save(path, width: self.width, height: self.height, scale: 1)
|
|
@@ -526,10 +715,6 @@ module Inkplot
|
|
|
526
715
|
path
|
|
527
716
|
end
|
|
528
717
|
|
|
529
|
-
def to_inlay
|
|
530
|
-
{ svg: to_svg, png: -> { to_png } }
|
|
531
|
-
end
|
|
532
|
-
|
|
533
718
|
private
|
|
534
719
|
|
|
535
720
|
def series = builder.series
|
|
@@ -549,7 +734,7 @@ module Inkplot
|
|
|
549
734
|
|
|
550
735
|
warn "Inkplot cycles its eight-color palette after eight series." if series.length > Theme::PALETTE.length
|
|
551
736
|
colors = Theme.colors(builder.theme)
|
|
552
|
-
x_values = series.flat_map { |item| item.points.flat_map { |point| [point[:x], point[:x2]].compact } }
|
|
737
|
+
x_values = series.flat_map { |item| item.points.flat_map { |point| [point[:x], point[:x0], point[:x1], point[:x2]].compact } }
|
|
553
738
|
y_values = series.flat_map { |item| item.points.map { |point| point[:y] } }
|
|
554
739
|
if series.any? { |item| item.options[:stacked] }
|
|
555
740
|
totals = stacked_totals(series)
|
|
@@ -569,14 +754,6 @@ module Inkplot
|
|
|
569
754
|
y_ticks = ticks(y_scale)
|
|
570
755
|
left = (y_ticks.map { |tick| TextMetrics.width(y_scale.format(tick), 11) }.max.to_f.ceil + 14).clamp(42, 120)
|
|
571
756
|
bottom = 34
|
|
572
|
-
rotate = x_scale.is_a?(Scales::Band) && x_ticks.any? && x_ticks.map { |tick| TextMetrics.width(x_scale.format(tick), 10) }.max > (width - left - 20) / x_ticks.length
|
|
573
|
-
if rotate
|
|
574
|
-
visible_count = [(width - left - 20) / 72, 1].max
|
|
575
|
-
stride = (x_ticks.length.to_f / visible_count).ceil
|
|
576
|
-
last_index = x_ticks.length - 1
|
|
577
|
-
x_ticks = x_ticks.each_with_index.filter_map { |tick, index| tick if (index % stride).zero? || index == last_index }
|
|
578
|
-
end
|
|
579
|
-
bottom += rotate ? 43 : 0
|
|
580
757
|
bottom += 20 if builder.x_label_text
|
|
581
758
|
left += 18 if builder.y_label_text
|
|
582
759
|
top = builder.title_text ? 40 : 18
|
|
@@ -584,6 +761,16 @@ module Inkplot
|
|
|
584
761
|
legend_items.uniq!(&:first)
|
|
585
762
|
legend_width = [legend_items.map { |label, _| TextMetrics.width(label, 11) }.max.to_f.ceil + 30, 120].max
|
|
586
763
|
right = builder.legend_options[:position].to_s.end_with?("right") && legend_items.length > 1 ? legend_width + 18 : 18
|
|
764
|
+
plot_width = width - right - left
|
|
765
|
+
tick_gap = plot_width.to_f / [x_ticks.length - 1, 1].max
|
|
766
|
+
longest_tick = x_ticks.map { |tick| TextMetrics.width(x_scale.format(tick), 10) }.max.to_f
|
|
767
|
+
tick_stride = [(longest_tick / [tick_gap, 1].max).ceil, 1].max
|
|
768
|
+
shown_ticks = x_ticks.each_index.select { |index| (index % tick_stride).zero? || index == x_ticks.length - 1 }
|
|
769
|
+
shown_gap = plot_width.to_f / [shown_ticks.length - 1, 1].max
|
|
770
|
+
rotate_ticks = height >= 180 && shown_ticks.any? { |index| TextMetrics.width(x_scale.format(x_ticks[index]), 10) > shown_gap * 0.75 }
|
|
771
|
+
bottom += 42 if rotate_ticks
|
|
772
|
+
raise ArgumentError, "chart dimensions are too small for labels or legend" if plot_width <= 0 || height - top - bottom <= 0
|
|
773
|
+
|
|
587
774
|
x_scale.range = [left, width - right]
|
|
588
775
|
y_scale.range = [height - bottom, top]
|
|
589
776
|
clip = [left, top, width - right, height - bottom]
|
|
@@ -597,13 +784,16 @@ module Inkplot
|
|
|
597
784
|
elements << { type: :line, class: "grid-line", points: [[left, y], [width - right, y]], stroke: colors[:grid], width: 1 }
|
|
598
785
|
elements << { type: :text, x: left - 8, y: y + 4, text: y_scale.format(tick), fill: colors[:axis], anchor: :end, size: 11 }
|
|
599
786
|
end
|
|
600
|
-
x_ticks.each_with_index do |tick,
|
|
787
|
+
x_ticks.each_with_index do |tick, index|
|
|
788
|
+
next unless shown_ticks.include?(index)
|
|
789
|
+
|
|
601
790
|
x = x_scale.map(tick)
|
|
602
791
|
next unless x
|
|
603
792
|
|
|
604
793
|
label = x_scale.format(tick)
|
|
605
794
|
elements << { type: :line, class: "grid-line", points: [[x, top], [x, height - bottom]], stroke: colors[:grid], width: 1 } if x_scale.is_a?(Scales::Band)
|
|
606
|
-
elements << { type: :text, x:, y: height - bottom +
|
|
795
|
+
elements << { type: :text, x:, y: height - bottom + 18, text: label, fill: colors[:axis], anchor: rotate_ticks ? :end : :middle, size: 10,
|
|
796
|
+
rotate: rotate_ticks ? -45 : nil }
|
|
607
797
|
end
|
|
608
798
|
elements << { type: :line, class: "axis-line", points: [[left, top], [left, height - bottom], [width - right, height - bottom]], stroke: colors[:axis], width: 1 }
|
|
609
799
|
elements << { type: :text, x: width / 2.0, y: 22, text: builder.title_text, fill: colors[:foreground], anchor: :middle, size: 15 } if builder.title_text
|
|
@@ -620,14 +810,27 @@ module Inkplot
|
|
|
620
810
|
series.each_with_index do |item, index|
|
|
621
811
|
color = item.color || Theme.series_color(index, builder.theme)
|
|
622
812
|
points = item.points
|
|
813
|
+
if item.type == :histogram
|
|
814
|
+
points.each do |point|
|
|
815
|
+
x0 = x_scale.map(point[:x0])
|
|
816
|
+
x1 = x_scale.map(point[:x1])
|
|
817
|
+
y0 = y_scale.map(0)
|
|
818
|
+
y1 = y_scale.map(point[:y])
|
|
819
|
+
next unless x0 && x1 && y0 && y1
|
|
820
|
+
|
|
821
|
+
marks << { type: :rect, class: "histogram-mark", series_index: index, x: x0, y: [y0, y1].min, width: x1 - x0,
|
|
822
|
+
height: (y0 - y1).abs, fill: color }
|
|
823
|
+
end
|
|
824
|
+
next
|
|
825
|
+
end
|
|
623
826
|
if item.type == :bar
|
|
624
827
|
current_bar_index = bar_index
|
|
625
828
|
bar_index += 1
|
|
829
|
+
total = [bars.length, 1].max
|
|
626
830
|
points.each do |point|
|
|
627
831
|
next unless point[:y] && point[:x]
|
|
628
832
|
|
|
629
833
|
if item.options[:horizontal]
|
|
630
|
-
total = [bars.length, 1].max
|
|
631
834
|
band = y_scale.bandwidth * 0.72
|
|
632
835
|
value = point[:y]
|
|
633
836
|
base = if item.options[:stacked]
|
|
@@ -647,15 +850,7 @@ module Inkplot
|
|
|
647
850
|
offset = item.options[:stacked] ? 0 : (current_bar_index - ((total - 1) / 2.0)) * band
|
|
648
851
|
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,
|
|
649
852
|
fill: color }
|
|
650
|
-
elsif item.options[:histogram]
|
|
651
|
-
left_edge = x_scale.map(point[:x])
|
|
652
|
-
right_edge = x_scale.map(point[:x2])
|
|
653
|
-
zero = y_scale.map(0)
|
|
654
|
-
value = y_scale.map(point[:y])
|
|
655
|
-
marks << { type: :rect, class: "bar-mark", series_index: index, x: left_edge, y: [zero, value].min, width: [right_edge - left_edge - 1, 1].max,
|
|
656
|
-
height: (zero - value).abs, fill: color }
|
|
657
853
|
else
|
|
658
|
-
total = [bars.length, 1].max
|
|
659
854
|
band = x_scale.bandwidth * (item.options[:stacked] ? 0.78 : 0.82 / total)
|
|
660
855
|
x = x_scale.map(point[:x])
|
|
661
856
|
value = point[:y]
|
|
@@ -687,7 +882,8 @@ module Inkplot
|
|
|
687
882
|
[[px, py]]
|
|
688
883
|
end
|
|
689
884
|
end
|
|
690
|
-
marks << { type: :line, class: "mark-line", series_index: index, points: coordinates, stroke: color, width: item.options[:width] || 2,
|
|
885
|
+
marks << { type: :line, class: "mark-line", series_index: index, points: coordinates, stroke: color, width: item.options[:width] || 2,
|
|
886
|
+
dash: item.options[:dash], cap: item.options[:cap], join: item.options[:join] }
|
|
691
887
|
end
|
|
692
888
|
when :area
|
|
693
889
|
drawable_segments(drawable, false).each do |segment|
|
|
@@ -714,14 +910,20 @@ module Inkplot
|
|
|
714
910
|
case note[:type]
|
|
715
911
|
when :hline
|
|
716
912
|
y = y_scale.map(note[:value])
|
|
717
|
-
|
|
913
|
+
if y
|
|
914
|
+
marks << { type: :line, class: "rule-line", points: [[left, y], [width - right, y]], stroke: color, width: 1.5, dash: note[:dash] }
|
|
915
|
+
marks << { type: :text, x: width - right - 4, y: y - 4, text: note[:label], fill: color, anchor: :end, size: 11 } if note[:label]
|
|
916
|
+
end
|
|
718
917
|
when :vline
|
|
719
918
|
x = x_scale.map(note[:value])
|
|
720
|
-
|
|
919
|
+
if x
|
|
920
|
+
marks << { type: :line, class: "rule-line", points: [[x, top], [x, height - bottom]], stroke: color, width: 1.5, dash: note[:dash] }
|
|
921
|
+
marks << { type: :text, x: x + 4, y: top + 12, text: note[:label], fill: color, anchor: :start, size: 11 } if note[:label]
|
|
922
|
+
end
|
|
721
923
|
when :text
|
|
722
924
|
x = x_scale.map(note[:x])
|
|
723
925
|
y = y_scale.map(note[:y])
|
|
724
|
-
marks << { type: :text, x:, y:, text: note[:
|
|
926
|
+
marks << { type: :text, x:, y:, text: note[:text], fill: color, anchor: note[:anchor], size: 11 } if x && y
|
|
725
927
|
end
|
|
726
928
|
end
|
|
727
929
|
|
|
@@ -739,10 +941,13 @@ module Inkplot
|
|
|
739
941
|
def build_scale(values, options, axis, series)
|
|
740
942
|
explicit = options[:scale] || options[:type]
|
|
741
943
|
horizontal_bar = series.any? { |item| item.type == :bar && item.options[:horizontal] }
|
|
742
|
-
bar_band = series.any? { |item| item.type == :bar &&
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
944
|
+
bar_band = series.any? { |item| item.type == :bar && ((item.options[:horizontal] && axis == :y) || (!item.options[:horizontal] && axis == :x)) }
|
|
945
|
+
histogram_axis = axis == :x && series.any? { |item| item.type == :histogram }
|
|
946
|
+
if histogram_axis && explicit == :band
|
|
947
|
+
raise ArgumentError, "histogram x axis must use a numeric scale"
|
|
948
|
+
elsif explicit == :time || explicit == "time" || (explicit.nil? && values.any? { |value| value.is_a?(::Time) || value.is_a?(Date) })
|
|
949
|
+
Scales::Time.new(values, options)
|
|
950
|
+
elsif explicit == :band || (explicit.nil? && !histogram_axis && (bar_band || values.any? { |value| !Data.number(value) }))
|
|
746
951
|
Scales::Band.new(values, options)
|
|
747
952
|
elsif explicit == :log
|
|
748
953
|
warn "Inkplot ignores zero and negative values on a log axis." if values.any? { |value| (number = Data.number(value)) && !number.positive? }
|
|
@@ -751,7 +956,7 @@ module Inkplot
|
|
|
751
956
|
raise ArgumentError, "unknown #{axis}-axis scale: #{explicit}" if explicit && explicit != :linear
|
|
752
957
|
|
|
753
958
|
include_zero = series.any? do |item|
|
|
754
|
-
(axis == :y && %i[bar area].include?(item.type) && !item.options[:horizontal]) ||
|
|
959
|
+
(axis == :y && %i[bar area histogram].include?(item.type) && !item.options[:horizontal]) ||
|
|
755
960
|
(horizontal_bar && axis == :x && item.options[:horizontal])
|
|
756
961
|
end
|
|
757
962
|
Scales::Linear.new(values, options, include_zero: include_zero)
|