zaniah 0.6.3 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d5d0895db7cb838e553fc0e7de6c20df016c9c56d3938cfaa94dc99cc47f2ab2
4
- data.tar.gz: ef786f1e8ee6e87da921f317bd7901d97553e1fbe9e3e6969bb5df588a300754
3
+ metadata.gz: 985d3d62a8ffa112effba1aed1976c2b7403a7e6ef2a49dca9b817c80d49b3da
4
+ data.tar.gz: 8e641aad423d506fcbf18fe5c6c22afa8beff2ae5b321ae73559cbec8ed26181
5
5
  SHA512:
6
- metadata.gz: e784d7df56037b50a88e4e8e4fdccbba5f9544b5e7d56b88480f78468627210919f8a87093ea6d461ba56355bb179c7fbe96d8d52d7990a6107d96ce73fc9872
7
- data.tar.gz: e9e9726a52da9e277d6ebc56f6587a08ca0550e4fbfd83fbb25ef8fe75b58cb8badc976af92b179e530342d9da96aeda8f91332d395f12c77963bfee1c808f19
6
+ metadata.gz: e71ed6d538a8ba9f48d3521def68d05957c637d631116dbf5676a47672d750b1128d361b7b8af48cd5231c01742db7ca88b6846961fc30c58339d4bf696e05a0
7
+ data.tar.gz: d6d48b77d5a4793d0e4aa2c4a5e23ccd34f4d5d690c858ecba51ad3585571275a12fa3b5ec882c1a03abdc2db174f757e6002d642d9b13b42aafe46b53f1ca23
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.4 — 2026-09-24
4
+
5
+ - Share Cartesian chart scales, axes, grid lines, ticks, and color-keyed legends across chart types.
6
+
3
7
  ## 0.6.3 — 2026-09-23
4
8
 
5
9
  - Render supplied text grid cells directly as cell surfaces, avoiding an extra wrapper per visible cell.
data/docs/components.md CHANGED
@@ -87,7 +87,9 @@ Zaniah::UI::Button.variants[:variant][:brand] = ->(theme) {
87
87
  | L4 | `Grid` | `(rows:, columns:, row_height:, column_width:, frozen_rows:, frozen_columns:)`; `scroll_to`, range `selection`, `on_select`, `on_edit`, `on_fill`, `on_resize` | two-axis virtualization, frozen panes, visible-cell resize/fill callbacks | table |
88
88
  | L4 | `TreeView` | `(items, height:, selected:)`; `expand`, `collapse`, `replace`, `replace_children`, `invalidate`, lazy `children` proc | arrows/Home/End | tree/treeitem |
89
89
  | L5 | `Sparkline` | `(values, width:, height:, color:, label:)` | line + tooltip | image |
90
- | L5 | `LineChart`, `BarChart` | `(series, width:, height:, colors:, label:)` | axes, legend, tooltip | image |
90
+ | L5 | `LineChart`, `BarChart`, `StackedBarChart`, `AreaChart` | `(series, width:, height:, colors:, label:)`; `AreaChart(stacked:)` | shared axes, ticks, grid lines, color-keyed legend, tooltip | image |
91
+ | L5 | `PieChart`, `DonutChart` | `(data, width:, height:, colors:, label:)` | slices, shared color-keyed legend, tooltip | image |
92
+ | L5 | `ScatterChart` | `(series, width:, height:, colors:, label:)` | shared axes, ticks, grid lines, color-keyed legend, nearest-point tooltip | image |
91
93
  | L5 | `Validation` | `required`, `format`, `length`, `number`, `rule` | composable rules | n/a |
92
94
  | L5 | `FormField` | `(name, value:, label:, control:, validation:, hint:)` | errors + describedby | group/control/alert |
93
95
  | L5 | `Form` | `field`, `on_change`, `on_submit`, `values`, `valid?` | validates before submit | form |
data/docs/tui.md CHANGED
@@ -26,7 +26,7 @@ tests and non-window integrations.
26
26
  | Toast | live status text |
27
27
  | Table, DataGrid | header and visible rows separated with `|` |
28
28
  | TreeView | indentation with `▸`/`▾` expansion markers |
29
- | Sparkline, LineChart, BarChart | eight-level Unicode sparkline summary |
29
+ | Sparkline and all chart types | eight-level Unicode sparkline summary |
30
30
  | Form, FormField | labeled values, `!` errors, and a submit button |
31
31
  | CodeEditor, RichText | numbered plain-text lines or concatenated text runs |
32
32
 
@@ -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
@@ -185,20 +216,16 @@ module Zaniah
185
216
  end
186
217
 
187
218
  def build(cx)
188
- palette = Array(@colors || [cx.theme.colors.accent, cx.theme.colors.info, cx.theme.colors.success, cx.theme.colors.warning])
189
- raise ArgumentError, "chart colors must not be empty" if palette.empty?
219
+ palette = chart_palette(cx)
190
220
 
191
221
  @canvas = Canvas.new do |bounds, context|
192
222
  @plot_bounds = bounds
193
223
  paint_slices(context.scene, bounds, palette)
194
224
  end.w(@width).h(@height).on_hover { |event, context| show_slice_tooltip(event, context) }
195
225
  .focusable(context: {in_chart: true}) { |action| chart_action(action, cx) }
196
- legend = Div.new.flex_row.gap(10).children(@slices.each_with_index.map do |(name, value), index|
197
- percent = value / @total * 100
198
- Div.new.flex_row.items_center.gap(4)
199
- .child(Div.new.w(8).h(8).bg(palette[index % palette.length]))
200
- .child(Label.new("#{name}: #{format("%.1f", percent)}%", size: :xs))
201
- end)
226
+ legend = chart_legend(@slices, palette) do |name, value|
227
+ "#{name}: #{format("%.1f", value / @total * 100)}%"
228
+ end
202
229
  Div.new.gap(4).child(@canvas).child(legend)
203
230
  end
204
231
 
@@ -308,20 +335,15 @@ module Zaniah
308
335
  end
309
336
 
310
337
  def build(cx)
311
- palette = Array(@colors || [cx.theme.colors.accent, cx.theme.colors.info, cx.theme.colors.success, cx.theme.colors.warning])
312
- raise ArgumentError, "chart colors must not be empty" if palette.empty?
338
+ palette = chart_palette(cx)
313
339
 
314
340
  canvas = @canvas = Canvas.new do |bounds, context|
315
341
  @plot_bounds = bounds.inset(Edges.new(12, 8, 20, 28))
316
- paint_axes(context.scene, @plot_bounds, cx.theme.colors.border)
342
+ cartesian_axes(context.scene, @plot_bounds, cx.theme.colors.border)
317
343
  paint_points(context.scene, @plot_bounds, palette)
318
344
  end.w(@width).h(@height).on_hover { |event, context| show_scatter_tooltip(event, context) }
319
345
  .focusable(context: {in_chart: true}) { |action| chart_action(action, cx) }
320
- legend = Div.new.flex_row.gap(10).children(@points.keys.each_with_index.map do |name, index|
321
- Div.new.flex_row.items_center.gap(4).child(Div.new.w(8).h(8).bg(palette[index % palette.length]))
322
- .child(Label.new(name, size: :xs))
323
- end)
324
- Div.new.gap(4).child(canvas).child(legend)
346
+ Div.new.gap(4).child(canvas).child(chart_legend(@points.keys, palette))
325
347
  end
326
348
 
327
349
  def tui_cells(*) = spark(@series.values.first)
@@ -341,10 +363,6 @@ module Zaniah
341
363
 
342
364
  private
343
365
 
344
- def paint_axes(scene, bounds, color)
345
- scene.path("M#{bounds.x},#{bounds.y}V#{bounds.bottom}H#{bounds.right}", stroke: color, width: 1)
346
- end
347
-
348
366
  def paint_points(scene, bounds, palette)
349
367
  all = @points.values.flatten(1)
350
368
  min_x, max_x = all.map(&:first).minmax
@@ -381,20 +399,15 @@ module Zaniah
381
399
  end
382
400
 
383
401
  def build(cx)
384
- palette = Array(@colors || [cx.theme.colors.accent, cx.theme.colors.info, cx.theme.colors.success, cx.theme.colors.warning])
385
- raise ArgumentError, "chart colors must not be empty" if palette.empty?
402
+ palette = chart_palette(cx)
386
403
 
387
404
  canvas = @canvas = Canvas.new do |bounds, context|
388
405
  @plot_bounds = bounds.inset(Edges.new(12, 8, 20, 28))
389
- axes(context.scene, @plot_bounds, cx.theme.colors.border)
406
+ cartesian_axes(context.scene, @plot_bounds, cx.theme.colors.border)
390
407
  paint_areas(context.scene, @plot_bounds, palette)
391
408
  end.w(@width).h(@height).on_hover { |event, context| show_line_tooltip(event, context) }
392
409
  .focusable(context: {in_chart: true}) { |action| chart_action(action, cx) }
393
- legend = Div.new.flex_row.gap(10).children(@series.keys.each_with_index.map do |name, index|
394
- Div.new.flex_row.items_center.gap(4).child(Div.new.w(8).h(8).bg(palette[index % palette.length]))
395
- .child(Label.new(name, size: :xs))
396
- end)
397
- Div.new.gap(4).child(canvas).child(legend)
410
+ Div.new.gap(4).child(canvas).child(chart_legend(@series.keys, palette))
398
411
  end
399
412
 
400
413
  private
@@ -457,7 +470,7 @@ module Zaniah
457
470
  value.negative? ? negative[index] = finish : positive[index] = finish
458
471
  end
459
472
  end
460
- scene.path("M#{bounds.x},#{zero}H#{bounds.right}", stroke: "#777", width: 1)
473
+ scene.path("M#{bounds.x},#{zero}H#{bounds.right}", stroke: @axis_color || "#777", width: 1)
461
474
  end
462
475
  end
463
476
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zaniah
4
- VERSION = "0.6.3"
4
+ VERSION = "0.6.4"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zaniah
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.3
4
+ version: 0.6.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yudai Takada