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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +13 -0
- data/README.md +9 -0
- data/docs/adr/012-productivity-components.md +25 -0
- data/docs/components.md +19 -2
- data/docs/images.md +18 -0
- data/docs/layout.md +46 -0
- data/docs/native.md +9 -0
- data/docs/text.md +22 -0
- data/docs/tui.md +1 -1
- data/lib/zaniah/image.rb +22 -5
- data/lib/zaniah/input/keymap.rb +12 -0
- data/lib/zaniah/jpeg.rb +431 -0
- data/lib/zaniah/list/height_index.rb +3 -2
- data/lib/zaniah/platform/headless/window.rb +8 -0
- data/lib/zaniah/platform/linux/wayland_window.rb +14 -0
- data/lib/zaniah/platform/linux/window.rb +12 -0
- data/lib/zaniah/platform/mac/window.rb +15 -0
- data/lib/zaniah/platform/windows/window.rb +15 -0
- data/lib/zaniah/ui/charts.rb +330 -18
- data/lib/zaniah/ui/editors.rb +303 -13
- data/lib/zaniah/ui/grid.rb +492 -0
- data/lib/zaniah/ui/rich_text_surface.rb +302 -0
- data/lib/zaniah/ui/terminal_view.rb +22 -0
- data/lib/zaniah/ui.rb +2 -0
- data/lib/zaniah/version.rb +1 -1
- data/lib/zaniah.rb +1 -0
- data/sig/elements.rbs +2 -0
- data/sig/native.rbs +7 -0
- data/sig/platform.rbs +2 -0
- data/sig/ui.rbs +67 -2
- data/sig/zaniah.rbs +14 -0
- metadata +15 -4
data/lib/zaniah/ui/charts.rb
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
103
|
-
@series.each_with_index
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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
|