From 1553dea65397c5e15266410160deff533663854c Mon Sep 17 00:00:00 2001 From: Boris Kaus Date: Fri, 25 Sep 2026 10:40:10 +0200 Subject: [PATCH] Restructure the GUI into separate functions The single 570-line start_AdA_Picker function is split into: - Data/profile_data.jl: extracting the data to plot from a ProfileData (no GUI) - Picks/picks_io.jl: converting, saving and loading picks (no GUI) - Layout/building_blocks.jl: reusable pieces (titled panels, toggle lists, logo) - GUI/gui_state.jl: PickerGUI struct that replaces the global variables - Layout/main_layout.jl, Controls/*, Plotting/profile_plot.jl, Picks/picking_interaction.jl, FileIO/fileIO_utils.jl: the parts of this GUI - AdA_Picker.jl: start_AdA_Picker, which puts them together Also: errors in the asynchronous menu actions are now printed, cancelling the file dialog no longer throws, and start_AdA_Picker(data = ...) loads a profile directly. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/AdA_Picker.jl | 34 ++ src/AdA_VizPickTool_V1.jl | 575 --------------------------- src/AdriaArrayGeometryPicker.jl | 23 +- src/Controls/data_controls.jl | 115 ++++++ src/Controls/layout_main_controls.jl | 39 +- src/Data/profile_data.jl | 92 +++++ src/FileIO/fileIO_utils.jl | 170 ++++++-- src/GUI/gui_state.jl | 44 ++ src/Layout/building_blocks.jl | 73 ++++ src/Layout/main_layout.jl | 29 ++ src/Picks/picking_interaction.jl | 56 +++ src/Picks/picks_io.jl | 90 +++++ src/Plotting/profile_plot.jl | 129 ++++++ src/utils.jl | 39 -- test/runtests.jl | 2 + test/test_data.jl | 39 ++ test/test_gui.jl | 28 +- 17 files changed, 918 insertions(+), 659 deletions(-) create mode 100644 src/AdA_Picker.jl delete mode 100644 src/AdA_VizPickTool_V1.jl create mode 100644 src/Controls/data_controls.jl create mode 100644 src/Data/profile_data.jl create mode 100644 src/GUI/gui_state.jl create mode 100644 src/Layout/building_blocks.jl create mode 100644 src/Layout/main_layout.jl create mode 100644 src/Picks/picking_interaction.jl create mode 100644 src/Picks/picks_io.jl create mode 100644 src/Plotting/profile_plot.jl delete mode 100644 src/utils.jl create mode 100644 test/test_data.jl diff --git a/src/AdA_Picker.jl b/src/AdA_Picker.jl new file mode 100644 index 0000000..8b16648 --- /dev/null +++ b/src/AdA_Picker.jl @@ -0,0 +1,34 @@ +# This is the main function of the Adria Array Visualization and Picking tool. +# This tool is meant to work with the GeophysicalModelGenerator to help interpreting geophysical datasets. +# It puts together the building blocks defined in the other files. +# IMPORTANT: FOR NOW, ONLY VERTICAL PROFILES WORK + +const LOGO_FILE = joinpath(@__DIR__, "..", "assets", "AdA_Picker_logo_tr.png") + +""" + start_AdA_Picker(; data = nothing) + +Start the AdA Picker GUI. No input arguments are required. Optionally, a profile can be +given as `data` (a `ProfileData` structure or the name of a jld2 file containing a +`Profile`), which is then loaded directly. + +Returns the figure. The state of the GUI can be accessed with `AdriaArrayGeometryPicker.current_gui()`. +""" +function start_AdA_Picker(; data = nothing) + fig, panels = create_main_layout() + gui = PickerGUI(fig = fig, panels = panels) + CURRENT_GUI[] = gui + + gui.pick_toggle, gui.user_name, gui.compare_toggle = set_picking_controls!(panels.pick_controls) + add_logo!(panels.logo[1, 1], LOGO_FILE) + + gui.menu = set_fileIO_menu!(fig, panels.main_menu) # see layout_main_controls.jl + on(s -> handle_menu_selection!(gui, s), gui.menu.selection) # see fileIO_utils.jl + + connect_picking!(gui) # see picking_interaction.jl + + isnothing(data) || load_profile!(gui, data) + + display(fig) + return fig +end diff --git a/src/AdA_VizPickTool_V1.jl b/src/AdA_VizPickTool_V1.jl deleted file mode 100644 index 0aedf3a..0000000 --- a/src/AdA_VizPickTool_V1.jl +++ /dev/null @@ -1,575 +0,0 @@ -# the is the first version of the Adria Array Visualizuation and Pickking tool -# this tool is meant to work with the GeophysicalModelGenerator to help interpreting geophysical datasets - -# For development reasons, all functionality is contained within this single file -# IMPORTANT: FOR NOW, ONLY VERTICAL PROFILES WORK - - -# add files for specific tasks -include("utils.jl") # this file contains some utility functions -include("FileIO/fileIO_utils.jl") # this file contains the functions to load and save data -include("Controls/layout_main_controls.jl") # this file contains the layour for the main controls - -using Interpolations - - -############################################################### -# main function to start the tool - -""" - start_AdA_Picker() - -Start the AdA Picker GUI. No input arguments are required. - -""" - - - -function start_AdA_Picker(;data=nothing) - - # initialization of some plot handles so that we can check things have been plotted yet - h1 = nothing - # initialization of picking-related variables - global dragging, idx, picks,ax1,p1 - - # initialize the picks observable - picks = Observable(Point3f[]) # --> this has to be done at the initialization, the third dimension is there to ensure that the scatter plot is always on top - - # arguments for picking, should go somewhere else - dragging = false - idx = 1 - - # create a figure - fig = Figure(backgroundcolor = RGBf(0.98, 0.98, 0.98), - size = (1000, 600)) - - # create the main layout for the GUI - this is the basic structure - # changes only occur in the plotting panel - panel_main_menu = fig[1,1] = GridLayout() # menu panel - panel_pick_controls = fig[1,2] = GridLayout() # main control panel - panel_pick_legend = fig[1,3] = GridLayout() # main control panel - panel_logo = fig[1,4] = GridLayout(width=250) # logo panel - panel_plot_controls = fig[2,1] = GridLayout() # plot cotrol panel - panel_plot = fig[2,2:4] = GridLayout() # main plotting window - - colsize!(fig.layout,1,Fixed(300)) # set a fixed column size - rowsize!(fig.layout,1,Fixed(80)) # set a fixed row size - ### PICKING CONTROLS ### - panel_pick_main = panel_pick_controls[1,1:4] = GridLayout(tellheight = false, halign = :left) - panel_pick_compare = panel_pick_controls[2,1:4] = GridLayout(tellheight = false, halign = :left) - - pick_label = Label(panel_pick_main[1,1],"Picking",tellwidth=false) - pick_toggle = Toggle(panel_pick_main[1,2],active=false,buttoncolor = RGBf(0.9, 0.9, 0.9), framecolor_inactive = RGBf(0.5, 0.1, 0.1), framecolor_active = RGBf(0.1, 0.5, 0.1)) - pick_name = Textbox(panel_pick_main[1,3],width=100,placeholder = "User name") - - compare_label = Label(panel_pick_compare[1,1],"Compare Picks",tellwidth=false) - compare_toggle = Toggle(panel_pick_compare[1,2],active=false,buttoncolor = RGBf(0.9, 0.9, 0.9), framecolor_inactive = RGBf(0.5, 0.1, 0.1), framecolor_active = RGBf(0.1, 0.5, 0.1)) - - ### LOGO ### - #insert_logo!(panel_logo[1,1]) - - logo_img = load(joinpath(@__DIR__,"../assets/AdA_Picker_logo_tr.png")) # load the logo - logo_axis = Axis(panel_logo[1,1], aspect = DataAspect()) - image!(logo_axis, rotr90(logo_img)) - hidedecorations!(logo_axis) # no axes labels for the logo - logo_axis.leftspinevisible = false # no left spine for the topo - logo_axis.rightspinevisible = false # no right spine for the topo - logo_axis.bottomspinevisible = false # no bottom spine for the topo - logo_axis.topspinevisible = false # no top spine for the topo - - ### MAIN MENU ### - plot_vertical = nothing # this will be used to store the vertical profile plot - # create the menu layout - menu_fileIO = set_fileIO_menu!(fig,panel_main_menu) # see layout_main_controls.jl for details - # set the menu response ( should be defined in fileIO.jl, but there are issues with that) - on(menu_fileIO.selection) do s - if s == "Load Profile..." - ############################################################ - # LOAD PROFILE DATA - @async begin - fn = fetch(Threads.@spawn PICK_FILE[]("")) - - data = load(fn,"Profile") # we need to make this more foolproof, I don't think we can rely on people calling hteir profile structure Profile - println(fn*" loaded") - - curfile = basename(fn) - - ### Volume Data ### - field_names = (collect(keys(data.VolData.fields))) - println("Volume data extracted") - ### Surface Data ### - surf_names = (collect(keys(data.SurfData))) - # remove the topography from the surface data names, we will plot it separately - surf_names = filter(name -> name != :Topography, surf_names) - # remove all datasets that only contain NaN values, this means that tihs surface does not intersect with the profile - surf_names = filter(name -> !all(isnan, data.SurfData[name].depth.val), surf_names) - println("Surface data extracted") - ### Point Data ### - point_names = (collect(keys(data.PointData))) - # remove all datasets that do not contain any data points - point_names = filter(name -> !isempty(data.PointData[name].fields), point_names) - println("Point data extracted") - # get the topography data - x_topo = data.SurfData.Topography.fields.x_profile; # is in km!!! - y_topo = Vector(ustrip(data.SurfData.Topography.fields.Topography)) # is also in km! thank Unitful for the mess - - # take the first field to get the field data - x = data.VolData.fields.x_profile[:,:,1] - x = x[:,1] # create a vector, we assume that we are dealing with a regular grid - y = data.VolData.depth.val[:,:,1] - y = y[1,:]; # create a vector, we assume that we are dealing with a regular grid - - value = ustrip.(data.VolData.fields[field_names[1]]) - value = Observable(value[:,:,1]) # convert to a 2D matrix for plotting, this is an Observable - - # min and max are also observables that depend on the value observable - minval = @lift(minimum($value[.!(isnan.($value))])) - maxval = @lift(maximum($value[.!(isnan.($value))])) - - # non-allocating version, to be tested - #minval = minimum(x->isnan(x) ? Inf : x,tmp) - #maxval = maximum(x->isnan(x) ? -Inf : x,tmp) - - # create a range from these values, which is also an observable (to be used with the interval slider) - colrange = @lift(LinRange($minval,$maxval,1000)) - - println("Data extracted for vertical profile plotting") - - ############################################################ - # SET THE CONTROL PANEL - - # FIELD DATA: THIS PANEL MORE OR LESS REMAINS THE SAME, ONLY SOME OF THE CONTENTS CHANGE - - # set up the panel, box and label - field_data_panel = panel_plot_controls[1, 1] = GridLayout(tellheight = false, halign = :left) - field_data_box = Box(field_data_panel[1, 1:5], color = :steelblue1,strokecolor = :steelblue1, cornerradius = 3); - Label(field_data_panel[1, 1:5], "Field data (Tomographies etc.)", fontsize = 16,halign = :left,width = nothing) - - Label(field_data_panel[3, 1], "Colormap", fontsize = 14,halign = :left,width=nothing) - menu_field_colormap = Menu(field_data_panel[3,2:5], options = [:seismic,:roma,:glasgow,:lipari,:vik,:managua,:lajolla,:inferno,:plasma,:magma,:RdBu,:RdYlBu], fontsize = 12) - colorrange_slider = IntervalSlider(field_data_panel[4,2:4],linewidth = 20,range = colrange) - # text for the label of the colorbar - colrange_text = lift(colorrange_slider.interval) do int - string(round.(int,digits = 2)) - end - - # add the colorbar label - col_label = Label(field_data_panel[5,2:4],colrange_text,fontsize = 14, ) - - # add textboxes to enter min and max of the colorbar manually - colmin_textbox = Textbox(field_data_panel[4,1],width=50) - colmax_textbox = Textbox(field_data_panel[4,5],width=50) - - rowgap!(field_data_panel,2) - - # set the menu for field data selection - menu_field_data = Menu(field_data_panel[2,1:5]) - menu_field_data.options = field_names[1:end-2] # the last one is empty, so we remove it - - # SURFACE DATA CONTROLS - surf_data_panel = panel_plot_controls[2, 1] = GridLayout(tellheight = false, halign = :left,tellwidth = false) - surf_data_box = Box(surf_data_panel[1, 1:5], color = :steelblue1,strokecolor = :steelblue1, cornerradius = 3); - Label(surf_data_panel[1, :], "Surface data (Moho etc.)", fontsize = 16,halign = :left,width = nothing) - - # create a vector to store the toggles for the surface controls - surf_toggles = [] - for isurf in eachindex(surf_names) - # create a label for the surface data - Label(surf_data_panel[isurf+1, 1], String(surf_names[isurf]), fontsize = 14, halign = :left) - # create a toggle for the surface data - ts = Toggle(surf_data_panel[isurf+1, 2], active = true) - push!(surf_toggles,ts) - end - rowgap!(surf_data_panel,2) - - # POINT DATA CONTROLS - point_data_panel = panel_plot_controls[3, 1] = GridLayout(tellheight = false, halign = :left,tellwidth = false) - point_data_box = Box(point_data_panel[1,1:5], color = :steelblue1,strokecolor = :steelblue1, cornerradius = 3,tellheight = false); - Label(point_data_panel[1, 1], "Point data (Seismicity etc.)", fontsize = 16,halign = :left,width = nothing) - - ipoint = 1 - # create a vector to store the toggles for the surface controls - point_toggles = [] - for ipoint in eachindex(point_names) - # create a label for the point data - Label(point_data_panel[1+ipoint, 1], String(point_names[ipoint]), fontsize = 14, halign = :left) - # create a toggle for the point data - toggle = Toggle(point_data_panel[1+ipoint, 2], active = true) - push!(point_toggles,toggle) - end - rowgap!(point_data_panel,2) - - # SCREENSHOT DATA CONTROLS - screenshot_panel = panel_plot_controls[4, 1] = GridLayout(tellheight = false, halign = :left) - screenshot_box = Box(screenshot_panel[1, 1], color = :white, cornerradius = 3); - - println("Data controls initialized") - ############################################################ - - ############################################################ - # SET THE PLOTTING WINDOW - # if we have loaded a vertical profile, we now create two axes: - # one on top for topography, one directly below for the tomography - topo_ax1 = Axis(panel_plot[1,1]) - ax1 = Axis(panel_plot[2:3,1]) # , aspect=DataAspect() - colorbar_panel = panel_plot[4,1] = GridLayout(height=100,tellheight = false,tellwidth=false) - - hidedecorations!(topo_ax1) # no axes labels for the topo - topo_ax1.leftspinevisible = false # no left spine for the topo - topo_ax1.rightspinevisible = false # no right spine for the topo - topo_ax1.bottomspinevisible = false # no bottom spine for the topo - topo_ax1.topspinevisible = false # no top spine for the topo - linkxaxes!(ax1, topo_ax1) # link the axes in the x-direction - - # display the profile limits - text!(topo_ax1,minimum(x_topo),maximum(y_topo);text = string(round.(data.start_lonlat,digits=2)),align = (:left, :center),offset = (20, 0)) - text!(topo_ax1,maximum(x_topo),maximum(y_topo);text = string(round.(data.end_lonlat,digits=2)), align = (:right, :center),offset = (-20, 0)) - # display the profile name in the title - topo_ax1.title = curfile - - # plot layout - rowgap!(panel_plot,0) # no vertical space between topo and profile plot - rowsize!(panel_plot, 1, Relative(0.2)) # make the topo plot take up 20% of the vertical space - - # set the ax1 limits to the profile limits - ax1.limits = (minimum(x), maximum(x), minimum(y), maximum(y)) - - ################## PLOT CONTROLS ##################### - - ###################### PLOTTING ###################### - # plot topo in the topo axis - band!(topo_ax1,x_topo,y_topo.*0 .+ minimum(y_topo) ,y_topo.*0,color = :skyblue2) # water level - topo1 = lines!(topo_ax1,x_topo,y_topo,color = :black) - band!(topo_ax1,x_topo,y_topo.*0 .+ minimum(y_topo) ,y_topo,color = :grey70) # fill the topography to base level to denote rock - - println("Topography plotted") - - # plot the heatmap - h1 = heatmap!(ax1,x,y,value,colormap = Reverse(:seismic)) - - println("Volume data plotted") - - # plot all surface data - surf_plot = Vector{Lines{Tuple{Vector{Point{2, Float64}}}}}() - surflabel_plot = Vector{String}() - - for isurf in eachindex(surf_names) - surf_data = data.SurfData[surf_names[isurf]] - x_surf = surf_data.fields.x_profile - y_surf = surf_data.depth.val - lines!(ax1, x_surf,y_surf, color = :white,linewidth = 3,visible = @lift($(surf_toggles[isurf].active) ? true : false)) - sp = lines!(ax1, x_surf,y_surf, visible = @lift($(surf_toggles[isurf].active) ? true : false)) - push!(surf_plot,sp) - push!(surflabel_plot,String(surf_names[isurf])) - end - - println("Surface data plotted") - - # plot all point data - point_plot = Vector{Scatter{Tuple{Vector{Point{2, Float64}}}}}() - pointlabel_plot = Vector{String}() - for ipoint in eachindex(point_names) - # get the coordinates of the points - point_data = data.PointData[point_names[ipoint]] - x_point = point_data.fields.x_profile - y_point = point_data.fields.depth_proj - p = scatter!(ax1,x_point,y_point,strokecolor = :black, strokewidth = 1,markersize = 5, visible = @lift($(point_toggles[ipoint].active) ? true : false)) - - push!(point_plot,p) - push!(pointlabel_plot,String(point_names[ipoint])) - - ipoint += 1 - end - - println("Point data plotted") - - # plot the picks - p1 = scatter!(ax1,picks,color = :white,markersize = 15,strokewidth = 2,strokecolor = :black) - - ######################### COLORBAR AND LEGENDS ######################### - cbar = Colorbar(colorbar_panel[1,1], h1, vertical = false,width = 300) - clabel = Label(colorbar_panel[2,1], String(field_names[1])) # split(field_names[1],"_",limit=3) - legend_surf = Legend(colorbar_panel[1:2,2:3],surf_plot,surflabel_plot,"Moho data",valign = :top,framevisible = false,nbanks = 2) - legend_point = Legend(colorbar_panel[1:2,4],point_plot,pointlabel_plot,"Seismicity",valign = :top,framevisible = false) - - ########################## CALLBACKS ########################## - - ### FOR COLORBAR - # Change values in textboxes to set the colorbar limits manually - on(colmin_textbox.stored_string) do valmin - tmpmin = maximum((minimum((parse(Float64,valmin),colorrange_slider.interval[][2])),minimum(colorrange_slider.range[]))) - tmpmax = minimum((maximum((parse(Float64,valmin),colorrange_slider.interval[][2])),maximum(colorrange_slider.range[]))) - set_close_to!(colorrange_slider,tmpmin,tmpmax) - end - on(colmax_textbox.stored_string) do valmin - tmpmin = maximum((minimum((parse(Float64,valmin),colorrange_slider.interval[][1])),minimum(colorrange_slider.range[]))) - tmpmax = minimum((maximum((parse(Float64,valmin),colorrange_slider.interval[][1])),maximum(colorrange_slider.range[]))) - set_close_to!(colorrange_slider,tmpmin,tmpmax) - end - - # change the color limits in the slider - on(colorrange_slider.interval) do int - # change the color limits of the heatmap, this should be directly reflected in the colorbar - h1.colorrange = int - end - - ### FIELD DATA SELECTION - on(menu_field_data.selection) do s - - # update heatmap values - tmp = data.VolData.fields[Symbol(s)] - tmp = ustrip.(tmp) # remove units (if applicable) - tmp = ustrip.(tmp[:,:,1]) # only take 2D data, strip again just to make sure - # delete the interval slider, insert it again and add all the callbacks - # this is necessary, as there are sometimes issues with the interval if the heatmap values change drastically - - # delete slider and label - delete!(colorrange_slider) - delete!(col_label) - - # update the heatmap value -> this should also update colrange through minval and maxval - value[] = tmp - - # create new slider - colorrange_slider = IntervalSlider(field_data_panel[4,2:4],linewidth = 20,range = colrange) - - ### recreate the label - # text for the label of the colorbar - colrange_text = lift(colorrange_slider.interval) do int - string(round.(int,digits = 2)) - end - col_label = Label(field_data_panel[5,2:4],colrange_text,fontsize = 14) - - # define what happens if the slider values are changed - on(colorrange_slider.interval) do int - # change the color limits of the heatmap, this should be directly reflected in the colorbar - h1.colorrange = int - end - - # adapt colorbar label - clabel.text = String(s) - - - println("Field data changed to: ",s) - end - - # change colormap in colormap menu - on(menu_field_colormap.selection) do s - # change the colormap of the heatmap - h1.colormap = Reverse(s) # colormap of the colorbar is automatically updated - end - end - elseif s == "Load Picks..." - # load picks from a text file, these will be modifyable - @async begin - fn = fetch(Threads.@spawn PICK_FILE[]("")) - println(fn) - - - # see if we are dealing with a jld2 or txt file (given as csv) -> distinguish via the extension - filetype = split(fn,".")[end] # this gives us everything after the last dot in the filename - if filetype == "jld2" - data_picks = load(fn) - println(fn*" loaded") - - # the available fields of the pick data are: - # picks: named tuple with x, depth, lat and lon of each pick - # profile_info: start and end lonlat of the profile - # user_name: name of the user who created the picks - # date: date of creation - - # check if the start and end point match - if data_picks["profile_info"].start_lonlat == data.start_lonlat && data_picks["profile_info"].end_lonlat == data.end_lonlat - println("The loaded picks belong to the current profile. Loading picks...") - else - println("Warning: The loaded picks do not belong to the current profile. Proceed with caution") - end - - # assign the loaded pick data to the picks observable - pickarray = data_picks["picks"] - # convert to Point3f vector - pickpoints = Point3f[] - - for ipick in eachindex(pickarray.x) - push!(pickpoints,Point3f(pickarray.x[ipick],pickarray.depth[ipick],10000)) # z-value is set to 10000 to ensure that picks are always on top - end - picks[] = pickpoints - notify(picks) - println("Picks loaded: ",length(picks[])) - - - elseif filetype == "csv" - # Not implemented yet - elseif filetype == "" - # not implemented yet - else - println("This is not a valid pick file at the moment. Feel free to add this functionality :)") - end - - end - elseif s == "Load Picks (not modifyable)..." - # load the picks as point data, these will be treated in a similar way as e.g. the seismicity data - @async begin - fn = fetch(Threads.@spawn PICK_FILE[]("")) - - # test plot in ax1 - #lines!(ax1, [0,100],[0,-200], color = :red,linewidth = 3) - - # see if we are dealing with a jld2 or txt file (given as csv) -> distinguish via the extension - filetype = split(fn,".")[end] # this gives us everything after the last dot in the filename - if filetype == "jld2" - data_picks = load(fn) - println(fn*" loaded") - - if data_picks["profile_info"].start_lonlat == data.start_lonlat && data_picks["profile_info"].end_lonlat == data.end_lonlat - println("The loaded picks belong to the current profile. Loading picks...") - else - println("Warning: The loaded picks do not belong to the current profile. Proceed with care.") - end - - # assign the loaded pick data to the picks observable - pickarray = data_picks["picks"] - x_pick = pickarray.x - y_pick = pickarray.depth - println("Picks loaded") - - # add these picks as a dashed line - lines!(ax1, x_pick,y_pick, color = :white,linewidth = 3,visible = @lift($(compare_toggle.active) ? true : false)) # white background line - pp = lines!(ax1, x_pick,y_pick,color=:red,linewidth=2,linestyle=(:dot,:dense),visible = @lift($(compare_toggle.active) ? true : false)) # black main line - #push!(comppick_plot,pp) - #push!(comppick_label,data_picks["user_name"]) - - println("Picks plotted") - - elseif filetype == "csv" - println("This is not a valid pick file at the moment. Feel free to add this functionality :)") - #csvread() - else - println("This is not a valid pick file at the moment. Feel free to add this functionality :)") - end - - end - - - elseif s == "Save All..." - # save a jld2 file with all data attached -- similar to a state file in paraview? - # this still has to be implemented - elseif s == "Save Picks..." - # save a file with the picked data - # picks can be stored as either jld2 file or csv file,depending on the chosen file extension - # if no file extension is chosen, the default is a jld2 file - println("saving picks...") - - # interpolate the lat and lon of the picked points from the profile start and end points - # this is done by creating a linear interpolation function for the lat and lon from the profile - xtmp = [minimum(data.VolData.fields.x_profile), maximum(data.VolData.fields.x_profile)] - lontmp = [data.start_lonlat[1], data.end_lonlat[1]] - lattmp = [data.start_lonlat[2], data.end_lonlat[2]] - - interp_linear_lon = linear_interpolation(xtmp, lontmp) - interp_linear_lat = linear_interpolation(xtmp, lattmp) - - # create a vector for each pick with x, depth, lat and lon - # then merge everything a a named tuple - x_pick = [picks[][ipick][1] for ipick in 1:length(picks[])] - y_pick = [picks[][ipick][2] for ipick in 1:length(picks[])] - lat_pick = interp_linear_lat(x_pick) - lon_pick = interp_linear_lon(x_pick) - - # use a new name here: `picks` is the (global) Observable used for picking - pick_data = (x = x_pick, depth = y_pick, lat = lat_pick, lon = lon_pick) - pick_info = (user_name = pick_name.stored_string[], date = now(), units = (x = "km", depth = "km", lat = "deg", lon = "deg")) - profile_info = (start_lonlat = data.start_lonlat, end_lonlat = data.end_lonlat) - - @async begin - fn_save = fetch(Threads.@spawn SAVE_FILE[]("")) # open native file dialog and choose a filename - filetype = split(fn_save,".")[end] # get the ending - # save depending on file ending - if filetype == "jld2" - # file should contain: profile information, picker information, picks, lat and lon of the picked points - - # save as jld2 file - jldsave(fn_save; picks=pick_data, pick_info=pick_info, profile_info=profile_info) - println("... "*fn_save*" saved") - elseif filetype == "csv" - println("Saving as csv is not implemented yet") - else - println("This is not a valid pick file format. Picks should be saved as jld2 files.") - end - end - # get the file ending - - #pickarray = zeros(length(picks[]),3) - - elseif s == "Save Screenshot..." - # save a screen shot of the makie window - @async begin - fn_screen = fetch(Threads.@spawn SAVE_FILE[]("")) - save(fn_screen, fig, px_per_unit = 2) - println(fn_screen*" saved") - end - elseif s == "Close" - GLMakie.closeall() # close the window - end - end - notify(menu_fileIO.selection) - - ##### PICKING ##### - # adding, deleting, dragging - on(events(fig).mousebutton, priority = 2) do event - if pick_toggle.active[] - if event.button == Mouse.left - if event.action == Mouse.press - plt, i = pick(fig) - if Keyboard.d in events(fig).keyboardstate - # Delete marker - deleteat!(picks[], i) - notify(picks) - println(" deleted pick") - return Consume(true) - elseif Keyboard.a in events(fig).keyboardstate - # Add marker - #if is_mouseinside(ax1) - push!(picks[], [mouseposition(ax1);1000]) - println([mouseposition(ax1);1000]) - #end - notify(picks) - println(" added pick") - return Consume(true) - else - # Initiate drag --> this is not working yet - dragging = plt == p1 # What is happening here? - idx = i - return Consume(dragging) - end - elseif event.action == Mouse.release - # Exit drag - dragging = false - return Consume(false) - end - end - return Consume(false) - end - end - - on(events(fig).mouseposition, priority = 2) do mp - if pick_toggle.active[] - if dragging - #if is_mouseinside(ax1) - picks[][idx] = [mouseposition(ax1);1000] - #end - - notify(picks) - return Consume(true) - end - return Consume(false) - end - end - ########################################## - display(fig) - return fig -end -############################################################### - - - diff --git a/src/AdriaArrayGeometryPicker.jl b/src/AdriaArrayGeometryPicker.jl index 8378ee0..67bd7f1 100644 --- a/src/AdriaArrayGeometryPicker.jl +++ b/src/AdriaArrayGeometryPicker.jl @@ -5,11 +5,28 @@ using GLMakie using GeophysicalModelGenerator using DelimitedFiles using Dates +using FileIO +using NativeFileDialog +using JLD2 +using Interpolations # export the function that starts the tool -export start_AdA_Picker # this is the function that starts the GUI, it is defined in AdA_VizPickTool_V1.jl +export start_AdA_Picker # this is the function that starts the GUI, it is defined in AdA_Picker.jl -# now include the main function that starts the GUI -include("AdA_VizPickTool_V1.jl") +# The code is split into: +# - functions without any graphics, that extract data from profiles and handle picks +include("Data/profile_data.jl") # get the data to plot from a GMG ProfileData structure +include("Picks/picks_io.jl") # convert, save and load picks +# - generic building blocks for GUIs (styled panels, toggle lists, logo, ...) +include("Layout/building_blocks.jl") +# - the parts of this specific GUI +include("GUI/gui_state.jl") # struct that holds the state of one GUI window +include("Layout/main_layout.jl") # main layout of the window +include("Controls/layout_main_controls.jl") # main menu and picking controls +include("Controls/data_controls.jl") # controls for field, surface and point data +include("Plotting/profile_plot.jl") # plots a vertical profile +include("Picks/picking_interaction.jl") # adding, deleting and dragging picks with the mouse +include("FileIO/fileIO_utils.jl") # file dialogs and the actions of the main menu +include("AdA_Picker.jl") # start_AdA_Picker, which puts everything together end diff --git a/src/Controls/data_controls.jl b/src/Controls/data_controls.jl new file mode 100644 index 0000000..7979cc0 --- /dev/null +++ b/src/Controls/data_controls.jl @@ -0,0 +1,115 @@ +# this is data_controls.jl +# It contains the controls for the data of a profile: field data (menu, colormap, color range), +# surface data and point data (toggles to show/hide them) + +const COLORMAPS = [:seismic, :roma, :glasgow, :lipari, :vik, :managua, :lajolla, :inferno, :plasma, :magma, :RdBu, :RdYlBu] + +""" + set_field_controls!(pos, field_names, colrange) + +Adds the controls for the field data (tomographies etc.): a menu to choose the field, a menu +for the colormap, and a slider and textboxes for the color range, which can be chosen within +`colrange`. Returns a named tuple with the controls. +""" +function set_field_controls!(pos, field_names, colrange) + panel = titled_panel(pos, "Field data (Tomographies etc.)") + + field_menu = Menu(panel[2, 1:5], options = field_names) + + Label(panel[3, 1], "Colormap", fontsize = 14, halign = :left, width = nothing) + colormap_menu = Menu(panel[3, 2:5], options = COLORMAPS, fontsize = 12) + + # textboxes to enter min and max of the colorbar manually + colmin_textbox = Textbox(panel[4, 1], width = 50) + colmax_textbox = Textbox(panel[4, 5], width = 50) + + # the slider is recreated when the field changes, so it is stored in a Ref + controls = (panel = panel, field_menu = field_menu, colormap_menu = colormap_menu, + colmin_textbox = colmin_textbox, colmax_textbox = colmax_textbox, + slider = Ref{Any}(nothing), slider_label = Ref{Any}(nothing)) + set_colorrange_slider!(controls, colrange) + + rowgap!(panel, 2) + return controls +end + +""" + set_colorrange_slider!(controls, colrange) + +Adds an interval slider (with a label) for the color range to the field controls. +""" +function set_colorrange_slider!(controls, colrange) + panel = controls.panel + slider = IntervalSlider(panel[4, 2:4], linewidth = 20, range = colrange) + + # text for the label of the colorbar + colrange_text = lift(int -> string(round.(int, digits = 2)), slider.interval) + label = Label(panel[5, 2:4], colrange_text, fontsize = 14) + + controls.slider[] = slider + controls.slider_label[] = label + return slider +end + +# change the color limits of the heatmap if the slider changes, this is directly reflected in the colorbar +connect_colorrange_slider!(slider, hm) = on(int -> hm.colorrange = int, slider.interval) + +# sets one end of the color range slider to `val`, keeping the end with index `other_index` +function set_colorrange_limit!(slider, val, other_index) + other = slider.interval[][other_index] + tmpmin = max(min(val, other), minimum(slider.range[])) + tmpmax = min(max(val, other), maximum(slider.range[])) + set_close_to!(slider, tmpmin, tmpmax) + return nothing +end + +""" + connect_field_controls!(gui, controls, value, colrange) + +Defines what happens if the field, the colormap or the color range are changed. +`value` is the Observable with the data of the heatmap. +""" +function connect_field_controls!(gui, controls, value, colrange) + hm = gui.heatmap + connect_colorrange_slider!(controls.slider[], hm) + + # change values in textboxes to set the colorbar limits manually + on(s -> set_colorrange_limit!(controls.slider[], parse(Float64, s), 2), controls.colmin_textbox.stored_string) + on(s -> set_colorrange_limit!(controls.slider[], parse(Float64, s), 1), controls.colmax_textbox.stored_string) + + # field data selection + on(controls.field_menu.selection) do s + # delete the interval slider and its label, and create them again after the data is updated. + # This is necessary, as there are sometimes issues with the interval if the heatmap values change drastically + delete!(controls.slider[]) + delete!(controls.slider_label[]) + + # update the heatmap value -> this also updates colrange + value[] = volume_slice(gui.profile, s) + + connect_colorrange_slider!(set_colorrange_slider!(controls, colrange), hm) + + # adapt colorbar label + gui.colorbar_label.text = String(s) + + println("Field data changed to: ", s) + end + + # change colormap in colormap menu + on(controls.colormap_menu.selection) do s + hm.colormap = Reverse(s) # colormap of the colorbar is automatically updated + end + return nothing +end + +""" + set_toggle_panel!(pos, title, names) + +Panel with a title and a toggle for each of the data sets `names`. Returns the toggles. +""" +function set_toggle_panel!(pos, title, names) + panel = titled_panel(pos, title; tellwidth = false) + toggles = toggle_list!(panel, names) + rowgap!(panel, 2) + return toggles +end diff --git a/src/Controls/layout_main_controls.jl b/src/Controls/layout_main_controls.jl index 332672c..43be6ed 100644 --- a/src/Controls/layout_main_controls.jl +++ b/src/Controls/layout_main_controls.jl @@ -1,10 +1,10 @@ -# This is set_main_controls.jl +# This is layout_main_controls.jl # It provides the functions to set the controls on: # - file input/output +# - picking # - panel switching (vertical/horizontal picking/ data comparison) -# function to set the buttons for panel switching - +# function to set the buttons for panel switching (not used yet) function set_panel_buttons!(fig,panel) panel[1,1] = buttongrid = GridLayout(tellwidth = false) button_picking = buttongrid[1,1] = Button(fig,label = "Pick") @@ -13,16 +13,39 @@ function set_panel_buttons!(fig,panel) return button_picking,button_comparison end -# the menu contains save picks, save screenshotload data, load picks (to modify and without modifying) and close +# the entries of the main menu, their actions are defined in fileIO_utils.jl +const MAIN_MENU_OPTIONS = ["Click to choose ... ", "Load Profile...", "Load Picks...", "Load Picks (not modifyable)...", + "Save All...", "Save Picks...", "Save Screenshot...", "Close"] +# the menu contains save picks, save screenshotload data, load picks (to modify and without modifying) and close function set_fileIO_menu!(fig,panel) - fileIO_menu_layout = GridLayout(panel[1,1],tellheight=false,halign= :left) + fileIO_menu_layout = GridLayout(panel[1,1],tellheight=false,halign= :left) fileIO_menu_box = Box(fileIO_menu_layout[1, 1], color = :darkorange1,strokecolor = :darkorange1, alignmode= Outside(),cornerradius = 3); # somehow add a label # menu Label(fileIO_menu_layout[1, 1], "Menu", fontsize = 16,halign = :left,width = nothing) - menu_fileIO = Menu(fileIO_menu_layout[2,1], options = ["Click to choose ... ","Load Profile...", "Load Picks...", "Load Picks (not modifyable)...", "Save All...", "Save Picks...","Save Screenshot...","Close"], fontsize=14, dropdown_arrow_color = (:darkorange1),dropdown_arrow_size = 14, prompt = "Select...") + menu_fileIO = Menu(fileIO_menu_layout[2,1], options = MAIN_MENU_OPTIONS, fontsize=14, dropdown_arrow_color = (:darkorange1),dropdown_arrow_size = 14, prompt = "Select...") - rowgap!(fileIO_menu_layout,2) # small vertical space + rowgap!(fileIO_menu_layout,2) # small vertical space return menu_fileIO -end \ No newline at end of file +end + +""" + pick_toggle, user_name, compare_toggle = set_picking_controls!(panel) + +Adds the controls to switch picking on/off, to enter the name of the user and to show/hide +the picks loaded for comparison. +""" +function set_picking_controls!(panel) + panel_pick_main = panel[1, 1:4] = GridLayout(tellheight = false, halign = :left) + panel_pick_compare = panel[2, 1:4] = GridLayout(tellheight = false, halign = :left) + + Label(panel_pick_main[1, 1], "Picking", tellwidth = false) + pick_toggle = styled_toggle(panel_pick_main[1, 2]) + user_name = Textbox(panel_pick_main[1, 3], width = 100, placeholder = "User name") + + Label(panel_pick_compare[1, 1], "Compare Picks", tellwidth = false) + compare_toggle = styled_toggle(panel_pick_compare[1, 2]) + + return pick_toggle, user_name, compare_toggle +end diff --git a/src/Data/profile_data.jl b/src/Data/profile_data.jl new file mode 100644 index 0000000..4b56873 --- /dev/null +++ b/src/Data/profile_data.jl @@ -0,0 +1,92 @@ +# this is profile_data.jl +# It contains functions that extract the data to plot from a GMG ProfileData structure. +# They don't depend on the GUI, so they can be reused (and tested) independently. +# IMPORTANT: FOR NOW, ONLY VERTICAL PROFILES WORK + +# fields that GMG adds to the volume data of a profile, which are not data to plot +const PROFILE_HELPER_FIELDS = (:x_profile, :FlatCrossSection) + +""" + volume_field_names(profile) + +Names of the volume data fields (tomographies etc.) of `profile` that can be plotted. +""" +volume_field_names(profile) = [name for name in keys(profile.VolData.fields) if name ∉ PROFILE_HELPER_FIELDS] + +""" + surface_names(profile) + +Names of the surface data sets (Moho etc.) that intersect the profile. The topography is +not included, as it is plotted separately. +""" +function surface_names(profile) + isnothing(profile.SurfData) && return Symbol[] + names = [name for name in keys(profile.SurfData) if name != :Topography] + # remove all datasets that only contain NaN values, this means that this surface does not intersect with the profile + return filter(name -> !all(isnan, profile.SurfData[name].depth.val), names) +end + +""" + point_names(profile) + +Names of the point data sets (seismicity etc.) that contain points close to the profile. +""" +function point_names(profile) + isnothing(profile.PointData) && return Symbol[] + return [name for name in keys(profile.PointData) if !isempty(profile.PointData[name].fields)] +end + +""" + x, y = topography(profile) + +Topography along the profile. Both `x` (distance along the profile) and `y` are in km. +""" +function topography(profile) + topo = profile.SurfData.Topography.fields + return topo.x_profile, Vector(ustrip.(topo.Topography)) +end + +""" + x, y = profile_grid(profile) + +Coordinates of the (regular) grid of the volume data: distance along the profile and depth, in km. +""" +function profile_grid(profile) + x = profile.VolData.fields.x_profile[:, 1, 1] + y = profile.VolData.depth.val[1, :, 1] + return x, y +end + +""" + volume_slice(profile, name) + +2D matrix (without units) of the volume data field `name`, to be plotted as heatmap. +""" +volume_slice(profile, name) = ustrip.(profile.VolData.fields[Symbol(name)][:, :, 1]) + +""" + x, y = surface_line(profile, name) + +Distance along the profile and depth of the surface data set `name`. +""" +function surface_line(profile, name) + surf = profile.SurfData[name] + return surf.fields.x_profile, surf.depth.val +end + +""" + x, y = point_coordinates(profile, name) + +Distance along the profile and depth of the points of point data set `name`, projected on the profile. +""" +function point_coordinates(profile, name) + points = profile.PointData[name] + return points.fields.x_profile, points.fields.depth_proj +end + +""" + finite_extrema(A) + +Minimum and maximum of `A`, ignoring NaN values. +""" +finite_extrema(A) = extrema(filter(!isnan, A)) diff --git a/src/FileIO/fileIO_utils.jl b/src/FileIO/fileIO_utils.jl index dc7f12d..bba7a3e 100644 --- a/src/FileIO/fileIO_utils.jl +++ b/src/FileIO/fileIO_utils.jl @@ -1,34 +1,152 @@ -# this is fileIO.jl -# it contains all functions that handle the file input and output -using FileIO -using NativeFileDialog -using JLD2 +# this is fileIO_utils.jl +# it contains the file dialogs and the actions of the main menu # The GUI opens the native file dialogs through these Refs, so that the tests can # replace them by functions that return a fixed filename (there is no user on CI) const PICK_FILE = Ref{Function}(pick_file) const SAVE_FILE = Ref{Function}(save_file) -# function for fileIO menu response -function menu_fileIO_response(s) - if s == "Load Profile..." - @async begin - filename = fetch(Threads.@spawn PICK_FILE[]("")) - println(filename) - #data = load_GMG(filename) # load the profile data, this did not work properly - data = load(filename,"Profile") - # depending on the type of data, initialize the plotting panel - # populate the dropdown menu - #dropdown_strings = collect(String.(keys(data.VolData.fields))) - #menu_plot1.options = ["Choose data to plot",dropdown_strings[1:end-1]] - end - elseif s == "Load Picks..." - elseif s == "Load Picks (not modifyable)..." - elseif s == "Save All..." - elseif s == "Save Picks..." - elseif s == "Save Screenshot..." - elseif s == "Close" - #GLMakie.closeall() # this needs to be done properly to free up everything +# the dialogs are opened on another thread, so that the GUI does not block +choose_file_to_open() = fetch(Threads.@spawn PICK_FILE[]("")) +choose_file_to_save() = fetch(Threads.@spawn SAVE_FILE[]("")) + +""" + run_async(f, description) + +Runs `f` asynchronously, so that the GUI remains responsive, and prints errors (which +would otherwise be silently swallowed by the task). +""" +function run_async(f, description) + return @async try + f() + catch err + @error "$description failed" exception = (err, catch_backtrace()) + end +end + +""" + load_profile!(gui, filename) + load_profile!(gui, profile::ProfileData; name = "") + +Loads a profile (from a jld2 file that contains a `Profile`) and creates the plot controls +and the plots for it. +""" +function load_profile!(gui, filename::AbstractString) + isempty(filename) && return nothing # the dialog was cancelled + profile = load(filename, "Profile") # we need to make this more foolproof, I don't think we can rely on people calling their profile structure Profile + println(filename * " loaded") + return load_profile!(gui, profile; name = basename(filename)) +end + +function load_profile!(gui, profile::ProfileData; name = "") + gui.profile = profile + gui.profile_file = name + + field_names = volume_field_names(profile) + surf_names = surface_names(profile) + pnt_names = point_names(profile) + println("Data extracted for vertical profile plotting") + + # the heatmap data is an Observable, and the range of the color range slider depends on it + value = Observable(volume_slice(profile, field_names[1])) + colrange = lift(v -> LinRange(finite_extrema(v)..., 1000), value) + + # plot controls + panel = gui.panels.plot_controls + field_ctrls = set_field_controls!(panel[1, 1], field_names, colrange) + surf_toggles = set_toggle_panel!(panel[2, 1], "Surface data (Moho etc.)", surf_names) + point_toggles = set_toggle_panel!(panel[3, 1], "Point data (Seismicity etc.)", pnt_names) + screenshot_panel = panel[4, 1] = GridLayout(tellheight = false, halign = :left) # screenshot controls, still to be done + Box(screenshot_panel[1, 1], color = :white, cornerradius = 3) + println("Data controls initialized") + + # plots + plot_profile!(gui, value, field_names[1], surf_names, surf_toggles, pnt_names, point_toggles) + + connect_field_controls!(gui, field_ctrls, value, colrange) + return nothing +end + +""" + load_modifiable_picks!(gui, filename) + +Loads picks that can be modified. +""" +function load_modifiable_picks!(gui, filename) + data_picks = load_picks(filename) + isnothing(data_picks) && return nothing + + warn_if_other_profile(data_picks, gui.profile) + gui.picks[] = pick_points(data_picks["picks"]) + println("Picks loaded: ", length(gui.picks[])) + return nothing +end + +""" + load_fixed_picks!(gui, filename) + +Loads picks that cannot be modified, which are shown if "Compare picks" is active. +""" +function load_fixed_picks!(gui, filename) + data_picks = load_picks(filename) + isnothing(data_picks) && return nothing + + warn_if_other_profile(data_picks, gui.profile) + plot_fixed_picks!(gui, data_picks["picks"]) + println("Picks plotted") + return nothing +end + +function warn_if_other_profile(data_picks, profile) + if picks_belong_to_profile(data_picks, profile) + println("The loaded picks belong to the current profile. Loading picks...") + else + println("Warning: The loaded picks do not belong to the current profile. Proceed with caution") end - # what do we need to return? + return nothing +end + +""" + save_current_picks(gui) + +Asks for a filename and saves the current picks. The lat and lon of the picks are +interpolated from the profile start and end points. +""" +function save_current_picks(gui) + println("saving picks...") + pick_data = pick_table(gui.picks[], gui.profile) + user_name = gui.user_name.stored_string[] + return run_async("Saving the picks") do + save_picks(choose_file_to_save(), pick_data, gui.profile; user_name = user_name) + end +end + +function save_screenshot(gui) + return run_async("Saving the screenshot") do + fn_screen = choose_file_to_save() + save(fn_screen, gui.fig, px_per_unit = 2) + println(fn_screen * " saved") + end +end + +# what happens if an entry of the main menu is selected +const MAIN_MENU_ACTIONS = Dict{String, Function}( + "Load Profile..." => gui -> run_async(() -> load_profile!(gui, choose_file_to_open()), "Loading the profile"), + "Load Picks..." => gui -> run_async(() -> load_modifiable_picks!(gui, choose_file_to_open()), "Loading the picks"), + "Load Picks (not modifyable)..." => gui -> run_async(() -> load_fixed_picks!(gui, choose_file_to_open()), "Loading the picks"), + "Save All..." => gui -> nothing, # save a jld2 file with all data attached, similar to a state file in paraview? Still has to be implemented + "Save Picks..." => save_current_picks, + "Save Screenshot..." => save_screenshot, + "Close" => gui -> GLMakie.closeall(), +) + +""" + handle_menu_selection!(gui, selection) + +Runs the action of the main menu entry `selection` (see `MAIN_MENU_ACTIONS`). +""" +function handle_menu_selection!(gui, selection) + action = get(MAIN_MENU_ACTIONS, selection, nothing) + isnothing(action) || action(gui) + return nothing end diff --git a/src/GUI/gui_state.jl b/src/GUI/gui_state.jl new file mode 100644 index 0000000..d1c061b --- /dev/null +++ b/src/GUI/gui_state.jl @@ -0,0 +1,44 @@ +# this is gui_state.jl +# It defines the structure that holds the state of one window of the AdA Picker, so that +# the different parts of the GUI can access it (instead of using global variables) + +""" + PickerGUI + +Holds the figure, its panels and controls, the loaded profile, the plots and the picks of +one AdA Picker window. The GUI that was started last can be accessed with `current_gui()`. +""" +Base.@kwdef mutable struct PickerGUI + fig::Figure + panels::NamedTuple # the main panels of the layout, see create_main_layout + + # controls that exist from the start + menu::Any = nothing # main (file) menu + pick_toggle::Any = nothing # switches picking on and off + compare_toggle::Any = nothing # shows/hides the picks loaded for comparison + user_name::Any = nothing # textbox with the name of the user + + # data and plots, available once a profile has been loaded + profile::Any = nothing # ProfileData + profile_file::String = "" + ax_topo::Any = nothing # axis with the topography + ax_profile::Any = nothing # axis with the profile data and the picks + heatmap::Any = nothing # heatmap of the volume data + colorbar_label::Any = nothing + + # picking + picks::Observable{Vector{Point3f}} = Observable(Point3f[]) # the third dimension is there to ensure that the picks are always on top + pick_plot::Any = nothing + dragging::Bool = false + drag_index::Int = 1 +end + +const CURRENT_GUI = Ref{Union{Nothing, PickerGUI}}(nothing) + +""" + current_gui() + +Returns the `PickerGUI` that was started last (or `nothing`). This is useful for debugging, +e.g. `current_gui().picks[]` gives the current picks. +""" +current_gui() = CURRENT_GUI[] diff --git a/src/Layout/building_blocks.jl b/src/Layout/building_blocks.jl new file mode 100644 index 0000000..e5154a3 --- /dev/null +++ b/src/Layout/building_blocks.jl @@ -0,0 +1,73 @@ +# this is building_blocks.jl +# It contains generic building blocks that can be used to create GUIs with a consistent look + +""" + titled_panel(pos, title; color = :steelblue1, ncols = 5, fontsize = 16, layout_kwargs...) + +Creates a GridLayout at the position `pos` (e.g. `fig[1, 1]`), with a colored title bar +spanning `ncols` columns in its first row. Add the contents of the panel from row 2 onwards, +and call `rowgap!(panel, 2)` afterwards for a compact layout (it only applies to existing rows). +""" +function titled_panel(pos, title; color = :steelblue1, ncols = 5, fontsize = 16, layout_kwargs...) + panel = GridLayout(pos; tellheight = false, halign = :left, layout_kwargs...) + Box(panel[1, 1:ncols], color = color, strokecolor = color, cornerradius = 3) + Label(panel[1, 1:ncols], title, fontsize = fontsize, halign = :left, width = nothing) + return panel +end + +""" + styled_toggle(pos; active = false) + +Toggle with a red frame when inactive and a green frame when active. +""" +function styled_toggle(pos; active = false) + return Toggle(pos, active = active, buttoncolor = RGBf(0.9, 0.9, 0.9), + framecolor_inactive = RGBf(0.5, 0.1, 0.1), framecolor_active = RGBf(0.1, 0.5, 0.1)) +end + +""" + toggle_list!(panel, names; first_row = 2, active = true) + +Adds a label and a toggle for each of `names` to `panel`, one per row starting at `first_row`. +Returns the toggles. +""" +function toggle_list!(panel, names; first_row = 2, active = true) + toggles = Toggle[] + for (i, name) in enumerate(names) + row = first_row + i - 1 + Label(panel[row, 1], String(name), fontsize = 14, halign = :left) + push!(toggles, Toggle(panel[row, 2], active = active)) + end + return toggles +end + +""" + visible_if(toggle) + +Observable that can be used as the `visible` attribute of a plot, to show it only if `toggle` is active. +""" +visible_if(toggle) = lift(identity, toggle.active) + +""" + add_logo!(pos, filename) + +Shows the image `filename` at the position `pos`, without axis decorations. +""" +function add_logo!(pos, filename) + logo_img = load(filename) + logo_axis = Axis(pos, aspect = DataAspect()) + image!(logo_axis, rotr90(logo_img)) + hide_axis!(logo_axis) + return logo_axis +end + +""" + hide_axis!(ax) + +Hides the decorations and all spines of `ax`. +""" +function hide_axis!(ax) + hidedecorations!(ax) + hidespines!(ax) + return ax +end diff --git a/src/Layout/main_layout.jl b/src/Layout/main_layout.jl new file mode 100644 index 0000000..a4691a0 --- /dev/null +++ b/src/Layout/main_layout.jl @@ -0,0 +1,29 @@ +# this is main_layout.jl +# It creates the figure and the basic structure of the GUI. +# Changes only occur in the plotting panel (and the plot controls). + +""" + fig, panels = create_main_layout(; size = (1000, 600)) + +Creates the figure and its main panels: + + | main_menu | pick_controls | pick_legend | logo | + | plot_controls | plot | +""" +function create_main_layout(; size = (1000, 600)) + fig = Figure(backgroundcolor = RGBf(0.98, 0.98, 0.98), size = size) + + panels = ( + main_menu = fig[1, 1] = GridLayout(), # menu panel + pick_controls = fig[1, 2] = GridLayout(), # picking controls + pick_legend = fig[1, 3] = GridLayout(), # legend of the picks + logo = fig[1, 4] = GridLayout(width = 250), # logo panel + plot_controls = fig[2, 1] = GridLayout(), # plot control panel + plot = fig[2, 2:4] = GridLayout(), # main plotting window + ) + + colsize!(fig.layout, 1, Fixed(300)) # set a fixed column size + rowsize!(fig.layout, 1, Fixed(80)) # set a fixed row size + + return fig, panels +end diff --git a/src/Picks/picking_interaction.jl b/src/Picks/picking_interaction.jl new file mode 100644 index 0000000..bb68e6a --- /dev/null +++ b/src/Picks/picking_interaction.jl @@ -0,0 +1,56 @@ +# this is picking_interaction.jl +# It defines how picks are added, deleted and dragged with the mouse, if picking is active: +# a + left click: add a pick +# d + left click: delete the pick under the mouse +# left click + drag: move a pick (not working yet) + +""" + connect_picking!(gui) + +Registers the mouse callbacks for picking in the figure of `gui`. +""" +function connect_picking!(gui) + fig = gui.fig + + on(events(fig).mousebutton, priority = 2) do event + (gui.pick_toggle.active[] && !isnothing(gui.ax_profile)) || return Consume(false) + event.button == Mouse.left || return Consume(false) + + if event.action == Mouse.press + plt, i = pick(fig) + if Keyboard.d in events(fig).keyboardstate + # Delete marker + deleteat!(gui.picks[], i) + notify(gui.picks) + println(" deleted pick") + return Consume(true) + elseif Keyboard.a in events(fig).keyboardstate + # Add marker + push!(gui.picks[], mouse_pick_position(gui)) + notify(gui.picks) + println(" added pick") + return Consume(true) + else + # Initiate drag --> this is not working yet + gui.dragging = plt == gui.pick_plot + gui.drag_index = i + return Consume(gui.dragging) + end + elseif event.action == Mouse.release + # Exit drag + gui.dragging = false + end + return Consume(false) + end + + on(events(fig).mouseposition, priority = 2) do mp + (gui.pick_toggle.active[] && gui.dragging) || return Consume(false) + gui.picks[][gui.drag_index] = mouse_pick_position(gui) + notify(gui.picks) + return Consume(true) + end + return nothing +end + +# position of the mouse in the profile axis, as a pick +mouse_pick_position(gui) = Point3f(mouseposition(gui.ax_profile)..., PICK_Z) diff --git a/src/Picks/picks_io.jl b/src/Picks/picks_io.jl new file mode 100644 index 0000000..b7d6862 --- /dev/null +++ b/src/Picks/picks_io.jl @@ -0,0 +1,90 @@ +# this is picks_io.jl +# It contains functions to convert, save and load picks. They don't depend on the GUI. +# +# Picks are saved as jld2 files, which contain: +# picks: named tuple with x, depth, lat and lon of each pick +# pick_info: name of the user who created the picks, date of creation and units +# profile_info: start and end lonlat of the profile + +# the z-value of the picks, which ensures that they are always plotted on top +const PICK_Z = 1000 + +""" + pick_table(picks, profile) + +Converts the picked points into a named tuple with `x`, `depth`, `lat` and `lon` of each +pick. The lat and lon of the picks are interpolated from the start and end points of the profile. +""" +function pick_table(picks, profile) + xtmp = [minimum(profile.VolData.fields.x_profile), maximum(profile.VolData.fields.x_profile)] + lontmp = [profile.start_lonlat[1], profile.end_lonlat[1]] + lattmp = [profile.start_lonlat[2], profile.end_lonlat[2]] + + interp_linear_lon = linear_interpolation(xtmp, lontmp) + interp_linear_lat = linear_interpolation(xtmp, lattmp) + + x_pick = [pick[1] for pick in picks] + y_pick = [pick[2] for pick in picks] + + return (x = x_pick, depth = y_pick, lat = interp_linear_lat(x_pick), lon = interp_linear_lon(x_pick)) +end + +""" + save_picks(filename, pick_data, profile; user_name = nothing) + +Saves the picks `pick_data` (see `pick_table`) that belong to `profile`. Picks can only be +saved as jld2 files for now. Returns `true` if the file was written. +""" +function save_picks(filename, pick_data, profile; user_name = nothing) + filetype = file_extension(filename) + if filetype == "jld2" + pick_info = (user_name = user_name, date = now(), units = (x = "km", depth = "km", lat = "deg", lon = "deg")) + profile_info = (start_lonlat = profile.start_lonlat, end_lonlat = profile.end_lonlat) + jldsave(filename; picks = pick_data, pick_info = pick_info, profile_info = profile_info) + println("... " * filename * " saved") + return true + elseif filetype == "csv" + println("Saving as csv is not implemented yet") + else + println("This is not a valid pick file format. Picks should be saved as jld2 files.") + end + return false +end + +""" + load_picks(filename) + +Loads a pick file created with `save_picks`. Returns `nothing` if this is not a valid pick file. +""" +function load_picks(filename) + filetype = file_extension(filename) + if filetype == "jld2" + data_picks = load(filename) + println(filename * " loaded") + return data_picks + else + # csv files are not implemented yet + println("This is not a valid pick file at the moment. Feel free to add this functionality :)") + return nothing + end +end + +""" + pick_points(pick_data; z = PICK_Z) + +Converts loaded picks (a named tuple with `x` and `depth`) into points that can be plotted and modified. +""" +pick_points(pick_data; z = PICK_Z) = [Point3f(x, depth, z) for (x, depth) in zip(pick_data.x, pick_data.depth)] + +""" + picks_belong_to_profile(data_picks, profile) + +Checks if the loaded picks were created on `profile`, by comparing the start and end points. +""" +function picks_belong_to_profile(data_picks, profile) + info = data_picks["profile_info"] + return info.start_lonlat == profile.start_lonlat && info.end_lonlat == profile.end_lonlat +end + +# everything after the last dot in the filename +file_extension(filename) = split(filename, ".")[end] diff --git a/src/Plotting/profile_plot.jl b/src/Plotting/profile_plot.jl new file mode 100644 index 0000000..735126d --- /dev/null +++ b/src/Plotting/profile_plot.jl @@ -0,0 +1,129 @@ +# this is profile_plot.jl +# It contains the functions that plot a vertical profile: +# topography on top, the profile data (field, surface and point data + picks) below + +""" + ax_topo, ax_profile, colorbar_panel = create_profile_axes!(panel) + +Creates two axes: one on top for topography, one directly below for the profile data, +and a panel for the colorbar and legends below them. +""" +function create_profile_axes!(panel) + ax_topo = Axis(panel[1, 1]) + ax_profile = Axis(panel[2:3, 1]) + colorbar_panel = panel[4, 1] = GridLayout(height = 100, tellheight = false, tellwidth = false) + + hide_axis!(ax_topo) # no axes labels and spines for the topo + linkxaxes!(ax_profile, ax_topo) # link the axes in the x-direction + + rowgap!(panel, 0) # no vertical space between topo and profile plot + rowsize!(panel, 1, Relative(0.2)) # make the topo plot take up 20% of the vertical space + return ax_topo, ax_profile, colorbar_panel +end + +""" + plot_topography!(ax, profile, title) + +Plots the topography of `profile`, with water in blue and rock in grey. The start and end +points of the profile are shown at the top. +""" +function plot_topography!(ax, profile, title) + x_topo, y_topo = topography(profile) + + # display the profile limits + text!(ax, minimum(x_topo), maximum(y_topo); text = string(round.(profile.start_lonlat, digits = 2)), align = (:left, :center), offset = (20, 0)) + text!(ax, maximum(x_topo), maximum(y_topo); text = string(round.(profile.end_lonlat, digits = 2)), align = (:right, :center), offset = (-20, 0)) + ax.title = title # display the profile name in the title + + base = y_topo .* 0 .+ minimum(y_topo) + band!(ax, x_topo, base, y_topo .* 0, color = :skyblue2) # water level + lines!(ax, x_topo, y_topo, color = :black) + band!(ax, x_topo, base, y_topo, color = :grey70) # fill the topography to base level to denote rock + return nothing +end + +""" + plot_surfaces!(ax, profile, names, toggles) + +Plots the surface data sets `names` as lines, which are only visible if the corresponding toggle is active. +Returns the plots and labels for the legend. +""" +function plot_surfaces!(ax, profile, names, toggles) + plots = Vector{Lines{Tuple{Vector{Point{2, Float64}}}}}() + labels = Vector{String}() + for (name, toggle) in zip(names, toggles) + x_surf, y_surf = surface_line(profile, name) + lines!(ax, x_surf, y_surf, color = :white, linewidth = 3, visible = visible_if(toggle)) # white background line + push!(plots, lines!(ax, x_surf, y_surf, visible = visible_if(toggle))) + push!(labels, String(name)) + end + return plots, labels +end + +""" + plot_points!(ax, profile, names, toggles) + +Plots the point data sets `names`, which are only visible if the corresponding toggle is active. +Returns the plots and labels for the legend. +""" +function plot_points!(ax, profile, names, toggles) + plots = Vector{Scatter{Tuple{Vector{Point{2, Float64}}}}}() + labels = Vector{String}() + for (name, toggle) in zip(names, toggles) + x_point, y_point = point_coordinates(profile, name) + push!(plots, scatter!(ax, x_point, y_point, strokecolor = :black, strokewidth = 1, markersize = 5, visible = visible_if(toggle))) + push!(labels, String(name)) + end + return plots, labels +end + +""" + plot_profile!(gui, value, field_name, surf_names, surf_toggles, point_names, point_toggles) + +Plots the loaded profile of `gui`: topography, the field data `value` (an Observable), +surface and point data, the picks, the colorbar and the legends. +""" +function plot_profile!(gui, value, field_name, surf_names, surf_toggles, point_names, point_toggles) + profile = gui.profile + ax_topo, ax, colorbar_panel = create_profile_axes!(gui.panels.plot) + gui.ax_topo, gui.ax_profile = ax_topo, ax + + plot_topography!(ax_topo, profile, gui.profile_file) + println("Topography plotted") + + # set the axis limits to the profile limits + x, y = profile_grid(profile) + ax.limits = (minimum(x), maximum(x), minimum(y), maximum(y)) + + gui.heatmap = heatmap!(ax, x, y, value, colormap = Reverse(:seismic)) + println("Volume data plotted") + + surf_plots, surf_labels = plot_surfaces!(ax, profile, surf_names, surf_toggles) + println("Surface data plotted") + + point_plots, point_labels = plot_points!(ax, profile, point_names, point_toggles) + println("Point data plotted") + + # plot the picks + gui.pick_plot = scatter!(ax, gui.picks, color = :white, markersize = 15, strokewidth = 2, strokecolor = :black) + + # colorbar and legends + Colorbar(colorbar_panel[1, 1], gui.heatmap, vertical = false, width = 300) + gui.colorbar_label = Label(colorbar_panel[2, 1], String(field_name)) + Legend(colorbar_panel[1:2, 2:3], surf_plots, surf_labels, "Moho data", valign = :top, framevisible = false, nbanks = 2) + Legend(colorbar_panel[1:2, 4], point_plots, point_labels, "Seismicity", valign = :top, framevisible = false) + return nothing +end + +""" + plot_fixed_picks!(gui, pick_data) + +Adds picks that cannot be modified (e.g. from another user) as a dotted line to the profile. +They are only visible if the compare toggle is active. +""" +function plot_fixed_picks!(gui, pick_data) + visible = visible_if(gui.compare_toggle) + lines!(gui.ax_profile, pick_data.x, pick_data.depth, color = :white, linewidth = 3, visible = visible) # white background line + lines!(gui.ax_profile, pick_data.x, pick_data.depth, color = :red, linewidth = 2, linestyle = (:dot, :dense), visible = visible) + return nothing +end diff --git a/src/utils.jl b/src/utils.jl deleted file mode 100644 index 6ada73e..0000000 --- a/src/utils.jl +++ /dev/null @@ -1,39 +0,0 @@ -# this is utils.jl -# it collects all the utility functions used in the GUI that do not belong to a specific category - -# function insert_logo!(panel) -# logo_img = load("./assets/AdA_GeometryPicker_logo_tr.png") -# ax_logo = Axis(panel[1,1],aspect = DataAspect()) -# image!(ax_logo,rotr90(logo_img)) -# #ax_logo.yreversed = true -# hidedecorations!(ax_logo) -# ax_logo.leftspinevisible=false -# ax_logo.rightspinevisible=false -# ax_logo.topspinevisible=false -# ax_logo.bottomspinevisible=false -# return nothing -# end - -# function insert_logo_GMG!(panel) -# img = load("./assets/GMG_Logo_new.png") -# ax_logo = Axis(panel[1,1],aspect = DataAspect()) -# image!(ax_logo,img') -# ax_logo.yreversed = true -# hidedecorations!(ax_logo) -# ax_logo.leftspinevisible=false -# ax_logo.rightspinevisible=false -# ax_logo.topspinevisible=false -# ax_logo.bottomspinevisible=false -# return nothing -# end - -# # function to clear a GridLayout unit (I call it panel) by recursively deleting all its children -# function clear_panel!(panel::GridLayout) -# for child in panel.children -# if child isa GridLayout -# clear_panel!(child) -# end -# delete!(panel, child) -# end -# return nothing -# end \ No newline at end of file diff --git a/test/runtests.jl b/test/runtests.jl index e6ae184..a8507f6 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -48,6 +48,8 @@ end @test ["Load Profile...", "Load Picks...", "Save Picks...", "Save Screenshot..."] ⊆ menu.options[] end + include("test_data.jl") + if GUI_TESTS include("test_gui.jl") else diff --git a/test/test_data.jl b/test/test_data.jl new file mode 100644 index 0000000..9502855 --- /dev/null +++ b/test/test_data.jl @@ -0,0 +1,39 @@ +# Tests of the functions that don't need a GUI (and thus OpenGL) +using AdriaArrayGeometryPicker, GLMakie, JLD2, Test + +const AGP = AdriaArrayGeometryPicker + +@testset "Profile data" begin + P = create_synthetic_profile() + @test AGP.volume_field_names(P) == [:dVs, :dVp] + @test AGP.surface_names(P) == [:Moho] + @test AGP.point_names(P) == [:Seismicity] + + x, y = AGP.profile_grid(P) + @test size(AGP.volume_slice(P, :dVs)) == (length(x), length(y)) + @test issorted(x) && minimum(y) ≈ -300 && maximum(y) ≈ 0 + + x_topo, y_topo = AGP.topography(P) + @test length(x_topo) == length(y_topo) && eltype(y_topo) <: Real + + @test AGP.finite_extrema([1.0, NaN, -2.0]) == (-2.0, 1.0) +end + +@testset "Picks" begin + P = create_synthetic_profile() + points = [Point3f(100, -40, 1000), Point3f(300, -80, 1000)] + pick_data = AGP.pick_table(points, P) + @test pick_data.x ≈ [100, 300] && pick_data.depth ≈ [-40, -80] + @test all(pick_data.lat .≈ 45.0) && issorted(pick_data.lon) + + dir = mktempdir() + fn = joinpath(dir, "picks.jld2") + @test AGP.save_picks(fn, pick_data, P; user_name = "test") + @test !AGP.save_picks(joinpath(dir, "picks.csv"), pick_data, P) # not implemented yet + + data_picks = AGP.load_picks(fn) + @test data_picks["pick_info"].user_name == "test" + @test AGP.picks_belong_to_profile(data_picks, P) + @test AGP.pick_points(data_picks["picks"]) ≈ points + @test isnothing(AGP.load_picks(joinpath(dir, "picks.txt"))) +end diff --git a/test/test_gui.jl b/test/test_gui.jl index aa1dfcc..ee243a1 100644 --- a/test/test_gui.jl +++ b/test/test_gui.jl @@ -28,6 +28,8 @@ end fig = start_AdA_Picker() @test fig isa Figure + gui = AGP.current_gui() + @test gui.fig === fig menus = find_blocks(fig, Menu) main_menu = only(filter(m -> "Load Profile..." in m.options[], menus)) @@ -43,7 +45,7 @@ end # data axis = the one with the heatmap ax = only(filter(ax -> !isempty(find_plots(ax, Heatmap)), find_blocks(fig, Axis))) - @test ax === AGP.ax1 + @test ax === gui.ax_profile @test length(find_plots(ax, Scatter)) == 2 # picks + seismicity # one toggle per surface (only Moho, topography is plotted separately) and point data set @@ -55,7 +57,7 @@ end @test field_menu.options[] == [:dVs, :dVp] end - ax = AGP.ax1 + ax = gui.ax_profile hm = only(find_plots(ax, Heatmap)) @testset "Change field and colormap" begin @@ -76,7 +78,7 @@ end # add picks, as done by the mouse callbacks x_picks = [100.0, 300.0, 500.0] depth_picks = [-40.0, -80.0, -120.0] - AGP.picks[] = [Point3f(x, d, 1000) for (x, d) in zip(x_picks, depth_picks)] + gui.picks[] = [Point3f(x, d, 1000) for (x, d) in zip(x_picks, depth_picks)] AGP.SAVE_FILE[] = _ -> picks_file select!(main_menu, "Save Picks...") @@ -99,15 +101,17 @@ end @test data["profile_info"].end_lonlat == (19.0, 45.0) # picking has to keep working after saving - @test AGP.picks isa Observable + push!(gui.picks[], Point3f(600, -150, 1000)) + notify(gui.picks) + @test length(gui.picks[]) == 4 # load them back as modifiable picks - AGP.picks[] = Point3f[] + gui.picks[] = Point3f[] AGP.PICK_FILE[] = _ -> picks_file select!(main_menu, "Load Picks...") - @test wait_until(() -> length(AGP.picks[]) == 3) - @test [p[1] for p in AGP.picks[]] ≈ x_picks - @test [p[2] for p in AGP.picks[]] ≈ depth_picks + @test wait_until(() -> length(gui.picks[]) == 3) + @test [p[1] for p in gui.picks[]] ≈ x_picks + @test [p[2] for p in gui.picks[]] ≈ depth_picks # and as fixed picks for comparison, which adds two lines to the profile nlines = length(find_plots(ax, Lines)) @@ -122,4 +126,12 @@ end end GLMakie.closeall() + + @testset "Start with a profile" begin + fig = start_AdA_Picker(data = profile_file) + gui = AGP.current_gui() + @test gui.profile_file == "test_profile.jld2" + @test length(find_plots(gui.ax_profile, Heatmap)) == 1 + GLMakie.closeall() + end end