diff --git a/Project.toml b/Project.toml index d56dc16..88ee9d6 100644 --- a/Project.toml +++ b/Project.toml @@ -6,10 +6,8 @@ version = "2.0.1-DEV" [deps] CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0" DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" -Interpolations = "a98d9a8b-a2ab-59e6-89dd-64a1c18fca59" [compat] CairoMakie = "0.12.18" DataFrames = "1.7.0" -Interpolations = "0.15.1" julia = "1.10" diff --git a/README.md b/README.md index 22361b1..6f5bbfd 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # ParallelPlots -[![Stable](https://img.shields.io/badge/docs-stable-blue.svg)](https://moritz155.github.io/ParallelPlots.jl/stable/) -[![Dev](https://img.shields.io/badge/docs-dev-blue.svg)](https://moritz155.github.io/ParallelPlots.jl/dev/) +[![Stable](https://img.shields.io/badge/docs-stable-blue.svg)](https://moritz155.github.io/ParallelPlots/stable/) +[![Dev](https://img.shields.io/badge/docs-dev-blue.svg)](https://moritz155.github.io/ParallelPlots/dev/) [![Build Status](https://github.com/moritz155/ParallelPlots/actions/workflows/CI.yml/badge.svg?branch=main)](https://github.com/moritz155/ParallelPlots/actions/workflows/CI.yml?query=branch%3Amain) [![Coverage](https://codecov.io/gh/moritz155/ParallelPlots/branch/main/graph/badge.svg)](https://codecov.io/gh/moritz155/ParallelPlots) @@ -10,27 +10,21 @@ This Project is for the TU-Berlin Course "Julia Programming for Machine Learning Please make sure, that Julia `1.10` is used! This Module will return you a nice Scene you can use to display your Data with [Parallel Coordinates](https://en.wikipedia.org/wiki/Parallel_coordinates)
- + _This Module was created with PkgTemplates.jl_ ## Getting Started -### Install Dependencies & Use Package -Please refer to this [Link](https://adrianhill.de/julia-ml-course/lectures/E1_Installation.html) for Installation of Julia - -You need to use the package (1-3) and install the dependencies (4-5) -1. Open Julia with `julia` in your command prompt -2. Open the package manager with `]` -3. Using our Package - * `activate /path/to/package`
- or
- `activate .` when Julia was opened with command prompt already in package path - - * _you will then see: `(ParallelPlots) pkg>`_ -4. go back to `julia>` by pressing `CMD`+`C` -5. `Import ParallelPlots` to download Dependencies and use the Package from Command Line - +### Install Dependencies & Use ParallelPlots +#### Script/REPL +`Pkg> add https://github.com/moritz155/ParallelPlots` +#### Notebook +``` +using Pkg +Pkg.add(url="https://github.com/moritz155/ParallelPlots") +using ParallelPlots +``` ### Usage #### Available Parameter @@ -92,23 +86,23 @@ parallelplot(df, Please read the [Docs](/docs/build/index.html) for further Information -### Working on this Package / Cheatsheet -1. Using the Package - * `activate /path/to/package`
- or
- `activate .` when Julia was opened with command prompt already in package path - - * _you will then see: `(ParallelPlots) pkg>`_ +### Working on ParallelPlots / Cheatsheet +1. Using ParallelPlots + * Moving to the project folder + * `julia --project` + * You will see `julia>` + * To move to the pkg, type in `]` + 2. Running commands * Adding external Dependencies - - `add DepName` - * Run Tests to check if Package is still working as intended - - `test` + - `(ParallelPlots) pkg>add 'DepName'` + * Run Tests to check if ParallelPlots is still working as intended + - `(ParallelPlots) pkg>test` * Build - - `build` + - `(ParallelPlots) pkg>build` * Precompile - - `precompile` + - `(ParallelPlots) pkg>precompile` #### Create Docs diff --git a/src/ParallelPlots.jl b/src/ParallelPlots.jl index e759df1..c0879a5 100644 --- a/src/ParallelPlots.jl +++ b/src/ParallelPlots.jl @@ -2,7 +2,6 @@ module ParallelPlots using CairoMakie using DataFrames -#using Interpolations function normalize_DF(data::DataFrame) @@ -16,17 +15,16 @@ end function input_data_check(data::DataFrame) - if data === nothing + if isnothing(data) throw(ArgumentError("Data cannot be nothing")) end if size(data, 2) < 2 # otherwise there will be a nullpointer exception later - throw(ArgumentError("Data must have at least two columns")) + throw(ArgumentError("Data must have at least two columns, currently ("*string(size(data, 2))*")")) end if size(data, 1) < 2 # otherwise there will be a nullpointer exception later - throw(ArgumentError("Data must have at least two lines")) + throw(ArgumentError("Data must have at least two lines, currently ("*string(size(data, 1))*") Rows")) end if any(collect(any(ismissing.(c)) for c in eachcol(data))) # checks for missing values - println("There are missing values in the DataFrame.") throw(ArgumentError("Data cannot have missing values")) end end @@ -34,7 +32,6 @@ end """ -- Julia version: 1.10.5 # Constructors ```julia @@ -43,12 +40,17 @@ ParallelPlot(data::DataFrame; normalize::Bool=false) # Arguments -- `data::DataFrame`: -- `normalize::Bool`: -- `color_feature::String || nothing`: select, which axis/feature should be used for the coloring (e.g. 'weight') (default: last) -- `title::String`: -- `feature_labels::String`: -- `curve::Bool`: +| 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 ```@example @@ -105,11 +107,7 @@ parallelplot(df, end -function Makie.plot!(pp::ParallelPlot{<:Tuple{<:DataFrame}}) - - # our first parameter is the DataFrame-Observable - df_observable = pp[1] - +function Makie.plot!(pp::ParallelPlot) # this helper function will update our observables # whenever df_observable change @@ -128,51 +126,19 @@ function Makie.plot!(pp::ParallelPlot{<:Tuple{<:DataFrame}}) empty!(fig) scene = fig.scene - # get the parent scene dimensions - scene_width, scene_height = size(scene) - # Create Overlaying, invisible Axis # 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] - ) - ) + title = pp.title ) - # make the Axis invisible - hidespines!(ax) - hidedecorations!(ax) - # 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 ("*repr(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 - - else - # check if name is available - @assert pp.color_feature[] in names(data) "Color Feature ("*repr(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) + color_col, color_values, color_min, color_max = calculate_color(pp, data) # 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[]] @@ -187,41 +153,33 @@ function Makie.plot!(pp::ParallelPlot{<:Tuple{<:DataFrame}}) 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 + show_color_legend = show_color_legend!(pp) # 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 + label = color_col, ) end + # get the parent scene dimensions + scene_width, scene_height = size(ax.scene) + + # Plot dimensions + width = scene_width[] * 0.95 #% of scene width + height = scene_height[] * 0.95 #% of scene width + offset = min(scene_width[], scene_height[]) * 0.1 #% of scene dimensions + + # make the Axis invisible + hidespines!(ax) + hidedecorations!(ax) # Parse the DataFrame into a list of arrays parsed_data = [data[!, col] for col in names(data)] @@ -237,99 +195,55 @@ function Makie.plot!(pp::ParallelPlot{<:Tuple{<:DataFrame}}) # # # # # # # # # # # Draw lines connecting points for each row - for i in 1:sampleSize - # 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) - ) - end + draw_lines( + scene, + pp, + data, + width, + height, + offset, + limits, + numberFeatures, + sampleSize, + parsed_data, + color_values, + color_min, + color_max + ) # # # # # # # # # # # # # A X I S # # # # # # # # # # # # # - # Create the new Parallel Axis - for i in 1:numberFeatures - # x will be used to split the Scene for each feature - x = numberFeatures==1 ? width/2 : (i - 1) / (numberFeatures - 1) * width - - # get default - def = Makie.default_attribute_values(Axis, nothing) - - # LineAxis will create one Axis Vertical, for each Feature one Axis - axis = Makie.LineAxis( - scene, - limits = limits[i], - dim_convert = Makie.NoDimConversion(), - # the lowest and highest point to maximize the Axis from Bottom to Top - endpoints = Point2f[(offset + x, offset), (offset + x, offset + height)], - tickformat = Makie.automatic, - spinecolor = :black, - spinevisible = true, - labelfont = def[:ylabelfont], - 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 + draw_axis( + scene, + width, + height, + offset, + limits, + labels, + numberFeatures + ) end + # our first parameter is the DataFrame-Observable + df_observable = pp[1] + + # add listener to Observable Arguments and trigger an update on change + # loop thorough the given Arguments + for kw in pp.kw + # e.g. normalize + attribute_key = kw[1] + on(pp[attribute_key]) do x + # trigger update + notify(df_observable) + end + end + # connect `update_plot` so that it is called whenever the DataFrame changes Makie.Observables.onany(update_plot, df_observable) @@ -341,6 +255,159 @@ function Makie.plot!(pp::ParallelPlot{<:Tuple{<:DataFrame}}) pp end +function get_color_col(pp::ParallelPlot, data::DataFrame) :: AbstractString + 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 ("*repr(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 + + else + # check if name is available + @assert pp.color_feature[] in names(data) "Color Feature ("*repr(pp.color_feature[])*") is not available in DataFrame ("*string(names(data))*")" + pp.color_feature[] + end + return color_col +end + +# Calculates the Color for the colorfeature +function calculate_color(pp::ParallelPlot, data::DataFrame) :: Tuple{AbstractString, Vector{Real}, Real, Real} + color_col = get_color_col(pp, data) + color_values = data[:,color_col] # Get all values for selected feature + color_min = minimum(color_values) + color_max = maximum(color_values) + + return color_col, color_values, color_min, color_max + +end + +# COLOR FEATURE +# If set, use the setted value +# Show, when color_feature is not in feature_selection +function show_color_legend!(pp) :: Bool + if pp.show_color_legend[] == true + return true + elseif pp.show_color_legend[] == false + return false + elseif !isnothing(pp.feature_selection[]) && !(pp.color_feature[] in pp.feature_selection[]) + return true + else + return false + end +end + +# Draw lines connecting points for each row +function draw_lines( + scene, + pp, + data, + width::Number, + height::Number, + offset::Number, + limits, + numberFeatures::Number, + sampleSize::Number, + parsed_data, + color_values, + color_min, + color_max + ) + for i in 1:sampleSize + # 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 + + # Create the Line + lines!(scene, dataPoints, + color = color_values[i], + colormap = pp.colormap[], + colorrange = (color_min, color_max) + ) + end +end + +function draw_axis( + scene, + width::Number, + height::Number, + offset::Number, + limits, + labels, + numberFeatures::Number, + ) + for i in 1:numberFeatures + # x will be used to split the Scene for each feature + x = numberFeatures==1 ? width/2 : (i - 1) / (numberFeatures - 1) * width + + # get default + def = Makie.default_attribute_values(Axis, nothing) + + # LineAxis will create one Axis Vertical, for each Feature one Axis + axis = Makie.LineAxis( + scene, + limits = limits[i], + dim_convert = Makie.NoDimConversion(), + # the lowest and highest point to maximize the Axis from Bottom to Top + endpoints = Point2f[(offset + x, offset), (offset + x, offset + height)], + tickformat = Makie.automatic, + spinecolor = :black, + spinevisible = true, + labelfont = def[:ylabelfont], + 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 +end + + +# Creates an Axis on top of each feature/axis function axis_title!( topscene, endpoints::Observable, diff --git a/test/parallel_coordinates_plot.png b/test/parallel_coordinates_plot.png deleted file mode 100644 index 1715d0c..0000000 Binary files a/test/parallel_coordinates_plot.png and /dev/null differ diff --git a/test/projectile_simulation.png b/test/projectile_simulation.png new file mode 100644 index 0000000..43f2212 Binary files /dev/null and b/test/projectile_simulation.png differ diff --git a/test/runtests.jl b/test/runtests.jl index 815e7e0..8454a75 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -8,4 +8,7 @@ include("test_call_with_normalize.jl") include("test_custom_dimensions.jl") include("test_default_call.jl") include("test_recipe_observable.jl") -include("test_lines_count.jl") \ No newline at end of file +include("test_lines_count.jl") + +# Watson +include("watson_example.jl") \ No newline at end of file diff --git a/test/test_call_with_color_feature.jl b/test/test_call_with_color_feature.jl index 52499be..aabf037 100644 --- a/test/test_call_with_color_feature.jl +++ b/test/test_call_with_color_feature.jl @@ -14,6 +14,7 @@ using Test: @testset, @test, @test_throws save("parallel_coordinates_plot_color_axis_weight.png", fig) fig = parallelplot(df, + title="Based on Weight", color_feature="weight", feature_selection=["height","age","income"], feature_labels=["Height","Age","Income"], @@ -37,10 +38,11 @@ using Test: @testset, @test, @test_throws ) save("parallel_coordinates_plot_color_no_selection.png", fig) - fig = parallelplot(df, + fig = parallelplot(create_person_df(20), color_feature="weight", colormap=:thermal, - show_color_legend = true + show_color_legend = true, + curve= true ) save("parallel_coordinates_plot_color_with_bar.png", fig) diff --git a/test/test_call_with_feature_labels.jl b/test/test_call_with_feature_labels.jl index 50eb673..dfbf98e 100644 --- a/test/test_call_with_feature_labels.jl +++ b/test/test_call_with_feature_labels.jl @@ -4,7 +4,7 @@ using Test: @testset, @test, @test_throws # 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"]) + fig = parallelplot(df, feature_labels=["Height","Weight","Age","Income","Ed-Years"]) # TODO: do not Test agains nothing ;) @test fig !== nothing save("parallel_coordinates_plot_feature_labels.png", fig) diff --git a/test/test_recipe_observable.jl b/test/test_recipe_observable.jl index d0298d1..a6a58af 100644 --- a/test/test_recipe_observable.jl +++ b/test/test_recipe_observable.jl @@ -8,29 +8,36 @@ using DataFrames # create the Data df_observable = Observable(create_person_df(2)) + title_observable = Observable("") normalize_observable = Observable(true) + curve_observable = Observable(true) # create the Plot - fig, ax, sc = parallelplot(df_observable, normalize=normalize_observable) + fig, ax, sc = parallelplot(df_observable, normalize=normalize_observable, title=title_observable, curve = curve_observable) save("pcp_initialized.png", fig) + # we can change a parameter and the graph will be automaticly changed + curve_observable[] = false + title_observable[] = "No Curve" + save("pcp_initialized_curve_Changed.png", fig) + # Record for Debug purpose record(fig, "PCP_recipe_animation.mp4", 2:60, framerate = 2) do t # Update Dataframe if(iseven(t)) - df_observable[] = create_person_df(5) normalize_observable[] = false + curve_observable[] = false + title_observable[] = "" + df_observable[] = create_person_df(5) else - df_observable[] = create_car_df(t) normalize_observable[] = true + curve_observable[] = true + title_observable[] = "Normalize" + df_observable[] = create_car_df(t) end end - # TODO: Write Testcases - # e.g. Test the Size for Changes - # w,h = size(scene) - end diff --git a/test/test_utils.jl b/test/test_utils.jl index 764023a..9a6e4d8 100644 --- a/test/test_utils.jl +++ b/test/test_utils.jl @@ -10,9 +10,10 @@ function create_person_df(n_samples = 10) df = DataFrame( height=rand(150:180, n_samples), weight=rand(40:130, n_samples), - age=rand(0:70, n_samples), # random numbers between 0 and 70 income=rand(450:5000, n_samples), - education_years=rand(0:25, n_samples) # random numbers between 0 and 70 + education_years=rand(0:25, n_samples), # random numbers between 0 and 70 + age=rand(0:70, n_samples), # random numbers between 0 and 70 + ) return df diff --git a/test/watson_example.jl b/test/watson_example.jl index d3ecaac..8c12061 100644 --- a/test/watson_example.jl +++ b/test/watson_example.jl @@ -1,7 +1,7 @@ using DrWatson: display, @unpack, push!, first, Dict, dict_list using DataFrames: DataFrame, nrow using ParallelPlots: parallelplot -using CairoMakie: save +using CairoMakie: save, Observable, record function projectile_simulation() dicts = prepare_simulation() @@ -83,17 +83,17 @@ function find_minimal_distinct_params(arr_of_dicts) param => unique([Float64(d[param]) for d in arr_of_dicts]) # Ensure all values are Float64 for param in params ) - + # Only two different values for each parameter minimal_values = Dict( param => param_values[param][1:min(2, length(param_values[param]))] for param in params ) - + # Create minimal set with proper types result = Vector{Dict{String, Float64}}() # Specify the exact type of the result push!(result, Dict(param => minimal_values[param][1] for param in params)) - + for param in params if length(minimal_values[param]) > 1 new_dict = copy(result[1]) # Copy the base dict @@ -101,7 +101,7 @@ function find_minimal_distinct_params(arr_of_dicts) push!(result, new_dict) end end - + return result end @@ -111,8 +111,14 @@ function main() println("Total parameter combinations: ", nrow(results)) println("\nSample results:") display(first(results, 5)) - fig = parallelplot(results) - save("projectile_simulation_final.png", fig) + fig = parallelplot(results, + figure = (size = (1300, 700),), + curve=true, + color_feature="max_height", + feature_selection=["initial_velocity","launch_angle","air_resistance","gravity","total_distance","time_of_flight"], + feature_labels=["Initial Velocity","Launch Angle","Air Resistance","Gravity","Total Distance","Time of Flight"], + ) + save("projectile_simulation.png", fig) end # Run the simulation