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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +13 -1
  3. data/LICENSE.txt +1 -1
  4. data/README.md +43 -67
  5. data/bench/inkplot.rb +12 -3
  6. data/docs/index.html +31 -0
  7. data/examples/gallery/01-line.svg +1 -1
  8. data/examples/gallery/02-line-gaps.svg +1 -1
  9. data/examples/gallery/03-series.svg +1 -1
  10. data/examples/gallery/09-histogram.svg +1 -0
  11. data/examples/gallery/12-time.svg +1 -0
  12. data/examples/gallery/13-annotations.svg +1 -0
  13. data/examples/gallery/14-dark-theme.svg +1 -0
  14. data/examples/gallery/15-category-labels.svg +1 -0
  15. data/examples/gallery/16-custom-bins.svg +1 -0
  16. data/examples/gallery/17-connected-gaps.svg +1 -0
  17. data/examples/gallery/18-step-series.svg +1 -0
  18. data/examples/gallery/19-combined-marks.svg +1 -0
  19. data/examples/gallery/20-scatter-sizes.svg +1 -0
  20. data/examples/gallery/README.md +7 -7
  21. data/examples/gallery.rb +26 -44
  22. data/lib/inkplot/core.rb +309 -140
  23. data/lib/inkplot/renderers/png.rb +390 -0
  24. data/lib/inkplot/renderers/svg.rb +3 -1
  25. data/lib/inkplot/version.rb +1 -1
  26. data/lib/inkplot.rb +3 -3
  27. data/sig/inkplot.rbs +7 -6
  28. data/test/snapshots/inkplot/01-line.png +0 -0
  29. data/test/snapshots/inkplot/02-line-gaps.png +0 -0
  30. data/test/snapshots/inkplot/03-series.png +0 -0
  31. data/test/snapshots/inkplot/04-scatter.png +0 -0
  32. data/test/snapshots/inkplot/05-bars.png +0 -0
  33. data/test/snapshots/inkplot/06-horizontal-bars.png +0 -0
  34. data/test/snapshots/inkplot/07-grouped-bars.png +0 -0
  35. data/test/snapshots/inkplot/08-stacked-bars.png +0 -0
  36. data/test/snapshots/inkplot/09-histogram.png +0 -0
  37. data/test/snapshots/inkplot/10-area.png +0 -0
  38. data/test/snapshots/inkplot/11-step.png +0 -0
  39. data/test/snapshots/inkplot/12-time.png +0 -0
  40. data/test/snapshots/inkplot/13-annotations.png +0 -0
  41. data/test/snapshots/inkplot/14-dark-theme.png +0 -0
  42. data/test/snapshots/inkplot/15-category-labels.png +0 -0
  43. data/test/snapshots/inkplot/16-custom-bins.png +0 -0
  44. data/test/snapshots/inkplot/17-connected-gaps.png +0 -0
  45. data/test/snapshots/inkplot/18-step-series.png +0 -0
  46. data/test/snapshots/inkplot/19-combined-marks.png +0 -0
  47. data/test/snapshots/inkplot/20-scatter-sizes.png +0 -0
  48. metadata +36 -15
  49. data/examples/gallery/09-signed-stack.svg +0 -1
  50. data/examples/gallery/12-histogram.svg +0 -1
  51. data/examples/gallery/13-log-scale.svg +0 -1
  52. data/examples/gallery/14-time-axis.svg +0 -1
  53. data/examples/gallery/15-category-order.svg +0 -1
  54. data/examples/gallery/16-dark-theme.svg +0 -1
  55. data/examples/gallery/17-annotations.svg +0 -1
  56. data/examples/gallery/18-combo.svg +0 -1
  57. data/examples/gallery/19-legend-position.svg +0 -1
  58. data/examples/gallery/20-long-categories.svg +0 -1
  59. 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
- def time(value)
63
- case value
64
- when Time then value
65
- when Date then value.to_time
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
- span = maximum - minimum
116
- intervals = [1, 5, 15, 30, 60, 300, 900, 1800, 3600, 10_800, 21_600, 43_200, 86_400, 604_800, 2_592_000, 7_776_000, 15_552_000, 31_536_000]
117
- step = intervals.find { |value| span / value <= 6 } || 31_536_000
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
- time = Time.at(minimum).getlocal(offset)
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 time_label(value, span, offset: 0)
140
- time = Time.at(value).getlocal(offset)
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
- time.strftime("%H:%M:%S")
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 TimeScale
270
- attr_reader :domain, :ticks, :offset
362
+ class Time
363
+ attr_reader :domain, :ticks
271
364
  attr_accessor :range
272
365
 
273
366
  def initialize(values, options = {})
274
- times = values.filter_map { |value| Data.time(value) }
275
- offsets = times.map(&:utc_offset).uniq
276
- raise ArgumentError, "time axis contains mixed UTC offsets" if offsets.length > 1
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 = times.map(&:to_f)
376
+ @offset = @local_time ? nil : offsets.first || 0
377
+ numbers = values.map { |value| timestamp(value) }
280
378
  low, high = numbers.minmax
281
- low ||= 0.0
282
- high ||= low + 86_400
283
- low = time_limit(options[:min], low, "minimum")
284
- high = time_limit(options[:max], high, "maximum")
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
- time = value.is_a?(Numeric) ? value.to_f : Data.time(value)&.to_f
293
- return nil unless time
294
- raise ArgumentError, "time axis contains mixed UTC offsets" if !value.is_a?(Numeric) && Data.time(value).utc_offset != @offset
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
- @range[0] + ((time - @domain[0]) * (@range[1] - @range[0]) / (@domain[1] - @domain[0]))
443
+ nil
297
444
  end
298
445
 
299
- def format(value) = Ticks.time_label(value, @domain[1] - @domain[0], offset: @offset)
446
+ def local_time?(value)
447
+ ::Time.at(value.to_r).localtime.zone == value.zone
448
+ end
300
449
 
301
- private
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
- def time_limit(value, fallback, name)
304
- return fallback if value.nil?
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
- time = Data.time(value)
307
- raise ArgumentError, "time axis #{name} must be a Time or Date" unless time
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
- time.to_f
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
- return Glyphic.load(Inkplot.config.font, size: size) if Inkplot.config.font
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, "PNG output needs glyphic; install it with `gem install glyphic` (#{e.message})"
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
- numbers = Array(values).filter_map { |value| Data.number(value) }
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 text(x, y, value, color: nil)
457
- @notes << { type: :text, x:, y: Data.number(y), value: value.to_s, color: Theme.validate_color(color) }
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
- raise ArgumentError, "unsupported chart mark: #{type}" unless %i[line scatter bar area step].include?(type.to_sym)
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
- @series << Series.new(type: type.to_sym, points:, label: label&.to_s, color: Theme.validate_color(color), options:)
465
- self
466
- end
467
-
468
- private
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
- def histogram_edges(numbers, bins)
471
- low, high = numbers.minmax
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
- require_relative "renderers/raster"
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
- begin
512
- require "tessel"
513
- rescue LoadError => e
514
- raise LoadError, "PNG output needs tessel; install it with `gem install tessel` (#{e.message})"
515
- end
516
- require_relative "renderers/raster"
517
- Tessel::PNG.encode(Renderers::Raster.render(SceneBuilder.call(self, width:, height:), scale: scale))
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, _index|
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 + (rotate ? 14 : 18), text: label, fill: colors[:axis], anchor: :middle, size: 10, rotate: (rotate ? -45 : nil) }
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, dash: item.options[:dash] }
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
- 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
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
- marks << { type: :line, class: "rule-line", points: [[x, top], [x, height - bottom]], stroke: color, width: 1.5, dash: note[:dash] } if x
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[:value], fill: color, anchor: :start, size: 11 } if x && y
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 && !item.options[:histogram] && ((item.options[:horizontal] && axis == :y) || (!item.options[:horizontal] && axis == :x)) }
743
- if explicit == :time || (explicit.nil? && values.any? { |value| Data.time(value) })
744
- Scales::TimeScale.new(values, options)
745
- elsif explicit == :band || (explicit.nil? && (bar_band || values.any? { |value| !Data.number(value) && !Data.time(value) }))
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)