diff --git a/Project.toml b/Project.toml index 079c1fe..2297331 100644 --- a/Project.toml +++ b/Project.toml @@ -6,10 +6,10 @@ version = "2.0.1-DEV" [deps] CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0" DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" -DrWatson = "634d3b9d-ee7a-5ddf-bec9-22491ea816e1" +Interpolations = "a98d9a8b-a2ab-59e6-89dd-64a1c18fca59" [compat] -julia = "1.10" CairoMakie = "0.12.18" DataFrames = "1.7.0" -DrWatson = "2.18.0" +julia = "1.10" +Interpolations = "0.15.1" diff --git a/README.md b/README.md index 679d979..880020b 100644 --- a/README.md +++ b/README.md @@ -34,14 +34,16 @@ You need to use the package (1-3) and install the dependencies (4-5) ### Usage #### Available Parameter -| Parameter | Default | Description | -|--------------------------------------------------------------------|-------------------------------------------------|----------------------------------------------------------------------------------| -| normalize::Bool | false | | -| custom_colors::[Strings] | [:red, :yellow, :green, :purple, :black, :pink] | | -| colormap::[viridis,magma,plasma,inferno,cividis,mako,rocket,turbo] | :viridis | | -| color_feature::Number | 1 | | -| title::String | "" | The Title of The Figure | -| ax_label ::[String] | nothing | Add your own Axis labels, just use the exact amount of labes as you have axis ;) | +| Parameter | Default | Example | Description | +|-------------------|----------|------------------------------------|------------------------------------------------------------------------------------------------------------------------| +| normalize::Bool | false | normalize=true | If the Data should be normalized (min/max) | +| title::String | "" | title="My Title" | The Title of The Figure, | +| colormap | :viridis | colormap=:thermal | The Colors of the [Lines](https://docs.makie.org/dev/explanations/colors) | +| color_feature | nothing | color_feature="weight" | The Color of the Lines will be based on the values of this selected feature. If nothing, the last feature will be used | +| feature_labels | nothing | feature_labels=["Weight","Age"] | Add your own Axis labels, just use the exact amount of labes as you have axis | +| feature_selection | nothing | feature_selection=["weight","age"] | Select, which features should be Displayed. If color_feature is not in this List, use the last one | +| curve | false | curve=true | Show the Lines Curved | +| show_color_legend | nothing | show_color_legend=true | Show the Color Legend. If parameter not set & color_feature not shown, it will be displayed automaticly | #### Examples @@ -68,7 +70,24 @@ julia> parallelplot(DataFrame(height=160:180,weight=reverse(60:80),age=20:40),ti ``` ``` # If you want to specify the axis labels, make sure to use the same number of labels as you have axis! -julia> parallelplot(DataFrame(height=160:180,weight=reverse(60:80),age=20:40), ax_label=["Height","Weight","Age"]) +julia> parallelplot(DataFrame(height=160:180,weight=reverse(60:80),age=20:40), feature_labels=["Height","Weight","Age"]) +``` +``` +# Adjust Color and and feature +parallelplot(df, + # You choose which axis/feature should be in charge for the coloring + color_feature="weight", + # you can as well select, which Axis should be shown + feature_selection=["height","age","income"], + # and label them as you like + feature_labels=["Height","Age","Income"], + # you can change the ColorMap (https://docs.makie.org/dev/explanations/colors) + colormap=:thermal, + # ...and can choose to display the color legend. + # If this Attribute is not set, + # it will only show the ColorBar, when the color feature is not in the selected feature + show_color_legend = true + ) ``` Please read the [Docs](/docs/build/index.html) for further Information diff --git a/src/ParallelPlots.jl b/src/ParallelPlots.jl index 9d112ba..fe0f739 100644 --- a/src/ParallelPlots.jl +++ b/src/ParallelPlots.jl @@ -2,6 +2,7 @@ module ParallelPlots using CairoMakie using DataFrames +using Interpolations function normalize_DF(data::DataFrame) @@ -44,9 +45,10 @@ ParallelPlot(data::DataFrame; normalize::Bool=false) - `data::DataFrame`: - `normalize::Bool`: -- `custom_colors::[String]`: +- `color_feature::String || nothing`: select, which axis/feature should be used for the coloring (e.g. 'weight') (default: last) - `title::String`: -- `ax_label::[String]`: +- `feature_labels::String`: +- `curve::Bool`: # Examples ```@example @@ -67,7 +69,23 @@ julia> fig, ax, sc = parallelplot(df_observable) julia> parallelplot(DataFrame(height=160:180,weight=reverse(60:80),age=20:40),title="My Title") # If you want to specify the axis labels, make sure to use the same number of labels as you have axis! -julia> parallelplot(DataFrame(height=160:180,weight=reverse(60:80),age=20:40), ax_label=["Height","Weight","Age"]) +julia> parallelplot(DataFrame(height=160:180,weight=reverse(60:80),age=20:40), feature_labels=["Height","Weight","Age"]) + +# Adjust Color and and feature +parallelplot(df, + # You choose which axis/feature should be in charge for the coloring + color_feature="weight", + # you can as well select, which Axis should be shown + feature_selection=["height","age","income"], + # and label them as you like + feature_labels=["Height","Age","Income"], + # you can change the ColorMap (https://docs.makie.org/dev/explanations/colors) + colormap=:thermal, + # ...and can choose to display the color legend. + # If this Attribute is not set, + # it will only show the ColorBar, when the color feature is not in the selected feature + show_color_legend = true + ) ``` """ @@ -75,11 +93,14 @@ julia> parallelplot(DataFrame(height=160:180,weight=reverse(60:80),age=20:40), a Attributes( # additional attributes normalize = false, - custom_colors = [:red, :yellow, :green, :purple, :black, :pink, :brown, :orange, :cyan, :blue], - colormap = :viridis, # options: viridis,magma,plasma,inferno,cividis,mako,rocket,turbo - color_feature = 1, # Which feature to use for coloring (column index) title = "", # Title of the Figure - ax_label = nothing, + colormap = :viridis, # https://docs.makie.org/dev/explanations/colors + color_feature = nothing, # Which feature to use for coloring (column name) + feature_labels = nothing, # the Label of each feature as List of Strings + feature_selection = nothing, # which features should be shown, default: nothing --> show all features + curve = false, # If Lines should be curved between the axis. Default false + # if colorlegend/ ColorBar should be shown. Default: when color_feature is not visible, true, else false + show_color_legend = nothing ) end @@ -95,19 +116,13 @@ function Makie.plot!(pp::ParallelPlot{<:Tuple{<:DataFrame}}) function update_plot(data) # check the given DataFrame - input_data_check(data) # TODO: throw Error when new Data is invalid + input_data_check(data) # Normalize the data if required - if pp.normalize[] # TODO: what happens when the parameter is an observeable to? will it update? + if pp.normalize[] data = normalize_DF(data) end - # Parse the DataFrame into a list of arrays - parsed_data = [data[!, col] for col in names(data)] - - # Compute limits for each column - limits = [(minimum(col), maximum(col)) for col in parsed_data] - # Get the Fig and empty it, so its nice and clean for the next itaration fig = current_figure() empty!(fig) @@ -116,73 +131,166 @@ function Makie.plot!(pp::ParallelPlot{<:Tuple{<:DataFrame}}) # get the parent scene dimensions scene_width, scene_height = size(scene) - numberFeatures = length(parsed_data) # Number of features, equivalent to the X Axis - sampleSize = size(data, 1) # Number of samples, equivalent to the Y Axis - - # Plot dimensions - width = scene_width[] * 0.75 # 75% of scene width - height = scene_height[] * 0.75 # 75% of scene width - offset = min(scene_width[], scene_height[]) * 0.15 # 15% of scene dimensions - # Create Overlaying, invisible Axis - # in here, all the lines will be stored - ax = Axis(fig[1, 1], title = pp.title) + # set hight to fit Label + ax = Axis(fig[1, 1], + title = pp.title, + height=(scene_height- + ( + 2*Makie.default_attribute_values(Axis, nothing)[:titlegap]+ + Makie.default_attribute_values(Axis, nothing)[:titlesize] + ) + ) + ) # make the Axis invisible hidespines!(ax) hidedecorations!(ax) - # # # # # # # # # # - # # # L I N E # # # - # # # # # # # # # # + # set the Color of the Color Feature + color_col = if isnothing(pp.color_feature[]) # check if colorFeature is set + # Its not Set, use the last feature + # therefore we need to check if user selected features + if !isnothing(pp.feature_selection[]) + # use the last seleted feature as color_col + @assert pp.feature_selection[][end] in names(data) "Feature Selection ("*pp.feature_selection[][end]*") is not available in DataFrame ("*string(names(data))*")" + pp.feature_selection[][end] + else + names(data)[end] # no columns selected, use the last one + end - # set the Color of the Line - color_col = pp.color_feature[] - color_values = parsed_data[color_col] # Get all values for selected feature + else + # check if name is available + @assert pp.color_feature[] in names(data) "Color Feature ("*pp.color_feature[]*") is not available in DataFrame ("*string(names(data))*")" + pp.color_feature[] + end + color_values = data[:,color_col] # Get all values for selected feature color_min = minimum(color_values) color_max = maximum(color_values) + # Select the Columns, the user wants to show (feature_selection) + if !isnothing(pp.feature_selection[]) + # check if all given selections are in the DF + for selection in pp.feature_selection[] + println(selection) + @assert selection in names(data) "Feature Selection ("*selection*") is not available in DataFrame ("*string(names(data))*")" + end + data = data[:, pp.feature_selection[]] + end + + # set the axis labels, if available + # check if ax_label has the same amount of labels as axis + labels = if isnothing(pp.feature_labels[]) # check if ax_label is set + names(data) # ax_label is not set, use the DB label + else + @assert length(pp.feature_labels[]) === length(names(data)) "'feature_labels' is set but has not the same amount of labels("*string(length(pp.feature_labels[]))*") as axis("*string(length(names(data)))*")" + pp.feature_labels[] + end + + # Plot dimensions + width = scene_width[] * 0.80 #% of scene width + height = scene_height[] * 0.80 #% of scene width + offset = min(scene_width[], scene_height[]) * 0.10 #% of scene dimensions + + # COLOR FEATURE + # If set, use the setted value + # Show, when color_feature is not in feature_selection + show_color_legend = if pp.show_color_legend[] == true + true + elseif pp.show_color_legend[] == false + false + elseif !isnothing(pp.feature_selection[]) && !(pp.color_feature[] in pp.feature_selection[]) + true + else + false + end + + # set the Color Bar on the side if it should be set + if show_color_legend[] + # update the width, combined -> 75% + bar_width = scene_width[] * 0.05 #% of scene width + width = scene_width[] * 0.75 #% of scene width + + Colorbar( + fig[1, 2], + limits = (color_min, color_max), + colormap = pp.colormap[], + height = height, + width = bar_width, + + label = color_col + ) + end + + + # Parse the DataFrame into a list of arrays + parsed_data = [data[!, col] for col in names(data)] + + # Compute limits for each column + limits = [(minimum(col), maximum(col)) for col in parsed_data] + + numberFeatures = length(parsed_data) # Number of features, equivalent to the X Axis + sampleSize = size(data, 1) # Number of samples, equivalent to the Y Axis + + # # # # # # # # # # + # # # L I N E # # # + # # # # # # # # # # + # Draw lines connecting points for each row for i in 1:sampleSize - dataPoints = [ - # calcuating the point respectivly of the width and height in the Screen - Point2f( - # calculates which feature the Point should be on - offset + (j - 1) / (numberFeatures - 1) * width, - # calculates the Y axis value - (parsed_data[j][i] - limits[j][1]) / (limits[j][2] - limits[j][1]) * height + offset, - ) - # iterates through the Features and creates for each feature the samplePoint (above) - for j in 1:numberFeatures - ] - color_idx = if length(pp.custom_colors[]) < i # in case too little custom colors are given, use the first color - 1 - @warn "too little Colors("*string(length(pp.custom_colors[]))*") are available for the Lines("*string(i)*"). You can set more with the 'custom_colors' attribute" - else - i - end + # If Curved, Interpolate + if(pp.curve[] == false) + # calcuating the point respectivly of the width and height in the Screen + dataPoints = [ + Point2f( + # calculates which feature the Point should be on + offset + (j - 1) / (numberFeatures - 1) * width, + # calculates the Y axis value + (parsed_data[j][i] - limits[j][1]) / (limits[j][2] - limits[j][1]) * height + offset, + ) + # iterates through the Features/Axis and creates for each feature the samplePoint (above) + for j in 1:numberFeatures + ] + else + # Interpolate + dataPoints = [] + + # iterates through the Features/Axis + # Start at 2, bc we check the precious axis/feature f + for j in 2:numberFeatures + last_x = offset + ((j-1) - 1) / (numberFeatures - 1) * width + current_x = offset + ((j) - 1) / (numberFeatures - 1) * width + + last_y = (parsed_data[j-1][i] - limits[j-1][1]) / (limits[j-1][2] - limits[j-1][1]) * height + offset + current_y = (parsed_data[j][i] - limits[j][1]) / (limits[j][2] - limits[j][1]) * height + offset + + # interpolate points between the current and the last point + for x in range(last_x, current_x, step = ( (current_x-last_x) / 30 ) ) + # calculate the interpolated Y Value + y = interpolate(last_x, current_x, last_y, current_y, x) + # create a new Point + push!(dataPoints, Point2f(x,y)) + end + end + + end + + # Color color_val = color_values[i] + # Create the Line lines!(scene, dataPoints, color = color_val, colormap = pp.colormap[], colorrange = (color_min, color_max) ) - # lines!(scene, dataPoints, color = pp.custom_colors[][color_idx]) end # # # # # # # # # # # # # A X I S # # # # # # # # # # # # # - # set the axis labels, if available - # check if ax_label has the same amount of labels as axis - label = if isnothing(pp.ax_label[]) # check if ax_label is set - names(data) # ax_label is not set, use the DB label - else - @assert length(pp.ax_label[]) === length(names(data)) "'ax_label' is set but has not the same amount of labels("*string(length(pp.ax_label[]))*") as axis("*string(length(names(data)))*")" - pp.ax_label[] - end + # Create the new Parallel Axis for i in 1:numberFeatures @@ -193,7 +301,7 @@ function Makie.plot!(pp::ParallelPlot{<:Tuple{<:DataFrame}}) def = Makie.default_attribute_values(Axis, nothing) # LineAxis will create one Axis Vertical, for each Feature one Axis - Makie.LineAxis( + axis = Makie.LineAxis( scene, limits = limits[i], dim_convert = Makie.NoDimConversion(), @@ -203,15 +311,20 @@ function Makie.plot!(pp::ParallelPlot{<:Tuple{<:DataFrame}}) spinecolor = :black, spinevisible = true, labelfont = def[:ylabelfont], - # rotate the label - labelrotation = π/2, - labelvisible = true, - # use either the dataFrame Name or the user-set labels - label = string(label[i]), + labelrotation = def[:ylabelrotation], + labelvisible = false, ticklabelfont = def[:yticklabelfont], ticklabelsize = def[:yticklabelsize], minorticks = def[:yminorticks], ) + + # Create Lable for the Axis + axis_title!( + scene, + axis.attributes.endpoints, + string(labels[i]); + titlegap = def[:titlegap], + ) end @@ -228,5 +341,46 @@ function Makie.plot!(pp::ParallelPlot{<:Tuple{<:DataFrame}}) pp end +function axis_title!( + topscene, + endpoints::Observable, + title::String; + titlegap = Observable(4.0f0), +) + titlepos = lift(endpoints, titlegap) do a, titlegap + x = a[1][1] + y = a[2][2] + titlegap + Point2(x, y) + end + + titlet = text!( + topscene, + title, + position = titlepos, + #visible = + #fontsize = + align = (:center, :bottom), + #font = + #color = + space = :data, + #show_axis=false, + inspectable = false, + ) +end + +# Interpolates between the x and y point +# Inputs a x value +# Outputs a y value +function interpolate(last_x::Float64, current_x::Float64, last_y::Float64, current_y::Float64, x::Float64) + + # calculate the % of Pi related to x between two x points + x_pi = (x - last_x)/(current_x - last_x) * π + + # calculate the % difference between both x Values + y_scale = 0.5-0.5*cos(x_pi) #between 0-1 + + return last_y + y_scale * (current_y - last_y) + +end end \ No newline at end of file diff --git a/src/test.jl b/src/test.jl deleted file mode 100644 index dfa55b7..0000000 --- a/src/test.jl +++ /dev/null @@ -1,44 +0,0 @@ -using CairoMakie -CairoMakie.activate!(type = "svg") - -let - s = Scene(camera = campixel!) - - n = 5 - k = 20 - - data = [randn(k) .* (rand() + 1) * 10 for _ in 1:n] - - - limits = extrema.(data) - - scaled = [(d .- mi) ./ (ma - mi) for (d, (mi, ma)) in zip(data, limits)] - - width = 600 - height = 400 - offset = 100 - - for i in 1:n - x = (i - 1) / (n - 1) * width - MakieLayout.LineAxis(s, limits = limits[i], - spinecolor = :black, labelfont = "Arial", - ticklabelfont = "Arial", spinevisible = true, - minorticks = IntervalsBetween(2), - endpoints = Point2f0[(offset + x, offset), (offset + x, offset + height)], - ticklabelalign = (:right, :center), labelvisible = false) - end - - for i in 1:k - values = map(1:n, data, limits) do j, d, l - x = (j - 1) / (n - 1) * width - Point2f0(offset + x, (d[i] - l[1]) ./ (l[2] - l[1]) * height + offset) - end - - lines!(s, values, color = get(Makie.ColorSchemes.inferno, (i - 1) / (k - 1)), - show_axis = false) - end - - s - save("parallel_coordinates_plot.png", s) - display(s) -end \ No newline at end of file diff --git a/test/Project.toml b/test/Project.toml index 4bc01b2..7c70209 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -4,9 +4,11 @@ JLD = "4138dd39-2aa7-5051-a626-17a0bb65d9c8" Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0" # For Recording the Animation +DrWatson = "634d3b9d-ee7a-5ddf-bec9-22491ea816e1" [compat] julia = "1.10" CairoMakie = "0.12.18" DataFrames = "1.7.0" +DrWatson = "2.18.0" diff --git a/test/parallel_coordinates_plot.png b/test/parallel_coordinates_plot.png index 7886093..1715d0c 100644 Binary files a/test/parallel_coordinates_plot.png and b/test/parallel_coordinates_plot.png differ diff --git a/test/runtests.jl b/test/runtests.jl index 95b4231..7ea04e4 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -8,9 +8,11 @@ using DataFrames include("test_utils.jl") include("test_argument_errors.jl") +include("test_curved.jl") +include("test_call_with_color_feature.jl") +include("test_call_with_feature_labels.jl") include("test_call_with_normalize.jl") include("test_custom_dimensions.jl") -include("test_call_with_ax_labels.jl") include("test_default_call.jl") include("test_recipe_observable.jl") include("test_lines_count.jl") \ No newline at end of file diff --git a/test/test_call_with_ax_labels.jl b/test/test_call_with_ax_labels.jl deleted file mode 100644 index de6e44e..0000000 --- a/test/test_call_with_ax_labels.jl +++ /dev/null @@ -1,27 +0,0 @@ -include("test_utils.jl") -using ParallelPlots -using Test - -@testset "call with ax-Labels" begin - - # Generate sample multivariate data - df = create_person_df(3) - - # Create set with correct Axis Labels - fig = parallelplot(df, ax_label=["Height","Weight","Age","Income","Education Years"]) - - # TODO: do not Test agains nothing ;) - @test fig !== nothing - save("parallel_coordinates_plot_ax_labels.png", fig) - - # Test with not enough Labels - @test_throws AssertionError begin - parallelplot(df, ax_label=["Height","Weight","Age","Income"]) - end - - # Test with too much Labels - @test_throws AssertionError begin - parallelplot(df, ax_label=["Height","Weight","Age","Income","Education Years","I am to much :("]) - end - -end \ No newline at end of file diff --git a/test/test_call_with_color_feature.jl b/test/test_call_with_color_feature.jl new file mode 100644 index 0000000..d9f7afa --- /dev/null +++ b/test/test_call_with_color_feature.jl @@ -0,0 +1,58 @@ +#include("test_utils.jl") +using ParallelPlots +using Test + +@testset "call with color feature Axis" begin + + # Generate sample multivariate data + df = create_person_df() + + # Create set with correct Axis Labels + fig = parallelplot(df, color_feature="weight", colormap=:thermal) + + # TODO: do not Test agains nothing ;) + @test fig !== nothing + save("parallel_coordinates_plot_color_axis_weight.png", fig) + + fig = parallelplot(df, + color_feature="weight", + feature_selection=["height","age","income"], + feature_labels=["Height","Age","Income"], + colormap=:thermal + ) + save("parallel_coordinates_plot_color_weight_deselected.png", fig) + + fig = parallelplot(df, + color_feature="weight", + feature_selection=["height","age","income"], + feature_labels=["Height","Age","Income"], + colormap=:thermal, + show_color_legend = false + ) + save("parallel_coordinates_plot_color_weight_deselected_noColorBar.png", fig) + + fig = parallelplot(df, + feature_selection=["height","age","income"], + feature_labels=["Height","Age","Income"], + colormap=:thermal + ) + save("parallel_coordinates_plot_color_no_selection.png", fig) + + fig = parallelplot(df, + color_feature="weight", + colormap=:thermal, + show_color_legend = true + ) + save("parallel_coordinates_plot_color_with_bar.png", fig) + + # Test with Label not available + @test_throws AssertionError begin + parallelplot(df, color_feature="wrong feature name") + end + + # Test with Selection not available + @test_throws AssertionError begin + parallelplot(df, feature_selection=["height","age","incomeWrongInput"]) + end + +end \ No newline at end of file diff --git a/test/test_call_with_feature_labels.jl b/test/test_call_with_feature_labels.jl new file mode 100644 index 0000000..f171354 --- /dev/null +++ b/test/test_call_with_feature_labels.jl @@ -0,0 +1,19 @@ +using ParallelPlots +using Test +@testset "call with feature_labels" begin + # Generate sample multivariate data + df = create_person_df(3) + # Create set with correct Axis Labels + fig = parallelplot(df, feature_labels=["Height","Weight","Age","Income","Education Years"]) + # TODO: do not Test agains nothing ;) + @test fig !== nothing + save("parallel_coordinates_plot_feature_labels.png", fig) + # Test with not enough Labels + @test_throws AssertionError begin + parallelplot(df, feature_labels=["Height","Weight","Age","Income"]) + end + # Test with too much Labels + @test_throws AssertionError begin + parallelplot(df, feature_labels=["Height","Weight","Age","Income","Education Years","I am to much :("]) + end +end \ No newline at end of file diff --git a/test/test_call_with_normalize.jl b/test/test_call_with_normalize.jl index dcf0bd3..8cbf96c 100644 --- a/test/test_call_with_normalize.jl +++ b/test/test_call_with_normalize.jl @@ -8,7 +8,7 @@ using Test df = create_person_df() #display - fig = parallelplot(df, normalize=true, title="Normalize") + fig = parallelplot(df, normalize=true; title="Normalize") @test fig !== nothing diff --git a/test/test_curved.jl b/test/test_curved.jl new file mode 100644 index 0000000..25a783e --- /dev/null +++ b/test/test_curved.jl @@ -0,0 +1,18 @@ +using ParallelPlots +using Test + +using DataFrames + +@testset "default call curved" begin + + # Generate sample multivariate data + df = create_person_df() + + #display + fig = parallelplot(df, curve=true) + + @test fig !== nothing + + save("parallel_coordinates_plot_curved.png", fig) + +end diff --git a/test/test_utils.jl b/test/test_utils.jl index 7601250..8060523 100644 --- a/test/test_utils.jl +++ b/test/test_utils.jl @@ -9,9 +9,9 @@ function create_person_df(n_samples = 10) Random.seed!(10) df = DataFrame( height=rand(150:180, n_samples), - weight=randn(n_samples), + weight=rand(40:130, n_samples), age=rand(0:70, n_samples), # random numbers between 0 and 70 - income=randn(n_samples), + income=rand(450:5000, n_samples), education_years=rand(0:25, n_samples) # random numbers between 0 and 70 )