diff --git a/ClimaExplorer/ClimaExplorer.jl b/ClimaExplorer/ClimaExplorer.jl new file mode 100644 index 00000000..c3ae6e2e --- /dev/null +++ b/ClimaExplorer/ClimaExplorer.jl @@ -0,0 +1,79 @@ +module ClimaExplorer + +import ClimaAnalysis + +import Bonito: + Observable, + App, + Slider, + Dropdown, + DOM, + Asset, + TextField, + Card, + Grid, + Label, + Styles, + Centered, + Button, + on +import WGLMakie +import GeoMakie + +include("layouts.jl") + +function _logo_img() + + # TODO: The logo does not resize correctly + imstyle = Styles( + "display" => :block, + "position" => :relative, + "width" => "180px", + "max-width" => :none, # needs to be set so it's not overwritten by others + ) + img = DOM.a( + href = "https://www.github.com/CliMA/ClimaAnalysis.jl", + DOM.img(; + src = Asset(joinpath("assets", "logo-full.svg")), + style = imstyle, + ), + ) + return img +end + + + +""" + BonitoApp(path) + +Return a `Bonito` `App` for data at `path`. +""" +function BonitoApp(path) + app = App(; title = "ClimaExplorer") do + + # Text field to change the path + # + # TODO: width = 98% is pretty random. We should find a truly responsive with. + path_obs = TextField(path, style = Styles("width" => "98%")) + + # Most of the logic is implemented in the layout.jl file. Every time path_obs + # change, the body of the web page is redrawn. This allows us to handle invalid + # states too. + body_layout = map(layout, path_obs) + + img = _logo_img() + input_field = Centered( + Grid( + "Insert the path of your output data and press ENTER", + path_obs, + ), + ) + + grid = Grid(Card(input_field), Card(img); columns = "90% 10%") + + return Grid(grid, body_layout) + end + return app +end + +end diff --git a/ClimaExplorer/Project.toml b/ClimaExplorer/Project.toml new file mode 100644 index 00000000..dc1039a8 --- /dev/null +++ b/ClimaExplorer/Project.toml @@ -0,0 +1,14 @@ +name = "ClimaExplorer" +authors = ["Gabriele Bozzola "] +version = "0.0.1" + +[deps] +Bonito = "824d6782-a2ef-11e9-3a09-e5662e0c26f8" +ClimaAnalysis = "29b5916a-a76c-4e73-9657-3c8fd22e65e6" +GeoMakie = "db073c08-6b98-4ee5-b6a4-5efafb3259c6" +WGLMakie = "276b4fcb-3e11-5398-bf8b-a0c2d153d008" + +[compat] +Bonito = "3" +ClimaAnalysis = "0.5.5" +GeoMakie = "0.6.5, 0.7" diff --git a/ClimaExplorer/README.md b/ClimaExplorer/README.md new file mode 100644 index 00000000..50eb66c5 --- /dev/null +++ b/ClimaExplorer/README.md @@ -0,0 +1,10 @@ +

+ + + + Shows the logo of ClimaExplorer, a compass styled with Julia's colors +
+ ClimaExplorer +

+ +`ClimaExplorer` is a tool to explore simulation outputs interactively. Please, refer to the ClimaAnalysis [documentation](https://clima.github.io/ClimaAnalysis.jl/) for more information. diff --git a/ClimaExplorer/assets/interactive.css b/ClimaExplorer/assets/interactive.css new file mode 100644 index 00000000..3bbcc096 --- /dev/null +++ b/ClimaExplorer/assets/interactive.css @@ -0,0 +1,12 @@ +.container { + display: flex; +} +.column { + padding: 10px; +} +.left { + flex: 0 1 20%; /* Flex-grow: 0, Flex-shrink: 1, Flex-basis: 20% */ +} +.right { + flex: 1; +} diff --git a/ClimaExplorer/assets/logo-full.svg b/ClimaExplorer/assets/logo-full.svg new file mode 100644 index 00000000..c4463c48 --- /dev/null +++ b/ClimaExplorer/assets/logo-full.svg @@ -0,0 +1,79 @@ + + + + + + + + + + ClimaExplorer + diff --git a/ClimaExplorer/assets/logo-small-white.svg b/ClimaExplorer/assets/logo-small-white.svg new file mode 100644 index 00000000..da5888d6 --- /dev/null +++ b/ClimaExplorer/assets/logo-small-white.svg @@ -0,0 +1,69 @@ + + + + + + + + + + diff --git a/ClimaExplorer/assets/logo-small.svg b/ClimaExplorer/assets/logo-small.svg new file mode 100644 index 00000000..34f7168c --- /dev/null +++ b/ClimaExplorer/assets/logo-small.svg @@ -0,0 +1,68 @@ + + + + + + + + + + diff --git a/ClimaExplorer/explorer.jl b/ClimaExplorer/explorer.jl new file mode 100644 index 00000000..08196457 --- /dev/null +++ b/ClimaExplorer/explorer.jl @@ -0,0 +1,15 @@ +include(joinpath(@__DIR__, "ClimaExplorer.jl")) + +import Bonito: route!, wait, Server + +path = length(ARGS) >= 1 ? ARGS[1] : "." + +# Create server +IPa = "127.0.0.1" +port = 8080 +server = Server(IPa, port; proxy_url = "http://localhost:8080") + +app = ClimaExplorer.BonitoApp(path) +route!(server, "/" => app) +println(server) +wait(server) diff --git a/ClimaExplorer/layouts.jl b/ClimaExplorer/layouts.jl new file mode 100644 index 00000000..2cf347da --- /dev/null +++ b/ClimaExplorer/layouts.jl @@ -0,0 +1,259 @@ +using WGLMakie # needed for @lift. Maybe this could be somewhere else. + +""" + layout(path) + +Construct most of the webapp (and do error handling). +""" +function layout(path) + if isdir(path) + simdir = ClimaAnalysis.SimDir(path) + if isempty(simdir) + return Card( + DOM.h4("⚠️ $path does not contain ClimaDiagnostics data"), + ) + else + return layout_valid(simdir) + end + else + return layout_invalid(path) + end +end + +function layout_invalid(path) + return Card(DOM.h4("⚠️ $path is not a valid path!")) +end + +function _prepare_menus(simdir::ClimaAnalysis.SimDir) + variables = ClimaAnalysis.available_vars(simdir) |> collect |> sort + vars_menu = Dropdown(variables) + reductions = + ClimaAnalysis.available_reductions( + simdir; + short_name = vars_menu.value.val, + ) |> collect + reductions_menu = Dropdown(reductions) + periods = + ClimaAnalysis.available_periods( + simdir; + short_name = vars_menu.value.val, + reduction = reductions_menu.value.val, + ) |> collect + periods_menu = Dropdown(periods) + return vars_menu, reductions_menu, periods_menu +end + +""" + layout_layout(simdir::ClimaAnalysis.SimDir) + +Construct most of the webapp, knowing that the `simdir` is valid. +""" +function layout_valid(simdir::ClimaAnalysis.SimDir) + # Prepare the menus. + vars_menu, reductions_menu, periods_menu = _prepare_menus(simdir) + + on(vars_menu.value) do short_name + reductions = + ClimaAnalysis.available_reductions(simdir; short_name) |> collect + # setindex! triggers on(reductions_menu.value) + setindex!(reductions_menu.options, reductions) + end + + on(reductions_menu.value) do reduction + periods = + ClimaAnalysis.available_periods( + simdir; + short_name = vars_menu.value.val, + reduction, + ) |> collect + # setindex! triggers on(periods_menu.value) + setindex!(periods_menu.options, periods) + end + + # We have to do this little trick to support Observable vars because + # ClimaAnalysis.get() returns OutputVars with different types, so we + # cannot simply map(observable) because it does not work when types are + # not uniform. We just have to unpack the value when using the var + generic_ref = Any[get( + simdir; + short_name = vars_menu.value.val, + reduction = reductions_menu.value.val, + period = periods_menu.value.val, + )] + var = Observable(generic_ref) + + on(periods_menu.value) do period + short_name = vars_menu.value.val + reduction = reductions_menu.value.val + setindex!(var, [get(simdir; short_name, reduction = reduction, period)]) + end + + header_dom = Card( + Grid( + Centered(DOM.div("Short name:", vars_menu)), + Centered(DOM.div("Reduction:", reductions_menu)), + Centered(DOM.div("Period:", periods_menu)), + columns = "1fr 1fr 1fr", + ), + ) + + # This is the main body. It contains the side menu and the figure(s). + content_dom = map(var) do var_complete + content_layout(var_complete[]) + end + return DOM.div(header_dom, content_dom) +end + +""" + content_layout(simdir::ClimaAnalysis.OutputVar) + +Prepare the body of the webapp. + +This function dispatches the layout so that boxes/sphere/columns/surfaces and so +on can have their customize layout. +""" +function content_layout(var_complete::ClimaAnalysis.OutputVar) + # TODO: Add other content_layouts (ie, for box and column) + if ClimaAnalysis.has_altitude(var_complete) + return sphere_content_layout(var_complete) + else + return spherical_shell_content_layout(var_complete) + end +end + + +""" + play_button(time_slider, var_complete::ClimaAnalysis.OutputVar) + +Add a button that steps the `time_slider` + +TODO: Add a pause button +""" +function play_button(time_slider, var_complete::ClimaAnalysis.OutputVar) + play_button = Button("▶") + on(play_button.value) do _ + play_times = filter( + t -> t >= time_slider.value.val, + ClimaAnalysis.times(var_complete), + ) + for t in play_times + setindex!(time_slider, t) + end + end + return play_button +end + +""" + time_units(var_complete::ClimaAnalysis.OutputVar) + +Return the units of time in `var_complete`. +""" +function time_units(var_complete::ClimaAnalysis.OutputVar) + return var_complete.dim_attributes[ClimaAnalysis.time_name(var_complete)]["units"] +end + +""" + altitude_units(var_complete::ClimaAnalysis.OutputVar) + +Return the units of altitude in `var_complete`. +""" +function altitude_units(var_complete::ClimaAnalysis.OutputVar) + return var_complete.dim_attributes[ClimaAnalysis.altitude_name(var_complete)]["units"] +end + +""" + sphere_content_layout(var::ClimaAnalysis.OutputVar) + +Set up the page content_layout and content for the given var, assuming it is a 3D sphere. + +A sphere content_layout has two columns: + +Left column +- A slider for the time +- A play button +- A slider for the level + +Right column: +- Figure with the 2D map at given time and level +""" +function sphere_content_layout(var_complete::ClimaAnalysis.OutputVar) + time_slider = Slider(ClimaAnalysis.times(var_complete)) + level_slider = Slider(ClimaAnalysis.altitudes(var_complete)) + fig = WGLMakie.Figure(size = (1200, 760), fontsize = 30) + ax = GeoMakie.GeoAxis(fig[1, 1]) # note: we can add back dynamic titles later, should be easy + + on(time_slider) do _ + # Trigger level_slider + setindex!(level_slider, level_slider.value.val) + end + + alt_name = ClimaAnalysis.altitude_name(var_complete) + + var = map(level_slider) do level + # We need this because variables might have different altitude dimensions (e.g., z + # vs z_reference) + kwargs = (Symbol(alt_name) => level,) + ClimaAnalysis.slice( + var_complete; + time = time_slider.value.val, + kwargs..., + ) + end + + ClimaAnalysis.Visualize.contour2D_on_globe!(fig, var, ax) + + controls = Centered( + Grid( + DOM.div("Time: ", time_slider.value, " ", time_units(var_complete)), + time_slider, + play_button(time_slider, var_complete), + DOM.div("Altitude: ", level_slider.value, " ", altitude_units(var_complete)), + level_slider; + ), + ) + + return Grid( + Card(controls), + Card(Centered(DOM.div(fig))); + columns = "1fr 5fr", + ) +end + + +""" + spherical_shell_content_layout(var::ClimaAnalysis.OutputVar) + +Set up the page content_layout and content for the given var, assuming it is a (2D) spherical shell. + +A spherical sphere content_layout has two columns: + +Left column +- A slider for the time +- A play button + +Right column: +- Figure with the 2D map at given time and level +""" +function spherical_shell_content_layout(var_complete::ClimaAnalysis.OutputVar) + time_slider = Slider(ClimaAnalysis.times(var_complete)) + fig = WGLMakie.Figure(size = (1200, 760), fontsize = 30) + ax = GeoMakie.GeoAxis(fig[1, 1]) + var = map(time_slider) do time + ClimaAnalysis.slice(var_complete; time) + end + ClimaAnalysis.Visualize.contour2D_on_globe!(fig, var, ax) + + controls = Centered( + Grid( + DOM.div("Time: ", time_slider.value, " $(time_units(var_complete))"), + time_slider, + play_button(time_slider, var_complete), + ), + ) + + return Grid( + Card(controls), + Card(Centered(DOM.div(fig))); + columns = "1fr 5fr", + ) +end diff --git a/ClimaExplorer/test/sample_data_sphere/hfes_1d_average.nc b/ClimaExplorer/test/sample_data_sphere/hfes_1d_average.nc new file mode 100644 index 00000000..5d317e4c Binary files /dev/null and b/ClimaExplorer/test/sample_data_sphere/hfes_1d_average.nc differ diff --git a/ClimaExplorer/test/sample_data_sphere/orog_inst.nc b/ClimaExplorer/test/sample_data_sphere/orog_inst.nc new file mode 100644 index 00000000..e32888a8 Binary files /dev/null and b/ClimaExplorer/test/sample_data_sphere/orog_inst.nc differ diff --git a/ClimaExplorer/test/sample_data_sphere/rhoa_1d_average.nc b/ClimaExplorer/test/sample_data_sphere/rhoa_1d_average.nc new file mode 100644 index 00000000..8f4625d6 Binary files /dev/null and b/ClimaExplorer/test/sample_data_sphere/rhoa_1d_average.nc differ diff --git a/ClimaExplorer/test/sample_data_sphere/ta_1d_average.nc b/ClimaExplorer/test/sample_data_sphere/ta_1d_average.nc new file mode 100644 index 00000000..7790c86a Binary files /dev/null and b/ClimaExplorer/test/sample_data_sphere/ta_1d_average.nc differ diff --git a/ClimaExplorer/test/sample_data_sphere/ta_1d_inst.nc b/ClimaExplorer/test/sample_data_sphere/ta_1d_inst.nc new file mode 100644 index 00000000..bd36188e Binary files /dev/null and b/ClimaExplorer/test/sample_data_sphere/ta_1d_inst.nc differ diff --git a/ClimaExplorer/test/sample_data_sphere/ts_1d_average.nc b/ClimaExplorer/test/sample_data_sphere/ts_1d_average.nc new file mode 100644 index 00000000..5532e72b Binary files /dev/null and b/ClimaExplorer/test/sample_data_sphere/ts_1d_average.nc differ diff --git a/ClimaExplorer/test/sample_data_sphere/ts_1d_max.nc b/ClimaExplorer/test/sample_data_sphere/ts_1d_max.nc new file mode 100644 index 00000000..665f547e Binary files /dev/null and b/ClimaExplorer/test/sample_data_sphere/ts_1d_max.nc differ diff --git a/ClimaExplorer/test/sample_data_sphere/ts_1d_min.nc b/ClimaExplorer/test/sample_data_sphere/ts_1d_min.nc new file mode 100644 index 00000000..0851602d Binary files /dev/null and b/ClimaExplorer/test/sample_data_sphere/ts_1d_min.nc differ diff --git a/ClimaExplorer/test/sample_data_sphere/ts_2d_max.nc b/ClimaExplorer/test/sample_data_sphere/ts_2d_max.nc new file mode 100644 index 00000000..5532e72b Binary files /dev/null and b/ClimaExplorer/test/sample_data_sphere/ts_2d_max.nc differ diff --git a/ClimaExplorer/test/sample_data_sphere/ua_1d_average.nc b/ClimaExplorer/test/sample_data_sphere/ua_1d_average.nc new file mode 100644 index 00000000..279e9d28 Binary files /dev/null and b/ClimaExplorer/test/sample_data_sphere/ua_1d_average.nc differ diff --git a/ClimaExplorer/test/sample_data_sphere/ua_1d_inst.nc b/ClimaExplorer/test/sample_data_sphere/ua_1d_inst.nc new file mode 100644 index 00000000..b25acdd5 Binary files /dev/null and b/ClimaExplorer/test/sample_data_sphere/ua_1d_inst.nc differ diff --git a/ClimaExplorer/test/sample_data_sphere/wa_1d_inst.nc b/ClimaExplorer/test/sample_data_sphere/wa_1d_inst.nc new file mode 100644 index 00000000..123139d9 Binary files /dev/null and b/ClimaExplorer/test/sample_data_sphere/wa_1d_inst.nc differ diff --git a/Project.toml b/Project.toml index 143fbc5c..df58072a 100644 --- a/Project.toml +++ b/Project.toml @@ -14,10 +14,12 @@ Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" [weakdeps] CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0" GeoMakie = "db073c08-6b98-4ee5-b6a4-5efafb3259c6" +Makie = "ee78f7c6-11fb-53f2-987a-cfe4a2b5a57a" +WGLMakie = "276b4fcb-3e11-5398-bf8b-a0c2d153d008" [extensions] CairoMakieExt = "CairoMakie" -GeoMakieExt = "GeoMakie" +GeoMakieExt = ["GeoMakie", "WGLMakie"] [compat] Aqua = "0.8" @@ -38,7 +40,9 @@ julia = "1.9" [extras] Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" +Makie = "ee78f7c6-11fb-53f2-987a-cfe4a2b5a57a" CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0" +WGLMakie = "276b4fcb-3e11-5398-bf8b-a0c2d153d008" Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" ExplicitImports = "7d51a73a-1435-4ff3-83d9-f097790105c7" GeoMakie = "db073c08-6b98-4ee5-b6a4-5efafb3259c6" diff --git a/README.md b/README.md index 2c0d91dc..fe5413e1 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,18 @@ Check out the [documentation](https://CliMA.github.io/ClimaAnalysis.jl) for more - Extract dimensions from conventional names (e.g., `times`) - Interpolate output variables onto arbitrary points - Reinterpolate output variables onto pressure levels +- Interactively explore the content of a simulation with `ClimaExplorer` + +## ClimaExplorer + +`ClimaExplorer` is a webapp built with +[Bonito.jl](https://github.com/SimonDanisch/Bonito.jl) to interactively inspect +the output of a simulation with `ClimaAnalysis`. + +Here is a gif demonstrating an early version of `ClimaExplorer`: +![ClimaExplorer](./docs/src/assets/climaexplorer.gif) +The grains in the gif should be taken as indication that current versions of +`ClimaExplorer` might be more polished and advanced. ## ClimaAnalysis.jl Developer Guidelines diff --git a/docs/make.jl b/docs/make.jl index 0b2ffc18..13f7ed82 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -29,6 +29,7 @@ makedocs(; checkdocs = :exports, pages = [ "Home" => "index.md", + "Explorer" => "explorer.md", "APIs" => "api.md", "How do I?" => "howdoi.md", ], diff --git a/docs/src/assets/climaexplorer.gif b/docs/src/assets/climaexplorer.gif new file mode 100644 index 00000000..6915fa65 Binary files /dev/null and b/docs/src/assets/climaexplorer.gif differ diff --git a/docs/src/assets/climaexplorer.webm b/docs/src/assets/climaexplorer.webm new file mode 100644 index 00000000..c59fc6b6 Binary files /dev/null and b/docs/src/assets/climaexplorer.webm differ diff --git a/docs/src/explorer.md b/docs/src/explorer.md new file mode 100644 index 00000000..36b51cab --- /dev/null +++ b/docs/src/explorer.md @@ -0,0 +1,61 @@ +`ClimaExplorer` +============= + +`ClimaExplorer` is an interactive utility to visualize the output of `CliMA` +simulations. `ClimaExplorer` uses `ClimaAnalysis.jl` and `Bonito.jl` to start a +local server and dynamically serve plots. + +How to install +=============== + +To install `ClimaExplorer`, first clone the `ClimaAnalysis.jl` repository +```sh +git clone https://github.com/CliMA/ClimaAnalysis.jl.git +``` +Then, instantiate the `climaexplorer` environment +```sh +julia --project=ClimaAnalysis.jl/ClimaExplorer -e 'using Pkg; Pkg.instantiate()' +``` + +How to use +========== + +Once you installed `ClimaExplorer`, you can start the server with +```sh +julia --project=ClimaAnalysis.jl/ClimaExplorer ClimaAnalysis.jl/ClimaExplorer/explorer.jl +``` +This might take a while. + +You can also pass a path as an argument to `explorer.jl`. This will start `ClimaExplorer` with +that folder (by default, the current working directory is used). + +If everything went correctly, you should see a message like +``` +Server: + isrunning: true + listen_url: http://localhost:8080 + online_url: http://localhost:8080 + http routes: 1 + / => App + websocket routes: 0 +``` +This means that the server has started. Visit `http://localhost:8080` to start +inspecting the output. Note that the port number `8080` might be different. +To close the server, press `Ctrl-C`. + +How to use on remote clusters +============================= + +Often data resides on remote clusters. You can run `ClimaExplorer` on the +machine where the data lives and use an ssh tunnel to access the server from +your computer. + +To do so, first start the server on the remote cluster (as described above). +Next, on your local machine, forward the remote port: +```bash +ssh -f username@host -L 8080:localhost:8080 -N +``` +instead of `username@host`, put your `usarename` and `hostname` (e.g., +`pippo@login.hpc.caltech.edu`) + +Next, open your browser and access `ClimaExplorer` visiting `localhost:8080`. diff --git a/ext/GeoMakieExt.jl b/ext/GeoMakieExt.jl index 0e77de68..60aa68b7 100644 --- a/ext/GeoMakieExt.jl +++ b/ext/GeoMakieExt.jl @@ -4,12 +4,13 @@ import GeoMakie import GeoMakie: Makie import ClimaAnalysis import ClimaAnalysis: Visualize +using WGLMakie # this is needed for @lift MakiePlace = Union{Makie.Figure, Makie.GridLayout} function _geomakie_plot_on_globe!( place::MakiePlace, - var::ClimaAnalysis.OutputVar; + var; p_loc = (1, 1), plot_coastline = true, plot_colorbar = true, @@ -66,6 +67,65 @@ function _geomakie_plot_on_globe!( end end +# Method for Observable var +function _geomakie_plot_on_globe!( + place::MakiePlace, + var, + ax; + p_loc = (1, 1), + plot_coastline = true, + plot_colorbar = true, + more_kwargs = Dict( + :plot => Dict(), + :cb => Dict(), + :axis => Dict(), + :coast => Dict(:color => :black), + ), + plot_fn = Makie.surface!, +) + length(var[].dims) == 2 || error("Can only plot 2D variables") + + lon_name = "" + lat_name = "" + + for dim in var[].index2dim + if dim in ClimaAnalysis.Var.LONGITUDE_NAMES + lon_name = dim + elseif dim in ClimaAnalysis.Var.LATITUDE_NAMES + lat_name = dim + else + error("$dim is neither longitude nor latitude") + end + end + + lon = var[].dims[lon_name] + lat = var[].dims[lat_name] + + units = var[].attributes["units"] + short_name = var[].attributes["short_name"] + colorbar_label = "$short_name [$units]" + + axis_kwargs = get(more_kwargs, :axis, Dict()) + plot_kwargs = get(more_kwargs, :plot, Dict()) + cb_kwargs = get(more_kwargs, :cb, Dict()) + coast_kwargs = get(more_kwargs, :coast, Dict(:color => :black)) + + title = get(axis_kwargs, :title, var[].attributes["long_name"]) + + plot = plot_fn(ax, lon, lat, @lift($var.data); plot_kwargs...) + plot_coastline && Makie.lines!(ax, GeoMakie.coastlines(); coast_kwargs...) + + if plot_colorbar + p_loc_cb = Tuple([p_loc[1], p_loc[2] + 1]) + Makie.Colorbar( + place[p_loc_cb...], + plot, + label = colorbar_label; + cb_kwargs..., + ) + end +end + """ heatmap2D_on_globe!(fig::CairoMakie.Figure, var::ClimaAnalysis.OutputVar; @@ -130,6 +190,33 @@ function Visualize.heatmap2D_on_globe!( ) end +# method for Observable +function Visualize.heatmap2D_on_globe!( + place::MakiePlace, + var, + ax; + p_loc = (1, 1), + plot_coastline = true, + plot_colorbar = true, + more_kwargs = Dict( + :plot => Dict(), + :cb => Dict(), + :axis => Dict(), + :coast => Dict(:color => :black), + ), +) + return _geomakie_plot_on_globe!( + place, + var, + ax; + p_loc, + plot_coastline, + plot_colorbar, + more_kwargs, + plot_fn = Makie.surface!, + ) +end + """ contours2D_on_globe!(fig::CairoMakie.Figure, var::ClimaAnalysis.OutputVar; @@ -196,4 +283,31 @@ function Visualize.contour2D_on_globe!( ) end +# Method for Observable +function Visualize.contour2D_on_globe!( + place::MakiePlace, + var, + ax; + p_loc = (1, 1), + plot_coastline = true, + plot_colorbar = true, + more_kwargs = Dict( + :plot => Dict(), + :cb => Dict(), + :axis => Dict(), + :coast => Dict(:color => :black), + ), +) + _geomakie_plot_on_globe!( + place, + var, + ax; + p_loc, + plot_coastline, + plot_colorbar, + more_kwargs, + plot_fn = Makie.contourf!, + ) +end + end diff --git a/ext/WGLMakieExt.jl b/ext/WGLMakieExt.jl new file mode 100644 index 00000000..42513fb3 --- /dev/null +++ b/ext/WGLMakieExt.jl @@ -0,0 +1,549 @@ +module WGLMakieExt + +import WGLMakie +import ClimaAnalysis +import ClimaAnalysis: Visualize + +MakiePlace = Union{WGLMakie.Figure, WGLMakie.GridLayout} + +""" + heatmap2D!(fig::WGLMakie.Figure, + var::ClimaAnalysis.OutputVar; + p_loc = (1,1), + more_kwargs) + heatmap2D!(grid_layout::WGLMakie.GridLayout, + var::ClimaAnalysis.OutputVar; + p_loc = (1,1), + more_kwargs) + + +Plot a heatmap of the given 2D `var`iable in the given place and location. +The place can be a `Figure` or a `GridLayout`. + +The plot comes with labels, units, and a colorbar. + +This function assumes that the following attributes are available: +- long_name +- short_name +- units (also for the dimensions) + +Additional arguments to the plotting and axis functions +======================================================= + +`more_kwargs` can be a dictionary that maps symbols to additional options for: +- the axis (`:axis`) +- the plotting function (`:plot`) +- the colorbar (`:cb`) + +The values are splatted in the relevant functions. Populate them with a +Dictionary of `Symbol`s => values to pass additional options. + +""" +function Visualize.heatmap2D!( + place::MakiePlace, + var::ClimaAnalysis.OutputVar; + p_loc = (1, 1), + more_kwargs = Dict(:plot => Dict(), :cb => Dict(), :axis => Dict()), +) + length(var.dims) == 2 || error("Can only plot 2D variables") + + dim1_name, dim2_name = var.index2dim + dim1 = var.dims[dim1_name] + dim2 = var.dims[dim2_name] + + units = var.attributes["units"] + short_name = var.attributes["short_name"] + colorbar_label = "$short_name [$units]" + dim1_units = var.dim_attributes[dim1_name]["units"] + dim2_units = var.dim_attributes[dim2_name]["units"] + + axis_kwargs = get(more_kwargs, :axis, Dict()) + plot_kwargs = get(more_kwargs, :plot, Dict()) + cb_kwargs = get(more_kwargs, :cb, Dict()) + + title = get(axis_kwargs, :title, var.attributes["long_name"]) + xlabel = get(axis_kwargs, :xlabel, "$dim1_name [$dim1_units]") + ylabel = get(axis_kwargs, :ylabel, "$dim2_name [$dim2_units]") + + # dim_on_y is only supported by plot_line1D. We remove it here to ensure that we can a + # consistent entry point between plot_line1D and heatmap2D. It we left it here, it would + # be passed down and lead to a unknown argument error. + # + # TODO: Refactor: We shouldn't have to deal with dim_on_y if we don't use it! + if haskey(axis_kwargs, :dim_on_y) + axis_kwargs_dict = Dict(axis_kwargs) + pop!(axis_kwargs_dict, :dim_on_y) + axis_kwargs = pairs(axis_kwargs_dict) + end + + WGLMakie.Axis(place[p_loc...]; title, xlabel, ylabel, axis_kwargs...) + + plot = WGLMakie.heatmap!(dim1, dim2, var.data; plot_kwargs...) + + p_loc_cb = Tuple([p_loc[1], p_loc[2] + 1]) + WGLMakie.Colorbar( + place[p_loc_cb...], + plot, + label = colorbar_label; + cb_kwargs..., + ) +end + +""" +Private function to define `sliced_` functions. + +It slices a given variable and applies `func` to it. +""" +function _sliced_plot_generic( + func, + fig, + var, + cut; + p_loc, + more_kwargs = Dict(:plot => Dict(), :cb => Dict(), :axis => Dict()), +) + isnothing(cut) && (cut = Dict()) + + var_sliced = var + + for (dim_name, val) in cut + var_sliced = ClimaAnalysis.Var._slice_general(var_sliced, val, dim_name) + end + + func(fig, var_sliced; p_loc, more_kwargs) +end + +""" +Private function to define `plot!` functions. + +It composes a `cut` from given `kwargs`. Used with `sliced` functions. +""" +function _plot_generic_kwargs( + func, + fig, + var; + p_loc, + more_kwargs = Dict(:plot => Dict(), :cb => Dict(), :axis => Dict()), + kwargs..., +) + cut = Dict("$k" => v for (k, v) in kwargs) + length(cut) == 0 && (cut = nothing) + return func(fig, var, cut; p_loc, more_kwargs) +end + +""" + sliced_heatmap!(fig::WGLMakie.Figure, + var::ClimaAnalysis.OutputVar, + cut::Union{Nothing, AbstractDict{String, <: Real}}; + p_loc = (1,1), + more_kwargs, + ) + sliced_heatmap!(grid_layout::WGLMakie.GridLayout, + var::ClimaAnalysis.OutputVar, + cut::Union{Nothing, AbstractDict{String, <: Real}}; + p_loc = (1,1), + more_kwargs, + ) + +Take a `var`iable, slice as directed, and plot a 2D heatmap in the given place and +location. + +The place can be a `Figure` or a `GridLayout`. + +The plot comes with labels, units, and a colorbar. + +Arguments +========= + +If the variable is not 2D, `cut` has to be a dictionary that maps the dimension that has to +be sliced and the value where to cut. + +For example, if `var` has four dimensions: `time`, `long`, `lat`, `z`, this function can be +used to plot a `lat-long` heatmap at fixed `time` and `z`. Assuming we want to plot +time `100.` and altitude `50.`, `cut` should be `Dict("time" => 100., "z" => 50.)`. + +This function assumes that the following attributes are available: +- long_name +- short_name +- units (also for the dimensions) + +Additional arguments to the plotting and axis functions +======================================================= + +`more_kwargs` can be a dictionary that maps symbols to additional options for: +- the axis (`:axis`) +- the plotting function (`:plot`) +- the colorbar (`:cb`) + +The values are splatted in the relevant functions. Populate them with a +Dictionary of `Symbol`s => values to pass additional options. +""" +function Visualize.sliced_heatmap!( + place::MakiePlace, + var::ClimaAnalysis.OutputVar, + cut::Union{Nothing, AbstractDict{String, <:Real}} = nothing; + p_loc = (1, 1), + more_kwargs = Dict(:plot => Dict(), :cb => Dict(), :axis => Dict()), +) + return _sliced_plot_generic( + Visualize.heatmap2D!, + place, + var, + cut; + p_loc, + more_kwargs, + ) +end + +""" + heatmap!(place::MakiePlace, + var::ClimaAnalysis.OutputVar; + p_loc = (1,1), + more_kwargs, + kwargs... + ) + +Syntactic sugar for `sliced_heatmap` with `kwargs` instead of `cut`. + +Example +======= + +`heatmap!(fig, var, time = 100, lat = 70)` plots a heatmap by slicing `var` along +the time nearest to 100 and latitude nearest 70. + +Additional arguments to the plotting and axis functions +======================================================= + +`more_kwargs` can be a dictionary that maps symbols to additional options for: +- the axis (`:axis`) +- the plotting function (`:plot`) +- the colorbar (`:cb`) + +The values are splatted in the relevant functions. Populate them with a +Dictionary of `Symbol`s => values to pass additional options. +""" +function Visualize.heatmap!( + place::MakiePlace, + var::ClimaAnalysis.OutputVar; + p_loc = (1, 1), + more_kwargs = Dict(:plot => Dict(), :cb => Dict(), :axis => Dict()), + kwargs..., +) + _plot_generic_kwargs( + Visualize.sliced_heatmap!, + place, + var; + p_loc, + more_kwargs, + kwargs..., + ) +end + +""" + line_plot1D!(place::WGLMakie.Figure, + var::ClimaAnalysis.OutputVar; + p_loc = (1,1), + more_kwargs + ) + line_plot1D!(place::WGLMakie.GridLayout, + var::ClimaAnalysis.OutputVar; + p_loc = (1,1), + more_kwargs + ) + +Plot a line plot of the given 1D `var`iable in the given place and location. +The place can be a `Figure` or a `GridLayout`. + +The plot comes with labels, units. + +This function assumes that the following attributes are available: +- long_name +- short_name +- units (also for the dimensions) + +Additional arguments to the plotting and axis functions +======================================================= + +`more_kwargs` can be a dictionary that maps symbols to additional options for: +- the axis (`:axis`) +- the plotting function (`:plot`) + +The values are splatted in the relevant functions. Populate them with a +Dictionary of `Symbol`s => values to pass additional options. + +A special argument that can be passed to `:axis` is `:dim_on_y`, which puts the dimension on +the y axis instead of the variable. This is useful to plot columns with `z` on the vertical +axis instead of the horizontal one. + +""" +function Visualize.line_plot1D!( + place::MakiePlace, + var::ClimaAnalysis.OutputVar; + p_loc = (1, 1), + more_kwargs = Dict(:plot => Dict(), :axis => Dict()), +) + length(var.dims) == 1 || error("Can only plot 1D variables") + + dim_name = var.index2dim[] + dim = var.dims[dim_name] + + units = var.attributes["units"] + short_name = var.attributes["short_name"] + + dim_units = var.dim_attributes[dim_name]["units"] + + axis_kwargs = get(more_kwargs, :axis, Dict()) + plot_kwargs = get(more_kwargs, :plot, Dict()) + + title = get(axis_kwargs, :title, var.attributes["long_name"]) + xlabel = get(axis_kwargs, :xlabel, "$dim_name [$dim_units]") + ylabel = get(axis_kwargs, :ylabel, "$short_name [$units]") + + x, y = dim, var.data + + if get(axis_kwargs, :dim_on_y, false) + xlabel, ylabel = ylabel, xlabel + x, y = y, x + # dim_on_y is not a real keyword for Axis, so we have to remove it from the + # arguments. Since axis_kwargs is a Pairs object, we have to go through its + # underlying dictionary first + axis_kwargs_dict = Dict(axis_kwargs) + pop!(axis_kwargs_dict, :dim_on_y) + axis_kwargs = pairs(axis_kwargs_dict) + end + + WGLMakie.Axis(place[p_loc...]; title, xlabel, ylabel, axis_kwargs...) + WGLMakie.lines!(x, y; plot_kwargs...) +end + +""" + sliced_line_plot!(place::WGLMakie.Figure, + var::ClimaAnalysis.OutputVar, + cut::Union{Nothing, AbstractDict{String, <: Real}}; + p_loc = (1,1), + more_kwargs + ) + sliced_line_plot!(place::WGLMakie.GridLayout, + var::ClimaAnalysis.OutputVar, + cut::Union{Nothing, AbstractDict{String, <: Real}}; + p_loc = (1,1), + more_kwargs + ) + +Take a `var`iable, slice as directed, and plot a 1D line plot in the given place and +location. The place can be a `Figure` or a `GridLayout`. + +The plot comes with labels, and units. + +Arguments +========= + +If the variable is not 1D, `cut` has to be a dictionary that maps the dimension that has to +be sliced and the value where to cut. + +For example, if `var` has four dimensions: `time`, `long`, `lat`, `z`, this function can be +used to plot a `lat-long` heatmap at fixed `time` and `z`. Assuming we want to plot +time `100.` and altitude `50.`, `cut` should be `Dict("time" => 100., "z" => 50.)`. + +This function assumes that the following attributes are available: +- long_name +- short_name +- units (also for the dimensions) + + +Additional arguments to the plotting and axis functions +======================================================= + +`more_kwargs` can be a dictionary that maps symbols to additional options for: +- the axis (`:axis`) +- the plotting function (`:plot`) + +The values are splatted in the relevant functions. Populate them with a +Dictionary of `Symbol`s => values to pass additional options. +""" +function Visualize.sliced_line_plot!( + place::MakiePlace, + var::ClimaAnalysis.OutputVar, + cut::Union{Nothing, AbstractDict{String, <:Real}} = nothing; + p_loc = (1, 1), + more_kwargs = Dict(:plot => Dict(), :axis => Dict()), +) + return _sliced_plot_generic( + Visualize.line_plot1D!, + place, + var, + cut; + p_loc, + more_kwargs, + ) +end + +""" + line_plot!(place::WGLMakie.Figure, + var::ClimaAnalysis.OutputVar; + p_loc = (1,1), + more_kwargs, + kwargs... + ) + line_plot!(place::WGLMakie.GridLayout, + var::ClimaAnalysis.OutputVar; + p_loc = (1,1), + more_kwargs, + kwargs... + ) + +Syntactic sugar for `sliced_line_plot` with `kwargs` instead of `cut`. + +Example +======= + +`line_plot!(fig, var, time = 100, lat = 70)` plots a line plot by slicing `var` along +the time nearest to 100 and latitude nearest 70. + +Additional arguments to the plotting and axis functions +======================================================= + +`more_kwargs` can be a dictionary that maps symbols to additional options for: +- the axis (`:axis`) +- the plotting function (`:plot`) + +The values are splatted in the relevant functions. Populate them with a +Dictionary of `Symbol`s => values to pass additional options. +""" +function Visualize.line_plot!( + place::MakiePlace, + var::ClimaAnalysis.OutputVar; + p_loc = (1, 1), + more_kwargs = Dict(:plot => Dict(), :axis => Dict()), + kwargs..., +) + _plot_generic_kwargs( + Visualize.sliced_line_plot!, + place, + var; + p_loc, + more_kwargs, + kwargs..., + ) +end + +""" + sliced_plot!(place::WGLMakie.Figure, + var::ClimaAnalysis.OutputVar, + cut::Union{Nothing, AbstractDict{String, <: Real}}; + p_loc = (1,1), + more_kwargs + ) + sliced_plot!(place::WGLMakie.GridLayout, + var::ClimaAnalysis.OutputVar, + cut::Union{Nothing, AbstractDict{String, <: Real}}; + p_loc = (1,1), + more_kwargs + ) + +Take a `var`iable, slice as directed, and plot a 1D line plot or 2D heatmap in the given place and +location. The place can be a `Figure` or a `GridLayout`. + +The plot comes with labels, and units (and possibly a colorbar). + +Arguments +========= + +If the variable is not 1D/2D, `cut` has to be a dictionary that maps the dimension that has to +be sliced and the value where to cut. + +For example, if `var` has four dimensions: `time`, `long`, `lat`, `z`, this function can be +used to plot a `lat-long` heatmap at fixed `time` and `z`. Assuming we want to plot +time `100.` and altitude `50.`, `cut` should be `Dict("time" => 100., "z" => 50.)`. + +This function assumes that the following attributes are available: +- long_name +- short_name +- units (also for the dimensions) + +Additional arguments to the plotting and axis functions +======================================================= + +`more_kwargs` can be a dictionary that maps symbols to additional options for: +- the axis (`:axis`) +- the plotting function (`:plot`) +- the colorbar (`:cb`) + +The values are splatted in the relevant functions. Populate them with a +Dictionary of `Symbol`s => values to pass additional options. +""" +function Visualize.sliced_plot!( + place::MakiePlace, + var::ClimaAnalysis.OutputVar, + cut::Union{Nothing, AbstractDict{String, <:Real}} = nothing; + p_loc = (1, 1), + more_kwargs = Dict(:plot => Dict(), :cb => Dict(), :axis => Dict()), +) + initial_dim = length(var.dims) + removed_dims = isnothing(cut) ? 0 : length(cut) + final_dim = initial_dim - removed_dims + + if final_dim == 1 + fun = Visualize.line_plot1D! + elseif final_dim == 2 + fun = Visualize.heatmap2D! + else + error("Sliced variable has $final_dim dimensions (needed 1 or 2)") + end + + return _sliced_plot_generic(fun, place, var, cut; p_loc, more_kwargs) +end + + +""" + plot!(place::WGLMakie.Figure, + var::ClimaAnalysis.OutputVar; + p_loc = (1,1), + more_kwargs, + kwargs... + ) + plot!(place::WGLMakie.GridLayout, + var::ClimaAnalysis.OutputVar; + p_loc = (1,1), + more_kwargs, + kwargs... + ) + +Syntactic sugar for `sliced_plot` with `kwargs` instead of `cut`. + +Example +======= + +`line_plot!(fig, var, time = 100, lat = 70)` plots a line plot or a heatmap by slicing +`var` along the time nearest to 100 and latitude nearest 70. + +Additional arguments to the plotting and axis functions +======================================================= + +`more_kwargs` can be a dictionary that maps symbols to additional options for: +- the axis (`:axis`) +- the plotting function (`:plot`) +- the colorbar (`:cb`) + +The values are splatted in the relevant functions. Populate them with a +Dictionary of `Symbol`s => values to pass additional options. +""" +function Visualize.plot!( + place::MakiePlace, + var::ClimaAnalysis.OutputVar; + p_loc = (1, 1), + more_kwargs = Dict(:plot => Dict(), :cb => Dict(), :axis => Dict()), + kwargs..., +) + _plot_generic_kwargs( + Visualize.sliced_plot!, + place, + var; + p_loc, + more_kwargs, + kwargs..., + ) +end + +end +