inkplot 0.1.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +12 -0
  3. data/README.md +33 -9
  4. data/bench/inkplot.rb +12 -0
  5. data/docs/index.html +31 -0
  6. data/examples/gallery/01-line.svg +1 -1
  7. data/examples/gallery/02-line-gaps.svg +1 -1
  8. data/examples/gallery/03-series.svg +1 -1
  9. data/examples/gallery/09-histogram.svg +1 -0
  10. data/examples/gallery/12-time.svg +1 -0
  11. data/examples/gallery/13-annotations.svg +1 -0
  12. data/examples/gallery/14-dark-theme.svg +1 -0
  13. data/examples/gallery/15-category-labels.svg +1 -0
  14. data/examples/gallery/16-custom-bins.svg +1 -0
  15. data/examples/gallery/17-connected-gaps.svg +1 -0
  16. data/examples/gallery/18-step-series.svg +1 -0
  17. data/examples/gallery/19-combined-marks.svg +1 -0
  18. data/examples/gallery/20-scatter-sizes.svg +1 -0
  19. data/examples/gallery/README.md +6 -1
  20. data/examples/gallery.rb +33 -1
  21. data/lib/inkplot/core.rb +354 -15
  22. data/lib/inkplot/renderers/png.rb +390 -0
  23. data/lib/inkplot/renderers/svg.rb +3 -1
  24. data/lib/inkplot/version.rb +1 -1
  25. data/lib/inkplot.rb +8 -0
  26. data/sig/inkplot.rbs +9 -2
  27. data/test/snapshots/inkplot/01-line.png +0 -0
  28. data/test/snapshots/inkplot/02-line-gaps.png +0 -0
  29. data/test/snapshots/inkplot/03-series.png +0 -0
  30. data/test/snapshots/inkplot/04-scatter.png +0 -0
  31. data/test/snapshots/inkplot/05-bars.png +0 -0
  32. data/test/snapshots/inkplot/06-horizontal-bars.png +0 -0
  33. data/test/snapshots/inkplot/07-grouped-bars.png +0 -0
  34. data/test/snapshots/inkplot/08-stacked-bars.png +0 -0
  35. data/test/snapshots/inkplot/09-histogram.png +0 -0
  36. data/test/snapshots/inkplot/10-area.png +0 -0
  37. data/test/snapshots/inkplot/11-step.png +0 -0
  38. data/test/snapshots/inkplot/12-time.png +0 -0
  39. data/test/snapshots/inkplot/13-annotations.png +0 -0
  40. data/test/snapshots/inkplot/14-dark-theme.png +0 -0
  41. data/test/snapshots/inkplot/15-category-labels.png +0 -0
  42. data/test/snapshots/inkplot/16-custom-bins.png +0 -0
  43. data/test/snapshots/inkplot/17-connected-gaps.png +0 -0
  44. data/test/snapshots/inkplot/18-step-series.png +0 -0
  45. data/test/snapshots/inkplot/19-combined-marks.png +0 -0
  46. data/test/snapshots/inkplot/20-scatter-sizes.png +0 -0
  47. metadata +41 -5
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
@@ -60,6 +62,64 @@ module Inkplot
60
62
  end
61
63
  end
62
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?
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 { |index| { x: (edges[index] + edges[index + 1]) / 2.0, x0: edges[index], x1: edges[index + 1], y: counts[index] } }
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
121
+ end
122
+
63
123
  module Ticks
64
124
  module_function
65
125
 
@@ -104,6 +164,81 @@ module Inkplot
104
164
  end
105
165
  end
106
166
 
167
+ def time(minimum, maximum, offset: 0)
168
+ unit, amount = time_interval(maximum - minimum)
169
+ ticks = if %i[month year].include?(unit)
170
+ calendar_ticks(minimum, maximum, offset, unit, amount)
171
+ else
172
+ time_ticks(minimum, maximum, offset, unit, amount)
173
+ end
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)
232
+ end
233
+ private_class_method :zoned_time
234
+
235
+ def time_at(year, month, day, offset)
236
+ return Time.local(year, month, day) if offset.nil?
237
+
238
+ Time.new(year, month, day, 0, 0, 0, offset)
239
+ end
240
+ private_class_method :time_at
241
+
107
242
  def number_label(value)
108
243
  value.to_i == value ? value.to_i.to_s : format("%.8f", value).sub(/0+\z/, "").sub(/\.\z/, "")
109
244
  end
@@ -223,6 +358,130 @@ module Inkplot
223
358
  def bandwidth = (@range[1] - @range[0]) / [@domain.length, 1].max
224
359
  def format(value) = value.to_s
225
360
  end
361
+
362
+ class Time
363
+ attr_reader :domain, :ticks
364
+ attr_accessor :range
365
+
366
+ def initialize(values, options = {})
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)
375
+
376
+ @offset = @local_time ? nil : offsets.first || 0
377
+ numbers = values.map { |value| timestamp(value) }
378
+ low, high = numbers.minmax
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
405
+ raise ArgumentError, "time axis minimum must be less than maximum" unless low < high
406
+
407
+ @domain = [low, high]
408
+ @tick_unit, = Ticks.time_interval(high - low)
409
+ @ticks = Ticks.time(low, high, offset: @offset)
410
+ end
411
+
412
+ def map(value)
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)
442
+
443
+ nil
444
+ end
445
+
446
+ def local_time?(value)
447
+ ::Time.at(value.to_r).localtime.zone == value.zone
448
+ end
449
+
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)
452
+
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
478
+
479
+ def date_time(value)
480
+ return ::Time.local(value.year, value.month, value.day) if @local_time
481
+
482
+ ::Time.new(value.year, value.month, value.day, 0, 0, 0, @offset)
483
+ end
484
+ end
226
485
  end
227
486
 
228
487
  module TextMetrics
@@ -243,7 +502,10 @@ module Inkplot
243
502
 
244
503
  def font_for(size = 12)
245
504
  require "glyphic"
246
- 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
247
509
 
248
510
  Glyphic.default
249
511
  rescue LoadError => e
@@ -319,8 +581,8 @@ module Inkplot
319
581
 
320
582
  def legend(position: :top_right) = @legend_options = { position: position.to_sym }
321
583
 
322
- def line(x, y = nil, label: nil, color: nil, dash: false, gaps: :break, **)
323
- add(:line, Data.points(x, y), label:, color:, dash:, gaps:, **)
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:, **)
324
586
  end
325
587
 
326
588
  def scatter(x, y = nil, label: nil, color: nil, size: nil, **)
@@ -340,6 +602,10 @@ module Inkplot
340
602
  add(:bar, points, label:, color:, horizontal:, stacked:, **)
341
603
  end
342
604
 
605
+ def histogram(values, bins: :auto, label: nil, color: nil)
606
+ add(:histogram, Histogram.points(values, bins), label:, color:)
607
+ end
608
+
343
609
  def hline(value, label: nil, color: nil, dash: true)
344
610
  number = Data.number(value)
345
611
  raise ArgumentError, "horizontal rule value must be finite and numeric" unless number
@@ -351,11 +617,29 @@ module Inkplot
351
617
  @notes << { type: :vline, value:, label:, color: Theme.validate_color(color), dash: }
352
618
  end
353
619
 
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 }
625
+ end
626
+
354
627
  def add(type, points, label: nil, color: nil, **options)
355
- raise ArgumentError, "unsupported chart mark: #{type}" unless %i[line scatter bar area step].include?(type.to_sym)
628
+ type = type.to_sym
629
+ raise ArgumentError, "unsupported chart mark: #{type}" unless %i[line scatter bar area step histogram].include?(type)
356
630
  raise ArgumentError, "gaps must be :break or :connect" if options[:gaps] && !%i[break connect].include?(options[:gaps])
357
631
 
358
- @series << Series.new(type: type.to_sym, points:, label: label&.to_s, color: Theme.validate_color(color), options:)
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])
637
+
638
+ options[:width] = Data.number(options[:width] || 2)
639
+ raise ArgumentError, "line width must be finite and positive" unless options[:width]&.positive?
640
+ end
641
+
642
+ @series << Series.new(type:, points:, label: label&.to_s, color: Theme.validate_color(color), options:)
359
643
  self
360
644
  end
361
645
  end
@@ -374,10 +658,25 @@ module Inkplot
374
658
  Renderers::SVG.render(SceneBuilder.call(self, width: width, height: height))
375
659
  end
376
660
 
377
- def save(path, width: self.width, height: self.height)
661
+ def to_image(width: self.width, height: self.height, scale: 1)
662
+ Renderers::PNG.render(SceneBuilder.call(self, width: width, height: height), scale:)
663
+ end
664
+
665
+ def to_png(width: self.width, height: self.height, scale: 1)
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 } }
673
+ end
674
+
675
+ def save(path, width: self.width, height: self.height, scale: 1)
378
676
  case File.extname(String(path)).downcase
379
677
  when ".svg" then File.write(path, to_svg(width:, height:))
380
- else raise ArgumentError, "output path must end in .svg"
678
+ when ".png" then File.binwrite(path, to_png(width:, height:, scale:))
679
+ else raise ArgumentError, "output path must end in .svg or .png"
381
680
  end
382
681
  path
383
682
  end
@@ -401,7 +700,7 @@ module Inkplot
401
700
 
402
701
  warn "Inkplot cycles its eight-color palette after eight series." if series.length > Theme::PALETTE.length
403
702
  colors = Theme.colors(builder.theme)
404
- 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 } }
405
704
  y_values = series.flat_map { |item| item.points.map { |point| point[:y] } }
406
705
  if series.any? { |item| item.options[:stacked] }
407
706
  totals = stacked_totals(series)
@@ -428,6 +727,14 @@ module Inkplot
428
727
  legend_items.uniq!(&:first)
429
728
  legend_width = [legend_items.map { |label, _| TextMetrics.width(label, 11) }.max.to_f.ceil + 30, 120].max
430
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
431
738
  x_scale.range = [left, width - right]
432
739
  y_scale.range = [height - bottom, top]
433
740
  clip = [left, top, width - right, height - bottom]
@@ -441,13 +748,16 @@ module Inkplot
441
748
  elements << { type: :line, class: "grid-line", points: [[left, y], [width - right, y]], stroke: colors[:grid], width: 1 }
442
749
  elements << { type: :text, x: left - 8, y: y + 4, text: y_scale.format(tick), fill: colors[:axis], anchor: :end, size: 11 }
443
750
  end
444
- 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
+
445
754
  x = x_scale.map(tick)
446
755
  next unless x
447
756
 
448
757
  label = x_scale.format(tick)
449
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)
450
- elements << { type: :text, x:, y: height - bottom + 18, text: label, fill: colors[:axis], anchor: :middle, size: 10 }
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 }
451
761
  end
452
762
  elements << { type: :line, class: "axis-line", points: [[left, top], [left, height - bottom], [width - right, height - bottom]], stroke: colors[:axis], width: 1 }
453
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
@@ -464,6 +774,19 @@ module Inkplot
464
774
  series.each_with_index do |item, index|
465
775
  color = item.color || Theme.series_color(index, builder.theme)
466
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
467
790
  if item.type == :bar
468
791
  current_bar_index = bar_index
469
792
  bar_index += 1
@@ -523,7 +846,8 @@ module Inkplot
523
846
  [[px, py]]
524
847
  end
525
848
  end
526
- 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] }
527
851
  end
528
852
  when :area
529
853
  drawable_segments(drawable, false).each do |segment|
@@ -550,10 +874,20 @@ module Inkplot
550
874
  case note[:type]
551
875
  when :hline
552
876
  y = y_scale.map(note[:value])
553
- marks << { type: :line, class: "rule-line", points: [[left, y], [width - right, y]], stroke: color, width: 1.5, dash: note[:dash], label: note[:label] } if y
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
554
881
  when :vline
555
882
  x = x_scale.map(note[:value])
556
- marks << { type: :line, class: "rule-line", points: [[x, top], [x, height - bottom]], stroke: color, width: 1.5, dash: note[:dash] } if x
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
887
+ when :text
888
+ x = x_scale.map(note[:x])
889
+ y = y_scale.map(note[:y])
890
+ marks << { type: :text, x:, y:, text: note[:text], fill: color, anchor: note[:anchor], size: 11 } if x && y
557
891
  end
558
892
  end
559
893
 
@@ -572,7 +906,12 @@ module Inkplot
572
906
  explicit = options[:scale] || options[:type]
573
907
  horizontal_bar = series.any? { |item| item.type == :bar && item.options[:horizontal] }
574
908
  bar_band = series.any? { |item| item.type == :bar && ((item.options[:horizontal] && axis == :y) || (!item.options[:horizontal] && axis == :x)) }
575
- if explicit == :band || (explicit.nil? && (bar_band || values.any? { |value| !Data.number(value) }))
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) }))
576
915
  Scales::Band.new(values, options)
577
916
  elsif explicit == :log
578
917
  warn "Inkplot ignores zero and negative values on a log axis." if values.any? { |value| (number = Data.number(value)) && !number.positive? }
@@ -581,7 +920,7 @@ module Inkplot
581
920
  raise ArgumentError, "unknown #{axis}-axis scale: #{explicit}" if explicit && explicit != :linear
582
921
 
583
922
  include_zero = series.any? do |item|
584
- (axis == :y && %i[bar area].include?(item.type) && !item.options[:horizontal]) ||
923
+ (axis == :y && %i[bar area histogram].include?(item.type) && !item.options[:horizontal]) ||
585
924
  (horizontal_bar && axis == :x && item.options[:horizontal])
586
925
  end
587
926
  Scales::Linear.new(values, options, include_zero: include_zero)