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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +12 -0
- data/README.md +33 -9
- data/bench/inkplot.rb +12 -0
- data/docs/index.html +31 -0
- data/examples/gallery/01-line.svg +1 -1
- data/examples/gallery/02-line-gaps.svg +1 -1
- data/examples/gallery/03-series.svg +1 -1
- data/examples/gallery/09-histogram.svg +1 -0
- data/examples/gallery/12-time.svg +1 -0
- data/examples/gallery/13-annotations.svg +1 -0
- data/examples/gallery/14-dark-theme.svg +1 -0
- data/examples/gallery/15-category-labels.svg +1 -0
- data/examples/gallery/16-custom-bins.svg +1 -0
- data/examples/gallery/17-connected-gaps.svg +1 -0
- data/examples/gallery/18-step-series.svg +1 -0
- data/examples/gallery/19-combined-marks.svg +1 -0
- data/examples/gallery/20-scatter-sizes.svg +1 -0
- data/examples/gallery/README.md +6 -1
- data/examples/gallery.rb +33 -1
- data/lib/inkplot/core.rb +354 -15
- data/lib/inkplot/renderers/png.rb +390 -0
- data/lib/inkplot/renderers/svg.rb +3 -1
- data/lib/inkplot/version.rb +1 -1
- data/lib/inkplot.rb +8 -0
- data/sig/inkplot.rbs +9 -2
- data/test/snapshots/inkplot/01-line.png +0 -0
- data/test/snapshots/inkplot/02-line-gaps.png +0 -0
- data/test/snapshots/inkplot/03-series.png +0 -0
- data/test/snapshots/inkplot/04-scatter.png +0 -0
- data/test/snapshots/inkplot/05-bars.png +0 -0
- data/test/snapshots/inkplot/06-horizontal-bars.png +0 -0
- data/test/snapshots/inkplot/07-grouped-bars.png +0 -0
- data/test/snapshots/inkplot/08-stacked-bars.png +0 -0
- data/test/snapshots/inkplot/09-histogram.png +0 -0
- data/test/snapshots/inkplot/10-area.png +0 -0
- data/test/snapshots/inkplot/11-step.png +0 -0
- data/test/snapshots/inkplot/12-time.png +0 -0
- data/test/snapshots/inkplot/13-annotations.png +0 -0
- data/test/snapshots/inkplot/14-dark-theme.png +0 -0
- data/test/snapshots/inkplot/15-category-labels.png +0 -0
- data/test/snapshots/inkplot/16-custom-bins.png +0 -0
- data/test/snapshots/inkplot/17-connected-gaps.png +0 -0
- data/test/snapshots/inkplot/18-step-series.png +0 -0
- data/test/snapshots/inkplot/19-combined-marks.png +0 -0
- data/test/snapshots/inkplot/20-scatter-sizes.png +0 -0
- metadata +41 -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|