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
@@ -0,0 +1,390 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inkplot
4
+ module Renderers
5
+ module PNG
6
+ # ponytail: 2x supersampling limits edge coverage accuracy; raise this or add exact area integration if visual tests show artifacts.
7
+ ANTIALIAS = 2
8
+ COLORS = {
9
+ "red" => "#FF0000", "blue" => "#0000FF", "green" => "#008000", "black" => "#000000", "white" => "#FFFFFF",
10
+ "orange" => "#FFA500", "gray" => "#808080", "grey" => "#808080", "purple" => "#800080", "yellow" => "#FFFF00",
11
+ "cyan" => "#00FFFF", "magenta" => "#FF00FF", "pink" => "#FFC0CB", "brown" => "#A52A2A", "navy" => "#000080",
12
+ "teal" => "#008080", "lime" => "#00FF00", "maroon" => "#800000", "olive" => "#808000", "silver" => "#C0C0C0",
13
+ "aqua" => "#00FFFF", "fuchsia" => "#FF00FF", "rebeccapurple" => "#663399", "transparent" => "#00000000"
14
+ }.freeze
15
+
16
+ module_function
17
+
18
+ def render(scene, scale: 1)
19
+ scale = Float(scale)
20
+ raise ArgumentError, "scale must be a positive integer" unless scale.positive? && scale.finite? && scale == scale.to_i
21
+
22
+ require_dependencies
23
+ scale = scale.to_i
24
+ factor = scale * ANTIALIAS
25
+ image = Tessel::Image.new(scene.width * factor, scene.height * factor)
26
+ warn "Inkplot PNG fallback font is ASCII-only; set Inkplot.config.font for Unicode text." if !Inkplot.config.font && (scene.elements + scene.marks).any? { |element| element[:type] == :text && element[:text].to_s.match?(/[^\x00-\x7F]/) }
27
+ fonts = {}
28
+ scene.elements.each { |element| draw(image, element, factor, fonts:) }
29
+
30
+ seen = {}
31
+ clip = scene.clip.map { |coordinate| coordinate * factor }
32
+ scene.marks.each do |mark|
33
+ if mark[:type] == :circle
34
+ key = [mark[:series_index], mark[:cx].floor, mark[:cy].floor]
35
+ next if seen[key]
36
+
37
+ seen[key] = true
38
+ end
39
+ draw(image, mark, factor, clip:, fonts:)
40
+ end
41
+ downsample(image)
42
+ end
43
+
44
+ def encode(image)
45
+ require_dependencies
46
+ Tessel::PNG.encode(image)
47
+ end
48
+
49
+ def require_dependencies
50
+ require "tessel"
51
+ require "glyphic"
52
+ rescue LoadError => e
53
+ raise LoadError, "PNG output requires tessel and glyphic; install them with `gem install tessel glyphic` (#{e.message})", cause: e
54
+ end
55
+ private_class_method :require_dependencies
56
+
57
+ def color(value)
58
+ text = value.to_s
59
+ text = COLORS.fetch(text.downcase, text)
60
+ hex = text.delete_prefix("#")
61
+ hex = hex.chars.flat_map { |digit| [digit, digit] }.join if [3, 4].include?(hex.length)
62
+ raise ArgumentError, "unsupported PNG color: #{value}" unless [6, 8].include?(hex.length) && hex.match?(/\A[\da-fA-F]+\z/)
63
+
64
+ channels = hex.scan(/../).map { |part| part.to_i(16) }
65
+ channels << 255 if channels.length == 3
66
+ channels
67
+ end
68
+ private_class_method :color
69
+
70
+ def draw(image, element, factor, fonts:, clip: nil)
71
+ case element[:type]
72
+ when :rect
73
+ rect(image, element, factor, clip)
74
+ when :line
75
+ Raster::Stroker.draw(image, element, factor, clip)
76
+ when :polygon
77
+ Raster::PathFiller.fill(image, element[:points].map { |point| point.map { |value| value * factor } }, color(element[:fill]),
78
+ clip: scaled_clip(clip), opacity: element[:opacity] || 1)
79
+ when :circle
80
+ circle(image, element, factor, clip)
81
+ when :text
82
+ text(image, element, factor, clip, fonts)
83
+ end
84
+ end
85
+ private_class_method :draw
86
+
87
+ def rect(image, element, factor, clip)
88
+ x = (element[:x] * factor).round
89
+ y = (element[:y] * factor).round
90
+ width = (element[:width] * factor).round
91
+ height = (element[:height] * factor).round
92
+ if clip
93
+ left, top, right, bottom = clip.map(&:round)
94
+ x0 = [x, left].max
95
+ y0 = [y, top].max
96
+ x1 = [x + width, right].min
97
+ y1 = [y + height, bottom].min
98
+ return if x0 >= x1 || y0 >= y1
99
+
100
+ x = x0
101
+ y = y0
102
+ width = x1 - x0
103
+ height = y1 - y0
104
+ end
105
+ image.fill_rect(x, y, width, height, color(element[:fill]), blend: :alpha)
106
+ end
107
+ private_class_method :rect
108
+
109
+ def circle(image, element, factor, clip)
110
+ cx = element[:cx] * factor
111
+ cy = element[:cy] * factor
112
+ radius = element[:r] * factor
113
+ pixels = color(element[:fill])
114
+ border = color(element[:stroke]) if element[:stroke] && element[:stroke_width].to_f.positive?
115
+ if border
116
+ stroke_radius = element[:stroke_width] * factor / 2
117
+ circle_pixels(image, cx, cy, radius + stroke_radius, border, clip)
118
+ circle_pixels(image, cx, cy, [radius - stroke_radius, 0].max, pixels, clip)
119
+ else
120
+ circle_pixels(image, cx, cy, radius, pixels, clip)
121
+ end
122
+ end
123
+ private_class_method :circle
124
+
125
+ def circle_pixels(image, cx, cy, radius, pixels, clip)
126
+ top = [0, (cy - radius).floor].max
127
+ bottom = [image.height - 1, (cy + radius).ceil].min
128
+ if clip
129
+ top = [top, clip[1].floor].max
130
+ bottom = [bottom, clip[3].ceil - 1].min
131
+ end
132
+ (top..bottom).each do |y|
133
+ dy = y + 0.5 - cy
134
+ half_width = Math.sqrt([(radius * radius) - (dy * dy), 0].max)
135
+ left = [0, (cx - half_width).ceil].max
136
+ right = [image.width - 1, (cx + half_width).floor].min
137
+ if clip
138
+ left = [left, clip[0].floor].max
139
+ right = [right, clip[2].ceil - 1].min
140
+ end
141
+ image.fill_rect(left, y, right - left + 1, 1, pixels, blend: :alpha) if left <= right
142
+ end
143
+ end
144
+ private_class_method :circle_pixels
145
+
146
+ def text(image, element, factor, clip, fonts)
147
+ string = element[:text].to_s
148
+ return if string.empty?
149
+
150
+ mask, ascent = text_mask(string, element[:size] || 12, factor, color(element[:fill]), fonts)
151
+ anchor = { start: 0, middle: mask.width / 2.0, end: mask.width }.fetch(element[:anchor] || :start)
152
+ pivot_x = element[:x] * factor
153
+ pivot_y = element[:y] * factor
154
+ angle = (element[:rotate] || 0) * Math::PI / 180
155
+ cosine = Math.cos(angle)
156
+ sine = Math.sin(angle)
157
+ bytes = mask.bytes
158
+ mask.height.times do |row|
159
+ mask.width.times do |column|
160
+ offset = ((row * mask.width) + column) * 4
161
+ alpha = bytes.getbyte(offset + 3)
162
+ next if alpha.zero?
163
+
164
+ local_x = column + 0.5 - anchor
165
+ local_y = row + 0.5 - ascent
166
+ x = (pivot_x + (local_x * cosine) - (local_y * sine)).round
167
+ y = (pivot_y + (local_x * sine) + (local_y * cosine)).round
168
+ next if clip && !(x >= clip[0] && x < clip[2] && y >= clip[1] && y < clip[3])
169
+
170
+ image.fill_rect(x, y, 1, 1, bytes.byteslice(offset, 4).bytes, blend: :alpha)
171
+ end
172
+ end
173
+ end
174
+ private_class_method :text
175
+
176
+ def text_mask(string, size, factor, pixels, fonts)
177
+ if Inkplot.config.font
178
+ font_size = [1, (size * factor).round].max
179
+ font = fonts[[Inkplot.config.font, font_size]] ||= Glyphic.load(Inkplot.config.font, size: font_size)
180
+ width, height = font.measure(string)
181
+ mask = Tessel::Image.new([1, width].max, [1, height].max)
182
+ font.draw(mask, 0, 0, string, color: pixels)
183
+ [mask, font.ascent]
184
+ else
185
+ font = fonts[:default] ||= Glyphic.default
186
+ mask = font.render(string, color: pixels)
187
+ ratio = size * factor / font.line_height
188
+ width = [1, (mask.width * ratio).round].max
189
+ height = [1, (mask.height * ratio).round].max
190
+ mask = mask.scale_nearest(width, height)
191
+ [mask, (font.ascent * ratio).round]
192
+ end
193
+ end
194
+ private_class_method :text_mask
195
+
196
+ def scaled_clip(clip)
197
+ clip&.map(&:round)
198
+ end
199
+ private_class_method :scaled_clip
200
+
201
+ def downsample(image)
202
+ factor = ANTIALIAS
203
+ width = image.width / factor
204
+ height = image.height / factor
205
+ source = image.bytes
206
+ output = String.new(capacity: width * height * 4, encoding: Encoding::BINARY)
207
+ height.times do |y|
208
+ width.times do |x|
209
+ channels = [0, 0, 0]
210
+ alpha = 0
211
+ factor.times do |dy|
212
+ factor.times do |dx|
213
+ offset = ((((y * factor) + dy) * image.width) + ((x * factor) + dx)) * 4
214
+ sample_alpha = source.getbyte(offset + 3)
215
+ alpha += sample_alpha
216
+ 3.times { |channel| channels[channel] += source.getbyte(offset + channel) * sample_alpha }
217
+ end
218
+ end
219
+ channels.map! { |value| alpha.zero? ? 0 : (value.to_f / alpha).round }
220
+ channels << (alpha.to_f / (factor * factor)).round
221
+ output << channels.pack("C4")
222
+ end
223
+ end
224
+ Tessel::Image.from_rgba(width, height, output)
225
+ end
226
+ private_class_method :downsample
227
+ end
228
+
229
+ module Raster
230
+ module PathFiller
231
+ module_function
232
+
233
+ def fill(image, points, color, clip: nil, opacity: 1)
234
+ return if points.length < 3
235
+
236
+ min_y = [points.map(&:last).min.floor, 0].max
237
+ max_y = [points.map(&:last).max.ceil, image.height].min
238
+ clip_top, clip_bottom = clip ? [clip[1], clip[3]] : [0, image.height]
239
+ min_y = [min_y, clip_top].max
240
+ max_y = [max_y, clip_bottom].min
241
+ rgba = color.dup
242
+ rgba[3] = (rgba[3] * opacity).round
243
+ (min_y...max_y).each do |y|
244
+ scan_y = y + 0.5
245
+ crossings = points.each_with_index.filter_map do |(x0, y0), index|
246
+ x1, y1 = points[(index + 1) % points.length]
247
+ next if y0 == y1 || !((y0 <= scan_y && scan_y < y1) || (y1 <= scan_y && scan_y < y0))
248
+
249
+ [x0 + ((scan_y - y0) * (x1 - x0) / (y1 - y0)), y1 > y0 ? 1 : -1]
250
+ end.sort_by(&:first)
251
+ winding = 0
252
+ start = nil
253
+ crossings.each do |x, direction|
254
+ previous = winding
255
+ winding += direction
256
+ start = x if previous.zero? && !winding.zero?
257
+ next unless !previous.zero? && winding.zero? && start
258
+
259
+ left = [0, (start - 0.5).ceil].max
260
+ right = [image.width, (x - 0.5).ceil].min
261
+ if clip
262
+ left = [left, (clip[0] - 0.5).ceil].max
263
+ right = [right, (clip[2] - 0.5).ceil].min
264
+ end
265
+ image.fill_rect(left, y, right - left, 1, rgba, blend: :alpha) if left < right
266
+ start = nil
267
+ end
268
+ end
269
+ end
270
+ end
271
+
272
+ module Stroker
273
+ module_function
274
+
275
+ def draw(image, element, factor, clip)
276
+ points = element[:points].map { |x, y| [x * factor, y * factor] }
277
+ return if points.length < 2
278
+
279
+ width = (element[:width] || 1) * factor
280
+ color = PNG.send(:color, element[:stroke])
281
+ segments = element[:dash] ? dashed(points, 5 * factor, 4 * factor) : points.each_cons(2).to_a
282
+ segments.each do |first, last|
283
+ polygon = segment_polygon(first, last, width)
284
+ PathFiller.fill(image, polygon, color, clip: PNG.send(:scaled_clip, clip))
285
+ end
286
+ return if element[:dash]
287
+
288
+ cap = (element[:cap] || :butt).to_sym
289
+ join = (element[:join] || :miter).to_sym
290
+ [points.first, points.last].each { |x, y| PNG.send(:circle_pixels, image, x, y, width / 2, color, clip) } if cap == :round
291
+ case join
292
+ when :round
293
+ points[1...-1].each { |x, y| PNG.send(:circle_pixels, image, x, y, width / 2, color, clip) }
294
+ when :miter
295
+ points.each_cons(3) { |previous, current, following| miter_join(image, previous, current, following, width, color, clip) }
296
+ when :bevel
297
+ points.each_cons(3) { |previous, current, following| bevel_join(image, previous, current, following, width, color, clip) }
298
+ end
299
+ end
300
+
301
+ def segment_polygon(first, last, width)
302
+ dx = last[0] - first[0]
303
+ dy = last[1] - first[1]
304
+ length = Math.sqrt((dx * dx) + (dy * dy))
305
+ return [] if length.zero?
306
+
307
+ nx = -dy * width / (2 * length)
308
+ ny = dx * width / (2 * length)
309
+ [[first[0] + nx, first[1] + ny], [last[0] + nx, last[1] + ny], [last[0] - nx, last[1] - ny], [first[0] - nx, first[1] - ny]]
310
+ end
311
+ private_class_method :segment_polygon
312
+
313
+ def miter_join(image, previous, current, following, width, color, clip)
314
+ first = unit(previous, current)
315
+ second = unit(current, following)
316
+ return unless first && second
317
+
318
+ cross = (first[0] * second[1]) - (first[1] * second[0])
319
+ return if cross.abs < 1e-9
320
+
321
+ half = width / 2
322
+ first_normal = [-first[1], first[0]]
323
+ second_normal = [-second[1], second[0]]
324
+ [-1, 1].each do |side|
325
+ a = [current[0] + (first_normal[0] * half * side), current[1] + (first_normal[1] * half * side)]
326
+ b = [current[0] + (second_normal[0] * half * side), current[1] + (second_normal[1] * half * side)]
327
+ t = (((b[0] - a[0]) * second[1]) - ((b[1] - a[1]) * second[0])) / cross
328
+ corner = [a[0] + (first[0] * t), a[1] + (first[1] * t)]
329
+ corner = current if Math.hypot(corner[0] - current[0], corner[1] - current[1]) > width * 4
330
+ PathFiller.fill(image, [a, corner, b], color, clip: PNG.send(:scaled_clip, clip))
331
+ end
332
+ end
333
+ private_class_method :miter_join
334
+
335
+ def unit(first, last)
336
+ dx = last[0] - first[0]
337
+ dy = last[1] - first[1]
338
+ length = Math.hypot(dx, dy)
339
+ length.zero? ? nil : [dx / length, dy / length]
340
+ end
341
+ private_class_method :unit
342
+
343
+ def bevel_join(image, previous, current, following, width, color, clip)
344
+ first = unit(previous, current)
345
+ second = unit(current, following)
346
+ return unless first && second
347
+
348
+ first_normal = [-first[1], first[0]]
349
+ second_normal = [-second[1], second[0]]
350
+ [-1, 1].each do |side|
351
+ a = [current[0] + (first_normal[0] * width * side / 2), current[1] + (first_normal[1] * width * side / 2)]
352
+ b = [current[0] + (second_normal[0] * width * side / 2), current[1] + (second_normal[1] * width * side / 2)]
353
+ PathFiller.fill(image, [current, a, b], color, clip: PNG.send(:scaled_clip, clip))
354
+ end
355
+ end
356
+ private_class_method :bevel_join
357
+
358
+ def dashed(points, dash, gap)
359
+ result = []
360
+ on = true
361
+ remaining = dash
362
+ points.each_cons(2) do |first, last|
363
+ dx = last[0] - first[0]
364
+ dy = last[1] - first[1]
365
+ length = Math.sqrt((dx * dx) + (dy * dy))
366
+ next if length.zero?
367
+
368
+ position = 0.0
369
+ while position < length
370
+ step = [remaining, length - position].min
371
+ if on && step.positive?
372
+ start = [first[0] + (dx * position / length), first[1] + (dy * position / length)]
373
+ finish = [first[0] + (dx * (position + step) / length), first[1] + (dy * (position + step) / length)]
374
+ result << [start, finish]
375
+ end
376
+ position += step
377
+ remaining -= step
378
+ if remaining <= 1e-9
379
+ on = !on
380
+ remaining = on ? dash : gap
381
+ end
382
+ end
383
+ end
384
+ result
385
+ end
386
+ private_class_method :dashed
387
+ end
388
+ end
389
+ end
390
+ end
@@ -45,7 +45,9 @@ module Inkplot
45
45
 
46
46
  d = "M #{n(points[0][0])} #{n(points[0][1])} " + points.drop(1).map { |px, py| "L #{n(px)} #{n(py)}" }.join(" ")
47
47
  dash = element[:dash] ? %( stroke-dasharray="5 4") : ""
48
- %(<path class="#{attr(element[:class] || 'mark-line')}" d="#{d}" fill="none" stroke="#{attr(element[:stroke])}" stroke-width="#{n(element[:width] || 1)}"#{dash}/>)
48
+ cap = element[:cap] ? %( stroke-linecap="#{attr(element[:cap])}") : ""
49
+ join = element[:join] ? %( stroke-linejoin="#{attr(element[:join])}") : ""
50
+ %(<path class="#{attr(element[:class] || 'mark-line')}" d="#{d}" fill="none" stroke="#{attr(element[:stroke])}" stroke-width="#{n(element[:width] || 1)}"#{dash}#{cap}#{join}/>)
49
51
  when :polygon
50
52
  points = element[:points]
51
53
  commands = points.drop(1).map { |px, py| "L #{n(px)} #{n(py)}" }.join(" ")
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Inkplot
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.1"
5
5
  end
data/lib/inkplot.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require_relative "inkplot/version"
4
4
  require_relative "inkplot/core"
5
5
  require_relative "inkplot/renderers/svg"
6
+ require_relative "inkplot/renderers/png"
6
7
 
7
8
  module Inkplot
8
9
  class Error < StandardError; end
@@ -42,6 +43,13 @@ module Inkplot
42
43
  Chart.new(builder)
43
44
  end
44
45
 
46
+ def histogram(data, bins: :auto, title: nil, x_label: nil, y_label: nil, **options)
47
+ chart_options, mark_options = options.partition { |key, _| %i[width height theme].include?(key) }.map(&:to_h)
48
+ builder = Builder.new(**chart_options, title:, x_label:, y_label:)
49
+ builder.histogram(data, bins:, **mark_options)
50
+ Chart.new(builder)
51
+ end
52
+
45
53
  def sparkline(values)
46
54
  values = values.filter_map { |value| Data.number(value) }
47
55
  return "" if values.empty?
data/sig/inkplot.rbs CHANGED
@@ -10,13 +10,15 @@ module Inkplot
10
10
  def x_axis: (?label: String?, ?type: Symbol?, ?scale: Symbol?, ?min: untyped, ?max: untyped, ?order: Array[untyped]?) -> Hash[Symbol, untyped]
11
11
  def y_axis: (?label: String?, ?type: Symbol?, ?scale: Symbol?, ?min: untyped, ?max: untyped) -> Hash[Symbol, untyped]
12
12
  def legend: (?position: Symbol) -> Hash[Symbol, Symbol]
13
- def line: (untyped x, ?untyped y, ?label: String?, ?color: String?, ?dash: bool, ?gaps: Symbol, **untyped options) -> Builder
13
+ def line: (untyped x, ?untyped y, ?label: String?, ?color: String?, ?dash: bool, ?gaps: Symbol, ?cap: Symbol, ?join: Symbol, **untyped options) -> Builder
14
14
  def scatter: (untyped x, ?untyped y, ?label: String?, ?color: String?, ?size: untyped, **untyped options) -> Builder
15
15
  def area: (untyped x, ?untyped y, ?label: String?, ?color: String?, **untyped options) -> Builder
16
16
  def step: (untyped x, ?untyped y, ?label: String?, ?color: String?, **untyped options) -> Builder
17
17
  def bar: (untyped categories, ?untyped values, ?label: String?, ?color: String?, ?horizontal: bool, ?stacked: bool, **untyped options) -> Builder
18
+ def histogram: (Array[untyped] values, ?bins: Symbol | Integer | Array[Numeric], ?label: String?, ?color: String?) -> Builder
18
19
  def hline: (Numeric value, ?label: String?, ?color: String?, ?dash: bool) -> Array[Hash[Symbol, untyped]]
19
20
  def vline: (untyped value, ?label: String?, ?color: String?, ?dash: bool) -> Array[Hash[Symbol, untyped]]
21
+ def annotate: (untyped x, Numeric y, String text, ?color: String?, ?anchor: Symbol) -> Array[Hash[Symbol, untyped]]
20
22
  end
21
23
 
22
24
  class Chart
@@ -24,7 +26,11 @@ module Inkplot
24
26
  attr_reader width: Integer
25
27
  attr_reader height: Integer
26
28
  def to_svg: (?width: Integer, ?height: Integer) -> String
27
- def save: (String | _Path path, ?width: Integer, ?height: Integer) -> String | _Path
29
+ def to_image: (?width: Integer, ?height: Integer, ?scale: Integer) -> untyped
30
+ def to_png: (?width: Integer, ?height: Integer, ?scale: Integer) -> String
31
+ def _repr_svg_: () -> String
32
+ def to_inlay: () -> Hash[Symbol, untyped]
33
+ def save: (String | Pathname path, ?width: Integer, ?height: Integer, ?scale: Integer) -> (String | Pathname)
28
34
  end
29
35
 
30
36
  def self.config: () -> Config
@@ -32,5 +38,6 @@ module Inkplot
32
38
  def self.line: (untyped data, ?untyped values, ?x: untyped, ?y: untyped, ?color: untyped, ?title: String?, ?x_label: String?, ?y_label: String?, **untyped options) -> Chart
33
39
  def self.scatter: (untyped data, ?untyped values, ?x: untyped, ?y: untyped, ?size: untyped, ?color: untyped, ?title: String?, ?x_label: String?, ?y_label: String?, **untyped options) -> Chart
34
40
  def self.bar: (untyped data, ?horizontal: bool, ?title: String?, ?x_label: String?, ?y_label: String?, **untyped options) -> Chart
41
+ def self.histogram: (Array[untyped] data, ?bins: Symbol | Integer | Array[Numeric], ?title: String?, ?x_label: String?, ?y_label: String?, **untyped options) -> Chart
35
42
  def self.sparkline: (Array[untyped] values) -> String
36
43
  end
Binary file
Binary file
Binary file
Binary file
Binary file
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: inkplot
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yudai Takada
8
+ autorequire:
8
9
  bindir: exe
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-09-25 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: rake
@@ -65,7 +66,8 @@ dependencies:
65
66
  - - "~>"
66
67
  - !ruby/object:Gem::Version
67
68
  version: '1.0'
68
- description: Build line, bar, scatter, and other charts as standalone SVG.
69
+ description: Build line, bar, scatter, histogram, and other charts as standalone SVG
70
+ or PNG.
69
71
  email:
70
72
  - t.yudai92@gmail.com
71
73
  executables: []
@@ -78,6 +80,7 @@ files:
78
80
  - README.md
79
81
  - Rakefile
80
82
  - bench/inkplot.rb
83
+ - docs/index.html
81
84
  - examples/gallery.rb
82
85
  - examples/gallery/01-line.svg
83
86
  - examples/gallery/02-line-gaps.svg
@@ -87,14 +90,45 @@ files:
87
90
  - examples/gallery/06-horizontal-bars.svg
88
91
  - examples/gallery/07-grouped-bars.svg
89
92
  - examples/gallery/08-stacked-bars.svg
93
+ - examples/gallery/09-histogram.svg
90
94
  - examples/gallery/10-area.svg
91
95
  - examples/gallery/11-step.svg
96
+ - examples/gallery/12-time.svg
97
+ - examples/gallery/13-annotations.svg
98
+ - examples/gallery/14-dark-theme.svg
99
+ - examples/gallery/15-category-labels.svg
100
+ - examples/gallery/16-custom-bins.svg
101
+ - examples/gallery/17-connected-gaps.svg
102
+ - examples/gallery/18-step-series.svg
103
+ - examples/gallery/19-combined-marks.svg
104
+ - examples/gallery/20-scatter-sizes.svg
92
105
  - examples/gallery/README.md
93
106
  - lib/inkplot.rb
94
107
  - lib/inkplot/core.rb
108
+ - lib/inkplot/renderers/png.rb
95
109
  - lib/inkplot/renderers/svg.rb
96
110
  - lib/inkplot/version.rb
97
111
  - sig/inkplot.rbs
112
+ - test/snapshots/inkplot/01-line.png
113
+ - test/snapshots/inkplot/02-line-gaps.png
114
+ - test/snapshots/inkplot/03-series.png
115
+ - test/snapshots/inkplot/04-scatter.png
116
+ - test/snapshots/inkplot/05-bars.png
117
+ - test/snapshots/inkplot/06-horizontal-bars.png
118
+ - test/snapshots/inkplot/07-grouped-bars.png
119
+ - test/snapshots/inkplot/08-stacked-bars.png
120
+ - test/snapshots/inkplot/09-histogram.png
121
+ - test/snapshots/inkplot/10-area.png
122
+ - test/snapshots/inkplot/11-step.png
123
+ - test/snapshots/inkplot/12-time.png
124
+ - test/snapshots/inkplot/13-annotations.png
125
+ - test/snapshots/inkplot/14-dark-theme.png
126
+ - test/snapshots/inkplot/15-category-labels.png
127
+ - test/snapshots/inkplot/16-custom-bins.png
128
+ - test/snapshots/inkplot/17-connected-gaps.png
129
+ - test/snapshots/inkplot/18-step-series.png
130
+ - test/snapshots/inkplot/19-combined-marks.png
131
+ - test/snapshots/inkplot/20-scatter-sizes.png
98
132
  homepage: https://github.com/rbgfx/inkplot
99
133
  licenses:
100
134
  - MIT
@@ -102,6 +136,7 @@ metadata:
102
136
  homepage_uri: https://github.com/rbgfx/inkplot
103
137
  source_code_uri: https://github.com/rbgfx/inkplot/tree/main
104
138
  rubygems_mfa_required: 'true'
139
+ post_install_message:
105
140
  rdoc_options: []
106
141
  require_paths:
107
142
  - lib
@@ -116,7 +151,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
116
151
  - !ruby/object:Gem::Version
117
152
  version: '0'
118
153
  requirements: []
119
- rubygems_version: 4.0.16
154
+ rubygems_version: 3.4.19
155
+ signing_key:
120
156
  specification_version: 4
121
- summary: SVG charts for Ruby
157
+ summary: Plain Ruby SVG and PNG charts
122
158
  test_files: []