inkplot 0.1.0

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.
@@ -0,0 +1,624 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inkplot
4
+ module Data
5
+ module_function
6
+
7
+ def number(value)
8
+ return nil if value.nil?
9
+ return value.to_f if value.is_a?(Numeric) && value.to_f.finite?
10
+
11
+ Float(value, exception: false)&.then { |number| number.finite? ? number : nil }
12
+ rescue RangeError
13
+ nil
14
+ end
15
+
16
+ def points(data, values = nil, x: nil, y: nil, size: nil)
17
+ if values
18
+ xs = Array(data)
19
+ ys = Array(values)
20
+ raise ArgumentError, "x and y must have the same number of values" unless xs.length == ys.length
21
+
22
+ sizes = size.is_a?(Array) ? size : Array.new(xs.length, size)
23
+ return xs.each_index.map { |index| { x: xs[index], y: number(ys[index]), size: number(sizes[index]) } }
24
+ end
25
+ if data.is_a?(Hash) && x && y
26
+ x_values = value(data, x)
27
+ y_values = value(data, y)
28
+ return points(x_values, y_values, size: size && value(data, size)) if x_values && y_values
29
+ end
30
+ return data.map { |key, value| { x: key, y: number(value) } } if data.is_a?(Hash)
31
+ return rows(data, x:, y:, size:) if records?(data)
32
+ return data.map { |x_value, y_value| { x: x_value, y: number(y_value) } } if data.is_a?(Array) && data.first.is_a?(Array) && data.first.length == 2
33
+
34
+ Array(data).each_with_index.map { |value, index| { x: index, y: number(value) } }
35
+ end
36
+
37
+ def records?(data)
38
+ first = data.first if data.is_a?(Array)
39
+ first&.then { |row| row.is_a?(Hash) || (row.respond_to?(:to_h) && !row.is_a?(Array)) } || (data.respond_to?(:headers) && data.respond_to?(:each))
40
+ end
41
+
42
+ def rows(data, x: nil, y: nil, size: nil, group: nil)
43
+ source = data.respond_to?(:headers) && data.respond_to?(:each) ? data : Array(data)
44
+ source.map do |row|
45
+ record = row.respond_to?(:to_h) ? row.to_h : row
46
+ x_key = x || record.keys[0]
47
+ y_key = y || record.keys[1]
48
+ { x: value(record, x_key), y: number(value(record, y_key)), size: number(value(record, size)), group: value(record, group) }
49
+ end
50
+ end
51
+
52
+ def value(record, key)
53
+ return nil if key.nil?
54
+ return record[key] if record.key?(key)
55
+
56
+ alternate = key.is_a?(Symbol) ? key.to_s : key.to_sym
57
+ record[alternate]
58
+ rescue NoMethodError
59
+ nil
60
+ end
61
+ end
62
+
63
+ module Ticks
64
+ module_function
65
+
66
+ def linear(minimum, maximum, count: 5)
67
+ return [minimum] if minimum >= maximum
68
+
69
+ step = linear_step(minimum, maximum, count:)
70
+ first = (minimum / step).ceil * step
71
+ last = (maximum / step).floor * step
72
+ ticks = []
73
+ value = first
74
+ while value <= last + (step * 1e-10) && ticks.length < 1000
75
+ ticks << ((value / step).round(10) * step)
76
+ value += step
77
+ end
78
+ ticks.empty? ? [minimum, maximum].uniq : ticks
79
+ end
80
+
81
+ def linear_step(minimum, maximum, count: 5)
82
+ return 1.0 unless maximum > minimum
83
+
84
+ raw = (maximum - minimum).to_f / [count - 1, 1].max
85
+ power = 10.0**Math.log10(raw).floor
86
+ fraction = raw / power
87
+ (if fraction <= 1
88
+ 1
89
+ elsif fraction <= 2
90
+ 2
91
+ else
92
+ fraction <= 5 ? 5 : 10
93
+ end) * power
94
+ end
95
+
96
+ def log(minimum, maximum)
97
+ low = Math.log10(minimum).floor
98
+ high = Math.log10(maximum).ceil
99
+ exponents = (low..high).to_a
100
+ if high - low <= 2
101
+ exponents.flat_map { |power| [1, 2, 5].map { |multiple| multiple * (10.0**power) } }.grep(minimum..maximum)
102
+ else
103
+ exponents.map { |power| 10.0**power }.grep(minimum..maximum)
104
+ end
105
+ end
106
+
107
+ def number_label(value)
108
+ value.to_i == value ? value.to_i.to_s : format("%.8f", value).sub(/0+\z/, "").sub(/\.\z/, "")
109
+ end
110
+ end
111
+
112
+ module Scales
113
+ class Linear
114
+ attr_reader :domain, :ticks
115
+ attr_accessor :range
116
+
117
+ def initialize(values, options = {}, include_zero: false, **axis_options)
118
+ options = options.merge(axis_options)
119
+ values = values.filter_map { |value| Data.number(value) }
120
+ low, high = values.minmax
121
+ low ||= 0.0
122
+ high ||= 1.0
123
+ low = [low, 0.0].min if include_zero
124
+ high = [high, 0.0].max if include_zero
125
+ low = limit(options[:min], low, "minimum")
126
+ high = limit(options[:max], high, "maximum")
127
+ if low == high
128
+ raise ArgumentError, "linear axis minimum must be less than maximum" if options[:min] && options[:max]
129
+
130
+ low -= low.zero? ? 1 : low.abs * 0.1 if options[:min].nil?
131
+ high += high.zero? ? 1 : high.abs * 0.1 if options[:max].nil?
132
+ end
133
+ raise ArgumentError, "linear axis minimum must be less than maximum" unless low < high
134
+
135
+ @ticks = Ticks.linear(low, high)
136
+ step = Ticks.linear_step(low, high)
137
+ low = (low / step).floor * step if options[:min].nil?
138
+ high = (high / step).ceil * step if options[:max].nil?
139
+ @domain = [low, high]
140
+ end
141
+
142
+ def map(value)
143
+ value = Data.number(value)
144
+ return nil unless value
145
+
146
+ @range[0] + ((value - @domain[0]) * (@range[1] - @range[0]) / (@domain[1] - @domain[0]))
147
+ end
148
+
149
+ def format(value) = Ticks.number_label(value)
150
+
151
+ private
152
+
153
+ def limit(value, fallback, name)
154
+ return fallback if value.nil?
155
+
156
+ number = Data.number(value)
157
+ raise ArgumentError, "linear axis #{name} must be numeric" unless number
158
+
159
+ number
160
+ end
161
+ end
162
+
163
+ class Log
164
+ attr_reader :domain, :ticks
165
+ attr_accessor :range
166
+
167
+ def initialize(values, options = {})
168
+ positive = values.filter_map { |value| Data.number(value) }.select(&:positive?)
169
+ low, high = positive.minmax
170
+ low ||= 1.0
171
+ high ||= 10.0
172
+ low = axis_limit(options[:min], low, "minimum")
173
+ high = axis_limit(options[:max], high, "maximum")
174
+ raise ArgumentError, "log axis limits must be positive" unless low.positive? && high.positive?
175
+ raise ArgumentError, "log axis minimum must be less than maximum" if low == high && options[:min] && options[:max]
176
+
177
+ high = low * 10 if low == high
178
+ raise ArgumentError, "log axis minimum must be less than maximum" unless low < high
179
+
180
+ @ticks = Ticks.log(low, high)
181
+ @domain = [low, high]
182
+ end
183
+
184
+ def map(value)
185
+ number = Data.number(value)
186
+ return nil unless number&.positive?
187
+
188
+ (@range[0] + ((Math.log10(number) - Math.log10(@domain[0])) * (@range[1] - @range[0]) / (Math.log10(@domain[1]) - Math.log10(@domain[0]))))
189
+ end
190
+
191
+ def format(value) = Ticks.number_label(value)
192
+
193
+ private
194
+
195
+ def axis_limit(value, fallback, name)
196
+ return fallback if value.nil?
197
+
198
+ number = Data.number(value)
199
+ raise ArgumentError, "log axis #{name} must be finite and positive" unless number&.positive?
200
+
201
+ number
202
+ end
203
+ end
204
+
205
+ class Band
206
+ attr_reader :domain, :ticks
207
+ attr_accessor :range
208
+
209
+ def initialize(values, options = {})
210
+ values = values.compact.uniq
211
+ order = options[:order]
212
+ values = order.select { |value| values.include?(value) } + (values - order) if order
213
+ @domain = @ticks = values
214
+ end
215
+
216
+ def map(value)
217
+ index = @domain.index(value)
218
+ return nil unless index
219
+
220
+ @range[0] + ((index + 0.5) * (@range[1] - @range[0]) / @domain.length)
221
+ end
222
+
223
+ def bandwidth = (@range[1] - @range[0]) / [@domain.length, 1].max
224
+ def format(value) = value.to_s
225
+ end
226
+ end
227
+
228
+ module TextMetrics
229
+ module_function
230
+
231
+ def width(text, size = 12)
232
+ if Inkplot.config.font
233
+ font = font_for(size)
234
+ return font.measure(text.to_s)[0]
235
+ end
236
+
237
+ text.to_s.each_char.sum { |character| character.ascii_only? ? size * 0.58 : size }
238
+ rescue LoadError
239
+ raise if Inkplot.config.font
240
+
241
+ text.to_s.length * size * 0.58
242
+ end
243
+
244
+ def font_for(size = 12)
245
+ require "glyphic"
246
+ return Glyphic.load(Inkplot.config.font, size: size) if Inkplot.config.font
247
+
248
+ Glyphic.default
249
+ rescue LoadError => e
250
+ raise LoadError, "font metrics need glyphic; install it with `gem install glyphic` (#{e.message})"
251
+ end
252
+ end
253
+
254
+ module Theme
255
+ PALETTE = %w[#0072B2 #E69F00 #009E73 #D55E00 #CC79A7 #56B4E9 #F0E442 #333333].freeze
256
+ NAMED_COLORS = %w[red blue green black white orange gray grey purple yellow cyan magenta pink brown navy teal lime maroon olive silver aqua fuchsia rebeccapurple
257
+ transparent].freeze
258
+ VALUES = {
259
+ light: { background: "#FFFFFF", foreground: "#20242B", grid: "#E7EAF0", axis: "#626B78" },
260
+ dark: { background: "#171B22", foreground: "#E8ECF2", grid: "#303744", axis: "#A9B2C0" }
261
+ }.freeze
262
+
263
+ module_function
264
+
265
+ def colors(name)
266
+ VALUES.fetch(name.to_sym) { raise ArgumentError, "theme must be :light or :dark" }
267
+ end
268
+
269
+ def series_color(index, theme)
270
+ color = PALETTE[index % PALETTE.length]
271
+ return color if index < PALETTE.length || theme.to_sym == :light
272
+
273
+ rgb = color.delete_prefix("#").scan(/../).map { |part| part.to_i(16) }
274
+ rgb.map { |channel| ((channel * 0.72) + (255 * 0.28)).round }.map { |channel| format("%02X", channel) }.join.prepend("#")
275
+ end
276
+
277
+ def validate_color(color)
278
+ return nil if color.nil?
279
+
280
+ value = color.to_s
281
+ return value if value.match?(/\A(?:#[\da-fA-F]{3,4}|#[\da-fA-F]{6}|#[\da-fA-F]{8})\z/) || NAMED_COLORS.include?(value.downcase)
282
+
283
+ raise ArgumentError, "color must be a CSS color name or hexadecimal value"
284
+ end
285
+ end
286
+
287
+ class Builder
288
+ attr_reader :width, :height, :theme, :series, :x_options, :y_options, :notes
289
+ attr_accessor :title_text, :x_label_text, :y_label_text, :legend_options
290
+
291
+ def initialize(width: 640, height: 360, theme: :light, title: nil, x_label: nil, y_label: nil, **_options)
292
+ @width = Integer(width)
293
+ @height = Integer(height)
294
+ raise ArgumentError, "chart dimensions must be at least 120×100" if @width < 120 || @height < 100
295
+
296
+ @theme = theme.to_sym
297
+ Theme.colors(@theme)
298
+ @title_text = title
299
+ @x_label_text = x_label
300
+ @y_label_text = y_label
301
+ @x_options = {}
302
+ @y_options = {}
303
+ @legend_options = { position: :top_right }
304
+ @series = []
305
+ @notes = []
306
+ end
307
+
308
+ def title(value) = @title_text = value.to_s
309
+
310
+ def x_axis(label: nil, type: nil, scale: nil, min: nil, max: nil, order: nil)
311
+ @x_label_text = label.to_s if label
312
+ @x_options = { type:, scale:, min:, max:, order: }.compact
313
+ end
314
+
315
+ def y_axis(label: nil, type: nil, scale: nil, min: nil, max: nil)
316
+ @y_label_text = label.to_s if label
317
+ @y_options = { type:, scale:, min:, max: }.compact
318
+ end
319
+
320
+ def legend(position: :top_right) = @legend_options = { position: position.to_sym }
321
+
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:, **)
324
+ end
325
+
326
+ def scatter(x, y = nil, label: nil, color: nil, size: nil, **)
327
+ add(:scatter, Data.points(x, y, size:), label:, color:, **)
328
+ end
329
+
330
+ def area(x, y = nil, label: nil, color: nil, **)
331
+ add(:area, Data.points(x, y), label:, color:, **)
332
+ end
333
+
334
+ def step(x, y = nil, label: nil, color: nil, **)
335
+ add(:step, Data.points(x, y), label:, color:, **)
336
+ end
337
+
338
+ def bar(categories, values = nil, label: nil, color: nil, horizontal: false, stacked: false, **)
339
+ points = Data.points(categories, values)
340
+ add(:bar, points, label:, color:, horizontal:, stacked:, **)
341
+ end
342
+
343
+ def hline(value, label: nil, color: nil, dash: true)
344
+ number = Data.number(value)
345
+ raise ArgumentError, "horizontal rule value must be finite and numeric" unless number
346
+
347
+ @notes << { type: :hline, value: number, label:, color: Theme.validate_color(color), dash: }
348
+ end
349
+
350
+ def vline(value, label: nil, color: nil, dash: true)
351
+ @notes << { type: :vline, value:, label:, color: Theme.validate_color(color), dash: }
352
+ end
353
+
354
+ def add(type, points, label: nil, color: nil, **options)
355
+ raise ArgumentError, "unsupported chart mark: #{type}" unless %i[line scatter bar area step].include?(type.to_sym)
356
+ raise ArgumentError, "gaps must be :break or :connect" if options[:gaps] && !%i[break connect].include?(options[:gaps])
357
+
358
+ @series << Series.new(type: type.to_sym, points:, label: label&.to_s, color: Theme.validate_color(color), options:)
359
+ self
360
+ end
361
+ end
362
+
363
+ class Chart
364
+ attr_reader :builder
365
+
366
+ def initialize(builder)
367
+ @builder = builder
368
+ end
369
+
370
+ def width = builder.width
371
+ def height = builder.height
372
+
373
+ def to_svg(width: self.width, height: self.height)
374
+ Renderers::SVG.render(SceneBuilder.call(self, width: width, height: height))
375
+ end
376
+
377
+ def save(path, width: self.width, height: self.height)
378
+ case File.extname(String(path)).downcase
379
+ when ".svg" then File.write(path, to_svg(width:, height:))
380
+ else raise ArgumentError, "output path must end in .svg"
381
+ end
382
+ path
383
+ end
384
+
385
+ private
386
+
387
+ def series = builder.series
388
+ end
389
+
390
+ module SceneBuilder
391
+ module_function
392
+
393
+ def call(chart, width:, height:)
394
+ builder = chart.builder
395
+ width = Integer(width)
396
+ height = Integer(height)
397
+ raise ArgumentError, "chart dimensions must be at least 120×100" if width < 120 || height < 100
398
+
399
+ series = builder.series
400
+ raise ArgumentError, "chart has no data marks" if series.empty?
401
+
402
+ warn "Inkplot cycles its eight-color palette after eight series." if series.length > Theme::PALETTE.length
403
+ colors = Theme.colors(builder.theme)
404
+ x_values = series.flat_map { |item| item.points.flat_map { |point| [point[:x], point[:x2]].compact } }
405
+ y_values = series.flat_map { |item| item.points.map { |point| point[:y] } }
406
+ if series.any? { |item| item.options[:stacked] }
407
+ totals = stacked_totals(series)
408
+ y_values.concat(totals)
409
+ end
410
+ horizontal = series.any? { |item| item.type == :bar && item.options[:horizontal] }
411
+ if horizontal
412
+ x_values = series.flat_map { |item| item.points.map { |point| point[:y] } }
413
+ y_values = series.flat_map { |item| item.points.map { |point| point[:x] } }
414
+ x_values.concat(stacked_totals(series)) if series.any? { |item| item.options[:stacked] }
415
+ end
416
+ x_options = builder.x_options
417
+ y_options = builder.y_options
418
+ x_scale = build_scale(x_values, x_options, :x, series)
419
+ y_scale = build_scale(y_values, y_options, :y, series)
420
+ x_ticks = ticks(x_scale)
421
+ y_ticks = ticks(y_scale)
422
+ left = (y_ticks.map { |tick| TextMetrics.width(y_scale.format(tick), 11) }.max.to_f.ceil + 14).clamp(42, 120)
423
+ bottom = 34
424
+ bottom += 20 if builder.x_label_text
425
+ left += 18 if builder.y_label_text
426
+ top = builder.title_text ? 40 : 18
427
+ legend_items = series.each_with_index.filter_map { |item, index| [item.label, item.color || Theme.series_color(index, builder.theme)] if item.label }
428
+ legend_items.uniq!(&:first)
429
+ legend_width = [legend_items.map { |label, _| TextMetrics.width(label, 11) }.max.to_f.ceil + 30, 120].max
430
+ right = builder.legend_options[:position].to_s.end_with?("right") && legend_items.length > 1 ? legend_width + 18 : 18
431
+ x_scale.range = [left, width - right]
432
+ y_scale.range = [height - bottom, top]
433
+ clip = [left, top, width - right, height - bottom]
434
+ marks = []
435
+ elements = [{ type: :rect, x: 0, y: 0, width:, height:, fill: colors[:background] }]
436
+
437
+ y_ticks.each do |tick|
438
+ y = y_scale.map(tick)
439
+ next unless y
440
+
441
+ elements << { type: :line, class: "grid-line", points: [[left, y], [width - right, y]], stroke: colors[:grid], width: 1 }
442
+ elements << { type: :text, x: left - 8, y: y + 4, text: y_scale.format(tick), fill: colors[:axis], anchor: :end, size: 11 }
443
+ end
444
+ x_ticks.each_with_index do |tick, _index|
445
+ x = x_scale.map(tick)
446
+ next unless x
447
+
448
+ label = x_scale.format(tick)
449
+ 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 }
451
+ end
452
+ elements << { type: :line, class: "axis-line", points: [[left, top], [left, height - bottom], [width - right, height - bottom]], stroke: colors[:axis], width: 1 }
453
+ elements << { type: :text, x: width / 2.0, y: 22, text: builder.title_text, fill: colors[:foreground], anchor: :middle, size: 15 } if builder.title_text
454
+ if builder.x_label_text
455
+ elements << { type: :text, x: (left + width - right) / 2.0, y: height - 5, text: builder.x_label_text, fill: colors[:foreground], anchor: :middle,
456
+ size: 11 }
457
+ end
458
+ elements << { type: :text, x: 14, y: height / 2.0, text: builder.y_label_text, fill: colors[:foreground], anchor: :middle, size: 11, rotate: -90 } if builder.y_label_text
459
+
460
+ bars = series.select { |item| item.type == :bar }
461
+ bar_index = 0
462
+ positive_stack = Hash.new(0.0)
463
+ negative_stack = Hash.new(0.0)
464
+ series.each_with_index do |item, index|
465
+ color = item.color || Theme.series_color(index, builder.theme)
466
+ points = item.points
467
+ if item.type == :bar
468
+ current_bar_index = bar_index
469
+ bar_index += 1
470
+ total = [bars.length, 1].max
471
+ points.each do |point|
472
+ next unless point[:y] && point[:x]
473
+
474
+ if item.options[:horizontal]
475
+ band = y_scale.bandwidth * 0.72
476
+ value = point[:y]
477
+ base = if item.options[:stacked]
478
+ (value.negative? ? negative_stack : positive_stack)[point[:x]]
479
+ else
480
+ 0
481
+ end
482
+ finish = base + value
483
+ if item.options[:stacked]
484
+ (value.negative? ? negative_stack : positive_stack)[point[:x]] = finish
485
+ end
486
+ value = x_scale.map(finish)
487
+ zero = x_scale.map(base)
488
+ next unless value && zero
489
+
490
+ category_y = y_scale.map(point[:x])
491
+ offset = item.options[:stacked] ? 0 : (current_bar_index - ((total - 1) / 2.0)) * band
492
+ marks << { type: :rect, class: "bar-mark", series_index: index, x: [value, zero].min, y: category_y - (band / 2) + offset, width: (value - zero).abs, height: band,
493
+ fill: color }
494
+ else
495
+ band = x_scale.bandwidth * (item.options[:stacked] ? 0.78 : 0.82 / total)
496
+ x = x_scale.map(point[:x])
497
+ value = point[:y]
498
+ stack = value.negative? ? negative_stack : positive_stack
499
+ base = item.options[:stacked] ? stack[point[:x]] : 0
500
+ stack[point[:x]] = base + value if item.options[:stacked]
501
+ y0 = y_scale.map(base)
502
+ y1 = y_scale.map(base + value)
503
+ offset = item.options[:stacked] ? 0 : (current_bar_index - ((total - 1) / 2.0)) * band
504
+ marks << { type: :rect, class: "bar-mark", series_index: index, x: x - (band / 2) + offset, y: [y0, y1].min, width: [band - 1, 1].max, height: (y0 - y1).abs,
505
+ fill: color }
506
+ end
507
+ end
508
+ next
509
+ end
510
+ drawable = points.map do |point|
511
+ px = x_scale.map(point[:x])
512
+ py = y_scale.map(point[:y])
513
+ px && py && [px, py, point]
514
+ end
515
+ case item.type
516
+ when :line, :step
517
+ segments = drawable_segments(drawable, item.options[:gaps] == :connect)
518
+ segments.each do |segment|
519
+ coordinates = segment.each_with_index.flat_map do |(px, py, _point), point_index|
520
+ if item.type == :step && point_index.positive?
521
+ [[px, segment[point_index - 1][1]], [px, py]]
522
+ else
523
+ [[px, py]]
524
+ end
525
+ end
526
+ marks << { type: :line, class: "mark-line", series_index: index, points: coordinates, stroke: color, width: item.options[:width] || 2, dash: item.options[:dash] }
527
+ end
528
+ when :area
529
+ drawable_segments(drawable, false).each do |segment|
530
+ next if segment.empty?
531
+
532
+ zero = y_scale.map(y_scale.is_a?(Scales::Log) ? y_scale.domain.first : 0)
533
+ polygon = [[segment.first[0], zero]] + segment.map { |px, py, _| [px, py] } + [[segment.last[0], zero]]
534
+ marks << { type: :polygon, class: "mark-area", series_index: index, points: polygon, fill: color, opacity: 0.28 }
535
+ marks << { type: :line, class: "series-area-line", series_index: index, points: segment.map { |px, py, _| [px, py] }, stroke: color, width: 2 }
536
+ end
537
+ when :scatter
538
+ valid = drawable.compact
539
+ range = valid.filter_map { |_, _, point| point[:size] }.minmax
540
+ valid.each do |px, py, point|
541
+ radius = 4
542
+ radius += (point[:size] - range[0]) / (range[1] - range[0]) * 5 if point[:size] && range && range[1] > range[0]
543
+ marks << { type: :circle, class: "series-point", series_index: index, cx: px, cy: py, r: radius, fill: color, stroke: colors[:background], stroke_width: 1 }
544
+ end
545
+ end
546
+ end
547
+
548
+ builder.notes.each do |note|
549
+ color = note[:color] || colors[:axis]
550
+ case note[:type]
551
+ when :hline
552
+ y = y_scale.map(note[:value])
553
+ marks << { type: :line, class: "rule-line", points: [[left, y], [width - right, y]], stroke: color, width: 1.5, dash: note[:dash], label: note[:label] } if y
554
+ when :vline
555
+ x = x_scale.map(note[:value])
556
+ marks << { type: :line, class: "rule-line", points: [[x, top], [x, height - bottom]], stroke: color, width: 1.5, dash: note[:dash] } if x
557
+ end
558
+ end
559
+
560
+ if legend_items.length > 1 || (legend_items.length == 1 && builder.legend_options[:show_single])
561
+ x, y = legend_position(builder.legend_options[:position], clip, legend_items.length, legend_width)
562
+ legend_items.each_with_index do |(label, color), index|
563
+ current_y = y + (index * 18)
564
+ elements << { type: :line, points: [[x, current_y - 4], [x + 16, current_y - 4]], stroke: color, width: 2 }
565
+ elements << { type: :text, x: x + 22, y: current_y, text: label, fill: colors[:foreground], anchor: :start, size: 11 }
566
+ end
567
+ end
568
+ Scene.new(width:, height:, background: colors[:background], clip:, marks:, elements:)
569
+ end
570
+
571
+ def build_scale(values, options, axis, series)
572
+ explicit = options[:scale] || options[:type]
573
+ horizontal_bar = series.any? { |item| item.type == :bar && item.options[:horizontal] }
574
+ bar_band = series.any? { |item| item.type == :bar && ((item.options[:horizontal] && axis == :y) || (!item.options[:horizontal] && axis == :x)) }
575
+ if explicit == :band || (explicit.nil? && (bar_band || values.any? { |value| !Data.number(value) }))
576
+ Scales::Band.new(values, options)
577
+ elsif explicit == :log
578
+ warn "Inkplot ignores zero and negative values on a log axis." if values.any? { |value| (number = Data.number(value)) && !number.positive? }
579
+ Scales::Log.new(values, options)
580
+ else
581
+ raise ArgumentError, "unknown #{axis}-axis scale: #{explicit}" if explicit && explicit != :linear
582
+
583
+ include_zero = series.any? do |item|
584
+ (axis == :y && %i[bar area].include?(item.type) && !item.options[:horizontal]) ||
585
+ (horizontal_bar && axis == :x && item.options[:horizontal])
586
+ end
587
+ Scales::Linear.new(values, options, include_zero: include_zero)
588
+ end
589
+ end
590
+
591
+ def ticks(scale)
592
+ scale.ticks
593
+ end
594
+
595
+ def stacked_totals(series)
596
+ series.select { |item| item.type == :bar && item.options[:stacked] }
597
+ .flat_map(&:points).group_by { |point| point[:x] }.values.flat_map do |points|
598
+ [points.sum { |point| [Data.number(point[:y]) || 0, 0].max }, points.sum { |point| [Data.number(point[:y]) || 0, 0].min }]
599
+ end
600
+ end
601
+
602
+ def drawable_segments(points, connect)
603
+ values = connect ? points.compact : points
604
+ values.each_with_object([[]]) do |point, segments|
605
+ if point
606
+ segments.last << point
607
+ elsif !connect && !segments.last.empty?
608
+ segments << []
609
+ end
610
+ end.reject(&:empty?)
611
+ end
612
+
613
+ def legend_position(position, clip, count, legend_width = 120)
614
+ x0, y0, x1, y1 = clip
615
+ case position.to_sym
616
+ when :top_left then [x0 + 8, y0 + 18]
617
+ when :bottom_left then [x0 + 8, y1 - (count * 18)]
618
+ when :bottom_right then [x1 - legend_width + 8, y1 - (count * 18)]
619
+ when :top then [((x0 + x1) / 2.0) - (legend_width / 2), y0 + 16]
620
+ else [x1 - legend_width + 8, y0 + 18]
621
+ end
622
+ end
623
+ end
624
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+
5
+ module Inkplot
6
+ module Renderers
7
+ module SVG
8
+ module_function
9
+
10
+ def render(scene)
11
+ width = scene.width
12
+ height = scene.height
13
+ x, y, right, bottom = scene.clip
14
+ output = [%(<?xml version="1.0" encoding="UTF-8"?>),
15
+ %(<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 #{width} #{height}" width="#{width}" height="#{height}" role="img" class="inkplot">),
16
+ %(<defs><clipPath id="plot-clip"><rect x="#{n(x)}" y="#{n(y)}" width="#{n(right - x)}" height="#{n(bottom - y)}"/></clipPath></defs>)]
17
+ output.concat(scene.elements.map { |element| element_svg(element) }.compact)
18
+ output << %(<g class="series-marks" clip-path="url(#plot-clip)">)
19
+ current_series = nil
20
+ scene.marks.each do |mark|
21
+ content = element_svg(mark)
22
+ next unless content
23
+
24
+ if mark[:series_index] != current_series
25
+ output << "</g>" if current_series
26
+ current_series = mark[:series_index]
27
+ output << %(<g class="series series-#{current_series}" data-series="#{current_series}">) if current_series
28
+ end
29
+ output << content
30
+ end
31
+ output << "</g>" if current_series
32
+ output << "</g>"
33
+ output << "</svg>"
34
+ output.join
35
+ end
36
+
37
+ def element_svg(element)
38
+ case element[:type]
39
+ when :rect
40
+ klass = element[:class] ? %( class="#{attr(element[:class])}") : ""
41
+ %(<rect#{klass} x="#{n(element[:x])}" y="#{n(element[:y])}" width="#{n(element[:width])}" height="#{n(element[:height])}" fill="#{attr(element[:fill])}"/>)
42
+ when :line
43
+ points = element[:points]
44
+ return if points.length < 2
45
+
46
+ d = "M #{n(points[0][0])} #{n(points[0][1])} " + points.drop(1).map { |px, py| "L #{n(px)} #{n(py)}" }.join(" ")
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}/>)
49
+ when :polygon
50
+ points = element[:points]
51
+ commands = points.drop(1).map { |px, py| "L #{n(px)} #{n(py)}" }.join(" ")
52
+ d = "M #{n(points[0][0])} #{n(points[0][1])} #{commands} Z"
53
+ %(<path class="#{attr(element[:class] || 'mark-area')}" d="#{d}" fill="#{attr(element[:fill])}" fill-opacity="#{n(element[:opacity] || 1)}"/>)
54
+ when :circle
55
+ %(<circle class="#{attr(element[:class] || 'mark-point')}" cx="#{n(element[:cx])}" cy="#{n(element[:cy])}" r="#{n(element[:r])}" fill="#{attr(element[:fill])}" stroke="#{attr(element[:stroke])}" stroke-width="#{n(element[:stroke_width] || 0)}"/>)
56
+ when :text
57
+ text = CGI.escapeHTML(element[:text].to_s)
58
+ rotate = element[:rotate] ? %( transform="rotate(#{n(element[:rotate])} #{n(element[:x])} #{n(element[:y])})") : ""
59
+ anchor = { start: "start", middle: "middle", end: "end" }.fetch(element[:anchor] || :start)
60
+ %(<text x="#{n(element[:x])}" y="#{n(element[:y])}" fill="#{attr(element[:fill])}" text-anchor="#{anchor}" font-size="#{n(element[:size] || 12)}" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'"#{rotate}>#{text}</text>)
61
+ end
62
+ end
63
+
64
+ def attr(value)
65
+ CGI.escapeHTML(value.to_s)
66
+ end
67
+
68
+ def n(value)
69
+ number = Float(value || 0)
70
+ formatted = format("%.2f", number).sub(/0+\z/, "").sub(/\.\z/, "")
71
+ formatted == "-0" ? "0" : formatted
72
+ end
73
+ end
74
+ end
75
+ end