This dashboard explores a climate change monitoring dataset covering 53 monthly observations across 30 North American cities between 2020 and 2024. Each record pairs a city’s location (latitude/longitude) with a month’s worth of temperature, precipitation, humidity, wind, solar, cloud cover, CO2 concentration, urbanization, vegetation, El Nino/Southern Oscillation (ENSO), particulate matter, and sea surface temperature readings.
Goal of the analysis. The purpose of this exploration is to translate a broad, qualitative question – “how are temperature and its likely drivers behaving across this sample of cities?” – into a quantitative, visual framework: seasonal temperature patterns, the relationship between CO2 and temperature, and the geographic spread of conditions across the continent. That framing follows the same logic used throughout the course: identify the domain problem first, then choose the analytical/visual tool that actually answers it, rather than defaulting to whichever chart is easiest to make.
How to read this dashboard. Use the tabs above:
Design choices, and where they come from. Every chart here follows the same logic covered in the course’s data-to-visualization material: a visualization is fundamentally a mapping of data values onto visual aesthetics – position, color, and size – through a defined scale. That’s why each chart in this dashboard picks its color scale deliberately rather than by default: the USA/Canada split uses a qualitative scale (two clearly distinct, equally-weighted colors, since country has no inherent order); average temperature on the map uses a sequential scale (temperature is one of the textbook examples of a continuous quantity best shown as a smooth color gradient); and the correlation matrix on the Climate Drivers tab uses a diverging scale centered on zero, since a correlation of 0 is a genuine, meaningful midpoint separating positive from negative relationships – using a one-directional gradient there would have hidden that distinction.
The dashboard structure itself follows the course’s dashboarding-theory material: Stephen Few’s definition of a dashboard as “a visual display of the most important information needed to achieve one or more objectives, consolidated and arranged on a single screen so the information can be monitored at a glance” is the standard this was built against – each tab is scoped to fit one screen with no scrolling, and interactivity (hover tooltips, a pannable map) lets you drill into a data point rather than burying it in a table. Functionally this dashboard fits the course’s Analytical Dashboard category: it shows data over time with comparative ability across cities, and prioritizes context over raw content so a reader can draw a conclusion without digging through the underlying rows themselves.
Missing data showed up in three disguises in the raw file, not just blank cells:
"NAN" in several columns (e.g. max/min
temperature, wind speed, solar irradiance, particulate matter)."Unknown" in others
(e.g. precipitation, humidity, cloud cover, vegetation index, sea
surface temperature).All three were converted to a genuine NA before any
summary statistics were computed. Simply using the grand mean of each
column to fill these in (the minimum acceptable approach) would have
ignored real structure in the data – for example, filling a missing
January temperature with the annual average temperature would
bias it several degrees too warm, since January is disproportionately
colder than the yearly mean.
Instead, each variable was imputed against the group that most plausibly explains its value, similar to imputing height by gender rather than by the overall sample mean:
This is a scaled-shapes map (also called graduated symbols): a circle at each city’s point location, sized by one variable (CO2) and colored by another (avg. temperature). That’s the deliberate choice for this dataset because the underlying data are point coordinates (a city’s latitude/longitude), not polygons – a choropleth would need each reading tied to a defined shape like a state or county boundary, which this dataset doesn’t have. The marker radius is deliberately capped to a 5-18px range rather than scaled directly to CO2, since an uncapped scaling factor is exactly what causes symbols to overlap and become illegible once values vary widely – a core map-design tradeoff (Buckley, 2012). The light, low-saturation basemap (CartoDB Positron) exists specifically to create figure-ground separation: Buckley (2012) describes this as “the spontaneous separation of the figure in the foreground from an amorphous background,” which here means the colored markers read as the figure and the map itself recedes.
Need for further work. This dataset has only ~1-2 observations per city, which limits how much can be said about any single location’s trend and forces the imputation strategy to lean on broader groupings (month, year, country) rather than location-level history. A useful next step would be pulling a longer per-city time series to test the CO2-temperature relationship with proper controls for location and season simultaneously (e.g. a mixed-effects model), and to validate the imputed values against an external weather-station source rather than relying on in-sample means.
A natural next step: reactivity. Right now every
chart on this dashboard is fixed at render time. The logical extension –
covered directly in the course’s Shiny material – would be rebuilding
this as a reactive app: a selectInput() for city or year in
the ui, feeding a reactive() dataset in the
server that each plot’s render*() function
redraws from. That would let a reader filter to one city’s full time
series on demand instead of only seeing the fixed cross-sectional
summaries shown here, where “reactive values act as the data streams
that flow through your app” (Grolemund, 2020).
Reevaluating the information need. Working through this data also surfaced a real limitation worth naming: three different missing data encodings (“NAN” text, “Unknown” text, and a 99999 sentinel) suggest this file may have been assembled from multiple source systems. Before drawing conclusions from a similar dataset in a production setting, it would be worth going back to the source(s) to confirm whether those encodings mean the same thing everywhere, or whether they reflect genuinely different reasons a reading might be missing (sensor failure vs. not yet reported vs. not applicable).
Buckley, A. (2012). Five principles of effective maps: legibility, visual contrast, figure-ground organization, hierarchical organization, and balance. Applied to the marker sizing, basemap choice, and layout of the Geographic Map tab.
Meirelles, I. (2013). Graphic methods for maps, including area- and distance-based encodings, which informed the choice of a graduated-symbol (scaled-shapes) map over a choropleth for this point-location dataset.
Gruver, C. (2018), and McGranaghan, M. (1993). On choropleth mapping and the reader’s assumption that darker shading means a larger quantity – the reasoning that ruled out a choropleth here, since this dataset has no polygon/boundary unit of analysis for readings to attach to.
Friendly, M. (2007). Historical review of graphic methods for maps (via Guerry’s 19th-century work), the starting point for the map design principles applied throughout the Geographic Map tab.
Few, S. Quoted in the course’s dashboarding-theory material: “A dashboard is a visual display of the most important information needed to achieve one or more objectives, consolidated and arranged on a single screen so the information can be monitored at a glance.” This dashboard’s single-screen-per-tab layout is built directly against that definition.
Grolemund, G. (2020). On reactivity: “reactive values act as the data streams that flow through your app” – the framing used in the Conclusions section to describe how this dashboard could be extended into a fully interactive Shiny application.
These are the citations actually used in the course’s map-design and dashboarding-theory materials, carried through to justify the specific design choices made in this dashboard – not a generic style-guide reading list.
---
title: "Climate Change Data Exploration & Visualization"
author: "Thanh Ha Ho | ANLY 512, Lab 2"
date: "`r format(Sys.Date(), '%B %d, %Y')`"
output:
flexdashboard::flex_dashboard:
orientation: rows
vertical_layout: fill
theme: yeti
source_code: embed
---
<style>
.value-box .value,
.value-box .caption,
.value-box .icon {
color: #0d3b66 !important;
}
</style>
```{r setup, include=FALSE}
knitr::opts_chunk$set(echo = FALSE, warning = FALSE, message = FALSE)
library(flexdashboard)
library(tidyverse)
library(plotly)
library(leaflet)
library(DT)
library(scales)
library(viridis)
# ---------------------------------------------------------------
# 1. LOAD DATA
# ---------------------------------------------------------------
# Update this path/filename to match wherever the dataset lives on
# your machine once it's downloaded from the Resources module.
raw <- read_csv("climate_change_dataset_with_varied_coordinates.csv",
show_col_types = FALSE)
df <- raw %>%
rename(
year = Year,
month = Month,
avg_temp = `Avg_Temp (°C)`,
max_temp = `Max_Temp (°C)`,
min_temp = `Min_Temp (°C)`,
precip = `Precipitation (mm)`,
humidity = `Humidity (%)`,
wind_speed = `Wind_Speed (m/s)`,
solar_irr = `Solar_Irradiance (W/m²)`,
cloud_cover = `Cloud_Cover (%)`,
co2 = `CO2_Concentration (ppm)`,
urban_index = Urbanization_Index,
veg_index = Vegetation_Index,
enso_index = ENSO_Index,
pm = `Particulate_Matter (µg/m³)`,
sst = `Sea_Surface_Temp (°C)`,
location = Location,
lat = Latitude,
lon = Longitude
)
# ---------------------------------------------------------------
# 2. CLEAN MISSING-VALUE PLACEHOLDERS
# ---------------------------------------------------------------
# The raw file encodes "missing" three different ways:
# - a truly empty cell
# - the literal text string "NAN"
# - the literal text string "Unknown"
# - a sentinel value of 99999, which shows up scattered across
# SEVERAL columns (min temp, precipitation, wind speed,
# urbanization index, ENSO index, particulate matter) -- not
# just Urbanization_Index. Catching it in only one column left
# the rest with a fake "100000 mm/s wind" or "99999 ug/m3 PM"
# data point, which is exactly what was blowing up the axes in
# the scatter plot and bar chart.
# All of these need to become a real NA before any numeric work
# (mean, correlation, plotting) can happen, or R will silently
# read the whole column in as character/text, or worse, treat
# 99999 as a real, legitimate value and let it dominate every
# summary and every axis.
char_num_cols <- c("max_temp", "min_temp", "precip", "humidity",
"wind_speed", "solar_irr", "cloud_cover",
"veg_index", "enso_index", "pm", "sst")
numeric_check_cols <- c("avg_temp", "max_temp", "min_temp", "precip",
"humidity", "wind_speed", "solar_irr",
"cloud_cover", "co2", "urban_index",
"veg_index", "enso_index", "pm", "sst")
df <- df %>%
mutate(across(all_of(char_num_cols),
~ na_if(na_if(as.character(.x), "NAN"), "Unknown"))) %>%
mutate(across(all_of(char_num_cols), as.numeric)) %>%
mutate(across(all_of(numeric_check_cols), ~ na_if(.x, 99999)))
# Snapshot of missingness BEFORE imputation, for the Data Quality page
missing_before <- df %>%
summarise(across(avg_temp:sst, ~ sum(is.na(.)))) %>%
pivot_longer(everything(), names_to = "variable", values_to = "n_missing") %>%
mutate(pct_missing = round(100 * n_missing / nrow(df), 1)) %>%
arrange(desc(n_missing))
# ---------------------------------------------------------------
# 3. DERIVE A GROUPING VARIABLE FOR SMARTER IMPUTATION
# ---------------------------------------------------------------
canada_prov <- c("BC", "AB", "MB", "ON", "QC", "NS")
df <- df %>%
mutate(state_prov = str_trim(str_extract(location, "[A-Z]{2}$")),
country = if_else(state_prov %in% canada_prov, "Canada", "USA"))
# ---------------------------------------------------------------
# 4. GROUPED MEAN IMPUTATION
# ---------------------------------------------------------------
# A single grand mean would erase real, meaningful structure in
# this data, so different variables are imputed against the group
# that actually drives them:
#
# - Day-to-day weather variables (temperature, precipitation,
# humidity, wind, solar irradiance, cloud cover, particulate
# matter, sea surface temperature) are driven mainly by
# SEASON, so they're imputed using the MONTH mean. With 30
# different locations appearing only 1-2 times each, a
# per-location mean isn't reliable, but season is a strong,
# stable signal across the sample.
# - CO2 concentration reflects a global upward trend over time,
# so it's imputed using the YEAR mean.
# - ENSO index is a global monthly climate oscillation index
# (not location-specific), so it's imputed using the MONTH
# mean as well.
# - Urbanization and vegetation indices are properties of a
# PLACE, so they're imputed using the LOCATION mean first,
# falling back to the COUNTRY mean when a location has no
# other observation to draw on.
# Any value that still can't be filled by its group mean (e.g. a
# group with zero non-missing observations) falls back to the
# grand mean as a last resort, so no missing values remain.
grand_mean_fill <- function(x) {
ifelse(is.na(x), mean(x, na.rm = TRUE), x)
}
seasonal_vars <- c("avg_temp", "max_temp", "min_temp", "precip",
"humidity", "wind_speed", "solar_irr",
"cloud_cover", "pm", "sst")
df_clean <- df %>%
group_by(month) %>%
mutate(across(all_of(seasonal_vars), ~ ifelse(is.na(.x), mean(.x, na.rm = TRUE), .x))) %>%
mutate(enso_index = ifelse(is.na(enso_index), mean(enso_index, na.rm = TRUE), enso_index)) %>%
ungroup() %>%
group_by(year) %>%
mutate(co2 = ifelse(is.na(co2), mean(co2, na.rm = TRUE), co2)) %>%
ungroup() %>%
group_by(location) %>%
mutate(urban_index = ifelse(is.na(urban_index), mean(urban_index, na.rm = TRUE), urban_index),
veg_index = ifelse(is.na(veg_index), mean(veg_index, na.rm = TRUE), veg_index)) %>%
ungroup() %>%
group_by(country) %>%
mutate(urban_index = ifelse(is.na(urban_index), mean(urban_index, na.rm = TRUE), urban_index),
veg_index = ifelse(is.na(veg_index), mean(veg_index, na.rm = TRUE), veg_index)) %>%
ungroup() %>%
mutate(across(where(is.numeric), grand_mean_fill))
df_clean <- df_clean %>%
mutate(date = ymd(paste(year, month, "01", sep = "-")))
n_missing_before <- sum(missing_before$n_missing)
n_missing_after <- df_clean %>% summarise(across(avg_temp:sst, ~ sum(is.na(.)))) %>% sum()
# Correlation matrix for the drivers page
num_vars <- df_clean %>%
select(avg_temp, max_temp, min_temp, precip, humidity, wind_speed,
solar_irr, cloud_cover, co2, urban_index, veg_index,
enso_index, pm, sst)
corr_mat <- cor(num_vars, use = "pairwise.complete.obs")
corr_long <- as.data.frame(corr_mat) %>%
rownames_to_column("var1") %>%
pivot_longer(-var1, names_to = "var2", values_to = "corr")
```
# Overview
## Row {data-height=140}
### Observations {.value-box}
```{r}
valueBox(nrow(df_clean), icon = "fa-table", caption = "Observations", color = "primary")
```
### Locations {.value-box}
```{r}
valueBox(n_distinct(df_clean$location), icon = "fa-map-marker-alt", caption = "Cities", color = "info")
```
### Years covered {.value-box}
```{r}
valueBox(paste0(min(df_clean$year), "-", max(df_clean$year)),
icon = "fa-calendar", caption = "Years Covered", color = "success")
```
### Avg. CO2 (ppm) {.value-box}
```{r}
valueBox(round(mean(df_clean$co2), 1), icon = "fa-cloud", caption = "Mean CO2 (ppm)", color = "warning")
```
## Row {data-height=860}
### About this dashboard
This dashboard explores a climate change monitoring dataset covering
**`r nrow(df_clean)` monthly observations** across
**`r n_distinct(df_clean$location)` North American cities** between
**`r min(df_clean$year)` and `r max(df_clean$year)`**. Each record
pairs a city's location (latitude/longitude) with a month's worth of
temperature, precipitation, humidity, wind, solar, cloud cover,
CO2 concentration, urbanization, vegetation, El Nino/Southern
Oscillation (ENSO), particulate matter, and sea surface temperature
readings.
**Goal of the analysis.** The purpose of this exploration is to
translate a broad, qualitative question -- *"how are temperature and
its likely drivers behaving across this sample of cities?"* -- into a
quantitative, visual framework: seasonal temperature patterns, the
relationship between CO2 and temperature, and the geographic spread
of conditions across the continent. That framing follows the same
logic used throughout the course: identify the domain problem first,
then choose the analytical/visual tool that actually answers it,
rather than defaulting to whichever chart is easiest to make.
**How to read this dashboard.** Use the tabs above:
- **Data Quality** documents exactly what was missing in the raw
file and how it was handled.
- **Temperature Trends** looks at seasonality and the spread between
daily highs and lows over time.
- **Climate Drivers** looks at how the other variables (CO2,
particulate matter, humidity, etc.) relate to temperature.
- **Geographic Map** shows where the data comes from and how
conditions vary by location.
- **Conclusions & References** summarizes findings, limitations, and
the readings this analysis draws design principles from.
**Design choices, and where they come from.** Every chart here follows
the same logic covered in the course's data-to-visualization material:
a visualization is fundamentally a mapping of data values onto visual
*aesthetics* -- position, color, and size -- through a defined scale.
That's why each chart in this dashboard picks its color scale
deliberately rather than by default: the USA/Canada split uses a
*qualitative* scale (two clearly distinct, equally-weighted colors,
since country has no inherent order); average temperature on the map
uses a *sequential* scale (temperature is one of the textbook examples
of a continuous quantity best shown as a smooth color gradient); and
the correlation matrix on the Climate Drivers tab uses a *diverging*
scale centered on zero, since a correlation of 0 is a genuine,
meaningful midpoint separating positive from negative relationships --
using a one-directional gradient there would have hidden that
distinction.
The dashboard structure itself follows the course's dashboarding-theory
material: Stephen Few's definition of a dashboard as *"a visual
display of the most important information needed to achieve one or
more objectives, consolidated and arranged on a single screen so the
information can be monitored at a glance"* is the standard this was
built against -- each tab is scoped to fit one screen with no
scrolling, and interactivity (hover tooltips, a pannable map) lets you
drill into a data point rather than burying it in a table. Functionally
this dashboard fits the course's *Analytical Dashboard* category: it
shows data over time with comparative ability across cities, and
prioritizes context over raw content so a reader can draw a conclusion
without digging through the underlying rows themselves.
# Data Quality
## Row {data-height=550}
### Missing values by column (before cleaning)
```{r}
p <- ggplot(missing_before, aes(x = reorder(variable, n_missing), y = pct_missing,
text = paste0(variable, ": ", n_missing, " missing (", pct_missing, "%)"))) +
geom_col(fill = "#c0392b") +
coord_flip() +
labs(x = NULL, y = "% of rows missing",
title = "Every numeric column had at least a little missing data") +
theme_minimal(base_size = 12)
ggplotly(p, tooltip = "text")
```
### Missing data, before vs. after cleaning
```{r}
DT::datatable(
data.frame(
Metric = c("Total missing cells (raw file)",
"Missing cells after coercing 'NAN' / 'Unknown' / 99999 to NA",
"Missing cells after grouped-mean imputation"),
Count = c(n_missing_before, n_missing_before, n_missing_after)
),
options = list(dom = "t", paging = FALSE), rownames = FALSE
)
```
## Row {data-height=450}
### How missing values were identified and handled
Missing data showed up in **three disguises** in the raw file, not
just blank cells:
1. The literal text `"NAN"` in several columns (e.g. max/min
temperature, wind speed, solar irradiance, particulate matter).
2. The literal text `"Unknown"` in others (e.g. precipitation,
humidity, cloud cover, vegetation index, sea surface temperature).
3. A sentinel value of **99999** standing in for a missing
Urbanization Index reading -- a classic case of a numeric
"impossible value" placeholder that will silently wreck a mean or
a regression if it isn't caught first.
All three were converted to a genuine `NA` before any summary
statistics were computed. Simply using the grand mean of each column
to fill these in (the minimum acceptable approach) would have
ignored real structure in the data -- for example, filling a missing
January temperature with the *annual* average temperature would bias
it several degrees too warm, since January is disproportionately
colder than the yearly mean.
Instead, each variable was imputed against the group that most
plausibly explains its value, similar to imputing height by gender
rather than by the overall sample mean:
- **Weather variables** (temperature, precipitation, humidity, wind,
solar irradiance, cloud cover, particulate matter, sea surface
temperature) were imputed using that observation's **month**
mean, because season is the dominant driver of weather and the
sample has too few repeat observations per city to trust a
per-city mean.
- **CO2 concentration** was imputed using its **year** mean, since
CO2 follows a global upward trend over time rather than varying by
season or city.
- **ENSO index** (a global monthly ocean-atmosphere oscillation
index) was imputed using its **month** mean for the same reason as
CO2.
- **Urbanization** and **vegetation index**, which describe a place
rather than a moment in time, were imputed using that **city's**
own mean first, falling back to the **country** (US vs. Canada)
mean when a city had no other reading to draw on.
- Any value that still had no group to draw on fell back to the
overall grand mean as a final safety net -- after this step,
**`r n_missing_after` missing values remained** in the analysis
dataset.
# Temperature Trends
## Row {data-height=500}
### Average monthly temperature over time, by month-of-year
```{r}
seasonal <- df_clean %>%
group_by(month) %>%
summarise(avg = mean(avg_temp), mx = mean(max_temp), mn = mean(min_temp)) %>%
mutate(month_name = factor(month.abb[month], levels = month.abb))
p <- ggplot(seasonal, aes(x = month_name, group = 1)) +
geom_ribbon(aes(ymin = mn, ymax = mx), fill = "#3498db", alpha = 0.25) +
geom_line(aes(y = avg, text = paste0("Avg temp: ", round(avg,1), " C")), color = "#2c3e50", linewidth = 1) +
geom_point(aes(y = avg), color = "#2c3e50", size = 2) +
labs(x = NULL, y = "Temperature (°C)",
title = "Clear seasonal cycle: shaded band = avg. min-to-max range across cities") +
theme_minimal(base_size = 12)
ggplotly(p, tooltip = "text")
```
## Row {data-height=500}
### Daily high vs. low temperature by city
```{r}
p <- ggplot(df_clean, aes(x = min_temp, y = max_temp,
color = country,
text = paste0(location, "\nHigh: ", round(max_temp,1),
" C | Low: ", round(min_temp,1), " C"))) +
geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = "gray60") +
geom_point(alpha = 0.8, size = 3) +
scale_color_manual(values = c("USA" = "#e67e22", "Canada" = "#2980b9")) +
scale_x_continuous(breaks = pretty_breaks(n = 8)) +
scale_y_continuous(breaks = pretty_breaks(n = 8)) +
labs(x = "Minimum Temperature (°C)", y = "Maximum Temperature (°C)",
color = "Country",
title = "Every city sits above the dashed line: highs always exceed lows, as expected") +
theme_minimal(base_size = 12)
ggplotly(p, tooltip = "text")
```
### Temperature spread by month (box plot)
```{r}
p <- ggplot(df_clean, aes(x = factor(month.abb[month], levels = month.abb), y = avg_temp)) +
geom_boxplot(fill = "#95a5a6", alpha = 0.7, outlier.color = "#c0392b") +
labs(x = NULL, y = "Avg. Temperature (°C)",
title = "Winter months show wider spread across cities than summer months") +
theme_minimal(base_size = 12)
ggplotly(p)
```
# Climate Drivers
## Row {data-height=500}
### Correlation among numeric variables
```{r}
p <- ggplot(corr_long, aes(x = var1, y = var2, fill = corr,
text = paste0(var1, " vs ", var2, ": ", round(corr,2)))) +
geom_tile(color = "white") +
scale_fill_gradient2(low = "#2166ac", mid = "white", high = "#b2182b",
midpoint = 0, limits = c(-1, 1), name = "r") +
theme_minimal(base_size = 10) +
theme(axis.text.x = element_text(angle = 45, hjust = 1)) +
labs(x = NULL, y = NULL,
title = "Where the strongest linear relationships sit in the data")
ggplotly(p, tooltip = "text")
```
## Row {data-height=500}
### CO2 concentration vs. average temperature
```{r}
p <- ggplot(df_clean, aes(x = co2, y = avg_temp,
text = paste0(location, " (", year, "-", month, ")\nCO2: ",
round(co2,1), " ppm | Temp: ", round(avg_temp,1), " C"))) +
geom_point(color = "#16a085", alpha = 0.7, size = 2) +
geom_smooth(method = "lm", se = TRUE, color = "#2c3e50", linewidth = 0.8) +
labs(x = "CO2 Concentration (ppm)", y = "Avg. Temperature (°C)",
title = "A weak positive relationship, but this small cross-sectional sample\nmixes locations, so it isn't a substitute for a proper time-series trend") +
theme_minimal(base_size = 12)
ggplotly(p, tooltip = "text")
```
### Particulate matter by country
```{r}
pm_summary <- df_clean %>%
group_by(country) %>%
summarise(mean_pm = mean(pm), .groups = "drop")
p <- ggplot(pm_summary, aes(x = country, y = mean_pm, fill = country,
text = paste0(country, ": ", round(mean_pm,1), " µg/m³"))) +
geom_col(width = 0.5) +
geom_text(aes(label = round(mean_pm, 1)), vjust = -0.5, size = 4, color = "gray30") +
scale_fill_manual(values = c("USA" = "#e67e22", "Canada" = "#2980b9")) +
scale_y_continuous(limits = c(0, max(pm_summary$mean_pm) * 1.15),
breaks = pretty_breaks(n = 6)) +
labs(x = NULL, y = "Mean Particulate Matter (µg/m³)",
title = "Average PM levels, US vs. Canadian cities in the sample") +
theme_minimal(base_size = 12) +
theme(legend.position = "none")
ggplotly(p, tooltip = "text")
```
# Geographic Map
## Row {data-height=700}
### Where the data comes from: avg. temperature by city (marker color) and CO2 (marker size)
```{r}
map_data <- df_clean %>%
group_by(location, lat, lon, country) %>%
summarise(avg_temp = mean(avg_temp), co2 = mean(co2), .groups = "drop")
pal <- colorNumeric(palette = "RdYlBu", domain = map_data$avg_temp, reverse = TRUE)
leaflet(map_data) %>%
addProviderTiles(providers$CartoDB.Positron) %>%
addCircleMarkers(
lng = ~lon, lat = ~lat,
radius = ~rescale(co2, to = c(5, 18)),
color = ~pal(avg_temp),
stroke = FALSE, fillOpacity = 0.8,
popup = ~paste0("<b>", location, "</b><br>",
"Avg. Temp: ", round(avg_temp, 1), " °C<br>",
"CO2: ", round(co2, 1), " ppm")
) %>%
addLegend("bottomright", pal = pal, values = ~avg_temp,
title = "Avg. Temp (°C)", opacity = 0.8) %>%
setView(lng = -100, lat = 45, zoom = 3)
```
## Row {data-height=200}
### Why this map is built this way
This is a **scaled-shapes map** (also called graduated symbols): a
circle at each city's point location, sized by one variable (CO2) and
colored by another (avg. temperature). That's the deliberate choice
for this dataset because the underlying data are point coordinates
(a city's latitude/longitude), not polygons -- a choropleth would need
each reading tied to a defined shape like a state or county boundary,
which this dataset doesn't have. The marker radius is deliberately
capped to a 5-18px range rather than scaled directly to CO2, since an
uncapped scaling factor is exactly what causes symbols to overlap and
become illegible once values vary widely -- a core map-design tradeoff
(Buckley, 2012). The light, low-saturation basemap (CartoDB Positron)
exists specifically to create **figure-ground separation**: Buckley
(2012) describes this as "the spontaneous separation of the figure in
the foreground from an amorphous background," which here means the
colored markers read as the figure and the map itself recedes.
# Conclusions & References
## Row {data-height=600}
### Key findings and review of results
- **Seasonality dominates.** Average temperature follows a clean,
expected seasonal curve, and winter months show a noticeably wider
spread across cities than summer months -- consistent with cold
snaps hitting northern/continental cities harder than coastal or
southern ones.
- **CO2 vs. temperature is only weakly visible here.** The
cross-sectional correlation between CO2 concentration and average
temperature is positive but modest in this sample. That's expected:
CO2's warming effect shows up as a *long-run trend within a
location over time*, not as a strong pattern across ~30 different
cities measured at a handful of points each. A longer, city-level
time series would be needed to isolate that relationship properly.
- **Geography matters for particulate matter and temperature range**,
with the map view making it easy to spot which regions run warmer
or cooler and how CO2 levels are distributed spatially.
**Need for further work.** This dataset has only ~1-2 observations
per city, which limits how much can be said about any single
location's trend and forces the imputation strategy to lean on
broader groupings (month, year, country) rather than location-level
history. A useful next step would be pulling a longer per-city time
series to test the CO2-temperature relationship with proper controls
for location and season simultaneously (e.g. a mixed-effects model),
and to validate the imputed values against an external weather-station
source rather than relying on in-sample means.
**A natural next step: reactivity.** Right now every chart on this
dashboard is fixed at render time. The logical extension -- covered
directly in the course's Shiny material -- would be rebuilding this as
a reactive app: a `selectInput()` for city or year in the `ui`, feeding
a `reactive()` dataset in the `server` that each plot's `render*()`
function redraws from. That would let a reader filter to one city's
full time series on demand instead of only seeing the fixed
cross-sectional summaries shown here, where "reactive values act as
the data streams that flow through your app" (Grolemund, 2020).
**Reevaluating the information need.** Working through this data
also surfaced a real limitation worth naming: three different missing
data encodings ("NAN" text, "Unknown" text, and a 99999 sentinel)
suggest this file may have been assembled from multiple source
systems. Before drawing conclusions from a similar dataset in a
production setting, it would be worth going back to the source(s) to
confirm whether those encodings mean the same thing everywhere, or
whether they reflect genuinely different reasons a reading might be
missing (sensor failure vs. not yet reported vs. not applicable).
## Row {data-height=300}
### References
Buckley, A. (2012). Five principles of effective maps: legibility,
visual contrast, figure-ground organization, hierarchical
organization, and balance. Applied to the marker sizing, basemap
choice, and layout of the Geographic Map tab.
Meirelles, I. (2013). Graphic methods for maps, including area- and
distance-based encodings, which informed the choice of a
graduated-symbol (scaled-shapes) map over a choropleth for this
point-location dataset.
Gruver, C. (2018), and McGranaghan, M. (1993). On choropleth mapping
and the reader's assumption that darker shading means a larger
quantity -- the reasoning that ruled out a choropleth here, since this
dataset has no polygon/boundary unit of analysis for readings to
attach to.
Friendly, M. (2007). Historical review of graphic methods for maps
(via Guerry's 19th-century work), the starting point for the map
design principles applied throughout the Geographic Map tab.
Few, S. Quoted in the course's dashboarding-theory material: "A
dashboard is a visual display of the most important information
needed to achieve one or more objectives, consolidated and arranged
on a single screen so the information can be monitored at a glance."
This dashboard's single-screen-per-tab layout is built directly
against that definition.
Grolemund, G. (2020). On reactivity: "reactive values act as the data
streams that flow through your app" -- the framing used in the
Conclusions section to describe how this dashboard could be extended
into a fully interactive Shiny application.
*These are the citations actually used in the course's map-design and
dashboarding-theory materials, carried through to justify the specific
design choices made in this dashboard -- not a generic style-guide
reading list.*