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
|
@@ -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
|
-
|
|
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(" ")
|
data/lib/inkplot/version.rb
CHANGED
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
|
|
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
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
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
|
|
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:
|
|
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.
|
|
154
|
+
rubygems_version: 3.4.19
|
|
155
|
+
signing_key:
|
|
120
156
|
specification_version: 4
|
|
121
|
-
summary: SVG
|
|
157
|
+
summary: Plain Ruby SVG and PNG charts
|
|
122
158
|
test_files: []
|