Skip to content

Commit 06d6160

Browse files
committed
Add spy
1 parent 43903b8 commit 06d6160

7 files changed

Lines changed: 498 additions & 0 deletions

File tree

examples/spy.cr

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
require "../src/unicode_plot"
2+
3+
include UnicodePlot
4+
5+
# Sparse 5x18 matrix with both positive and negative values.
6+
# auto-color mode: positive -> red ("> 0"), negative -> blue ("< 0")
7+
a = Array.new(5) { Array.new(18, 0.0) }
8+
a[0][3] = 1.0
9+
a[3][6] = 2.0
10+
a[2][17] = -5.0
11+
a[4][8] = 3.0
12+
13+
p1 = spy(a, title: "Sparse pattern")
14+
puts p1
15+
puts
16+
17+
# Explicit color
18+
p2 = spy(a, color: :green, title: "Green single color")
19+
puts p2
20+
puts
21+
22+
# xflip / yflip
23+
p3 = spy(a, xflip: true, yflip: false, title: "Flipped axes")
24+
puts p3
25+
puts
26+
27+
# show_zeros: highlight zero entries instead of non-zeros
28+
p4 = spy(a, show_zeros: true, title: "Zero entries")
29+
puts p4
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
using Random: seed!, randsubseq
2+
using SparseArrays
3+
using StableRNGs
4+
5+
# Mirrors UnicodePlots.jl test/fixes.jl _stable_sprand.
6+
# sprand is not stable across Julia versions; this implementation is.
7+
function _stable_sprand(rng, m::Integer, n::Integer, density::AbstractFloat)
8+
I = Int[]
9+
J = Int[]
10+
for li in randsubseq(rng, 1:(m * n), density)
11+
j, i = divrem(li - 1, m)
12+
push!(I, i + 1)
13+
push!(J, j + 1)
14+
end
15+
V = rand(rng, length(I))
16+
return sparse(I, J, V)
17+
end
18+
19+
function sparse_to_dict(A::SparseMatrixCSC)
20+
rows_i, cols_j, vals_v = findnz(A)
21+
Dict(
22+
"nrows" => size(A, 1),
23+
"ncols" => size(A, 2),
24+
"rows" => rows_i,
25+
"cols" => cols_j,
26+
"vals" => vals_v,
27+
)
28+
end
29+
30+
function write_json_value(io::IO, v)
31+
if v isa AbstractVector{Int}
32+
print(io, "[")
33+
for (k, x) in enumerate(v)
34+
k > 1 && print(io, ",")
35+
print(io, x)
36+
end
37+
print(io, "]")
38+
elseif v isa AbstractVector{Float64}
39+
print(io, "[")
40+
for (k, x) in enumerate(v)
41+
k > 1 && print(io, ",")
42+
print(io, repr(x))
43+
end
44+
print(io, "]")
45+
elseif v isa Integer
46+
print(io, v)
47+
else
48+
error("unhandled type: $(typeof(v))")
49+
end
50+
end
51+
52+
function write_spy_json(path::String, cases::Vector{Pair{String,SparseMatrixCSC}})
53+
open(path, "w") do io
54+
println(io, "{")
55+
for (k, (name, A)) in enumerate(cases)
56+
d = sparse_to_dict(A)
57+
print(io, " ", repr(name), ": {")
58+
fields = ["nrows", "ncols", "rows", "cols", "vals"]
59+
for (fi, f) in enumerate(fields)
60+
print(io, repr(f), ": ")
61+
write_json_value(io, d[f])
62+
fi < length(fields) && print(io, ", ")
63+
end
64+
print(io, "}")
65+
k < length(cases) ? println(io, ",") : println(io)
66+
end
67+
println(io, "}")
68+
end
69+
end
70+
71+
function main()
72+
rng = StableRNG(1_337)
73+
74+
cases = Pair{String,SparseMatrixCSC}[]
75+
76+
seed!(rng, 1_337); push!(cases, "10x10" => _stable_sprand(rng, 10, 10, 0.15))
77+
seed!(rng, 1_337); push!(cases, "10x15" => _stable_sprand(rng, 10, 15, 0.15))
78+
seed!(rng, 1_337); push!(cases, "15x10" => _stable_sprand(rng, 15, 10, 0.15))
79+
seed!(rng, 1_337); push!(cases, "200x200_normal" => _stable_sprand(rng, 200, 200, 0.001))
80+
seed!(rng, 1_337); push!(cases, "200x200_zeros" => _stable_sprand(rng, 200, 200, 0.99))
81+
seed!(rng, 1_337); push!(cases, "80x80" => _stable_sprand(rng, 80, 80, 0.15))
82+
seed!(rng, 1_337); push!(cases, "2000x200" => _stable_sprand(rng, 2000, 200, 0.0001))
83+
seed!(rng, 1_337); push!(cases, "200x2000" => _stable_sprand(rng, 200, 2000, 0.0001))
84+
85+
out = joinpath(dirname(@__FILE__), "julia_spy_data.json")
86+
write_spy_json(out, cases)
87+
println("Written: $out")
88+
end
89+
90+
main()

spec/fixtures/julia_spy_data.json

Lines changed: 10 additions & 0 deletions
Large diffs are not rendered by default.

spec/reference_spec.cr

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ DENSITYPLOT_FIXTURE_JSON = JSON.parse(File.read(DENSITYPLOT_FIXTURE_PATH))
1212
DENSITYPLOT_X = DENSITYPLOT_FIXTURE_JSON["x"].as_a.map(&.as_f)
1313
DENSITYPLOT_Y = DENSITYPLOT_FIXTURE_JSON["y"].as_a.map(&.as_f)
1414

15+
# Spy fixtures generated by spec/fixtures/generate_julia_spy_data.jl
16+
SPY_FIXTURE_PATH = File.join(__DIR__, "fixtures", "julia_spy_data.json")
17+
SPY_FIXTURE_JSON = JSON.parse(File.read(SPY_FIXTURE_PATH))
18+
1519
# Returns a matrix fixture by key ("60x60", "10x10", "10x11").
1620
# The parsed matrix is cached because multiple examples reuse the same payload.
1721
def heatmap_fixture_matrix(key : String) : Array(Array(Float64))
@@ -44,6 +48,32 @@ def matrix_columns(rows : Array(Array(Float64))) : Array(Array(Float64))
4448
end
4549
end
4650

51+
# Reconstruct a dense matrix from the spy fixture JSON entry (sparse COO format, 1-indexed).
52+
def spy_fixture_matrix(key : String) : Array(Array(Float64))
53+
entry = SPY_FIXTURE_JSON[key]
54+
nrows = entry["nrows"].as_i
55+
ncols = entry["ncols"].as_i
56+
rows_a = entry["rows"].as_a.map(&.as_i)
57+
cols_a = entry["cols"].as_a.map(&.as_i)
58+
vals_a = entry["vals"].as_a.map(&.as_f)
59+
matrix = Array.new(nrows) { Array.new(ncols, 0.0) }
60+
rows_a.each_with_index do |r, idx|
61+
matrix[r - 1][cols_a[idx] - 1] = vals_a[idx]
62+
end
63+
matrix
64+
end
65+
66+
# Deterministic sparse matrix matching Julia's flip test:
67+
# I = [1,4,3,5], J = [4,7,18,9], V = [1,2,-5,3]
68+
def spy_flip_matrix : Array(Array(Float64))
69+
a = Array.new(5) { Array.new(18, 0.0) }
70+
a[0][3] = 1.0
71+
a[3][6] = 2.0
72+
a[2][17] = -5.0
73+
a[4][8] = 3.0
74+
a
75+
end
76+
4777
def linspace(start_v : Float64, end_v : Float64, length : Int32) : Array(Float64)
4878
return [] of Float64 if length <= 0
4979
return [start_v] if length == 1
@@ -1083,4 +1113,106 @@ describe "Julia reference output compatibility" do
10831113
test_ref("densityplot/densityplot_dscale_custom.txt", p)
10841114
end
10851115
end
1116+
1117+
describe "spy" do
1118+
describe "flip" do
1119+
a = spy_flip_matrix
1120+
1121+
it "matches spy/flip_xflip-false_yflip-true" do
1122+
p = UnicodePlot.spy(a, xflip: false, yflip: true)
1123+
test_ref("spy/flip_xflip-false_yflip-true.txt", p)
1124+
end
1125+
1126+
it "matches spy/flip_xflip-true_yflip-false" do
1127+
p = UnicodePlot.spy(a, xflip: true, yflip: false)
1128+
test_ref("spy/flip_xflip-true_yflip-false.txt", p)
1129+
end
1130+
1131+
it "matches spy/flip_xflip-false_yflip-false" do
1132+
p = UnicodePlot.spy(a, xflip: false, yflip: false)
1133+
test_ref("spy/flip_xflip-false_yflip-false.txt", p)
1134+
end
1135+
1136+
it "matches spy/flip_xflip-true_yflip-true" do
1137+
p = UnicodePlot.spy(a, xflip: true, yflip: true)
1138+
test_ref("spy/flip_xflip-true_yflip-true.txt", p)
1139+
end
1140+
end
1141+
1142+
describe "sizing" do
1143+
it "matches spy/default_10x10" do
1144+
p = UnicodePlot.spy(spy_fixture_matrix("10x10"))
1145+
test_ref("spy/default_10x10.txt", p)
1146+
end
1147+
1148+
it "matches spy/default_10x15" do
1149+
p = UnicodePlot.spy(spy_fixture_matrix("10x15"))
1150+
test_ref("spy/default_10x15.txt", p)
1151+
end
1152+
1153+
it "matches spy/default_15x10" do
1154+
p = UnicodePlot.spy(spy_fixture_matrix("15x10"))
1155+
test_ref("spy/default_15x10.txt", p)
1156+
end
1157+
1158+
it "matches spy/default_200x200_normal" do
1159+
p = UnicodePlot.spy(spy_fixture_matrix("200x200_normal"))
1160+
test_ref("spy/default_200x200_normal.txt", p)
1161+
end
1162+
1163+
it "matches spy/default_200x200_normal_nocolor" do
1164+
p = UnicodePlot.spy(spy_fixture_matrix("200x200_normal"))
1165+
test_ref("spy/default_200x200_normal_nocolor.txt", p)
1166+
end
1167+
1168+
it "matches spy/default_200x200_normal_small (width: 10)" do
1169+
p = UnicodePlot.spy(spy_fixture_matrix("200x200_normal"), width: 10)
1170+
test_ref("spy/default_200x200_normal_small.txt", p)
1171+
end
1172+
1173+
it "matches spy/default_200x200_normal_misshaped (height: 5, width: 20)" do
1174+
p = UnicodePlot.spy(spy_fixture_matrix("200x200_normal"), height: 5, width: 20)
1175+
test_ref("spy/default_200x200_normal_misshaped.txt", p)
1176+
end
1177+
1178+
it "matches spy/default_2000x200" do
1179+
p = UnicodePlot.spy(spy_fixture_matrix("2000x200"))
1180+
test_ref("spy/default_2000x200.txt", p)
1181+
end
1182+
1183+
it "matches spy/default_200x2000" do
1184+
p = UnicodePlot.spy(spy_fixture_matrix("200x2000"))
1185+
test_ref("spy/default_200x2000.txt", p)
1186+
end
1187+
end
1188+
1189+
describe "parameters" do
1190+
it "matches spy/parameters_200x200_green" do
1191+
p = UnicodePlot.spy(spy_fixture_matrix("200x200_normal"), color: :green)
1192+
test_ref("spy/parameters_200x200_green.txt", p)
1193+
end
1194+
1195+
it "matches spy/parameters_200x200_green_nocolor" do
1196+
p = UnicodePlot.spy(spy_fixture_matrix("200x200_normal"), color: :green)
1197+
test_ref("spy/parameters_200x200_green_nocolor.txt", p)
1198+
end
1199+
1200+
it "matches spy/parameters_200x200_dotcanvas" do
1201+
p = UnicodePlot.spy(spy_fixture_matrix("200x200_normal"), canvas: :dot, border: :ascii, title: "Custom Title")
1202+
test_ref("spy/parameters_200x200_dotcanvas.txt", p)
1203+
end
1204+
1205+
it "matches spy/parameters_200x200_zeros" do
1206+
p = UnicodePlot.spy(spy_fixture_matrix("200x200_zeros"), show_zeros: true, compact_labels: true)
1207+
test_ref("spy/parameters_200x200_zeros.txt", p)
1208+
end
1209+
end
1210+
1211+
describe "fix_aspect_ratio" do
1212+
it "matches spy/fix_aspect_ratio_80x80_" do
1213+
p = UnicodePlot.spy(spy_fixture_matrix("80x80"), fix_ar: true)
1214+
test_ref("spy/fix_aspect_ratio_80x80_.txt", p)
1215+
end
1216+
end
1217+
end
10861218
end

spec/unicode_plot_spec.cr

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,31 @@ describe UnicodePlot do
452452
end
453453
end
454454

455+
describe "spy" do
456+
it "returns a Plot" do
457+
p = UnicodePlot.spy([[1.0, 0.0], [0.0, -2.0]])
458+
p.should be_a(UnicodePlot::Plot)
459+
end
460+
461+
it "uses auto color legend for positive and negative values" do
462+
p = UnicodePlot.spy([[1.0, 0.0], [0.0, -2.0]])
463+
p.labels_right[1]?.should eq("> 0")
464+
p.labels_right[2]?.should eq("< 0")
465+
end
466+
467+
it "shows zeros pattern when show_zeros is true" do
468+
p = UnicodePlot.spy([[1.0, 0.0], [0.0, -2.0]], show_zeros: true)
469+
p.labels_right[1]?.should eq("⩵ 0")
470+
p.xlabel.should contain("⩵ 0")
471+
end
472+
473+
it "rejects empty matrices" do
474+
expect_raises(ArgumentError, /must not be empty/) do
475+
UnicodePlot.spy([] of Array(Float64))
476+
end
477+
end
478+
end
479+
455480
describe "stairs" do
456481
it "raises on mismatched x/y lengths" do
457482
expect_raises(ArgumentError, /same length/) do

src/unicode_plot.cr

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ require "./unicode_plot/interface/histogram"
2121
require "./unicode_plot/interface/boxplot"
2222
require "./unicode_plot/interface/densityplot"
2323
require "./unicode_plot/interface/heatmap"
24+
require "./unicode_plot/interface/spy"
2425
require "./unicode_plot/interface/stairs"
2526

2627
module UnicodePlot

0 commit comments

Comments
 (0)