inkplot 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.codespellignore +0 -0
- data/.yamllint +16 -0
- data/CHANGELOG.md +16 -1
- data/LICENSE.txt +1 -1
- data/README.md +43 -67
- data/bench/inkplot.rb +12 -3
- 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 +7 -7
- data/examples/gallery.rb +26 -44
- data/lib/inkplot/core.rb +351 -146
- 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 +3 -3
- data/sig/inkplot.rbs +7 -6
- 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 -18
- data/examples/gallery/09-signed-stack.svg +0 -1
- data/examples/gallery/12-histogram.svg +0 -1
- data/examples/gallery/13-log-scale.svg +0 -1
- data/examples/gallery/14-time-axis.svg +0 -1
- data/examples/gallery/15-category-order.svg +0 -1
- data/examples/gallery/16-dark-theme.svg +0 -1
- data/examples/gallery/17-annotations.svg +0 -1
- data/examples/gallery/18-combo.svg +0 -1
- data/examples/gallery/19-legend-position.svg +0 -1
- data/examples/gallery/20-long-categories.svg +0 -1
- data/lib/inkplot/renderers/raster.rb +0 -322
|
@@ -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
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "date"
|
|
4
3
|
require_relative "inkplot/version"
|
|
5
4
|
require_relative "inkplot/core"
|
|
6
5
|
require_relative "inkplot/renderers/svg"
|
|
6
|
+
require_relative "inkplot/renderers/png"
|
|
7
7
|
|
|
8
8
|
module Inkplot
|
|
9
9
|
class Error < StandardError; end
|
|
@@ -43,10 +43,10 @@ module Inkplot
|
|
|
43
43
|
Chart.new(builder)
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
-
def histogram(
|
|
46
|
+
def histogram(data, bins: :auto, title: nil, x_label: nil, y_label: nil, **options)
|
|
47
47
|
chart_options, mark_options = options.partition { |key, _| %i[width height theme].include?(key) }.map(&:to_h)
|
|
48
48
|
builder = Builder.new(**chart_options, title:, x_label:, y_label:)
|
|
49
|
-
builder.histogram(
|
|
49
|
+
builder.histogram(data, bins:, **mark_options)
|
|
50
50
|
Chart.new(builder)
|
|
51
51
|
end
|
|
52
52
|
|
data/sig/inkplot.rbs
CHANGED
|
@@ -10,15 +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: (untyped values, ?bins:
|
|
18
|
+
def histogram: (Array[untyped] values, ?bins: Symbol | Integer | Array[Numeric], ?label: String?, ?color: String?) -> Builder
|
|
19
19
|
def hline: (Numeric value, ?label: String?, ?color: String?, ?dash: bool) -> Array[Hash[Symbol, untyped]]
|
|
20
20
|
def vline: (untyped value, ?label: String?, ?color: String?, ?dash: bool) -> Array[Hash[Symbol, untyped]]
|
|
21
|
-
def
|
|
21
|
+
def annotate: (untyped x, Numeric y, String text, ?color: String?, ?anchor: Symbol) -> Array[Hash[Symbol, untyped]]
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
class Chart
|
|
@@ -26,10 +26,11 @@ module Inkplot
|
|
|
26
26
|
attr_reader width: Integer
|
|
27
27
|
attr_reader height: Integer
|
|
28
28
|
def to_svg: (?width: Integer, ?height: Integer) -> String
|
|
29
|
+
def to_image: (?width: Integer, ?height: Integer, ?scale: Integer) -> untyped
|
|
29
30
|
def to_png: (?width: Integer, ?height: Integer, ?scale: Integer) -> String
|
|
30
|
-
def
|
|
31
|
-
def save: (String | _Path path, ?width: Integer, ?height: Integer, ?scale: Integer) -> String | _Path
|
|
31
|
+
def _repr_svg_: () -> String
|
|
32
32
|
def to_inlay: () -> Hash[Symbol, untyped]
|
|
33
|
+
def save: (String | Pathname path, ?width: Integer, ?height: Integer, ?scale: Integer) -> (String | Pathname)
|
|
33
34
|
end
|
|
34
35
|
|
|
35
36
|
def self.config: () -> Config
|
|
@@ -37,6 +38,6 @@ module Inkplot
|
|
|
37
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
|
|
38
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
|
|
39
40
|
def self.bar: (untyped data, ?horizontal: bool, ?title: String?, ?x_label: String?, ?y_label: String?, **untyped options) -> Chart
|
|
40
|
-
def self.histogram: (untyped
|
|
41
|
+
def self.histogram: (Array[untyped] data, ?bins: Symbol | Integer | Array[Numeric], ?title: String?, ?x_label: String?, ?y_label: String?, **untyped options) -> Chart
|
|
41
42
|
def self.sparkline: (Array[untyped] values) -> String
|
|
42
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,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: inkplot
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.3.
|
|
4
|
+
version: 0.3.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yudai Takada
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-26 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rake
|
|
@@ -30,14 +30,14 @@ dependencies:
|
|
|
30
30
|
requirements:
|
|
31
31
|
- - "~>"
|
|
32
32
|
- !ruby/object:Gem::Version
|
|
33
|
-
version: '
|
|
33
|
+
version: '4.1'
|
|
34
34
|
type: :development
|
|
35
35
|
prerelease: false
|
|
36
36
|
version_requirements: !ruby/object:Gem::Requirement
|
|
37
37
|
requirements:
|
|
38
38
|
- - "~>"
|
|
39
39
|
- !ruby/object:Gem::Version
|
|
40
|
-
version: '
|
|
40
|
+
version: '4.1'
|
|
41
41
|
- !ruby/object:Gem::Dependency
|
|
42
42
|
name: rspec
|
|
43
43
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -66,20 +66,23 @@ dependencies:
|
|
|
66
66
|
- - "~>"
|
|
67
67
|
- !ruby/object:Gem::Version
|
|
68
68
|
version: '1.0'
|
|
69
|
-
description: Build line, bar, scatter, and other charts as standalone SVG
|
|
70
|
-
PNG
|
|
69
|
+
description: Build line, bar, scatter, histogram, and other charts as standalone SVG
|
|
70
|
+
or PNG.
|
|
71
71
|
email:
|
|
72
72
|
- t.yudai92@gmail.com
|
|
73
73
|
executables: []
|
|
74
74
|
extensions: []
|
|
75
75
|
extra_rdoc_files: []
|
|
76
76
|
files:
|
|
77
|
+
- ".codespellignore"
|
|
77
78
|
- ".rubocop.yml"
|
|
79
|
+
- ".yamllint"
|
|
78
80
|
- CHANGELOG.md
|
|
79
81
|
- LICENSE.txt
|
|
80
82
|
- README.md
|
|
81
83
|
- Rakefile
|
|
82
84
|
- bench/inkplot.rb
|
|
85
|
+
- docs/index.html
|
|
83
86
|
- examples/gallery.rb
|
|
84
87
|
- examples/gallery/01-line.svg
|
|
85
88
|
- examples/gallery/02-line-gaps.svg
|
|
@@ -89,25 +92,45 @@ files:
|
|
|
89
92
|
- examples/gallery/06-horizontal-bars.svg
|
|
90
93
|
- examples/gallery/07-grouped-bars.svg
|
|
91
94
|
- examples/gallery/08-stacked-bars.svg
|
|
92
|
-
- examples/gallery/09-
|
|
95
|
+
- examples/gallery/09-histogram.svg
|
|
93
96
|
- examples/gallery/10-area.svg
|
|
94
97
|
- examples/gallery/11-step.svg
|
|
95
|
-
- examples/gallery/12-
|
|
96
|
-
- examples/gallery/13-
|
|
97
|
-
- examples/gallery/14-
|
|
98
|
-
- examples/gallery/15-category-
|
|
99
|
-
- examples/gallery/16-
|
|
100
|
-
- examples/gallery/17-
|
|
101
|
-
- examples/gallery/18-
|
|
102
|
-
- examples/gallery/19-
|
|
103
|
-
- examples/gallery/20-
|
|
98
|
+
- examples/gallery/12-time.svg
|
|
99
|
+
- examples/gallery/13-annotations.svg
|
|
100
|
+
- examples/gallery/14-dark-theme.svg
|
|
101
|
+
- examples/gallery/15-category-labels.svg
|
|
102
|
+
- examples/gallery/16-custom-bins.svg
|
|
103
|
+
- examples/gallery/17-connected-gaps.svg
|
|
104
|
+
- examples/gallery/18-step-series.svg
|
|
105
|
+
- examples/gallery/19-combined-marks.svg
|
|
106
|
+
- examples/gallery/20-scatter-sizes.svg
|
|
104
107
|
- examples/gallery/README.md
|
|
105
108
|
- lib/inkplot.rb
|
|
106
109
|
- lib/inkplot/core.rb
|
|
107
|
-
- lib/inkplot/renderers/
|
|
110
|
+
- lib/inkplot/renderers/png.rb
|
|
108
111
|
- lib/inkplot/renderers/svg.rb
|
|
109
112
|
- lib/inkplot/version.rb
|
|
110
113
|
- sig/inkplot.rbs
|
|
114
|
+
- test/snapshots/inkplot/01-line.png
|
|
115
|
+
- test/snapshots/inkplot/02-line-gaps.png
|
|
116
|
+
- test/snapshots/inkplot/03-series.png
|
|
117
|
+
- test/snapshots/inkplot/04-scatter.png
|
|
118
|
+
- test/snapshots/inkplot/05-bars.png
|
|
119
|
+
- test/snapshots/inkplot/06-horizontal-bars.png
|
|
120
|
+
- test/snapshots/inkplot/07-grouped-bars.png
|
|
121
|
+
- test/snapshots/inkplot/08-stacked-bars.png
|
|
122
|
+
- test/snapshots/inkplot/09-histogram.png
|
|
123
|
+
- test/snapshots/inkplot/10-area.png
|
|
124
|
+
- test/snapshots/inkplot/11-step.png
|
|
125
|
+
- test/snapshots/inkplot/12-time.png
|
|
126
|
+
- test/snapshots/inkplot/13-annotations.png
|
|
127
|
+
- test/snapshots/inkplot/14-dark-theme.png
|
|
128
|
+
- test/snapshots/inkplot/15-category-labels.png
|
|
129
|
+
- test/snapshots/inkplot/16-custom-bins.png
|
|
130
|
+
- test/snapshots/inkplot/17-connected-gaps.png
|
|
131
|
+
- test/snapshots/inkplot/18-step-series.png
|
|
132
|
+
- test/snapshots/inkplot/19-combined-marks.png
|
|
133
|
+
- test/snapshots/inkplot/20-scatter-sizes.png
|
|
111
134
|
homepage: https://github.com/rbgfx/inkplot
|
|
112
135
|
licenses:
|
|
113
136
|
- MIT
|
|
@@ -133,5 +156,5 @@ requirements: []
|
|
|
133
156
|
rubygems_version: 3.4.19
|
|
134
157
|
signing_key:
|
|
135
158
|
specification_version: 4
|
|
136
|
-
summary:
|
|
159
|
+
summary: Plain Ruby SVG and PNG charts
|
|
137
160
|
test_files: []
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 360" width="640" height="360" role="img" class="inkplot"><defs><clipPath id="plot-clip"><rect x="42" y="40" width="460" height="286"/></clipPath></defs><rect x="0" y="0" width="640" height="360" fill="#FFFFFF"/><path class="grid-line" d="M 42 326 L 502 326" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="34" y="330" fill="#626B78" text-anchor="end" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">-10</text><path class="grid-line" d="M 42 230.67 L 502 230.67" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="34" y="234.67" fill="#626B78" text-anchor="end" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">0</text><path class="grid-line" d="M 42 135.33 L 502 135.33" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="34" y="139.33" fill="#626B78" text-anchor="end" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">10</text><path class="grid-line" d="M 88 40 L 88 326" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="88" y="344" fill="#626B78" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">Mon</text><path class="grid-line" d="M 180 40 L 180 326" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="180" y="344" fill="#626B78" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">Tue</text><path class="grid-line" d="M 272 40 L 272 326" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="272" y="344" fill="#626B78" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">Wed</text><path class="grid-line" d="M 364 40 L 364 326" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="364" y="344" fill="#626B78" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">Thu</text><path class="grid-line" d="M 456 40 L 456 326" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="456" y="344" fill="#626B78" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">Fri</text><path class="axis-line" d="M 42 40 L 42 326 L 502 326" fill="none" stroke="#626B78" stroke-width="1"/><text x="320" y="22" fill="#20242B" text-anchor="middle" font-size="15" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">Daily net change</text><path class="mark-line" d="M 390 54 L 406 54" fill="none" stroke="#0072B2" stroke-width="2"/><text x="412" y="58" fill="#20242B" text-anchor="start" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">New</text><path class="mark-line" d="M 390 72 L 406 72" fill="none" stroke="#E69F00" stroke-width="2"/><text x="412" y="76" fill="#20242B" text-anchor="start" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">Removed</text><g class="series-marks" clip-path="url(#plot-clip)"><g class="series series-0" data-series="0"><rect class="bar-mark" x="52.12" y="154.4" width="70.76" height="76.27" fill="#0072B2"/><rect class="bar-mark" x="144.12" y="230.67" width="70.76" height="38.13" fill="#0072B2"/><rect class="bar-mark" x="236.12" y="183" width="70.76" height="47.67" fill="#0072B2"/><rect class="bar-mark" x="328.12" y="230.67" width="70.76" height="19.07" fill="#0072B2"/><rect class="bar-mark" x="420.12" y="144.87" width="70.76" height="85.8" fill="#0072B2"/></g><g class="series series-1" data-series="1"><rect class="bar-mark" x="52.12" y="125.8" width="70.76" height="28.6" fill="#E69F00"/><rect class="bar-mark" x="144.12" y="268.8" width="70.76" height="57.2" fill="#E69F00"/><rect class="bar-mark" x="236.12" y="163.93" width="70.76" height="19.07" fill="#E69F00"/><rect class="bar-mark" x="328.12" y="249.73" width="70.76" height="47.67" fill="#E69F00"/><rect class="bar-mark" x="420.12" y="106.73" width="70.76" height="38.13" fill="#E69F00"/></g></g></svg>
|