library(tidyverse)
library(gt)
library(gtExtras)M05-Visualizing Data with Tables
1 Introduction
This report reflects on how tables can be used as a form of data visualization and demonstrates how the
gtandgtExtraspackages can create polished, reader-friendly tables in R.
In this assignment, I summarize what I learned from the assigned videos, explain how tables complement charts, and create a descriptive statistics table using synthetic marketing data. The goal is to demonstrate that tables are not only containers for numbers, but also visual communication tools that can guide the reader toward important patterns.
This report uses Quarto, gt, and gtExtras to combine written reflection, data wrangling, descriptive statistics, and table-based visualization in one HTML report.
2 Essay Reflection
2.1 Summarize what you learned from the videos you watched.
The videos emphasized that tables should be treated as a form of data visualization rather than as plain data output. From the conversation with Tom Mock, I learned that effective tables guide the reader’s eye by using clear headers, subtle dividers, thoughtful alignment, appropriate precision, and selective highlighting. A table should make it easy for the reader to understand what matters without forcing them to decode unnecessary clutter.
I also learned that data used for analysis and data used for presentation may need to be structured differently. In the tidyverse, long and tidy data is often best for analysis, but wider or grouped tables may be better for communication. This helped me understand that presentation tables sometimes require reshaping data to make the story clearer.
The second video introduced gtExtras, which extends the gt package by making it easier to create polished tables with themes, summary tables, and inline graphics. I especially liked the use of functions such as gt_plt_summary() and gt_plt_dist() because they allow tables to include small visual elements such as distributions directly inside cells. This makes the table more informative while still preserving exact numerical values.
2.2 What did you like about the gt and gtExtras packages demonstrated in the videos?
I liked that gt gives users detailed control over how a table looks. For example, functions such as tab_header(), tab_spanner(), cols_label(), tab_source_note(), tab_footnote(), fmt_number(), fmt_currency(), and tab_style() allow the table creator to decide how the reader should experience the information. This is useful because a table can be designed around a specific communication goal instead of simply displaying raw output.
I also liked that gtExtras saves time and adds visual polish. Instead of manually writing many lines of formatting code, gtExtras provides themes and visualization helpers that can make tables look professional quickly. The ability to insert distribution plots, sparklines, and summary visuals into tables is especially helpful for marketing analytics because it allows a report to show both exact numbers and visual patterns in a compact format.
2.3 How do tables complement charts for data visualization?
Tables complement charts because they provide exact values, while charts are often better for showing overall patterns, trends, and relationships. A chart can quickly show whether a metric is increasing, decreasing, or different across groups, but a table allows the reader to inspect the specific numbers behind the pattern. In a business report, both are valuable because charts help communicate the big picture while tables provide evidence and detail.
For example, a bar chart might show that email has the highest conversion rate across channels, but a table can show the exact conversion rate, average order value, revenue, and customer satisfaction score for each channel. This makes tables useful when the audience needs precision, comparison, and supporting details.
2.4 Under what circumstances would you prefer to use tables rather than charts to visualize data?
I would prefer tables when the audience needs to compare exact values across categories or when there are multiple metrics that need to be reviewed together. Tables are also useful when the dataset includes key performance indicators such as revenue, conversion rate, average order value, customer satisfaction, and repeat purchase rate. These metrics can be difficult to show clearly in one chart, but they can be organized effectively in a table.
I would also use tables when preparing written reports, dashboards, or presentation appendices where decision-makers may want to verify specific numbers. For example, in a marketing analytics or MSDM project, a table can summarize customer segments, campaign channels, or product categories while still allowing the reader to see exact performance differences.
3 Data Demonstration with gt and gtExtras
3.2 Load packages
3.3 Use synthetic data
# ── Synthetic direct-mail dataset ──────────────────────────────────────────────
set.seed(123)
n <- 3000
mail_data <- tibble(
customer_id = paste0("C", str_pad(1:n, 5, pad = "0")),
age = round(rnorm(n, mean = 45, sd = 12)),
income = round(rlnorm(n, meanlog = 10.8, sdlog = 0.6)), # right-skewed
recency_days = round(rexp(n, rate = 1 / 60)), # days since last purchase
freq_12mo = rpois(n, lambda = 3), # purchases in 12 months
avg_order_amt = round(rlnorm(n, meanlog = 4.2, sdlog = 0.5), 2),
channel = sample(
c("email", "direct_mail", "digital"),
n,
replace = TRUE,
prob = c(0.5, 0.3, 0.2)
),
region = sample(
c("West", "South", "Midwest", "Northeast"),
n,
replace = TRUE
),
loyalty_tier = sample(
c("Bronze", "Silver", "Gold", "Platinum"),
n,
replace = TRUE,
prob = c(0.4, 0.3, 0.2, 0.1)
),
responded = rbinom(
n,
1,
prob = plogis(
-3 +
0.02 * (age - 45) +
0.3 * log(income / 50000) +
0.1 * freq_12mo -
0.005 * recency_days
)
)
) |>
mutate(
income = if_else(runif(n) < 0.08, NA_real_, income),
avg_order_amt = if_else(runif(n) < 0.05, NA_real_, avg_order_amt),
responded = factor(responded, levels = c(1, 0), labels = c("yes", "no"))
)
glimpse(mail_data)Rows: 3,000
Columns: 10
$ customer_id <chr> "C00001", "C00002", "C00003", "C00004", "C00005", "C0000…
$ age <dbl> 38, 42, 64, 46, 47, 66, 51, 30, 37, 40, 60, 49, 50, 46, …
$ income <dbl> 44793, 40269, 20560, 32261, 233070, 47933, 84804, 43883,…
$ recency_days <dbl> 103, 102, 27, 115, 47, 106, 4, 62, 13, 21, 139, 100, 9, …
$ freq_12mo <int> 4, 3, 0, 3, 1, 3, 5, 2, 1, 6, 1, 3, 3, 3, 6, 1, 1, 1, 6,…
$ avg_order_amt <dbl> 80.19, NA, 54.80, 46.49, 64.86, 78.43, 39.45, NA, 68.26,…
$ channel <chr> "email", "digital", "digital", "email", "digital", "dire…
$ region <chr> "West", "South", "Northeast", "Northeast", "Midwest", "W…
$ loyalty_tier <chr> "Bronze", "Bronze", "Bronze", "Bronze", "Gold", "Silver"…
$ responded <fct> no, no, yes, no, yes, yes, no, no, no, no, no, no, no, n…
Grouping by marketing channel makes the table useful for comparing acquisition sources. This mirrors real direct-mail and retail marketing questions such as which channel attracts higher-value customers, stronger response behavior, or more frequent purchasing.
3.4 Summary table with inline distribution graphics
channel_summary <- mail_data |>
group_by(channel) |>
summarize(
customers = n(),
avg_income = mean(income, na.rm = TRUE),
median_order_value = median(avg_order_amt, na.rm = TRUE),
avg_frequency = mean(freq_12mo, na.rm = TRUE),
avg_recency_days = mean(recency_days, na.rm = TRUE),
response_rate = mean(responded == "yes"),
order_distribution = list(na.omit(avg_order_amt)),
.groups = "drop"
) |>
arrange(desc(response_rate))
channel_summary# A tibble: 3 × 8
channel customers avg_income median_order_value avg_frequency avg_recency_days
<chr> <int> <dbl> <dbl> <dbl> <dbl>
1 digital 595 54444. 70.2 2.89 63.7
2 email 1529 58662. 65.9 2.95 58.1
3 direct… 876 60441. 65.1 2.97 61.7
# ℹ 2 more variables: response_rate <dbl>, order_distribution <list>
channel_summary |>
gt(rowname_col = "channel") |>
gt_plt_dist(order_distribution) |>
tab_header(
title = md("**Synthetic Direct-Mail Channel Performance**"),
subtitle = "Descriptive statistics by channel with inline average order amount distributions"
) |>
tab_stubhead(label = "Marketing Channel") |>
tab_spanner(
label = "Customer Volume",
columns = c(customers)
) |>
tab_spanner(
label = "Financial Metrics",
columns = c(avg_income, median_order_value, order_distribution)
) |>
tab_spanner(
label = "Behavioral Metrics",
columns = c(avg_frequency, avg_recency_days, response_rate)
) |>
cols_label(
customers = "Customers",
avg_income = "Avg. Income",
median_order_value = "Median Order Value",
avg_frequency = "Avg. Purchases",
avg_recency_days = "Avg. Recency Days",
response_rate = "Response Rate",
order_distribution = "Order Amount Distribution"
) |>
fmt_currency(
columns = c(avg_income, median_order_value),
currency = "USD",
decimals = 2
) |>
fmt_number(
columns = c(avg_frequency, avg_recency_days),
decimals = 2
) |>
fmt_percent(
columns = c(response_rate),
decimals = 2
) |>
tab_style(
style = list(
cell_fill(color = "#F8F4EC"),
cell_text(weight = "bold")
),
locations = cells_body(
columns = response_rate,
rows = response_rate == max(response_rate)
)
) |>
tab_style(
style = cell_text(weight = "bold"),
locations = cells_column_labels(everything())
) |>
tab_footnote(
footnote = "The highlighted cell identifies the channel with the highest response rate.",
locations = cells_body(
columns = response_rate,
rows = response_rate == max(response_rate)
)
) |>
tab_source_note(
source_note = "Source: Synthetic direct-mail dataset created for M05 table visualization practice."
) |>
gt_theme_guardian()