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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/.codespellignore +0 -0
  3. data/.yamllint +16 -0
  4. data/CHANGELOG.md +16 -1
  5. data/LICENSE.txt +1 -1
  6. data/README.md +43 -67
  7. data/bench/inkplot.rb +12 -3
  8. data/docs/index.html +31 -0
  9. data/examples/gallery/01-line.svg +1 -1
  10. data/examples/gallery/02-line-gaps.svg +1 -1
  11. data/examples/gallery/03-series.svg +1 -1
  12. data/examples/gallery/09-histogram.svg +1 -0
  13. data/examples/gallery/12-time.svg +1 -0
  14. data/examples/gallery/13-annotations.svg +1 -0
  15. data/examples/gallery/14-dark-theme.svg +1 -0
  16. data/examples/gallery/15-category-labels.svg +1 -0
  17. data/examples/gallery/16-custom-bins.svg +1 -0
  18. data/examples/gallery/17-connected-gaps.svg +1 -0
  19. data/examples/gallery/18-step-series.svg +1 -0
  20. data/examples/gallery/19-combined-marks.svg +1 -0
  21. data/examples/gallery/20-scatter-sizes.svg +1 -0
  22. data/examples/gallery/README.md +7 -7
  23. data/examples/gallery.rb +26 -44
  24. data/lib/inkplot/core.rb +351 -146
  25. data/lib/inkplot/renderers/png.rb +390 -0
  26. data/lib/inkplot/renderers/svg.rb +3 -1
  27. data/lib/inkplot/version.rb +1 -1
  28. data/lib/inkplot.rb +3 -3
  29. data/sig/inkplot.rbs +7 -6
  30. data/test/snapshots/inkplot/01-line.png +0 -0
  31. data/test/snapshots/inkplot/02-line-gaps.png +0 -0
  32. data/test/snapshots/inkplot/03-series.png +0 -0
  33. data/test/snapshots/inkplot/04-scatter.png +0 -0
  34. data/test/snapshots/inkplot/05-bars.png +0 -0
  35. data/test/snapshots/inkplot/06-horizontal-bars.png +0 -0
  36. data/test/snapshots/inkplot/07-grouped-bars.png +0 -0
  37. data/test/snapshots/inkplot/08-stacked-bars.png +0 -0
  38. data/test/snapshots/inkplot/09-histogram.png +0 -0
  39. data/test/snapshots/inkplot/10-area.png +0 -0
  40. data/test/snapshots/inkplot/11-step.png +0 -0
  41. data/test/snapshots/inkplot/12-time.png +0 -0
  42. data/test/snapshots/inkplot/13-annotations.png +0 -0
  43. data/test/snapshots/inkplot/14-dark-theme.png +0 -0
  44. data/test/snapshots/inkplot/15-category-labels.png +0 -0
  45. data/test/snapshots/inkplot/16-custom-bins.png +0 -0
  46. data/test/snapshots/inkplot/17-connected-gaps.png +0 -0
  47. data/test/snapshots/inkplot/18-step-series.png +0 -0
  48. data/test/snapshots/inkplot/19-combined-marks.png +0 -0
  49. data/test/snapshots/inkplot/20-scatter-sizes.png +0 -0
  50. metadata +41 -18
  51. data/examples/gallery/09-signed-stack.svg +0 -1
  52. data/examples/gallery/12-histogram.svg +0 -1
  53. data/examples/gallery/13-log-scale.svg +0 -1
  54. data/examples/gallery/14-time-axis.svg +0 -1
  55. data/examples/gallery/15-category-order.svg +0 -1
  56. data/examples/gallery/16-dark-theme.svg +0 -1
  57. data/examples/gallery/17-annotations.svg +0 -1
  58. data/examples/gallery/18-combo.svg +0 -1
  59. data/examples/gallery/19-legend-position.svg +0 -1
  60. data/examples/gallery/20-long-categories.svg +0 -1
  61. 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
- def time(value)
63
- case value
64
- when Time then value
65
- when Date then value.to_time
66
- end
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
- raw = (maximum - minimum).to_f / [count - 1, 1].max
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
- 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 }
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
- 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 }
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 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
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
- time.strftime("%H:%M:%S")
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
- step = Ticks.linear_step(low, high)
179
- low = (low / step).floor * step if options[:min].nil?
180
- high = (high / step).ceil * step if options[:max].nil?
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
- @range[0] + ((value - @domain[0]) * (@range[1] - @range[0]) / (@domain[1] - @domain[0]))
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 TimeScale
270
- attr_reader :domain, :ticks, :offset
396
+ class Time
397
+ attr_reader :domain, :ticks
271
398
  attr_accessor :range
272
399
 
273
400
  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
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
- @offset = offsets.first || 0
279
- numbers = times.map(&:to_f)
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
- low ||= 0.0
282
- high ||= low + 86_400
283
- low = time_limit(options[:min], low, "minimum")
284
- high = time_limit(options[:max], high, "maximum")
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
- 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
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
- @range[0] + ((time - @domain[0]) * (@range[1] - @range[0]) / (@domain[1] - @domain[0]))
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 format(value) = Ticks.time_label(value, @domain[1] - @domain[0], offset: @offset)
480
+ def local_time?(value)
481
+ ::Time.at(value.to_r).localtime.zone == value.zone
482
+ end
300
483
 
301
- private
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
- def time_limit(value, fallback, name)
304
- return fallback if value.nil?
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
- 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
513
+ def date_time(value)
514
+ return ::Time.local(value.year, value.month, value.day) if @local_time
309
515
 
310
- time.to_f
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
- return Glyphic.load(Inkplot.config.font, size: size) if Inkplot.config.font
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, "PNG output needs glyphic; install it with `gem install glyphic` (#{e.message})"
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
- 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, **)
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 text(x, y, value, color: nil)
457
- @notes << { type: :text, x:, y: Data.number(y), value: value.to_s, color: Theme.validate_color(color) }
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
- raise ArgumentError, "unsupported chart mark: #{type}" unless %i[line scatter bar area step].include?(type.to_sym)
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
- @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
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
- 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"
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
- require_relative "renderers/raster"
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
- 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))
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, _index|
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 + (rotate ? 14 : 18), text: label, fill: colors[:axis], anchor: :middle, size: 10, rotate: (rotate ? -45 : nil) }
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, dash: item.options[:dash] }
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
- 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
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
- marks << { type: :line, class: "rule-line", points: [[x, top], [x, height - bottom]], stroke: color, width: 1.5, dash: note[:dash] } if x
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[:value], fill: color, anchor: :start, size: 11 } if x && y
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 && !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) }))
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)