zaniah 0.6.2 → 0.6.4

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.
@@ -32,14 +32,14 @@ module Zaniah
32
32
  result.freeze
33
33
  end
34
34
 
35
- def paint_line(scene, bounds, values, color)
36
- points = chart_points(bounds, values)
35
+ def paint_line(scene, bounds, values, color, range: nil)
36
+ points = chart_points(bounds, values, range: range)
37
37
  path = points.map.with_index { |(x, y), index| "#{index.zero? ? "M" : "L"}#{x},#{y}" }.join
38
38
  scene.path(path, stroke: color, width: 2)
39
39
  end
40
40
 
41
- def chart_points(bounds, values)
42
- minimum, maximum = values.minmax
41
+ def chart_points(bounds, values, range: nil)
42
+ minimum, maximum = range || values.minmax
43
43
  span = maximum == minimum ? 1.0 : maximum - minimum
44
44
  step = values.length == 1 ? 0 : bounds.width / (values.length - 1)
45
45
  values.map.with_index { |value, index| [bounds.x + index * step, bounds.bottom - (value - minimum) / span * bounds.height] }
@@ -83,6 +83,40 @@ module Zaniah
83
83
  span = maximum == minimum ? 1.0 : maximum - minimum
84
84
  values.map { |value| levels[((value - minimum) / span * (levels.length - 1)).round] }.join
85
85
  end
86
+
87
+ def chart_palette(cx)
88
+ palette = Array(@colors || [cx.theme.colors.accent, cx.theme.colors.info, cx.theme.colors.success, cx.theme.colors.warning])
89
+ raise ArgumentError, "chart colors must not be empty" if palette.empty?
90
+ palette
91
+ end
92
+
93
+ def chart_legend(entries, palette)
94
+ Div.new.flex_row.gap(10).children(entries.each_with_index.map do |entry, index|
95
+ label = block_given? ? yield(entry) : entry.to_s
96
+ Div.new.flex_row.items_center.gap(4)
97
+ .child(Div.new.w(8).h(8).bg(palette[index % palette.length]))
98
+ .child(Label.new(label, size: :xs))
99
+ end)
100
+ end
101
+
102
+ def cartesian_axes(scene, bounds, color)
103
+ divisions = 4
104
+ grid = (1...divisions).flat_map do |index|
105
+ fraction = index.to_f / divisions
106
+ x = bounds.x + bounds.width * fraction
107
+ y = bounds.y + bounds.height * fraction
108
+ ["M#{x},#{bounds.y}V#{bounds.bottom}", "M#{bounds.x},#{y}H#{bounds.right}"]
109
+ end.join
110
+ ticks = (1...divisions).flat_map do |index|
111
+ fraction = index.to_f / divisions
112
+ x = bounds.x + bounds.width * fraction
113
+ y = bounds.y + bounds.height * fraction
114
+ ["M#{x},#{bounds.bottom}V#{bounds.bottom - 3}", "M#{bounds.x},#{y}H#{bounds.x + 3}"]
115
+ end.join
116
+ scene.path(grid, stroke: color, width: 0.5)
117
+ scene.path("M#{bounds.x},#{bounds.y}V#{bounds.bottom}H#{bounds.right}", stroke: color, width: 1)
118
+ scene.path(ticks, stroke: color, width: 1)
119
+ end
86
120
  end
87
121
 
88
122
  class LineChart < Sparkline
@@ -96,25 +130,21 @@ module Zaniah
96
130
  end
97
131
 
98
132
  def build(cx)
99
- palette = Array(@colors || [cx.theme.colors.accent, cx.theme.colors.info, cx.theme.colors.success, cx.theme.colors.warning])
133
+ palette = chart_palette(cx)
134
+ range = @series.values.flatten.minmax
100
135
  canvas = @canvas = Canvas.new do |bounds, context|
101
136
  @plot_bounds = bounds.inset(Edges.new(12, 8, 20, 28))
102
- axes(context.scene, @plot_bounds, cx.theme.colors.border)
103
- @series.each_with_index { |(_name, values), index| paint_line(context.scene, @plot_bounds, values, palette[index % palette.length]) }
137
+ cartesian_axes(context.scene, @plot_bounds, cx.theme.colors.border)
138
+ @series.each_with_index do |(_name, values), index|
139
+ paint_line(context.scene, @plot_bounds, values, palette[index % palette.length], range: range)
140
+ end
104
141
  end.w(@width).h(@height).on_hover { |event, context| show_line_tooltip(event, context) }
105
142
  .focusable(context: {in_chart: true}) { |action| chart_action(action, cx) }
106
- legend = Div.new.flex_row.gap(10).children(@series.keys.each_with_index.map do |name, index|
107
- Div.new.flex_row.items_center.gap(4).child(Div.new.w(8).h(8).bg(palette[index % palette.length])).child(Label.new(name, size: :xs))
108
- end)
109
- Div.new.gap(4).child(canvas).child(legend)
143
+ Div.new.gap(4).child(canvas).child(chart_legend(@series.keys, palette))
110
144
  end
111
145
 
112
146
  private
113
147
 
114
- def axes(scene, bounds, color)
115
- scene.path("M#{bounds.x},#{bounds.y}V#{bounds.bottom}H#{bounds.right}", stroke: color, width: 1)
116
- end
117
-
118
148
  def show_line_tooltip(event, cx)
119
149
  return unless @plot_bounds
120
150
  text = @series.map do |name, values|
@@ -131,14 +161,15 @@ module Zaniah
131
161
  end
132
162
 
133
163
  def build(cx)
134
- palette = Array(@colors || [cx.theme.colors.accent, cx.theme.colors.info, cx.theme.colors.success, cx.theme.colors.warning])
164
+ palette = chart_palette(cx)
165
+ @axis_color = cx.theme.colors.border
135
166
  canvas = @canvas = Canvas.new do |bounds, context|
136
167
  @plot_bounds = bounds.inset(Edges.new(12, 8, 20, 28))
137
- axes(context.scene, @plot_bounds, cx.theme.colors.border)
168
+ cartesian_axes(context.scene, @plot_bounds, @axis_color)
138
169
  paint_bars(context.scene, @plot_bounds, palette)
139
170
  end.w(@width).h(@height).on_hover { |event, context| show_line_tooltip(event, context) }
140
171
  .focusable(context: {in_chart: true}) { |action| chart_action(action, cx) }
141
- Div.new.gap(4).child(canvas).child(Label.new(@series.keys.join(" · "), size: :xs))
172
+ Div.new.gap(4).child(canvas).child(chart_legend(@series.keys, palette))
142
173
  end
143
174
 
144
175
  private
@@ -161,5 +192,286 @@ module Zaniah
161
192
  end
162
193
  end
163
194
  end
195
+
196
+ class PieChart < Sparkline
197
+ attr_reader :slices
198
+
199
+ def initialize(data, width: 320, height: 240, colors: nil, label: "Pie chart")
200
+ super([1], width: width, height: height, label: label)
201
+ entries = data.is_a?(Hash) ? data.to_a : data.to_a.each_with_index.map { |value, index| ["#{index + 1}", value] }
202
+ raise ArgumentError, "pie chart needs at least one slice" if entries.empty?
203
+
204
+ @slices = entries.map do |name, value|
205
+ value = Float(value)
206
+ raise ArgumentError, "pie values must be finite and nonnegative" unless value.finite? && value >= 0
207
+ [name.to_s.freeze, value].freeze
208
+ end.freeze
209
+ raise ArgumentError, "pie slice labels must be unique" unless @slices.map(&:first).uniq.length == @slices.length
210
+ @values = @slices.map(&:last).freeze
211
+ @total = @values.sum
212
+ raise ArgumentError, "pie chart needs a positive total" unless @total.positive?
213
+ @series = {label.to_s => @values}.freeze
214
+ @width, @height, @colors, @label = Float(width), Float(height), colors, label.to_s
215
+ raise ArgumentError, "chart dimensions must be positive" unless @width.positive? && @height.positive?
216
+ end
217
+
218
+ def build(cx)
219
+ palette = chart_palette(cx)
220
+
221
+ @canvas = Canvas.new do |bounds, context|
222
+ @plot_bounds = bounds
223
+ paint_slices(context.scene, bounds, palette)
224
+ end.w(@width).h(@height).on_hover { |event, context| show_slice_tooltip(event, context) }
225
+ .focusable(context: {in_chart: true}) { |action| chart_action(action, cx) }
226
+ legend = chart_legend(@slices, palette) do |name, value|
227
+ "#{name}: #{format("%.1f", value / @total * 100)}%"
228
+ end
229
+ Div.new.gap(4).child(@canvas).child(legend)
230
+ end
231
+
232
+ def tui_cells(*) = spark(@values)
233
+
234
+ def accessibility_node(_cx)
235
+ categories = @slices.map { |name, value| {label: name, value: value}.freeze }.freeze
236
+ node(:image, label: @label, value: {total: @total, categories: categories, selected: selected_summary}.freeze)
237
+ end
238
+
239
+ protected
240
+
241
+ def tooltip_at(index)
242
+ name, value = @slices.fetch(index)
243
+ "#{name}: #{value}"
244
+ end
245
+
246
+ private
247
+
248
+ def paint_slices(scene, bounds, palette)
249
+ cx, cy = bounds.x + bounds.width / 2, bounds.y + bounds.height / 2
250
+ radius = [bounds.width, bounds.height].min / 2
251
+ angle = -Math::PI / 2
252
+ @slices.each_with_index do |(_name, value), index|
253
+ next if value.zero?
254
+
255
+ finish = angle + Math::PI * 2 * value / @total
256
+ scene.path(slice_path(cx, cy, radius, angle, finish), fill: palette[index % palette.length])
257
+ angle = finish
258
+ end
259
+ end
260
+
261
+ def slice_path(cx, cy, radius, start, finish)
262
+ if finish - start >= Math::PI * 2 - 1e-10
263
+ "M#{cx},#{cy - radius} A#{radius},#{radius} 0 1 1 #{cx},#{cy + radius} A#{radius},#{radius} 0 1 1 #{cx},#{cy - radius} Z"
264
+ else
265
+ sx, sy = polar(cx, cy, radius, start)
266
+ ex, ey = polar(cx, cy, radius, finish)
267
+ large = finish - start > Math::PI ? 1 : 0
268
+ "M#{cx},#{cy} L#{sx},#{sy} A#{radius},#{radius} 0 #{large} 1 #{ex},#{ey} Z"
269
+ end
270
+ end
271
+
272
+ def polar(cx, cy, radius, angle) = [cx + Math.cos(angle) * radius, cy + Math.sin(angle) * radius]
273
+
274
+ def show_slice_tooltip(event, cx)
275
+ return unless @plot_bounds
276
+
277
+ center_x = @plot_bounds.x + @plot_bounds.width / 2
278
+ center_y = @plot_bounds.y + @plot_bounds.height / 2
279
+ angle = (Math.atan2(event.position.y - center_y, event.position.x - center_x) + Math::PI / 2) % (Math::PI * 2)
280
+ target = angle / (Math::PI * 2) * @total
281
+ index = @values.each_index.find do |candidate|
282
+ target -= @values[candidate]
283
+ target < 0
284
+ end || @values.rindex(&:positive?)
285
+ @selected_index = index
286
+ cx.window.offer_tooltip(tooltip_at(index), position: event.position, delay: 0)
287
+ end
288
+ end
289
+
290
+ class DonutChart < PieChart
291
+ def initialize(data, **options)
292
+ super
293
+ @inner_radius = 0.56
294
+ end
295
+
296
+ private
297
+
298
+ def slice_path(cx, cy, radius, start, finish)
299
+ inner = radius * @inner_radius
300
+ if finish - start >= Math::PI * 2 - 1e-10
301
+ "M#{cx},#{cy - radius} A#{radius},#{radius} 0 1 1 #{cx},#{cy + radius} A#{radius},#{radius} 0 1 1 #{cx},#{cy - radius} " \
302
+ "L#{cx},#{cy - inner} A#{inner},#{inner} 0 1 0 #{cx},#{cy + inner} A#{inner},#{inner} 0 1 0 #{cx},#{cy - inner} Z"
303
+ else
304
+ sx, sy = polar(cx, cy, radius, start)
305
+ ex, ey = polar(cx, cy, radius, finish)
306
+ isx, isy = polar(cx, cy, inner, start)
307
+ iex, iey = polar(cx, cy, inner, finish)
308
+ large = finish - start > Math::PI ? 1 : 0
309
+ "M#{sx},#{sy} A#{radius},#{radius} 0 #{large} 1 #{ex},#{ey} L#{iex},#{iey} " \
310
+ "A#{inner},#{inner} 0 #{large} 0 #{isx},#{isy} Z"
311
+ end
312
+ end
313
+ end
314
+
315
+ class ScatterChart < Sparkline
316
+ attr_reader :points
317
+
318
+ def initialize(series, width: 480, height: 240, colors: nil, label: "Scatter chart")
319
+ input = series.is_a?(Hash) ? series : {label => series}
320
+ raise ArgumentError, "scatter chart needs at least one series" if input.empty?
321
+ @points = input.to_h do |name, values|
322
+ pairs = values.to_a.map do |pair|
323
+ raise ArgumentError, "scatter points must contain x and y" unless pair.respond_to?(:length) && pair.length == 2
324
+ x, y = pair.map { |value| Float(value) }
325
+ raise ArgumentError, "scatter values must be finite" unless x.finite? && y.finite?
326
+ [x, y].freeze
327
+ end
328
+ raise ArgumentError, "scatter series must not be empty" if pairs.empty?
329
+ [name.to_s.freeze, pairs.freeze]
330
+ end.freeze
331
+ super(@points.values.first.map(&:last), width: width, height: height, label: label)
332
+ @series = @points.transform_values { |pairs| pairs.map(&:last).freeze }.freeze
333
+ @width, @height, @colors, @label = Float(width), Float(height), colors, label.to_s
334
+ raise ArgumentError, "chart dimensions must be positive" unless @width.positive? && @height.positive?
335
+ end
336
+
337
+ def build(cx)
338
+ palette = chart_palette(cx)
339
+
340
+ canvas = @canvas = Canvas.new do |bounds, context|
341
+ @plot_bounds = bounds.inset(Edges.new(12, 8, 20, 28))
342
+ cartesian_axes(context.scene, @plot_bounds, cx.theme.colors.border)
343
+ paint_points(context.scene, @plot_bounds, palette)
344
+ end.w(@width).h(@height).on_hover { |event, context| show_scatter_tooltip(event, context) }
345
+ .focusable(context: {in_chart: true}) { |action| chart_action(action, cx) }
346
+ Div.new.gap(4).child(canvas).child(chart_legend(@points.keys, palette))
347
+ end
348
+
349
+ def tui_cells(*) = spark(@series.values.first)
350
+
351
+ def accessibility_node(_cx)
352
+ node(:image, label: @label, value: {series: @points, selected: selected_summary}.freeze)
353
+ end
354
+
355
+ protected
356
+
357
+ def tooltip_at(index)
358
+ @points.map do |name, values|
359
+ x, y = values[[index, values.length - 1].min]
360
+ "#{name}: (#{x}, #{y})"
361
+ end.join(" · ")
362
+ end
363
+
364
+ private
365
+
366
+ def paint_points(scene, bounds, palette)
367
+ all = @points.values.flatten(1)
368
+ min_x, max_x = all.map(&:first).minmax
369
+ min_y, max_y = all.map(&:last).minmax
370
+ @screen_points = []
371
+ @points.each_with_index do |(name, values), index|
372
+ paths = values.map do |x_value, y_value|
373
+ x = bounds.x + (max_x == min_x ? 0.5 : (x_value - min_x) / (max_x - min_x)) * bounds.width
374
+ y = bounds.bottom - (max_y == min_y ? 0.5 : (y_value - min_y) / (max_y - min_y)) * bounds.height
375
+ @screen_points << [name, x_value, y_value, x, y]
376
+ "M#{x - 3},#{y} A3,3 0 1 0 #{x + 3},#{y} A3,3 0 1 0 #{x - 3},#{y} Z"
377
+ end.join
378
+ scene.path(paths, fill: palette[index % palette.length]) unless paths.empty?
379
+ end
380
+ end
381
+
382
+ def show_scatter_tooltip(event, cx)
383
+ nearest = @screen_points.min_by do |(_name, _x_value, _y_value, x, y)|
384
+ (x - event.position.x)**2 + (y - event.position.y)**2
385
+ end
386
+ return unless nearest
387
+
388
+ name, x_value, y_value = nearest
389
+ cx.window.offer_tooltip("#{name}: (#{x_value}, #{y_value})", position: event.position, delay: 0)
390
+ end
391
+ end
392
+
393
+ class AreaChart < LineChart
394
+ def initialize(series, width: 480, height: 240, colors: nil, stacked: false, label: "Area chart")
395
+ super(series, width: width, height: height, colors: colors, label: label)
396
+ raise ArgumentError, "stacked area series must have equal lengths" if stacked && @series.values.map(&:length).uniq.length > 1
397
+ raise ArgumentError, "stacked area values must be nonnegative" if stacked && @series.values.flatten.any?(&:negative?)
398
+ @stacked = stacked
399
+ end
400
+
401
+ def build(cx)
402
+ palette = chart_palette(cx)
403
+
404
+ canvas = @canvas = Canvas.new do |bounds, context|
405
+ @plot_bounds = bounds.inset(Edges.new(12, 8, 20, 28))
406
+ cartesian_axes(context.scene, @plot_bounds, cx.theme.colors.border)
407
+ paint_areas(context.scene, @plot_bounds, palette)
408
+ end.w(@width).h(@height).on_hover { |event, context| show_line_tooltip(event, context) }
409
+ .focusable(context: {in_chart: true}) { |action| chart_action(action, cx) }
410
+ Div.new.gap(4).child(canvas).child(chart_legend(@series.keys, palette))
411
+ end
412
+
413
+ private
414
+
415
+ def paint_areas(scene, bounds, palette)
416
+ count = @series.values.map(&:length).max
417
+ totals = Array.new(count, 0.0)
418
+ if @stacked
419
+ @series.each_value { |values| values.each_with_index { |value, index| totals[index] += value } }
420
+ minimum, maximum = 0.0, totals.max
421
+ else
422
+ minimum, maximum = [0.0, *@series.values.flatten].minmax
423
+ end
424
+ span = maximum == minimum ? 1.0 : maximum - minimum
425
+ y = ->(value) { bounds.bottom - (value - minimum) / span * bounds.height }
426
+ x = ->(index, length) { bounds.x + (length == 1 ? 0.5 : index.to_f / (length - 1)) * bounds.width }
427
+ cumulative = Array.new(count, 0.0)
428
+
429
+ @series.each_value.with_index do |values, series_index|
430
+ base = @stacked ? cumulative.dup : Array.new(values.length, 0.0)
431
+ top = values.each_with_index.map { |value, index| @stacked ? (cumulative[index] += value) : value }
432
+ top_x = top.each_index.map { |index| x.call(index, top.length) }
433
+ base_x = base.each_index.map { |index| x.call(index, base.length) }
434
+ path = "M#{base_x.first},#{y.call(base.first)} " \
435
+ "L#{top_x.zip(top).map { |px, value| "#{px},#{y.call(value)}" }.join(" L")} " \
436
+ "L#{base_x.zip(base).reverse.map { |px, value| "#{px},#{y.call(value)}" }.join(" L")} Z"
437
+ scene.path(path, fill: palette[series_index % palette.length])
438
+ end
439
+ end
440
+ end
441
+
442
+ class StackedBarChart < BarChart
443
+ private
444
+
445
+ def paint_bars(scene, bounds, palette)
446
+ count = @series.values.map(&:length).max
447
+ positive_totals = Array.new(count, 0.0)
448
+ negative_totals = Array.new(count, 0.0)
449
+ @series.each_value do |values|
450
+ values.each_with_index do |value, index|
451
+ value.negative? ? negative_totals[index] += value : positive_totals[index] += value
452
+ end
453
+ end
454
+ minimum, maximum = [negative_totals.min, 0].min, [positive_totals.max, 0].max
455
+ span = maximum == minimum ? 1.0 : maximum - minimum
456
+ y = ->(value) { bounds.bottom - (value - minimum) / span * bounds.height }
457
+ zero = y.call(0)
458
+ group_width = bounds.width / [count, 1].max
459
+ bar_width = group_width * 0.75
460
+ positive = Array.new(count, 0.0)
461
+ negative = Array.new(count, 0.0)
462
+
463
+ @series.values.each_with_index do |values, series_index|
464
+ values.each_with_index do |value, index|
465
+ base = value.negative? ? negative[index] - value : positive[index] - value
466
+ finish = base + value
467
+ first, last = [y.call(base), y.call(finish)].minmax
468
+ x = bounds.x + index * group_width + (group_width - bar_width) / 2
469
+ scene.path("M#{x},#{first}H#{x + bar_width}V#{[last, first + 1].max}H#{x}Z", fill: palette[series_index % palette.length])
470
+ value.negative? ? negative[index] = finish : positive[index] = finish
471
+ end
472
+ end
473
+ scene.path("M#{bounds.x},#{zero}H#{bounds.right}", stroke: @axis_color || "#777", width: 1)
474
+ end
475
+ end
164
476
  end
165
477
  end