diff --git a/.github/workflows/format-lint-comment.yml b/.github/workflows/format-lint-comment.yml
new file mode 100644
index 0000000..be8bbaf
--- /dev/null
+++ b/.github/workflows/format-lint-comment.yml
@@ -0,0 +1,105 @@
+# This takes the results of "format-lint.yaml" and creates a comment on the PR to explain
+# them.
+#
+# See "format-lint.yaml" for more details on why this workflow split is needed.
+
+name: PR comment
+
+on:
+ workflow_run:
+ workflows: [Format and lint]
+ types: [completed]
+
+jobs:
+ comment:
+ runs-on: ubuntu-latest
+ if: github.event.workflow_run.event == 'pull_request'
+ permissions:
+ pull-requests: write
+ actions: read
+ steps:
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+
+ - name: Download panache results
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ continue-on-error: true
+ with:
+ name: panache-results
+ run-id: ${{ github.event.workflow_run.id }}
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ path: /tmp/panache
+
+ - name: Download jarl results
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ continue-on-error: true
+ with:
+ name: jarl-results
+ run-id: ${{ github.event.workflow_run.id }}
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ path: /tmp/jarl
+
+ - name: Read results
+ id: results
+ run: |
+ echo "pr_number=$(cat /tmp/panache/pr-number.txt 2>/dev/null)" >> "$GITHUB_OUTPUT"
+ echo "panache_result=$(cat /tmp/panache/panache-result.txt 2>/dev/null)" >> "$GITHUB_OUTPUT"
+ echo "jarl_result=$(cat /tmp/jarl/jarl-result.txt 2>/dev/null)" >> "$GITHUB_OUTPUT"
+
+ # After reading the results of Panache and Jarl, we have several possible outcomes:
+ # - both pass: if we had created a comment before because at least one of them was
+ # failing, then we update this comment. Otherwise, we don't open a comment just to
+ # say that this is passing.
+ # - Jarl, Panache, or both fail: we create a comment with a custom template to explain
+ # what failed and how to fix it locally. This should help new contributors when
+ # they see that CI for linting and formatting fails.
+
+ # This is needed to know if we need to create or update a comment.
+ - name: Find existing comment
+ if: steps.results.outputs.pr_number != ''
+ uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0
+ id: find-comment
+ with:
+ issue-number: ${{ steps.results.outputs.pr_number }}
+ comment-author: 'github-actions[bot]'
+ body-includes: ''
+
+ # Build the comment body depending on Panache and Jarl results. For the case where both
+ # fail, we concatenate the two single-failure templates.
+ #
+ # Note that the case with both succeeding produces a template only if we have
+ # "$COMMENT_ID", i.e. if we already created a comment before. If we didn't, then
+ # `steps.build.outputs.ready` is false and the last step isn't triggered.
+ - name: Build comment
+ id: build
+ if: steps.results.outputs.pr_number != ''
+ env:
+ PANACHE_RESULT: ${{ steps.results.outputs.panache_result }}
+ JARL_RESULT: ${{ steps.results.outputs.jarl_result }}
+ COMMENT_ID: ${{ steps.find-comment.outputs.comment-id }}
+ # The "echo" between the two "cat" ensures a new line between the two templates.
+ run: |
+ if [[ "$PANACHE_RESULT" == "failure" && "$JARL_RESULT" == "failure" ]]; then
+ {
+ cat .github/workflows/templates/panache-only.md
+ echo
+ echo "---"
+ echo
+ cat .github/workflows/templates/jarl-only.md
+ } > /tmp/comment.md
+ elif [[ "$PANACHE_RESULT" == "failure" ]]; then
+ cp .github/workflows/templates/panache-only.md /tmp/comment.md
+ elif [[ "$JARL_RESULT" == "failure" ]]; then
+ cp .github/workflows/templates/jarl-only.md /tmp/comment.md
+ elif [[ -n "$COMMENT_ID" ]]; then
+ cp .github/workflows/templates/all-pass.md /tmp/comment.md
+ fi
+ [[ -f /tmp/comment.md ]] && echo "ready=true" >> "$GITHUB_OUTPUT" || echo "ready=false" >> "$GITHUB_OUTPUT"
+
+ - name: Create or update comment
+ if: steps.build.outputs.ready == 'true' && steps.results.outputs.pr_number != ''
+ uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
+ with:
+ comment-id: ${{ steps.find-comment.outputs.comment-id }}
+ issue-number: ${{ steps.results.outputs.pr_number }}
+ body-path: /tmp/comment.md
+ edit-mode: replace
\ No newline at end of file
diff --git a/.github/workflows/format-lint.yml b/.github/workflows/format-lint.yml
new file mode 100644
index 0000000..610f881
--- /dev/null
+++ b/.github/workflows/format-lint.yml
@@ -0,0 +1,68 @@
+# This workflow runs Panache for formatting and Jarl for linting. It stores artifacts about the
+# outcome of each tool and the PR number. Those artifacts are then used in the workflow
+# "format-lint-pr-comment.yaml" to post a comment so that it's clearer for external
+# contributors.
+#
+# This could have been done in a single workflow with a "pull_request_target" trigger but
+# this might have some security issues (this is unlikely in practice but still better to
+# be ahead of it):
+# https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/
+
+name: Format and lint
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.head_ref }}
+ cancel-in-progress: true
+
+jobs:
+ format-panache:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ - name: Run Panache
+ id: check
+ uses: jolars/panache-action@a1e4e68c74a41e6b9dade9c93095a262a23aa48c # v1
+ with:
+ lint: "false"
+ continue-on-error: true
+ - name: Save results
+ if: github.event_name == 'pull_request'
+ run: |
+ echo "${{ steps.check.outcome }}" > /tmp/panache-result.txt
+ echo "${{ github.event.pull_request.number }}" > /tmp/pr-number.txt
+ - name: Upload artifacts
+ if: github.event_name == 'pull_request'
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: panache-results
+ path: |
+ /tmp/panache-result.txt
+ /tmp/pr-number.txt
+ - if: steps.check.outcome == 'failure'
+ run: exit 1
+
+ lint-jarl:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ - name: Check
+ id: check
+ uses: etiennebacher/setup-jarl@eabf7e572f2991d165059007531b9bbe2987e39c # v0.1.1
+ continue-on-error: true
+ - name: Save result
+ if: github.event_name == 'pull_request'
+ run: echo "${{ steps.check.outcome }}" > /tmp/jarl-result.txt
+ - name: Upload artifact
+ if: github.event_name == 'pull_request'
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: jarl-results
+ path: /tmp/jarl-result.txt
+ - if: steps.check.outcome == 'failure'
+ run: exit 1
\ No newline at end of file
diff --git a/.github/workflows/templates/all-pass.md b/.github/workflows/templates/all-pass.md
new file mode 100644
index 0000000..0fa95eb
--- /dev/null
+++ b/.github/workflows/templates/all-pass.md
@@ -0,0 +1,2 @@
+
+:white_check_mark: All formatting and linting checks passed!
diff --git a/.github/workflows/templates/jarl-only.md b/.github/workflows/templates/jarl-only.md
new file mode 100644
index 0000000..24f9c0b
--- /dev/null
+++ b/.github/workflows/templates/jarl-only.md
@@ -0,0 +1,14 @@
+
+:x: This Pull Request failed our automated **linting checks**. Please resolve these prior to requesting review. We use Jarl to automatically lint R code. Please [install it](https://jarl.etiennebacher.com/#installation) (you may need to close and reopen your IDE, e.g. RStudio or Positron, after that) and run the following command in the terminal (not the R console) to find linting issues:
+
+```sh
+jarl check .
+```
+
+Some of these issues might be automatically fixed with the following command:
+
+```sh
+jarl check . --fix
+```
+
+This comment will be automatically updated once linting checks pass.
diff --git a/.github/workflows/templates/panache-only.md b/.github/workflows/templates/panache-only.md
new file mode 100644
index 0000000..b04478d
--- /dev/null
+++ b/.github/workflows/templates/panache-only.md
@@ -0,0 +1,8 @@
+
+:x: This Pull Request failed our automated **formatting checks**. Please resolve these prior to requesting review. We use Panache to automatically format R code chunks in Quarto files. Please [install it](https://panache.bz/getting-started.html#installation) (you may need to close and reopen your IDE, e.g. RStudio or Positron, after that) and run the following command in the terminal (not the R console) to reformat the code:
+
+```sh
+panache format **/*.qmd
+```
+
+This comment will be automatically updated once formatting checks pass.
diff --git a/ggplot/index.qmd b/ggplot/index.qmd
index 68c72c7..e78d40f 100644
--- a/ggplot/index.qmd
+++ b/ggplot/index.qmd
@@ -14,8 +14,8 @@ execute:
warning: false
message: false
freeze: auto
-editor:
- markdown:
+editor:
+ markdown:
wrap: 72
---
@@ -23,14 +23,18 @@ editor:
While you can make plots with just the packages that come bundled with base R, many R users make their visualizations entirely using the [`ggplot2`](https://ggplot2.tidyverse.org/index.html) package and an [ecosystem of packages](https://exts.ggplot2.tidyverse.org/gallery/) designed around it.
-```{r ggplot2}
+```{r}
+#| label: ggplot2
+
# load the ggplot2 package
library(ggplot2)
```
As with the previous sessions, we'll be using the Palmer penguins dataset. While we built our own combined dataset in the introduction session, now we're going to use the built-in cleaned dataset. First, let's load the dataset. Then let's inspect the data using the `glimpse()` function.
-```{r palmerpenguins}
+```{r}
+#| label: palmerpenguins
+
data(penguins)
dplyr::glimpse(penguins)
```
@@ -41,7 +45,9 @@ As we discovered before, this dataset includes many different measurements for i
The most important function in the `ggplot2` package is `ggplot()`. Note that this function doesn't include the "2" of the package name. Let's go ahead and try using this function on our penguins data.
-```{r ggplot-raw}
+```{r}
+#| label: ggplot-raw
+
ggplot(penguins)
```
@@ -49,14 +55,18 @@ You'll notice that the `ggplot()` function doesn't actually do much by itself. H
By using the `ggplot()` function, we are essentially stating that we are beginning a plotting "sentence". We then combine this with other "words" (function calls) using the `+` operator. The next component you usually want to specify in a `ggplot` "sentence" is our "aesthetic" mappings. These specify the columns of the dataset that correspond to each axis of the plot, including the x/y axes, but also the axes of color, shape, etc. We do this with the `aes()` function:
-```{r ggplot-aes}
+```{r}
+#| label: ggplot-aes
+
ggplot(penguins) +
aes(x = body_mass, y = flipper_len)
```
Hey, it's starting to look like a plot now! Except there isn't any actual data being plotted. Let's fix that. We'll start off with a simple scatter plot by using the `geom_point()` function:
-```{r geom_point}
+```{r}
+#| label: geom_point
+
ggplot(penguins) +
aes(x = body_mass, y = flipper_len) +
geom_point()
@@ -64,22 +74,26 @@ ggplot(penguins) +
And there we go! You'll notice that with just a few lines, we've already made a pretty nice visualization of this penguin data. `ggplot2` does most of the work for us once we specify our dataset and our `x` and `y` variables.
-:::: {.callout-note}
+::: {.callout-note}
## Missing data
+
You may have noticed a warning that some rows of the dataset contain missing values. There are two penguins without any measurements in the dataset. How might we remove them using `dplyr` so we don't get this warning over and over?
-::: {.callout-caution collapse="true" appearance="simple" icon="false"}
+::::: {.callout-caution collapse="true" appearance="simple" icon="false"}
##### Solution
+
```{r}
penguins <- penguins |>
dplyr::filter(!is.na(body_mass))
```
+:::::
:::
-::::
Now, let's go a step further and color the points by another variable (e.g., the island of the penguins). With `ggplot2`, all that requires is specifying another aesthetic:
-```{r geom_point-color}
+```{r}
+#| label: geom_point-color
+
ggplot(penguins) +
aes(x = body_mass, y = flipper_len, color = island) +
geom_point()
@@ -89,7 +103,9 @@ Notice that `ggplot2` comes with its own default color scheme. However, I would
First, let's try one of the [`viridis`](https://cran.r-project.org/web/packages/viridis/vignettes/intro-to-viridis.html) color palettes. Since this palette is included in `ggplot2`, all we need to do is add the proper "scale" to our `ggplot()` call. Scales tell ggplot how to handle a particular aesthetic, and are usually of the form `scale_[aesthetic]_[type]()`.
-```{r viridis}
+```{r}
+#| label: viridis
+
ggplot(penguins) +
aes(x = body_mass, y = flipper_len, color = island) +
geom_point() +
@@ -98,7 +114,9 @@ ggplot(penguins) +
Now let's try one of the [brewer color palettes](https://r-graph-gallery.com/38-rcolorbrewers-palettes.html).
-```{r brewer}
+```{r}
+#| label: brewer
+
ggplot(penguins) +
aes(x = body_mass, y = flipper_len, color = island) +
geom_point() +
@@ -107,7 +125,9 @@ ggplot(penguins) +
Outside of color, there are many other [aspects of the graph](https://ggplot2.tidyverse.org/articles/ggplot2-specs.html) that we can modify using aesthetics and "scale"s. For example, we can modify the shape of the points:
-```{r shape-aes}
+```{r}
+#| label: shape-aes
+
ggplot(penguins) +
aes(x = body_mass, y = flipper_len,
color = island, shape = sex) +
@@ -117,9 +137,12 @@ ggplot(penguins) +
::: {.callout-note}
### More missing data
+
Urgh, more missing data! It appears that nine of the penguins are missing sex identification data. We can ignore the warning message, but that extra "NA" category in the legend is quite annoying. We can remove this category from the legend using `na.translate = FALSE` within `scale_shape_discrete()` (you could also use `scale_shape_manual()` if you wanted to supply your own shapes):
-```{r na.translate}
+```{r}
+#| label: na.translate
+
ggplot(penguins) +
aes(x = body_mass, y = flipper_len,
color = island, shape = sex) +
@@ -131,7 +154,9 @@ ggplot(penguins) +
And the x/y axes:
-```{r axis-scales}
+```{r}
+#| label: axis-scales
+
ggplot(penguins) +
aes(x = body_mass, y = flipper_len,
color = island, shape = sex) +
@@ -146,7 +171,9 @@ ggplot(penguins) +
The last basic thing you might want to do with `ggplot2` is modify the style of the visualization. This is extremely customizable, but the first place to start is with a [built-in theme](https://ggplot2.tidyverse.org/reference/ggtheme.html). I personally prefer the classic theme, which looks very similar to base R plots:
-```{r theme_classic}
+```{r}
+#| label: theme_classic
+
ggplot(penguins) +
aes(x = body_mass, y = flipper_len,
color = island, shape = sex) +
@@ -160,7 +187,9 @@ ggplot(penguins) +
Using this built-in theme has changed many visual aspects of the graph, including changing the plot background color, adding axis lines, and removing the internal grid lines. If you look very closely, however, the axis tick labels are still a slight grey. We can use the `theme()` function to further customize the appearance and change this. In this case, we'll make the axis text elements have a black color instead of the default gray.
-```{r theming}
+```{r}
+#| label: theming
+
ggplot(penguins) +
aes(x = body_mass, y = flipper_len,
color = island, shape = sex) +
@@ -187,7 +216,9 @@ There are many other [types of plots](https://ggplot2.tidyverse.org/reference/#l
We can visualize the density of values for a single variable with a histogram:
-```{r geom_histogram}
+```{r}
+#| label: geom_histogram
+
ggplot(penguins) +
aes(x = body_mass, fill = species) +
geom_histogram() +
@@ -198,7 +229,9 @@ ggplot(penguins) +
::: {.callout-note}
Histograms don't require a y-axis aesthetic by default. The counts are tabulated for you. If you specify a "fill" aesthetic, the default is to stack the bars which can sometimes be a bit misleading. You can also dodge them to fix this:
-```{r hist-dodge}
+```{r}
+#| label: hist-dodge
+
ggplot(penguins) +
aes(x = body_mass, fill = species) +
geom_histogram(position = "dodge") +
@@ -211,7 +244,9 @@ ggplot(penguins) +
We can visualize the density of values for a single variable across a discrete variable with boxplots or violin plots:
-```{r geom_boxplot}
+```{r}
+#| label: geom_boxplot
+
ggplot(penguins) +
aes(x = island, y = bill_len) +
geom_boxplot() +
@@ -219,7 +254,9 @@ ggplot(penguins) +
theme(axis.text = element_text(color = "black"))
```
-```{r geom_violin}
+```{r}
+#| label: geom_violin
+
ggplot(penguins) +
aes(x = island, y = bill_len) +
geom_violin(scale = "width", draw_quantiles = c(0.25, 0.5, 0.75)) +
@@ -229,6 +266,7 @@ ggplot(penguins) +
::: {.callout-note}
#### Geom options
+
Note that many of these "geom"s have lots of options. For example, here we've decided to `scale` all of the violin plots to the same width and to draw the quartiles on them (mimicking the boxplots above). You can see all of the options for a geom by checking out it's help page (`?geom_violin`) or on the ggplot [website](https://ggplot2.tidyverse.org/reference/geom_violin.html).
:::
@@ -236,7 +274,9 @@ Note that many of these "geom"s have lots of options. For example, here we've de
We can also visualize the density of values across two continuous variables using a 2D contour:
-```{r geom_density_2d}
+```{r}
+#| label: geom_density_2d
+
ggplot(penguins) +
aes(x = bill_len, y = bill_dep) +
geom_density_2d(linewidth = 0.25, colour = "black") +
@@ -245,7 +285,9 @@ ggplot(penguins) +
```
Note that sometimes you may need to expand the axes a little bit to better show the contours:
-```{r geom_density_2d_expand}
+```{r}
+#| label: geom_density_2d_expand
+
ggplot(penguins) +
aes(x = bill_len, y = bill_dep) +
geom_density_2d(linewidth = 0.25, colour = "black") +
@@ -258,7 +300,9 @@ ggplot(penguins) +
Since there isn't really any time series data in the penguins dataset, we'll take a quick detour and use the built-in `economics` dataset to explore visualizing a time series. In this case, we are looking at unemployment over time:
-```{r geom_line}
+```{r}
+#| label: geom_line
+
ggplot(economics, aes(x = date, y = unemploy)) +
geom_line() +
theme_classic() +
@@ -267,7 +311,9 @@ ggplot(economics, aes(x = date, y = unemploy)) +
`geom_path()` lets you explore how two variables are related over time. For example, unemployment and personal savings rate:
-```{r geom_path}
+```{r}
+#| label: geom_path
+
ggplot(economics, aes(x = unemploy / pop, y = psavert)) +
geom_path(aes(colour = as.numeric(date))) +
theme_classic() +
@@ -276,6 +322,7 @@ ggplot(economics, aes(x = unemploy / pop, y = psavert)) +
::: {.callout-note}
#### Multiple columns for individual aesthetics
+
Note how we've used multiple columns of the data to define the x-axis here. You can use any sort of mathematical operators to combine multiple columns into a single aesthetic, as long as you are doing row-wise math.
:::
@@ -283,7 +330,9 @@ Note how we've used multiple columns of the data to define the x-axis here. You
We can also combine multiple layers to show the same data in different ways in the same plot. For example, we could show the raw data for the above contour plot in addition to the contours:
-```{r combined-layers}
+```{r}
+#| label: combined-layers
+
ggplot(penguins) +
aes(x = bill_len, y = bill_dep) +
geom_point() +
@@ -298,6 +347,7 @@ ggplot(penguins) +
::: {.callout-note}
### Layer order
+
When combining layers, the layers are added to the plot in order, so in this case the points are the bottom layer and the contour lines are the top layer. We changed the alpha of the middle layer to prevent the points from being blocked. I've also used the `coord_cartesian()` function to remove the default axis expansion. This way the background color reaches both axes and doesn't have a white gap.
:::
@@ -305,7 +355,9 @@ When combining layers, the layers are added to the plot in order, so in this cas
Let's take our scatterplot example from earlier:
-```{r scatter-full}
+```{r}
+#| label: scatter-full
+
ggplot(penguins) +
aes(x = body_mass, y = flipper_len,
color = island, shape = sex) +
@@ -320,7 +372,9 @@ ggplot(penguins) +
Now, what if we wanted to also split the data by the species of the penguins? We're already using color and shape, so what other aesthetic could we use? We could possible use some shapes that have both a fill and outline color, but that sounds messy. Instead of using another aesthetic, we could also use a `facet`. This splits the chart into multiple panels:
-```{r scatter-facet}
+```{r}
+#| label: scatter-facet
+
ggplot(penguins) +
aes(x = body_mass, y = flipper_len,
color = island, shape = sex) +
@@ -336,7 +390,9 @@ ggplot(penguins) +
We can get even crazier by faceting by multiple variables:
-```{r scatter-facet-grid}
+```{r}
+#| label: scatter-facet-grid
+
ggplot(penguins) +
aes(x = body_mass, y = flipper_len,
color = island, shape = sex) +
@@ -353,15 +409,20 @@ ggplot(penguins) +
OK, maybe we've gone a little too far here, but you get the picture!
# Combining plots
+
When publishing results, often we need to combine multiple figures into a single visualization. There are lots of packages for accomplishing this (even my own [deeptime](https://williamgearty.com/deeptime) package has some functionality for it), but today we'll check out the [`patchwork`](https://patchwork.data-imaginist.com/) package which extends the "grammar of graphics" to combining plots (you may need to install it if you haven't done so already).
-```{r patchwork}
+```{r}
+#| label: patchwork
+
library(patchwork)
```
First, let's go ahead and make some plots. Instead of plotting them, though, we'll save them as objects in our environment. Note that when you save a plot to an object it isn't displayed in the "Plots" tab.
-```{r plot-objects}
+```{r}
+#| label: plot-objects
+
g1 <- ggplot(penguins) +
aes(x = body_mass, y = flipper_len) +
geom_point() +
@@ -382,13 +443,17 @@ g2 <- ggplot(penguins) +
Now, in order to combine these, all we need to do is combine them using the `+` operator, like we did with the individual elements of the plots.
-```{r add-plots}
+```{r}
+#| label: add-plots
+
g1 + g2
```
You can see that `patchwork` does all of the work for us, lining up the different components of the plots. If we have more plots to combine, we can then use the `|` (side-by-side) and `/` (above-and-below) operators to make more complex arrangements of plots.
-```{r patchwork-complex}
+```{r}
+#| label: patchwork-complex
+
g3 <- ggplot(penguins) +
aes(x = island, y = bill_len) +
geom_boxplot() +
@@ -401,9 +466,13 @@ g3 <- ggplot(penguins) +
There's a lot more you can do with `patchwork`, including adjusting the widths and heights, but we'll stop here for now.
# Saving plots
+
The last big thing you'll need to know about plotting is how to save your plots. `ggplot2` comes with a nifty `ggsave()` function which you can use to save your plots in a number of different formats. Here we'll save our most recent combined plot as both a PDF and a JPEG. The former is a vector format, meaning all of the elements of the figure as geometric shapes, and, because of this, none of the data is lost (aka "lossless"). The latter is a raster format, meaning the figure is converted to a 2-D array of colored pixels of a desired size, and because of this, some of the data is lost in the process (aka "lossy"). This results in the pixellation that you see when you zoom in on a JPEG. `ggsave()` detects what format you want based on the file extension, so we just need to specify the file location and the object to be saved (and optionally the dimensions of the output file). It's usually a good idea to keep all figures in their own folder (which was already created for you).
-```{r ggsave, eval=FALSE}
+```{r}
+#| label: ggsave
+#| eval: false
+
gg <- (g1 | g2) / g3
ggsave("figures/penguins_1.pdf", gg, height = 10, width = 10)
ggsave("figures/penguins_1.jpg", gg, height = 10, width = 10)
diff --git a/panache.toml b/panache.toml
new file mode 100644
index 0000000..c9b394a
--- /dev/null
+++ b/panache.toml
@@ -0,0 +1,2 @@
+[format]
+wrap = "preserve"
\ No newline at end of file