Skip to content

Commit 43903b8

Browse files
committed
Introduce matrix and color-vector drawing foundations
1 parent e98d4cd commit 43903b8

7 files changed

Lines changed: 249 additions & 21 deletions

File tree

spec/matrix_spec.cr

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
require "./spec_helper"
2+
3+
describe UnicodePlot::MatrixView do
4+
it "stores rectangular matrix dimensions" do
5+
m = UnicodePlot::MatrixView.new([
6+
[1.0, 2.0, 3.0],
7+
[4.0, 5.0, 6.0],
8+
])
9+
10+
m.nrows.should eq(2)
11+
m.ncols.should eq(3)
12+
m[0, 1].should eq(2.0)
13+
m[1, 2].should eq(6.0)
14+
end
15+
16+
it "rejects jagged matrices" do
17+
expect_raises(ArgumentError, /same length/) do
18+
UnicodePlot::MatrixView.new([
19+
[1.0, 2.0],
20+
[3.0],
21+
])
22+
end
23+
end
24+
25+
it "allows empty matrices at the view level" do
26+
m = UnicodePlot::MatrixView.new([] of Array(Float64))
27+
28+
m.nrows.should eq(0)
29+
m.ncols.should eq(0)
30+
m.empty?.should be_true
31+
end
32+
end
33+
34+
describe ".matrix_axis_coords" do
35+
it "uses one-based coordinates when fact is nil" do
36+
UnicodePlot.matrix_axis_coords(3, nil, 0.0).should eq([1.0, 2.0, 3.0])
37+
end
38+
39+
it "applies offset to one-based coordinates when fact is nil" do
40+
UnicodePlot.matrix_axis_coords(3, nil, 10.0).should eq([11.0, 12.0, 13.0])
41+
end
42+
43+
it "uses zero-based scaled coordinates when fact is given" do
44+
UnicodePlot.matrix_axis_coords(3, 0.5, 10.0).should eq([10.0, 10.5, 11.0])
45+
end
46+
end
47+
48+
describe ".matrix_extrema_finite" do
49+
it "returns min and max of finite values" do
50+
m = UnicodePlot::MatrixView.new([
51+
[1.0, Float64::NAN],
52+
[3.0, -2.0],
53+
])
54+
55+
UnicodePlot.matrix_extrema_finite(m).should eq({-2.0, 3.0})
56+
end
57+
58+
it "ignores infinities" do
59+
m = UnicodePlot::MatrixView.new([
60+
[Float64::INFINITY, 2.0],
61+
[-Float64::INFINITY, 5.0],
62+
])
63+
64+
UnicodePlot.matrix_extrema_finite(m).should eq({2.0, 5.0})
65+
end
66+
67+
it "returns nil when there are no finite values" do
68+
m = UnicodePlot::MatrixView.new([
69+
[Float64::NAN, Float64::INFINITY],
70+
])
71+
72+
UnicodePlot.matrix_extrema_finite(m).should be_nil
73+
end
74+
end

spec/unicode_plot_spec.cr

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,13 @@ describe UnicodePlot do
420420
z = [[Float64::NAN, Float64::NAN]]
421421
p = UnicodePlot.heatmap(z)
422422
p.to_s.should be_a(String)
423+
p.colormap.lim.should eq({0.0, 1.0})
424+
end
425+
426+
it "uses finite extrema for colormap limits" do
427+
z = [[Float64::INFINITY, 1.0], [2.0, -Float64::INFINITY]]
428+
p = UnicodePlot.heatmap(z)
429+
p.colormap.lim.should eq({1.0, 2.0})
423430
end
424431

425432
it "accepts Int matrix via generic numeric overload" do
@@ -437,6 +444,12 @@ describe UnicodePlot do
437444
UnicodePlot.heatmap([[1.0, 2.0], [3.0, 4.0]], colormap: :mystery)
438445
end
439446
end
447+
448+
it "rejects jagged matrix input" do
449+
expect_raises(ArgumentError, /same length/) do
450+
UnicodePlot.heatmap([[1.0, 2.0], [3.0]])
451+
end
452+
end
440453
end
441454

442455
describe "stairs" do
@@ -492,6 +505,32 @@ describe UnicodePlot do
492505

493506
c.to_s.should be_a(String)
494507
end
508+
509+
it "rejects point color vectors with wrong length" do
510+
p = UnicodePlot.scatterplot([1.0, 2.0], [1.0, 2.0])
511+
512+
expect_raises(ArgumentError, /colors/) do
513+
p.points!(
514+
[1.0, 2.0],
515+
[1.0, 2.0],
516+
[UnicodePlot.plot_color(:red)],
517+
p.canvas.blend?
518+
)
519+
end
520+
end
521+
522+
it "rejects segment color vectors with wrong length" do
523+
p = UnicodePlot.lineplot([1.0, 2.0, 3.0], [1.0, 2.0, 3.0])
524+
525+
expect_raises(ArgumentError, /segment_colors/) do
526+
p.lines!(
527+
[1.0, 2.0, 3.0],
528+
[1.0, 2.0, 3.0],
529+
[UnicodePlot.plot_color(:red)],
530+
p.canvas.blend?
531+
)
532+
end
533+
end
495534
end
496535

497536
describe "BlockCanvas" do

src/unicode_plot.cr

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
require "./unicode_plot/terminal_size"
22
require "./unicode_plot/common"
33
require "./unicode_plot/color"
4+
require "./unicode_plot/matrix"
45
require "./unicode_plot/graphics"
56
require "./unicode_plot/canvas"
67
require "./unicode_plot/canvas/braille_canvas"

src/unicode_plot/canvas.cr

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,24 @@ module UnicodePlot
190190
self
191191
end
192192

193+
# Plot a vector of points with per-point colors
194+
def points!(xs : Array(Float64), ys : Array(Float64), colors : Array(UInt32), blend : Bool) : self
195+
unless xs.size == ys.size
196+
raise ArgumentError.new("xs and ys must have the same length")
197+
end
198+
199+
unless xs.size == colors.size
200+
raise ArgumentError.new("colors must have the same length as xs and ys")
201+
end
202+
203+
xs.each_with_index do |x, i|
204+
y = ys[i]
205+
next unless x.finite? && y.finite?
206+
points!(x, y, colors[i], blend)
207+
end
208+
self
209+
end
210+
193211
# Digital differential analyser (DDA) line drawing
194212
def lines!(
195213
x1 : Float64, y1 : Float64,
@@ -246,6 +264,26 @@ module UnicodePlot
246264
self
247265
end
248266

267+
# Vector line drawing (polyline) with per-segment colors
268+
def lines!(xs : Array(Float64), ys : Array(Float64), segment_colors : Array(UInt32), blend : Bool) : self
269+
unless xs.size == ys.size
270+
raise ArgumentError.new("xs and ys must have the same length")
271+
end
272+
273+
expected = xs.size > 0 ? xs.size - 1 : 0
274+
unless segment_colors.size == expected
275+
raise ArgumentError.new("segment_colors must have xs.size - 1 elements")
276+
end
277+
278+
(1...xs.size).each do |i|
279+
x, y = xs[i], ys[i]
280+
xm1, ym1 = xs[i - 1], ys[i - 1]
281+
next unless x.finite? && y.finite? && xm1.finite? && ym1.finite?
282+
lines!(xm1, ym1, x, y, segment_colors[i - 1], blend)
283+
end
284+
self
285+
end
286+
249287
# Annotate a character at canvas coords (x, y)
250288
def annotate!(x : Float64, y : Float64, char : Char, color : UInt32, blend : Bool) : self
251289
return self unless valid_x?(x) && valid_y?(y)

src/unicode_plot/interface/heatmap.cr

Lines changed: 11 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -133,25 +133,15 @@ z : Array(Array(Float64)),
133133
width : Int32? = nil,
134134
fix_ar : Bool = false,
135135
out_stream : IO? = nil,) : Plot
136-
raise ArgumentError.new("z must not be empty") if z.empty?
136+
matrix = MatrixView.new(z)
137+
raise ArgumentError.new("z must not be empty") if matrix.empty?
137138

138-
data_nrows = z.size
139-
data_ncols = z.empty? ? 0 : z[0].size
139+
data_nrows = matrix.nrows
140+
data_ncols = matrix.ncols
140141

141142
# Build Y and X coordinate arrays (mirrors Julia's Y/X computation).
142-
y_arr = if yfact.nil?
143-
Array.new(data_nrows) { |i| (i + 1).to_f + yoffset }
144-
else
145-
f = yfact || 1.0
146-
Array.new(data_nrows) { |i| (i.to_f * f + yoffset).as(Float64) }
147-
end
148-
149-
x_arr = if xfact.nil?
150-
Array.new(data_ncols) { |i| (i + 1).to_f + xoffset }
151-
else
152-
f = xfact || 1.0
153-
Array.new(data_ncols) { |i| (i.to_f * f + xoffset).as(Float64) }
154-
end
143+
y_arr = matrix_axis_coords(data_nrows, yfact, yoffset)
144+
x_arr = matrix_axis_coords(data_ncols, xfact, xoffset)
155145

156146
# autolims: {0,0} means auto → use full data range.
157147
eff_ylim = if ylim == {0.0, 0.0}
@@ -229,17 +219,17 @@ z : Array(Array(Float64)),
229219
)
230220

231221
# zmin/zmax from data or explicit zlim.
222+
submatrix = MatrixView.new(z_sub)
232223
has_extrema = false
233224
zmin, zmax = if zlim != {0.0, 0.0}
234225
has_extrema = true
235226
{zlim[0], zlim[1]}
236227
else
237-
finite_vals = z_sub.each.flat_map(&.each).select(&.finite?).to_a
238-
if finite_vals.empty?
239-
{0.0, 1.0}
240-
else
228+
if extrema = matrix_extrema_finite(submatrix)
241229
has_extrema = true
242-
{finite_vals.min, finite_vals.max}
230+
extrema
231+
else
232+
{0.0, 1.0}
243233
end
244234
end
245235
# Colorbar auto-detection (height >= 7 matches Julia's `height < 7` threshold).

src/unicode_plot/matrix.cr

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
module UnicodePlot
2+
struct MatrixView(T)
3+
getter rows : Array(Array(T))
4+
getter nrows : Int32
5+
getter ncols : Int32
6+
7+
def initialize(@rows : Array(Array(T)))
8+
@nrows = @rows.size
9+
@ncols = @rows.empty? ? 0 : @rows[0].size
10+
11+
@rows.each_with_index do |row, i|
12+
unless row.size == @ncols
13+
raise ArgumentError.new(
14+
"matrix rows must all have the same length; row #{i} has #{row.size}, expected #{@ncols}"
15+
)
16+
end
17+
end
18+
end
19+
20+
def empty? : Bool
21+
@nrows == 0 || @ncols == 0
22+
end
23+
24+
def [](row : Int32, col : Int32) : T
25+
@rows[row][col]
26+
end
27+
28+
def each_cell(& : Int32, Int32, T ->)
29+
@rows.each_with_index do |row_values, row|
30+
row_values.each_with_index do |value, col|
31+
yield row, col, value
32+
end
33+
end
34+
end
35+
end
36+
37+
def matrix_axis_coords(
38+
n : Int32,
39+
fact : Float64?,
40+
offset : Float64 = 0.0,
41+
) : Array(Float64)
42+
if fact.nil?
43+
Array.new(n) { |i| (i + 1).to_f64 + offset }
44+
else
45+
f = fact || 1.0
46+
Array.new(n) { |i| i.to_f64 * f + offset }
47+
end
48+
end
49+
50+
def matrix_extrema_finite(matrix : MatrixView(T)) : {Float64, Float64}? forall T
51+
{% unless T <= Number %}
52+
{% raise "matrix_extrema_finite requires numeric matrix elements" %}
53+
{% end %}
54+
55+
min_value = nil.as(Float64?)
56+
max_value = nil.as(Float64?)
57+
58+
matrix.each_cell do |_row, _col, value|
59+
v = value.to_f64
60+
next unless v.finite?
61+
62+
if min_value.nil?
63+
min_value = v
64+
max_value = v
65+
else
66+
current_min = min_value || v
67+
current_max = max_value || v
68+
min_value = v if v < current_min
69+
max_value = v if v > current_max
70+
end
71+
end
72+
73+
return if min_value.nil?
74+
{min_value || 0.0, max_value || 0.0}
75+
end
76+
end

src/unicode_plot/plot.cr

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,11 +173,21 @@ module UnicodePlot
173173
self
174174
end
175175

176+
def lines!(xs : Array(Float64), ys : Array(Float64), segment_colors : Array(UInt32), blend : Bool) : self
177+
canvas.lines!(xs, ys, segment_colors, blend)
178+
self
179+
end
180+
176181
def points!(xs : Array(Float64), ys : Array(Float64), color : UInt32, blend : Bool) : self
177182
canvas.points!(xs, ys, color, blend)
178183
self
179184
end
180185

186+
def points!(xs : Array(Float64), ys : Array(Float64), colors : Array(UInt32), blend : Bool) : self
187+
canvas.points!(xs, ys, colors, blend)
188+
self
189+
end
190+
181191
def annotate!(x : Float64, y : Float64, char : Char, color : UInt32, blend : Bool) : self
182192
canvas.annotate!(x, y, char, color, blend)
183193
self

0 commit comments

Comments
 (0)