Static Data Visualization

A Parallel Guide in R and Python

1 Static Data Visualization

In this section, we walk through the iterative process of building data visualizations. We start with raw observations, layer on aesthetic mappings and summary geometry, aggregate data to account for repeated measures, and conclude with multi-panel publication figures.


1.1 Step 1: Raw Geometry (The Foundation)

We begin by mapping Surface condition to the x-axis and PathLength_Total to the y-axis. This raw point plot reveals the basic distribution of data but suffers from heavy overplotting across conditions.

It is worth noting that plotnine is a package for Python that uses syntax essentially identical to ggplot in R (both plotnine and ggplot are based on Leland Wilkinson’s “Grammar of Graphics” - a layered system to build data visualizations). However, when making this document using Quarto inside of R Studio, I need to call Python functions using reticulate and - for computing reasons I do not fully understand - it is difficult to render plotnine objects through reticulate. Thus, for these examples, I am using matplotlib and seaborn to great plots similar to my ggplot visualizations, but for pragmatic users, it is probably more efficient to learn ggplot/plotnine syntax that is portable across languages.

Show Code
library(tidyverse)
library(patchwork)
library(GGally)

DATA <- readRDS("./data/BDS/output/BDS_trial_summary.rds") %>% 
  mutate(Subject = factor(Subject),
         AgeGroup = fct_relevel(AgeGroup, "Young", "Old"))

# Plot 1: Basic scatter plot
ggplot(data = DATA, aes(x = Surface, y = PathLength_Total)) +
  geom_point()

Show Code
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

DATA = pd.read_csv("./data/BDS/output/BDS_trial_summary.csv")
DATA["AgeGroup"] = pd.Categorical(DATA["AgeGroup"], categories=["Young", "Old"], ordered=True)

# Step 1: Basic scatter plot
fig, ax = plt.subplots(figsize=(6, 4))
sns.scatterplot(data=DATA, x="Surface", y="PathLength_Total", color="black", ax=ax)
ax.set_ylabel("Total Path Length (cm)")
sns.despine()
plt.show()


1.2 Step 2: Adding Aesthetic Mappings

To evaluate the effect of visual input, we map Vision to point shape and color while dodging the points along the x-axis to reduce overlap.

Show Code
# Plot 2: Dodged points by Vision condition
ggplot(data = DATA, aes(x = Surface, y = PathLength_Total)) +
  geom_point(aes(shape = Vision, col = Vision),
             position = position_dodge(width = 0.5)) +
  theme_bw()

Show Code
import matplotlib.pyplot as plt
import seaborn as sns

# Step 2: Dodged points by Vision condition
fig, ax = plt.subplots(figsize=(6, 4))
sns.stripplot(
    data=DATA, 
    x="Surface", 
    y="PathLength_Total",
    hue="Vision", 
    dodge=True, 
    jitter=0.2, 
    ax=ax
)
ax.set_ylabel("Total Path Length (cm)")
sns.despine()
plt.show()


1.3 Step 3: Layering Jittered Points, Boxplots, and Custom Palettes

To better summarize central tendency and spread, we overlay boxplots beneath jitter-dodged raw data points and apply a high-contrast, colorblind-friendly palette.

Show Code
cbPalette <- c("#1E6654", "#CC5500", "#954535", "#5B7C99",
               "#8366BD", "#D1C071", "#F0EAD6", "grey20")

# Plot 3: Boxplots with jitter-dodged points
ggplot(data = DATA, aes(x = Surface, y = PathLength_Total)) +
  geom_point(aes(shape = Vision, col = Vision),
             position = position_jitterdodge(dodge.width = 0.5, jitter.width = 0.2),
             alpha = 0.5) +
  geom_boxplot(aes(fill = Vision), width = 0.5,
               position = position_dodge(width = 0.5),
               alpha = 0.5, outlier.shape = NA) + 
  scale_color_manual(values = cbPalette[c(1,2)]) + 
  scale_fill_manual(values = cbPalette[c(1,2)]) +
  theme_bw()

Show Code
cbPalette = ["#1E6654", "#CC5500", "#954535", "#5B7C99", "#8366BD", "#D1C071", "#F0EAD6", "#333333"]

# Step 3: Boxplots with jitter-dodged points
fig, ax = plt.subplots(figsize=(7, 4.5))
sns.boxplot(
    data=DATA, x="Surface", y="PathLength_Total", hue="Vision",
    palette=cbPalette[:2], boxprops=dict(alpha=0.5), showfliers=False, ax=ax
)
sns.stripplot(
    data=DATA, x="Surface", y="PathLength_Total", hue="Vision",
    dodge=True, jitter=0.2, palette=cbPalette[:2], alpha=0.5, ax=ax
)

# Deduplicate legend entries
handles, labels = ax.get_legend_handles_labels()
ax.legend(handles[:2], labels[:2], title="Vision")
ax.set_ylabel("Total Path Length (cm)")
sns.despine()
plt.show()


1.4 Step 4: Subject Aggregation and Adding Plot Features

Because individual trials introduce nested variance, we aggregate data to subject-level means (AVE_DATA). We also refine labels, typography, and legend positioning to polish the figure.

Show Code
# Aggregate trial data to subject-level averages
AVE_DATA <- DATA %>% 
  select(-Trial) %>%
  group_by(Subject, Vision, Surface) %>%
  summarise(
    across(where(is.numeric), ~ mean(.x, na.rm = TRUE)),
    across(!where(is.numeric), dplyr::first),
    .groups = "drop"
  )

# Plot 4: Formatted subject-level summary
ggplot(data = AVE_DATA, aes(x = Surface, y = PathLength_Total)) +
  geom_point(aes(shape = Vision, col = Vision),
             position = position_jitterdodge(dodge.width = 0.5, jitter.width = 0.2),
             alpha = 0.5) +
  geom_boxplot(aes(fill = Vision), width = 0.5,
               position = position_dodge(width = 0.5),
               alpha = 0.5, outlier.shape = NA) + 
  scale_color_manual(values = cbPalette[c(1,2)]) + 
  scale_fill_manual(values = cbPalette[c(1,2)]) +
  theme_bw() +
  labs(
    title = "Postural Sway: Total Path Length by Surface and Vision",
    subtitle = "Mean values aggregated across trials per subject",
    x = "Surface Condition",
    y = "Total Path Length (cm)",
    shape = "Vision Condition", 
    col = "Vision Condition",
    fill = "Vision Condition"
  ) +
  theme(
    axis.title = element_text(size = 12, face = "bold"),
    axis.text = element_text(size = 11),
    legend.position = "bottom",
    plot.title = element_text(size = 14, face = "bold"),
    plot.subtitle = element_text(size = 12, face = "italic", color = "gray30")
  )

Show Code
AVE_DATA = DATA.groupby(["Subject", "Vision", "Surface", "AgeGroup"], as_index=False).mean(numeric_only=True)

# Step 4: Formatted subject-level summary
fig, ax = plt.subplots(figsize=(8, 5))
sns.boxplot(
    data=AVE_DATA, x="Surface", y="PathLength_Total", hue="Vision",
    palette=cbPalette[:2], boxprops=dict(alpha=0.5), showfliers=False, ax=ax
)
sns.stripplot(
    data=AVE_DATA, x="Surface", y="PathLength_Total", hue="Vision",
    dodge=True, jitter=0.2, palette=cbPalette[:2], alpha=0.5, ax=ax
)

handles, labels = ax.get_legend_handles_labels()
ax.legend(handles[:2], labels[:2], title="Vision Condition", loc="upper right")

ax.set_title("Postural Sway: Total Path Length by Surface and Vision\n", fontweight="bold", fontsize=13)
ax.set_xlabel("Surface Condition", fontweight="bold")
ax.set_ylabel("Total Path Length (cm)", fontweight="bold")
sns.despine()
plt.show()


1.5 Step 5: Visualizing Within-Subject Trajectories

In this study, Surface and Vision are manipulated within a person (i.e., the are both within-subject variables). A critical peace of information in these studies is how consistent participants are in their individal responses (i.e., does the same person tend to score high/low across conditions). Mathematically, we might calculate an intraclass correlation coefficiant to capture how (dis)similar data from the same person happen to be. Visually, we can add lines to our plot “connecting the dots” for data from the same person. To show individual changes across conditions, we facet by Surface and connect subject points from Open to Closed vision conditions using light gray trajectory lines.

Show Code
# Plot 5: Within-subject lines faceted by Surface
ggplot(data = AVE_DATA, aes(x = Vision, y = PathLength_Total)) +
  geom_line(aes(group = Subject), color = "gray75", alpha = 0.5, linewidth = 0.5,
            position = position_dodge(width = 0.2)) +
  geom_point(aes(group = Subject, shape = Vision, col = Vision), size = 2, alpha = 0.5,
             position = position_dodge(width = 0.2)) +
  geom_boxplot(aes(fill = Vision), width = 0.2, alpha = 0.4, outlier.shape = NA) +
  facet_wrap(~ Surface) + 
  scale_color_manual(values = cbPalette[c(1,2)]) + 
  scale_fill_manual(values = cbPalette[c(1,2)]) +
  theme_bw() +
  labs(
    title = "Postural Sway: Within-Subject Visual Effects",
    subtitle = "Lines connect individual subjects from Open to Closed conditions",
    x = "Vision Condition",
    y = "Total Path Length (cm)"
  ) +
  theme(
    plot.title = element_text(size = 16, face = "bold"),
    plot.subtitle = element_text(size = 12, face = "italic", color = "gray30"),
    axis.title = element_text(size = 14, face = "bold"),
    axis.text = element_text(size = 11),
    legend.position = "none",
    strip.text = element_text(size = 12, face = "bold")
  )

Show Code
# Step 5: Within-subject trajectory lines faceted by Surface
vis_palette = {"Open": cbPalette[0], "Closed": cbPalette[1]}
g = sns.FacetGrid(AVE_DATA, col="Surface", height=4.5, aspect=1.1)

for surf_name, ax in g.axes_dict.items():
    sub = AVE_DATA[AVE_DATA["Surface"] == surf_name]
    
    # Layer 1: Subject lines
    sns.lineplot(
        data=sub, x="Vision", y="PathLength_Total", units="Subject", 
        estimator=None, color="gray", alpha=0.5, ax=ax
    )
    
    # Layer 2: Overlay scatter points
    sns.scatterplot(
        data=sub, x="Vision", y="PathLength_Total", hue="Vision", 
        style="Vision", palette=vis_palette, s=60, alpha=0.8, legend=False, ax=ax
    )
    
    # Layer 3: Boxplot
    sns.boxplot(
        data=sub, x="Vision", y="PathLength_Total", 
        palette=vis_palette, boxprops=dict(alpha=0.4), showfliers=False, ax=ax
    )
    
    ax.set_title(f"Surface: {surf_name}", fontweight="bold")
    ax.set_xlabel("Vision Condition", fontweight="bold")
    ax.set_ylabel("Total Path Length (cm)", fontweight="bold")

g.fig.subplots_adjust(top=0.8)
g.fig.suptitle("Postural Sway: Within-Subject Visual Effects", fontweight="bold", fontsize=14)
sns.despine()
plt.show()


1.6 Step 6: Incorporating Between-Subject Factors (AgeGroup)

Next, we evaluate how age group interacts with balance conditions. We plot the combined interaction (Surface:Vision) along the x-axis and facet by AgeGroup.

Show Code
# Create interaction factor for clean aesthetic mapping
AVE_DATA <- AVE_DATA %>% 
  mutate(Condition = interaction(Surface, Vision, sep = ":"))

# Plot 6: Interaction conditions faceted by AgeGroup
ggplot(data = AVE_DATA, aes(x = Condition, y = PathLength_Total)) +
  geom_line(aes(group = Subject), color = "gray75", alpha = 0.5, linewidth = 0.5,
            position = position_dodge(width = 0.2)) +
  geom_point(aes(shape = Condition, col = Condition, group = Subject), 
             size = 2, alpha = 0.5, position = position_dodge(width = 0.2)) +
  geom_boxplot(aes(fill = Condition), width = 0.2, alpha = 0.4, outlier.shape = NA) +
  facet_wrap(~ AgeGroup) + 
  scale_color_manual(values = cbPalette) + 
  scale_fill_manual(values = cbPalette) +
  theme_bw() +
  labs(
    title = "Postural Sway: Between- and Within-Subject Effects",
    subtitle = "Lines connect individual subjects across conditions",
    x = NULL,
    y = "Total Path Length (cm)",
    shape = "Condition", col = "Condition", fill = "Condition"
  ) +
  theme(
    plot.title = element_text(size = 16, face = "bold"),
    plot.subtitle = element_text(size = 12, face = "italic", color = "gray30"),
    axis.title = element_text(size = 14, face = "bold"),
    axis.text.x = element_blank(),
    axis.text.y = element_text(size = 11),
    legend.position = "bottom",
    strip.text = element_text(size = 12, face = "bold")
  )

Show Code
AVE_DATA["Condition"] = AVE_DATA["Surface"].astype(str) + ":" + AVE_DATA["Vision"].astype(str)
cond_order = ["Firm:Open", "Firm:Closed", "Foam:Open", "Foam:Closed"]
cond_palette = {cond: cbPalette[i] for i, cond in enumerate(cond_order)}

g = sns.FacetGrid(AVE_DATA, col="AgeGroup", height=4.5, aspect=1.2)

for age_group, ax in g.axes_dict.items():
    sub = AVE_DATA[AVE_DATA["AgeGroup"] == age_group]
    
    # Layer 1: Subject lines
    sns.lineplot(
        data=sub, x="Condition", y="PathLength_Total", units="Subject", 
        estimator=None, color="gray", alpha=0.5, ax=ax
    )
    # Layer 2: Overlay scatter points
    sns.scatterplot(
        data=sub, x="Condition", y="PathLength_Total", hue="Condition", 
        style="Condition", palette=cond_palette, s=60, alpha=0.8, legend=False, ax=ax
    )
    # Layer 3: Boxplot
    sns.boxplot(
        data=sub, x="Condition", y="PathLength_Total", order=cond_order,
        palette=cond_palette, boxprops=dict(alpha=0.4), showfliers=False, ax=ax
    )
    
    ax.set_title(f"AgeGroup: {age_group}", fontweight="bold")
    ax.set_xlabel("")
    ax.set_ylabel("Total Path Length (cm)", fontweight="bold")

g.fig.subplots_adjust(top=0.8)
g.fig.suptitle("Postural Sway: Between- and Within-Subject Effects", fontweight="bold", fontsize=14)
sns.despine()
plt.show()


1.7 Step 7: Multivariate Correlation Matrix

One great feature of programmatic approaches to data visualization is that there are many complex, but relatively common data visualizations that you might want to make. It is often too time consuming to make all of these figures completely from scratch, but fortunately there are many software packages/functions that other people have built that you can readily use. As an example, we will build a multifaceted plot to show the correlations between a set of variables. Visualizing this kind of correlation matrix comes up all the time in data analysis projects - whether you are exploring the data or formally checking mutlicolinearity before building a regression model - so it is a good example to start with here.

To examine all the correlations between our postural metrics across age groups, we build a scatterplot matrix containing bivariate scatter plots comparing each variable to every other variable (lower triangle), density distributions for each variable on its own (diagonal), and correlation coefficients (upper triangle).

Show Code
plot_data <- DATA %>% 
  select(AgeGroup, PathLength_Total, PathLength_ML, PathLength_AP, EllipseArea95) 

# Plot 7: Correlogram matrix
ggpairs(
  data = plot_data,
  columns = 2:5,
  mapping = aes(color = AgeGroup, fill = AgeGroup),
  lower = list(continuous = wrap("points", alpha = 0.3, size = 1.2)),
  diag = list(continuous = wrap("densityDiag", alpha = 0.3)),
  upper = list(continuous = wrap("cor", size = 4))
) +
  theme_minimal() +
  scale_fill_manual(values = cbPalette) +
  scale_color_manual(values = cbPalette) +
  theme(
    panel.grid.major = element_blank(),
    strip.text = element_text(face = "bold")
  )

Show Code
import seaborn as sns
import matplotlib.pyplot as plt

plot_vars = ["PathLength_Total", "PathLength_ML", "PathLength_AP", "EllipseArea95", "AgeGroup"]
plot_data = DATA[plot_vars]

# Plot 7: Seaborn pairplot matrix
g = sns.pairplot(
    plot_data,
    vars=["PathLength_Total", "PathLength_ML", "PathLength_AP", "EllipseArea95"],
    hue="AgeGroup",
    palette={"Young": cbPalette[0], "Old": cbPalette[1]},
    diag_kind="kde",
    plot_kws={"alpha": 0.4, "s": 20}
)
g.fig.suptitle("Multivariate Correlation Matrix by Age Group", y=1.02, fontweight="bold")
plt.show()


1.8 Step 8: Multi-Panel Publication Composite Layout

Finally, another common issue that arises in practice is the need to “stitch” multiple panels together into a single figure. People often do this manually using tools like the Gnu Image Manipulation Program or Inkscape (which are open source competitors of tools like Photoshop or Illustrator). It is completely fine to export images into another program to manage/fine tune your layout. However, there are tools in R and Python that allow you to make these kinds of multi-panelled figures. These are great tools to learn because it makes you projects more efficient (e.g., from reading in the data to analysis to final publication-ready figures, you can do it all in one place!).

In the code below, we arrange a few distinct figure types into a multi-panel layout: * Panel A: Age distribution histogram. * Panel B: Directional path length scatter plot (\(\text{PathLength}_{\text{ML}}\) vs \(\text{PathLength}_{\text{AP}}\)) with confidence ellipses. * Panel C: Total path length density distributions by condition faceted across age groups.

Show Code
# Panel A: Age Histogram
FigA <- ggplot(data = AVE_DATA, aes(x = Age)) +
  geom_histogram(fill = "grey", col = "black", binwidth = 2) +
  scale_x_continuous(limits = c(15, 90), breaks = seq(10, 90, 10)) +
  theme_bw() +
  labs(x = "Age (years)", y = "Count") +
  theme(axis.title = element_text(size = 11, face = "bold"))

# Panel B: ML vs AP Path Length Scatter
FigB <- ggplot(data = AVE_DATA, aes(x = PathLength_ML, y = PathLength_AP)) +
  stat_ellipse(aes(fill = Condition), geom = "polygon", alpha = 0.4, col = "black") +
  geom_point(aes(fill = Condition), col = "black", shape = 21, alpha = 0.6) +
  scale_fill_manual(values = cbPalette) +
  theme_bw() +
  labs(x = "Path Length M/L (cm)", y = "Path Length A/P (cm)") +
  theme(axis.title = element_text(size = 11, face = "bold"), legend.position = "none")

# Panel C: Density Distributions by Condition
FigC <- ggplot(data = AVE_DATA, aes(x = PathLength_Total)) +
  geom_density(aes(fill = Condition), alpha = 0.4, col = "black") +
  scale_fill_manual(values = cbPalette) +
  facet_wrap(~ AgeGroup) +
  theme_bw() +
  labs(x = "Total Path Length (cm)", y = "Density") +
  theme(
    axis.title = element_text(size = 11, face = "bold"),
    legend.position = "bottom",
    strip.text = element_text(size = 11, face = "bold")
  )

# Combine Panels via Patchwork
((FigA + FigB) / FigC) +
  plot_annotation(tag_levels = 'A') & 
  theme(plot.tag = element_text(face = 'bold', size = 14))

Show Code
import matplotlib.pyplot as plt
import seaborn as sns

fig = plt.figure(figsize=(8, 8))
gs = fig.add_gridspec(2, 2, height_ratios=[1, 1])

# Top Row: Panels A and B
ax_a = fig.add_subplot(gs[0, 0])
ax_b = fig.add_subplot(gs[0, 1])

# Bottom Row: Panel C split by AgeGroup (Young vs Old)
ax_c1 = fig.add_subplot(gs[1, 0])
ax_c2 = fig.add_subplot(gs[1, 1])

cond_colors = {
    "Firm:Open": cbPalette[0], "Firm:Closed": cbPalette[1],
    "Foam:Open": cbPalette[2], "Foam:Closed": cbPalette[3]
}

# Panel A: Age Histogram
sns.histplot(data=AVE_DATA, x="Age", binwidth=2, color="gray", edgecolor="black", ax=ax_a)
ax_a.set_xlim(15, 90)
(15.0, 90.0)
Show Code
ax_a.set_xlabel("Age (years)", fontweight="bold")
ax_a.set_ylabel("Count", fontweight="bold")
ax_a.set_title("A", loc="left", fontweight="bold", fontsize=14)

# Panel B: ML vs AP Path Length Scatter
sns.scatterplot(
    data=AVE_DATA, x="PathLength_ML", y="PathLength_AP",
    hue="Condition", palette=cond_colors, alpha=0.6, edgecolor="black", ax=ax_b
)
ax_b.set_xlabel("Path Length M/L (cm)", fontweight="bold")
ax_b.set_ylabel("Path Length A/P (cm)", fontweight="bold")
ax_b.get_legend().remove()
ax_b.set_title("B", loc="left", fontweight="bold", fontsize=14)

# Panel C1: Density Distribution - Young Adults
sns.kdeplot(
    data=AVE_DATA[AVE_DATA["AgeGroup"] == "Young"], x="PathLength_Total", hue="Condition",
    palette=cond_colors, fill=True, common_norm=False, alpha=0.4, ax=ax_c1, legend=False
)
ax_c1.set_xlabel("Total Path Length (cm)", fontweight="bold")
ax_c1.set_ylabel("Density", fontweight="bold")
ax_c1.set_title("Young", fontweight="bold", fontsize=11)
# Add "C" tag to the left density subplot
ax_c1.text(-0.18, 1.05, "C", transform=ax_c1.transAxes, fontweight="bold", fontsize=14)

# Panel C2: Density Distribution - Older Adults
sns.kdeplot(
    data=AVE_DATA[AVE_DATA["AgeGroup"] == "Old"], x="PathLength_Total", hue="Condition",
    palette=cond_colors, fill=True, common_norm=False, alpha=0.4, ax=ax_c2
)
ax_c2.set_xlabel("Total Path Length (cm)", fontweight="bold")
ax_c2.set_ylabel("Density", fontweight="bold")
ax_c2.set_title("Old", fontweight="bold", fontsize=11)

# Extract legend handles from ax_c2 and create a clean shared figure legend
handles, labels = ax_c2.get_legend_handles_labels()
ax_c2.get_legend().remove()

fig.legend(
    handles, labels, loc="lower center", 
    bbox_to_anchor=(0.5, -0.01), ncol=4, frameon=False
)

sns.despine()
plt.subplots_adjust(bottom=0.12, hspace=0.35, wspace=0.28)
plt.show()