inkplot 0.3.0 → 0.3.1
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/CHANGELOG.md +13 -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 +309 -140
- 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 +36 -15
- 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,64 @@ module Inkplot
|
|
|
58
60
|
rescue NoMethodError
|
|
59
61
|
nil
|
|
60
62
|
end
|
|
63
|
+
end
|
|
64
|
+
|
|
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?
|
|
61
71
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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)
|
|
66
78
|
end
|
|
79
|
+
counts.each_index.map { |index| { x: (edges[index] + edges[index + 1]) / 2.0, x0: edges[index], x1: edges[index + 1], y: counts[index] } }
|
|
67
80
|
end
|
|
81
|
+
|
|
82
|
+
def edges(values, bins)
|
|
83
|
+
return validate_edges(bins) if bins.is_a?(Array)
|
|
84
|
+
|
|
85
|
+
low, high = values.minmax
|
|
86
|
+
if low == high
|
|
87
|
+
low -= 0.5
|
|
88
|
+
high += 0.5
|
|
89
|
+
end
|
|
90
|
+
count = if bins == :auto
|
|
91
|
+
sturges = (Math.log2(values.length) + 1).ceil
|
|
92
|
+
width = 2 * (quantile(values, 0.75) - quantile(values, 0.25)) / (values.length**(1.0 / 3))
|
|
93
|
+
fd_count = ((high - low) / width).ceil if width.positive? && width.finite?
|
|
94
|
+
fd_count && fd_count <= values.length ? fd_count : sturges
|
|
95
|
+
elsif bins.is_a?(Integer) && bins.positive?
|
|
96
|
+
bins
|
|
97
|
+
else
|
|
98
|
+
raise ArgumentError, "bins must be :auto, a positive Integer, or an increasing edge Array"
|
|
99
|
+
end
|
|
100
|
+
count = count.clamp(1, values.length) if bins == :auto
|
|
101
|
+
width = (high - low) / count
|
|
102
|
+
Array.new(count + 1) { |index| index == count ? high : low + (index * width) }
|
|
103
|
+
end
|
|
104
|
+
private_class_method :edges
|
|
105
|
+
|
|
106
|
+
def validate_edges(values)
|
|
107
|
+
edges = values.map { |value| Data.number(value) }
|
|
108
|
+
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 }
|
|
109
|
+
|
|
110
|
+
edges
|
|
111
|
+
end
|
|
112
|
+
private_class_method :validate_edges
|
|
113
|
+
|
|
114
|
+
def quantile(values, probability)
|
|
115
|
+
sorted = values.sort
|
|
116
|
+
position = (sorted.length - 1) * probability
|
|
117
|
+
low = position.floor
|
|
118
|
+
sorted[low] + ((sorted[position.ceil] - sorted[low]) * (position - low))
|
|
119
|
+
end
|
|
120
|
+
private_class_method :quantile
|
|
68
121
|
end
|
|
69
122
|
|
|
70
123
|
module Ticks
|
|
@@ -112,39 +165,79 @@ module Inkplot
|
|
|
112
165
|
end
|
|
113
166
|
|
|
114
167
|
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 }
|
|
168
|
+
unit, amount = time_interval(maximum - minimum)
|
|
169
|
+
ticks = if %i[month year].include?(unit)
|
|
170
|
+
calendar_ticks(minimum, maximum, offset, unit, amount)
|
|
130
171
|
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 }
|
|
172
|
+
time_ticks(minimum, maximum, offset, unit, amount)
|
|
135
173
|
end
|
|
136
|
-
ticks.empty? ? [minimum, maximum].uniq : ticks
|
|
174
|
+
ticks.empty? ? [zoned_time(minimum, offset), zoned_time(maximum, offset)].uniq : ticks
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def time_interval(span)
|
|
178
|
+
intervals = [
|
|
179
|
+
[:second, 1, 1], [:second, 5, 5], [:second, 15, 15], [:second, 30, 30],
|
|
180
|
+
[:minute, 1, 60], [:minute, 5, 300], [:minute, 15, 900], [:minute, 30, 1800],
|
|
181
|
+
[:hour, 1, 3600], [:hour, 3, 10_800], [:hour, 6, 21_600], [:hour, 12, 43_200],
|
|
182
|
+
[:day, 1, 86_400], [:day, 2, 172_800], [:week, 1, 604_800], [:week, 2, 1_209_600],
|
|
183
|
+
[:month, 1, 2_629_746], [:month, 2, 5_259_492], [:month, 3, 7_889_238], [:month, 6, 15_778_476],
|
|
184
|
+
[:year, 1, 31_556_952], [:year, 2, 63_113_904], [:year, 5, 157_784_760], [:year, 10, 315_569_520]
|
|
185
|
+
]
|
|
186
|
+
unit, amount, = intervals.min_by { |_, _, seconds| Math.log(seconds / [span / 5.0, 1].max).abs }
|
|
187
|
+
[unit, amount]
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def time_ticks(minimum, maximum, offset, unit, amount)
|
|
191
|
+
seconds = { second: 1, minute: 60, hour: 3600, day: 86_400, week: 604_800 }.fetch(unit) * amount
|
|
192
|
+
anchor = unit == :week ? 345_600 : 0 # 1970-01-05, a Monday.
|
|
193
|
+
zone_offset = offset || Time.at(minimum).localtime.utc_offset
|
|
194
|
+
first = (((minimum + zone_offset - anchor) / seconds).ceil * seconds) + anchor - zone_offset
|
|
195
|
+
values = []
|
|
196
|
+
value = first
|
|
197
|
+
while value <= maximum && values.length < 1000
|
|
198
|
+
values << zoned_time(value, offset)
|
|
199
|
+
value += seconds
|
|
200
|
+
end
|
|
201
|
+
values
|
|
202
|
+
end
|
|
203
|
+
private_class_method :time_ticks
|
|
204
|
+
|
|
205
|
+
def calendar_ticks(minimum, maximum, offset, unit, amount)
|
|
206
|
+
first = zoned_time(minimum, offset)
|
|
207
|
+
last = zoned_time(maximum, offset)
|
|
208
|
+
if unit == :month
|
|
209
|
+
month_index = (first.year * 12) + first.month - 1
|
|
210
|
+
month_index = (month_index.to_f / amount).ceil * amount
|
|
211
|
+
values = []
|
|
212
|
+
while month_index <= (last.year * 12) + last.month - 1 && values.length < 1000
|
|
213
|
+
year, month = month_index.divmod(12)
|
|
214
|
+
values << time_at(year, month + 1, 1, offset)
|
|
215
|
+
month_index += amount
|
|
216
|
+
end
|
|
217
|
+
else
|
|
218
|
+
year = ((first.year.to_f / amount).ceil * amount).to_i
|
|
219
|
+
values = []
|
|
220
|
+
while year <= last.year && values.length < 1000
|
|
221
|
+
values << time_at(year, 1, 1, offset)
|
|
222
|
+
year += amount
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
values
|
|
226
|
+
end
|
|
227
|
+
private_class_method :calendar_ticks
|
|
228
|
+
|
|
229
|
+
def zoned_time(timestamp, offset)
|
|
230
|
+
time = Time.at(timestamp)
|
|
231
|
+
offset.nil? ? time.localtime : time.getlocal(offset)
|
|
137
232
|
end
|
|
233
|
+
private_class_method :zoned_time
|
|
138
234
|
|
|
139
|
-
def
|
|
140
|
-
|
|
141
|
-
return time.strftime("%Y") if span >= 31_536_000
|
|
142
|
-
return time.strftime("%Y-%m") if span >= 2_592_000
|
|
143
|
-
return time.strftime("%Y-%m-%d") if span >= 86_400
|
|
144
|
-
return time.strftime("%H:%M") if span >= 60
|
|
235
|
+
def time_at(year, month, day, offset)
|
|
236
|
+
return Time.local(year, month, day) if offset.nil?
|
|
145
237
|
|
|
146
|
-
|
|
238
|
+
Time.new(year, month, day, 0, 0, 0, offset)
|
|
147
239
|
end
|
|
240
|
+
private_class_method :time_at
|
|
148
241
|
|
|
149
242
|
def number_label(value)
|
|
150
243
|
value.to_i == value ? value.to_i.to_s : format("%.8f", value).sub(/0+\z/, "").sub(/\.\z/, "")
|
|
@@ -266,48 +359,127 @@ module Inkplot
|
|
|
266
359
|
def format(value) = value.to_s
|
|
267
360
|
end
|
|
268
361
|
|
|
269
|
-
class
|
|
270
|
-
attr_reader :domain, :ticks
|
|
362
|
+
class Time
|
|
363
|
+
attr_reader :domain, :ticks
|
|
271
364
|
attr_accessor :range
|
|
272
365
|
|
|
273
366
|
def initialize(values, options = {})
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
367
|
+
values = values.compact
|
|
368
|
+
raise ArgumentError, "time axis needs Time or Date values" unless values.all? { |value| value.is_a?(::Time) || value.is_a?(Date) }
|
|
369
|
+
|
|
370
|
+
times = values.grep(::Time)
|
|
371
|
+
zone_names = times.map(&:zone).uniq
|
|
372
|
+
offsets = values.filter_map { |value| time_offset(value) }.uniq
|
|
373
|
+
@local_time = !times.empty? && values.none?(DateTime) && times.all? { |value| local_time?(value) }
|
|
374
|
+
raise ArgumentError, "time axis values must use the same time zone" if !@local_time && (offsets.length > 1 || zone_names.length > 1)
|
|
277
375
|
|
|
278
|
-
@offset = offsets.first || 0
|
|
279
|
-
numbers =
|
|
376
|
+
@offset = @local_time ? nil : offsets.first || 0
|
|
377
|
+
numbers = values.map { |value| timestamp(value) }
|
|
280
378
|
low, high = numbers.minmax
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
379
|
+
raise ArgumentError, "time axis needs at least one date or time value" unless low
|
|
380
|
+
|
|
381
|
+
if options[:min]
|
|
382
|
+
validate_bound(options[:min], "minimum")
|
|
383
|
+
|
|
384
|
+
low = timestamp(options[:min])
|
|
385
|
+
raise ArgumentError, "time axis minimum must be a Time or Date" unless low
|
|
386
|
+
end
|
|
387
|
+
if options[:max]
|
|
388
|
+
validate_bound(options[:max], "maximum")
|
|
389
|
+
|
|
390
|
+
high = timestamp(options[:max])
|
|
391
|
+
raise ArgumentError, "time axis maximum must be a Time or Date" unless high
|
|
392
|
+
end
|
|
393
|
+
if low == high
|
|
394
|
+
raise ArgumentError, "time axis minimum must be less than maximum" if options[:min] && options[:max]
|
|
395
|
+
|
|
396
|
+
if options[:min]
|
|
397
|
+
high += 3600
|
|
398
|
+
elsif options[:max]
|
|
399
|
+
low -= 3600
|
|
400
|
+
else
|
|
401
|
+
low -= 1800
|
|
402
|
+
high += 1800
|
|
403
|
+
end
|
|
404
|
+
end
|
|
285
405
|
raise ArgumentError, "time axis minimum must be less than maximum" unless low < high
|
|
286
406
|
|
|
287
|
-
@ticks = Ticks.time(low, high, offset: @offset)
|
|
288
407
|
@domain = [low, high]
|
|
408
|
+
@tick_unit, = Ticks.time_interval(high - low)
|
|
409
|
+
@ticks = Ticks.time(low, high, offset: @offset)
|
|
289
410
|
end
|
|
290
411
|
|
|
291
412
|
def map(value)
|
|
292
|
-
|
|
293
|
-
return nil unless
|
|
294
|
-
|
|
413
|
+
number = timestamp(value)
|
|
414
|
+
return nil unless number
|
|
415
|
+
|
|
416
|
+
@range[0] + ((number - @domain[0]) * (@range[1] - @range[0]) / (@domain[1] - @domain[0]))
|
|
417
|
+
rescue ArgumentError, TypeError
|
|
418
|
+
nil
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def format(value)
|
|
422
|
+
time = if value.is_a?(::Time)
|
|
423
|
+
@local_time ? value.localtime : value.getlocal(@offset)
|
|
424
|
+
else
|
|
425
|
+
as_time(value)
|
|
426
|
+
end
|
|
427
|
+
format_string = case @tick_unit
|
|
428
|
+
when :second then "%H:%M:%S"
|
|
429
|
+
when :minute, :hour then "%H:%M"
|
|
430
|
+
when :day, :week then "%Y-%m-%d"
|
|
431
|
+
when :month then "%Y-%m"
|
|
432
|
+
when :year then "%Y"
|
|
433
|
+
end
|
|
434
|
+
time.strftime(format_string)
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
private
|
|
438
|
+
|
|
439
|
+
def time_offset(value)
|
|
440
|
+
return value.utc_offset if value.is_a?(::Time)
|
|
441
|
+
return (value.offset * 86_400).to_i if value.is_a?(DateTime)
|
|
295
442
|
|
|
296
|
-
|
|
443
|
+
nil
|
|
297
444
|
end
|
|
298
445
|
|
|
299
|
-
def
|
|
446
|
+
def local_time?(value)
|
|
447
|
+
::Time.at(value.to_r).localtime.zone == value.zone
|
|
448
|
+
end
|
|
300
449
|
|
|
301
|
-
|
|
450
|
+
def validate_bound(value, name)
|
|
451
|
+
raise ArgumentError, "time axis #{name} must be a Time or Date" unless value.is_a?(::Time) || value.is_a?(Date)
|
|
302
452
|
|
|
303
|
-
|
|
304
|
-
|
|
453
|
+
if @local_time
|
|
454
|
+
valid = value.is_a?(::Time) ? local_time?(value) : value.is_a?(Date) && !value.is_a?(DateTime)
|
|
455
|
+
raise ArgumentError, "time axis values must use the same time zone" unless valid
|
|
456
|
+
else
|
|
457
|
+
bound_offset = time_offset(value)
|
|
458
|
+
raise ArgumentError, "time axis values must use the same time zone" if bound_offset && bound_offset != @offset
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def timestamp(value)
|
|
463
|
+
return value.to_f if value.is_a?(::Time)
|
|
464
|
+
return value.to_time.to_f if value.is_a?(DateTime)
|
|
465
|
+
return date_time(value).to_f if value.is_a?(Date)
|
|
466
|
+
|
|
467
|
+
nil
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def as_time(value)
|
|
471
|
+
return ::Time.at(value.to_time.to_f).localtime if @local_time && value.is_a?(DateTime)
|
|
472
|
+
return ::Time.at(value.to_time.to_f).getlocal(@offset) if value.is_a?(DateTime)
|
|
473
|
+
return date_time(value) if value.is_a?(Date)
|
|
474
|
+
|
|
475
|
+
local_zone = ::Time.at(timestamp(value))
|
|
476
|
+
@local_time ? local_zone.localtime : local_zone.getlocal(@offset)
|
|
477
|
+
end
|
|
305
478
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
raise ArgumentError, "time axis contains mixed UTC offsets" unless time.utc_offset == @offset
|
|
479
|
+
def date_time(value)
|
|
480
|
+
return ::Time.local(value.year, value.month, value.day) if @local_time
|
|
309
481
|
|
|
310
|
-
|
|
482
|
+
::Time.new(value.year, value.month, value.day, 0, 0, 0, @offset)
|
|
311
483
|
end
|
|
312
484
|
end
|
|
313
485
|
end
|
|
@@ -330,11 +502,14 @@ module Inkplot
|
|
|
330
502
|
|
|
331
503
|
def font_for(size = 12)
|
|
332
504
|
require "glyphic"
|
|
333
|
-
|
|
505
|
+
if Inkplot.config.font
|
|
506
|
+
@fonts ||= {}
|
|
507
|
+
return @fonts[[Inkplot.config.font, size]] ||= Glyphic.load(Inkplot.config.font, size: size)
|
|
508
|
+
end
|
|
334
509
|
|
|
335
510
|
Glyphic.default
|
|
336
511
|
rescue LoadError => e
|
|
337
|
-
raise LoadError, "
|
|
512
|
+
raise LoadError, "font metrics need glyphic; install it with `gem install glyphic` (#{e.message})"
|
|
338
513
|
end
|
|
339
514
|
end
|
|
340
515
|
|
|
@@ -406,8 +581,8 @@ module Inkplot
|
|
|
406
581
|
|
|
407
582
|
def legend(position: :top_right) = @legend_options = { position: position.to_sym }
|
|
408
583
|
|
|
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:, **)
|
|
584
|
+
def line(x, y = nil, label: nil, color: nil, dash: false, gaps: :break, cap: :butt, join: :miter, **)
|
|
585
|
+
add(:line, Data.points(x, y), label:, color:, dash:, gaps:, cap:, join:, **)
|
|
411
586
|
end
|
|
412
587
|
|
|
413
588
|
def scatter(x, y = nil, label: nil, color: nil, size: nil, **)
|
|
@@ -427,19 +602,8 @@ module Inkplot
|
|
|
427
602
|
add(:bar, points, label:, color:, horizontal:, stacked:, **)
|
|
428
603
|
end
|
|
429
604
|
|
|
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, **)
|
|
605
|
+
def histogram(values, bins: :auto, label: nil, color: nil)
|
|
606
|
+
add(:histogram, Histogram.points(values, bins), label:, color:)
|
|
443
607
|
end
|
|
444
608
|
|
|
445
609
|
def hline(value, label: nil, color: nil, dash: true)
|
|
@@ -453,38 +617,30 @@ module Inkplot
|
|
|
453
617
|
@notes << { type: :vline, value:, label:, color: Theme.validate_color(color), dash: }
|
|
454
618
|
end
|
|
455
619
|
|
|
456
|
-
def
|
|
457
|
-
|
|
620
|
+
def annotate(x, y, text, color: nil, anchor: :start)
|
|
621
|
+
raise ArgumentError, "annotation text must not be empty" if text.to_s.empty?
|
|
622
|
+
raise ArgumentError, "annotation anchor must be :start, :middle, or :end" unless %i[start middle end].include?(anchor.to_sym)
|
|
623
|
+
|
|
624
|
+
@notes << { type: :text, x:, y:, text: text.to_s, color: Theme.validate_color(color), anchor: anchor.to_sym }
|
|
458
625
|
end
|
|
459
626
|
|
|
460
627
|
def add(type, points, label: nil, color: nil, **options)
|
|
461
|
-
|
|
628
|
+
type = type.to_sym
|
|
629
|
+
raise ArgumentError, "unsupported chart mark: #{type}" unless %i[line scatter bar area step histogram].include?(type)
|
|
462
630
|
raise ArgumentError, "gaps must be :break or :connect" if options[:gaps] && !%i[break connect].include?(options[:gaps])
|
|
463
631
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
632
|
+
if type == :line
|
|
633
|
+
options[:cap] = (options[:cap] || :butt).to_sym
|
|
634
|
+
options[:join] = (options[:join] || :miter).to_sym
|
|
635
|
+
raise ArgumentError, "line cap must be :butt or :round" unless %i[butt round].include?(options[:cap])
|
|
636
|
+
raise ArgumentError, "line join must be :miter, :round, or :bevel" unless %i[miter round bevel].include?(options[:join])
|
|
469
637
|
|
|
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"
|
|
638
|
+
options[:width] = Data.number(options[:width] || 2)
|
|
639
|
+
raise ArgumentError, "line width must be finite and positive" unless options[:width]&.positive?
|
|
487
640
|
end
|
|
641
|
+
|
|
642
|
+
@series << Series.new(type:, points:, label: label&.to_s, color: Theme.validate_color(color), options:)
|
|
643
|
+
self
|
|
488
644
|
end
|
|
489
645
|
end
|
|
490
646
|
|
|
@@ -502,19 +658,18 @@ module Inkplot
|
|
|
502
658
|
Renderers::SVG.render(SceneBuilder.call(self, width: width, height: height))
|
|
503
659
|
end
|
|
504
660
|
|
|
505
|
-
def to_image(scale: 1)
|
|
506
|
-
|
|
507
|
-
Renderers::Raster.render(SceneBuilder.call(self, width: width, height: height), scale: scale)
|
|
661
|
+
def to_image(width: self.width, height: self.height, scale: 1)
|
|
662
|
+
Renderers::PNG.render(SceneBuilder.call(self, width: width, height: height), scale:)
|
|
508
663
|
end
|
|
509
664
|
|
|
510
665
|
def to_png(width: self.width, height: self.height, scale: 1)
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
666
|
+
Renderers::PNG.encode(to_image(width:, height:, scale:))
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
def _repr_svg_ = to_svg
|
|
670
|
+
|
|
671
|
+
def to_inlay
|
|
672
|
+
{ svg: to_svg, png: -> { to_png } }
|
|
518
673
|
end
|
|
519
674
|
|
|
520
675
|
def save(path, width: self.width, height: self.height, scale: 1)
|
|
@@ -526,10 +681,6 @@ module Inkplot
|
|
|
526
681
|
path
|
|
527
682
|
end
|
|
528
683
|
|
|
529
|
-
def to_inlay
|
|
530
|
-
{ svg: to_svg, png: -> { to_png } }
|
|
531
|
-
end
|
|
532
|
-
|
|
533
684
|
private
|
|
534
685
|
|
|
535
686
|
def series = builder.series
|
|
@@ -549,7 +700,7 @@ module Inkplot
|
|
|
549
700
|
|
|
550
701
|
warn "Inkplot cycles its eight-color palette after eight series." if series.length > Theme::PALETTE.length
|
|
551
702
|
colors = Theme.colors(builder.theme)
|
|
552
|
-
x_values = series.flat_map { |item| item.points.flat_map { |point| [point[:x], point[:x2]].compact } }
|
|
703
|
+
x_values = series.flat_map { |item| item.points.flat_map { |point| [point[:x], point[:x0], point[:x1], point[:x2]].compact } }
|
|
553
704
|
y_values = series.flat_map { |item| item.points.map { |point| point[:y] } }
|
|
554
705
|
if series.any? { |item| item.options[:stacked] }
|
|
555
706
|
totals = stacked_totals(series)
|
|
@@ -569,14 +720,6 @@ module Inkplot
|
|
|
569
720
|
y_ticks = ticks(y_scale)
|
|
570
721
|
left = (y_ticks.map { |tick| TextMetrics.width(y_scale.format(tick), 11) }.max.to_f.ceil + 14).clamp(42, 120)
|
|
571
722
|
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
723
|
bottom += 20 if builder.x_label_text
|
|
581
724
|
left += 18 if builder.y_label_text
|
|
582
725
|
top = builder.title_text ? 40 : 18
|
|
@@ -584,6 +727,14 @@ module Inkplot
|
|
|
584
727
|
legend_items.uniq!(&:first)
|
|
585
728
|
legend_width = [legend_items.map { |label, _| TextMetrics.width(label, 11) }.max.to_f.ceil + 30, 120].max
|
|
586
729
|
right = builder.legend_options[:position].to_s.end_with?("right") && legend_items.length > 1 ? legend_width + 18 : 18
|
|
730
|
+
plot_width = width - right - left
|
|
731
|
+
tick_gap = plot_width.to_f / [x_ticks.length - 1, 1].max
|
|
732
|
+
longest_tick = x_ticks.map { |tick| TextMetrics.width(x_scale.format(tick), 10) }.max.to_f
|
|
733
|
+
tick_stride = [(longest_tick / [tick_gap, 1].max).ceil, 1].max
|
|
734
|
+
shown_ticks = x_ticks.each_index.select { |index| (index % tick_stride).zero? || index == x_ticks.length - 1 }
|
|
735
|
+
shown_gap = plot_width.to_f / [shown_ticks.length - 1, 1].max
|
|
736
|
+
rotate_ticks = height >= 180 && shown_ticks.any? { |index| TextMetrics.width(x_scale.format(x_ticks[index]), 10) > shown_gap * 0.75 }
|
|
737
|
+
bottom += 42 if rotate_ticks
|
|
587
738
|
x_scale.range = [left, width - right]
|
|
588
739
|
y_scale.range = [height - bottom, top]
|
|
589
740
|
clip = [left, top, width - right, height - bottom]
|
|
@@ -597,13 +748,16 @@ module Inkplot
|
|
|
597
748
|
elements << { type: :line, class: "grid-line", points: [[left, y], [width - right, y]], stroke: colors[:grid], width: 1 }
|
|
598
749
|
elements << { type: :text, x: left - 8, y: y + 4, text: y_scale.format(tick), fill: colors[:axis], anchor: :end, size: 11 }
|
|
599
750
|
end
|
|
600
|
-
x_ticks.each_with_index do |tick,
|
|
751
|
+
x_ticks.each_with_index do |tick, index|
|
|
752
|
+
next unless shown_ticks.include?(index)
|
|
753
|
+
|
|
601
754
|
x = x_scale.map(tick)
|
|
602
755
|
next unless x
|
|
603
756
|
|
|
604
757
|
label = x_scale.format(tick)
|
|
605
758
|
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 +
|
|
759
|
+
elements << { type: :text, x:, y: height - bottom + 18, text: label, fill: colors[:axis], anchor: rotate_ticks ? :end : :middle, size: 10,
|
|
760
|
+
rotate: rotate_ticks ? -45 : nil }
|
|
607
761
|
end
|
|
608
762
|
elements << { type: :line, class: "axis-line", points: [[left, top], [left, height - bottom], [width - right, height - bottom]], stroke: colors[:axis], width: 1 }
|
|
609
763
|
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 +774,27 @@ module Inkplot
|
|
|
620
774
|
series.each_with_index do |item, index|
|
|
621
775
|
color = item.color || Theme.series_color(index, builder.theme)
|
|
622
776
|
points = item.points
|
|
777
|
+
if item.type == :histogram
|
|
778
|
+
points.each do |point|
|
|
779
|
+
x0 = x_scale.map(point[:x0])
|
|
780
|
+
x1 = x_scale.map(point[:x1])
|
|
781
|
+
y0 = y_scale.map(0)
|
|
782
|
+
y1 = y_scale.map(point[:y])
|
|
783
|
+
next unless x0 && x1 && y0 && y1
|
|
784
|
+
|
|
785
|
+
marks << { type: :rect, class: "histogram-mark", series_index: index, x: x0, y: [y0, y1].min, width: x1 - x0,
|
|
786
|
+
height: (y0 - y1).abs, fill: color }
|
|
787
|
+
end
|
|
788
|
+
next
|
|
789
|
+
end
|
|
623
790
|
if item.type == :bar
|
|
624
791
|
current_bar_index = bar_index
|
|
625
792
|
bar_index += 1
|
|
793
|
+
total = [bars.length, 1].max
|
|
626
794
|
points.each do |point|
|
|
627
795
|
next unless point[:y] && point[:x]
|
|
628
796
|
|
|
629
797
|
if item.options[:horizontal]
|
|
630
|
-
total = [bars.length, 1].max
|
|
631
798
|
band = y_scale.bandwidth * 0.72
|
|
632
799
|
value = point[:y]
|
|
633
800
|
base = if item.options[:stacked]
|
|
@@ -647,15 +814,7 @@ module Inkplot
|
|
|
647
814
|
offset = item.options[:stacked] ? 0 : (current_bar_index - ((total - 1) / 2.0)) * band
|
|
648
815
|
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
816
|
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
817
|
else
|
|
658
|
-
total = [bars.length, 1].max
|
|
659
818
|
band = x_scale.bandwidth * (item.options[:stacked] ? 0.78 : 0.82 / total)
|
|
660
819
|
x = x_scale.map(point[:x])
|
|
661
820
|
value = point[:y]
|
|
@@ -687,7 +846,8 @@ module Inkplot
|
|
|
687
846
|
[[px, py]]
|
|
688
847
|
end
|
|
689
848
|
end
|
|
690
|
-
marks << { type: :line, class: "mark-line", series_index: index, points: coordinates, stroke: color, width: item.options[:width] || 2,
|
|
849
|
+
marks << { type: :line, class: "mark-line", series_index: index, points: coordinates, stroke: color, width: item.options[:width] || 2,
|
|
850
|
+
dash: item.options[:dash], cap: item.options[:cap], join: item.options[:join] }
|
|
691
851
|
end
|
|
692
852
|
when :area
|
|
693
853
|
drawable_segments(drawable, false).each do |segment|
|
|
@@ -714,14 +874,20 @@ module Inkplot
|
|
|
714
874
|
case note[:type]
|
|
715
875
|
when :hline
|
|
716
876
|
y = y_scale.map(note[:value])
|
|
717
|
-
|
|
877
|
+
if y
|
|
878
|
+
marks << { type: :line, class: "rule-line", points: [[left, y], [width - right, y]], stroke: color, width: 1.5, dash: note[:dash] }
|
|
879
|
+
marks << { type: :text, x: width - right - 4, y: y - 4, text: note[:label], fill: color, anchor: :end, size: 11 } if note[:label]
|
|
880
|
+
end
|
|
718
881
|
when :vline
|
|
719
882
|
x = x_scale.map(note[:value])
|
|
720
|
-
|
|
883
|
+
if x
|
|
884
|
+
marks << { type: :line, class: "rule-line", points: [[x, top], [x, height - bottom]], stroke: color, width: 1.5, dash: note[:dash] }
|
|
885
|
+
marks << { type: :text, x: x + 4, y: top + 12, text: note[:label], fill: color, anchor: :start, size: 11 } if note[:label]
|
|
886
|
+
end
|
|
721
887
|
when :text
|
|
722
888
|
x = x_scale.map(note[:x])
|
|
723
889
|
y = y_scale.map(note[:y])
|
|
724
|
-
marks << { type: :text, x:, y:, text: note[:
|
|
890
|
+
marks << { type: :text, x:, y:, text: note[:text], fill: color, anchor: note[:anchor], size: 11 } if x && y
|
|
725
891
|
end
|
|
726
892
|
end
|
|
727
893
|
|
|
@@ -739,10 +905,13 @@ module Inkplot
|
|
|
739
905
|
def build_scale(values, options, axis, series)
|
|
740
906
|
explicit = options[:scale] || options[:type]
|
|
741
907
|
horizontal_bar = series.any? { |item| item.type == :bar && item.options[:horizontal] }
|
|
742
|
-
bar_band = series.any? { |item| item.type == :bar &&
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
908
|
+
bar_band = series.any? { |item| item.type == :bar && ((item.options[:horizontal] && axis == :y) || (!item.options[:horizontal] && axis == :x)) }
|
|
909
|
+
histogram_axis = axis == :x && series.any? { |item| item.type == :histogram }
|
|
910
|
+
if histogram_axis && explicit == :band
|
|
911
|
+
raise ArgumentError, "histogram x axis must use a numeric scale"
|
|
912
|
+
elsif explicit == :time || explicit == "time" || (explicit.nil? && values.any? { |value| value.is_a?(::Time) || value.is_a?(Date) })
|
|
913
|
+
Scales::Time.new(values, options)
|
|
914
|
+
elsif explicit == :band || (explicit.nil? && !histogram_axis && (bar_band || values.any? { |value| !Data.number(value) }))
|
|
746
915
|
Scales::Band.new(values, options)
|
|
747
916
|
elsif explicit == :log
|
|
748
917
|
warn "Inkplot ignores zero and negative values on a log axis." if values.any? { |value| (number = Data.number(value)) && !number.positive? }
|
|
@@ -751,7 +920,7 @@ module Inkplot
|
|
|
751
920
|
raise ArgumentError, "unknown #{axis}-axis scale: #{explicit}" if explicit && explicit != :linear
|
|
752
921
|
|
|
753
922
|
include_zero = series.any? do |item|
|
|
754
|
-
(axis == :y && %i[bar area].include?(item.type) && !item.options[:horizontal]) ||
|
|
923
|
+
(axis == :y && %i[bar area histogram].include?(item.type) && !item.options[:horizontal]) ||
|
|
755
924
|
(horizontal_bar && axis == :x && item.options[:horizontal])
|
|
756
925
|
end
|
|
757
926
|
Scales::Linear.new(values, options, include_zero: include_zero)
|